@desplega.ai/agent-swarm 1.129.0 → 1.131.0

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 (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.129.0",
3
+ "version": "1.131.0",
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",
@@ -92,6 +92,7 @@
92
92
  "check:api-key-boundary": "bash scripts/check-api-key-boundary.sh",
93
93
  "check:vendored-openapi": "bun scripts/check-vendored-openapi.ts",
94
94
  "check:rbac-coverage": "bun scripts/check-rbac-coverage.ts",
95
+ "check:openapi-response-coverage": "bun scripts/check-openapi-response-coverage.ts",
95
96
  "check:script-types": "bash scripts/check-script-types-freshness.sh",
96
97
  "check:audit-columns": "bash scripts/check-audit-columns.sh",
97
98
  "lint:structure": "konsistent check",
@@ -151,6 +152,7 @@
151
152
  "@types/bun": "latest",
152
153
  "dependency-cruiser": "^18.0.0",
153
154
  "konsistent": "1.0.0-beta.1",
155
+ "openapi-typescript": "^7",
154
156
  "ts-morph": "^28.0.0",
155
157
  "turbo": "^2.10.0"
156
158
  },
@@ -167,14 +169,14 @@
167
169
  "@aws-sdk/client-bedrock": "3.1048.0",
168
170
  "@desplega.ai/business-use": "^0.4.2",
169
171
  "@desplega.ai/localtunnel": "^2.2.0",
170
- "@earendil-works/pi-agent-core": "^0.83.0",
171
- "@earendil-works/pi-ai": "^0.83.0",
172
- "@earendil-works/pi-coding-agent": "^0.83.0",
172
+ "@earendil-works/pi-agent-core": "^0.84.1",
173
+ "@earendil-works/pi-ai": "^0.84.1",
174
+ "@earendil-works/pi-coding-agent": "^0.84.1",
173
175
  "@inkjs/ui": "^2.0.0",
174
176
  "@linear/sdk": "^77.0.0",
175
177
  "@modelcontextprotocol/sdk": "^1.25.1",
176
- "@openai/codex-sdk": "^0.146.0",
177
- "@opencode-ai/sdk": "^1.18.13",
178
+ "@openai/codex-sdk": "^0.147.0",
179
+ "@opencode-ai/sdk": "^1.18.15",
178
180
  "@openfort/openfort-node": "^0.9.1",
179
181
  "@opentelemetry/api": "^1.9.1",
180
182
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod";
2
+ import { listScriptConnections } from "../be/script-connections";
2
3
  import { getScriptById } from "../be/scripts/db";
3
4
  import { getSavedScriptOwnerAgentId } from "../be/scripts/run-saved";
4
5
  import catalog from "./catalog.generated.json";
@@ -8,6 +9,7 @@ import {
8
9
  elementDefinitionIssues,
9
10
  validatePage,
10
11
  } from "./page-validator";
12
+ import { resolveSyncRunAs } from "./sync-run-as";
11
13
 
12
14
  export const AppNameSchema = z.string().regex(/^[a-z][a-zA-Z0-9_]{0,39}$/, {
13
15
  message: "must start with a lowercase letter and contain only letters, numbers, or underscores",
@@ -21,6 +23,25 @@ export function isIso8601Date(value: string): boolean {
21
23
  return ISO_8601_PREFIX.test(value) && !Number.isNaN(Date.parse(value));
22
24
  }
23
25
 
26
+ export const SourceTransformSchema = z.enum(["slug", "lower", "upper", "cents", "date-parse"]);
27
+
28
+ /** Column kind each transform may target — the sync engine projects into it. */
29
+ const TRANSFORM_COLUMN_KIND: Record<z.infer<typeof SourceTransformSchema>, ColumnKind> = {
30
+ slug: "string",
31
+ lower: "string",
32
+ upper: "string",
33
+ cents: "number",
34
+ "date-parse": "date",
35
+ };
36
+
37
+ const ColumnSourceBindingSchema = z
38
+ .object({
39
+ of: AppNameSchema,
40
+ field: z.string().min(1),
41
+ transform: SourceTransformSchema.optional(),
42
+ })
43
+ .strict();
44
+
24
45
  const ColumnDefSchema = z
25
46
  .object({
26
47
  kind: ColumnKindSchema,
@@ -29,6 +50,7 @@ const ColumnDefSchema = z
29
50
  index: z.boolean().optional(),
30
51
  default: z.union([z.string(), z.number(), z.boolean()]).optional(),
31
52
  hidden: z.boolean().optional(),
53
+ source: ColumnSourceBindingSchema.optional(),
32
54
  })
33
55
  .superRefine((column, ctx) => {
34
56
  if (column.kind === "enum") {
@@ -119,9 +141,36 @@ const UserConfigFieldSchema = z
119
141
 
120
142
  export const UserConfigSchema = z.record(AppNameSchema, UserConfigFieldSchema);
121
143
 
144
+ /**
145
+ * An inbound projection of an external system into a model. Script sources are
146
+ * the extensible default; `swarm-tasks` is the one native connector. The union
147
+ * is discriminated so a merge patch can never splice two connector shapes.
148
+ */
149
+ const SourceDefSchema = z.discriminatedUnion("connector", [
150
+ // Strict: a typo'd key (e.g. `connnection`) silently stripped here would
151
+ // surface only as an uncredentialed pull at sync time.
152
+ z
153
+ .object({
154
+ connector: z.literal("script"),
155
+ scriptId: z.string().uuid(),
156
+ joinKey: AppNameSchema,
157
+ args: z.record(z.string(), z.unknown()).optional(),
158
+ connection: z.string().min(1).optional(),
159
+ })
160
+ .strict(),
161
+ z
162
+ .object({
163
+ connector: z.literal("swarm-tasks"),
164
+ joinKey: AppNameSchema,
165
+ config: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
166
+ })
167
+ .strict(),
168
+ ]);
169
+
122
170
  const ModelDefSchema = z
123
171
  .object({
124
172
  columns: z.record(AppNameSchema, ColumnDefSchema),
173
+ sources: z.record(AppNameSchema, SourceDefSchema).optional(),
125
174
  })
126
175
  .superRefine((model, ctx) => {
127
176
  const count = Object.keys(model.columns).length;
@@ -137,6 +186,9 @@ const ModelDefSchema = z
137
186
  });
138
187
  }
139
188
  }
189
+ if (Object.keys(model.sources ?? {}).length > 4) {
190
+ ctx.addIssue({ code: "custom", path: ["sources"], message: "must define at most 4 sources" });
191
+ }
140
192
  });
141
193
 
142
194
  const AppQueryParamRefSchema = z
@@ -156,8 +208,18 @@ export const SYSTEM_COLUMN_KINDS: Record<string, "string" | "date" | "boolean">
156
208
  updatedAt: "date",
157
209
  createdBy: "string",
158
210
  updatedBy: "string",
211
+ // Sync provenance, present only on source-owned rows.
212
+ source: "string",
213
+ syncedAt: "date",
214
+ stale: "boolean",
159
215
  };
160
216
 
217
+ /**
218
+ * System row fields a sort may target alongside declared model columns. All are
219
+ * ISO-8601 dates, so membership also selects date-aware comparison.
220
+ */
221
+ export const SORTABLE_SYSTEM_DATE_COLUMNS = new Set(["createdAt", "updatedAt", "syncedAt"]);
222
+
161
223
  const AppQueryDefSchema = z.object({
162
224
  model: AppNameSchema,
163
225
  filter: z
@@ -185,6 +247,12 @@ const AppActionDefSchema = z.discriminatedUnion("kind", [
185
247
  // non-UUID (custom stable ids), so no format pin here.
186
248
  agentId: z.string().min(1).optional(),
187
249
  }),
250
+ z.object({
251
+ kind: z.literal("sync"),
252
+ // Omitting both fans out to every (model x source) pair the app declares.
253
+ model: AppNameSchema.optional(),
254
+ source: AppNameSchema.optional(),
255
+ }),
188
256
  ]);
189
257
 
190
258
  const AppPageParamSchema = z
@@ -392,8 +460,7 @@ export const AppDefinitionSchema = z
392
460
  const sortColumn = query.sort?.column;
393
461
  if (
394
462
  sortColumn &&
395
- sortColumn !== "createdAt" &&
396
- sortColumn !== "updatedAt" &&
463
+ !SORTABLE_SYSTEM_DATE_COLUMNS.has(sortColumn) &&
397
464
  (!Object.hasOwn(model.columns, sortColumn) || model.columns[sortColumn]!.hidden === true)
398
465
  ) {
399
466
  ctx.addIssue({
@@ -407,6 +474,9 @@ export const AppDefinitionSchema = z
407
474
 
408
475
  export type ColumnKind = z.infer<typeof ColumnKindSchema>;
409
476
  export type ColumnDef = z.infer<typeof ColumnDefSchema>;
477
+ export type ColumnSourceBinding = z.infer<typeof ColumnSourceBindingSchema>;
478
+ export type SourceTransform = z.infer<typeof SourceTransformSchema>;
479
+ export type SourceDef = z.infer<typeof SourceDefSchema>;
410
480
  export type UserConfigField = z.infer<typeof UserConfigFieldSchema>;
411
481
  export type ModelDef = z.infer<typeof ModelDefSchema>;
412
482
  export type AppQueryDef = z.infer<typeof AppQueryDefSchema>;
@@ -467,25 +537,287 @@ export type AppDefinitionParseContext = ElementReferenceContext & {
467
537
  */
468
538
  writerAgentId?: string | null;
469
539
  /**
470
- * The app's current stored definition, for grandfathering: script ids already
471
- * wired into the app stay referenceable so an agent can keep editing an app
472
- * that legitimately carries another owner's script action.
540
+ * True when the writer is an authenticated web USER. Users own no scripts,
541
+ * so every fresh agent-scoped reference is foreign for them — without this
542
+ * flag a user write (which carries no agent id) would skip the ownership
543
+ * gate exactly like the trusted operator.
544
+ */
545
+ writerIsUser?: boolean;
546
+ /**
547
+ * The app's current stored definition, for grandfathering: a script already
548
+ * wired into the app stays referenceable AT ITS EXISTING PATH so an agent can
549
+ * keep editing an app that legitimately carries another owner's script. The
550
+ * bare id is never grandfathered — a stored foreign action must not seed a
551
+ * new foreign source (or vice versa) with fresh args or a connection.
473
552
  */
474
553
  existingDefinition?: unknown;
475
554
  };
476
555
 
477
- /** Defensively collect script action ids from a possibly-broken definition. */
478
- function collectScriptActionIds(definition: unknown): Set<string> {
479
- const ids = new Set<string>();
480
- if (!isMergePatchObject(definition)) return ids;
556
+ /**
557
+ * Defensively collect every script reference a possibly-broken definition
558
+ * carries — script actions and model sources alike — keyed by script id with
559
+ * the definition paths that name it. Tolerant by design: it walks raw JSON, so
560
+ * an app whose definition no longer parses still reports its references.
561
+ *
562
+ * Two callers: grandfathering on write (an agent editing an app that
563
+ * legitimately carries another owner's script must not be locked out) and the
564
+ * script-delete guard in the scripts API.
565
+ */
566
+ export function collectScriptReferences(definition: unknown): Map<string, string[]> {
567
+ const references = new Map<string, string[]>();
568
+ const add = (scriptId: unknown, path: string): void => {
569
+ if (typeof scriptId !== "string" || scriptId.length === 0) return;
570
+ const paths = references.get(scriptId);
571
+ if (paths) paths.push(path);
572
+ else references.set(scriptId, [path]);
573
+ };
574
+ if (!isMergePatchObject(definition)) return references;
575
+ const actions = definition.actions;
576
+ if (isMergePatchObject(actions)) {
577
+ for (const [actionName, action] of Object.entries(actions)) {
578
+ if (isMergePatchObject(action)) add(action.scriptId, `actions.${actionName}`);
579
+ }
580
+ }
581
+ const models = definition.models;
582
+ if (isMergePatchObject(models)) {
583
+ for (const [modelName, model] of Object.entries(models)) {
584
+ if (!isMergePatchObject(model) || !isMergePatchObject(model.sources)) continue;
585
+ for (const [sourceName, source] of Object.entries(model.sources)) {
586
+ if (isMergePatchObject(source)) {
587
+ add(source.scriptId, `models.${modelName}.sources.${sourceName}`);
588
+ }
589
+ }
590
+ }
591
+ }
592
+ return references;
593
+ }
594
+
595
+ /**
596
+ * Is this agent-scoped script foreign to the writing principal? Operators
597
+ * (no writer identity at all) may wire anything; an agent may wire its own;
598
+ * a web user owns no scripts, so every agent-scoped script is foreign to it.
599
+ */
600
+ function foreignScriptForWriter(
601
+ context: AppDefinitionParseContext,
602
+ script: NonNullable<ReturnType<typeof getScriptById>>,
603
+ ): boolean {
604
+ if (script.scope !== "agent") return false;
605
+ if (context.writerIsUser === true) return true;
606
+ if (!context.writerAgentId) return false;
607
+ return getSavedScriptOwnerAgentId(script) !== context.writerAgentId;
608
+ }
609
+
610
+ /**
611
+ * Canonical form of everything security-relevant about a script reference:
612
+ * which script runs, with what arguments, over which connection. Grandfathering
613
+ * compares THIS, not the bare id — an id-only match would let a non-owner keep
614
+ * a stored foreign reference while swapping in attacker-chosen args or a
615
+ * different connection, all executed under the owner's credentials.
616
+ */
617
+ function scriptRefKey(scriptId: unknown, args: unknown, connection: unknown): string | null {
618
+ if (typeof scriptId !== "string" || scriptId.length === 0) return null;
619
+ return JSON.stringify({ scriptId, args: args ?? null, connection: connection ?? null });
620
+ }
621
+
622
+ /**
623
+ * Path-exact grandfathering index: `definition path -> scriptRefKey`. A
624
+ * reference is grandfathered only when the stored definition already carries
625
+ * the SAME script, args, and connection at the SAME path.
626
+ */
627
+ function collectScriptReferencePathMap(definition: unknown): Map<string, string> {
628
+ const byPath = new Map<string, string>();
629
+ if (!isMergePatchObject(definition)) return byPath;
481
630
  const actions = definition.actions;
482
- if (!isMergePatchObject(actions)) return ids;
483
- for (const action of Object.values(actions)) {
484
- if (isMergePatchObject(action) && typeof action.scriptId === "string") {
485
- ids.add(action.scriptId);
631
+ if (isMergePatchObject(actions)) {
632
+ for (const [actionName, action] of Object.entries(actions)) {
633
+ if (!isMergePatchObject(action)) continue;
634
+ const key = scriptRefKey(action.scriptId, action.args, undefined);
635
+ if (key) byPath.set(`actions.${actionName}`, key);
486
636
  }
487
637
  }
488
- return ids;
638
+ const models = definition.models;
639
+ if (isMergePatchObject(models)) {
640
+ for (const [modelName, model] of Object.entries(models)) {
641
+ if (!isMergePatchObject(model) || !isMergePatchObject(model.sources)) continue;
642
+ for (const [sourceName, source] of Object.entries(model.sources)) {
643
+ if (!isMergePatchObject(source)) continue;
644
+ const key = scriptRefKey(source.scriptId, source.args, source.connection);
645
+ if (key) byPath.set(`models.${modelName}.sources.${sourceName}`, key);
646
+ }
647
+ }
648
+ }
649
+ return byPath;
650
+ }
651
+
652
+ /** Semantic checks for a model's sources and its columns' source bindings. */
653
+ function modelSourceIssues(
654
+ modelName: string,
655
+ model: ModelDef,
656
+ context: AppDefinitionParseContext,
657
+ grandfatheredScriptRefs: Map<string, string>,
658
+ ): AppValidationIssue[] {
659
+ const issues: AppValidationIssue[] = [];
660
+ const sources = model.sources ?? {};
661
+ const sourceNames = new Set(Object.keys(sources));
662
+
663
+ for (const [sourceName, source] of Object.entries(sources)) {
664
+ const path = `models.${modelName}.sources.${sourceName}`;
665
+ const joinColumn = Object.hasOwn(model.columns, source.joinKey)
666
+ ? model.columns[source.joinKey]!
667
+ : undefined;
668
+ if (!joinColumn || joinColumn.hidden === true) {
669
+ issues.push({
670
+ path: `${path}.joinKey`,
671
+ message: `unknown or hidden column "${source.joinKey}"`,
672
+ });
673
+ } else if (joinColumn.kind !== "string") {
674
+ issues.push({
675
+ path: `${path}.joinKey`,
676
+ message: `join key column "${source.joinKey}" must be a string column`,
677
+ });
678
+ } else {
679
+ if (joinColumn.source) {
680
+ issues.push({
681
+ path: `${path}.joinKey`,
682
+ message: `join key column "${source.joinKey}" must not be bound to a source`,
683
+ });
684
+ }
685
+ if (joinColumn.required === true) {
686
+ issues.push({
687
+ path: `${path}.joinKey`,
688
+ message: `join key column "${source.joinKey}" must not be required`,
689
+ });
690
+ }
691
+ if (joinColumn.default !== undefined) {
692
+ issues.push({
693
+ path: `${path}.joinKey`,
694
+ message: `join key column "${source.joinKey}" must not declare a default`,
695
+ });
696
+ }
697
+ }
698
+
699
+ if (source.connector !== "script") continue;
700
+ const script = getScriptById(source.scriptId);
701
+ if (!script) {
702
+ issues.push({ path: `${path}.scriptId`, message: `script "${source.scriptId}" not found` });
703
+ continue;
704
+ }
705
+ const runAs = resolveSyncRunAs(script);
706
+ const grandfatheredRef =
707
+ grandfatheredScriptRefs.get(path) ===
708
+ scriptRefKey(source.scriptId, source.args, source.connection);
709
+ // A pull runs the script with its OWNER's bindings, so a writer may only
710
+ // wire scripts it owns (or global ones) — the script-action rule.
711
+ if (foreignScriptForWriter(context, script) && !grandfatheredRef) {
712
+ issues.push({
713
+ path: `${path}.scriptId`,
714
+ message: `script "${source.scriptId}" is agent-scoped to another agent — reference a script you own or a global script`,
715
+ });
716
+ } else if (
717
+ // Owner-less (and foreign-owned) GLOBAL scripts sync with runAs's
718
+ // credential bindings — for the catalog that is the LEAD. Introducing or
719
+ // altering such a source is that identity's (or the operator's)
720
+ // privilege; every other writer keeps only pinned stored references.
721
+ script.scope !== "agent" &&
722
+ (context.writerIsUser === true || typeof context.writerAgentId === "string") &&
723
+ context.writerAgentId !== runAs &&
724
+ !grandfatheredRef
725
+ ) {
726
+ issues.push({
727
+ path: `${path}.scriptId`,
728
+ message: `script "${source.scriptId}" syncs with agent "${runAs}"'s credentials — only that agent or the operator may wire or alter this source`,
729
+ });
730
+ }
731
+ if (source.connection !== undefined) {
732
+ const reachable = listScriptConnections({ agentId: runAs });
733
+ if (!reachable.some((connection) => connection.slug === source.connection)) {
734
+ issues.push({
735
+ path: `${path}.connection`,
736
+ message: `connection "${source.connection}" not found or disabled for the sync run-as identity`,
737
+ });
738
+ }
739
+ }
740
+ }
741
+
742
+ for (const [columnName, column] of Object.entries(model.columns)) {
743
+ const path = `models.${modelName}.columns.${columnName}`;
744
+ if (!column.source) {
745
+ // Sync creates rows from projected fields alone, so every required column
746
+ // it does NOT own has to be satisfiable without a writer.
747
+ if (
748
+ sourceNames.size > 0 &&
749
+ column.required === true &&
750
+ column.hidden !== true &&
751
+ column.default === undefined
752
+ ) {
753
+ issues.push({
754
+ path,
755
+ message:
756
+ "required column on a model with sources must declare a default — sync-created rows cannot supply it",
757
+ });
758
+ }
759
+ continue;
760
+ }
761
+ if (!sourceNames.has(column.source.of)) {
762
+ issues.push({ path: `${path}.source.of`, message: `unknown source "${column.source.of}"` });
763
+ }
764
+ const transform = column.source.transform;
765
+ if (transform && TRANSFORM_COLUMN_KIND[transform] !== column.kind) {
766
+ issues.push({
767
+ path: `${path}.source.transform`,
768
+ message: `transform "${transform}" requires a ${TRANSFORM_COLUMN_KIND[transform]} column`,
769
+ });
770
+ }
771
+ if (column.required === true) {
772
+ issues.push({
773
+ path: `${path}.required`,
774
+ message: "source-bound column must not be required",
775
+ });
776
+ }
777
+ if (column.default !== undefined) {
778
+ issues.push({
779
+ path: `${path}.default`,
780
+ message: "source-bound column must not declare a default",
781
+ });
782
+ }
783
+ }
784
+
785
+ return issues;
786
+ }
787
+
788
+ /** A `sync` action must resolve to at least one (model x source) pair. */
789
+ function syncActionIssues(
790
+ name: string,
791
+ action: { model?: string; source?: string },
792
+ models: Record<string, ModelDef>,
793
+ ): AppValidationIssue[] {
794
+ if (action.model !== undefined && !Object.hasOwn(models, action.model)) {
795
+ return [{ path: `actions.${name}.model`, message: `unknown model "${action.model}"` }];
796
+ }
797
+ const candidates = action.model !== undefined ? [action.model] : Object.keys(models);
798
+ const pairs = candidates.flatMap((modelName) =>
799
+ Object.keys(models[modelName]?.sources ?? {}).filter(
800
+ (sourceName) => action.source === undefined || sourceName === action.source,
801
+ ),
802
+ );
803
+ if (pairs.length > 0) return [];
804
+ if (action.source !== undefined) {
805
+ return [
806
+ {
807
+ path: `actions.${name}.source`,
808
+ message:
809
+ action.model !== undefined
810
+ ? `unknown source "${action.source}" on model "${action.model}"`
811
+ : `unknown source "${action.source}" — no model declares it`,
812
+ },
813
+ ];
814
+ }
815
+ if (action.model !== undefined) {
816
+ return [
817
+ { path: `actions.${name}.model`, message: `model "${action.model}" declares no sources` },
818
+ ];
819
+ }
820
+ return [{ path: `actions.${name}`, message: "no model declares a source to sync" }];
489
821
  }
490
822
 
491
823
  export function parseAppDefinition(
@@ -527,8 +859,15 @@ export function parseAppDefinition(
527
859
  ...crossPageDefinitionIssues(parsed.data, catalog),
528
860
  ...elementDefinitionIssues(parsed.data, catalog, elementContext),
529
861
  ];
530
- const grandfatheredScriptIds = collectScriptActionIds(elementContext.existingDefinition);
862
+ const grandfatheredScriptRefs = collectScriptReferencePathMap(elementContext.existingDefinition);
863
+ for (const [modelName, model] of Object.entries(parsed.data.models)) {
864
+ issues.push(...modelSourceIssues(modelName, model, elementContext, grandfatheredScriptRefs));
865
+ }
531
866
  for (const [name, action] of Object.entries(parsed.data.actions ?? {})) {
867
+ if (action.kind === "sync") {
868
+ issues.push(...syncActionIssues(name, action, parsed.data.models));
869
+ continue;
870
+ }
532
871
  if (action.kind !== "script") continue;
533
872
  const script = getScriptById(action.scriptId);
534
873
  if (!script) {
@@ -538,15 +877,15 @@ export function parseAppDefinition(
538
877
  });
539
878
  continue;
540
879
  }
541
- // Invoke-time runs the script with the OWNER's bindings, so an agent writer
542
- // may only wire scripts it owns (or global ones). Script ids already present
543
- // in the stored definition are grandfathered so foreign-authored apps stay
544
- // editable.
880
+ // Invoke-time runs the script with the OWNER's bindings, so a writer may
881
+ // only wire scripts it owns (or global ones). A reference the stored
882
+ // definition already carries at this exact path is grandfathered so
883
+ // foreign-authored apps stay editable — the bare id is not, so an existing
884
+ // foreign reference cannot seed a new one elsewhere.
545
885
  if (
546
- elementContext.writerAgentId &&
547
- script.scope === "agent" &&
548
- getSavedScriptOwnerAgentId(script) !== elementContext.writerAgentId &&
549
- !grandfatheredScriptIds.has(action.scriptId)
886
+ foreignScriptForWriter(elementContext, script) &&
887
+ grandfatheredScriptRefs.get(`actions.${name}`) !==
888
+ scriptRefKey(action.scriptId, action.args, undefined)
550
889
  ) {
551
890
  issues.push({
552
891
  path: `actions.${name}.scriptId`,
@@ -649,6 +988,7 @@ function applyMergePatch(target: unknown, patch: unknown, path: string[]): unkno
649
988
  (path.length === 1 && path[0] === "elements") ||
650
989
  (path.length === 1 && path[0] === "userConfig") ||
651
990
  (path.length === 3 && path[0] === "models" && path[2] === "columns") ||
991
+ (path.length === 3 && path[0] === "models" && path[2] === "sources") ||
652
992
  (path.length === 3 && path[0] === "elements" && path[2] === "elements") ||
653
993
  (path.length === 3 && path[0] === "pages" && (path[2] === "elements" || path[2] === "params"));
654
994
 
@@ -677,9 +1017,11 @@ function applyMergePatch(target: unknown, patch: unknown, path: string[]): unkno
677
1017
 
678
1018
  /**
679
1019
  * Apply RFC 7396 JSON Merge Patch semantics to an app definition without
680
- * mutating either input. Individual action, page-element, and model-column
681
- * entries are intentionally atomic. A reusable-element patch containing only
682
- * `elements` merges node-by-node; any other key makes it a full replacement.
1020
+ * mutating either input. Individual action, page-element, model-column, and
1021
+ * model-source entries are intentionally atomic — whole-replaced, never
1022
+ * key-merged — so a patch can never splice two connector shapes together.
1023
+ * A reusable-element patch containing only `elements` merges node-by-node;
1024
+ * any other key makes it a full replacement.
683
1025
  */
684
1026
  export function applyAppDefinitionPatch(
685
1027
  stored: AppDefinition,