@agent-native/core 0.70.3 → 0.72.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 (221) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +51 -0
  3. package/corpus/core/docs/content/actions.md +23 -0
  4. package/corpus/core/docs/content/audit-log.md +111 -0
  5. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  6. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  7. package/corpus/core/package.json +2 -1
  8. package/corpus/core/src/action.ts +80 -1
  9. package/corpus/core/src/agent/durable-background.ts +192 -0
  10. package/corpus/core/src/agent/production-agent.ts +357 -13
  11. package/corpus/core/src/agent/run-manager.ts +66 -3
  12. package/corpus/core/src/agent/run-store.ts +143 -23
  13. package/corpus/core/src/agent/types.ts +20 -0
  14. package/corpus/core/src/application-state/store.ts +5 -0
  15. package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
  16. package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
  17. package/corpus/core/src/audit/cleanup-job.ts +100 -0
  18. package/corpus/core/src/audit/config.ts +91 -0
  19. package/corpus/core/src/audit/index.ts +43 -0
  20. package/corpus/core/src/audit/record.ts +143 -0
  21. package/corpus/core/src/audit/redact.ts +109 -0
  22. package/corpus/core/src/audit/store.ts +244 -0
  23. package/corpus/core/src/audit/types.ts +125 -0
  24. package/corpus/core/src/chat-threads/store.ts +10 -0
  25. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  26. package/corpus/core/src/client/AssistantChat.tsx +6 -0
  27. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  28. package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
  29. package/corpus/core/src/db/client.ts +4 -0
  30. package/corpus/core/src/db/widen-columns.ts +75 -0
  31. package/corpus/core/src/deploy/build.ts +96 -0
  32. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  33. package/corpus/core/src/mcp/build-server.ts +23 -13
  34. package/corpus/core/src/oauth-tokens/store.ts +6 -0
  35. package/corpus/core/src/provider-api/custom-registry.ts +8 -0
  36. package/corpus/core/src/resources/store.ts +10 -0
  37. package/corpus/core/src/scripts/runner.ts +6 -3
  38. package/corpus/core/src/server/action-discovery.ts +6 -0
  39. package/corpus/core/src/server/action-routes.ts +1 -0
  40. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  41. package/corpus/core/src/server/auth.ts +5 -0
  42. package/corpus/core/src/server/core-routes-plugin.ts +14 -0
  43. package/corpus/core/src/server/security-headers.ts +9 -6
  44. package/corpus/core/src/settings/store.ts +6 -0
  45. package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  46. package/corpus/core/src/usage/store.ts +6 -0
  47. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  48. package/corpus/templates/analytics/AGENTS.md +8 -0
  49. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  50. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  51. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  52. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
  53. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
  54. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
  55. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
  56. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
  57. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  58. package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
  59. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
  60. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
  61. package/corpus/templates/clips/chrome-extension/src/background.ts +163 -32
  62. package/corpus/templates/clips/chrome-extension/src/content-script.ts +301 -24
  63. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +245 -67
  64. package/corpus/templates/clips/chrome-extension/src/overlay.css +77 -43
  65. package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
  66. package/corpus/templates/clips/chrome-extension/src/overlay.ts +102 -38
  67. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  68. package/corpus/templates/clips/chrome-extension/src/popup.ts +275 -4
  69. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  70. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  71. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  72. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  73. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  74. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  75. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  76. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  77. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  78. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  79. package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
  80. package/corpus/templates/clips/shared/recording-audio.ts +62 -0
  81. package/corpus/templates/clips/shared/recording-core.ts +94 -0
  82. package/dist/action.d.ts +31 -0
  83. package/dist/action.d.ts.map +1 -1
  84. package/dist/action.js +45 -1
  85. package/dist/action.js.map +1 -1
  86. package/dist/agent/context-xray/schema.d.ts +1 -1
  87. package/dist/agent/durable-background.d.ts +60 -0
  88. package/dist/agent/durable-background.d.ts.map +1 -0
  89. package/dist/agent/durable-background.js +144 -0
  90. package/dist/agent/durable-background.js.map +1 -0
  91. package/dist/agent/observational-memory/schema.d.ts +1 -1
  92. package/dist/agent/production-agent.d.ts +20 -0
  93. package/dist/agent/production-agent.d.ts.map +1 -1
  94. package/dist/agent/production-agent.js +320 -14
  95. package/dist/agent/production-agent.js.map +1 -1
  96. package/dist/agent/run-manager.d.ts +48 -0
  97. package/dist/agent/run-manager.d.ts.map +1 -1
  98. package/dist/agent/run-manager.js +45 -3
  99. package/dist/agent/run-manager.js.map +1 -1
  100. package/dist/agent/run-store.d.ts +30 -1
  101. package/dist/agent/run-store.d.ts.map +1 -1
  102. package/dist/agent/run-store.js +138 -24
  103. package/dist/agent/run-store.js.map +1 -1
  104. package/dist/agent/types.d.ts +20 -0
  105. package/dist/agent/types.d.ts.map +1 -1
  106. package/dist/agent/types.js.map +1 -1
  107. package/dist/application-state/store.d.ts.map +1 -1
  108. package/dist/application-state/store.js +5 -0
  109. package/dist/application-state/store.js.map +1 -1
  110. package/dist/audit/actions/get-audit-event.d.ts +11 -0
  111. package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
  112. package/dist/audit/actions/get-audit-event.js +22 -0
  113. package/dist/audit/actions/get-audit-event.js.map +1 -0
  114. package/dist/audit/actions/list-audit-events.d.ts +22 -0
  115. package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
  116. package/dist/audit/actions/list-audit-events.js +61 -0
  117. package/dist/audit/actions/list-audit-events.js.map +1 -0
  118. package/dist/audit/cleanup-job.d.ts +12 -0
  119. package/dist/audit/cleanup-job.d.ts.map +1 -0
  120. package/dist/audit/cleanup-job.js +93 -0
  121. package/dist/audit/cleanup-job.js.map +1 -0
  122. package/dist/audit/config.d.ts +30 -0
  123. package/dist/audit/config.d.ts.map +1 -0
  124. package/dist/audit/config.js +65 -0
  125. package/dist/audit/config.js.map +1 -0
  126. package/dist/audit/index.d.ts +13 -0
  127. package/dist/audit/index.d.ts.map +1 -0
  128. package/dist/audit/index.js +6 -0
  129. package/dist/audit/index.js.map +1 -0
  130. package/dist/audit/record.d.ts +24 -0
  131. package/dist/audit/record.d.ts.map +1 -0
  132. package/dist/audit/record.js +106 -0
  133. package/dist/audit/record.js.map +1 -0
  134. package/dist/audit/redact.d.ts +28 -0
  135. package/dist/audit/redact.d.ts.map +1 -0
  136. package/dist/audit/redact.js +109 -0
  137. package/dist/audit/redact.js.map +1 -0
  138. package/dist/audit/store.d.ts +14 -0
  139. package/dist/audit/store.d.ts.map +1 -0
  140. package/dist/audit/store.js +219 -0
  141. package/dist/audit/store.js.map +1 -0
  142. package/dist/audit/types.d.ts +114 -0
  143. package/dist/audit/types.d.ts.map +1 -0
  144. package/dist/audit/types.js +15 -0
  145. package/dist/audit/types.js.map +1 -0
  146. package/dist/chat-threads/store.d.ts.map +1 -1
  147. package/dist/chat-threads/store.js +10 -0
  148. package/dist/chat-threads/store.js.map +1 -1
  149. package/dist/client/AgentPanel.d.ts.map +1 -1
  150. package/dist/client/AgentPanel.js +2 -2
  151. package/dist/client/AgentPanel.js.map +1 -1
  152. package/dist/client/AssistantChat.d.ts.map +1 -1
  153. package/dist/client/AssistantChat.js +6 -1
  154. package/dist/client/AssistantChat.js.map +1 -1
  155. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  156. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  157. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  158. package/dist/client/chat/repo-helpers.d.ts +16 -0
  159. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  160. package/dist/client/chat/repo-helpers.js +40 -0
  161. package/dist/client/chat/repo-helpers.js.map +1 -1
  162. package/dist/db/client.d.ts.map +1 -1
  163. package/dist/db/client.js +3 -0
  164. package/dist/db/client.js.map +1 -1
  165. package/dist/db/widen-columns.d.ts +39 -0
  166. package/dist/db/widen-columns.d.ts.map +1 -0
  167. package/dist/db/widen-columns.js +73 -0
  168. package/dist/db/widen-columns.js.map +1 -0
  169. package/dist/deploy/build.d.ts +29 -0
  170. package/dist/deploy/build.d.ts.map +1 -1
  171. package/dist/deploy/build.js +85 -0
  172. package/dist/deploy/build.js.map +1 -1
  173. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  174. package/dist/deploy/workspace-deploy.js +108 -0
  175. package/dist/deploy/workspace-deploy.js.map +1 -1
  176. package/dist/mcp/build-server.d.ts.map +1 -1
  177. package/dist/mcp/build-server.js +24 -9
  178. package/dist/mcp/build-server.js.map +1 -1
  179. package/dist/oauth-tokens/store.d.ts.map +1 -1
  180. package/dist/oauth-tokens/store.js +6 -0
  181. package/dist/oauth-tokens/store.js.map +1 -1
  182. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  183. package/dist/provider-api/custom-registry.js +8 -0
  184. package/dist/provider-api/custom-registry.js.map +1 -1
  185. package/dist/resources/store.d.ts.map +1 -1
  186. package/dist/resources/store.js +9 -0
  187. package/dist/resources/store.js.map +1 -1
  188. package/dist/scripts/runner.js +4 -3
  189. package/dist/scripts/runner.js.map +1 -1
  190. package/dist/server/action-discovery.d.ts.map +1 -1
  191. package/dist/server/action-discovery.js +6 -0
  192. package/dist/server/action-discovery.js.map +1 -1
  193. package/dist/server/action-routes.d.ts.map +1 -1
  194. package/dist/server/action-routes.js +1 -0
  195. package/dist/server/action-routes.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +68 -13
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/auth.d.ts.map +1 -1
  200. package/dist/server/auth.js +5 -0
  201. package/dist/server/auth.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  203. package/dist/server/core-routes-plugin.js +13 -0
  204. package/dist/server/core-routes-plugin.js.map +1 -1
  205. package/dist/server/security-headers.d.ts +8 -5
  206. package/dist/server/security-headers.d.ts.map +1 -1
  207. package/dist/server/security-headers.js +9 -6
  208. package/dist/server/security-headers.js.map +1 -1
  209. package/dist/settings/store.d.ts.map +1 -1
  210. package/dist/settings/store.js +6 -0
  211. package/dist/settings/store.js.map +1 -1
  212. package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  213. package/dist/usage/store.d.ts.map +1 -1
  214. package/dist/usage/store.js +5 -0
  215. package/dist/usage/store.js.map +1 -1
  216. package/docs/content/actions.md +23 -0
  217. package/docs/content/audit-log.md +111 -0
  218. package/docs/content/multi-app-workspace.md +2 -2
  219. package/docs/design/durable-agent-runs.md +458 -4
  220. package/package.json +2 -1
  221. package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
@@ -64,9 +64,42 @@ export const DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS = 40_000;
64
64
  * env value just guarantees the cutoff is a hard error instead of a graceful
65
65
  * hand-off. Any resolved value above this is clamped down when hosted. Local
66
66
  * dev (non-hosted) is left alone so long-running local turns aren't chunked.
67
+ *
68
+ * IMPORTANT: this clamp is for the INTERACTIVE / foreground path and must NOT
69
+ * be raised. The foreground POST still rides a synchronous serverless function
70
+ * (~60-65s wall), so 40s remains correct there. The only sanctioned exception
71
+ * is the opt-in `backgroundFunction` mode (see
72
+ * `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`), which runs inside a Netlify background
73
+ * function (no ~60s wall, 15-min budget) and therefore can safely outlast 40s.
67
74
  */
68
75
  export const HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000;
69
76
 
77
+ /**
78
+ * Hard ceiling for the soft timeout when a run executes inside a Netlify
79
+ * background function (any deployed function whose name ends in `-background`).
80
+ * Background functions return 202 immediately and run detached for up to 15
81
+ * minutes, so the ~60s synchronous function wall that 40s defends against does
82
+ * NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
83
+ * kill to abort, persist the partial turn, write the terminal event, and (for
84
+ * the rare >13-min turn) self-fire another background continuation.
85
+ *
86
+ * This ceiling is used ONLY when a caller explicitly opts in with
87
+ * `backgroundFunction: true`. It does not change the foreground/interactive
88
+ * ceiling and does not fire unless the durable-background path dispatched the
89
+ * run into a background function. Per the design doc Guardrail, the 40s
90
+ * interactive clamp stays correct for every non-background run.
91
+ */
92
+ export const BACKGROUND_SOFT_TIMEOUT_CEILING_MS = 13 * 60_000; // 780_000
93
+
94
+ /**
95
+ * Default soft-timeout budget for a background-function run when the caller
96
+ * does not pass an explicit `softTimeoutMs`. Same value as the ceiling — we
97
+ * want a background turn to use nearly its whole 15-min budget before handing
98
+ * off to a chained background continuation.
99
+ */
100
+ export const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS =
101
+ BACKGROUND_SOFT_TIMEOUT_CEILING_MS;
102
+
70
103
  /** Default SQL retention for completed run event logs (24 hours). */
71
104
  export const DEFAULT_COMPLETED_RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
72
105
 
@@ -137,10 +170,28 @@ export interface StartRunOptions {
137
170
  * share one `turnId` so the durable assistant message can be folded across
138
171
  * them instead of dropped per-run. Defaults to the runId (turn == run). */
139
172
  turnId?: string;
173
+ /**
174
+ * Opt into the durable-background-function soft-timeout regime for THIS run
175
+ * only. When true, `resolveRunSoftTimeoutMs` lifts the hosted ceiling from
176
+ * 40s to ~13min (`BACKGROUND_SOFT_TIMEOUT_CEILING_MS`) because the run is
177
+ * executing inside a Netlify background function (no ~60s wall). Off by
178
+ * default — the foreground/interactive path never sets this, so its 40s
179
+ * clamp is unchanged. See the design doc + the durable-background dispatch
180
+ * decision in production-agent.ts.
181
+ */
182
+ backgroundFunction?: boolean;
140
183
  }
141
184
 
142
185
  export interface ResolveRunSoftTimeoutOptions {
143
186
  useHostedDefault?: boolean;
187
+ /**
188
+ * Resolve the soft timeout for a run executing inside a Netlify background
189
+ * function. Lifts the hosted clamp to `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`
190
+ * (~13min) for this invocation only and, when no override/env is supplied,
191
+ * defaults to `DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS`. Does NOT change the
192
+ * foreground ceiling. Off by default.
193
+ */
194
+ backgroundFunction?: boolean;
144
195
  }
145
196
 
146
197
  function isHostedRuntime(): boolean {
@@ -172,15 +223,21 @@ export function resolveRunSoftTimeoutMs(
172
223
  options?: ResolveRunSoftTimeoutOptions,
173
224
  ): number {
174
225
  const hosted = isHostedRuntime();
226
+ const background = options?.backgroundFunction === true;
227
+ // The interactive/foreground ceiling is 40s — the synchronous serverless
228
+ // function wall. A background-function run (opt-in only) has no ~60s wall, so
229
+ // it is allowed to outlast that and is clamped to the larger 13-min ceiling
230
+ // instead. The 40s clamp for non-background hosted runs is unchanged.
231
+ const ceiling = background
232
+ ? BACKGROUND_SOFT_TIMEOUT_CEILING_MS
233
+ : HOSTED_SOFT_TIMEOUT_CEILING_MS;
175
234
  // A configured/env soft timeout that exceeds the upstream walls can never
176
235
  // actually fire (the gateway/function kills the run first), so clamp it down
177
236
  // on hosted runtimes. This is what makes auto_continue reach the client
178
237
  // instead of the run dying as builder_gateway_timeout / stale_run. `0` means
179
238
  // "disabled" and is never clamped up.
180
239
  const clampHosted = (ms: number): number =>
181
- hosted && ms > HOSTED_SOFT_TIMEOUT_CEILING_MS
182
- ? HOSTED_SOFT_TIMEOUT_CEILING_MS
183
- : ms;
240
+ hosted && ms > ceiling ? ceiling : ms;
184
241
 
185
242
  if (typeof overrideMs === "number" && Number.isFinite(overrideMs)) {
186
243
  return clampHosted(Math.max(0, overrideMs));
@@ -190,6 +247,11 @@ export function resolveRunSoftTimeoutMs(
190
247
  const raw = Number(envValue);
191
248
  if (Number.isFinite(raw) && raw >= 0) return clampHosted(raw);
192
249
  }
250
+ // A background-function run uses the full background budget by default; the
251
+ // foreground default (40s) is unchanged.
252
+ if (background) {
253
+ return hosted ? DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS : 0;
254
+ }
193
255
  return options?.useHostedDefault && hosted
194
256
  ? DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS
195
257
  : 0;
@@ -343,6 +405,7 @@ export function startRun(
343
405
  }, 1500);
344
406
  const softTimeoutMs = resolveRunSoftTimeoutMs(options?.softTimeoutMs, {
345
407
  useHostedDefault: options?.useHostedSoftTimeoutDefault === true,
408
+ backgroundFunction: options?.backgroundFunction === true,
346
409
  });
347
410
  const softTimeoutTimer =
348
411
  softTimeoutMs > 0
@@ -4,6 +4,7 @@
4
4
  * reliable reconnection after page refreshes.
5
5
  */
6
6
  import { getDbExec, intType, isPostgres } from "../db/client.js";
7
+ import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
7
8
  import { captureError } from "../server/capture-error.js";
8
9
  import type { AgentChatEvent } from "./types.js";
9
10
 
@@ -18,6 +19,21 @@ let _initPromise: Promise<void> | undefined;
18
19
  */
19
20
  export const RUN_STALE_MS = 15_000;
20
21
 
22
+ /**
23
+ * Stale window for runs dispatched into a Netlify background function
24
+ * (`dispatch_mode = 'background'`). The design doc flags the 15s reaper vs a
25
+ * background cold-start as the #1 false-failure risk: the foreground POST
26
+ * inserts the `running` row, then `fireInternalDispatch` returns 202 and the
27
+ * background function may take >15s to cold-start and emit its first heartbeat.
28
+ * With the normal 15s window the reaper would falsely kill that freshly-
29
+ * inserted-but-not-yet-heartbeaten row. 90s tolerates a slow background
30
+ * cold-start while still reaping a genuinely dead background worker promptly.
31
+ *
32
+ * Only applied to rows explicitly marked background-dispatched; ordinary
33
+ * foreground runs keep the tight 15s window unchanged.
34
+ */
35
+ export const BACKGROUND_RUN_STALE_MS = 90_000;
36
+
21
37
  export const STALE_RUN_ERROR_EVENT = {
22
38
  type: "error",
23
39
  error:
@@ -44,7 +60,8 @@ async function ensureRunTables(): Promise<void> {
44
60
  last_progress_at ${intType()},
45
61
  turn_id TEXT,
46
62
  error_code TEXT,
47
- error_detail TEXT
63
+ error_detail TEXT,
64
+ dispatch_mode TEXT
48
65
  )
49
66
  `);
50
67
  // Backfill heartbeat_at on older deployments.
@@ -97,7 +114,16 @@ async function ensureRunTables(): Promise<void> {
97
114
  // error_code / error_detail = terminal failure classification captured
98
115
  // at completion so errored/cut-off runs are queryable for
99
116
  // pattern analysis (see listErroredRuns).
100
- for (const col of ["turn_id", "error_code", "error_detail"] as const) {
117
+ // dispatch_mode marks how a run was started: NULL/"foreground" for the
118
+ // normal synchronous path, "background" for a run dispatched into a
119
+ // Netlify background function. The reaper/claim widen the stale window
120
+ // for background rows so a slow cold-start isn't falsely reaped.
121
+ for (const col of [
122
+ "turn_id",
123
+ "error_code",
124
+ "error_detail",
125
+ "dispatch_mode",
126
+ ] as const) {
101
127
  try {
102
128
  if (isPostgres()) {
103
129
  await client.execute(
@@ -134,6 +160,19 @@ async function ensureRunTables(): Promise<void> {
134
160
  PRIMARY KEY (thread_id, tool_key)
135
161
  )
136
162
  `);
163
+ // Widen millisecond-timestamp columns that older deployments created as
164
+ // 32-bit `INTEGER`. `insertRun()` writes `Date.now()` into `started_at`
165
+ // on every turn, so an int4 column makes every agent prompt fail on
166
+ // Postgres with "value … is out of range for type integer". No-op once
167
+ // widened (and on fresh DBs that already use BIGINT). See
168
+ // widenIntColumnsToBigInt.
169
+ await widenIntColumnsToBigInt("agent_runs", [
170
+ "started_at",
171
+ "completed_at",
172
+ "heartbeat_at",
173
+ "last_progress_at",
174
+ ]);
175
+ await widenIntColumnsToBigInt("agent_tool_ledger", ["completed_at"]);
137
176
  })().catch((err) => {
138
177
  // Retry init on the next call after a failed startup.
139
178
  _initPromise = undefined;
@@ -234,16 +273,68 @@ export async function insertRun(
234
273
  id: string,
235
274
  threadId: string,
236
275
  turnId?: string,
276
+ options?: { dispatchMode?: "foreground" | "background" },
237
277
  ): Promise<void> {
238
278
  await ensureRunTables();
239
279
  const client = getDbExec();
240
280
  const now = Date.now();
241
281
  await client.execute({
242
- sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id) VALUES (?, ?, 'running', ?, ?, ?, ?)`,
243
- args: [id, threadId, now, now, now, turnId ?? id],
282
+ sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id, dispatch_mode) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)`,
283
+ args: [
284
+ id,
285
+ threadId,
286
+ now,
287
+ now,
288
+ now,
289
+ turnId ?? id,
290
+ options?.dispatchMode ?? null,
291
+ ],
244
292
  });
245
293
  }
246
294
 
295
+ /**
296
+ * SQL fragment that resolves the per-row stale cutoff. Background-dispatched
297
+ * runs (`dispatch_mode` starting with `background`) tolerate a much longer gap
298
+ * without a heartbeat (slow Netlify background-function cold-start) before being
299
+ * reaped; every other run keeps the tight 15s window. The bound `now` is
300
+ * subtracted by the resolved window so the comparison is
301
+ * `COALESCE(heartbeat_at, started_at) < (now - window)`.
302
+ *
303
+ * `dispatch_mode` is one of NULL/"foreground" (normal sync path), "background"
304
+ * (foreground inserted the row for a background dispatch), or
305
+ * "background-processing" (the background worker claimed it). Both background
306
+ * states get the wider window via a LIKE-prefix match.
307
+ */
308
+ function backgroundAwareStaleCutoffSql(): string {
309
+ return `(? - CASE WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
310
+ }
311
+
312
+ /**
313
+ * Atomically claim a background-dispatched run for processing. The foreground
314
+ * POST inserts the run row with `dispatch_mode = 'background'`; the FIRST
315
+ * delivery of the background dispatch flips it to `background-processing` and
316
+ * wins the claim. A duplicate Netlify delivery (background functions can be
317
+ * retried) sees `background-processing` and loses, so it no-ops — mirroring
318
+ * `claimAgentTeamRun` returning null. Returns true when this caller won.
319
+ *
320
+ * Idempotent and conditional: the WHERE clause only matches the unclaimed
321
+ * `background` state AND a still-running row, so a reaped/terminal row can't be
322
+ * re-claimed.
323
+ */
324
+ export async function claimBackgroundRun(runId: string): Promise<boolean> {
325
+ await ensureRunTables();
326
+ const client = getDbExec();
327
+ const { rowsAffected } = await client.execute({
328
+ sql: `UPDATE agent_runs
329
+ SET dispatch_mode = 'background-processing'
330
+ WHERE id = ?
331
+ AND status = 'running'
332
+ AND dispatch_mode = 'background'`,
333
+ args: [runId],
334
+ });
335
+ return (rowsAffected ?? 0) > 0;
336
+ }
337
+
247
338
  /**
248
339
  * Atomically acquire a run lease for a thread. Succeeds (returns true) only
249
340
  * when no other run for the same thread is currently status='running' with a
@@ -256,18 +347,37 @@ export async function insertRun(
256
347
  */
257
348
  export async function tryClaimRunSlot(
258
349
  threadId: string,
259
- maxStaleMs: number = RUN_STALE_MS,
350
+ maxStaleMs?: number,
260
351
  ): Promise<{ claimed: boolean; activeRunId: string | null }> {
261
352
  await ensureRunTables();
262
353
  const client = getDbExec();
263
- const heartbeatCutoff = Date.now() - maxStaleMs;
354
+ const now = Date.now();
355
+ // Default: per-row background-aware window so a live background run (which can
356
+ // legitimately go >15s between heartbeats during a cold-start) isn't seen as
357
+ // "free" and double-claimed by a racing foreground POST. An explicit
358
+ // `maxStaleMs` override keeps a flat window for callers that want one.
359
+ if (typeof maxStaleMs === "number") {
360
+ const heartbeatCutoff = now - maxStaleMs;
361
+ const { rows } = await client.execute({
362
+ sql: `SELECT id FROM agent_runs
363
+ WHERE thread_id = ?
364
+ AND status = 'running'
365
+ AND COALESCE(heartbeat_at, started_at) >= ?
366
+ ORDER BY started_at DESC LIMIT 1`,
367
+ args: [threadId, heartbeatCutoff],
368
+ });
369
+ if (rows.length > 0) {
370
+ return { claimed: false, activeRunId: (rows[0] as { id: string }).id };
371
+ }
372
+ return { claimed: true, activeRunId: null };
373
+ }
264
374
  const { rows } = await client.execute({
265
375
  sql: `SELECT id FROM agent_runs
266
376
  WHERE thread_id = ?
267
377
  AND status = 'running'
268
- AND COALESCE(heartbeat_at, started_at) >= ?
378
+ AND COALESCE(heartbeat_at, started_at) >= ${backgroundAwareStaleCutoffSql()}
269
379
  ORDER BY started_at DESC LIMIT 1`,
270
- args: [threadId, heartbeatCutoff],
380
+ args: [threadId, now],
271
381
  });
272
382
  if (rows.length > 0) {
273
383
  const row = rows[0] as { id: string };
@@ -335,12 +445,20 @@ export async function bumpRunProgress(runId: string): Promise<void> {
335
445
  */
336
446
  export async function reapIfStale(
337
447
  runId: string,
338
- maxStaleMs: number = RUN_STALE_MS,
448
+ maxStaleMs?: number,
339
449
  ): Promise<boolean> {
340
450
  await ensureRunTables();
341
451
  const client = getDbExec();
342
452
  const completedAt = Date.now();
343
- const cutoff = completedAt - maxStaleMs;
453
+ // Background-dispatched runs get the wider stale window so a slow cold-start
454
+ // isn't reaped; foreground runs keep the tight 15s window. An explicit
455
+ // override forces a flat window for callers that want one.
456
+ const staleClause =
457
+ typeof maxStaleMs === "number"
458
+ ? `COALESCE(heartbeat_at, started_at) < ?`
459
+ : `COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`;
460
+ const staleArgs =
461
+ typeof maxStaleMs === "number" ? [completedAt - maxStaleMs] : [completedAt];
344
462
  const { rowsAffected } = await client.execute({
345
463
  sql: `UPDATE agent_runs
346
464
  SET status = 'errored',
@@ -349,13 +467,13 @@ export async function reapIfStale(
349
467
  error_detail = ?
350
468
  WHERE id = ?
351
469
  AND status = 'running'
352
- AND COALESCE(heartbeat_at, started_at) < ?`,
470
+ AND ${staleClause}`,
353
471
  args: [
354
472
  completedAt,
355
473
  STALE_RUN_ERROR_EVENT.errorCode,
356
474
  STALE_RUN_ERROR_EVENT.details,
357
475
  runId,
358
- cutoff,
476
+ ...staleArgs,
359
477
  ],
360
478
  });
361
479
  const reaped = (rowsAffected ?? 0) > 0;
@@ -675,12 +793,13 @@ export async function getCurrentTurnEventsForThread(
675
793
  export async function reapAllStaleRuns(): Promise<number> {
676
794
  await ensureRunTables();
677
795
  const client = getDbExec();
678
- const heartbeatCutoff = Date.now() - RUN_STALE_MS;
796
+ const now = Date.now();
797
+ // Background-dispatched runs use the wider window; everything else 15s.
679
798
  const stale = await client.execute({
680
799
  sql: `SELECT id FROM agent_runs
681
800
  WHERE status = 'running'
682
- AND COALESCE(heartbeat_at, started_at) < ?`,
683
- args: [heartbeatCutoff],
801
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
802
+ args: [now],
684
803
  });
685
804
  const completedAt = Date.now();
686
805
  const { rowsAffected } = await client.execute({
@@ -690,12 +809,12 @@ export async function reapAllStaleRuns(): Promise<number> {
690
809
  error_code = ?,
691
810
  error_detail = ?
692
811
  WHERE status = 'running'
693
- AND COALESCE(heartbeat_at, started_at) < ?`,
812
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
694
813
  args: [
695
814
  completedAt,
696
815
  STALE_RUN_ERROR_EVENT.errorCode,
697
816
  STALE_RUN_ERROR_EVENT.details,
698
- heartbeatCutoff,
817
+ completedAt,
699
818
  ],
700
819
  });
701
820
  for (const row of stale.rows) {
@@ -730,15 +849,15 @@ export async function cleanupOldRuns(
730
849
  // SELECT covers BOTH UPDATE conditions so the terminal-event-append loop
731
850
  // below catches every row we're about to flip — a 24h-old row with a
732
851
  // somehow-fresh heartbeat would slip past a heartbeat-only SELECT.
733
- const heartbeatCutoff = Date.now() - RUN_STALE_MS;
852
+ const now = Date.now();
734
853
  const stale = await client.execute({
735
854
  sql: `SELECT id FROM agent_runs
736
855
  WHERE status = 'running'
737
856
  AND (
738
- COALESCE(heartbeat_at, started_at) < ?
857
+ COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}
739
858
  OR started_at < ?
740
859
  )`,
741
- args: [heartbeatCutoff, cutoff],
860
+ args: [now, cutoff],
742
861
  });
743
862
  const completedAt = Date.now();
744
863
  await client.execute({
@@ -755,7 +874,8 @@ export async function cleanupOldRuns(
755
874
  cutoff,
756
875
  ],
757
876
  });
758
- // Also expire runs whose heartbeat is stale — producer has died.
877
+ // Also expire runs whose heartbeat is stale — producer has died. Uses the
878
+ // background-aware window so a slow background cold-start isn't reaped early.
759
879
  await client.execute({
760
880
  sql: `UPDATE agent_runs
761
881
  SET status = 'errored',
@@ -763,12 +883,12 @@ export async function cleanupOldRuns(
763
883
  error_code = ?,
764
884
  error_detail = ?
765
885
  WHERE status = 'running'
766
- AND COALESCE(heartbeat_at, started_at) < ?`,
886
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
767
887
  args: [
768
888
  completedAt,
769
889
  STALE_RUN_ERROR_EVENT.errorCode,
770
890
  STALE_RUN_ERROR_EVENT.details,
771
- heartbeatCutoff,
891
+ completedAt,
772
892
  ],
773
893
  });
774
894
  for (const row of stale.rows) {
@@ -129,6 +129,26 @@ export interface AgentChatRequest {
129
129
  attachments?: AgentChatAttachment[];
130
130
  /** Internal retry/continuation requests should not create visible user turns. */
131
131
  internalContinuation?: boolean;
132
+ /**
133
+ * Internal marker set ONLY by the durable-background self-dispatch (see
134
+ * `AGENT_CHAT_BACKGROUND_RUN_FIELD`). Present when the agent-chat handler is
135
+ * re-entered as the background worker: it carries the pre-claimed `runId` and
136
+ * logical `turnId` so the worker runs the loop inline with the background
137
+ * soft-timeout instead of re-claiming the slot or re-dispatching. Untrusted
138
+ * on its own — the `_process-run` route HMAC-verifies the dispatch before
139
+ * invoking the handler. Absent on every normal client request.
140
+ */
141
+ __backgroundRun?: {
142
+ runId: string;
143
+ turnId?: string;
144
+ /**
145
+ * Number of server-driven background→background continuations already
146
+ * chained into this logical turn (0 on the first chunk). The worker
147
+ * increments this when it re-fires `_process-run` at a soft-timeout
148
+ * boundary and refuses to chain past `MAX_BACKGROUND_RUN_CONTINUATIONS`.
149
+ */
150
+ continuationCount?: number;
151
+ };
132
152
  /**
133
153
  * Stable identity for the logical assistant turn this request belongs to.
134
154
  * The client sends the SAME turnId for the initial POST and every
@@ -4,6 +4,7 @@ import {
4
4
  isPostgres,
5
5
  intType,
6
6
  } from "../db/client.js";
7
+ import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
7
8
  import { emitAppStateChange, emitAppStateDelete } from "./emitter.js";
8
9
  import type { StoreWriteOptions } from "../settings/store.js";
9
10
 
@@ -30,6 +31,10 @@ async function ensureTable(): Promise<void> {
30
31
  PRIMARY KEY (session_id, key)
31
32
  )
32
33
  `);
34
+ // Older deployments created `updated_at` as 32-bit `INTEGER`; on Postgres
35
+ // the `Date.now()` written by appStatePut() on every turn overflows int4.
36
+ // Widen it in place (no-op once done / on fresh BIGINT databases).
37
+ await widenIntColumnsToBigInt("application_state", ["updated_at"]);
33
38
  // Indexes for the two hot poll paths:
34
39
  // - `SELECT … WHERE updated_at > ?` (watermark scan, every poll cycle)
35
40
  // - `SELECT … WHERE key = ? … ORDER BY updated_at ASC` (marker lookups)
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { defineAction } from "../../action.js";
3
+ import { getAuditEventById } from "../store.js";
4
+
5
+ /**
6
+ * Fetch a single audit event by id, including its redacted input payload.
7
+ * Scoped to the caller's identity — returns null if they can't access it.
8
+ */
9
+ export default defineAction({
10
+ description:
11
+ "Get one audit-log event by id, with its full redacted input payload. Returns null if you don't have access to it.",
12
+ schema: z.object({
13
+ id: z.string().describe("The audit event id."),
14
+ }),
15
+ http: { method: "GET" },
16
+ run: async (args, ctx) => {
17
+ const event = await getAuditEventById(args.id, {
18
+ userEmail: ctx?.userEmail,
19
+ orgId: ctx?.orgId ?? null,
20
+ });
21
+ return { event };
22
+ },
23
+ });
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import { defineAction } from "../../action.js";
3
+ import { queryAuditEvents } from "../store.js";
4
+
5
+ /**
6
+ * List audit-log events the current user can see — their own actions plus the
7
+ * agent's actions on their behalf, scoped in SQL to the caller's identity and
8
+ * org. Read-only; never exposes other tenants' rows.
9
+ */
10
+ export default defineAction({
11
+ description:
12
+ "List audit-log events (who changed what, when, and whether it was you or the agent) for resources you can access. Supports filtering by target resource, actor (agent vs human), status, agent thread/turn, and time. Use this to answer 'what did the agent change', 'who edited this record', or 'show recent changes'.",
13
+ schema: z.object({
14
+ targetType: z
15
+ .string()
16
+ .optional()
17
+ .describe("Filter to one resource type, e.g. 'recording'."),
18
+ targetId: z
19
+ .string()
20
+ .optional()
21
+ .describe("Filter to one resource id (pair with targetType)."),
22
+ actorKind: z
23
+ .enum(["agent", "human", "system"])
24
+ .optional()
25
+ .describe("Filter to changes made by the agent, a human, or the system."),
26
+ actorEmail: z.string().optional().describe("Filter to one actor's email."),
27
+ status: z
28
+ .enum(["success", "error", "denied"])
29
+ .optional()
30
+ .describe("Filter by outcome."),
31
+ threadId: z.string().optional().describe("Filter to one agent thread."),
32
+ turnId: z
33
+ .string()
34
+ .optional()
35
+ .describe("Filter to one agent turn (a single agent response)."),
36
+ action: z.string().optional().describe("Filter to one action name."),
37
+ sinceMs: z
38
+ .number()
39
+ .optional()
40
+ .describe("Only events at or after this Unix epoch (ms)."),
41
+ limit: z
42
+ .number()
43
+ .optional()
44
+ .describe("Max events to return (default 100, max 500)."),
45
+ }),
46
+ http: { method: "GET" },
47
+ run: async (args, ctx) => {
48
+ const events = await queryAuditEvents(
49
+ { userEmail: ctx?.userEmail, orgId: ctx?.orgId ?? null },
50
+ {
51
+ ...(args.targetType ? { targetType: args.targetType } : {}),
52
+ ...(args.targetId ? { targetId: args.targetId } : {}),
53
+ ...(args.actorKind ? { actorKind: args.actorKind } : {}),
54
+ ...(args.actorEmail ? { actorEmail: args.actorEmail } : {}),
55
+ ...(args.status ? { status: args.status } : {}),
56
+ ...(args.threadId ? { threadId: args.threadId } : {}),
57
+ ...(args.turnId ? { turnId: args.turnId } : {}),
58
+ ...(args.action ? { action: args.action } : {}),
59
+ ...(typeof args.sinceMs === "number" ? { sinceMs: args.sinceMs } : {}),
60
+ ...(typeof args.limit === "number" ? { limit: args.limit } : {}),
61
+ },
62
+ );
63
+ return { events, count: events.length };
64
+ },
65
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Audit-log retention job.
3
+ *
4
+ * Periodically purges `agent_audit_log` rows older than the configured horizon
5
+ * so the log doesn't grow unbounded. Retention is configurable via
6
+ * `AGENT_NATIVE_AUDIT_RETENTION_DAYS` (default: 365 days — audit trails are kept
7
+ * far longer than sampled traces). Setting it to `0` disables the purge (keep
8
+ * forever). Mirrors observability/cleanup-job.ts.
9
+ *
10
+ * Runs once on startup after a short delay, then on a 24-hour interval. Timers
11
+ * are unref'd so they never keep the process alive on their own.
12
+ */
13
+ import { deleteOldAuditEvents } from "./store.js";
14
+
15
+ const DEFAULT_RETENTION_DAYS = 365;
16
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
17
+ const STARTUP_DELAY_MS = 5 * 60 * 1000;
18
+
19
+ let _cleanupTimer: NodeJS.Timeout | null = null;
20
+ let _intervalTimer: NodeJS.Timeout | null = null;
21
+
22
+ function resolveRetentionDays(): number {
23
+ const raw = process.env.AGENT_NATIVE_AUDIT_RETENTION_DAYS;
24
+ if (raw === undefined || raw === null || raw === "") {
25
+ return DEFAULT_RETENTION_DAYS;
26
+ }
27
+ const parsed = Number.parseInt(raw, 10);
28
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RETENTION_DAYS;
29
+ return parsed;
30
+ }
31
+
32
+ /**
33
+ * Run the audit cleanup once. Returns the deleted row count, or null when
34
+ * retention is disabled (`AGENT_NATIVE_AUDIT_RETENTION_DAYS=0`).
35
+ */
36
+ export async function runAuditCleanupOnce(): Promise<number | null> {
37
+ const days = resolveRetentionDays();
38
+ if (days === 0) return null;
39
+ const cutoff = Date.now() - days * ONE_DAY_MS;
40
+ return deleteOldAuditEvents(cutoff);
41
+ }
42
+
43
+ /**
44
+ * Start the recurring audit-cleanup job. Idempotent — calling more than once is
45
+ * a no-op while a previous schedule is active. Returns a stop function.
46
+ */
47
+ export function startAuditCleanupJob(): () => void {
48
+ if (_cleanupTimer || _intervalTimer) return stopAuditCleanupJob;
49
+ const days = resolveRetentionDays();
50
+ if (days === 0) {
51
+ if (process.env.DEBUG)
52
+ // eslint-disable-next-line no-console
53
+ console.log(
54
+ "[audit] Audit cleanup disabled (AGENT_NATIVE_AUDIT_RETENTION_DAYS=0)",
55
+ );
56
+ return () => {};
57
+ }
58
+
59
+ const tick = () => {
60
+ runAuditCleanupOnce()
61
+ .then((deleted) => {
62
+ if (deleted == null) return;
63
+ if (process.env.DEBUG) {
64
+ // eslint-disable-next-line no-console
65
+ console.log(
66
+ `[audit] Audit cleanup purged ${deleted} rows (retention=${days}d)`,
67
+ );
68
+ }
69
+ })
70
+ .catch((err) => {
71
+ // eslint-disable-next-line no-console
72
+ console.error("[audit] Audit cleanup failed:", err?.message ?? err);
73
+ });
74
+ };
75
+
76
+ _cleanupTimer = setTimeout(() => {
77
+ _cleanupTimer = null;
78
+ tick();
79
+ _intervalTimer = setInterval(tick, ONE_DAY_MS);
80
+ if (typeof _intervalTimer.unref === "function") _intervalTimer.unref();
81
+ }, STARTUP_DELAY_MS);
82
+ if (typeof _cleanupTimer.unref === "function") _cleanupTimer.unref();
83
+
84
+ if (process.env.DEBUG)
85
+ // eslint-disable-next-line no-console
86
+ console.log(`[audit] Audit cleanup scheduled (retention=${days}d, daily)`);
87
+
88
+ return stopAuditCleanupJob;
89
+ }
90
+
91
+ export function stopAuditCleanupJob(): void {
92
+ if (_cleanupTimer) {
93
+ clearTimeout(_cleanupTimer);
94
+ _cleanupTimer = null;
95
+ }
96
+ if (_intervalTimer) {
97
+ clearInterval(_intervalTimer);
98
+ _intervalTimer = null;
99
+ }
100
+ }