From d6b2e1c1819d8a2f85170e70962cae75b056c369 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 6 Apr 2026 06:48:12 +0800 Subject: [PATCH] Refactor TaskThread workspace binding semantics --- .../assistant-thread-target-model-20260328.md | 14 +-- ...k-thread-session-key-isolation-20260329.md | 17 ++- .../xworkmate-internal-state-architecture.md | 3 + lib/app/app_controller_desktop_core.dart | 10 ++ ...p_controller_desktop_settings_runtime.dart | 7 ++ .../app_controller_desktop_single_agent.dart | 15 ++- ..._controller_desktop_skill_permissions.dart | 50 ++------ ...app_controller_desktop_thread_actions.dart | 66 +---------- ...app_controller_desktop_thread_binding.dart | 34 ++---- ...pp_controller_desktop_thread_sessions.dart | 12 +- ...app_controller_desktop_thread_storage.dart | 25 ++-- ...ontroller_desktop_workspace_execution.dart | 73 +++++++++--- lib/app/app_controller_web_gateway_relay.dart | 2 +- lib/app/app_controller_web_helpers.dart | 46 ++++--- lib/app/app_shell_desktop.dart | 112 ++++++++++++------ .../runtime_models_runtime_payloads.dart | 85 +++++-------- lib/runtime/secure_config_store.dart | 3 + lib/runtime/settings_store.dart | 22 ++++ .../assistant_page_suite_composer.dart | 40 +++++-- ...er_ai_gateway_chat_suite_single_agent.dart | 11 +- ...pp_controller_thread_skills_suite_acp.dart | 22 +++- ...hread_skills_suite_workspace_fallback.dart | 33 ++++-- ...cure_config_store_suite_compatibility.dart | 101 ++++++++-------- .../secure_config_store_suite_lifecycle.dart | 28 ++++- .../secure_config_store_suite_settings.dart | 11 +- ...emote_session_repository_browser_test.dart | 9 +- ...web_settings_persistence_browser_test.dart | 11 +- 27 files changed, 491 insertions(+), 371 deletions(-) diff --git a/docs/architecture/assistant-thread-target-model-20260328.md b/docs/architecture/assistant-thread-target-model-20260328.md index fb8a1c9f..e7f55a5a 100644 --- a/docs/architecture/assistant-thread-target-model-20260328.md +++ b/docs/architecture/assistant-thread-target-model-20260328.md @@ -9,10 +9,10 @@ 1. `TaskThread` 是任务线程的唯一主对象。 2. UI 保持现有结构不变,但线程选择的唯一键是 `TaskThread.threadId`。 3. UI 选中线程后,系统必须读取完整 `TaskThread`,而不是从页面状态拼装线程信息。 -4. `TaskThread` 持久化 schema 保持不变;本轮只迁移 runtime 解释层与执行链路。 +4. `TaskThread` 持久化 schema 保持不变,但 `workspaceBinding` 在 create/load 时必须完整;缺失 binding 的旧记录按非法数据处理并跳过加载。 5. 执行请求由 controller / runtime 根据 `ownerScope / workspaceBinding / executionBinding / contextState` 构造。 6. controller / runtime 统一通过 `Go Agent-core` 调度执行:Desktop 走 App 内 local bridge,Web 走远端 ACP / RPC endpoint。 -7. 执行结果先回写 `TaskThread.contextState`,主体区域同步显示;只有必要时才更新 `workspaceBinding`;右栏展示读取的是当前 `TaskThread` 最新记录。 +7. 执行结果先回写 `TaskThread.contextState`,主体区域同步显示;UI 与执行始终只读取当前 `TaskThread.workspaceBinding`,不再存在 runtime first-binding 或 fallback 到 `main`。 ## 2. TaskThread 结构 @@ -112,7 +112,7 @@ ThreadLifecycleState 职责: - `archived`:归档标记 -- `status`:线程生命周期摘要,例如 `needs_workspace / ready` +- `status`:线程生命周期摘要,例如 `ready / error` - `lastRunAtMs / lastResultCode`:最近执行摘要 ## 3. TaskThread 生命周期主链 @@ -136,7 +136,7 @@ flowchart LR F --> G["执行结果"] G --> H["回写线程上下文\n(主体区域 同步显示)"] - G --> I["必要时更新 workspaceBinding"] + G --> I["仅显式更新当前线程 workspaceBinding"] H --> J["右栏显示"] I --> J @@ -149,8 +149,8 @@ flowchart LR 3. `构造执行请求` 属于 agent-core / runtime 协调层,不属于 UI。 4. `Go Agent-core` 是唯一执行调度面;Desktop / Web 共用同一套 session 语义,只在 transport 上有差异。 5. `回写线程上下文` 是执行结束后的第一落点;主体区域同步显示依赖这一回写。 -6. `必要时更新 workspaceBinding` 是条件分支,不是固定每次都发生的回写步骤。 -7. `右栏显示` 读取的是当前 `TaskThread` 最新记录,因此它与主体区域共享同一线程事实来源。 +6. `workspaceBinding` 不是运行时补齐对象;线程在 create/load 时必须已经完整。 +7. `右栏显示` 与执行请求都读取当前 `TaskThread.workspaceBinding`,因此它与主体区域共享同一线程事实来源。 ## 4. 当前设计约束 @@ -170,7 +170,7 @@ flowchart LR ### 4.3 TaskThread 约束 - `threadId` 是线程身份唯一键。 -- `workspaceBinding` 是线程生命周期字段,不再承担 fallback 猜测语义。 +- `workspaceBinding` 是 `TaskThread` 的必填生命周期字段;create/load 阶段缺失即为非法线程数据。 - `contextState` 是线程上下文真相源。 - `lifecycleState` 只表达归档与生命周期摘要,不替代线程主体模型。 diff --git a/docs/architecture/task-thread-session-key-isolation-20260329.md b/docs/architecture/task-thread-session-key-isolation-20260329.md index a6324295..60165f8b 100644 --- a/docs/architecture/task-thread-session-key-isolation-20260329.md +++ b/docs/architecture/task-thread-session-key-isolation-20260329.md @@ -27,7 +27,7 @@ currentSessionKey 这条链路说明: 1. 真正决定执行目录的是 `sessionKey` -2. `workspace_root` 文本上下文本身不会自动进入线程绑定 +2. `workspace_root` 文本上下文本身不会创建 first binding 3. 空 `sessionKey` 会被归一为 `main` 4. 一旦多个任务线没有真正切换到独立 `sessionKey`,它们就会共享 `main` @@ -51,7 +51,7 @@ currentSessionKey 3. `TaskThread.threadId` 就是该任务线的 `sessionKey`。 4. 任何可触发执行的入口都不得在空 `sessionKey` 下运行。 5. 对非主线程任务线,禁止 silent fallback 到 `main`。 -6. `workspace_root` 不是线程身份;它最多只能作为该线程 `workspaceBinding` 的输入之一。 +6. `workspace_root` 不是线程身份;它只能更新当前已存在线程的 `workspaceBinding`,不能创建 first binding。 换句话说: @@ -146,7 +146,7 @@ request.workingDirectory == current TaskThread.workspaceBinding.workspacePath - 空 `sessionKey -> main` 只允许用于真正的主线程初始化阶段 - 非主线程任务线若缺少 `sessionKey`,状态应为 `needs_binding` 或 `not_runnable` -- 非主线程任务线若缺少工作路径,状态应为 `needs_workspace` +- 本地可执行线程在 create/load 阶段必须已经拥有唯一工作目录 - 不允许继续执行并偷偷落到 `threads/main` ## 5. `workspace_root` 的正确角色 @@ -161,15 +161,14 @@ request.workingDirectory == current TaskThread.workspaceBinding.workspacePath 它可以是: -- 创建新线程时的初始工作区候选根目录 -- 外部 provider / 执行上下文导入时的显式 workspace bootstrap 输入 -- 当前线程 `workspaceBinding` 的一次用户确认更新来源 +- 当前线程 `workspaceBinding` 的一次显式更新来源 +- 外部 provider / 执行上下文导入时对当前线程 binding 的确认更新输入 因此正确顺序应为: ```text Execution context.workspace_root --> bind/update current TaskThread.workspaceBinding +-> update current TaskThread.workspaceBinding -> persist on that TaskThread -> subsequent execute reads TaskThread.workspaceBinding.workspacePath ``` @@ -280,7 +279,7 @@ single-agent 入口必须在执行前验证: 1. 历史共享 `main` 的记录继续作为 `main` 2. 从修正版本开始,新建任务线必须创建独立 `sessionKey` -3. 对已暴露出共享问题的入口,优先阻止继续 silent fallback +3. 对已暴露出共享问题的入口,优先阻止继续 silent fallback,并移除 runtime first-binding ### 8.3 未绑定任务线 @@ -303,7 +302,7 @@ single-agent 入口必须在执行前验证: 3. 切换任务线后,`currentSessionKey` 与右栏路径同步变化。 4. single-agent 请求里的 `sessionId / threadId / workingDirectory` 始终对应当前线程。 5. 任意非主线程缺少 `sessionKey` 时,执行被阻止,而不是回落到 `main`。 -6. `workspace_root` 被当作线程 binding 输入处理,而不是 prompt-only 文本或跨线程覆盖指令。 +6. `workspace_root` 被当作当前线程 binding 的显式更新输入处理,而不是 prompt-only 文本、first-binding 指令或跨线程覆盖指令。 ## 10. 与现有架构文档的关系 diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md index 7ceac1b1..8787b9a7 100644 --- a/docs/architecture/xworkmate-internal-state-architecture.md +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -181,6 +181,8 @@ Ownership summary: - 如果值已经存在于 `TaskThread`,则线程值优先于 Settings 默认值 - `TaskThread` 是当前线程展示与执行的唯一主对象 +- `TaskThread` 在 create/load 时必须已经拥有完整 `workspaceBinding` +- 缺少 `workspaceBinding` 的旧记录属于非法线程数据,应在恢复阶段跳过并通过启动告警暴露 ### 3.3 Agent-Core / Runtime 协调状态 @@ -196,6 +198,7 @@ Primary responsibilities: - 请求构造不属于 UI - Flutter UI 不直接承担 runtime dispatch 职责 - 工作空间选择不再通过旧式运行前猜测获得 +- 不允许 runtime fallback 到 `main`、`Directory.current` 或 prompt first-binding - 结果回写先更新线程上下文,再驱动主体区域与右栏刷新 - Desktop / Web 共用相同 session 生命周期;不再单独发明 relay-only 执行协议 diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index bad7edf3..73f37e98 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -357,6 +357,7 @@ class AppController extends ChangeNotifier { String settingsDraftStatusMessageInternal = ''; bool initializingInternal = true; String? bootstrapErrorInternal; + String? startupTaskThreadWarningInternal; StreamSubscription? runtimeEventsSubscriptionInternal; bool disposedInternal = false; String resolvedUserHomeDirectoryInternal = resolveUserHomeDirectory(); @@ -414,6 +415,15 @@ class AppController extends ChangeNotifier { DetailPanelData? get detailPanel => detailPanelInternal; bool get initializing => initializingInternal; String? get bootstrapError => bootstrapErrorInternal; + String? get startupTaskThreadWarning => startupTaskThreadWarningInternal; + + void dismissStartupTaskThreadWarning() { + if ((startupTaskThreadWarningInternal ?? '').trim().isEmpty) { + return; + } + startupTaskThreadWarningInternal = null; + notifyIfActiveInternal(); + } UiFeatureAccess featuresFor(UiFeaturePlatform platform) { final manifest = applyAppleAppStorePolicy( diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index ca375561..92333e20 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -449,6 +449,13 @@ extension AppControllerDesktopSettingsRuntime on AppController { await skillDirectoryAccessServiceInternal.resolveUserHomeDirectory(); await settingsControllerInternal.initialize(); final storedAssistantThreads = await storeInternal.loadTaskThreads(); + final skippedInvalidThreadIds = storeInternal.lastSkippedInvalidTaskThreadIds; + startupTaskThreadWarningInternal = skippedInvalidThreadIds.isEmpty + ? null + : appText( + '已跳过 ${skippedInvalidThreadIds.length} 个缺少完整 workspaceBinding 的旧任务线程: ${skippedInvalidThreadIds.join(', ')}', + 'Skipped ${skippedInvalidThreadIds.length} persisted task threads missing a complete workspaceBinding: ${skippedInvalidThreadIds.join(', ')}', + ); if (disposedInternal) { return; } diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index d863f934..61ce187a 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -232,8 +232,19 @@ extension AppControllerDesktopSingleAgent on AppController { resolvedWorkspaceKind != WorkspaceRefKind.localPath) { upsertTaskThreadInternal( sessionKey, - workspaceRef: resolvedWorkingDirectory, - workspaceRefKind: resolvedWorkspaceKind, + workspaceBinding: WorkspaceBinding( + workspaceId: normalizedAssistantSessionKeyInternal(sessionKey), + workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath + ? WorkspaceKind.remoteFs + : WorkspaceKind.localFs, + workspacePath: resolvedWorkingDirectory, + displayPath: resolvedWorkingDirectory, + writable: + assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal(sessionKey)] + ?.workspaceBinding + .writable ?? + true, + ), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); } diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 2d01f718..e85eb7d6 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -273,8 +273,6 @@ extension AppControllerDesktopSkillPermissions on AppController { ThreadSelectionSource? assistantModelSource, ThreadSelectionSource? selectedSkillsSource, String? gatewayEntryState, - String? workspaceRef, - WorkspaceRefKind? workspaceRefKind, }) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -307,40 +305,13 @@ extension AppControllerDesktopSkillPermissions on AppController { subjectId: '', displayName: '', ); - final explicitWorkspaceKind = workspaceRefKind == null - ? null - : (workspaceRefKind == WorkspaceRefKind.localPath - ? WorkspaceKind.localFs - : WorkspaceKind.remoteFs); - final baseWorkspaceBinding = - workspaceBinding ?? - existing?.workspaceBinding ?? - WorkspaceBinding( - workspaceId: normalizedSessionKey, - workspaceKind: - explicitWorkspaceKind ?? - (nextExecutionTarget == AssistantExecutionTarget.singleAgent - ? WorkspaceKind.localFs - : WorkspaceKind.remoteFs), - workspacePath: '', - displayPath: '', - writable: true, - ); - final nextWorkspacePath = - workspaceRef ?? baseWorkspaceBinding.workspacePath; - final nextDisplayPath = - workspaceRef ?? - (baseWorkspaceBinding.displayPath.trim().isNotEmpty - ? baseWorkspaceBinding.displayPath - : nextWorkspacePath); - final nextWorkspaceBinding = baseWorkspaceBinding.copyWith( - workspaceId: baseWorkspaceBinding.workspaceId.trim().isEmpty - ? normalizedSessionKey - : baseWorkspaceBinding.workspaceId, - workspaceKind: explicitWorkspaceKind, - workspacePath: nextWorkspacePath, - displayPath: nextDisplayPath, - ); + final nextWorkspaceBinding = + workspaceBinding ?? existing?.workspaceBinding; + if (nextWorkspaceBinding == null || !nextWorkspaceBinding.isComplete) { + throw StateError( + 'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.', + ); + } final nextProvider = singleAgentProvider ?? SingleAgentProviderCopy.fromJsonValue( @@ -410,11 +381,8 @@ extension AppControllerDesktopSkillPermissions on AppController { existing?.contextState.selectedSkillsSource, gatewayEntryState: gatewayEntryState, ); - final nextStatus = nextWorkspaceBinding.workspacePath.trim().isEmpty - ? 'needs_workspace' - : (lifecycleState?.status ?? - existing?.lifecycleState.status ?? - 'ready'); + final nextStatus = + lifecycleState?.status ?? existing?.lifecycleState.status ?? 'ready'; final nextLifecycleState = (lifecycleState ?? existing?.lifecycleState ?? diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 770385cb..4cad1037 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -544,6 +544,9 @@ extension AppControllerDesktopThreadActions on AppController { sessionKey, ); final existing = assistantThreadRecordsInternal[normalizedSessionKey]; + if (existing == null || !existing.workspaceBinding.isComplete) { + return; + } upsertTaskThreadInternal( normalizedSessionKey, workspaceBinding: WorkspaceBinding( @@ -551,16 +554,10 @@ extension AppControllerDesktopThreadActions on AppController { workspaceKind: WorkspaceKind.localFs, workspacePath: workspaceRoot, displayPath: workspaceRoot, - writable: existing?.workspaceBinding.writable ?? true, + writable: existing.workspaceBinding.writable, ), lifecycleState: - (existing?.lifecycleState ?? - const ThreadLifecycleState( - archived: false, - status: 'ready', - lastRunAtMs: null, - lastResultCode: null, - )) + (existing.lifecycleState) .copyWith(status: 'ready'), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); @@ -597,56 +594,5 @@ extension AppControllerDesktopThreadActions on AppController { Future tryBindWorkspaceForOnlyChatFallbackInternal( String sessionKey, AssistantExecutionTarget currentTarget, - ) async { - if (currentTarget != AssistantExecutionTarget.singleAgent && - currentTarget != AssistantExecutionTarget.auto) { - return; - } - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - sessionKey, - ); - if (!singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { - return; - } - if (assistantWorkspacePathForSession(normalizedSessionKey).trim().isNotEmpty) { - return; - } - - final candidateRoots = { - settings.workspacePath.trim(), - Directory.current.path.trim(), - settings.remoteProjectRoot.trim(), - }.where((item) => item.isNotEmpty); - for (final root in candidateRoots) { - final threadWorkspace = - '${trimTrailingPathSeparatorInternal(root)}/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}'; - if (!ensureLocalWorkspaceDirectoryInternal(threadWorkspace)) { - continue; - } - final existing = assistantThreadRecordsInternal[normalizedSessionKey]; - upsertTaskThreadInternal( - normalizedSessionKey, - workspaceBinding: WorkspaceBinding( - workspaceId: normalizedSessionKey, - workspaceKind: WorkspaceKind.localFs, - workspacePath: threadWorkspace, - displayPath: threadWorkspace, - writable: existing?.workspaceBinding.writable ?? true, - ), - lifecycleState: - (existing?.lifecycleState ?? - const ThreadLifecycleState( - archived: false, - status: 'ready', - lastRunAtMs: null, - lastResultCode: null, - )) - .copyWith(status: 'ready'), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await flushAssistantThreadPersistenceInternal(); - recomputeTasksInternal(); - return; - } - } + ) async {} } diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 9620224a..bac3f987 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -142,29 +142,16 @@ extension AppControllerDesktopThreadBinding on AppController { if (executionTarget == AssistantExecutionTarget.auto || executionTarget == AssistantExecutionTarget.singleAgent) { if (existingBinding != null && - existingBinding.workspacePath.trim().isNotEmpty) { - if (existingBinding.workspaceKind == WorkspaceKind.localFs) { - if (ensureLocalWorkspaceDirectoryInternal( - existingBinding.workspacePath, - )) { - return existingBinding.copyWith( - displayPath: existingBinding.workspacePath, - ); - } - } - final defaultRemotePath = remoteThreadWorkspacePathInternal( - sessionKey, - ownerScope, - ); - if (existingBinding.workspacePath.trim() != defaultRemotePath) { - return existingBinding.copyWith( - displayPath: existingBinding.displayPath.trim().isEmpty - ? existingBinding.workspacePath - : null, - ); - } + existingBinding.workspaceKind == WorkspaceKind.localFs && + ensureLocalWorkspaceDirectoryInternal(existingBinding.workspacePath)) { + return existingBinding.copyWith(displayPath: existingBinding.workspacePath); } final localPath = localThreadWorkspacePathInternal(sessionKey); + if (localPath.isEmpty) { + throw StateError( + 'Local executable thread $sessionKey requires a writable local workspace.', + ); + } return WorkspaceBinding( workspaceId: normalizedAssistantSessionKeyInternal(sessionKey), workspaceKind: WorkspaceKind.localFs, @@ -242,9 +229,6 @@ extension AppControllerDesktopThreadBinding on AppController { ownerScope: ownerScope, existingBinding: existing?.workspaceBinding, ); - final lifecycleStatus = workspaceBinding.workspacePath.trim().isEmpty - ? 'needs_workspace' - : 'ready'; upsertTaskThreadInternal( normalizedSessionKey, ownerScope: ownerScope, @@ -263,7 +247,7 @@ extension AppControllerDesktopThreadBinding on AppController { lastRunAtMs: null, lastResultCode: null, )) - .copyWith(status: lifecycleStatus), + .copyWith(status: 'ready'), executionTarget: resolvedExecutionTarget, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index a16292ae..09b1d1cd 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -144,21 +144,11 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - final existing = - assistantThreadRecordsInternal[normalizedSessionKey] + return assistantThreadRecordsInternal[normalizedSessionKey] ?.workspaceBinding .workspacePath .trim() ?? ''; - if (existing.isNotEmpty) { - return existing; - } - final target = assistantExecutionTargetForSession(normalizedSessionKey); - if (target == AssistantExecutionTarget.singleAgent || - target == AssistantExecutionTarget.auto) { - return localThreadWorkspacePathInternal(normalizedSessionKey); - } - return ''; } WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) { diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index e071a5c8..876c1e59 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -669,9 +669,20 @@ extension AppControllerDesktopThreadStorage on AppController { if (sessionKey.isEmpty) { continue; } + if (!record.workspaceBinding.isComplete) { + continue; + } final titleFromSettings = assistantCustomTaskTitle(sessionKey); + final workspaceBinding = record.workspaceBinding.copyWith( + workspaceId: sessionKey, + displayPath: record.workspaceKind == WorkspaceKind.localFs + ? record.workspacePath.trim() + : (record.displayPath.trim().isEmpty + ? record.workspacePath.trim() + : record.displayPath.trim()), + ); final normalizedRecord = record.copyWith( - sessionKey: sessionKey, + threadId: sessionKey, title: titleFromSettings.isEmpty ? record.title.trim() : titleFromSettings, @@ -690,16 +701,8 @@ extension AppControllerDesktopThreadStorage on AppController { gatewayEntryState: (record.gatewayEntryState ?? '').trim().isEmpty ? gatewayEntryStateForTargetInternal(record.executionTarget) : record.gatewayEntryState, - workspacePath: record.workspacePath.trim(), - displayPath: record.workspaceKind == WorkspaceKind.localFs - ? record.workspacePath.trim() - : (record.displayPath.trim().isEmpty - ? record.workspacePath.trim() - : record.displayPath.trim()), - workspaceKind: record.workspaceKind, - lifecycleStatus: record.workspacePath.trim().isEmpty - ? 'needs_workspace' - : record.lifecycleState.status, + workspaceBinding: workspaceBinding, + lifecycleState: record.lifecycleState.copyWith(status: 'ready'), ); if (normalizedRecord.workspaceKind == WorkspaceKind.localFs && normalizedRecord.workspacePath.trim().isNotEmpty) { diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index 05588333..618e6166 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -60,16 +60,26 @@ extension AppControllerDesktopWorkspaceExecution on AppController { settings.assistantExecutionTarget == resolvedTarget) { return; } + if (!assistantThreadRecordsInternal.containsKey( + sessionsControllerInternal.currentSessionKey, + )) { + initializeAssistantThreadContext( + sessionsControllerInternal.currentSessionKey, + executionTarget: resolvedTarget, + messageViewMode: currentAssistantMessageViewMode, + singleAgentProvider: currentSingleAgentProvider, + ); + } + await ensureDesktopTaskThreadBindingInternal( + sessionsControllerInternal.currentSessionKey, + executionTarget: resolvedTarget, + ); upsertTaskThreadInternal( sessionsControllerInternal.currentSessionKey, executionTarget: resolvedTarget, executionTargetSource: ThreadSelectionSource.explicit, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); - await ensureDesktopTaskThreadBindingInternal( - sessionsControllerInternal.currentSessionKey, - executionTarget: resolvedTarget, - ); recomputeTasksInternal(); notifyIfActiveInternal(); await applyAssistantExecutionTargetInternal( @@ -93,6 +103,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController { if (singleAgentProviderForSession(sessionKey) == sanitizedProvider) { return; } + if (!assistantThreadRecordsInternal.containsKey(sessionKey)) { + initializeAssistantThreadContext( + sessionKey, + executionTarget: assistantExecutionTargetForSession(sessionKey), + messageViewMode: assistantMessageViewModeForSession(sessionKey), + singleAgentProvider: currentSingleAgentProvider, + ); + } singleAgentRuntimeModelBySessionInternal.remove(sessionKey); upsertTaskThreadInternal( sessionKey, @@ -122,6 +140,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController { if (assistantMessageViewModeForSession(sessionKey) == mode) { return; } + if (!assistantThreadRecordsInternal.containsKey(sessionKey)) { + initializeAssistantThreadContext( + sessionKey, + executionTarget: assistantExecutionTargetForSession(sessionKey), + messageViewMode: assistantMessageViewModeForSession(sessionKey), + singleAgentProvider: singleAgentProviderForSession(sessionKey), + ); + } upsertTaskThreadInternal( sessionKey, messageViewMode: mode, @@ -245,6 +271,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController { trimmed) { return; } + if (!assistantThreadRecordsInternal.containsKey(normalizedSessionKey)) { + initializeAssistantThreadContext( + normalizedSessionKey, + executionTarget: assistantExecutionTargetForSession(normalizedSessionKey), + messageViewMode: assistantMessageViewModeForSession(normalizedSessionKey), + singleAgentProvider: singleAgentProviderForSession(normalizedSessionKey), + ); + } upsertTaskThreadInternal( normalizedSessionKey, assistantModelId: trimmed, @@ -281,24 +315,25 @@ extension AppControllerDesktopWorkspaceExecution on AppController { final resolvedTarget = executionTarget ?? assistantExecutionTargetForSession(currentSessionKey); - final initialWorkspaceBinding = - resolvedTarget == AssistantExecutionTarget.singleAgent - ? (() { - final localPath = localThreadWorkspacePathInternal( - normalizedSessionKey, - ); - return WorkspaceBinding( - workspaceId: normalizedSessionKey, - workspaceKind: WorkspaceKind.localFs, - workspacePath: localPath, - displayPath: localPath, - writable: true, - ); - })() - : null; + final initialOwnerScope = + assistantThreadRecordsInternal[normalizedSessionKey]?.ownerScope ?? + const ThreadOwnerScope( + realm: ThreadRealm.local, + subjectType: ThreadSubjectType.user, + subjectId: '', + displayName: '', + ); + final initialWorkspaceBinding = buildDesktopWorkspaceBindingInternal( + normalizedSessionKey, + executionTarget: resolvedTarget, + ownerScope: initialOwnerScope, + existingBinding: + assistantThreadRecordsInternal[normalizedSessionKey]?.workspaceBinding, + ); upsertTaskThreadInternal( normalizedSessionKey, title: title.trim(), + ownerScope: initialOwnerScope, executionTarget: resolvedTarget, workspaceBinding: initialWorkspaceBinding, messageViewMode: diff --git a/lib/app/app_controller_web_gateway_relay.dart b/lib/app/app_controller_web_gateway_relay.dart index d7bf07c3..bcec3fee 100644 --- a/lib/app/app_controller_web_gateway_relay.dart +++ b/lib/app/app_controller_web_gateway_relay.dart @@ -247,7 +247,7 @@ extension AppControllerWebGatewayRelay on AppController { ); final existing = threadRecordsInternal[resolvedKey]; final next = (existing ?? newRecordInternal(target: target)).copyWith( - sessionKey: resolvedKey, + threadId: resolvedKey, messages: messages, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), title: deriveThreadTitleInternal( diff --git a/lib/app/app_controller_web_helpers.dart b/lib/app/app_controller_web_helpers.dart index 9a7d4747..f4ef32bf 100644 --- a/lib/app/app_controller_web_helpers.dart +++ b/lib/app/app_controller_web_helpers.dart @@ -105,19 +105,22 @@ extension AppControllerWebHelpers on AppController { final target = sanitizeTargetInternal(record.executionTarget) ?? AssistantExecutionTarget.singleAgent; + final workspacePath = record.workspacePath.trim(); return record.copyWith( executionTarget: target, title: record.title.trim().isEmpty ? appText('新对话', 'New conversation') : record.title.trim(), - workspacePath: record.workspacePath.trim(), - displayPath: record.displayPath.trim().isEmpty - ? record.workspacePath.trim() - : record.displayPath.trim(), - workspaceKind: WorkspaceKind.remoteFs, - lifecycleStatus: record.workspacePath.trim().isEmpty - ? 'needs_workspace' - : 'ready', + workspaceBinding: WorkspaceBinding( + workspaceId: record.threadId, + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: workspacePath, + displayPath: record.displayPath.trim().isEmpty + ? workspacePath + : record.displayPath.trim(), + writable: record.workspaceBinding.writable, + ), + lifecycleState: record.lifecycleState.copyWith(status: 'ready'), ); } @@ -146,6 +149,8 @@ extension AppControllerWebHelpers on AppController { AssistantExecutionTarget.remote => 'remote', }; final threadId = '$prefix:$timestamp'; + final workspacePath = + '/owners/${ThreadRealm.remote.name}/${ThreadSubjectType.user.name}/threads/$threadId'; return TaskThread( threadId: threadId, createdAtMs: timestamp.toDouble(), @@ -160,8 +165,8 @@ extension AppControllerWebHelpers on AppController { workspaceBinding: WorkspaceBinding( workspaceId: threadId, workspaceKind: WorkspaceKind.remoteFs, - workspacePath: '', - displayPath: '', + workspacePath: workspacePath, + displayPath: workspacePath, writable: true, ), executionBinding: ExecutionBinding( @@ -188,7 +193,7 @@ extension AppControllerWebHelpers on AppController { ), lifecycleState: const ThreadLifecycleState( archived: false, - status: 'needs_workspace', + status: 'ready', lastRunAtMs: null, lastResultCode: null, ), @@ -281,7 +286,7 @@ extension AppControllerWebHelpers on AppController { ); threadRecordsInternal[key] = (existing ?? newRecordInternal(target: resolvedTarget)).copyWith( - sessionKey: key, + threadId: key, ownerScope: ownerScope, workspaceBinding: workspaceBinding, executionBinding: buildWebExecutionBindingInternal( @@ -462,6 +467,12 @@ extension AppControllerWebHelpers on AppController { assistantExecutionTargetForSession(key); final existing = threadRecordsInternal[key] ?? newRecordInternal(target: resolvedTarget); + final nextWorkspaceBinding = existing.workspaceBinding; + if (!nextWorkspaceBinding.isComplete) { + throw StateError( + 'TaskThread $key is missing a complete workspaceBinding.', + ); + } threadRecordsInternal[key] = existing.copyWith( threadId: key, messages: messages ?? existing.messages, @@ -485,8 +496,15 @@ extension AppControllerWebHelpers on AppController { selectedSkillsSource ?? existing.contextState.selectedSkillsSource, gatewayEntryState: gatewayEntryState ?? existing.gatewayEntryState, clearGatewayEntryState: clearGatewayEntryState, - workspacePath: workspacePath ?? existing.workspacePath, - workspaceKind: workspaceKind ?? existing.workspaceKind, + workspaceBinding: + (workspacePath != null || workspaceKind != null) + ? nextWorkspaceBinding.copyWith( + workspacePath: workspacePath, + displayPath: workspacePath ?? nextWorkspaceBinding.displayPath, + workspaceKind: workspaceKind, + ) + : nextWorkspaceBinding, + lifecycleState: existing.lifecycleState.copyWith(status: 'ready'), ); recomputeDerivedWorkspaceStateInternal(); } diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index 0b1ec57b..12996eaf 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -97,48 +97,81 @@ class _AppShellState extends State { animation: widget.controller, builder: (context, _) { final controller = widget.controller; + final palette = context.palette; return Scaffold( body: SafeArea( bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final palette = context.palette; - final platform = Theme.of(context).platform; - final isCompactMobile = - (platform == TargetPlatform.iOS || - platform == TargetPlatform.android) && - constraints.maxWidth < 900; - final isMobile = constraints.maxWidth < 900; - final sidebarState = controller.sidebarState; - final showSidebar = sidebarState != AppSidebarState.hidden; - final uiFeatures = controller.featuresFor( - resolveUiFeaturePlatformFromContext(context), - ); - final sidebarTaskItems = _buildSidebarTaskItems(controller); - final expandedSidebarWidth = _clampSidebarWidth( - _sidebarExpandedWidth ?? - _defaultSidebarWidth( - controller.appLanguage, - constraints.maxWidth, + child: Column( + children: [ + if ((controller.startupTaskThreadWarning ?? '').trim().isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.accentMuted, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: palette.warning), ), - constraints.maxWidth, - ); - final showPinnedDetail = - controller.detailPanel != null && - constraints.maxWidth > 1280; - final mobileDestination = - controller.destination == WorkspaceDestination.account - ? WorkspaceDestination.assistant - : controller.destination; - final availableMobileDestinations = _mobileDestinations - .where(controller.capabilities.supportsDestination) - .toList(growable: false); - final resolvedMobileDestination = - availableMobileDestinations.contains(mobileDestination) - ? mobileDestination - : (availableMobileDestinations.isEmpty + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + controller.startupTaskThreadWarning!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + TextButton( + onPressed: controller.dismissStartupTaskThreadWarning, + child: Text(appText('关闭', 'Dismiss')), + ), + ], + ), + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final palette = context.palette; + final platform = Theme.of(context).platform; + final isCompactMobile = + (platform == TargetPlatform.iOS || + platform == TargetPlatform.android) && + constraints.maxWidth < 900; + final isMobile = constraints.maxWidth < 900; + final sidebarState = controller.sidebarState; + final showSidebar = sidebarState != AppSidebarState.hidden; + final uiFeatures = controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); + final sidebarTaskItems = _buildSidebarTaskItems(controller); + final expandedSidebarWidth = _clampSidebarWidth( + _sidebarExpandedWidth ?? + _defaultSidebarWidth( + controller.appLanguage, + constraints.maxWidth, + ), + constraints.maxWidth, + ); + final showPinnedDetail = + controller.detailPanel != null && + constraints.maxWidth > 1280; + final mobileDestination = + controller.destination == WorkspaceDestination.account + ? WorkspaceDestination.assistant + : controller.destination; + final availableMobileDestinations = _mobileDestinations + .where(controller.capabilities.supportsDestination) + .toList(growable: false); + final resolvedMobileDestination = + availableMobileDestinations.contains(mobileDestination) ? mobileDestination - : availableMobileDestinations.first); + : (availableMobileDestinations.isEmpty + ? mobileDestination + : availableMobileDestinations.first); void openMobileDetail(DetailPanelData detail) { showModalBottomSheet( @@ -404,7 +437,10 @@ class _AppShellState extends State { ), ], ); - }, + }, + ), + ), + ], ), ), ); diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 2e08a29a..5cbe23c1 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -588,6 +588,9 @@ class WorkspaceBinding { final String displayPath; final bool writable; + bool get isComplete => + workspaceId.trim().isNotEmpty && workspacePath.trim().isNotEmpty; + WorkspaceBinding copyWith({ String? workspaceId, WorkspaceKind? workspaceKind, @@ -616,11 +619,17 @@ class WorkspaceBinding { factory WorkspaceBinding.fromJson(Map json) { final path = json['workspacePath']?.toString() ?? ''; + final workspaceId = json['workspaceId']?.toString() ?? ''; + final workspaceKindValue = json['workspaceKind']?.toString(); + if (workspaceId.trim().isEmpty || + path.trim().isEmpty || + workspaceKindValue == null || + workspaceKindValue.trim().isEmpty) { + throw const FormatException('TaskThread.workspaceBinding is incomplete.'); + } return WorkspaceBinding( - workspaceId: json['workspaceId']?.toString() ?? '', - workspaceKind: WorkspaceKindCopy.fromJsonValue( - json['workspaceKind']?.toString(), - ), + workspaceId: workspaceId, + workspaceKind: WorkspaceKindCopy.fromJsonValue(workspaceKindValue), workspacePath: path, displayPath: json['displayPath']?.toString() ?? path, writable: json['writable'] as bool? ?? true, @@ -877,11 +886,10 @@ class ThreadLifecycleState { class TaskThread { TaskThread({ - String? threadId, - String? sessionKey, + required String threadId, String? title, ThreadOwnerScope? ownerScope, - WorkspaceBinding? workspaceBinding, + required WorkspaceBinding workspaceBinding, ExecutionBinding? executionBinding, ThreadContextState? contextState, ThreadLifecycleState? lifecycleState, @@ -896,15 +904,11 @@ class TaskThread { String? assistantModelId, SingleAgentProvider? singleAgentProvider, String? gatewayEntryState, - String? workspaceRef, - WorkspaceRefKind? workspaceRefKind, - String? displayPath, AssistantPermissionLevel? permissionLevel, String? latestResolvedRuntimeModel, - String? lifecycleStatus, double? lastRunAtMs, String? lastResultCode, - }) : threadId = _resolveThreadId(threadId, sessionKey), + }) : threadId = _resolveThreadId(threadId), title = title ?? '', ownerScope = ownerScope ?? @@ -914,15 +918,7 @@ class TaskThread { subjectId: '', displayName: '', ), - workspaceBinding = - workspaceBinding ?? - WorkspaceBinding( - workspaceId: _resolveThreadId(threadId, sessionKey), - workspaceKind: _workspaceKindFromLegacy(workspaceRefKind), - workspacePath: workspaceRef?.trim() ?? '', - displayPath: (displayPath ?? workspaceRef ?? '').trim(), - writable: true, - ), + workspaceBinding = _validateWorkspaceBinding(workspaceBinding), executionBinding = executionBinding ?? ExecutionBinding( @@ -953,11 +949,7 @@ class TaskThread { lifecycleState ?? ThreadLifecycleState( archived: archived ?? false, - status: - lifecycleStatus ?? - ((workspaceRef?.trim().isEmpty ?? true) - ? 'needs_workspace' - : 'ready'), + status: 'ready', lastRunAtMs: lastRunAtMs, lastResultCode: lastResultCode?.trim(), ), @@ -1016,7 +1008,6 @@ class TaskThread { TaskThread copyWith({ String? threadId, - String? sessionKey, String? title, ThreadOwnerScope? ownerScope, WorkspaceBinding? workspaceBinding, @@ -1040,13 +1031,6 @@ class TaskThread { ThreadSelectionSource? selectedSkillsSource, String? gatewayEntryState, bool clearGatewayEntryState = false, - String? workspaceRef, - WorkspaceRefKind? workspaceRefKind, - String? workspacePath, - String? displayPath, - WorkspaceKind? workspaceKind, - bool? writable, - String? lifecycleStatus, String? latestResolvedRuntimeModel, }) { final nextExecutionBinding = executionBinding ?? this.executionBinding; @@ -1063,19 +1047,10 @@ class TaskThread { ThreadExecutionMode.gatewayRemote, }; return TaskThread( - threadId: threadId ?? sessionKey ?? this.threadId, + threadId: threadId ?? this.threadId, title: title ?? this.title, ownerScope: ownerScope ?? this.ownerScope, - workspaceBinding: (workspaceBinding ?? this.workspaceBinding).copyWith( - workspacePath: workspacePath ?? workspaceRef, - displayPath: displayPath, - workspaceKind: - workspaceKind ?? - (workspaceRefKind == null - ? null - : _workspaceKindFromLegacy(workspaceRefKind)), - writable: writable, - ), + workspaceBinding: workspaceBinding ?? this.workspaceBinding, executionBinding: nextExecutionBinding.copyWith( executionMode: nextExecutionMode, executorId: singleAgentProvider?.providerId, @@ -1097,23 +1072,25 @@ class TaskThread { ), lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith( archived: archived, - status: lifecycleStatus, ), createdAtMs: createdAtMs ?? this.createdAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, ); } - static String _resolveThreadId(String? threadId, String? sessionKey) { - return (threadId ?? sessionKey ?? '').trim(); + static String _resolveThreadId(String threadId) { + return threadId.trim(); } - static WorkspaceKind _workspaceKindFromLegacy(WorkspaceRefKind? kind) { - return switch (kind) { - WorkspaceRefKind.remotePath || - WorkspaceRefKind.objectStore => WorkspaceKind.remoteFs, - _ => WorkspaceKind.localFs, - }; + static WorkspaceBinding _validateWorkspaceBinding( + WorkspaceBinding workspaceBinding, + ) { + if (!workspaceBinding.isComplete) { + throw StateError( + 'TaskThread requires a complete workspaceBinding at create/load time.', + ); + } + return workspaceBinding; } static ThreadExecutionMode _executionModeFromLegacy( diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index b34634e3..db43de2e 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -79,6 +79,9 @@ class SecureConfigStore { return _settingsStore.loadTaskThreads(); } + List get lastSkippedInvalidTaskThreadIds => + _settingsStore.lastSkippedInvalidTaskThreadIds; + Future saveTaskThreads(List records) { return _settingsStore.saveTaskThreads(records); } diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index 7f71a8e4..70d32b81 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -58,10 +58,13 @@ class SettingsStore { PersistentWriteFailure? _tasksWriteFailure; PersistentWriteFailure? _auditWriteFailure; bool _taskThreadStateResetRequired = false; + List _lastSkippedInvalidTaskThreadIds = const []; PersistentWriteFailure? get settingsWriteFailure => _settingsWriteFailure; PersistentWriteFailure? get tasksWriteFailure => _tasksWriteFailure; PersistentWriteFailure? get auditWriteFailure => _auditWriteFailure; + List get lastSkippedInvalidTaskThreadIds => + List.unmodifiable(_lastSkippedInvalidTaskThreadIds); Future initialize() async { if (_initialized) { @@ -426,8 +429,25 @@ class SettingsStore { return const []; } _taskThreadStateResetRequired = false; + _lastSkippedInvalidTaskThreadIds = const []; final orderedKeys = index.sessions; final recordsByKey = {}; + final skippedIds = {}; + + String inferThreadIdFromTaskFile(File file) { + final name = file.uri.pathSegments.isEmpty + ? file.path + : file.uri.pathSegments.last; + final encoded = name.endsWith('.json') + ? name.substring(0, name.length - 5) + : name; + try { + return utf8.decode(base64Url.decode(base64Url.normalize(encoded))); + } catch (_) { + return encoded; + } + } + try { await for (final entity in layout.tasksDirectory.list()) { if (entity is! File || @@ -452,6 +472,7 @@ class SettingsStore { } } } catch (_) { + skippedIds.add(inferThreadIdFromTaskFile(entity)); continue; } } @@ -472,6 +493,7 @@ class SettingsStore { ordered.add(record); } } + _lastSkippedInvalidTaskThreadIds = skippedIds.toList()..sort(); return ordered; } diff --git a/test/features/assistant_page_suite_composer.dart b/test/features/assistant_page_suite_composer.dart index ad746103..28ce3d24 100644 --- a/test/features/assistant_page_suite_composer.dart +++ b/test/features/assistant_page_suite_composer.dart @@ -254,26 +254,36 @@ void registerAssistantPageSuiteComposerTestsInternal() { ).create(recursive: true); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '${tempDirectory.path}/thread-main', + displayPath: '${tempDirectory.path}/thread-main', + writable: true, + ), messages: const [], updatedAtMs: 1, title: 'Main', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: '${tempDirectory.path}/thread-main', - workspaceRefKind: WorkspaceRefKind.localPath, ), TaskThread( - sessionKey: 'draft:artifact-thread', + threadId: 'draft:artifact-thread', + workspaceBinding: WorkspaceBinding( + workspaceId: 'draft:artifact-thread', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '${tempDirectory.path}/thread-task', + displayPath: '${tempDirectory.path}/thread-task', + writable: true, + ), messages: const [], updatedAtMs: 2, title: 'Artifact Thread', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: '${tempDirectory.path}/thread-task', - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); controller = CaptureSendAppControllerInternal( @@ -823,7 +833,14 @@ void registerAssistantPageSuiteComposerTestsInternal() { final controller = await createControllerWithThreadRecordsInternal( records: [ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '/tmp/main-thread', + displayPath: '/tmp/main-thread', + writable: true, + ), title: '研发任务', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, @@ -937,7 +954,14 @@ void registerAssistantPageSuiteComposerTestsInternal() { final controller = await createControllerWithThreadRecordsInternal( records: [ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '/tmp/main-thread', + displayPath: '/tmp/main-thread', + writable: true, + ), title: '研发任务', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index b1be1af7..816d2b1b 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -530,15 +530,20 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: threadWorkspace.path, + displayPath: threadWorkspace.path, + writable: true, + ), messages: const [], updatedAtMs: 1, title: 'Main', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: threadWorkspace.path, - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); diff --git a/test/runtime/app_controller_thread_skills_suite_acp.dart b/test/runtime/app_controller_thread_skills_suite_acp.dart index e0713f3e..60f9843b 100644 --- a/test/runtime/app_controller_thread_skills_suite_acp.dart +++ b/test/runtime/app_controller_thread_skills_suite_acp.dart @@ -89,15 +89,20 @@ void registerThreadSkillsAcpTests() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: workspaceRoot.path, + displayPath: workspaceRoot.path, + writable: true, + ), messages: const [], updatedAtMs: 1, title: '', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: workspaceRoot.path, - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); @@ -281,15 +286,20 @@ void registerThreadSkillsAcpTests() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '${tempDirectory.path}/missing-workspace', + displayPath: '${tempDirectory.path}/missing-workspace', + writable: true, + ), messages: const [], updatedAtMs: 1, title: '', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: '${tempDirectory.path}/missing-workspace', - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); diff --git a/test/runtime/app_controller_thread_skills_suite_workspace_fallback.dart b/test/runtime/app_controller_thread_skills_suite_workspace_fallback.dart index ff3720bd..996b3144 100644 --- a/test/runtime/app_controller_thread_skills_suite_workspace_fallback.dart +++ b/test/runtime/app_controller_thread_skills_suite_workspace_fallback.dart @@ -54,15 +54,20 @@ void registerThreadSkillsWorkspaceFallbackTests() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: workspaceRoot.path, + displayPath: workspaceRoot.path, + writable: true, + ), messages: const [], updatedAtMs: 1, title: '', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: workspaceRoot.path, - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); @@ -146,15 +151,20 @@ void registerThreadSkillsWorkspaceFallbackTests() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: workspaceRoot.path, + displayPath: workspaceRoot.path, + writable: true, + ), messages: const [], updatedAtMs: 1, title: '', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: workspaceRoot.path, - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); @@ -235,15 +245,20 @@ void registerThreadSkillsWorkspaceFallbackTests() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: workspaceRoot.path, + displayPath: workspaceRoot.path, + writable: true, + ), messages: const [], updatedAtMs: 1, title: '', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, - workspaceRef: workspaceRoot.path, - workspaceRefKind: WorkspaceRefKind.localPath, ), ]); diff --git a/test/runtime/secure_config_store_suite_compatibility.dart b/test/runtime/secure_config_store_suite_compatibility.dart index 75323d0d..28f4616c 100644 --- a/test/runtime/secure_config_store_suite_compatibility.dart +++ b/test/runtime/secure_config_store_suite_compatibility.dart @@ -58,7 +58,15 @@ void registerSecureConfigStoreSuiteCompatibilityTestsInternal() { ); final legacyRecords = [ TaskThread( - sessionKey: 'draft:legacy-1', + threadId: 'draft:legacy-1', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'draft:legacy-1', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: '/owners/remote/user/legacy/threads/draft:legacy-1', + displayPath: + '/owners/remote/user/legacy/threads/draft:legacy-1', + writable: true, + ), title: 'Legacy thread', archived: false, executionTarget: AssistantExecutionTarget.local, @@ -255,52 +263,51 @@ void registerSecureConfigStoreSuiteCompatibilityTestsInternal() { expect(decoded.lifecycleState.status, 'ready'); }); - test('TaskThread defaults lifecycle status to needs_workspace', () { - final decoded = TaskThread.fromJson({ - 'schemaVersion': taskThreadSchemaVersion, - 'threadId': 'thread-legacy', - 'title': 'Needs Workspace', - 'ownerScope': const { - 'realm': 'local', - 'subjectType': 'user', - 'subjectId': 'device-1', - 'displayName': 'device-1', - }, - 'workspaceBinding': const { - 'workspaceId': 'thread-legacy', - 'workspaceKind': 'localFs', - 'workspacePath': '', - 'displayPath': '', - 'writable': true, - }, - 'executionBinding': const { - 'executionMode': 'localAgent', - 'executorId': 'auto', - 'providerId': 'auto', - 'endpointId': '', - }, - 'contextState': const { - 'messages': [], - 'selectedModelId': '', - 'selectedSkillKeys': [], - 'importedSkills': [], - 'permissionLevel': 'defaultAccess', - 'messageViewMode': 'rendered', - 'latestResolvedRuntimeModel': '', - }, - 'lifecycleState': const { - 'archived': false, - 'status': 'needs_workspace', - 'lastRunAtMs': null, - 'lastResultCode': null, - }, - 'createdAtMs': 1700000000000, - 'updatedAtMs': 1700000000000, - }); - - expect(decoded.workspaceRef, isEmpty); - expect(decoded.workspaceKind, WorkspaceKind.localFs); - expect(decoded.lifecycleState.status, 'needs_workspace'); + test('TaskThread rejects persisted records without a complete binding', () { + expect( + () => TaskThread.fromJson({ + 'schemaVersion': taskThreadSchemaVersion, + 'threadId': 'thread-legacy', + 'title': 'Needs Workspace', + 'ownerScope': const { + 'realm': 'local', + 'subjectType': 'user', + 'subjectId': 'device-1', + 'displayName': 'device-1', + }, + 'workspaceBinding': const { + 'workspaceId': 'thread-legacy', + 'workspaceKind': 'localFs', + 'workspacePath': '', + 'displayPath': '', + 'writable': true, + }, + 'executionBinding': const { + 'executionMode': 'localAgent', + 'executorId': 'auto', + 'providerId': 'auto', + 'endpointId': '', + }, + 'contextState': const { + 'messages': [], + 'selectedModelId': '', + 'selectedSkillKeys': [], + 'importedSkills': [], + 'permissionLevel': 'defaultAccess', + 'messageViewMode': 'rendered', + 'latestResolvedRuntimeModel': '', + }, + 'lifecycleState': const { + 'archived': false, + 'status': 'needs_workspace', + 'lastRunAtMs': null, + 'lastResultCode': null, + }, + 'createdAtMs': 1700000000000, + 'updatedAtMs': 1700000000000, + }), + throwsFormatException, + ); }); }); } diff --git a/test/runtime/secure_config_store_suite_lifecycle.dart b/test/runtime/secure_config_store_suite_lifecycle.dart index 4f4ab0fe..308b0922 100644 --- a/test/runtime/secure_config_store_suite_lifecycle.dart +++ b/test/runtime/secure_config_store_suite_lifecycle.dart @@ -30,7 +30,14 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() { ); final records = [ TaskThread( - sessionKey: 'main', + threadId: 'main', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: '/owners/remote/user/main/threads/main', + displayPath: '/owners/remote/user/main/threads/main', + writable: true, + ), title: '研发任务', archived: true, executionTarget: AssistantExecutionTarget.remote, @@ -132,7 +139,14 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() { ); final records = [ TaskThread( - sessionKey: 'draft:backup-1', + threadId: 'draft:backup-1', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'draft:backup-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '/tmp/draft-backup-1', + displayPath: '/tmp/draft-backup-1', + writable: true, + ), title: '备份线程', archived: false, executionTarget: AssistantExecutionTarget.singleAgent, @@ -202,7 +216,15 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() { ); final records = [ TaskThread( - sessionKey: 'draft:clear-1', + threadId: 'draft:clear-1', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'draft:clear-1', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: '/owners/remote/user/clear/threads/draft:clear-1', + displayPath: + '/owners/remote/user/clear/threads/draft:clear-1', + writable: true, + ), title: '清理线程', archived: false, executionTarget: AssistantExecutionTarget.local, diff --git a/test/runtime/secure_config_store_suite_settings.dart b/test/runtime/secure_config_store_suite_settings.dart index cda21239..85bd220b 100644 --- a/test/runtime/secure_config_store_suite_settings.dart +++ b/test/runtime/secure_config_store_suite_settings.dart @@ -187,7 +187,16 @@ void registerSecureConfigStoreSuiteSettingsTestsInternal() { await store.saveTaskThreads([ TaskThread( - sessionKey: 'draft:memory-only', + threadId: 'draft:memory-only', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'draft:memory-only', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: + '/owners/remote/user/memory/threads/draft:memory-only', + displayPath: + '/owners/remote/user/memory/threads/draft:memory-only', + writable: true, + ), title: 'Memory only', archived: false, executionTarget: AssistantExecutionTarget.local, diff --git a/test/web/web_remote_session_repository_browser_test.dart b/test/web/web_remote_session_repository_browser_test.dart index 467a1883..2890609e 100644 --- a/test/web/web_remote_session_repository_browser_test.dart +++ b/test/web/web_remote_session_repository_browser_test.dart @@ -45,7 +45,14 @@ void main() { final bodies = []; final records = [ TaskThread( - sessionKey: 'direct:1', + threadId: 'direct:1', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'direct:1', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: '/owners/remote/user/direct/threads/direct:1', + displayPath: '/owners/remote/user/direct/threads/direct:1', + writable: true, + ), messages: const [ GatewayChatMessage( id: 'm1', diff --git a/test/web/web_settings_persistence_browser_test.dart b/test/web/web_settings_persistence_browser_test.dart index 8b8d6f49..603af4b5 100644 --- a/test/web/web_settings_persistence_browser_test.dart +++ b/test/web/web_settings_persistence_browser_test.dart @@ -131,7 +131,16 @@ void main() { ); await store.saveTaskThreads([ TaskThread( - sessionKey: 'direct:stale-browser-cache', + threadId: 'direct:stale-browser-cache', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'direct:stale-browser-cache', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: + '/owners/remote/user/direct/threads/direct:stale-browser-cache', + displayPath: + '/owners/remote/user/direct/threads/direct:stale-browser-cache', + writable: true, + ), messages: const [], updatedAtMs: 1, title: 'stale browser cache',