@cursor/july 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels/origin/origin-webhook.d.ts +23 -2
- package/dist/channels/origin/origin-webhook.d.ts.map +1 -1
- package/dist/channels/origin/origin-webhook.js +82 -20
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.b8bhB2SV.js → app.BsA7t7rS.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.Dcs6boxD.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.lLSeIyWw.js → VPLocalSearchBox.B1hHhktM.js} +1 -1
- package/dist/docs/assets/chunks/{theme.C_owesa-.js → theme.DLpLSsQh.js} +2 -2
- package/dist/docs/assets/{storage.md.D8NQ389l.js → storage.md.ks1u64_R.js} +3 -3
- package/dist/docs/assets/storage.md.ks1u64_R.lean.js +1 -0
- package/dist/docs/building-with-agents.html +3 -3
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +3 -3
- package/dist/docs/evals.html +3 -3
- package/dist/docs/example-agents/approval-buddy.html +3 -3
- package/dist/docs/example-agents/benny.html +3 -3
- package/dist/docs/example-agents/bugbot.html +3 -3
- package/dist/docs/example-agents/codebase-wiki.html +3 -3
- package/dist/docs/example-agents/codeowners-review.html +3 -3
- package/dist/docs/example-agents/concierge.html +3 -3
- package/dist/docs/example-agents/fsd.html +3 -3
- package/dist/docs/example-agents/index.html +3 -3
- package/dist/docs/example-agents/knowledge-base.html +3 -3
- package/dist/docs/example-agents/oncall.html +3 -3
- package/dist/docs/example-agents/security-reviewer.html +3 -3
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +3 -3
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +3 -3
- package/dist/docs/guides/convert-automation.html +3 -3
- package/dist/docs/guides/github.html +3 -3
- package/dist/docs/guides/human-in-the-loop.html +3 -3
- package/dist/docs/guides/mcp-oauth.html +3 -3
- package/dist/docs/guides/opentelemetry.html +3 -3
- package/dist/docs/guides/slack.html +3 -3
- package/dist/docs/guides/webhooks.html +3 -3
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +3 -3
- package/dist/docs/quickstart.html +3 -3
- package/dist/docs/reference/agent-config.html +3 -3
- package/dist/docs/reference/artifacts.html +3 -3
- package/dist/docs/reference/channels.html +3 -3
- package/dist/docs/reference/cli.html +3 -3
- package/dist/docs/reference/connections.html +3 -3
- package/dist/docs/reference/hooks.html +3 -3
- package/dist/docs/reference/http-api.html +3 -3
- package/dist/docs/reference/instructions.html +3 -3
- package/dist/docs/reference/playground.html +3 -3
- package/dist/docs/reference/project-layout.html +3 -3
- package/dist/docs/reference/prompt.html +3 -3
- package/dist/docs/reference/schedules.html +3 -3
- package/dist/docs/reference/sessions.html +3 -3
- package/dist/docs/reference/skills.html +3 -3
- package/dist/docs/reference/subagents.html +3 -3
- package/dist/docs/reference/tools.html +3 -3
- package/dist/docs/scaffolding-agents.html +3 -3
- package/dist/docs/storage.html +7 -7
- package/dist/docs/templates/agentic-owners.html +3 -3
- package/dist/docs/templates/demo.html +3 -3
- package/dist/docs/templates/pr-autofixer.html +3 -3
- package/dist/docs/templates/security-reviewer.html +3 -3
- package/dist/docs/templates/triage.html +3 -3
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/internal/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +3 -21
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +0 -1
- package/dist/internal/session-engine.d.ts +1 -60
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +10 -109
- package/dist/kv/memory-kv.d.ts +1 -10
- package/dist/kv/memory-kv.d.ts.map +1 -1
- package/dist/kv/memory-kv.js +1 -10
- package/dist/kv.d.ts +2 -3
- package/dist/kv.d.ts.map +1 -1
- package/dist/kv.js +2 -3
- package/dist/playground/assets/{index-Bfwz2yLN.css → index-DJzcIeGt.css} +1 -1
- package/dist/playground/assets/{index-BWqjJcRo.js → index-K3Sxyx0I.js} +48 -45
- package/dist/playground/index.html +2 -2
- package/dist/storage-backends/cursor-hosted.js +3 -3
- package/dist/storage-protocol.d.ts +3 -8
- package/dist/storage-protocol.d.ts.map +1 -1
- package/dist/storage-protocol.js +4 -10
- package/dist/storage.d.ts +5 -11
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +3 -4
- package/dist/types.d.ts +5 -34
- package/dist/types.d.ts.map +1 -1
- package/docs/storage.md +11 -44
- package/package.json +1 -8
- package/src/channels/origin/origin-webhook.ts +111 -25
- package/src/index.ts +0 -6
- package/src/internal/discovery.ts +3 -27
- package/src/internal/distribution.ts +0 -1
- package/src/internal/session-engine.ts +1 -168
- package/src/kv/memory-kv.ts +1 -10
- package/src/kv.ts +2 -3
- package/src/storage-backends/cursor-hosted.ts +3 -3
- package/src/storage-protocol.ts +4 -10
- package/src/storage.ts +6 -12
- package/src/types.ts +5 -35
- package/dist/docs/assets/chunks/@localSearchIndexroot.DGZeXVqn.js +0 -1
- package/dist/docs/assets/storage.md.D8NQ389l.lean.js +0 -1
- package/dist/internal/durable-session-store.d.ts +0 -60
- package/dist/internal/durable-session-store.d.ts.map +0 -1
- package/dist/internal/durable-session-store.js +0 -131
- package/dist/internal/ledger-serving.d.ts +0 -169
- package/dist/internal/ledger-serving.d.ts.map +0 -1
- package/dist/internal/ledger-serving.js +0 -393
- package/dist/ledger/janitor.d.ts +0 -60
- package/dist/ledger/janitor.d.ts.map +0 -1
- package/dist/ledger/janitor.js +0 -137
- package/dist/ledger/session-ledger.d.ts +0 -293
- package/dist/ledger/session-ledger.d.ts.map +0 -1
- package/dist/ledger/session-ledger.js +0 -614
- package/dist/ledger.d.ts +0 -12
- package/dist/ledger.d.ts.map +0 -1
- package/dist/ledger.js +0 -9
- package/src/internal/durable-session-store.ts +0 -134
- package/src/internal/ledger-serving.ts +0 -531
- package/src/ledger/janitor.ts +0 -193
- package/src/ledger/session-ledger.ts +0 -839
- package/src/ledger.ts +0 -44
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable-session store resolution.
|
|
3
|
-
*
|
|
4
|
-
* Two APIs, one backend: `defineStorage` is the **storage API** (agent
|
|
5
|
-
* state — session records, event chunks, author KV), and the session
|
|
6
|
-
* ledger is the **durable session API** (session identity, mailboxes,
|
|
7
|
-
* leases, fenced commits). The ledger consumes {@link SessionLedgerKv} —
|
|
8
|
-
* the full CAS-capable KV surface, since the inbox reads/sweeps mailbox
|
|
9
|
-
* payload blobs (`get`/`put`/`delete`) and scans by prefix (`listKeys`)
|
|
10
|
-
* on top of the CAS trio; this module adapts a storage backend's methods
|
|
11
|
-
* into that interface, so a deployment's one authored backend serves
|
|
12
|
-
* both APIs by default.
|
|
13
|
-
*
|
|
14
|
-
* Calls go straight at the config's methods — NOT through the
|
|
15
|
-
* StorageCoordinator: the coordinator's queue is bounded, serialized, and
|
|
16
|
-
* at-most-once (mirror semantics), while ledger CAS calls are the
|
|
17
|
-
* correctness path and must observe their own results synchronously.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { SessionLedgerKv } from "../ledger/session-ledger.js";
|
|
21
|
-
import {
|
|
22
|
-
keySegment,
|
|
23
|
-
STORAGE_KEY_ROOT,
|
|
24
|
-
type StorageConfig,
|
|
25
|
-
type StorageContext,
|
|
26
|
-
} from "../storage.js";
|
|
27
|
-
import type { JsonValue } from "../types.js";
|
|
28
|
-
|
|
29
|
-
export interface StorageCasKvArgs {
|
|
30
|
-
agentName: string;
|
|
31
|
-
projectRoot: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The one place the ledger's physical namespace is minted: the per-agent
|
|
36
|
-
* prefix for ledger state, following the {@link storageKeys} convention
|
|
37
|
-
* (`<root>/<agent>/<family>/…`) so a store shared by several ledger-mode
|
|
38
|
-
* agents (one fileKv dir, one hosted deployment) never mixes their
|
|
39
|
-
* sessions, leases, or mailboxes — and so each agent's janitor
|
|
40
|
-
* prefix-scan only ever sees its own ledgers. The extra
|
|
41
|
-
* `durable-session` segment keeps the ledger's `session/` family out of
|
|
42
|
-
* `storageKeys.sessionPrefix` scans (storage restore lists
|
|
43
|
-
* `<root>/<agent>/session/`). The ledger itself never sees this string:
|
|
44
|
-
* it speaks bare `session/…` / `ledger/…` / `mailbox/…` keys against a
|
|
45
|
-
* handle that {@link namespacedKv} has already scoped.
|
|
46
|
-
*/
|
|
47
|
-
export function ledgerKeyPrefix(agentName: string): string {
|
|
48
|
-
return `${STORAGE_KEY_ROOT}/${keySegment(agentName)}/durable-session/`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Adapt a storage backend into the ledger's KV interface. Throws when
|
|
53
|
-
* the backend does not implement the `cas` group, or omits `get` /
|
|
54
|
-
* `delete` (optional for resident-only sinks, but the ledger reads and
|
|
55
|
-
* sweeps mailbox payload blobs through the same store) — the caller
|
|
56
|
-
* turns that into the fail-fast startup error for `serving: "ledger"`.
|
|
57
|
-
* Every call carries a `reason: "coordination"` {@link StorageContext}.
|
|
58
|
-
*/
|
|
59
|
-
export function storageCasKv(
|
|
60
|
-
config: StorageConfig,
|
|
61
|
-
args: StorageCasKvArgs
|
|
62
|
-
): SessionLedgerKv {
|
|
63
|
-
const ctx: StorageContext = {
|
|
64
|
-
agentName: args.agentName,
|
|
65
|
-
projectRoot: args.projectRoot,
|
|
66
|
-
reason: "coordination",
|
|
67
|
-
};
|
|
68
|
-
const { cas } = config;
|
|
69
|
-
if (cas === undefined) {
|
|
70
|
-
throw new Error(
|
|
71
|
-
"storageCasKv: the storage backend does not implement the cas group (conditional writes)"
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
const get = config.get;
|
|
75
|
-
const remove = config.delete;
|
|
76
|
-
if (get === undefined || remove === undefined) {
|
|
77
|
-
throw new Error(
|
|
78
|
-
"storageCasKv: the storage backend must implement get and delete (the session ledger reads and sweeps payload blobs through the same store)"
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
return {
|
|
82
|
-
async get(key): Promise<JsonValue | undefined> {
|
|
83
|
-
return (await get(key, ctx)) ?? undefined;
|
|
84
|
-
},
|
|
85
|
-
async put(key, value): Promise<void> {
|
|
86
|
-
await config.put(key, value, ctx);
|
|
87
|
-
},
|
|
88
|
-
async delete(key): Promise<void> {
|
|
89
|
-
await remove(key, ctx);
|
|
90
|
-
},
|
|
91
|
-
async getWithVersion(key) {
|
|
92
|
-
return await cas.getWithVersion(key, ctx);
|
|
93
|
-
},
|
|
94
|
-
async putIfAbsent(key, value) {
|
|
95
|
-
return await cas.putIfAbsent(key, value, ctx);
|
|
96
|
-
},
|
|
97
|
-
async putIfVersion(key, value, expectedVersion) {
|
|
98
|
-
return await cas.putIfVersion(key, value, expectedVersion, ctx);
|
|
99
|
-
},
|
|
100
|
-
async listKeys(prefix) {
|
|
101
|
-
return await cas.listKeys(prefix, ctx);
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Scope a raw store handle under a namespace: every key going in gets it
|
|
108
|
-
* prepended, and `listKeys` results come back with it **stripped**, so a
|
|
109
|
-
* caller that lists and then reads/deletes the returned keys stays fully
|
|
110
|
-
* prefix-free (the janitor scan and the orphan sweep do exactly that).
|
|
111
|
-
* Applied exactly once, where the engine constructs the ledger's store
|
|
112
|
-
* handle — the same conceptual boundary where `host.kv` gets its agent
|
|
113
|
-
* scoping. Internal: not part of the public kv interface, and callers
|
|
114
|
-
* (including the explicit `durableSessionStore` override) always supply
|
|
115
|
-
* the raw store — they never write prefixes.
|
|
116
|
-
*/
|
|
117
|
-
export function namespacedKv(
|
|
118
|
-
store: SessionLedgerKv,
|
|
119
|
-
namespace: string
|
|
120
|
-
): SessionLedgerKv {
|
|
121
|
-
return {
|
|
122
|
-
get: (key) => store.get(namespace + key),
|
|
123
|
-
put: (key, value) => store.put(namespace + key, value),
|
|
124
|
-
delete: (key) => store.delete(namespace + key),
|
|
125
|
-
getWithVersion: (key) => store.getWithVersion(namespace + key),
|
|
126
|
-
putIfAbsent: (key, value) => store.putIfAbsent(namespace + key, value),
|
|
127
|
-
putIfVersion: (key, value, expectedVersion) =>
|
|
128
|
-
store.putIfVersion(namespace + key, value, expectedVersion),
|
|
129
|
-
listKeys: async (prefix) => {
|
|
130
|
-
const keys = await store.listKeys(namespace + prefix);
|
|
131
|
-
return keys.map((key) => key.slice(namespace.length));
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ledger-mode serving seam (`serving: "ledger"` on `defineAgent`): an event
|
|
3
|
-
* pump that routes every engine `send` through the KV session ledger
|
|
4
|
-
* instead of starting turns directly.
|
|
5
|
-
*
|
|
6
|
-
* Delivery: ensureSession (continuation key, as today) → principal gate
|
|
7
|
-
* (an existing session rejects a sender that is not its principal BEFORE
|
|
8
|
-
* anything is parked; mirrors the resident `samePrincipal` check) →
|
|
9
|
-
* appendEvent (payload + CAS — the source ack point moves after this) →
|
|
10
|
-
* claim → drain → run the drained turns through the resident engine path →
|
|
11
|
-
* commit (heartbeats while turns run) → repeat while the commit reports
|
|
12
|
-
* pending. A deliver that finds the lease held walks away (the holder's
|
|
13
|
-
* commit sees the append); the janitor recovers sessions whose holder
|
|
14
|
-
* died. A foreign-principal envelope that reaches the mailbox anyway is
|
|
15
|
-
* consumed and committed away at drain (warn log), never replayed.
|
|
16
|
-
*
|
|
17
|
-
* v1 scope: the mailbox envelope carries `message`, `auth`, `title`,
|
|
18
|
-
* `state`, `mode`, `purpose`, and the isolation-relevant per-send
|
|
19
|
-
* overrides `workspaceDir` and `cloud` — the two options
|
|
20
|
-
* `resolveSessionRuntime` and harness-cwd selection read. Dropping
|
|
21
|
-
* those is a sandbox escape, not a feature gap: without them a drained
|
|
22
|
-
* GitHub/Slack turn falls back to the local runtime with the default
|
|
23
|
-
* harness cwd, so untrusted channel content drives tools on the serve
|
|
24
|
-
* host instead of the cloud VM / channel worktree. `coalesceSourceTs`
|
|
25
|
-
* (when present) is used as the drain-ordering `sourceTs`. Deferred
|
|
26
|
-
* (documented, not silently faked — deliver logs a warning naming what
|
|
27
|
-
* it dropped): `workspaceFiles` and per-turn `images` (content
|
|
28
|
-
* injection, and their base64 payloads can blow the mailbox blob past
|
|
29
|
-
* hosted KV value caps), `sdkAgentId` seeding, and `admission`
|
|
30
|
-
* semantics (both `"preempt"` and Slack `"coalesce"` nudge bookkeeping
|
|
31
|
-
* — ledger draining already serializes turns, but nothing is
|
|
32
|
-
* interrupted or coalesced through the mailbox).
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
36
|
-
import type {
|
|
37
|
-
JanitorClaimedSession,
|
|
38
|
-
LedgerJanitor,
|
|
39
|
-
} from "../ledger/janitor.js";
|
|
40
|
-
import { createLedgerJanitor } from "../ledger/janitor.js";
|
|
41
|
-
import type { LedgerLease, SessionLedger } from "../ledger/session-ledger.js";
|
|
42
|
-
import {
|
|
43
|
-
HEARTBEAT_INTERVAL_MS,
|
|
44
|
-
LedgerFencedError,
|
|
45
|
-
} from "../ledger/session-ledger.js";
|
|
46
|
-
import type {
|
|
47
|
-
AgentCloudOptions,
|
|
48
|
-
AuthContext,
|
|
49
|
-
ChannelSession,
|
|
50
|
-
JsonValue,
|
|
51
|
-
SendMessageOptions,
|
|
52
|
-
SessionPurpose,
|
|
53
|
-
} from "../types.js";
|
|
54
|
-
import { SessionAuthError, samePrincipal } from "./auth.js";
|
|
55
|
-
import { describeError } from "./describe-error.js";
|
|
56
|
-
import { newContinuationKey } from "./ids.js";
|
|
57
|
-
|
|
58
|
-
/** The JSON payload stored in `mailbox/<sessionId>/<eventId>`. */
|
|
59
|
-
export interface LedgerDeliveryEnvelope {
|
|
60
|
-
channelId: string;
|
|
61
|
-
continuationToken: string;
|
|
62
|
-
message: string;
|
|
63
|
-
auth: AuthContext | null;
|
|
64
|
-
title?: string;
|
|
65
|
-
state?: JsonValue;
|
|
66
|
-
mode?: "chat" | "task";
|
|
67
|
-
purpose?: SessionPurpose;
|
|
68
|
-
/**
|
|
69
|
-
* Isolation-relevant per-send overrides, reapplied on the resident
|
|
70
|
-
* send at drain. Absent on envelopes parked by earlier builds — the
|
|
71
|
-
* drain then behaves as those builds did (agent-default runtime/cwd).
|
|
72
|
-
*/
|
|
73
|
-
workspaceDir?: string;
|
|
74
|
-
cloud?: AgentCloudOptions;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The `send` options the envelope can carry through the mailbox. */
|
|
78
|
-
export type LedgerDeliverOptions = SendMessageOptions & {
|
|
79
|
-
mode?: "chat" | "task";
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export interface LedgerServingOptions {
|
|
83
|
-
ledger: SessionLedger;
|
|
84
|
-
/**
|
|
85
|
-
* Run one drained event as a resident-path turn (the engine's `send`
|
|
86
|
-
* with the internal `fromLedgerPump` flag). Must resolve once the turn
|
|
87
|
-
* is admitted; the pump awaits `waitForCompletion` itself.
|
|
88
|
-
*/
|
|
89
|
-
deliverTurn: (envelope: LedgerDeliveryEnvelope) => Promise<ChannelSession>;
|
|
90
|
-
/** Engine continuation lookup (local index + lazy storage restore). */
|
|
91
|
-
findSession: (
|
|
92
|
-
channelId: string,
|
|
93
|
-
continuationToken: string
|
|
94
|
-
) => Promise<ChannelSession | undefined>;
|
|
95
|
-
logger?: (line: string) => void;
|
|
96
|
-
/** Lease heartbeat cadence while turns run (default 10s). */
|
|
97
|
-
heartbeatIntervalMs?: number;
|
|
98
|
-
/** Janitor scan cadence (default `DEFAULT_JANITOR_INTERVAL_MS`). */
|
|
99
|
-
janitorIntervalMs?: number;
|
|
100
|
-
clock?: () => number;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export class LedgerServing {
|
|
104
|
-
private readonly ledger: SessionLedger;
|
|
105
|
-
private readonly deliverTurn: LedgerServingOptions["deliverTurn"];
|
|
106
|
-
private readonly findSession: LedgerServingOptions["findSession"];
|
|
107
|
-
private readonly logger: (line: string) => void;
|
|
108
|
-
private readonly heartbeatIntervalMs: number;
|
|
109
|
-
private readonly clock: () => number;
|
|
110
|
-
private readonly janitor: LedgerJanitor;
|
|
111
|
-
/** One pump per ledger session — the in-process face of the lease. */
|
|
112
|
-
private readonly pumps = new Map<string, Promise<void>>();
|
|
113
|
-
/** Fire-and-forget re-pump checks, awaited on close. */
|
|
114
|
-
private readonly background = new Set<Promise<void>>();
|
|
115
|
-
|
|
116
|
-
constructor(options: LedgerServingOptions) {
|
|
117
|
-
this.ledger = options.ledger;
|
|
118
|
-
this.deliverTurn = options.deliverTurn;
|
|
119
|
-
this.findSession = options.findSession;
|
|
120
|
-
this.logger = options.logger ?? (() => {});
|
|
121
|
-
this.heartbeatIntervalMs =
|
|
122
|
-
options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
123
|
-
this.clock = options.clock ?? Date.now;
|
|
124
|
-
this.janitor = createLedgerJanitor({
|
|
125
|
-
ledger: options.ledger,
|
|
126
|
-
onSession: (claimed) => this.serveClaimed(claimed),
|
|
127
|
-
// Consumed mailbox blobs (full message envelopes) would otherwise
|
|
128
|
-
// accumulate forever; the default TTL (1h) is far beyond any lease
|
|
129
|
-
// or turn horizon, so only truly dead blobs are collected.
|
|
130
|
-
orphanSweep: {},
|
|
131
|
-
...(options.janitorIntervalMs === undefined
|
|
132
|
-
? {}
|
|
133
|
-
: { intervalMs: options.janitorIntervalMs }),
|
|
134
|
-
onError: (error, sessionId) => {
|
|
135
|
-
this.logger(
|
|
136
|
-
`[ledger] janitor error${sessionId === undefined ? "" : ` (session ${sessionId})`}: ${describeError(error)}`
|
|
137
|
-
);
|
|
138
|
-
},
|
|
139
|
-
clock: this.clock,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Start the janitor loop (recovers expired-lease sessions). */
|
|
144
|
-
start(): void {
|
|
145
|
-
this.janitor.start();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Stop the janitor and settle in-flight pumps and re-pump checks. */
|
|
149
|
-
async close(): Promise<void> {
|
|
150
|
-
// Awaiting stop() matters: an in-flight janitor pass can claim a
|
|
151
|
-
// session and register a pump after this method snapshots the maps.
|
|
152
|
-
await this.janitor.stop();
|
|
153
|
-
await Promise.allSettled([...this.pumps.values()]);
|
|
154
|
-
await Promise.allSettled([...this.background]);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Ledger-mode `send`: append the message to the session mailbox, then
|
|
159
|
-
* serve pending events (or walk away if another holder is already
|
|
160
|
-
* serving them). Resolves with the engine session handle once one
|
|
161
|
-
* exists — the ack point is the durable append, not turn completion —
|
|
162
|
-
* so HTTP create/follow-up can return a session id while the holder is
|
|
163
|
-
* still in flight. When this process wins the claim, the pump keeps
|
|
164
|
-
* draining in the background; callers that need the turn to finish use
|
|
165
|
-
* `waitForCompletion` (and the lease releases on the pump's commit).
|
|
166
|
-
* When another holder is serving the session and no engine session
|
|
167
|
-
* exists on this pod, the delivery is still a success — the event is
|
|
168
|
-
* durably parked and the holder's commit (or the janitor) drains it —
|
|
169
|
-
* so the resolved handle is a parked stand-in carrying the durable
|
|
170
|
-
* session identity.
|
|
171
|
-
*/
|
|
172
|
-
async deliver(
|
|
173
|
-
channelId: string,
|
|
174
|
-
message: string,
|
|
175
|
-
options: LedgerDeliverOptions
|
|
176
|
-
): Promise<ChannelSession> {
|
|
177
|
-
const continuationToken = options.continuationToken ?? newContinuationKey();
|
|
178
|
-
this.warnOnDroppedOptions(channelId, options);
|
|
179
|
-
const ensured = await this.ledger.ensureSession(
|
|
180
|
-
`${channelId}/${continuationToken}`
|
|
181
|
-
);
|
|
182
|
-
const ledgerSessionId = ensured.session.sessionId;
|
|
183
|
-
// Principal gate BEFORE the durable append: once an envelope is
|
|
184
|
-
// parked it WILL be drained, so a foreign sender must be rejected
|
|
185
|
-
// here — not on the resident path after the mailbox write. A new
|
|
186
|
-
// ledger session has no owner yet: the creating sender becomes it,
|
|
187
|
-
// exactly as on resident session creation.
|
|
188
|
-
if (!ensured.created) {
|
|
189
|
-
const existing = await this.findSession(channelId, continuationToken);
|
|
190
|
-
if (existing !== undefined) {
|
|
191
|
-
if (!samePrincipal(existing.info.auth, options.auth ?? null)) {
|
|
192
|
-
throw new SessionAuthError(existing.id);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// No handle: the engine session does not exist yet (the first
|
|
196
|
-
// parked event has not run — the lease holder creates it) or a
|
|
197
|
-
// cross-pod restore transiently failed. Park rather than fail
|
|
198
|
-
// closed: rejecting here would bounce the legitimate owner racing
|
|
199
|
-
// the session's first turn. This is not fail-open — the resident
|
|
200
|
-
// path re-checks the principal when the event drains, and the
|
|
201
|
-
// drain treats a SessionAuthError as consume-and-commit, so a
|
|
202
|
-
// foreign envelope that parks through this window costs one warn
|
|
203
|
-
// line and never actuates, bricks, or replays the session.
|
|
204
|
-
}
|
|
205
|
-
const envelope: LedgerDeliveryEnvelope = {
|
|
206
|
-
channelId,
|
|
207
|
-
continuationToken,
|
|
208
|
-
message,
|
|
209
|
-
auth: options.auth ?? null,
|
|
210
|
-
...(options.title === undefined ? {} : { title: options.title }),
|
|
211
|
-
...(options.state === undefined ? {} : { state: options.state }),
|
|
212
|
-
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
213
|
-
...(options.purpose === undefined ? {} : { purpose: options.purpose }),
|
|
214
|
-
...(options.workspaceDir === undefined
|
|
215
|
-
? {}
|
|
216
|
-
: { workspaceDir: options.workspaceDir }),
|
|
217
|
-
...(options.cloud === undefined ? {} : { cloud: options.cloud }),
|
|
218
|
-
};
|
|
219
|
-
await this.ledger.appendEvent(ledgerSessionId, {
|
|
220
|
-
eventId: `evt_${randomBytes(12).toString("hex")}`,
|
|
221
|
-
envelope: envelope as unknown as JsonValue,
|
|
222
|
-
sourceTs: this.resolveSourceTs(options),
|
|
223
|
-
});
|
|
224
|
-
// Ack point: the event is durable (mailbox blob + ledger pending).
|
|
225
|
-
// Do not await the pump: awaiting turns here would block HTTP create
|
|
226
|
-
// until the first turn committed, so a client could never park a
|
|
227
|
-
// mid-turn follow-up and a second pod would see a released lease.
|
|
228
|
-
const serving = this.servePending(ledgerSessionId);
|
|
229
|
-
return this.resolveDeliverHandle({
|
|
230
|
-
channelId,
|
|
231
|
-
continuationToken,
|
|
232
|
-
ledgerSessionId,
|
|
233
|
-
envelope,
|
|
234
|
-
created: ensured.created,
|
|
235
|
-
serving,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* The stand-in handle for a delivery whose event is parked while
|
|
241
|
-
* another holder serves the session: real durable identity, no local
|
|
242
|
-
* turn. `waitForCompletion` resolves immediately — the parked event's
|
|
243
|
-
* turn runs on the holder, so there is nothing local to await (the
|
|
244
|
-
* same contract as a coalesced send, whose outcome is not its own
|
|
245
|
-
* message's turn). The event stream and re-keying live wherever the
|
|
246
|
-
* turn runs, so both fail loudly here.
|
|
247
|
-
*/
|
|
248
|
-
private parkedSession(
|
|
249
|
-
ledgerSessionId: string,
|
|
250
|
-
envelope: LedgerDeliveryEnvelope,
|
|
251
|
-
isNew: boolean
|
|
252
|
-
): ChannelSession {
|
|
253
|
-
const unavailable = (what: string): Error =>
|
|
254
|
-
new Error(
|
|
255
|
-
`ledger serving: ${what} is unavailable for session ${ledgerSessionId}: the parked event runs on the session's current lease holder, not this process`
|
|
256
|
-
);
|
|
257
|
-
return {
|
|
258
|
-
id: ledgerSessionId,
|
|
259
|
-
continuationToken: envelope.continuationToken,
|
|
260
|
-
isNew,
|
|
261
|
-
info: {
|
|
262
|
-
id: ledgerSessionId,
|
|
263
|
-
channelId: envelope.channelId,
|
|
264
|
-
mode: envelope.mode ?? "chat",
|
|
265
|
-
auth: envelope.auth,
|
|
266
|
-
...(envelope.title === undefined ? {} : { title: envelope.title }),
|
|
267
|
-
...(envelope.purpose === undefined
|
|
268
|
-
? {}
|
|
269
|
-
: { purpose: envelope.purpose }),
|
|
270
|
-
continuationKey: envelope.continuationToken,
|
|
271
|
-
},
|
|
272
|
-
getEventStream: () => Promise.reject(unavailable("the event stream")),
|
|
273
|
-
waitForCompletion: async () => ({ status: "finished" }),
|
|
274
|
-
setContinuationToken: () =>
|
|
275
|
-
Promise.reject(unavailable("re-keying the continuation token")),
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Return a handle as soon as the engine session exists, without waiting
|
|
281
|
-
* for the pump to commit. If the pump settles without creating a
|
|
282
|
-
* session (walk-away, or a throw before admit), surface that: rethrow
|
|
283
|
-
* a failed claimant turn, otherwise the parked stand-in.
|
|
284
|
-
*/
|
|
285
|
-
private async resolveDeliverHandle(args: {
|
|
286
|
-
channelId: string;
|
|
287
|
-
continuationToken: string;
|
|
288
|
-
ledgerSessionId: string;
|
|
289
|
-
envelope: LedgerDeliveryEnvelope;
|
|
290
|
-
created: boolean;
|
|
291
|
-
serving: Promise<void>;
|
|
292
|
-
}): Promise<ChannelSession> {
|
|
293
|
-
const {
|
|
294
|
-
channelId,
|
|
295
|
-
continuationToken,
|
|
296
|
-
ledgerSessionId,
|
|
297
|
-
envelope,
|
|
298
|
-
created,
|
|
299
|
-
serving,
|
|
300
|
-
} = args;
|
|
301
|
-
for (;;) {
|
|
302
|
-
const handle = await this.findSession(channelId, continuationToken);
|
|
303
|
-
if (handle !== undefined) {
|
|
304
|
-
return handle;
|
|
305
|
-
}
|
|
306
|
-
const pump = this.pumps.get(ledgerSessionId);
|
|
307
|
-
if (pump !== undefined) {
|
|
308
|
-
await Promise.race([
|
|
309
|
-
new Promise<void>((resolve) => {
|
|
310
|
-
setTimeout(resolve, 15);
|
|
311
|
-
}),
|
|
312
|
-
pump.then(
|
|
313
|
-
() => undefined,
|
|
314
|
-
() => undefined
|
|
315
|
-
),
|
|
316
|
-
]);
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
const createdLate = await this.findSession(channelId, continuationToken);
|
|
320
|
-
if (createdLate !== undefined) {
|
|
321
|
-
return createdLate;
|
|
322
|
-
}
|
|
323
|
-
await serving;
|
|
324
|
-
this.logger(
|
|
325
|
-
`[ledger] deliver on channel "${channelId}": event parked for session ${ledgerSessionId} (continuation ${continuationToken}); another holder is serving it and no local session exists — its commit (or the janitor) drains the event`
|
|
326
|
-
);
|
|
327
|
-
return this.parkedSession(ledgerSessionId, envelope, created);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Drain ordering: prefer the real source time over this pod's receipt
|
|
333
|
-
* time. `coalesceSourceTs` is a Slack-style seconds string
|
|
334
|
-
* (`"1712345678.000100"`); normalize sub-epoch-ms values to ms so they
|
|
335
|
-
* stay comparable with the `Date.now()` fallback.
|
|
336
|
-
*/
|
|
337
|
-
private resolveSourceTs(options: LedgerDeliverOptions): number {
|
|
338
|
-
if (options.coalesceSourceTs !== undefined) {
|
|
339
|
-
const parsed = Number(options.coalesceSourceTs);
|
|
340
|
-
if (Number.isFinite(parsed) && parsed > 0) {
|
|
341
|
-
return parsed < 1e11 ? Math.round(parsed * 1000) : parsed;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return this.clock();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* One warning per delivery naming the send options the mailbox does not
|
|
349
|
-
* replay — better a loud line than an author silently losing images or
|
|
350
|
-
* admission semantics on a ledger-mode agent.
|
|
351
|
-
*/
|
|
352
|
-
private warnOnDroppedOptions(
|
|
353
|
-
channelId: string,
|
|
354
|
-
options: LedgerDeliverOptions
|
|
355
|
-
): void {
|
|
356
|
-
const dropped = (
|
|
357
|
-
["images", "sdkAgentId", "admission", "workspaceFiles"] as const
|
|
358
|
-
).filter((name) => options[name] !== undefined);
|
|
359
|
-
if (dropped.length > 0) {
|
|
360
|
-
this.logger(
|
|
361
|
-
`[ledger] deliver on channel "${channelId}": dropping send options not replayed through the mailbox: ${dropped.join(", ")}`
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Serve a session's pending events. When a pump is already running the
|
|
368
|
-
* caller walks away (the holder's commit re-reads pending) — except
|
|
369
|
-
* that the holder's *final* commit may have released before this
|
|
370
|
-
* append landed, so a re-check is chained behind it. Deliver never
|
|
371
|
-
* awaits this promise for its full lifetime: awaiting a new pump would
|
|
372
|
-
* block HTTP until the turn committed, and awaiting a running pump
|
|
373
|
-
* would deadlock a send issued from inside one of its turns.
|
|
374
|
-
*/
|
|
375
|
-
private servePending(ledgerSessionId: string): Promise<void> {
|
|
376
|
-
const running = this.pumps.get(ledgerSessionId);
|
|
377
|
-
if (running !== undefined) {
|
|
378
|
-
const recheck = running
|
|
379
|
-
.catch(() => {})
|
|
380
|
-
.then(() => this.repumpIfPending(ledgerSessionId))
|
|
381
|
-
.catch(() => {});
|
|
382
|
-
this.background.add(recheck);
|
|
383
|
-
void recheck.finally(() => {
|
|
384
|
-
this.background.delete(recheck);
|
|
385
|
-
});
|
|
386
|
-
return Promise.resolve();
|
|
387
|
-
}
|
|
388
|
-
return this.track(ledgerSessionId, () => this.runPump(ledgerSessionId));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** Claim-or-defer entry used after an append and after a pump settles. */
|
|
392
|
-
private async runPump(ledgerSessionId: string): Promise<void> {
|
|
393
|
-
const claim = await this.ledger.claim(ledgerSessionId);
|
|
394
|
-
if (!claim.claimed) {
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
await this.runClaimedTurns(ledgerSessionId, claim.lease);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/** Janitor hand-off: the claim already happened, just run the turns. */
|
|
401
|
-
private serveClaimed(claimed: JanitorClaimedSession): Promise<void> {
|
|
402
|
-
return this.track(claimed.sessionId, () =>
|
|
403
|
-
this.runClaimedTurns(claimed.sessionId, claimed.lease)
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Re-pump after the previous holder settled, covering the release window
|
|
409
|
-
* where an append landed after that holder's final commit.
|
|
410
|
-
*/
|
|
411
|
-
private async repumpIfPending(ledgerSessionId: string): Promise<void> {
|
|
412
|
-
if (this.pumps.has(ledgerSessionId)) {
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const snapshot = await this.ledger.readLedger(ledgerSessionId);
|
|
416
|
-
if (snapshot === undefined || snapshot.pending.length === 0) {
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
if (snapshot.lease !== null && snapshot.lease.expiresAt > this.clock()) {
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
await this.track(ledgerSessionId, () => this.runPump(ledgerSessionId));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Register a pump in the per-session map for its lifetime. */
|
|
426
|
-
private track(
|
|
427
|
-
ledgerSessionId: string,
|
|
428
|
-
run: () => Promise<void>
|
|
429
|
-
): Promise<void> {
|
|
430
|
-
const tracked: Promise<void> = run().finally(() => {
|
|
431
|
-
if (this.pumps.get(ledgerSessionId) === tracked) {
|
|
432
|
-
this.pumps.delete(ledgerSessionId);
|
|
433
|
-
}
|
|
434
|
-
});
|
|
435
|
-
this.pumps.set(ledgerSessionId, tracked);
|
|
436
|
-
return tracked;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* The drain → turn → commit loop for a held lease. Heartbeats extend the
|
|
441
|
-
* lease while turns run; a fenced heartbeat (or fenced commit) means a
|
|
442
|
-
* new holder took over — discard without committing; the new holder
|
|
443
|
-
* re-drains (at-least-once actuation, exactly-once ledger apply).
|
|
444
|
-
*/
|
|
445
|
-
private async runClaimedTurns(
|
|
446
|
-
ledgerSessionId: string,
|
|
447
|
-
lease: LedgerLease
|
|
448
|
-
): Promise<void> {
|
|
449
|
-
let fenced = false;
|
|
450
|
-
const heartbeat = setInterval(() => {
|
|
451
|
-
this.ledger.heartbeat(ledgerSessionId, lease.generation).then(
|
|
452
|
-
(result) => {
|
|
453
|
-
if (!result.ok) {
|
|
454
|
-
fenced = true;
|
|
455
|
-
}
|
|
456
|
-
},
|
|
457
|
-
() => {
|
|
458
|
-
// Transient KV failure: the next beat retries within the TTL.
|
|
459
|
-
}
|
|
460
|
-
);
|
|
461
|
-
}, this.heartbeatIntervalMs);
|
|
462
|
-
heartbeat.unref?.();
|
|
463
|
-
try {
|
|
464
|
-
for (;;) {
|
|
465
|
-
if (fenced) {
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
const { head, events } = await this.ledger.drain(ledgerSessionId);
|
|
469
|
-
if (events.length === 0) {
|
|
470
|
-
await this.ledger.release(ledgerSessionId, lease.generation);
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
for (const event of events) {
|
|
474
|
-
if (fenced) {
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
const envelope = event.envelope as unknown as LedgerDeliveryEnvelope;
|
|
478
|
-
try {
|
|
479
|
-
const session = await this.deliverTurn(envelope);
|
|
480
|
-
await session.waitForCompletion();
|
|
481
|
-
} catch (error) {
|
|
482
|
-
if (!(error instanceof SessionAuthError)) {
|
|
483
|
-
throw error;
|
|
484
|
-
}
|
|
485
|
-
// Defense in depth behind deliver's principal gate: a parked
|
|
486
|
-
// envelope from a foreign principal (gate raced the session's
|
|
487
|
-
// first turn, or a pre-gate build wrote it) is consumed and
|
|
488
|
-
// committed away like any drained event. Aborting the batch
|
|
489
|
-
// instead would replay the already-actuated turns before it
|
|
490
|
-
// and re-throw on every future drain — one poison event must
|
|
491
|
-
// cost a log line, never a stuck session.
|
|
492
|
-
this.logger(
|
|
493
|
-
`[ledger] session ${ledgerSessionId}: discarding parked event ${event.eventId} — its sender is not the session's principal (${describeError(error)})`
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const commit = await this.ledger.commit(ledgerSessionId, {
|
|
498
|
-
drainedEventIds: events.map((event) => event.eventId),
|
|
499
|
-
generation: lease.generation,
|
|
500
|
-
expectedHead: head,
|
|
501
|
-
commitId: randomUUID(),
|
|
502
|
-
});
|
|
503
|
-
if (commit.leaseReleased) {
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
} catch (error) {
|
|
508
|
-
if (error instanceof LedgerFencedError) {
|
|
509
|
-
this.logger(
|
|
510
|
-
`[ledger] session ${ledgerSessionId} fenced mid-turn (generation ${lease.generation}); discarding — the new holder re-drains`
|
|
511
|
-
);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
// The turn never committed; free the session for the next
|
|
515
|
-
// append-driven claim instead of making it wait out the lease TTL
|
|
516
|
-
// (and the janitor cadence). Best-effort: a fenced holder's
|
|
517
|
-
// release is a no-op, and the events stay pending either way.
|
|
518
|
-
await this.ledger
|
|
519
|
-
.release(ledgerSessionId, lease.generation)
|
|
520
|
-
.catch(() => {});
|
|
521
|
-
throw error;
|
|
522
|
-
} finally {
|
|
523
|
-
clearInterval(heartbeat);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** Mint the lease holder id for this process. */
|
|
529
|
-
export function newLedgerPodId(): string {
|
|
530
|
-
return `pod_${randomBytes(6).toString("hex")}`;
|
|
531
|
-
}
|