按产品口径移除项目快照的客户端可见界面
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 8m6s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m25s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m15s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m54s
Project CI / Frontend tests (pull_request) Successful in 5m11s
Project CI / Native shell tests (pull_request) Successful in 9m9s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 8m6s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m25s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m15s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m54s
Project CI / Frontend tests (pull_request) Successful in 5m11s
Project CI / Native shell tests (pull_request) Successful in 9m9s
- 删除项目快照面板组件与其前端测试,回退工作区头部入口、App 接线、前端类型与面板样式 - 两条快照命令恢复为 native-only 登记,并在注释中说明按产品口径不做客户端界面 - 主规范与里程碑同步:可观测性收敛到 AppData 诊断日志,未决问题补记用户侧不可见
This commit is contained in:
@@ -121,6 +121,10 @@ const allowedUncalledTauriCommands = [
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
|
||||
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
|
||||
'read_local_project_snapshot_state',
|
||||
'sync_local_project_snapshot',
|
||||
'reset_design_agent_session',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
|
||||
@@ -275,7 +275,6 @@ import {
|
||||
resolveChatProjectPath,
|
||||
resolvePendingCommandProjectPath,
|
||||
} from './features/project-workspace/projectCommandPolicy';
|
||||
import { ProjectSnapshotDialog } from './features/project-workspace/ProjectSnapshotDialog';
|
||||
import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands';
|
||||
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
|
||||
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
|
||||
@@ -1920,7 +1919,6 @@ export function App({
|
||||
const [professionalAgentResultsById, setProfessionalAgentResultsById] =
|
||||
useState<Record<string, ProjectAgentResultSummary>>({});
|
||||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||||
const [projectSnapshotOpen, setProjectSnapshotOpen] = useState(false);
|
||||
const [llmConfigStatus, setLlmConfigStatus] =
|
||||
useState<GameCreatorLlmConfigStatus | null>(null);
|
||||
const [workspaceStatus, setWorkspaceStatus] = useState(
|
||||
@@ -13261,7 +13259,6 @@ export function App({
|
||||
handleRevealCurrentProjectDirectory
|
||||
}
|
||||
handleRuntimeConfigOpen={handleRuntimeConfigOpen}
|
||||
handleProjectSnapshotOpen={() => setProjectSnapshotOpen(true)}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
hasEarlierConversationMessages={hasEarlierConversationMessages}
|
||||
llmConfigStatus={llmConfigStatus}
|
||||
@@ -13310,13 +13307,6 @@ export function App({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{projectSnapshotOpen ? (
|
||||
<ProjectSnapshotDialog
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
onClose={() => setProjectSnapshotOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedAgent ? (
|
||||
<AgentConversationOverlay
|
||||
agentConversationBackgroundBusy={agentConversationBackgroundBusy}
|
||||
|
||||
@@ -941,40 +941,6 @@ export interface LocalProjectIndexResult {
|
||||
files: Array<{ path: string; size: number; checksum: string }>;
|
||||
}
|
||||
|
||||
/** 项目快照同步中被跳过、延后或失败的单个路径。 */
|
||||
export interface LocalProjectSnapshotFailure {
|
||||
relativePath: string;
|
||||
code: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface LocalProjectSnapshotState {
|
||||
projectId: string;
|
||||
indexPath: string;
|
||||
indexPresent: boolean;
|
||||
fileCount: number;
|
||||
syncRevision: number;
|
||||
syncedAtMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LocalProjectSnapshotSyncResult {
|
||||
projectId: string;
|
||||
trigger: string;
|
||||
status: string;
|
||||
syncRevision: number;
|
||||
uploadedFiles: number;
|
||||
uploadedBytes: number;
|
||||
remoteSkippedFiles: number;
|
||||
deletedFiles: number;
|
||||
deferredFiles: number;
|
||||
metadataOnlyFiles: number;
|
||||
skippedFiles: LocalProjectSnapshotFailure[];
|
||||
pendingFiles: LocalProjectSnapshotFailure[];
|
||||
failedFiles: LocalProjectSnapshotFailure[];
|
||||
syncedAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectCheckpointResult {
|
||||
checkpointId: string;
|
||||
checkpointPath: string;
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { CloudUpload, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
closeDialogOnBackdropMouseDown,
|
||||
closeDialogOnEscape,
|
||||
useEscapeToClose,
|
||||
} from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type {
|
||||
LocalProjectSnapshotFailure,
|
||||
LocalProjectSnapshotState,
|
||||
LocalProjectSnapshotSyncResult,
|
||||
} from '../../app/types';
|
||||
|
||||
type ProjectSnapshotDialogProps = {
|
||||
projectPath: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const SNAPSHOT_STATUS_LABELS: Record<string, string> = {
|
||||
synced: '已同步',
|
||||
'no-op': '无改动',
|
||||
partial: '部分成功',
|
||||
failed: '同步失败',
|
||||
};
|
||||
|
||||
function syncStatusLabel(status: string) {
|
||||
return SNAPSHOT_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
function formatSyncTime(value: number) {
|
||||
if (!value) return '尚无记录';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function describePending(
|
||||
label: string,
|
||||
entries: LocalProjectSnapshotFailure[],
|
||||
): string | null {
|
||||
const first = entries.at(0);
|
||||
if (!first) return null;
|
||||
return entries.length === 1
|
||||
? `${label}:${first.relativePath}(${first.detail})`
|
||||
: `${label}:${entries.length} 个,例如 ${first.relativePath}(${first.detail})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目快照面板:显示本机索引状态与最近一次同步结果,并提供手动触发入口。
|
||||
*/
|
||||
export function ProjectSnapshotDialog({
|
||||
projectPath,
|
||||
onClose,
|
||||
}: ProjectSnapshotDialogProps) {
|
||||
const [state, setState] = useState<LocalProjectSnapshotState | null>(null);
|
||||
const [result, setResult] = useState<LocalProjectSnapshotSyncResult | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshState = useCallback(async () => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const trimmed = projectPath?.trim();
|
||||
if (!invoke || !trimmed) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await invoke<LocalProjectSnapshotState>(
|
||||
'read_local_project_snapshot_state',
|
||||
{ projectPath: trimmed },
|
||||
);
|
||||
setState(next);
|
||||
setError(null);
|
||||
} catch (readError) {
|
||||
setState(null);
|
||||
setError(
|
||||
readError instanceof Error ? readError.message : String(readError),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshState();
|
||||
}, [refreshState]);
|
||||
|
||||
useEscapeToClose(onClose, !busy);
|
||||
|
||||
async function runSync() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const trimmed = projectPath?.trim();
|
||||
if (!invoke || !trimmed) {
|
||||
setError('需要在 Tauri App 内打开项目');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(
|
||||
await invoke<LocalProjectSnapshotSyncResult>(
|
||||
'sync_local_project_snapshot',
|
||||
{ projectPath: trimmed },
|
||||
),
|
||||
);
|
||||
await refreshState();
|
||||
} catch (syncError) {
|
||||
setResult(null);
|
||||
setError(
|
||||
syncError instanceof Error ? syncError.message : String(syncError),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const details = result
|
||||
? [
|
||||
describePending('失败', result.failedFiles),
|
||||
describePending('本次跳过', result.pendingFiles),
|
||||
describePending('未纳入同步', result.skippedFiles),
|
||||
].filter((value): value is string => Boolean(value))
|
||||
: [];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="launcher-dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (busy) return;
|
||||
closeDialogOnBackdropMouseDown(event, onClose);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-labelledby="project-snapshot-dialog-title"
|
||||
aria-modal="true"
|
||||
className="launcher-dialog project-snapshot-dialog"
|
||||
role="dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (busy) return;
|
||||
closeDialogOnEscape(event, onClose);
|
||||
}}
|
||||
>
|
||||
<h2 id="project-snapshot-dialog-title">项目快照</h2>
|
||||
{error ? (
|
||||
<p className="project-snapshot-dialog-error" role="status">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="project-snapshot-dialog-facts">
|
||||
<dt>上传状态</dt>
|
||||
<dd>{state?.enabled === false ? '已停用' : '已启用'}</dd>
|
||||
<dt>上次同步</dt>
|
||||
<dd>{formatSyncTime(state?.syncedAtMs ?? 0)}</dd>
|
||||
<dt>同步序号</dt>
|
||||
<dd>{state?.syncRevision ?? 0}</dd>
|
||||
<dt>已纳管文件</dt>
|
||||
<dd>{state?.fileCount ?? 0}</dd>
|
||||
<dt>本地索引</dt>
|
||||
<dd>{state?.indexPresent ? '已写入' : '尚未写入'}</dd>
|
||||
{result ? (
|
||||
<>
|
||||
<dt>最近一次</dt>
|
||||
<dd>{syncStatusLabel(result.status)}</dd>
|
||||
<dt>上传/字节</dt>
|
||||
<dd>
|
||||
{result.uploadedFiles} 个 / {result.uploadedBytes} 字节
|
||||
</dd>
|
||||
<dt>远端已有</dt>
|
||||
<dd>{result.remoteSkippedFiles} 个</dd>
|
||||
<dt>删除/延后</dt>
|
||||
<dd>
|
||||
{result.deletedFiles} / {result.deferredFiles}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{details.map((line) => (
|
||||
<p key={line} className="project-snapshot-dialog-detail">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
<div className="launcher-dialog-actions">
|
||||
<button type="button" disabled={busy} onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void refreshState()}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
)}
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-busy={busy}
|
||||
disabled={busy || !projectPath}
|
||||
onClick={() => void runSync()}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<CloudUpload size={14} aria-hidden="true" />
|
||||
)}
|
||||
立即同步
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -156,7 +156,6 @@ type ProjectWorkspaceChatPaneProps = {
|
||||
) => Promise<void>;
|
||||
handleRevealCurrentProjectDirectory: () => Promise<void>;
|
||||
handleRuntimeConfigOpen: () => void;
|
||||
handleProjectSnapshotOpen: () => void;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages?: boolean;
|
||||
llmConfigStatus: GameCreatorLlmConfigStatus | null;
|
||||
@@ -257,7 +256,6 @@ export function ProjectWorkspaceChatPane({
|
||||
handleProjectSupervisorUserInput,
|
||||
handleRevealCurrentProjectDirectory,
|
||||
handleRuntimeConfigOpen,
|
||||
handleProjectSnapshotOpen,
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages = false,
|
||||
llmConfigStatus,
|
||||
@@ -324,13 +322,6 @@ export function ProjectWorkspaceChatPane({
|
||||
<button type="button" onClick={handleRuntimeConfigOpen}>
|
||||
配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!localProject}
|
||||
onClick={handleProjectSnapshotOpen}
|
||||
>
|
||||
项目快照
|
||||
</button>
|
||||
<button type="button" onClick={() => void executeLlmConfigStatus()}>
|
||||
LLM状态
|
||||
</button>
|
||||
|
||||
@@ -4979,52 +4979,6 @@ h2 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog-error {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog-facts dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog-facts dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog-detail {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog .launcher-dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.project-snapshot-dialog .launcher-dialog-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-reference-picker-scopes {
|
||||
padding: 0 12px 4px;
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProjectSnapshotDialog } from '../src/features/project-workspace/ProjectSnapshotDialog';
|
||||
|
||||
const PROJECT_PATH = 'C:\\games\\demo';
|
||||
|
||||
const state = {
|
||||
projectId: 'gameagent-033b6cf397094f3e8d4e48c380ff4629',
|
||||
indexPath: 'C:\\AppData\\project-snapshots\\gameagent\\index.json',
|
||||
indexPresent: true,
|
||||
fileCount: 6,
|
||||
syncRevision: 2,
|
||||
syncedAtMs: 1_789_629_339_000,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const syncResult = {
|
||||
projectId: state.projectId,
|
||||
trigger: 'manual',
|
||||
status: 'synced',
|
||||
syncRevision: 3,
|
||||
uploadedFiles: 1,
|
||||
uploadedBytes: 2048,
|
||||
remoteSkippedFiles: 0,
|
||||
deletedFiles: 0,
|
||||
deferredFiles: 0,
|
||||
metadataOnlyFiles: 0,
|
||||
skippedFiles: [],
|
||||
pendingFiles: [],
|
||||
failedFiles: [],
|
||||
syncedAtMs: 1_789_629_400_000,
|
||||
};
|
||||
|
||||
function installInvoke(
|
||||
implementation: (command: string, args?: Record<string, unknown>) => unknown,
|
||||
) {
|
||||
const invoke = vi.fn(implementation);
|
||||
(window as unknown as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
core: { invoke },
|
||||
};
|
||||
return invoke;
|
||||
}
|
||||
|
||||
describe('ProjectSnapshotDialog', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
it('打开时读取本机索引状态并显示同步序号与文件数', async () => {
|
||||
const invoke = installInvoke((command) => {
|
||||
if (command === 'read_local_project_snapshot_state') return state;
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
render(
|
||||
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('2')).toBeTruthy();
|
||||
expect(screen.getByText('6')).toBeTruthy();
|
||||
expect(screen.getByText('已写入')).toBeTruthy();
|
||||
expect(invoke).toHaveBeenCalledWith('read_local_project_snapshot_state', {
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it('手动同步后显示上传计数与状态,并刷新索引状态', async () => {
|
||||
let stateReads = 0;
|
||||
const invoke = installInvoke((command) => {
|
||||
if (command === 'read_local_project_snapshot_state') {
|
||||
stateReads += 1;
|
||||
return state;
|
||||
}
|
||||
if (command === 'sync_local_project_snapshot') return syncResult;
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
render(
|
||||
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => expect(stateReads).toBe(1));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /立即同步/u }));
|
||||
|
||||
expect(await screen.findByText('已同步')).toBeTruthy();
|
||||
expect(screen.getByText('1 个 / 2048 字节')).toBeTruthy();
|
||||
await waitFor(() => expect(stateReads).toBe(2));
|
||||
expect(invoke).toHaveBeenCalledWith('sync_local_project_snapshot', {
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it('同步失败时显示错误而不是伪造成功状态', async () => {
|
||||
const invoke = installInvoke((command) => {
|
||||
if (command === 'read_local_project_snapshot_state') return state;
|
||||
if (command === 'sync_local_project_snapshot') {
|
||||
throw new Error('authentication-required: 请先登录陶泥儿账号');
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
render(
|
||||
<ProjectSnapshotDialog projectPath={PROJECT_PATH} onClose={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalled());
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /立即同步/u }));
|
||||
|
||||
expect(await screen.findByText(/authentication-required/u)).toBeTruthy();
|
||||
expect(screen.queryByText('已同步')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`
|
||||
- 不保留多版本历史:清单写入成功后回收不再被引用的旧对象,同一路径只保留当前内容。
|
||||
- 不下发 bucket 生命周期策略;不做跨节点的用户级总量配额与计费口径。
|
||||
- 不新增 SpacetimeDB 表或 procedure,不修改 `/api/external/v1` 与 External OpenAPI。
|
||||
- 不新增上传设置项与进度条;界面只提供状态与手动触发。
|
||||
- 不在客户端暴露上传状态、时间线或入口按钮;状态只落本机诊断日志,排障走 native-only 命令。
|
||||
|
||||
## 验收标准
|
||||
|
||||
@@ -41,7 +41,6 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`
|
||||
10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。
|
||||
11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`。
|
||||
12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。
|
||||
13. 工作区「项目快照」面板能显示上传状态、上次同步时间、同步序号与已纳管文件数,并能手动触发同步与显示失败原因。
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -1529,8 +1529,8 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
|
||||
|
||||
### 目标与非目标
|
||||
|
||||
- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛,用户可在项目里查看同步状态并手动触发。
|
||||
- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。
|
||||
- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛。
|
||||
- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。
|
||||
- 非目标:不把 OSS AccessKey 放进客户端;客户端不直连 OSS。
|
||||
|
||||
### 参与入口、状态与跨模块边界
|
||||
@@ -1539,7 +1539,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
|
||||
- 应用退出(`RunEvent::Exit`)不重复发起同步:该时刻窗口已销毁,按窗口重新枚举项目只会得到空集;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步能写完索引再退出。
|
||||
- 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。
|
||||
- 本地索引是增量对比的唯一依据:`<AppData>/project-snapshots/<projectId>/index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。
|
||||
- 界面入口:工作区聊天头部的「项目快照」按钮打开独立面板(`ProjectSnapshotDialog`),显示上传状态、上次同步时间、同步序号、已纳管文件数、本地索引是否写入,以及最近一次同步的上传/远端已有/删除/延后计数与失败明细;面板提供「刷新」与「立即同步」。面板不写功能说明文案,只用标签与数值。
|
||||
- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。
|
||||
- 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。
|
||||
- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。
|
||||
- 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`,未配置时回退 `ALIYUN_OSS_*`;与"资源 bucket 与备份 bucket 分离"的既有口径一致。
|
||||
@@ -1567,12 +1567,12 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
|
||||
|
||||
- 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。
|
||||
- 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。
|
||||
- 前端测试:面板打开即读取索引状态并显示同步序号与文件数;手动同步后显示上传计数并刷新状态;同步失败显示错误而不是伪造成功。
|
||||
- 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。
|
||||
- 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。
|
||||
|
||||
### 未决问题
|
||||
|
||||
- 用户侧看不到同步状态与失败原因(界面按产品口径不暴露),排障只能读 AppData 诊断日志或调用 native-only 命令;如果后续要支持用户自助排查,需要先确认是否允许在客户端出现上传相关 UI。
|
||||
- 历史版本:本轮只保留"当前状态镜像 + 清单",旧内容对象在清单写入成功后即被回收,没有回滚能力;要保留历史版本需要先定"保留几个 revision + 由谁回收"的策略。
|
||||
- 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。
|
||||
- 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。
|
||||
|
||||
Reference in New Issue
Block a user