@desplega.ai/agent-swarm 2.4.0 → 2.4.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 (239) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-kedstxx6.js → acp-adapter-dv7hmx4r.js} +9 -9
  3. package/dist/{actions-vget6r1k.js → actions-51nc1gny.js} +21 -21
  4. package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-abp5tdx8.js} +4 -4
  5. package/dist/{app-mqap8t8m.js → app-9h14qvr6.js} +3 -3
  6. package/dist/{assistant-h0gv97mv.js → assistant-wv1gz3mt.js} +22 -22
  7. package/dist/{boot-reembed-qr8a894z.js → boot-reembed-6yn53ms9.js} +1 -1
  8. package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-7fcka4z5.js} +1 -1
  9. package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-pm1580ax.js} +4 -4
  10. package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-g5hfc1x8.js} +4 -4
  11. package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gf3kxhf0.js} +9 -9
  12. package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
  13. package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
  14. package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-s2k1qd8y.js} +13 -13
  15. package/dist/{cli-tj4j2ts4.js → cli-02qm7src.js} +8 -8
  16. package/dist/{cli-1cevz46c.js → cli-0qvtffe9.js} +6 -6
  17. package/dist/{cli-avmqhatn.js → cli-10hhg0km.js} +8 -8
  18. package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
  19. package/dist/{cli-kz116kvw.js → cli-239yjk27.js} +7 -7
  20. package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
  21. package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
  22. package/dist/{cli-x719j3vs.js → cli-5rk36sjf.js} +442 -205
  23. package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
  24. package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
  25. package/dist/{cli-5s5hx57y.js → cli-7awf1fp8.js} +1 -1
  26. package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
  27. package/dist/{cli-ywyrffff.js → cli-8sxqsbbq.js} +1 -1
  28. package/dist/{cli-rsd7ga7v.js → cli-8zwe9mpk.js} +11 -11
  29. package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
  30. package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
  31. package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
  32. package/dist/{cli-mvg2hf8p.js → cli-ahtpy9kf.js} +14 -7
  33. package/dist/{cli-x1p5dhq2.js → cli-bcj753sq.js} +3 -3
  34. package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
  35. package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
  36. package/dist/{cli-rg7ntypm.js → cli-d3qqdy4c.js} +456 -94
  37. package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
  38. package/dist/{cli-02vhhssk.js → cli-efaqv1c7.js} +5 -5
  39. package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
  40. package/dist/{cli-kk2r80n5.js → cli-faypxz7q.js} +4 -4
  41. package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
  42. package/dist/{cli-wrv09v7q.js → cli-ftyypbg5.js} +1 -1
  43. package/dist/{cli-zvze9rbm.js → cli-fyzjmseb.js} +3 -3
  44. package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
  45. package/dist/{cli-nrrg0fvy.js → cli-h38rjv87.js} +133 -12
  46. package/dist/{cli-tg9ksbn8.js → cli-h93c9vb3.js} +2 -2
  47. package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
  48. package/dist/{cli-bhwa8rzc.js → cli-hyfw2j1e.js} +13 -12
  49. package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
  50. package/dist/{cli-6ftrrwm1.js → cli-n85dqg6d.js} +5 -5
  51. package/dist/{cli-8cxkw1f5.js → cli-nmqdc2t3.js} +2 -2
  52. package/dist/{cli-4fhyq711.js → cli-nn47d7e8.js} +13 -13
  53. package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
  54. package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
  55. package/dist/{cli-xfe63zx3.js → cli-pkht5hap.js} +2 -2
  56. package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
  57. package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
  58. package/dist/{cli-tgvpmnwz.js → cli-r99kc9yq.js} +5 -5
  59. package/dist/{cli-bfc3k1x5.js → cli-rdyw3mct.js} +9 -9
  60. package/dist/{cli-b80a1100.js → cli-s6vr7r2d.js} +2 -2
  61. package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
  62. package/dist/{cli-ttaz6c7m.js → cli-saw9a6f6.js} +6 -5
  63. package/dist/{cli-mz1ezzzh.js → cli-shvfedvm.js} +3 -3
  64. package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
  65. package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
  66. package/dist/{cli-5ff2sjyt.js → cli-t30yj95q.js} +2 -2
  67. package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
  68. package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
  69. package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
  70. package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
  71. package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
  72. package/dist/{cli-nme7wery.js → cli-y2x52gs2.js} +46 -13
  73. package/dist/{cli-ffbrkh1r.js → cli-y8n5k515.js} +46 -38
  74. package/dist/{cli-ecshf504.js → cli-z4wd2ftv.js} +7 -7
  75. package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
  76. package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
  77. package/dist/cli.js +20 -20
  78. package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-ttygw382.js} +6 -6
  79. package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
  80. package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
  81. package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-60b58vv3.js} +7 -7
  82. package/dist/{commands-psqhkttb.js → commands-edmdwa4x.js} +2 -2
  83. package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
  84. package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-5fym8sw1.js} +23 -23
  85. package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
  86. package/dist/{db-nc1rd869.js → db-2y0mgstc.js} +3 -3
  87. package/dist/{db-2xbyenjr.js → db-nn0hcmx5.js} +6 -2
  88. package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
  89. package/dist/{dispatcher-ewceb8ts.js → dispatcher-0a9wwjcx.js} +20 -20
  90. package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
  91. package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
  92. package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
  93. package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
  94. package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
  95. package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
  96. package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
  97. package/dist/{external-channel-a5dsmxn4.js → external-channel-zhvt95n3.js} +2 -2
  98. package/dist/{grok-adapter-vj88xr23.js → grok-adapter-qnr1hp2y.js} +10 -10
  99. package/dist/handlers-dkcarkd0.js +39 -0
  100. package/dist/{heartbeat-t2m9r3wa.js → heartbeat-gz97r7ac.js} +23 -23
  101. package/dist/{hook-xa677f8r.js → hook-1mmdgjqs.js} +16 -9
  102. package/dist/{hook-8femx1bg.js → hook-x8rthca4.js} +6 -6
  103. package/dist/{http-xpmmq028.js → http-r8f85xr9.js} +274 -160
  104. package/dist/index-5jecgp3n.js +41 -0
  105. package/dist/index-aex0eba9.js +40 -0
  106. package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
  107. package/dist/{index-cdzyfrsp.js → index-smxyk04s.js} +1 -1
  108. package/dist/{index-dg2tmj99.js → index-vzvfqzfj.js} +20 -20
  109. package/dist/{index-s7ckdajq.js → index-y7rbcrc1.js} +10 -10
  110. package/dist/{keepalive-3nr4st3r.js → keepalive-64c0f34c.js} +4 -4
  111. package/dist/lead-b1da0f97.js +78 -0
  112. package/dist/{maintenance-verwpe2c.js → maintenance-mnxpar12.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-36hs2v4h.js} +3 -3
  114. package/dist/{onboard-aytqedmq.js → onboard-xes8de61.js} +3 -3
  115. package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
  116. package/dist/{otel-impl-hckw4wt1.js → otel-impl-fzc57kcq.js} +2 -2
  117. package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
  118. package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-1nek2w7b.js} +3 -3
  119. package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-sqefb5hw.js} +2 -2
  120. package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-ttyfaep2.js} +1 -1
  121. package/dist/{render-v2-7nqgvk2q.js → render-v2-vb527jwn.js} +3 -3
  122. package/dist/{resume-b6djcj3c.js → resume-jcdzg1bn.js} +10 -8
  123. package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
  124. package/dist/{session-access-fc3f9y12.js → session-access-zsp4wsp0.js} +4 -4
  125. package/dist/{setup-n39szm6b.js → setup-1nb2zqd3.js} +1 -1
  126. package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
  127. package/dist/{work-objects-jem2fre0.js → work-objects-61w08fwr.js} +23 -23
  128. package/dist/worker-c1e68hpa.js +78 -0
  129. package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
  130. package/openapi.json +491 -52
  131. package/package.json +3 -3
  132. package/src/apps/action-runner.ts +146 -0
  133. package/src/apps/catalog.generated.json +3 -2
  134. package/src/apps/definition.ts +152 -4
  135. package/src/apps/row-store.ts +23 -9
  136. package/src/apps/schema-migrate.ts +14 -6
  137. package/src/apps/script-types.ts +2 -1
  138. package/src/apps/sync.ts +20 -19
  139. package/src/be/db/tasks/read.ts +112 -0
  140. package/src/be/db.ts +16 -4
  141. package/src/be/fleet-benchmark-stats.ts +10 -5
  142. package/src/be/models-catalog.ts +4 -0
  143. package/src/be/onboarding.ts +6 -0
  144. package/src/be/scripts/typecheck.ts +13 -0
  145. package/src/be/swarm-config-guard.ts +25 -1
  146. package/src/be/telemetry-enable.ts +1 -1
  147. package/src/commands/provider-credentials.ts +12 -2
  148. package/src/commands/runner.ts +3 -0
  149. package/src/heartbeat/heartbeat.ts +2 -2
  150. package/src/hooks/codex-hook.ts +6 -2
  151. package/src/hooks/hook.ts +7 -0
  152. package/src/hooks/pr-body-guard.ts +38 -11
  153. package/src/hooks/pr-room-guard.ts +88 -0
  154. package/src/http/agents.ts +1 -0
  155. package/src/http/apps.ts +99 -183
  156. package/src/http/fleet-benchmark.ts +79 -30
  157. package/src/http/global-config.ts +7 -0
  158. package/src/http/ranking.ts +24 -40
  159. package/src/http/status.ts +49 -43
  160. package/src/http/tasks.ts +49 -1
  161. package/src/http/telemetry-ui.ts +1 -1
  162. package/src/http/used-by.ts +55 -19
  163. package/src/http/users.ts +2 -2
  164. package/src/providers/acp-targets.ts +1 -1
  165. package/src/providers/amp-adapter.ts +1 -1
  166. package/src/providers/pi-mono-adapter.ts +15 -0
  167. package/src/providers/reasoning-effort.ts +2 -1
  168. package/src/providers/types.ts +2 -0
  169. package/src/ranking/telemetry-ranking.ts +5 -33
  170. package/src/scripts-runtime/sdk-allowlist.ts +7 -0
  171. package/src/scripts-runtime/types/stdlib.d.ts +31 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
  173. package/src/server.ts +4 -0
  174. package/src/telemetry-context.ts +19 -1
  175. package/src/telemetry-contract/EVENTS.md +42 -8
  176. package/src/telemetry-contract/SOURCE.json +6 -5
  177. package/src/telemetry-contract/catalog.json +225 -5
  178. package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
  179. package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
  180. package/src/telemetry-contract/types.gen.ts +225 -27
  181. package/src/telemetry-identity.ts +47 -9
  182. package/src/telemetry-snapshot.ts +9 -1
  183. package/src/telemetry-ui-events.ts +9 -2
  184. package/src/telemetry.ts +29 -6
  185. package/src/tests/app-action-run-tool.test.ts +178 -0
  186. package/src/tests/app-row-tools.test.ts +385 -0
  187. package/src/tests/apps-json-column.test.ts +611 -0
  188. package/src/tests/apps-script-types.test.ts +2 -0
  189. package/src/tests/apps-sync-engine.test.ts +42 -0
  190. package/src/tests/check-pr-body.test.ts +0 -23
  191. package/src/tests/credential-check.test.ts +38 -0
  192. package/src/tests/db-task-read-contract.test.ts +4 -0
  193. package/src/tests/db-task-write-contract.test.ts +2 -0
  194. package/src/tests/docs-index-footprint.test.js +2 -2
  195. package/src/tests/fleet-benchmark.test.ts +122 -2
  196. package/src/tests/http-users.test.ts +62 -1
  197. package/src/tests/model-catalog-table.test.ts +63 -0
  198. package/src/tests/model-profiles-api.test.ts +19 -7
  199. package/src/tests/models-catalog.test.ts +50 -0
  200. package/src/tests/onboarding.test.ts +21 -0
  201. package/src/tests/org-website.test.ts +62 -0
  202. package/src/tests/pi-mono-adapter.test.ts +23 -0
  203. package/src/tests/pr-body-leaks.test.ts +4 -0
  204. package/src/tests/pr-room-guard.test.ts +242 -0
  205. package/src/tests/ranking-listing.test.ts +8 -23
  206. package/src/tests/ranking-route.test.ts +49 -47
  207. package/src/tests/reasoning-effort.test.ts +39 -3
  208. package/src/tests/runner-harness-telemetry-env.test.ts +62 -1
  209. package/src/tests/status.test.ts +158 -9
  210. package/src/tests/swarm-tool-result-gate.test.ts +2 -4
  211. package/src/tests/task-lineage.test.ts +180 -0
  212. package/src/tests/telemetry-context.test.ts +128 -2
  213. package/src/tests/telemetry-init.test.ts +62 -5
  214. package/src/tests/telemetry-ui-route.test.ts +123 -0
  215. package/src/tests/tool-annotations.test.ts +3 -2
  216. package/src/tests/used-by-route.test.ts +144 -10
  217. package/src/tests/user-token-rest-auth.test.ts +38 -0
  218. package/src/tests/workflow-agent-task-retry.test.ts +130 -4
  219. package/src/tests/workflow-wait-recovery.test.ts +116 -4
  220. package/src/tools/app-action-run.ts +63 -0
  221. package/src/tools/app-rows.ts +239 -0
  222. package/src/tools/model-catalog/index.ts +1 -1
  223. package/src/tools/tool-config.ts +8 -0
  224. package/src/tools/utils.ts +1 -1
  225. package/src/tools/workflows/retry-workflow-run.ts +1 -1
  226. package/src/types.ts +17 -0
  227. package/src/utils/company-profile.ts +24 -0
  228. package/src/utils/org-website.ts +58 -21
  229. package/src/workflows/recovery.ts +10 -22
  230. package/src/workflows/resume.ts +16 -0
  231. package/src/workflows/task-step-routing.ts +47 -7
  232. package/templates/skills/apps/SKILL.md +44 -4
  233. package/templates/skills/apps/content.md +44 -4
  234. package/dist/handlers-zbs90bny.js +0 -39
  235. package/dist/index-8pwc1jc8.js +0 -40
  236. package/dist/index-nxa6me0q.js +0 -41
  237. package/dist/lead-td8h2sfn.js +0 -78
  238. package/dist/worker-wr6xgfr2.js +0 -78
  239. package/src/tests/check-ranking-entry.test.ts +0 -146
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
5
5
  "keywords": [
6
6
  "agent-swarm.dev",
@@ -200,7 +200,7 @@
200
200
  "dependencies": {
201
201
  "@agentclientprotocol/sdk": "1.9.0",
202
202
  "@ai-sdk/openai": "^3.0.41",
203
- "@anthropic-ai/claude-agent-sdk": "0.3.302",
203
+ "@anthropic-ai/claude-agent-sdk": "0.3.303",
204
204
  "@anthropic-ai/sdk": "^0.135.0",
205
205
  "@asteasolutions/zod-to-openapi": "^8.0.0",
206
206
  "@aws-sdk/client-bedrock": "3.1048.0",
@@ -209,7 +209,7 @@
209
209
  "@chat-adapter/teams": "4.41.1",
210
210
  "@chat-adapter/telegram": "4.41.1",
211
211
  "@cursor/sdk": "1.0.38",
212
- "@desplega.ai/agent-fs-just-bash": "0.18.4",
212
+ "@desplega.ai/agent-fs-just-bash": "0.18.5",
213
213
  "@desplega.ai/business-use": "^0.4.2",
214
214
  "@earendil-works/pi-agent-core": "^1.3.0",
215
215
  "@earendil-works/pi-ai": "^1.3.0",
@@ -0,0 +1,146 @@
1
+ import { normalizeAssetKey } from "../assets/key";
2
+ import { getLeadAgent } from "../be/db";
3
+ import { getScriptById } from "../be/scripts/db";
4
+ import { getSavedScriptOwnerAgentId, runSavedScriptAsAgent } from "../be/scripts/run-saved";
5
+ import { resolveTemplate } from "../prompts/resolver";
6
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
7
+ import { scrubObject } from "../utils/secret-scrubber";
8
+ import type { AppActionCaller } from "./action-caller";
9
+ import type { AppRecord } from "./store";
10
+ import { runAppSync } from "./sync";
11
+
12
+ /**
13
+ * One `error` string for a sync result, or undefined when every pass succeeded.
14
+ * Each failed pair is named so a multi-pair sync says which one broke; the pass
15
+ * list in `result.passes` carries the rest.
16
+ */
17
+ function syncPassError(sync: Awaited<ReturnType<typeof runAppSync>>): string | undefined {
18
+ const failed = sync.passes.filter((pass) => pass.error !== undefined);
19
+ if (failed.length === 0) return undefined;
20
+ return failed.map((pass) => `${pass.model}.${pass.source}: ${pass.error}`).join("; ");
21
+ }
22
+
23
+ export async function runAppAction({
24
+ app,
25
+ name,
26
+ input = {},
27
+ caller,
28
+ actor,
29
+ }: {
30
+ app: AppRecord;
31
+ name: string;
32
+ input?: Record<string, unknown>;
33
+ caller: AppActionCaller;
34
+ actor: string;
35
+ }) {
36
+ const actions = app.definition.actions;
37
+ if (!actions || !Object.hasOwn(actions, name)) {
38
+ return { status: 404 as const, body: { error: "app action not found" } };
39
+ }
40
+
41
+ const action = actions[name]!;
42
+
43
+ if (action.kind === "script") {
44
+ const script = await getScriptById(action.scriptId);
45
+ if (!script) {
46
+ return {
47
+ status: 400 as const,
48
+ body: {
49
+ error: "invalid app action",
50
+ issues: [
51
+ {
52
+ path: `actions.${name}.scriptId`,
53
+ message: `script "${action.scriptId}" no longer exists`,
54
+ },
55
+ ],
56
+ },
57
+ };
58
+ }
59
+
60
+ // Script actions use the owner's bindings. Definition validation checks
61
+ // which scripts an agent can wire into an app.
62
+ const runAsAgentId = getSavedScriptOwnerAgentId(script);
63
+ if (!runAsAgentId) {
64
+ return {
65
+ status: 400 as const,
66
+ body: { error: "agentId is required: this script has no owning agent to run as" },
67
+ };
68
+ }
69
+
70
+ const output = await runSavedScriptAsAgent({
71
+ script,
72
+ input: { ...action.args, ...input, app: { id: app.id }, caller },
73
+ agentId: runAsAgentId,
74
+ });
75
+ const ok = output.exitCode === 0 && !output.error && !output.runtimeError;
76
+ const error = ok
77
+ ? undefined
78
+ : output.runtimeError
79
+ ? `${output.runtimeError.name}: ${output.runtimeError.message}`
80
+ : (output.error ?? `Script exited with code ${output.exitCode}`);
81
+ return {
82
+ status: 200 as const,
83
+ body: scrubObject({
84
+ ok,
85
+ result: output.result,
86
+ stdout: output.stdout,
87
+ ...(error === undefined ? {} : { error }),
88
+ durationMs: output.durationMs,
89
+ }),
90
+ };
91
+ }
92
+
93
+ if (action.kind === "sync") {
94
+ const startedAt = Date.now();
95
+ const sync = await runAppSync({
96
+ appId: app.id,
97
+ ...(action.model === undefined ? {} : { model: action.model }),
98
+ ...(action.source === undefined ? {} : { source: action.source }),
99
+ invokedBy: actor,
100
+ });
101
+ const durationMs = Date.now() - startedAt;
102
+ if (sync.issues && sync.issues.length > 0) {
103
+ return { status: 400 as const, body: { error: "invalid sync action", issues: sync.issues } };
104
+ }
105
+ // Deliberately the SCRIPT-action response shape, minus `taskId`:
106
+ // app-surface.tsx branches on `taskId` first, so omitting it gives the
107
+ // dashboard running -> ok/error + refetchAll() with zero UI changes.
108
+ const error = syncPassError(sync);
109
+ return {
110
+ status: 200 as const,
111
+ body: scrubObject({
112
+ ok: sync.ok,
113
+ result: { passes: sync.passes },
114
+ ...(error === undefined ? {} : { error }),
115
+ durationMs,
116
+ }),
117
+ };
118
+ }
119
+
120
+ const lead = action.agentId ? null : await getLeadAgent();
121
+ const taskPrompt = resolveTemplate("task.app.action", {
122
+ prompt: action.prompt,
123
+ app_id: app.id,
124
+ action_name: name,
125
+ input_json: JSON.stringify(input),
126
+ caller_json: JSON.stringify(caller),
127
+ });
128
+ const task = await createTaskWithSiblingAwareness(
129
+ taskPrompt.text,
130
+ {
131
+ source: "api",
132
+ agentId: action.agentId ?? lead?.id,
133
+ routingReason: action.agentId ? "human_pinned" : lead ? "skill" : undefined,
134
+ routingSource: action.agentId || lead ? "engine_default" : undefined,
135
+ // App-spawned tasks group under the app's asset namespace so a
136
+ // swarm-tasks source can pull them back via config.assetKey.
137
+ key: normalizeAssetKey(`shared/app:${app.id}/action:${name}/`),
138
+ ...(actor.startsWith("user:") ? { requestedByUserId: actor.slice("user:".length) } : {}),
139
+ },
140
+ { origin: "app" },
141
+ );
142
+ return {
143
+ status: 200 as const,
144
+ body: { ok: true as const, taskId: task.id, status: task.status },
145
+ };
146
+ }
@@ -1243,7 +1243,7 @@
1243
1243
  }
1244
1244
  },
1245
1245
  "Table": {
1246
- "description": "Data table. Bind `data`/`loading`/`error` to a named query (`/queries/<name>/...`). `rowActions` chains receive `{ \"$row\": \"<col>\" }` (or `{ \"$row\": \"\" }` for the whole row) and `{ \"$rowIndex\": true }`. Destructive row actions always confirm via a dialog; override the copy with `confirm: { title, description, confirmLabel }`. `search` (case-insensitive substring across every listed column) and `filters` (per-column equality, `null`/`\"\"` disables one; an array means \"one of\", which is what a multiple `Chips` writes, and an empty array disables it) narrow the polled rows client-side — bind them to a `SearchInput` / `Select` via `/ui/<id>/value`, or pin a constant (e.g. `\"filters\": { \"pinned\": true }`). Columns take `pinned: \"left\"|\"right\"` to survive horizontal scroll; `pagination` pages large row sets (auto past 200 rows); `density: \"compact\"` tightens row height. Column kinds: `status` (lifecycle values like pending/in_progress/completed/failed get the dashboard status icon, anything else a `tones` pill), `user` / `agent` (avatar + name), `relative` (smart time with the exact time on hover), `duration` (ms → 1m 23s), `progress` (bar, `max` default 100), `copy` (value + copy button), `switch` (runs the column's `onChange` chain with `$row` set to the new value; read-only without it), `link` (http(s) URL, opens in a new tab). Columns also take `align`, `truncate: \"middle\"` (ids, paths), a header `icon` and `tooltip` (a row field shown on hover). `onRowClick` runs a chain with `$row` when a row is clicked — pair it with `app.navigate` + a Drawer and bind `selectedId` to the drawer param to highlight the open row. `rowId` names the id field (default `id`). Below 768px the table renders as a phone list (`mobile: \"list\"`, fields picked by `mobileFields: { title, subtitle?, meta?, status? }`); `mobile: \"scroll\"` keeps the grid. `emptyState: { icon?, title, body? }` replaces the empty message. Row actions take `icon` and `placement: \"menu\"` (collected into a More menu).",
1246
+ "description": "Data table. Bind `data`/`loading`/`error` to a named query (`/queries/<name>/...`). `rowActions` chains receive `{ \"$row\": \"<col>\" }` (or `{ \"$row\": \"\" }` for the whole row) and `{ \"$rowIndex\": true }`. Destructive row actions always confirm via a dialog; override the copy with `confirm: { title, description, confirmLabel }`. `search` (case-insensitive substring across every listed column) and `filters` (per-column equality, `null`/`\"\"` disables one; an array means \"one of\", which is what a multiple `Chips` writes, and an empty array disables it) narrow the polled rows client-side — bind them to a `SearchInput` / `Select` via `/ui/<id>/value`, or pin a constant (e.g. `\"filters\": { \"pinned\": true }`). Columns take `pinned: \"left\"|\"right\"` to survive horizontal scroll; `pagination` pages large row sets (auto past 200 rows); `density: \"compact\"` tightens row height. Column kinds: `status` (lifecycle values like pending/in_progress/completed/failed get the dashboard status icon, anything else a `tones` pill), `user` / `agent` (avatar + name), `relative` (smart time with the exact time on hover), `duration` (ms → 1m 23s), `progress` (bar, `max` default 100), `copy` (value + copy button), `switch` (runs the column's `onChange` chain with `$row` set to the new value; read-only without it), `link` (http(s) URL, opens in a new tab), `json` (compact single-line `JSON.stringify` preview, truncated). Columns also take `align`, `truncate: \"middle\"` (ids, paths), a header `icon` and `tooltip` (a row field shown on hover). `onRowClick` runs a chain with `$row` when a row is clicked — pair it with `app.navigate` + a Drawer and bind `selectedId` to the drawer param to highlight the open row. `rowId` names the id field (default `id`). Below 768px the table renders as a phone list (`mobile: \"list\"`, fields picked by `mobileFields: { title, subtitle?, meta?, status? }`); `mobile: \"scroll\"` keeps the grid. `emptyState: { icon?, title, body? }` replaces the empty message. Row actions take `icon` and `placement: \"menu\"` (collected into a More menu).",
1247
1247
  "props": {
1248
1248
  "$schema": "http://json-schema.org/draft-07/schema#",
1249
1249
  "type": "object",
@@ -1287,7 +1287,8 @@
1287
1287
  "progress",
1288
1288
  "copy",
1289
1289
  "switch",
1290
- "link"
1290
+ "link",
1291
+ "json"
1291
1292
  ]
1292
1293
  },
1293
1294
  "tones": {
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  import { listScriptConnections } from "../be/script-connections";
3
3
  import { getScriptById } from "../be/scripts/db";
4
4
  import { getSavedScriptOwnerAgentId } from "../be/scripts/run-saved";
5
+ import { APPS_JSON_COLUMN_MAX_BYTES_BOUNDS } from "../be/swarm-config-guard";
5
6
  import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
6
7
  import catalog from "./catalog.generated.json";
7
8
  import {
@@ -16,7 +17,13 @@ export const AppNameSchema = z.string().regex(/^[a-z][a-zA-Z0-9_]{0,39}$/, {
16
17
  message: "must start with a lowercase letter and contain only letters, numbers, or underscores",
17
18
  });
18
19
 
19
- export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum"]);
20
+ export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum", "json"]);
21
+
22
+ /**
23
+ * Column kinds allowed where values must stay scalar (user-config fields and
24
+ * element props) — `json` is model-column only.
25
+ */
26
+ export const ScalarColumnKindSchema = ColumnKindSchema.exclude(["json"]);
20
27
 
21
28
  const ISO_8601_PREFIX = /^\d{4}-\d{2}-\d{2}(?:T.*)?$/;
22
29
 
@@ -24,6 +31,77 @@ export function isIso8601Date(value: string): boolean {
24
31
  return ISO_8601_PREFIX.test(value) && !Number.isNaN(Date.parse(value));
25
32
  }
26
33
 
34
+ /** Default serialized-size cap for json column values AND defaults: 256 KB. */
35
+ export const DEFAULT_JSON_COLUMN_MAX_BYTES = 256 * 1024;
36
+
37
+ /**
38
+ * The serialized-size cap for json column values and defaults, read on every
39
+ * call — never at module load — so a stored `APPS_JSON_COLUMN_MAX_BYTES`
40
+ * override, injected over the process env on config reload, applies without
41
+ * a restart. Mirrors the retention knobs: a non-digit or out-of-range value
42
+ * falls back to the default, and the config API rejects writes outside the
43
+ * same `APPS_JSON_COLUMN_MAX_BYTES_BOUNDS`.
44
+ */
45
+ export function jsonColumnMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
46
+ const raw = env.APPS_JSON_COLUMN_MAX_BYTES?.trim();
47
+ if (!raw || !/^\d+$/.test(raw)) return DEFAULT_JSON_COLUMN_MAX_BYTES;
48
+ const value = Number(raw);
49
+ const { min, max } = APPS_JSON_COLUMN_MAX_BYTES_BOUNDS;
50
+ return Number.isSafeInteger(value) && value >= min && value <= max
51
+ ? value
52
+ : DEFAULT_JSON_COLUMN_MAX_BYTES;
53
+ }
54
+
55
+ /** The cap as a message label: whole KB when it divides evenly (`256KB`), else bytes. */
56
+ export function jsonColumnSizeLabel(bytes: number = jsonColumnMaxBytes()): string {
57
+ return bytes % 1024 === 0 ? `${bytes / 1024}KB` : `${bytes} bytes`;
58
+ }
59
+
60
+ export type JsonColumnValueIssue = "unserializable" | "non-finite" | "oversized";
61
+
62
+ /**
63
+ * Why a value cannot live in a json column — not JSON-serializable, carrying a
64
+ * non-finite number anywhere inside it, or over `jsonColumnMaxBytes()` once
65
+ * serialized — or `null` when it fits. The single implementation of the cap:
66
+ * it serializes exactly once, flagging non-finite numbers on that same walk
67
+ * (a lenient parser turns `1e400` into `Infinity`, which `JSON.stringify`
68
+ * would quietly store as `null`), and each caller maps the reason to its own
69
+ * message.
70
+ */
71
+ export function jsonColumnValueIssue(value: unknown): JsonColumnValueIssue | null {
72
+ let nonFinite = false;
73
+ let serialized: string | undefined;
74
+ try {
75
+ serialized = JSON.stringify(value, (_key, entry: unknown) => {
76
+ if (typeof entry === "number" && !Number.isFinite(entry)) nonFinite = true;
77
+ return entry;
78
+ });
79
+ } catch {
80
+ return "unserializable"; // circular reference / BigInt / throwing toJSON
81
+ }
82
+ if (serialized === undefined) return "unserializable";
83
+ if (nonFinite) return "non-finite";
84
+ return Buffer.byteLength(serialized, "utf8") > jsonColumnMaxBytes() ? "oversized" : null;
85
+ }
86
+
87
+ /**
88
+ * The one per-kind check for a NON-null column value, shared by the row store,
89
+ * schema migrations and the sync engine so a value the store accepts is a
90
+ * value a migration directive or a projection accepts too. Null policy
91
+ * (required / hidden / nulling a projection) stays with each caller.
92
+ */
93
+ export function isValidColumnValue(
94
+ column: Pick<ColumnDef, "kind" | "enum">,
95
+ value: unknown,
96
+ ): boolean {
97
+ if (column.kind === "string") return typeof value === "string";
98
+ if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
99
+ if (column.kind === "boolean") return typeof value === "boolean";
100
+ if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
101
+ if (column.kind === "json") return jsonColumnValueIssue(value) === null;
102
+ return typeof value === "string" && Boolean(column.enum?.includes(value));
103
+ }
104
+
27
105
  export const SourceTransformSchema = z.enum(["slug", "lower", "upper", "cents", "date-parse"]);
28
106
 
29
107
  /** Column kind each transform may target — the sync engine projects into it. */
@@ -49,11 +127,23 @@ const ColumnDefSchema = z
49
127
  required: z.boolean().optional(),
50
128
  enum: z.array(z.string()).optional(),
51
129
  index: z.boolean().optional(),
52
- default: z.union([z.string(), z.number(), z.boolean()]).optional(),
130
+ default: z
131
+ .union([
132
+ z.string(),
133
+ z.number(),
134
+ z.boolean(),
135
+ z.record(z.string(), z.unknown()),
136
+ z.array(z.unknown()),
137
+ ])
138
+ .optional(),
53
139
  hidden: z.boolean().optional(),
54
140
  source: ColumnSourceBindingSchema.optional(),
55
141
  })
56
142
  .superRefine((column, ctx) => {
143
+ if (column.kind === "json" && column.index) {
144
+ ctx.addIssue({ code: "custom", path: ["index"], message: "json columns cannot be indexed" });
145
+ }
146
+
57
147
  if (column.kind === "enum") {
58
148
  if (!column.enum || column.enum.length === 0) {
59
149
  ctx.addIssue({ code: "custom", path: ["enum"], message: "enum values are required" });
@@ -71,6 +161,24 @@ const ColumnDefSchema = z
71
161
  }
72
162
 
73
163
  if (column.default === undefined) return;
164
+ if (column.kind === "json") {
165
+ // Structural checks only; the size cap is write-time (`jsonColumnDefaultSizeIssues`).
166
+ const issue = jsonColumnValueIssue(column.default);
167
+ if (issue === "unserializable") {
168
+ ctx.addIssue({
169
+ code: "custom",
170
+ path: ["default"],
171
+ message: "json column default must be JSON-serializable",
172
+ });
173
+ } else if (issue === "non-finite") {
174
+ ctx.addIssue({
175
+ code: "custom",
176
+ path: ["default"],
177
+ message: "json column default contains a non-finite number",
178
+ });
179
+ }
180
+ return;
181
+ }
74
182
  const valid =
75
183
  (column.kind === "string" && typeof column.default === "string") ||
76
184
  (column.kind === "number" &&
@@ -94,7 +202,7 @@ const ColumnDefSchema = z
94
202
 
95
203
  const UserConfigFieldSchema = z
96
204
  .object({
97
- kind: ColumnKindSchema,
205
+ kind: ScalarColumnKindSchema,
98
206
  default: z.union([z.string(), z.number(), z.boolean()]).optional(),
99
207
  enum: z.array(z.string()).optional(),
100
208
  label: z.string().optional(),
@@ -276,7 +384,7 @@ const AppPageSchema = z
276
384
 
277
385
  const ElementPropDefSchema = z
278
386
  .object({
279
- kind: ColumnKindSchema,
387
+ kind: ScalarColumnKindSchema,
280
388
  required: z.boolean().optional(),
281
389
  enum: z.array(z.string()).min(1).optional(),
282
390
  default: z.union([z.string(), z.number(), z.boolean()]).optional(),
@@ -439,6 +547,14 @@ export const AppDefinitionSchema = z
439
547
  });
440
548
  continue;
441
549
  }
550
+ if (columnDefinition.kind === "json") {
551
+ ctx.addIssue({
552
+ code: "custom",
553
+ path: ["queries", queryName, "filter", column],
554
+ message: "json columns cannot be filtered",
555
+ });
556
+ continue;
557
+ }
442
558
  if (typeof value === "object") continue;
443
559
  const valid =
444
560
  (columnDefinition.kind === "string" && typeof value === "string") ||
@@ -469,6 +585,12 @@ export const AppDefinitionSchema = z
469
585
  path: ["queries", queryName, "sort", "column"],
470
586
  message: `unknown or hidden column "${sortColumn}"`,
471
587
  });
588
+ } else if (sortColumn && model.columns[sortColumn]?.kind === "json") {
589
+ ctx.addIssue({
590
+ code: "custom",
591
+ path: ["queries", queryName, "sort", "column"],
592
+ message: "json columns cannot be sorted",
593
+ });
472
594
  }
473
595
  }
474
596
  });
@@ -836,6 +958,31 @@ function syncActionIssues(
836
958
  return [{ path: `actions.${name}`, message: "no model declares a source to sync" }];
837
959
  }
838
960
 
961
+ /**
962
+ * The serialized-size cap on json column defaults, applied on definition
963
+ * WRITES only. It lives outside `AppDefinitionSchema` on purpose: the schema
964
+ * also decodes persisted definitions (`decodeAppDefinition`, the row store's
965
+ * model lookup), and a cap lowered after an app was saved must not turn that
966
+ * app into one that "needs repair" on every read. Writing the definition
967
+ * again re-applies the current cap. An oversized default would otherwise be
968
+ * accepted only to fail every row create that relies on it.
969
+ */
970
+ function jsonColumnDefaultSizeIssues(definition: AppDefinition): AppValidationIssue[] {
971
+ const issues: AppValidationIssue[] = [];
972
+ for (const [modelName, model] of Object.entries(definition.models)) {
973
+ for (const [columnName, column] of Object.entries(model.columns)) {
974
+ if (column.kind !== "json" || column.default === undefined) continue;
975
+ if (jsonColumnValueIssue(column.default) === "oversized") {
976
+ issues.push({
977
+ path: `models.${modelName}.columns.${columnName}.default`,
978
+ message: `json column default exceeds the ${jsonColumnSizeLabel()} size limit`,
979
+ });
980
+ }
981
+ }
982
+ }
983
+ return issues;
984
+ }
985
+
839
986
  export async function parseAppDefinition(
840
987
  input: unknown,
841
988
  elementContext: AppDefinitionParseContext = {},
@@ -871,6 +1018,7 @@ export async function parseAppDefinition(
871
1018
  if (!parsed.success) return { success: false, issues: appDefinitionIssues(parsed.error) };
872
1019
 
873
1020
  const issues = [
1021
+ ...jsonColumnDefaultSizeIssues(parsed.data),
874
1022
  ...Object.keys(parsed.data.pages).flatMap((pageName) =>
875
1023
  validatePage(parsed.data, catalog, pageName),
876
1024
  ),
@@ -4,7 +4,9 @@ import {
4
4
  AppDefinitionSchema,
5
5
  type AppValidationIssue,
6
6
  type ColumnDef,
7
- isIso8601Date,
7
+ isValidColumnValue,
8
+ jsonColumnSizeLabel,
9
+ jsonColumnValueIssue,
8
10
  type ModelDef,
9
11
  } from "./definition";
10
12
  import { upgradeAppDefinition } from "./format-upgrades";
@@ -94,6 +96,7 @@ export function appIndexKey(model: string, column: string, value: unknown, rowId
94
96
 
95
97
  function isIndexed(column: ColumnDef): boolean {
96
98
  if (column.hidden === true) return false;
99
+ if (column.kind === "json") return false;
97
100
  if (column.kind === "enum") return true;
98
101
  return column.index === true && (column.kind === "string" || column.kind === "boolean");
99
102
  }
@@ -109,13 +112,9 @@ function indexKeys(model: string, definition: ModelDef, row: AppRow): string[] {
109
112
  return keys;
110
113
  }
111
114
 
112
- function validValue(column: ColumnDef, value: unknown): boolean {
115
+ export function validValue(column: ColumnDef, value: unknown): boolean {
113
116
  if (value === null) return column.required !== true;
114
- if (column.kind === "string") return typeof value === "string";
115
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
116
- if (column.kind === "boolean") return typeof value === "boolean";
117
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
118
- return typeof value === "string" && Boolean(column.enum?.includes(value));
117
+ return isValidColumnValue(column, value);
119
118
  }
120
119
 
121
120
  function joinKeyColumnNames(definition: ModelDef): Set<string> {
@@ -185,7 +184,20 @@ function prepareValues(
185
184
  for (const [name, value] of Object.entries(prepared)) {
186
185
  if (!Object.hasOwn(definition.columns, name)) continue;
187
186
  const column = definition.columns[name]!;
188
- if (!validValue(column, value)) {
187
+ if (column.kind === "json" && value !== null) {
188
+ // Asked directly so the value is serialized once and the size cap gets its own message.
189
+ const issue = jsonColumnValueIssue(value);
190
+ if (issue === "unserializable") {
191
+ issues.push({ path: `values.${name}`, message: "must be a valid json value" });
192
+ } else if (issue === "non-finite") {
193
+ issues.push({ path: `values.${name}`, message: "json value contains a non-finite number" });
194
+ } else if (issue === "oversized") {
195
+ issues.push({
196
+ path: `values.${name}`,
197
+ message: `json value exceeds the ${jsonColumnSizeLabel()} size limit`,
198
+ });
199
+ }
200
+ } else if (!validValue(column, value)) {
189
201
  issues.push({ path: `values.${name}`, message: `must be a valid ${column.kind} value` });
190
202
  }
191
203
  }
@@ -526,18 +538,20 @@ export async function rebuildAppColumnIndexUnlocked(
526
538
  }
527
539
  }
528
540
 
541
+ /** `matches` re-checks the row under the lock; a row that no longer matches is kept. */
529
542
  export function deleteAppRow(
530
543
  appId: string,
531
544
  model: string,
532
545
  _definition: ModelDef,
533
546
  rowId: string,
547
+ matches?: (row: AppRow) => boolean,
534
548
  ): Promise<boolean> {
535
549
  return withMutationLock(appId, model, async () => {
536
550
  const currentDefinition = await currentModelDefinition(appId, model);
537
551
  if (!currentDefinition) return false;
538
552
  const namespace = appsNamespace(appId);
539
553
  const row = await getAppRow(appId, model, rowId);
540
- if (!row) return false;
554
+ if (!row || (matches && !matches(row))) return false;
541
555
  for (const key of indexKeys(model, currentDefinition, row)) await deleteKv(namespace, key);
542
556
  await deleteKv(namespace, rowKey(model, rowId));
543
557
  return true;
@@ -8,6 +8,7 @@ import {
8
8
  type AppValidationIssue,
9
9
  type ColumnDef,
10
10
  isIso8601Date,
11
+ isValidColumnValue,
11
12
  type ModelDef,
12
13
  parseAppDefinition,
13
14
  type SourceDef,
@@ -23,7 +24,18 @@ import {
23
24
  } from "./row-store";
24
25
  import { invalidateChangedSyncStatus } from "./sync";
25
26
 
26
- const MigrationValueSchema = z.union([z.string(), z.number(), z.boolean()]);
27
+ /**
28
+ * Literal directive values (`set`, `map` targets, `else`): scalars, plus the
29
+ * objects and arrays a `json` column holds. The per-column kind check in
30
+ * `validateDirectiveValues` is what rejects a shape the target column cannot take.
31
+ */
32
+ const MigrationValueSchema = z.union([
33
+ z.string(),
34
+ z.number(),
35
+ z.boolean(),
36
+ z.record(z.string(), z.unknown()),
37
+ z.array(z.unknown()),
38
+ ]);
27
39
 
28
40
  export const AppMigrationDirectiveSchema = z.union([
29
41
  z.object({ set: MigrationValueSchema }).strict(),
@@ -368,11 +380,7 @@ function isPurgeDirective(
368
380
 
369
381
  function columnAccepts(column: ColumnDef, value: unknown): boolean {
370
382
  if (value === null) return column.hidden === true || column.required !== true;
371
- if (column.kind === "string") return typeof value === "string";
372
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
373
- if (column.kind === "boolean") return typeof value === "boolean";
374
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
375
- return typeof value === "string" && Boolean(column.enum?.includes(value));
383
+ return isValidColumnValue(column, value);
376
384
  }
377
385
 
378
386
  function coerceValue(
@@ -69,6 +69,7 @@ function commentSafe(value: string): string {
69
69
  }
70
70
 
71
71
  function tsTypeForColumn(column: Pick<ColumnDef, "kind" | "enum">): string {
72
+ if (column.kind === "json") return "unknown";
72
73
  if (column.kind !== "enum") return column.kind === "date" ? "string" : column.kind;
73
74
  return column.enum?.map((value) => JSON.stringify(value)).join(" | ") || "string";
74
75
  }
@@ -206,7 +207,7 @@ function renderApp(app: AppRecord, namespace: string): string {
206
207
  export namespace ${namespace} {
207
208
  ${renderedModels.map((model) => model.source).join("\n\n")}
208
209
 
209
- /** Declared actions. Invocation is REST-only: POST /api/apps/<id>/actions/<name>. */
210
+ /** Scripts can write rows. Declared actions are callable through REST or MCP only. */
210
211
  export type ActionName = ${actionType};
211
212
 
212
213
  /** Script action arguments include server-stamped app and caller context. */
package/src/apps/sync.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type AppDefinition,
10
10
  type AppValidationIssue,
11
11
  type ColumnDef,
12
- isIso8601Date,
12
+ isValidColumnValue,
13
13
  type ModelDef,
14
14
  type SourceDef,
15
15
  type SourceTransform,
@@ -484,19 +484,6 @@ function applyTransform(value: unknown, transform: SourceTransform | undefined):
484
484
  }
485
485
  }
486
486
 
487
- /**
488
- * Mirrors the row-store's per-kind value check. Projections are pre-validated
489
- * here so one bad field nulls one column instead of throwing mid-reconcile and
490
- * leaving the pass half-applied.
491
- */
492
- function validForKind(column: ColumnDef, value: unknown): boolean {
493
- if (column.kind === "string") return typeof value === "string";
494
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
495
- if (column.kind === "boolean") return typeof value === "boolean";
496
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
497
- return typeof value === "string" && Boolean(column.enum?.includes(value));
498
- }
499
-
500
487
  function projectRecord(args: {
501
488
  record: SourceRecord;
502
489
  bindings: Array<[string, ColumnDef]>;
@@ -518,7 +505,10 @@ function projectRecord(args: {
518
505
  );
519
506
  value = null;
520
507
  }
521
- if (value !== null && !validForKind(column, value)) {
508
+ // Projections are pre-validated with the row store's own kind check so one
509
+ // bad field nulls one column instead of throwing mid-reconcile and leaving
510
+ // the pass half-applied.
511
+ if (value !== null && !isValidColumnValue(column, value)) {
522
512
  warn(
523
513
  args.warnings,
524
514
  `column "${name}": field "${binding.field}" produced a value that is not a valid ${column.kind}`,
@@ -530,9 +520,20 @@ function projectRecord(args: {
530
520
  return values;
531
521
  }
532
522
 
533
- /** A missing column and an explicit null are the same absence of a value. */
534
- function sameValue(existing: unknown, projected: unknown): boolean {
535
- return Object.is(existing ?? null, projected ?? null);
523
+ /**
524
+ * A missing column and an explicit null are the same absence of a value. A
525
+ * json column compares structurally — the value decoded from storage is never
526
+ * the same object as a fresh projection — every other kind by identity.
527
+ */
528
+ function sameValue(
529
+ column: ModelDef["columns"][string] | undefined,
530
+ existing: unknown,
531
+ projected: unknown,
532
+ ): boolean {
533
+ const before = existing ?? null;
534
+ const after = projected ?? null;
535
+ if (column?.kind === "json") return Bun.deepEquals(before, after);
536
+ return Object.is(before, after);
536
537
  }
537
538
 
538
539
  // ---------------------------------------------------------------------------
@@ -701,7 +702,7 @@ function reconcile(args: {
701
702
  return;
702
703
  }
703
704
  const differs = Object.entries(values).some(
704
- ([name, value]) => !sameValue(existing[name], value),
705
+ ([name, value]) => !sameValue(modelDef.columns[name], existing[name], value),
705
706
  );
706
707
  const updated = await patchAppRowUnlocked(appId, model, modelDef, existing.id, values, {
707
708
  allowSourceManaged: true,