Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb7db4923b | |||
| e7bd6a339b | |||
| de4dfe8538 | |||
| 110e9260a7 | |||
| fae02694fb | |||
| 180a249a68 | |||
| 61e1021a4d | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| 656c89e4b2 | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 721e45f01b |
+16
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
**运行态事件**:
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"react-window": "^1.8.11",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -73,7 +72,6 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
} from './cargo-features.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
const repoRoot = path.resolve(appRoot, '..', '..');
|
||||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||||
const releaseTarget =
|
||||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||||
@@ -41,20 +39,6 @@ const releaseChannels = {
|
||||
'dev-mac': 'darwin',
|
||||
};
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||||
*/
|
||||
export const agcReleasePathPatterns = [
|
||||
'apps/ai-game-creator-shell/',
|
||||
'packages/',
|
||||
'server-rs/crates/',
|
||||
'plugins/agc-cocos-editor/',
|
||||
'apps/desktop-shell/src-tauri/icons/',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
];
|
||||
|
||||
function ossBaseUrl() {
|
||||
return (
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||||
@@ -159,96 +143,27 @@ export function resolveManifestPlatformKeys(target = releaseTarget) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */
|
||||
function legacyBridgeManifestUrl() {
|
||||
return `${ossBaseUrl()}/latest.json`;
|
||||
}
|
||||
|
||||
async function fetchManifest(manifestUrl, label) {
|
||||
async function readRemoteVersion(channel = resolveReleaseChannel()) {
|
||||
const manifestUrl = updateManifestUrl(channel);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(manifestUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`读取 ${label} 失败:${error.message}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:${error.message}`);
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
return await response.json();
|
||||
manifest = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`${label} 不是有效 JSON:${error.message}`);
|
||||
throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifestVersion(manifestUrl, label) {
|
||||
const manifest = await fetchManifest(manifestUrl, label);
|
||||
return manifest == null
|
||||
? null
|
||||
: parseVersion(manifest?.version, `${label} version`);
|
||||
}
|
||||
|
||||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||||
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
||||
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要锚点:上次发布对应的提交。
|
||||
*
|
||||
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
|
||||
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
|
||||
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
|
||||
*/
|
||||
export async function resolvePreviousReleaseCommit(
|
||||
channel = resolveReleaseChannel(),
|
||||
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
|
||||
) {
|
||||
const explicit = override?.trim();
|
||||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const manifest = await readRemoteChannelManifest(channel);
|
||||
const commit =
|
||||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||||
} catch (error) {
|
||||
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
|
||||
console.warn(
|
||||
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本高水位:渠道清单与旧协议迁移指针取较大值。
|
||||
*
|
||||
* 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 ——
|
||||
* 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务
|
||||
* Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。
|
||||
*/
|
||||
export async function resolveRemoteHighWaterVersion(
|
||||
channel = resolveReleaseChannel(),
|
||||
) {
|
||||
const channelVersion = await readManifestVersion(
|
||||
updateManifestUrl(channel),
|
||||
'OSS 渠道清单',
|
||||
);
|
||||
if (channel !== 'dev-win') return channelVersion;
|
||||
const legacyVersion = await readManifestVersion(
|
||||
legacyBridgeManifestUrl(),
|
||||
'OSS 迁移指针',
|
||||
);
|
||||
if (channelVersion == null) return legacyVersion;
|
||||
if (legacyVersion == null) return channelVersion;
|
||||
return compareVersions(channelVersion, legacyVersion) >= 0
|
||||
? channelVersion
|
||||
: legacyVersion;
|
||||
return parseVersion(manifest?.version, 'OSS渠道清单 version');
|
||||
}
|
||||
|
||||
function replaceVersionLine(source, version, pattern, label) {
|
||||
@@ -259,7 +174,7 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
export async function prepareReleaseVersion() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
|
||||
const remoteVersion = await readRemoteVersion(channel);
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
@@ -453,8 +368,6 @@ export function createUpdateManifest(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = releaseTarget,
|
||||
publishedAt = new Date().toISOString(),
|
||||
notes = readReleaseNotes(),
|
||||
commit = readHeadCommit(),
|
||||
} = {},
|
||||
) {
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
@@ -465,136 +378,24 @@ export function createUpdateManifest(
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
platforms[key] = { signature, url };
|
||||
}
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
...(notes ? { notes } : {}),
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||
...(commit ? { commit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readHeadCommit() {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次发布到本次之间的客户端相关提交。
|
||||
*
|
||||
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
|
||||
*/
|
||||
export function collectReleaseCommits(
|
||||
previousCommit,
|
||||
headCommit = 'HEAD',
|
||||
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
|
||||
) {
|
||||
if (!previousCommit) return null;
|
||||
try {
|
||||
for (const revision of [previousCommit, headCommit]) {
|
||||
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'--no-merges',
|
||||
'--format=%h%x09%s',
|
||||
`${previousCommit}..${headCommit}`,
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
}
|
||||
|
||||
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
|
||||
export function formatReleaseNotes(
|
||||
commits,
|
||||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||||
) {
|
||||
if (!commits || commits.length === 0) return '';
|
||||
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
|
||||
const trimmed =
|
||||
subject.length > subjectLength
|
||||
? `${subject.slice(0, subjectLength - 1)}…`
|
||||
: subject;
|
||||
return `- ${trimmed}(${sha})`;
|
||||
});
|
||||
if (commits.length > limit) {
|
||||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
|
||||
export function collectRecentReleaseCommits({
|
||||
cwd = repoRoot,
|
||||
paths = agcReleasePathPatterns,
|
||||
limit = 8,
|
||||
} = {}) {
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const commits = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
return commits.length > 0 ? commits : null;
|
||||
}
|
||||
|
||||
export function formatRecentReleaseNotes(commits) {
|
||||
const notes = formatReleaseNotes(commits, { limit: 8 });
|
||||
if (!notes) return '';
|
||||
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
|
||||
}
|
||||
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
|
||||
{ channel = resolveReleaseChannel() } = {},
|
||||
) {
|
||||
const bytes = fs.readFileSync(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const fileName = path.basename(artifactPath);
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||
@@ -604,36 +405,18 @@ export function createLegacyUpdateManifest(
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateUpdateManifest() {
|
||||
export function generateUpdateManifest() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||||
if (!artifact) {
|
||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||
}
|
||||
const manualNotes = readReleaseNotes();
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||||
const commits = collectReleaseCommits(previousCommit);
|
||||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||||
const notes =
|
||||
manualNotes ||
|
||||
formatReleaseNotes(commits) ||
|
||||
formatRecentReleaseNotes(recentCommits);
|
||||
if (!manualNotes && !notes) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||||
);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact, { channel, notes });
|
||||
const manifest = createUpdateManifest(artifact, { channel });
|
||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||||
fs.writeFileSync(
|
||||
notesPath,
|
||||
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
||||
);
|
||||
const legacyManifest =
|
||||
channel === 'dev-win'
|
||||
? createLegacyUpdateManifest(artifact, { channel, notes })
|
||||
? createLegacyUpdateManifest(artifact, { channel })
|
||||
: null;
|
||||
const legacyManifestPath = legacyManifest
|
||||
? path.join(bundleRoot, 'legacy-latest.json')
|
||||
@@ -648,14 +431,6 @@ export async function generateUpdateManifest() {
|
||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
console.log(
|
||||
manualNotes
|
||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||
: notes && !previousCommit
|
||||
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
|
||||
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
|
||||
if (legacyManifestPath) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||||
@@ -666,10 +441,6 @@ export async function generateUpdateManifest() {
|
||||
artifact,
|
||||
manifest,
|
||||
manifestPath,
|
||||
notes,
|
||||
notesPath,
|
||||
previousCommit,
|
||||
commits,
|
||||
legacyManifest,
|
||||
legacyManifestPath,
|
||||
};
|
||||
@@ -682,5 +453,5 @@ if (
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
|
||||
runTauriBuild(args);
|
||||
if (!args.includes('--no-bundle')) await generateUpdateManifest();
|
||||
if (!args.includes('--no-bundle')) generateUpdateManifest();
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
agcReleasePathPatterns,
|
||||
collectRecentReleaseCommits,
|
||||
collectReleaseCommits,
|
||||
compareVersions,
|
||||
createChannelConfig,
|
||||
createLegacyUpdateManifest,
|
||||
createUpdateManifest,
|
||||
formatRecentReleaseNotes,
|
||||
formatReleaseNotes,
|
||||
nextPatchVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolvePreviousReleaseCommit,
|
||||
resolveReleaseChannel,
|
||||
resolveRemoteHighWaterVersion,
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
@@ -63,22 +49,6 @@ function withSignedArtifact(fileName, run) {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
function withStubbedFetch(handler, run) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => handler(String(url));
|
||||
return Promise.resolve(run()).finally(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = fileURLToPath(
|
||||
new URL('../package.json', import.meta.url),
|
||||
@@ -203,137 +173,6 @@ test('next release version follows the higher local or channel version', () => {
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('version high water keeps the legacy pointer during the migration window', async () => {
|
||||
await withStubbedFetch(
|
||||
(url) =>
|
||||
url.endsWith('/agc/dev-win/latest.json')
|
||||
? jsonResponse({}, 404)
|
||||
: jsonResponse({ version: '0.1.57' }),
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
|
||||
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
|
||||
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('version high water takes the higher of channel and legacy pointer', async () => {
|
||||
await withStubbedFetch(
|
||||
(url) =>
|
||||
url.endsWith('/agc/dev-win/latest.json')
|
||||
? jsonResponse({ version: '0.1.60' })
|
||||
: jsonResponse({ version: '0.1.57' }),
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('version high water ignores the windows migration pointer for other channels', async () => {
|
||||
await withStubbedFetch(
|
||||
(url) => {
|
||||
assert.ok(
|
||||
!url.endsWith('/agc/latest.json'),
|
||||
'non-windows channel must not read the windows migration pointer',
|
||||
);
|
||||
return jsonResponse({ version: '0.1.12' });
|
||||
},
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
|
||||
await withStubbedFetch(
|
||||
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
override: '6017d46088c04199e99cf89f347b12d67591475e',
|
||||
}),
|
||||
'6017d46088c04199e99cf89f347b12d67591475e',
|
||||
);
|
||||
// 覆盖值非法时忽略,继续用清单里的 commit。
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
override: 'not-a-sha',
|
||||
}),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await withStubbedFetch(
|
||||
() => jsonResponse({ version: '0.1.61' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error('fetch failed');
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('recent commit fallback marks that entries may repeat the previous release', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||||
try {
|
||||
git('init', '--quiet');
|
||||
git('config', 'user.email', 'release@example.test');
|
||||
git('config', 'user.name', 'release test');
|
||||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||||
recursive: true,
|
||||
});
|
||||
for (const name of ['one', 'two']) {
|
||||
writeFileSync(
|
||||
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
|
||||
`fn ${name}() {}\n`,
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', `客户端:${name}`);
|
||||
}
|
||||
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '文档:说明');
|
||||
|
||||
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
|
||||
assert.deepEqual(
|
||||
recent.map((entry) => entry.subject),
|
||||
['客户端:two', '客户端:one'],
|
||||
);
|
||||
const notes = formatRecentReleaseNotes(recent);
|
||||
assert.match(
|
||||
notes,
|
||||
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
|
||||
);
|
||||
assert.equal(formatRecentReleaseNotes([]), '');
|
||||
assert.equal(formatRecentReleaseNotes(null), '');
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
@@ -346,141 +185,3 @@ test('release upload forces overwrite for artifact, signature and channel pointe
|
||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||||
assert.match(source, /agc\/latest\.json/u);
|
||||
});
|
||||
|
||||
test('release notes list client commits with short sha and bound their size', () => {
|
||||
const notes = formatReleaseNotes([
|
||||
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
|
||||
{ sha: '55af6014', subject: '修'.repeat(120) },
|
||||
]);
|
||||
const lines = notes.split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
|
||||
const truncatedSubject = lines[1]
|
||||
.replace(/^- /u, '')
|
||||
.replace(/(55af6014)$/u, '');
|
||||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||||
assert.match(truncatedSubject, /…$/u);
|
||||
assert.match(lines[1], /(55af6014)$/u);
|
||||
|
||||
const many = formatReleaseNotes(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
sha: `sha${index}`,
|
||||
subject: `改动 ${index}`,
|
||||
})),
|
||||
);
|
||||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||||
assert.equal(formatReleaseNotes([]), '');
|
||||
assert.equal(formatReleaseNotes(null), '');
|
||||
});
|
||||
|
||||
test('release commits cover only client paths and skip merge commits', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||||
try {
|
||||
git('init', '--quiet');
|
||||
git('config', 'user.email', 'release@example.test');
|
||||
git('config', 'user.name', 'release test');
|
||||
git('commit', '--allow-empty', '--quiet', '-m', '基点');
|
||||
const base = git('rev-parse', 'HEAD').trim();
|
||||
|
||||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||||
recursive: true,
|
||||
});
|
||||
mkdirSync(path.join(directory, 'docs'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
|
||||
'fn main() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
|
||||
|
||||
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '文档:补充说明');
|
||||
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
|
||||
'fn other() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:修复版本回退');
|
||||
|
||||
git('checkout', '--quiet', '-b', 'side');
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
|
||||
'fn side() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:侧分支改动');
|
||||
git('checkout', '--quiet', 'master');
|
||||
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
|
||||
|
||||
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
|
||||
assert.ok(commits, '应能在临时仓库里收集提交');
|
||||
const subjects = commits.map((entry) => entry.subject);
|
||||
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
|
||||
assert.deepEqual(subjects, [
|
||||
'客户端:侧分支改动',
|
||||
'客户端:修复版本回退',
|
||||
'客户端:新增更新插件接入',
|
||||
]);
|
||||
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
|
||||
|
||||
assert.equal(
|
||||
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
|
||||
null,
|
||||
);
|
||||
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduler path filter stays in sync with client release paths', () => {
|
||||
const jenkinsfile = readFileSync(
|
||||
new URL(
|
||||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const filterLine = jenkinsfile
|
||||
.split('\n')
|
||||
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
|
||||
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
|
||||
for (const pattern of agcReleasePathPatterns) {
|
||||
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
|
||||
assert.ok(
|
||||
filterLine.includes(bashPattern),
|
||||
`调度管线过滤缺少 ${bashPattern}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduler skips the full build only for non-deploy paths', () => {
|
||||
const jenkinsfile = readFileSync(
|
||||
new URL(
|
||||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const skipLine = jenkinsfile
|
||||
.split('\n')
|
||||
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
|
||||
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
|
||||
for (const pattern of [
|
||||
'docs/*',
|
||||
'.codex/*',
|
||||
'jenkins/*',
|
||||
'apps/ai-game-creator-shell/*',
|
||||
'apps/mobile-shell/*',
|
||||
'apps/desktop-shell/*',
|
||||
'apps/preview-deployer-web/*',
|
||||
'tools/*',
|
||||
'*.md',
|
||||
]) {
|
||||
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ function runOssutil(args) {
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
||||
await generateUpdateManifest();
|
||||
generateUpdateManifest();
|
||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createServer, loadConfigFromFile, normalizePath } from 'vite';
|
||||
|
||||
test(
|
||||
'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: 'serve', mode: 'development' },
|
||||
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
|
||||
);
|
||||
assert.ok(loaded);
|
||||
assert.notEqual(loaded.config.server?.watch, null);
|
||||
assert.notEqual(loaded.config.server?.hmr, false);
|
||||
assert.ok(
|
||||
[loaded.config.server?.watch?.ignored]
|
||||
.flat()
|
||||
.includes('**/src-tauri/target/**'),
|
||||
);
|
||||
|
||||
const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
|
||||
const root = join(fixture, 'apps', 'ai-game-creator-shell');
|
||||
const source = join(root, 'src', 'main.js');
|
||||
const css = join(root, 'src', 'styles.css');
|
||||
const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
|
||||
const target = join(root, 'src-tauri', 'target');
|
||||
const artifact = join(target, 'debug', 'incremental', 'cache.bin');
|
||||
let server;
|
||||
try {
|
||||
for (const file of [source, css, shared, artifact]) {
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: red; }' : 'export default 1;',
|
||||
);
|
||||
}
|
||||
// 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
|
||||
// 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
server: {
|
||||
watch: loaded.config.server?.watch,
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
fs: { allow: [fixture] },
|
||||
},
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const waitForWatchedFile = async (file) => {
|
||||
const normalized = normalizePath(file);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (
|
||||
Object.entries(server.watcher.getWatched()).some(
|
||||
([directory, names]) =>
|
||||
names.some(
|
||||
(name) => normalizePath(join(directory, name)) === normalized,
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
await delay(50);
|
||||
}
|
||||
assert.fail(`源码必须仍被监听:${normalized}`);
|
||||
};
|
||||
await waitForWatchedFile(source);
|
||||
|
||||
// 真实模块转换应将 root 外的共享源码加入监听。
|
||||
await server.transformRequest(`/@fs/${normalizePath(shared)}`);
|
||||
for (const file of [source, css, shared]) {
|
||||
const normalized = normalizePath(file);
|
||||
await waitForWatchedFile(file);
|
||||
const changed = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
server.watcher.off('change', onChange);
|
||||
reject(new Error(`未收到源码变更:${normalized}`));
|
||||
}, 5_000);
|
||||
function onChange(path) {
|
||||
if (normalizePath(path) !== normalized) return;
|
||||
clearTimeout(timer);
|
||||
server.watcher.off('change', onChange);
|
||||
resolve();
|
||||
}
|
||||
server.watcher.on('change', onChange);
|
||||
});
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: blue; }' : 'export default 2;',
|
||||
);
|
||||
await changed;
|
||||
}
|
||||
const targetPath = normalizePath(target);
|
||||
const targetDirectories = Object.keys(server.watcher.getWatched())
|
||||
.map(normalizePath)
|
||||
.filter(
|
||||
(path) => path === targetPath || path.startsWith(`${targetPath}/`),
|
||||
);
|
||||
assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
|
||||
} finally {
|
||||
await server?.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -33,11 +33,7 @@ chromiumoxide = "0.9.1"
|
||||
futures = "0.3"
|
||||
getrandom = "0.3"
|
||||
http = "1"
|
||||
# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的
|
||||
# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。
|
||||
# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让
|
||||
# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
jsonschema = { version = "0.49.3", default-features = false }
|
||||
oxc_allocator = "0.143.0"
|
||||
oxc_ast = "0.143.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
## Workflow
|
||||
|
||||
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
|
||||
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
|
||||
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
|
||||
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
||||
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
||||
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ The client projects three distinct facts:
|
||||
|
||||
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
|
||||
|
||||
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
|
||||
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
|
||||
|
||||
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.20",
|
||||
"version": "2026-08-26.19",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
|
||||
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
@@ -40,7 +41,8 @@ use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at,
|
||||
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
direct_thread_id_for_project, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
@@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
+23
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
|
||||
))
|
||||
}
|
||||
|
||||
/// 项目根目录在 Thread Manager 里的线程身份。
|
||||
///
|
||||
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
|
||||
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
|
||||
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
|
||||
///
|
||||
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
|
||||
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
|
||||
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
|
||||
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
|
||||
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
|
||||
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
|
||||
return root.to_string_lossy().into_owned();
|
||||
};
|
||||
canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix(r"\\?\"))
|
||||
.map(std::path::Path::new)
|
||||
.unwrap_or(canonical_root.as_path())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?;
|
||||
if prepare_art {
|
||||
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
|
||||
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
|
||||
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
|
||||
} else {
|
||||
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1380,33 +1380,6 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
|
||||
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp"
|
||||
| "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql"
|
||||
| "gql" | "vue" | "svelte" => ("code", Some("text/plain")),
|
||||
/*
|
||||
* Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。
|
||||
*
|
||||
* 这里只做**发现分类**:`model` / `binary` 是发现层新词,与 manifest 资产 `kind`
|
||||
* 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。
|
||||
* 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层
|
||||
* 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的
|
||||
* 第三份投影,同样要跟改。
|
||||
*/
|
||||
"glb" => ("model", Some("model/gltf-binary")),
|
||||
"gltf" => ("model", Some("model/gltf+json")),
|
||||
"fbx" => ("model", Some("application/octet-stream")),
|
||||
"mesh" | "skeleton" => ("model", Some("application/json")),
|
||||
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
|
||||
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => {
|
||||
("document", Some("application/json"))
|
||||
}
|
||||
"tmx" | "plist" => ("document", Some("application/xml")),
|
||||
"effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")),
|
||||
"tga" => ("image", Some("image/x-tga")),
|
||||
"tif" | "tiff" => ("image", Some("image/tiff")),
|
||||
"hdr" => ("image", Some("image/vnd.radiance")),
|
||||
"exr" => ("image", Some("image/x-exr")),
|
||||
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
|
||||
("binary", Some("application/octet-stream"))
|
||||
}
|
||||
"pcm" => ("audio", Some("audio/pcm")),
|
||||
_ => ("other", None),
|
||||
}
|
||||
}
|
||||
@@ -1448,10 +1421,8 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
.map(|value| value.to_lowercase());
|
||||
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
if ![
|
||||
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
|
||||
]
|
||||
.contains(&requested_kind.as_str())
|
||||
if !["all", "image", "font", "audio", "video", "document", "code"]
|
||||
.contains(&requested_kind.as_str())
|
||||
{
|
||||
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
|
||||
}
|
||||
@@ -3210,9 +3181,7 @@ mod tests {
|
||||
"supported raster image should be importable: {path}"
|
||||
);
|
||||
}
|
||||
// `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler),
|
||||
// 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。
|
||||
for path in ["assets/theme.dat", "assets/unknown.xyz"] {
|
||||
for path in ["assets/theme.bin", "assets/unknown.xyz"] {
|
||||
assert!(
|
||||
!bridge_project_file_is_asset_importable(path),
|
||||
"unsupported project file must not be advertised as importable: {path}"
|
||||
@@ -3232,64 +3201,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType`
|
||||
/// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。
|
||||
///
|
||||
/// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。
|
||||
#[test]
|
||||
fn bridge_project_file_class_covers_cocos_creator_assets() {
|
||||
for (path, expected_class, expected_media_type) in [
|
||||
("assets/model/hero.glb", "model", "model/gltf-binary"),
|
||||
("assets/model/hero.gltf", "model", "model/gltf+json"),
|
||||
("assets/model/hero.fbx", "model", "application/octet-stream"),
|
||||
("assets/model/hero.mesh", "model", "application/json"),
|
||||
("assets/model/hero.skeleton", "model", "application/json"),
|
||||
("assets/scene/main.scene", "document", "application/json"),
|
||||
("assets/scene/enemy.prefab", "document", "application/json"),
|
||||
("assets/anim/walk.anim", "document", "application/json"),
|
||||
(
|
||||
"assets/anim/graph.animgraph",
|
||||
"document",
|
||||
"application/json",
|
||||
),
|
||||
("assets/mtl/hero.mtl", "document", "application/json"),
|
||||
("assets/shader/glow.effect", "document", "text/plain"),
|
||||
("assets/atlas/hero.plist", "document", "application/xml"),
|
||||
("assets/map/level.tmx", "document", "application/xml"),
|
||||
("assets/font/bitmap.fnt", "document", "text/plain"),
|
||||
("assets/atlas/auto.pac", "document", "application/json"),
|
||||
("assets/tex/grass.tga", "image", "image/x-tga"),
|
||||
("assets/tex/height.hdr", "image", "image/vnd.radiance"),
|
||||
(
|
||||
"assets/tex/hero.texture",
|
||||
"binary",
|
||||
"application/octet-stream",
|
||||
),
|
||||
(
|
||||
"assets/spine/hero.skel",
|
||||
"binary",
|
||||
"application/octet-stream",
|
||||
),
|
||||
("assets/audio/voice.pcm", "audio", "audio/pcm"),
|
||||
] {
|
||||
let (class, media_type) = bridge_project_file_class(path);
|
||||
assert_eq!(class, expected_class, "{path}");
|
||||
assert_eq!(media_type, Some(expected_media_type), "{path}");
|
||||
assert!(
|
||||
bridge_project_file_is_asset_importable(path),
|
||||
"Cocos 资源必须可登记:{path}"
|
||||
);
|
||||
}
|
||||
// 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。
|
||||
for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] {
|
||||
assert_eq!(bridge_project_file_class(path).0, "other", "{path}");
|
||||
assert!(
|
||||
!bridge_project_file_is_asset_importable(path),
|
||||
"非资源文件不得可登记:{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_project_file_listing_projects_importability_per_file() {
|
||||
let temporary = tempfile::tempdir().expect("create project file listing root");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。
|
||||
//! GameAgent 对话「工具调用卡片」的采集与持久化。
|
||||
//!
|
||||
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:
|
||||
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
|
||||
@@ -8,11 +8,9 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use super::direct_thread_wire::sanitize_detail_text;
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -24,7 +22,7 @@ use std::path::{Path, PathBuf};
|
||||
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
|
||||
/// 条目 schema 版本。
|
||||
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
|
||||
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
|
||||
/// `detail.command` / `detail.output` 的字符上限。
|
||||
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
|
||||
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/tool-calls.jsonl")
|
||||
}
|
||||
|
||||
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
|
||||
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
|
||||
let mut index = start;
|
||||
let mut relative = String::new();
|
||||
while index < value.len() {
|
||||
let character = value[index..].chars().next().unwrap_or_default();
|
||||
if matches!(character, '/' | '\\') {
|
||||
if !relative.is_empty() {
|
||||
relative.push('/');
|
||||
}
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ';'
|
||||
| '|'
|
||||
| '&'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '<'
|
||||
| '>'
|
||||
| ':'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
relative.push(character);
|
||||
index += character.len_utf8();
|
||||
}
|
||||
while relative.ends_with('/') {
|
||||
relative.pop();
|
||||
}
|
||||
(index, relative)
|
||||
}
|
||||
|
||||
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
|
||||
///
|
||||
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
|
||||
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
|
||||
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
|
||||
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
|
||||
let root_text = root.to_string_lossy();
|
||||
let root_text = root_text.trim_end_matches(['/', '\\']);
|
||||
if root_text.is_empty() {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut needles = [
|
||||
root_text.to_string(),
|
||||
root_text.replace('\\', "/"),
|
||||
root_text.replace('/', "\\"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|needle| needle.to_ascii_lowercase())
|
||||
.filter(|needle| !needle.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
needles.sort();
|
||||
needles.dedup();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < value.len() {
|
||||
let mut hit: Option<(usize, usize)> = None;
|
||||
for needle in &needles {
|
||||
let mut search = cursor;
|
||||
while let Some(relative) = lower[search..].find(needle.as_str()) {
|
||||
let start = search + relative;
|
||||
let end = start + needle.len();
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start].chars().next_back().is_some_and(|character| {
|
||||
!character.is_alphanumeric() && character != '_' && character != '-'
|
||||
});
|
||||
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
|
||||
if hit.is_none_or(|(best_start, _)| start < best_start) {
|
||||
hit = Some((start, end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
let Some((start, end)) = hit else {
|
||||
break;
|
||||
};
|
||||
output.push_str(&value[cursor..start]);
|
||||
let (consumed, relative) = project_relative_path_segment(value, end);
|
||||
if relative.is_empty() {
|
||||
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
|
||||
output.push_str("<absolute-path>");
|
||||
} else {
|
||||
output.push_str(&relative);
|
||||
}
|
||||
cursor = consumed;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
|
||||
/// 错误上下文脱敏。
|
||||
///
|
||||
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
|
||||
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
|
||||
///
|
||||
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
|
||||
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
|
||||
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
|
||||
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
|
||||
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
|
||||
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
|
||||
///
|
||||
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
|
||||
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
|
||||
let without_project_root = relativize_project_root_paths(root, value);
|
||||
let without_absolute = redact_absolute_path_tokens(&without_project_root);
|
||||
let without_secret = redact_secret_tokens(&without_absolute);
|
||||
sanitize_error_context(&without_secret)
|
||||
}
|
||||
|
||||
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
@@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
|
||||
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
|
||||
let path = tool_calls_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
|
||||
}
|
||||
|
||||
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
|
||||
fn status_certainty(status: &str) -> u8 {
|
||||
match status {
|
||||
@@ -684,10 +545,22 @@ mod tests {
|
||||
use super::{
|
||||
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
|
||||
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
|
||||
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
|
||||
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
|
||||
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
|
||||
DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
|
||||
/// 断言因此落在「磁盘上到底写了什么」这一层。
|
||||
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
|
||||
std::fs::read_to_string(tool_calls_path(root))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(tool_call_from_line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一行合法的落盘信封(回读用例的夹具)。
|
||||
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
|
||||
@@ -812,7 +685,7 @@ mod tests {
|
||||
.expect("completed tool call");
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(calls[0].status, "completed");
|
||||
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
|
||||
@@ -912,85 +785,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
|
||||
#[test]
|
||||
fn tool_call_read_skips_corrupted_lines() {
|
||||
let root = init_tool_call_project("tool-call-corrupt");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let good = serde_json::to_string(&json!({
|
||||
"type": "tool_call_item",
|
||||
"payload": {
|
||||
"schemaVersion": "agc-tool-call.v1",
|
||||
"id": "item-good",
|
||||
"turnId": "turn-1",
|
||||
"kind": "command",
|
||||
"title": "执行命令",
|
||||
"summary": "npm run build",
|
||||
"status": "completed",
|
||||
"detail": {"command": "npm run build"},
|
||||
"startedAt": 1,
|
||||
"updatedAt": 2
|
||||
}
|
||||
}))
|
||||
.expect("serialize good row");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
|
||||
)
|
||||
.expect("write fixture");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_direct_tool_calls_at(missing.path())
|
||||
.expect("missing file is empty")
|
||||
.is_empty(),
|
||||
"历史文件缺失必须返回空数组"
|
||||
);
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
|
||||
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
|
||||
assert_eq!(calls[0].id, "item-good");
|
||||
}
|
||||
|
||||
/// 判据:回读按时间正序,且超出上限时保留最新。
|
||||
#[test]
|
||||
fn tool_call_read_is_ordered_and_capped() {
|
||||
let root = init_tool_call_project("tool-call-cap");
|
||||
let total = DIRECT_TOOL_CALL_LIMIT + 5;
|
||||
let calls = (0..total)
|
||||
.map(|index| {
|
||||
direct_tool_call_from_item(
|
||||
root.path(),
|
||||
&json!({
|
||||
"id": format!("item-{index:04}"),
|
||||
"type": "commandExecution",
|
||||
"command": format!("run {index}"),
|
||||
"startedAtMs": 1000 + index as u64,
|
||||
}),
|
||||
"turn-1",
|
||||
false,
|
||||
1000 + index as u64,
|
||||
)
|
||||
.expect("tool call")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
|
||||
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
|
||||
assert_eq!(
|
||||
read.first().expect("first").id,
|
||||
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
|
||||
"最早被裁掉的是最旧的条目"
|
||||
);
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"回读必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
|
||||
#[test]
|
||||
fn tool_call_file_change_title_counts_unique_paths() {
|
||||
@@ -1189,7 +983,7 @@ mod tests {
|
||||
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
|
||||
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
@@ -1201,7 +995,7 @@ mod tests {
|
||||
// 回合末整批落盘那条路径同样不得回退。
|
||||
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
|
||||
.expect("persist stale running batch");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"整批落盘路径同样不得把 completed 打回 running"
|
||||
@@ -1209,28 +1003,6 @@ mod tests {
|
||||
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
|
||||
}
|
||||
|
||||
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
|
||||
#[test]
|
||||
fn tool_call_read_merges_duplicate_rows_monotonically() {
|
||||
let root = init_tool_call_project("tool-call-read-monotonic");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let completed = tool_call_row("item-1", 1000, 2000);
|
||||
let stale_running = tool_call_row("item-1", 1000, 1000)
|
||||
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
|
||||
assert!(stale_running.contains("\"status\":\"running\""));
|
||||
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
|
||||
assert_eq!(calls.len(), 1, "同 id 归并成一条");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"磁盘上更旧的快照不得把状态打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
|
||||
assert_eq!(calls[0].started_at, 1000);
|
||||
}
|
||||
|
||||
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
|
||||
#[test]
|
||||
fn tool_call_paths_become_project_relative() {
|
||||
@@ -1283,9 +1055,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。
|
||||
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回,
|
||||
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
|
||||
#[test]
|
||||
fn tool_call_read_skips_invalid_utf8_line() {
|
||||
fn tool_call_pre_read_skips_invalid_utf8_line() {
|
||||
let root = init_tool_call_project("tool-call-invalid-utf8");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
@@ -1298,7 +1071,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1318,7 +1091,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1326,10 +1099,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(calls[0].id, "item-a");
|
||||
assert_eq!(calls[1].id, "item-c");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
|
||||
"历史文件缺失时写前读取必须返回空表"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
|
||||
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
|
||||
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
#[test]
|
||||
fn tool_call_cap_drops_oldest_turn_cards() {
|
||||
let root = init_tool_call_project("tool-call-cap-oldest");
|
||||
@@ -1366,7 +1145,7 @@ mod tests {
|
||||
.expect("newest tool call");
|
||||
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
|
||||
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
let read = persisted_tool_calls(root.path());
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
|
||||
assert_eq!(
|
||||
read.last().expect("last").id,
|
||||
@@ -1381,7 +1160,7 @@ mod tests {
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"回读必须按时间正序"
|
||||
"落盘顺序必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -369,8 +369,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"],
|
||||
"description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源"
|
||||
"enum": ["all", "image", "font", "audio", "video", "document", "code"]
|
||||
},
|
||||
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||
@@ -772,10 +771,7 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
|
||||
}
|
||||
if arguments.get("kind").is_some() {
|
||||
let kind = bounded_tool_string(arguments, "kind", 16)?;
|
||||
if ![
|
||||
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
|
||||
]
|
||||
.contains(&kind.as_str())
|
||||
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str())
|
||||
{
|
||||
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「回合流」的采集、持久化与回读。
|
||||
//! GameAgent 对话「回合流」的采集与持久化。
|
||||
//!
|
||||
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
|
||||
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
|
||||
@@ -11,7 +11,7 @@
|
||||
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
|
||||
|
||||
use crate::agent::sanitize_detail_text;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
|
||||
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
|
||||
let path = turn_stream_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_stream_items(read_stream_lines(&path)))
|
||||
}
|
||||
|
||||
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
|
||||
///
|
||||
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
|
||||
output.push_str(
|
||||
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
|
||||
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
|
||||
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等)可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
|
||||
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档或代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
|
||||
);
|
||||
for (path, size, media_type) in unregistered.iter().take(48) {
|
||||
let asset_importable = !media_type.is_empty();
|
||||
@@ -228,27 +228,6 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
|
||||
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts"
|
||||
| "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
|
||||
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
|
||||
/*
|
||||
* Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与
|
||||
* `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
|
||||
* `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名,
|
||||
* Agent 的提示词就会把「其实可以登记」的资源说成只能发现。
|
||||
*/
|
||||
"glb" => Some("model/gltf-binary"),
|
||||
"gltf" => Some("model/gltf+json"),
|
||||
"fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
|
||||
Some("application/octet-stream")
|
||||
}
|
||||
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
|
||||
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh"
|
||||
| "skeleton" => Some("application/json"),
|
||||
"tmx" | "plist" => Some("application/xml"),
|
||||
"effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"),
|
||||
"tga" => Some("image/x-tga"),
|
||||
"tif" | "tiff" => Some("image/tiff"),
|
||||
"hdr" => Some("image/vnd.radiance"),
|
||||
"exr" => Some("image/x-exr"),
|
||||
"pcm" => Some("audio/pcm"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -267,21 +246,10 @@ mod tests {
|
||||
("assets/data.json", "application/json"),
|
||||
("game/index.html", "text/html"),
|
||||
("game/main.rs", "text/plain"),
|
||||
// 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名,
|
||||
// Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。
|
||||
("assets/model/hero.glb", "model/gltf-binary"),
|
||||
("assets/model/hero.fbx", "application/octet-stream"),
|
||||
("assets/anim/walk.anim", "application/json"),
|
||||
("assets/scene/main.scene", "application/json"),
|
||||
("assets/shader/glow.effect", "text/plain"),
|
||||
("assets/atlas/hero.plist", "application/xml"),
|
||||
("assets/tex/grass.tga", "image/x-tga"),
|
||||
("assets/audio/voice.pcm", "audio/pcm"),
|
||||
] {
|
||||
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
|
||||
}
|
||||
// `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。
|
||||
assert_eq!(prompt_context_media_type("assets/unknown.dat"), None);
|
||||
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2630,7 +2630,6 @@ fn main() {
|
||||
read_local_project_image_preview,
|
||||
save_local_project_asset_file,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_structured_preview,
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
write_local_project_file,
|
||||
@@ -2647,8 +2646,6 @@ fn main() {
|
||||
archive_game_creator_agent_session,
|
||||
read_local_conversation,
|
||||
read_direct_project_conversation,
|
||||
read_direct_tool_calls,
|
||||
read_direct_turn_stream,
|
||||
read_agent_runtime_error_detail,
|
||||
list_game_creator_direct_active_turns,
|
||||
subscribe_direct_project_thread,
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
/**
|
||||
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
|
||||
*
|
||||
* 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源
|
||||
* 目录里的普通文件夹,不属于这里。
|
||||
*/
|
||||
fn is_engine_generated_root_directory(relative_path: &str) -> bool {
|
||||
if relative_path.contains('/') {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
relative_path.to_ascii_lowercase().as_str(),
|
||||
"library" | "temp" | "profiles" | "local"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_local_project_files_at(
|
||||
root: &Path,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
@@ -27,15 +11,6 @@ pub(crate) fn list_local_project_files_at(
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。
|
||||
*
|
||||
* 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在
|
||||
* 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的
|
||||
* `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让
|
||||
* 前端资源树加载一堆永远用不上的条目。
|
||||
*/
|
||||
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
@@ -66,11 +41,6 @@ pub(crate) fn list_local_project_files_at(
|
||||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0);
|
||||
if file_type.is_dir() {
|
||||
if skip_engine_generated_directories
|
||||
&& is_engine_generated_root_directory(&relative_path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.push(LocalProjectFileEntry {
|
||||
path: relative_path,
|
||||
kind: "directory".to_string(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user