@cursor/july 0.1.76 → 0.1.78
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/dist/channels/origin/origin-webhook.d.ts +23 -2
- package/dist/channels/origin/origin-webhook.d.ts.map +1 -1
- package/dist/channels/origin/origin-webhook.js +82 -20
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.b8bhB2SV.js → app.BsA7t7rS.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.Dcs6boxD.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.lLSeIyWw.js → VPLocalSearchBox.B1hHhktM.js} +1 -1
- package/dist/docs/assets/chunks/{theme.C_owesa-.js → theme.DLpLSsQh.js} +2 -2
- package/dist/docs/assets/{storage.md.D8NQ389l.js → storage.md.ks1u64_R.js} +3 -3
- package/dist/docs/assets/storage.md.ks1u64_R.lean.js +1 -0
- package/dist/docs/building-with-agents.html +3 -3
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +3 -3
- package/dist/docs/evals.html +3 -3
- package/dist/docs/example-agents/approval-buddy.html +3 -3
- package/dist/docs/example-agents/benny.html +3 -3
- package/dist/docs/example-agents/bugbot.html +3 -3
- package/dist/docs/example-agents/codebase-wiki.html +3 -3
- package/dist/docs/example-agents/codeowners-review.html +3 -3
- package/dist/docs/example-agents/concierge.html +3 -3
- package/dist/docs/example-agents/fsd.html +3 -3
- package/dist/docs/example-agents/index.html +3 -3
- package/dist/docs/example-agents/knowledge-base.html +3 -3
- package/dist/docs/example-agents/oncall.html +3 -3
- package/dist/docs/example-agents/security-reviewer.html +3 -3
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +3 -3
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +3 -3
- package/dist/docs/guides/convert-automation.html +3 -3
- package/dist/docs/guides/github.html +3 -3
- package/dist/docs/guides/human-in-the-loop.html +3 -3
- package/dist/docs/guides/mcp-oauth.html +3 -3
- package/dist/docs/guides/opentelemetry.html +3 -3
- package/dist/docs/guides/slack.html +3 -3
- package/dist/docs/guides/webhooks.html +3 -3
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +3 -3
- package/dist/docs/quickstart.html +3 -3
- package/dist/docs/reference/agent-config.html +3 -3
- package/dist/docs/reference/artifacts.html +3 -3
- package/dist/docs/reference/channels.html +3 -3
- package/dist/docs/reference/cli.html +3 -3
- package/dist/docs/reference/connections.html +3 -3
- package/dist/docs/reference/hooks.html +3 -3
- package/dist/docs/reference/http-api.html +3 -3
- package/dist/docs/reference/instructions.html +3 -3
- package/dist/docs/reference/playground.html +3 -3
- package/dist/docs/reference/project-layout.html +3 -3
- package/dist/docs/reference/prompt.html +3 -3
- package/dist/docs/reference/schedules.html +3 -3
- package/dist/docs/reference/sessions.html +3 -3
- package/dist/docs/reference/skills.html +3 -3
- package/dist/docs/reference/subagents.html +3 -3
- package/dist/docs/reference/tools.html +3 -3
- package/dist/docs/scaffolding-agents.html +3 -3
- package/dist/docs/storage.html +7 -7
- package/dist/docs/templates/agentic-owners.html +3 -3
- package/dist/docs/templates/demo.html +3 -3
- package/dist/docs/templates/pr-autofixer.html +3 -3
- package/dist/docs/templates/security-reviewer.html +3 -3
- package/dist/docs/templates/triage.html +3 -3
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/internal/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +3 -21
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +0 -1
- package/dist/internal/session-engine.d.ts +1 -60
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +10 -109
- package/dist/kv/memory-kv.d.ts +1 -10
- package/dist/kv/memory-kv.d.ts.map +1 -1
- package/dist/kv/memory-kv.js +1 -10
- package/dist/kv.d.ts +2 -3
- package/dist/kv.d.ts.map +1 -1
- package/dist/kv.js +2 -3
- package/dist/playground/assets/{index-Bfwz2yLN.css → index-DJzcIeGt.css} +1 -1
- package/dist/playground/assets/{index-BWqjJcRo.js → index-K3Sxyx0I.js} +48 -45
- package/dist/playground/index.html +2 -2
- package/dist/storage-backends/cursor-hosted.js +3 -3
- package/dist/storage-protocol.d.ts +3 -8
- package/dist/storage-protocol.d.ts.map +1 -1
- package/dist/storage-protocol.js +4 -10
- package/dist/storage.d.ts +5 -11
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +3 -4
- package/dist/types.d.ts +5 -34
- package/dist/types.d.ts.map +1 -1
- package/docs/storage.md +11 -44
- package/package.json +1 -8
- package/src/channels/origin/origin-webhook.ts +111 -25
- package/src/index.ts +0 -6
- package/src/internal/discovery.ts +3 -27
- package/src/internal/distribution.ts +0 -1
- package/src/internal/session-engine.ts +1 -168
- package/src/kv/memory-kv.ts +1 -10
- package/src/kv.ts +2 -3
- package/src/storage-backends/cursor-hosted.ts +3 -3
- package/src/storage-protocol.ts +4 -10
- package/src/storage.ts +6 -12
- package/src/types.ts +5 -35
- package/dist/docs/assets/chunks/@localSearchIndexroot.DGZeXVqn.js +0 -1
- package/dist/docs/assets/storage.md.D8NQ389l.lean.js +0 -1
- package/dist/internal/durable-session-store.d.ts +0 -60
- package/dist/internal/durable-session-store.d.ts.map +0 -1
- package/dist/internal/durable-session-store.js +0 -131
- package/dist/internal/ledger-serving.d.ts +0 -169
- package/dist/internal/ledger-serving.d.ts.map +0 -1
- package/dist/internal/ledger-serving.js +0 -393
- package/dist/ledger/janitor.d.ts +0 -60
- package/dist/ledger/janitor.d.ts.map +0 -1
- package/dist/ledger/janitor.js +0 -137
- package/dist/ledger/session-ledger.d.ts +0 -293
- package/dist/ledger/session-ledger.d.ts.map +0 -1
- package/dist/ledger/session-ledger.js +0 -614
- package/dist/ledger.d.ts +0 -12
- package/dist/ledger.d.ts.map +0 -1
- package/dist/ledger.js +0 -9
- package/src/internal/durable-session-store.ts +0 -134
- package/src/internal/ledger-serving.ts +0 -531
- package/src/ledger/janitor.ts +0 -193
- package/src/ledger/session-ledger.ts +0 -839
- package/src/ledger.ts +0 -44
|
@@ -1,839 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session ledger — KV coordination with a durable inbox.
|
|
3
|
-
*
|
|
4
|
-
* Three key families, all driven with `get` + CAS on a
|
|
5
|
-
* {@link SessionLedgerKv}; agents never touch these keys directly:
|
|
6
|
-
*
|
|
7
|
-
* ```
|
|
8
|
-
* session/<continuationKey> identity, created with put-if-absent
|
|
9
|
-
* ledger/<sessionId> head + pending + lease — every mutation one CAS
|
|
10
|
-
* mailbox/<sessionId>/<eventId> immutable payload blobs, plain put
|
|
11
|
-
* ```
|
|
12
|
-
*
|
|
13
|
-
* The keys are deliberately bare: namespacing (per agent, per
|
|
14
|
-
* deployment) is not the ledger's concern. The store handle it is given
|
|
15
|
-
* must already be scoped — the engine applies the namespace exactly once
|
|
16
|
-
* where it constructs the handle (see `durable-session-store.ts`), the
|
|
17
|
-
* same boundary where `host.kv` gets its agent scoping.
|
|
18
|
-
*
|
|
19
|
-
* The guarantee: **exactly-once ledger apply, at-least-once actuation.**
|
|
20
|
-
* A commit is one CAS carrying the read version, the expected `head`, and
|
|
21
|
-
* the holder's `generation` — one committed transition per turn, atomic by
|
|
22
|
-
* construction. What the CAS cannot fence (a zombie's plain `host.kv`
|
|
23
|
-
* writes, external tool calls) is a stated bound, not this module's job.
|
|
24
|
-
*
|
|
25
|
-
* The inbox makes events durable before their turn runs: `appendEvent`
|
|
26
|
-
* writes the payload blob, then one CAS indexes the eventId into
|
|
27
|
-
* `pending` — that CAS is the ack point. A delivery that finds the lease
|
|
28
|
-
* held walks away; the holder's commit sees the appended id in the same
|
|
29
|
-
* CAS value, keeps the lease, and drains again. Sessions whose holder
|
|
30
|
-
* died with events pending are recovered by the janitor
|
|
31
|
-
* (`./janitor.js`), which is also why the store must implement
|
|
32
|
-
* `listKeys`: the inbox brings the prefix scans (janitor + orphan
|
|
33
|
-
* sweep) that the coordination-only ledger did not need.
|
|
34
|
-
*
|
|
35
|
-
* Clocks: lease expiry compares `expiresAt` against {@link
|
|
36
|
-
* SessionLedgerOptions.clock} (default `Date.now`). Self-hosted N-pod
|
|
37
|
-
* deployments accept NTP-synced pods as a documented requirement; a
|
|
38
|
-
* backend-clock primitive can replace the default without touching the
|
|
39
|
-
* protocol code.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import { newSessionId } from "../internal/ids.js";
|
|
43
|
-
import { keySegment } from "../storage.js";
|
|
44
|
-
import type { HostKvCasApi, JsonObject, JsonValue } from "../types.js";
|
|
45
|
-
|
|
46
|
-
// Lease mechanics: TTL ~30s, heartbeat ~10s, generation increments per
|
|
47
|
-
// acquisition.
|
|
48
|
-
export const LEASE_TTL_MS = 30_000;
|
|
49
|
-
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
50
|
-
/** Unreferenced mailbox blobs older than this are sweepable. */
|
|
51
|
-
export const DEFAULT_ORPHAN_TTL_MS: number = 60 * 60_000;
|
|
52
|
-
|
|
53
|
-
// Sized against the two-pod simulation running at this default (no
|
|
54
|
-
// override): 64 exhausts appendEvent under a hot session's worst-case
|
|
55
|
-
// interleaving, 128 passes with margin. Exhaustion mid-lease stalls the
|
|
56
|
-
// session for a lease TTL, so the budget errs generous — the backoff
|
|
57
|
-
// keeps late attempts cheap.
|
|
58
|
-
const DEFAULT_MAX_CAS_ATTEMPTS = 128;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The KV surface the ledger uses — the full CAS-capable contract. The
|
|
62
|
-
* coordination half needs only `getWithVersion` / `putIfAbsent` /
|
|
63
|
-
* `putIfVersion`; the inbox adds plain `get`/`put`/`delete` for mailbox
|
|
64
|
-
* payload blobs and `listKeys` for the janitor scan and the orphan
|
|
65
|
-
* sweep.
|
|
66
|
-
*/
|
|
67
|
-
export type SessionLedgerKv = HostKvCasApi;
|
|
68
|
-
|
|
69
|
-
/** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
|
|
70
|
-
export function supportsSessionLedgerKv(kv: object): kv is SessionLedgerKv {
|
|
71
|
-
const candidate = kv as Partial<SessionLedgerKv>;
|
|
72
|
-
return (
|
|
73
|
-
typeof candidate.get === "function" &&
|
|
74
|
-
typeof candidate.put === "function" &&
|
|
75
|
-
typeof candidate.delete === "function" &&
|
|
76
|
-
typeof candidate.getWithVersion === "function" &&
|
|
77
|
-
typeof candidate.putIfAbsent === "function" &&
|
|
78
|
-
typeof candidate.putIfVersion === "function" &&
|
|
79
|
-
typeof candidate.listKeys === "function"
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** `session/<continuationKey>` value. Immutable after creation. */
|
|
84
|
-
export interface SessionIdentity {
|
|
85
|
-
sessionId: string;
|
|
86
|
-
sdkSessionRef: string | null;
|
|
87
|
-
createdAt: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface LedgerLease {
|
|
91
|
-
holder: string;
|
|
92
|
-
generation: number;
|
|
93
|
-
expiresAt: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* `ledger/<sessionId>` value: `{ sessionId, head, pending, lease,
|
|
98
|
-
* generation, lastCommitId }`. `sessionId` lets the janitor's prefix
|
|
99
|
-
* scan address sessions without inverting digested key segments;
|
|
100
|
-
* `generation` is a monotonic acquisition counter that must survive
|
|
101
|
-
* lease release, or a zombie of a cleanly released lease could reuse
|
|
102
|
-
* its generation; `lastCommitId` is the ambiguous-commit recovery
|
|
103
|
-
* token.
|
|
104
|
-
*/
|
|
105
|
-
export interface LedgerSnapshot {
|
|
106
|
-
sessionId: string;
|
|
107
|
-
head: number;
|
|
108
|
-
pending: string[];
|
|
109
|
-
lease: LedgerLease | null;
|
|
110
|
-
generation: number;
|
|
111
|
-
lastCommitId: string | null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** One event handed to {@link SessionLedger.appendEvent}. */
|
|
115
|
-
export interface LedgerEventInput {
|
|
116
|
-
eventId: string;
|
|
117
|
-
envelope: JsonValue;
|
|
118
|
-
hookContext?: JsonValue;
|
|
119
|
-
/** Source-assigned timestamp used for drain ordering. */
|
|
120
|
-
sourceTs: number;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** One drained mailbox event. */
|
|
124
|
-
export interface LedgerEvent {
|
|
125
|
-
eventId: string;
|
|
126
|
-
envelope: JsonValue;
|
|
127
|
-
hookContext: JsonValue | null;
|
|
128
|
-
sourceTs: number;
|
|
129
|
-
/** Pod-clock write time; drives the orphan sweep TTL. */
|
|
130
|
-
storedAt: number;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export type EnsureSessionResult = {
|
|
134
|
-
created: boolean;
|
|
135
|
-
session: SessionIdentity;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
export type ClaimResult =
|
|
139
|
-
| { claimed: true; lease: LedgerLease; head: number; pending: string[] }
|
|
140
|
-
| { claimed: false; reason: "held"; holder: string; expiresAt: number };
|
|
141
|
-
|
|
142
|
-
export type HeartbeatResult =
|
|
143
|
-
| { ok: true; expiresAt: number }
|
|
144
|
-
| { ok: false; reason: "not-found" | "fenced" };
|
|
145
|
-
|
|
146
|
-
export interface DrainResult {
|
|
147
|
-
/** `head` at read time — pass as `expectedHead` to the matching commit. */
|
|
148
|
-
head: number;
|
|
149
|
-
/** Pending events sorted by `sourceTs`, then `eventId`. */
|
|
150
|
-
events: LedgerEvent[];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export interface CommitArgs {
|
|
154
|
-
/** Event ids this turn actually consumed. Only these leave `pending`. */
|
|
155
|
-
drainedEventIds: string[];
|
|
156
|
-
/** The holder's lease generation (fences zombies). */
|
|
157
|
-
generation: number;
|
|
158
|
-
/** `head` observed at drain time (fences concurrent turns). */
|
|
159
|
-
expectedHead: number;
|
|
160
|
-
/** Unique per commit attempt; enables ambiguous-outcome recovery. */
|
|
161
|
-
commitId: string;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface CommitResult {
|
|
165
|
-
head: number;
|
|
166
|
-
/** Events that arrived mid-turn; non-empty ⇒ the lease was kept. */
|
|
167
|
-
pending: string[];
|
|
168
|
-
/**
|
|
169
|
-
* Whether this holder's lease is gone — for the caller's purposes, not
|
|
170
|
-
* the record's: `true` also when the re-read of an ambiguous commit
|
|
171
|
-
* finds a *new claimant's* lease, since we no longer hold the session
|
|
172
|
-
* either way and continuing the turn loop would duplicate the new
|
|
173
|
-
* holder's work.
|
|
174
|
-
*/
|
|
175
|
-
leaseReleased: boolean;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface SweepOrphansArgs {
|
|
179
|
-
/** Limit the sweep to one session (default: all mailbox blobs). */
|
|
180
|
-
sessionId?: string;
|
|
181
|
-
/** Age gate (default {@link DEFAULT_ORPHAN_TTL_MS}). */
|
|
182
|
-
olderThanMs?: number;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export interface SweepOrphansResult {
|
|
186
|
-
/** Deleted blob ids as `<sessionId>/<eventId>`. */
|
|
187
|
-
deleted: string[];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The turn was fenced: a stale generation, a moved head, or a missing
|
|
192
|
-
* ledger. The caller must abort the turn and discard warm state.
|
|
193
|
-
*/
|
|
194
|
-
export class LedgerFencedError extends Error {
|
|
195
|
-
constructor(message: string) {
|
|
196
|
-
super(message);
|
|
197
|
-
this.name = "LedgerFencedError";
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** A CAS retry loop ran out of attempts (sustained contention or outage). */
|
|
202
|
-
export class LedgerCasExhaustedError extends Error {
|
|
203
|
-
constructor(operation: string, attempts: number) {
|
|
204
|
-
super(`${operation}: CAS did not settle after ${attempts} attempts`);
|
|
205
|
-
this.name = "LedgerCasExhaustedError";
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export interface SessionLedgerOptions {
|
|
210
|
-
/** Already-scoped store handle; the ledger writes bare keys into it. */
|
|
211
|
-
kv: SessionLedgerKv;
|
|
212
|
-
/** Stable identity of this pod/process — the lease `holder`. */
|
|
213
|
-
podId: string;
|
|
214
|
-
/** Lease TTL (default {@link LEASE_TTL_MS}). */
|
|
215
|
-
leaseTtlMs?: number;
|
|
216
|
-
/** Time source for lease expiry and blob ages (default `Date.now`). */
|
|
217
|
-
clock?: () => number;
|
|
218
|
-
/** Max attempts per CAS loop (default 16). */
|
|
219
|
-
maxCasAttempts?: number;
|
|
220
|
-
/**
|
|
221
|
-
* Wait between CAS retries. Default: immediate for the first retries,
|
|
222
|
-
* then a jittered exponential capped at 250ms.
|
|
223
|
-
*/
|
|
224
|
-
backoff?: (attempt: number) => Promise<void>;
|
|
225
|
-
/** Session id mint (default `ses_<24 hex>` like the session engine). */
|
|
226
|
-
newSessionId?: () => string;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function defaultBackoff(attempt: number): Promise<void> {
|
|
230
|
-
// The common case is a single conflicting writer (one heartbeat or one
|
|
231
|
-
// append); retrying immediately wins the next round. Sleep only when
|
|
232
|
-
// contention persists.
|
|
233
|
-
if (attempt < 2) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
const cap = Math.min(5 * 2 ** attempt, 250);
|
|
237
|
-
const waitMs = cap / 2 + Math.random() * (cap / 2);
|
|
238
|
-
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Drives the three ledger key families over a CAS-capable KV. */
|
|
242
|
-
export class SessionLedger {
|
|
243
|
-
private readonly kv: SessionLedgerKv;
|
|
244
|
-
private readonly podId: string;
|
|
245
|
-
private readonly leaseTtlMs: number;
|
|
246
|
-
private readonly clock: () => number;
|
|
247
|
-
private readonly maxCasAttempts: number;
|
|
248
|
-
private readonly backoff: (attempt: number) => Promise<void>;
|
|
249
|
-
private readonly mintSessionId: () => string;
|
|
250
|
-
|
|
251
|
-
constructor(options: SessionLedgerOptions) {
|
|
252
|
-
this.kv = options.kv;
|
|
253
|
-
this.podId = options.podId;
|
|
254
|
-
this.leaseTtlMs = options.leaseTtlMs ?? LEASE_TTL_MS;
|
|
255
|
-
this.clock = options.clock ?? Date.now;
|
|
256
|
-
this.maxCasAttempts = options.maxCasAttempts ?? DEFAULT_MAX_CAS_ATTEMPTS;
|
|
257
|
-
this.backoff = options.backoff ?? defaultBackoff;
|
|
258
|
-
this.mintSessionId = options.newSessionId ?? newSessionId;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private sessionKey(continuationKey: string): string {
|
|
262
|
-
return `session/${keySegment(continuationKey)}`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private ledgerKey(sessionId: string): string {
|
|
266
|
-
return `ledger/${keySegment(sessionId)}`;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private mailboxKey(sessionId: string, eventId: string): string {
|
|
270
|
-
return `${this.mailboxPrefix(sessionId)}${keySegment(eventId)}`;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private mailboxPrefix(sessionId: string): string {
|
|
274
|
-
return `mailbox/${keySegment(sessionId)}/`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private emptyLedger(sessionId: string): LedgerSnapshot {
|
|
278
|
-
return {
|
|
279
|
-
sessionId,
|
|
280
|
-
head: 0,
|
|
281
|
-
pending: [],
|
|
282
|
-
lease: null,
|
|
283
|
-
generation: 0,
|
|
284
|
-
lastCommitId: null,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Create-or-read the session identity for a continuation key. The create
|
|
290
|
-
* race has one winner (put-if-absent); losers read the winner's identity
|
|
291
|
-
* and append there.
|
|
292
|
-
*/
|
|
293
|
-
async ensureSession(
|
|
294
|
-
continuationKey: string,
|
|
295
|
-
init?: { sdkSessionRef?: string | null }
|
|
296
|
-
): Promise<EnsureSessionResult> {
|
|
297
|
-
const key = this.sessionKey(continuationKey);
|
|
298
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
299
|
-
const existing = await this.kv.getWithVersion(key);
|
|
300
|
-
if (existing !== undefined) {
|
|
301
|
-
return {
|
|
302
|
-
created: false,
|
|
303
|
-
session: parseSessionIdentity(existing.value),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
const session: SessionIdentity = {
|
|
307
|
-
sessionId: this.mintSessionId(),
|
|
308
|
-
sdkSessionRef: init?.sdkSessionRef ?? null,
|
|
309
|
-
createdAt: this.clock(),
|
|
310
|
-
};
|
|
311
|
-
const result = await this.kv.putIfAbsent(key, toJson(session));
|
|
312
|
-
if (result.ok) {
|
|
313
|
-
return { created: true, session };
|
|
314
|
-
}
|
|
315
|
-
if (result.current !== undefined) {
|
|
316
|
-
return {
|
|
317
|
-
created: false,
|
|
318
|
-
session: parseSessionIdentity(result.current.value),
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
// Conflict with no current entry (created then deleted underneath
|
|
322
|
-
// us) — retry from the read.
|
|
323
|
-
await this.backoff(attempt);
|
|
324
|
-
}
|
|
325
|
-
throw new LedgerCasExhaustedError("ensureSession", this.maxCasAttempts);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Make one event durable: payload blob first, then one CAS indexing its
|
|
330
|
-
* id into `pending`. The blob must land before the index — a pending id
|
|
331
|
-
* with no payload would break drain, while the reverse (blob without
|
|
332
|
-
* index, i.e. a crash between the two writes) is a sweepable orphan.
|
|
333
|
-
* Idempotent by `eventId`: `pending` is a set, so redelivery re-runs are
|
|
334
|
-
* no-ops. The CAS in step two is the ack point — callers ack the source
|
|
335
|
-
* only after this resolves.
|
|
336
|
-
*/
|
|
337
|
-
async appendEvent(
|
|
338
|
-
sessionId: string,
|
|
339
|
-
event: LedgerEventInput
|
|
340
|
-
): Promise<{ appended: boolean }> {
|
|
341
|
-
const blob: JsonObject = {
|
|
342
|
-
sessionId,
|
|
343
|
-
eventId: event.eventId,
|
|
344
|
-
envelope: event.envelope,
|
|
345
|
-
hookContext: event.hookContext ?? null,
|
|
346
|
-
sourceTs: event.sourceTs,
|
|
347
|
-
storedAt: this.clock(),
|
|
348
|
-
};
|
|
349
|
-
await this.kv.put(this.mailboxKey(sessionId, event.eventId), blob);
|
|
350
|
-
|
|
351
|
-
const key = this.ledgerKey(sessionId);
|
|
352
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
353
|
-
const entry = await this.kv.getWithVersion(key);
|
|
354
|
-
if (entry === undefined) {
|
|
355
|
-
const initial: LedgerSnapshot = {
|
|
356
|
-
...this.emptyLedger(sessionId),
|
|
357
|
-
pending: [event.eventId],
|
|
358
|
-
};
|
|
359
|
-
const result = await this.kv.putIfAbsent(key, toJson(initial));
|
|
360
|
-
if (result.ok) {
|
|
361
|
-
return { appended: true };
|
|
362
|
-
}
|
|
363
|
-
await this.backoff(attempt);
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
367
|
-
if (ledger.pending.includes(event.eventId)) {
|
|
368
|
-
return { appended: false };
|
|
369
|
-
}
|
|
370
|
-
const next: LedgerSnapshot = {
|
|
371
|
-
...ledger,
|
|
372
|
-
pending: [...ledger.pending, event.eventId],
|
|
373
|
-
};
|
|
374
|
-
const result = await this.kv.putIfVersion(
|
|
375
|
-
key,
|
|
376
|
-
toJson(next),
|
|
377
|
-
entry.version
|
|
378
|
-
);
|
|
379
|
-
if (result.ok) {
|
|
380
|
-
return { appended: true };
|
|
381
|
-
}
|
|
382
|
-
await this.backoff(attempt);
|
|
383
|
-
}
|
|
384
|
-
throw new LedgerCasExhaustedError("appendEvent", this.maxCasAttempts);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Try to take the session lease, creating the ledger record on first
|
|
389
|
-
* contact. Succeeds only when the lease is null or expired; the CAS
|
|
390
|
-
* bumps the acquisition generation, fencing every prior holder. A
|
|
391
|
-
* losing caller walks away — the holder's commit sees any appended
|
|
392
|
-
* events in the same CAS value, and the janitor recovers sessions
|
|
393
|
-
* whose holder died.
|
|
394
|
-
*/
|
|
395
|
-
async claim(sessionId: string): Promise<ClaimResult> {
|
|
396
|
-
const key = this.ledgerKey(sessionId);
|
|
397
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
398
|
-
const entry = await this.kv.getWithVersion(key);
|
|
399
|
-
if (entry === undefined) {
|
|
400
|
-
const result = await this.kv.putIfAbsent(
|
|
401
|
-
key,
|
|
402
|
-
toJson(this.emptyLedger(sessionId))
|
|
403
|
-
);
|
|
404
|
-
// Won or lost, the record now exists — loop back and claim it.
|
|
405
|
-
if (!result.ok) {
|
|
406
|
-
await this.backoff(attempt);
|
|
407
|
-
}
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
411
|
-
const now = this.clock();
|
|
412
|
-
if (ledger.lease !== null && ledger.lease.expiresAt > now) {
|
|
413
|
-
return {
|
|
414
|
-
claimed: false,
|
|
415
|
-
reason: "held",
|
|
416
|
-
holder: ledger.lease.holder,
|
|
417
|
-
expiresAt: ledger.lease.expiresAt,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
const lease: LedgerLease = {
|
|
421
|
-
holder: this.podId,
|
|
422
|
-
generation: ledger.generation + 1,
|
|
423
|
-
expiresAt: now + this.leaseTtlMs,
|
|
424
|
-
};
|
|
425
|
-
const next: LedgerSnapshot = {
|
|
426
|
-
...ledger,
|
|
427
|
-
generation: lease.generation,
|
|
428
|
-
lease,
|
|
429
|
-
};
|
|
430
|
-
const result = await this.kv.putIfVersion(
|
|
431
|
-
key,
|
|
432
|
-
toJson(next),
|
|
433
|
-
entry.version
|
|
434
|
-
);
|
|
435
|
-
if (result.ok) {
|
|
436
|
-
return { claimed: true, lease, head: next.head, pending: next.pending };
|
|
437
|
-
}
|
|
438
|
-
await this.backoff(attempt);
|
|
439
|
-
}
|
|
440
|
-
throw new LedgerCasExhaustedError("claim", this.maxCasAttempts);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Extend the lease. Fails when the generation is no longer ours — the
|
|
445
|
-
* caller must abort the turn and discard warm state; the session has a
|
|
446
|
-
* new legitimate holder.
|
|
447
|
-
*/
|
|
448
|
-
async heartbeat(
|
|
449
|
-
sessionId: string,
|
|
450
|
-
generation: number
|
|
451
|
-
): Promise<HeartbeatResult> {
|
|
452
|
-
const key = this.ledgerKey(sessionId);
|
|
453
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
454
|
-
const entry = await this.kv.getWithVersion(key);
|
|
455
|
-
if (entry === undefined) {
|
|
456
|
-
return { ok: false, reason: "not-found" };
|
|
457
|
-
}
|
|
458
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
459
|
-
if (
|
|
460
|
-
ledger.lease === null ||
|
|
461
|
-
ledger.lease.holder !== this.podId ||
|
|
462
|
-
ledger.lease.generation !== generation
|
|
463
|
-
) {
|
|
464
|
-
return { ok: false, reason: "fenced" };
|
|
465
|
-
}
|
|
466
|
-
const expiresAt = this.clock() + this.leaseTtlMs;
|
|
467
|
-
const next: LedgerSnapshot = {
|
|
468
|
-
...ledger,
|
|
469
|
-
lease: { ...ledger.lease, expiresAt },
|
|
470
|
-
};
|
|
471
|
-
const result = await this.kv.putIfVersion(
|
|
472
|
-
key,
|
|
473
|
-
toJson(next),
|
|
474
|
-
entry.version
|
|
475
|
-
);
|
|
476
|
-
if (result.ok) {
|
|
477
|
-
return { ok: true, expiresAt };
|
|
478
|
-
}
|
|
479
|
-
// Version conflict here is a concurrent append, not a lost lease —
|
|
480
|
-
// the re-read revalidates the generation before trying again.
|
|
481
|
-
await this.backoff(attempt);
|
|
482
|
-
}
|
|
483
|
-
throw new LedgerCasExhaustedError("heartbeat", this.maxCasAttempts);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Read the pending mailbox: fetch each indexed payload blob and return
|
|
488
|
-
* them sorted by `sourceTs`, then `eventId` (mailbox order ≠ source
|
|
489
|
-
* order is a stated non-guarantee; this is the drain order). The
|
|
490
|
-
* returned `head` is the `expectedHead` for the matching commit.
|
|
491
|
-
*/
|
|
492
|
-
async drain(sessionId: string): Promise<DrainResult> {
|
|
493
|
-
const entry = await this.kv.getWithVersion(this.ledgerKey(sessionId));
|
|
494
|
-
if (entry === undefined) {
|
|
495
|
-
return { head: 0, events: [] };
|
|
496
|
-
}
|
|
497
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
498
|
-
const events: LedgerEvent[] = [];
|
|
499
|
-
for (const eventId of ledger.pending) {
|
|
500
|
-
const blob = await this.kv.get(this.mailboxKey(sessionId, eventId));
|
|
501
|
-
// Append order (blob before index) makes a missing blob impossible;
|
|
502
|
-
// skip defensively rather than wedge the session on corruption.
|
|
503
|
-
if (blob === undefined) {
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
events.push(parseLedgerEvent(blob));
|
|
507
|
-
}
|
|
508
|
-
events.sort(
|
|
509
|
-
(a, b) => a.sourceTs - b.sourceTs || compareStrings(a.eventId, b.eventId)
|
|
510
|
-
);
|
|
511
|
-
return { head: ledger.head, events };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* Commit one turn. The single CAS is the commit point: it bumps `head`,
|
|
516
|
-
* removes ONLY the drained ids from `pending`, releases the lease iff
|
|
517
|
-
* nothing is left pending, and records `commitId` — atomically. A
|
|
518
|
-
* non-empty result `pending` means events landed mid-turn: the lease was
|
|
519
|
-
* kept and the caller drains again.
|
|
520
|
-
*
|
|
521
|
-
* Ambiguous outcomes (the KV threw and we never saw the CAS result) are
|
|
522
|
-
* resolved by re-reading, never by blind-retrying: `head` advanced to
|
|
523
|
-
* `expectedHead + 1` with our `commitId` ⇒ our write landed, report
|
|
524
|
-
* success; `head` still at `expectedHead` with our lease ⇒ it did not,
|
|
525
|
-
* retry against the fresh version; anything else ⇒ fenced.
|
|
526
|
-
*/
|
|
527
|
-
async commit(sessionId: string, args: CommitArgs): Promise<CommitResult> {
|
|
528
|
-
const key = this.ledgerKey(sessionId);
|
|
529
|
-
const drained = new Set(args.drainedEventIds);
|
|
530
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
531
|
-
const entry = await this.kv.getWithVersion(key);
|
|
532
|
-
if (entry === undefined) {
|
|
533
|
-
throw new LedgerFencedError(
|
|
534
|
-
`commit(${sessionId}): ledger key is missing`
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
538
|
-
if (
|
|
539
|
-
ledger.head === args.expectedHead + 1 &&
|
|
540
|
-
ledger.lastCommitId === args.commitId
|
|
541
|
-
) {
|
|
542
|
-
// A previous attempt landed even though its result was lost. The
|
|
543
|
-
// lease is released *for us* unless the observed lease is exactly
|
|
544
|
-
// ours (our commit kept it for a non-empty pending): any other
|
|
545
|
-
// non-null lease belongs to a claimant who took the session after
|
|
546
|
-
// our releasing write, and reporting it as "still held" would
|
|
547
|
-
// keep the caller's turn loop alive alongside the new holder's.
|
|
548
|
-
const stillOurs =
|
|
549
|
-
ledger.lease !== null &&
|
|
550
|
-
ledger.lease.holder === this.podId &&
|
|
551
|
-
ledger.lease.generation === args.generation;
|
|
552
|
-
return {
|
|
553
|
-
head: ledger.head,
|
|
554
|
-
pending: ledger.pending,
|
|
555
|
-
leaseReleased: !stillOurs,
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
if (ledger.head !== args.expectedHead) {
|
|
559
|
-
throw new LedgerFencedError(
|
|
560
|
-
`commit(${sessionId}): head is ${ledger.head}, expected ${args.expectedHead}`
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
if (
|
|
564
|
-
ledger.lease === null ||
|
|
565
|
-
ledger.lease.holder !== this.podId ||
|
|
566
|
-
ledger.lease.generation !== args.generation
|
|
567
|
-
) {
|
|
568
|
-
throw new LedgerFencedError(
|
|
569
|
-
`commit(${sessionId}): lease generation ${args.generation} is stale`
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
const pending = ledger.pending.filter((id) => !drained.has(id));
|
|
573
|
-
const leaseReleased = pending.length === 0;
|
|
574
|
-
const next: LedgerSnapshot = {
|
|
575
|
-
...ledger,
|
|
576
|
-
head: args.expectedHead + 1,
|
|
577
|
-
pending,
|
|
578
|
-
lease: leaseReleased ? null : ledger.lease,
|
|
579
|
-
lastCommitId: args.commitId,
|
|
580
|
-
};
|
|
581
|
-
let result: Awaited<ReturnType<SessionLedgerKv["putIfVersion"]>>;
|
|
582
|
-
try {
|
|
583
|
-
result = await this.kv.putIfVersion(key, toJson(next), entry.version);
|
|
584
|
-
} catch {
|
|
585
|
-
// Unknown outcome — loop back to the re-read, which either finds
|
|
586
|
-
// our commitId (success) or a fresh version to CAS against.
|
|
587
|
-
await this.backoff(attempt);
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
if (result.ok) {
|
|
591
|
-
return { head: next.head, pending, leaseReleased };
|
|
592
|
-
}
|
|
593
|
-
// Version conflict: a concurrent append. Re-read and retry — the
|
|
594
|
-
// fencing predicates re-run against the fresh value.
|
|
595
|
-
await this.backoff(attempt);
|
|
596
|
-
}
|
|
597
|
-
throw new LedgerCasExhaustedError("commit", this.maxCasAttempts);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Release a held lease without committing — graceful shutdown, a turn
|
|
602
|
-
* that failed before its commit, or a claim that found nothing to
|
|
603
|
-
* drain. Fenced by generation: a stale holder's release is a no-op
|
|
604
|
-
* (the session has moved on). `head` and the acquisition counter are
|
|
605
|
-
* untouched, so prior holders stay fenced.
|
|
606
|
-
*/
|
|
607
|
-
async release(
|
|
608
|
-
sessionId: string,
|
|
609
|
-
generation: number
|
|
610
|
-
): Promise<{ released: boolean }> {
|
|
611
|
-
const key = this.ledgerKey(sessionId);
|
|
612
|
-
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
613
|
-
const entry = await this.kv.getWithVersion(key);
|
|
614
|
-
if (entry === undefined) {
|
|
615
|
-
return { released: false };
|
|
616
|
-
}
|
|
617
|
-
const ledger = parseLedgerSnapshot(entry.value);
|
|
618
|
-
if (
|
|
619
|
-
ledger.lease === null ||
|
|
620
|
-
ledger.lease.holder !== this.podId ||
|
|
621
|
-
ledger.lease.generation !== generation
|
|
622
|
-
) {
|
|
623
|
-
return { released: false };
|
|
624
|
-
}
|
|
625
|
-
const next: LedgerSnapshot = { ...ledger, lease: null };
|
|
626
|
-
const result = await this.kv.putIfVersion(
|
|
627
|
-
key,
|
|
628
|
-
toJson(next),
|
|
629
|
-
entry.version
|
|
630
|
-
);
|
|
631
|
-
if (result.ok) {
|
|
632
|
-
return { released: true };
|
|
633
|
-
}
|
|
634
|
-
await this.backoff(attempt);
|
|
635
|
-
}
|
|
636
|
-
throw new LedgerCasExhaustedError("release", this.maxCasAttempts);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** Parsed ledger value for one session, or `undefined` when never created. */
|
|
640
|
-
async readLedger(sessionId: string): Promise<LedgerSnapshot | undefined> {
|
|
641
|
-
const entry = await this.kv.getWithVersion(this.ledgerKey(sessionId));
|
|
642
|
-
return entry === undefined ? undefined : parseLedgerSnapshot(entry.value);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* All ledgers under this prefix (the janitor's scan). An unparseable
|
|
647
|
-
* record is skipped — reported through `onSkip` — never thrown: one
|
|
648
|
-
* corrupt value must degrade that one session, not abort the scan and
|
|
649
|
-
* kill the whole agent's recovery pass.
|
|
650
|
-
*/
|
|
651
|
-
async listLedgers(
|
|
652
|
-
onSkip?: (key: string, error: unknown) => void
|
|
653
|
-
): Promise<LedgerSnapshot[]> {
|
|
654
|
-
const keys = await this.kv.listKeys("ledger/");
|
|
655
|
-
const ledgers: LedgerSnapshot[] = [];
|
|
656
|
-
for (const key of keys) {
|
|
657
|
-
const entry = await this.kv.getWithVersion(key);
|
|
658
|
-
if (entry === undefined) {
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
try {
|
|
662
|
-
ledgers.push(parseLedgerSnapshot(entry.value));
|
|
663
|
-
} catch (error) {
|
|
664
|
-
onSkip?.(key, error);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
return ledgers;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Delete mailbox blobs that are (a) not referenced by their session's
|
|
672
|
-
* `pending` and (b) older than the TTL. Covers both true orphans (a
|
|
673
|
-
* crash between the payload put and the ledger CAS) and consumed blobs
|
|
674
|
-
* whose ids a commit already removed. Membership is checked against a
|
|
675
|
-
* ledger read taken *after* listing the blobs, so a blob whose append
|
|
676
|
-
* CAS is in flight is never swept: its id is either already in
|
|
677
|
-
* `pending`, or the blob is younger than any sane TTL.
|
|
678
|
-
*/
|
|
679
|
-
async sweepOrphans(args?: SweepOrphansArgs): Promise<SweepOrphansResult> {
|
|
680
|
-
const olderThanMs = args?.olderThanMs ?? DEFAULT_ORPHAN_TTL_MS;
|
|
681
|
-
const prefix =
|
|
682
|
-
args?.sessionId === undefined
|
|
683
|
-
? "mailbox/"
|
|
684
|
-
: this.mailboxPrefix(args.sessionId);
|
|
685
|
-
const keys = await this.kv.listKeys(prefix);
|
|
686
|
-
const now = this.clock();
|
|
687
|
-
|
|
688
|
-
// Blob contents carry sessionId/eventId (key segments may be digested
|
|
689
|
-
// and are not invertible), so group by reading each blob.
|
|
690
|
-
const bySession = new Map<
|
|
691
|
-
string,
|
|
692
|
-
Array<{ key: string; event: LedgerEvent }>
|
|
693
|
-
>();
|
|
694
|
-
for (const key of keys) {
|
|
695
|
-
const value = await this.kv.get(key);
|
|
696
|
-
if (value === undefined) {
|
|
697
|
-
continue;
|
|
698
|
-
}
|
|
699
|
-
const event = parseLedgerEvent(value);
|
|
700
|
-
const sessionId = readBlobSessionId(value);
|
|
701
|
-
const group = bySession.get(sessionId) ?? [];
|
|
702
|
-
group.push({ key, event });
|
|
703
|
-
bySession.set(sessionId, group);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const deleted: string[] = [];
|
|
707
|
-
for (const [sessionId, blobs] of bySession) {
|
|
708
|
-
const ledger = await this.readLedger(sessionId);
|
|
709
|
-
const pending = new Set(ledger?.pending ?? []);
|
|
710
|
-
for (const { key, event } of blobs) {
|
|
711
|
-
if (pending.has(event.eventId)) {
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
if (event.storedAt + olderThanMs > now) {
|
|
715
|
-
continue;
|
|
716
|
-
}
|
|
717
|
-
await this.kv.delete(key);
|
|
718
|
-
deleted.push(`${sessionId}/${event.eventId}`);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
return { deleted };
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function compareStrings(a: string, b: string): number {
|
|
726
|
-
if (a < b) {
|
|
727
|
-
return -1;
|
|
728
|
-
}
|
|
729
|
-
return a > b ? 1 : 0;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function toJson(value: SessionIdentity | LedgerSnapshot): JsonValue {
|
|
733
|
-
return value as unknown as JsonValue;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function parseSessionIdentity(value: JsonValue): SessionIdentity {
|
|
737
|
-
const record = asRecord(value, "session identity");
|
|
738
|
-
const sessionId = record.sessionId;
|
|
739
|
-
const createdAt = record.createdAt;
|
|
740
|
-
if (typeof sessionId !== "string" || typeof createdAt !== "number") {
|
|
741
|
-
throw new Error("session ledger: malformed session identity value");
|
|
742
|
-
}
|
|
743
|
-
const sdkSessionRef = record.sdkSessionRef;
|
|
744
|
-
return {
|
|
745
|
-
sessionId,
|
|
746
|
-
sdkSessionRef: typeof sdkSessionRef === "string" ? sdkSessionRef : null,
|
|
747
|
-
createdAt,
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function parseLedgerSnapshot(value: JsonValue): LedgerSnapshot {
|
|
752
|
-
const record = asRecord(value, "ledger");
|
|
753
|
-
const { sessionId, head, lease, generation, lastCommitId } = record;
|
|
754
|
-
if (
|
|
755
|
-
typeof sessionId !== "string" ||
|
|
756
|
-
typeof head !== "number" ||
|
|
757
|
-
typeof generation !== "number"
|
|
758
|
-
) {
|
|
759
|
-
throw new Error("session ledger: malformed ledger value");
|
|
760
|
-
}
|
|
761
|
-
return {
|
|
762
|
-
sessionId,
|
|
763
|
-
head,
|
|
764
|
-
pending: parsePending(record.pending),
|
|
765
|
-
lease: parseLease(lease),
|
|
766
|
-
generation,
|
|
767
|
-
lastCommitId: typeof lastCommitId === "string" ? lastCommitId : null,
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Records written by the pre-inbox ledger have no `pending` field; read
|
|
773
|
-
* them as an empty inbox instead of rejecting them — one legacy record
|
|
774
|
-
* must not brick `claim`/`readLedger` (or a whole `listLedgers` scan)
|
|
775
|
-
* after an upgrade. Every write from the inbox on includes the field.
|
|
776
|
-
*/
|
|
777
|
-
function parsePending(value: JsonValue | undefined): string[] {
|
|
778
|
-
if (value === undefined) {
|
|
779
|
-
return [];
|
|
780
|
-
}
|
|
781
|
-
if (
|
|
782
|
-
!Array.isArray(value) ||
|
|
783
|
-
!value.every((id): id is string => typeof id === "string")
|
|
784
|
-
) {
|
|
785
|
-
throw new Error("session ledger: malformed ledger value");
|
|
786
|
-
}
|
|
787
|
-
return value;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function parseLease(value: JsonValue | undefined): LedgerLease | null {
|
|
791
|
-
if (value === null || value === undefined) {
|
|
792
|
-
return null;
|
|
793
|
-
}
|
|
794
|
-
const record = asRecord(value, "lease");
|
|
795
|
-
const { holder, generation, expiresAt } = record;
|
|
796
|
-
if (
|
|
797
|
-
typeof holder !== "string" ||
|
|
798
|
-
typeof generation !== "number" ||
|
|
799
|
-
typeof expiresAt !== "number"
|
|
800
|
-
) {
|
|
801
|
-
throw new Error("session ledger: malformed lease value");
|
|
802
|
-
}
|
|
803
|
-
return { holder, generation, expiresAt };
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function parseLedgerEvent(value: JsonValue): LedgerEvent {
|
|
807
|
-
const record = asRecord(value, "mailbox blob");
|
|
808
|
-
const { eventId, sourceTs, storedAt } = record;
|
|
809
|
-
if (
|
|
810
|
-
typeof eventId !== "string" ||
|
|
811
|
-
typeof sourceTs !== "number" ||
|
|
812
|
-
typeof storedAt !== "number"
|
|
813
|
-
) {
|
|
814
|
-
throw new Error("session ledger: malformed mailbox blob");
|
|
815
|
-
}
|
|
816
|
-
return {
|
|
817
|
-
eventId,
|
|
818
|
-
envelope: record.envelope ?? null,
|
|
819
|
-
hookContext: record.hookContext ?? null,
|
|
820
|
-
sourceTs,
|
|
821
|
-
storedAt,
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function readBlobSessionId(value: JsonValue): string {
|
|
826
|
-
const record = asRecord(value, "mailbox blob");
|
|
827
|
-
const sessionId = record.sessionId;
|
|
828
|
-
if (typeof sessionId !== "string") {
|
|
829
|
-
throw new Error("session ledger: mailbox blob is missing sessionId");
|
|
830
|
-
}
|
|
831
|
-
return sessionId;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function asRecord(value: JsonValue, what: string): JsonObject {
|
|
835
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
836
|
-
throw new Error(`session ledger: ${what} value is not an object`);
|
|
837
|
-
}
|
|
838
|
-
return value;
|
|
839
|
-
}
|