@cursor/july 0.1.39 → 0.1.41
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 -3
- package/dist/bin/agent-serve.js +28 -9
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +18 -8
- package/dist/channels/slack/attachments.d.ts +82 -0
- package/dist/channels/slack/attachments.d.ts.map +1 -0
- package/dist/channels/slack/attachments.js +520 -0
- package/dist/channels/slack/cursor-account.d.ts +7 -1
- package/dist/channels/slack/cursor-account.d.ts.map +1 -1
- package/dist/channels/slack/cursor-account.js +2 -2
- package/dist/channels/slack/defaults.d.ts.map +1 -1
- package/dist/channels/slack/defaults.js +26 -17
- package/dist/channels/slack/dispatch.d.ts +4 -3
- package/dist/channels/slack/dispatch.d.ts.map +1 -1
- package/dist/channels/slack/dispatch.js +84 -21
- package/dist/channels/slack/inbound.d.ts +6 -0
- package/dist/channels/slack/inbound.d.ts.map +1 -1
- package/dist/channels/slack/inbound.js +48 -2
- package/dist/channels/slack/index.d.ts +1 -0
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +1 -0
- package/dist/channels/slack/live-delivery.d.ts.map +1 -1
- package/dist/channels/slack/live-delivery.js +16 -4
- package/dist/channels/slack/manifest.d.ts.map +1 -1
- package/dist/channels/slack/manifest.js +1 -0
- package/dist/channels/slack/slack-channel.d.ts.map +1 -1
- package/dist/channels/slack/slack-channel.js +7 -4
- package/dist/channels/slack/types.d.ts +49 -1
- package/dist/channels/slack/types.d.ts.map +1 -1
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +6 -6
- package/dist/docs/assets/{ab.md.6cLOW7--.js → ab.md.hdxsvX5M.js} +2 -2
- package/dist/docs/assets/{app.BQ8Hihdf.js → app.mIw24pb1.js} +1 -1
- package/dist/docs/assets/{building-with-agents.md.txrcGU2B.js → building-with-agents.md.PeZaZA1P.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.BA7V8dEJ.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.BBCr8Yuy.js → VPLocalSearchBox.hOzpPwrG.js} +1 -1
- package/dist/docs/assets/chunks/{theme.CK_NiGC-.js → theme.BtFhUYmh.js} +2 -2
- package/dist/docs/assets/{deployment.md.CuK5SNjN.js → deployment.md.BHVjVqRa.js} +4 -4
- package/dist/docs/assets/{deployment.md.CuK5SNjN.lean.js → deployment.md.BHVjVqRa.lean.js} +1 -1
- package/dist/docs/assets/{evals.md.BQXI3rXy.js → evals.md.qeoGgPw0.js} +3 -3
- package/dist/docs/assets/example-agents_index.md.xmJ87d_3.js +2 -0
- package/dist/docs/assets/{guides_webhooks.md.BERuBSJW.js → guides_webhooks.md.DB-r_er9.js} +47 -4
- package/dist/docs/assets/{guides_webhooks.md.BERuBSJW.lean.js → guides_webhooks.md.DB-r_er9.lean.js} +1 -1
- package/dist/docs/assets/index.md.BKpbkCa6.js +24 -0
- package/dist/docs/assets/{index.md.CmhptOmN.lean.js → index.md.BKpbkCa6.lean.js} +1 -1
- package/dist/docs/assets/{quickstart.md.C_b6ESpD.js → quickstart.md.rIbfahJM.js} +4 -4
- package/dist/docs/assets/{reference_cli.md.Byvrg8eu.js → reference_cli.md.DqEqQpGn.js} +5 -5
- package/dist/docs/assets/{reference_cli.md.Byvrg8eu.lean.js → reference_cli.md.DqEqQpGn.lean.js} +1 -1
- package/dist/docs/assets/{reference_hooks.md.BGDw4VLm.js → reference_hooks.md.BHeG-vjK.js} +2 -2
- package/dist/docs/assets/{reference_hooks.md.BGDw4VLm.lean.js → reference_hooks.md.BHeG-vjK.lean.js} +1 -1
- package/dist/docs/assets/{storage.md.mQDtIULc.js → storage.md.DcGSnIxX.js} +10 -4
- package/dist/docs/assets/{storage.md.mQDtIULc.lean.js → storage.md.DcGSnIxX.lean.js} +1 -1
- package/dist/docs/assets/troubleshooting.md.CkgwmVI0.js +1 -0
- package/dist/docs/assets/{troubleshooting.md.B5RVX_tL.lean.js → troubleshooting.md.CkgwmVI0.lean.js} +1 -1
- package/dist/docs/building-with-agents.html +5 -5
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +6 -6
- package/dist/docs/evals.html +7 -7
- 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 +5 -5
- 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 +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/slack.html +3 -3
- package/dist/docs/guides/webhooks.html +49 -6
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +7 -7
- package/dist/docs/quickstart.html +8 -8
- 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 +8 -8
- 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 +14 -8
- package/dist/docs/troubleshooting.html +5 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/internal/cli-ax.d.ts.map +1 -1
- package/dist/internal/cli-ax.js +22 -2
- package/dist/internal/cli-deploy.d.ts +6 -0
- package/dist/internal/cli-deploy.d.ts.map +1 -1
- package/dist/internal/cli-deploy.js +14 -4
- package/dist/internal/cli-mcp-oauth.d.ts.map +1 -1
- package/dist/internal/cli-mcp-oauth.js +3 -1
- package/dist/internal/cli-mcp.js +4 -4
- package/dist/internal/cursor-event-relay.d.ts +45 -0
- package/dist/internal/cursor-event-relay.d.ts.map +1 -1
- package/dist/internal/cursor-event-relay.js +96 -2
- package/dist/internal/deploy-client.js +1 -1
- package/dist/internal/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +35 -7
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +3 -1
- package/dist/internal/durable-session-store.d.ts +48 -0
- package/dist/internal/durable-session-store.d.ts.map +1 -0
- package/dist/internal/durable-session-store.js +86 -0
- package/dist/internal/hosting.d.ts +1 -1
- package/dist/internal/hosting.js +2 -2
- package/dist/internal/init-project.d.ts +34 -0
- package/dist/internal/init-project.d.ts.map +1 -1
- package/dist/internal/init-project.js +121 -2
- package/dist/internal/ledger-serving.d.ts +101 -0
- package/dist/internal/ledger-serving.d.ts.map +1 -0
- package/dist/internal/ledger-serving.js +174 -0
- package/dist/internal/listen-port.d.ts +22 -0
- package/dist/internal/listen-port.d.ts.map +1 -0
- package/dist/internal/listen-port.js +77 -0
- package/dist/internal/mcp-oauth.d.ts +12 -1
- package/dist/internal/mcp-oauth.d.ts.map +1 -1
- package/dist/internal/mcp-oauth.js +25 -3
- package/dist/internal/playground/static.d.ts +21 -4
- package/dist/internal/playground/static.d.ts.map +1 -1
- package/dist/internal/playground/static.js +82 -18
- package/dist/internal/sdk-runner.d.ts.map +1 -1
- package/dist/internal/sdk-runner.js +10 -1
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +67 -49
- package/dist/internal/session-engine.d.ts +57 -1
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +117 -28
- package/dist/internal/slack-provision-client.js +1 -1
- package/dist/internal/workspace.d.ts +6 -2
- package/dist/internal/workspace.d.ts.map +1 -1
- package/dist/internal/workspace.js +10 -2
- package/dist/kv/memory-kv.d.ts +23 -0
- package/dist/kv/memory-kv.d.ts.map +1 -0
- package/dist/kv/memory-kv.js +103 -0
- package/dist/kv.d.ts +22 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +20 -0
- package/dist/ledger/session-ledger.d.ts +204 -0
- package/dist/ledger/session-ledger.d.ts.map +1 -0
- package/dist/ledger/session-ledger.js +382 -0
- package/dist/ledger.d.ts +11 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +9 -0
- package/dist/playground/assets/index-B3HBneZZ.js +85 -0
- package/dist/playground/assets/index-Bqk9nplh.css +1 -0
- package/dist/playground/index.html +2 -2
- package/dist/storage-backends/cursor-hosted.d.ts +25 -3
- package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
- package/dist/storage-backends/cursor-hosted.js +50 -2
- package/dist/storage-backends/file-kv.d.ts +5 -2
- package/dist/storage-backends/file-kv.d.ts.map +1 -1
- package/dist/storage-backends/file-kv.js +107 -7
- package/dist/storage.d.ts +60 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +28 -4
- package/dist/types.d.ts +123 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -0
- package/docs/README.md +10 -7
- package/docs/ab.md +2 -2
- package/docs/building-with-agents.md +1 -1
- package/docs/deployment.md +34 -11
- package/docs/evals.md +3 -3
- package/docs/example-agents/index.md +1 -1
- package/docs/guides/webhooks.md +87 -0
- package/docs/quickstart.md +7 -8
- package/docs/reference/cli.md +15 -6
- package/docs/reference/hooks.md +8 -2
- package/docs/storage.md +67 -7
- package/docs/troubleshooting.md +10 -2
- package/package.json +20 -7
- package/skills/create-agent/SKILL.md +1 -1
- package/src/bin/agent-serve.ts +27 -11
- package/src/channels/slack/api.ts +19 -14
- package/src/channels/slack/attachments.ts +635 -0
- package/src/channels/slack/cursor-account.ts +8 -0
- package/src/channels/slack/defaults.ts +34 -18
- package/src/channels/slack/dispatch.ts +121 -6
- package/src/channels/slack/inbound.ts +60 -0
- package/src/channels/slack/index.ts +1 -0
- package/src/channels/slack/live-delivery.ts +17 -3
- package/src/channels/slack/manifest.ts +1 -0
- package/src/channels/slack/slack-channel.ts +7 -0
- package/src/channels/slack/types.ts +47 -1
- package/src/index.ts +9 -0
- package/src/internal/cli-ax.ts +25 -1
- package/src/internal/cli-deploy.ts +22 -4
- package/src/internal/cli-mcp-oauth.ts +3 -1
- package/src/internal/cli-mcp.ts +4 -4
- package/src/internal/cursor-event-relay.ts +168 -0
- package/src/internal/deploy-client.ts +1 -1
- package/src/internal/discovery.ts +42 -5
- package/src/internal/distribution.ts +3 -1
- package/src/internal/durable-session-store.ts +95 -0
- package/src/internal/hosting.ts +2 -2
- package/src/internal/init-project.ts +151 -2
- package/src/internal/ledger-serving.ts +240 -0
- package/src/internal/listen-port.ts +85 -0
- package/src/internal/mcp-oauth.ts +29 -4
- package/src/internal/playground/static.ts +111 -20
- package/src/internal/sdk-runner.ts +11 -1
- package/src/internal/server.ts +76 -62
- package/src/internal/session-engine.ts +192 -16
- package/src/internal/slack-provision-client.ts +1 -1
- package/src/internal/workspace.ts +14 -3
- package/src/kv/memory-kv.ts +99 -0
- package/src/kv.ts +27 -0
- package/src/ledger/session-ledger.ts +535 -0
- package/src/ledger.ts +29 -0
- package/src/storage-backends/cursor-hosted.ts +108 -3
- package/src/storage-backends/file-kv.ts +128 -12
- package/src/storage.ts +90 -6
- package/src/types.ts +130 -4
- package/dist/channels/slack/post-update-delivery.d.ts +0 -85
- package/dist/channels/slack/post-update-delivery.d.ts.map +0 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.B-VTH4As.js +0 -1
- package/dist/docs/assets/example-agents_index.md.DgGBwckv.js +0 -2
- package/dist/docs/assets/index.md.CmhptOmN.js +0 -24
- package/dist/docs/assets/troubleshooting.md.B5RVX_tL.js +0 -1
- package/dist/internal/json-dir-store.d.ts +0 -32
- package/dist/internal/json-dir-store.d.ts.map +0 -1
- package/dist/internal/persistence-coordinator.d.ts +0 -127
- package/dist/internal/persistence-coordinator.d.ts.map +0 -1
- package/dist/multi-tenant.d.ts +0 -80
- package/dist/multi-tenant.d.ts.map +0 -1
- package/dist/multi-tenant.js +0 -69
- package/dist/persistence.d.ts +0 -184
- package/dist/persistence.d.ts.map +0 -1
- package/dist/playground/assets/index-BRhBbnd4.css +0 -1
- package/dist/playground/assets/index-_2WKw9a-.js +0 -85
- package/dist/storage-backends/postgres-kv.d.ts +0 -82
- package/dist/storage-backends/postgres-kv.d.ts.map +0 -1
- package/dist/storage-backends/postgres-kv.js +0 -185
- package/src/storage-backends/postgres-kv.ts +0 -253
- /package/dist/docs/assets/{ab.md.6cLOW7--.lean.js → ab.md.hdxsvX5M.lean.js} +0 -0
- /package/dist/docs/assets/{building-with-agents.md.txrcGU2B.lean.js → building-with-agents.md.PeZaZA1P.lean.js} +0 -0
- /package/dist/docs/assets/{evals.md.BQXI3rXy.lean.js → evals.md.qeoGgPw0.lean.js} +0 -0
- /package/dist/docs/assets/{example-agents_index.md.DgGBwckv.lean.js → example-agents_index.md.xmJ87d_3.lean.js} +0 -0
- /package/dist/docs/assets/{quickstart.md.C_b6ESpD.lean.js → quickstart.md.rIbfahJM.lean.js} +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ledger — coordination only (no inbox).
|
|
3
|
+
*
|
|
4
|
+
* Two key families, driven with CAS over a {@link SessionLedgerKv};
|
|
5
|
+
* agents never touch these keys directly:
|
|
6
|
+
*
|
|
7
|
+
* ```
|
|
8
|
+
* session/<continuationKey> identity, created with put-if-absent
|
|
9
|
+
* ledger/<sessionId> head + lease — every mutation one CAS
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The keys are deliberately bare: namespacing (per agent, per
|
|
13
|
+
* deployment) is not the ledger's concern. The store handle it is given
|
|
14
|
+
* must already be scoped — the engine applies the namespace exactly once
|
|
15
|
+
* where it constructs the handle (see `durable-session-store.ts`), the
|
|
16
|
+
* same boundary where `host.kv` gets its agent scoping.
|
|
17
|
+
*
|
|
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.
|
|
28
|
+
*
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* Clocks: lease expiry compares `expiresAt` against {@link
|
|
35
|
+
* SessionLedgerOptions.clock} (default `Date.now`). Self-hosted N-pod
|
|
36
|
+
* deployments accept NTP-synced pods as a documented requirement; a
|
|
37
|
+
* backend-clock primitive can replace the default without touching the
|
|
38
|
+
* protocol code.
|
|
39
|
+
*/
|
|
40
|
+
import type { HostKvCasApi } from "../types.js";
|
|
41
|
+
export declare const LEASE_TTL_MS = 30000;
|
|
42
|
+
export declare const HEARTBEAT_INTERVAL_MS = 10000;
|
|
43
|
+
/**
|
|
44
|
+
* Exactly the KV surface the coordination-only ledger uses. `listKeys`
|
|
45
|
+
* is deliberately absent: nothing here scans, so a store must not be
|
|
46
|
+
* rejected for lacking it.
|
|
47
|
+
*/
|
|
48
|
+
export type SessionLedgerKv = Pick<HostKvCasApi, "getWithVersion" | "putIfAbsent" | "putIfVersion">;
|
|
49
|
+
/** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
|
|
50
|
+
export declare function supportsSessionLedgerKv(kv: object): kv is SessionLedgerKv;
|
|
51
|
+
/** `session/<continuationKey>` value. Immutable after creation. */
|
|
52
|
+
export interface SessionIdentity {
|
|
53
|
+
sessionId: string;
|
|
54
|
+
sdkSessionRef: string | null;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
}
|
|
57
|
+
export interface LedgerLease {
|
|
58
|
+
holder: string;
|
|
59
|
+
generation: number;
|
|
60
|
+
expiresAt: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `ledger/<sessionId>` value: `{ sessionId, head, lease, generation,
|
|
64
|
+
* lastCommitId }`. `sessionId` lets a future scan address sessions
|
|
65
|
+
* without inverting digested key segments; `generation` is a monotonic
|
|
66
|
+
* acquisition counter that must survive lease release, or a zombie of a
|
|
67
|
+
* cleanly released lease could reuse its generation; `lastCommitId` is
|
|
68
|
+
* the ambiguous-commit recovery token.
|
|
69
|
+
*/
|
|
70
|
+
export interface LedgerSnapshot {
|
|
71
|
+
sessionId: string;
|
|
72
|
+
head: number;
|
|
73
|
+
lease: LedgerLease | null;
|
|
74
|
+
generation: number;
|
|
75
|
+
lastCommitId: string | null;
|
|
76
|
+
}
|
|
77
|
+
export type EnsureSessionResult = {
|
|
78
|
+
created: boolean;
|
|
79
|
+
session: SessionIdentity;
|
|
80
|
+
};
|
|
81
|
+
export type ClaimResult = {
|
|
82
|
+
claimed: true;
|
|
83
|
+
lease: LedgerLease;
|
|
84
|
+
head: number;
|
|
85
|
+
} | {
|
|
86
|
+
claimed: false;
|
|
87
|
+
reason: "held";
|
|
88
|
+
holder: string;
|
|
89
|
+
expiresAt: number;
|
|
90
|
+
};
|
|
91
|
+
export type HeartbeatResult = {
|
|
92
|
+
ok: true;
|
|
93
|
+
expiresAt: number;
|
|
94
|
+
} | {
|
|
95
|
+
ok: false;
|
|
96
|
+
reason: "not-found" | "fenced";
|
|
97
|
+
};
|
|
98
|
+
export interface CommitArgs {
|
|
99
|
+
/** The holder's lease generation (fences zombies). */
|
|
100
|
+
generation: number;
|
|
101
|
+
/** `head` observed at claim time (fences concurrent turns). */
|
|
102
|
+
expectedHead: number;
|
|
103
|
+
/** Unique per commit attempt; enables ambiguous-outcome recovery. */
|
|
104
|
+
commitId: string;
|
|
105
|
+
}
|
|
106
|
+
export interface CommitResult {
|
|
107
|
+
head: number;
|
|
108
|
+
/**
|
|
109
|
+
* Whether this holder's lease is gone — for the caller's purposes, not
|
|
110
|
+
* the record's: `true` also when the re-read of an ambiguous commit
|
|
111
|
+
* finds a *new claimant's* lease, since we no longer hold the session
|
|
112
|
+
* either way and continuing the turn loop would duplicate the new
|
|
113
|
+
* holder's work. On this coordination-only stage every commit
|
|
114
|
+
* releases, so this is always `true`; it exists so callers already
|
|
115
|
+
* branch on it before the inbox makes keep-the-lease outcomes real.
|
|
116
|
+
*/
|
|
117
|
+
leaseReleased: boolean;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The turn was fenced: a stale generation, a moved head, or a missing
|
|
121
|
+
* ledger. The caller must abort the turn and discard warm state.
|
|
122
|
+
*/
|
|
123
|
+
export declare class LedgerFencedError extends Error {
|
|
124
|
+
constructor(message: string);
|
|
125
|
+
}
|
|
126
|
+
/** A CAS retry loop ran out of attempts (sustained contention or outage). */
|
|
127
|
+
export declare class LedgerCasExhaustedError extends Error {
|
|
128
|
+
constructor(operation: string, attempts: number);
|
|
129
|
+
}
|
|
130
|
+
export interface SessionLedgerOptions {
|
|
131
|
+
/** Already-scoped store handle; the ledger writes bare keys into it. */
|
|
132
|
+
kv: SessionLedgerKv;
|
|
133
|
+
/** Stable identity of this pod/process — the lease `holder`. */
|
|
134
|
+
podId: string;
|
|
135
|
+
/** Lease TTL (default {@link LEASE_TTL_MS}). */
|
|
136
|
+
leaseTtlMs?: number;
|
|
137
|
+
/** Time source for lease expiry (default `Date.now`). */
|
|
138
|
+
clock?: () => number;
|
|
139
|
+
/** Max attempts per CAS loop (default 16). */
|
|
140
|
+
maxCasAttempts?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Wait between CAS retries. Default: immediate for the first retries,
|
|
143
|
+
* then a jittered exponential capped at 250ms.
|
|
144
|
+
*/
|
|
145
|
+
backoff?: (attempt: number) => Promise<void>;
|
|
146
|
+
/** Session id mint (default `ses_<24 hex>` like the session engine). */
|
|
147
|
+
newSessionId?: () => string;
|
|
148
|
+
}
|
|
149
|
+
/** Drives the `session/` and `ledger/` key families over a CAS KV. */
|
|
150
|
+
export declare class SessionLedger {
|
|
151
|
+
private readonly kv;
|
|
152
|
+
private readonly podId;
|
|
153
|
+
private readonly leaseTtlMs;
|
|
154
|
+
private readonly clock;
|
|
155
|
+
private readonly maxCasAttempts;
|
|
156
|
+
private readonly backoff;
|
|
157
|
+
private readonly mintSessionId;
|
|
158
|
+
constructor(options: SessionLedgerOptions);
|
|
159
|
+
private sessionKey;
|
|
160
|
+
private ledgerKey;
|
|
161
|
+
/**
|
|
162
|
+
* Create-or-read the session identity for a continuation key. The create
|
|
163
|
+
* race has one winner (put-if-absent); losers read the winner's identity.
|
|
164
|
+
*/
|
|
165
|
+
ensureSession(continuationKey: string, init?: {
|
|
166
|
+
sdkSessionRef?: string | null;
|
|
167
|
+
}): Promise<EnsureSessionResult>;
|
|
168
|
+
/**
|
|
169
|
+
* Try to take the session lease, creating the ledger record on first
|
|
170
|
+
* contact. Succeeds only when the lease is null or expired; the CAS
|
|
171
|
+
* bumps the acquisition generation, fencing every prior holder. A
|
|
172
|
+
* losing caller gets `reason: "held"` and decides whether to wait.
|
|
173
|
+
*/
|
|
174
|
+
claim(sessionId: string): Promise<ClaimResult>;
|
|
175
|
+
/**
|
|
176
|
+
* Extend the lease. Fails when the generation is no longer ours — the
|
|
177
|
+
* caller must abort the turn and discard warm state; the session has a
|
|
178
|
+
* new legitimate holder.
|
|
179
|
+
*/
|
|
180
|
+
heartbeat(sessionId: string, generation: number): Promise<HeartbeatResult>;
|
|
181
|
+
/**
|
|
182
|
+
* Commit one turn. The single CAS is the commit point: it bumps `head`,
|
|
183
|
+
* releases the lease, and records `commitId` — atomically.
|
|
184
|
+
*
|
|
185
|
+
* Ambiguous outcomes (the KV threw and we never saw the CAS result) are
|
|
186
|
+
* resolved by re-reading, never by blind-retrying: `head` advanced to
|
|
187
|
+
* `expectedHead + 1` with our `commitId` ⇒ our write landed, report
|
|
188
|
+
* success; `head` still at `expectedHead` with our lease ⇒ it did not,
|
|
189
|
+
* retry against the fresh version; anything else ⇒ fenced.
|
|
190
|
+
*/
|
|
191
|
+
commit(sessionId: string, args: CommitArgs): Promise<CommitResult>;
|
|
192
|
+
/**
|
|
193
|
+
* Release a held lease without committing — graceful shutdown, or a
|
|
194
|
+
* turn that failed before its commit. Fenced by generation: a stale
|
|
195
|
+
* holder's release is a no-op (the session has moved on). `head` and
|
|
196
|
+
* the acquisition counter are untouched, so prior holders stay fenced.
|
|
197
|
+
*/
|
|
198
|
+
release(sessionId: string, generation: number): Promise<{
|
|
199
|
+
released: boolean;
|
|
200
|
+
}>;
|
|
201
|
+
/** Parsed ledger value for one session, or `undefined` when never created. */
|
|
202
|
+
readLedger(sessionId: string): Promise<LedgerSnapshot | undefined>;
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=session-ledger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-ledger.d.ts","sourceRoot":"","sources":["../../src/ledger/session-ledger.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ledger — coordination only (no inbox).
|
|
3
|
+
*
|
|
4
|
+
* Two key families, driven with CAS over a {@link SessionLedgerKv};
|
|
5
|
+
* agents never touch these keys directly:
|
|
6
|
+
*
|
|
7
|
+
* ```
|
|
8
|
+
* session/<continuationKey> identity, created with put-if-absent
|
|
9
|
+
* ledger/<sessionId> head + lease — every mutation one CAS
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The keys are deliberately bare: namespacing (per agent, per
|
|
13
|
+
* deployment) is not the ledger's concern. The store handle it is given
|
|
14
|
+
* must already be scoped — the engine applies the namespace exactly once
|
|
15
|
+
* where it constructs the handle (see `durable-session-store.ts`), the
|
|
16
|
+
* same boundary where `host.kv` gets its agent scoping.
|
|
17
|
+
*
|
|
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.
|
|
28
|
+
*
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* Clocks: lease expiry compares `expiresAt` against {@link
|
|
35
|
+
* SessionLedgerOptions.clock} (default `Date.now`). Self-hosted N-pod
|
|
36
|
+
* deployments accept NTP-synced pods as a documented requirement; a
|
|
37
|
+
* backend-clock primitive can replace the default without touching the
|
|
38
|
+
* protocol code.
|
|
39
|
+
*/
|
|
40
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
41
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
42
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
43
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
44
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
45
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
46
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
import { newSessionId } from "../internal/ids.js";
|
|
50
|
+
import { keySegment } from "../storage.js";
|
|
51
|
+
// Lease mechanics: TTL ~30s, heartbeat ~10s, generation increments per
|
|
52
|
+
// acquisition.
|
|
53
|
+
export const LEASE_TTL_MS = 30000;
|
|
54
|
+
export const HEARTBEAT_INTERVAL_MS = 10000;
|
|
55
|
+
const DEFAULT_MAX_CAS_ATTEMPTS = 16;
|
|
56
|
+
/** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
|
|
57
|
+
export function supportsSessionLedgerKv(kv) {
|
|
58
|
+
const candidate = kv;
|
|
59
|
+
return (typeof candidate.getWithVersion === "function" &&
|
|
60
|
+
typeof candidate.putIfAbsent === "function" &&
|
|
61
|
+
typeof candidate.putIfVersion === "function");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The turn was fenced: a stale generation, a moved head, or a missing
|
|
65
|
+
* ledger. The caller must abort the turn and discard warm state.
|
|
66
|
+
*/
|
|
67
|
+
export class LedgerFencedError extends Error {
|
|
68
|
+
constructor(message) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "LedgerFencedError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** A CAS retry loop ran out of attempts (sustained contention or outage). */
|
|
74
|
+
export class LedgerCasExhaustedError extends Error {
|
|
75
|
+
constructor(operation, attempts) {
|
|
76
|
+
super(`${operation}: CAS did not settle after ${attempts} attempts`);
|
|
77
|
+
this.name = "LedgerCasExhaustedError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function defaultBackoff(attempt) {
|
|
81
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
82
|
+
// The common case is a single conflicting writer (one heartbeat or one
|
|
83
|
+
// claim); retrying immediately wins the next round. Sleep only when
|
|
84
|
+
// contention persists.
|
|
85
|
+
if (attempt < 2) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const cap = Math.min(5 * Math.pow(2, attempt), 250);
|
|
89
|
+
const waitMs = cap / 2 + Math.random() * (cap / 2);
|
|
90
|
+
yield new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Drives the `session/` and `ledger/` key families over a CAS KV. */
|
|
94
|
+
export class SessionLedger {
|
|
95
|
+
constructor(options) {
|
|
96
|
+
var _a, _b, _c, _d, _e;
|
|
97
|
+
this.kv = options.kv;
|
|
98
|
+
this.podId = options.podId;
|
|
99
|
+
this.leaseTtlMs = (_a = options.leaseTtlMs) !== null && _a !== void 0 ? _a : LEASE_TTL_MS;
|
|
100
|
+
this.clock = (_b = options.clock) !== null && _b !== void 0 ? _b : Date.now;
|
|
101
|
+
this.maxCasAttempts = (_c = options.maxCasAttempts) !== null && _c !== void 0 ? _c : DEFAULT_MAX_CAS_ATTEMPTS;
|
|
102
|
+
this.backoff = (_d = options.backoff) !== null && _d !== void 0 ? _d : defaultBackoff;
|
|
103
|
+
this.mintSessionId = (_e = options.newSessionId) !== null && _e !== void 0 ? _e : newSessionId;
|
|
104
|
+
}
|
|
105
|
+
sessionKey(continuationKey) {
|
|
106
|
+
return `session/${keySegment(continuationKey)}`;
|
|
107
|
+
}
|
|
108
|
+
ledgerKey(sessionId) {
|
|
109
|
+
return `ledger/${keySegment(sessionId)}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 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.
|
|
114
|
+
*/
|
|
115
|
+
ensureSession(continuationKey, init) {
|
|
116
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
var _a;
|
|
118
|
+
const key = this.sessionKey(continuationKey);
|
|
119
|
+
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
120
|
+
const existing = yield this.kv.getWithVersion(key);
|
|
121
|
+
if (existing !== undefined) {
|
|
122
|
+
return {
|
|
123
|
+
created: false,
|
|
124
|
+
session: parseSessionIdentity(existing.value),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const session = {
|
|
128
|
+
sessionId: this.mintSessionId(),
|
|
129
|
+
sdkSessionRef: (_a = init === null || init === void 0 ? void 0 : init.sdkSessionRef) !== null && _a !== void 0 ? _a : null,
|
|
130
|
+
createdAt: this.clock(),
|
|
131
|
+
};
|
|
132
|
+
const result = yield this.kv.putIfAbsent(key, toJson(session));
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
return { created: true, session };
|
|
135
|
+
}
|
|
136
|
+
if (result.current !== undefined) {
|
|
137
|
+
return {
|
|
138
|
+
created: false,
|
|
139
|
+
session: parseSessionIdentity(result.current.value),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Conflict with no current entry (created then deleted underneath
|
|
143
|
+
// us) — retry from the read.
|
|
144
|
+
yield this.backoff(attempt);
|
|
145
|
+
}
|
|
146
|
+
throw new LedgerCasExhaustedError("ensureSession", this.maxCasAttempts);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Try to take the session lease, creating the ledger record on first
|
|
151
|
+
* contact. Succeeds only when the lease is null or expired; the CAS
|
|
152
|
+
* bumps the acquisition generation, fencing every prior holder. A
|
|
153
|
+
* losing caller gets `reason: "held"` and decides whether to wait.
|
|
154
|
+
*/
|
|
155
|
+
claim(sessionId) {
|
|
156
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
157
|
+
const key = this.ledgerKey(sessionId);
|
|
158
|
+
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
159
|
+
const entry = yield this.kv.getWithVersion(key);
|
|
160
|
+
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));
|
|
169
|
+
// Won or lost, the record now exists — loop back and claim it.
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
yield this.backoff(attempt);
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const ledger = parseLedgerSnapshot(entry.value);
|
|
176
|
+
const now = this.clock();
|
|
177
|
+
if (ledger.lease !== null && ledger.lease.expiresAt > now) {
|
|
178
|
+
return {
|
|
179
|
+
claimed: false,
|
|
180
|
+
reason: "held",
|
|
181
|
+
holder: ledger.lease.holder,
|
|
182
|
+
expiresAt: ledger.lease.expiresAt,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const lease = {
|
|
186
|
+
holder: this.podId,
|
|
187
|
+
generation: ledger.generation + 1,
|
|
188
|
+
expiresAt: now + this.leaseTtlMs,
|
|
189
|
+
};
|
|
190
|
+
const next = Object.assign(Object.assign({}, ledger), { generation: lease.generation, lease });
|
|
191
|
+
const result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
|
|
192
|
+
if (result.ok) {
|
|
193
|
+
return { claimed: true, lease, head: next.head };
|
|
194
|
+
}
|
|
195
|
+
yield this.backoff(attempt);
|
|
196
|
+
}
|
|
197
|
+
throw new LedgerCasExhaustedError("claim", this.maxCasAttempts);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Extend the lease. Fails when the generation is no longer ours — the
|
|
202
|
+
* caller must abort the turn and discard warm state; the session has a
|
|
203
|
+
* new legitimate holder.
|
|
204
|
+
*/
|
|
205
|
+
heartbeat(sessionId, generation) {
|
|
206
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
207
|
+
const key = this.ledgerKey(sessionId);
|
|
208
|
+
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
209
|
+
const entry = yield this.kv.getWithVersion(key);
|
|
210
|
+
if (entry === undefined) {
|
|
211
|
+
return { ok: false, reason: "not-found" };
|
|
212
|
+
}
|
|
213
|
+
const ledger = parseLedgerSnapshot(entry.value);
|
|
214
|
+
if (ledger.lease === null ||
|
|
215
|
+
ledger.lease.holder !== this.podId ||
|
|
216
|
+
ledger.lease.generation !== generation) {
|
|
217
|
+
return { ok: false, reason: "fenced" };
|
|
218
|
+
}
|
|
219
|
+
const expiresAt = this.clock() + this.leaseTtlMs;
|
|
220
|
+
const next = Object.assign(Object.assign({}, ledger), { lease: Object.assign(Object.assign({}, ledger.lease), { expiresAt }) });
|
|
221
|
+
const result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
|
|
222
|
+
if (result.ok) {
|
|
223
|
+
return { ok: true, expiresAt };
|
|
224
|
+
}
|
|
225
|
+
// Version conflict here is a concurrent writer, not a lost lease —
|
|
226
|
+
// the re-read revalidates the generation before trying again.
|
|
227
|
+
yield this.backoff(attempt);
|
|
228
|
+
}
|
|
229
|
+
throw new LedgerCasExhaustedError("heartbeat", this.maxCasAttempts);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Commit one turn. The single CAS is the commit point: it bumps `head`,
|
|
234
|
+
* releases the lease, and records `commitId` — atomically.
|
|
235
|
+
*
|
|
236
|
+
* Ambiguous outcomes (the KV threw and we never saw the CAS result) are
|
|
237
|
+
* resolved by re-reading, never by blind-retrying: `head` advanced to
|
|
238
|
+
* `expectedHead + 1` with our `commitId` ⇒ our write landed, report
|
|
239
|
+
* success; `head` still at `expectedHead` with our lease ⇒ it did not,
|
|
240
|
+
* retry against the fresh version; anything else ⇒ fenced.
|
|
241
|
+
*/
|
|
242
|
+
commit(sessionId, args) {
|
|
243
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
244
|
+
const key = this.ledgerKey(sessionId);
|
|
245
|
+
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
246
|
+
const entry = yield this.kv.getWithVersion(key);
|
|
247
|
+
if (entry === undefined) {
|
|
248
|
+
throw new LedgerFencedError(`commit(${sessionId}): ledger key is missing`);
|
|
249
|
+
}
|
|
250
|
+
const ledger = parseLedgerSnapshot(entry.value);
|
|
251
|
+
if (ledger.head === args.expectedHead + 1 &&
|
|
252
|
+
ledger.lastCommitId === args.commitId) {
|
|
253
|
+
// A previous attempt landed even though its result was lost. The
|
|
254
|
+
// 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.
|
|
259
|
+
const stillOurs = ledger.lease !== null &&
|
|
260
|
+
ledger.lease.holder === this.podId &&
|
|
261
|
+
ledger.lease.generation === args.generation;
|
|
262
|
+
return { head: ledger.head, leaseReleased: !stillOurs };
|
|
263
|
+
}
|
|
264
|
+
if (ledger.head !== args.expectedHead) {
|
|
265
|
+
throw new LedgerFencedError(`commit(${sessionId}): head is ${ledger.head}, expected ${args.expectedHead}`);
|
|
266
|
+
}
|
|
267
|
+
if (ledger.lease === null ||
|
|
268
|
+
ledger.lease.holder !== this.podId ||
|
|
269
|
+
ledger.lease.generation !== args.generation) {
|
|
270
|
+
throw new LedgerFencedError(`commit(${sessionId}): lease generation ${args.generation} is stale`);
|
|
271
|
+
}
|
|
272
|
+
const next = Object.assign(Object.assign({}, ledger), { head: args.expectedHead + 1, lease: null, lastCommitId: args.commitId });
|
|
273
|
+
let result;
|
|
274
|
+
try {
|
|
275
|
+
result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
|
|
276
|
+
}
|
|
277
|
+
catch (_a) {
|
|
278
|
+
// Unknown outcome — loop back to the re-read, which either finds
|
|
279
|
+
// our commitId (success) or a fresh version to CAS against.
|
|
280
|
+
yield this.backoff(attempt);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (result.ok) {
|
|
284
|
+
return { head: next.head, leaseReleased: true };
|
|
285
|
+
}
|
|
286
|
+
// Version conflict: a concurrent writer moved the record. Re-read
|
|
287
|
+
// and retry — the fencing predicates re-run against the fresh value.
|
|
288
|
+
yield this.backoff(attempt);
|
|
289
|
+
}
|
|
290
|
+
throw new LedgerCasExhaustedError("commit", this.maxCasAttempts);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
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.
|
|
298
|
+
*/
|
|
299
|
+
release(sessionId, generation) {
|
|
300
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
301
|
+
const key = this.ledgerKey(sessionId);
|
|
302
|
+
for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
|
|
303
|
+
const entry = yield this.kv.getWithVersion(key);
|
|
304
|
+
if (entry === undefined) {
|
|
305
|
+
return { released: false };
|
|
306
|
+
}
|
|
307
|
+
const ledger = parseLedgerSnapshot(entry.value);
|
|
308
|
+
if (ledger.lease === null ||
|
|
309
|
+
ledger.lease.holder !== this.podId ||
|
|
310
|
+
ledger.lease.generation !== generation) {
|
|
311
|
+
return { released: false };
|
|
312
|
+
}
|
|
313
|
+
const next = Object.assign(Object.assign({}, ledger), { lease: null });
|
|
314
|
+
const result = yield this.kv.putIfVersion(key, toJson(next), entry.version);
|
|
315
|
+
if (result.ok) {
|
|
316
|
+
return { released: true };
|
|
317
|
+
}
|
|
318
|
+
yield this.backoff(attempt);
|
|
319
|
+
}
|
|
320
|
+
throw new LedgerCasExhaustedError("release", this.maxCasAttempts);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** Parsed ledger value for one session, or `undefined` when never created. */
|
|
324
|
+
readLedger(sessionId) {
|
|
325
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
326
|
+
const entry = yield this.kv.getWithVersion(this.ledgerKey(sessionId));
|
|
327
|
+
return entry === undefined ? undefined : parseLedgerSnapshot(entry.value);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function toJson(value) {
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
function parseSessionIdentity(value) {
|
|
335
|
+
const record = asRecord(value, "session identity");
|
|
336
|
+
const sessionId = record.sessionId;
|
|
337
|
+
const createdAt = record.createdAt;
|
|
338
|
+
if (typeof sessionId !== "string" || typeof createdAt !== "number") {
|
|
339
|
+
throw new Error("session ledger: malformed session identity value");
|
|
340
|
+
}
|
|
341
|
+
const sdkSessionRef = record.sdkSessionRef;
|
|
342
|
+
return {
|
|
343
|
+
sessionId,
|
|
344
|
+
sdkSessionRef: typeof sdkSessionRef === "string" ? sdkSessionRef : null,
|
|
345
|
+
createdAt,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function parseLedgerSnapshot(value) {
|
|
349
|
+
const record = asRecord(value, "ledger");
|
|
350
|
+
const { sessionId, head, lease, generation, lastCommitId } = record;
|
|
351
|
+
if (typeof sessionId !== "string" ||
|
|
352
|
+
typeof head !== "number" ||
|
|
353
|
+
typeof generation !== "number") {
|
|
354
|
+
throw new Error("session ledger: malformed ledger value");
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
sessionId,
|
|
358
|
+
head,
|
|
359
|
+
lease: parseLease(lease),
|
|
360
|
+
generation,
|
|
361
|
+
lastCommitId: typeof lastCommitId === "string" ? lastCommitId : null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function parseLease(value) {
|
|
365
|
+
if (value === null || value === undefined) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const record = asRecord(value, "lease");
|
|
369
|
+
const { holder, generation, expiresAt } = record;
|
|
370
|
+
if (typeof holder !== "string" ||
|
|
371
|
+
typeof generation !== "number" ||
|
|
372
|
+
typeof expiresAt !== "number") {
|
|
373
|
+
throw new Error("session ledger: malformed lease value");
|
|
374
|
+
}
|
|
375
|
+
return { holder, generation, expiresAt };
|
|
376
|
+
}
|
|
377
|
+
function asRecord(value, what) {
|
|
378
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
379
|
+
throw new Error(`session ledger: ${what} value is not an object`);
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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.
|
|
8
|
+
*/
|
|
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";
|
|
11
|
+
//# sourceMappingURL=ledger.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export { HEARTBEAT_INTERVAL_MS, LEASE_TTL_MS, LedgerCasExhaustedError, LedgerFencedError, SessionLedger, supportsSessionLedgerKv, } from "./ledger/session-ledger.js";
|