refactor: align OpenClaw session key state flow
This commit is contained in:
parent
ea781b5206
commit
fe84d69e10
3
.gitignore
vendored
3
.gitignore
vendored
@ -70,3 +70,6 @@ app.*.map.json
|
||||
|
||||
# Repomix — dynamically generated, not committed
|
||||
repomix-output.xml
|
||||
|
||||
# Flutter analyze — local diagnostic dump
|
||||
flutter_analyze_output.txt
|
||||
|
||||
198
docs/architecture/openclaw-session-key-state-and-data-flow.md
Normal file
198
docs/architecture/openclaw-session-key-state-and-data-flow.md
Normal 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.
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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((_) {}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user