@desplega.ai/agent-swarm 1.129.0 → 1.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
package/src/http/apps.ts CHANGED
@@ -6,9 +6,11 @@ import {
6
6
  type AppQueryDef,
7
7
  type AppValidationIssue,
8
8
  applyAppDefinitionPatch,
9
+ ColumnKindSchema,
9
10
  isIso8601Date,
10
11
  type ModelDef,
11
12
  parseAppDefinition,
13
+ SORTABLE_SYSTEM_DATE_COLUMNS,
12
14
  SYSTEM_COLUMN_KINDS,
13
15
  } from "../apps/definition";
14
16
  import {
@@ -41,6 +43,7 @@ import {
41
43
  listApps,
42
44
  updateApp,
43
45
  } from "../apps/store";
46
+ import { collectAppSyncStatus, runAppSync } from "../apps/sync";
44
47
  import {
45
48
  getAppUserConfigValues,
46
49
  isReservedUserConfigKey,
@@ -56,6 +59,7 @@ import {
56
59
  rollbackApp,
57
60
  snapshotApp,
58
61
  } from "../apps/version";
62
+ import { normalizeAssetKey } from "../assets/key";
59
63
  import { getAgentById, getAppVersion, getAppVersions, getLeadAgent } from "../be/db";
60
64
  import { getScriptById } from "../be/scripts/db";
61
65
  import { getSavedScriptOwnerAgentId, runSavedScriptAsAgent } from "../be/scripts/run-saved";
@@ -63,6 +67,7 @@ import { resolveTemplate } from "../prompts/resolver";
63
67
  import type { RbacPrincipal, RbacResource } from "../rbac";
64
68
  import { can } from "../rbac";
65
69
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
70
+ import { AgentTaskStatusSchema } from "../types";
66
71
  import { getRequestAuth } from "../utils/request-auth-context";
67
72
  import { scrubObject } from "../utils/secret-scrubber";
68
73
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
@@ -88,8 +93,142 @@ const rowParamsSchema = z.object({
88
93
  rowId: z.string().min(1),
89
94
  });
90
95
  const valuesSchema = z.record(z.string(), z.unknown());
96
+
97
+ const AppValidationIssueSchema = z.object({ path: z.string(), message: z.string() });
98
+
99
+ /**
100
+ * Any JSON value. Used for the genuinely free-form-but-ALWAYS-PRESENT payloads
101
+ * below. `z.unknown()` would be wrong for those: it accepts `undefined`, so the
102
+ * generator drops the property from the spec's `required` list even though
103
+ * every write path emits it (same reasoning as `argsJsonSchemaValueSchema` in
104
+ * src/http/scripts.ts).
105
+ */
106
+ const JsonValueSchema = z.union([
107
+ z.record(z.string(), z.unknown()),
108
+ z.array(z.unknown()),
109
+ z.string(),
110
+ z.number(),
111
+ z.boolean(),
112
+ z.null(),
113
+ ]);
114
+
115
+ /**
116
+ * Mirrors `AppRecord` (src/apps/store.ts). `definition` is free-form JSON: it's
117
+ * either a validated `AppDefinition & { schemaVersion }` or — when
118
+ * `definitionError` is set — the raw, possibly malformed, stored value (which
119
+ * `decodeApp` leaves as the un-parsed JSON *string* when the column itself is
120
+ * corrupt).
121
+ */
122
+ const AppRecordSchema = z.object({
123
+ id: z.string(),
124
+ name: z.string(),
125
+ description: z.string().optional(),
126
+ definition: JsonValueSchema,
127
+ definitionError: z.array(AppValidationIssueSchema).optional(),
128
+ createdAt: z.string(),
129
+ updatedAt: z.string(),
130
+ });
131
+
132
+ /**
133
+ * Mirrors what `listApps()` (src/apps/store.ts) actually builds. Its declared
134
+ * return type is `Omit<AppRecord, "definition">`, but the row mapper also never
135
+ * emits `definitionError` — so this is deliberately not derived from
136
+ * `AppRecordSchema`.
137
+ */
138
+ const AppSummarySchema = z.object({
139
+ id: z.string(),
140
+ name: z.string(),
141
+ description: z.string().optional(),
142
+ createdAt: z.string(),
143
+ updatedAt: z.string(),
144
+ });
145
+
146
+ /**
147
+ * Mirrors `AppVersion` (src/be/db.ts). `snapshot` is a free-form JSON blob —
148
+ * normally an `AppSnapshot`, but `rollbackSnapshot()` proves historical rows can
149
+ * hold anything. Unlike `AppRecord.definition` it stays `z.unknown()` rather
150
+ * than `JsonValueSchema`: `AppVersion.snapshot` is declared `unknown` in db.ts,
151
+ * so narrowing it would need an unsafe cast at both call sites. The cost is
152
+ * that the generated spec lists `snapshot` as optional even though
153
+ * `rowToAppVersion` always emits it.
154
+ */
155
+ const AppVersionSchema = z.object({
156
+ id: z.string(),
157
+ appId: z.string(),
158
+ version: z.number(),
159
+ snapshot: z.unknown(),
160
+ changedByAgentId: z.string().optional(),
161
+ createdAt: z.string(),
162
+ });
163
+
164
+ /** Mirrors `AppRow` (src/apps/row-store.ts): fixed system columns plus arbitrary model-defined columns. */
165
+ const AppRowSchema = z.looseObject({
166
+ id: z.string(),
167
+ createdAt: z.string(),
168
+ updatedAt: z.string(),
169
+ createdBy: z.string().optional(),
170
+ updatedBy: z.string().optional(),
171
+ });
172
+
173
+ /** Mirrors `SyncPassResult` (src/apps/sync.ts): one (model x source) sync pass outcome. */
174
+ const SyncPassResultSchema = z.object({
175
+ model: z.string(),
176
+ source: z.string(),
177
+ connector: z.enum(["script", "swarm-tasks"]),
178
+ pulled: z.number(),
179
+ created: z.number(),
180
+ updated: z.number(),
181
+ refreshed: z.number(),
182
+ unchanged: z.number(),
183
+ markedStale: z.number(),
184
+ staleSweepSkipped: z.boolean().optional(),
185
+ warnings: z.array(z.string()),
186
+ durationMs: z.number(),
187
+ invokedBy: z.string().optional(),
188
+ error: z.string().optional(),
189
+ skipped: z.literal(true).optional(),
190
+ alreadyRunning: z.literal(true).optional(),
191
+ });
192
+
193
+ /** Mirrors `AppSyncStatus` (src/apps/sync.ts): per-(model:source) pass freshness. */
194
+ const AppSyncStatusSchema = z.object({
195
+ lastStartedAt: z.string(),
196
+ lastFinishedAt: z.string(),
197
+ ok: z.boolean(),
198
+ created: z.number(),
199
+ updated: z.number(),
200
+ refreshed: z.number(),
201
+ markedStale: z.number(),
202
+ error: z.string().optional(),
203
+ });
204
+
205
+ /** Mirrors `UserConfigValues` (src/apps/user-config.ts). */
206
+ const UserConfigValuesSchema = z.record(
207
+ z.string(),
208
+ z.union([z.string(), z.number(), z.boolean(), z.null()]),
209
+ );
210
+
211
+ /**
212
+ * Wire mirror of one `userConfig` field declaration (`UserConfigFieldSchema`,
213
+ * src/apps/definition.ts). The definition module's own `UserConfigSchema` is
214
+ * deliberately NOT reused here: its `required: z.never().optional()` guard has
215
+ * no OpenAPI representation and makes the spec generator throw
216
+ * (`Unknown zod object type … {type: "never"}`), which breaks both
217
+ * `bun run docs:openapi` and `GET /openapi.json`. `required` is unobservable
218
+ * on the wire anyway — the key can only ever be absent.
219
+ */
220
+ const AppUserConfigFieldSchema = z.object({
221
+ kind: ColumnKindSchema,
222
+ default: z.union([z.string(), z.number(), z.boolean()]).optional(),
223
+ enum: z.array(z.string()).optional(),
224
+ label: z.string().optional(),
225
+ });
226
+
227
+ /** Mirrors an app definition's `userConfig` declaration map. */
228
+ const AppUserConfigSchema = z.record(AppNameSchema, AppUserConfigFieldSchema);
229
+
91
230
  const appWriteResponseSchema = z.object({
92
- app: z.unknown(),
231
+ app: AppRecordSchema,
93
232
  migration: AppMigrationReportSchema,
94
233
  });
95
234
  const userConfigBodySchema = z.object({ values: z.record(z.string(), z.unknown()) }).strict();
@@ -104,7 +243,10 @@ const getUserConfigRoute = route({
104
243
  tags: ["Apps"],
105
244
  params: appParamsSchema,
106
245
  responses: {
107
- 200: { description: "Merged user configuration" },
246
+ 200: {
247
+ description: "Merged user configuration",
248
+ schema: z.object({ values: UserConfigValuesSchema, schema: AppUserConfigSchema }),
249
+ },
108
250
  403: { description: "Permission denied" },
109
251
  404: { description: "App not found" },
110
252
  409: { description: "App definition needs repair" },
@@ -123,7 +265,10 @@ const putUserConfigRoute = route({
123
265
  params: appParamsSchema,
124
266
  body: userConfigBodySchema,
125
267
  responses: {
126
- 200: { description: "Stored user configuration" },
268
+ 200: {
269
+ description: "Stored user configuration",
270
+ schema: z.object({ values: UserConfigValuesSchema, schema: AppUserConfigSchema }),
271
+ },
127
272
  400: { description: "Invalid user configuration" },
128
273
  403: { description: "Permission denied" },
129
274
  413: { description: "Request exceeds 64 KB or serialized values exceed 16 KB" },
@@ -139,7 +284,12 @@ const listAppsRoute = route({
139
284
  pattern: ["api", "apps"],
140
285
  summary: "List apps",
141
286
  tags: ["Apps"],
142
- responses: { 200: { description: "App summaries without definitions" } },
287
+ responses: {
288
+ 200: {
289
+ description: "App summaries without definitions",
290
+ schema: z.object({ apps: z.array(AppSummarySchema) }),
291
+ },
292
+ },
143
293
  // App summaries remain list-level until a future policy can filter them per app.
144
294
  rbac: { ungated: "app summaries are list-level; per-app filtering is future work" },
145
295
  });
@@ -157,7 +307,7 @@ const createAppRoute = route({
157
307
  forceElementBreak: ForceElementBreakSchema.optional(),
158
308
  }),
159
309
  responses: {
160
- 201: { description: "Created app" },
310
+ 201: { description: "Created app", schema: z.object({ app: AppRecordSchema }) },
161
311
  400: { description: "Invalid app definition" },
162
312
  403: { description: "Permission denied" },
163
313
  },
@@ -172,7 +322,10 @@ const listAppVersionsRoute = route({
172
322
  tags: ["Apps"],
173
323
  params: appParamsSchema,
174
324
  responses: {
175
- 200: { description: "App definition versions" },
325
+ 200: {
326
+ description: "App definition versions",
327
+ schema: z.object({ versions: z.array(AppVersionSchema) }),
328
+ },
176
329
  404: { description: "App not found" },
177
330
  },
178
331
  rbac: { permission: "app.manage" },
@@ -186,7 +339,7 @@ const getAppVersionRoute = route({
186
339
  tags: ["Apps"],
187
340
  params: appVersionParamsSchema,
188
341
  responses: {
189
- 200: { description: "App definition version" },
342
+ 200: { description: "App definition version", schema: z.object({ version: AppVersionSchema }) },
190
343
  404: { description: "App or version not found" },
191
344
  },
192
345
  rbac: { permission: "app.manage" },
@@ -223,7 +376,14 @@ const getAppRoute = route({
223
376
  tags: ["Apps"],
224
377
  params: appParamsSchema,
225
378
  responses: {
226
- 200: { description: "App including its definition" },
379
+ 200: {
380
+ description: "App including its definition",
381
+ schema: z.object({
382
+ app: AppRecordSchema,
383
+ // Present only once at least one declared source has completed a pass.
384
+ syncStatus: z.record(z.string(), AppSyncStatusSchema).optional(),
385
+ }),
386
+ },
227
387
  403: { description: "Permission denied" },
228
388
  404: { description: "App not found" },
229
389
  },
@@ -286,7 +446,7 @@ const deleteAppRoute = route({
286
446
  tags: ["Apps"],
287
447
  params: appParamsSchema,
288
448
  responses: {
289
- 200: { description: "App deleted" },
449
+ 200: { description: "App deleted", schema: z.object({ ok: z.literal(true) }) },
290
450
  403: { description: "Permission denied" },
291
451
  404: { description: "App not found" },
292
452
  },
@@ -302,7 +462,7 @@ const createRowRoute = route({
302
462
  params: modelParamsSchema,
303
463
  body: z.object({ values: valuesSchema }),
304
464
  responses: {
305
- 201: { description: "Created row" },
465
+ 201: { description: "Created row", schema: z.object({ row: AppRowSchema }) },
306
466
  400: { description: "Invalid row values" },
307
467
  403: { description: "Permission denied" },
308
468
  404: { description: "App or model not found" },
@@ -319,7 +479,7 @@ const bulkCreateRowsRoute = route({
319
479
  params: modelParamsSchema,
320
480
  body: z.object({ rows: z.array(z.object({ values: valuesSchema })).max(500) }),
321
481
  responses: {
322
- 200: { description: "Created rows" },
482
+ 200: { description: "Created rows", schema: z.object({ rows: z.array(AppRowSchema) }) },
323
483
  400: { description: "Invalid row values" },
324
484
  403: { description: "Permission denied" },
325
485
  404: { description: "App or model not found" },
@@ -339,7 +499,10 @@ const listRowsRoute = route({
339
499
  limit: z.coerce.number().int().positive().max(1000).optional(),
340
500
  }),
341
501
  responses: {
342
- 200: { description: "Filtered app model rows" },
502
+ 200: {
503
+ description: "Filtered app model rows",
504
+ schema: z.object({ rows: z.array(AppRowSchema), total: z.number() }),
505
+ },
343
506
  400: { description: "Invalid filter or sort" },
344
507
  403: { description: "Permission denied" },
345
508
  404: { description: "App or model not found" },
@@ -355,7 +518,7 @@ const getRowRoute = route({
355
518
  tags: ["Apps"],
356
519
  params: rowParamsSchema,
357
520
  responses: {
358
- 200: { description: "App model row" },
521
+ 200: { description: "App model row", schema: z.object({ row: AppRowSchema }) },
359
522
  403: { description: "Permission denied" },
360
523
  404: { description: "App, model, or row not found" },
361
524
  },
@@ -371,7 +534,7 @@ const patchRowRoute = route({
371
534
  params: rowParamsSchema,
372
535
  body: z.object({ values: valuesSchema }),
373
536
  responses: {
374
- 200: { description: "Updated row" },
537
+ 200: { description: "Updated row", schema: z.object({ row: AppRowSchema }) },
375
538
  400: { description: "Invalid row values" },
376
539
  403: { description: "Permission denied" },
377
540
  404: { description: "App, model, or row not found" },
@@ -387,7 +550,7 @@ const deleteRowRoute = route({
387
550
  tags: ["Apps"],
388
551
  params: rowParamsSchema,
389
552
  responses: {
390
- 200: { description: "Row deleted" },
553
+ 200: { description: "Row deleted", schema: z.object({ ok: z.literal(true) }) },
391
554
  403: { description: "Permission denied" },
392
555
  404: { description: "App, model, or row not found" },
393
556
  },
@@ -402,7 +565,7 @@ const runNamedQueryRoute = route({
402
565
  tags: ["Apps"],
403
566
  params: z.object({ id: z.string().min(1), name: AppNameSchema }),
404
567
  responses: {
405
- 200: { description: "Named query rows" },
568
+ 200: { description: "Named query rows", schema: z.object({ rows: z.array(AppRowSchema) }) },
406
569
  400: { description: "Missing or invalid named query parameters" },
407
570
  403: { description: "Permission denied" },
408
571
  409: { description: "App definition needs repair" },
@@ -411,6 +574,40 @@ const runNamedQueryRoute = route({
411
574
  rbac: { permission: "app.use" },
412
575
  });
413
576
 
577
+ /**
578
+ * `runActionRoute`'s 200 shape branches on the action kind: script actions
579
+ * echo the script's exit result, task actions echo the created task's id and
580
+ * status.
581
+ */
582
+ const runActionScriptResultSchema = z.object({
583
+ ok: z.boolean(),
584
+ result: z.unknown(),
585
+ stdout: z.string(),
586
+ error: z.string().optional(),
587
+ durationMs: z.number(),
588
+ });
589
+ const runActionTaskResultSchema = z.object({
590
+ ok: z.literal(true),
591
+ taskId: z.string(),
592
+ status: AgentTaskStatusSchema,
593
+ });
594
+ /**
595
+ * Sync actions deliberately reuse the SCRIPT-action shape minus `stdout`/
596
+ * `taskId` (see the handler comment): `result` carries the pass list instead
597
+ * of a script's return value.
598
+ */
599
+ const runActionSyncResultSchema = z.object({
600
+ ok: z.boolean(),
601
+ result: z.object({ passes: z.array(SyncPassResultSchema) }),
602
+ error: z.string().optional(),
603
+ durationMs: z.number(),
604
+ });
605
+ const runActionResultSchema = z.union([
606
+ runActionScriptResultSchema,
607
+ runActionTaskResultSchema,
608
+ runActionSyncResultSchema,
609
+ ]);
610
+
414
611
  const runActionRoute = route({
415
612
  method: "post",
416
613
  path: "/api/apps/{id}/actions/{name}",
@@ -421,7 +618,7 @@ const runActionRoute = route({
421
618
  params: z.object({ id: z.string().min(1), name: AppNameSchema }),
422
619
  body: z.object({ input: z.record(z.string(), z.unknown()).optional() }),
423
620
  responses: {
424
- 200: { description: "Action invoked" },
621
+ 200: { description: "Action invoked", schema: runActionResultSchema },
425
622
  400: { description: "Invalid action input or stale script reference" },
426
623
  403: { description: "Permission denied" },
427
624
  409: { description: "App definition needs repair" },
@@ -430,6 +627,29 @@ const runActionRoute = route({
430
627
  rbac: { permission: "app.use" },
431
628
  });
432
629
 
630
+ const syncAppRoute = route({
631
+ method: "post",
632
+ path: "/api/apps/{id}/sync",
633
+ pattern: ["api", "apps", null, "sync"],
634
+ summary: "Sync an app's declared sources",
635
+ description:
636
+ "Runs every (model x source) pair the body selects. Each pass pulls outside the row mutation lock and reconciles inside it.",
637
+ tags: ["Apps"],
638
+ params: appParamsSchema,
639
+ body: z.object({ model: AppNameSchema.optional(), source: AppNameSchema.optional() }),
640
+ responses: {
641
+ 200: {
642
+ description: "Sync passes",
643
+ schema: z.object({ ok: z.boolean(), passes: z.array(SyncPassResultSchema) }),
644
+ },
645
+ 400: { description: "Unknown model or source, or no model declares a source" },
646
+ 403: { description: "Permission denied" },
647
+ 409: { description: "App definition needs repair" },
648
+ 404: { description: "App not found" },
649
+ },
650
+ rbac: { permission: "app.use" },
651
+ });
652
+
433
653
  /**
434
654
  * RBAC-gate an app operation and resolve the acting principal's stable actor id
435
655
  * (`user:<id>`, `agent:<id>`, or `operator`) for row provenance. Returns null
@@ -501,6 +721,17 @@ function definitionNeedsRepair(res: ServerResponse, app: ReturnType<typeof getAp
501
721
  return true;
502
722
  }
503
723
 
724
+ /**
725
+ * One `error` string for a sync result, or undefined when every pass succeeded.
726
+ * Each failed pair is named so a multi-pair sync says which one broke; the pass
727
+ * list in `result.passes` carries the rest.
728
+ */
729
+ function syncPassError(sync: Awaited<ReturnType<typeof runAppSync>>): string | undefined {
730
+ const failed = sync.passes.filter((pass) => pass.error !== undefined);
731
+ if (failed.length === 0) return undefined;
732
+ return failed.map((pass) => `${pass.model}.${pass.source}: ${pass.error}`).join("; ");
733
+ }
734
+
504
735
  function snapshotFailure(res: ServerResponse): void {
505
736
  jsonError(res, "failed to snapshot app", 500);
506
737
  }
@@ -670,6 +901,12 @@ interface RowFilter {
670
901
  value: unknown;
671
902
  }
672
903
 
904
+ /**
905
+ * Ad-hoc REST `filter.<col>` stays model-columns-only on purpose: system fields
906
+ * (including the sync envelope) are filterable through named queries, which
907
+ * validate kinds at definition-write time. Widening this loosely-typed
908
+ * query-string path would give two filter vocabularies with different rules.
909
+ */
673
910
  function filtersFromQuery(
674
911
  queryParams: URLSearchParams,
675
912
  model: ModelDef,
@@ -737,8 +974,7 @@ export function applyQuery(
737
974
  rowValue(a, sort.column),
738
975
  rowValue(b, sort.column),
739
976
  sort.dir,
740
- sort.column === "createdAt" ||
741
- sort.column === "updatedAt" ||
977
+ SORTABLE_SYSTEM_DATE_COLUMNS.has(sort.column) ||
742
978
  (Object.hasOwn(model.columns, sort.column) &&
743
979
  model.columns[sort.column]!.kind === "date"),
744
980
  );
@@ -772,27 +1008,24 @@ export async function handleApps(
772
1008
  const definition = parseAppDefinition(parsed.body.definition, {
773
1009
  resolveApp: getApp,
774
1010
  writerAgentId: myAgentId ?? null,
1011
+ writerIsUser: getRequestAuth(req)?.kind === "user",
775
1012
  });
776
1013
  if (!definition.success) {
777
1014
  invalidDefinition(res, definition.issues);
778
1015
  return true;
779
1016
  }
780
- json(
781
- res,
782
- {
783
- app: createApp({
784
- name: parsed.body.name,
785
- description: parsed.body.description,
786
- definition: definition.definition,
787
- }),
788
- },
789
- 201,
790
- );
1017
+ createAppRoute.respond(res, 201, {
1018
+ app: createApp({
1019
+ name: parsed.body.name,
1020
+ description: parsed.body.description,
1021
+ definition: definition.definition,
1022
+ }),
1023
+ });
791
1024
  return true;
792
1025
  }
793
1026
 
794
1027
  if (listAppsRoute.match(req.method, pathSegments)) {
795
- json(res, { apps: listApps() });
1028
+ listAppsRoute.respond(res, 200, { apps: listApps() });
796
1029
  return true;
797
1030
  }
798
1031
 
@@ -804,7 +1037,9 @@ export async function handleApps(
804
1037
  jsonError(res, "app not found", 404);
805
1038
  return true;
806
1039
  }
807
- json(res, { versions: getAppVersions(parsed.params.id).map(decodeAppVersion) });
1040
+ listAppVersionsRoute.respond(res, 200, {
1041
+ versions: getAppVersions(parsed.params.id).map(decodeAppVersion),
1042
+ });
808
1043
  return true;
809
1044
  }
810
1045
 
@@ -821,7 +1056,7 @@ export async function handleApps(
821
1056
  jsonError(res, "app version not found", 404);
822
1057
  return true;
823
1058
  }
824
- json(res, { version: decodeAppVersion(version) });
1059
+ getAppVersionRoute.respond(res, 200, { version: decodeAppVersion(version) });
825
1060
  return true;
826
1061
  }
827
1062
 
@@ -837,8 +1072,10 @@ export async function handleApps(
837
1072
  migration: parsed.body.migration,
838
1073
  forceElementBreak: parsed.body.forceElementBreak,
839
1074
  changedByAgentId: myAgentId,
1075
+ writerAgentId: myAgentId ?? null,
1076
+ writerIsUser: getRequestAuth(req)?.kind === "user",
840
1077
  });
841
- json(res, { app: rolledBack.app, migration: rolledBack.migration });
1078
+ rollbackAppRoute.respond(res, 200, { app: rolledBack.app, migration: rolledBack.migration });
842
1079
  } catch (error) {
843
1080
  if (error instanceof AppRollbackAppNotFoundError) {
844
1081
  jsonError(res, "app not found", 404);
@@ -874,7 +1111,7 @@ export async function handleApps(
874
1111
  parsed.body.rows.map((row) => row.values),
875
1112
  { actor },
876
1113
  );
877
- json(res, { rows });
1114
+ bulkCreateRowsRoute.respond(res, 200, { rows });
878
1115
  } catch (error) {
879
1116
  if (!invalidRows(res, error)) throw error;
880
1117
  }
@@ -897,7 +1134,7 @@ export async function handleApps(
897
1134
  parsed.body.values,
898
1135
  { actor },
899
1136
  );
900
- json(res, { row }, 201);
1137
+ createRowRoute.respond(res, 201, { row });
901
1138
  } catch (error) {
902
1139
  if (!invalidRows(res, error)) throw error;
903
1140
  }
@@ -925,8 +1162,7 @@ export async function handleApps(
925
1162
  extra !== undefined ||
926
1163
  !column ||
927
1164
  (dir !== "asc" && dir !== "desc") ||
928
- (column !== "createdAt" &&
929
- column !== "updatedAt" &&
1165
+ (!SORTABLE_SYSTEM_DATE_COLUMNS.has(column) &&
930
1166
  (!Object.hasOwn(resolved.model.columns, column) ||
931
1167
  resolved.model.columns[column]!.hidden === true))
932
1168
  ) {
@@ -945,8 +1181,7 @@ export async function handleApps(
945
1181
  rowValue(a, column),
946
1182
  rowValue(b, column),
947
1183
  dir,
948
- column === "createdAt" ||
949
- column === "updatedAt" ||
1184
+ SORTABLE_SYSTEM_DATE_COLUMNS.has(column) ||
950
1185
  (Object.hasOwn(resolved.model.columns, column) &&
951
1186
  resolved.model.columns[column]!.kind === "date"),
952
1187
  );
@@ -954,7 +1189,7 @@ export async function handleApps(
954
1189
  });
955
1190
  }
956
1191
  const total = rows.length;
957
- json(res, { rows: rows.slice(0, parsed.query.limit ?? 200), total });
1192
+ listRowsRoute.respond(res, 200, { rows: rows.slice(0, parsed.query.limit ?? 200), total });
958
1193
  return true;
959
1194
  }
960
1195
 
@@ -968,7 +1203,7 @@ export async function handleApps(
968
1203
  jsonError(res, "row not found", 404);
969
1204
  return true;
970
1205
  }
971
- json(res, { row });
1206
+ getRowRoute.respond(res, 200, { row });
972
1207
  return true;
973
1208
  }
974
1209
 
@@ -993,7 +1228,7 @@ export async function handleApps(
993
1228
  jsonError(res, "row not found", 404);
994
1229
  return true;
995
1230
  }
996
- json(res, { row });
1231
+ patchRowRoute.respond(res, 200, { row });
997
1232
  } catch (error) {
998
1233
  if (!invalidRows(res, error)) throw error;
999
1234
  }
@@ -1017,7 +1252,7 @@ export async function handleApps(
1017
1252
  jsonError(res, "row not found", 404);
1018
1253
  return true;
1019
1254
  }
1020
- json(res, { ok: true });
1255
+ deleteRowRoute.respond(res, 200, { ok: true });
1021
1256
  return true;
1022
1257
  }
1023
1258
 
@@ -1039,7 +1274,7 @@ export async function handleApps(
1039
1274
  }
1040
1275
  const model = app.definition.models[query.model]!;
1041
1276
  try {
1042
- json(res, {
1277
+ runNamedQueryRoute.respond(res, 200, {
1043
1278
  rows: applyQuery(
1044
1279
  listAppRows(app.id, query.model),
1045
1280
  query,
@@ -1121,8 +1356,9 @@ export async function handleApps(
1121
1356
  : output.runtimeError
1122
1357
  ? `${output.runtimeError.name}: ${output.runtimeError.message}`
1123
1358
  : (output.error ?? `Script exited with code ${output.exitCode}`);
1124
- json(
1359
+ runActionRoute.respond(
1125
1360
  res,
1361
+ 200,
1126
1362
  scrubObject({
1127
1363
  ok,
1128
1364
  result: output.result,
@@ -1134,6 +1370,36 @@ export async function handleApps(
1134
1370
  return true;
1135
1371
  }
1136
1372
 
1373
+ if (action.kind === "sync") {
1374
+ const startedAt = Date.now();
1375
+ const sync = await runAppSync({
1376
+ appId: app.id,
1377
+ ...(action.model === undefined ? {} : { model: action.model }),
1378
+ ...(action.source === undefined ? {} : { source: action.source }),
1379
+ invokedBy: actor,
1380
+ });
1381
+ const durationMs = Date.now() - startedAt;
1382
+ if (sync.issues && sync.issues.length > 0) {
1383
+ json(res, { error: "invalid sync action", issues: sync.issues }, 400);
1384
+ return true;
1385
+ }
1386
+ // Deliberately the SCRIPT-action response shape, minus `taskId`:
1387
+ // app-surface.tsx branches on `taskId` first, so omitting it gives the
1388
+ // dashboard running -> ok/error + refetchAll() with zero UI changes.
1389
+ const error = syncPassError(sync);
1390
+ runActionRoute.respond(
1391
+ res,
1392
+ 200,
1393
+ scrubObject({
1394
+ ok: sync.ok,
1395
+ result: { passes: sync.passes },
1396
+ ...(error === undefined ? {} : { error }),
1397
+ durationMs,
1398
+ }),
1399
+ );
1400
+ return true;
1401
+ }
1402
+
1137
1403
  const lead = action.agentId ? null : getLeadAgent();
1138
1404
  const taskPrompt = resolveTemplate("task.app.action", {
1139
1405
  prompt: action.prompt,
@@ -1144,9 +1410,40 @@ export async function handleApps(
1144
1410
  const task = createTaskWithSiblingAwareness(taskPrompt.text, {
1145
1411
  source: "api",
1146
1412
  agentId: action.agentId ?? lead?.id,
1413
+ // App-spawned tasks group under the app's asset namespace so a
1414
+ // swarm-tasks source can pull them back via config.assetKey.
1415
+ key: normalizeAssetKey(`shared/app:${app.id}/action:${parsed.params.name}/`),
1147
1416
  ...(actor.startsWith("user:") ? { requestedByUserId: actor.slice("user:".length) } : {}),
1148
1417
  });
1149
- json(res, { ok: true, taskId: task.id, status: task.status });
1418
+ runActionRoute.respond(res, 200, { ok: true, taskId: task.id, status: task.status });
1419
+ return true;
1420
+ }
1421
+
1422
+ if (syncAppRoute.match(req.method, pathSegments)) {
1423
+ if (enforceContentLengthCap(req, res, MAX_APP_ROW_BODY_BYTES) === BODY_TOO_LARGE) return true;
1424
+ const parsed = await syncAppRoute.parse(req, res, pathSegments, queryParams);
1425
+ if (!parsed) return true;
1426
+ const actor = authorizeAppUse(req, res, myAgentId, parsed.params.id);
1427
+ if (!actor) return true;
1428
+
1429
+ const app = getApp(parsed.params.id);
1430
+ if (!app) {
1431
+ jsonError(res, "app not found", 404);
1432
+ return true;
1433
+ }
1434
+ if (definitionNeedsRepair(res, app)) return true;
1435
+
1436
+ const sync = await runAppSync({
1437
+ appId: app.id,
1438
+ ...(parsed.body.model === undefined ? {} : { model: parsed.body.model }),
1439
+ ...(parsed.body.source === undefined ? {} : { source: parsed.body.source }),
1440
+ invokedBy: actor,
1441
+ });
1442
+ if (sync.issues && sync.issues.length > 0) {
1443
+ json(res, { error: "nothing to sync", issues: sync.issues }, 400);
1444
+ return true;
1445
+ }
1446
+ syncAppRoute.respond(res, 200, scrubObject({ ok: sync.ok, passes: sync.passes }));
1150
1447
  return true;
1151
1448
  }
1152
1449
 
@@ -1185,6 +1482,7 @@ export async function handleApps(
1185
1482
  currentAppId: parsed.params.id,
1186
1483
  resolveApp: getApp,
1187
1484
  writerAgentId: myAgentId ?? null,
1485
+ writerIsUser: getRequestAuth(req)?.kind === "user",
1188
1486
  existingDefinition: existing.definition,
1189
1487
  });
1190
1488
  if (!definition.success) {
@@ -1232,7 +1530,7 @@ export async function handleApps(
1232
1530
  jsonError(res, "app not found", 404);
1233
1531
  return true;
1234
1532
  }
1235
- json(res, { app, migration });
1533
+ patchAppRoute.respond(res, 200, { app, migration });
1236
1534
  return true;
1237
1535
  }
1238
1536
 
@@ -1262,6 +1560,7 @@ export async function handleApps(
1262
1560
  currentAppId: parsed.params.id,
1263
1561
  resolveApp: getApp,
1264
1562
  writerAgentId: myAgentId ?? null,
1563
+ writerIsUser: getRequestAuth(req)?.kind === "user",
1265
1564
  existingDefinition: existing.definition,
1266
1565
  });
1267
1566
  if (!parsedDefinition.success) {
@@ -1315,7 +1614,7 @@ export async function handleApps(
1315
1614
  jsonError(res, "app not found", 404);
1316
1615
  return true;
1317
1616
  }
1318
- json(res, { app, migration });
1617
+ updateAppRoute.respond(res, 200, { app, migration });
1319
1618
  return true;
1320
1619
  }
1321
1620
 
@@ -1342,7 +1641,7 @@ export async function handleApps(
1342
1641
  jsonError(res, "app not found", 404);
1343
1642
  return true;
1344
1643
  }
1345
- json(res, { ok: true });
1644
+ deleteAppRoute.respond(res, 200, { ok: true });
1346
1645
  return true;
1347
1646
  }
1348
1647
 
@@ -1355,7 +1654,13 @@ export async function handleApps(
1355
1654
  jsonError(res, "app not found", 404);
1356
1655
  return true;
1357
1656
  }
1358
- json(res, { app });
1657
+ // Per-source freshness for the runtime payload — present only once a
1658
+ // declared source has completed at least one pass.
1659
+ const syncStatus = collectAppSyncStatus(app.id);
1660
+ getAppRoute.respond(res, 200, {
1661
+ app,
1662
+ ...(Object.keys(syncStatus).length > 0 ? { syncStatus } : {}),
1663
+ });
1359
1664
  return true;
1360
1665
  }
1361
1666
 
@@ -1372,7 +1677,7 @@ export async function handleApps(
1372
1677
  const schema = app.definition.userConfig ?? {};
1373
1678
  const owner = resolveHttpFavoriteOwner(req, myAgentId);
1374
1679
  const stored = owner ? getAppUserConfigValues(app.id, owner.scope) : {};
1375
- json(res, { values: mergeUserConfigValues(schema, stored), schema });
1680
+ getUserConfigRoute.respond(res, 200, { values: mergeUserConfigValues(schema, stored), schema });
1376
1681
  return true;
1377
1682
  }
1378
1683
 
@@ -1418,7 +1723,10 @@ export async function handleApps(
1418
1723
  return true;
1419
1724
  }
1420
1725
  upsertAppUserConfigValues(app.id, owner.scope, parsed.body.values);
1421
- json(res, { values: mergeUserConfigValues(schema, parsed.body.values), schema });
1726
+ putUserConfigRoute.respond(res, 200, {
1727
+ values: mergeUserConfigValues(schema, parsed.body.values),
1728
+ schema,
1729
+ });
1422
1730
  return true;
1423
1731
  }
1424
1732