@cursor/july 0.1.45 → 0.1.46
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/AGENTS.md +4 -2
- package/README.md +3 -1
- package/dist/channels/github/api.d.ts +25 -0
- package/dist/channels/github/api.d.ts.map +1 -1
- package/dist/channels/github/api.js +48 -0
- package/dist/channels/github/cursor-account.d.ts +7 -0
- package/dist/channels/github/cursor-account.d.ts.map +1 -1
- package/dist/channels/github/cursor-account.js +18 -0
- package/dist/channels/github/defaults.d.ts +1 -1
- package/dist/channels/github/defaults.js +1 -1
- package/dist/channels/github/github-channel.d.ts.map +1 -1
- package/dist/channels/github/github-channel.js +2 -2
- package/dist/channels/github/progress.d.ts +6 -5
- package/dist/channels/github/progress.d.ts.map +1 -1
- package/dist/channels/github/progress.js +58 -16
- package/dist/channels/github/state.d.ts +11 -0
- package/dist/channels/github/state.d.ts.map +1 -1
- package/dist/channels/github/state.js +1 -1
- package/dist/channels/github/types.d.ts +3 -3
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.fHnNXEJ6.js → app.C2NFTb-h.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.B-FxvuoI.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.Cf6ghfFY.js → VPLocalSearchBox.o1_FJGip.js} +1 -1
- package/dist/docs/assets/chunks/{theme.B_5-VLe8.js → theme.BcGduE7g.js} +2 -2
- package/dist/docs/assets/{guides_github.md.DSg0j9qj.js → guides_github.md.B8QKtIpw.js} +7 -6
- package/dist/docs/assets/{guides_github.md.DSg0j9qj.lean.js → guides_github.md.B8QKtIpw.lean.js} +1 -1
- package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.js → reference_agent-config.md.DHkkvcEZ.js} +4 -4
- package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.lean.js → reference_agent-config.md.DHkkvcEZ.lean.js} +1 -1
- package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.js → reference_hooks.md.DJE5DXcT.js} +1 -1
- package/dist/docs/assets/{storage.md.DcGSnIxX.js → storage.md.CJYd5YmN.js} +2 -2
- package/dist/docs/assets/{storage.md.DcGSnIxX.lean.js → storage.md.CJYd5YmN.lean.js} +1 -1
- 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/github.html +10 -9
- 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 +6 -6
- 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 +5 -5
- 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 +5 -5
- package/dist/docs/templates/triage.html +3 -3
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/auth.d.ts +4 -0
- package/dist/internal/auth.d.ts.map +1 -1
- package/dist/internal/auth.js +7 -0
- package/dist/internal/durable-session-store.d.ts +35 -23
- package/dist/internal/durable-session-store.d.ts.map +1 -1
- package/dist/internal/durable-session-store.js +71 -26
- package/dist/internal/ledger-serving.d.ts +135 -67
- package/dist/internal/ledger-serving.d.ts.map +1 -1
- package/dist/internal/ledger-serving.js +326 -107
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +2 -2
- package/dist/internal/session-engine.d.ts +18 -17
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +60 -27
- package/dist/kv/memory-kv.d.ts +8 -0
- package/dist/kv/memory-kv.d.ts.map +1 -1
- package/dist/kv/memory-kv.js +8 -0
- package/dist/ledger/janitor.d.ts +60 -0
- package/dist/ledger/janitor.d.ts.map +1 -0
- package/dist/ledger/janitor.js +137 -0
- package/dist/ledger/session-ledger.d.ts +132 -43
- package/dist/ledger/session-ledger.d.ts.map +1 -1
- package/dist/ledger/session-ledger.js +282 -50
- package/dist/ledger.d.ts +9 -8
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +7 -7
- package/dist/playground/assets/{index-Dces8BDp.js → index-6wW6SAc4.js} +32 -32
- package/dist/playground/assets/index-CSRYDAFw.css +1 -0
- package/dist/playground/index.html +2 -2
- package/dist/storage-backends/cursor-hosted.d.ts +5 -0
- package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
- package/dist/storage-backends/cursor-hosted.js +6 -0
- package/dist/types.d.ts +19 -8
- package/dist/types.d.ts.map +1 -1
- package/docs/guides/github.md +23 -18
- package/docs/reference/agent-config.md +31 -36
- package/docs/reference/hooks.md +1 -1
- package/docs/storage.md +26 -9
- package/package.json +1 -1
- package/src/channels/github/api.ts +122 -0
- package/src/channels/github/cursor-account.ts +23 -0
- package/src/channels/github/defaults.ts +1 -1
- package/src/channels/github/github-channel.ts +5 -1
- package/src/channels/github/progress.ts +84 -19
- package/src/channels/github/state.ts +14 -0
- package/src/channels/github/types.ts +3 -3
- package/src/index.ts +1 -0
- package/src/internal/auth.ts +10 -0
- package/src/internal/durable-session-store.ts +66 -27
- package/src/internal/ledger-serving.ts +439 -148
- package/src/internal/server.ts +6 -2
- package/src/internal/session-engine.ts +95 -37
- package/src/kv/memory-kv.ts +8 -0
- package/src/ledger/janitor.ts +193 -0
- package/src/ledger/session-ledger.ts +372 -68
- package/src/ledger.ts +21 -6
- package/src/storage-backends/cursor-hosted.ts +17 -2
- package/src/types.ts +19 -8
- package/dist/docs/assets/chunks/@localSearchIndexroot.BMT0xM4I.js +0 -1
- package/dist/playground/assets/index-CZH9EFvO.css +0 -1
- /package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.lean.js → reference_hooks.md.DJE5DXcT.lean.js} +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session ledger — coordination
|
|
2
|
+
* Session ledger — KV coordination with a durable inbox.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* agents never touch these keys directly:
|
|
4
|
+
* Three key families, all driven with `get` + CAS on a
|
|
5
|
+
* {@link SessionLedgerKv}; agents never touch these keys directly:
|
|
6
6
|
*
|
|
7
7
|
* ```
|
|
8
|
-
* session/<continuationKey>
|
|
9
|
-
* ledger/<sessionId>
|
|
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
|
|
10
11
|
* ```
|
|
11
12
|
*
|
|
12
13
|
* The keys are deliberately bare: namespacing (per agent, per
|
|
@@ -15,21 +16,21 @@
|
|
|
15
16
|
* where it constructs the handle (see `durable-session-store.ts`), the
|
|
16
17
|
* same boundary where `host.kv` gets its agent scoping.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* *in-process* on the delivering pod; a crash loses that in-flight
|
|
24
|
-
* event exactly as resident serving does. What ledger mode adds over
|
|
25
|
-
* resident is cross-pod mutual exclusion, zombie fencing, and the
|
|
26
|
-
* committed head sequence. A dead pod needs no janitor: its lease
|
|
27
|
-
* simply expires (~30s) and the next delivery claims at generation+1.
|
|
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.
|
|
28
24
|
*
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
33
34
|
*
|
|
34
35
|
* Clocks: lease expiry compares `expiresAt` against {@link
|
|
35
36
|
* SessionLedgerOptions.clock} (default `Date.now`). Self-hosted N-pod
|
|
@@ -46,26 +47,36 @@ import type { HostKvCasApi, JsonObject, JsonValue } from "../types.js";
|
|
|
46
47
|
// acquisition.
|
|
47
48
|
export const LEASE_TTL_MS = 30_000;
|
|
48
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;
|
|
49
52
|
|
|
50
|
-
|
|
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;
|
|
51
59
|
|
|
52
60
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
56
66
|
*/
|
|
57
|
-
export type SessionLedgerKv =
|
|
58
|
-
HostKvCasApi,
|
|
59
|
-
"getWithVersion" | "putIfAbsent" | "putIfVersion"
|
|
60
|
-
>;
|
|
67
|
+
export type SessionLedgerKv = HostKvCasApi;
|
|
61
68
|
|
|
62
69
|
/** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
|
|
63
70
|
export function supportsSessionLedgerKv(kv: object): kv is SessionLedgerKv {
|
|
64
71
|
const candidate = kv as Partial<SessionLedgerKv>;
|
|
65
72
|
return (
|
|
73
|
+
typeof candidate.get === "function" &&
|
|
74
|
+
typeof candidate.put === "function" &&
|
|
75
|
+
typeof candidate.delete === "function" &&
|
|
66
76
|
typeof candidate.getWithVersion === "function" &&
|
|
67
77
|
typeof candidate.putIfAbsent === "function" &&
|
|
68
|
-
typeof candidate.putIfVersion === "function"
|
|
78
|
+
typeof candidate.putIfVersion === "function" &&
|
|
79
|
+
typeof candidate.listKeys === "function"
|
|
69
80
|
);
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -83,38 +94,68 @@ export interface LedgerLease {
|
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
/**
|
|
86
|
-
* `ledger/<sessionId>` value: `{ sessionId, head,
|
|
87
|
-
* lastCommitId }`. `sessionId` lets
|
|
88
|
-
* without inverting digested key segments;
|
|
89
|
-
* acquisition counter that must survive
|
|
90
|
-
* cleanly released lease could reuse
|
|
91
|
-
* the ambiguous-commit recovery
|
|
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.
|
|
92
104
|
*/
|
|
93
105
|
export interface LedgerSnapshot {
|
|
94
106
|
sessionId: string;
|
|
95
107
|
head: number;
|
|
108
|
+
pending: string[];
|
|
96
109
|
lease: LedgerLease | null;
|
|
97
110
|
generation: number;
|
|
98
111
|
lastCommitId: string | null;
|
|
99
112
|
}
|
|
100
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
|
+
|
|
101
133
|
export type EnsureSessionResult = {
|
|
102
134
|
created: boolean;
|
|
103
135
|
session: SessionIdentity;
|
|
104
136
|
};
|
|
105
137
|
|
|
106
138
|
export type ClaimResult =
|
|
107
|
-
| { claimed: true; lease: LedgerLease; head: number }
|
|
139
|
+
| { claimed: true; lease: LedgerLease; head: number; pending: string[] }
|
|
108
140
|
| { claimed: false; reason: "held"; holder: string; expiresAt: number };
|
|
109
141
|
|
|
110
142
|
export type HeartbeatResult =
|
|
111
143
|
| { ok: true; expiresAt: number }
|
|
112
144
|
| { ok: false; reason: "not-found" | "fenced" };
|
|
113
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
|
+
|
|
114
153
|
export interface CommitArgs {
|
|
154
|
+
/** Event ids this turn actually consumed. Only these leave `pending`. */
|
|
155
|
+
drainedEventIds: string[];
|
|
115
156
|
/** The holder's lease generation (fences zombies). */
|
|
116
157
|
generation: number;
|
|
117
|
-
/** `head` observed at
|
|
158
|
+
/** `head` observed at drain time (fences concurrent turns). */
|
|
118
159
|
expectedHead: number;
|
|
119
160
|
/** Unique per commit attempt; enables ambiguous-outcome recovery. */
|
|
120
161
|
commitId: string;
|
|
@@ -122,18 +163,30 @@ export interface CommitArgs {
|
|
|
122
163
|
|
|
123
164
|
export interface CommitResult {
|
|
124
165
|
head: number;
|
|
166
|
+
/** Events that arrived mid-turn; non-empty ⇒ the lease was kept. */
|
|
167
|
+
pending: string[];
|
|
125
168
|
/**
|
|
126
169
|
* Whether this holder's lease is gone — for the caller's purposes, not
|
|
127
170
|
* the record's: `true` also when the re-read of an ambiguous commit
|
|
128
171
|
* finds a *new claimant's* lease, since we no longer hold the session
|
|
129
172
|
* either way and continuing the turn loop would duplicate the new
|
|
130
|
-
* holder's work.
|
|
131
|
-
* releases, so this is always `true`; it exists so callers already
|
|
132
|
-
* branch on it before the inbox makes keep-the-lease outcomes real.
|
|
173
|
+
* holder's work.
|
|
133
174
|
*/
|
|
134
175
|
leaseReleased: boolean;
|
|
135
176
|
}
|
|
136
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
|
+
|
|
137
190
|
/**
|
|
138
191
|
* The turn was fenced: a stale generation, a moved head, or a missing
|
|
139
192
|
* ledger. The caller must abort the turn and discard warm state.
|
|
@@ -160,7 +213,7 @@ export interface SessionLedgerOptions {
|
|
|
160
213
|
podId: string;
|
|
161
214
|
/** Lease TTL (default {@link LEASE_TTL_MS}). */
|
|
162
215
|
leaseTtlMs?: number;
|
|
163
|
-
/** Time source for lease expiry (default `Date.now`). */
|
|
216
|
+
/** Time source for lease expiry and blob ages (default `Date.now`). */
|
|
164
217
|
clock?: () => number;
|
|
165
218
|
/** Max attempts per CAS loop (default 16). */
|
|
166
219
|
maxCasAttempts?: number;
|
|
@@ -175,7 +228,7 @@ export interface SessionLedgerOptions {
|
|
|
175
228
|
|
|
176
229
|
async function defaultBackoff(attempt: number): Promise<void> {
|
|
177
230
|
// The common case is a single conflicting writer (one heartbeat or one
|
|
178
|
-
//
|
|
231
|
+
// append); retrying immediately wins the next round. Sleep only when
|
|
179
232
|
// contention persists.
|
|
180
233
|
if (attempt < 2) {
|
|
181
234
|
return;
|
|
@@ -185,7 +238,7 @@ async function defaultBackoff(attempt: number): Promise<void> {
|
|
|
185
238
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
186
239
|
}
|
|
187
240
|
|
|
188
|
-
/** Drives the
|
|
241
|
+
/** Drives the three ledger key families over a CAS-capable KV. */
|
|
189
242
|
export class SessionLedger {
|
|
190
243
|
private readonly kv: SessionLedgerKv;
|
|
191
244
|
private readonly podId: string;
|
|
@@ -213,9 +266,29 @@ export class SessionLedger {
|
|
|
213
266
|
return `ledger/${keySegment(sessionId)}`;
|
|
214
267
|
}
|
|
215
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
|
+
|
|
216
288
|
/**
|
|
217
289
|
* Create-or-read the session identity for a continuation key. The create
|
|
218
|
-
* race has one winner (put-if-absent); losers read the winner's identity
|
|
290
|
+
* race has one winner (put-if-absent); losers read the winner's identity
|
|
291
|
+
* and append there.
|
|
219
292
|
*/
|
|
220
293
|
async ensureSession(
|
|
221
294
|
continuationKey: string,
|
|
@@ -252,25 +325,82 @@ export class SessionLedger {
|
|
|
252
325
|
throw new LedgerCasExhaustedError("ensureSession", this.maxCasAttempts);
|
|
253
326
|
}
|
|
254
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
|
+
|
|
255
387
|
/**
|
|
256
388
|
* Try to take the session lease, creating the ledger record on first
|
|
257
389
|
* contact. Succeeds only when the lease is null or expired; the CAS
|
|
258
390
|
* bumps the acquisition generation, fencing every prior holder. A
|
|
259
|
-
* losing caller
|
|
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.
|
|
260
394
|
*/
|
|
261
395
|
async claim(sessionId: string): Promise<ClaimResult> {
|
|
262
396
|
const key = this.ledgerKey(sessionId);
|
|
263
397
|
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
264
398
|
const entry = await this.kv.getWithVersion(key);
|
|
265
399
|
if (entry === undefined) {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
generation: 0,
|
|
271
|
-
lastCommitId: null,
|
|
272
|
-
};
|
|
273
|
-
const result = await this.kv.putIfAbsent(key, toJson(initial));
|
|
400
|
+
const result = await this.kv.putIfAbsent(
|
|
401
|
+
key,
|
|
402
|
+
toJson(this.emptyLedger(sessionId))
|
|
403
|
+
);
|
|
274
404
|
// Won or lost, the record now exists — loop back and claim it.
|
|
275
405
|
if (!result.ok) {
|
|
276
406
|
await this.backoff(attempt);
|
|
@@ -303,7 +433,7 @@ export class SessionLedger {
|
|
|
303
433
|
entry.version
|
|
304
434
|
);
|
|
305
435
|
if (result.ok) {
|
|
306
|
-
return { claimed: true, lease, head: next.head };
|
|
436
|
+
return { claimed: true, lease, head: next.head, pending: next.pending };
|
|
307
437
|
}
|
|
308
438
|
await this.backoff(attempt);
|
|
309
439
|
}
|
|
@@ -346,16 +476,47 @@ export class SessionLedger {
|
|
|
346
476
|
if (result.ok) {
|
|
347
477
|
return { ok: true, expiresAt };
|
|
348
478
|
}
|
|
349
|
-
// Version conflict here is a concurrent
|
|
479
|
+
// Version conflict here is a concurrent append, not a lost lease —
|
|
350
480
|
// the re-read revalidates the generation before trying again.
|
|
351
481
|
await this.backoff(attempt);
|
|
352
482
|
}
|
|
353
483
|
throw new LedgerCasExhaustedError("heartbeat", this.maxCasAttempts);
|
|
354
484
|
}
|
|
355
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
|
+
|
|
356
514
|
/**
|
|
357
515
|
* Commit one turn. The single CAS is the commit point: it bumps `head`,
|
|
358
|
-
*
|
|
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.
|
|
359
520
|
*
|
|
360
521
|
* Ambiguous outcomes (the KV threw and we never saw the CAS result) are
|
|
361
522
|
* resolved by re-reading, never by blind-retrying: `head` advanced to
|
|
@@ -365,6 +526,7 @@ export class SessionLedger {
|
|
|
365
526
|
*/
|
|
366
527
|
async commit(sessionId: string, args: CommitArgs): Promise<CommitResult> {
|
|
367
528
|
const key = this.ledgerKey(sessionId);
|
|
529
|
+
const drained = new Set(args.drainedEventIds);
|
|
368
530
|
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
369
531
|
const entry = await this.kv.getWithVersion(key);
|
|
370
532
|
if (entry === undefined) {
|
|
@@ -379,15 +541,19 @@ export class SessionLedger {
|
|
|
379
541
|
) {
|
|
380
542
|
// A previous attempt landed even though its result was lost. The
|
|
381
543
|
// lease is released *for us* unless the observed lease is exactly
|
|
382
|
-
// ours
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// holder's.
|
|
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.
|
|
386
548
|
const stillOurs =
|
|
387
549
|
ledger.lease !== null &&
|
|
388
550
|
ledger.lease.holder === this.podId &&
|
|
389
551
|
ledger.lease.generation === args.generation;
|
|
390
|
-
return {
|
|
552
|
+
return {
|
|
553
|
+
head: ledger.head,
|
|
554
|
+
pending: ledger.pending,
|
|
555
|
+
leaseReleased: !stillOurs,
|
|
556
|
+
};
|
|
391
557
|
}
|
|
392
558
|
if (ledger.head !== args.expectedHead) {
|
|
393
559
|
throw new LedgerFencedError(
|
|
@@ -403,10 +569,13 @@ export class SessionLedger {
|
|
|
403
569
|
`commit(${sessionId}): lease generation ${args.generation} is stale`
|
|
404
570
|
);
|
|
405
571
|
}
|
|
572
|
+
const pending = ledger.pending.filter((id) => !drained.has(id));
|
|
573
|
+
const leaseReleased = pending.length === 0;
|
|
406
574
|
const next: LedgerSnapshot = {
|
|
407
575
|
...ledger,
|
|
408
576
|
head: args.expectedHead + 1,
|
|
409
|
-
|
|
577
|
+
pending,
|
|
578
|
+
lease: leaseReleased ? null : ledger.lease,
|
|
410
579
|
lastCommitId: args.commitId,
|
|
411
580
|
};
|
|
412
581
|
let result: Awaited<ReturnType<SessionLedgerKv["putIfVersion"]>>;
|
|
@@ -419,20 +588,21 @@ export class SessionLedger {
|
|
|
419
588
|
continue;
|
|
420
589
|
}
|
|
421
590
|
if (result.ok) {
|
|
422
|
-
return { head: next.head, leaseReleased
|
|
591
|
+
return { head: next.head, pending, leaseReleased };
|
|
423
592
|
}
|
|
424
|
-
// Version conflict: a concurrent
|
|
425
|
-
//
|
|
593
|
+
// Version conflict: a concurrent append. Re-read and retry — the
|
|
594
|
+
// fencing predicates re-run against the fresh value.
|
|
426
595
|
await this.backoff(attempt);
|
|
427
596
|
}
|
|
428
597
|
throw new LedgerCasExhaustedError("commit", this.maxCasAttempts);
|
|
429
598
|
}
|
|
430
599
|
|
|
431
600
|
/**
|
|
432
|
-
* Release a held lease without committing — graceful shutdown,
|
|
433
|
-
*
|
|
434
|
-
* holder's release is a no-op
|
|
435
|
-
* the
|
|
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.
|
|
436
606
|
*/
|
|
437
607
|
async release(
|
|
438
608
|
sessionId: string,
|
|
@@ -471,6 +641,92 @@ export class SessionLedger {
|
|
|
471
641
|
const entry = await this.kv.getWithVersion(this.ledgerKey(sessionId));
|
|
472
642
|
return entry === undefined ? undefined : parseLedgerSnapshot(entry.value);
|
|
473
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;
|
|
474
730
|
}
|
|
475
731
|
|
|
476
732
|
function toJson(value: SessionIdentity | LedgerSnapshot): JsonValue {
|
|
@@ -505,12 +761,32 @@ function parseLedgerSnapshot(value: JsonValue): LedgerSnapshot {
|
|
|
505
761
|
return {
|
|
506
762
|
sessionId,
|
|
507
763
|
head,
|
|
764
|
+
pending: parsePending(record.pending),
|
|
508
765
|
lease: parseLease(lease),
|
|
509
766
|
generation,
|
|
510
767
|
lastCommitId: typeof lastCommitId === "string" ? lastCommitId : null,
|
|
511
768
|
};
|
|
512
769
|
}
|
|
513
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
|
+
|
|
514
790
|
function parseLease(value: JsonValue | undefined): LedgerLease | null {
|
|
515
791
|
if (value === null || value === undefined) {
|
|
516
792
|
return null;
|
|
@@ -527,6 +803,34 @@ function parseLease(value: JsonValue | undefined): LedgerLease | null {
|
|
|
527
803
|
return { holder, generation, expiresAt };
|
|
528
804
|
}
|
|
529
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
|
+
|
|
530
834
|
function asRecord(value: JsonValue, what: string): JsonObject {
|
|
531
835
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
532
836
|
throw new Error(`session ledger: ${what} value is not an object`);
|