refactor: align OpenClaw session key state flow

This commit is contained in:
Haitao Pan 2026-06-06 11:51:01 +08:00
parent ea781b5206
commit fe84d69e10
17 changed files with 239 additions and 30 deletions

3
.gitignore vendored
View File

@ -70,3 +70,6 @@ app.*.map.json
# Repomix — dynamically generated, not committed
repomix-output.xml
# Flutter analyze — local diagnostic dump
flutter_analyze_output.txt

View File

@ -0,0 +1,198 @@
# OpenClaw Session Key State And Data Flow
This note is the source-of-truth for the XWorkmate App -> Bridge -> OpenClaw
session identity boundary.
## Terms
| Term | Owner | Example | Meaning |
| --- | --- | --- | --- |
| `TaskThread.sessionKey` | XWorkmate App | `draft:1780658097668838-1` | App-local thread identity. It owns UI state, queue state, local workspace, and persisted TaskThread data. |
| `appThreadKey` | XWorkmate typed integration metadata | `draft:1780658097668838-1` | The typed cross-repo name for the App TaskThread identity. It is currently the same value as `TaskThread.sessionKey`. |
| local thread workspace | XWorkmate App | `~/.xworkmate/threads/draft-1780658097668838-1` | Filesystem path derived from the App thread key. Path formatting is not a protocol key. |
| `openclawSessionKey` | OpenClaw native session layer | `agent:main:draft:1780658097668838-1` | OpenClaw SessionEntry key and `chat.send.sessionKey` value. |
| `runId` | OpenClaw task/runtime layer | `run-openclaw-...` | The active OpenClaw run/task id. Bridge updates to the actual OpenClaw `runId` if `chat.send` returns one different from the initial request id. |
Rules:
- App code may keep `sessionKey` for local TaskThread and UI session APIs.
- XWorkmate OpenClaw integration payloads must use `appThreadKey` and
`openclawSessionKey`.
- Bridge may send `sessionKey` only when calling OpenClaw native APIs that
require that field, such as `chat.send`.
- Plugin lookup must read `appThreadKey -> openclawSessionKey` from
`SessionEntry.pluginExtensions`, not from string replace or reverse parsing.
- Legacy `sessionKey` aliases are not accepted from App or Bridge as mapping
inputs.
## State Graph
```mermaid
stateDiagram-v2
[*] --> AppDraftCreated
AppDraftCreated: TaskThread.sessionKey = appThreadKey
AppDraftCreated: local workspace path is derived for filesystem use
AppDraftCreated --> Submitted: App sends session.start
Submitted: params.sessionId/threadId = App thread identity
Submitted: metadata.xworkmateTaskArtifactContract.appThreadKey
Submitted: metadata.xworkmateTaskArtifactContract.expectedArtifactDirs
Submitted --> BridgePrepared: Bridge calls xworkmate.session.prepare
BridgePrepared: schemaVersion = 1
BridgePrepared: appThreadKey
BridgePrepared: openclawSessionKey
BridgePrepared: expectedArtifactDirs
BridgePrepared: requestId/externalTaskId
BridgePrepared --> MappingPersisted: Plugin patches SessionEntry.pluginExtensions
MappingPersisted: source = bridge_prepare or session_start
MappingPersisted: conflicts fail closed
MappingPersisted --> OpenClawRunning: Bridge calls chat.send
OpenClawRunning: chat.send.sessionKey = openclawSessionKey
OpenClawRunning: idempotencyKey = requestId
OpenClawRunning --> OpenClawRunning: agent.wait still running
OpenClawRunning --> RunIdReprepared: chat.send returns actual runId
RunIdReprepared: Bridge prepares actual run scope
RunIdReprepared --> OpenClawRunning
OpenClawRunning --> Terminal: agent.wait or native task-registry terminal state
Terminal --> ArtifactSnapshot: collect-and-snapshot
ArtifactSnapshot --> ArtifactExport: xworkmate.artifacts.export
ArtifactExport --> AppReady: session.update or xworkmate.tasks.get result
AppReady --> [*]
OpenClawRunning --> ReconcileAfterReconnect: App/Bridge reconnect
ReconcileAfterReconnect --> MappingPersisted: lookup mapping by appThreadKey
ReconcileAfterReconnect --> Terminal: query native task-registry by openclawSessionKey/runId
```
## Data Flow
```mermaid
flowchart LR
subgraph App["xworkmate-app"]
TT["TaskThread\nsessionKey = draft:..."]
Meta["Typed metadata\nschemaVersion=1\nappThreadKey\nexpectedArtifactDirs"]
Assoc["OpenClawTaskAssociation\nappThreadKey\nopenclawSessionKey\nrunId"]
end
subgraph Bridge["xworkmate-bridge"]
Validate["validate request"]
PrepareParams["prepare params\nschemaVersion=1\nappThreadKey\nopenclawSessionKey\nexpectedArtifactDirs\nrequestId/externalTaskId"]
NativeChat["OpenClaw native chat.send\nsessionKey = openclawSessionKey\nidempotencyKey"]
Events["normalize native events\nsession.update to App"]
end
subgraph OpenClaw["openclaw.svc.plus"]
SessionEntry["SessionEntry\nkey = openclawSessionKey"]
NativeTask["native task-registry\nrunId/taskId/status"]
Transcript["transcript/runtime events"]
end
subgraph Plugin["openclaw-multi-session-plugins"]
Prepare["xworkmate.session.prepare"]
Ext["pluginExtensions mapping\nxworkmate.sessionMapping"]
TasksGet["xworkmate.tasks.get\nnative registry lookup"]
Artifact["artifact resolver\nexpectedArtifactDirs"]
end
TT --> Meta
Meta --> Validate
Validate --> PrepareParams
PrepareParams --> Prepare
Prepare --> Ext
Ext --> SessionEntry
PrepareParams --> NativeChat
NativeChat --> SessionEntry
NativeChat --> NativeTask
NativeTask --> Transcript
Transcript --> Events
Events --> Assoc
Assoc --> TasksGet
TasksGet --> NativeTask
TasksGet --> Artifact
Artifact --> Events
```
## Field Contract
App `session.start`:
```json
{
"sessionId": "draft:1780658097668838-1",
"threadId": "draft:1780658097668838-1",
"metadata": {
"xworkmateTaskArtifactContract": {
"schemaVersion": 1,
"appThreadKey": "draft:1780658097668838-1",
"expectedArtifactDirs": [
"artifacts/",
"reports/",
"exports/",
"assets/",
"assets/images/",
"dist/"
]
}
}
}
```
Bridge `xworkmate.session.prepare`:
```json
{
"schemaVersion": 1,
"appThreadKey": "draft:1780658097668838-1",
"openclawSessionKey": "agent:main:draft:1780658097668838-1",
"runId": "turn-...",
"requestId": "turn-...",
"externalTaskId": "turn-...",
"expectedArtifactDirs": ["artifacts/", "reports/"]
}
```
Plugin writes:
```json
{
"schemaVersion": 1,
"appThreadKey": "draft:1780658097668838-1",
"openclawSessionKey": "agent:main:draft:1780658097668838-1",
"expectedArtifactDirs": ["artifacts/", "reports/"],
"source": "bridge_prepare",
"createdAt": "2026-06-05T00:00:00.000Z",
"updatedAt": "2026-06-05T00:00:00.000Z"
}
```
App `xworkmate.tasks.get` params:
```json
{
"appThreadKey": "draft:1780658097668838-1",
"openclawSessionKey": "agent:main:draft:1780658097668838-1",
"runId": "run-openclaw-...",
"includeArtifacts": true
}
```
## Final Consistency
For the debug case:
- App local key: `TaskThread.sessionKey = appThreadKey = draft:1780658097668838-1`
- App local path: `~/.xworkmate/threads/draft-1780658097668838-1`
- OpenClaw URL: `?session=agent%3Amain%3Adraft%3A1780658097668838-1`
- OpenClaw key: `openclawSessionKey = agent:main:draft:1780658097668838-1`
- Durable mapping:
`SessionEntry.pluginExtensions["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"]`
This keeps the visible IDs aligned for debugging while avoiding string inference
as the lookup source. The deterministic `agent:main:<appThreadKey>` creation
policy is only used before the mapping exists; after `xworkmate.session.prepare`
the durable mapping is authoritative.

View File

@ -247,7 +247,7 @@ extension AppControllerDesktopGateway on AppController {
forceRefresh: true,
persistMountTargets: true,
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Keep the Gateway connect flow usable even if ACP capability refresh
// trails the runtime handshake.
}

View File

@ -267,7 +267,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
);
try {
await runtime.health();
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Connectivity succeeded; health is best-effort for the test path.
}
final endpoint =
@ -286,14 +286,14 @@ extension AppControllerDesktopSettingsRuntime on AppController {
} finally {
try {
await runtime.disconnect(clearDesiredProfile: false);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Ignore teardown noise from temporary connectivity checks.
}
runtime.dispose();
temporaryStore.dispose();
try {
await temporaryRoot.delete(recursive: true);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Ignore cleanup noise for temporary connectivity checks.
}
}
@ -447,7 +447,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
}
try {
await settingsControllerInternal.restoreAccountSession();
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Keep initialization resilient when remote account restore fails.
}
restoreAssistantThreadsInternal(sanitizedAssistantThreads);
@ -501,7 +501,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
startupTarget,
),
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Keep the shell usable when auto-connect fails.
}
}

View File

@ -77,7 +77,7 @@ extension AppControllerDesktopThreadStorage on AppController {
}
try {
await syncAiGatewayCatalog(snapshot.aiGateway, apiKeyOverride: apiKey);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Keep the saved draft applied even if model sync fails immediately.
}
}
@ -564,7 +564,7 @@ extension AppControllerDesktopThreadStorage on AppController {
normalizedRecord.workspacePath.trim().isNotEmpty) {
try {
Directory(normalizedRecord.workspacePath).createSync(recursive: true);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only. The thread should still restore even when the
// directory cannot be recreated immediately.
}

View File

@ -238,7 +238,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
resolvedTarget,
),
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Keep the selected execution target even when the immediate reconnect
// fails so the user can retry or adjust gateway settings manually.
}
@ -441,7 +441,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionId: normalizedSessionKey,
threadId: normalizedSessionKey,
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only.
}
}).catchError((_) {}),

View File

@ -170,7 +170,7 @@ Future<Directory> resolveClipboardAttachmentTempDirectoryInternal() async {
Directory rootDirectory;
try {
rootDirectory = await getTemporaryDirectory();
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
rootDirectory = Directory.systemTemp;
}
final clipboardDirectory = Directory(

View File

@ -359,7 +359,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
Future<void> continueCurrentTaskInternal(String sessionKey) async {
try {
await widget.controller.continueAssistantTaskInternal(sessionKey);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
focusComposerInternal();
}
}

View File

@ -185,12 +185,12 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Account login should not fail only because runtime refresh is transient.
}
try {
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Runtime capabilities can be refreshed again from Assistant.
}
}

View File

@ -86,7 +86,7 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
@ -296,13 +296,13 @@ class _SettingsPageState extends State<SettingsPage> {
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only. Account sync should still succeed if runtime refresh
// is temporarily unavailable.
}
try {
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only. Runtime capabilities can be retried later.
}
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import '../app/app_metadata.dart';
import 'runtime_coordinator.dart';
import 'runtime_models.dart';
@ -68,7 +70,7 @@ class CodeAgentNodeOrchestrator {
metadata: resolution.metadata,
);
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Dispatch metadata is advisory; task execution still carries routing.
}
}

View File

@ -487,7 +487,7 @@ mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
version: info.version,
buildNumber: info.buildNumber,
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return const RuntimePackageInfo(
appName: kSystemAppName,
packageName: 'plus.svc.xworkmate',
@ -546,7 +546,7 @@ mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
modelIdentifier: info.machineId ?? 'linux',
);
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Fall through to generic info.
}
return RuntimeDeviceInfo(
@ -589,7 +589,7 @@ GatewaySetupPayload? decodeGatewaySetupCode(String rawInput) {
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
final decoded = utf8.decode(base64.decode(padded));
return decodeSetupPayloadJsonInternal(decoded);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return null;
}
}
@ -614,7 +614,7 @@ GatewaySetupPayload? decodeSetupPayloadJsonInternal(String raw) {
token: stringValue(json['token']) ?? '',
password: stringValue(json['password']) ?? '',
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return null;
}
}
@ -625,7 +625,7 @@ String resolveSetupCodeCandidateInternal(String raw) {
if (decoded is Map<String, dynamic>) {
return stringValue(decoded['setupCode']) ?? raw;
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Leave raw as-is.
}
return raw;

View File

@ -535,7 +535,7 @@ class SettingsController extends ChangeNotifier {
}),
);
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only. If file watching fails, directory watching may still work.
}
}
@ -549,7 +549,7 @@ class SettingsController extends ChangeNotifier {
scheduleReload();
}),
);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Best effort only. Missing watch support should not block runtime.
}
}

View File

@ -339,7 +339,7 @@ class SettingsSnapshot {
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return SettingsSnapshot.fromJson(decoded);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return SettingsSnapshot.defaults();
}
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'dart:convert';
import '../models/app_models.dart';
@ -97,7 +99,7 @@ class AppUiState {
return AppUiState.defaults();
}
return AppUiState.fromJson(decoded);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return AppUiState.defaults();
}
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'dart:io';
@ -134,7 +136,7 @@ class SecureConfigStore {
}
try {
return AppUiState.fromJson(payload);
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
return AppUiState.defaults();
}
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -122,7 +124,7 @@ class SettingsStore {
final layout = await _layoutResolver.resolve();
await deleteIfExists(File('${layout.tasksDirectory.path}/threads.json'));
await deleteIfExists(File('${layout.configDirectory.path}/settings.yaml'));
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Ignore errors for secondary persistence.
}
}
@ -138,7 +140,7 @@ class SettingsStore {
return decoded.map((e) => SecretAuditEntry.fromJson(e)).toList();
}
}
} catch (_) {
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
// Ignore errors for secondary persistence.
}
return const [];