补齐 AGC 项目快照的回收、配额、可见性与一致性校验
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m37s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m0s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell web 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 / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m37s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m0s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell web 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
- 服务端在清单写入成功后回收上一版清单不再引用的对象,单次最多 2000 个,上一版不可读时整轮跳过 - 服务端新增单项目 2 GiB 上限(413)、同一项目 5 秒最小清单间隔与用户小时配额(429 + Retry-After) - 客户端新增单项目上限预判,并把单次同步预算改名为 MAX_SYNC_BYTES,避免与项目上限混淆 - 客户端在读取摘要后与上传前各复核一次文件元数据,同步期间被改写的文件不上传、不推进索引、也不被误判成删除 - 客户端新增 file-changed 与 throttled 两类可重试失败,429 计入可重试而不是终态失败 - 新增工作区「项目快照」面板:显示上传状态、上次同步时间、同步序号、已纳管文件数与最近一次同步计数,支持刷新与立即同步 - 新增面板前端测试,并把两条快照命令从 check-config 的 native-only 白名单移入前端 invoke 名单 - 部署模板补充 AGC 项目快照 OSS 变量,api-server 启动时打印启用状态与凭据来源 - 同步主规范、里程碑、开发运维文档
This commit is contained in:
@@ -121,10 +121,6 @@ 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',
|
||||
|
||||
@@ -21,6 +21,8 @@ pub(crate) struct ProjectSnapshotDiff {
|
||||
pub(crate) skipped: Vec<ProjectSnapshotSkippedPath>,
|
||||
/// 本轮因为累计上限没有上传、留给下一次同步的路径。
|
||||
pub(crate) deferred: Vec<ProjectSnapshotSkippedPath>,
|
||||
/// 同步期间被改写、本轮不参与上传与索引推进的路径。
|
||||
pub(crate) pending: Vec<ProjectSnapshotSkippedPath>,
|
||||
pub(crate) upload_bytes: u64,
|
||||
/// 扫描后的完整清单(尚未扣除上传失败与延后项),成功同步后就是新的索引。
|
||||
pub(crate) current: BTreeMap<String, ProjectSnapshotIndexedFile>,
|
||||
@@ -59,6 +61,21 @@ pub(crate) fn compute_project_snapshot_diff(
|
||||
}
|
||||
|
||||
let bytes = read_project_snapshot_file_bytes(&file.absolute_path)?;
|
||||
// 同步不持项目写锁:读完立刻复核一次,文件在读取期间被改写就留给下一轮,
|
||||
// 既不把这份内容写进索引,也不上传它。
|
||||
if !project_snapshot_file_matches(&file.absolute_path, file.size_bytes, file.modified_ms) {
|
||||
diff.pending.push(ProjectSnapshotSkippedPath {
|
||||
relative_path: file.relative_path.clone(),
|
||||
reason: "同步期间文件发生变化,留到下一次".to_string(),
|
||||
});
|
||||
// 上一轮已同步过这条路径时沿用旧记录:文件仍然存在,只是这一轮读不到
|
||||
// 一致版本,所以既不能算删除(会被远端 GC 掉),也不能记成本轮已同步。
|
||||
if let Some(entry) = previous_entry {
|
||||
diff.current
|
||||
.insert(file.relative_path.clone(), entry.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let checksum = project_snapshot_checksum(&bytes);
|
||||
match previous_entry {
|
||||
Some(entry) if entry.checksum == checksum => {
|
||||
|
||||
@@ -31,7 +31,10 @@ const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||
pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 =
|
||||
shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES;
|
||||
pub(crate) const PROJECT_SNAPSHOT_MAX_SYNC_BYTES: u64 =
|
||||
shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_BYTES;
|
||||
shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_SYNC_BYTES;
|
||||
/// 单项目常驻占用上限。超过时不再尝试上传,直接给出明确失败而不是反复被服务端拒绝。
|
||||
pub(crate) const PROJECT_SNAPSHOT_MAX_PROJECT_BYTES: u64 =
|
||||
shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES;
|
||||
|
||||
const PROJECT_SNAPSHOT_FILES_ENDPOINT: &str = "/api/agc/project-snapshots/files";
|
||||
const PROJECT_SNAPSHOT_MANIFEST_ENDPOINT: &str = "/api/agc/project-snapshots/manifest";
|
||||
@@ -85,6 +88,8 @@ pub(crate) struct ProjectSnapshotSyncReport {
|
||||
pub(crate) deferred_files: usize,
|
||||
pub(crate) metadata_only_files: usize,
|
||||
pub(crate) skipped_files: Vec<ProjectSnapshotFailureView>,
|
||||
/// 同步期间被改写、本轮未参与上传与索引推进的路径。
|
||||
pub(crate) pending_files: Vec<ProjectSnapshotFailureView>,
|
||||
pub(crate) failed_files: Vec<ProjectSnapshotFailureView>,
|
||||
pub(crate) synced_at_ms: u64,
|
||||
}
|
||||
@@ -279,6 +284,16 @@ async fn sync_project_snapshot_async(
|
||||
};
|
||||
|
||||
let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?;
|
||||
// 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。
|
||||
let scan_total_bytes = scan
|
||||
.files
|
||||
.iter()
|
||||
.fold(0_u64, |total, file| total.saturating_add(file.size_bytes));
|
||||
if scan_total_bytes > PROJECT_SNAPSHOT_MAX_PROJECT_BYTES {
|
||||
return Err(format!(
|
||||
"项目体积 {scan_total_bytes} 字节超过项目快照单项目上限 {PROJECT_SNAPSHOT_MAX_PROJECT_BYTES} 字节"
|
||||
));
|
||||
}
|
||||
let diff =
|
||||
compute_project_snapshot_diff(&scan, &previous.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)?;
|
||||
if !diff.has_changes() {
|
||||
@@ -294,6 +309,7 @@ async fn sync_project_snapshot_async(
|
||||
deferred_files: 0,
|
||||
metadata_only_files: diff.metadata_only.len(),
|
||||
skipped_files: failure_views(&diff.skipped),
|
||||
pending_files: failure_views(&diff.pending),
|
||||
failed_files: Vec::new(),
|
||||
synced_at_ms: previous.synced_at_ms,
|
||||
});
|
||||
@@ -352,6 +368,7 @@ async fn sync_project_snapshot_async(
|
||||
deferred_files: diff.deferred.len(),
|
||||
metadata_only_files: diff.metadata_only.len(),
|
||||
skipped_files: failure_views(&diff.skipped),
|
||||
pending_files: failure_views(&diff.pending),
|
||||
failed_files: upload
|
||||
.failures
|
||||
.iter()
|
||||
|
||||
@@ -133,3 +133,25 @@ pub(crate) fn read_project_snapshot_file_bytes(path: &Path) -> Result<Vec<u8>, S
|
||||
pub(crate) fn project_snapshot_checksum(bytes: &[u8]) -> String {
|
||||
format!("fnv1a64:{:016x}", fnv1a64(bytes))
|
||||
}
|
||||
|
||||
/// 复核文件是否仍是扫描时刻的那一份内容。
|
||||
///
|
||||
/// 同步不持有项目写锁:Agent 或编辑器可能在扫描之后、读取或上传之前改写文件。
|
||||
/// 读取前后都按 `(字节数, 修改时间)` 比对,任一处不一致就判定为"同步期间发生变化",
|
||||
/// 该文件不计入索引、留给下一次同步,而不是上传一份自相矛盾的快照。
|
||||
pub(crate) fn project_snapshot_file_matches(
|
||||
path: &Path,
|
||||
size_bytes: u64,
|
||||
modified_ms: u64,
|
||||
) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
return false;
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
metadata.len() == size_bytes && project_snapshot_modified_ms(&metadata) == modified_ms
|
||||
}
|
||||
|
||||
@@ -350,12 +350,15 @@ fn fixture_candidate(
|
||||
bytes: &[u8],
|
||||
) -> ProjectSnapshotUploadCandidate {
|
||||
write_fixture_file(root, relative_path, bytes);
|
||||
let path = root.join(relative_path);
|
||||
let metadata = fs::symlink_metadata(&path).expect("read fixture metadata");
|
||||
ProjectSnapshotUploadCandidate {
|
||||
relative_path: relative_path.to_string(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
modified_ms: 1,
|
||||
// 用真实修改时间:上传前的一致性复核会拿它比对,写死常量会把请求提前挡掉。
|
||||
modified_ms: project_snapshot_modified_ms(&metadata),
|
||||
checksum: project_snapshot_checksum(bytes),
|
||||
absolute_path: root.join(relative_path),
|
||||
absolute_path: path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +404,93 @@ fn project_snapshot_upload_marks_remote_duplicates_and_sends_marker_headers() {
|
||||
assert!(request.ends_with("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_file_matches_detects_metadata_drift() {
|
||||
let root = fixture_root();
|
||||
write_fixture_file(root.path(), "game/a.txt", b"alpha");
|
||||
let path = root.path().join("game/a.txt");
|
||||
let metadata = fs::symlink_metadata(&path).expect("fixture metadata");
|
||||
let size = metadata.len();
|
||||
let modified = project_snapshot_modified_ms(&metadata);
|
||||
|
||||
assert!(project_snapshot_file_matches(&path, size, modified));
|
||||
assert!(
|
||||
!project_snapshot_file_matches(&path, size + 1, modified),
|
||||
"字节数变化必须被判为已改写"
|
||||
);
|
||||
assert!(
|
||||
!project_snapshot_file_matches(&path, size, modified + 1),
|
||||
"修改时间变化必须被判为已改写"
|
||||
);
|
||||
assert!(
|
||||
!project_snapshot_file_matches(&root.path().join("game/missing.txt"), size, modified),
|
||||
"文件消失必须被判为已改写"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_diff_defers_files_that_changed_while_being_read() {
|
||||
let root = fixture_root();
|
||||
write_fixture_file(root.path(), "game/a.txt", b"alpha");
|
||||
let mut previous = BTreeMap::new();
|
||||
previous.insert(
|
||||
"game/a.txt".to_string(),
|
||||
ProjectSnapshotIndexedFile {
|
||||
size_bytes: 5,
|
||||
modified_ms: 42,
|
||||
checksum: "fnv1a64:00000000000000aa".to_string(),
|
||||
},
|
||||
);
|
||||
// 扫描时刻的元数据与磁盘现状不一致:等价于"扫描之后文件被改写"。
|
||||
let scan = ProjectSnapshotScanResult {
|
||||
files: vec![ProjectSnapshotScannedFile {
|
||||
relative_path: "game/a.txt".to_string(),
|
||||
size_bytes: 5,
|
||||
modified_ms: 41,
|
||||
absolute_path: root.path().join("game/a.txt"),
|
||||
}],
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
|
||||
let diff = compute_project_snapshot_diff(&scan, &previous, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
|
||||
.expect("diff");
|
||||
assert!(diff.uploads.is_empty(), "{:?}", diff.uploads);
|
||||
assert_eq!(diff.pending.len(), 1);
|
||||
assert_eq!(diff.pending[0].relative_path, "game/a.txt");
|
||||
assert!(
|
||||
diff.deleted.is_empty(),
|
||||
"同步期间被改写的文件不能被当成删除,否则远端 GC 会误删"
|
||||
);
|
||||
assert!(
|
||||
diff.current.contains_key("game/a.txt"),
|
||||
"应沿用上一轮记录,保持清单与远端对象一致"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_upload_skips_files_that_changed_after_the_diff() {
|
||||
let root = fixture_root();
|
||||
let mut candidate = fixture_candidate(root.path(), "game/a.txt", b"alpha");
|
||||
candidate.modified_ms = candidate.modified_ms.saturating_sub(1_000);
|
||||
// 服务地址故意指向未监听端口:只要请求真的发出去就会出现 transport 失败,
|
||||
// 因此这里同时证明"没有发出请求"和"分类是 file-changed"。
|
||||
let session = fixture_session("http://127.0.0.1:9".to_string());
|
||||
let diff = ProjectSnapshotDiff {
|
||||
uploads: vec![candidate],
|
||||
..ProjectSnapshotDiff::default()
|
||||
};
|
||||
|
||||
let report =
|
||||
tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff));
|
||||
assert!(report.uploaded_paths.is_empty());
|
||||
assert_eq!(report.failures.len(), 1);
|
||||
assert_eq!(report.failures[0].code, "file-changed");
|
||||
assert!(
|
||||
ProjectSnapshotUploadErrorKind::FileChanged.is_retryable(),
|
||||
"文件被改写不是终态失败,下一次触发应重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_upload_stops_after_a_deterministic_authentication_failure() {
|
||||
let root = fixture_root();
|
||||
|
||||
@@ -6,6 +6,10 @@ use std::collections::BTreeSet;
|
||||
pub(crate) enum ProjectSnapshotUploadErrorKind {
|
||||
Authentication,
|
||||
Permission,
|
||||
/// 同步期间文件被改写:本轮跳过该文件,下一次重算即可,不是终态失败。
|
||||
FileChanged,
|
||||
/// 服务端按配额或频率拒绝:等下一次触发再试。
|
||||
Throttled,
|
||||
Rejected,
|
||||
Upstream,
|
||||
Transport,
|
||||
@@ -16,6 +20,8 @@ impl ProjectSnapshotUploadErrorKind {
|
||||
match self {
|
||||
Self::Authentication => "authentication-required",
|
||||
Self::Permission => "permission-denied",
|
||||
Self::FileChanged => "file-changed",
|
||||
Self::Throttled => "throttled",
|
||||
Self::Rejected => "request-rejected",
|
||||
Self::Upstream => "upstream-failed",
|
||||
Self::Transport => "transport-failed",
|
||||
@@ -24,7 +30,10 @@ impl ProjectSnapshotUploadErrorKind {
|
||||
|
||||
/// 鉴权、权限与请求被拒都是确定性失败;重复提交同样的内容不会变好。
|
||||
pub(crate) fn is_retryable(self) -> bool {
|
||||
matches!(self, Self::Upstream | Self::Transport)
|
||||
matches!(
|
||||
self,
|
||||
Self::Upstream | Self::Transport | Self::FileChanged | Self::Throttled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +175,22 @@ async fn upload_project_snapshot_file(
|
||||
})?;
|
||||
if bytes.len() as u64 != candidate.size_bytes {
|
||||
return Err(ProjectSnapshotUploadError::new(
|
||||
ProjectSnapshotUploadErrorKind::Rejected,
|
||||
ProjectSnapshotUploadErrorKind::FileChanged,
|
||||
format!(
|
||||
"项目快照文件在同步期间发生变化:{}",
|
||||
candidate.relative_path
|
||||
),
|
||||
));
|
||||
}
|
||||
// 上传前再复核一次元数据:只比长度会漏掉"改完又改回同样大小"的情况,
|
||||
// 这种文件本轮不传,也不进入索引。
|
||||
if !project_snapshot_file_matches(
|
||||
&candidate.absolute_path,
|
||||
candidate.size_bytes,
|
||||
candidate.modified_ms,
|
||||
) {
|
||||
return Err(ProjectSnapshotUploadError::new(
|
||||
ProjectSnapshotUploadErrorKind::FileChanged,
|
||||
format!(
|
||||
"项目快照文件在同步期间发生变化:{}",
|
||||
candidate.relative_path
|
||||
@@ -290,6 +314,8 @@ async fn project_snapshot_response_error(
|
||||
let kind = match status.as_u16() {
|
||||
401 => ProjectSnapshotUploadErrorKind::Authentication,
|
||||
403 => ProjectSnapshotUploadErrorKind::Permission,
|
||||
// 服务端配额与频率闸门:等下一次触发,不做本轮内重试。
|
||||
429 => ProjectSnapshotUploadErrorKind::Throttled,
|
||||
code if (400..500).contains(&code) => ProjectSnapshotUploadErrorKind::Rejected,
|
||||
_ => ProjectSnapshotUploadErrorKind::Upstream,
|
||||
};
|
||||
|
||||
@@ -275,6 +275,7 @@ 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';
|
||||
@@ -1919,6 +1920,7 @@ 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(
|
||||
@@ -13259,6 +13261,7 @@ export function App({
|
||||
handleRevealCurrentProjectDirectory
|
||||
}
|
||||
handleRuntimeConfigOpen={handleRuntimeConfigOpen}
|
||||
handleProjectSnapshotOpen={() => setProjectSnapshotOpen(true)}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
hasEarlierConversationMessages={hasEarlierConversationMessages}
|
||||
llmConfigStatus={llmConfigStatus}
|
||||
@@ -13307,6 +13310,13 @@ export function App({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{projectSnapshotOpen ? (
|
||||
<ProjectSnapshotDialog
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
onClose={() => setProjectSnapshotOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedAgent ? (
|
||||
<AgentConversationOverlay
|
||||
agentConversationBackgroundBusy={agentConversationBackgroundBusy}
|
||||
|
||||
@@ -941,6 +941,40 @@ 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;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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,6 +156,7 @@ type ProjectWorkspaceChatPaneProps = {
|
||||
) => Promise<void>;
|
||||
handleRevealCurrentProjectDirectory: () => Promise<void>;
|
||||
handleRuntimeConfigOpen: () => void;
|
||||
handleProjectSnapshotOpen: () => void;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages?: boolean;
|
||||
llmConfigStatus: GameCreatorLlmConfigStatus | null;
|
||||
@@ -256,6 +257,7 @@ export function ProjectWorkspaceChatPane({
|
||||
handleProjectSupervisorUserInput,
|
||||
handleRevealCurrentProjectDirectory,
|
||||
handleRuntimeConfigOpen,
|
||||
handleProjectSnapshotOpen,
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages = false,
|
||||
llmConfigStatus,
|
||||
@@ -322,6 +324,13 @@ 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,6 +4979,52 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/** @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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user