refactor thread workspace resolution and align architecture docs
This commit is contained in:
parent
de11eb55c4
commit
ef18a068be
@ -144,6 +144,38 @@ flowchart LR
|
||||
| 发送前附件草稿 | 否 | 当前只有页面级 `_attachments` |
|
||||
| 导出 | 否 | 未实现 |
|
||||
|
||||
## 线程工作目录与 WorkspaceRefKind(当前实现)
|
||||
|
||||
### 统一线程工作目录规则
|
||||
|
||||
当前 Desktop 实现中,所有线程(含 `main`)统一使用:
|
||||
|
||||
`workspacePath/.xworkmate/threads/<SessionKey>`
|
||||
|
||||
其中:
|
||||
|
||||
- `<SessionKey>` 会经过目录名安全化(非法字符替换)
|
||||
- 目录不存在时会自动创建
|
||||
- 线程切换与恢复时,如发现旧记录目录缺失或仍指向共享根目录,会自动迁移到该统一目录
|
||||
|
||||
### `workspaceRefKind` 的语义(与路径解耦)
|
||||
|
||||
`workspaceRefKind` 用来表达运行通道语义,而不是决定目录拼接规则:
|
||||
|
||||
- 本地 Agent(`singleAgent`)=> `localPath`
|
||||
- OpenClaw Gateway(`local` / `remote`)=> `remotePath`
|
||||
|
||||
注意:即使 `workspaceRefKind = remotePath`,线程目录仍然按统一规则落在
|
||||
`workspacePath/.xworkmate/threads/<SessionKey>`。
|
||||
|
||||
### 已清理的旧行为
|
||||
|
||||
以下旧行为不再作为当前实现:
|
||||
|
||||
- `main` 线程直接使用 `workspacePath` 根目录
|
||||
- 通过 `remoteProjectRoot` 单独决定线程目录
|
||||
- Single Agent runner 返回 `resolvedWorkingDirectory` 后覆盖线程目录
|
||||
|
||||
## 当前交互规则
|
||||
|
||||
### 新建线程
|
||||
@ -204,5 +236,5 @@ flowchart LR
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [模式切换与线程连续追问](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/cases/thread_mode_switch_followup.md)
|
||||
- [XWorkmate 集成架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/architecture/xworkmate-integrations.md)
|
||||
- [模式切换与线程连续追问](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/cases/thread_mode_switch_followup.md)
|
||||
- [XWorkmate 集成架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-integrations.md)
|
||||
|
||||
@ -265,6 +265,27 @@ AssistantThreadRecord fields that matter most:
|
||||
- assistantModelId
|
||||
- gatewayEntryState
|
||||
- messages
|
||||
- workspaceRef
|
||||
- workspaceRefKind
|
||||
|
||||
Thread workspace resolution (Desktop current baseline):
|
||||
|
||||
- All sessions (including `main`) resolve to:
|
||||
`settings.workspacePath/.xworkmate/threads/<SessionKey>`
|
||||
- Session directory is created on demand when missing
|
||||
- Legacy records that point to shared root or missing directories are migrated
|
||||
back to the per-session isolated path
|
||||
- `workspaceRefKind` is runtime-channel semantics, not path-layout semantics:
|
||||
- `singleAgent` => `localPath`
|
||||
- gateway-backed targets (`local` / `remote`) => `remotePath`
|
||||
- both still use the same isolated thread directory path pattern above
|
||||
|
||||
Important cleanup note:
|
||||
|
||||
- `remoteProjectRoot` is no longer used as a separate thread workspace path
|
||||
selector in Desktop thread workspace resolution.
|
||||
- Runtime-reported `resolvedWorkingDirectory` from single-agent execution
|
||||
should not overwrite the canonical per-thread workspaceRef path.
|
||||
|
||||
Responsibilities:
|
||||
- Hold per-thread overrides
|
||||
|
||||
@ -112,17 +112,6 @@ extension AppControllerDesktopSingleAgent on AppController {
|
||||
if (resolvedRuntimeModel.isNotEmpty) {
|
||||
_singleAgentRuntimeModelBySession[sessionKey] = resolvedRuntimeModel;
|
||||
}
|
||||
final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim();
|
||||
if (resolvedWorkingDirectory.isNotEmpty) {
|
||||
_upsertAssistantThreadRecord(
|
||||
sessionKey,
|
||||
workspaceRef: resolvedWorkingDirectory,
|
||||
workspaceRefKind:
|
||||
result.resolvedWorkspaceRefKind ??
|
||||
assistantWorkspaceRefKindForSession(sessionKey),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
}
|
||||
_clearAiGatewayStreamingText(sessionKey);
|
||||
if (result.aborted) {
|
||||
final partial = result.output.trim();
|
||||
|
||||
@ -717,19 +717,14 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
|
||||
String _defaultWorkspaceRefForSession(String sessionKey) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final target = assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
||||
AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent =>
|
||||
_defaultLocalWorkspaceRefForSession(normalizedSessionKey),
|
||||
};
|
||||
return _defaultLocalWorkspaceRefForSession(normalizedSessionKey);
|
||||
}
|
||||
|
||||
String _defaultLocalWorkspaceRefForSession(String sessionKey) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final baseWorkspace = settings.workspacePath.trim();
|
||||
if (baseWorkspace.isEmpty || normalizedSessionKey == 'main') {
|
||||
return baseWorkspace;
|
||||
if (baseWorkspace.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final threadWorkspace =
|
||||
'${_trimTrailingPathSeparator(baseWorkspace)}/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}';
|
||||
@ -766,21 +761,9 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
}
|
||||
|
||||
bool _usesLegacySharedWorkspaceRef(
|
||||
String sessionKey, {
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
String? workspaceRef,
|
||||
WorkspaceRefKind? workspaceRefKind,
|
||||
}) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (normalizedSessionKey == 'main') {
|
||||
return false;
|
||||
}
|
||||
final resolvedTarget =
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
if (resolvedTarget == AssistantExecutionTarget.remote) {
|
||||
return false;
|
||||
}
|
||||
) {
|
||||
final normalizedRef = workspaceRef?.trim() ?? '';
|
||||
if (normalizedRef.isEmpty) {
|
||||
return false;
|
||||
@ -791,17 +774,10 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
|
||||
bool _usesDefaultThreadWorkspaceRefFromAnotherRoot(
|
||||
String sessionKey, {
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
String? workspaceRef,
|
||||
WorkspaceRefKind? workspaceRefKind,
|
||||
}) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final resolvedTarget =
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
if (resolvedTarget == AssistantExecutionTarget.remote) {
|
||||
return false;
|
||||
}
|
||||
final normalizedRef = workspaceRef?.trim() ?? '';
|
||||
if (normalizedRef.isEmpty ||
|
||||
workspaceRefKind != WorkspaceRefKind.localPath) {
|
||||
@ -822,9 +798,6 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
if (normalizedPath == normalizedExpected) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedSessionKey == 'main') {
|
||||
return normalizedPath == SettingsSnapshot.defaults().workspacePath;
|
||||
}
|
||||
final expectedSuffix =
|
||||
'/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}';
|
||||
return normalizedPath.endsWith(expectedSuffix);
|
||||
@ -832,7 +805,6 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
|
||||
bool _shouldMigrateWorkspaceRef(
|
||||
String sessionKey, {
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
String? workspaceRef,
|
||||
WorkspaceRefKind? workspaceRefKind,
|
||||
}) {
|
||||
@ -840,27 +812,40 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
if (normalizedRef.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return _usesLegacySharedWorkspaceRef(
|
||||
sessionKey,
|
||||
executionTarget: executionTarget,
|
||||
workspaceRef: normalizedRef,
|
||||
workspaceRefKind: workspaceRefKind,
|
||||
) ||
|
||||
if (_usesMissingWorkspaceRef(sessionKey, workspaceRefKind, normalizedRef)) {
|
||||
return true;
|
||||
}
|
||||
return _usesLegacySharedWorkspaceRef(normalizedRef, workspaceRefKind) ||
|
||||
_usesDefaultThreadWorkspaceRefFromAnotherRoot(
|
||||
sessionKey,
|
||||
executionTarget: executionTarget,
|
||||
workspaceRef: normalizedRef,
|
||||
workspaceRefKind: workspaceRefKind,
|
||||
);
|
||||
}
|
||||
|
||||
bool _usesMissingWorkspaceRef(
|
||||
String sessionKey,
|
||||
WorkspaceRefKind? workspaceRefKind,
|
||||
String workspaceRef,
|
||||
) {
|
||||
if (workspaceRefKind != WorkspaceRefKind.localPath) {
|
||||
return false;
|
||||
}
|
||||
final normalizedPath = workspaceRef.trim();
|
||||
if (normalizedPath.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return FileSystemEntity.typeSync(normalizedPath) ==
|
||||
FileSystemEntityType.notFound;
|
||||
}
|
||||
|
||||
WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath,
|
||||
AssistantExecutionTarget.local ||
|
||||
AssistantExecutionTarget.singleAgent => WorkspaceRefKind.localPath,
|
||||
AssistantExecutionTarget.local ||
|
||||
AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath,
|
||||
};
|
||||
}
|
||||
|
||||
@ -869,14 +854,12 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
}) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final resolvedTarget =
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
final nextWorkspaceRef = _defaultWorkspaceRefForSession(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
final nextWorkspaceRefKind = _defaultWorkspaceRefKindForTarget(
|
||||
resolvedTarget,
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey),
|
||||
);
|
||||
final existing = _assistantThreadRecords[normalizedSessionKey];
|
||||
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
|
||||
@ -885,7 +868,6 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
existing.workspaceRefKind == nextWorkspaceRefKind &&
|
||||
!_shouldMigrateWorkspaceRef(
|
||||
normalizedSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
workspaceRef: existingWorkspaceRef,
|
||||
workspaceRefKind: existing.workspaceRefKind,
|
||||
)) {
|
||||
@ -898,7 +880,9 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
}
|
||||
_upsertAssistantThreadRecord(
|
||||
normalizedSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
executionTarget:
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey),
|
||||
workspaceRef: nextWorkspaceRef,
|
||||
workspaceRefKind: nextWorkspaceRefKind,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
|
||||
@ -587,8 +587,6 @@ extension AppControllerDesktopThreadStorage on AppController {
|
||||
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
|
||||
final shouldMigrateWorkspaceRef = _shouldMigrateWorkspaceRef(
|
||||
sessionKey,
|
||||
executionTarget:
|
||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||
workspaceRef: record.workspaceRef,
|
||||
workspaceRefKind: record.workspaceRefKind,
|
||||
);
|
||||
|
||||
@ -773,8 +773,9 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
executionTarget: executionTarget,
|
||||
singleAgentProvider: controller.currentSingleAgentProvider,
|
||||
permissionLevel: settings.assistantPermissionLevel,
|
||||
workspacePath: settings.workspacePath,
|
||||
remoteProjectRoot: settings.remoteProjectRoot,
|
||||
workspacePath: controller.assistantWorkspaceRefForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
@ -985,7 +986,6 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
required SingleAgentProvider singleAgentProvider,
|
||||
required AssistantPermissionLevel permissionLevel,
|
||||
required String workspacePath,
|
||||
required String remoteProjectRoot,
|
||||
}) {
|
||||
final attachmentBlock = attachmentNames.isEmpty
|
||||
? ''
|
||||
@ -993,9 +993,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
final skillBlock = selectedSkillLabels.isEmpty
|
||||
? ''
|
||||
: 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n';
|
||||
final targetRoot = executionTarget == AssistantExecutionTarget.local
|
||||
? workspacePath.trim()
|
||||
: remoteProjectRoot.trim();
|
||||
final targetRoot = workspacePath.trim();
|
||||
final executionContext =
|
||||
'Execution context:\n'
|
||||
'- target: ${executionTarget.promptValue}\n'
|
||||
|
||||
@ -96,7 +96,9 @@ class RuntimeBootstrapConfig {
|
||||
value.trim().isEmpty || value.trim() == '/opt/data';
|
||||
|
||||
static bool _isDefaultRemoteRoot(String value) =>
|
||||
value.trim().isEmpty || value.trim() == '/opt/data/workspace';
|
||||
value.trim().isEmpty ||
|
||||
value.trim() == '/opt/data/workspace' ||
|
||||
value.trim() == '/opt/data';
|
||||
|
||||
static bool _isDefaultCliPath(String value) =>
|
||||
value.trim().isEmpty || value.trim() == 'openclaw';
|
||||
|
||||
@ -603,7 +603,8 @@ class AssistantThreadRecord {
|
||||
if (workspaceRef.startsWith('object://')) {
|
||||
return WorkspaceRefKind.objectStore;
|
||||
}
|
||||
if (executionTarget == AssistantExecutionTarget.remote) {
|
||||
if (executionTarget != null &&
|
||||
executionTarget != AssistantExecutionTarget.singleAgent) {
|
||||
return WorkspaceRefKind.remotePath;
|
||||
}
|
||||
return WorkspaceRefKind.localPath;
|
||||
|
||||
@ -79,8 +79,8 @@ class SettingsSnapshot {
|
||||
appActive: true,
|
||||
launchAtLogin: false,
|
||||
showDockIcon: true,
|
||||
workspacePath: '/opt/data',
|
||||
remoteProjectRoot: '/opt/data/workspace',
|
||||
workspacePath: '',
|
||||
remoteProjectRoot: '',
|
||||
cliPath: 'openclaw',
|
||||
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
codexCliPath: '',
|
||||
|
||||
@ -405,7 +405,7 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() {
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController adopts and reuses resolved remote single-agent thread workspaces',
|
||||
'AppController keeps isolated thread workspace even when runner reports another directory',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-remote-thread-cwd-',
|
||||
@ -434,7 +434,7 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() {
|
||||
shouldFallbackToAiChat: false,
|
||||
resolvedWorkingDirectory:
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
resolvedWorkspaceRefKind: WorkspaceRefKind.remotePath,
|
||||
resolvedWorkspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
@ -463,17 +463,17 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() {
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('draft:remote-thread'),
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefKindForSession('draft:remote-thread'),
|
||||
WorkspaceRefKind.remotePath,
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('第二次运行', thinking: 'low');
|
||||
expect(
|
||||
runner.requests.last.workingDirectory,
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -33,13 +33,13 @@ void main() {
|
||||
controller.assistantWorkspaceRefForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
controller.settings.workspacePath,
|
||||
'${controller.settings.workspacePath}/.xworkmate/threads/main',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefKindForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
WorkspaceRefKind.localPath,
|
||||
WorkspaceRefKind.remotePath,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
@ -49,7 +49,7 @@ void main() {
|
||||
controller.assistantWorkspaceRefForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
controller.settings.remoteProjectRoot,
|
||||
'${controller.settings.workspacePath}/.xworkmate/threads/main',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefKindForSession(
|
||||
@ -289,4 +289,65 @@ void main() {
|
||||
expect(Directory(migratedWorkspace).existsSync(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController migrates missing draft workspace refs to isolated thread directories',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-workspace-missing-',
|
||||
);
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await workspaceRoot.create(recursive: true);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(workspacePath: workspaceRoot.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:missing-ref-thread',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: 'Missing Ref Thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: '${workspaceRoot.path}/.xworkmate/threads/missing-dir',
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (controller.initializing) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('controller did not initialize in time');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
|
||||
final migratedWorkspace = controller.assistantWorkspaceRefForSession(
|
||||
'draft:missing-ref-thread',
|
||||
);
|
||||
expect(
|
||||
migratedWorkspace,
|
||||
'${workspaceRoot.path}/.xworkmate/threads/draft-missing-ref-thread',
|
||||
);
|
||||
expect(Directory(migratedWorkspace).existsSync(), isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user