@cursor/july 0.1.1 → 0.1.2

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 (199) hide show
  1. package/AGENTS.md +24 -2
  2. package/README.md +25 -15
  3. package/dist/bin/agent-serve.js +101 -12
  4. package/dist/channels/github/api.d.ts.map +1 -1
  5. package/dist/channels/github/api.js +31 -14
  6. package/dist/channels/github/cursor-account.d.ts +43 -0
  7. package/dist/channels/github/cursor-account.d.ts.map +1 -0
  8. package/dist/channels/github/cursor-account.js +95 -0
  9. package/dist/channels/github/github-channel.d.ts.map +1 -1
  10. package/dist/channels/github/github-channel.js +46 -9
  11. package/dist/channels/github/index.d.ts +2 -2
  12. package/dist/channels/github/index.js +2 -2
  13. package/dist/channels/github/types.d.ts +17 -0
  14. package/dist/channels/github/types.d.ts.map +1 -1
  15. package/dist/channels/slack/slack-channel.d.ts +8 -2
  16. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  17. package/dist/channels/slack/slack-channel.js +8 -0
  18. package/dist/channels/slack/types.d.ts +24 -3
  19. package/dist/channels/slack/types.d.ts.map +1 -1
  20. package/dist/channels/slack/types.js +15 -1
  21. package/dist/docs/404.html +2 -2
  22. package/dist/docs/ab.html +8 -8
  23. package/dist/docs/assets/{ab.md.COdXkces.js → ab.md.BMCZ6Hd7.js} +3 -3
  24. package/dist/docs/assets/{ab.md.COdXkces.lean.js → ab.md.BMCZ6Hd7.lean.js} +1 -1
  25. package/dist/docs/assets/{app.DqfFEmJd.js → app.Oje4vhlk.js} +1 -1
  26. package/dist/docs/assets/chunks/@localSearchIndexroot.zwQ9RCQ7.js +1 -0
  27. package/dist/docs/assets/chunks/{VPLocalSearchBox.BaLEdS15.js → VPLocalSearchBox.H5XZ2zCB.js} +1 -1
  28. package/dist/docs/assets/chunks/{theme.CZRvu_0q.js → theme.CTR_TuaE.js} +2 -2
  29. package/dist/docs/assets/{deployment.md.Dx1TYNk5.js → deployment.md.DTKwE15Z.js} +3 -3
  30. package/dist/docs/assets/{deployment.md.Dx1TYNk5.lean.js → deployment.md.DTKwE15Z.lean.js} +1 -1
  31. package/dist/docs/assets/{evals.md.DPZ_MAnI.js → evals.md.DAgEc_hL.js} +3 -3
  32. package/dist/docs/assets/{guides_agent-to-agent.md.CrtrsySy.js → guides_agent-to-agent.md.Bpzgq2Pq.js} +1 -1
  33. package/dist/docs/assets/{guides_cloud-runtime.md.CYlNMTNp.js → guides_cloud-runtime.md.gVzabdQL.js} +1 -1
  34. package/dist/docs/assets/{guides_github.md.DwbKhCeS.js → guides_github.md.DOOCpqsW.js} +11 -4
  35. package/dist/docs/assets/{guides_github.md.DwbKhCeS.lean.js → guides_github.md.DOOCpqsW.lean.js} +1 -1
  36. package/dist/docs/assets/{guides_human-in-the-loop.md.Dvuctx7s.js → guides_human-in-the-loop.md.DlUqsp1S.js} +2 -2
  37. package/dist/docs/assets/{guides_slack.md.bv41fHfW.js → guides_slack.md.CCwqHvSV.js} +4 -4
  38. package/dist/docs/assets/{guides_slack.md.bv41fHfW.lean.js → guides_slack.md.CCwqHvSV.lean.js} +1 -1
  39. package/dist/docs/assets/{guides_webhooks.md.hFTik3lf.js → guides_webhooks.md.B1EswtUu.js} +2 -2
  40. package/dist/docs/assets/index.md.m81y7TY7.js +20 -0
  41. package/dist/docs/assets/{index.md.BPKcj5AI.lean.js → index.md.m81y7TY7.lean.js} +1 -1
  42. package/dist/docs/assets/quickstart.md.CfU8_uTC.js +192 -0
  43. package/dist/docs/assets/quickstart.md.CfU8_uTC.lean.js +1 -0
  44. package/dist/docs/assets/{reference_agent-config.md.Bpd7HQwf.js → reference_agent-config.md.DrW2JUM8.js} +4 -4
  45. package/dist/docs/assets/{reference_agent-config.md.Bpd7HQwf.lean.js → reference_agent-config.md.DrW2JUM8.lean.js} +1 -1
  46. package/dist/docs/assets/{reference_channels.md.D7JTR03W.js → reference_channels.md.DdmiKgqf.js} +4 -4
  47. package/dist/docs/assets/{reference_channels.md.D7JTR03W.lean.js → reference_channels.md.DdmiKgqf.lean.js} +1 -1
  48. package/dist/docs/assets/{reference_connections.md.C3vNH_DE.js → reference_connections.md.zaEYCLHT.js} +1 -1
  49. package/dist/docs/assets/{reference_hooks.md.BCEc3MyM.js → reference_hooks.md.DyLVfE1O.js} +1 -1
  50. package/dist/docs/assets/{reference_hooks.md.BCEc3MyM.lean.js → reference_hooks.md.DyLVfE1O.lean.js} +1 -1
  51. package/dist/docs/assets/{reference_http-api.md.DBAahtdz.js → reference_http-api.md.Dx_nmDG6.js} +1 -1
  52. package/dist/docs/assets/{reference_instructions.md.BC05LEQ8.js → reference_instructions.md.CgoV-YEb.js} +9 -7
  53. package/dist/docs/assets/{reference_instructions.md.BC05LEQ8.lean.js → reference_instructions.md.CgoV-YEb.lean.js} +1 -1
  54. package/dist/docs/assets/{reference_schedules.md.D7qijxLk.js → reference_schedules.md.w_F2mXB6.js} +2 -2
  55. package/dist/docs/assets/{reference_skills.md.VQnlBT3Q.js → reference_skills.md.B_jHN7JL.js} +3 -3
  56. package/dist/docs/assets/{reference_skills.md.VQnlBT3Q.lean.js → reference_skills.md.B_jHN7JL.lean.js} +1 -1
  57. package/dist/docs/assets/{reference_subagents.md.CIRAVcPK.js → reference_subagents.md.zWAMNfi1.js} +1 -1
  58. package/dist/docs/assets/{reference_tools.md.DF5kwlt0.js → reference_tools.md.CqgJroI0.js} +2 -2
  59. package/dist/docs/assets/scaffolding-agents.md.C3pTrmoE.js +1 -0
  60. package/dist/docs/assets/scaffolding-agents.md.C3pTrmoE.lean.js +1 -0
  61. package/dist/docs/assets/storage.md.CVnInNiN.js +17 -0
  62. package/dist/docs/assets/storage.md.CVnInNiN.lean.js +1 -0
  63. package/dist/docs/building-with-agents.html +4 -4
  64. package/dist/docs/concepts.html +4 -4
  65. package/dist/docs/deployment.html +7 -7
  66. package/dist/docs/evals.html +7 -7
  67. package/dist/docs/guides/agent-to-agent.html +6 -6
  68. package/dist/docs/guides/cloud-runtime.html +5 -5
  69. package/dist/docs/guides/github.html +14 -7
  70. package/dist/docs/guides/human-in-the-loop.html +6 -6
  71. package/dist/docs/guides/slack.html +8 -8
  72. package/dist/docs/guides/webhooks.html +6 -6
  73. package/dist/docs/hashmap.json +1 -1
  74. package/dist/docs/hillclimbing.html +5 -5
  75. package/dist/docs/index.html +7 -7
  76. package/dist/docs/quickstart.html +180 -23
  77. package/dist/docs/reference/agent-config.html +7 -7
  78. package/dist/docs/reference/channels.html +8 -8
  79. package/dist/docs/reference/cli.html +4 -4
  80. package/dist/docs/reference/connections.html +5 -5
  81. package/dist/docs/reference/hooks.html +5 -5
  82. package/dist/docs/reference/http-api.html +6 -6
  83. package/dist/docs/reference/instructions.html +13 -11
  84. package/dist/docs/reference/playground.html +4 -4
  85. package/dist/docs/reference/project-layout.html +4 -4
  86. package/dist/docs/reference/schedules.html +7 -7
  87. package/dist/docs/reference/sessions.html +4 -4
  88. package/dist/docs/reference/skills.html +6 -6
  89. package/dist/docs/reference/subagents.html +6 -6
  90. package/dist/docs/reference/tools.html +7 -7
  91. package/dist/docs/scaffolding-agents.html +5 -5
  92. package/dist/docs/storage.html +41 -0
  93. package/dist/docs/troubleshooting.html +4 -4
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +1 -0
  97. package/dist/internal/cli-deploy.d.ts +52 -0
  98. package/dist/internal/cli-deploy.d.ts.map +1 -0
  99. package/dist/internal/cli-deploy.js +731 -0
  100. package/dist/internal/cursor/backend-client.d.ts +10 -1
  101. package/dist/internal/cursor/backend-client.d.ts.map +1 -1
  102. package/dist/internal/cursor/backend-client.js +79 -1
  103. package/dist/internal/cursor/github-credentials.d.ts +44 -0
  104. package/dist/internal/cursor/github-credentials.d.ts.map +1 -0
  105. package/dist/internal/cursor/github-credentials.js +195 -0
  106. package/dist/internal/deploy-client.d.ts +176 -0
  107. package/dist/internal/deploy-client.d.ts.map +1 -0
  108. package/dist/internal/deploy-client.js +375 -0
  109. package/dist/internal/discovery.d.ts.map +1 -1
  110. package/dist/internal/discovery.js +73 -5
  111. package/dist/internal/distribution.d.ts.map +1 -1
  112. package/dist/internal/distribution.js +1 -0
  113. package/dist/internal/eval-run-store.d.ts +22 -3
  114. package/dist/internal/eval-run-store.d.ts.map +1 -1
  115. package/dist/internal/eval-run-store.js +37 -19
  116. package/dist/internal/handleAgentServeTrigger.d.ts.map +1 -1
  117. package/dist/internal/handleAgentServeTrigger.js +10 -12
  118. package/dist/internal/host-platforms.d.ts +7 -2
  119. package/dist/internal/host-platforms.d.ts.map +1 -1
  120. package/dist/internal/host-platforms.js +15 -10
  121. package/dist/internal/hosting.d.ts +37 -0
  122. package/dist/internal/hosting.d.ts.map +1 -0
  123. package/dist/internal/hosting.js +67 -0
  124. package/dist/internal/reminder-runner.d.ts +7 -0
  125. package/dist/internal/reminder-runner.d.ts.map +1 -1
  126. package/dist/internal/reminder-runner.js +50 -6
  127. package/dist/internal/reminder-store.d.ts +2 -0
  128. package/dist/internal/reminder-store.d.ts.map +1 -1
  129. package/dist/internal/reminder-store.js +18 -0
  130. package/dist/internal/server.d.ts.map +1 -1
  131. package/dist/internal/server.js +173 -42
  132. package/dist/internal/session-engine.d.ts +49 -0
  133. package/dist/internal/session-engine.d.ts.map +1 -1
  134. package/dist/internal/session-engine.js +246 -17
  135. package/dist/internal/storage-coordinator.d.ts +139 -0
  136. package/dist/internal/storage-coordinator.d.ts.map +1 -0
  137. package/dist/internal/storage-coordinator.js +499 -0
  138. package/dist/playground/assets/cursor-icons-outline-BxTT_FVJ.woff2 +0 -0
  139. package/dist/playground/assets/index-B1Qc9h2u.css +1 -0
  140. package/dist/playground/assets/{index-FlWjhg3x.js → index-CpDYCj8W.js} +42 -42
  141. package/dist/playground/index.html +2 -2
  142. package/dist/storage.d.ts +204 -0
  143. package/dist/storage.d.ts.map +1 -0
  144. package/dist/storage.js +153 -0
  145. package/dist/types.d.ts +44 -3
  146. package/dist/types.d.ts.map +1 -1
  147. package/docs/README.md +3 -2
  148. package/docs/guides/github.md +43 -8
  149. package/docs/guides/slack.md +1 -1
  150. package/docs/quickstart.md +329 -51
  151. package/docs/reference/instructions.md +8 -6
  152. package/docs/scaffolding-agents.md +1 -1
  153. package/docs/storage.md +98 -0
  154. package/package.json +8 -1
  155. package/skills/github/SKILL.md +9 -1
  156. package/src/bin/agent-serve.ts +139 -0
  157. package/src/channels/github/api.ts +42 -23
  158. package/src/channels/github/cursor-account.ts +165 -0
  159. package/src/channels/github/github-channel.ts +66 -6
  160. package/src/channels/github/index.ts +2 -2
  161. package/src/channels/github/types.ts +19 -0
  162. package/src/channels/slack/slack-channel.ts +17 -3
  163. package/src/channels/slack/types.ts +44 -3
  164. package/src/index.ts +13 -0
  165. package/src/internal/cli-deploy.ts +940 -0
  166. package/src/internal/cursor/backend-client.ts +103 -1
  167. package/src/internal/cursor/github-credentials.ts +248 -0
  168. package/src/internal/deploy-client.ts +591 -0
  169. package/src/internal/discovery.ts +88 -1
  170. package/src/internal/distribution.ts +1 -0
  171. package/src/internal/eval-run-store.ts +48 -19
  172. package/src/internal/handleAgentServeTrigger.ts +10 -12
  173. package/src/internal/host-platforms.ts +28 -11
  174. package/src/internal/hosting.ts +77 -0
  175. package/src/internal/reminder-runner.ts +50 -6
  176. package/src/internal/reminder-store.ts +21 -0
  177. package/src/internal/server.ts +213 -27
  178. package/src/internal/session-engine.ts +285 -7
  179. package/src/internal/storage-coordinator.ts +615 -0
  180. package/src/storage.ts +325 -0
  181. package/src/types.ts +40 -2
  182. package/dist/docs/assets/chunks/@localSearchIndexroot.CcVk1uKq.js +0 -1
  183. package/dist/docs/assets/index.md.BPKcj5AI.js +0 -20
  184. package/dist/docs/assets/quickstart.md.tVPiGK_L.js +0 -35
  185. package/dist/docs/assets/quickstart.md.tVPiGK_L.lean.js +0 -1
  186. package/dist/docs/assets/scaffolding-agents.md.CyYfWGdc.js +0 -1
  187. package/dist/docs/assets/scaffolding-agents.md.CyYfWGdc.lean.js +0 -1
  188. package/dist/playground/assets/cursor-icons-outline-oY2V_mvK.woff2 +0 -0
  189. package/dist/playground/assets/index-1K-hG-7p.css +0 -1
  190. /package/dist/docs/assets/{evals.md.DPZ_MAnI.lean.js → evals.md.DAgEc_hL.lean.js} +0 -0
  191. /package/dist/docs/assets/{guides_agent-to-agent.md.CrtrsySy.lean.js → guides_agent-to-agent.md.Bpzgq2Pq.lean.js} +0 -0
  192. /package/dist/docs/assets/{guides_cloud-runtime.md.CYlNMTNp.lean.js → guides_cloud-runtime.md.gVzabdQL.lean.js} +0 -0
  193. /package/dist/docs/assets/{guides_human-in-the-loop.md.Dvuctx7s.lean.js → guides_human-in-the-loop.md.DlUqsp1S.lean.js} +0 -0
  194. /package/dist/docs/assets/{guides_webhooks.md.hFTik3lf.lean.js → guides_webhooks.md.B1EswtUu.lean.js} +0 -0
  195. /package/dist/docs/assets/{reference_connections.md.C3vNH_DE.lean.js → reference_connections.md.zaEYCLHT.lean.js} +0 -0
  196. /package/dist/docs/assets/{reference_http-api.md.DBAahtdz.lean.js → reference_http-api.md.Dx_nmDG6.lean.js} +0 -0
  197. /package/dist/docs/assets/{reference_schedules.md.D7qijxLk.lean.js → reference_schedules.md.w_F2mXB6.lean.js} +0 -0
  198. /package/dist/docs/assets/{reference_subagents.md.CIRAVcPK.lean.js → reference_subagents.md.zWAMNfi1.lean.js} +0 -0
  199. /package/dist/docs/assets/{reference_tools.md.DF5kwlt0.lean.js → reference_tools.md.CqgJroI0.lean.js} +0 -0
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Runtime for `defineStorage` (`agent/storage.ts`).
3
+ *
4
+ * The coordinator is the single funnel between the engine's hot paths and
5
+ * the author's key-value sink. The sink is four functions (`put` / `get` /
6
+ * `delete` / `list`); the coordinator owns everything else: it mints every
7
+ * key from the versioned scheme ({@link storageKeys}), coalesces
8
+ * session records and batches event chunks per turn (or debounce window),
9
+ * serializes delivery on one bounded queue per agent, and isolates
10
+ * failures — a throwing sink is logged and its write dropped; storage
11
+ * must never stall or fail a turn.
12
+ *
13
+ * Scaling shape: sessions, reminders, evals, and A/Bs all reduce to keyed
14
+ * puts on the same queue, so one author-owned sink covers every domain,
15
+ * and new domains are new key prefixes — not new config surface.
16
+ */
17
+
18
+ import type { ABMetricSample, ABSamplePersistence } from "../ab.js";
19
+ import type { EvalRunPersistence, EvalRunSnapshot } from "../evals.js";
20
+ import {
21
+ type ResolvedStoragePolicy,
22
+ resolveStoragePolicy,
23
+ type StorageContext,
24
+ type StorageDefinition,
25
+ storageKeys,
26
+ } from "../storage.js";
27
+ import type { JsonValue, SessionEvent, SessionRecord } from "../types.js";
28
+ import type { ABSnapshot } from "./ab-snapshot.js";
29
+ import { describeError } from "./describe-error.js";
30
+ import { isReminderRecord, type ReminderRecord } from "./reminder-store.js";
31
+
32
+ /** Event types that close a unit of work — flush point for turn-end batching. */
33
+ const TURN_BOUNDARY_EVENTS: ReadonlySet<SessionEvent["type"]> = new Set([
34
+ "turn.completed",
35
+ "turn.failed",
36
+ "session.waiting",
37
+ "session.completed",
38
+ "session.failed",
39
+ ]);
40
+
41
+ /**
42
+ * Max writes waiting on the delivery queue before new ones are dropped.
43
+ * At-most-once delivery already tolerates loss; bounding the queue keeps a
44
+ * slow sink from growing memory without limit under sustained traffic.
45
+ */
46
+ const MAX_PENDING_OPS = 1_000;
47
+
48
+ /**
49
+ * How long {@link StorageCoordinator.close} waits for the sink to
50
+ * drain before giving up. Keeps a hung sink from stalling shutdown past
51
+ * the pod grace period (where a SIGKILL would lose the flush anyway).
52
+ */
53
+ const CLOSE_DRAIN_TIMEOUT_MS = 10_000;
54
+
55
+ /** Debounced event batching flushes early past this many buffered events. */
56
+ const DEBOUNCE_MAX_BATCH = 200;
57
+
58
+ /**
59
+ * Aggregate A/B snapshots are refreshed at most this often — the
60
+ * playground polls `GET /v1/abs` every few seconds, and the snapshot is a
61
+ * convenience backfill, not a source of truth.
62
+ */
63
+ const AB_SNAPSHOT_MIN_INTERVAL_MS = 60_000;
64
+
65
+ /** One queued sink call. */
66
+ type StorageOp =
67
+ | { op: "put"; key: string; value: JsonValue }
68
+ | { op: "delete"; key: string };
69
+
70
+ export interface StorageCoordinatorOptions {
71
+ definition: StorageDefinition;
72
+ agentName: string;
73
+ projectRoot: string;
74
+ logger: (line: string) => void;
75
+ }
76
+
77
+ interface SessionBuffer {
78
+ events: SessionEvent[];
79
+ /** Coalesced latest record; only the newest at flush time is written. */
80
+ record?: SessionRecord;
81
+ debounceTimer?: ReturnType<typeof setTimeout>;
82
+ }
83
+
84
+ export class StorageCoordinator {
85
+ readonly policy: ResolvedStoragePolicy;
86
+
87
+ private readonly definition: StorageDefinition;
88
+ private readonly agentName: string;
89
+ private readonly projectRoot: string;
90
+ private readonly logger: (line: string) => void;
91
+ /** Per-session pending state (events + coalesced record). */
92
+ private readonly buffers = new Map<string, SessionBuffer>();
93
+ /**
94
+ * Last continuation key written to the sink per session, so a token
95
+ * change emits a delete for the stale index entry alongside the new put.
96
+ */
97
+ private readonly continuationIndex = new Map<string, string | null>();
98
+ /** Single delivery chain: writes reach the sink serialized, in order. */
99
+ private queue: Promise<void> = Promise.resolve();
100
+ /** Ops on the queue not yet delivered (bounded by MAX_PENDING_OPS). */
101
+ private pending = 0;
102
+ /** Ops dropped because the queue was full. */
103
+ private dropped = 0;
104
+ private lastAbSnapshotAt = 0;
105
+ private closed = false;
106
+
107
+ constructor(options: StorageCoordinatorOptions) {
108
+ this.definition = options.definition;
109
+ this.policy = resolveStoragePolicy(options.definition.policy);
110
+ this.agentName = options.agentName;
111
+ this.projectRoot = options.projectRoot;
112
+ this.logger = options.logger;
113
+ }
114
+
115
+ // ==========================================================================
116
+ // Sessions (writes)
117
+ // ==========================================================================
118
+
119
+ /** Durable session-record update (engine calls after every store save). */
120
+ sessionRecord(record: SessionRecord): void {
121
+ if (this.closed) {
122
+ return;
123
+ }
124
+ // Coalesced: only the newest record at flush time reaches the sink.
125
+ this.buffer(record.sessionId).record = record;
126
+ }
127
+
128
+ /** One appended session event (engine calls from its dispatch funnel). */
129
+ event(event: SessionEvent): void {
130
+ if (this.closed) {
131
+ return;
132
+ }
133
+ const buffer = this.buffer(event.sessionId);
134
+ buffer.events.push(event);
135
+ const events = this.policy.events;
136
+ if (events !== "turnEnd") {
137
+ // Debounce spans turn boundaries by design ("flush when the session
138
+ // goes quiet", not once per turn); the coalesced record rides the
139
+ // same debounced flush.
140
+ if (buffer.events.length >= DEBOUNCE_MAX_BATCH) {
141
+ this.flushSession(event.sessionId);
142
+ } else {
143
+ this.armDebounce(event.sessionId, buffer, events.debounceMs);
144
+ }
145
+ }
146
+ // Turn boundaries do NOT flush here: the boundary event's own handlers
147
+ // (and their record updates) haven't run yet. The engine calls
148
+ // eventDispatched() once dispatch settles — that's the flush point.
149
+ }
150
+
151
+ /**
152
+ * A boundary event's dispatch settled: channel/hook handlers ran and
153
+ * their record updates (channel state, continuation tokens) are
154
+ * buffered. This — not the append — is the turn-end flush point, so the
155
+ * persisted session record includes the boundary event's own handler
156
+ * mutations. Debounced batching intentionally ignores boundaries.
157
+ */
158
+ eventDispatched(event: SessionEvent): void {
159
+ if (
160
+ this.closed ||
161
+ this.policy.events !== "turnEnd" ||
162
+ !TURN_BOUNDARY_EVENTS.has(event.type)
163
+ ) {
164
+ return;
165
+ }
166
+ this.flushSession(event.sessionId);
167
+ }
168
+
169
+ /** Flush everything buffered for one session (ordered: events, record). */
170
+ flushSession(
171
+ sessionId: string,
172
+ reason: StorageContext["reason"] = "policy"
173
+ ): void {
174
+ const buffer = this.buffers.get(sessionId);
175
+ if (buffer === undefined) {
176
+ return;
177
+ }
178
+ this.buffers.delete(sessionId);
179
+ if (buffer.debounceTimer !== undefined) {
180
+ clearTimeout(buffer.debounceTimer);
181
+ }
182
+ const ops: StorageOp[] = [];
183
+ const first = buffer.events[0];
184
+ if (first !== undefined) {
185
+ ops.push({
186
+ op: "put",
187
+ key: storageKeys.sessionEvents(this.agentName, sessionId, first.index),
188
+ value: buffer.events as unknown as JsonValue,
189
+ });
190
+ }
191
+ if (buffer.record !== undefined) {
192
+ ops.push({
193
+ op: "put",
194
+ key: storageKeys.session(this.agentName, sessionId),
195
+ value: buffer.record as unknown as JsonValue,
196
+ });
197
+ ops.push(...this.continuationOps(buffer.record));
198
+ }
199
+ if (ops.length > 0) {
200
+ this.enqueue(ops, reason);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Index maintenance for `continuation/{channelId}/{token}` → sessionId:
206
+ * put the current token, delete the previous one when it changed.
207
+ */
208
+ private continuationOps(record: SessionRecord): StorageOp[] {
209
+ const previous = this.continuationIndex.get(record.sessionId);
210
+ const next = record.continuationKey;
211
+ if (previous === next) {
212
+ return [];
213
+ }
214
+ this.continuationIndex.set(record.sessionId, next);
215
+ const ops: StorageOp[] = [];
216
+ if (previous != null) {
217
+ ops.push({
218
+ op: "delete",
219
+ key: storageKeys.continuation(
220
+ this.agentName,
221
+ record.channelId,
222
+ previous
223
+ ),
224
+ });
225
+ }
226
+ if (next != null) {
227
+ ops.push({
228
+ op: "put",
229
+ key: storageKeys.continuation(this.agentName, record.channelId, next),
230
+ value: { sessionId: record.sessionId },
231
+ });
232
+ }
233
+ return ops;
234
+ }
235
+
236
+ // ==========================================================================
237
+ // Sessions (reads / restore)
238
+ // ==========================================================================
239
+
240
+ /** Whether the sink can serve the startup bulk restore (`list`). */
241
+ get canBulkRestore(): boolean {
242
+ return this.definition.list !== undefined;
243
+ }
244
+
245
+ /**
246
+ * Saved session records for this agent (startup restore). Errors are
247
+ * logged and read as "nothing saved" — a broken store must never block
248
+ * serve start.
249
+ */
250
+ async listSessions(): Promise<SessionRecord[]> {
251
+ const entries = await this.tryList(
252
+ storageKeys.sessionPrefix(this.agentName)
253
+ );
254
+ return entries
255
+ .map((entry) => entry.value as unknown as SessionRecord)
256
+ .filter((record) => record != null);
257
+ }
258
+
259
+ /**
260
+ * Saved event stream for one session: chunk values concatenated in key
261
+ * order (chunks are keyed by their first event's index, zero-padded, so
262
+ * ascending key order is append order). Errors read as an empty stream.
263
+ */
264
+ async listSessionEvents(sessionId: string): Promise<SessionEvent[]> {
265
+ const entries = await this.tryList(
266
+ storageKeys.sessionEventsPrefix(this.agentName, sessionId)
267
+ );
268
+ const events: SessionEvent[] = [];
269
+ for (const entry of entries) {
270
+ if (Array.isArray(entry.value)) {
271
+ events.push(...(entry.value as unknown as SessionEvent[]));
272
+ }
273
+ }
274
+ return events;
275
+ }
276
+
277
+ /**
278
+ * One saved session by channel continuation key (lazy restore). Unlike
279
+ * the startup reads, sink errors **propagate**: a failing store must
280
+ * fail the follow-up (which the caller can retry) rather than read as
281
+ * "unknown token" — that would mint a new session under the same
282
+ * continuation key and permanently shadow the real one on this host.
283
+ * A definitive miss (no index entry, or a write-only sink without
284
+ * `get`) resolves undefined.
285
+ */
286
+ async getSessionByContinuation(
287
+ channelId: string,
288
+ continuationKey: string
289
+ ): Promise<SessionRecord | undefined> {
290
+ const get = this.definition.get;
291
+ if (get === undefined) {
292
+ return undefined;
293
+ }
294
+ const ctx = this.context("restore");
295
+ const pointer = await get(
296
+ storageKeys.continuation(this.agentName, channelId, continuationKey),
297
+ ctx
298
+ );
299
+ if (pointer == null) {
300
+ return undefined;
301
+ }
302
+ const sessionId =
303
+ typeof pointer === "object" && !Array.isArray(pointer)
304
+ ? pointer.sessionId
305
+ : undefined;
306
+ if (typeof sessionId !== "string" || sessionId === "") {
307
+ throw new Error(
308
+ `storage returned a malformed continuation entry for channel ${channelId}`
309
+ );
310
+ }
311
+ const record = (await get(
312
+ storageKeys.session(this.agentName, sessionId),
313
+ ctx
314
+ )) as SessionRecord | null | undefined;
315
+ if (record == null) {
316
+ // Index entry without its record (e.g. the record write was shed
317
+ // under at-most-once). The session is unrecoverable; treat as a miss
318
+ // so the conversation can continue rather than failing forever.
319
+ this.logger(
320
+ `[agent-serve] storage continuation entry points at missing session ${sessionId}; treating as a miss`
321
+ );
322
+ return undefined;
323
+ }
324
+ // The sink must answer for the exact key it was asked about — a
325
+ // mismatched row is a broken store, not a miss; treating it as a miss
326
+ // would fork the continuation token onto a new session.
327
+ if (
328
+ record.sessionId !== sessionId ||
329
+ record.channelId !== channelId ||
330
+ record.continuationKey !== continuationKey
331
+ ) {
332
+ throw new Error(
333
+ `storage returned a session that does not match the requested continuation (channel ${channelId})`
334
+ );
335
+ }
336
+ return record;
337
+ }
338
+
339
+ // ==========================================================================
340
+ // Reminders
341
+ // ==========================================================================
342
+
343
+ /**
344
+ * Mirror one reminder record (create / fire / cancel / disarm). Local
345
+ * disk under `--state-root/reminders` remains the live source of truth;
346
+ * this is the durable copy for host replacement.
347
+ */
348
+ reminder(record: ReminderRecord): void {
349
+ if (this.closed) {
350
+ return;
351
+ }
352
+ this.enqueue(
353
+ [
354
+ {
355
+ op: "put",
356
+ key: storageKeys.reminder(this.agentName, record.id),
357
+ value: record as unknown as JsonValue,
358
+ },
359
+ ],
360
+ "policy"
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Saved reminder records for this agent (startup hydrate into local
366
+ * disk). Errors read as empty — same posture as session bulk restore.
367
+ */
368
+ async listReminders(): Promise<ReminderRecord[]> {
369
+ const entries = await this.tryList(
370
+ storageKeys.reminderPrefix(this.agentName)
371
+ );
372
+ return entries.flatMap((entry) =>
373
+ isReminderRecord(entry.value) ? [entry.value] : []
374
+ );
375
+ }
376
+
377
+ // ==========================================================================
378
+ // Evals
379
+ // ==========================================================================
380
+
381
+ /**
382
+ * Adapter for {@link EvalRunPersistence} so the playground eval store
383
+ * can fall back to this sink when `evals.config.ts` sets no
384
+ * `persistRuns`.
385
+ */
386
+ asEvalRunPersistence(): EvalRunPersistence {
387
+ return {
388
+ load: async () => {
389
+ const entries = await this.tryList(
390
+ storageKeys.evalRunPrefix(this.agentName)
391
+ );
392
+ return entries
393
+ .map((entry) => entry.value as unknown as EvalRunSnapshot)
394
+ .filter((run) => run != null);
395
+ },
396
+ save: (run: EvalRunSnapshot) => {
397
+ this.enqueue(
398
+ [
399
+ {
400
+ op: "put",
401
+ key: storageKeys.evalRun(this.agentName, run.runId),
402
+ value: run as unknown as JsonValue,
403
+ },
404
+ ],
405
+ "policy"
406
+ );
407
+ },
408
+ delete: (runId: string) => {
409
+ this.enqueue(
410
+ [
411
+ {
412
+ op: "delete",
413
+ key: storageKeys.evalRun(this.agentName, runId),
414
+ },
415
+ ],
416
+ "policy"
417
+ );
418
+ },
419
+ };
420
+ }
421
+
422
+ // ==========================================================================
423
+ // A/Bs
424
+ // ==========================================================================
425
+
426
+ /**
427
+ * Adapter for {@link ABSamplePersistence} so the AB collector can fall
428
+ * back to this sink when `ab.config.ts` sets no `persistSamples`.
429
+ */
430
+ asABSamplePersistence(): ABSamplePersistence {
431
+ return {
432
+ save: (sample: ABMetricSample) => {
433
+ this.enqueue(
434
+ [
435
+ {
436
+ op: "put",
437
+ key: storageKeys.abSample(
438
+ this.agentName,
439
+ sample.sessionId,
440
+ sample.at
441
+ ),
442
+ value: sample as unknown as JsonValue,
443
+ },
444
+ ],
445
+ "policy"
446
+ );
447
+ },
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Refresh the persisted aggregate A/B snapshot, throttled to once per
453
+ * {@link AB_SNAPSHOT_MIN_INTERVAL_MS} (the playground recomputes the
454
+ * fold on every `GET /v1/abs` poll).
455
+ */
456
+ abSnapshot(snapshot: ABSnapshot): void {
457
+ const now = Date.now();
458
+ if (
459
+ this.closed ||
460
+ now - this.lastAbSnapshotAt < AB_SNAPSHOT_MIN_INTERVAL_MS
461
+ ) {
462
+ return;
463
+ }
464
+ this.lastAbSnapshotAt = now;
465
+ this.enqueue(
466
+ [
467
+ {
468
+ op: "put",
469
+ key: storageKeys.abSnapshot(this.agentName),
470
+ value: snapshot as unknown as JsonValue,
471
+ },
472
+ ],
473
+ "policy"
474
+ );
475
+ }
476
+
477
+ /** Latest persisted aggregate A/B snapshot; errors read as absent. */
478
+ async getLatestAbSnapshot(): Promise<ABSnapshot | undefined> {
479
+ const get = this.definition.get;
480
+ if (get === undefined) {
481
+ return undefined;
482
+ }
483
+ try {
484
+ const value = await get(
485
+ storageKeys.abSnapshot(this.agentName),
486
+ this.context("restore")
487
+ );
488
+ return value == null ? undefined : (value as unknown as ABSnapshot);
489
+ } catch (error) {
490
+ this.logger(
491
+ `[agent-serve] storage get(ab-snapshot) failed: ${describeError(error)}`
492
+ );
493
+ return undefined;
494
+ }
495
+ }
496
+
497
+ // ==========================================================================
498
+ // Delivery
499
+ // ==========================================================================
500
+
501
+ /**
502
+ * Flush all buffers and drain the queue, giving the sink at most
503
+ * {@link CLOSE_DRAIN_TIMEOUT_MS} — a hung sink must not stall shutdown
504
+ * past the pod grace period. Called from engine close.
505
+ */
506
+ async close(): Promise<void> {
507
+ if (!this.closed) {
508
+ for (const sessionId of [...this.buffers.keys()]) {
509
+ this.flushSession(sessionId, "shutdown");
510
+ }
511
+ this.closed = true;
512
+ }
513
+ let timer: ReturnType<typeof setTimeout> | undefined;
514
+ const drained = await Promise.race([
515
+ this.queue.then(() => true),
516
+ new Promise<boolean>((resolve) => {
517
+ timer = setTimeout(() => resolve(false), CLOSE_DRAIN_TIMEOUT_MS);
518
+ timer.unref?.();
519
+ }),
520
+ ]);
521
+ clearTimeout(timer);
522
+ if (!drained) {
523
+ this.logger(
524
+ `[agent-serve] storage shutdown drain timed out after ${CLOSE_DRAIN_TIMEOUT_MS}ms with ${this.pending} write(s) undelivered`
525
+ );
526
+ }
527
+ }
528
+
529
+ /** Pending deliveries (exposed for tests and drain instrumentation). */
530
+ whenIdle(): Promise<void> {
531
+ return this.queue;
532
+ }
533
+
534
+ private buffer(sessionId: string): SessionBuffer {
535
+ let buffer = this.buffers.get(sessionId);
536
+ if (buffer === undefined) {
537
+ buffer = { events: [] };
538
+ this.buffers.set(sessionId, buffer);
539
+ }
540
+ return buffer;
541
+ }
542
+
543
+ private armDebounce(
544
+ sessionId: string,
545
+ buffer: SessionBuffer,
546
+ debounceMs: number
547
+ ): void {
548
+ if (buffer.debounceTimer !== undefined) {
549
+ clearTimeout(buffer.debounceTimer);
550
+ }
551
+ buffer.debounceTimer = setTimeout(() => {
552
+ buffer.debounceTimer = undefined;
553
+ this.flushSession(sessionId);
554
+ }, debounceMs);
555
+ // Never keep the process alive just for a pending storage flush.
556
+ buffer.debounceTimer.unref?.();
557
+ }
558
+
559
+ /** `list` wrapper for the startup reads: missing hook or throw ⇒ empty. */
560
+ private async tryList(
561
+ prefix: string
562
+ ): Promise<Array<{ key: string; value: JsonValue }>> {
563
+ const list = this.definition.list;
564
+ if (list === undefined) {
565
+ return [];
566
+ }
567
+ try {
568
+ const entries = await list(prefix, this.context("restore"));
569
+ return Array.isArray(entries) ? entries : [];
570
+ } catch (error) {
571
+ this.logger(
572
+ `[agent-serve] storage list(${prefix}) failed: ${describeError(error)}`
573
+ );
574
+ return [];
575
+ }
576
+ }
577
+
578
+ private enqueue(ops: StorageOp[], reason: StorageContext["reason"]): void {
579
+ if (this.closed && reason !== "shutdown") {
580
+ return;
581
+ }
582
+ if (this.pending + ops.length > MAX_PENDING_OPS) {
583
+ this.dropped += ops.length;
584
+ if (this.dropped <= ops.length || this.dropped % 1_000 < ops.length) {
585
+ this.logger(
586
+ `[agent-serve] storage queue full (${MAX_PENDING_OPS} pending writes); dropped ${this.dropped} write(s) so far — the sink is too slow for the traffic`
587
+ );
588
+ }
589
+ return;
590
+ }
591
+ this.pending += ops.length;
592
+ const ctx = this.context(reason);
593
+ this.queue = this.queue.then(async () => {
594
+ for (const op of ops) {
595
+ try {
596
+ if (op.op === "put") {
597
+ await this.definition.put(op.key, op.value, ctx);
598
+ } else {
599
+ await this.definition.delete?.(op.key, ctx);
600
+ }
601
+ } catch (error) {
602
+ this.logger(
603
+ `[agent-serve] storage ${op.op}(${op.key}) failed: ${describeError(error)}`
604
+ );
605
+ } finally {
606
+ this.pending -= 1;
607
+ }
608
+ }
609
+ });
610
+ }
611
+
612
+ private context(reason: StorageContext["reason"]): StorageContext {
613
+ return { agentName: this.agentName, projectRoot: this.projectRoot, reason };
614
+ }
615
+ }