-
-
Notifications
You must be signed in to change notification settings - Fork 80.7k
Expand file tree
/
Copy pathsession-accessor.ts
More file actions
2949 lines (2774 loc) · 101 KB
/
Copy pathsession-accessor.ts
File metadata and controls
2949 lines (2774 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
} from "../../agents/session-write-lock.js";
import {
resolveSessionStoreAgentId,
resolveSessionStoreKey,
} from "../../gateway/session-store-key.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type {
SessionTranscriptUpdate,
SessionTranscriptUpdateTarget,
} from "../../sessions/transcript-events.js";
import { getRuntimeConfig } from "../io.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { formatSessionArchiveTimestamp } from "./artifacts.js";
import { extractGeneratedTranscriptSessionId } from "./generated-transcript-session-id.js";
import { resolveAgentMainSessionKey } from "./main-session.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
resolveStorePath,
} from "./paths.js";
import {
cleanupPluginHostSessionStore as cleanupFilePluginHostSessionStore,
clearPluginOwnedSessionState,
type PluginHostSessionCleanupStoreParams,
} from "./plugin-host-cleanup.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import type {
ResolvedSessionMaintenanceConfig,
SessionMaintenanceWarning,
} from "./store-maintenance.js";
import {
getSessionEntry,
cleanupSessionLifecycleArtifacts as cleanupFileSessionLifecycleArtifacts,
deleteSessionEntryLifecycle as deleteFileSessionEntryLifecycle,
applySessionEntryLifecycleMutation as applyFileSessionEntryLifecycleMutation,
listSessionEntries as listFileSessionEntries,
loadSessionStore,
applySessionEntryPatchProjection as applyFileSessionEntryPatchProjection,
patchSessionEntry as patchFileSessionEntry,
patchSessionEntryWithKey as patchFileSessionEntryWithKey,
purgeDeletedAgentSessionEntries as purgeFileDeletedAgentSessionEntries,
readSessionUpdatedAt as readFileSessionUpdatedAt,
resolveSessionStoreEntry,
resetSessionEntryLifecycle as resetFileSessionEntryLifecycle,
updateSessionStore,
updateSessionStoreEntry as updateFileSessionStoreEntry,
type DeleteSessionEntryLifecycleResult,
type ResetSessionEntryLifecycleMutation,
type ResetSessionEntryLifecycleResult,
type DeletedAgentSessionEntryPurgeParams,
type SessionArchivedTranscriptCleanupRule,
type SessionEntryLifecycleMutationResult,
type SessionEntryLifecycleRemoval,
type SessionEntryLifecycleUpsert,
type SessionEntryPatchProjectionContext,
type SessionEntryPatchProjectionFailure,
type SessionEntryPatchProjectionResult,
type SessionEntryPatchProjectionSnapshot,
type SessionEntryPatchProjectionTarget,
type SessionLifecycleArchivedTranscript,
type SessionLifecycleArtifactCleanupParams,
type SessionLifecycleArtifactCleanupResult,
type SessionLifecycleStoreTarget,
} from "./store.js";
import { resolveAllAgentSessionStoreTargetsSync, type SessionStoreTarget } from "./targets.js";
import { parseSessionThreadInfo } from "./thread-info.js";
import {
type AppendSessionTranscriptMessageParams,
type AppendSessionTranscriptMessageResult,
appendSessionTranscriptEvent,
appendSessionTranscriptMessage,
appendSessionTranscriptMessageWithOwnedWriteLock,
withSessionTranscriptAppendQueue,
} from "./transcript-append.js";
import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import { writeJsonlLines } from "./transcript-jsonl.js";
import { replayRecentUserAssistantMessages } from "./transcript-replay.js";
import { streamSessionTranscriptLines } from "./transcript-stream.js";
import {
scanSessionTranscriptTree,
selectSessionTranscriptTreePathNodes,
} from "./transcript-tree.js";
import {
type OwnedSessionTranscriptPublishedEntry,
resolveOwnedSessionTranscriptWriteLockRunner,
withOwnedSessionTranscriptWrites,
} from "./transcript-write-context.js";
import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js";
/**
* Session access API for callers that need entries or transcripts without
* depending on the persisted store layout. Callers provide stable session
* identity, and this module resolves the current entry/transcript target while
* preserving canonical-key, transcript-linking, and update-notification rules.
*
* Ownership contract (#88838): this accessor is the permanent storage-neutral
* domain boundary for session/transcript runtime access; the SQLite storage
* flip implements this interface. The entry workflow helpers in store.ts are
* the file-backend implementation it delegates to plus the plugin-SDK
* deprecation-window surface (RFC 0007); they become internal as direct
* callers migrate here. New runtime callers use this module, not store.ts.
*/
export type SessionAccessScope = {
/** Agent owner used when the session key does not already encode one. */
agentId?: string;
/**
* Set false only for internal read-only hot paths that will not retain or
* mutate the returned entry.
*/
clone?: boolean;
/** Environment override used when resolving agent-scoped store paths in tests/tools. */
env?: NodeJS.ProcessEnv;
/** Set false for metadata-only reads that do not need hydrated prompt refs. */
hydrateSkillPromptRefs?: boolean;
/** Use latest when the caller must bypass any in-process metadata snapshot. */
readConsistency?: "latest";
/** Canonical or alias session key for the entry being read or written. */
sessionKey: string;
/** Explicit store path for callers that already resolved the owning store. */
storePath?: string;
};
export type LogicalSessionAccessScope = {
/** Runtime config whose session store rules define the logical session owner. */
cfg: OpenClawConfig;
/** Environment override used when resolving configured/discovered agent stores. */
env?: NodeJS.ProcessEnv;
/** Canonical or alias session key for the logical entry being read or written. */
sessionKey: string;
};
export type ResolvedSessionEntryAccessTarget = {
/** Agent owner inferred from the canonical session key. */
agentId: string;
/** Canonical session key returned to callers even when an alias row won. */
canonicalKey: string;
/** Freshest matching entry, if any. */
entry?: SessionEntry;
/** Original caller-supplied key after trimming. */
requestedKey: string;
/** Persisted key for the selected row. */
storeKey: string;
};
type ResolvedSessionEntryStoreTarget = ResolvedSessionEntryAccessTarget & {
storePath: string;
};
export type SessionEntryCandidateAccessScope = {
/** Agent owner whose session store is searched. */
agentId: string;
/** Ordered session keys to test inside the resolved store. */
candidateKeys: readonly string[];
/** Runtime config whose session store rule selects the backend target. */
cfg: OpenClawConfig;
/** Environment override used when resolving agent-scoped store paths in tests/tools. */
env?: NodeJS.ProcessEnv;
/** Optional synthesized entry returned only when no candidate exists. */
fallback?: {
entry: SessionEntry;
sessionKey: string;
};
};
export type ResolvedSessionEntryCandidateTarget = {
/** Agent owner whose session store produced this result. */
agentId: string;
/** Candidate key that selected the result, or the fallback key. */
candidateKey: string;
/** Session metadata cloned from storage or from the synthesized fallback. */
entry: SessionEntry;
/** False only for synthesized fallback entries that have not been written. */
persisted: boolean;
/** Persisted key selected by the backend, or the fallback key. */
sessionKey: string;
};
export type ResolvedSessionEntryUpdateContext = Omit<ResolvedSessionEntryAccessTarget, "entry"> & {
/** Mutable entry inside the storage operation. */
entry: SessionEntry;
};
export type ResolvedSessionEntryUpdateResult<T> =
| {
canonicalKey: string;
found: false;
}
| {
canonicalKey: string;
entry: SessionEntry;
found: true;
result: T;
storeKey: string;
};
export type SessionTranscriptAccessScope = Omit<SessionAccessScope, "sessionKey"> & {
/** Explicit transcript file path; bypasses store lookup when already known. */
sessionFile?: string;
/** Runtime session id used to derive a transcript file when no explicit file is provided. */
sessionId: string;
/** Required when resolving through session metadata; optional for explicit transcript artifacts. */
sessionKey?: string;
/** Channel thread suffix used when deriving topic transcript paths. */
threadId?: string | number;
};
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
/** Resolved file-backed artifact for the current runtime target. */
sessionFile?: string;
sessionId: string;
threadId?: string | number;
};
export type SessionTranscriptReadScope = Omit<SessionTranscriptRuntimeScope, "sessionKey"> & {
/** Canonical key when the caller has a session-store identity for this read. */
sessionKey?: string;
/** Entry already loaded by hot callers; avoids rereading the session store. */
sessionEntry?: Pick<SessionEntry, "sessionFile"> & Partial<Pick<SessionEntry, "sessionId">>;
};
export type SessionTranscriptReadTarget = Omit<
SessionTranscriptRuntimeTarget,
"agentId" | "sessionKey"
> & {
agentId?: string;
sessionKey?: string;
};
export type SessionTranscriptWriteScope = Omit<SessionTranscriptAccessScope, "sessionId"> & {
/** Optional for appenders that can operate on an existing explicit transcript target. */
sessionId?: string;
};
export type SessionEntrySummary = {
/** Persisted key for the entry. */
sessionKey: string;
/** Entry value cloned from the backing store unless the caller requested borrowed reads. */
entry: SessionEntry;
};
/** Raw transcript record for non-message events; message records use appendTranscriptMessage. */
export type TranscriptEvent = unknown;
export type TranscriptMessageAppendOptions<TMessage> = {
/** Runtime config used for message redaction and transcript header metadata. */
config?: OpenClawConfig;
/** Working directory recorded in a newly created transcript header. */
cwd?: string;
/** How duplicate message idempotency keys are detected before append. */
idempotencyLookup?: "scan" | "caller-checked";
/** Provider/channel message payload to persist. */
message: TMessage;
/** Testable timestamp override for the generated transcript entry. */
now?: number;
/** Optional finalizer that runs after duplicate detection but before persistence. */
prepareMessageAfterIdempotencyCheck?: (message: TMessage) => TMessage | undefined;
/** Allow append without parent-link migration for large legacy linear transcripts. */
useRawWhenLinear?: boolean;
};
export type TranscriptMessageAppendResult<TMessage> = {
/** False when idempotency lookup found an existing transcript message. */
appended: boolean;
/** Redacted message payload as persisted or replayed from the transcript. */
message: TMessage;
/** Existing or newly generated transcript message id. */
messageId: string;
};
/** Transcript update fields supplied by callers; sessionFile is resolved here. */
export type TranscriptUpdatePayload = Omit<SessionTranscriptUpdate, "sessionFile">;
export type SessionTranscriptTurnUpdateMode = "inline" | "file-only" | "none";
export type SessionTranscriptTurnMessageAppend = TranscriptMessageAppendOptions<unknown> & {
/**
* Runs inside the file-backed write lock before this message is appended.
* SQLite implementation note: duplicate/skip decisions should be evaluated
* inside the same write transaction as the transcript row append.
*/
shouldAppend?: (context: SessionTranscriptTurnWriteContext) => Promise<boolean> | boolean;
};
export type SessionTranscriptTurnWriteContext = {
agentId?: string;
sessionFile: string;
sessionId?: string;
sessionKey?: string;
};
export type SessionTranscriptTurnPersistOptions = {
/** Runtime config used for lock settings, redaction, and header metadata. */
config?: OpenClawConfig;
/** Working directory recorded in a newly created transcript header. */
cwd?: string;
/**
* Rejects the turn when the persisted session key no longer points at this
* runtime session id. SQLite implementations must evaluate this guard inside
* the same write transaction as the transcript append and metadata touch.
*/
expectedSessionId?: string;
/** Message rows to append under one transcript write lock. */
messages: readonly SessionTranscriptTurnMessageAppend[];
/** Controls whether the update event includes the last appended message. */
updateMode?: SessionTranscriptTurnUpdateMode;
/** Emit file-only updates even when every candidate message was skipped. */
publishWhen?: "always" | "when-appended";
/**
* Touch updatedAt/sessionFile metadata after appending.
* SQLite implementation note: transcript row append(s) plus this session
* metadata touch should be one SQLite write transaction; publish happens
* after that transaction commits.
*/
touchSessionEntry?: boolean;
};
export type SessionTranscriptTurnPersistResult = {
appendedCount: number;
messages: TranscriptMessageAppendResult<unknown>[];
rejectedReason?: "session-rebound";
sessionEntry: SessionEntry | undefined;
sessionFile: string;
};
type SessionTranscriptTurnAppendRunner = <TMessage>(
params: AppendSessionTranscriptMessageParams<TMessage>,
) => Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined>;
export type SessionTranscriptRuntimeTarget = {
agentId: string;
sessionFile: string;
sessionId: string;
sessionKey: string;
};
export type SessionTranscriptManualTrimResult =
| {
compacted: false;
reason: "no transcript";
}
| {
compacted: false;
kept: number;
}
| {
archived: string;
compacted: true;
kept: number;
};
export type SessionTranscriptManualTrimPreflightResult =
| Extract<SessionTranscriptManualTrimResult, { compacted: false }>
| {
compacted: true;
};
export type SessionEntryUpdateOptions = {
/** Skip prune/cap/rotation maintenance for specialized internal updates. */
skipMaintenance?: boolean;
/** Let the writer cache retain the updated object without cloning. */
takeCacheOwnership?: boolean;
/** Throw when best-effort store recovery cannot confirm the requested write. */
requireWriteSuccess?: boolean;
};
export type SessionAbortTargetCutoff = {
messageSid?: string;
timestamp?: number;
};
export type SessionAbortTargetContext = {
entry: SessionEntry;
sessionKey: string;
};
export type SessionAbortTargetIdentity = SessionAbortTargetContext & {
sessionId?: string;
};
export type SessionAbortTargetResult = SessionAbortTargetIdentity & {
persisted: boolean;
persistenceError?: string;
};
export type SessionLifecycleTranscriptInfo = {
sessionFile?: string;
transcriptArchived?: boolean;
};
export type SessionLifecycleRolloverResult = {
previousSessionTranscript: SessionLifecycleTranscriptInfo;
sessionEntry: SessionEntry;
};
export type ReplySessionInitializationSnapshot = {
currentEntry?: SessionEntry;
readEntry: (sessionKey: string) => SessionEntry | undefined;
revision: string;
};
export type ReplySessionInitializationCommitContext = {
currentEntry?: SessionEntry;
readEntry: (sessionKey: string) => SessionEntry | undefined;
sessionEntry: SessionEntry;
};
export type ReplySessionInitializationCommitResult =
| {
ok: true;
previousSessionTranscript: SessionLifecycleTranscriptInfo;
sessionEntry: SessionEntry;
sessionStoreView: Record<string, SessionEntry>;
}
| {
ok: false;
currentEntry?: SessionEntry;
reason: "stale-snapshot";
revision: string;
};
type SessionEntryRetirement = {
entry: SessionEntry;
key: string;
};
let sessionArchiveRuntimePromise: Promise<
typeof import("../../gateway/session-archive.runtime.js")
> | null = null;
function loadSessionArchiveRuntime() {
sessionArchiveRuntimePromise ??= import("../../gateway/session-archive.runtime.js");
return sessionArchiveRuntimePromise;
}
export type SessionEntryPatchOptions = {
/** Entry to synthesize when a patch operation is allowed to create. */
fallbackEntry?: SessionEntry;
/** Fully resolved maintenance settings when the caller already has config loaded. */
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
/** Keep the previous updatedAt value when the patch should not count as activity. */
preserveActivity?: boolean;
/** Throw when best-effort store recovery cannot confirm the requested write. */
requireWriteSuccess?: boolean;
/** Replace the whole entry instead of merging the returned patch. */
replaceEntry?: boolean;
/** Skip prune/cap/rotation maintenance for specialized internal updates. */
skipMaintenance?: boolean;
/** Let the writer cache retain the updated object without cloning. */
takeCacheOwnership?: boolean;
};
export type SessionEntryPatchContext = {
/** Present when the patched entry already existed before fallback synthesis. */
existingEntry?: SessionEntry;
};
export type SessionEntryPatchResult = {
/** Exact persisted key for the patched entry after alias normalization. */
sessionKey: string;
/** Persisted entry returned by the backing store. */
entry: SessionEntry;
};
export type RestartRecoveryLifecycleEntry = {
/** Exact persisted key for the restart recovery candidate row. */
sessionKey: string;
/** Detached entry snapshot; mutating it does not persist unless returned as a replacement. */
entry: SessionEntry;
};
export type RestartRecoveryLifecycleReplacement = {
/** Exact persisted key to replace. Missing keys are ignored. */
sessionKey: string;
/** Full replacement row to persist for this restart recovery lifecycle step. */
entry: SessionEntry;
};
export type RestartRecoveryLifecycleUpdate<T> = {
/** Caller-owned result returned after replacements are persisted. */
result: T;
/** Exact rows to replace inside the storage transaction. */
replacements?: Iterable<RestartRecoveryLifecycleReplacement>;
};
/** File-backed checkpoint transcript fork produced by the checkpoint storage boundary. */
export type SessionCompactionCheckpointForkedTranscript = {
sessionFile: string;
sessionId: string;
totalTokens?: number;
};
/** Result of resolving and copying checkpoint transcript content for branch/restore. */
export type SessionCompactionCheckpointTranscriptForkResult =
| { status: "created"; transcript: SessionCompactionCheckpointForkedTranscript }
| { status: "missing-boundary" }
| { status: "failed" };
/** Result of applying a checkpoint branch or restore mutation to session storage. */
export type SessionCompactionCheckpointMutationResult =
| {
status: "created";
key: string;
checkpoint: SessionCompactionCheckpoint;
entry: SessionEntry;
}
| { status: "missing-session" }
| { status: "missing-checkpoint" }
| { status: "missing-boundary" }
| { status: "failed" };
export type SessionCompactionCheckpointEntryBuildContext = {
/** Checkpoint row selected from the current persisted session entry. */
checkpoint: SessionCompactionCheckpoint;
/** Persisted entry that owns the selected checkpoint. */
currentEntry: SessionEntry;
/** Forked transcript identity created from the stored checkpoint boundary. */
forkedTranscript: SessionCompactionCheckpointForkedTranscript;
};
export type SessionCompactionCheckpointTranscriptForker = (
checkpoint: SessionCompactionCheckpoint,
) => Promise<SessionCompactionCheckpointTranscriptForkResult>;
export type SessionCompactionCheckpointEntryBuilder = (
context: SessionCompactionCheckpointEntryBuildContext,
) => Promise<SessionEntry> | SessionEntry;
export type BranchSessionFromCompactionCheckpointParams = {
/** Checkpoint id stored on the source session entry. */
checkpointId: string;
/** Builds the branched session entry from the forked transcript. */
buildEntry: SessionCompactionCheckpointEntryBuilder;
/** Copies transcript content through the stored checkpoint boundary. */
forkTranscriptFromCheckpoint: SessionCompactionCheckpointTranscriptForker;
/** Persisted key for the new checkpoint branch. */
nextKey: string;
/** Canonical key used as the branch parent. */
sourceKey: string;
/** Actual persisted key to read when a legacy alias still owns the row. */
sourceStoreKey?: string;
/** Explicit store target for file-backed stores and SQLite migration adapters. */
storePath: string;
};
export type RestoreSessionFromCompactionCheckpointParams = {
/** Checkpoint id stored on the current session entry. */
checkpointId: string;
/** Builds the restored session entry from the forked transcript. */
buildEntry: SessionCompactionCheckpointEntryBuilder;
/** Copies transcript content through the stored checkpoint boundary. */
forkTranscriptFromCheckpoint: SessionCompactionCheckpointTranscriptForker;
/** Canonical key to replace with the restored checkpoint state. */
sessionKey: string;
/** Actual persisted key to read when a legacy alias still owns the row. */
sessionStoreKey?: string;
/** Explicit store target for file-backed stores and SQLite migration adapters. */
storePath: string;
};
export type TemporarySessionMappingPreservationResult<T> = {
/** Result returned by the operation while the temporary mapping may exist. */
result: T;
/** Snapshot failure; callers may continue when temporary cleanup is best-effort. */
snapshotFailure?: string;
/** Restore/delete failure for the original temporary mapping state. */
restoreFailure?: string;
};
type TemporarySessionMappingSnapshot =
| {
canRestore: false;
sessionKey: string;
snapshotFailure: string;
storePath: string;
}
| {
canRestore: true;
hadEntry: false;
sessionKey: string;
storePath: string;
}
| {
canRestore: true;
entry: SessionEntry;
hadEntry: true;
sessionKey: string;
storePath: string;
};
type TemporarySessionMappingOperationResult<T> =
| {
ok: true;
result: T;
}
| {
error: unknown;
ok: false;
};
export type SessionEntryCreateWithTranscriptContext = {
/** Current entry under the requested key before creation, if any. */
existingEntry?: SessionEntry;
/** Current entries snapshot for validation rules such as label uniqueness. */
sessionEntries: Record<string, SessionEntry>;
};
export type SessionEntryCreateWithTranscriptResult<TError = string> =
| { ok: true; entry: SessionEntry; sessionFile: string }
| { ok: false; error: TError; phase: "entry" }
| { ok: false; error: string; phase: "transcript" };
export type SessionEntryCreateWithTranscriptPrepareResult<TError = string> =
| { ok: true; entry: SessionEntry }
| { ok: false; error: TError };
type CreatedSessionTranscriptResult =
| { ok: true; sessionFile: string }
| { ok: false; error: string; phase: "transcript" };
export type SessionPatchProjectionContext = SessionEntryPatchProjectionContext;
export type SessionPatchProjectionFailure = SessionEntryPatchProjectionFailure;
export type SessionPatchProjectionResult<TFailure extends SessionPatchProjectionFailure> =
SessionEntryPatchProjectionResult<TFailure>;
export type SessionPatchProjectionSnapshot = SessionEntryPatchProjectionSnapshot;
export type SessionPatchProjectionTarget = SessionEntryPatchProjectionTarget;
export type {
DeleteSessionEntryLifecycleResult,
ResetSessionEntryLifecycleResult,
SessionLifecycleArchivedTranscript,
SessionLifecycleArtifactCleanupParams,
SessionLifecycleArtifactCleanupResult,
SessionLifecycleStoreTarget,
};
export type {
DeletedAgentSessionEntryPurgeParams,
SessionArchivedTranscriptCleanupRule,
SessionEntryLifecycleMutationResult,
SessionEntryLifecycleRemoval,
SessionEntryLifecycleUpsert,
};
export type ResetSessionEntryLifecycleParams = {
/** Runs after the persisted entry rotates and before transcript artifacts move. */
afterEntryMutation?: (mutation: ResetSessionEntryLifecycleMutation) => Promise<void> | void;
/** Agent owner used to resolve backend transcript artifacts. */
agentId?: string;
/** Builds the persisted replacement entry from the current backend row. */
buildNextEntry: (context: {
currentEntry?: SessionEntry;
primaryKey: string;
}) => Promise<SessionEntry> | SessionEntry;
/** Explicit store target for file-backed stores and SQLite migration adapters. */
storePath: string;
/** Canonical key plus aliases that identify the logical entry. */
target: SessionLifecycleStoreTarget;
};
export type DeleteSessionEntryLifecycleParams = {
/** Agent owner used to resolve backend transcript artifacts. */
agentId?: string;
/** Whether transcript artifacts should be archived/deleted with the entry. */
archiveTranscript: boolean;
/** Explicit store target for file-backed stores and SQLite migration adapters. */
storePath: string;
/** Canonical key plus aliases that identify the logical entry. */
target: SessionLifecycleStoreTarget;
};
export type CanonicalizeSessionEntryAliasesResult = {
canonicalKey: string;
entry?: SessionEntry;
};
export { clearPluginOwnedSessionState };
function isStorePathTemplate(store?: string): boolean {
return typeof store === "string" && store.includes("{agentId}");
}
function resolveLogicalSessionStoreCandidates(params: {
agentId: string;
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): SessionStoreTarget[] {
const storeConfig = params.cfg.session?.store;
const defaultTarget = {
agentId: params.agentId,
storePath: resolveStorePath(storeConfig, { agentId: params.agentId, env: params.env }),
};
if (!isStorePathTemplate(storeConfig)) {
return [defaultTarget];
}
const targets = new Map<string, SessionStoreTarget>();
targets.set(defaultTarget.storePath, defaultTarget);
for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg, { env: params.env })) {
if (target.agentId === params.agentId) {
targets.set(target.storePath, target);
}
}
return [...targets.values()];
}
function buildLogicalSessionEntryCandidateKeys(params: {
agentId: string;
canonicalKey: string;
cfg: OpenClawConfig;
requestedKey: string;
}): string[] {
const targets = new Set<string>();
if (params.canonicalKey) {
targets.add(params.canonicalKey);
}
if (params.requestedKey && params.requestedKey !== params.canonicalKey) {
targets.add(params.requestedKey);
}
if (params.canonicalKey === "global" || params.canonicalKey === "unknown") {
return [...targets];
}
const agentMainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId: params.agentId,
});
if (params.canonicalKey === agentMainKey) {
targets.add(`agent:${params.agentId}:main`);
}
return [...targets];
}
function findFreshestSessionEntryMatch(
entries: SessionEntrySummary[],
candidateKeys: readonly string[],
): SessionEntrySummary | undefined {
let freshest: SessionEntrySummary | undefined;
for (const candidate of candidateKeys) {
const trimmed = candidate.trim();
if (!trimmed) {
continue;
}
const match = entries.find((entry) => entry.sessionKey === trimmed);
if (match && (!freshest || (match.entry.updatedAt ?? 0) >= (freshest.entry.updatedAt ?? 0))) {
freshest = match;
}
}
return freshest;
}
/**
* Resolves a logical session key to the freshest matching entry across the
* configured store and discovered same-agent stores.
*/
export function resolveSessionEntryAccessTarget(
scope: LogicalSessionAccessScope,
): ResolvedSessionEntryAccessTarget {
const target = resolveSessionEntryStoreTarget(scope);
return {
agentId: target.agentId,
canonicalKey: target.canonicalKey,
entry: target.entry,
requestedKey: target.requestedKey,
storeKey: target.storeKey,
};
}
/** Resolves ordered candidate keys inside one agent-owned session store. */
export function resolveSessionEntryCandidateTarget(
scope: SessionEntryCandidateAccessScope,
): ResolvedSessionEntryCandidateTarget | null {
const storePath = resolveStorePath(scope.cfg.session?.store, {
agentId: scope.agentId,
env: scope.env,
});
const store = loadSessionStore(storePath);
for (const candidateKey of uniqueStrings(scope.candidateKeys.map((key) => key.trim()))) {
if (!candidateKey) {
continue;
}
const resolved = resolveSessionStoreEntry({ store, sessionKey: candidateKey });
if (!resolved.existing) {
continue;
}
return {
agentId: scope.agentId,
candidateKey,
entry: structuredClone(resolved.existing),
persisted: true,
sessionKey: resolved.normalizedKey,
};
}
const fallbackKey = scope.fallback?.sessionKey.trim();
if (!fallbackKey || !scope.fallback) {
return null;
}
return {
agentId: scope.agentId,
candidateKey: fallbackKey,
entry: structuredClone(scope.fallback.entry),
persisted: false,
sessionKey: fallbackKey,
};
}
function resolveSessionEntryStoreTarget(
scope: LogicalSessionAccessScope,
): ResolvedSessionEntryStoreTarget {
const requestedKey = scope.sessionKey.trim();
const canonicalKey = resolveSessionStoreKey({ cfg: scope.cfg, sessionKey: requestedKey });
const agentId = resolveSessionStoreAgentId(scope.cfg, canonicalKey);
const scanTargets = buildLogicalSessionEntryCandidateKeys({
agentId,
canonicalKey,
cfg: scope.cfg,
requestedKey,
});
const candidates = resolveLogicalSessionStoreCandidates({
agentId,
cfg: scope.cfg,
env: scope.env,
});
const fallback = candidates[0] ?? {
agentId,
storePath: resolveStorePath(scope.cfg.session?.store, { agentId, env: scope.env }),
};
let selectedStorePath = fallback.storePath;
let selectedMatch = findFreshestSessionEntryMatch(
listSessionEntries({ storePath: fallback.storePath }),
scanTargets,
);
for (let index = 1; index < candidates.length; index += 1) {
const candidate = candidates[index];
if (!candidate) {
continue;
}
const match = findFreshestSessionEntryMatch(
listSessionEntries({ storePath: candidate.storePath }),
scanTargets,
);
if (
match &&
(!selectedMatch || (match.entry.updatedAt ?? 0) >= (selectedMatch.entry.updatedAt ?? 0))
) {
selectedStorePath = candidate.storePath;
selectedMatch = match;
}
}
return {
agentId,
canonicalKey,
entry: selectedMatch?.entry,
requestedKey,
storeKey: selectedMatch?.sessionKey ?? canonicalKey,
storePath: selectedStorePath,
};
}
/**
* Mutates the freshest matching logical session entry without exposing the
* backing store map to callers.
*/
export async function updateResolvedSessionEntry<T>(
scope: LogicalSessionAccessScope,
update: (entry: SessionEntry, context: ResolvedSessionEntryUpdateContext) => Promise<T> | T,
): Promise<ResolvedSessionEntryUpdateResult<T>> {
const target = resolveSessionEntryStoreTarget(scope);
if (!target.entry) {
return { canonicalKey: target.canonicalKey, found: false };
}
return await updateSessionStore(target.storePath, async (store) => {
const entry = store[target.storeKey];
if (!entry) {
return { canonicalKey: target.canonicalKey, found: false };
}
const context: ResolvedSessionEntryUpdateContext = {
agentId: target.agentId,
canonicalKey: target.canonicalKey,
entry,
requestedKey: target.requestedKey,
storeKey: target.storeKey,
};
const result = await update(entry, context);
return {
canonicalKey: target.canonicalKey,
entry: structuredClone(entry),
found: true,
result,
storeKey: target.storeKey,
};
});
}
/** Returns the entry for a canonical or alias session key, if one exists. */
export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | undefined {
if (scope.clone === false || scope.readConsistency === "latest") {
const store = loadSessionStore(resolveAccessStorePath(scope), {
...(scope.clone === false ? { clone: false } : {}),
...(scope.readConsistency === "latest" ? { skipCache: true } : {}),
...(scope.hydrateSkillPromptRefs === false ? { hydrateSkillPromptRefs: false } : {}),
});
return resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }).existing;
}
return getSessionEntry(scope);
}
/** Lists entries from the resolved store, preserving the persisted key for each row. */
export function listSessionEntries(
scope: Partial<Omit<SessionAccessScope, "sessionKey">> = {},
): SessionEntrySummary[] {
if (scope.clone === false) {
return Object.entries(
loadSessionStore(resolveAccessStorePath({ ...scope, sessionKey: "" }), {
clone: false,
...(scope.hydrateSkillPromptRefs === false ? { hydrateSkillPromptRefs: false } : {}),
}),
).map(([sessionKey, entry]) => ({ sessionKey, entry }));
}
return listFileSessionEntries(scope);
}
/** Reads the last activity timestamp for one session entry, or undefined when absent. */
export function readSessionUpdatedAt(scope: SessionAccessScope): number | undefined {
if (scope.storePath) {
return readFileSessionUpdatedAt({
storePath: scope.storePath,
sessionKey: scope.sessionKey,
});
}
return loadSessionEntry(scope)?.updatedAt;
}
/** Creates or updates one entry from a partial patch and returns the persisted entry. */
export async function upsertSessionEntry(
scope: SessionAccessScope,
patch: Partial<SessionEntry>,
): Promise<SessionEntry | null> {
return await patchFileSessionEntry({
...scope,
fallbackEntry: createFallbackSessionEntry(patch),
update: () => patch,
});
}
/** Replaces one entry with the supplied value and returns the persisted entry. */
export async function replaceSessionEntry(
scope: SessionAccessScope,
entry: SessionEntry,
): Promise<SessionEntry | null> {
return await patchFileSessionEntry({
...scope,
fallbackEntry: entry,
replaceEntry: true,
update: () => entry,
});
}
/**
* Applies an atomic patch to one entry.
* The updater sees the current entry plus whether it was synthesized from a
* fallback; returning null skips persistence.
*/
export async function patchSessionEntry(
scope: SessionAccessScope,
update: (
entry: SessionEntry,
context: SessionEntryPatchContext,
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
options: SessionEntryPatchOptions = {},
): Promise<SessionEntry | null> {
return await patchFileSessionEntry({
...scope,
fallbackEntry: options.fallbackEntry,
maintenanceConfig: options.maintenanceConfig,
preserveActivity: options.preserveActivity,
requireWriteSuccess: options.requireWriteSuccess,
replaceEntry: options.replaceEntry,
skipMaintenance: options.skipMaintenance,
takeCacheOwnership: options.takeCacheOwnership,
update,
});
}
/**
* Applies an atomic patch and returns the persisted key selected by the backing
* store. Use when a caller must keep sidecar state keyed to the final row.
*/
export async function patchSessionEntryWithKey(