@sema-agent/server 7.103.2 → 7.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +378 -2
- package/README.md +6 -1
- package/README.zh-CN.md +5 -1
- package/USAGE.md +320 -16
- package/deploy/sema-up/README.md +57 -7
- package/deploy/sema-up/cluster-up.sh +18 -4
- package/deploy/sema-up/kube-up.sh +16 -0
- package/deploy/sema-up/lib/dataroot.sh +142 -0
- package/deploy/sema-up/lib/flagshape.sh +69 -0
- package/deploy/sema-up/lib/lanes.py +14 -4
- package/deploy/sema-up/lib/shell-access.sh +41 -0
- package/deploy/sema-up/preflight.sh +14 -1
- package/deploy/sema-up/sema-up.sh +134 -37
- package/deploy/sema-up/smoke.sh +5 -0
- package/dist/auto-mode-face.js +3 -4
- package/dist/boot/budget-tracing.js +1 -1
- package/dist/boot/config-center.d.ts +6 -2
- package/dist/boot/config-center.js +5 -59
- package/dist/boot/execution-env.d.ts +11 -0
- package/dist/boot/execution-env.js +14 -0
- package/dist/boot/execution-lane.d.ts +25 -0
- package/dist/boot/execution-lane.js +95 -0
- package/dist/boot/memory-consolidation.d.ts +185 -9
- package/dist/boot/memory-consolidation.js +192 -32
- package/dist/boot/resolve-spec.d.ts +2 -0
- package/dist/boot/resolve-spec.js +28 -24
- package/dist/boot/runner-deps.d.ts +6 -2
- package/dist/boot/runner-deps.js +6 -1
- package/dist/boot/session-faces.d.ts +5 -0
- package/dist/boot/session-faces.js +18 -2
- package/dist/boot/session-incarnation-seat.d.ts +41 -0
- package/dist/boot/session-incarnation-seat.js +69 -0
- package/dist/boot/stage-01-config.js +2 -2
- package/dist/boot/stage-02-registry-pull.js +3 -1
- package/dist/boot/stage-04-stores.d.ts +2 -0
- package/dist/boot/stage-04-stores.js +3 -1
- package/dist/boot/stage-05-execution-env.d.ts +4 -0
- package/dist/boot/stage-05-execution-env.js +2 -2
- package/dist/boot/stage-06-runners.d.ts +4 -1
- package/dist/boot/stage-06-runners.js +7 -14
- package/dist/boot/stage-07-capability-layer.d.ts +4 -1
- package/dist/boot/stage-08-reapers.d.ts +4 -1
- package/dist/boot/stage-09-leader.d.ts +6 -1
- package/dist/boot/stage-09-leader.js +2 -1
- package/dist/boot/stage-10-http-server.d.ts +6 -1
- package/dist/boot/stage-10-http-server.js +4 -1
- package/dist/brain.d.ts +1 -1
- package/dist/config-catalog.d.ts +16 -1
- package/dist/config-catalog.js +9 -9
- package/dist/config-center/restart-signal.d.ts +4 -4
- package/dist/config-invariants.d.ts +7 -1
- package/dist/config-invariants.js +37 -6
- package/dist/config-provider.d.ts +42 -10
- package/dist/config-provider.js +18 -16
- package/dist/config-types.d.ts +37 -16
- package/dist/config-types.js +1 -0
- package/dist/config.d.ts +28 -30
- package/dist/config.js +95 -77
- package/dist/env-facts.d.ts +13 -3
- package/dist/env-facts.js +15 -3
- package/dist/execution-lane-caps.d.ts +15 -0
- package/dist/execution-lane-caps.js +8 -0
- package/dist/file-history-capture.d.ts +42 -35
- package/dist/file-history-capture.js +15 -10
- package/dist/file-history-disclosure.d.ts +7 -31
- package/dist/file-history-disclosure.js +12 -0
- package/dist/fleet/fleet-bus.d.ts +5 -1
- package/dist/http/admission.d.ts +12 -5
- package/dist/http/admission.js +5 -1
- package/dist/http/dispatch.d.ts +18 -0
- package/dist/http/dispatch.js +19 -6
- package/dist/http/principal-gate.d.ts +22 -0
- package/dist/http/principal-gate.js +20 -0
- package/dist/http/resume-legs.d.ts +1 -1
- package/dist/http/resume-legs.js +9 -5
- package/dist/http/route-table.d.ts +15 -4
- package/dist/http/route-table.js +6 -5
- package/dist/http/routes/agents-roster.d.ts +7 -0
- package/dist/http/routes/agents-roster.js +6 -2
- package/dist/http/routes/approvals-assistant.d.ts +48 -19
- package/dist/http/routes/approvals-assistant.js +117 -50
- package/dist/http/routes/capabilities.js +3 -2
- package/dist/http/routes/config-catalog.js +1 -0
- package/dist/http/routes/fleet.d.ts +3 -2
- package/dist/http/routes/images.js +0 -1
- package/dist/http/routes/memory-consolidation.d.ts +3 -2
- package/dist/http/routes/memory-policy.js +3 -3
- package/dist/http/routes/rules.js +2 -2
- package/dist/http/routes/runs.js +29 -10
- package/dist/http/routes/session-sync.js +14 -15
- package/dist/http/routes/sessions-list.js +3 -2
- package/dist/http/routes/sessions.js +49 -25
- package/dist/http/routes/shared-memory.js +2 -2
- package/dist/http/routes/side-query.js +8 -1
- package/dist/http/routes/tasks.js +22 -19
- package/dist/http/routes/trace-usage.d.ts +2 -0
- package/dist/http/routes/trace-usage.js +8 -13
- package/dist/http/send.d.ts +12 -0
- package/dist/http/send.js +12 -1
- package/dist/http/server.d.ts +30 -8
- package/dist/http/server.js +11 -5
- package/dist/http/sse-lifecycle.d.ts +1 -1
- package/dist/http/sse-log.d.ts +12 -9
- package/dist/http/sse-log.js +1 -8
- package/dist/http/sync-submit-body.d.ts +43 -0
- package/dist/http/sync-submit-body.js +4 -0
- package/dist/http/wire-types.d.ts +23 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/leader/merge.d.ts +8 -3
- package/dist/memory-incident-sink.js +2 -1
- package/dist/model-select.d.ts +9 -0
- package/dist/model-select.js +5 -1
- package/dist/observability/corrupt-read-seat.d.ts +151 -16
- package/dist/observability/corrupt-read-seat.js +158 -15
- package/dist/observability/fail-open.d.ts +17 -1
- package/dist/observability/fail-open.js +17 -1
- package/dist/observability/metrics.js +1 -1
- package/dist/observability/run-terminal.d.ts +60 -10
- package/dist/observability/run-terminal.js +119 -9
- package/dist/parked-decide.d.ts +8 -3
- package/dist/parked-decide.js +56 -20
- package/dist/parked-revive-surface.d.ts +14 -7
- package/dist/parked-revive-surface.js +5 -1
- package/dist/plugins/approval-ask-store-file.d.ts +4 -1
- package/dist/plugins/approval-ask-store-file.js +9 -12
- package/dist/plugins/approval-ask-store-sql.d.ts +4 -0
- package/dist/plugins/approval-ask-store-sql.js +4 -1
- package/dist/plugins/approval-exemption-store.d.ts +21 -1
- package/dist/plugins/approval-exemption-store.js +36 -7
- package/dist/plugins/approval-nonce-store.d.ts +16 -2
- package/dist/plugins/approval-nonce-store.js +17 -7
- package/dist/plugins/caching-session-store.d.ts +5 -0
- package/dist/plugins/caching-session-store.js +3 -0
- package/dist/plugins/file-resume-anchor-store.d.ts +7 -2
- package/dist/plugins/file-resume-anchor-store.js +19 -5
- package/dist/plugins/file-run-store.d.ts +21 -8
- package/dist/plugins/file-run-store.js +60 -38
- package/dist/plugins/image-bake-store-sql.d.ts +0 -2
- package/dist/plugins/image-bake-store-sql.js +0 -5
- package/dist/plugins/local-session-store.d.ts +22 -0
- package/dist/plugins/local-session-store.js +74 -11
- package/dist/plugins/memory-run-store.d.ts +7 -5
- package/dist/plugins/memory-run-store.js +11 -4
- package/dist/plugins/one-time-migrations.d.ts +38 -1
- package/dist/plugins/one-time-migrations.js +143 -15
- package/dist/plugins/permission-rule-store-file.d.ts +9 -2
- package/dist/plugins/permission-rule-store-file.js +37 -51
- package/dist/plugins/pg-session-storage.d.ts +3 -0
- package/dist/plugins/pg-session-storage.js +19 -10
- package/dist/plugins/retention-store-sql.js +1 -0
- package/dist/plugins/run-store-sql.d.ts +15 -6
- package/dist/plugins/run-store-sql.js +15 -13
- package/dist/plugins/schema-indexes.js +1 -0
- package/dist/plugins/send-file-ledger.d.ts +5 -1
- package/dist/plugins/send-file-ledger.js +11 -6
- package/dist/plugins/session-event-payload.d.ts +14 -4
- package/dist/plugins/session-event-payload.js +5 -2
- package/dist/plugins/session-policy-store-sql.js +18 -5
- package/dist/plugins/store-backend.d.ts +13 -7
- package/dist/plugins/store-backend.js +10 -6
- package/dist/plugins/store-contracts.d.ts +7 -2
- package/dist/plugins/tidb-pool.js +12 -5
- package/dist/plugins/tidb-session-store.d.ts +6 -1
- package/dist/plugins/tidb-session-store.js +17 -10
- package/dist/project-identity.d.ts +4 -6
- package/dist/read-face-posture.d.ts +21 -0
- package/dist/read-face-posture.js +9 -0
- package/dist/request-key-closure.js +1 -1
- package/dist/request-value-closure.d.ts +72 -3
- package/dist/request-value-closure.js +12 -4
- package/dist/roster-bound-http-status.d.ts +6 -0
- package/dist/roster-bound-http-status.js +6 -0
- package/dist/rules-consent.js +0 -11
- package/dist/run-local.js +22 -27
- package/dist/runs.d.ts +22 -2
- package/dist/runs.js +29 -7
- package/dist/sealed-key.d.ts +31 -4
- package/dist/sealed-key.js +9 -0
- package/dist/security.d.ts +5 -1
- package/dist/session-children-retire.d.ts +92 -0
- package/dist/session-children-retire.js +85 -0
- package/dist/session-error-wire.d.ts +58 -0
- package/dist/session-error-wire.js +38 -0
- package/dist/session-incarnation.d.ts +61 -0
- package/dist/session-incarnation.js +43 -0
- package/dist/session-policy-wire.d.ts +98 -12
- package/dist/session-policy-wire.js +71 -8
- package/dist/session-records-skipped-notice.d.ts +20 -0
- package/dist/session-records-skipped-notice.js +27 -0
- package/dist/tool-approval.d.ts +65 -0
- package/dist/tool-approval.js +36 -9
- package/dist/trace/core-keyset-guard.d.ts +8 -7
- package/dist/trace/engine-notice-wire.d.ts +3 -1
- package/dist/trace/engine-notice-wire.js +2 -0
- package/dist/trace/project.d.ts +11 -6
- package/dist/trace/project.js +12 -3
- package/dist/trace/redact.d.ts +7 -0
- package/dist/trace/redact.js +153 -61
- package/dist/trace/wire-projection-faces.d.ts +10 -3
- package/dist/trace/wire-projection-faces.js +8 -2
- package/package.json +2 -2
- package/dist/plugins/memory-session-policy-store.d.ts +0 -46
- package/dist/plugins/memory-session-policy-store.js +0 -52
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
-
import { AppendLog, ensureDir,
|
|
3
|
+
import { AppendLog, ensureDir, sanitizePathComponent } from "@sema-agent/core";
|
|
4
|
+
import { replayJsonlSkipping } from "../observability/corrupt-read-seat.js";
|
|
4
5
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
6
|
+
const ANCHOR_RECORD_SHAPE = {
|
|
7
|
+
what: "resume-anchor record without a string eventId / entryId",
|
|
8
|
+
read(record) {
|
|
9
|
+
const { eventId, entryId, owner } = record;
|
|
10
|
+
if (typeof eventId !== "string" || typeof entryId !== "string")
|
|
11
|
+
return undefined;
|
|
12
|
+
return { eventId, entryId, owner: typeof owner === "string" ? owner : null, ownerReadable: owner === undefined || owner === null || typeof owner === "string" };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
5
15
|
export class FileResumeAnchorStore {
|
|
6
16
|
dir;
|
|
7
17
|
bySession = new Map();
|
|
8
18
|
logs = new Map();
|
|
9
|
-
|
|
19
|
+
warn;
|
|
20
|
+
constructor(root, opts) {
|
|
10
21
|
this.dir = join(root, "resume-anchors");
|
|
22
|
+
this.warn = opts?.warn;
|
|
11
23
|
ensureDir(this.dir);
|
|
12
24
|
this.hydrate();
|
|
13
25
|
}
|
|
@@ -26,14 +38,16 @@ export class FileResumeAnchorStore {
|
|
|
26
38
|
if (!name.endsWith(".jsonl"))
|
|
27
39
|
continue;
|
|
28
40
|
const key = name.slice(0, -".jsonl".length);
|
|
29
|
-
const records =
|
|
41
|
+
const { records } = replayJsonlSkipping(join(this.dir, name), "resume-anchor", ANCHOR_RECORD_SHAPE, this.warn);
|
|
30
42
|
if (!records.length)
|
|
31
43
|
continue;
|
|
32
44
|
const m = this.bySession.get(key) ?? new Map();
|
|
33
45
|
for (const r of records) {
|
|
34
|
-
if (!r
|
|
46
|
+
if (!r.ownerReadable) {
|
|
47
|
+
m.delete(r.eventId);
|
|
35
48
|
continue;
|
|
36
|
-
|
|
49
|
+
}
|
|
50
|
+
m.set(r.eventId, { entryId: r.entryId, owner: r.owner });
|
|
37
51
|
}
|
|
38
52
|
this.bySession.set(key, m);
|
|
39
53
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type UsageRow } from "../usage-analytics.js";
|
|
2
2
|
import type { TaskStatus } from "@sema-agent/core";
|
|
3
|
+
import type { Logger } from "../observability/logger.js";
|
|
3
4
|
import { StoreBootRefusalError, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
|
|
4
5
|
import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
|
|
5
6
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
@@ -45,9 +46,12 @@ export declare class FileRunStore {
|
|
|
45
46
|
/** [ref] 件B(census 第 29 行终局):`RUN_STORE_STRICT_HYDRATE` 的**部署级**表态。
|
|
46
47
|
* `true` ⇒ hydrate 的 readdir 失败拒启;缺席/`false` ⇒ 件6 的记债腿,行为字节不变。 */
|
|
47
48
|
private readonly strictHydrate;
|
|
49
|
+
/** S-582:三个盘面坏行的腐读披露座(`LocalBackend` 接 `discloseWarn`;缺席 ⇒ 重放属主退回 `console.warn`)。 */
|
|
50
|
+
private readonly corruptReadWarn;
|
|
48
51
|
constructor(root: string, opts?: {
|
|
49
52
|
error?(msg: string, fields?: Record<string, unknown>): void;
|
|
50
53
|
strictHydrate?: boolean;
|
|
54
|
+
warn?: Logger["warn"];
|
|
51
55
|
});
|
|
52
56
|
private runDir;
|
|
53
57
|
private runJsonPath;
|
|
@@ -58,8 +62,9 @@ export declare class FileRunStore {
|
|
|
58
62
|
private toRecord;
|
|
59
63
|
/** Replay the on-disk registry/claims/event-logs into the in-memory index on boot. Each plane is torn-tail-/
|
|
60
64
|
* partial-write-safe (run.json via atomicWriteFile can never be half-written; the active file via writeThenLink is
|
|
61
|
-
* all-or-nothing; events.jsonl via
|
|
62
|
-
* skipped — that run is lost, not a poison that fails the boot
|
|
65
|
+
* all-or-nothing; events.jsonl via the JSONL replay drops a crash tail). A corrupt run.json (failed parse) is
|
|
66
|
+
* skipped — that run is lost, not a poison that fails the boot — and since S-582 the skip is disclosed and counted
|
|
67
|
+
* (`server.run-store.corrupt-record-skipped`), never silent. */
|
|
63
68
|
/**
|
|
64
69
|
* hydrate 期**逐文件**读盘的统一口([ref] 件B / codex 对抗复审 R4-[high])。
|
|
65
70
|
*
|
|
@@ -73,6 +78,12 @@ export declare class FileRunStore {
|
|
|
73
78
|
* 吞、也不改形),既有的「坏行跳过、不毒 boot」宽容契约与既有的抛法一起原封保留。
|
|
74
79
|
*/
|
|
75
80
|
private replay;
|
|
81
|
+
/**
|
|
82
|
+
* S-582 —— 本店**唯一**的盘上记录读口(hydrate 经 {@link replay} 包一层 strict 档,运行期读点直调它):
|
|
83
|
+
* 交形判据给重放属主,坏行按登记名 `run-store` 跳过 + 披露 + 计数。strict 档的射程是「文件读不出」,
|
|
84
|
+
* 不含「文件里有坏行」—— 后者在两档下都走这里的 F 臂(普查表第 96 行)。
|
|
85
|
+
*/
|
|
86
|
+
private readRecords;
|
|
76
87
|
/**
|
|
77
88
|
* strict 档下的**存在性判别**(codex R4-[high] 的前半):`existsSync` 对 ENOENT 与 EACCES 同答 false,
|
|
78
89
|
* 而这两句话在本门下必须分开 —— 前者是常态(`runs/` 下的非目录杂物、还没落盘的目录),后者是「我读
|
|
@@ -110,10 +121,12 @@ export declare class FileRunStore {
|
|
|
110
121
|
/** [ref] FIRST terminal writer wins(SQL 孪生同款正向 CAS on running/suspended/needs_review,
|
|
111
122
|
* [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
|
|
112
123
|
setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
|
|
113
|
-
/** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard).
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
/** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard).
|
|
125
|
+
* S-173:同时落 `result` —— 封口脱敏后的投影(`sealRunRowResult`,凭据不落;`null` = 没观察到 park 结果),SQL 孪生同注。 */
|
|
126
|
+
setSuspended(taskId: string, result: PersistedTaskResult | null): Promise<void>;
|
|
127
|
+
/** [ref] D-B: park NON-terminal `needs_review`, KEEP the claim. CAS on running/needs_review.
|
|
128
|
+
* S-173:同 {@link setSuspended} 落封口脱敏后的 `result`。 */
|
|
129
|
+
setNeedsReview(taskId: string, result: PersistedTaskResult | null): Promise<void>;
|
|
117
130
|
/** Durable F4 resume: flip suspended/needs_review → running (CAS) + reset cancel/preempt flags.
|
|
118
131
|
* [ref](codex R1 [medium]):认领 = 接管 `instanceId`(= 当前持有副本;SQL 孪生同注)。CAS 输不改。 */
|
|
119
132
|
markResuming(taskId: string, instanceId: string): Promise<boolean>;
|
|
@@ -192,7 +205,7 @@ export declare class FileRunStore {
|
|
|
192
205
|
/** [ref] wire the checkpoint probe (LocalBackend.checkpoint(), where checkpoint + run store share one root). */
|
|
193
206
|
setCheckpointProbe(probe: RunStoreCheckpointProbe): void;
|
|
194
207
|
/** [ref] shared tail of both suspended-run reapers: flip one parked row → failed('approval.expired') and
|
|
195
|
-
* PERSIST it — the file twin of the SQL UPDATE (`error`/`error_code` set directly, no result blob). */
|
|
208
|
+
* PERSIST it — the file twin of the SQL UPDATE (`error`/`error_code` set directly, no result blob; S-173: the park-time `result` is cleared, same as the SQL `result = NULL`). */
|
|
196
209
|
private failExpiredPark;
|
|
197
210
|
/**
|
|
198
211
|
* [ref] Durable F4 expiry — the file twin of TiDBRunStore.reapSuspended, driven by the injected checkpoint
|
|
@@ -294,7 +307,7 @@ export declare class FileRunStore {
|
|
|
294
307
|
/** Drop a run entirely (registry + event log + open fd + on-disk dir) — used by deleteBySession. */
|
|
295
308
|
private dropRun;
|
|
296
309
|
/**
|
|
297
|
-
* 🔴 **一次性迁移
|
|
310
|
+
* 🔴 **一次性迁移 M3 的 local 腿(窗口见 `one-time-migrations.ts` 登记表,随 M3 一起删)** —— 退役词旧拼法的账本改写,
|
|
298
311
|
* SQL 两孪生那条整表 UPDATE 的 local 形。逐条走**同一只**纯改写函数,所以三条车道改出来的字节一致。
|
|
299
312
|
*
|
|
300
313
|
* 为什么是 store 上的一只动词、而不是外面一个文件走查:本店的账本有**两份**在场 —— 盘上的 jsonl
|
|
@@ -1,21 +1,45 @@
|
|
|
1
|
-
import { redactErrorMessage, redactLedgerEventData,
|
|
1
|
+
import { redactErrorMessage, redactLedgerEventData, sealRunRowResult } from "../observability/run-terminal.js";
|
|
2
2
|
import { outwardPlane } from "../terminal.js";
|
|
3
3
|
import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
|
-
import { AppendLog, atomicWriteFile, ensureDir, isProcessLive, processFingerprint,
|
|
7
|
+
import { AppendLog, atomicWriteFile, ensureDir, isProcessLive, processFingerprint, sanitizePathComponent, writeThenLink } from "@sema-agent/core";
|
|
8
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
9
|
+
import { replayJsonlSkipping } from "../observability/corrupt-read-seat.js";
|
|
9
10
|
import { StoreBootRefusalError } from "./store-contracts.js";
|
|
10
11
|
import { notifyRunTerminal } from "../observability/run-terminal.js";
|
|
11
12
|
import { migrateRetiredAskOrigin } from "./one-time-migrations.js";
|
|
12
13
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
14
|
+
const isParseableDate = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
15
|
+
const RUN_ROW_SHAPE = {
|
|
16
|
+
what: "run.json record without a string taskId and parseable createdAt / updatedAt",
|
|
17
|
+
read(record) {
|
|
18
|
+
const r = record;
|
|
19
|
+
return typeof r.taskId === "string" && isParseableDate(r.createdAt) && isParseableDate(r.updatedAt) ? record : undefined;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const EVENT_RECORD_SHAPE = {
|
|
23
|
+
what: "run event record without a numeric seq",
|
|
24
|
+
read(record) {
|
|
25
|
+
return typeof record.seq === "number" ? record : undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const CLAIM_RECORD_SHAPE = {
|
|
29
|
+
what: "active-run claim record without a string sessionId / taskId",
|
|
30
|
+
read(record) {
|
|
31
|
+
const r = record;
|
|
32
|
+
return typeof r.sessionId === "string" && typeof r.taskId === "string" ? record : undefined;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
13
35
|
const CLAIM_TAKEOVER_ATTEMPTS = 3;
|
|
14
36
|
const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_.-]+$/;
|
|
15
37
|
const isSafePathComponent = (s) => s !== "" && s !== "." && s !== ".." && SAFE_PATH_COMPONENT.test(s);
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
function singleRecordOf(read) {
|
|
39
|
+
return read.skipped > 0 ? undefined : read.records[read.records.length - 1];
|
|
40
|
+
}
|
|
41
|
+
function readClaimRecord(c, sessionId) {
|
|
42
|
+
if (!c || c.sessionId !== sessionId)
|
|
19
43
|
return undefined;
|
|
20
44
|
if (!isSafePathComponent(c.taskId))
|
|
21
45
|
return undefined;
|
|
@@ -38,9 +62,11 @@ export class FileRunStore {
|
|
|
38
62
|
claimLocks = new Map();
|
|
39
63
|
logger;
|
|
40
64
|
strictHydrate;
|
|
65
|
+
corruptReadWarn;
|
|
41
66
|
constructor(root, opts) {
|
|
42
67
|
this.logger = opts;
|
|
43
68
|
this.strictHydrate = opts?.strictHydrate === true;
|
|
69
|
+
this.corruptReadWarn = opts?.warn;
|
|
44
70
|
this.runsDir = join(root, "runs");
|
|
45
71
|
this.activeDir = join(root, "runs", "active");
|
|
46
72
|
this.tmpDir = join(root, "tmp");
|
|
@@ -96,9 +122,9 @@ export class FileRunStore {
|
|
|
96
122
|
updatedAt: r.updatedAt.toISOString(),
|
|
97
123
|
};
|
|
98
124
|
}
|
|
99
|
-
replay(path) {
|
|
125
|
+
replay(path, shape) {
|
|
100
126
|
try {
|
|
101
|
-
return
|
|
127
|
+
return this.readRecords(path, shape);
|
|
102
128
|
}
|
|
103
129
|
catch (err) {
|
|
104
130
|
if (!this.strictHydrate)
|
|
@@ -110,6 +136,9 @@ export class FileRunStore {
|
|
|
110
136
|
"Fix the permissions/mount on that path, point LOCAL_DATA_ROOT at a readable dir, or unset RUN_STORE_STRICT_HYDRATE to boot anyway (the pre-existing behavior).");
|
|
111
137
|
}
|
|
112
138
|
}
|
|
139
|
+
readRecords(path, shape) {
|
|
140
|
+
return replayJsonlSkipping(path, "run-store", shape, this.corruptReadWarn);
|
|
141
|
+
}
|
|
113
142
|
refuseIfUnreadable(jsonPath, dir) {
|
|
114
143
|
try {
|
|
115
144
|
statSync(jsonPath);
|
|
@@ -159,21 +188,17 @@ export class FileRunStore {
|
|
|
159
188
|
this.refuseIfUnreadable(jsonPath, dir);
|
|
160
189
|
continue;
|
|
161
190
|
}
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
if (!disk || typeof disk.taskId !== "string")
|
|
165
|
-
continue;
|
|
166
|
-
if (typeof disk.createdAt !== "string" || Number.isNaN(Date.parse(disk.createdAt)) ||
|
|
167
|
-
typeof disk.updatedAt !== "string" || Number.isNaN(Date.parse(disk.updatedAt)))
|
|
191
|
+
const disk = singleRecordOf(this.replay(jsonPath, RUN_ROW_SHAPE));
|
|
192
|
+
if (!disk)
|
|
168
193
|
continue;
|
|
169
194
|
this.runs.set(disk.taskId, this.toRow(disk));
|
|
170
195
|
const eventsPath = join(dir, "events.jsonl");
|
|
171
|
-
const recs = this.replay(eventsPath);
|
|
196
|
+
const recs = this.replay(eventsPath, EVENT_RECORD_SHAPE).records;
|
|
172
197
|
if (recs.length) {
|
|
173
198
|
const list = [];
|
|
174
199
|
const seen = new Set();
|
|
175
200
|
for (const e of recs) {
|
|
176
|
-
if (
|
|
201
|
+
if (seen.has(e.seq))
|
|
177
202
|
continue;
|
|
178
203
|
seen.add(e.seq);
|
|
179
204
|
list.push({ seq: e.seq, type: String(e.type), data: e.data ?? null, ts: String(e.ts) });
|
|
@@ -215,9 +240,8 @@ export class FileRunStore {
|
|
|
215
240
|
}
|
|
216
241
|
for (const sanitized of claims) {
|
|
217
242
|
const path = join(this.activeDir, sanitized);
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
if (claim && typeof claim.sessionId === "string" && typeof claim.taskId === "string" && isSafePathComponent(claim.taskId)) {
|
|
243
|
+
const claim = singleRecordOf(this.replay(path, CLAIM_RECORD_SHAPE));
|
|
244
|
+
if (claim && isSafePathComponent(claim.taskId)) {
|
|
221
245
|
this.active.set(claim.sessionId, claim.taskId);
|
|
222
246
|
continue;
|
|
223
247
|
}
|
|
@@ -355,7 +379,7 @@ export class FileRunStore {
|
|
|
355
379
|
.map((e) => ({ ...e, data: e.data == null ? null : structuredClone(e.data) }));
|
|
356
380
|
}
|
|
357
381
|
async setTerminal(taskId, status, result, error) {
|
|
358
|
-
result =
|
|
382
|
+
result = sealRunRowResult(result);
|
|
359
383
|
error = error === null ? null : (redactErrorMessage(error) ?? null);
|
|
360
384
|
const r = this.runs.get(taskId);
|
|
361
385
|
if (!r) {
|
|
@@ -391,18 +415,22 @@ export class FileRunStore {
|
|
|
391
415
|
if (pending)
|
|
392
416
|
notifyRunTerminal(pending);
|
|
393
417
|
}
|
|
394
|
-
async setSuspended(taskId) {
|
|
418
|
+
async setSuspended(taskId, result) {
|
|
419
|
+
const sealed = sealRunRowResult(result);
|
|
395
420
|
const r = this.runs.get(taskId);
|
|
396
421
|
if (r && (r.status === "running" || r.status === "suspended")) {
|
|
397
422
|
r.status = "suspended";
|
|
423
|
+
r.result = sealed;
|
|
398
424
|
r.updatedAt = new Date();
|
|
399
425
|
this.persist(r);
|
|
400
426
|
}
|
|
401
427
|
}
|
|
402
|
-
async setNeedsReview(taskId) {
|
|
428
|
+
async setNeedsReview(taskId, result) {
|
|
429
|
+
const sealed = sealRunRowResult(result);
|
|
403
430
|
const r = this.runs.get(taskId);
|
|
404
431
|
if (r && (r.status === "running" || r.status === "needs_review")) {
|
|
405
432
|
r.status = "needs_review";
|
|
433
|
+
r.result = sealed;
|
|
406
434
|
r.updatedAt = new Date();
|
|
407
435
|
this.persist(r);
|
|
408
436
|
}
|
|
@@ -412,6 +440,7 @@ export class FileRunStore {
|
|
|
412
440
|
if (!r || (r.status !== "suspended" && r.status !== "needs_review"))
|
|
413
441
|
return false;
|
|
414
442
|
r.status = "running";
|
|
443
|
+
r.result = null;
|
|
415
444
|
r.instanceId = instanceId;
|
|
416
445
|
r.cancelRequested = false;
|
|
417
446
|
r.preemptRequested = false;
|
|
@@ -551,6 +580,7 @@ export class FileRunStore {
|
|
|
551
580
|
for (const r of this.runs.values()) {
|
|
552
581
|
if (r.status === "running" && pred(r)) {
|
|
553
582
|
r.status = "failed";
|
|
583
|
+
r.result = null;
|
|
554
584
|
r.error = reason;
|
|
555
585
|
r.updatedAt = new Date();
|
|
556
586
|
this.persist(r);
|
|
@@ -572,6 +602,7 @@ export class FileRunStore {
|
|
|
572
602
|
}
|
|
573
603
|
for (const r of orphans) {
|
|
574
604
|
r.status = "failed";
|
|
605
|
+
r.result = null;
|
|
575
606
|
r.error = "run orphaned by engine restart";
|
|
576
607
|
r.updatedAt = new Date();
|
|
577
608
|
this.persist(r);
|
|
@@ -584,6 +615,7 @@ export class FileRunStore {
|
|
|
584
615
|
}
|
|
585
616
|
failExpiredPark(r) {
|
|
586
617
|
r.status = "failed";
|
|
618
|
+
r.result = null;
|
|
587
619
|
r.error = "approval expired before decision";
|
|
588
620
|
r.errorCode = "approval.expired";
|
|
589
621
|
r.updatedAt = new Date();
|
|
@@ -642,14 +674,8 @@ export class FileRunStore {
|
|
|
642
674
|
const path = this.runJsonPath(taskId);
|
|
643
675
|
if (!existsSync(path))
|
|
644
676
|
return undefined;
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
if (!disk || typeof disk.taskId !== "string")
|
|
648
|
-
return undefined;
|
|
649
|
-
if (typeof disk.createdAt !== "string" || Number.isNaN(Date.parse(disk.createdAt)) ||
|
|
650
|
-
typeof disk.updatedAt !== "string" || Number.isNaN(Date.parse(disk.updatedAt)))
|
|
651
|
-
return undefined;
|
|
652
|
-
return this.toRow(disk);
|
|
677
|
+
const disk = singleRecordOf(this.readRecords(path, RUN_ROW_SHAPE));
|
|
678
|
+
return disk === undefined ? undefined : this.toRow(disk);
|
|
653
679
|
}
|
|
654
680
|
newestNonTerminalRowOnDisk(sessionId) {
|
|
655
681
|
const names = readdirSync(this.runsDir);
|
|
@@ -660,12 +686,8 @@ export class FileRunStore {
|
|
|
660
686
|
const path = join(this.runsDir, name, "run.json");
|
|
661
687
|
if (!existsSync(path))
|
|
662
688
|
continue;
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
if (!disk || typeof disk.taskId !== "string" || disk.sessionId !== sessionId)
|
|
666
|
-
continue;
|
|
667
|
-
if (typeof disk.createdAt !== "string" || Number.isNaN(Date.parse(disk.createdAt)) ||
|
|
668
|
-
typeof disk.updatedAt !== "string" || Number.isNaN(Date.parse(disk.updatedAt)))
|
|
689
|
+
const disk = singleRecordOf(this.readRecords(path, RUN_ROW_SHAPE));
|
|
690
|
+
if (!disk || disk.sessionId !== sessionId)
|
|
669
691
|
continue;
|
|
670
692
|
if (disk.status !== "running" && disk.status !== "suspended" && disk.status !== "needs_review")
|
|
671
693
|
continue;
|
|
@@ -705,7 +727,7 @@ export class FileRunStore {
|
|
|
705
727
|
catch (e) {
|
|
706
728
|
if (e.code !== "EEXIST")
|
|
707
729
|
throw e;
|
|
708
|
-
const claim = readClaimRecord(
|
|
730
|
+
const claim = readClaimRecord(singleRecordOf(this.readRecords(this.activePath(sessionId), CLAIM_RECORD_SHAPE)), sessionId);
|
|
709
731
|
const holder = claim?.taskId ?? this.active.get(sessionId) ?? "";
|
|
710
732
|
const verdict = await this.judgeClaim(sessionId, claim, holder);
|
|
711
733
|
if (verdict.kind === "live")
|
|
@@ -721,7 +743,7 @@ export class FileRunStore {
|
|
|
721
743
|
if (e.code !== "EEXIST")
|
|
722
744
|
throw e;
|
|
723
745
|
}
|
|
724
|
-
const finalClaim = readClaimRecord(
|
|
746
|
+
const finalClaim = readClaimRecord(singleRecordOf(this.readRecords(this.activePath(sessionId), CLAIM_RECORD_SHAPE)), sessionId);
|
|
725
747
|
return { ok: false, activeTaskId: finalClaim?.taskId ?? this.active.get(sessionId) ?? "" };
|
|
726
748
|
}
|
|
727
749
|
async judgeClaim(sessionId, claim, holderTaskId) {
|
|
@@ -789,7 +811,7 @@ export class FileRunStore {
|
|
|
789
811
|
return { kind: "stale", reason: `run orphaned by engine restart (claim held by ${who}, which is gone)`, snapshot };
|
|
790
812
|
}
|
|
791
813
|
takeOverClaim(sessionId, reason, snapshot) {
|
|
792
|
-
const nowClaim = readClaimRecord(
|
|
814
|
+
const nowClaim = readClaimRecord(singleRecordOf(this.readRecords(this.activePath(sessionId), CLAIM_RECORD_SHAPE)), sessionId);
|
|
793
815
|
if (snapshot.torn) {
|
|
794
816
|
if (nowClaim !== undefined)
|
|
795
817
|
return false;
|
|
@@ -122,8 +122,6 @@ export declare class SqlImageBake {
|
|
|
122
122
|
private lineOrdExists;
|
|
123
123
|
/** Highest event seq for a bake (0 if none) — the ++seq allocator + the resume cursor head. */
|
|
124
124
|
maxSeq(bakeId: string): Promise<number>;
|
|
125
|
-
/** Earliest retained event seq (0 if none) → the SSE 416 boundary (a resume below this was evicted, P2.9). */
|
|
126
|
-
retainedFrom(bakeId: string): Promise<number>;
|
|
127
125
|
/** Events with seq strictly greater than `afterSeq` (0 for the whole stream), ascending. */
|
|
128
126
|
getEvents(bakeId: string, afterSeq: number): Promise<BakeEvent[]>;
|
|
129
127
|
/**
|
|
@@ -379,11 +379,6 @@ export class SqlImageBake {
|
|
|
379
379
|
const m = rows[0]?.m;
|
|
380
380
|
return m == null ? 0 : Number(m);
|
|
381
381
|
}
|
|
382
|
-
async retainedFrom(bakeId) {
|
|
383
|
-
const { rows } = await this.db.query(this.q("SELECT MIN(seq) AS m FROM image_bake_event WHERE bake_id = ?", "SELECT MIN(seq) AS m FROM image_bake_event WHERE bake_id = $1"), [bakeId]);
|
|
384
|
-
const m = rows[0]?.m;
|
|
385
|
-
return m == null ? 0 : Number(m);
|
|
386
|
-
}
|
|
387
382
|
async getEvents(bakeId, afterSeq) {
|
|
388
383
|
const { rows } = await this.db.query(this.q(`SELECT seq, line_ord, kind, level, ${jsonColumnText("tidb", "data")}, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, line_ord, kind, level, ${jsonColumnText("pg", "data")}, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC`), [bakeId, afterSeq]);
|
|
389
384
|
return rows.map((r) => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core";
|
|
2
2
|
import type { SessionSummary } from "./store-contracts.js";
|
|
3
|
+
import { type SessionFace } from "../session-incarnation.js";
|
|
3
4
|
import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
|
|
4
5
|
import { type StagingHandle } from "../session-sync-kernel.js";
|
|
5
6
|
export declare class LocalSessionStore implements SessionStore {
|
|
@@ -59,11 +60,30 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
59
60
|
* run selected, encoded like the SQL twins' `session_meta.instruction_source` column ('' = none; missing key = never
|
|
60
61
|
* recorded). Its own file for the same reason titles have theirs: one small map per concern, no format coupling. */
|
|
61
62
|
private readonly instructionSources;
|
|
63
|
+
/** S-790 / S-506([ref] §2):会话化身(`session-incarnations.json`,`{ [sessionId]: token }`)。SQL 孪生的
|
|
64
|
+
* `session_meta.incarnation` 列在本店的对位。🔴 **必须落盘**(不能像 `owners` 那样只活在内存):化身的口径是「一位住客一生
|
|
65
|
+
* 稳定」,而记忆引擎按它绑定会话级记录(opt-out / 污染标记)—— 重启后换一只值 = 同一位住客的 opt-out 不再绑它 = 静默恢复采集。
|
|
66
|
+
* 设计稿原说「存进属主边车」,但本店**没有**落盘的属主边车(`owners` 是纯内存 Map,S3 裁定),所以落在同一族的第四只边档
|
|
67
|
+
* (同一只 `SidecarStringMap`:同写链、失败回滚)。铸点只在新建会话;盘上旧会话没有这一格 ⇒ 第一次读补铸并落盘。 */
|
|
68
|
+
private readonly incarnations;
|
|
62
69
|
/** @param repo the core SessionRepo this store projects into the §0.5 SessionStore shape.
|
|
63
70
|
* @param dataDir the SAME data dir the file-backed repo persists under (LocalBackend passes `fileBackend.root`).
|
|
64
71
|
* When given, lastActivity is persisted to `<dataDir>/session-last-activity.json` so it survives a
|
|
65
72
|
* restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives). */
|
|
66
73
|
constructor(repo: SessionRepo, dataDir?: string);
|
|
74
|
+
/**
|
|
75
|
+
* S-790:把 `sessionId` 的化身写成 `next(before)` 的结果并**落盘**(写链里判、写、失败回滚 —— 与 instruction-source 同一只
|
|
76
|
+
* `commit`)。`next` 返回 `undefined` = 不改。落不了盘 ⇒ 抛(化身是正确性事实,不做「内存里先记着」的降级:一只没落盘的化身
|
|
77
|
+
* 重启后会被换掉,那就是另一位住客)。
|
|
78
|
+
*/
|
|
79
|
+
private writeIncarnation;
|
|
80
|
+
/** S-790:新建会话 ⇒ 铸一只新的(覆盖同 id 上任何残留 —— 新建行就是新住客)。 */
|
|
81
|
+
private mintIncarnation;
|
|
82
|
+
/** S-790:已存在的会话缺一只化身(盘上 7.104.0 之前的会话)⇒ 补铸;已有的一个字节不动。 */
|
|
83
|
+
private ensureIncarnation;
|
|
84
|
+
/** S-790:属主 + 化身一次读(SQL 孪生同义;`undefined` = 无此会话)。盘上有会话而边档里没有它的化身 ⇒ 补铸并落盘后再答;
|
|
85
|
+
* 读的过程中会话被删 / 换了主 ⇒ `undefined`(与 {@link getHead} 同一条「读间被删」判据)。 */
|
|
86
|
+
sessionFaceOf(sessionId: string): Promise<SessionFace | undefined>;
|
|
67
87
|
/** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is
|
|
68
88
|
* no "none" (register-lag) dimension here — two states only. */
|
|
69
89
|
probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
|
|
@@ -222,6 +242,8 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
222
242
|
* importing principal (re-stamped into the service-side owner map; core's local repo has no owner column, §9).
|
|
223
243
|
* `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`). */
|
|
224
244
|
importEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise<void>;
|
|
245
|
+
/** importEntries / replaceEntries 的共用写半场:导日志 + 盖属主 + 落化身(新建铸的那只,或换装沿用的那只)。 */
|
|
246
|
+
private importEntriesAs;
|
|
225
247
|
/** 2c session-sync (§7/§8) — IDEMPOTENT REPLACE. The local twin's replace is DELETE-then-IMPORT: core's
|
|
226
248
|
* InMemorySessionRepo / FileSessionRepo expose no single replace primitive, but the local backend is single-writer
|
|
227
249
|
* with NO compaction floor / cross-instance race (a process-local in-memory map / a single-process file repo), so
|
|
@@ -3,9 +3,11 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { SessionError, validateEntriesForImport } from "@sema-agent/core";
|
|
4
4
|
import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
|
|
5
5
|
import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
|
|
6
|
-
import { decodeJsonColumn, readStringRecordColumn } from "./sql-json-column.js";
|
|
6
|
+
import { decodeJsonColumn, readStringRecordColumn, SqlJsonColumnError } from "./sql-json-column.js";
|
|
7
7
|
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
8
|
+
import { carriedIncarnation, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
|
|
8
9
|
import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
|
|
10
|
+
import { openSessionForItself } from "../session-records-skipped-notice.js";
|
|
9
11
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
|
|
10
12
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
11
13
|
const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
|
|
@@ -28,19 +30,21 @@ function userText(entry) {
|
|
|
28
30
|
}
|
|
29
31
|
return null;
|
|
30
32
|
}
|
|
31
|
-
const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline" };
|
|
33
|
+
const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline", incarnation: "session-incarnation (the write that needed it failed loudly)" };
|
|
32
34
|
const SIDECAR_MAP_STATE_BY_PATH = new Map();
|
|
33
35
|
class SidecarStringMap {
|
|
34
36
|
path;
|
|
35
37
|
label;
|
|
36
38
|
onUnreadable;
|
|
37
39
|
onWriteFailed;
|
|
40
|
+
strict;
|
|
38
41
|
own = { entries: new Map(), writeChain: Promise.resolve() };
|
|
39
|
-
constructor(path, label, onUnreadable, onWriteFailed) {
|
|
42
|
+
constructor(path, label, onUnreadable, onWriteFailed, strict = false) {
|
|
40
43
|
this.path = path;
|
|
41
44
|
this.label = label;
|
|
42
45
|
this.onUnreadable = onUnreadable;
|
|
43
46
|
this.onWriteFailed = onWriteFailed;
|
|
47
|
+
this.strict = strict;
|
|
44
48
|
if (path && !SIDECAR_MAP_STATE_BY_PATH.has(path))
|
|
45
49
|
SIDECAR_MAP_STATE_BY_PATH.set(path, { entries: new Map(), writeChain: Promise.resolve() });
|
|
46
50
|
}
|
|
@@ -62,8 +66,16 @@ class SidecarStringMap {
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
catch (e) {
|
|
65
|
-
if (e?.code
|
|
69
|
+
if (e?.code === "ENOENT")
|
|
70
|
+
return;
|
|
71
|
+
if (!this.strict) {
|
|
66
72
|
this.onUnreadable(path, e);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const kind = e instanceof SqlJsonColumnError ? e.problem : (e?.code ?? "unreadable");
|
|
76
|
+
throw new Error(`${this.label} is unreadable (${kind}) — refusing to read or mint session incarnations until it is repaired: a replacement ` +
|
|
77
|
+
`token for an existing session would make it a different occupant (its memory opt-out would stop binding it), and a write ` +
|
|
78
|
+
`would overwrite the unreadable bytes. The file is left in place at ${path}.`);
|
|
67
79
|
}
|
|
68
80
|
})();
|
|
69
81
|
}
|
|
@@ -112,12 +124,48 @@ export class LocalSessionStore {
|
|
|
112
124
|
sidecarPath;
|
|
113
125
|
titles;
|
|
114
126
|
instructionSources;
|
|
127
|
+
incarnations;
|
|
115
128
|
constructor(repo, dataDir) {
|
|
116
129
|
this.repo = repo;
|
|
117
130
|
this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
|
|
118
|
-
const sidecar = (file, which) => new SidecarStringMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which));
|
|
131
|
+
const sidecar = (file, which, strict = false) => new SidecarStringMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which), strict);
|
|
119
132
|
this.titles = sidecar("session-titles.json", "title");
|
|
120
133
|
this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source");
|
|
134
|
+
this.incarnations = sidecar("session-incarnations.json", "incarnation", true);
|
|
135
|
+
}
|
|
136
|
+
async writeIncarnation(sessionId, next) {
|
|
137
|
+
await this.incarnations.ensureLoaded();
|
|
138
|
+
const committed = await this.incarnations.commit((entries) => {
|
|
139
|
+
const before = entries.get(sessionId);
|
|
140
|
+
const value = next(before);
|
|
141
|
+
if (value === undefined || value === before)
|
|
142
|
+
return undefined;
|
|
143
|
+
entries.set(sessionId, value);
|
|
144
|
+
return () => {
|
|
145
|
+
if (before === undefined)
|
|
146
|
+
entries.delete(sessionId);
|
|
147
|
+
else
|
|
148
|
+
entries.set(sessionId, before);
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
if (!committed)
|
|
152
|
+
throw new SessionError("storage", `LocalSessionStore: session-incarnation sidecar write failed for session ${sessionId} — nothing was recorded`);
|
|
153
|
+
}
|
|
154
|
+
mintIncarnation(sessionId) {
|
|
155
|
+
return this.writeIncarnation(sessionId, () => mintSessionIncarnation());
|
|
156
|
+
}
|
|
157
|
+
ensureIncarnation(sessionId) {
|
|
158
|
+
return this.writeIncarnation(sessionId, (before) => (before === undefined ? mintSessionIncarnation() : undefined));
|
|
159
|
+
}
|
|
160
|
+
async sessionFaceOf(sessionId) {
|
|
161
|
+
const owner = await this.ownerOf(sessionId);
|
|
162
|
+
if (owner === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
await this.ensureIncarnation(sessionId);
|
|
165
|
+
const incarnation = this.incarnations.state.entries.get(sessionId);
|
|
166
|
+
if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
|
|
167
|
+
return undefined;
|
|
168
|
+
return sessionFaceFromRow(sessionId, owner, incarnation);
|
|
121
169
|
}
|
|
122
170
|
async probeTitle(sessionId) {
|
|
123
171
|
await this.titles.ensureLoaded();
|
|
@@ -223,9 +271,11 @@ export class LocalSessionStore {
|
|
|
223
271
|
}
|
|
224
272
|
async acquire(sessionId, opts) {
|
|
225
273
|
if (!sessionId) {
|
|
274
|
+
await this.incarnations.ensureLoaded();
|
|
226
275
|
const session = await this.repo.create({});
|
|
227
276
|
const id = (await session.getMetadata()).id;
|
|
228
277
|
this.owners.set(id, null);
|
|
278
|
+
await this.mintIncarnation(id);
|
|
229
279
|
return { session: session, sessionId: id };
|
|
230
280
|
}
|
|
231
281
|
if (opts?.requireExisting)
|
|
@@ -239,7 +289,7 @@ export class LocalSessionStore {
|
|
|
239
289
|
}
|
|
240
290
|
async load(sessionId, requireExisting = false) {
|
|
241
291
|
try {
|
|
242
|
-
const session = await this.repo.open({ id: sessionId, createdAt: "" });
|
|
292
|
+
const session = await openSessionForItself(sessionId, () => this.repo.open({ id: sessionId, createdAt: "" }));
|
|
243
293
|
if (!this.owners.has(sessionId))
|
|
244
294
|
this.owners.set(sessionId, null);
|
|
245
295
|
return { session: session, sessionId };
|
|
@@ -249,7 +299,9 @@ export class LocalSessionStore {
|
|
|
249
299
|
throw e;
|
|
250
300
|
if (requireExisting)
|
|
251
301
|
throw e;
|
|
302
|
+
await this.incarnations.ensureLoaded();
|
|
252
303
|
const session = await this.repo.create({ id: sessionId });
|
|
304
|
+
await this.mintIncarnation(sessionId);
|
|
253
305
|
if (!this.owners.has(sessionId))
|
|
254
306
|
this.owners.set(sessionId, null);
|
|
255
307
|
return { session: session, sessionId };
|
|
@@ -258,15 +310,17 @@ export class LocalSessionStore {
|
|
|
258
310
|
async register(sessionId, owner) {
|
|
259
311
|
if (this.owners.has(sessionId))
|
|
260
312
|
return;
|
|
313
|
+
await this.incarnations.ensureLoaded();
|
|
261
314
|
this.owners.set(sessionId, owner);
|
|
262
315
|
try {
|
|
263
316
|
await this.repo.open({ id: sessionId, createdAt: "" });
|
|
317
|
+
await this.ensureIncarnation(sessionId);
|
|
264
318
|
}
|
|
265
319
|
catch (e) {
|
|
266
|
-
if (isNotFound(e))
|
|
267
|
-
await this.repo.create({ id: sessionId });
|
|
268
|
-
else
|
|
320
|
+
if (!isNotFound(e))
|
|
269
321
|
throw e;
|
|
322
|
+
await this.repo.create({ id: sessionId });
|
|
323
|
+
await this.mintIncarnation(sessionId);
|
|
270
324
|
}
|
|
271
325
|
}
|
|
272
326
|
async ownerOf(sessionId) {
|
|
@@ -369,10 +423,12 @@ export class LocalSessionStore {
|
|
|
369
423
|
}
|
|
370
424
|
}
|
|
371
425
|
async fork(sourceId, owner) {
|
|
426
|
+
await this.incarnations.ensureLoaded();
|
|
372
427
|
try {
|
|
373
428
|
const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
|
|
374
429
|
const id = (await session.getMetadata()).id;
|
|
375
430
|
this.owners.set(id, owner);
|
|
431
|
+
await this.mintIncarnation(id);
|
|
376
432
|
return id;
|
|
377
433
|
}
|
|
378
434
|
catch (e) {
|
|
@@ -410,10 +466,15 @@ export class LocalSessionStore {
|
|
|
410
466
|
return gen();
|
|
411
467
|
}
|
|
412
468
|
async importEntries(sessionId, owner, entries) {
|
|
469
|
+
await this.importEntriesAs(sessionId, owner, entries, mintSessionIncarnation());
|
|
470
|
+
}
|
|
471
|
+
async importEntriesAs(sessionId, owner, entries, incarnation) {
|
|
413
472
|
if (!this.repo.importEntries)
|
|
414
473
|
throw new SessionError("storage", "session import is not supported by this local backend");
|
|
474
|
+
await this.incarnations.ensureLoaded();
|
|
415
475
|
await this.repo.importEntries(sessionId, owner ?? undefined, entries);
|
|
416
476
|
this.owners.set(sessionId, owner);
|
|
477
|
+
await this.writeIncarnation(sessionId, () => incarnation);
|
|
417
478
|
}
|
|
418
479
|
async replaceEntries(sessionId, owner, entries) {
|
|
419
480
|
if (!this.repo.importEntries)
|
|
@@ -421,8 +482,10 @@ export class LocalSessionStore {
|
|
|
421
482
|
if (!this.repo.delete)
|
|
422
483
|
throw new SessionError("storage", "session replace is not supported by this local backend (no delete seam)");
|
|
423
484
|
validateEntriesForImport(entries);
|
|
485
|
+
await this.incarnations.ensureLoaded();
|
|
486
|
+
const prior = this.owners.has(sessionId) ? { owner: this.owners.get(sessionId) ?? null, incarnation: this.incarnations.state.entries.get(sessionId) } : undefined;
|
|
424
487
|
await this.deleteSession(sessionId, owner);
|
|
425
|
-
await this.
|
|
488
|
+
await this.importEntriesAs(sessionId, owner, entries, carriedIncarnation(prior, owner));
|
|
426
489
|
}
|
|
427
490
|
beginImportStaging(realSessionId, token) {
|
|
428
491
|
const h = new LocalStagingHandle(this, realSessionId, stagingIdFor(realSessionId, token));
|
|
@@ -449,7 +512,7 @@ export class LocalSessionStore {
|
|
|
449
512
|
this.owners.delete(sessionId);
|
|
450
513
|
this.lastActivity.delete(sessionId);
|
|
451
514
|
this.lastTaskRun.delete(sessionId);
|
|
452
|
-
for (const map of [this.titles, this.instructionSources]) {
|
|
515
|
+
for (const map of [this.titles, this.instructionSources, this.incarnations]) {
|
|
453
516
|
await map.ensureLoaded();
|
|
454
517
|
if (map.state.entries.delete(sessionId))
|
|
455
518
|
await map.persist();
|