Merge branch 'master' into refactor/remove-toolcall.jsonl-legacy
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-01 11:55:43 +08:00
11 changed files with 456 additions and 31 deletions
+12 -3
View File
@@ -47,8 +47,8 @@ env:
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
jobs:
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;
# build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。
ai-game-creator-shell-rust-lane-1:
name: AI game creator shell Rust lane 1/2
runs-on: genarrative-ci
@@ -62,6 +62,9 @@ jobs:
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
@@ -118,6 +121,9 @@ jobs:
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
@@ -154,7 +160,7 @@ jobs:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖,
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
ai-game-creator-shell-rust-smoke:
name: AI game creator shell Rust smoke
@@ -169,6 +175,9 @@ jobs:
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
@@ -16,7 +16,10 @@ import {
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
import type {
AdminProjectSnapshotEntry,
AdminProjectSnapshotListResponse,
} from '../api/adminApiTypes';
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
vi.mock('../api/adminApiClient', async () => ({
@@ -58,6 +61,241 @@ afterEach(() => {
vi.useRealTimers();
});
test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => {
const olderProjects = Array.from({ length: 22 }, (_, index) => ({
...entry,
projectId: `older-${index}`,
projectName: `旧项目 ${index}`,
syncedAtMs: entry.syncedAtMs + index,
}));
const tiedProjects = [
{ ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
{ ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
{ ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
.mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
// 后端有界扫描可能返回带下一游标的空页,不能提前结束。
.mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
.mockResolvedValueOnce({
items: [
...tiedProjects,
{
...olderProjects[0]!,
projectName: '更新项目',
syncedAtMs: entry.syncedAtMs + 200,
},
],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByRole('button', { name: '恢复默认顺序' });
const rowNames = () =>
screen
.getAllByRole('row')
.map((row) => row.querySelector('td strong')?.textContent)
.filter(Boolean);
expect(rowNames().slice(0, 4)).toEqual([
'更新项目',
'同秒甲一',
'同秒甲二',
'同秒乙',
]);
expect(rowNames()).toHaveLength(20);
expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
4,
'token',
{ cursor: 'scan-3', limit: 100, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(rowNames()).toHaveLength(5);
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
expect(rowNames()[0]).toBe('更新项目');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
expect(rowNames()).toHaveLength(25);
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [{ ...entry, projectName: '刷新后的项目' }],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('刷新后的项目');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
5,
'token',
{ cursor: null, limit: 100, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
6,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
});
test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
const projects = Array.from({ length: 45 }, (_, index) => ({
...entry,
projectId: `sorted-${index}`,
projectName: `排序项目 ${index}`,
syncedAtMs: entry.syncedAtMs - index,
}));
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({ items: projects, nextCursor: null })
.mockRejectedValueOnce(new Error('恢复失败'))
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '默认第一页' }],
nextCursor: 'default-next',
})
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '默认第二页' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('排序项目 0');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('恢复失败');
expect(screen.getByText('排序项目 20')).toBeTruthy();
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(screen.getByText('排序项目 40')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
expect(screen.getByText('排序项目 20')).toBeTruthy();
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('默认第一页');
expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
4,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('默认第二页');
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
5,
'token',
{ cursor: 'default-next', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
});
test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '部分读取项目' }],
nextCursor: 'scan-next',
})
.mockRejectedValueOnce(new Error('全量读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('全量读取失败');
expect(screen.getByText('三消工程')).toBeTruthy();
expect(screen.queryByText('部分读取项目')).toBeNull();
expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [{ ...entry, projectName: '完整排序项目' }],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('完整排序项目');
vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
new Error('刷新读取失败'),
);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('刷新读取失败');
expect(screen.getByText('完整排序项目')).toBeTruthy();
expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
});
test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'dev',
channels: ['dev', 'release'],
});
let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockImplementationOnce(
() =>
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
finishCancelled = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
finishOldChannel = resolve;
}),
)
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
.calls[1]![2]!;
fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
expect(cancelledSignal.aborted).toBe(true);
await act(async () => {
finishCancelled({
items: [{ ...entry, projectName: '已取消项目' }],
nextCursor: null,
});
});
expect(screen.getByText('三消工程')).toBeTruthy();
expect(screen.queryByText('已取消项目')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
.calls[2]![2]!;
fireEvent.change(screen.getByLabelText('项目工程渠道'), {
target: { value: 'release' },
});
await screen.findByText('正式渠道项目');
expect(oldChannelSignal.aborted).toBe(true);
await act(async () => {
finishOldChannel({
items: [{ ...entry, projectName: '旧渠道项目' }],
nextCursor: null,
});
});
expect(screen.getByText('正式渠道项目')).toBeTruthy();
expect(screen.queryByText('旧渠道项目')).toBeNull();
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
});
test('按项目展示完整性并限制未完成工程下载', async () => {
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
items: [
@@ -10,7 +10,7 @@ import {
AdminPagination,
AdminStatusPill,
} from '@genarrative/shared/components';
import { Download, RefreshCcw, X } from 'lucide-react';
import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -50,6 +50,9 @@ const snapshotStatuses: Record<
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
const MAX_SORT_PAGES = 200;
const MAX_SORT_PROJECTS = 10_000;
const SORT_TIMEOUT_MS = 60_000;
export function AdminProjectSnapshotsPage({
token,
@@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({
const [channelOptions, setChannelOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [isSorted, setIsSorted] = useState(false);
const [isSorting, setIsSorting] = useState(false);
const [sortProgress, setSortProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const listController = useRef<AbortController | null>(null);
@@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
// 全量读取完成后才发布;分页只切片本次读到的完整集合。
const sortedItems = useRef<AdminProjectSnapshotEntry[] | null>(null);
const loadPage = useCallback(
async (cursor: string | null, limit: number, page: number) => {
async (
cursor: string | null,
limit: number,
page: number,
restoreDefault = false,
) => {
if (!restoreDefault && sortedItems.current !== null) {
setItems(sortedItems.current.slice((page - 1) * limit, page * limit));
setPageIndex(page);
setHasLoaded(true);
setErrorMessage('');
return;
}
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
@@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({
controller.signal,
);
if (controller.signal.aborted) return;
// 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。
if (restoreDefault) {
sortedItems.current = null;
setIsSorted(false);
pageCursors.current = [null];
}
setItems(response.items);
setNextCursor(response.nextCursor);
setPageIndex(page);
@@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({
return () => controller.abort();
}, [token, onUnauthorized]);
useEffect(() => {
sortedItems.current = null;
setIsSorted(false);
}, [token, channel]);
useEffect(() => {
if (channel === null) return undefined;
setIsSorting(false);
setIsLoading(false);
setSortProgress(0);
pageCursors.current = [null];
setItems([]);
setNextCursor(null);
@@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({
}, [loadPage, pageSize, channel]);
function goToNextPage() {
if (sortedItems.current !== null) {
void loadPage(null, pageSize, pageIndex + 1);
return;
}
if (!nextCursor) return;
pageCursors.current[pageIndex] = nextCursor;
void loadPage(nextCursor, pageSize, pageIndex + 1);
@@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({
}
function refreshCurrentPage() {
if (sortedItems.current !== null) {
void sortAllProjects();
return;
}
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
@@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({
);
}
async function sortAllProjects() {
if (channel === null) return;
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
setIsLoading(true);
setIsSorting(true);
setSortProgress(0);
setErrorMessage('');
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, SORT_TIMEOUT_MS);
try {
const projects = new Map<string, AdminProjectSnapshotEntry>();
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (let page = 0; page < MAX_SORT_PAGES; page += 1) {
const response = await listAdminProjectSnapshots(
token,
{ cursor, limit: 100, channel },
controller.signal,
);
if (controller.signal.aborted) return;
for (const project of response.items) {
const key = snapshotKey(project);
const previous = projects.get(key);
if (!previous || project.syncedAtMs >= previous.syncedAtMs) {
projects.set(key, project);
}
}
if (projects.size > MAX_SORT_PROJECTS) {
throw new Error('项目超过 10,000 个,无法完成全量排序');
}
setSortProgress(projects.size);
if (!response.nextCursor) {
const result = [...projects.values()].sort(
(left, right) =>
right.syncedAtMs - left.syncedAtMs ||
compareIds(left.userId, right.userId) ||
compareIds(left.projectId, right.projectId),
);
sortedItems.current = result;
setIsSorted(true);
setItems(result.slice(0, pageSize));
setPageIndex(1);
setNextCursor(null);
setHasLoaded(true);
return;
}
if (seenCursors.has(response.nextCursor)) {
throw new Error('项目列表游标重复,无法完成全量排序,请重试');
}
seenCursors.add(response.nextCursor);
cursor = response.nextCursor;
}
throw new Error('项目读取超过 200 页,无法完成全量排序');
} catch (error: unknown) {
if (!controller.signal.aborted) {
handlePageError(error, onUnauthorized, setErrorMessage);
}
} finally {
clearTimeout(timeout);
if (listController.current === controller) {
listController.current = null;
setIsLoading(false);
setIsSorting(false);
if (timedOut) setErrorMessage('全量项目读取超时,请重试');
}
}
}
function cancelSorting() {
listController.current?.abort();
listController.current = null;
setIsLoading(false);
setIsSorting(false);
}
function restoreDefaultOrder() {
void loadPage(null, pageSize, 1, true);
}
async function downloadProject(entry: AdminProjectSnapshotEntry) {
if (downloadController.current || entry.status === 'partial') return;
const controller = new AbortController();
@@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({
))}
</select>
</AdminField>
<AdminButton
variant="secondary"
disabled={isLoading || !hasLoaded}
type="button"
onClick={
isSorted ? restoreDefaultOrder : () => void sortAllProjects()
}
>
<ArrowDownWideNarrow size={17} aria-hidden="true" />
<span>{isSorted ? '恢复默认顺序' : '按同步时间排序'}</span>
</AdminButton>
{isSorting ? (
<>
<span role="status">
正在读取全部项目,已读取 {sortProgress} 个
</span>
<AdminButton
variant="secondary"
type="button"
onClick={cancelSorting}
>
<X size={17} aria-hidden="true" />
<span>取消排序</span>
</AdminButton>
</>
) : null}
<AdminButton
variant="secondary"
disabled={isLoading}
@@ -269,6 +421,9 @@ export function AdminProjectSnapshotsPage({
<>
第 {pageIndex} 页
{items.length ? `,本页 ${items.length} 个项目` : ''}
{isSorted
? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧`
: ''}
</>
}
pageSize={{
@@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({
}}
busy={isLoading}
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
next={{ disabled: !nextCursor, onClick: goToNextPage }}
next={{
disabled: isSorted
? pageIndex * pageSize >= (sortedItems.current?.length ?? 0)
: !nextCursor,
onClick: goToNextPage,
}}
/>
) : null
}
@@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
function compareIds(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function formatBytes(bytes: number) {
const units = ['B', 'KiB', 'MiB', 'GiB'];
const unit = Math.min(
@@ -258,7 +258,7 @@ fn prompt_entry_points_load_their_prose_from_external_files() {
),
(
"src/agent/direct_codex_user_item/wire.rs",
&["render_ui_design_code_context"],
&["ui_design_code_context_for"],
),
(
"src/commands.rs",
@@ -1113,11 +1113,6 @@ export function RuntimeConfigDialog({
<div className="runtime-settings-extension-main">
<strong title={plugin.name}>{plugin.name}</strong>
<span>内置 Plugin · 不可卸载</span>
{plugin.lastError ? (
<small title={plugin.lastError}>
{plugin.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>
@@ -1258,11 +1253,6 @@ export function RuntimeConfigDialog({
<span>
{typeLabel} · 来自 {item.sourceName}
</span>
{item.lastError ? (
<small title={item.lastError}>
{item.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>
@@ -9266,7 +9266,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。
- 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`<AdminTable>` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。
- 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。
- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。
- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。
- 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。
- 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。
- 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。
@@ -114,4 +114,4 @@ Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-ga
AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane 与 smoke job 必须在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`:AGC 壳的 `build.rs` 会从 `node_modules` 准备 Claude Agent SDK 与目标平台原生运行时,镜像中的 npm 下载缓存不能替代安装。人工 `scripts/build-gitea-rust-cache.sh` bootstrap 同样在首次编译 AGC 壳前安装 npm 依赖;只有不构建壳的 crates job 继续省略 npm 安装。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
@@ -91,6 +91,10 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
| 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` |
| 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` |
## 2026-09-30 扩展设置隐藏插件进程诊断文案
设置→扩展的内置插件与导入插件卡片不再渲染宿主返回的 `lastError`(包括“插件进程已退出”)。插件启用状态、运行状态、重载与启用/禁用操作保持现有行为;错误仍由宿主保留用于状态判定和操作反馈。
## 2026-09-20 DirectProject 七项效率闭环(补齐合同)
本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。
@@ -740,6 +744,8 @@ Prompt 静态门禁必须断言上述 Bundle section 当前定义的权威语义
Agent 可见的系统指令、工具与参数说明、恢复指引和上下文模板统一由外置提示词文件维护。AGC 沿用 `prompts/runtime/manifest.json`:已有 composition/section 保持原有组合关系,独立调用的文本按职责登记在 `textCatalogs`,目录为 `prompts/runtime/texts/`,每份 JSON 是稳定文本键到正文的映射。构建期校验目录、文件、重复键和空正文,并生成可供 `format!` 使用的编译期文本宏;变量填充沿用 Rust 格式语法。Runtime 状态、用户内容、schema 类型与枚举、权限和校验继续由代码生成。服务端 Agent 的独立 crate 使用各自 `prompts/` 中的编译期文本文件。迁移以当前组装结果和工具 schema 等价为验收依据,源码门禁检查各提示词入口的内联正文与外置引用。
`tests/prompt_source_boundaries.rs` 的入口清单随生产函数迁移同步更新。UI 设计文档代码上下文在入队冻结时由 `src/agent/direct_codex_user_item/wire.rs::ui_design_code_context_for` 生成,门禁检查该函数继续使用外置的 `projectContext.uiDesign.codeContext` 与 `projectContext.uiDesign.generationErrorContext`;旧 `render_ui_design_code_context` 已随冻结与纯投影拆分删除,不保留兼容入口。
2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](<./【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md>) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。
同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。对一次性 `command.exec` 而言,只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。这里对 PTY 和后台进程的排除仅适用于 `command.exec`,不能用来否定 V1.10 的独立持久进程工具,也不能把 `command.exec` 自身改成长驻入口。`command.exec` 的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gate,Git / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离。
@@ -1781,6 +1787,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。
- 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。
- 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。
- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。
- 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。
- 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。
- `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。
- `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。
- ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。
@@ -329,8 +329,8 @@ Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建
- `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`、`cargo test --locked -p spacetime-module --no-fail-fast`、`api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。
- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract`、`npm run check:native-shells:shells` 和 `npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。
- `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`:AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。
- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-<i>`(每次分片运行器使用对应的 `--shard-index=<i>`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 只用 cargo 与 node 内建模块,因此不装 npm 依赖。
- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;只用 cargo 与 node 内建模块(脚本只 import `node:*`),因此不装 npm 依赖。
- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-<i>`(每次分片运行器使用对应的 `--shard-index=<i>`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`,为 `build.rs` 准备 Claude Agent SDK 与 Linux 原生运行时。
- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;smoke 脚本自身只 import `node:*`,但壳的 `build.rs` 需要 Claude Agent SDK,因此同样在编译前执行根 `npm ci`。
- `AI game creator shell Rust crates`:预热 `server-rs/Cargo.toml` 与两个无锁独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)后执行 `npm run check:native-shells:agc-rust-crates`(即 `npm run ai-game-creator-shell:check:rust:crates`),覆盖 `agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm` 与 `shared-contracts`。这四条命令用的是 server-rs workspace 与独立 crate 的 manifest,属另一套依赖图,因此单独一个 job,也只跑 cargo、不装 npm 依赖。
九个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs` 的 `--groups=`:十个分组(`contract`、`shells`、`agc-web`、`agc-rust-crates`、`agc-rust-shard-1` ~ `agc-rust-shard-4`、`agc-rust-smoke`、`release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 lane/job 里被恰好调用一次;每个 Rust lane 顺序调用两组,不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容、lane/job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
@@ -354,7 +354,7 @@ bash scripts/gitea-ci-job-image.sh load-runner
执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的九个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane、`AI game creator shell Rust smoke` 与 `AI game creator shell Rust crates` 是纯 cargo 门禁(只用 cargo 与 node 内建模块),显式不装 npm 依赖,这也由 `scripts/project-ci-workflow.test.ts` 钉住。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。
九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane 与 `AI game creator shell Rust smoke` 也必须在编译前安装 npm 依赖,因为 `build.rs` 会准备随包 Claude Agent SDK 与目标平台原生运行时;只有不构建壳的 `AI game creator shell Rust crates` 省略 npm 安装。安装覆盖与编译前顺序由 `scripts/project-ci-workflow.test.ts` 验证。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。
站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。
@@ -368,7 +368,7 @@ master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Pro
AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试均启用 sccache。Native shell 的 release build smoke 显式清空两个 wrapper,保持发布构建原有 profile/features 与资源 staging;前端和 repository checks 不启用对象缓存。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。
人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中只编译预热,不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。
人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中先通过 `scripts/ci-npm-ci-with-retry.sh` 安装根 npm 依赖,再编译预热 AGC 壳,以满足 Claude Agent SDK 随包资源的构建要求;不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。
各 Rust job 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 4 GiB,快照构建完成后输出实际体积;最终测试 bin 的链接仍须执行。全组开启时必须生成覆盖全部目标的新快照,不能把旧 AGC 单目标快照当作后端/桌面壳的预热验收。
@@ -620,6 +620,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。
“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。
自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。
后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。
+2
View File
@@ -107,6 +107,8 @@ done
echo '[rust-cache] warming desktop tests and AGC prompt contracts'
cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run
# AGC build.rs 从 node_modules 准备 Claude Agent SDK 与目标平台原生运行时。
bash scripts/ci-npm-ci-with-retry.sh
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \
--test runtime_prompt_bundle_build --test prompt_source_boundaries --no-run
+18 -7
View File
@@ -67,14 +67,9 @@ const jobNames = [
'ai-game-creator-shell-rust-crates',
] as const;
// 这几个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与
// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令,
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
// crate 级测试只跑共享 / 平台 / 编辑器插件的 Cargo manifest,不构建 AGC 壳。
// 壳的 Rust 分片与 agent-run smoke 会执行 build.rs,需要 npm 安装的 Claude Agent SDK。
const jobsWithoutNpmInstall: readonly string[] = [
'ai-game-creator-shell-rust-lane-1',
'ai-game-creator-shell-rust-lane-2',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
];
@@ -199,6 +194,13 @@ describe('project CI workflow', () => {
section.indexOf('Checkout full history from Gitea'),
);
expect(prepare).toBeLessThan(section.indexOf(`- name: ${firstRustStep}`));
if (!jobsWithoutNpmInstall.includes(job)) {
const npmInstall = section.indexOf(
'bash scripts/ci-npm-ci-with-retry.sh',
);
expect(npmInstall).toBeGreaterThanOrEqual(0);
expect(npmInstall).toBeLessThan(prepare);
}
expect(
stepSection(job, 'Report isolated Rust compilation cache'),
).toContain('if: always()');
@@ -246,6 +248,15 @@ describe('project CI workflow', () => {
expect(warmCommands).toContain(
'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run',
);
const npmInstall = warmCommands.indexOf(
'bash scripts/ci-npm-ci-with-retry.sh',
);
expect(npmInstall).toBeGreaterThanOrEqual(0);
expect(npmInstall).toBeLessThan(
warmCommands.indexOf(
'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml',
),
);
});
it('runs for master pushes, pull requests, and manual dispatch only', () => {