@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,2540 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { unlink } from "node:fs/promises";
12
+ import {
13
+ createServer as createHttpServer,
14
+ type IncomingMessage,
15
+ type Server,
16
+ type ServerResponse,
17
+ } from "node:http";
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { type ModelDef, parseAppDefinition } from "../apps/definition";
20
+ import * as rowStore from "../apps/row-store";
21
+ import {
22
+ type AppRow,
23
+ createAppRow,
24
+ listAppRows,
25
+ patchAppRow,
26
+ withMutationLock,
27
+ } from "../apps/row-store";
28
+ import { createApp, getApp, updateApp } from "../apps/store";
29
+ import { getAppSyncStatus, runAppSync, type SyncPassResult } from "../apps/sync";
30
+ import {
31
+ closeDb,
32
+ createAgent,
33
+ createTaskExtended,
34
+ createUser,
35
+ getDb,
36
+ getKv,
37
+ initDb,
38
+ } from "../be/db";
39
+ import { upsertScriptConnection } from "../be/script-connections";
40
+ import { buildScriptCredentialBindingsWithFailures } from "../be/script-credential-broker";
41
+ import { upsertScriptByName } from "../be/scripts/db";
42
+ import { typecheckScript } from "../be/scripts/typecheck";
43
+ import { SEED_SCRIPTS } from "../be/seed-scripts";
44
+ import appSyncRun from "../be/seed-scripts/catalog/app-sync-run";
45
+ import githubIssuesPull from "../be/seed-scripts/catalog/github-issues-pull";
46
+ import { handleApps } from "../http/apps";
47
+ import { resolveHttpRequestAuth } from "../http/auth";
48
+ import { handleScripts } from "../http/scripts";
49
+ import { getPathSegments, parseQueryParams } from "../http/utils";
50
+ import { LEGACY_POLICY, type LegacyRule } from "../rbac";
51
+ import { validateScriptImports } from "../scripts-runtime/import-allowlist";
52
+ import { runtimeFetchJson } from "../scripts-runtime/stdlib/fetch";
53
+ import { registerAppGetTool } from "../tools/app-get";
54
+ import { registerAppSyncTool } from "../tools/app-sync";
55
+ import { registerScriptDeleteTool } from "../tools/script-delete";
56
+ import { setRequestAuth } from "../utils/request-auth-context";
57
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
58
+
59
+ const TEST_DB_PATH = `/private/tmp/test-apps-sync-engine-${process.pid}.sqlite`;
60
+ const API_KEY = "apps-sync-engine-test-key-0123456789";
61
+ const OWNER_AGENT_ID = crypto.randomUUID();
62
+ const LEAD_AGENT_ID = crypto.randomUUID();
63
+ const savedEnv = { ...process.env };
64
+
65
+ type RegisteredTool = { handler: (args: unknown, extra: unknown) => Promise<unknown> };
66
+ type StructuredResult<T> = { isError?: boolean; structuredContent: T; content: unknown };
67
+
68
+ let server: Server;
69
+ let base = "";
70
+ let syncTool: RegisteredTool;
71
+ let scriptDeleteTool: RegisteredTool;
72
+
73
+ const PAGE = { main: { root: "root", elements: { root: { type: "Container", props: {} } } } };
74
+
75
+ type Definition = Record<string, unknown>;
76
+
77
+ const ISSUE_COLUMNS: Record<string, unknown> = {
78
+ issueKey: { kind: "string" },
79
+ title: { kind: "string", source: { of: "gh", field: "title" } },
80
+ handle: { kind: "string", source: { of: "gh", field: "user.login", transform: "slug" } },
81
+ amountCents: { kind: "number", source: { of: "gh", field: "price", transform: "cents" } },
82
+ openedAt: { kind: "date", source: { of: "gh", field: "created_at", transform: "date-parse" } },
83
+ note: { kind: "string" },
84
+ priority: { kind: "string", required: true, default: "normal" },
85
+ };
86
+
87
+ /** The same model with every source binding stripped — a plain, owned model. */
88
+ function ownedIssueColumns(): Record<string, unknown> {
89
+ return Object.fromEntries(
90
+ Object.entries(ISSUE_COLUMNS).map(([name, column]) => {
91
+ const copy = { ...(column as Record<string, unknown>) };
92
+ delete copy.source;
93
+ return [name, copy];
94
+ }),
95
+ );
96
+ }
97
+
98
+ function appWith(models: Record<string, unknown>): Definition {
99
+ return { models, pages: PAGE, defaultPage: "main" };
100
+ }
101
+
102
+ function ghSource(scriptId: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
103
+ return {
104
+ connector: "script",
105
+ scriptId,
106
+ joinKey: "issueKey",
107
+ args: { repo: "owner/name" },
108
+ ...extra,
109
+ };
110
+ }
111
+
112
+ function issueDefinition(scriptId: string, extra: Record<string, unknown> = {}): Definition {
113
+ return appWith({ issue: { columns: ISSUE_COLUMNS, sources: { gh: ghSource(scriptId, extra) } } });
114
+ }
115
+
116
+ function parsed(definition: Definition) {
117
+ const result = parseAppDefinition(definition);
118
+ if (!result.success) throw new Error(JSON.stringify(result.issues));
119
+ return result.definition;
120
+ }
121
+
122
+ function createSyncApp(definition: Definition, name = "Engine app"): string {
123
+ return createApp({ name, definition: parsed(definition) }).id;
124
+ }
125
+
126
+ function modelOf(appId: string, model: string): ModelDef {
127
+ const app = getApp(appId);
128
+ const resolved = app?.definition.models[model];
129
+ if (!resolved) throw new Error(`unknown model ${model}`);
130
+ return resolved;
131
+ }
132
+
133
+ function rowsOf(appId: string, model = "issue", joinKey = "issueKey"): AppRow[] {
134
+ return listAppRows(appId, model).sort((a, b) =>
135
+ String(a[joinKey] ?? a.id).localeCompare(String(b[joinKey] ?? b.id)),
136
+ );
137
+ }
138
+
139
+ function rowSnapshot(appId: string, model = "issue"): string {
140
+ return JSON.stringify(listAppRows(appId, model).sort((a, b) => a.id.localeCompare(b.id)));
141
+ }
142
+
143
+ let scriptCounter = 0;
144
+
145
+ /** Upsert by name: re-saving the same name swaps the source and keeps the id. */
146
+ async function saveScript(args: {
147
+ name: string;
148
+ source: string;
149
+ scope?: "global" | "agent";
150
+ scopeId?: string;
151
+ agentId?: string;
152
+ }): Promise<string> {
153
+ const result = await upsertScriptByName({
154
+ name: args.name,
155
+ source: args.source,
156
+ description: "apps sync engine fixture",
157
+ intent: "apps sync engine fixture",
158
+ signatureJson: JSON.stringify({ args: { type: "object" }, result: { type: "object" } }),
159
+ typeChecked: true,
160
+ embeddingMode: "skip",
161
+ scope: args.scope ?? "global",
162
+ scopeId: args.scopeId ?? null,
163
+ agentId: args.agentId ?? null,
164
+ });
165
+ return result.script.id;
166
+ }
167
+
168
+ function scriptName(label: string): string {
169
+ scriptCounter += 1;
170
+ return `apps_sync_engine_${label}_${scriptCounter}`;
171
+ }
172
+
173
+ /** A saved script returning a literal payload; `set` swaps the payload in place. */
174
+ async function fixtureScript(label: string, payload: unknown) {
175
+ const name = scriptName(label);
176
+ const body = (value: unknown) => `export default async () => (${JSON.stringify(value)});`;
177
+ const id = await saveScript({ name, source: body(payload) });
178
+ return {
179
+ id,
180
+ set: async (next: unknown) => {
181
+ await saveScript({ name, source: body(next) });
182
+ },
183
+ setSource: async (source: string) => {
184
+ await saveScript({ name, source });
185
+ },
186
+ };
187
+ }
188
+
189
+ function ghRecord(key: string | number, overrides: Record<string, unknown> = {}) {
190
+ return {
191
+ key,
192
+ fields: {
193
+ title: `Issue ${key}`,
194
+ user: { login: "Ada Lovelace" },
195
+ price: 12.34,
196
+ created_at: "2026-01-02T03:04:05.000Z",
197
+ ...overrides,
198
+ },
199
+ };
200
+ }
201
+
202
+ // ── HTTP + MCP door harness (Phase 5) ───────────────────────────────────────
203
+
204
+ /** node:http around the two handlers the doors live in — no ports but 0. */
205
+ function createTestServer(): Server {
206
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
207
+ setRequestAuth(req, resolveHttpRequestAuth(req, API_KEY));
208
+ res.setHeader("Content-Type", "application/json");
209
+ const pathSegments = getPathSegments(req.url || "");
210
+ const queryParams = parseQueryParams(req.url || "");
211
+ const agentId = req.headers["x-agent-id"] as string | undefined;
212
+ if (await handleApps(req, res, pathSegments, queryParams, agentId)) return;
213
+ if (await handleScripts(req, res, pathSegments, queryParams, agentId)) return;
214
+ res.writeHead(404);
215
+ res.end(JSON.stringify({ error: "not found" }));
216
+ });
217
+ }
218
+
219
+ async function request<T>(
220
+ path: string,
221
+ init: RequestInit & { agentId?: string } = {},
222
+ ): Promise<{ status: number; body: T }> {
223
+ const { agentId, ...rest } = init;
224
+ const response = await fetch(`${base}${path}`, {
225
+ ...rest,
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ Authorization: `Bearer ${API_KEY}`,
229
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
230
+ ...rest.headers,
231
+ },
232
+ });
233
+ return { status: response.status, body: (await response.json()) as T };
234
+ }
235
+
236
+ function registeredTool(register: (server: McpServer) => void, name: string): RegisteredTool {
237
+ const toolServer = new McpServer({ name: "apps-sync-engine-test", version: "1.0.0" });
238
+ register(toolServer);
239
+ return (toolServer as unknown as { _registeredTools: Record<string, RegisteredTool> })
240
+ ._registeredTools[name]!;
241
+ }
242
+
243
+ /** MCP agent identity is a real UUID — several app tool schemas pin UUIDs. */
244
+ function toolMeta(agentId = OWNER_AGENT_ID) {
245
+ return { sessionId: "apps-sync-engine", requestInfo: { headers: { "x-agent-id": agentId } } };
246
+ }
247
+
248
+ beforeAll(async () => {
249
+ for (const suffix of ["", "-wal", "-shm"]) {
250
+ try {
251
+ await unlink(`${TEST_DB_PATH}${suffix}`);
252
+ } catch {}
253
+ }
254
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
255
+ delete process.env.API_KEY;
256
+ refreshSecretScrubberCache();
257
+ initDb(TEST_DB_PATH);
258
+ createAgent({ id: OWNER_AGENT_ID, name: "apps-sync-owner", isLead: false, status: "idle" });
259
+ createAgent({ id: LEAD_AGENT_ID, name: "apps-sync-lead", isLead: true, status: "idle" });
260
+ syncTool = registeredTool(registerAppSyncTool, "app-sync");
261
+ scriptDeleteTool = registeredTool(registerScriptDeleteTool, "script-delete");
262
+ server = createTestServer();
263
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
264
+ const address = server.address();
265
+ if (!address || typeof address === "string") throw new Error("test server did not bind a port");
266
+ base = `http://127.0.0.1:${address.port}`;
267
+ // script-delete proxies over HTTP — point the tool transport at this server.
268
+ process.env.MCP_BASE_URL = base;
269
+ });
270
+
271
+ afterAll(async () => {
272
+ // Keep-alive sockets from the proxying tools would otherwise hold close().
273
+ server.closeAllConnections();
274
+ await new Promise<void>((resolve) => server.close(() => resolve()));
275
+ closeDb();
276
+ for (const key of Object.keys(process.env)) {
277
+ if (!(key in savedEnv)) delete process.env[key];
278
+ }
279
+ for (const [key, value] of Object.entries(savedEnv)) {
280
+ if (value === undefined) delete process.env[key];
281
+ else process.env[key] = value;
282
+ }
283
+ refreshSecretScrubberCache();
284
+ for (const suffix of ["", "-wal", "-shm"]) {
285
+ try {
286
+ await unlink(`${TEST_DB_PATH}${suffix}`);
287
+ } catch {}
288
+ }
289
+ });
290
+
291
+ beforeEach(() => {
292
+ getDb().run("DELETE FROM kv_entries WHERE namespace LIKE 'apps:%'");
293
+ getDb().run("DELETE FROM apps");
294
+ });
295
+
296
+ describe("script source pulls", () => {
297
+ test("first pass creates rows with the envelope, join key and transforms", async () => {
298
+ const script = await fixtureScript("create", [
299
+ ghRecord(1),
300
+ ghRecord("two", { user: { login: "Grace Hopper" }, price: 1, created_at: "2026-02-02" }),
301
+ ]);
302
+ const appId = createSyncApp(issueDefinition(script.id));
303
+
304
+ const result = await runAppSync({ appId, invokedBy: "user:tester" });
305
+
306
+ expect(result.ok).toBe(true);
307
+ expect(result.passes).toHaveLength(1);
308
+ expect(result.passes[0]).toMatchObject({
309
+ model: "issue",
310
+ source: "gh",
311
+ connector: "script",
312
+ pulled: 2,
313
+ created: 2,
314
+ updated: 0,
315
+ refreshed: 0,
316
+ markedStale: 0,
317
+ unchanged: 0,
318
+ invokedBy: "user:tester",
319
+ });
320
+ expect(result.passes[0]?.error).toBeUndefined();
321
+
322
+ const rows = rowsOf(appId);
323
+ expect(rows).toHaveLength(2);
324
+ expect(rows[0]).toMatchObject({
325
+ issueKey: "1",
326
+ title: "Issue 1",
327
+ handle: "ada-lovelace",
328
+ amountCents: 1234,
329
+ openedAt: "2026-01-02T03:04:05.000Z",
330
+ priority: "normal",
331
+ source: "gh",
332
+ stale: false,
333
+ createdBy: "sync:gh",
334
+ updatedBy: "sync:gh",
335
+ });
336
+ expect(typeof rows[0]?.syncedAt).toBe("string");
337
+ expect(rows[1]).toMatchObject({ issueKey: "two", handle: "grace-hopper", amountCents: 100 });
338
+ });
339
+
340
+ test("changed data updates projected columns only; an unchanged pass just refreshes", async () => {
341
+ const script = await fixtureScript("update", [ghRecord(1), ghRecord(2)]);
342
+ const appId = createSyncApp(issueDefinition(script.id));
343
+ await runAppSync({ appId });
344
+
345
+ // An operator owns `note`; sync must never touch it.
346
+ const before = rowsOf(appId)[0]!;
347
+ await patchAppRow(
348
+ appId,
349
+ "issue",
350
+ modelOf(appId, "issue"),
351
+ before.id,
352
+ { note: "mine" },
353
+ {
354
+ actor: "user:operator",
355
+ },
356
+ );
357
+
358
+ await script.set([ghRecord(1, { title: "Renamed" }), ghRecord(2)]);
359
+ const second = await runAppSync({ appId });
360
+ expect(second.passes[0]).toMatchObject({ pulled: 2, created: 0, updated: 1, refreshed: 1 });
361
+
362
+ const updated = rowsOf(appId)[0]!;
363
+ expect(updated.title).toBe("Renamed");
364
+ expect(updated.note).toBe("mine");
365
+ expect(updated.updatedBy).toBe("sync:gh");
366
+ expect(Date.parse(String(updated.updatedAt))).toBeGreaterThan(
367
+ Date.parse(String(before.updatedAt)),
368
+ );
369
+
370
+ const third = await runAppSync({ appId });
371
+ expect(third.passes[0]).toMatchObject({ created: 0, updated: 0, refreshed: 2 });
372
+
373
+ const refreshed = rowsOf(appId)[0]!;
374
+ expect(refreshed.updatedAt).toBe(updated.updatedAt);
375
+ expect(refreshed.updatedBy).toBe(updated.updatedBy);
376
+ expect(refreshed.note).toBe("mine");
377
+ expect(Date.parse(String(refreshed.syncedAt))).toBeGreaterThan(
378
+ Date.parse(String(updated.syncedAt)),
379
+ );
380
+ });
381
+
382
+ test("a vanished record goes stale with syncedAt frozen; reappearing clears it", async () => {
383
+ const script = await fixtureScript("stale", [ghRecord(1), ghRecord(2)]);
384
+ const appId = createSyncApp(issueDefinition(script.id));
385
+ await runAppSync({ appId });
386
+ const seeded = rowsOf(appId)[0]!;
387
+
388
+ await script.set([ghRecord(2)]);
389
+ const sweep = await runAppSync({ appId });
390
+ expect(sweep.passes[0]).toMatchObject({ pulled: 1, markedStale: 1, refreshed: 1 });
391
+ expect(sweep.passes[0]?.staleSweepSkipped).toBeUndefined();
392
+
393
+ const stale = rowsOf(appId)[0]!;
394
+ expect(stale.stale).toBe(true);
395
+ expect(stale.syncedAt).toBe(seeded.syncedAt);
396
+ expect(stale.updatedAt).toBe(seeded.updatedAt);
397
+
398
+ // A second sweep must not re-write an already-stale row.
399
+ const again = await runAppSync({ appId });
400
+ expect(again.passes[0]).toMatchObject({ markedStale: 0, unchanged: 1 });
401
+
402
+ await script.set([ghRecord(1), ghRecord(2)]);
403
+ const back = await runAppSync({ appId });
404
+ expect(back.passes[0]).toMatchObject({ created: 0, markedStale: 0 });
405
+ const revived = rowsOf(appId)[0]!;
406
+ expect(revived.stale).toBe(false);
407
+ expect(Date.parse(String(revived.syncedAt))).toBeGreaterThan(
408
+ Date.parse(String(stale.syncedAt)),
409
+ );
410
+ });
411
+
412
+ test("an unprojectable field nulls one column and warns instead of failing the pass", async () => {
413
+ const script = await fixtureScript("projection", [
414
+ ghRecord(1, { price: "not a number", created_at: "yesterday", title: 42 }),
415
+ ]);
416
+ const appId = createSyncApp(issueDefinition(script.id));
417
+
418
+ const pass = (await runAppSync({ appId })).passes[0]!;
419
+
420
+ expect(pass.error).toBeUndefined();
421
+ expect(pass.created).toBe(1);
422
+ const row = rowsOf(appId)[0]!;
423
+ expect(row.amountCents).toBeNull();
424
+ expect(row.openedAt).toBeNull();
425
+ expect(row.title).toBeNull();
426
+ expect(pass.warnings).toHaveLength(3);
427
+ expect(pass.warnings.some((warning) => warning.includes('column "amountCents"'))).toBe(true);
428
+ expect(pass.warnings.some((warning) => warning.includes('column "title"'))).toBe(true);
429
+ });
430
+
431
+ test("complete:false skips the stale sweep and warns", async () => {
432
+ const script = await fixtureScript("incomplete", [ghRecord(1), ghRecord(2)]);
433
+ const appId = createSyncApp(issueDefinition(script.id));
434
+ await runAppSync({ appId });
435
+
436
+ await script.set({ records: [ghRecord(2)], complete: false });
437
+ const pass = (await runAppSync({ appId })).passes[0]!;
438
+
439
+ expect(pass).toMatchObject({
440
+ pulled: 1,
441
+ markedStale: 0,
442
+ unchanged: 1,
443
+ staleSweepSkipped: true,
444
+ });
445
+ expect(pass.warnings.some((warning) => warning.includes("stale sweep skipped"))).toBe(true);
446
+ expect(rowsOf(appId)[0]?.stale).toBe(false);
447
+ });
448
+
449
+ test("a pull above the 500-record cap truncates and drops completeness", async () => {
450
+ const script = await fixtureScript("cap", []);
451
+ await script.setSource(
452
+ "export default async () => Array.from({ length: 501 }, (_, i) => ({ key: 'k' + i, fields: { title: 't' + i } }));",
453
+ );
454
+ const appId = createSyncApp(issueDefinition(script.id));
455
+
456
+ const pass = (await runAppSync({ appId })).passes[0]!;
457
+
458
+ expect(pass).toMatchObject({ pulled: 500, created: 500, staleSweepSkipped: true });
459
+ expect(pass.warnings.some((warning) => warning.includes("500-record cap"))).toBe(true);
460
+ expect(listAppRows(appId, "issue")).toHaveLength(500);
461
+ });
462
+
463
+ test("an invalid return shape fails the pass with zero row churn", async () => {
464
+ const script = await fixtureScript("shape", [ghRecord(1)]);
465
+ const appId = createSyncApp(issueDefinition(script.id));
466
+ await runAppSync({ appId });
467
+ const before = rowSnapshot(appId);
468
+
469
+ await script.set({ error: "upstream said no" });
470
+ const result = await runAppSync({ appId });
471
+
472
+ expect(result.ok).toBe(false);
473
+ expect(result.passes[0]?.error).toContain("invalid payload");
474
+ expect(result.passes[0]).toMatchObject({ pulled: 0, created: 0, updated: 0, markedStale: 0 });
475
+ expect(rowSnapshot(appId)).toBe(before);
476
+ });
477
+
478
+ test("a thrown script error fails the pass with zero row churn", async () => {
479
+ const script = await fixtureScript("throw", [ghRecord(1)]);
480
+ const appId = createSyncApp(issueDefinition(script.id));
481
+ await runAppSync({ appId });
482
+ const before = rowSnapshot(appId);
483
+
484
+ await script.setSource('export default async () => { throw new Error("upstream exploded"); };');
485
+ const result = await runAppSync({ appId });
486
+
487
+ expect(result.ok).toBe(false);
488
+ expect(result.passes[0]?.error).toContain("upstream exploded");
489
+ expect(rowSnapshot(appId)).toBe(before);
490
+ });
491
+
492
+ test("a non-zero exit fails the pass with zero row churn", async () => {
493
+ const script = await fixtureScript("exit", [ghRecord(1)]);
494
+ const appId = createSyncApp(issueDefinition(script.id));
495
+ await runAppSync({ appId });
496
+ const before = rowSnapshot(appId);
497
+
498
+ await script.setSource("export default async () => { process.exit(3); };");
499
+ const result = await runAppSync({ appId });
500
+
501
+ expect(result.ok).toBe(false);
502
+ expect(result.passes[0]?.error).toBeDefined();
503
+ expect(result.passes[0]).toMatchObject({ pulled: 0, created: 0, updated: 0, markedStale: 0 });
504
+ expect(rowSnapshot(appId)).toBe(before);
505
+ });
506
+
507
+ test("does not adopt an unowned row that already carries the join key", async () => {
508
+ const script = await fixtureScript("adopt", [ghRecord(1)]);
509
+ const appId = createSyncApp(appWith({ issue: { columns: ownedIssueColumns() } }));
510
+ await createAppRow(
511
+ appId,
512
+ "issue",
513
+ modelOf(appId, "issue"),
514
+ { issueKey: "1", title: "hand made", note: "human" },
515
+ { actor: "user:operator" },
516
+ );
517
+ // Adding a source to a model that already has rows is a free schema edit.
518
+ updateApp(appId, { definition: parsed(issueDefinition(script.id)) });
519
+
520
+ const pass = (await runAppSync({ appId })).passes[0]!;
521
+
522
+ expect(pass).toMatchObject({ created: 1, updated: 0 });
523
+ const rows = rowsOf(appId);
524
+ expect(rows).toHaveLength(2);
525
+ const human = rows.find((row) => row.note === "human")!;
526
+ expect(human.source).toBeUndefined();
527
+ expect(human.title).toBe("hand made");
528
+ expect(rows.find((row) => row.source === "gh")?.title).toBe("Issue 1");
529
+ });
530
+ });
531
+
532
+ describe("script source inputs and run-as", () => {
533
+ const ECHO_COLUMNS: Record<string, unknown> = {
534
+ issueKey: { kind: "string" },
535
+ payload: { kind: "string", source: { of: "gh", field: "payload" } },
536
+ };
537
+
538
+ function echoDefinition(scriptId: string, extra: Record<string, unknown> = {}): Definition {
539
+ return appWith({
540
+ issue: { columns: ECHO_COLUMNS, sources: { gh: ghSource(scriptId, extra) } },
541
+ });
542
+ }
543
+
544
+ test("args, app, model, source and connection reach the script", async () => {
545
+ await upsertScriptConnection({
546
+ slug: "echoConn",
547
+ kind: "graphql",
548
+ scope: "global",
549
+ baseUrl: "https://api.echo.test/graphql",
550
+ allowedHosts: ["api.echo.test"],
551
+ });
552
+ const name = scriptName("echo");
553
+ const scriptId = await saveScript({
554
+ name,
555
+ source:
556
+ "export default async (args) => [{ key: 'echo', fields: { payload: JSON.stringify(args) } }];",
557
+ });
558
+ const appId = createSyncApp(echoDefinition(scriptId, { connection: "echoConn" }));
559
+
560
+ const result = await runAppSync({ appId });
561
+
562
+ expect(result.ok).toBe(true);
563
+ const payload = JSON.parse(String(rowsOf(appId)[0]?.payload));
564
+ expect(payload).toEqual({
565
+ repo: "owner/name",
566
+ app: { id: appId },
567
+ model: "issue",
568
+ source: "gh",
569
+ connection: "echoConn",
570
+ });
571
+ });
572
+
573
+ test("engine-supplied model and source win over colliding args keys", async () => {
574
+ const scriptId = await saveScript({
575
+ name: scriptName("precedence"),
576
+ source:
577
+ "export default async (args) => [{ key: 'echo', fields: { payload: JSON.stringify(args) } }];",
578
+ });
579
+ const appId = createSyncApp(
580
+ echoDefinition(scriptId, { args: { repo: "x", model: "hijack", source: "hijack2" } }),
581
+ );
582
+
583
+ const result = await runAppSync({ appId });
584
+
585
+ expect(result.ok).toBe(true);
586
+ expect(JSON.parse(String(rowsOf(appId)[0]?.payload))).toEqual({
587
+ repo: "x",
588
+ app: { id: appId },
589
+ model: "issue",
590
+ source: "gh",
591
+ });
592
+ });
593
+
594
+ test("an owner-owned script runs with the owner's connections", async () => {
595
+ await upsertScriptConnection({
596
+ slug: "ownerOnly",
597
+ kind: "graphql",
598
+ scope: "agent",
599
+ scopeId: OWNER_AGENT_ID,
600
+ baseUrl: "https://api.owner.test/graphql",
601
+ allowedHosts: ["api.owner.test"],
602
+ });
603
+ const scriptId = await saveScript({
604
+ name: scriptName("owned"),
605
+ source: "export default async () => [{ key: 'o1', fields: { payload: 'ok' } }];",
606
+ scope: "agent",
607
+ scopeId: OWNER_AGENT_ID,
608
+ agentId: OWNER_AGENT_ID,
609
+ });
610
+ const appId = createSyncApp(echoDefinition(scriptId, { connection: "ownerOnly" }));
611
+
612
+ const result = await runAppSync({ appId });
613
+
614
+ expect(result.ok).toBe(true);
615
+ expect(result.passes[0]?.created).toBe(1);
616
+ });
617
+
618
+ test("an owner-less global script runs as the lead", async () => {
619
+ await upsertScriptConnection({
620
+ slug: "leadOnly",
621
+ kind: "graphql",
622
+ scope: "agent",
623
+ scopeId: LEAD_AGENT_ID,
624
+ baseUrl: "https://api.lead.test/graphql",
625
+ allowedHosts: ["api.lead.test"],
626
+ });
627
+ const scriptId = await saveScript({
628
+ name: scriptName("leadrun"),
629
+ source: "export default async () => [{ key: 'l1', fields: { payload: 'ok' } }];",
630
+ });
631
+ // The lead-scoped connection is only reachable when run-as resolved to the
632
+ // lead — both at definition-write time and in the pull preflight.
633
+ const appId = createSyncApp(echoDefinition(scriptId, { connection: "leadOnly" }));
634
+
635
+ const result = await runAppSync({ appId });
636
+
637
+ expect(result.ok).toBe(true);
638
+ expect(result.passes[0]?.created).toBe(1);
639
+ });
640
+
641
+ test("a connection disabled after the write fails preflight before the script runs", async () => {
642
+ await upsertScriptConnection({
643
+ slug: "goesDark",
644
+ kind: "graphql",
645
+ scope: "global",
646
+ baseUrl: "https://api.dark.test/graphql",
647
+ allowedHosts: ["api.dark.test"],
648
+ });
649
+ const scriptId = await saveScript({
650
+ name: scriptName("marker"),
651
+ source: 'export default async () => { throw new Error("MARKER script ran"); };',
652
+ });
653
+ const appId = createSyncApp(echoDefinition(scriptId, { connection: "goesDark" }));
654
+
655
+ await upsertScriptConnection({
656
+ slug: "goesDark",
657
+ kind: "graphql",
658
+ scope: "global",
659
+ baseUrl: "https://api.dark.test/graphql",
660
+ allowedHosts: ["api.dark.test"],
661
+ enabled: false,
662
+ });
663
+ const result = await runAppSync({ appId });
664
+
665
+ expect(result.ok).toBe(false);
666
+ expect(result.passes[0]?.error).toContain('connection "goesDark" not found or disabled');
667
+ // The marker proves the script was never invoked.
668
+ expect(result.passes[0]?.error).not.toContain("MARKER");
669
+ expect(listAppRows(appId, "issue")).toHaveLength(0);
670
+ });
671
+
672
+ test("a source naming a connection that never existed fails the pass", async () => {
673
+ const scriptId = await saveScript({
674
+ name: scriptName("ghost"),
675
+ source: 'export default async () => { throw new Error("MARKER script ran"); };',
676
+ });
677
+ await upsertScriptConnection({
678
+ slug: "ghostConn",
679
+ kind: "graphql",
680
+ scope: "global",
681
+ baseUrl: "https://api.ghost.test/graphql",
682
+ allowedHosts: ["api.ghost.test"],
683
+ });
684
+ const appId = createSyncApp(echoDefinition(scriptId, { connection: "ghostConn" }));
685
+ // Stored definitions outlive their connections; the engine re-resolves the
686
+ // slug on every pull rather than trusting write-time validation.
687
+ getDb().run("DELETE FROM script_connections WHERE slug = 'ghostConn'");
688
+
689
+ const result = await runAppSync({ appId });
690
+
691
+ expect(result.ok).toBe(false);
692
+ expect(result.passes[0]?.error).toContain('connection "ghostConn" not found or disabled');
693
+ expect(result.passes[0]?.error).not.toContain("MARKER");
694
+ });
695
+ });
696
+
697
+ describe("swarm-tasks source", () => {
698
+ const TASK_COLUMNS: Record<string, unknown> = {
699
+ taskKey: { kind: "string" },
700
+ prompt: { kind: "string", source: { of: "pool", field: "prompt" } },
701
+ taskStatus: { kind: "string", source: { of: "pool", field: "status" } },
702
+ taskPriority: { kind: "number", source: { of: "pool", field: "priority" } },
703
+ author: { kind: "string", source: { of: "pool", field: "vcsAuthor" } },
704
+ };
705
+
706
+ function taskDefinition(config: Record<string, unknown> = {}): Definition {
707
+ return appWith({
708
+ task: {
709
+ columns: TASK_COLUMNS,
710
+ sources: { pool: { connector: "swarm-tasks", joinKey: "taskKey", config } },
711
+ },
712
+ });
713
+ }
714
+
715
+ beforeEach(() => {
716
+ getDb().run("DELETE FROM agent_tasks");
717
+ });
718
+
719
+ test("projects tasks flatly, truncates the prompt and honours the default heartbeat filter", async () => {
720
+ const longPrompt = "x".repeat(1500);
721
+ const task = createTaskExtended(longPrompt, {
722
+ agentId: OWNER_AGENT_ID,
723
+ tags: ["alpha"],
724
+ priority: 70,
725
+ vcsProvider: "github",
726
+ vcsAuthor: "octocat",
727
+ });
728
+ createTaskExtended("heartbeat noise", { agentId: OWNER_AGENT_ID, tags: ["heartbeat"] });
729
+ const appId = createSyncApp(taskDefinition());
730
+
731
+ const pass = (await runAppSync({ appId })).passes[0]!;
732
+
733
+ expect(pass).toMatchObject({ connector: "swarm-tasks", pulled: 1, created: 1 });
734
+ const rows = rowsOf(appId, "task", "taskKey");
735
+ expect(rows).toHaveLength(1);
736
+ expect(rows[0]).toMatchObject({
737
+ taskKey: task.id,
738
+ taskStatus: "pending",
739
+ taskPriority: 70,
740
+ author: "octocat",
741
+ source: "pool",
742
+ stale: false,
743
+ });
744
+ expect(String(rows[0]?.prompt)).toHaveLength(1000);
745
+ });
746
+
747
+ test("assetKey prefix-scopes the window and includeHeartbeat widens it", async () => {
748
+ createTaskExtended("app owned", { agentId: OWNER_AGENT_ID, key: "shared/apps/demo/one" });
749
+ createTaskExtended("elsewhere", { agentId: OWNER_AGENT_ID, key: "shared/other/two" });
750
+ createTaskExtended("beat", { agentId: OWNER_AGENT_ID, tags: ["heartbeat"] });
751
+
752
+ const scoped = createSyncApp(taskDefinition({ assetKey: "shared/apps/demo" }), "Scoped");
753
+ expect((await runAppSync({ appId: scoped })).passes[0]?.pulled).toBe(1);
754
+
755
+ const withBeats = createSyncApp(taskDefinition({ includeHeartbeat: true }), "With beats");
756
+ expect((await runAppSync({ appId: withBeats })).passes[0]?.pulled).toBe(3);
757
+ });
758
+
759
+ test("a full page marks the pull incomplete and skips the sweep", async () => {
760
+ for (let index = 0; index < 3; index += 1) {
761
+ createTaskExtended(`task ${index}`, { agentId: OWNER_AGENT_ID });
762
+ }
763
+ const appId = createSyncApp(taskDefinition({ limit: 2 }));
764
+
765
+ const pass = (await runAppSync({ appId })).passes[0]!;
766
+
767
+ expect(pass).toMatchObject({ pulled: 2, created: 2, staleSweepSkipped: true });
768
+ });
769
+
770
+ test("status, tags and agentId filters narrow the window", async () => {
771
+ const other = crypto.randomUUID();
772
+ createAgent({ id: other, name: "apps-sync-other", isLead: false, status: "idle" });
773
+ createTaskExtended("mine tagged", { agentId: OWNER_AGENT_ID, tags: ["alpha", "beta"] });
774
+ createTaskExtended("mine untagged", { agentId: OWNER_AGENT_ID });
775
+ createTaskExtended("theirs tagged", { agentId: other, tags: ["alpha"] });
776
+ createTaskExtended("backlog", { tags: ["alpha"], status: "backlog" });
777
+
778
+ const byAgent = createSyncApp(taskDefinition({ agentId: OWNER_AGENT_ID }), "By agent");
779
+ expect((await runAppSync({ appId: byAgent })).passes[0]?.pulled).toBe(2);
780
+
781
+ const byTag = createSyncApp(taskDefinition({ tags: "beta" }), "By tag");
782
+ expect((await runAppSync({ appId: byTag })).passes[0]?.pulled).toBe(1);
783
+
784
+ const byStatus = createSyncApp(taskDefinition({ status: "backlog,pending" }), "By status");
785
+ expect((await runAppSync({ appId: byStatus })).passes[0]?.pulled).toBe(4);
786
+
787
+ const bogus = createSyncApp(taskDefinition({ status: "nonsense" }), "Bogus status");
788
+ const failed = await runAppSync({ appId: bogus });
789
+ expect(failed.ok).toBe(false);
790
+ expect(failed.passes[0]?.error).toContain('unknown task status "nonsense"');
791
+ });
792
+
793
+ test("a user-invoked sync is scoped to the requester and never sweeps stale", async () => {
794
+ const userId = createUser({ name: "Apps Sync Requester" }).id;
795
+ createTaskExtended("mine: fix the login flow", {
796
+ agentId: OWNER_AGENT_ID,
797
+ requestedByUserId: userId,
798
+ });
799
+ createTaskExtended("theirs: rotate the billing keys", {
800
+ agentId: OWNER_AGENT_ID,
801
+ requestedByUserId: createUser({ name: "Apps Sync Other" }).id,
802
+ });
803
+ createTaskExtended("pool: unattributed chore", { agentId: OWNER_AGENT_ID });
804
+ const appId = createSyncApp(taskDefinition());
805
+
806
+ const scoped = (await runAppSync({ appId, invokedBy: `user:${userId}` })).passes[0]!;
807
+
808
+ expect(scoped).toMatchObject({ pulled: 1, created: 1, staleSweepSkipped: true });
809
+ expect(scoped.warnings.some((w) => w.includes("scoped to tasks requested"))).toBe(true);
810
+ const rows = rowsOf(appId, "task", "taskKey");
811
+ expect(rows).toHaveLength(1);
812
+ expect(String(rows[0]?.prompt)).toContain("fix the login flow");
813
+ expect(JSON.stringify(rows)).not.toContain("rotate the billing keys");
814
+
815
+ // Operator- and agent-invoked passes keep the full window and its sweep.
816
+ const full = (await runAppSync({ appId, invokedBy: "operator" })).passes[0]!;
817
+ expect(full.pulled).toBe(3);
818
+ expect(full.staleSweepSkipped).toBeUndefined();
819
+ });
820
+
821
+ test("malformed scoping config fails the pass instead of widening it", async () => {
822
+ createTaskExtended("only task", { agentId: OWNER_AGENT_ID });
823
+
824
+ const badAgent = createSyncApp(taskDefinition({ agentId: 123 }), "Bad agentId");
825
+ const agentResult = await runAppSync({ appId: badAgent });
826
+ expect(agentResult.ok).toBe(false);
827
+ expect(agentResult.passes[0]?.error).toContain("config.agentId must be a non-empty string");
828
+ expect(agentResult.passes[0]?.pulled).toBe(0);
829
+
830
+ const badKey = createSyncApp(taskDefinition({ assetKey: true }), "Bad assetKey");
831
+ const keyResult = await runAppSync({ appId: badKey });
832
+ expect(keyResult.ok).toBe(false);
833
+ expect(keyResult.passes[0]?.error).toContain("config.assetKey must be a non-empty string");
834
+ expect(rowsOf(badKey, "task", "taskKey")).toHaveLength(0);
835
+
836
+ const badStatus = createSyncApp(taskDefinition({ status: " , " }), "Bad status");
837
+ const statusResult = await runAppSync({ appId: badStatus });
838
+ expect(statusResult.ok).toBe(false);
839
+ expect(statusResult.passes[0]?.error).toContain("config.status must name at least one");
840
+
841
+ const badTags = createSyncApp(taskDefinition({ tags: "" }), "Bad tags");
842
+ const tagsResult = await runAppSync({ appId: badTags });
843
+ expect(tagsResult.ok).toBe(false);
844
+ expect(tagsResult.passes[0]?.error).toContain("config.tags must name at least one");
845
+ });
846
+
847
+ test("an unsupported config key is reported as a warning", async () => {
848
+ createTaskExtended("only task", { agentId: OWNER_AGENT_ID });
849
+ const appId = createSyncApp(taskDefinition({ nonsense: "value" }));
850
+
851
+ const pass = (await runAppSync({ appId })).passes[0]!;
852
+
853
+ expect(pass.warnings.some((warning) => warning.includes('config key "nonsense"'))).toBe(true);
854
+ });
855
+
856
+ test("limit rails: over the cap and below the floor each warn", async () => {
857
+ createTaskExtended("only task", { agentId: OWNER_AGENT_ID });
858
+
859
+ const over = createSyncApp(taskDefinition({ limit: 500 }), "Over cap");
860
+ const overPass = (await runAppSync({ appId: over })).passes[0]!;
861
+ expect(overPass.warnings).toHaveLength(1);
862
+ expect(overPass.warnings[0]).toBe("config.limit 500 exceeds the 200 cap; using 200");
863
+
864
+ const under = createSyncApp(taskDefinition({ limit: 0 }), "Under floor");
865
+ const underPass = (await runAppSync({ appId: under })).passes[0]!;
866
+ expect(underPass.warnings).toHaveLength(1);
867
+ expect(underPass.warnings[0]).toBe('config.limit "0" is not a positive integer; using 100');
868
+ });
869
+
870
+ test("projects every documented task field onto its bound column", async () => {
871
+ const task = createTaskExtended("full shape", {
872
+ agentId: OWNER_AGENT_ID,
873
+ source: "slack",
874
+ tags: ["alpha", "beta"],
875
+ priority: 42,
876
+ vcsProvider: "gitlab",
877
+ vcsNumber: 77,
878
+ vcsUrl: "https://git.test/mr/77",
879
+ vcsAuthor: "octocat",
880
+ });
881
+ const appId = createSyncApp(
882
+ appWith({
883
+ task: {
884
+ columns: {
885
+ taskKey: { kind: "string" },
886
+ taskId: { kind: "string", source: { of: "pool", field: "id" } },
887
+ taskStatus: { kind: "string", source: { of: "pool", field: "status" } },
888
+ prompt: { kind: "string", source: { of: "pool", field: "prompt" } },
889
+ taskSource: { kind: "string", source: { of: "pool", field: "source" } },
890
+ taskAgentId: { kind: "string", source: { of: "pool", field: "agentId" } },
891
+ // Arrays are not a column kind; `lower` stringifies the tag list.
892
+ tagsCsv: { kind: "string", source: { of: "pool", field: "tags", transform: "lower" } },
893
+ taskPriority: { kind: "number", source: { of: "pool", field: "priority" } },
894
+ openedAt: { kind: "date", source: { of: "pool", field: "createdAt" } },
895
+ touchedAt: { kind: "date", source: { of: "pool", field: "updatedAt" } },
896
+ vcsProvider: { kind: "string", source: { of: "pool", field: "vcsProvider" } },
897
+ vcsNumber: { kind: "number", source: { of: "pool", field: "vcsNumber" } },
898
+ vcsUrl: { kind: "string", source: { of: "pool", field: "vcsUrl" } },
899
+ vcsAuthor: { kind: "string", source: { of: "pool", field: "vcsAuthor" } },
900
+ },
901
+ sources: { pool: { connector: "swarm-tasks", joinKey: "taskKey", config: {} } },
902
+ },
903
+ }),
904
+ );
905
+
906
+ const pass = (await runAppSync({ appId })).passes[0]!;
907
+
908
+ expect(pass.warnings).toHaveLength(0);
909
+ const row = rowsOf(appId, "task", "taskKey")[0]!;
910
+ expect(row).toMatchObject({
911
+ taskKey: task.id,
912
+ taskId: task.id,
913
+ taskStatus: "pending",
914
+ prompt: "full shape",
915
+ taskSource: "slack",
916
+ taskAgentId: OWNER_AGENT_ID,
917
+ tagsCsv: "alpha,beta",
918
+ taskPriority: 42,
919
+ openedAt: task.createdAt,
920
+ touchedAt: task.lastUpdatedAt,
921
+ vcsProvider: "gitlab",
922
+ vcsNumber: 77,
923
+ vcsUrl: "https://git.test/mr/77",
924
+ vcsAuthor: "octocat",
925
+ });
926
+ });
927
+
928
+ test("engine-generated warnings carrying a known secret come back redacted", async () => {
929
+ const secret = "fixture-secret-value-0123456789";
930
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
931
+ refreshSecretScrubberCache();
932
+ try {
933
+ // The task text never leaves the DB layer, so nothing upstream of the
934
+ // engine can scrub it: the cents transform fails and quotes the raw
935
+ // value into a warning the engine itself composes.
936
+ createTaskExtended(`leaked ${secret}`, { agentId: OWNER_AGENT_ID });
937
+ const appId = createSyncApp(
938
+ appWith({
939
+ task: {
940
+ columns: {
941
+ taskKey: { kind: "string" },
942
+ amount: {
943
+ kind: "number",
944
+ source: { of: "pool", field: "prompt", transform: "cents" },
945
+ },
946
+ },
947
+ sources: { pool: { connector: "swarm-tasks", joinKey: "taskKey", config: {} } },
948
+ },
949
+ }),
950
+ );
951
+
952
+ const pass = (await runAppSync({ appId })).passes[0]!;
953
+
954
+ expect(pass.warnings).toHaveLength(1);
955
+ expect(pass.warnings[0]).toContain("[REDACTED:APPS_SYNC_FIXTURE_TOKEN]");
956
+ expect(pass.warnings[0]).not.toContain(secret);
957
+ } finally {
958
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
959
+ refreshSecretScrubberCache();
960
+ }
961
+ });
962
+
963
+ test("an engine-generated pass error carrying a known secret is redacted in the status KV", async () => {
964
+ const secret = "fixture-secret-value-0123456789";
965
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
966
+ refreshSecretScrubberCache();
967
+ try {
968
+ // An unknown status token is echoed back by the engine itself — the only
969
+ // scrub between it and the caller is the engine's own.
970
+ const appId = createSyncApp(taskDefinition({ status: secret }));
971
+
972
+ const result = await runAppSync({ appId });
973
+
974
+ expect(result.ok).toBe(false);
975
+ expect(result.passes[0]?.error).toContain("[REDACTED:APPS_SYNC_FIXTURE_TOKEN]");
976
+ expect(result.passes[0]?.error).not.toContain(secret);
977
+ const status = getAppSyncStatus(appId, "task", "pool")!;
978
+ expect(status.error).toContain("[REDACTED:APPS_SYNC_FIXTURE_TOKEN]");
979
+ expect(status.error).not.toContain(secret);
980
+ } finally {
981
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
982
+ refreshSecretScrubberCache();
983
+ }
984
+ });
985
+ });
986
+
987
+ describe("pair expansion", () => {
988
+ test("fans out to every declared pair and reports unresolvable requests", async () => {
989
+ getDb().run("DELETE FROM agent_tasks");
990
+ createTaskExtended("pool task", { agentId: OWNER_AGENT_ID });
991
+ const script = await fixtureScript("fanout", [ghRecord(1)]);
992
+ const appId = createSyncApp(
993
+ appWith({
994
+ issue: {
995
+ columns: {
996
+ ...ISSUE_COLUMNS,
997
+ taskKey: { kind: "string" },
998
+ taskStatus: { kind: "string", source: { of: "pool", field: "status" } },
999
+ },
1000
+ sources: {
1001
+ gh: ghSource(script.id),
1002
+ pool: { connector: "swarm-tasks", joinKey: "taskKey", config: { limit: 50 } },
1003
+ },
1004
+ },
1005
+ }),
1006
+ );
1007
+
1008
+ const all = await runAppSync({ appId });
1009
+ expect(all.ok).toBe(true);
1010
+ expect(all.passes.map((pass) => pass.source)).toEqual(["gh", "pool"]);
1011
+ expect(all.passes.map((pass) => pass.created)).toEqual([1, 1]);
1012
+
1013
+ const single = await runAppSync({ appId, source: "gh" });
1014
+ expect(single.passes).toHaveLength(1);
1015
+ expect(single.passes[0]?.source).toBe("gh");
1016
+
1017
+ expect(await runAppSync({ appId: crypto.randomUUID() })).toMatchObject({
1018
+ ok: false,
1019
+ passes: [],
1020
+ issues: [{ path: "appId" }],
1021
+ });
1022
+ expect((await runAppSync({ appId, model: "nope" })).issues?.[0]).toMatchObject({
1023
+ path: "model",
1024
+ message: 'unknown model "nope"',
1025
+ });
1026
+ expect((await runAppSync({ appId, source: "nope" })).issues?.[0]).toEqual({
1027
+ path: "source",
1028
+ message: 'unknown source "nope" — no model declares it',
1029
+ });
1030
+ expect((await runAppSync({ appId, model: "issue", source: "nope" })).issues?.[0]).toEqual({
1031
+ path: "source",
1032
+ message: 'unknown source "nope" on model "issue"',
1033
+ });
1034
+
1035
+ const sourceless = createSyncApp(
1036
+ appWith({ issue: { columns: { issueKey: { kind: "string" } } } }),
1037
+ "Sourceless",
1038
+ );
1039
+ const none = await runAppSync({ appId: sourceless });
1040
+ expect(none.ok).toBe(false);
1041
+ expect(none.issues?.[0]).toEqual({
1042
+ path: "appId",
1043
+ message: "no model declares a source to sync",
1044
+ });
1045
+ expect((await runAppSync({ appId: sourceless, model: "issue" })).issues?.[0]).toEqual({
1046
+ path: "model",
1047
+ message: 'model "issue" declares no sources',
1048
+ });
1049
+ });
1050
+ });
1051
+
1052
+ describe("concurrency", () => {
1053
+ test("single-flight short-circuits and an interleaved operator write survives", async () => {
1054
+ const script = await fixtureScript("concurrent", [ghRecord(1), ghRecord(2)]);
1055
+ const appId = createSyncApp(issueDefinition(script.id));
1056
+ await runAppSync({ appId });
1057
+ const seededRow = rowsOf(appId)[0]!;
1058
+
1059
+ // The pull now takes ~400ms and returns changed data for record 1.
1060
+ await script.setSource(
1061
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 400)); return ${JSON.stringify(
1062
+ [ghRecord(1, { title: "Changed while locked" }), ghRecord(2)],
1063
+ )}; };`,
1064
+ );
1065
+
1066
+ // Hold the model lock so reconcile cannot start until the barrier opens.
1067
+ let release!: () => void;
1068
+ const gate = new Promise<void>((resolve) => {
1069
+ release = resolve;
1070
+ });
1071
+ const held = withMutationLock(appId, "issue", () => gate);
1072
+
1073
+ const first = runAppSync({ appId, invokedBy: "user:a" });
1074
+ // Same pair, already in flight: no second pull.
1075
+ const second = await runAppSync({ appId, invokedBy: "user:b" });
1076
+ expect(second.passes[0]).toMatchObject({
1077
+ model: "issue",
1078
+ source: "gh",
1079
+ skipped: true,
1080
+ alreadyRunning: true,
1081
+ pulled: 0,
1082
+ created: 0,
1083
+ invokedBy: "user:b",
1084
+ });
1085
+
1086
+ // The slow pass has not finished, so the status still describes the seed
1087
+ // pass: a short-circuited trigger must never write status of its own.
1088
+ expect(getAppSyncStatus(appId, "issue", "gh")).toMatchObject({
1089
+ ok: true,
1090
+ created: 2,
1091
+ updated: 0,
1092
+ refreshed: 0,
1093
+ markedStale: 0,
1094
+ });
1095
+
1096
+ // Queued behind the barrier and therefore ahead of the reconcile, which
1097
+ // only asks for the lock once its pull has finished.
1098
+ const operatorWrite = patchAppRow(
1099
+ appId,
1100
+ "issue",
1101
+ modelOf(appId, "issue"),
1102
+ seededRow.id,
1103
+ { note: "written mid-pull" },
1104
+ { actor: "user:operator" },
1105
+ );
1106
+ const operatorRow = createAppRow(
1107
+ appId,
1108
+ "issue",
1109
+ modelOf(appId, "issue"),
1110
+ { note: "operator only" },
1111
+ { actor: "user:operator" },
1112
+ );
1113
+
1114
+ release();
1115
+ await held;
1116
+ await operatorWrite;
1117
+ await operatorRow;
1118
+ const result = await first;
1119
+
1120
+ expect(result.ok).toBe(true);
1121
+ expect(result.passes[0]).toMatchObject({ pulled: 2, created: 0, updated: 1, refreshed: 1 });
1122
+
1123
+ const rows = rowsOf(appId);
1124
+ const synced = rows.filter((row) => row.source === "gh");
1125
+ expect(synced).toHaveLength(2);
1126
+ expect(new Set(synced.map((row) => row.issueKey)).size).toBe(2);
1127
+ expect(rows).toHaveLength(3);
1128
+
1129
+ const reconciled = synced.find((row) => row.issueKey === "1")!;
1130
+ expect(reconciled.title).toBe("Changed while locked");
1131
+ expect(reconciled.note).toBe("written mid-pull");
1132
+
1133
+ const unowned = rows.find((row) => row.note === "operator only")!;
1134
+ expect(unowned.source).toBeUndefined();
1135
+ expect(unowned.stale).toBeUndefined();
1136
+
1137
+ // The short-circuited trigger must not have overwritten the real pass state.
1138
+ expect(getAppSyncStatus(appId, "issue", "gh")).toMatchObject({
1139
+ ok: true,
1140
+ created: 0,
1141
+ updated: 1,
1142
+ refreshed: 1,
1143
+ });
1144
+ });
1145
+
1146
+ test("a definition change during the pull aborts the pass with no writes", async () => {
1147
+ const script = await fixtureScript("racy", [ghRecord(1)]);
1148
+ const appId = createSyncApp(issueDefinition(script.id));
1149
+ await runAppSync({ appId });
1150
+ const before = rowSnapshot(appId);
1151
+
1152
+ await script.setSource(
1153
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1154
+ [ghRecord(1, { title: "never written" })],
1155
+ )}; };`,
1156
+ );
1157
+ const pass = runAppSync({ appId });
1158
+ // Drop the source while the pull is in the air.
1159
+ updateApp(appId, {
1160
+ definition: parsed(appWith({ issue: { columns: ownedIssueColumns() } })),
1161
+ });
1162
+
1163
+ const result = await pass;
1164
+
1165
+ expect(result.ok).toBe(false);
1166
+ expect(result.passes[0]?.error).toContain("no longer declares source");
1167
+ expect(rowSnapshot(appId)).toBe(before);
1168
+ });
1169
+
1170
+ test("a join-key swap during the pull aborts the pass with no writes", async () => {
1171
+ const script = await fixtureScript("joinkey", [ghRecord(1)]);
1172
+ const withAltKey = (columns: Record<string, unknown>) => ({
1173
+ ...columns,
1174
+ altKey: { kind: "string" },
1175
+ });
1176
+ const appId = createSyncApp(
1177
+ appWith({
1178
+ issue: { columns: withAltKey(ISSUE_COLUMNS), sources: { gh: ghSource(script.id) } },
1179
+ }),
1180
+ );
1181
+ await runAppSync({ appId });
1182
+
1183
+ await script.setSource(
1184
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1185
+ [ghRecord(1, { title: "never written" })],
1186
+ )}; };`,
1187
+ );
1188
+ const pass = runAppSync({ appId });
1189
+ // A join key is immutable in place, so the only way to move it is
1190
+ // remove-then-re-add — both halves land while the pull is in the air.
1191
+ updateApp(appId, {
1192
+ definition: parsed(appWith({ issue: { columns: withAltKey(ownedIssueColumns()) } })),
1193
+ });
1194
+ updateApp(appId, {
1195
+ definition: parsed(
1196
+ appWith({
1197
+ issue: {
1198
+ columns: withAltKey(ISSUE_COLUMNS),
1199
+ sources: { gh: ghSource(script.id, { joinKey: "altKey" }) },
1200
+ },
1201
+ }),
1202
+ ),
1203
+ });
1204
+ const before = rowSnapshot(appId);
1205
+
1206
+ const result = await pass;
1207
+
1208
+ expect(result.ok).toBe(false);
1209
+ expect(result.passes[0]?.error).toContain("changed while the pull was running");
1210
+ expect(rowSnapshot(appId)).toBe(before);
1211
+ });
1212
+
1213
+ test("an args swap during the pull aborts the pass with no writes", async () => {
1214
+ const script = await fixtureScript("args-drift", [ghRecord(1)]);
1215
+ const appId = createSyncApp(issueDefinition(script.id));
1216
+ await runAppSync({ appId });
1217
+ await script.setSource(
1218
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1219
+ [ghRecord(1, { title: "stale payload" })],
1220
+ )}; };`,
1221
+ );
1222
+ const before = rowSnapshot(appId);
1223
+
1224
+ const pass = runAppSync({ appId });
1225
+ // Same connector and join key, different args: the old guard missed this.
1226
+ updateApp(appId, {
1227
+ definition: parsed(issueDefinition(script.id, { args: { repo: "owner/other" } })),
1228
+ });
1229
+
1230
+ const result = await pass;
1231
+
1232
+ expect(result.ok).toBe(false);
1233
+ expect(result.passes[0]?.error).toContain("changed while the pull was running");
1234
+ expect(rowSnapshot(appId)).toBe(before);
1235
+ });
1236
+
1237
+ test("a binding swap during the pull aborts the pass with no writes", async () => {
1238
+ const script = await fixtureScript("binding-drift", [ghRecord(1)]);
1239
+ const appId = createSyncApp(issueDefinition(script.id));
1240
+ await runAppSync({ appId });
1241
+ await script.setSource(
1242
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1243
+ [ghRecord(1, { title: "stale payload" })],
1244
+ )}; };`,
1245
+ );
1246
+ const before = rowSnapshot(appId);
1247
+
1248
+ const pass = runAppSync({ appId });
1249
+ // Rebind `title` to a different field mid-pull: the payload was projected
1250
+ // against the old rules and must not land under the new ones.
1251
+ updateApp(appId, {
1252
+ definition: parsed(
1253
+ appWith({
1254
+ issue: {
1255
+ columns: {
1256
+ ...ISSUE_COLUMNS,
1257
+ title: { kind: "string", source: { of: "gh", field: "user.login" } },
1258
+ },
1259
+ sources: { gh: ghSource(script.id) },
1260
+ },
1261
+ }),
1262
+ ),
1263
+ });
1264
+
1265
+ const result = await pass;
1266
+
1267
+ expect(result.ok).toBe(false);
1268
+ expect(result.passes[0]?.error).toContain("changed while the pull was running");
1269
+ expect(rowSnapshot(appId)).toBe(before);
1270
+ });
1271
+
1272
+ test("a mid-pass write failure reports the committed churn instead of zero counts", async () => {
1273
+ // Row writes are independent KV upserts, not a transaction: fail the
1274
+ // SECOND create after the first already committed and the pass counts
1275
+ // must say so instead of reporting the zero-count base.
1276
+ const script = await fixtureScript("partial", [ghRecord(1), ghRecord(2)]);
1277
+ const appId = createSyncApp(issueDefinition(script.id));
1278
+
1279
+ const realCreate = rowStore.createAppRowUnlocked;
1280
+ let creates = 0;
1281
+ const spy = spyOn(rowStore, "createAppRowUnlocked").mockImplementation((...callArgs) => {
1282
+ creates += 1;
1283
+ if (creates === 2) throw new Error("kv write failed (injected)");
1284
+ return realCreate(...callArgs);
1285
+ });
1286
+ try {
1287
+ const result = await runAppSync({ appId });
1288
+ const pass = result.passes[0]!;
1289
+
1290
+ expect(result.ok).toBe(false);
1291
+ expect(pass.error).toContain("kv write failed (injected)");
1292
+ expect(pass).toMatchObject({ pulled: 2, created: 1 });
1293
+ expect(rowsOf(appId).map((row) => row.issueKey)).toEqual(["1"]);
1294
+ expect(getAppSyncStatus(appId, "issue", "gh")).toMatchObject({ ok: false, created: 1 });
1295
+ } finally {
1296
+ spy.mockRestore();
1297
+ }
1298
+ });
1299
+
1300
+ test("single-flight is keyed per source, not per model", async () => {
1301
+ createTaskExtended("pool work", { agentId: OWNER_AGENT_ID });
1302
+ const script = await fixtureScript("perSource", []);
1303
+ await script.setSource(
1304
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 400)); return ${JSON.stringify(
1305
+ [ghRecord(1)],
1306
+ )}; };`,
1307
+ );
1308
+ const appId = createSyncApp(
1309
+ appWith({
1310
+ issue: {
1311
+ columns: {
1312
+ ...ISSUE_COLUMNS,
1313
+ taskKey: { kind: "string" },
1314
+ taskStatus: { kind: "string", source: { of: "pool", field: "status" } },
1315
+ },
1316
+ sources: {
1317
+ gh: ghSource(script.id),
1318
+ pool: { connector: "swarm-tasks", joinKey: "taskKey", config: { limit: 50 } },
1319
+ },
1320
+ },
1321
+ }),
1322
+ );
1323
+
1324
+ const slow = runAppSync({ appId, source: "gh" });
1325
+ // Different source on the same model: this must run, not short-circuit.
1326
+ const pool = await runAppSync({ appId, source: "pool" });
1327
+
1328
+ expect(pool.ok).toBe(true);
1329
+ expect(pool.passes[0]?.skipped).toBeUndefined();
1330
+ expect(pool.passes[0]?.alreadyRunning).toBeUndefined();
1331
+ expect(pool.passes[0]?.error).toBeUndefined();
1332
+ expect(pool.passes[0]?.created).toBeGreaterThan(0);
1333
+
1334
+ const slowResult = await slow;
1335
+ expect(slowResult.ok).toBe(true);
1336
+ expect(slowResult.passes[0]).toMatchObject({ source: "gh", created: 1, markedStale: 0 });
1337
+ // Each source sweeps only its own rows.
1338
+ expect(
1339
+ rowsOf(appId)
1340
+ .filter((row) => row.source === "pool")
1341
+ .every((row) => row.stale === false),
1342
+ ).toBe(true);
1343
+ });
1344
+ });
1345
+
1346
+ describe("populated-column rebind guard", () => {
1347
+ test("rebinding a populated column to another source is rejected like a fresh binding", async () => {
1348
+ const script = await fixtureScript("rebind", [ghRecord(1)]);
1349
+ const appId = createSyncApp(
1350
+ appWith({
1351
+ issue: {
1352
+ columns: { ...ISSUE_COLUMNS, taskKey: { kind: "string" } },
1353
+ sources: {
1354
+ gh: ghSource(script.id),
1355
+ pool: { connector: "swarm-tasks", joinKey: "taskKey", config: { limit: 50 } },
1356
+ },
1357
+ },
1358
+ }),
1359
+ );
1360
+ await runAppSync({ appId, source: "gh" });
1361
+ expect(rowsOf(appId)[0]?.title).toBe("Issue 1");
1362
+
1363
+ // Move title from gh to pool while a gh-written value exists: the value
1364
+ // would be stranded read-only on a row pool never reconciles.
1365
+ const rejected = await request<{ issues?: Array<{ path: string; message: string }> }>(
1366
+ `/api/apps/${appId}`,
1367
+ {
1368
+ method: "PATCH",
1369
+ body: JSON.stringify({
1370
+ definition: {
1371
+ models: {
1372
+ issue: {
1373
+ columns: { title: { kind: "string", source: { of: "pool", field: "prompt" } } },
1374
+ },
1375
+ },
1376
+ },
1377
+ }),
1378
+ },
1379
+ );
1380
+
1381
+ expect(rejected.status).toBe(400);
1382
+ expect(
1383
+ rejected.body.issues?.some(
1384
+ (issue) =>
1385
+ issue.path === "models.issue.columns.title.source" &&
1386
+ issue.message.includes("binding an existing column"),
1387
+ ),
1388
+ ).toBe(true);
1389
+ });
1390
+ });
1391
+
1392
+ describe("pass snapshot consistency", () => {
1393
+ test("a later pass pulls the definition current at ITS start, not selection time", async () => {
1394
+ const slow = await fixtureScript("race-slow", []);
1395
+ await slow.setSource(
1396
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1397
+ [ghRecord(1)],
1398
+ )}; };`,
1399
+ );
1400
+ const oldScript = await fixtureScript("race-old", [ghRecord(1, { title: "from-old" })]);
1401
+ const newScript = await fixtureScript("race-new", [ghRecord(1, { title: "from-new" })]);
1402
+ const definitionWith = (secondId: string) =>
1403
+ appWith({
1404
+ a: { columns: { ...ISSUE_COLUMNS }, sources: { gh: ghSource(slow.id) } },
1405
+ b: { columns: { ...ISSUE_COLUMNS }, sources: { gh: ghSource(secondId) } },
1406
+ });
1407
+ const appId = createSyncApp(definitionWith(oldScript.id));
1408
+
1409
+ const run = runAppSync({ appId });
1410
+ // While pass "a" awaits its slow pull, repoint model b's source. Pass "b"
1411
+ // must pull the CURRENT script — pulling the selection-time one while
1412
+ // fingerprinting the fresh resolve would commit drifted data silently.
1413
+ updateApp(appId, { definition: parsed(definitionWith(newScript.id)) });
1414
+ const result = await run;
1415
+
1416
+ expect(result.ok).toBe(true);
1417
+ const bRows = rowsOf(appId, "b");
1418
+ expect(bRows).toHaveLength(1);
1419
+ expect(bRows[0]?.title).toBe("from-new");
1420
+ });
1421
+ });
1422
+
1423
+ describe("secret hygiene and sync status", () => {
1424
+ test("a pass error carrying a known secret comes back redacted", async () => {
1425
+ const secret = "fixture-secret-value-0123456789";
1426
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
1427
+ refreshSecretScrubberCache();
1428
+ try {
1429
+ const script = await fixtureScript("secret", []);
1430
+ await script.setSource(
1431
+ `export default async () => { throw new Error("upstream rejected ${secret}"); };`,
1432
+ );
1433
+ const appId = createSyncApp(issueDefinition(script.id));
1434
+
1435
+ const result = await runAppSync({ appId });
1436
+
1437
+ expect(result.ok).toBe(false);
1438
+ expect(result.passes[0]?.error).toContain("[REDACTED:APPS_SYNC_FIXTURE_TOKEN]");
1439
+ expect(result.passes[0]?.error).not.toContain(secret);
1440
+ expect(getAppSyncStatus(appId, "issue", "gh")?.error).not.toContain(secret);
1441
+ } finally {
1442
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
1443
+ refreshSecretScrubberCache();
1444
+ }
1445
+ });
1446
+
1447
+ test("sync status records the last pass at the documented key", async () => {
1448
+ const script = await fixtureScript("status", [ghRecord(1)]);
1449
+ const appId = createSyncApp(issueDefinition(script.id));
1450
+ await runAppSync({ appId });
1451
+
1452
+ const entry = getKv(`apps:${appId}`, "sync-status:issue:gh");
1453
+ expect(entry).not.toBeNull();
1454
+ const ok = getAppSyncStatus(appId, "issue", "gh")!;
1455
+ expect(ok).toMatchObject({ ok: true, created: 1, updated: 0, refreshed: 0, markedStale: 0 });
1456
+ expect(Object.keys(ok).sort()).toEqual([
1457
+ "created",
1458
+ "lastFinishedAt",
1459
+ "lastStartedAt",
1460
+ "markedStale",
1461
+ "ok",
1462
+ "refreshed",
1463
+ "updated",
1464
+ ]);
1465
+ expect(typeof ok.lastStartedAt).toBe("string");
1466
+ expect(Date.parse(ok.lastFinishedAt)).toBeGreaterThanOrEqual(Date.parse(ok.lastStartedAt));
1467
+ expect(ok.error).toBeUndefined();
1468
+
1469
+ await script.setSource('export default async () => { throw new Error("pull failed"); };');
1470
+ await runAppSync({ appId });
1471
+
1472
+ const failed = getAppSyncStatus(appId, "issue", "gh")!;
1473
+ expect(failed.ok).toBe(false);
1474
+ expect(failed.error).toContain("pull failed");
1475
+ expect(Object.keys(failed).sort()).toEqual([
1476
+ "created",
1477
+ "error",
1478
+ "lastFinishedAt",
1479
+ "lastStartedAt",
1480
+ "markedStale",
1481
+ "ok",
1482
+ "refreshed",
1483
+ "updated",
1484
+ ]);
1485
+ });
1486
+
1487
+ test("per-source status rides the app payload once a pass has run", async () => {
1488
+ const script = await fixtureScript("payload-status", [ghRecord(1)]);
1489
+ const appId = createSyncApp(issueDefinition(script.id));
1490
+
1491
+ // No pass yet: the payload carries no syncStatus key at all.
1492
+ const before = await request<{ app: object; syncStatus?: unknown }>(`/api/apps/${appId}`);
1493
+ expect(before.status).toBe(200);
1494
+ expect(Object.hasOwn(before.body, "syncStatus")).toBe(false);
1495
+
1496
+ await runAppSync({ appId });
1497
+
1498
+ const after = await request<{
1499
+ app: object;
1500
+ syncStatus?: Record<string, { ok: boolean; created: number; error?: string }>;
1501
+ }>(`/api/apps/${appId}`);
1502
+ expect(after.status).toBe(200);
1503
+ expect(after.body.syncStatus?.["issue:gh"]).toMatchObject({ ok: true, created: 1 });
1504
+ expect(Object.keys(after.body.syncStatus ?? {})).toEqual(["issue:gh"]);
1505
+ });
1506
+
1507
+ test("app-get carries sync status on both result channels", async () => {
1508
+ const script = await fixtureScript("appget-status", [ghRecord(1)]);
1509
+ const appId = createSyncApp(issueDefinition(script.id));
1510
+ await runAppSync({ appId });
1511
+
1512
+ const appGetTool = registeredTool(registerAppGetTool, "app-get");
1513
+ const result = (await appGetTool.handler({ appId }, toolMeta())) as StructuredResult<{
1514
+ syncStatus?: Record<string, { ok?: boolean; created?: number }>;
1515
+ }> & { content: Array<{ type: string; text?: string }> };
1516
+
1517
+ expect(result.structuredContent.syncStatus?.["issue:gh"]).toMatchObject({
1518
+ ok: true,
1519
+ created: 1,
1520
+ });
1521
+ const text = result.content.map((chunk) => chunk.text ?? "").join("\n");
1522
+ expect(text).toContain("Sync status (model:source)");
1523
+ expect(text).toContain("issue:gh");
1524
+ });
1525
+
1526
+ test("a source change invalidates the stale sync status; unrelated edits keep it", async () => {
1527
+ const script = await fixtureScript("status-invalidate", [ghRecord(1)]);
1528
+ const appId = createSyncApp(issueDefinition(script.id));
1529
+ await runAppSync({ appId });
1530
+ expect(getAppSyncStatus(appId, "issue", "gh")).not.toBeNull();
1531
+
1532
+ // An unrelated definition edit keeps the pair's freshness.
1533
+ const unrelated = await request<{ app: object }>(`/api/apps/${appId}`, {
1534
+ method: "PATCH",
1535
+ body: JSON.stringify({
1536
+ definition: { models: { issue: { columns: { note2: { kind: "string" } } } } },
1537
+ }),
1538
+ });
1539
+ expect(unrelated.status).toBe(200);
1540
+ expect(getAppSyncStatus(appId, "issue", "gh")).not.toBeNull();
1541
+
1542
+ // Changing the source's args discards freshness the old config earned:
1543
+ // the status would otherwise claim a pass the new config never ran.
1544
+ const changed = await request<{ app: object }>(`/api/apps/${appId}`, {
1545
+ method: "PATCH",
1546
+ body: JSON.stringify({
1547
+ definition: issueDefinition(script.id, { args: { repo: "owner/other" } }),
1548
+ }),
1549
+ });
1550
+ expect(changed.status).toBe(200);
1551
+ expect(getAppSyncStatus(appId, "issue", "gh")).toBeNull();
1552
+ });
1553
+
1554
+ test("an obsolete in-flight pass cannot resurrect invalidated status", async () => {
1555
+ const script = await fixtureScript("status-race", [ghRecord(1)]);
1556
+ const appId = createSyncApp(issueDefinition(script.id));
1557
+ await runAppSync({ appId });
1558
+ expect(getAppSyncStatus(appId, "issue", "gh")).not.toBeNull();
1559
+
1560
+ await script.setSource(
1561
+ `export default async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return ${JSON.stringify(
1562
+ [ghRecord(1)],
1563
+ )}; };`,
1564
+ );
1565
+ const inFlight = runAppSync({ appId });
1566
+ // The migration deletes the pair's status while the pull is in the air...
1567
+ const changed = await request<{ app: object }>(`/api/apps/${appId}`, {
1568
+ method: "PATCH",
1569
+ body: JSON.stringify({
1570
+ definition: issueDefinition(script.id, { args: { repo: "owner/other" } }),
1571
+ }),
1572
+ });
1573
+ expect(changed.status).toBe(200);
1574
+ expect(getAppSyncStatus(appId, "issue", "gh")).toBeNull();
1575
+
1576
+ // ...and the aborted obsolete pass must NOT write it back.
1577
+ const result = await inFlight;
1578
+ expect(result.ok).toBe(false);
1579
+ expect(result.passes[0]?.error).toContain("changed while the pull was running");
1580
+ expect(getAppSyncStatus(appId, "issue", "gh")).toBeNull();
1581
+ });
1582
+
1583
+ test("a pulled field carrying a known secret is redacted before it lands in a row", async () => {
1584
+ const secret = "fixture-secret-value-0123456789";
1585
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
1586
+ refreshSecretScrubberCache();
1587
+ try {
1588
+ const script = await fixtureScript("secret-field", [
1589
+ ghRecord(1, { title: `deploy key ${secret}` }),
1590
+ ]);
1591
+ const appId = createSyncApp(issueDefinition(script.id));
1592
+
1593
+ const pass = (await runAppSync({ appId })).passes[0]!;
1594
+
1595
+ expect(pass.error).toBeUndefined();
1596
+ const rows = rowsOf(appId);
1597
+ expect(rows[0]?.title).toBe("deploy key [REDACTED:APPS_SYNC_FIXTURE_TOKEN]");
1598
+ expect(JSON.stringify(rows)).not.toContain(secret);
1599
+ } finally {
1600
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
1601
+ refreshSecretScrubberCache();
1602
+ }
1603
+ });
1604
+
1605
+ test("a secret straddling the prompt cap is scrubbed before truncation", async () => {
1606
+ const secret = "fixture-secret-value-0123456789";
1607
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
1608
+ refreshSecretScrubberCache();
1609
+ try {
1610
+ // 990 filler chars put the secret across the 1000-char cap: truncating
1611
+ // first would strand an unrecognizable 10-char prefix in the row.
1612
+ getDb().run("DELETE FROM agent_tasks");
1613
+ createTaskExtended("x".repeat(990) + secret, { agentId: OWNER_AGENT_ID });
1614
+ const appId = createSyncApp(
1615
+ appWith({
1616
+ task: {
1617
+ columns: {
1618
+ taskKey: { kind: "string" },
1619
+ prompt: { kind: "string", source: { of: "pool", field: "prompt" } },
1620
+ },
1621
+ sources: { pool: { connector: "swarm-tasks", joinKey: "taskKey", config: {} } },
1622
+ },
1623
+ }),
1624
+ );
1625
+
1626
+ await runAppSync({ appId });
1627
+
1628
+ const prompt = String(rowsOf(appId, "task", "taskKey")[0]?.prompt);
1629
+ expect(prompt).toHaveLength(1000);
1630
+ expect(prompt).toContain("[REDACTED");
1631
+ expect(prompt).not.toContain(secret.slice(0, 12));
1632
+ } finally {
1633
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
1634
+ refreshSecretScrubberCache();
1635
+ }
1636
+ });
1637
+
1638
+ test("a secret straddling the stderr cap is scrubbed before truncation", async () => {
1639
+ const secret = "fixture-secret-value-0123456789";
1640
+ process.env.APPS_SYNC_FIXTURE_TOKEN = secret;
1641
+ refreshSecretScrubberCache();
1642
+ try {
1643
+ // 480 filler chars put the secret across the 500-char stderr cap.
1644
+ const script = await fixtureScript("stderr-straddle", []);
1645
+ await script.setSource(
1646
+ `export default async () => { console.error("${"x".repeat(480)}" + ${JSON.stringify(secret)}); process.exit(2); };`,
1647
+ );
1648
+ const appId = createSyncApp(issueDefinition(script.id));
1649
+
1650
+ const result = await runAppSync({ appId });
1651
+
1652
+ expect(result.ok).toBe(false);
1653
+ const error = result.passes[0]?.error ?? "";
1654
+ expect(error).toContain("[REDACTED");
1655
+ expect(error).not.toContain(secret.slice(0, 12));
1656
+ } finally {
1657
+ delete process.env.APPS_SYNC_FIXTURE_TOKEN;
1658
+ refreshSecretScrubberCache();
1659
+ }
1660
+ });
1661
+ });
1662
+
1663
+ // ─── Phase 5: the three doors ────────────────────────────────────────────────
1664
+
1665
+ type SyncBody = {
1666
+ ok?: boolean;
1667
+ passes?: SyncPassResult[];
1668
+ error?: string;
1669
+ issues?: Array<{ path: string; message: string }>;
1670
+ };
1671
+
1672
+ const mutableLegacyPolicy = LEGACY_POLICY as unknown as Record<"app.use", LegacyRule>;
1673
+
1674
+ describe("HTTP POST /api/apps/{id}/sync", () => {
1675
+ test("runs every declared pair and answers {ok, passes}", async () => {
1676
+ const script = await fixtureScript("http-sync", [ghRecord(1), ghRecord(2)]);
1677
+ const appId = createSyncApp(issueDefinition(script.id));
1678
+
1679
+ const response = await request<SyncBody>(`/api/apps/${appId}/sync`, {
1680
+ method: "POST",
1681
+ body: JSON.stringify({}),
1682
+ });
1683
+
1684
+ expect(response.status).toBe(200);
1685
+ expect(response.body.ok).toBe(true);
1686
+ expect(response.body.passes).toHaveLength(1);
1687
+ expect(response.body.passes?.[0]).toMatchObject({
1688
+ model: "issue",
1689
+ source: "gh",
1690
+ connector: "script",
1691
+ pulled: 2,
1692
+ created: 2,
1693
+ invokedBy: "operator",
1694
+ });
1695
+ const rows = rowsOf(appId);
1696
+ expect(rows.map((row) => row.issueKey)).toEqual(["1", "2"]);
1697
+ expect(rows[0]).toMatchObject({ source: "gh", stale: false, title: "Issue 1" });
1698
+ expect(typeof rows[0]?.syncedAt).toBe("string");
1699
+ });
1700
+
1701
+ test("narrows to one pair when the body names model and source", async () => {
1702
+ const script = await fixtureScript("http-sync-narrow", [ghRecord(9)]);
1703
+ const appId = createSyncApp(issueDefinition(script.id));
1704
+
1705
+ const response = await request<SyncBody>(`/api/apps/${appId}/sync`, {
1706
+ method: "POST",
1707
+ body: JSON.stringify({ model: "issue", source: "gh" }),
1708
+ });
1709
+
1710
+ expect(response.status).toBe(200);
1711
+ expect(response.body.passes).toHaveLength(1);
1712
+ expect(rowsOf(appId)).toHaveLength(1);
1713
+ });
1714
+
1715
+ test("404s for an unknown app", async () => {
1716
+ const response = await request<{ error: string }>(`/api/apps/${crypto.randomUUID()}/sync`, {
1717
+ method: "POST",
1718
+ body: JSON.stringify({}),
1719
+ });
1720
+
1721
+ expect(response.status).toBe(404);
1722
+ expect(response.body.error).toBe("app not found");
1723
+ });
1724
+
1725
+ test("400s with path-bearing issues when nothing matches", async () => {
1726
+ const script = await fixtureScript("http-sync-nopair", []);
1727
+ const withSource = createSyncApp(issueDefinition(script.id));
1728
+ const sourceless = createSyncApp(
1729
+ appWith({ issue: { columns: ownedIssueColumns() } }),
1730
+ "Sourceless app",
1731
+ );
1732
+
1733
+ const unknownSource = await request<SyncBody>(`/api/apps/${withSource}/sync`, {
1734
+ method: "POST",
1735
+ body: JSON.stringify({ source: "nope" }),
1736
+ });
1737
+ expect(unknownSource.status).toBe(400);
1738
+ expect(unknownSource.body.issues).toEqual([
1739
+ { path: "source", message: 'unknown source "nope" — no model declares it' },
1740
+ ]);
1741
+
1742
+ const unknownModel = await request<SyncBody>(`/api/apps/${withSource}/sync`, {
1743
+ method: "POST",
1744
+ body: JSON.stringify({ model: "ghost" }),
1745
+ });
1746
+ expect(unknownModel.status).toBe(400);
1747
+ expect(unknownModel.body.issues).toEqual([{ path: "model", message: 'unknown model "ghost"' }]);
1748
+
1749
+ const noSources = await request<SyncBody>(`/api/apps/${sourceless}/sync`, {
1750
+ method: "POST",
1751
+ body: JSON.stringify({}),
1752
+ });
1753
+ expect(noSources.status).toBe(400);
1754
+ expect(noSources.body.issues).toEqual([
1755
+ { path: "appId", message: "no model declares a source to sync" },
1756
+ ]);
1757
+ });
1758
+
1759
+ test("403s and never pulls when app.use is denied", async () => {
1760
+ const script = await fixtureScript("http-sync-denied", [ghRecord(1)]);
1761
+ const appId = createSyncApp(issueDefinition(script.id));
1762
+ const original = mutableLegacyPolicy["app.use"];
1763
+ mutableLegacyPolicy["app.use"] = { ...original, evaluate: () => false };
1764
+ try {
1765
+ const response = await request<{ error: string }>(`/api/apps/${appId}/sync`, {
1766
+ method: "POST",
1767
+ body: JSON.stringify({}),
1768
+ });
1769
+ expect(response.status).toBe(403);
1770
+ } finally {
1771
+ mutableLegacyPolicy["app.use"] = original;
1772
+ }
1773
+ expect(rowsOf(appId)).toHaveLength(0);
1774
+ });
1775
+
1776
+ test("accepts a request with no body at all", async () => {
1777
+ const script = await fixtureScript("http-sync-bodyless", [ghRecord(1)]);
1778
+ const appId = createSyncApp(issueDefinition(script.id));
1779
+
1780
+ // No Content-Type, no payload — parseBody must yield {} rather than throw.
1781
+ const response = await fetch(`${base}/api/apps/${appId}/sync`, {
1782
+ method: "POST",
1783
+ headers: { Authorization: `Bearer ${API_KEY}` },
1784
+ });
1785
+ const body = (await response.json()) as SyncBody;
1786
+
1787
+ expect(response.status).toBe(200);
1788
+ expect(body.ok).toBe(true);
1789
+ expect(body.passes).toHaveLength(1);
1790
+ expect(rowsOf(appId)).toHaveLength(1);
1791
+ });
1792
+
1793
+ test("answers 200 with ok:false when a pass fails", async () => {
1794
+ const script = await fixtureScript("http-sync-passfail", []);
1795
+ await script.setSource('export default async () => { throw new Error("door pull failed"); };');
1796
+ const appId = createSyncApp(issueDefinition(script.id));
1797
+
1798
+ const response = await request<SyncBody & { taskId?: string }>(`/api/apps/${appId}/sync`, {
1799
+ method: "POST",
1800
+ body: JSON.stringify({}),
1801
+ });
1802
+
1803
+ // A failed pull is a reported outcome, not a transport error.
1804
+ expect(response.status).toBe(200);
1805
+ expect(response.body.ok).toBe(false);
1806
+ expect(response.body.issues).toBeUndefined();
1807
+ expect(response.body.passes).toHaveLength(1);
1808
+ expect(response.body.passes?.[0]?.error).toContain("door pull failed");
1809
+ expect(Object.hasOwn(response.body, "taskId")).toBe(false);
1810
+ expect(rowsOf(appId)).toHaveLength(0);
1811
+ });
1812
+ });
1813
+
1814
+ describe("sync action kind", () => {
1815
+ test("answers the script-action shape with no taskId key", async () => {
1816
+ const script = await fixtureScript("action-sync", [ghRecord(1)]);
1817
+ const definition = issueDefinition(script.id);
1818
+ (definition as { actions?: unknown }).actions = { refresh: { kind: "sync" } };
1819
+ const appId = createSyncApp(definition);
1820
+
1821
+ const response = await request<{
1822
+ ok: boolean;
1823
+ result: { passes: SyncPassResult[] };
1824
+ durationMs: number;
1825
+ taskId?: string;
1826
+ error?: string;
1827
+ }>(`/api/apps/${appId}/actions/refresh`, {
1828
+ method: "POST",
1829
+ body: JSON.stringify({ input: {} }),
1830
+ });
1831
+
1832
+ expect(response.status).toBe(200);
1833
+ // The zero-UI-change contract: app-surface.tsx branches on taskId FIRST.
1834
+ expect(Object.hasOwn(response.body, "taskId")).toBe(false);
1835
+ expect(response.body.ok).toBe(true);
1836
+ expect(response.body.error).toBeUndefined();
1837
+ expect(response.body.result.passes).toHaveLength(1);
1838
+ expect(response.body.result.passes[0]).toMatchObject({
1839
+ model: "issue",
1840
+ source: "gh",
1841
+ created: 1,
1842
+ });
1843
+ expect(typeof response.body.durationMs).toBe("number");
1844
+ expect(rowsOf(appId)).toHaveLength(1);
1845
+ });
1846
+
1847
+ test("reports a failed pass as ok:false plus a named error, still without taskId", async () => {
1848
+ const script = await fixtureScript("action-sync-fail", []);
1849
+ await script.setSource('export default async () => { throw new Error("pull exploded"); };');
1850
+ const definition = issueDefinition(script.id);
1851
+ (definition as { actions?: unknown }).actions = {
1852
+ refresh: { kind: "sync", model: "issue", source: "gh" },
1853
+ };
1854
+ const appId = createSyncApp(definition);
1855
+
1856
+ const response = await request<{
1857
+ ok: boolean;
1858
+ result: { passes: SyncPassResult[] };
1859
+ error?: string;
1860
+ taskId?: string;
1861
+ }>(`/api/apps/${appId}/actions/refresh`, {
1862
+ method: "POST",
1863
+ body: JSON.stringify({ input: {} }),
1864
+ });
1865
+
1866
+ expect(response.status).toBe(200);
1867
+ expect(Object.hasOwn(response.body, "taskId")).toBe(false);
1868
+ expect(response.body.ok).toBe(false);
1869
+ expect(response.body.error).toContain("issue.gh: ");
1870
+ expect(response.body.error).toContain("pull exploded");
1871
+ expect(response.body.result.passes).toHaveLength(1);
1872
+ });
1873
+
1874
+ test("400s with issues when the action selects a pair the app no longer has", async () => {
1875
+ const script = await fixtureScript("action-sync-detached", [ghRecord(1)]);
1876
+ const definition = issueDefinition(script.id);
1877
+ (definition as { actions?: unknown }).actions = { refresh: { kind: "sync" } };
1878
+ const appId = createSyncApp(definition);
1879
+ // Drop the source behind the action's back — the stored action stays valid.
1880
+ const app = getApp(appId)!;
1881
+ const models = structuredClone(app.definition.models) as Record<string, ModelDef>;
1882
+ delete models.issue?.sources;
1883
+ for (const column of Object.values(models.issue?.columns ?? {})) {
1884
+ delete (column as { source?: unknown }).source;
1885
+ }
1886
+ updateApp(appId, { definition: { ...app.definition, models } });
1887
+
1888
+ const response = await request<SyncBody>(`/api/apps/${appId}/actions/refresh`, {
1889
+ method: "POST",
1890
+ body: JSON.stringify({ input: {} }),
1891
+ });
1892
+
1893
+ expect(response.status).toBe(400);
1894
+ expect(response.body.issues).toEqual([
1895
+ { path: "appId", message: "no model declares a source to sync" },
1896
+ ]);
1897
+ });
1898
+ });
1899
+
1900
+ describe("app-sync MCP tool", () => {
1901
+ test("returns a rendered pass table and the result object", async () => {
1902
+ const script = await fixtureScript("mcp-sync", [ghRecord(1), ghRecord(2)]);
1903
+ const appId = createSyncApp(issueDefinition(script.id));
1904
+
1905
+ const result = (await syncTool.handler({ appId }, toolMeta())) as StructuredResult<{
1906
+ success: boolean;
1907
+ message: string;
1908
+ details: string;
1909
+ ok: boolean;
1910
+ passes: SyncPassResult[];
1911
+ }>;
1912
+
1913
+ expect(result.isError).toBeFalsy();
1914
+ expect(result.structuredContent.success).toBe(true);
1915
+ expect(result.structuredContent.ok).toBe(true);
1916
+ expect(result.structuredContent.passes).toHaveLength(1);
1917
+ expect(result.structuredContent.passes[0]).toMatchObject({
1918
+ model: "issue",
1919
+ source: "gh",
1920
+ created: 2,
1921
+ invokedBy: `agent:${OWNER_AGENT_ID}`,
1922
+ });
1923
+ expect(result.structuredContent.message).toContain("2 created");
1924
+ expect(result.structuredContent.details).toContain("| model | source |");
1925
+ expect(result.structuredContent.details).toContain("| issue | gh |");
1926
+ expect(rowsOf(appId)).toHaveLength(2);
1927
+ });
1928
+
1929
+ test("is an error result when a pass fails or nothing matches", async () => {
1930
+ const script = await fixtureScript("mcp-sync-fail", []);
1931
+ await script.setSource('export default async () => { throw new Error("mcp pull failed"); };');
1932
+ const appId = createSyncApp(issueDefinition(script.id));
1933
+
1934
+ const failed = (await syncTool.handler({ appId }, toolMeta())) as StructuredResult<{
1935
+ success: boolean;
1936
+ details: string;
1937
+ ok: boolean;
1938
+ }>;
1939
+ expect(failed.isError).toBe(true);
1940
+ expect(failed.structuredContent.success).toBe(false);
1941
+ expect(failed.structuredContent.ok).toBe(false);
1942
+ expect(failed.structuredContent.details).toContain("mcp pull failed");
1943
+
1944
+ const nothing = (await syncTool.handler(
1945
+ { appId, source: "nope" },
1946
+ toolMeta(),
1947
+ )) as StructuredResult<{ success: boolean; message: string; details: string }>;
1948
+ expect(nothing.isError).toBe(true);
1949
+ expect(nothing.structuredContent.message).toContain("Cannot sync");
1950
+ expect(nothing.structuredContent.details).toContain('unknown source "nope"');
1951
+
1952
+ const missing = (await syncTool.handler(
1953
+ { appId: crypto.randomUUID() },
1954
+ toolMeta(),
1955
+ )) as StructuredResult<{ success: boolean; message: string }>;
1956
+ expect(missing.isError).toBe(true);
1957
+ expect(missing.structuredContent.message).toContain("not found");
1958
+ });
1959
+ });
1960
+
1961
+ describe("script delete guard", () => {
1962
+ /** A model whose only source binding is the join key — patchable to sourceless. */
1963
+ function guardDefinition(scriptId: string): Definition {
1964
+ return appWith({
1965
+ issue: {
1966
+ columns: { issueKey: { kind: "string" }, note: { kind: "string" } },
1967
+ sources: { gh: { connector: "script", scriptId, joinKey: "issueKey" } },
1968
+ },
1969
+ });
1970
+ }
1971
+
1972
+ async function deleteScriptByName(name: string) {
1973
+ return request<{
1974
+ deleted?: boolean;
1975
+ error?: string;
1976
+ issues?: Array<{ path: string; message: string }>;
1977
+ }>(`/api/scripts/${name}?scope=global`, { method: "DELETE", agentId: LEAD_AGENT_ID });
1978
+ }
1979
+
1980
+ test("409s naming the app and the source path", async () => {
1981
+ const name = scriptName("guard-source");
1982
+ const scriptId = await saveScript({ name, source: "export default async () => ([]);" });
1983
+ const appId = createSyncApp(guardDefinition(scriptId), "Guarded app");
1984
+
1985
+ const blocked = await deleteScriptByName(name);
1986
+
1987
+ expect(blocked.status).toBe(409);
1988
+ expect(blocked.body.error).toBe("script is referenced by an app definition");
1989
+ expect(blocked.body.issues).toEqual([
1990
+ {
1991
+ path: `apps.${appId}`,
1992
+ message: `app "Guarded app" (${appId}) uses this script at models.issue.sources.gh`,
1993
+ },
1994
+ ]);
1995
+ expect(getApp(appId)).not.toBeNull();
1996
+ });
1997
+
1998
+ test("409s when only a script action references it", async () => {
1999
+ const name = scriptName("guard-action");
2000
+ const scriptId = await saveScript({ name, source: "export default async () => ({});" });
2001
+ const definition = appWith({ issue: { columns: { note: { kind: "string" } } } });
2002
+ (definition as { actions?: unknown }).actions = { run: { kind: "script", scriptId } };
2003
+ const appId = createSyncApp(definition, "Action app");
2004
+
2005
+ const blocked = await deleteScriptByName(name);
2006
+
2007
+ expect(blocked.status).toBe(409);
2008
+ expect(blocked.body.issues).toEqual([
2009
+ {
2010
+ path: `apps.${appId}`,
2011
+ message: `app "Action app" (${appId}) uses this script at actions.run`,
2012
+ },
2013
+ ]);
2014
+ });
2015
+
2016
+ test("blocks on a broken definition that still names the script", async () => {
2017
+ const name = scriptName("guard-broken");
2018
+ const scriptId = await saveScript({ name, source: "export default async () => ([]);" });
2019
+ const appId = createSyncApp(guardDefinition(scriptId), "Broken app");
2020
+ // Corrupt the stored definition the way a bad hand-edit would.
2021
+ getDb().run("UPDATE apps SET definition = ? WHERE id = ?", [
2022
+ JSON.stringify({ models: { issue: { sources: { gh: { scriptId } } } }, oops: true }),
2023
+ appId,
2024
+ ]);
2025
+ expect(getApp(appId)?.definitionError).toBeDefined();
2026
+
2027
+ const blocked = await deleteScriptByName(name);
2028
+
2029
+ expect(blocked.status).toBe(409);
2030
+ expect(blocked.body.issues?.[0]?.message).toContain(
2031
+ "uses this script at models.issue.sources.gh",
2032
+ );
2033
+ });
2034
+
2035
+ test("blocks on an unparseable definition that still contains the id", async () => {
2036
+ const name = scriptName("guard-nonjson");
2037
+ const scriptId = await saveScript({ name, source: "export default async () => ([]);" });
2038
+ const appId = createSyncApp(guardDefinition(scriptId), "Unparseable app");
2039
+ // Not JSON at all: decodeApp cannot even parse it, so the tolerant
2040
+ // collector yields nothing and only the raw probe can see the reference.
2041
+ getDb().run("UPDATE apps SET definition = ? WHERE id = ?", [`{not json ${scriptId}`, appId]);
2042
+ expect(getApp(appId)?.definitionError?.[0]?.message).toContain("invalid stored JSON");
2043
+
2044
+ const blocked = await deleteScriptByName(name);
2045
+
2046
+ expect(blocked.status).toBe(409);
2047
+ expect(blocked.body.issues).toEqual([
2048
+ {
2049
+ path: `apps.${appId}`,
2050
+ message: `app "Unparseable app" (${appId}) uses this script at its (unparseable) definition`,
2051
+ },
2052
+ ]);
2053
+ });
2054
+
2055
+ test("the script-delete MCP tool carries the blocking apps in its text channel", async () => {
2056
+ const name = scriptName("guard-mcp");
2057
+ const scriptId = await saveScript({ name, source: "export default async () => ([]);" });
2058
+ const appId = createSyncApp(guardDefinition(scriptId), "MCP guarded app");
2059
+
2060
+ const result = (await scriptDeleteTool.handler(
2061
+ { name, scope: "global" },
2062
+ toolMeta(LEAD_AGENT_ID),
2063
+ )) as StructuredResult<{ success: boolean; message: string; details: string }>;
2064
+
2065
+ expect(result.isError).toBe(true);
2066
+ expect(result.structuredContent.success).toBe(false);
2067
+ expect(result.structuredContent.message).toContain("referenced by an app definition");
2068
+ expect(result.structuredContent.details).toContain(
2069
+ `apps.${appId}: app "MCP guarded app" (${appId}) uses this script at models.issue.sources.gh`,
2070
+ );
2071
+ // Both channels stay consistent — the text channel must carry it too.
2072
+ expect(JSON.stringify(result.content)).toContain("models.issue.sources.gh");
2073
+ expect(getApp(appId)).not.toBeNull();
2074
+ });
2075
+
2076
+ test("succeeds once the source is removed via PATCH", async () => {
2077
+ const name = scriptName("guard-removable");
2078
+ const scriptId = await saveScript({ name, source: "export default async () => ([]);" });
2079
+ const appId = createSyncApp(guardDefinition(scriptId), "Removable app");
2080
+
2081
+ expect((await deleteScriptByName(name)).status).toBe(409);
2082
+
2083
+ const patched = await request<{ app: { definition: { models: Record<string, ModelDef> } } }>(
2084
+ `/api/apps/${appId}`,
2085
+ {
2086
+ method: "PATCH",
2087
+ body: JSON.stringify({ definition: { models: { issue: { sources: { gh: null } } } } }),
2088
+ },
2089
+ );
2090
+ expect(patched.status).toBe(200);
2091
+ // Deleting the last entry leaves an empty map, not an absent key.
2092
+ expect(patched.body.app.definition.models.issue?.sources ?? {}).toEqual({});
2093
+
2094
+ const deleted = await deleteScriptByName(name);
2095
+ expect(deleted.status).toBe(200);
2096
+ expect(deleted.body.deleted).toBe(true);
2097
+ });
2098
+
2099
+ test("still deletes an unreferenced script", async () => {
2100
+ const name = scriptName("guard-free");
2101
+ await saveScript({ name, source: "export default async () => ([]);" });
2102
+
2103
+ const deleted = await deleteScriptByName(name);
2104
+
2105
+ expect(deleted.status).toBe(200);
2106
+ expect(deleted.body.deleted).toBe(true);
2107
+ });
2108
+ });
2109
+
2110
+ // ── Catalog seed scripts (Phase 6) ──────────────────────────────────────────
2111
+
2112
+ function catalogSource(name: string): string {
2113
+ const entry = SEED_SCRIPTS.find((script) => script.name === name);
2114
+ if (!entry) throw new Error(`catalog script "${name}" is not registered in SEED_SCRIPTS`);
2115
+ return entry.source;
2116
+ }
2117
+
2118
+ /** A GitHub issues-endpoint entry, in the API's own snake_case wire shape. */
2119
+ function ghApiIssue(number: number, overrides: Record<string, unknown> = {}) {
2120
+ return {
2121
+ number,
2122
+ id: 1000 + number,
2123
+ title: `Issue ${number}`,
2124
+ state: "open",
2125
+ body: `body ${number}`,
2126
+ user: { login: "ada" },
2127
+ labels: [{ name: "bug" }, { name: "p1" }],
2128
+ comments: 3,
2129
+ html_url: `https://github.com/owner/name/issues/${number}`,
2130
+ created_at: "2026-01-02T03:04:05Z",
2131
+ updated_at: "2026-01-03T03:04:05Z",
2132
+ ...overrides,
2133
+ };
2134
+ }
2135
+
2136
+ /** The same endpoint interleaves pull requests; only PR entries carry `pull_request`. */
2137
+ function ghApiPull(number: number) {
2138
+ return { ...ghApiIssue(number), pull_request: { url: `https://api.github.com/pulls/${number}` } };
2139
+ }
2140
+
2141
+ describe("github-issues-pull", () => {
2142
+ // The script reaches GitHub through ctx.stdlib.fetchJson, which bottoms out in
2143
+ // globalThis.fetch. Stub that (never mock.module — it leaks process-wide) and
2144
+ // hand the script the real stdlib so the fetch layer under test is the real one.
2145
+ const stdlibCtx = { stdlib: { fetchJson: runtimeFetchJson } };
2146
+ let originalFetch: typeof globalThis.fetch;
2147
+ let lastRequest: { url: string; headers: Headers } | undefined;
2148
+ let fetchCalls = 0;
2149
+
2150
+ function stubGithub(body: unknown, status = 200) {
2151
+ globalThis.fetch = (async (input: unknown, init?: { headers?: Record<string, string> }) => {
2152
+ fetchCalls += 1;
2153
+ lastRequest = { url: String(input), headers: new Headers(init?.headers ?? {}) };
2154
+ return new Response(JSON.stringify(body), {
2155
+ status,
2156
+ headers: { "content-type": "application/json" },
2157
+ });
2158
+ }) as unknown as typeof globalThis.fetch;
2159
+ }
2160
+
2161
+ beforeEach(() => {
2162
+ originalFetch = globalThis.fetch;
2163
+ lastRequest = undefined;
2164
+ fetchCalls = 0;
2165
+ });
2166
+
2167
+ afterEach(() => {
2168
+ globalThis.fetch = originalFetch;
2169
+ });
2170
+
2171
+ test("filters out pull requests and projects flat camelCase fields", async () => {
2172
+ stubGithub([ghApiIssue(1), ghApiPull(2), ghApiIssue(3)]);
2173
+
2174
+ const result = (await githubIssuesPull({ repo: "owner/name" }, stdlibCtx)) as {
2175
+ records: Array<{ key: string; fields: Record<string, unknown> }>;
2176
+ complete: boolean;
2177
+ };
2178
+
2179
+ expect(result.records.map((record) => record.key)).toEqual(["1", "3"]);
2180
+ expect(result.records[0]).toEqual({
2181
+ key: "1",
2182
+ fields: {
2183
+ number: 1,
2184
+ id: 1001,
2185
+ title: "Issue 1",
2186
+ state: "open",
2187
+ body: "body 1",
2188
+ userLogin: "ada",
2189
+ labelsCsv: "bug,p1",
2190
+ comments: 3,
2191
+ htmlUrl: "https://github.com/owner/name/issues/1",
2192
+ createdAt: "2026-01-02T03:04:05Z",
2193
+ updatedAt: "2026-01-03T03:04:05Z",
2194
+ },
2195
+ });
2196
+ expect(result.complete).toBe(true);
2197
+ expect(lastRequest?.url).toBe(
2198
+ "https://api.github.com/repos/owner/name/issues?state=open&per_page=100",
2199
+ );
2200
+ expect(lastRequest?.headers.get("accept")).toBe("application/vnd.github+json");
2201
+ expect(lastRequest?.headers.get("user-agent")).toBe("agent-swarm-apps-sync");
2202
+ // The script hands the egress layer a placeholder, never secret material.
2203
+ expect(lastRequest?.headers.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
2204
+ });
2205
+
2206
+ test("forwards state and limit to the issues query", async () => {
2207
+ stubGithub([]);
2208
+
2209
+ await githubIssuesPull({ repo: "owner/name", state: "all", limit: 25 }, stdlibCtx);
2210
+
2211
+ expect(lastRequest?.url).toBe(
2212
+ "https://api.github.com/repos/owner/name/issues?state=all&per_page=25",
2213
+ );
2214
+ });
2215
+
2216
+ test("echoes the connection slug back without otherwise using it", async () => {
2217
+ stubGithub([ghApiIssue(1)]);
2218
+
2219
+ const result = (await githubIssuesPull(
2220
+ { repo: "owner/name", connection: "ghApp" },
2221
+ stdlibCtx,
2222
+ )) as { connection: string };
2223
+
2224
+ expect(result.connection).toBe("ghApp");
2225
+ // Credentials resolve at the egress layer for the run-as identity — naming a
2226
+ // connection changes nothing about the request the script builds.
2227
+ expect(lastRequest?.url).toBe(
2228
+ "https://api.github.com/repos/owner/name/issues?state=open&per_page=100",
2229
+ );
2230
+ expect(lastRequest?.headers.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
2231
+ });
2232
+
2233
+ test("a full raw page reports an incomplete window even after PRs are filtered out", async () => {
2234
+ stubGithub([ghApiIssue(1), ghApiPull(2)]);
2235
+
2236
+ const result = (await githubIssuesPull({ repo: "owner/name", limit: 2 }, stdlibCtx)) as {
2237
+ records: unknown[];
2238
+ complete: boolean;
2239
+ };
2240
+
2241
+ // Two raw entries filled the page even though only one survived filtering.
2242
+ expect(result.records).toHaveLength(1);
2243
+ expect(result.complete).toBe(false);
2244
+ });
2245
+
2246
+ test("a short raw page reports a complete window", async () => {
2247
+ stubGithub([ghApiIssue(1)]);
2248
+
2249
+ const result = (await githubIssuesPull({ repo: "owner/name", limit: 2 }, stdlibCtx)) as {
2250
+ complete: boolean;
2251
+ };
2252
+
2253
+ expect(result.complete).toBe(true);
2254
+ });
2255
+
2256
+ test("returns the full body untruncated for the engine to scrub whole", async () => {
2257
+ stubGithub([ghApiIssue(1, { body: "x".repeat(5000) })]);
2258
+
2259
+ const result = (await githubIssuesPull({ repo: "owner/name" }, stdlibCtx)) as {
2260
+ records: Array<{ fields: { body: string } }>;
2261
+ };
2262
+
2263
+ // Truncating in the script would run BEFORE the engine's scrub and could
2264
+ // split a secret across the cut; the complete value must travel back.
2265
+ expect(result.records[0]?.fields.body).toHaveLength(5000);
2266
+ });
2267
+
2268
+ for (const repo of ["owner", "owner/name/extra", "../name", "owner/..", "/name", "owner/"]) {
2269
+ test(`rejects the malformed repo "${repo}" without a request`, async () => {
2270
+ stubGithub([ghApiIssue(1)]);
2271
+
2272
+ await expect(githubIssuesPull({ repo }, stdlibCtx)).rejects.toThrow("owner/name");
2273
+ expect(fetchCalls).toBe(0);
2274
+ });
2275
+ }
2276
+
2277
+ test("rejects a missing repo as invalid args", async () => {
2278
+ stubGithub([ghApiIssue(1)]);
2279
+
2280
+ await expect(githubIssuesPull({}, stdlibCtx)).rejects.toThrow("invalid args");
2281
+ expect(fetchCalls).toBe(0);
2282
+ });
2283
+
2284
+ test("a non-2xx response throws with GitHub's own message", async () => {
2285
+ stubGithub({ message: "API rate limit exceeded" }, 403);
2286
+
2287
+ // A returned {error} object would exit 0 and reach the engine as a generic
2288
+ // invalid-payload error; throwing preserves the cause via scriptFailure.
2289
+ await expect(githubIssuesPull({ repo: "owner/name" }, stdlibCtx)).rejects.toThrow(
2290
+ "API rate limit exceeded",
2291
+ );
2292
+ });
2293
+ });
2294
+
2295
+ describe("apps-sync catalog registration", () => {
2296
+ for (const name of ["github-issues-pull", "app-sync-run"]) {
2297
+ test(`${name} is registered and typechecks against the live SDK`, () => {
2298
+ const entry = SEED_SCRIPTS.find((script) => script.name === name);
2299
+ expect(entry).toBeDefined();
2300
+ expect(entry?.description.length).toBeGreaterThan(0);
2301
+ expect(entry?.intent.length).toBeGreaterThan(0);
2302
+
2303
+ expect(validateScriptImports(entry?.source ?? "").ok).toBe(true);
2304
+ const typecheck = typecheckScript(entry?.source ?? "");
2305
+ expect(typecheck.ok ? [] : typecheck.diagnostics).toEqual([]);
2306
+ });
2307
+ }
2308
+
2309
+ test("the source script uses placeholder auth and never reads secrets", () => {
2310
+ const source = catalogSource("github-issues-pull");
2311
+
2312
+ expect(source).toContain("Bearer [REDACTED:GITHUB_TOKEN]");
2313
+ expect(source).not.toContain("includeSecrets");
2314
+ expect(source).not.toContain("Redacted.value");
2315
+ // Seeded scripts are typechecked against the LIVE connection registry, which
2316
+ // is empty on a fresh DB — a ctx.api.<slug> reference would fail boot seeding.
2317
+ expect(source).not.toContain("ctx.api.");
2318
+ });
2319
+
2320
+ test("app-sync-run just forwards to the app_sync tool", () => {
2321
+ const source = catalogSource("app-sync-run");
2322
+
2323
+ expect(source).toContain("ctx.swarm.app_sync");
2324
+ });
2325
+
2326
+ test("app-sync-run throws when the sync payload reports failure", async () => {
2327
+ // The bridge answers HTTP 200 with a structured error payload; returning
2328
+ // it would exit 0 and the scheduler would record a successful run.
2329
+ const failing = {
2330
+ swarm: {
2331
+ app_sync: async () => ({
2332
+ success: true,
2333
+ status: 200,
2334
+ data: { success: false, ok: false, error: "pass failed: boom" },
2335
+ }),
2336
+ },
2337
+ };
2338
+ await expect(appSyncRun({ appId: "app-1" }, failing)).rejects.toThrow("pass failed: boom");
2339
+ });
2340
+
2341
+ test("app-sync-run returns the payload when the sync succeeded", async () => {
2342
+ const okCtx = {
2343
+ swarm: {
2344
+ app_sync: async () => ({
2345
+ success: true,
2346
+ status: 200,
2347
+ data: { success: true, ok: true, passes: [] },
2348
+ }),
2349
+ },
2350
+ };
2351
+ const result = (await appSyncRun({ appId: "app-1" }, okCtx)) as { success: boolean };
2352
+ expect(result.success).toBe(true);
2353
+ });
2354
+
2355
+ test("app-sync-run rejects invalid args without calling the tool", async () => {
2356
+ await expect(appSyncRun({}, { swarm: {} })).rejects.toThrow("invalid args");
2357
+ });
2358
+ });
2359
+
2360
+ describe("a sync source against a dummy GitHub through the real sandbox", () => {
2361
+ // The real path end to end: runSavedScriptAsAgent -> sandbox subprocess ->
2362
+ // patched fetch -> a Bun.serve fixture on 127.0.0.1. Nothing reaches GitHub.
2363
+ //
2364
+ // Egress note: the sandbox's network is open, so no allowlist entry is needed
2365
+ // to REACH the fixture. The allowlist governs secret SUBSTITUTION only, and
2366
+ // that is the point of these tests: GITHUB_TOKEN's binding is allowlisted to
2367
+ // api.github.com, so an active binding exists while the fixture host does not
2368
+ // match it — the placeholder must therefore leave the sandbox unsubstituted.
2369
+ const FIXTURE_TOKEN = "ghp_fixture_secret_value_9876543210";
2370
+
2371
+ let fixture: ReturnType<typeof Bun.serve>;
2372
+ let origin = "";
2373
+ let seen: { path: string; authorization: string | null } | undefined;
2374
+ let nextPage: (echo: { authorization: string | null }) => unknown[] = () => [];
2375
+ let releaseStall: (() => void) | undefined;
2376
+
2377
+ /** The shipped catalog script, repointed at the fixture. Same logic, no GitHub. */
2378
+ function fixtureSource(): string {
2379
+ return catalogSource("github-issues-pull").replace("https://api.github.com", origin);
2380
+ }
2381
+
2382
+ function fixtureDefinition(scriptId: string, args: Record<string, unknown>): Definition {
2383
+ return appWith({
2384
+ issue: {
2385
+ columns: {
2386
+ issueKey: { kind: "string" },
2387
+ title: { kind: "string", source: { of: "gh", field: "title" } },
2388
+ userLogin: { kind: "string", source: { of: "gh", field: "userLogin" } },
2389
+ comments: { kind: "number", source: { of: "gh", field: "comments" } },
2390
+ },
2391
+ sources: { gh: { connector: "script", scriptId, joinKey: "issueKey", args } },
2392
+ },
2393
+ });
2394
+ }
2395
+
2396
+ beforeAll(() => {
2397
+ // Mints the default GITHUB_TOKEN credential binding (allowlisted to
2398
+ // api.github.com) for every run-as identity.
2399
+ process.env.GITHUB_TOKEN = FIXTURE_TOKEN;
2400
+ refreshSecretScrubberCache();
2401
+ fixture = Bun.serve({
2402
+ port: 0,
2403
+ hostname: "127.0.0.1",
2404
+ fetch: async (req: Request) => {
2405
+ const url = new URL(req.url);
2406
+ const authorization = req.headers.get("authorization");
2407
+ seen = { path: url.pathname + url.search, authorization };
2408
+ if (url.pathname === "/stall") {
2409
+ await new Promise<void>((resolve) => {
2410
+ releaseStall = resolve;
2411
+ });
2412
+ }
2413
+ return new Response(JSON.stringify(nextPage({ authorization })), {
2414
+ headers: {
2415
+ "content-type": "application/json",
2416
+ // Echo what the fixture actually received back to the caller.
2417
+ "x-echo-authorization": authorization ?? "<none>",
2418
+ },
2419
+ });
2420
+ },
2421
+ });
2422
+ origin = `http://127.0.0.1:${fixture.port}`;
2423
+ });
2424
+
2425
+ afterAll(() => {
2426
+ releaseStall?.();
2427
+ fixture.stop(true);
2428
+ delete process.env.GITHUB_TOKEN;
2429
+ refreshSecretScrubberCache();
2430
+ });
2431
+
2432
+ beforeEach(() => {
2433
+ seen = undefined;
2434
+ nextPage = () => [];
2435
+ });
2436
+
2437
+ test("a successful pull creates rows from the fixture's page", async () => {
2438
+ nextPage = () => [ghApiIssue(1), ghApiPull(2), ghApiIssue(3)];
2439
+ const scriptId = await saveScript({ name: scriptName("fixture-ok"), source: fixtureSource() });
2440
+ const appId = createSyncApp(fixtureDefinition(scriptId, { repo: "owner/name", limit: 100 }));
2441
+
2442
+ const pass = (await runAppSync({ appId })).passes[0]!;
2443
+
2444
+ expect(pass.error).toBeUndefined();
2445
+ expect(pass).toMatchObject({ pulled: 2, created: 2 });
2446
+ expect(pass.staleSweepSkipped).toBeUndefined();
2447
+ expect(seen?.path).toBe("/repos/owner/name/issues?state=open&per_page=100");
2448
+ const rows = rowsOf(appId);
2449
+ expect(rows.map((row) => row.issueKey)).toEqual(["1", "3"]);
2450
+ expect(rows[0]).toMatchObject({
2451
+ issueKey: "1",
2452
+ title: "Issue 1",
2453
+ userLogin: "ada",
2454
+ comments: 3,
2455
+ source: "gh",
2456
+ stale: false,
2457
+ });
2458
+ });
2459
+
2460
+ test("a full page at the limit skips the stale sweep", async () => {
2461
+ const scriptId = await saveScript({
2462
+ name: scriptName("fixture-page"),
2463
+ source: fixtureSource(),
2464
+ });
2465
+ const appId = createSyncApp(fixtureDefinition(scriptId, { repo: "owner/name", limit: 2 }));
2466
+
2467
+ nextPage = () => [ghApiIssue(10)];
2468
+ const first = (await runAppSync({ appId })).passes[0]!;
2469
+ expect(first).toMatchObject({ created: 1 });
2470
+ expect(first.staleSweepSkipped).toBeUndefined();
2471
+
2472
+ // A raw page of exactly `limit` entries, one of which is a PR: the window is
2473
+ // incomplete even though only one record survives filtering.
2474
+ nextPage = () => [ghApiIssue(11), ghApiPull(12)];
2475
+ const second = (await runAppSync({ appId })).passes[0]!;
2476
+
2477
+ expect(second).toMatchObject({
2478
+ pulled: 1,
2479
+ created: 1,
2480
+ markedStale: 0,
2481
+ staleSweepSkipped: true,
2482
+ });
2483
+ expect(second.warnings.some((warning) => warning.includes("stale sweep skipped"))).toBe(true);
2484
+ // The row the incomplete window never saw must survive.
2485
+ expect(rowsOf(appId).find((row) => row.issueKey === "10")?.stale).toBe(false);
2486
+ });
2487
+
2488
+ test("a fetch that outlives its timeout fails the pass with zero row churn", async () => {
2489
+ const name = scriptName("fixture-timeout");
2490
+ const scriptId = await saveScript({ name, source: fixtureSource() });
2491
+ const appId = createSyncApp(fixtureDefinition(scriptId, { repo: "owner/name", limit: 100 }));
2492
+ nextPage = () => [ghApiIssue(30)];
2493
+ await runAppSync({ appId });
2494
+ const before = rowSnapshot(appId);
2495
+
2496
+ // Bare fetch, not ctx.stdlib.fetch: the stdlib wrapper retries three times,
2497
+ // so an external abort there costs three stalled attempts instead of one.
2498
+ await saveScript({
2499
+ name,
2500
+ source: `export default async () => {
2501
+ await fetch("${origin}/stall", { signal: AbortSignal.timeout(300) });
2502
+ return [];
2503
+ };`,
2504
+ });
2505
+ const result = await runAppSync({ appId });
2506
+
2507
+ expect(result.ok).toBe(false);
2508
+ expect(result.passes[0]?.error).toContain("TimeoutError");
2509
+ expect(result.passes[0]).toMatchObject({ pulled: 0, created: 0, updated: 0, markedStale: 0 });
2510
+ expect(rowSnapshot(appId)).toBe(before);
2511
+ releaseStall?.();
2512
+ });
2513
+
2514
+ test("the fixture never sees the token — the unresolved placeholder header is dropped", async () => {
2515
+ // An ACTIVE binding for this identity — otherwise the assertion is vacuous.
2516
+ const bindings = await buildScriptCredentialBindingsWithFailures({ agentId: LEAD_AGENT_ID });
2517
+ const github = bindings.egressSecrets.find((secret) => secret.configKey === "GITHUB_TOKEN");
2518
+ expect(github?.value).toBe(FIXTURE_TOKEN);
2519
+ expect(github?.allowedHosts).toEqual(["api.github.com"]);
2520
+
2521
+ // The fixture echoes the Authorization header it received into the record it
2522
+ // serves, so the projected row carries whatever actually left the sandbox.
2523
+ nextPage = (echo) => [ghApiIssue(40, { title: echo.authorization })];
2524
+ const scriptId = await saveScript({
2525
+ name: scriptName("fixture-auth"),
2526
+ source: fixtureSource(),
2527
+ });
2528
+ const appId = createSyncApp(fixtureDefinition(scriptId, { repo: "owner/name", limit: 100 }));
2529
+
2530
+ const result = await runAppSync({ appId });
2531
+
2532
+ // The fixture host is not allowlisted for the binding, so the placeholder
2533
+ // header is DROPPED before egress: no auth reaches it — never the token.
2534
+ expect(seen?.authorization ?? null).toBeNull();
2535
+ expect(rowsOf(appId)[0]?.title ?? null).toBeNull();
2536
+ expect(JSON.stringify(rowsOf(appId))).not.toContain(FIXTURE_TOKEN);
2537
+ expect(JSON.stringify(result)).not.toContain(FIXTURE_TOKEN);
2538
+ expect(JSON.stringify(getAppSyncStatus(appId, "issue", "gh"))).not.toContain(FIXTURE_TOKEN);
2539
+ });
2540
+ });