@desplega.ai/agent-swarm 2.4.1 → 2.4.3

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 (153) hide show
  1. package/dist/{acp-adapter-zvzr538d.js → acp-adapter-1885dpr7.js} +4 -4
  2. package/dist/{actions-jhtqdw69.js → actions-pn76n32q.js} +18 -18
  3. package/dist/{amp-adapter-bd84rhs2.js → amp-adapter-af84rx1d.js} +3 -3
  4. package/dist/{app-kreq1gwg.js → app-6sv8mhzm.js} +3 -3
  5. package/dist/{assistant-3k22sbsq.js → assistant-sc2tf825.js} +20 -20
  6. package/dist/{boot-reembed-f6z923ny.js → boot-reembed-gvxjs2py.js} +1 -1
  7. package/dist/{boot-reembed-e53x6b9v.js → boot-reembed-y3m0ydzj.js} +1 -1
  8. package/dist/{boot-scrub-sweep-0538vsb7.js → boot-scrub-sweep-1j9mk05f.js} +3 -3
  9. package/dist/{boot-seeds-6wxqkfk2.js → boot-seeds-37sp626b.js} +4 -4
  10. package/dist/{claude-adapter-gfts5044.js → claude-adapter-rp0jtnwr.js} +4 -4
  11. package/dist/{claude-managed-setup-h00hsgtz.js → claude-managed-setup-rdt248k7.js} +1 -1
  12. package/dist/{claude-sdk-session-56besmfz.js → claude-sdk-session-53rcw5we.js} +9822 -9749
  13. package/dist/{cli-3tnpj2bh.js → cli-038fga5k.js} +2 -2
  14. package/dist/{cli-kmqgmcnb.js → cli-0dxxwfkt.js} +8 -8
  15. package/dist/{cli-471qk6s2.js → cli-1012xb7j.js} +28 -28
  16. package/dist/{cli-sv5gb0yb.js → cli-1eszr343.js} +1 -1
  17. package/dist/{cli-7225gwj7.js → cli-1m17dc9d.js} +1 -1
  18. package/dist/{cli-xftrvxag.js → cli-1ma335fz.js} +3 -3
  19. package/dist/{cli-s48yvzb8.js → cli-1n9gdg56.js} +4 -4
  20. package/dist/{cli-ash6xxk7.js → cli-1xsraagq.js} +3 -3
  21. package/dist/{cli-xbzvet96.js → cli-36ckw0wx.js} +5 -4
  22. package/dist/{cli-mhywgrrz.js → cli-4sydckyj.js} +1 -1
  23. package/dist/{cli-3r448dqn.js → cli-4yef8ghv.js} +3 -3
  24. package/dist/{cli-g0hgtz4p.js → cli-58vwn100.js} +3 -3
  25. package/dist/{cli-pw7v69vp.js → cli-625tb7aq.js} +1 -1
  26. package/dist/{cli-m2qgh5sv.js → cli-6aen3ezb.js} +1 -1
  27. package/dist/{cli-z071fb95.js → cli-6eyncg0y.js} +6 -6
  28. package/dist/{cli-0bh477zn.js → cli-73ezfhha.js} +120 -12
  29. package/dist/{cli-x5mq1fyq.js → cli-74c1wpm0.js} +3 -3
  30. package/dist/{cli-b1dfr1m4.js → cli-7a6p4e65.js} +1 -1
  31. package/dist/{cli-fpmsqq59.js → cli-836ehxmt.js} +1 -1
  32. package/dist/{cli-1pa3rz69.js → cli-8vverzf7.js} +1 -1
  33. package/dist/{cli-rdq7tzwm.js → cli-8y99kfey.js} +11 -11
  34. package/dist/{cli-z1ew8vbq.js → cli-aa3tb29r.js} +10 -5
  35. package/dist/{cli-6ncjvx55.js → cli-aypbg9r2.js} +1 -1
  36. package/dist/{cli-3kh4kpqy.js → cli-bxrqvpg5.js} +6 -6
  37. package/dist/{cli-0hyhdkdk.js → cli-cebvbc9t.js} +4 -1
  38. package/dist/{cli-p6argxh9.js → cli-dm8p2dpf.js} +7 -7
  39. package/dist/{cli-jh0qcrga.js → cli-e4d78xsv.js} +1 -1
  40. package/dist/{cli-7a91w22y.js → cli-e8xyjvfc.js} +1 -1
  41. package/dist/{cli-axh29tyr.js → cli-f0p0f6ja.js} +10 -10
  42. package/dist/{cli-w9g4sz81.js → cli-gf2850q7.js} +1 -1
  43. package/dist/{cli-bn5kevnf.js → cli-gx0j2khs.js} +4 -4
  44. package/dist/{cli-gf0kfg81.js → cli-h0mgxann.js} +5 -5
  45. package/dist/{cli-kr2pmfqx.js → cli-kgnkyabk.js} +85 -71
  46. package/dist/{cli-hqhzg1qq.js → cli-mzkwkbdy.js} +1 -1
  47. package/dist/{cli-k9z4zd4p.js → cli-npfe9t1s.js} +2 -2
  48. package/dist/{cli-syhq378v.js → cli-p0qerd2r.js} +1 -1
  49. package/dist/{cli-sjyetj21.js → cli-pygdsgh8.js} +1 -1
  50. package/dist/{cli-2wag8973.js → cli-qeaq3j6t.js} +1 -1
  51. package/dist/{cli-nx462e4v.js → cli-qp2m4btv.js} +3 -3
  52. package/dist/{cli-tbhwrxpt.js → cli-s9rr4gr2.js} +5 -5
  53. package/dist/{cli-fz9kewbh.js → cli-sds5zzs8.js} +1 -1
  54. package/dist/{cli-vy0fay6k.js → cli-tkf4x6ha.js} +15 -15
  55. package/dist/{cli-mfe7f7b5.js → cli-v87dr6e3.js} +2 -2
  56. package/dist/{cli-rqqsfz30.js → cli-vcgscaan.js} +91 -21
  57. package/dist/{cli-zfg9enh9.js → cli-vfha21c9.js} +6 -1
  58. package/dist/{cli-3p8fqcnh.js → cli-vq9gqarq.js} +1 -1
  59. package/dist/{cli-a4gvwttf.js → cli-wk28hzqn.js} +2 -2
  60. package/dist/{cli-p0rakbrk.js → cli-zvbb4n0e.js} +6 -5
  61. package/dist/cli.js +17 -17
  62. package/dist/{codex-adapter-p6rmm7vv.js → codex-adapter-2zrmab9b.js} +2 -2
  63. package/dist/{codex-hook-x7nejyrr.js → codex-hook-e2he7aca.js} +2 -2
  64. package/dist/{codex-session-runner-8vd5t9t0.js → codex-session-runner-kbkz62vc.js} +2 -2
  65. package/dist/{commands-7ems2p13.js → commands-hd574th6.js} +3 -3
  66. package/dist/{controller-m263fg43.js → controller-pz5mcfg4.js} +3 -3
  67. package/dist/{controllers-connect-bg33ae53.js → controllers-connect-5rgq2hfk.js} +19 -19
  68. package/dist/{cursor-adapter-cz6nm6mj.js → cursor-adapter-mgaprq0j.js} +2 -2
  69. package/dist/{db-w505dwa8.js → db-6nf1207q.js} +6 -2
  70. package/dist/{db-ys2rbdft.js → db-h06dxp05.js} +3 -3
  71. package/dist/{dispatcher-0s0mckqr.js → dispatcher-zthpy4m1.js} +17 -17
  72. package/dist/{dsh-adapter-3n3net33.js → dsh-adapter-64dhw0j7.js} +2 -2
  73. package/dist/{e2b-thdcxtjd.js → e2b-16d6ch39.js} +1 -1
  74. package/dist/{e2b-7am6tmq6.js → e2b-zn0b4w5w.js} +1 -1
  75. package/dist/{external-channel-5b23fv8z.js → external-channel-x01t7ke6.js} +2 -2
  76. package/dist/{grok-adapter-q9fghe5q.js → grok-adapter-h21jv594.js} +5 -5
  77. package/dist/handlers-2qjw434w.js +39 -0
  78. package/dist/{heartbeat-tww6yx1w.js → heartbeat-zzfksckz.js} +20 -20
  79. package/dist/{hook-pvmh9b6m.js → hook-3xfqhh2s.js} +2 -2
  80. package/dist/{hook-dx0pkvar.js → hook-z4zhzj1f.js} +10 -5
  81. package/dist/{http-k2ejbt05.js → http-q813tmya.js} +121 -75
  82. package/dist/{index-2z710v4x.js → index-220tk9bw.js} +9 -9
  83. package/dist/index-d0g18bax.js +41 -0
  84. package/dist/{index-dgz61nff.js → index-m0a4ydqj.js} +17 -17
  85. package/dist/{index-h2ek08d6.js → index-wbbs14c5.js} +17 -17
  86. package/dist/{index-x5aktmp8.js → index-x8xbyn1t.js} +1 -1
  87. package/dist/{keepalive-nhvpw10z.js → keepalive-y5t1my7m.js} +3 -3
  88. package/dist/{lead-jb6sf69z.js → lead-2xwtq66b.js} +30 -30
  89. package/dist/{maintenance-agakc519.js → maintenance-ybfsaedv.js} +1 -1
  90. package/dist/{oauth-refresh-sweep-4dcbhtn0.js → oauth-refresh-sweep-909jmn12.js} +2 -2
  91. package/dist/{onboard-4n2kwa4v.js → onboard-xn6fkkys.js} +3 -3
  92. package/dist/{opencode-adapter-3s7ax02e.js → opencode-adapter-x4tqzhkh.js} +3 -3
  93. package/dist/{otel-impl-tfa12080.js → otel-impl-e6mn0z13.js} +1 -1
  94. package/dist/{pi-mono-adapter-ytp7gz8b.js → pi-mono-adapter-mcfkn9vf.js} +3 -3
  95. package/dist/{pricing-refresh-3cqx3f3x.js → pricing-refresh-ygd362ep.js} +3 -3
  96. package/dist/{rbac-roles-9fae5rnv.js → rbac-roles-c9axvfnz.js} +3 -3
  97. package/dist/{rbac-roles-y6zbfzve.js → rbac-roles-mb470p4e.js} +2 -2
  98. package/dist/{render-v2-emawjvc4.js → render-v2-g0306nh1.js} +3 -3
  99. package/dist/{resume-zhgts7h7.js → resume-v315hnq6.js} +5 -5
  100. package/dist/{session-access-cm823e06.js → session-access-2zg285a1.js} +4 -4
  101. package/dist/{setup-tkp99w1e.js → setup-a4mtf02k.js} +1 -1
  102. package/dist/{work-objects-f722h263.js → work-objects-9fr0fgtn.js} +21 -21
  103. package/dist/{worker-p7f336rq.js → worker-qae22jeb.js} +30 -30
  104. package/openapi.json +393 -1
  105. package/package.json +3 -3
  106. package/src/apps/catalog.generated.json +3 -2
  107. package/src/apps/definition.ts +152 -4
  108. package/src/apps/row-store.ts +19 -7
  109. package/src/apps/schema-migrate.ts +14 -6
  110. package/src/apps/script-types.ts +1 -0
  111. package/src/apps/sync.ts +20 -19
  112. package/src/be/db/tasks/read.ts +112 -0
  113. package/src/be/db.ts +9 -4
  114. package/src/be/migrations/231_slack_tasks_last_updated_index.sql +5 -0
  115. package/src/be/swarm-config-guard.ts +15 -0
  116. package/src/github/handlers.ts +6 -5
  117. package/src/hooks/hook.ts +2 -1
  118. package/src/http/apps.ts +15 -2
  119. package/src/http/status.ts +49 -43
  120. package/src/http/tasks.ts +47 -0
  121. package/src/providers/acp-targets.ts +1 -1
  122. package/src/providers/amp-adapter.ts +1 -1
  123. package/src/providers/codex-adapter.ts +4 -4
  124. package/src/providers/opencode-adapter.ts +2 -2
  125. package/src/providers/pi-mono-extension.ts +2 -1
  126. package/src/telemetry.ts +15 -3
  127. package/src/tests/app-row-tools.test.ts +25 -0
  128. package/src/tests/apps-json-column.test.ts +611 -0
  129. package/src/tests/apps-script-types.test.ts +2 -0
  130. package/src/tests/apps-sync-engine.test.ts +42 -0
  131. package/src/tests/claude-stop-hook.test.ts +47 -0
  132. package/src/tests/codex-adapter.test.ts +42 -0
  133. package/src/tests/db-task-read-contract.test.ts +4 -0
  134. package/src/tests/db-task-write-contract.test.ts +2 -0
  135. package/src/tests/docs-index-footprint.test.js +2 -2
  136. package/src/tests/env-flag.test.ts +15 -1
  137. package/src/tests/github-handlers-cancel-config.test.ts +29 -8
  138. package/src/tests/github-handlers-inline-comments.test.ts +9 -2
  139. package/src/tests/runner-harness-telemetry-env.test.ts +6 -0
  140. package/src/tests/script-connections.test.ts +2 -0
  141. package/src/tests/skill-script-refs.test.ts +63 -0
  142. package/src/tests/slack-render-v2-query-plans.test.ts +147 -0
  143. package/src/tests/status.test.ts +158 -9
  144. package/src/tests/task-lineage.test.ts +180 -0
  145. package/src/tests/telemetry-init.test.ts +62 -5
  146. package/src/tests/user-token-rest-auth.test.ts +38 -0
  147. package/src/tools/app-rows.ts +5 -0
  148. package/src/utils/env-flag.ts +9 -0
  149. package/templates/skills/apps/SKILL.md +4 -2
  150. package/templates/skills/apps/content.md +4 -2
  151. package/templates/skills/code-quality/content.md +9 -14
  152. package/dist/handlers-mqrym347.js +0 -39
  153. package/dist/index-pqfwh9nn.js +0 -41
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  import { listScriptConnections } from "../be/script-connections";
3
3
  import { getScriptById } from "../be/scripts/db";
4
4
  import { getSavedScriptOwnerAgentId } from "../be/scripts/run-saved";
5
+ import { APPS_JSON_COLUMN_MAX_BYTES_BOUNDS } from "../be/swarm-config-guard";
5
6
  import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
6
7
  import catalog from "./catalog.generated.json";
7
8
  import {
@@ -16,7 +17,13 @@ export const AppNameSchema = z.string().regex(/^[a-z][a-zA-Z0-9_]{0,39}$/, {
16
17
  message: "must start with a lowercase letter and contain only letters, numbers, or underscores",
17
18
  });
18
19
 
19
- export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum"]);
20
+ export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum", "json"]);
21
+
22
+ /**
23
+ * Column kinds allowed where values must stay scalar (user-config fields and
24
+ * element props) — `json` is model-column only.
25
+ */
26
+ export const ScalarColumnKindSchema = ColumnKindSchema.exclude(["json"]);
20
27
 
21
28
  const ISO_8601_PREFIX = /^\d{4}-\d{2}-\d{2}(?:T.*)?$/;
22
29
 
@@ -24,6 +31,77 @@ export function isIso8601Date(value: string): boolean {
24
31
  return ISO_8601_PREFIX.test(value) && !Number.isNaN(Date.parse(value));
25
32
  }
26
33
 
34
+ /** Default serialized-size cap for json column values AND defaults: 256 KB. */
35
+ export const DEFAULT_JSON_COLUMN_MAX_BYTES = 256 * 1024;
36
+
37
+ /**
38
+ * The serialized-size cap for json column values and defaults, read on every
39
+ * call — never at module load — so a stored `APPS_JSON_COLUMN_MAX_BYTES`
40
+ * override, injected over the process env on config reload, applies without
41
+ * a restart. Mirrors the retention knobs: a non-digit or out-of-range value
42
+ * falls back to the default, and the config API rejects writes outside the
43
+ * same `APPS_JSON_COLUMN_MAX_BYTES_BOUNDS`.
44
+ */
45
+ export function jsonColumnMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
46
+ const raw = env.APPS_JSON_COLUMN_MAX_BYTES?.trim();
47
+ if (!raw || !/^\d+$/.test(raw)) return DEFAULT_JSON_COLUMN_MAX_BYTES;
48
+ const value = Number(raw);
49
+ const { min, max } = APPS_JSON_COLUMN_MAX_BYTES_BOUNDS;
50
+ return Number.isSafeInteger(value) && value >= min && value <= max
51
+ ? value
52
+ : DEFAULT_JSON_COLUMN_MAX_BYTES;
53
+ }
54
+
55
+ /** The cap as a message label: whole KB when it divides evenly (`256KB`), else bytes. */
56
+ export function jsonColumnSizeLabel(bytes: number = jsonColumnMaxBytes()): string {
57
+ return bytes % 1024 === 0 ? `${bytes / 1024}KB` : `${bytes} bytes`;
58
+ }
59
+
60
+ export type JsonColumnValueIssue = "unserializable" | "non-finite" | "oversized";
61
+
62
+ /**
63
+ * Why a value cannot live in a json column — not JSON-serializable, carrying a
64
+ * non-finite number anywhere inside it, or over `jsonColumnMaxBytes()` once
65
+ * serialized — or `null` when it fits. The single implementation of the cap:
66
+ * it serializes exactly once, flagging non-finite numbers on that same walk
67
+ * (a lenient parser turns `1e400` into `Infinity`, which `JSON.stringify`
68
+ * would quietly store as `null`), and each caller maps the reason to its own
69
+ * message.
70
+ */
71
+ export function jsonColumnValueIssue(value: unknown): JsonColumnValueIssue | null {
72
+ let nonFinite = false;
73
+ let serialized: string | undefined;
74
+ try {
75
+ serialized = JSON.stringify(value, (_key, entry: unknown) => {
76
+ if (typeof entry === "number" && !Number.isFinite(entry)) nonFinite = true;
77
+ return entry;
78
+ });
79
+ } catch {
80
+ return "unserializable"; // circular reference / BigInt / throwing toJSON
81
+ }
82
+ if (serialized === undefined) return "unserializable";
83
+ if (nonFinite) return "non-finite";
84
+ return Buffer.byteLength(serialized, "utf8") > jsonColumnMaxBytes() ? "oversized" : null;
85
+ }
86
+
87
+ /**
88
+ * The one per-kind check for a NON-null column value, shared by the row store,
89
+ * schema migrations and the sync engine so a value the store accepts is a
90
+ * value a migration directive or a projection accepts too. Null policy
91
+ * (required / hidden / nulling a projection) stays with each caller.
92
+ */
93
+ export function isValidColumnValue(
94
+ column: Pick<ColumnDef, "kind" | "enum">,
95
+ value: unknown,
96
+ ): boolean {
97
+ if (column.kind === "string") return typeof value === "string";
98
+ if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
99
+ if (column.kind === "boolean") return typeof value === "boolean";
100
+ if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
101
+ if (column.kind === "json") return jsonColumnValueIssue(value) === null;
102
+ return typeof value === "string" && Boolean(column.enum?.includes(value));
103
+ }
104
+
27
105
  export const SourceTransformSchema = z.enum(["slug", "lower", "upper", "cents", "date-parse"]);
28
106
 
29
107
  /** Column kind each transform may target — the sync engine projects into it. */
@@ -49,11 +127,23 @@ const ColumnDefSchema = z
49
127
  required: z.boolean().optional(),
50
128
  enum: z.array(z.string()).optional(),
51
129
  index: z.boolean().optional(),
52
- default: z.union([z.string(), z.number(), z.boolean()]).optional(),
130
+ default: z
131
+ .union([
132
+ z.string(),
133
+ z.number(),
134
+ z.boolean(),
135
+ z.record(z.string(), z.unknown()),
136
+ z.array(z.unknown()),
137
+ ])
138
+ .optional(),
53
139
  hidden: z.boolean().optional(),
54
140
  source: ColumnSourceBindingSchema.optional(),
55
141
  })
56
142
  .superRefine((column, ctx) => {
143
+ if (column.kind === "json" && column.index) {
144
+ ctx.addIssue({ code: "custom", path: ["index"], message: "json columns cannot be indexed" });
145
+ }
146
+
57
147
  if (column.kind === "enum") {
58
148
  if (!column.enum || column.enum.length === 0) {
59
149
  ctx.addIssue({ code: "custom", path: ["enum"], message: "enum values are required" });
@@ -71,6 +161,24 @@ const ColumnDefSchema = z
71
161
  }
72
162
 
73
163
  if (column.default === undefined) return;
164
+ if (column.kind === "json") {
165
+ // Structural checks only; the size cap is write-time (`jsonColumnDefaultSizeIssues`).
166
+ const issue = jsonColumnValueIssue(column.default);
167
+ if (issue === "unserializable") {
168
+ ctx.addIssue({
169
+ code: "custom",
170
+ path: ["default"],
171
+ message: "json column default must be JSON-serializable",
172
+ });
173
+ } else if (issue === "non-finite") {
174
+ ctx.addIssue({
175
+ code: "custom",
176
+ path: ["default"],
177
+ message: "json column default contains a non-finite number",
178
+ });
179
+ }
180
+ return;
181
+ }
74
182
  const valid =
75
183
  (column.kind === "string" && typeof column.default === "string") ||
76
184
  (column.kind === "number" &&
@@ -94,7 +202,7 @@ const ColumnDefSchema = z
94
202
 
95
203
  const UserConfigFieldSchema = z
96
204
  .object({
97
- kind: ColumnKindSchema,
205
+ kind: ScalarColumnKindSchema,
98
206
  default: z.union([z.string(), z.number(), z.boolean()]).optional(),
99
207
  enum: z.array(z.string()).optional(),
100
208
  label: z.string().optional(),
@@ -276,7 +384,7 @@ const AppPageSchema = z
276
384
 
277
385
  const ElementPropDefSchema = z
278
386
  .object({
279
- kind: ColumnKindSchema,
387
+ kind: ScalarColumnKindSchema,
280
388
  required: z.boolean().optional(),
281
389
  enum: z.array(z.string()).min(1).optional(),
282
390
  default: z.union([z.string(), z.number(), z.boolean()]).optional(),
@@ -439,6 +547,14 @@ export const AppDefinitionSchema = z
439
547
  });
440
548
  continue;
441
549
  }
550
+ if (columnDefinition.kind === "json") {
551
+ ctx.addIssue({
552
+ code: "custom",
553
+ path: ["queries", queryName, "filter", column],
554
+ message: "json columns cannot be filtered",
555
+ });
556
+ continue;
557
+ }
442
558
  if (typeof value === "object") continue;
443
559
  const valid =
444
560
  (columnDefinition.kind === "string" && typeof value === "string") ||
@@ -469,6 +585,12 @@ export const AppDefinitionSchema = z
469
585
  path: ["queries", queryName, "sort", "column"],
470
586
  message: `unknown or hidden column "${sortColumn}"`,
471
587
  });
588
+ } else if (sortColumn && model.columns[sortColumn]?.kind === "json") {
589
+ ctx.addIssue({
590
+ code: "custom",
591
+ path: ["queries", queryName, "sort", "column"],
592
+ message: "json columns cannot be sorted",
593
+ });
472
594
  }
473
595
  }
474
596
  });
@@ -836,6 +958,31 @@ function syncActionIssues(
836
958
  return [{ path: `actions.${name}`, message: "no model declares a source to sync" }];
837
959
  }
838
960
 
961
+ /**
962
+ * The serialized-size cap on json column defaults, applied on definition
963
+ * WRITES only. It lives outside `AppDefinitionSchema` on purpose: the schema
964
+ * also decodes persisted definitions (`decodeAppDefinition`, the row store's
965
+ * model lookup), and a cap lowered after an app was saved must not turn that
966
+ * app into one that "needs repair" on every read. Writing the definition
967
+ * again re-applies the current cap. An oversized default would otherwise be
968
+ * accepted only to fail every row create that relies on it.
969
+ */
970
+ function jsonColumnDefaultSizeIssues(definition: AppDefinition): AppValidationIssue[] {
971
+ const issues: AppValidationIssue[] = [];
972
+ for (const [modelName, model] of Object.entries(definition.models)) {
973
+ for (const [columnName, column] of Object.entries(model.columns)) {
974
+ if (column.kind !== "json" || column.default === undefined) continue;
975
+ if (jsonColumnValueIssue(column.default) === "oversized") {
976
+ issues.push({
977
+ path: `models.${modelName}.columns.${columnName}.default`,
978
+ message: `json column default exceeds the ${jsonColumnSizeLabel()} size limit`,
979
+ });
980
+ }
981
+ }
982
+ }
983
+ return issues;
984
+ }
985
+
839
986
  export async function parseAppDefinition(
840
987
  input: unknown,
841
988
  elementContext: AppDefinitionParseContext = {},
@@ -871,6 +1018,7 @@ export async function parseAppDefinition(
871
1018
  if (!parsed.success) return { success: false, issues: appDefinitionIssues(parsed.error) };
872
1019
 
873
1020
  const issues = [
1021
+ ...jsonColumnDefaultSizeIssues(parsed.data),
874
1022
  ...Object.keys(parsed.data.pages).flatMap((pageName) =>
875
1023
  validatePage(parsed.data, catalog, pageName),
876
1024
  ),
@@ -4,7 +4,9 @@ import {
4
4
  AppDefinitionSchema,
5
5
  type AppValidationIssue,
6
6
  type ColumnDef,
7
- isIso8601Date,
7
+ isValidColumnValue,
8
+ jsonColumnSizeLabel,
9
+ jsonColumnValueIssue,
8
10
  type ModelDef,
9
11
  } from "./definition";
10
12
  import { upgradeAppDefinition } from "./format-upgrades";
@@ -94,6 +96,7 @@ export function appIndexKey(model: string, column: string, value: unknown, rowId
94
96
 
95
97
  function isIndexed(column: ColumnDef): boolean {
96
98
  if (column.hidden === true) return false;
99
+ if (column.kind === "json") return false;
97
100
  if (column.kind === "enum") return true;
98
101
  return column.index === true && (column.kind === "string" || column.kind === "boolean");
99
102
  }
@@ -111,11 +114,7 @@ function indexKeys(model: string, definition: ModelDef, row: AppRow): string[] {
111
114
 
112
115
  export function validValue(column: ColumnDef, value: unknown): boolean {
113
116
  if (value === null) return column.required !== true;
114
- if (column.kind === "string") return typeof value === "string";
115
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
116
- if (column.kind === "boolean") return typeof value === "boolean";
117
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
118
- return typeof value === "string" && Boolean(column.enum?.includes(value));
117
+ return isValidColumnValue(column, value);
119
118
  }
120
119
 
121
120
  function joinKeyColumnNames(definition: ModelDef): Set<string> {
@@ -185,7 +184,20 @@ function prepareValues(
185
184
  for (const [name, value] of Object.entries(prepared)) {
186
185
  if (!Object.hasOwn(definition.columns, name)) continue;
187
186
  const column = definition.columns[name]!;
188
- if (!validValue(column, value)) {
187
+ if (column.kind === "json" && value !== null) {
188
+ // Asked directly so the value is serialized once and the size cap gets its own message.
189
+ const issue = jsonColumnValueIssue(value);
190
+ if (issue === "unserializable") {
191
+ issues.push({ path: `values.${name}`, message: "must be a valid json value" });
192
+ } else if (issue === "non-finite") {
193
+ issues.push({ path: `values.${name}`, message: "json value contains a non-finite number" });
194
+ } else if (issue === "oversized") {
195
+ issues.push({
196
+ path: `values.${name}`,
197
+ message: `json value exceeds the ${jsonColumnSizeLabel()} size limit`,
198
+ });
199
+ }
200
+ } else if (!validValue(column, value)) {
189
201
  issues.push({ path: `values.${name}`, message: `must be a valid ${column.kind} value` });
190
202
  }
191
203
  }
@@ -8,6 +8,7 @@ import {
8
8
  type AppValidationIssue,
9
9
  type ColumnDef,
10
10
  isIso8601Date,
11
+ isValidColumnValue,
11
12
  type ModelDef,
12
13
  parseAppDefinition,
13
14
  type SourceDef,
@@ -23,7 +24,18 @@ import {
23
24
  } from "./row-store";
24
25
  import { invalidateChangedSyncStatus } from "./sync";
25
26
 
26
- const MigrationValueSchema = z.union([z.string(), z.number(), z.boolean()]);
27
+ /**
28
+ * Literal directive values (`set`, `map` targets, `else`): scalars, plus the
29
+ * objects and arrays a `json` column holds. The per-column kind check in
30
+ * `validateDirectiveValues` is what rejects a shape the target column cannot take.
31
+ */
32
+ const MigrationValueSchema = z.union([
33
+ z.string(),
34
+ z.number(),
35
+ z.boolean(),
36
+ z.record(z.string(), z.unknown()),
37
+ z.array(z.unknown()),
38
+ ]);
27
39
 
28
40
  export const AppMigrationDirectiveSchema = z.union([
29
41
  z.object({ set: MigrationValueSchema }).strict(),
@@ -368,11 +380,7 @@ function isPurgeDirective(
368
380
 
369
381
  function columnAccepts(column: ColumnDef, value: unknown): boolean {
370
382
  if (value === null) return column.hidden === true || column.required !== true;
371
- if (column.kind === "string") return typeof value === "string";
372
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
373
- if (column.kind === "boolean") return typeof value === "boolean";
374
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
375
- return typeof value === "string" && Boolean(column.enum?.includes(value));
383
+ return isValidColumnValue(column, value);
376
384
  }
377
385
 
378
386
  function coerceValue(
@@ -69,6 +69,7 @@ function commentSafe(value: string): string {
69
69
  }
70
70
 
71
71
  function tsTypeForColumn(column: Pick<ColumnDef, "kind" | "enum">): string {
72
+ if (column.kind === "json") return "unknown";
72
73
  if (column.kind !== "enum") return column.kind === "date" ? "string" : column.kind;
73
74
  return column.enum?.map((value) => JSON.stringify(value)).join(" | ") || "string";
74
75
  }
package/src/apps/sync.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type AppDefinition,
10
10
  type AppValidationIssue,
11
11
  type ColumnDef,
12
- isIso8601Date,
12
+ isValidColumnValue,
13
13
  type ModelDef,
14
14
  type SourceDef,
15
15
  type SourceTransform,
@@ -484,19 +484,6 @@ function applyTransform(value: unknown, transform: SourceTransform | undefined):
484
484
  }
485
485
  }
486
486
 
487
- /**
488
- * Mirrors the row-store's per-kind value check. Projections are pre-validated
489
- * here so one bad field nulls one column instead of throwing mid-reconcile and
490
- * leaving the pass half-applied.
491
- */
492
- function validForKind(column: ColumnDef, value: unknown): boolean {
493
- if (column.kind === "string") return typeof value === "string";
494
- if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
495
- if (column.kind === "boolean") return typeof value === "boolean";
496
- if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
497
- return typeof value === "string" && Boolean(column.enum?.includes(value));
498
- }
499
-
500
487
  function projectRecord(args: {
501
488
  record: SourceRecord;
502
489
  bindings: Array<[string, ColumnDef]>;
@@ -518,7 +505,10 @@ function projectRecord(args: {
518
505
  );
519
506
  value = null;
520
507
  }
521
- if (value !== null && !validForKind(column, value)) {
508
+ // Projections are pre-validated with the row store's own kind check so one
509
+ // bad field nulls one column instead of throwing mid-reconcile and leaving
510
+ // the pass half-applied.
511
+ if (value !== null && !isValidColumnValue(column, value)) {
522
512
  warn(
523
513
  args.warnings,
524
514
  `column "${name}": field "${binding.field}" produced a value that is not a valid ${column.kind}`,
@@ -530,9 +520,20 @@ function projectRecord(args: {
530
520
  return values;
531
521
  }
532
522
 
533
- /** A missing column and an explicit null are the same absence of a value. */
534
- function sameValue(existing: unknown, projected: unknown): boolean {
535
- return Object.is(existing ?? null, projected ?? null);
523
+ /**
524
+ * A missing column and an explicit null are the same absence of a value. A
525
+ * json column compares structurally — the value decoded from storage is never
526
+ * the same object as a fresh projection — every other kind by identity.
527
+ */
528
+ function sameValue(
529
+ column: ModelDef["columns"][string] | undefined,
530
+ existing: unknown,
531
+ projected: unknown,
532
+ ): boolean {
533
+ const before = existing ?? null;
534
+ const after = projected ?? null;
535
+ if (column?.kind === "json") return Bun.deepEquals(before, after);
536
+ return Object.is(before, after);
536
537
  }
537
538
 
538
539
  // ---------------------------------------------------------------------------
@@ -701,7 +702,7 @@ function reconcile(args: {
701
702
  return;
702
703
  }
703
704
  const differs = Object.entries(values).some(
704
- ([name, value]) => !sameValue(existing[name], value),
705
+ ([name, value]) => !sameValue(modelDef.columns[name], existing[name], value),
705
706
  );
706
707
  const updated = await patchAppRowUnlocked(appId, model, modelDef, existing.id, values, {
707
708
  allowSourceManaged: true,
@@ -436,6 +436,118 @@ export async function getChildTasks(parentTaskId: string): Promise<AgentTask[]>
436
436
  return rows.map(rowToAgentTask);
437
437
  }
438
438
 
439
+ /** Caps for {@link getTaskLineage}: a fan-out of thousands still answers in one bounded read. */
440
+ export const TASK_LINEAGE_LIMITS = { maxNodes: 500, maxDepth: 30, maxAncestors: 50 } as const;
441
+
442
+ /** One task in a lineage tree: the slim list row plus the workflow run it belongs to. */
443
+ type TaskLineageNode = AgentTaskSummary & { workflowRunId?: string };
444
+
445
+ interface TaskLineage {
446
+ /** The topmost ancestor reached (the session root unless `truncated`). */
447
+ rootTaskId: string;
448
+ /** Root first, then descendants; always holds the path root → `taskId`. */
449
+ tasks: TaskLineageNode[];
450
+ /** True when some ancestor or descendant is missing from `tasks`. */
451
+ truncated: boolean;
452
+ }
453
+
454
+ const LINEAGE_COLUMNS = `${LIST_COLUMNS.slim}, agent_tasks.workflowRunId,
455
+ (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd`;
456
+
457
+ type TaskLineageRow = AgentTaskSummaryRow & { workflowRunId: string | null };
458
+
459
+ function rowToTaskLineageNode(row: TaskLineageRow): TaskLineageNode {
460
+ return { ...rowToAgentTaskSummary(row), workflowRunId: row.workflowRunId ?? undefined };
461
+ }
462
+
463
+ /**
464
+ * The whole tree `taskId` belongs to, following `parentTaskId`: up to the
465
+ * topmost ancestor, then every descendant of that root breadth-first. The
466
+ * descendants of `taskId` itself are walked first, so a wide tree cut at
467
+ * `maxNodes` still shows the task's own subtree. Read-only and bounded: at
468
+ * most `maxAncestors` hops up, `maxDepth` levels down and about `2 * maxNodes`
469
+ * rows. Null when the task does not exist.
470
+ */
471
+ export async function getTaskLineage(
472
+ taskId: string,
473
+ limits: { maxNodes?: number; maxDepth?: number; maxAncestors?: number } = {},
474
+ ): Promise<TaskLineage | null> {
475
+ const maxNodes = limits.maxNodes ?? TASK_LINEAGE_LIMITS.maxNodes;
476
+ const maxDepth = limits.maxDepth ?? TASK_LINEAGE_LIMITS.maxDepth;
477
+ const maxAncestors = limits.maxAncestors ?? TASK_LINEAGE_LIMITS.maxAncestors;
478
+ const db = getDbClient();
479
+ // Hops are counted, so a parentTaskId cycle stops at the cap instead of looping.
480
+ const path = await db.query<{ id: string; parentTaskId: string | null; hops: number }>(
481
+ `WITH RECURSIVE up(id, parentTaskId, hops) AS (
482
+ SELECT id, parentTaskId, 0 FROM agent_tasks WHERE id = ?
483
+ UNION ALL
484
+ SELECT t.id, t.parentTaskId, up.hops + 1 FROM agent_tasks t
485
+ JOIN up ON t.id = up.parentTaskId
486
+ WHERE up.hops < ?
487
+ )
488
+ SELECT id, parentTaskId, hops FROM up ORDER BY hops`,
489
+ [taskId, maxAncestors],
490
+ );
491
+ if (path.length === 0) return null;
492
+ const top = path[path.length - 1]!;
493
+ // A parent that no longer exists ends the walk like a root; a cap does not.
494
+ let truncated = top.hops === maxAncestors && top.parentTaskId !== null;
495
+
496
+ // LIMIT inside the recursive CTE stops the walk itself, not just the output.
497
+ const descendants = (startId: string) =>
498
+ db.query<TaskLineageRow>(
499
+ `WITH RECURSIVE down(id, depth) AS (
500
+ SELECT ?, 0
501
+ UNION ALL
502
+ SELECT t.id, down.depth + 1 FROM down
503
+ JOIN agent_tasks t ON t.parentTaskId = down.id
504
+ WHERE down.depth < ?
505
+ ORDER BY 2
506
+ LIMIT ?
507
+ )
508
+ SELECT ${LINEAGE_COLUMNS} FROM down
509
+ CROSS JOIN agent_tasks ON agent_tasks.id = down.id
510
+ ORDER BY down.depth, agent_tasks.createdAt, agent_tasks.rowid`,
511
+ [startId, maxDepth, maxNodes + 1],
512
+ );
513
+
514
+ const byId = new Map<string, TaskLineageNode>();
515
+ const add = (rows: TaskLineageRow[]) => {
516
+ for (const row of rows) {
517
+ if (!byId.has(row.id)) byId.set(row.id, rowToTaskLineageNode(row));
518
+ }
519
+ };
520
+ const ownSubtree = await descendants(taskId);
521
+ const fromRoot = top.id === taskId ? ownSubtree : await descendants(top.id);
522
+ add(fromRoot.slice(0, maxNodes));
523
+ add(ownSubtree.slice(0, maxNodes));
524
+
525
+ // The path root → task is always present, even past the depth or node cap.
526
+ const missing = path.map((row) => row.id).filter((id) => !byId.has(id));
527
+ if (missing.length > 0) {
528
+ add(
529
+ await db.query<TaskLineageRow>(
530
+ `SELECT ${LINEAGE_COLUMNS} FROM agent_tasks WHERE id IN (${missing.map(() => "?").join(", ")})`,
531
+ missing,
532
+ ),
533
+ );
534
+ }
535
+
536
+ // The caps cut the walk at many points (depth, node count, either walk), so
537
+ // ask the table directly: is any child of a returned task not returned?
538
+ if (!truncated) {
539
+ const ids = [...byId.keys()];
540
+ const list = ids.map(() => "?").join(", ");
541
+ const omitted = await db.get(
542
+ `SELECT 1 FROM agent_tasks WHERE parentTaskId IN (${list}) AND id NOT IN (${list}) LIMIT 1`,
543
+ [...ids, ...ids],
544
+ );
545
+ truncated = omitted !== undefined && omitted !== null;
546
+ }
547
+
548
+ return { rootTaskId: top.id, tasks: [...byId.values()], truncated };
549
+ }
550
+
439
551
  /**
440
552
  * Returns true if `parentId` has at least one non-terminal child task with
441
553
  * `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
package/src/be/db.ts CHANGED
@@ -327,6 +327,7 @@ export {
327
327
  getSupersededTasksWithoutResume,
328
328
  getSupersededTasksWithUnsettledDependents,
329
329
  getTaskById,
330
+ getTaskLineage,
330
331
  getTaskStats,
331
332
  getTasksByAgentId,
332
333
  getTasksByStatus,
@@ -346,6 +347,7 @@ export {
346
347
  recordChatRelayError,
347
348
  resetTasksNotified,
348
349
  SUPERSEDED_RESUME_REPAIR_WINDOW_MS,
350
+ TASK_LINEAGE_LIMITS,
349
351
  type TaskFilters,
350
352
  } from "./db/tasks/read";
351
353
 
@@ -811,6 +813,9 @@ export async function getSlackOutcomeMessage(taskId: string): Promise<SlackMessa
811
813
  * subsequent re-defers to the latest wake, keeping the original card open.
812
814
  * `deferredAt` (not the `deferred` tag) identifies the deferring task,
813
815
  * because the wake-up task inherits the tag.
816
+ *
817
+ * CROSS JOIN and `+wake.taskType` pin the join order to the small chain. Without
818
+ * them the planner drives the outer join from every terminal task (~1s at 150k tasks).
814
819
  */
815
820
  export async function getResolvableDeferralOutcomes(
816
821
  limit = 50,
@@ -824,13 +829,13 @@ export async function getResolvableDeferralOutcomes(
824
829
  AND card.ts NOT LIKE 'pending:%'
825
830
  UNION
826
831
  SELECT chain.card_id, wake.id FROM chain
827
- JOIN agent_tasks parent ON parent.id = chain.task_id AND parent.deferredAt IS NOT NULL
828
- JOIN agent_tasks wake ON wake.parentTaskId = parent.id AND wake.taskType = 'deferred'
832
+ CROSS JOIN agent_tasks parent ON parent.id = chain.task_id AND parent.deferredAt IS NOT NULL
833
+ CROSS JOIN agent_tasks wake ON wake.parentTaskId = parent.id AND +wake.taskType = 'deferred'
829
834
  )
830
835
  SELECT card.*, wake.id AS wake_task_id
831
836
  FROM chain
832
- JOIN slack_messages card ON card.id = chain.card_id
833
- JOIN agent_tasks wake ON wake.id = chain.task_id AND wake.id != card.task_id
837
+ CROSS JOIN slack_messages card ON card.id = chain.card_id
838
+ CROSS JOIN agent_tasks wake ON wake.id = chain.task_id AND wake.id != card.task_id
834
839
  WHERE card.kind = 'outcome'
835
840
  AND card.finalized_at IS NOT NULL
836
841
  AND card.deferral_resolved_at IS NULL
@@ -0,0 +1,5 @@
1
+ -- getSlackTasksMissingTree runs on every Slack watcher tick. Without this index it walks
2
+ -- idx_agent_tasks_last_updated across every task to find the few Slack ones.
3
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_last_updated
4
+ ON agent_tasks(lastUpdatedAt DESC)
5
+ WHERE source = 'slack' AND slackChannelId IS NOT NULL AND slackThreadTs IS NOT NULL;
@@ -139,6 +139,16 @@ export const DB_RETENTION_TUNING_BOUNDS = {
139
139
  DB_RETENTION_MAX_STATEMENT_MS: { min: 25, max: 5_000 },
140
140
  } as const;
141
141
 
142
+ /**
143
+ * Bounds for the Swarm Apps json column size cap (`APPS_JSON_COLUMN_MAX_BYTES`).
144
+ *
145
+ * `src/apps/definition.ts` reads the knob against exactly these at call time
146
+ * and falls back to its 256 KB default outside them; VALIDATED_KEYS below
147
+ * rejects out-of-range writes with the same numbers, so the config API cannot
148
+ * accept a cap the row store would silently ignore.
149
+ */
150
+ export const APPS_JSON_COLUMN_MAX_BYTES_BOUNDS = { min: 1_024, max: 16_777_216 } as const;
151
+
142
152
  /** Build `{ KEY: validator }` entries accepting only boolean literals. */
143
153
  function booleanValidators(keys: string[]): Record<string, ConfigValidator> {
144
154
  const message = (key: string) =>
@@ -527,6 +537,11 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
527
537
  // an operator could save a 500ms tick budget, see it accepted, and have the
528
538
  // sweep keep running for the default 30000ms.
529
539
  ...boundedIntegerValidatorsFor(DB_RETENTION_TUNING_BOUNDS),
540
+ ...boundedIntegerValidators(
541
+ ["APPS_JSON_COLUMN_MAX_BYTES"],
542
+ APPS_JSON_COLUMN_MAX_BYTES_BOUNDS.min,
543
+ APPS_JSON_COLUMN_MAX_BYTES_BOUNDS.max,
544
+ ),
530
545
  ...boundedIntegerValidators(
531
546
  [
532
547
  "SESSION_LOG_RETENTION_DAYS",
@@ -4,6 +4,7 @@ import { findUserByExternalId } from "../be/users";
4
4
  import { resolveTemplate } from "../prompts/resolver";
5
5
  import { githubContextKey } from "../tasks/context-key";
6
6
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
7
+ import { parseEnvFlag } from "../utils/env-flag";
7
8
  import { getInstallationToken } from "./app";
8
9
  import {
9
10
  detectMention,
@@ -50,12 +51,12 @@ function buildGithubContextKey(
50
51
 
51
52
  /**
52
53
  * Runtime-config guards for cancel-on-unassign and cancel-on-review-request-removed.
53
- * Absent key / any value other than "false" → true (cancel, current behavior).
54
- * Value "false" → false (skip cancel, leave task untouched).
54
+ * Values "false" / "0" (case-insensitive, trimmed) leave the task untouched.
55
+ * Absent, empty, or unrecognized values default to true (cancel).
55
56
  */
56
57
  async function cancelFlagEnabled(key: string): Promise<boolean> {
57
58
  const row = (await getSwarmConfigs({ scope: "global", key }))[0];
58
- return row?.value !== "false";
59
+ return parseEnvFlag(row?.value, true);
59
60
  }
60
61
  const cancelOnUnassignEnabled = () => cancelFlagEnabled("github.cancelOnUnassign");
61
62
  const cancelOnReviewRequestRemovedEnabled = () =>
@@ -1186,9 +1187,9 @@ const PR_REVIEW_ACTION_ITEMS_QUERY = `query($owner: String!, $name: String!, $nu
1186
1187
  }
1187
1188
  }`;
1188
1189
 
1189
- /** Approved reviews with nothing to act on skip the lead task unless this is "false". */
1190
+ /** Approved reviews with nothing to act on skip the lead task by default. */
1190
1191
  function skipNoopApprovalsEnabled(): boolean {
1191
- return process.env.GITHUB_SKIP_NOOP_APPROVALS?.trim().toLowerCase() !== "false";
1192
+ return parseEnvFlag(process.env.GITHUB_SKIP_NOOP_APPROVALS, true);
1192
1193
  }
1193
1194
 
1194
1195
  /**
package/src/hooks/hook.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import type { Agent } from "../types";
22
22
  import { getApiKey } from "../utils/api-key";
23
23
  import { getMcpBaseUrl, MAX_PROFILE_FILE_LENGTH } from "../utils/constants";
24
+ import { isSessionSummarySkipped } from "../utils/env-flag";
24
25
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
25
26
  import { scrubSecrets } from "../utils/secret-scrubber";
26
27
  import { guardGhPrBody } from "./pr-body-guard";
@@ -315,7 +316,7 @@ export async function runStopHookSessionSummary(
315
316
  deps: RunStopHookSessionSummaryDeps = {},
316
317
  ): Promise<void> {
317
318
  const env = opts.env ?? process.env;
318
- if (env.SKIP_SESSION_SUMMARY) {
319
+ if (isSessionSummarySkipped(env)) {
319
320
  console.debug("session_summary skipped (claude): SKIP_SESSION_SUMMARY is set");
320
321
  return;
321
322
  }