@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.
Files changed (259) hide show
  1. package/AGENTS.md +4 -3
  2. package/dist/bin/agent-serve.js +28 -9
  3. package/dist/channels/slack/api.d.ts.map +1 -1
  4. package/dist/channels/slack/api.js +18 -8
  5. package/dist/channels/slack/attachments.d.ts +82 -0
  6. package/dist/channels/slack/attachments.d.ts.map +1 -0
  7. package/dist/channels/slack/attachments.js +520 -0
  8. package/dist/channels/slack/cursor-account.d.ts +7 -1
  9. package/dist/channels/slack/cursor-account.d.ts.map +1 -1
  10. package/dist/channels/slack/cursor-account.js +2 -2
  11. package/dist/channels/slack/defaults.d.ts.map +1 -1
  12. package/dist/channels/slack/defaults.js +26 -17
  13. package/dist/channels/slack/dispatch.d.ts +4 -3
  14. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  15. package/dist/channels/slack/dispatch.js +84 -21
  16. package/dist/channels/slack/inbound.d.ts +6 -0
  17. package/dist/channels/slack/inbound.d.ts.map +1 -1
  18. package/dist/channels/slack/inbound.js +48 -2
  19. package/dist/channels/slack/index.d.ts +1 -0
  20. package/dist/channels/slack/index.d.ts.map +1 -1
  21. package/dist/channels/slack/index.js +1 -0
  22. package/dist/channels/slack/live-delivery.d.ts.map +1 -1
  23. package/dist/channels/slack/live-delivery.js +16 -4
  24. package/dist/channels/slack/manifest.d.ts.map +1 -1
  25. package/dist/channels/slack/manifest.js +1 -0
  26. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  27. package/dist/channels/slack/slack-channel.js +7 -4
  28. package/dist/channels/slack/types.d.ts +49 -1
  29. package/dist/channels/slack/types.d.ts.map +1 -1
  30. package/dist/docs/404.html +2 -2
  31. package/dist/docs/ab.html +6 -6
  32. package/dist/docs/assets/{ab.md.6cLOW7--.js → ab.md.hdxsvX5M.js} +2 -2
  33. package/dist/docs/assets/{app.BQ8Hihdf.js → app.mIw24pb1.js} +1 -1
  34. package/dist/docs/assets/{building-with-agents.md.txrcGU2B.js → building-with-agents.md.PeZaZA1P.js} +1 -1
  35. package/dist/docs/assets/chunks/@localSearchIndexroot.BA7V8dEJ.js +1 -0
  36. package/dist/docs/assets/chunks/{VPLocalSearchBox.BBCr8Yuy.js → VPLocalSearchBox.hOzpPwrG.js} +1 -1
  37. package/dist/docs/assets/chunks/{theme.CK_NiGC-.js → theme.BtFhUYmh.js} +2 -2
  38. package/dist/docs/assets/{deployment.md.CuK5SNjN.js → deployment.md.BHVjVqRa.js} +4 -4
  39. package/dist/docs/assets/{deployment.md.CuK5SNjN.lean.js → deployment.md.BHVjVqRa.lean.js} +1 -1
  40. package/dist/docs/assets/{evals.md.BQXI3rXy.js → evals.md.qeoGgPw0.js} +3 -3
  41. package/dist/docs/assets/example-agents_index.md.xmJ87d_3.js +2 -0
  42. package/dist/docs/assets/{guides_webhooks.md.BERuBSJW.js → guides_webhooks.md.DB-r_er9.js} +47 -4
  43. package/dist/docs/assets/{guides_webhooks.md.BERuBSJW.lean.js → guides_webhooks.md.DB-r_er9.lean.js} +1 -1
  44. package/dist/docs/assets/index.md.BKpbkCa6.js +24 -0
  45. package/dist/docs/assets/{index.md.CmhptOmN.lean.js → index.md.BKpbkCa6.lean.js} +1 -1
  46. package/dist/docs/assets/{quickstart.md.C_b6ESpD.js → quickstart.md.rIbfahJM.js} +4 -4
  47. package/dist/docs/assets/{reference_cli.md.Byvrg8eu.js → reference_cli.md.DqEqQpGn.js} +5 -5
  48. package/dist/docs/assets/{reference_cli.md.Byvrg8eu.lean.js → reference_cli.md.DqEqQpGn.lean.js} +1 -1
  49. package/dist/docs/assets/{reference_hooks.md.BGDw4VLm.js → reference_hooks.md.BHeG-vjK.js} +2 -2
  50. package/dist/docs/assets/{reference_hooks.md.BGDw4VLm.lean.js → reference_hooks.md.BHeG-vjK.lean.js} +1 -1
  51. package/dist/docs/assets/{storage.md.mQDtIULc.js → storage.md.DcGSnIxX.js} +10 -4
  52. package/dist/docs/assets/{storage.md.mQDtIULc.lean.js → storage.md.DcGSnIxX.lean.js} +1 -1
  53. package/dist/docs/assets/troubleshooting.md.CkgwmVI0.js +1 -0
  54. package/dist/docs/assets/{troubleshooting.md.B5RVX_tL.lean.js → troubleshooting.md.CkgwmVI0.lean.js} +1 -1
  55. package/dist/docs/building-with-agents.html +5 -5
  56. package/dist/docs/concepts.html +3 -3
  57. package/dist/docs/deployment.html +6 -6
  58. package/dist/docs/evals.html +7 -7
  59. package/dist/docs/example-agents/approval-buddy.html +3 -3
  60. package/dist/docs/example-agents/benny.html +3 -3
  61. package/dist/docs/example-agents/bugbot.html +3 -3
  62. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  63. package/dist/docs/example-agents/codeowners-review.html +3 -3
  64. package/dist/docs/example-agents/concierge.html +3 -3
  65. package/dist/docs/example-agents/fsd.html +3 -3
  66. package/dist/docs/example-agents/index.html +5 -5
  67. package/dist/docs/example-agents/knowledge-base.html +3 -3
  68. package/dist/docs/example-agents/oncall.html +3 -3
  69. package/dist/docs/example-agents/security-reviewer.html +3 -3
  70. package/dist/docs/example-agents/slack-agent.html +3 -3
  71. package/dist/docs/example-agents/weather-agent.html +3 -3
  72. package/dist/docs/guides/agent-to-agent.html +3 -3
  73. package/dist/docs/guides/cloud-runtime.html +3 -3
  74. package/dist/docs/guides/github.html +3 -3
  75. package/dist/docs/guides/human-in-the-loop.html +3 -3
  76. package/dist/docs/guides/mcp-oauth.html +3 -3
  77. package/dist/docs/guides/slack.html +3 -3
  78. package/dist/docs/guides/webhooks.html +49 -6
  79. package/dist/docs/hashmap.json +1 -1
  80. package/dist/docs/hillclimbing.html +3 -3
  81. package/dist/docs/index.html +7 -7
  82. package/dist/docs/quickstart.html +8 -8
  83. package/dist/docs/reference/agent-config.html +3 -3
  84. package/dist/docs/reference/artifacts.html +3 -3
  85. package/dist/docs/reference/channels.html +3 -3
  86. package/dist/docs/reference/cli.html +8 -8
  87. package/dist/docs/reference/connections.html +3 -3
  88. package/dist/docs/reference/hooks.html +5 -5
  89. package/dist/docs/reference/http-api.html +3 -3
  90. package/dist/docs/reference/instructions.html +3 -3
  91. package/dist/docs/reference/playground.html +3 -3
  92. package/dist/docs/reference/project-layout.html +3 -3
  93. package/dist/docs/reference/prompt.html +3 -3
  94. package/dist/docs/reference/schedules.html +3 -3
  95. package/dist/docs/reference/sessions.html +3 -3
  96. package/dist/docs/reference/skills.html +3 -3
  97. package/dist/docs/reference/subagents.html +3 -3
  98. package/dist/docs/reference/tools.html +3 -3
  99. package/dist/docs/scaffolding-agents.html +3 -3
  100. package/dist/docs/storage.html +14 -8
  101. package/dist/docs/troubleshooting.html +5 -5
  102. package/dist/index.d.ts +4 -2
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +3 -1
  105. package/dist/internal/cli-ax.d.ts.map +1 -1
  106. package/dist/internal/cli-ax.js +22 -2
  107. package/dist/internal/cli-deploy.d.ts +6 -0
  108. package/dist/internal/cli-deploy.d.ts.map +1 -1
  109. package/dist/internal/cli-deploy.js +14 -4
  110. package/dist/internal/cli-mcp-oauth.d.ts.map +1 -1
  111. package/dist/internal/cli-mcp-oauth.js +3 -1
  112. package/dist/internal/cli-mcp.js +4 -4
  113. package/dist/internal/cursor-event-relay.d.ts +45 -0
  114. package/dist/internal/cursor-event-relay.d.ts.map +1 -1
  115. package/dist/internal/cursor-event-relay.js +96 -2
  116. package/dist/internal/deploy-client.js +1 -1
  117. package/dist/internal/discovery.d.ts.map +1 -1
  118. package/dist/internal/discovery.js +35 -7
  119. package/dist/internal/distribution.d.ts.map +1 -1
  120. package/dist/internal/distribution.js +3 -1
  121. package/dist/internal/durable-session-store.d.ts +48 -0
  122. package/dist/internal/durable-session-store.d.ts.map +1 -0
  123. package/dist/internal/durable-session-store.js +86 -0
  124. package/dist/internal/hosting.d.ts +1 -1
  125. package/dist/internal/hosting.js +2 -2
  126. package/dist/internal/init-project.d.ts +34 -0
  127. package/dist/internal/init-project.d.ts.map +1 -1
  128. package/dist/internal/init-project.js +121 -2
  129. package/dist/internal/ledger-serving.d.ts +101 -0
  130. package/dist/internal/ledger-serving.d.ts.map +1 -0
  131. package/dist/internal/ledger-serving.js +174 -0
  132. package/dist/internal/listen-port.d.ts +22 -0
  133. package/dist/internal/listen-port.d.ts.map +1 -0
  134. package/dist/internal/listen-port.js +77 -0
  135. package/dist/internal/mcp-oauth.d.ts +12 -1
  136. package/dist/internal/mcp-oauth.d.ts.map +1 -1
  137. package/dist/internal/mcp-oauth.js +25 -3
  138. package/dist/internal/playground/static.d.ts +21 -4
  139. package/dist/internal/playground/static.d.ts.map +1 -1
  140. package/dist/internal/playground/static.js +82 -18
  141. package/dist/internal/sdk-runner.d.ts.map +1 -1
  142. package/dist/internal/sdk-runner.js +10 -1
  143. package/dist/internal/server.d.ts.map +1 -1
  144. package/dist/internal/server.js +67 -49
  145. package/dist/internal/session-engine.d.ts +57 -1
  146. package/dist/internal/session-engine.d.ts.map +1 -1
  147. package/dist/internal/session-engine.js +117 -28
  148. package/dist/internal/slack-provision-client.js +1 -1
  149. package/dist/internal/workspace.d.ts +6 -2
  150. package/dist/internal/workspace.d.ts.map +1 -1
  151. package/dist/internal/workspace.js +10 -2
  152. package/dist/kv/memory-kv.d.ts +23 -0
  153. package/dist/kv/memory-kv.d.ts.map +1 -0
  154. package/dist/kv/memory-kv.js +103 -0
  155. package/dist/kv.d.ts +22 -0
  156. package/dist/kv.d.ts.map +1 -0
  157. package/dist/kv.js +20 -0
  158. package/dist/ledger/session-ledger.d.ts +204 -0
  159. package/dist/ledger/session-ledger.d.ts.map +1 -0
  160. package/dist/ledger/session-ledger.js +382 -0
  161. package/dist/ledger.d.ts +11 -0
  162. package/dist/ledger.d.ts.map +1 -0
  163. package/dist/ledger.js +9 -0
  164. package/dist/playground/assets/index-B3HBneZZ.js +85 -0
  165. package/dist/playground/assets/index-Bqk9nplh.css +1 -0
  166. package/dist/playground/index.html +2 -2
  167. package/dist/storage-backends/cursor-hosted.d.ts +25 -3
  168. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
  169. package/dist/storage-backends/cursor-hosted.js +50 -2
  170. package/dist/storage-backends/file-kv.d.ts +5 -2
  171. package/dist/storage-backends/file-kv.d.ts.map +1 -1
  172. package/dist/storage-backends/file-kv.js +107 -7
  173. package/dist/storage.d.ts +60 -5
  174. package/dist/storage.d.ts.map +1 -1
  175. package/dist/storage.js +28 -4
  176. package/dist/types.d.ts +123 -3
  177. package/dist/types.d.ts.map +1 -1
  178. package/dist/types.js +14 -0
  179. package/docs/README.md +10 -7
  180. package/docs/ab.md +2 -2
  181. package/docs/building-with-agents.md +1 -1
  182. package/docs/deployment.md +34 -11
  183. package/docs/evals.md +3 -3
  184. package/docs/example-agents/index.md +1 -1
  185. package/docs/guides/webhooks.md +87 -0
  186. package/docs/quickstart.md +7 -8
  187. package/docs/reference/cli.md +15 -6
  188. package/docs/reference/hooks.md +8 -2
  189. package/docs/storage.md +67 -7
  190. package/docs/troubleshooting.md +10 -2
  191. package/package.json +20 -7
  192. package/skills/create-agent/SKILL.md +1 -1
  193. package/src/bin/agent-serve.ts +27 -11
  194. package/src/channels/slack/api.ts +19 -14
  195. package/src/channels/slack/attachments.ts +635 -0
  196. package/src/channels/slack/cursor-account.ts +8 -0
  197. package/src/channels/slack/defaults.ts +34 -18
  198. package/src/channels/slack/dispatch.ts +121 -6
  199. package/src/channels/slack/inbound.ts +60 -0
  200. package/src/channels/slack/index.ts +1 -0
  201. package/src/channels/slack/live-delivery.ts +17 -3
  202. package/src/channels/slack/manifest.ts +1 -0
  203. package/src/channels/slack/slack-channel.ts +7 -0
  204. package/src/channels/slack/types.ts +47 -1
  205. package/src/index.ts +9 -0
  206. package/src/internal/cli-ax.ts +25 -1
  207. package/src/internal/cli-deploy.ts +22 -4
  208. package/src/internal/cli-mcp-oauth.ts +3 -1
  209. package/src/internal/cli-mcp.ts +4 -4
  210. package/src/internal/cursor-event-relay.ts +168 -0
  211. package/src/internal/deploy-client.ts +1 -1
  212. package/src/internal/discovery.ts +42 -5
  213. package/src/internal/distribution.ts +3 -1
  214. package/src/internal/durable-session-store.ts +95 -0
  215. package/src/internal/hosting.ts +2 -2
  216. package/src/internal/init-project.ts +151 -2
  217. package/src/internal/ledger-serving.ts +240 -0
  218. package/src/internal/listen-port.ts +85 -0
  219. package/src/internal/mcp-oauth.ts +29 -4
  220. package/src/internal/playground/static.ts +111 -20
  221. package/src/internal/sdk-runner.ts +11 -1
  222. package/src/internal/server.ts +76 -62
  223. package/src/internal/session-engine.ts +192 -16
  224. package/src/internal/slack-provision-client.ts +1 -1
  225. package/src/internal/workspace.ts +14 -3
  226. package/src/kv/memory-kv.ts +99 -0
  227. package/src/kv.ts +27 -0
  228. package/src/ledger/session-ledger.ts +535 -0
  229. package/src/ledger.ts +29 -0
  230. package/src/storage-backends/cursor-hosted.ts +108 -3
  231. package/src/storage-backends/file-kv.ts +128 -12
  232. package/src/storage.ts +90 -6
  233. package/src/types.ts +130 -4
  234. package/dist/channels/slack/post-update-delivery.d.ts +0 -85
  235. package/dist/channels/slack/post-update-delivery.d.ts.map +0 -1
  236. package/dist/docs/assets/chunks/@localSearchIndexroot.B-VTH4As.js +0 -1
  237. package/dist/docs/assets/example-agents_index.md.DgGBwckv.js +0 -2
  238. package/dist/docs/assets/index.md.CmhptOmN.js +0 -24
  239. package/dist/docs/assets/troubleshooting.md.B5RVX_tL.js +0 -1
  240. package/dist/internal/json-dir-store.d.ts +0 -32
  241. package/dist/internal/json-dir-store.d.ts.map +0 -1
  242. package/dist/internal/persistence-coordinator.d.ts +0 -127
  243. package/dist/internal/persistence-coordinator.d.ts.map +0 -1
  244. package/dist/multi-tenant.d.ts +0 -80
  245. package/dist/multi-tenant.d.ts.map +0 -1
  246. package/dist/multi-tenant.js +0 -69
  247. package/dist/persistence.d.ts +0 -184
  248. package/dist/persistence.d.ts.map +0 -1
  249. package/dist/playground/assets/index-BRhBbnd4.css +0 -1
  250. package/dist/playground/assets/index-_2WKw9a-.js +0 -85
  251. package/dist/storage-backends/postgres-kv.d.ts +0 -82
  252. package/dist/storage-backends/postgres-kv.d.ts.map +0 -1
  253. package/dist/storage-backends/postgres-kv.js +0 -185
  254. package/src/storage-backends/postgres-kv.ts +0 -253
  255. /package/dist/docs/assets/{ab.md.6cLOW7--.lean.js → ab.md.hdxsvX5M.lean.js} +0 -0
  256. /package/dist/docs/assets/{building-with-agents.md.txrcGU2B.lean.js → building-with-agents.md.PeZaZA1P.lean.js} +0 -0
  257. /package/dist/docs/assets/{evals.md.BQXI3rXy.lean.js → evals.md.qeoGgPw0.lean.js} +0 -0
  258. /package/dist/docs/assets/{example-agents_index.md.DgGBwckv.lean.js → example-agents_index.md.xmJ87d_3.lean.js} +0 -0
  259. /package/dist/docs/assets/{quickstart.md.C_b6ESpD.lean.js → quickstart.md.rIbfahJM.lean.js} +0 -0
@@ -10,8 +10,7 @@
10
10
  * On Cursor-managed hosting the start script pins `CURSOR_API_BASE_URL` to
11
11
  * `https://api.cursor.com` (where the proxy is mounted). Locally, point
12
12
  * `CURSOR_API_BASE_URL` at a backend that mounts Agent Serve routes and use
13
- * a deployment SA key, or use `@cursor/july/storage/postgres-kv` /
14
- * `file-kv` instead.
13
+ * a deployment SA key, or use `@cursor/july/storage/file-kv` instead.
15
14
  *
16
15
  * ```ts
17
16
  * import { defineStorage } from "@cursor/july/storage";
@@ -27,11 +26,12 @@ import type { ABSnapshot } from "../ab.js";
27
26
  import type { EvalRunSnapshot } from "../evals.js";
28
27
  import type {
29
28
  StorageABTable,
29
+ StorageCasTable,
30
30
  StorageConfig,
31
31
  StorageEntry,
32
32
  StorageEvalsTable,
33
33
  } from "../storage.js";
34
- import type { JsonValue } from "../types.js";
34
+ import type { JsonValue, KvCasResult, KvVersionedEntry } from "../types.js";
35
35
 
36
36
  /**
37
37
  * Same default as {@link cursorExternalApiUrl} in the CLI credentials module,
@@ -103,10 +103,75 @@ type CursorHostedStorageMethods = Required<
103
103
  Pick<StorageConfig, "put" | "get" | "delete" | "list">
104
104
  > & {
105
105
  readonly [CURSOR_HOSTED_STORAGE]: true;
106
+ cas: StorageCasTable;
106
107
  evals: StorageEvalsTable;
107
108
  abs: StorageABTable;
108
109
  };
109
110
 
111
+ // ============================================================================
112
+ // CAS wire protocol
113
+ // ============================================================================
114
+ //
115
+ // Served by the backend Agent Serve storage proxy
116
+ // (`backend/server/src/agentServe/agentServeHostedStorage.ts`, routes in
117
+ // `agentServeEndpoints.ts`) over `agent_serve_kv` and its `version BIGINT`
118
+ // column. Each call is atomic server-side:
119
+ //
120
+ // - `POST /internal/agent-serve/storage/kv/get-with-version`
121
+ // `{ key }` → `{ found, value, version }` ({@link KvGetWithVersionResponse}).
122
+ // - `POST /internal/agent-serve/storage/kv/put-if-absent`
123
+ // `{ key, value }` → `{ ok, version, current }` ({@link KvCasResponse});
124
+ // plain INSERT, unique-violation conflict returns the existing row as
125
+ // `current`.
126
+ // - `POST /internal/agent-serve/storage/kv/put-if-version`
127
+ // `{ key, value, expectedVersion }` → `{ ok, version, current }`;
128
+ // one conditional `UPDATE … SET value, version = version + 1 WHERE key
129
+ // AND version = expected`, conflict returns the current row (or
130
+ // `current: null` when the row is gone).
131
+ // - `POST /internal/agent-serve/storage/kv/list-keys`
132
+ // `{ prefix }` → `{ keys }` ({@link KvListKeysResponse}) in ascending
133
+ // key order (prefix scan on the `(deployment_id, key)` primary key).
134
+
135
+ /** Wire shape of the `get-with-version` route. `null` ⇒ JSON `undefined`. */
136
+ export interface KvGetWithVersionResponse {
137
+ found: boolean;
138
+ value: JsonValue | null;
139
+ version: string | null;
140
+ }
141
+
142
+ /** Wire shape of the `put-if-absent` / `put-if-version` routes. */
143
+ export interface KvCasResponse {
144
+ ok: boolean;
145
+ /** New version on success, else `null`. */
146
+ version: string | null;
147
+ /** Entry observed on conflict; `null` when the key is absent. */
148
+ current: { value: JsonValue | null; version: string } | null;
149
+ }
150
+
151
+ /** Wire shape of the `list-keys` route. */
152
+ export interface KvListKeysResponse {
153
+ keys: string[];
154
+ }
155
+
156
+ function toCasResult(response: KvCasResponse): KvCasResult {
157
+ if (response.ok) {
158
+ if (response.version === null) {
159
+ throw new Error(
160
+ "cursorHostedStorage: CAS success without a version token"
161
+ );
162
+ }
163
+ return { ok: true, version: response.version };
164
+ }
165
+ const current: KvVersionedEntry | undefined =
166
+ response.current === null
167
+ ? undefined
168
+ : {
169
+ value: response.current.value ?? null,
170
+ version: response.current.version,
171
+ };
172
+ return { ok: false, current };
173
+ }
174
+
110
175
  function resolveApiKey(options: CursorHostedStorageOptions): string {
111
176
  const key = (options.apiKey ?? process.env.CURSOR_API_KEY)?.trim();
112
177
  if (key === undefined || key === "") {
@@ -203,6 +268,46 @@ export function cursorHostedStorage(
203
268
  }>(options, "/internal/agent-serve/storage/kv/list", { prefix });
204
269
  return result.entries;
205
270
  },
271
+ // Conditional writes over the same proxy (server routes: see the CAS
272
+ // wire-protocol block above). The proxy scopes rows from the service
273
+ // account, so ctx is not part of the wire contract.
274
+ cas: {
275
+ async getWithVersion(key): Promise<KvVersionedEntry | undefined> {
276
+ const result = await storageRpc<KvGetWithVersionResponse>(
277
+ options,
278
+ "/internal/agent-serve/storage/kv/get-with-version",
279
+ { key }
280
+ );
281
+ if (!result.found || result.version === null) {
282
+ return undefined;
283
+ }
284
+ return { value: result.value ?? null, version: result.version };
285
+ },
286
+ async putIfAbsent(key, value): Promise<KvCasResult> {
287
+ const result = await storageRpc<KvCasResponse>(
288
+ options,
289
+ "/internal/agent-serve/storage/kv/put-if-absent",
290
+ { key, value }
291
+ );
292
+ return toCasResult(result);
293
+ },
294
+ async putIfVersion(key, value, expectedVersion): Promise<KvCasResult> {
295
+ const result = await storageRpc<KvCasResponse>(
296
+ options,
297
+ "/internal/agent-serve/storage/kv/put-if-version",
298
+ { key, value, expectedVersion }
299
+ );
300
+ return toCasResult(result);
301
+ },
302
+ async listKeys(prefix): Promise<string[]> {
303
+ const result = await storageRpc<KvListKeysResponse>(
304
+ options,
305
+ "/internal/agent-serve/storage/kv/list-keys",
306
+ { prefix }
307
+ );
308
+ return result.keys;
309
+ },
310
+ },
206
311
  evals: {
207
312
  async put(run, ctx): Promise<void> {
208
313
  await storageRpc(options, "/internal/agent-serve/storage/evals/put", {
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Implements the whole `defineStorage` surface with zero external
6
6
  * dependencies: the four KV methods against a flat directory of JSON files
7
- * (one file per key, filename = URI-encoded key), eval runs as
7
+ * (one file per key, filename = URI-encoded key), the `cas`
8
+ * group (single-process conditional writes — see the `cas` member below),
9
+ * eval runs as
8
10
  * `eval-runs/<agent>/<runId>.json`, and A/B metrics as
9
11
  * `ab/<agent>/samples.ndjson` (append-only) plus `ab/<agent>/snapshot.json`
10
12
  * (latest aggregate). KV keys carry the agent name inside the framework
@@ -25,7 +27,7 @@
25
27
  * throw correctly fails the follow-up instead of forking the session.
26
28
  */
27
29
 
28
- import { randomUUID } from "node:crypto";
30
+ import { createHash, randomUUID } from "node:crypto";
29
31
  import {
30
32
  appendFile,
31
33
  mkdir,
@@ -35,17 +37,18 @@ import {
35
37
  rm,
36
38
  writeFile,
37
39
  } from "node:fs/promises";
38
- import { join } from "node:path";
40
+ import { isAbsolute, join } from "node:path";
39
41
  import type { ABSnapshot } from "../ab.js";
40
42
  import type { EvalRunSnapshot } from "../evals.js";
41
43
  import type {
42
44
  StorageABTable,
45
+ StorageCasTable,
43
46
  StorageConfig,
44
47
  StorageContext,
45
48
  StorageEntry,
46
49
  StorageEvalsTable,
47
50
  } from "../storage.js";
48
- import type { JsonValue } from "../types.js";
51
+ import type { JsonValue, KvVersionedEntry } from "../types.js";
49
52
 
50
53
  export interface FileKvOptions {
51
54
  /**
@@ -58,14 +61,17 @@ export interface FileKvOptions {
58
61
  type FileKvMethods = Required<
59
62
  Pick<StorageConfig, "put" | "get" | "delete" | "list">
60
63
  > & {
64
+ cas: StorageCasTable;
61
65
  evals: StorageEvalsTable;
62
66
  abs: StorageABTable;
63
67
  };
64
68
 
65
69
  /** File-backed `defineStorage` methods, ready to spread into `defineStorage({...})`. */
66
70
  export function fileKv(options?: FileKvOptions): FileKvMethods {
67
- const resolveDir = (ctx: StorageContext): string =>
68
- join(ctx.projectRoot, options?.dir ?? ".agent-serve/kv");
71
+ const resolveDir = (ctx: StorageContext): string => {
72
+ const dir = options?.dir ?? ".agent-serve/kv";
73
+ return isAbsolute(dir) ? dir : join(ctx.projectRoot, dir);
74
+ };
69
75
  // The evals/abs tables are scoped per agent (KV keys already carry the
70
76
  // agent name via the framework key scheme).
71
77
  const evalRunsDir = (ctx: StorageContext): string =>
@@ -74,14 +80,12 @@ export function fileKv(options?: FileKvOptions): FileKvMethods {
74
80
  join(resolveDir(ctx), "ab", encodeURIComponent(ctx.agentName));
75
81
  const filePath = (ctx: StorageContext, key: string): string =>
76
82
  join(resolveDir(ctx), `${encodeURIComponent(key)}.json`);
77
- const readKey = async (
83
+ const readRaw = async (
78
84
  ctx: StorageContext,
79
85
  key: string
80
- ): Promise<JsonValue | undefined> => {
86
+ ): Promise<string | undefined> => {
81
87
  try {
82
- return JSON.parse(
83
- await readFile(filePath(ctx, key), "utf8")
84
- ) as JsonValue;
88
+ return await readFile(filePath(ctx, key), "utf8");
85
89
  } catch (error) {
86
90
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
87
91
  return undefined; // definitive miss — key was never written
@@ -89,6 +93,37 @@ export function fileKv(options?: FileKvOptions): FileKvMethods {
89
93
  throw error; // real failure: let lazy restore fail the follow-up
90
94
  }
91
95
  };
96
+ const readKey = async (
97
+ ctx: StorageContext,
98
+ key: string
99
+ ): Promise<JsonValue | undefined> => {
100
+ const text = await readRaw(ctx, key);
101
+ return text === undefined ? undefined : (JSON.parse(text) as JsonValue);
102
+ };
103
+ const readVersioned = async (
104
+ ctx: StorageContext,
105
+ key: string
106
+ ): Promise<KvVersionedEntry | undefined> => {
107
+ const text = await readRaw(ctx, key);
108
+ if (text === undefined) {
109
+ return undefined;
110
+ }
111
+ return { value: JSON.parse(text) as JsonValue, version: versionOf(text) };
112
+ };
113
+
114
+ // In-process mutex: CAS check-then-write pairs queue on one promise
115
+ // chain so they cannot interleave with each other. Plain `put` stays
116
+ // outside the chain on purpose — it is unconditional LWW and always
117
+ // wins the key, like a SQL upsert would win the row.
118
+ let casChain: Promise<unknown> = Promise.resolve();
119
+ const locked = <T>(task: () => Promise<T>): Promise<T> => {
120
+ const result = casChain.then(task, task);
121
+ casChain = result.then(
122
+ () => undefined,
123
+ () => undefined
124
+ );
125
+ return result;
126
+ };
92
127
 
93
128
  return {
94
129
  async put(key, value, ctx): Promise<void> {
@@ -132,6 +167,69 @@ export function fileKv(options?: FileKvOptions): FileKvMethods {
132
167
  return entries;
133
168
  },
134
169
 
170
+ // Conditional writes over the same raw value files — no on-disk format
171
+ // change, so data written before the CAS group existed stays readable
172
+ // and plain `put` stays byte-identical. The version token is a content
173
+ // hash of the stored JSON text: it changes whenever the value does,
174
+ // and the in-process mutex makes read-check-write atomic, so a token
175
+ // can never go stale between the check and the rename. The one caveat
176
+ // of hash versions — ABA when a value returns to identical bytes — is
177
+ // moot for coordination values that carry a monotonic counter. CAS
178
+ // correctness is **single-process only**: two pods sharing this
179
+ // directory would race between read and rename, so a file store is
180
+ // only safe for single-instance deployments.
181
+ cas: {
182
+ getWithVersion: (key, ctx): Promise<KvVersionedEntry | undefined> =>
183
+ readVersioned(ctx, key),
184
+
185
+ putIfAbsent: (key, value, ctx) =>
186
+ locked(async () => {
187
+ const current = await readVersioned(ctx, key);
188
+ if (current !== undefined) {
189
+ return { ok: false, current };
190
+ }
191
+ const text = JSON.stringify(value);
192
+ await writeTextAtomic({
193
+ dir: resolveDir(ctx),
194
+ fileName: `${encodeURIComponent(key)}.json`,
195
+ text,
196
+ });
197
+ return { ok: true, version: versionOf(text) };
198
+ }),
199
+
200
+ putIfVersion: (key, value, expectedVersion, ctx) =>
201
+ locked(async () => {
202
+ const current = await readVersioned(ctx, key);
203
+ if (current === undefined || current.version !== expectedVersion) {
204
+ return { ok: false, current };
205
+ }
206
+ const text = JSON.stringify(value);
207
+ await writeTextAtomic({
208
+ dir: resolveDir(ctx),
209
+ fileName: `${encodeURIComponent(key)}.json`,
210
+ text,
211
+ });
212
+ return { ok: true, version: versionOf(text) };
213
+ }),
214
+
215
+ async listKeys(prefix, ctx): Promise<string[]> {
216
+ let names: string[];
217
+ try {
218
+ names = await readdir(resolveDir(ctx));
219
+ } catch (error) {
220
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
221
+ return [];
222
+ }
223
+ throw error;
224
+ }
225
+ return names
226
+ .filter((name) => name.endsWith(".json"))
227
+ .map((name) => decodeURIComponent(name.slice(0, -".json".length)))
228
+ .filter((key) => key.startsWith(prefix))
229
+ .sort();
230
+ },
231
+ },
232
+
135
233
  // Playground eval batches: one JSON file per run under eval-runs/<agent>/.
136
234
  evals: {
137
235
  async put(run, ctx): Promise<void> {
@@ -208,14 +306,32 @@ export function fileKv(options?: FileKvOptions): FileKvMethods {
208
306
  };
209
307
  }
210
308
 
309
+ /** Content-hash version token for a stored JSON text. */
310
+ function versionOf(text: string): string {
311
+ return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
312
+ }
313
+
211
314
  /** tmp + rename write so concurrent readers never see a torn file. */
212
315
  async function writeJsonAtomic(args: {
213
316
  dir: string;
214
317
  fileName: string;
215
318
  value: unknown;
319
+ }): Promise<void> {
320
+ await writeTextAtomic({
321
+ dir: args.dir,
322
+ fileName: args.fileName,
323
+ text: JSON.stringify(args.value),
324
+ });
325
+ }
326
+
327
+ /** tmp + rename write of exact text (CAS hashes the same bytes it wrote). */
328
+ async function writeTextAtomic(args: {
329
+ dir: string;
330
+ fileName: string;
331
+ text: string;
216
332
  }): Promise<void> {
217
333
  await mkdir(args.dir, { recursive: true });
218
334
  const tmp = join(args.dir, `.tmp-${randomUUID()}`);
219
- await writeFile(tmp, JSON.stringify(args.value), "utf8");
335
+ await writeFile(tmp, args.text, "utf8");
220
336
  await rename(tmp, join(args.dir, args.fileName));
221
337
  }
package/src/storage.ts CHANGED
@@ -6,9 +6,12 @@
6
6
  * pipeline, …). Without it, state lives under `--state-root` on local disk
7
7
  * only, and eval/A/B history stays in process memory.
8
8
  *
9
- * The sink is a plain key-value store — four functions, no schema — plus
10
- * two optional dedicated tables ({@link StorageConfig.evals} and
11
- * {@link StorageConfig.abs}) for eval-run and A/B history:
9
+ * The sink is a plain key-value store — put/get/delete/list, no schema —
10
+ * plus a conditional-write group ({@link StorageConfig.cas}, the substrate
11
+ * for framework coordination; optional for plain storage, required by
12
+ * coordination features) and two optional dedicated tables
13
+ * ({@link StorageConfig.evals} and {@link StorageConfig.abs}) for
14
+ * eval-run and A/B history:
12
15
  *
13
16
  * ```ts
14
17
  * import { defineStorage } from "@anysphere/agent-serve/storage";
@@ -18,6 +21,7 @@
18
21
  * get: (key) => db.get(key),
19
22
  * delete: (key) => db.delete(key),
20
23
  * list: (prefix) => db.listByPrefix(prefix), // [{ key, value }] in key order
24
+ * cas: { getWithVersion: ..., putIfAbsent: ..., putIfVersion: ..., listKeys: ... },
21
25
  * evals: { put: ..., delete: ..., list: ... }, // eval-runs table
22
26
  * abs: { putSample: ... }, // A/B table
23
27
  * });
@@ -50,7 +54,7 @@ import type { ABMetricSample } from "./ab.js";
50
54
  import type { EvalRunSnapshot } from "./evals.js";
51
55
  import type { ABSnapshot } from "./internal/ab-snapshot.js";
52
56
  import { brandDefinition } from "./internal/brand.js";
53
- import type { JsonValue } from "./types.js";
57
+ import type { JsonValue, KvCasResult, KvVersionedEntry } from "./types.js";
54
58
 
55
59
  // ============================================================================
56
60
  // Sink interface
@@ -73,8 +77,10 @@ export interface StorageContext {
73
77
  * - `"policy"` — a flush trigger fired (turn end, debounce, change)
74
78
  * - `"shutdown"` — the serve process is draining; last chance to write
75
79
  * - `"restore"` — serve start or a lazy restore; reads rebuilding state
80
+ * - `"coordination"` — framework coordination driving
81
+ * {@link StorageCasTable} (conditional writes)
76
82
  */
77
- reason: "policy" | "shutdown" | "restore";
83
+ reason: "policy" | "shutdown" | "restore" | "coordination";
78
84
  }
79
85
 
80
86
  export interface StoragePolicy {
@@ -151,6 +157,50 @@ export interface StorageABTable {
151
157
  ): ABSnapshot | undefined | null | Promise<ABSnapshot | undefined | null>;
152
158
  }
153
159
 
160
+ /**
161
+ * Conditional writes (CAS) — the coordination tier of the storage backend
162
+ * contract. Plain storage works without it, so existing backends keep
163
+ * working across a platform upgrade; framework coordination (the upcoming
164
+ * session ledger — see `@cursor/july/kv` for the backend-agnostic
165
+ * interface) requires it and fails at startup, with an actionable
166
+ * message, when the backend lacks it. The built-in backends all include
167
+ * it, so implementing this group yourself only comes up for BYO storage.
168
+ *
169
+ * Grouped under {@link StorageConfig.cas} rather than as loose methods —
170
+ * matching the `evals` / `abs` table groups — so `defineStorage`
171
+ * validates the whole group at once: there is no meaningful
172
+ * half-implementation.
173
+ *
174
+ * Version tokens are opaque per-key strings that change on every
175
+ * successful write (counter, UUID, content hash — whatever is cheap for
176
+ * the backend). See {@link KvVersionedEntry} / {@link KvCasResult}.
177
+ */
178
+ export interface StorageCasTable {
179
+ /** Read one value with its version token. Miss → `undefined`. */
180
+ getWithVersion(
181
+ key: string,
182
+ ctx: StorageContext
183
+ ): Promise<KvVersionedEntry | undefined> | KvVersionedEntry | undefined;
184
+ /** Create the key only when absent. Conflict returns the existing entry. */
185
+ putIfAbsent(
186
+ key: string,
187
+ value: JsonValue,
188
+ ctx: StorageContext
189
+ ): Promise<KvCasResult> | KvCasResult;
190
+ /**
191
+ * Replace the value only when the key still carries `expectedVersion`.
192
+ * Conflict (version moved, or key deleted) returns the current entry.
193
+ */
194
+ putIfVersion(
195
+ key: string,
196
+ value: JsonValue,
197
+ expectedVersion: string,
198
+ ctx: StorageContext
199
+ ): Promise<KvCasResult> | KvCasResult;
200
+ /** All keys under a prefix, in ascending key order. */
201
+ listKeys(prefix: string, ctx: StorageContext): Promise<string[]> | string[];
202
+ }
203
+
154
204
  export interface StorageConfig {
155
205
  /** Optional label surfaced on `GET /v1/info` diagnostics. */
156
206
  name?: string;
@@ -166,6 +216,14 @@ export interface StorageConfig {
166
216
  * (session event logs remain the assignment/fold source of truth).
167
217
  */
168
218
  abs?: StorageABTable;
219
+ /**
220
+ * Conditional writes (CAS). Optional for plain storage — existing
221
+ * backends keep working without it — but required by coordination
222
+ * features (the session ledger), which fail fast when it is absent.
223
+ * The built-in backends (`fileKv`, `cursorHostedStorage`) both include
224
+ * it. See {@link StorageCasTable}.
225
+ */
226
+ cas?: StorageCasTable;
169
227
  /**
170
228
  * Store one value under a key (upsert, last-write-wins). Called on the
171
229
  * framework's schedule — never concurrently, always in order. Keep it
@@ -228,6 +286,29 @@ export function defineStorage(config: StorageConfig): StorageDefinition {
228
286
  }
229
287
  }
230
288
  }
289
+ if (config.cas !== undefined) {
290
+ const casHooks = [
291
+ "getWithVersion",
292
+ "putIfAbsent",
293
+ "putIfVersion",
294
+ "listKeys",
295
+ ] as const;
296
+ const cas = config.cas;
297
+ // A partial group is a bug, not a legacy config: half-implemented CAS
298
+ // would pass the capability probe and corrupt coordination.
299
+ const missingCasHooks = casHooks.filter(
300
+ (hook) => typeof cas[hook] !== "function"
301
+ );
302
+ if (missingCasHooks.length > 0) {
303
+ throw new Error(
304
+ `defineStorage: ${missingCasHooks
305
+ .map((hook) => `cas.${hook}`)
306
+ .join(
307
+ ", "
308
+ )} must be ${missingCasHooks.length === 1 ? "a function" : "functions"} when the cas group is set`
309
+ );
310
+ }
311
+ }
231
312
  if (config.abs !== undefined) {
232
313
  if (typeof config.abs.putSample !== "function") {
233
314
  throw new Error(
@@ -321,8 +402,11 @@ export const MAX_KEY_SEGMENT_BYTES = 256;
321
402
  * digest of the raw value when the encoding exceeds
322
403
  * {@link MAX_KEY_SEGMENT_BYTES}. Deterministic, so key construction on the
323
404
  * write path and the continuation-lookup path always agree.
405
+ *
406
+ * Exported for the session ledger, which builds its `session/` and
407
+ * `ledger/` keys with the same encoding rules.
324
408
  */
325
- function keySegment(raw: string): string {
409
+ export function keySegment(raw: string): string {
326
410
  const encoded = encodeURIComponent(raw);
327
411
  if (Buffer.byteLength(encoded, "utf8") <= MAX_KEY_SEGMENT_BYTES) {
328
412
  return encoded;