@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
@@ -14,12 +14,28 @@ export type AppRow = {
14
14
  updatedAt: string;
15
15
  createdBy?: string;
16
16
  updatedBy?: string;
17
+ /** Sync provenance — present only on rows a source owns. */
18
+ source?: string;
19
+ syncedAt?: string;
20
+ stale?: boolean;
17
21
  } & Record<string, unknown>;
18
22
 
23
+ export type AppRowEnvelope = { source: string; syncedAt: string; stale: boolean };
24
+
19
25
  export interface AppRowWriteOptions {
20
26
  skipUpdatedAt?: boolean;
21
- /** Stable id of the acting principal (`user:<id>`, `agent:<id>`, `operator`) for row provenance. */
27
+ /**
28
+ * Stable id of the acting principal (`user:<id>`, `agent:<id>`, `operator`,
29
+ * or `sync:<source>` for engine writes) for row provenance.
30
+ */
22
31
  actor?: string;
32
+ /**
33
+ * Sync-engine escape hatch. External write paths leave this false so
34
+ * source-bound and join-key columns stay read-only projections.
35
+ */
36
+ allowSourceManaged?: boolean;
37
+ /** Row provenance stamped after value preparation; honoured only with `allowSourceManaged`. */
38
+ envelope?: AppRowEnvelope;
23
39
  }
24
40
 
25
41
  export class AppRowValidationError extends Error {
@@ -92,18 +108,50 @@ function validValue(column: ColumnDef, value: unknown): boolean {
92
108
  return typeof value === "string" && Boolean(column.enum?.includes(value));
93
109
  }
94
110
 
111
+ function joinKeyColumnNames(definition: ModelDef): Set<string> {
112
+ const names = new Set<string>();
113
+ for (const source of Object.values(definition.sources ?? {})) names.add(source.joinKey);
114
+ return names;
115
+ }
116
+
117
+ /** Row provenance to stamp, when the caller is the sync engine. */
118
+ function sourceEnvelope(options: AppRowWriteOptions): AppRowEnvelope | undefined {
119
+ return options.allowSourceManaged === true ? options.envelope : undefined;
120
+ }
121
+
95
122
  function prepareValues(
96
123
  definition: ModelDef,
97
124
  values: Record<string, unknown>,
98
125
  mode: "create" | "patch",
126
+ options: AppRowWriteOptions = {},
99
127
  ): Record<string, unknown> {
100
128
  const issues: AppValidationIssue[] = [];
101
129
  const prepared: Record<string, unknown> = { ...values };
130
+ const sourceManaged = options.allowSourceManaged === true;
131
+ if (sourceManaged && !options.envelope) {
132
+ // Without provenance the privileged write would produce a row
133
+ // indistinguishable from a user-owned one.
134
+ throw new Error("allowSourceManaged writes must carry an envelope");
135
+ }
136
+ const joinKeys = sourceManaged ? new Set<string>() : joinKeyColumnNames(definition);
102
137
 
103
138
  for (const name of Object.keys(values)) {
104
139
  const column = Object.hasOwn(definition.columns, name) ? definition.columns[name] : undefined;
105
140
  if (!column || column.hidden === true) {
106
141
  issues.push({ path: `values.${name}`, message: `unknown or hidden column "${name}"` });
142
+ continue;
143
+ }
144
+ if (sourceManaged) continue;
145
+ if (column.source) {
146
+ issues.push({
147
+ path: `values.${name}`,
148
+ message: `column is a read-only projection from source "${column.source.of}"; mutate it via the source or a sync refresh`,
149
+ });
150
+ } else if (joinKeys.has(name)) {
151
+ issues.push({
152
+ path: `values.${name}`,
153
+ message: "column is the sync join key and is managed by the sync engine",
154
+ });
107
155
  }
108
156
  }
109
157
 
@@ -112,6 +160,9 @@ function prepareValues(
112
160
  if (column.hidden === true) continue;
113
161
  if (!Object.hasOwn(prepared, name) && column.default !== undefined)
114
162
  prepared[name] = column.default;
163
+ // A source pull supplies only the fields the record carried; a bound
164
+ // column with no projected value is the source's business, not the row's.
165
+ if (sourceManaged && column.source) continue;
115
166
  if (
116
167
  column.required === true &&
117
168
  (!Object.hasOwn(prepared, name) || prepared[name] === undefined || prepared[name] === null)
@@ -186,17 +237,19 @@ function createRowUnlocked(
186
237
  model: string,
187
238
  definition: ModelDef,
188
239
  prepared: Record<string, unknown>,
189
- actor?: string,
240
+ options: AppRowWriteOptions,
190
241
  ): AppRow {
191
242
  const issuedMs = Math.max(Date.now(), lastCreatedAtMs + 1);
192
243
  lastCreatedAtMs = issuedMs;
193
244
  const now = new Date(issuedMs).toISOString();
245
+ const actor = options.actor;
194
246
  return writeRow(appId, model, definition, {
195
247
  id: crypto.randomUUID(),
196
248
  createdAt: now,
197
249
  updatedAt: now,
198
250
  ...(actor !== undefined ? { createdBy: actor, updatedBy: actor } : {}),
199
251
  ...prepared,
252
+ ...sourceEnvelope(options),
200
253
  });
201
254
  }
202
255
 
@@ -208,9 +261,9 @@ export function createAppRowUnlocked(
208
261
  values: Record<string, unknown>,
209
262
  options: AppRowWriteOptions = {},
210
263
  ): AppRow {
211
- const prepared = prepareValues(definition, values, "create");
264
+ const prepared = prepareValues(definition, values, "create", options);
212
265
  if (!appExists(appId)) throw new AppRowAppNotFoundError(appId);
213
- return createRowUnlocked(appId, model, definition, prepared, options.actor);
266
+ return createRowUnlocked(appId, model, definition, prepared, options);
214
267
  }
215
268
 
216
269
  export function createAppRow(
@@ -223,8 +276,8 @@ export function createAppRow(
223
276
  return withMutationLock(appId, model, () => {
224
277
  const currentDefinition = currentModelDefinition(appId, model);
225
278
  if (!currentDefinition) throw new AppRowAppNotFoundError(appId);
226
- const prepared = prepareValues(currentDefinition, values, "create");
227
- return createRowUnlocked(appId, model, currentDefinition, prepared, options.actor);
279
+ const prepared = prepareValues(currentDefinition, values, "create", options);
280
+ return createRowUnlocked(appId, model, currentDefinition, prepared, options);
228
281
  });
229
282
  }
230
283
 
@@ -238,9 +291,11 @@ export function createAppRows(
238
291
  return withMutationLock(appId, model, () => {
239
292
  const currentDefinition = currentModelDefinition(appId, model);
240
293
  if (!currentDefinition) throw new AppRowAppNotFoundError(appId);
241
- const prepared = rows.map((values) => prepareValues(currentDefinition, values, "create"));
294
+ const prepared = rows.map((values) =>
295
+ prepareValues(currentDefinition, values, "create", options),
296
+ );
242
297
  return prepared.map((values) =>
243
- createRowUnlocked(appId, model, currentDefinition, values, options.actor),
298
+ createRowUnlocked(appId, model, currentDefinition, values, options),
244
299
  );
245
300
  });
246
301
  }
@@ -299,7 +354,7 @@ export function patchAppRow(
299
354
  return withMutationLock(appId, model, () => {
300
355
  const currentDefinition = currentModelDefinition(appId, model);
301
356
  if (!currentDefinition) throw new AppRowAppNotFoundError(appId);
302
- const prepared = prepareValues(currentDefinition, values, "patch");
357
+ const prepared = prepareValues(currentDefinition, values, "patch", options);
303
358
  return patchPreparedRowUnlocked(appId, model, currentDefinition, rowId, prepared, options);
304
359
  });
305
360
  }
@@ -332,6 +387,7 @@ function patchPreparedRowUnlocked(
332
387
  updated[name] = value;
333
388
  }
334
389
  }
390
+ Object.assign(updated, sourceEnvelope(options));
335
391
  const newKeys = new Set(indexKeys(model, definition, updated));
336
392
  const namespace = appsNamespace(appId);
337
393
  for (const key of oldKeys) if (!newKeys.has(key)) deleteKv(namespace, key);
@@ -351,7 +407,7 @@ export function patchAppRowUnlocked(
351
407
  values: Record<string, unknown>,
352
408
  options: AppRowWriteOptions = {},
353
409
  ): AppRow | null {
354
- const prepared = prepareValues(definition, values, "patch");
410
+ const prepared = prepareValues(definition, values, "patch", options);
355
411
  return patchPreparedRowUnlocked(appId, model, definition, rowId, prepared, options);
356
412
  }
357
413
 
@@ -10,6 +10,7 @@ import {
10
10
  isIso8601Date,
11
11
  type ModelDef,
12
12
  parseAppDefinition,
13
+ type SourceDef,
13
14
  SYSTEM_COLUMN_KINDS,
14
15
  } from "./definition";
15
16
  import { upgradeAppDefinition } from "./format-upgrades";
@@ -20,6 +21,7 @@ import {
20
21
  withMutationLock,
21
22
  writeAppRowForMigrationUnlocked,
22
23
  } from "./row-store";
24
+ import { invalidateChangedSyncStatus } from "./sync";
23
25
 
24
26
  const MigrationValueSchema = z.union([z.string(), z.number(), z.boolean()]);
25
27
 
@@ -49,6 +51,7 @@ export const AppMigrationReportSchema = z.object({
49
51
  elsed: z.number().int().nonnegative(),
50
52
  purgedValues: z.number().int().nonnegative(),
51
53
  idxRebuilt: z.number().int().nonnegative(),
54
+ detachedRows: z.number().int().nonnegative(),
52
55
  orphanFields: z.array(z.string()),
53
56
  userConfigChanged: z.array(z.string()),
54
57
  });
@@ -61,6 +64,7 @@ export const AppMigrationReportOutputSchema = z.looseObject({
61
64
  elsed: z.number().optional(),
62
65
  purgedValues: z.number().optional(),
63
66
  idxRebuilt: z.number().optional(),
67
+ detachedRows: z.number().optional(),
64
68
  orphanFields: z.array(z.string()).optional(),
65
69
  userConfigChanged: z.array(z.string()).optional(),
66
70
  });
@@ -102,6 +106,7 @@ const EMPTY_REPORT: AppMigrationReport = {
102
106
  elsed: 0,
103
107
  purgedValues: 0,
104
108
  idxRebuilt: 0,
109
+ detachedRows: 0,
105
110
  orphanFields: [],
106
111
  userConfigChanged: [],
107
112
  };
@@ -637,6 +642,67 @@ function applyDirective(
637
642
  }
638
643
  }
639
644
 
645
+ function ownSource(model: ModelDef | undefined, sourceName: string): SourceDef | undefined {
646
+ const sources = model?.sources;
647
+ return sources && Object.hasOwn(sources, sourceName) ? sources[sourceName] : undefined;
648
+ }
649
+
650
+ /**
651
+ * Source lifecycle classification. Adding a source, and editing its
652
+ * args/config/connection/scriptId, are free. Join-key identity is immutable and
653
+ * a connector swap is refused while the source owns rows — both would silently
654
+ * re-key or orphan the rows the next pass reconciles against. Removing a source
655
+ * detaches its rows instead of destroying them (Invariant I4): the values stay,
656
+ * the `source`/`syncedAt`/`stale` envelope is stripped, and the count is
657
+ * reported. Detached rows ride the plan's ordinary changed-row write, which
658
+ * never touches `updatedAt`/`updatedBy` — a detach is not a data edit.
659
+ */
660
+ function planSources(
661
+ modelName: string,
662
+ oldModel: ModelDef | undefined,
663
+ nextModel: ModelDef | undefined,
664
+ rows: AppRow[],
665
+ report: AppMigrationReport,
666
+ issues: AppValidationIssue[],
667
+ ): void {
668
+ const sourceNames = new Set([
669
+ ...Object.keys(oldModel?.sources ?? {}),
670
+ ...Object.keys(nextModel?.sources ?? {}),
671
+ ]);
672
+ for (const sourceName of [...sourceNames].sort()) {
673
+ const oldSource = ownSource(oldModel, sourceName);
674
+ // A source that did not exist before owns no rows, so every shape it
675
+ // declares is free (Phase-1 validation still gets its say).
676
+ if (!oldSource) continue;
677
+ const nextSource = ownSource(nextModel, sourceName);
678
+ const path = `models.${modelName}.sources.${sourceName}`;
679
+ const owned = rows.filter((row) => row.source === sourceName);
680
+
681
+ if (!nextSource) {
682
+ for (const row of owned) {
683
+ delete row.source;
684
+ delete row.syncedAt;
685
+ delete row.stale;
686
+ report.detachedRows += 1;
687
+ }
688
+ continue;
689
+ }
690
+
691
+ if (nextSource.joinKey !== oldSource.joinKey) {
692
+ issues.push({
693
+ path: `${path}.joinKey`,
694
+ message: "join key is immutable; remove the source and add it again",
695
+ });
696
+ }
697
+ if (nextSource.connector !== oldSource.connector && owned.length > 0) {
698
+ issues.push({
699
+ path: `${path}.connector`,
700
+ message: `connector change would orphan ${owned.length} row(s) this source owns; remove the source and add it again`,
701
+ });
702
+ }
703
+ }
704
+ }
705
+
640
706
  function planModel(
641
707
  appId: string,
642
708
  modelName: string,
@@ -662,6 +728,8 @@ function planModel(
662
728
  });
663
729
  }
664
730
 
731
+ planSources(modelName, oldModel, nextModel, rows, report, issues);
732
+
665
733
  for (const columnName of changedColumns) {
666
734
  const oldColumn = ownColumn(oldModel, columnName);
667
735
  const nextColumn = ownColumn(nextModel, columnName);
@@ -696,6 +764,28 @@ function planModel(
696
764
  continue;
697
765
  }
698
766
 
767
+ // Binding a column that already holds data hands it to the source: the next
768
+ // pass projects over every value it did not write. A REBIND (source.of
769
+ // moving to a different source) is the same hazard — the old source stops
770
+ // projecting the column, the new one only reconciles its own rows, and the
771
+ // stranded values stay read-only forever. Adding the binding on a fresh
772
+ // (or emptied) column is the supported path.
773
+ if (
774
+ nextColumn.source !== undefined &&
775
+ (oldColumn?.source === undefined || oldColumn.source.of !== nextColumn.source.of)
776
+ ) {
777
+ const populated = rows.filter(
778
+ (row) => Object.hasOwn(row, columnName) && row[columnName] !== null,
779
+ ).length;
780
+ if (populated > 0) {
781
+ issues.push({
782
+ path: `${path}.source`,
783
+ message: `binding an existing column would let the next pass overwrite ${populated} row(s) of existing data; hide or purge the column and add it bound instead`,
784
+ });
785
+ continue;
786
+ }
787
+ }
788
+
699
789
  if (exactUnhide && nextColumn.required === true && (!directive || !("set" in directive))) {
700
790
  const missing = rows.filter(
701
791
  (row) => !Object.hasOwn(row, columnName) || row[columnName] === null,
@@ -973,7 +1063,11 @@ export async function migrateAppSchema<T>(input: {
973
1063
  );
974
1064
  }
975
1065
  }
976
- return input.writeDefinition();
1066
+ const written = input.writeDefinition();
1067
+ // The stored per-pair sync status describes the OLD configuration:
1068
+ // presenting it for a changed pair would claim a pass that never ran.
1069
+ invalidateChangedSyncStatus(input.appId, input.previousDefinition, input.nextDefinition);
1070
+ return written;
977
1071
  });
978
1072
  return { result: transaction(), migration: plan.report };
979
1073
  });
package/src/apps/store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { defaultAssetKey } from "../assets/key";
1
2
  import { getDb } from "../be/db";
2
3
  import type { AppDefinition, AppValidationIssue } from "./definition";
3
4
  import { AppDefinitionSchema, appDefinitionIssues } from "./definition";
@@ -105,12 +106,20 @@ export function createApp(input: {
105
106
  const id = input.id ?? crypto.randomUUID();
106
107
  const now = nextTimestamp();
107
108
  const row = getDb()
108
- .prepare<AppDbRow, [string, string, string | null, string, string, string]>(
109
- `INSERT INTO apps (id, name, description, definition, created_at, updated_at)
110
- VALUES (?, ?, ?, ?, ?, ?)
109
+ .prepare<AppDbRow, [string, string, string | null, string, string, string, string]>(
110
+ `INSERT INTO apps (id, name, description, definition, created_at, updated_at, "key")
111
+ VALUES (?, ?, ?, ?, ?, ?, ?)
111
112
  RETURNING id, name, description, definition, created_at, updated_at`,
112
113
  )
113
- .get(id, input.name, input.description ?? null, encodeDefinition(input.definition), now, now);
114
+ .get(
115
+ id,
116
+ input.name,
117
+ input.description ?? null,
118
+ encodeDefinition(input.definition),
119
+ now,
120
+ now,
121
+ defaultAssetKey("app", id),
122
+ );
114
123
  if (!row) throw new Error("Failed to create app");
115
124
  return decodeApp(row);
116
125
  }
@@ -0,0 +1,18 @@
1
+ import { getLeadAgent } from "../be/db";
2
+ import { getSavedScriptOwnerAgentId } from "../be/scripts/run-saved";
3
+ import type { ScriptRecord } from "../types";
4
+
5
+ /**
6
+ * Whose credential and connection bindings a source pull runs with.
7
+ *
8
+ * The script owner when there is one; otherwise the lead, because seeded
9
+ * catalog scripts are owner-less (`scope:"global"`, no `createdByAgentId`) and
10
+ * the whole catalog-source design depends on them being runnable. The literal
11
+ * fallback only applies to a swarm with no lead registered yet.
12
+ *
13
+ * Definition validation and the sync engine both resolve through here so they
14
+ * can never disagree about whose connections count.
15
+ */
16
+ export function resolveSyncRunAs(script: ScriptRecord): string {
17
+ return getSavedScriptOwnerAgentId(script) ?? getLeadAgent()?.id ?? "app-sync";
18
+ }