@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.
Files changed (145) hide show
  1. package/AGENTS.md +4 -2
  2. package/README.md +3 -1
  3. package/dist/channels/github/api.d.ts +25 -0
  4. package/dist/channels/github/api.d.ts.map +1 -1
  5. package/dist/channels/github/api.js +48 -0
  6. package/dist/channels/github/cursor-account.d.ts +7 -0
  7. package/dist/channels/github/cursor-account.d.ts.map +1 -1
  8. package/dist/channels/github/cursor-account.js +18 -0
  9. package/dist/channels/github/defaults.d.ts +1 -1
  10. package/dist/channels/github/defaults.js +1 -1
  11. package/dist/channels/github/github-channel.d.ts.map +1 -1
  12. package/dist/channels/github/github-channel.js +2 -2
  13. package/dist/channels/github/progress.d.ts +6 -5
  14. package/dist/channels/github/progress.d.ts.map +1 -1
  15. package/dist/channels/github/progress.js +58 -16
  16. package/dist/channels/github/state.d.ts +11 -0
  17. package/dist/channels/github/state.d.ts.map +1 -1
  18. package/dist/channels/github/state.js +1 -1
  19. package/dist/channels/github/types.d.ts +3 -3
  20. package/dist/docs/404.html +2 -2
  21. package/dist/docs/ab.html +3 -3
  22. package/dist/docs/assets/{app.fHnNXEJ6.js → app.C2NFTb-h.js} +1 -1
  23. package/dist/docs/assets/chunks/@localSearchIndexroot.B-FxvuoI.js +1 -0
  24. package/dist/docs/assets/chunks/{VPLocalSearchBox.Cf6ghfFY.js → VPLocalSearchBox.o1_FJGip.js} +1 -1
  25. package/dist/docs/assets/chunks/{theme.B_5-VLe8.js → theme.BcGduE7g.js} +2 -2
  26. package/dist/docs/assets/{guides_github.md.DSg0j9qj.js → guides_github.md.B8QKtIpw.js} +7 -6
  27. package/dist/docs/assets/{guides_github.md.DSg0j9qj.lean.js → guides_github.md.B8QKtIpw.lean.js} +1 -1
  28. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.js → reference_agent-config.md.DHkkvcEZ.js} +4 -4
  29. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.lean.js → reference_agent-config.md.DHkkvcEZ.lean.js} +1 -1
  30. package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.js → reference_hooks.md.DJE5DXcT.js} +1 -1
  31. package/dist/docs/assets/{storage.md.DcGSnIxX.js → storage.md.CJYd5YmN.js} +2 -2
  32. package/dist/docs/assets/{storage.md.DcGSnIxX.lean.js → storage.md.CJYd5YmN.lean.js} +1 -1
  33. package/dist/docs/building-with-agents.html +3 -3
  34. package/dist/docs/concepts.html +3 -3
  35. package/dist/docs/deployment.html +3 -3
  36. package/dist/docs/evals.html +3 -3
  37. package/dist/docs/example-agents/approval-buddy.html +3 -3
  38. package/dist/docs/example-agents/benny.html +3 -3
  39. package/dist/docs/example-agents/bugbot.html +3 -3
  40. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  41. package/dist/docs/example-agents/codeowners-review.html +3 -3
  42. package/dist/docs/example-agents/concierge.html +3 -3
  43. package/dist/docs/example-agents/fsd.html +3 -3
  44. package/dist/docs/example-agents/index.html +3 -3
  45. package/dist/docs/example-agents/knowledge-base.html +3 -3
  46. package/dist/docs/example-agents/oncall.html +3 -3
  47. package/dist/docs/example-agents/security-reviewer.html +3 -3
  48. package/dist/docs/example-agents/slack-agent.html +3 -3
  49. package/dist/docs/example-agents/weather-agent.html +3 -3
  50. package/dist/docs/guides/agent-to-agent.html +3 -3
  51. package/dist/docs/guides/cloud-runtime.html +3 -3
  52. package/dist/docs/guides/github.html +10 -9
  53. package/dist/docs/guides/human-in-the-loop.html +3 -3
  54. package/dist/docs/guides/mcp-oauth.html +3 -3
  55. package/dist/docs/guides/opentelemetry.html +3 -3
  56. package/dist/docs/guides/slack.html +3 -3
  57. package/dist/docs/guides/webhooks.html +3 -3
  58. package/dist/docs/hashmap.json +1 -1
  59. package/dist/docs/hillclimbing.html +3 -3
  60. package/dist/docs/index.html +3 -3
  61. package/dist/docs/quickstart.html +3 -3
  62. package/dist/docs/reference/agent-config.html +6 -6
  63. package/dist/docs/reference/artifacts.html +3 -3
  64. package/dist/docs/reference/channels.html +3 -3
  65. package/dist/docs/reference/cli.html +3 -3
  66. package/dist/docs/reference/connections.html +3 -3
  67. package/dist/docs/reference/hooks.html +5 -5
  68. package/dist/docs/reference/http-api.html +3 -3
  69. package/dist/docs/reference/instructions.html +3 -3
  70. package/dist/docs/reference/playground.html +3 -3
  71. package/dist/docs/reference/project-layout.html +3 -3
  72. package/dist/docs/reference/prompt.html +3 -3
  73. package/dist/docs/reference/schedules.html +3 -3
  74. package/dist/docs/reference/sessions.html +3 -3
  75. package/dist/docs/reference/skills.html +3 -3
  76. package/dist/docs/reference/subagents.html +3 -3
  77. package/dist/docs/reference/tools.html +3 -3
  78. package/dist/docs/scaffolding-agents.html +3 -3
  79. package/dist/docs/storage.html +5 -5
  80. package/dist/docs/templates/triage.html +3 -3
  81. package/dist/docs/troubleshooting.html +3 -3
  82. package/dist/index.d.ts +1 -1
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -1
  85. package/dist/internal/auth.d.ts +4 -0
  86. package/dist/internal/auth.d.ts.map +1 -1
  87. package/dist/internal/auth.js +7 -0
  88. package/dist/internal/durable-session-store.d.ts +35 -23
  89. package/dist/internal/durable-session-store.d.ts.map +1 -1
  90. package/dist/internal/durable-session-store.js +71 -26
  91. package/dist/internal/ledger-serving.d.ts +135 -67
  92. package/dist/internal/ledger-serving.d.ts.map +1 -1
  93. package/dist/internal/ledger-serving.js +326 -107
  94. package/dist/internal/server.d.ts.map +1 -1
  95. package/dist/internal/server.js +2 -2
  96. package/dist/internal/session-engine.d.ts +18 -17
  97. package/dist/internal/session-engine.d.ts.map +1 -1
  98. package/dist/internal/session-engine.js +60 -27
  99. package/dist/kv/memory-kv.d.ts +8 -0
  100. package/dist/kv/memory-kv.d.ts.map +1 -1
  101. package/dist/kv/memory-kv.js +8 -0
  102. package/dist/ledger/janitor.d.ts +60 -0
  103. package/dist/ledger/janitor.d.ts.map +1 -0
  104. package/dist/ledger/janitor.js +137 -0
  105. package/dist/ledger/session-ledger.d.ts +132 -43
  106. package/dist/ledger/session-ledger.d.ts.map +1 -1
  107. package/dist/ledger/session-ledger.js +282 -50
  108. package/dist/ledger.d.ts +9 -8
  109. package/dist/ledger.d.ts.map +1 -1
  110. package/dist/ledger.js +7 -7
  111. package/dist/playground/assets/{index-Dces8BDp.js → index-6wW6SAc4.js} +32 -32
  112. package/dist/playground/assets/index-CSRYDAFw.css +1 -0
  113. package/dist/playground/index.html +2 -2
  114. package/dist/storage-backends/cursor-hosted.d.ts +5 -0
  115. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
  116. package/dist/storage-backends/cursor-hosted.js +6 -0
  117. package/dist/types.d.ts +19 -8
  118. package/dist/types.d.ts.map +1 -1
  119. package/docs/guides/github.md +23 -18
  120. package/docs/reference/agent-config.md +31 -36
  121. package/docs/reference/hooks.md +1 -1
  122. package/docs/storage.md +26 -9
  123. package/package.json +1 -1
  124. package/src/channels/github/api.ts +122 -0
  125. package/src/channels/github/cursor-account.ts +23 -0
  126. package/src/channels/github/defaults.ts +1 -1
  127. package/src/channels/github/github-channel.ts +5 -1
  128. package/src/channels/github/progress.ts +84 -19
  129. package/src/channels/github/state.ts +14 -0
  130. package/src/channels/github/types.ts +3 -3
  131. package/src/index.ts +1 -0
  132. package/src/internal/auth.ts +10 -0
  133. package/src/internal/durable-session-store.ts +66 -27
  134. package/src/internal/ledger-serving.ts +439 -148
  135. package/src/internal/server.ts +6 -2
  136. package/src/internal/session-engine.ts +95 -37
  137. package/src/kv/memory-kv.ts +8 -0
  138. package/src/ledger/janitor.ts +193 -0
  139. package/src/ledger/session-ledger.ts +372 -68
  140. package/src/ledger.ts +21 -6
  141. package/src/storage-backends/cursor-hosted.ts +17 -2
  142. package/src/types.ts +19 -8
  143. package/dist/docs/assets/chunks/@localSearchIndexroot.BMT0xM4I.js +0 -1
  144. package/dist/playground/assets/index-CZH9EFvO.css +0 -1
  145. /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 only (no inbox).
2
+ * Session ledger — KV coordination with a durable inbox.
3
3
  *
4
- * Two key families, driven with CAS over a {@link SessionLedgerKv};
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> identity, created with put-if-absent
9
- * ledger/<sessionId> head + lease — every mutation one CAS
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
- * This is a coordination ledger: session identity, one writer per
19
- * session (leases with generation fencing), and a fenced,
20
- * ambiguity-safe commit sequence (`head`). It deliberately has **no
21
- * durable inbox** no pending list, no payload blobs, no append/drain,
22
- * no janitor. An event that arrives while the lease is held waits
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 commit is one CAS carrying the read version, the expected `head`,
30
- * and the holder's `generation` one committed transition per turn,
31
- * atomic by construction. Commit always releases the lease: with no
32
- * pending list there is nothing to keep it for.
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
@@ -52,13 +53,24 @@ import { keySegment } from "../storage.js";
52
53
  // acquisition.
53
54
  export const LEASE_TTL_MS = 30000;
54
55
  export const HEARTBEAT_INTERVAL_MS = 10000;
55
- const DEFAULT_MAX_CAS_ATTEMPTS = 16;
56
+ /** Unreferenced mailbox blobs older than this are sweepable. */
57
+ export const DEFAULT_ORPHAN_TTL_MS = 60 * 60000;
58
+ // Sized against the two-pod simulation running at this default (no
59
+ // override): 64 exhausts appendEvent under a hot session's worst-case
60
+ // interleaving, 128 passes with margin. Exhaustion mid-lease stalls the
61
+ // session for a lease TTL, so the budget errs generous — the backoff
62
+ // keeps late attempts cheap.
63
+ const DEFAULT_MAX_CAS_ATTEMPTS = 128;
56
64
  /** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
57
65
  export function supportsSessionLedgerKv(kv) {
58
66
  const candidate = kv;
59
- return (typeof candidate.getWithVersion === "function" &&
67
+ return (typeof candidate.get === "function" &&
68
+ typeof candidate.put === "function" &&
69
+ typeof candidate.delete === "function" &&
70
+ typeof candidate.getWithVersion === "function" &&
60
71
  typeof candidate.putIfAbsent === "function" &&
61
- typeof candidate.putIfVersion === "function");
72
+ typeof candidate.putIfVersion === "function" &&
73
+ typeof candidate.listKeys === "function");
62
74
  }
63
75
  /**
64
76
  * The turn was fenced: a stale generation, a moved head, or a missing
@@ -80,7 +92,7 @@ export class LedgerCasExhaustedError extends Error {
80
92
  function defaultBackoff(attempt) {
81
93
  return __awaiter(this, void 0, void 0, function* () {
82
94
  // The common case is a single conflicting writer (one heartbeat or one
83
- // claim); retrying immediately wins the next round. Sleep only when
95
+ // append); retrying immediately wins the next round. Sleep only when
84
96
  // contention persists.
85
97
  if (attempt < 2) {
86
98
  return;
@@ -90,7 +102,7 @@ function defaultBackoff(attempt) {
90
102
  yield new Promise((resolve) => setTimeout(resolve, waitMs));
91
103
  });
92
104
  }
93
- /** Drives the `session/` and `ledger/` key families over a CAS KV. */
105
+ /** Drives the three ledger key families over a CAS-capable KV. */
94
106
  export class SessionLedger {
95
107
  constructor(options) {
96
108
  var _a, _b, _c, _d, _e;
@@ -108,9 +120,26 @@ export class SessionLedger {
108
120
  ledgerKey(sessionId) {
109
121
  return `ledger/${keySegment(sessionId)}`;
110
122
  }
123
+ mailboxKey(sessionId, eventId) {
124
+ return `${this.mailboxPrefix(sessionId)}${keySegment(eventId)}`;
125
+ }
126
+ mailboxPrefix(sessionId) {
127
+ return `mailbox/${keySegment(sessionId)}/`;
128
+ }
129
+ emptyLedger(sessionId) {
130
+ return {
131
+ sessionId,
132
+ head: 0,
133
+ pending: [],
134
+ lease: null,
135
+ generation: 0,
136
+ lastCommitId: null,
137
+ };
138
+ }
111
139
  /**
112
140
  * Create-or-read the session identity for a continuation key. The create
113
- * race has one winner (put-if-absent); losers read the winner's identity.
141
+ * race has one winner (put-if-absent); losers read the winner's identity
142
+ * and append there.
114
143
  */
115
144
  ensureSession(continuationKey, init) {
116
145
  return __awaiter(this, void 0, void 0, function* () {
@@ -146,11 +175,60 @@ export class SessionLedger {
146
175
  throw new LedgerCasExhaustedError("ensureSession", this.maxCasAttempts);
147
176
  });
148
177
  }
178
+ /**
179
+ * Make one event durable: payload blob first, then one CAS indexing its
180
+ * id into `pending`. The blob must land before the index — a pending id
181
+ * with no payload would break drain, while the reverse (blob without
182
+ * index, i.e. a crash between the two writes) is a sweepable orphan.
183
+ * Idempotent by `eventId`: `pending` is a set, so redelivery re-runs are
184
+ * no-ops. The CAS in step two is the ack point — callers ack the source
185
+ * only after this resolves.
186
+ */
187
+ appendEvent(sessionId, event) {
188
+ return __awaiter(this, void 0, void 0, function* () {
189
+ var _a;
190
+ const blob = {
191
+ sessionId,
192
+ eventId: event.eventId,
193
+ envelope: event.envelope,
194
+ hookContext: (_a = event.hookContext) !== null && _a !== void 0 ? _a : null,
195
+ sourceTs: event.sourceTs,
196
+ storedAt: this.clock(),
197
+ };
198
+ yield this.kv.put(this.mailboxKey(sessionId, event.eventId), blob);
199
+ const key = this.ledgerKey(sessionId);
200
+ for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
201
+ const entry = yield this.kv.getWithVersion(key);
202
+ if (entry === undefined) {
203
+ const initial = Object.assign(Object.assign({}, this.emptyLedger(sessionId)), { pending: [event.eventId] });
204
+ const result = yield this.kv.putIfAbsent(key, toJson(initial));
205
+ if (result.ok) {
206
+ return { appended: true };
207
+ }
208
+ yield this.backoff(attempt);
209
+ continue;
210
+ }
211
+ const ledger = parseLedgerSnapshot(entry.value);
212
+ if (ledger.pending.includes(event.eventId)) {
213
+ return { appended: false };
214
+ }
215
+ const next = Object.assign(Object.assign({}, ledger), { pending: [...ledger.pending, event.eventId] });
216
+ const result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
217
+ if (result.ok) {
218
+ return { appended: true };
219
+ }
220
+ yield this.backoff(attempt);
221
+ }
222
+ throw new LedgerCasExhaustedError("appendEvent", this.maxCasAttempts);
223
+ });
224
+ }
149
225
  /**
150
226
  * Try to take the session lease, creating the ledger record on first
151
227
  * contact. Succeeds only when the lease is null or expired; the CAS
152
228
  * bumps the acquisition generation, fencing every prior holder. A
153
- * losing caller gets `reason: "held"` and decides whether to wait.
229
+ * losing caller walks away the holder's commit sees any appended
230
+ * events in the same CAS value, and the janitor recovers sessions
231
+ * whose holder died.
154
232
  */
155
233
  claim(sessionId) {
156
234
  return __awaiter(this, void 0, void 0, function* () {
@@ -158,14 +236,7 @@ export class SessionLedger {
158
236
  for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
159
237
  const entry = yield this.kv.getWithVersion(key);
160
238
  if (entry === undefined) {
161
- const initial = {
162
- sessionId,
163
- head: 0,
164
- lease: null,
165
- generation: 0,
166
- lastCommitId: null,
167
- };
168
- const result = yield this.kv.putIfAbsent(key, toJson(initial));
239
+ const result = yield this.kv.putIfAbsent(key, toJson(this.emptyLedger(sessionId)));
169
240
  // Won or lost, the record now exists — loop back and claim it.
170
241
  if (!result.ok) {
171
242
  yield this.backoff(attempt);
@@ -190,7 +261,7 @@ export class SessionLedger {
190
261
  const next = Object.assign(Object.assign({}, ledger), { generation: lease.generation, lease });
191
262
  const result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
192
263
  if (result.ok) {
193
- return { claimed: true, lease, head: next.head };
264
+ return { claimed: true, lease, head: next.head, pending: next.pending };
194
265
  }
195
266
  yield this.backoff(attempt);
196
267
  }
@@ -222,16 +293,46 @@ export class SessionLedger {
222
293
  if (result.ok) {
223
294
  return { ok: true, expiresAt };
224
295
  }
225
- // Version conflict here is a concurrent writer, not a lost lease —
296
+ // Version conflict here is a concurrent append, not a lost lease —
226
297
  // the re-read revalidates the generation before trying again.
227
298
  yield this.backoff(attempt);
228
299
  }
229
300
  throw new LedgerCasExhaustedError("heartbeat", this.maxCasAttempts);
230
301
  });
231
302
  }
303
+ /**
304
+ * Read the pending mailbox: fetch each indexed payload blob and return
305
+ * them sorted by `sourceTs`, then `eventId` (mailbox order ≠ source
306
+ * order is a stated non-guarantee; this is the drain order). The
307
+ * returned `head` is the `expectedHead` for the matching commit.
308
+ */
309
+ drain(sessionId) {
310
+ return __awaiter(this, void 0, void 0, function* () {
311
+ const entry = yield this.kv.getWithVersion(this.ledgerKey(sessionId));
312
+ if (entry === undefined) {
313
+ return { head: 0, events: [] };
314
+ }
315
+ const ledger = parseLedgerSnapshot(entry.value);
316
+ const events = [];
317
+ for (const eventId of ledger.pending) {
318
+ const blob = yield this.kv.get(this.mailboxKey(sessionId, eventId));
319
+ // Append order (blob before index) makes a missing blob impossible;
320
+ // skip defensively rather than wedge the session on corruption.
321
+ if (blob === undefined) {
322
+ continue;
323
+ }
324
+ events.push(parseLedgerEvent(blob));
325
+ }
326
+ events.sort((a, b) => a.sourceTs - b.sourceTs || compareStrings(a.eventId, b.eventId));
327
+ return { head: ledger.head, events };
328
+ });
329
+ }
232
330
  /**
233
331
  * Commit one turn. The single CAS is the commit point: it bumps `head`,
234
- * releases the lease, and records `commitId` atomically.
332
+ * removes ONLY the drained ids from `pending`, releases the lease iff
333
+ * nothing is left pending, and records `commitId` — atomically. A
334
+ * non-empty result `pending` means events landed mid-turn: the lease was
335
+ * kept and the caller drains again.
235
336
  *
236
337
  * Ambiguous outcomes (the KV threw and we never saw the CAS result) are
237
338
  * resolved by re-reading, never by blind-retrying: `head` advanced to
@@ -242,6 +343,7 @@ export class SessionLedger {
242
343
  commit(sessionId, args) {
243
344
  return __awaiter(this, void 0, void 0, function* () {
244
345
  const key = this.ledgerKey(sessionId);
346
+ const drained = new Set(args.drainedEventIds);
245
347
  for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
246
348
  const entry = yield this.kv.getWithVersion(key);
247
349
  if (entry === undefined) {
@@ -252,14 +354,18 @@ export class SessionLedger {
252
354
  ledger.lastCommitId === args.commitId) {
253
355
  // A previous attempt landed even though its result was lost. The
254
356
  // lease is released *for us* unless the observed lease is exactly
255
- // ours: a non-null lease here belongs to a claimant who took the
256
- // session after our releasing write, and reporting it as "still
257
- // held" would keep the caller's turn loop alive alongside the new
258
- // holder's.
357
+ // ours (our commit kept it for a non-empty pending): any other
358
+ // non-null lease belongs to a claimant who took the session after
359
+ // our releasing write, and reporting it as "still held" would
360
+ // keep the caller's turn loop alive alongside the new holder's.
259
361
  const stillOurs = ledger.lease !== null &&
260
362
  ledger.lease.holder === this.podId &&
261
363
  ledger.lease.generation === args.generation;
262
- return { head: ledger.head, leaseReleased: !stillOurs };
364
+ return {
365
+ head: ledger.head,
366
+ pending: ledger.pending,
367
+ leaseReleased: !stillOurs,
368
+ };
263
369
  }
264
370
  if (ledger.head !== args.expectedHead) {
265
371
  throw new LedgerFencedError(`commit(${sessionId}): head is ${ledger.head}, expected ${args.expectedHead}`);
@@ -269,7 +375,9 @@ export class SessionLedger {
269
375
  ledger.lease.generation !== args.generation) {
270
376
  throw new LedgerFencedError(`commit(${sessionId}): lease generation ${args.generation} is stale`);
271
377
  }
272
- const next = Object.assign(Object.assign({}, ledger), { head: args.expectedHead + 1, lease: null, lastCommitId: args.commitId });
378
+ const pending = ledger.pending.filter((id) => !drained.has(id));
379
+ const leaseReleased = pending.length === 0;
380
+ const next = Object.assign(Object.assign({}, ledger), { head: args.expectedHead + 1, pending, lease: leaseReleased ? null : ledger.lease, lastCommitId: args.commitId });
273
381
  let result;
274
382
  try {
275
383
  result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
@@ -281,20 +389,21 @@ export class SessionLedger {
281
389
  continue;
282
390
  }
283
391
  if (result.ok) {
284
- return { head: next.head, leaseReleased: true };
392
+ return { head: next.head, pending, leaseReleased };
285
393
  }
286
- // Version conflict: a concurrent writer moved the record. Re-read
287
- // and retry — the fencing predicates re-run against the fresh value.
394
+ // Version conflict: a concurrent append. Re-read and retry — the
395
+ // fencing predicates re-run against the fresh value.
288
396
  yield this.backoff(attempt);
289
397
  }
290
398
  throw new LedgerCasExhaustedError("commit", this.maxCasAttempts);
291
399
  });
292
400
  }
293
401
  /**
294
- * Release a held lease without committing — graceful shutdown, or a
295
- * turn that failed before its commit. Fenced by generation: a stale
296
- * holder's release is a no-op (the session has moved on). `head` and
297
- * the acquisition counter are untouched, so prior holders stay fenced.
402
+ * Release a held lease without committing — graceful shutdown, a turn
403
+ * that failed before its commit, or a claim that found nothing to
404
+ * drain. Fenced by generation: a stale holder's release is a no-op
405
+ * (the session has moved on). `head` and the acquisition counter are
406
+ * untouched, so prior holders stay fenced.
298
407
  */
299
408
  release(sessionId, generation) {
300
409
  return __awaiter(this, void 0, void 0, function* () {
@@ -327,6 +436,87 @@ export class SessionLedger {
327
436
  return entry === undefined ? undefined : parseLedgerSnapshot(entry.value);
328
437
  });
329
438
  }
439
+ /**
440
+ * All ledgers under this prefix (the janitor's scan). An unparseable
441
+ * record is skipped — reported through `onSkip` — never thrown: one
442
+ * corrupt value must degrade that one session, not abort the scan and
443
+ * kill the whole agent's recovery pass.
444
+ */
445
+ listLedgers(onSkip) {
446
+ return __awaiter(this, void 0, void 0, function* () {
447
+ const keys = yield this.kv.listKeys("ledger/");
448
+ const ledgers = [];
449
+ for (const key of keys) {
450
+ const entry = yield this.kv.getWithVersion(key);
451
+ if (entry === undefined) {
452
+ continue;
453
+ }
454
+ try {
455
+ ledgers.push(parseLedgerSnapshot(entry.value));
456
+ }
457
+ catch (error) {
458
+ onSkip === null || onSkip === void 0 ? void 0 : onSkip(key, error);
459
+ }
460
+ }
461
+ return ledgers;
462
+ });
463
+ }
464
+ /**
465
+ * Delete mailbox blobs that are (a) not referenced by their session's
466
+ * `pending` and (b) older than the TTL. Covers both true orphans (a
467
+ * crash between the payload put and the ledger CAS) and consumed blobs
468
+ * whose ids a commit already removed. Membership is checked against a
469
+ * ledger read taken *after* listing the blobs, so a blob whose append
470
+ * CAS is in flight is never swept: its id is either already in
471
+ * `pending`, or the blob is younger than any sane TTL.
472
+ */
473
+ sweepOrphans(args) {
474
+ return __awaiter(this, void 0, void 0, function* () {
475
+ var _a, _b, _c;
476
+ const olderThanMs = (_a = args === null || args === void 0 ? void 0 : args.olderThanMs) !== null && _a !== void 0 ? _a : DEFAULT_ORPHAN_TTL_MS;
477
+ const prefix = (args === null || args === void 0 ? void 0 : args.sessionId) === undefined
478
+ ? "mailbox/"
479
+ : this.mailboxPrefix(args.sessionId);
480
+ const keys = yield this.kv.listKeys(prefix);
481
+ const now = this.clock();
482
+ // Blob contents carry sessionId/eventId (key segments may be digested
483
+ // and are not invertible), so group by reading each blob.
484
+ const bySession = new Map();
485
+ for (const key of keys) {
486
+ const value = yield this.kv.get(key);
487
+ if (value === undefined) {
488
+ continue;
489
+ }
490
+ const event = parseLedgerEvent(value);
491
+ const sessionId = readBlobSessionId(value);
492
+ const group = (_b = bySession.get(sessionId)) !== null && _b !== void 0 ? _b : [];
493
+ group.push({ key, event });
494
+ bySession.set(sessionId, group);
495
+ }
496
+ const deleted = [];
497
+ for (const [sessionId, blobs] of bySession) {
498
+ const ledger = yield this.readLedger(sessionId);
499
+ const pending = new Set((_c = ledger === null || ledger === void 0 ? void 0 : ledger.pending) !== null && _c !== void 0 ? _c : []);
500
+ for (const { key, event } of blobs) {
501
+ if (pending.has(event.eventId)) {
502
+ continue;
503
+ }
504
+ if (event.storedAt + olderThanMs > now) {
505
+ continue;
506
+ }
507
+ yield this.kv.delete(key);
508
+ deleted.push(`${sessionId}/${event.eventId}`);
509
+ }
510
+ }
511
+ return { deleted };
512
+ });
513
+ }
514
+ }
515
+ function compareStrings(a, b) {
516
+ if (a < b) {
517
+ return -1;
518
+ }
519
+ return a > b ? 1 : 0;
330
520
  }
331
521
  function toJson(value) {
332
522
  return value;
@@ -356,11 +546,28 @@ function parseLedgerSnapshot(value) {
356
546
  return {
357
547
  sessionId,
358
548
  head,
549
+ pending: parsePending(record.pending),
359
550
  lease: parseLease(lease),
360
551
  generation,
361
552
  lastCommitId: typeof lastCommitId === "string" ? lastCommitId : null,
362
553
  };
363
554
  }
555
+ /**
556
+ * Records written by the pre-inbox ledger have no `pending` field; read
557
+ * them as an empty inbox instead of rejecting them — one legacy record
558
+ * must not brick `claim`/`readLedger` (or a whole `listLedgers` scan)
559
+ * after an upgrade. Every write from the inbox on includes the field.
560
+ */
561
+ function parsePending(value) {
562
+ if (value === undefined) {
563
+ return [];
564
+ }
565
+ if (!Array.isArray(value) ||
566
+ !value.every((id) => typeof id === "string")) {
567
+ throw new Error("session ledger: malformed ledger value");
568
+ }
569
+ return value;
570
+ }
364
571
  function parseLease(value) {
365
572
  if (value === null || value === undefined) {
366
573
  return null;
@@ -374,6 +581,31 @@ function parseLease(value) {
374
581
  }
375
582
  return { holder, generation, expiresAt };
376
583
  }
584
+ function parseLedgerEvent(value) {
585
+ var _a, _b;
586
+ const record = asRecord(value, "mailbox blob");
587
+ const { eventId, sourceTs, storedAt } = record;
588
+ if (typeof eventId !== "string" ||
589
+ typeof sourceTs !== "number" ||
590
+ typeof storedAt !== "number") {
591
+ throw new Error("session ledger: malformed mailbox blob");
592
+ }
593
+ return {
594
+ eventId,
595
+ envelope: (_a = record.envelope) !== null && _a !== void 0 ? _a : null,
596
+ hookContext: (_b = record.hookContext) !== null && _b !== void 0 ? _b : null,
597
+ sourceTs,
598
+ storedAt,
599
+ };
600
+ }
601
+ function readBlobSessionId(value) {
602
+ const record = asRecord(value, "mailbox blob");
603
+ const sessionId = record.sessionId;
604
+ if (typeof sessionId !== "string") {
605
+ throw new Error("session ledger: mailbox blob is missing sessionId");
606
+ }
607
+ return sessionId;
608
+ }
377
609
  function asRecord(value, what) {
378
610
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
379
611
  throw new Error(`session ledger: ${what} value is not an object`);
package/dist/ledger.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Session ledger — coordination only. The protocol library that drives
3
- * the `session/` and `ledger/` key families over a CAS-capable KV:
4
- * identity creation, lease claim/heartbeat/release, and the fenced
5
- * commit sequence. The durable inbox (pending events, mailbox payloads,
6
- * append/drain, the janitor) lands in a follow-up; see
7
- * `src/ledger/session-ledger.ts` for the trade-off this stage accepts.
2
+ * Session ledger — KV coordination with a durable inbox. The protocol
3
+ * library that drives the `session/`, `ledger/`, and `mailbox/` key
4
+ * families over a CAS-capable KV: identity creation, event append,
5
+ * lease claim/heartbeat/release, fenced commit, drain, the janitor
6
+ * loop, and the orphan sweep.
8
7
  */
9
- export type { ClaimResult, CommitArgs, CommitResult, EnsureSessionResult, HeartbeatResult, LedgerLease, LedgerSnapshot, SessionIdentity, SessionLedgerKv, SessionLedgerOptions, } from "./ledger/session-ledger.js";
10
- export { HEARTBEAT_INTERVAL_MS, LEASE_TTL_MS, LedgerCasExhaustedError, LedgerFencedError, SessionLedger, supportsSessionLedgerKv, } from "./ledger/session-ledger.js";
8
+ export type { JanitorClaimedSession, JanitorRunResult, LedgerJanitor, LedgerJanitorOptions, } from "./ledger/janitor.js";
9
+ export { createLedgerJanitor, DEFAULT_JANITOR_INTERVAL_MS, } from "./ledger/janitor.js";
10
+ export type { ClaimResult, CommitArgs, CommitResult, DrainResult, EnsureSessionResult, HeartbeatResult, LedgerEvent, LedgerEventInput, LedgerLease, LedgerSnapshot, SessionIdentity, SessionLedgerKv, SessionLedgerOptions, SweepOrphansArgs, SweepOrphansResult, } from "./ledger/session-ledger.js";
11
+ export { DEFAULT_ORPHAN_TTL_MS, HEARTBEAT_INTERVAL_MS, LEASE_TTL_MS, LedgerCasExhaustedError, LedgerFencedError, SessionLedger, supportsSessionLedgerKv, } from "./ledger/session-ledger.js";
11
12
  //# sourceMappingURL=ledger.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,YAAY,EACV,WAAW,EACX,UAAU,EACV,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,WAAW,EACX,cAAc,EACd,eAAe,EACf,eAAe,EACf,oBAAoB,GACrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,qBAAqB,EACrB,YAAY,EACZ,uBAAuB,EACvB,iBAAiB,EACjB,aAAa,EACb,uBAAuB,GACxB,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,YAAY,EACV,qBAAqB,EACrB,gBAAgB,EAChB,aAAa,EACb,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,mBAAmB,EACnB,2BAA2B,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,WAAW,EACX,UAAU,EACV,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,WAAW,EACX,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,EACZ,uBAAuB,EACvB,iBAAiB,EACjB,aAAa,EACb,uBAAuB,GACxB,MAAM,4BAA4B,CAAC"}
package/dist/ledger.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Session ledger — coordination only. The protocol library that drives
3
- * the `session/` and `ledger/` key families over a CAS-capable KV:
4
- * identity creation, lease claim/heartbeat/release, and the fenced
5
- * commit sequence. The durable inbox (pending events, mailbox payloads,
6
- * append/drain, the janitor) lands in a follow-up; see
7
- * `src/ledger/session-ledger.ts` for the trade-off this stage accepts.
2
+ * Session ledger — KV coordination with a durable inbox. The protocol
3
+ * library that drives the `session/`, `ledger/`, and `mailbox/` key
4
+ * families over a CAS-capable KV: identity creation, event append,
5
+ * lease claim/heartbeat/release, fenced commit, drain, the janitor
6
+ * loop, and the orphan sweep.
8
7
  */
9
- export { HEARTBEAT_INTERVAL_MS, LEASE_TTL_MS, LedgerCasExhaustedError, LedgerFencedError, SessionLedger, supportsSessionLedgerKv, } from "./ledger/session-ledger.js";
8
+ export { createLedgerJanitor, DEFAULT_JANITOR_INTERVAL_MS, } from "./ledger/janitor.js";
9
+ export { DEFAULT_ORPHAN_TTL_MS, HEARTBEAT_INTERVAL_MS, LEASE_TTL_MS, LedgerCasExhaustedError, LedgerFencedError, SessionLedger, supportsSessionLedgerKv, } from "./ledger/session-ledger.js";