@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
@@ -0,0 +1,934 @@
1
+ import * as z from "zod";
2
+ import { deleteKv, getAllTasks, getKv, type TaskFilters, upsertKv } from "../be/db";
3
+ import { listScriptConnections } from "../be/script-connections";
4
+ import { getScriptById } from "../be/scripts/db";
5
+ import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
6
+ import { type AgentTask, AgentTaskStatusSchema } from "../types";
7
+ import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
8
+ import {
9
+ type AppDefinition,
10
+ type AppValidationIssue,
11
+ type ColumnDef,
12
+ isIso8601Date,
13
+ type ModelDef,
14
+ type SourceDef,
15
+ type SourceTransform,
16
+ } from "./definition";
17
+ import {
18
+ type AppRow,
19
+ appsNamespace,
20
+ createAppRowUnlocked,
21
+ listAllAppRowsForMigrationUnlocked,
22
+ patchAppRowUnlocked,
23
+ withMutationLock,
24
+ } from "./row-store";
25
+ import { appDefinitionNeedsRepair, getApp } from "./store";
26
+ import { resolveSyncRunAs } from "./sync-run-as";
27
+
28
+ /** One record a source pull produced: an identity plus its raw field bag. */
29
+ export type SourceRecord = { key: string; fields: Record<string, unknown> };
30
+
31
+ /**
32
+ * `complete: false` means the pull window may have missed records, so the
33
+ * caller must NOT sweep unseen rows stale.
34
+ */
35
+ export type PullResult = { records: SourceRecord[]; complete: boolean; warnings: string[] };
36
+
37
+ export type SyncPassResult = {
38
+ model: string;
39
+ source: string;
40
+ connector: "script" | "swarm-tasks";
41
+ pulled: number;
42
+ created: number;
43
+ updated: number;
44
+ refreshed: number;
45
+ /** Source-owned rows this pass wrote nothing to: unseen and already stale, or unseen with the sweep skipped. */
46
+ unchanged: number;
47
+ markedStale: number;
48
+ staleSweepSkipped?: boolean;
49
+ warnings: string[];
50
+ durationMs: number;
51
+ invokedBy?: string;
52
+ error?: string;
53
+ /** Set together when single-flight short-circuited this trigger — no pull, no writes. */
54
+ skipped?: true;
55
+ alreadyRunning?: true;
56
+ };
57
+
58
+ /**
59
+ * `ok` is false when any pass errored, or when the request never resolved to a
60
+ * runnable pair — in which case `passes` is empty and `issues` carries
61
+ * path-bearing reasons the doors (HTTP 400 / `toolErr`) can render verbatim.
62
+ */
63
+ export type AppSyncResult = {
64
+ ok: boolean;
65
+ passes: SyncPassResult[];
66
+ issues?: AppValidationIssue[];
67
+ };
68
+
69
+ /** Last-pass state per pair. No history — the current state is the whole story. */
70
+ export type AppSyncStatus = {
71
+ lastStartedAt: string;
72
+ lastFinishedAt: string;
73
+ ok: boolean;
74
+ created: number;
75
+ updated: number;
76
+ refreshed: number;
77
+ markedStale: number;
78
+ error?: string;
79
+ };
80
+
81
+ const MAX_PULL_RECORDS = 500;
82
+ const MAX_PASS_WARNINGS = 20;
83
+ const MAX_TASK_LIMIT = 200;
84
+ const DEFAULT_TASK_LIMIT = 100;
85
+ const MAX_TASK_PROMPT_CHARS = 1000;
86
+ const MAX_ERROR_DETAIL_CHARS = 500;
87
+
88
+ /** A pass failure with a message meant for an operator; never carries row churn. */
89
+ class SyncPassError extends Error {
90
+ constructor(message: string) {
91
+ super(message);
92
+ this.name = "SyncPassError";
93
+ }
94
+ }
95
+
96
+ /** Transform could not produce a value for this column — the column goes null. */
97
+ class TransformError extends Error {}
98
+
99
+ function warn(warnings: string[], message: string): void {
100
+ // Hard cap: a broken source must not turn one pass into an unbounded payload.
101
+ if (warnings.length < MAX_PASS_WARNINGS) warnings.push(message);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Sync status (KV)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Full KV path is `apps:<appId>:sync-status:<model>:<source>` — the reserved
110
+ * `apps:*` namespace plus this key. Writes go through the row-store's own
111
+ * internal KV path (`src/be/db`), which the generic KV choke points guard.
112
+ */
113
+ function syncStatusKey(model: string, source: string): string {
114
+ return `sync-status:${model}:${source}`;
115
+ }
116
+
117
+ function writeSyncStatus(appId: string, pass: SyncPassResult, lastStartedAt: string): void {
118
+ // A pass can outlive its app: deletion purges the apps:<id> namespace while
119
+ // a pull is in flight, and writing here would resurrect it as an orphan.
120
+ if (!getApp(appId)) return;
121
+ const status: AppSyncStatus = {
122
+ lastStartedAt,
123
+ lastFinishedAt: new Date().toISOString(),
124
+ ok: pass.error === undefined,
125
+ created: pass.created,
126
+ updated: pass.updated,
127
+ refreshed: pass.refreshed,
128
+ markedStale: pass.markedStale,
129
+ ...(pass.error === undefined ? {} : { error: pass.error }),
130
+ };
131
+ upsertKv({
132
+ namespace: appsNamespace(appId),
133
+ key: syncStatusKey(pass.model, pass.source),
134
+ value: status,
135
+ valueType: "json",
136
+ });
137
+ }
138
+
139
+ /** Last completed pass for a pair, or null when none has run. */
140
+ export function getAppSyncStatus(
141
+ appId: string,
142
+ model: string,
143
+ source: string,
144
+ ): AppSyncStatus | null {
145
+ const entry = getKv(appsNamespace(appId), syncStatusKey(model, source));
146
+ const value = entry?.value;
147
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
148
+ return value as AppSyncStatus;
149
+ }
150
+
151
+ /**
152
+ * Every declared (model x source) pair's last-pass status, keyed
153
+ * `<model>:<source>` — the per-source freshness surface the app payload and
154
+ * `app-get` expose so UI/agents can render "last synced / last error".
155
+ */
156
+ export function collectAppSyncStatus(appId: string): Record<string, AppSyncStatus> {
157
+ const statuses: Record<string, AppSyncStatus> = {};
158
+ const app = getApp(appId);
159
+ if (!app || appDefinitionNeedsRepair(app)) return statuses;
160
+ for (const [modelName, model] of Object.entries(app.definition.models)) {
161
+ for (const sourceName of Object.keys(model.sources ?? {})) {
162
+ const status = getAppSyncStatus(appId, modelName, sourceName);
163
+ if (status) statuses[`${modelName}:${sourceName}`] = status;
164
+ }
165
+ }
166
+ return statuses;
167
+ }
168
+
169
+ /**
170
+ * Delete the per-pair status for every (model x source) whose pull or
171
+ * projection dependencies changed between two definitions, or whose pair is
172
+ * gone: the stored freshness described the OLD configuration, and presenting
173
+ * it for the new one would claim a pass that never ran.
174
+ */
175
+ export function invalidateChangedSyncStatus(
176
+ appId: string,
177
+ previous: AppDefinition | undefined,
178
+ next: AppDefinition,
179
+ ): void {
180
+ if (!previous) return;
181
+ for (const [modelName, oldModel] of Object.entries(previous.models)) {
182
+ for (const [sourceName, oldSource] of Object.entries(oldModel.sources ?? {})) {
183
+ const nextModel = next.models[modelName];
184
+ const nextSource = nextModel?.sources?.[sourceName];
185
+ const unchanged =
186
+ nextModel !== undefined &&
187
+ nextSource !== undefined &&
188
+ pairFingerprint(nextModel, sourceName, nextSource) ===
189
+ pairFingerprint(oldModel, sourceName, oldSource);
190
+ if (!unchanged) deleteKv(appsNamespace(appId), syncStatusKey(modelName, sourceName));
191
+ }
192
+ }
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Pull: script connector
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * The canonical sync-script contract: a bare array is a complete snapshot;
201
+ * the object form may declare an incomplete window.
202
+ */
203
+ const SourceRecordSchema = z.object({
204
+ key: z.union([z.string().min(1), z.number()]).transform(String),
205
+ fields: z.record(z.string(), z.unknown()),
206
+ });
207
+
208
+ const PullPayloadSchema = z.union([
209
+ z.array(SourceRecordSchema),
210
+ z.object({ records: z.array(SourceRecordSchema), complete: z.boolean().optional() }),
211
+ ]);
212
+
213
+ function scriptFailure(output: {
214
+ exitCode: number;
215
+ error?: string;
216
+ runtimeError?: { name: string; message: string };
217
+ stderr: string;
218
+ }): string | undefined {
219
+ if (output.exitCode === 0 && !output.error && !output.runtimeError) return undefined;
220
+ const base = output.runtimeError
221
+ ? `${output.runtimeError.name}: ${output.runtimeError.message}`
222
+ : (output.error ?? `script exited with code ${output.exitCode}`);
223
+ // stderr is a prime secret carrier — scrub BEFORE truncating, or a secret
224
+ // straddling the cap loses its suffix and defeats exact-value redaction.
225
+ const stderr = scrubSecrets(output.stderr).trim().slice(0, MAX_ERROR_DETAIL_CHARS);
226
+ return stderr.length > 0 ? `${base} — ${stderr}` : base;
227
+ }
228
+
229
+ function parsePullPayload(payload: unknown, warnings: string[]): PullResult {
230
+ const parsed = PullPayloadSchema.safeParse(payload);
231
+ if (!parsed.success) {
232
+ const detail = parsed.error.issues
233
+ .slice(0, 3)
234
+ .map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
235
+ .join("; ");
236
+ throw new SyncPassError(
237
+ `source script returned an invalid payload — expected Array<{key, fields}> or {records, complete?} (${detail})`,
238
+ );
239
+ }
240
+ // A bare array is a complete snapshot by contract.
241
+ let records = Array.isArray(parsed.data) ? parsed.data : parsed.data.records;
242
+ let complete = Array.isArray(parsed.data) ? true : (parsed.data.complete ?? true);
243
+ if (records.length > MAX_PULL_RECORDS) {
244
+ warn(
245
+ warnings,
246
+ `source returned ${records.length} records; truncated to the ${MAX_PULL_RECORDS}-record cap and the stale sweep is skipped`,
247
+ );
248
+ records = records.slice(0, MAX_PULL_RECORDS);
249
+ complete = false;
250
+ }
251
+ return { records, complete, warnings };
252
+ }
253
+
254
+ async function pullFromScript(args: {
255
+ appId: string;
256
+ model: string;
257
+ sourceName: string;
258
+ source: Extract<SourceDef, { connector: "script" }>;
259
+ }): Promise<PullResult> {
260
+ const warnings: string[] = [];
261
+ const { source } = args;
262
+ const script = getScriptById(source.scriptId);
263
+ if (!script) throw new SyncPassError(`script "${source.scriptId}" not found`);
264
+ const agentId = resolveSyncRunAs(script);
265
+
266
+ // Re-run the definition-time connection check at runtime: a connection
267
+ // disabled after the write must fail the pass before the script is invoked.
268
+ if (source.connection !== undefined) {
269
+ const reachable = listScriptConnections({ agentId });
270
+ if (!reachable.some((connection) => connection.slug === source.connection)) {
271
+ throw new SyncPassError(
272
+ `connection "${source.connection}" not found or disabled for the sync run-as identity`,
273
+ );
274
+ }
275
+ }
276
+
277
+ // runSavedScriptAsAgent is the entire credential story: egress secrets,
278
+ // ctx.api.<slug> and ctx.mcp.<slug> for this identity. The engine never
279
+ // reads, resolves, or forwards secret material.
280
+ const output = await runSavedScriptAsAgent({
281
+ script,
282
+ agentId,
283
+ input: {
284
+ ...source.args,
285
+ app: { id: args.appId },
286
+ model: args.model,
287
+ source: args.sourceName,
288
+ ...(source.connection ? { connection: source.connection } : {}),
289
+ },
290
+ });
291
+ const failure = scriptFailure(output);
292
+ if (failure) throw new SyncPassError(failure);
293
+ return parsePullPayload(output.result, warnings);
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Pull: swarm-tasks connector
298
+ // ---------------------------------------------------------------------------
299
+
300
+ const TASK_CONFIG_KEYS = new Set([
301
+ "status",
302
+ "agentId",
303
+ "tags",
304
+ "assetKey",
305
+ "limit",
306
+ "includeHeartbeat",
307
+ ]);
308
+
309
+ function commaList(value: unknown): string[] {
310
+ return String(value)
311
+ .split(",")
312
+ .map((entry) => entry.trim())
313
+ .filter((entry) => entry.length > 0);
314
+ }
315
+
316
+ function taskLimit(raw: unknown, warnings: string[]): number {
317
+ if (raw === undefined) return DEFAULT_TASK_LIMIT;
318
+ const parsed = Math.trunc(Number(raw));
319
+ if (!Number.isFinite(parsed) || parsed < 1) {
320
+ warn(
321
+ warnings,
322
+ `config.limit "${String(raw)}" is not a positive integer; using ${DEFAULT_TASK_LIMIT}`,
323
+ );
324
+ return DEFAULT_TASK_LIMIT;
325
+ }
326
+ if (parsed > MAX_TASK_LIMIT) {
327
+ warn(
328
+ warnings,
329
+ `config.limit ${parsed} exceeds the ${MAX_TASK_LIMIT} cap; using ${MAX_TASK_LIMIT}`,
330
+ );
331
+ return MAX_TASK_LIMIT;
332
+ }
333
+ return parsed;
334
+ }
335
+
336
+ function taskRecord(task: AgentTask): SourceRecord {
337
+ return {
338
+ key: task.id,
339
+ fields: {
340
+ id: task.id,
341
+ status: task.status,
342
+ // Scrub BEFORE truncating: a secret straddling the cap would otherwise
343
+ // lose its suffix and defeat exact-value redaction downstream.
344
+ prompt: scrubSecrets(task.task).slice(0, MAX_TASK_PROMPT_CHARS),
345
+ source: task.source,
346
+ agentId: task.agentId,
347
+ tags: task.tags,
348
+ priority: task.priority,
349
+ createdAt: task.createdAt,
350
+ updatedAt: task.lastUpdatedAt,
351
+ vcsProvider: task.vcsProvider,
352
+ vcsNumber: task.vcsNumber,
353
+ vcsUrl: task.vcsUrl,
354
+ vcsAuthor: task.vcsAuthor,
355
+ },
356
+ };
357
+ }
358
+
359
+ function pullFromSwarmTasks(
360
+ source: Extract<SourceDef, { connector: "swarm-tasks" }>,
361
+ invokedBy?: string,
362
+ ): PullResult {
363
+ const warnings: string[] = [];
364
+ const config = source.config ?? {};
365
+ for (const key of Object.keys(config)) {
366
+ if (!TASK_CONFIG_KEYS.has(key)) {
367
+ warn(
368
+ warnings,
369
+ `config key "${key}" is not supported by the swarm-tasks connector and was ignored`,
370
+ );
371
+ }
372
+ }
373
+
374
+ const limit = taskLimit(config.limit, warnings);
375
+ const filters: TaskFilters = {
376
+ limit,
377
+ includeHeartbeat: config.includeHeartbeat === true || config.includeHeartbeat === "true",
378
+ };
379
+
380
+ if (config.status !== undefined) {
381
+ const statuses = commaList(config.status).map((token) => {
382
+ const parsed = AgentTaskStatusSchema.safeParse(token);
383
+ if (!parsed.success) throw new SyncPassError(`config.status: unknown task status "${token}"`);
384
+ return parsed.data;
385
+ });
386
+ // Fail CLOSED like agentId/assetKey: an empty filter silently dropping
387
+ // would widen a previously scoped pull to the whole pool.
388
+ if (statuses.length === 0) {
389
+ throw new SyncPassError(
390
+ `config.status must name at least one task status, got ${JSON.stringify(config.status)}`,
391
+ );
392
+ }
393
+ filters.status = statuses;
394
+ }
395
+ // Scoping config fails CLOSED: silently dropping a malformed filter would
396
+ // widen the pull to the whole task pool.
397
+ if (config.agentId !== undefined) {
398
+ if (typeof config.agentId !== "string" || config.agentId.trim().length === 0) {
399
+ throw new SyncPassError(
400
+ `config.agentId must be a non-empty string, got ${JSON.stringify(config.agentId)}`,
401
+ );
402
+ }
403
+ filters.agentId = config.agentId.trim();
404
+ }
405
+ if (config.tags !== undefined) {
406
+ const tags = commaList(config.tags);
407
+ if (tags.length === 0) {
408
+ throw new SyncPassError(
409
+ `config.tags must name at least one tag, got ${JSON.stringify(config.tags)}`,
410
+ );
411
+ }
412
+ filters.tags = tags;
413
+ }
414
+ if (config.assetKey !== undefined) {
415
+ if (typeof config.assetKey !== "string" || config.assetKey.trim().length === 0) {
416
+ throw new SyncPassError(
417
+ `config.assetKey must be a non-empty string, got ${JSON.stringify(config.assetKey)}`,
418
+ );
419
+ }
420
+ filters.keyPrefix = config.assetKey.trim();
421
+ }
422
+
423
+ // Mirror the get-tasks surface: a user principal is hard-scoped to tasks it
424
+ // requested. A scoped window can never confirm absence outside that scope,
425
+ // so the pull always reports incomplete and the stale sweep stays off.
426
+ const scopedUserId = invokedBy?.startsWith("user:") ? invokedBy.slice("user:".length) : undefined;
427
+ if (scopedUserId !== undefined) {
428
+ filters.requestedByUserId = scopedUserId;
429
+ warn(warnings, "pull scoped to tasks requested by the invoking user");
430
+ }
431
+
432
+ const records = getAllTasks(filters).map(taskRecord);
433
+ // A full page means the window may have cut records off.
434
+ return { records, complete: scopedUserId === undefined && records.length < limit, warnings };
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Projection
439
+ // ---------------------------------------------------------------------------
440
+
441
+ function getByDottedPath(fields: Record<string, unknown>, path: string): unknown {
442
+ let value: unknown = fields;
443
+ for (const segment of path.split(".")) {
444
+ if (value === null || typeof value !== "object") return undefined;
445
+ value = (value as Record<string, unknown>)[segment];
446
+ }
447
+ return value;
448
+ }
449
+
450
+ function slugify(value: string): string {
451
+ return value
452
+ .toLowerCase()
453
+ .replace(/[^a-z0-9]+/g, "-")
454
+ .replace(/^-+|-+$/g, "");
455
+ }
456
+
457
+ function applyTransform(value: unknown, transform: SourceTransform | undefined): unknown {
458
+ if (value === undefined || value === null) return null;
459
+ switch (transform) {
460
+ case undefined:
461
+ return value;
462
+ case "lower":
463
+ return String(value).toLowerCase();
464
+ case "upper":
465
+ return String(value).toUpperCase();
466
+ case "slug":
467
+ return slugify(String(value));
468
+ case "cents": {
469
+ const numeric = Number(value);
470
+ if (!Number.isFinite(numeric)) throw new TransformError(`"${String(value)}" is not a number`);
471
+ return Math.round(numeric * 100);
472
+ }
473
+ case "date-parse": {
474
+ const date = new Date(typeof value === "number" ? value : String(value));
475
+ if (Number.isNaN(date.getTime()))
476
+ throw new TransformError(`"${String(value)}" is not a date`);
477
+ return date.toISOString();
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Mirrors the row-store's per-kind value check. Projections are pre-validated
484
+ * here so one bad field nulls one column instead of throwing mid-reconcile and
485
+ * leaving the pass half-applied.
486
+ */
487
+ function validForKind(column: ColumnDef, value: unknown): boolean {
488
+ if (column.kind === "string") return typeof value === "string";
489
+ if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
490
+ if (column.kind === "boolean") return typeof value === "boolean";
491
+ if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
492
+ return typeof value === "string" && Boolean(column.enum?.includes(value));
493
+ }
494
+
495
+ function projectRecord(args: {
496
+ record: SourceRecord;
497
+ bindings: Array<[string, ColumnDef]>;
498
+ joinKey: string;
499
+ warnings: string[];
500
+ }): Record<string, unknown> {
501
+ const values: Record<string, unknown> = { [args.joinKey]: args.record.key };
502
+ for (const [name, column] of args.bindings) {
503
+ const binding = column.source;
504
+ if (!binding) continue;
505
+ const raw = getByDottedPath(args.record.fields, binding.field);
506
+ let value: unknown;
507
+ try {
508
+ value = applyTransform(raw, binding.transform);
509
+ } catch (error) {
510
+ warn(
511
+ args.warnings,
512
+ `column "${name}": transform "${binding.transform}" failed — ${error instanceof Error ? error.message : String(error)}`,
513
+ );
514
+ value = null;
515
+ }
516
+ if (value !== null && !validForKind(column, value)) {
517
+ warn(
518
+ args.warnings,
519
+ `column "${name}": field "${binding.field}" produced a value that is not a valid ${column.kind}`,
520
+ );
521
+ value = null;
522
+ }
523
+ values[name] = value;
524
+ }
525
+ return values;
526
+ }
527
+
528
+ /** A missing column and an explicit null are the same absence of a value. */
529
+ function sameValue(existing: unknown, projected: unknown): boolean {
530
+ return Object.is(existing ?? null, projected ?? null);
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Reconcile
535
+ // ---------------------------------------------------------------------------
536
+
537
+ type PairDefinition = { model: ModelDef; source: SourceDef };
538
+
539
+ function resolvePair(appId: string, model: string, source: string): PairDefinition | null {
540
+ const app = getApp(appId);
541
+ if (!app || appDefinitionNeedsRepair(app)) return null;
542
+ const modelDef = app.definition.models[model];
543
+ const sourceDef = modelDef?.sources?.[source];
544
+ return modelDef && sourceDef ? { model: modelDef, source: sourceDef } : null;
545
+ }
546
+
547
+ /**
548
+ * Everything a pass's projection depends on: the full source definition plus
549
+ * the shape of every column bound to it. Snapshotted before the unlocked pull
550
+ * and compared under the lock — ANY drift (script, args, connection, config,
551
+ * join key, bindings) aborts the pass rather than projecting a stale payload
552
+ * through fresh rules. Serialization order is stable because both reads parse
553
+ * the same stored definition; a concurrent definition write that merely
554
+ * reorders keys aborts too, which is the safe direction.
555
+ */
556
+ function pairFingerprint(model: ModelDef, sourceName: string, source: SourceDef): string {
557
+ const bindings = Object.entries(model.columns)
558
+ .filter(([, column]) => column.source?.of === sourceName)
559
+ .map(([name, column]) => ({
560
+ name,
561
+ kind: column.kind,
562
+ enum: column.enum ?? null,
563
+ required: column.required === true,
564
+ hidden: column.hidden === true,
565
+ field: column.source?.field,
566
+ transform: column.source?.transform ?? null,
567
+ }));
568
+ return JSON.stringify({ source, bindings });
569
+ }
570
+
571
+ type ReconcileCounts = {
572
+ created: number;
573
+ updated: number;
574
+ refreshed: number;
575
+ unchanged: number;
576
+ markedStale: number;
577
+ staleSweepSkipped?: boolean;
578
+ };
579
+
580
+ function reconcile(args: {
581
+ appId: string;
582
+ model: string;
583
+ sourceName: string;
584
+ fingerprint: string;
585
+ joinKey: string;
586
+ pull: PullResult;
587
+ warnings: string[];
588
+ /**
589
+ * Mutated write-by-write so a mid-pass failure still reports the row churn
590
+ * that actually committed — the writes are independent KV upserts, not a
591
+ * transaction, and a thrown error must not zero them out.
592
+ */
593
+ counts: ReconcileCounts;
594
+ }): Promise<void> {
595
+ const { appId, model, sourceName, joinKey, pull, warnings, counts } = args;
596
+ return withMutationLock(appId, model, () => {
597
+ // Re-read under the lock: the pull ran unlocked, so the definition it was
598
+ // planned against may be gone. Anything that moves the identity or the
599
+ // projection rules of this pair aborts before the first write.
600
+ const fresh = resolvePair(appId, model, sourceName);
601
+ if (!fresh) {
602
+ throw new SyncPassError(
603
+ `model "${model}" no longer declares source "${sourceName}"; pass aborted with no writes`,
604
+ );
605
+ }
606
+ if (pairFingerprint(fresh.model, sourceName, fresh.source) !== args.fingerprint) {
607
+ throw new SyncPassError(
608
+ `source "${sourceName}" changed while the pull was running; pass aborted with no writes`,
609
+ );
610
+ }
611
+ const modelDef = fresh.model;
612
+ // Hidden columns are rejected by the row-store on every write path, so a
613
+ // hidden binding must not be projected.
614
+ const bindings = Object.entries(modelDef.columns).filter(
615
+ ([, column]) => column.source?.of === sourceName && column.hidden !== true,
616
+ );
617
+
618
+ const mine = new Map<string, AppRow>();
619
+ const unkeyed: AppRow[] = [];
620
+ // Reconcile holds the mutation lock, so the unbounded pager is safe. The
621
+ // plain listAppRows cap (100k) would hide rows past it: pulled keys would
622
+ // duplicate and the hidden rows would never be swept stale.
623
+ for (const row of listAllAppRowsForMigrationUnlocked(appId, model)) {
624
+ if (row.source !== sourceName) continue;
625
+ const key = row[joinKey];
626
+ if (typeof key === "string") mine.set(key, row);
627
+ else unkeyed.push(row);
628
+ }
629
+
630
+ const now = new Date().toISOString();
631
+ const actor = `sync:${sourceName}`;
632
+ const seen = new Set<string>();
633
+
634
+ for (const record of pull.records) {
635
+ if (seen.has(record.key)) {
636
+ warn(warnings, `source returned duplicate key "${record.key}"; the last record wins`);
637
+ }
638
+ seen.add(record.key);
639
+ const values = projectRecord({ record, bindings, joinKey, warnings });
640
+ const envelope = { source: sourceName, syncedAt: now, stale: false };
641
+ const existing = mine.get(record.key);
642
+ if (!existing) {
643
+ // No adoption: a row with no source of its own is not ours to take.
644
+ const created = createAppRowUnlocked(appId, model, modelDef, values, {
645
+ allowSourceManaged: true,
646
+ envelope,
647
+ actor,
648
+ });
649
+ mine.set(record.key, created);
650
+ counts.created += 1;
651
+ continue;
652
+ }
653
+ const differs = Object.entries(values).some(
654
+ ([name, value]) => !sameValue(existing[name], value),
655
+ );
656
+ const updated = patchAppRowUnlocked(appId, model, modelDef, existing.id, values, {
657
+ allowSourceManaged: true,
658
+ envelope,
659
+ actor,
660
+ // Freshness must be meaningful: syncedAt advances on every confirmed
661
+ // row, updatedAt only when projected data actually moved.
662
+ ...(differs ? {} : { skipUpdatedAt: true }),
663
+ });
664
+ if (!updated) {
665
+ warn(warnings, `row "${existing.id}" vanished before it could be updated`);
666
+ continue;
667
+ }
668
+ mine.set(record.key, updated);
669
+ if (differs) counts.updated += 1;
670
+ else counts.refreshed += 1;
671
+ }
672
+
673
+ const unseen = [...mine.entries()]
674
+ .filter(([key]) => !seen.has(key))
675
+ .map(([, row]) => row)
676
+ .concat(unkeyed);
677
+ if (!pull.complete) {
678
+ counts.staleSweepSkipped = true;
679
+ counts.unchanged += unseen.length;
680
+ warn(warnings, "pull reported an incomplete window; stale sweep skipped");
681
+ return;
682
+ }
683
+ for (const row of unseen) {
684
+ if (row.stale === true) {
685
+ counts.unchanged += 1;
686
+ continue;
687
+ }
688
+ patchAppRowUnlocked(
689
+ appId,
690
+ model,
691
+ modelDef,
692
+ row.id,
693
+ {},
694
+ {
695
+ allowSourceManaged: true,
696
+ skipUpdatedAt: true,
697
+ // syncedAt records last confirmed presence, so it stays frozen here.
698
+ envelope: {
699
+ source: sourceName,
700
+ syncedAt: typeof row.syncedAt === "string" ? row.syncedAt : now,
701
+ stale: true,
702
+ },
703
+ },
704
+ );
705
+ counts.markedStale += 1;
706
+ }
707
+ });
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Pass orchestration
712
+ // ---------------------------------------------------------------------------
713
+
714
+ /** In-process single-flight guard, keyed `<appId>:<model>:<source>`. */
715
+ const inFlight = new Map<string, Promise<SyncPassResult>>();
716
+
717
+ function passBase(args: {
718
+ model: string;
719
+ source: string;
720
+ connector: SourceDef["connector"];
721
+ invokedBy?: string;
722
+ }): SyncPassResult {
723
+ return {
724
+ model: args.model,
725
+ source: args.source,
726
+ connector: args.connector,
727
+ pulled: 0,
728
+ created: 0,
729
+ updated: 0,
730
+ refreshed: 0,
731
+ unchanged: 0,
732
+ markedStale: 0,
733
+ warnings: [],
734
+ durationMs: 0,
735
+ ...(args.invokedBy === undefined ? {} : { invokedBy: args.invokedBy }),
736
+ };
737
+ }
738
+
739
+ async function executePass(args: {
740
+ appId: string;
741
+ model: string;
742
+ sourceName: string;
743
+ source: SourceDef;
744
+ invokedBy?: string;
745
+ }): Promise<SyncPassResult> {
746
+ const startedMs = Date.now();
747
+ const lastStartedAt = new Date(startedMs).toISOString();
748
+ const warnings: string[] = [];
749
+ const base = passBase({
750
+ model: args.model,
751
+ source: args.sourceName,
752
+ connector: args.source.connector,
753
+ invokedBy: args.invokedBy,
754
+ });
755
+
756
+ // Set once the pair resolves. finish() skips the status write when the live
757
+ // definition no longer matches this pass's snapshot: an obsolete pass must
758
+ // not resurrect status a migration just invalidated, nor seed an orphan key
759
+ // for a pair that changed or vanished while it was in flight.
760
+ let plannedFingerprint: string | null = null;
761
+
762
+ const finish = (result: SyncPassResult): SyncPassResult => {
763
+ const scrubbed = scrubObject({ ...result, warnings, durationMs: Date.now() - startedMs });
764
+ const current = resolvePair(args.appId, args.model, args.sourceName);
765
+ const stillCurrent =
766
+ plannedFingerprint !== null &&
767
+ current !== null &&
768
+ pairFingerprint(current.model, args.sourceName, current.source) === plannedFingerprint;
769
+ if (stillCurrent) writeSyncStatus(args.appId, scrubbed, lastStartedAt);
770
+ return scrubbed;
771
+ };
772
+
773
+ // Reconcile mutates this accumulator write-by-write, so the error path below
774
+ // reports the churn that actually committed instead of the zero-count base.
775
+ const counts: ReconcileCounts = {
776
+ created: 0,
777
+ updated: 0,
778
+ refreshed: 0,
779
+ unchanged: 0,
780
+ markedStale: 0,
781
+ };
782
+ let pulled = 0;
783
+
784
+ try {
785
+ // Snapshot the pair's projection rules before the unlocked pull; reconcile
786
+ // compares under the lock and aborts on any drift.
787
+ const planned = resolvePair(args.appId, args.model, args.sourceName);
788
+ if (!planned) {
789
+ throw new SyncPassError(
790
+ `model "${args.model}" no longer declares source "${args.sourceName}"; pass aborted with no writes`,
791
+ );
792
+ }
793
+ // Pull from the SAME snapshot the fingerprint was computed from:
794
+ // args.source was captured at pair-selection time and may already be
795
+ // stale (an earlier pass's await lets the definition move); pulling it
796
+ // while fingerprinting the fresh resolve would let drifted data commit.
797
+ const source = planned.source;
798
+ const fingerprint = pairFingerprint(planned.model, args.sourceName, source);
799
+ plannedFingerprint = fingerprint;
800
+
801
+ // Pull OUTSIDE the lock; reconcile inside it. Pulled values persist into
802
+ // rows any app.use principal can later read, so secrets are redacted here
803
+ // at the persistence boundary — the finish() scrub only covers the pass
804
+ // summary, never pull.records.
805
+ const pull = scrubObject(
806
+ source.connector === "script"
807
+ ? await pullFromScript({
808
+ appId: args.appId,
809
+ model: args.model,
810
+ sourceName: args.sourceName,
811
+ source,
812
+ })
813
+ : pullFromSwarmTasks(source, args.invokedBy),
814
+ );
815
+ pulled = pull.records.length;
816
+ for (const warning of pull.warnings) warn(warnings, warning);
817
+ await reconcile({
818
+ appId: args.appId,
819
+ model: args.model,
820
+ sourceName: args.sourceName,
821
+ fingerprint,
822
+ joinKey: source.joinKey,
823
+ pull,
824
+ warnings,
825
+ counts,
826
+ });
827
+ return finish({ ...base, ...counts, pulled });
828
+ } catch (error) {
829
+ const message =
830
+ error instanceof SyncPassError
831
+ ? error.message
832
+ : error instanceof Error
833
+ ? `${error.name}: ${error.message}`
834
+ : String(error);
835
+ return finish({ ...base, ...counts, pulled, error: message });
836
+ }
837
+ }
838
+
839
+ async function runPass(args: {
840
+ appId: string;
841
+ model: string;
842
+ sourceName: string;
843
+ source: SourceDef;
844
+ invokedBy?: string;
845
+ }): Promise<SyncPassResult> {
846
+ const key = `${args.appId}:${args.model}:${args.sourceName}`;
847
+ if (inFlight.has(key)) {
848
+ // A pass for this pair is already running: do not pull again.
849
+ return scrubObject({
850
+ ...passBase({
851
+ model: args.model,
852
+ source: args.sourceName,
853
+ connector: args.source.connector,
854
+ invokedBy: args.invokedBy,
855
+ }),
856
+ warnings: ["a sync pass for this pair is already running; this trigger did not pull"],
857
+ skipped: true as const,
858
+ alreadyRunning: true as const,
859
+ });
860
+ }
861
+ const pass = executePass(args);
862
+ inFlight.set(key, pass);
863
+ try {
864
+ return await pass;
865
+ } finally {
866
+ if (inFlight.get(key) === pass) inFlight.delete(key);
867
+ }
868
+ }
869
+
870
+ function noPairIssue(input: { model?: string; source?: string }): AppValidationIssue {
871
+ if (input.source !== undefined) {
872
+ return {
873
+ path: "source",
874
+ message:
875
+ input.model !== undefined
876
+ ? `unknown source "${input.source}" on model "${input.model}"`
877
+ : `unknown source "${input.source}" — no model declares it`,
878
+ };
879
+ }
880
+ if (input.model !== undefined) {
881
+ return { path: "model", message: `model "${input.model}" declares no sources` };
882
+ }
883
+ return { path: "appId", message: "no model declares a source to sync" };
884
+ }
885
+
886
+ /**
887
+ * Run every `(model x source)` pair the input selects, sequentially. Each pass
888
+ * pulls outside the per-(app, model) mutation lock and reconciles inside it.
889
+ */
890
+ export async function runAppSync(input: {
891
+ appId: string;
892
+ model?: string;
893
+ source?: string;
894
+ invokedBy?: string;
895
+ }): Promise<AppSyncResult> {
896
+ const app = getApp(input.appId);
897
+ if (!app) {
898
+ return {
899
+ ok: false,
900
+ passes: [],
901
+ issues: [{ path: "appId", message: `app "${input.appId}" not found` }],
902
+ };
903
+ }
904
+ if (appDefinitionNeedsRepair(app)) {
905
+ return {
906
+ ok: false,
907
+ passes: [],
908
+ issues: [{ path: "definition", message: "app definition needs repair before it can sync" }],
909
+ };
910
+ }
911
+ const models = app.definition.models;
912
+ if (input.model !== undefined && !Object.hasOwn(models, input.model)) {
913
+ return {
914
+ ok: false,
915
+ passes: [],
916
+ issues: [{ path: "model", message: `unknown model "${input.model}"` }],
917
+ };
918
+ }
919
+
920
+ const pairs = (input.model !== undefined ? [input.model] : Object.keys(models)).flatMap((model) =>
921
+ Object.entries(models[model]?.sources ?? {})
922
+ .filter(([sourceName]) => input.source === undefined || sourceName === input.source)
923
+ .map(([sourceName, source]) => ({ model, sourceName, source })),
924
+ );
925
+ if (pairs.length === 0) {
926
+ return { ok: false, passes: [], issues: [noPairIssue(input)] };
927
+ }
928
+
929
+ const passes: SyncPassResult[] = [];
930
+ for (const pair of pairs) {
931
+ passes.push(await runPass({ appId: app.id, ...pair, invokedBy: input.invokedBy }));
932
+ }
933
+ return { ok: passes.every((pass) => pass.error === undefined), passes };
934
+ }