@kky42/pievo-server 2.4.14 → 3.0.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 (90) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{actionUi-5PtES4q0.js → actionUi-BZrLpgHT.js} +1 -1
  3. package/.output/public/assets/app-DXLuQNb9.css +2 -0
  4. package/.output/public/assets/index-Di_wpAtL.js +10 -0
  5. package/.output/public/assets/loops._loopId-X3ClNPMY.js +67 -0
  6. package/.output/public/assets/loops._loopId_.runs._runId-ByU2BSyF.js +3 -0
  7. package/.output/public/assets/preload-helper-hRTYB_ju.js +3 -0
  8. package/.output/public/assets/{routes-Dm_3J2aV.js → routes-BIOJZsig.js} +1 -1
  9. package/.output/public/assets/routes-K8D5uBiV.js +1 -0
  10. package/.output/server/_chunks/ssr-renderer.mjs +1 -1
  11. package/.output/server/_libs/{drizzle-orm+postgres.mjs → drizzle-orm.mjs} +93 -2218
  12. package/.output/server/_ssr/{MachinesModal-BaEtzycr.mjs → MachinesModal-CmVMec6B.mjs} +46 -46
  13. package/.output/server/_ssr/{actionUi-5HOCZnom.mjs → actionUi-DE-Pb2tv.mjs} +1 -2
  14. package/.output/server/_ssr/{artifactFiles-DO43wwXA.mjs → artifactFiles-Di1xDKvX.mjs} +5 -8
  15. package/.output/server/_ssr/{artifactKind-DwDr8IYc.mjs → artifactKind-DqpmtxDU.mjs} +3 -9
  16. package/.output/server/_ssr/{boot-C472kLwY.mjs → boot-CYRi-wez.mjs} +2152 -351
  17. package/.output/server/_ssr/lifecycleUi-BsbUsi5K.mjs +106 -0
  18. package/.output/server/_ssr/{loopApi-DT751TJA.mjs → loopApi-Bez1rHV0.mjs} +50 -66
  19. package/.output/server/_ssr/{loopProjection-qaD2I48a.mjs → loopProjection-esCjyZJg.mjs} +4 -23
  20. package/.output/server/_ssr/{loops._loopId-CXgw2Rz1.mjs → loops._loopId-KUezNY69.mjs} +8 -23
  21. package/.output/server/_ssr/{loops._loopId_.runs._runId-Dk94Z0Jq.mjs → loops._loopId_.runs._runId-BFP9oMrp.mjs} +2 -7
  22. package/.output/server/_ssr/{loops._loopId_.runs._runId-SASSqOfy.mjs → loops._loopId_.runs._runId-DOkUEesG.mjs} +18 -33
  23. package/.output/server/_ssr/{machineFns-BSrIgn3f.mjs → machineFns-CniGcY8s.mjs} +13 -50
  24. package/.output/server/_ssr/networkAccess-CIlK3ION.mjs +30 -0
  25. package/.output/server/_ssr/router-BFIH2UAE.mjs +362 -0
  26. package/.output/server/_ssr/routes-BSVBANI3.mjs +12 -0
  27. package/.output/server/_ssr/{routes-Cr-9yDEH.mjs → routes-DnE7XDe_.mjs} +91 -50
  28. package/.output/server/_ssr/{routes-DRBS4Zi4.mjs → routes-ngpX7h8D.mjs} +2 -4
  29. package/.output/server/_ssr/ssr.mjs +25 -34
  30. package/.output/server/_ssr/start-B5_9Z5W-.mjs +35 -0
  31. package/.output/server/{_tanstack-start-manifest_v-DxLIQ0cc.mjs → _tanstack-start-manifest_v-DnwAupbZ.mjs} +8 -13
  32. package/.output/server/drizzle/0000_baseline.sql +6 -97
  33. package/.output/server/drizzle/meta/_journal.json +2 -23
  34. package/.output/server/index.mjs +45 -52
  35. package/drizzle/0000_baseline.sql +6 -97
  36. package/drizzle/meta/_journal.json +2 -23
  37. package/package.json +4 -8
  38. package/scripts/pievo-server.mjs +49 -47
  39. package/scripts/server-cli-lib.mjs +21 -12
  40. package/scripts/server-network.mjs +139 -0
  41. package/.output/public/assets/SignIn-CIFwnZtF.js +0 -1
  42. package/.output/public/assets/app-DvHMhs7S.css +0 -2
  43. package/.output/public/assets/index-CRX2pu0W.js +0 -10
  44. package/.output/public/assets/loops._loopId-BsuV-Rmf.js +0 -67
  45. package/.output/public/assets/loops._loopId_.runs._runId-DpK_fzmZ.js +0 -3
  46. package/.output/public/assets/preload-helper-sAZOZb3G.js +0 -5
  47. package/.output/public/assets/routes-DxF_s3cD.js +0 -1
  48. package/.output/server/_libs/@aws-crypto/crc32+[...].mjs +0 -419
  49. package/.output/server/_libs/@aws-sdk/checksums+[...].mjs +0 -10732
  50. package/.output/server/_libs/@aws-sdk/client-s3+[...].mjs +0 -9475
  51. package/.output/server/_libs/@aws-sdk/credential-provider-http+[...].mjs +0 -137
  52. package/.output/server/_libs/@aws-sdk/credential-provider-ini+[...].mjs +0 -440
  53. package/.output/server/_libs/@aws-sdk/credential-provider-process+[...].mjs +0 -62
  54. package/.output/server/_libs/@aws-sdk/credential-provider-sso+[...].mjs +0 -818
  55. package/.output/server/_libs/@aws-sdk/credential-provider-web-identity+[...].mjs +0 -59
  56. package/.output/server/_libs/@smithy/credential-provider-imds+[...].mjs +0 -342
  57. package/.output/server/_libs/aws-crypto__crc32c.mjs +0 -314
  58. package/.output/server/_libs/aws-sdk__nested-clients.mjs +0 -2671
  59. package/.output/server/_ssr/SignIn-sHQaBQJC.mjs +0 -53
  60. package/.output/server/_ssr/artifacts-Cd7Qx3Qp.mjs +0 -2
  61. package/.output/server/_ssr/auth-0AamrwHo.mjs +0 -38355
  62. package/.output/server/_ssr/auth-client-lSMb_Idt.mjs +0 -1070
  63. package/.output/server/_ssr/bun-sqlite-dialect-BW9W1_Ps-DLIXocA0.mjs +0 -162
  64. package/.output/server/_ssr/core-RrmWoKne.mjs +0 -5
  65. package/.output/server/_ssr/d1-sqlite-dialect-BLC8LXE6-BoFMIPr_.mjs +0 -112
  66. package/.output/server/_ssr/db-BzvMQ2kx.mjs +0 -2
  67. package/.output/server/_ssr/dist-ClzqkwxD.mjs +0 -342
  68. package/.output/server/_ssr/factory-ixUjZqJn.mjs +0 -6903
  69. package/.output/server/_ssr/kysely-adapter-DkZbmKgV.mjs +0 -2
  70. package/.output/server/_ssr/kysely-migration-tables-JkVUjPF_-bPAX9LR_.mjs +0 -19
  71. package/.output/server/_ssr/logger-DDjsYB2X.mjs +0 -2
  72. package/.output/server/_ssr/loopTags-CXx5UdGf.mjs +0 -3356
  73. package/.output/server/_ssr/node-sqlite-dialect-BbUqnnYv.mjs +0 -162
  74. package/.output/server/_ssr/router-BlLlhfJe.mjs +0 -543
  75. package/.output/server/_ssr/routes-DTBltabU.mjs +0 -27
  76. package/.output/server/_ssr/start-4ifArtKx.mjs +0 -4
  77. package/.output/server/_ssr/store-vDZ9INSa.mjs +0 -2
  78. package/.output/server/_ssr/tokens-BHiCElYD.mjs +0 -2
  79. package/.output/server/drizzle/0001_add_pi_agent.sql +0 -9
  80. package/.output/server/drizzle/0002_remove_teams.sql +0 -15
  81. package/.output/server/drizzle/0003_add_loop_tags.sql +0 -5
  82. package/.output/server/node_modules/tslib/modules/index.js +0 -70
  83. package/.output/server/node_modules/tslib/modules/package.json +0 -3
  84. package/.output/server/node_modules/tslib/package.json +0 -47
  85. package/.output/server/node_modules/tslib/tslib.js +0 -484
  86. package/.output/server/package.json +0 -9
  87. package/drizzle/0001_add_pi_agent.sql +0 -9
  88. package/drizzle/0002_remove_teams.sql +0 -15
  89. package/drizzle/0003_add_loop_tags.sql +0 -5
  90. package/scripts/prestart.mjs +0 -66
@@ -1,31 +1,2091 @@
1
- import { r as __exportAll } from "../_runtime.mjs";
2
- import { _ as isNotNull, d as and, f as eq, g as inArray, j as sql, m as gte, u as desc, x as lte } from "../_libs/drizzle-orm+postgres.mjs";
1
+ import { o as __toESM, r as __exportAll$1 } from "../_runtime.mjs";
2
+ import { t as Ve } from "../_libs/electric-sql__pglite.mjs";
3
+ import { C as integer, S as jsonb, T as sql, _ as notInArray, a as asc, b as timestamp, c as eq, d as inArray, f as isNotNull, g as ne, h as lte, i as check, l as gt, m as lt, n as index, o as desc, p as isNull, r as uniqueIndex, s as and, t as drizzle, u as gte, v as or, w as boolean, x as text, y as pgTable } from "../_libs/drizzle-orm.mjs";
3
4
  import { t as E } from "../_libs/croner.mjs";
4
- import { $ as getLoop, $t as reclaimRun, A as createLoop, An as updateLoop, At as logger, B as enqueueRun, Bt as parseArtifactSyncBody, C as claimReadyRunForMachine, Cn as sha256Buf, Ct as listLoops, D as countRuns, Dn as stopLoop, Dt as listRuns, E as countRunSnapshots, En as startLoop, F as dbWatchdogFailureThreshold, Ft as machinePresence, Gt as pruneRunSnapshots, H as expirePendingRun, Ht as pauseLoopState, I as dbWatchdogIntervalMs, It as machineReferencesBlob, Jt as putTerminalReportIncidentIfAbsent, K as gcIntervalMs, Kt as putReportReceiptIfAbsent, L as dbWatchdogTimeoutMs, M as dataDir, Mn as upsertConfiguredArtifactFile, Mt as loopIdsWithSnapshots, N as db, Nn as validateLoopTags, Nt as loopsForMachine, P as dbWatchdogEnabled, Pt as machineIdFromToken, Sn as sha256, T as coerceCodingAgent, Tn as snapshotRetention, Tt as listMachines, U as finalizeRunningRun, Ut as prevRunSnapshot, W as forceDeleteLoop, Wt as pruneExpiredLeases, Xt as readConnectKeyBinding, Yt as r2Config, Z as getExactTerminalReportIncident, Zt as readNewIdempotency, _ as blobExists, _n as runningRunForLoop, an as recordRunReportOnce, at as getRunSnapshot, b as blobSizes, cn as releaseAbsentReconciliations, dn as requestDeleteLoop, dt as isDeviceTokenShape, en as reconcileConfiguredArtifacts, et as getMachine, f as acknowledgeRetiredReport, fn as requestRunCancel, gn as runMigrations, h as artifactFileReferencesHash, hn as retireLease, in as recordNewIdempotency, it as getRun, j as createMachine, jn as updateMachine, jt as loginGateEnabled, kn as tryDeleteLoop, kt as liveBlobRefs, lt as initializeCronCadence, m as advanceDueSchedules, mn as resolveLease, on as refreshRunHeartbeats, ot as getTerminalReportIncidents, p as acknowledgeRetiredTerminalIncident, pt as isOutdated, qt as putRunSnapshot, rn as recordBlob, rt as getReportReceipt, s as CODING_AGENTS, sn as rejectTerminalReport, tn as reconcileReclaimedRun, u as TERMINAL_GRACE_MS, un as repairTerminalRunLeases, v as blobGcGraceMs, vn as runningRunsForMachine, vt as isValidHash, w as closeClient, wn as snapshotBlobRefs, x as buildLoopManifest, xn as setMachineOnline, xt as lastRun, y as blobHashesOlderThan, yn as runs, yt as isValidSemver, z as deleteBlob, zt as openRuns } from "./loopTags-CXx5UdGf.mjs";
5
- import { i as __exportAll$1 } from "./factory-ixUjZqJn.mjs";
5
+ import { t as require_pino } from "../_libs/pino+[...].mjs";
6
+ import { t as require_pino_pretty } from "../_libs/pino-pretty+[...].mjs";
6
7
  import { t as createTwoFilesPatch } from "../_libs/diff.mjs";
7
- import { createHash, randomUUID } from "node:crypto";
8
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
9
+ import fs from "node:fs";
10
+ import { fileURLToPath } from "node:url";
8
11
  import path from "node:path";
12
+ import os from "node:os";
9
13
  import fs$1 from "node:fs/promises";
10
- //#region node_modules/.nitro/vite/services/ssr/assets/boot-C472kLwY.js
11
- var boot_C472kLwY_exports = /* @__PURE__ */ __exportAll({
14
+ //#region node_modules/.nitro/vite/services/ssr/assets/loopTags-C6vATFCp.js
15
+ var loopTags_C6vATFCp_exports = /* @__PURE__ */ __exportAll$1({
16
+ $: () => lastRun,
17
+ A: () => buildLoopManifest,
18
+ At: () => runningRunForLoop,
19
+ B: () => forceDeleteLoop,
20
+ Bt: () => upsertConfiguredArtifactFile,
21
+ C: () => acknowledgeRetiredReport,
22
+ Ct: () => recordRunReportOnce,
23
+ D: () => blobExists,
24
+ Dt: () => repairTerminalRunLeases,
25
+ E: () => artifactFileReferencesHash,
26
+ Et: () => releaseAbsentReconciliations,
27
+ F: () => createMachine,
28
+ Ft: () => stopLoop,
29
+ G: () => getMachine,
30
+ Gt: () => CODING_AGENTS,
31
+ H: () => getArtifactFile,
32
+ Ht: () => db,
33
+ I: () => deleteBlob,
34
+ It: () => store_exports,
35
+ J: () => getRunSnapshot,
36
+ Jt: () => logger_exports,
37
+ K: () => getReportReceipt,
38
+ Kt: () => coerceCodingAgent,
39
+ L: () => enqueueRun,
40
+ Lt: () => tryDeleteLoop,
41
+ M: () => countRunSnapshots,
42
+ Mt: () => setMachineOnline,
43
+ N: () => countRuns,
44
+ Nt: () => snapshotBlobRefs,
45
+ O: () => blobHashesOlderThan,
46
+ Ot: () => requestDeleteLoop,
47
+ P: () => createLoop,
48
+ Pt: () => startLoop,
49
+ Q: () => initializeCronCadence,
50
+ Qt: () => __exportAll,
51
+ R: () => expirePendingRun,
52
+ Rt: () => updateLoop,
53
+ S: () => sha256Buf,
54
+ St: () => recordBlob,
55
+ T: () => advanceDueSchedules,
56
+ Tt: () => rejectTerminalReport,
57
+ U: () => getExactTerminalReportIncident,
58
+ Ut: () => runMigrations,
59
+ V: () => forceDeleteMachine,
60
+ Vt: () => closeClient,
61
+ W: () => getLoop,
62
+ Wt: () => runs,
63
+ X: () => hasPendingRun,
64
+ Xt: () => GC_INTERVAL_MS,
65
+ Y: () => getTerminalReportIncidents,
66
+ Yt: () => BLOB_GC_GRACE_MS,
67
+ Z: () => hasRunningRun,
68
+ Zt: () => dataDir,
69
+ _: () => BLOB_CAP,
70
+ _t: () => recentLoopUsage,
71
+ a: () => TERMINAL_GRACE_MS,
72
+ at: () => liveBlobRefs,
73
+ b: () => parseArtifactSyncBody,
74
+ bt: () => reconcileReclaimedRun,
75
+ c: () => machineIdFromToken,
76
+ ct: () => machineReferencesBlob,
77
+ d: () => readNewIdempotency,
78
+ dt: () => pauseLoopState,
79
+ et: () => listArtifacts,
80
+ f: () => recordNewIdempotency,
81
+ ft: () => prevRunSnapshot,
82
+ g: () => tokens_exports,
83
+ gt: () => putTerminalReportIncidentIfAbsent,
84
+ h: () => sha256,
85
+ ht: () => putRunSnapshot,
86
+ i: () => machinePresence,
87
+ it: () => listRunsBefore,
88
+ j: () => claimReadyRunForMachine,
89
+ jt: () => runningRunsForMachine,
90
+ k: () => blobSizes,
91
+ kt: () => requestRunCancel,
92
+ l: () => mintDeviceToken,
93
+ lt: () => openRuns,
94
+ m: () => retireLease,
95
+ mt: () => putReportReceiptIfAbsent,
96
+ n: () => isOutdated,
97
+ nt: () => listMachines,
98
+ o: () => connectKeyValid,
99
+ ot: () => loopIdsWithSnapshots,
100
+ p: () => resolveLease,
101
+ pt: () => pruneRunSnapshots,
102
+ q: () => getRun,
103
+ qt: () => logger,
104
+ r: () => isValidSemver,
105
+ rt: () => listRuns,
106
+ s: () => isDeviceTokenShape,
107
+ st: () => loopsForMachine,
108
+ t: () => validateLoopTags,
109
+ tt: () => listLoops,
110
+ u: () => pruneExpiredLeases,
111
+ ut: () => pauseLoop,
112
+ v: () => artifacts_exports,
113
+ vt: () => reclaimRun,
114
+ w: () => acknowledgeRetiredTerminalIncident,
115
+ wt: () => refreshRunHeartbeats,
116
+ x: () => safeRelPath,
117
+ xt: () => reconciliationStatesForRuns,
118
+ y: () => isValidHash,
119
+ yt: () => reconcileConfiguredArtifacts,
120
+ z: () => finalizeRunningRun,
121
+ zt: () => updateMachine
122
+ });
123
+ var import_pino = /* @__PURE__ */ __toESM(require_pino());
124
+ var import_pino_pretty = /* @__PURE__ */ __toESM(require_pino_pretty());
125
+ var __defProp = Object.defineProperty;
126
+ var __exportAll = (all, no_symbols) => {
127
+ let target = {};
128
+ for (var name in all) __defProp(target, name, {
129
+ get: all[name],
130
+ enumerable: true
131
+ });
132
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
133
+ return target;
134
+ };
135
+ /**
136
+ * Server data directory: the embedded database (`pgdata/`), artifact bytes
137
+ * (`blobs/`), and the launcher's pid/lock/log files. Kept apart from the
138
+ * daemon's `~/.pievo` so a device running both never mixes their state.
139
+ * Override with `PIEVO_DATA_DIR` (absolute; `~` is not expanded).
140
+ */
141
+ function dataDir() {
142
+ return process.env.PIEVO_DATA_DIR?.trim() || path.join(os.homedir(), ".pievo-server");
143
+ }
144
+ /**
145
+ * A blob whose metadata row is younger than this is never collected, so a
146
+ * sync that just wrote a blob can still reference it. A leaked blob is only a
147
+ * storage cost that the next pass reclaims. 1 hour.
148
+ */
149
+ var BLOB_GC_GRACE_MS = 3600 * 1e3;
150
+ /** Storage-maintenance cadence (prune snapshots, then GC blobs). 15 minutes. */
151
+ var GC_INTERVAL_MS = 900 * 1e3;
152
+ /**
153
+ * Uses pino-pretty as a *synchronous* destination stream (not a worker-thread
154
+ * transport) so logs flush before process.exit() in the one-shot CLIs. Writes to
155
+ * stderr (fd 2) to keep stdout clean for QR codes and human CLI output.
156
+ */
157
+ var logger_exports = /* @__PURE__ */ __exportAll({ logger: () => logger });
158
+ var level = (process.env.PIEVO_LOG_LEVEL || "info").toLowerCase();
159
+ var stream = (0, import_pino_pretty.default)({
160
+ destination: 2,
161
+ colorize: true,
162
+ translateTime: "SYS:HH:MM:ss",
163
+ ignore: "pid,hostname"
164
+ });
165
+ var logger = (0, import_pino.default)({ level }, stream);
166
+ /** The coding agent a loop is bound to AND executed with (BYOA on the owner's
167
+ * machine): `claude-code` → Claude Code, `codex` → `codex exec`, `pi` → Pi.
168
+ * Non-Claude agents may still have thinner daemon telemetry until stream
169
+ * integration lands; execution itself is real for every value.
170
+ *
171
+ * Runtime SINGLE SOURCE (anti-drift): every server consumer DERIVES from this
172
+ * array — the `CodingAgent` type here, the `db/schema.ts` `CodingAgent` type AND
173
+ * the `loops.agent` column enum, the edit validator (`coerceCodingAgent`), and the
174
+ * web agent `<select>` (LoopForm). So widening the set is a one-line edit HERE with
175
+ * no other server change (the daemon's own enum in `packages/daemon/src/create.ts`
176
+ * is a separate package, widened alongside). */
177
+ var CODING_AGENTS = [
178
+ "claude-code",
179
+ "codex",
180
+ "pi"
181
+ ];
182
+ function coerceCodingAgent(value) {
183
+ return typeof value === "string" && CODING_AGENTS.includes(value) ? value : null;
184
+ }
185
+ var RESERVED_LOOP_TAGS = [
186
+ "all loops",
187
+ "active",
188
+ "paused",
189
+ "blocked"
190
+ ];
191
+ /**
192
+ * Pievo schema (Drizzle, Postgres `pg-core` dialect, served by embedded PGlite).
193
+ * Pievo is single-user: no row carries an owner. Timestamp values are ISO
194
+ * strings; JSON columns use `jsonb().$type<>()`.
195
+ */
196
+ /** `skipped`: a pending run retired without executing (pause/retention cleanup,
197
+ * queue coalescing, or the offline catch-up window elapsed).
198
+ * Neither success nor failure — excluded from the failure streak (it rides
199
+ * phase `canceled`, and the streak counts only phase `error`). */
200
+ var machines = pgTable("machines", {
201
+ id: text("id").primaryKey(),
202
+ name: text("name").notNull(),
203
+ hostname: text("hostname"),
204
+ platform: text("platform"),
205
+ arch: text("arch"),
206
+ daemonVersion: text("daemon_version"),
207
+ /** Breaking machine protocol last observed on poll. */
208
+ daemonProtocol: integer("daemon_protocol"),
209
+ tokenHash: text("token_hash").notNull(),
210
+ /**
211
+ * Plaintext device token. Stored so the UI can re-show the connect command
212
+ * anytime (MVP convenience — deviates from "store only the hash"; acceptable
213
+ * for a self-hosted control plane where the DB is already the trust root).
214
+ */
215
+ token: text("token"),
216
+ roots: jsonb("roots").$type(),
217
+ lastSeen: text("last_seen"),
218
+ online: boolean("online").notNull().default(false),
219
+ createdAt: text("created_at").notNull()
220
+ });
221
+ var loops = pgTable("loops", {
222
+ id: text("id").primaryKey(),
223
+ /** Execution machine (set at creation; no cross-machine fallback). */
224
+ machineId: text("machine_id").notNull(),
225
+ name: text("name").notNull(),
226
+ tags: text("tags").array().notNull().default(sql`ARRAY[]::text[]`),
227
+ prompt: text("prompt").notNull(),
228
+ statusKeep: text("status_keep").notNull(),
229
+ statusNoChange: text("status_no_change").notNull(),
230
+ statusBlock: text("status_block").notNull(),
231
+ /** Exact workdir-relative artifact paths collected by the daemon after provider exit. */
232
+ artifacts: jsonb("artifacts").$type().notNull().default([]),
233
+ cron: text("cron").notNull(),
234
+ scheduleMode: text("schedule_mode", { enum: ["cron", "continuous"] }).notNull(),
235
+ /** Cron-only overlap behavior. Continuous runs never overlap. */
236
+ cronOverlap: text("cron_overlap", { enum: ["skip", "queue-one"] }).notNull(),
237
+ continuousDelayMinutes: integer("continuous_delay_minutes").notNull(),
238
+ timezone: text("timezone"),
239
+ workdir: text("workdir").notNull(),
240
+ /** Durable Stop-before-delete marker. A deleting loop is never claimable. */
241
+ deleteRequestedAt: timestamp("delete_requested_at", {
242
+ withTimezone: true,
243
+ mode: "string"
244
+ }),
245
+ pauseCause: jsonb("pause_cause").$type(),
246
+ model: text("model"),
247
+ reasoningEffort: text("reasoning_effort"),
248
+ /** Coding agent this loop is BOUND TO and EXECUTED with: the daemon's
249
+ * `buildAgentSpawn` branches on this value (`claude-code` → claude,
250
+ * `codex` → `codex exec`, `pi` → Pi print mode). Measured from the creating CLI's env when
251
+ * detectable, else the declared/selected value. The text column carries
252
+ * both a TypeScript enum and a database CHECK constraint. */
253
+ agent: text("agent", { enum: CODING_AGENTS }).notNull(),
254
+ enabled: boolean("enabled").notNull().default(true),
255
+ nextRunAt: text("next_run_at"),
256
+ /** The next recurring cadence occurrence not yet materialized as a run. Cron
257
+ * advances this fact after each due occurrence; continuous clears it on due
258
+ * and restores it from a successful/error terminal. */
259
+ nextCadenceAt: text("next_cadence_at"),
260
+ lastRunIndex: integer("last_run_index").notNull().default(0),
261
+ createdAt: text("created_at").notNull(),
262
+ updatedAt: text("updated_at").notNull()
263
+ }, (t) => [
264
+ index("loops_machine_idx").on(t.machineId),
265
+ index("delete_requested_loops").on(t.deleteRequestedAt).where(sql`${t.deleteRequestedAt} IS NOT NULL`),
266
+ check("loops_agent_check", sql`${t.agent} IN ('claude-code', 'codex', 'pi')`),
267
+ check("loops_tags_count_check", sql`cardinality(${t.tags}) <= 4`),
268
+ check("loops_tags_reserved_check", sql`NOT (${t.tags} && ARRAY['all loops', 'active', 'paused', 'blocked']::text[])`),
269
+ check("loops_schedule_mode_check", sql`${t.scheduleMode} IN ('cron', 'continuous')`),
270
+ check("loops_cron_overlap_check", sql`${t.cronOverlap} IN ('skip', 'queue-one')`)
271
+ ]);
272
+ var runs = pgTable("runs", {
273
+ id: text("id").primaryKey(),
274
+ loopId: text("loop_id").notNull(),
275
+ machineId: text("machine_id").notNull(),
276
+ agent: text("agent", { enum: CODING_AGENTS }),
277
+ model: text("model"),
278
+ reasoningEffort: text("reasoning_effort"),
279
+ runIndex: integer("run_index"),
280
+ phase: text("phase", { enum: [
281
+ "pending",
282
+ "running",
283
+ "done",
284
+ "error",
285
+ "canceled"
286
+ ] }).notNull(),
287
+ /** Durable queue authority. It only promotes system→owner; diagnostic trigger
288
+ * reasons, if added later, must never drive lifecycle state. */
289
+ requestedBy: text("requested_by", { enum: ["owner", "system"] }).notNull().default("system"),
290
+ ts: text("ts").notNull(),
291
+ createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
292
+ updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
293
+ status: text("status", { enum: [
294
+ "keep",
295
+ "no-change",
296
+ "block"
297
+ ] }),
298
+ message: text("message"),
299
+ durationMs: integer("duration_ms"),
300
+ exitCode: integer("exit_code"),
301
+ finalText: text("final_text"),
302
+ error: text("error"),
303
+ sessionId: text("session_id"),
304
+ usage: jsonb("usage").$type(),
305
+ /** Monotonic cancellation intent. Running becomes canceled only on daemon report. */
306
+ cancelRequestedAt: timestamp("cancel_requested_at", {
307
+ withTimezone: true,
308
+ mode: "string"
309
+ }),
310
+ heartbeatAt: text("heartbeat_at"),
311
+ reportIncident: jsonb("report_incident").$type()
312
+ }, (t) => [
313
+ index("runs_loop_idx").on(t.loopId),
314
+ index("runs_phase_idx").on(t.phase),
315
+ index("runs_machine_phase_ready_idx").on(t.machineId, t.phase),
316
+ index("runs_loop_ts_idx").on(t.loopId, t.ts),
317
+ uniqueIndex("runs_loop_run_index_idx").on(t.loopId, t.runIndex).where(sql`${t.runIndex} IS NOT NULL`),
318
+ index("runs_loop_terminal_history_idx").on(t.loopId, t.runIndex).where(sql`${t.runIndex} IS NOT NULL AND ${t.phase} IN ('done', 'error', 'canceled')`),
319
+ uniqueIndex("runs_loop_pending_idx").on(t.loopId).where(sql`${t.phase} = 'pending'`),
320
+ uniqueIndex("one_running_run_per_loop").on(t.loopId).where(sql`${t.phase} = 'running'`),
321
+ check("runs_agent_check", sql`${t.agent} IS NULL OR ${t.agent} IN ('claude-code', 'codex', 'pi')`),
322
+ check("runs_phase_check", sql`${t.phase} IN ('pending', 'running', 'done', 'error', 'canceled')`),
323
+ check("runs_requested_by_check", sql`${t.requestedBy} IN ('owner', 'system')`),
324
+ check("runs_status_check", sql`${t.status} IS NULL OR ${t.status} IN ('keep', 'no-change', 'block')`)
325
+ ]);
326
+ var runLeases = pgTable("run_leases", {
327
+ tokenHash: text("token_hash").primaryKey(),
328
+ runId: text("run_id").notNull(),
329
+ loopId: text("loop_id").notNull(),
330
+ machineId: text("machine_id").notNull(),
331
+ state: text("state", { enum: [
332
+ "active",
333
+ "terminal-grace",
334
+ "reconciliation-only",
335
+ "retired"
336
+ ] }).notNull().default("active"),
337
+ /** Null while active/retired; ISO only during terminal-grace. */
338
+ expiresAt: text("expires_at"),
339
+ createdAt: text("created_at").notNull()
340
+ }, (t) => [
341
+ index("run_leases_run_idx").on(t.runId),
342
+ index("run_leases_loop_idx").on(t.loopId),
343
+ check("run_leases_state_check", sql`${t.state} IN ('active', 'terminal-grace', 'reconciliation-only', 'retired')`)
344
+ ]);
345
+ var runReportReceipts = pgTable("run_report_receipts", {
346
+ reportId: text("report_id").primaryKey(),
347
+ runId: text("run_id").notNull(),
348
+ payloadDigest: text("payload_digest").notNull(),
349
+ ackStatus: integer("ack_status").notNull(),
350
+ ackBody: jsonb("ack_body").$type().notNull(),
351
+ createdAt: timestamp("created_at", {
352
+ withTimezone: true,
353
+ mode: "string"
354
+ }).notNull()
355
+ }, (t) => [index("report_receipts_created").on(t.createdAt)]);
356
+ /** Durable evidence for a rejected terminal attempt. It is keyed by
357
+ * sha256(reportId + canonical-payload-digest), is never loop-owned, and therefore
358
+ * survives loop deletion just like normal report receipts. */
359
+ var terminalReportIncidents = pgTable("terminal_report_incidents", {
360
+ id: text("id").primaryKey(),
361
+ runId: text("run_id").notNull(),
362
+ reportId: text("report_id").notNull(),
363
+ payloadDigest: text("payload_digest").notNull(),
364
+ disposition: text("disposition", { enum: ["run-error", "telemetry-rejected"] }).$type().notNull(),
365
+ ackBody: jsonb("ack_body").$type().notNull(),
366
+ createdAt: timestamp("created_at", {
367
+ withTimezone: true,
368
+ mode: "string"
369
+ }).notNull()
370
+ }, (t) => [
371
+ index("terminal_report_incidents_report_id").on(t.reportId),
372
+ index("terminal_report_incidents_created").on(t.createdAt),
373
+ check("terminal_report_incidents_disposition_check", sql`${t.disposition} IN ('run-error', 'telemetry-rejected')`)
374
+ ]);
375
+ var connectKeys = pgTable("connect_keys", {
376
+ machineId: text("machine_id").primaryKey(),
377
+ /** Mint time (ISO) — drives the TTL. */
378
+ mintedAt: text("minted_at").notNull()
379
+ });
380
+ /**
381
+ * One content-addressed blob (deduped across every loop/run). The bytes live in
382
+ * the byte store under the hash; this row records verified byte presence and size.
383
+ */
384
+ var blobs = pgTable("blobs", {
385
+ hash: text("hash").primaryKey(),
386
+ size: integer("size").notNull(),
387
+ createdAt: text("created_at").notNull()
388
+ });
389
+ /**
390
+ * The current configured file set of each loop — one row per live or tombstoned
391
+ * relative path. `hash` → `blobs.hash`; null when the file is deleted or oversize
392
+ * (metadata-only, no bytes synced). The
393
+ * unique (loopId, path) index is the upsert key the sync reconciliation drives.
394
+ */
395
+ var artifactFiles = pgTable("artifact_files", {
396
+ id: text("id").primaryKey(),
397
+ loopId: text("loop_id").notNull(),
398
+ /** Normalized workdir-relative path (never absolute or escaping the workdir). */
399
+ path: text("path").notNull(),
400
+ hash: text("hash"),
401
+ size: integer("size"),
402
+ /** Bytes contain a NUL (set even for oversize metadata-only files). */
403
+ binary: boolean("binary").notNull().default(false),
404
+ /** File exceeds the per-file byte cap → metadata-only (path + size), no blob. */
405
+ oversize: boolean("oversize").notNull().default(false),
406
+ deleted: boolean("deleted").notNull().default(false),
407
+ updatedAt: text("updated_at").notNull()
408
+ }, (t) => [
409
+ index("artifact_files_loop_idx").on(t.loopId),
410
+ uniqueIndex("artifact_files_loop_path_idx").on(t.loopId, t.path),
411
+ index("artifact_files_hash_idx").on(t.hash)
412
+ ]);
413
+ /**
414
+ * The loop's full artifact manifest captured at each run's finalize — the input
415
+ * to the per-run diff. Written cheaply on report (no diff computed on write);
416
+ * `getRunDiff` lazily compares run N's snapshot with the preceding snapshot.
417
+ * One row per run (runId primary key).
418
+ */
419
+ var runSnapshots = pgTable("run_snapshots", {
420
+ runId: text("run_id").primaryKey(),
421
+ loopId: text("loop_id").notNull(),
422
+ manifest: jsonb("manifest").$type().notNull(),
423
+ createdAt: text("created_at").notNull()
424
+ }, (t) => [index("run_snapshots_loop_idx").on(t.loopId)]);
425
+ /**
426
+ * Embedded PGlite (WASM Postgres) database, file-backed at `<dataDir>/pgdata`,
427
+ * plus the Drizzle handle. A `globalThis` guard keeps the handle a singleton
428
+ * across dev HMR reloads (the Scheduler shares it in-process). Exactly one
429
+ * server process may own a data directory.
430
+ */
431
+ var schema = {
432
+ machines,
433
+ loops,
434
+ runs,
435
+ blobs,
436
+ artifactFiles,
437
+ runSnapshots,
438
+ runLeases,
439
+ runReportReceipts,
440
+ terminalReportIncidents,
441
+ connectKeys
442
+ };
443
+ var g$1 = globalThis;
444
+ function open() {
445
+ if (g$1.__pievoDb && g$1.__pievoClient) return {
446
+ db: g$1.__pievoDb,
447
+ client: g$1.__pievoClient
448
+ };
449
+ const dir = path.join(dataDir(), "pgdata");
450
+ logger.info({ pgdata: dir }, "database: pglite");
451
+ fs.mkdirSync(dataDir(), { recursive: true });
452
+ const client = new Ve(dir);
453
+ const db = drizzle(client, { schema });
454
+ g$1.__pievoClient = client;
455
+ g$1.__pievoDb = db;
456
+ return {
457
+ db,
458
+ client
459
+ };
460
+ }
461
+ var opened = open();
462
+ var db = opened.db;
463
+ opened.client;
464
+ /**
465
+ * Migrations dir. In DEV this file lives at `src/db`, so the folder is at the
466
+ * package root (`../../drizzle`). In the BUILT server the module is bundled under
467
+ * `.output/server/{_ssr,_libs}` and `copy-pglite-assets.mjs` lands the folder at
468
+ * `.output/server/drizzle` (`../drizzle`). Return the first candidate that exists
469
+ * so the database migrates in-process from either layout.
470
+ */
471
+ function migrationsDir() {
472
+ const here = path.dirname(fileURLToPath(import.meta.url));
473
+ const devDir = path.resolve(here, "..", "..", "drizzle");
474
+ const builtDir = path.resolve(here, "..", "drizzle");
475
+ if (fs.existsSync(devDir)) return devDir;
476
+ if (fs.existsSync(builtDir)) return builtDir;
477
+ return devDir;
478
+ }
479
+ /** Apply generated migrations in-process (idempotent). */
480
+ async function runMigrations() {
481
+ const dir = migrationsDir();
482
+ if (!fs.existsSync(dir)) return;
483
+ const { migrate } = await import("../_libs/drizzle-orm.mjs").then((n) => n.E);
484
+ await migrate(opened.db, { migrationsFolder: dir });
485
+ }
486
+ /**
487
+ * Best-effort clean shutdown of the database client (called from the
488
+ * boot AbortController on SIGINT/SIGTERM). PGlite must be closed explicitly:
489
+ * after migrations it owns a referenced WASM timer that otherwise keeps Node
490
+ * alive until the managed launcher's graceful-stop deadline. Never throws —
491
+ * shutdown must not wedge on a slow drain.
492
+ */
493
+ async function closeClient() {
494
+ try {
495
+ await opened.client.close();
496
+ } catch {}
497
+ }
498
+ /**
499
+ * Data-access layer over Drizzle. Loop-locked state transitions live here; every
500
+ * DB-touching function is async, while the pure helpers stay sync.
501
+ */
502
+ var store_exports = /* @__PURE__ */ __exportAll({
503
+ TERMINAL_REPORT_GRACE_MS: () => TERMINAL_REPORT_GRACE_MS,
504
+ acknowledgeRetiredReport: () => acknowledgeRetiredReport,
505
+ acknowledgeRetiredTerminalIncident: () => acknowledgeRetiredTerminalIncident,
506
+ advanceDueSchedules: () => advanceDueSchedules,
507
+ artifactFileReferencesHash: () => artifactFileReferencesHash,
508
+ blobExists: () => blobExists,
509
+ blobHashesOlderThan: () => blobHashesOlderThan,
510
+ blobSizes: () => blobSizes,
511
+ buildLoopManifest: () => buildLoopManifest,
512
+ claimReadyRunForMachine: () => claimReadyRunForMachine,
513
+ countRunSnapshots: () => countRunSnapshots,
514
+ countRuns: () => countRuns,
515
+ createLoop: () => createLoop,
516
+ createMachine: () => createMachine,
517
+ deleteBlob: () => deleteBlob,
518
+ enqueueRun: () => enqueueRun,
519
+ expirePendingRun: () => expirePendingRun,
520
+ finalizeRunningRun: () => finalizeRunningRun,
521
+ forceDeleteLoop: () => forceDeleteLoop,
522
+ forceDeleteMachine: () => forceDeleteMachine,
523
+ getArtifactFile: () => getArtifactFile,
524
+ getExactTerminalReportIncident: () => getExactTerminalReportIncident,
525
+ getLoop: () => getLoop,
526
+ getMachine: () => getMachine,
527
+ getReportReceipt: () => getReportReceipt,
528
+ getRun: () => getRun,
529
+ getRunSnapshot: () => getRunSnapshot,
530
+ getTerminalReportIncidents: () => getTerminalReportIncidents,
531
+ hasPendingRun: () => hasPendingRun,
532
+ hasRunningRun: () => hasRunningRun,
533
+ initializeCronCadence: () => initializeCronCadence,
534
+ lastRun: () => lastRun,
535
+ listArtifacts: () => listArtifacts,
536
+ listLoops: () => listLoops,
537
+ listMachines: () => listMachines,
538
+ listRuns: () => listRuns,
539
+ listRunsBefore: () => listRunsBefore,
540
+ liveBlobRefs: () => liveBlobRefs,
541
+ loopIdsWithSnapshots: () => loopIdsWithSnapshots,
542
+ loopsForMachine: () => loopsForMachine,
543
+ machineReferencesBlob: () => machineReferencesBlob,
544
+ newLoopId: () => newLoopId,
545
+ nextCronAt: () => nextCronAt,
546
+ openRuns: () => openRuns,
547
+ pauseLoop: () => pauseLoop,
548
+ pauseLoopState: () => pauseLoopState,
549
+ prevRunSnapshot: () => prevRunSnapshot,
550
+ pruneRunSnapshots: () => pruneRunSnapshots,
551
+ putReportReceiptIfAbsent: () => putReportReceiptIfAbsent,
552
+ putRunSnapshot: () => putRunSnapshot,
553
+ putTerminalReportIncidentIfAbsent: () => putTerminalReportIncidentIfAbsent,
554
+ recentLoopUsage: () => recentLoopUsage,
555
+ reclaimRun: () => reclaimRun,
556
+ reconcileConfiguredArtifacts: () => reconcileConfiguredArtifacts,
557
+ reconcileReclaimedRun: () => reconcileReclaimedRun,
558
+ reconciliationStatesForRuns: () => reconciliationStatesForRuns,
559
+ recordBlob: () => recordBlob,
560
+ recordRunReportOnce: () => recordRunReportOnce,
561
+ refreshRunHeartbeats: () => refreshRunHeartbeats,
562
+ rejectTerminalReport: () => rejectTerminalReport,
563
+ releaseAbsentReconciliations: () => releaseAbsentReconciliations,
564
+ repairTerminalRunLeases: () => repairTerminalRunLeases,
565
+ requestDeleteLoop: () => requestDeleteLoop,
566
+ requestRunCancel: () => requestRunCancel,
567
+ runningRunForLoop: () => runningRunForLoop,
568
+ runningRunsForMachine: () => runningRunsForMachine,
569
+ setMachineOnline: () => setMachineOnline,
570
+ snapshotBlobRefs: () => snapshotBlobRefs,
571
+ startLoop: () => startLoop,
572
+ stopLoop: () => stopLoop,
573
+ terminalIncidentReceiptId: () => terminalIncidentReceiptId,
574
+ tryDeleteLoop: () => tryDeleteLoop,
575
+ updateLoop: () => updateLoop,
576
+ updateMachine: () => updateMachine,
577
+ upsertConfiguredArtifactFile: () => upsertConfiguredArtifactFile
578
+ });
579
+ function newLoopId() {
580
+ return `loop-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
581
+ }
582
+ function nowIso$1() {
583
+ return (/* @__PURE__ */ new Date()).toISOString();
584
+ }
585
+ function nextCronAt(cron, timezone, after) {
586
+ if (!timezone) throw new Error("invariant: cron schedule has no timezone");
587
+ const probe = new E(cron, {
588
+ paused: true,
589
+ timezone
590
+ });
591
+ try {
592
+ const next = probe.nextRun(new Date(after));
593
+ if (!next) throw new Error(`cron expression never fires again: ${cron}`);
594
+ return next.toISOString();
595
+ } finally {
596
+ probe.stop();
597
+ }
598
+ }
599
+ function runTimes(at = nowIso$1()) {
600
+ return {
601
+ ts: at,
602
+ createdAt: at,
603
+ updatedAt: at
604
+ };
605
+ }
606
+ async function listLoops() {
607
+ return db.select().from(loops);
608
+ }
609
+ async function getLoop(id) {
610
+ return (await db.select().from(loops).where(eq(loops.id, id)))[0];
611
+ }
612
+ async function loopsForMachine(machineId) {
613
+ return db.select().from(loops).where(eq(loops.machineId, machineId));
614
+ }
615
+ async function createLoop(input) {
616
+ const ts = nowIso$1();
617
+ const enabled = input.enabled ?? true;
618
+ if (input.scheduleMode === "cron" && !input.timezone) throw new Error("invariant: cron loop creation requires a timezone");
619
+ if (input.scheduleMode === "continuous" && input.timezone !== null) throw new Error("invariant: continuous loop creation requires a null timezone");
620
+ const nextCadenceAt = !enabled ? null : input.scheduleMode === "continuous" ? ts : nextCronAt(input.cron, input.timezone, ts);
621
+ const row = {
622
+ ...input,
623
+ enabled,
624
+ pauseCause: input.pauseCause ?? (!enabled ? {
625
+ kind: "owner",
626
+ at: ts
627
+ } : null),
628
+ nextRunAt: enabled ? input.nextRunAt ?? null : null,
629
+ nextCadenceAt,
630
+ id: input.id ?? newLoopId(),
631
+ createdAt: ts,
632
+ updatedAt: ts
633
+ };
634
+ return db.transaction(async (tx) => {
635
+ if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, input.machineId)).for("key share"))[0]) throw new Error(`machine ${input.machineId} does not exist`);
636
+ return (await tx.insert(loops).values(row).returning())[0];
637
+ });
638
+ }
639
+ /** Allocate the next 1-based history number. Every caller already holds the
640
+ * owning loop row lock, so the counter and run transition share one commit. */
641
+ async function allocateRunIndexTx(tx, loopId) {
642
+ const updated = (await tx.update(loops).set({ lastRunIndex: sql`${loops.lastRunIndex} + 1` }).where(eq(loops.id, loopId)).returning({ runIndex: loops.lastRunIndex }))[0];
643
+ if (!updated) throw new Error(`cannot allocate history index for missing loop ${loopId}`);
644
+ return updated.runIndex;
645
+ }
646
+ async function ensureRunIndexTx(tx, run) {
647
+ return run.runIndex ?? allocateRunIndexTx(tx, run.loopId);
648
+ }
649
+ async function cancelPendingTx(tx, where, message, at) {
650
+ const pending = await tx.select({
651
+ id: runs.id,
652
+ loopId: runs.loopId,
653
+ runIndex: runs.runIndex
654
+ }).from(runs).where(where).orderBy(asc(runs.createdAt), asc(runs.id)).for("update");
655
+ for (const run of pending) {
656
+ const runIndex = await ensureRunIndexTx(tx, run);
657
+ await tx.update(runs).set({
658
+ phase: "canceled",
659
+ runIndex,
660
+ message,
661
+ ts: at,
662
+ updatedAt: at
663
+ }).where(and(eq(runs.id, run.id), eq(runs.phase, "pending")));
664
+ }
665
+ }
666
+ /** Apply one loop patch while its row lock is held. This is shared by owner edits
667
+ * and run-authorized mutations, so cadence semantics cannot drift. */
668
+ async function updateLoopTx(tx, current, patch, at) {
669
+ const extra = {};
670
+ if (patch.enabled === true) extra.pauseCause = null;
671
+ else if (patch.enabled === false && current.enabled && patch.pauseCause === void 0) extra.pauseCause = {
672
+ kind: "owner",
673
+ at
674
+ };
675
+ const effective = {
676
+ ...current,
677
+ ...patch,
678
+ ...extra
679
+ };
680
+ if (current.deleteRequestedAt) extra.enabled = false;
681
+ const pausing = !effective.enabled || current.deleteRequestedAt != null;
682
+ const activating = effective.enabled && (patch.enabled === true && !current.enabled || patch.scheduleMode !== void 0 && patch.scheduleMode !== current.scheduleMode);
683
+ if (pausing) {
684
+ extra.nextCadenceAt = null;
685
+ extra.nextRunAt = null;
686
+ } else if (activating) if (effective.scheduleMode === "cron") extra.nextCadenceAt = nextCronAt(effective.cron, effective.timezone, at);
687
+ else extra.nextCadenceAt = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, current.id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0] ? null : at;
688
+ else if (effective.enabled && effective.scheduleMode === "cron" && (patch.cron !== void 0 && patch.cron !== current.cron || patch.timezone !== void 0 && patch.timezone !== current.timezone)) extra.nextCadenceAt = nextCronAt(effective.cron, effective.timezone, at);
689
+ else if (effective.enabled && effective.scheduleMode === "continuous" && current.nextCadenceAt && Date.parse(current.nextCadenceAt) > Date.parse(at) && patch.continuousDelayMinutes !== void 0 && patch.continuousDelayMinutes !== current.continuousDelayMinutes) {
690
+ const terminalAt = Date.parse(current.nextCadenceAt) - current.continuousDelayMinutes * 6e4;
691
+ extra.nextCadenceAt = new Date(terminalAt + effective.continuousDelayMinutes * 6e4).toISOString();
692
+ }
693
+ const loop = (await tx.update(loops).set({
694
+ ...patch,
695
+ ...extra,
696
+ updatedAt: at
697
+ }).where(eq(loops.id, current.id)).returning())[0];
698
+ if (patch.enabled === false) await cancelPendingTx(tx, and(eq(runs.loopId, current.id), eq(runs.phase, "pending"), eq(runs.requestedBy, "system")), "canceled - loop paused before this system run was claimed", at);
699
+ if (patch.artifacts !== void 0) {
700
+ const allowed = new Set(patch.artifacts ?? []);
701
+ const removed = (await tx.select({
702
+ id: artifactFiles.id,
703
+ path: artifactFiles.path
704
+ }).from(artifactFiles).where(and(eq(artifactFiles.loopId, current.id), eq(artifactFiles.deleted, false)))).filter((file) => !allowed.has(file.path)).map((file) => file.id);
705
+ if (removed.length) await tx.update(artifactFiles).set({
706
+ hash: null,
707
+ deleted: true,
708
+ updatedAt: at
709
+ }).where(inArray(artifactFiles.id, removed));
710
+ }
711
+ return loop;
712
+ }
713
+ async function updateLoop(id, patch) {
714
+ return db.transaction(async (tx) => {
715
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
716
+ return current ? updateLoopTx(tx, current, patch, nowIso$1()) : void 0;
717
+ });
718
+ }
719
+ /** Delete loop-owned history/content without erasing durable late-report evidence.
720
+ * A retired lease is non-authorizing and never blocks deletion; only the matching
721
+ * report→410 receipt transaction may consume it. */
722
+ async function deleteLoopDataTx(tx, id) {
723
+ if (!(await tx.delete(loops).where(eq(loops.id, id)).returning({ id: loops.id })).length) return false;
724
+ await tx.delete(runs).where(eq(runs.loopId, id));
725
+ await tx.delete(runLeases).where(and(eq(runLeases.loopId, id), ne(runLeases.state, "retired")));
726
+ await tx.delete(artifactFiles).where(eq(artifactFiles.loopId, id));
727
+ await tx.delete(runSnapshots).where(eq(runSnapshots.loopId, id));
728
+ return true;
729
+ }
730
+ /** Stable Pause plus the post-transition running fact, observed under the same loop
731
+ * lock. Callers use this result for truthful wording without a pre-lock race. */
732
+ async function pauseLoopState(id) {
733
+ return db.transaction(async (tx) => {
734
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
735
+ if (!current) return void 0;
736
+ const loop = await updateLoopTx(tx, current, { enabled: false }, nowIso$1());
737
+ const running = (await tx.select().from(runs).where(and(eq(runs.loopId, id), eq(runs.phase, "running"))).limit(1))[0];
738
+ return {
739
+ loop,
740
+ ...running ? { running } : {}
741
+ };
742
+ });
743
+ }
744
+ async function pauseLoop(id) {
745
+ return (await pauseLoopState(id))?.loop;
746
+ }
747
+ async function startLoop(id) {
748
+ return db.transaction(async (tx) => {
749
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
750
+ if (!current || current.deleteRequestedAt) return void 0;
751
+ return updateLoopTx(tx, current, { enabled: true }, nowIso$1());
752
+ });
753
+ }
754
+ async function stopLoopTx(tx, current, at) {
755
+ const loop = (await tx.update(loops).set({
756
+ enabled: false,
757
+ pauseCause: {
758
+ kind: "owner",
759
+ at
760
+ },
761
+ nextCadenceAt: null,
762
+ nextRunAt: null,
763
+ updatedAt: at
764
+ }).where(eq(loops.id, current.id)).returning())[0];
765
+ await cancelPendingTx(tx, and(eq(runs.loopId, current.id), eq(runs.phase, "pending")), "canceled - loop stopped before this queued run was claimed", at);
766
+ const running = (await tx.select().from(runs).where(and(eq(runs.loopId, current.id), eq(runs.phase, "running"))).limit(1).for("update"))[0];
767
+ if (!running) return { loop };
768
+ return {
769
+ loop,
770
+ running: running.cancelRequestedAt ? running : (await tx.update(runs).set({
771
+ cancelRequestedAt: at,
772
+ updatedAt: at
773
+ }).where(and(eq(runs.id, running.id), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? running
774
+ };
775
+ }
776
+ async function stopLoop(id) {
777
+ return db.transaction(async (tx) => {
778
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
779
+ return current ? stopLoopTx(tx, current, nowIso$1()) : void 0;
780
+ });
781
+ }
782
+ async function requestRunCancel(loopId, runId) {
783
+ return db.transaction(async (tx) => {
784
+ if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, loopId)).for("update"))[0]) return void 0;
785
+ const run = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId))).limit(1).for("update"))[0];
786
+ if (!run) return void 0;
787
+ if (run.phase === "pending") {
788
+ const at = nowIso$1();
789
+ const runIndex = await ensureRunIndexTx(tx, run);
790
+ return (await tx.update(runs).set({
791
+ phase: "canceled",
792
+ runIndex,
793
+ error: "stopped by user",
794
+ ts: at,
795
+ updatedAt: at
796
+ }).where(and(eq(runs.id, runId), eq(runs.phase, "pending"))).returning())[0] ?? run;
797
+ }
798
+ if (run.phase === "running" && !run.cancelRequestedAt) {
799
+ const at = nowIso$1();
800
+ return (await tx.update(runs).set({
801
+ cancelRequestedAt: at,
802
+ updatedAt: at
803
+ }).where(and(eq(runs.id, runId), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? run;
804
+ }
805
+ return run;
806
+ });
807
+ }
808
+ async function requestDeleteLoop(id) {
809
+ return db.transaction(async (tx) => {
810
+ let current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
811
+ if (!current) return void 0;
812
+ const at = current.deleteRequestedAt ?? nowIso$1();
813
+ if (!current.deleteRequestedAt) current = (await tx.update(loops).set({
814
+ deleteRequestedAt: at,
815
+ updatedAt: at
816
+ }).where(eq(loops.id, id)).returning())[0];
817
+ return stopLoopTx(tx, current, at);
818
+ });
819
+ }
820
+ async function tryDeleteLoop(id) {
821
+ return db.transaction(async (tx) => {
822
+ if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]?.deleteRequestedAt) return false;
823
+ const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
824
+ const authority = (await tx.select({ id: runLeases.tokenHash }).from(runLeases).where(and(eq(runLeases.loopId, id), inArray(runLeases.state, ["active", "terminal-grace"]))).limit(1))[0];
825
+ if (open || authority) return false;
826
+ await tx.update(runLeases).set({
827
+ state: "retired",
828
+ expiresAt: null
829
+ }).where(and(eq(runLeases.loopId, id), eq(runLeases.state, "reconciliation-only")));
830
+ return deleteLoopDataTx(tx, id);
831
+ });
832
+ }
833
+ async function forceDeleteLoopTx(tx, id) {
834
+ if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]) return false;
835
+ await tx.update(runLeases).set({
836
+ state: "retired",
837
+ expiresAt: null
838
+ }).where(and(eq(runLeases.loopId, id), inArray(runLeases.state, [
839
+ "active",
840
+ "terminal-grace",
841
+ "reconciliation-only",
842
+ "retired"
843
+ ])));
844
+ return deleteLoopDataTx(tx, id);
845
+ }
846
+ async function forceDeleteLoop(id) {
847
+ return db.transaction((tx) => forceDeleteLoopTx(tx, id));
848
+ }
849
+ async function enqueueRunTx(tx, loop, request, at) {
850
+ if (loop.deleteRequestedAt) return {
851
+ state: "rejected",
852
+ reason: "loop is being deleted"
853
+ };
854
+ if (request.requestedBy !== "owner" && !loop.enabled) return {
855
+ state: "skipped",
856
+ reason: "loop is paused"
857
+ };
858
+ const pending = (await tx.select().from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "pending"))).limit(1).for("update"))[0];
859
+ if (pending) {
860
+ const requestedBy = pending.requestedBy === "owner" || request.requestedBy === "owner" ? "owner" : "system";
861
+ const updated = (await tx.update(runs).set({
862
+ requestedBy,
863
+ ts: at,
864
+ updatedAt: at
865
+ }).where(and(eq(runs.id, pending.id), eq(runs.phase, "pending"))).returning())[0];
866
+ return updated ? {
867
+ state: "coalesced",
868
+ run: updated
869
+ } : {
870
+ state: "skipped",
871
+ reason: "queued run was claimed concurrently"
872
+ };
873
+ }
874
+ const row = {
875
+ id: randomUUID(),
876
+ loopId: loop.id,
877
+ machineId: loop.machineId,
878
+ phase: "pending",
879
+ requestedBy: request.requestedBy,
880
+ ...runTimes(at)
881
+ };
882
+ return {
883
+ state: "queued",
884
+ run: (await tx.insert(runs).values(row).returning())[0]
885
+ };
886
+ }
887
+ async function failureStreakTx(tx, loopId) {
888
+ const lastOk = (await tx.select({ runIndex: runs.runIndex }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "done"), isNotNull(runs.runIndex))).orderBy(desc(runs.runIndex)).limit(1))[0];
889
+ const conds = [
890
+ eq(runs.loopId, loopId),
891
+ eq(runs.phase, "error"),
892
+ isNotNull(runs.runIndex)
893
+ ];
894
+ if (lastOk?.runIndex != null) conds.push(gt(runs.runIndex, lastOk.runIndex));
895
+ const row = (await tx.select({ n: sql`count(*)` }).from(runs).where(and(...conds)))[0];
896
+ return Number(row?.n ?? 0);
897
+ }
898
+ async function terminalLifecycleTx(tx, currentLoop, run, terminalAt, loopPatch = {}, failureAutopauseStreak = 0) {
899
+ const effective = {
900
+ ...currentLoop,
901
+ ...loopPatch
902
+ };
903
+ const update = { ...loopPatch };
904
+ if ((run.phase === "done" || run.phase === "error") && effective.scheduleMode === "continuous") {
905
+ const openExec = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, currentLoop.id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
906
+ update.nextCadenceAt = effective.enabled && !openExec ? new Date(Date.parse(terminalAt) + Math.max(1, effective.continuousDelayMinutes) * 6e4).toISOString() : null;
907
+ }
908
+ let loop = Object.keys(update).length ? (await tx.update(loops).set({
909
+ ...update,
910
+ updatedAt: terminalAt
911
+ }).where(eq(loops.id, currentLoop.id)).returning())[0] : currentLoop;
912
+ let failureStreak = 0;
913
+ let autoPaused = false;
914
+ if (run.phase === "done" && run.status === "block") {
915
+ loop = (await tx.update(loops).set({
916
+ enabled: false,
917
+ pauseCause: {
918
+ kind: "blocked",
919
+ at: terminalAt,
920
+ runId: run.id
921
+ },
922
+ nextCadenceAt: null,
923
+ nextRunAt: null,
924
+ updatedAt: terminalAt
925
+ }).where(eq(loops.id, currentLoop.id)).returning())[0];
926
+ await cancelPendingTx(tx, and(eq(runs.loopId, currentLoop.id), eq(runs.phase, "pending"), eq(runs.requestedBy, "system")), "canceled - loop auto-paused after a block report", terminalAt);
927
+ autoPaused = true;
928
+ }
929
+ if (run.phase === "error") {
930
+ failureStreak = await failureStreakTx(tx, currentLoop.id);
931
+ if (failureAutopauseStreak > 0 && failureStreak >= failureAutopauseStreak && loop.enabled) {
932
+ loop = (await tx.update(loops).set({
933
+ enabled: false,
934
+ pauseCause: {
935
+ kind: "failure-streak",
936
+ at: terminalAt,
937
+ runId: run.id,
938
+ count: failureStreak
939
+ },
940
+ nextCadenceAt: null,
941
+ nextRunAt: null,
942
+ updatedAt: terminalAt
943
+ }).where(eq(loops.id, currentLoop.id)).returning())[0];
944
+ await cancelPendingTx(tx, and(eq(runs.loopId, currentLoop.id), eq(runs.phase, "pending"), eq(runs.requestedBy, "system")), "canceled - loop auto-paused after consecutive failures", terminalAt);
945
+ autoPaused = true;
946
+ }
947
+ }
948
+ return {
949
+ loop,
950
+ failureStreak,
951
+ autoPaused
952
+ };
953
+ }
954
+ async function getRun(id) {
955
+ return (await db.select().from(runs).where(eq(runs.id, id)))[0];
956
+ }
957
+ async function runningRunsForMachine(machineId) {
958
+ return db.select().from(runs).where(and(eq(runs.machineId, machineId), eq(runs.phase, "running"))).orderBy(asc(runs.createdAt), asc(runs.id));
959
+ }
960
+ async function runningRunForLoop(loopId) {
961
+ return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
962
+ }
963
+ async function getReportReceipt(reportId) {
964
+ return (await db.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, reportId)))[0];
965
+ }
966
+ function terminalIncidentReceiptId(reportId, payloadDigest) {
967
+ return createHash("sha256").update(reportId + payloadDigest).digest("hex");
968
+ }
969
+ async function getTerminalReportIncidents(reportId) {
970
+ return db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, reportId)).orderBy(asc(terminalReportIncidents.createdAt));
971
+ }
972
+ async function getExactTerminalReportIncident(reportId, payloadDigest) {
973
+ return (await db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(reportId, payloadDigest))))[0];
974
+ }
975
+ async function putTerminalReportIncidentIfAbsent(input) {
976
+ return db.transaction(async (tx) => {
977
+ await lockReportIdTx(tx, input.reportId);
978
+ const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
979
+ const existing = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
980
+ if (existing) return existing;
981
+ return (await tx.insert(terminalReportIncidents).values({
982
+ id,
983
+ ...input,
984
+ createdAt: nowIso$1()
985
+ }).returning())[0];
986
+ });
987
+ }
988
+ /** Serialize all terminal handling for one reportId, even when the competing
989
+ * runs belong to different loop locks or one terminal result lands in the incident table. */
990
+ async function lockReportIdTx(tx, reportId) {
991
+ const unsigned = BigInt(`0x${createHash("sha256").update(reportId).digest("hex").slice(0, 16)}`);
992
+ const key = BigInt.asIntN(64, unsigned).toString();
993
+ await tx.execute(sql`select pg_advisory_xact_lock(${key}::bigint)`);
994
+ }
995
+ /** Race-safe receipt reservation for definitive non-finalization ACKs (RETIRED).
996
+ * Returns the row that owns reportId, whether inserted here or concurrently. */
997
+ async function putReportReceiptIfAbsent(input) {
998
+ return db.transaction(async (tx) => {
999
+ await lockReportIdTx(tx, input.reportId);
1000
+ return (await tx.insert(runReportReceipts).values(input).onConflictDoNothing({ target: runReportReceipts.reportId }).returning())[0] ?? (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
1001
+ });
1002
+ }
1003
+ /** Idempotent startup repair for a crash across terminalization.
1004
+ * Terminal runs can never retain active authority. */
1005
+ async function repairTerminalRunLeases(now = Date.now()) {
1006
+ const repaired = await db.update(runLeases).set({
1007
+ state: "terminal-grace",
1008
+ expiresAt: new Date(now + TERMINAL_REPORT_GRACE_MS).toISOString()
1009
+ }).where(and(eq(runLeases.state, "active"), sql`exists (select 1 from ${runs} where ${runs.id} = ${runLeases.runId} and ${runs.phase} in ('done', 'error', 'canceled'))`)).returning({ tokenHash: runLeases.tokenHash });
1010
+ await db.update(runLeases).set({ expiresAt: null }).where(eq(runLeases.state, "retired"));
1011
+ return repaired.length;
1012
+ }
1013
+ /** Atomically persist a definitive 410 and consume only the matching retired
1014
+ * tombstone. If the HTTP response is lost, the independent receipt replays it. */
1015
+ async function acknowledgeRetiredReport(leaseTokenHash, input) {
1016
+ return db.transaction(async (tx) => {
1017
+ await lockReportIdTx(tx, input.reportId);
1018
+ if (!(await tx.select().from(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.state, "retired"))).limit(1).for("update"))[0]) return void 0;
1019
+ await tx.insert(runReportReceipts).values(input).onConflictDoNothing({ target: runReportReceipts.reportId });
1020
+ const stored = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
1021
+ if (stored?.runId === input.runId && stored.payloadDigest === input.payloadDigest && stored.ackStatus === input.ackStatus) await tx.delete(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.state, "retired")));
1022
+ return stored;
1023
+ });
1024
+ }
1025
+ /** Refresh provider-neutral liveness in one machine-scoped UPDATE. Untrusted ids
1026
+ * cannot stamp another machine or a non-running row; fresh stamps stay untouched. */
1027
+ async function refreshRunHeartbeats(machineId, runIds, at, staleBefore) {
1028
+ if (!runIds.length) return 0;
1029
+ return (await db.update(runs).set({
1030
+ heartbeatAt: at,
1031
+ updatedAt: at
1032
+ }).where(and(eq(runs.machineId, machineId), eq(runs.phase, "running"), inArray(runs.id, runIds), or(isNull(runs.heartbeatAt), lt(runs.heartbeatAt, staleBefore)))).returning({ id: runs.id })).length;
1033
+ }
1034
+ async function activeRunForReportTx(tx, loopId, runId, leaseTokenHash) {
1035
+ const lease = (await tx.select().from(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.runId, runId), eq(runLeases.loopId, loopId), eq(runLeases.state, "active"))).limit(1).for("update"))[0];
1036
+ if (!lease) return { state: "invalid-lease" };
1037
+ const run = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId), eq(runs.machineId, lease.machineId), eq(runs.phase, "running"))).limit(1).for("update"))[0];
1038
+ return run ? {
1039
+ state: "active",
1040
+ run
1041
+ } : { state: "run-not-running" };
1042
+ }
1043
+ /** Record the agent-authored status/message exactly once. The loop row is the
1044
+ * serialization point, so sequential and concurrent duplicate callbacks both
1045
+ * lose before any field can be overwritten. */
1046
+ async function recordRunReportOnce(input) {
1047
+ return db.transaction(async (tx) => {
1048
+ if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0]) return { state: "missing-loop" };
1049
+ const active = await activeRunForReportTx(tx, input.loopId, input.runId, input.leaseTokenHash);
1050
+ if (active.state !== "active") return active;
1051
+ if (active.run.status != null || active.run.message != null) return { state: "already-reported" };
1052
+ const at = nowIso$1();
1053
+ const run = (await tx.update(runs).set({
1054
+ status: input.status,
1055
+ message: input.message,
1056
+ updatedAt: at
1057
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"), isNull(runs.status), isNull(runs.message))).returning())[0];
1058
+ return run ? {
1059
+ state: "applied",
1060
+ run
1061
+ } : { state: "already-reported" };
1062
+ });
1063
+ }
1064
+ async function finalizeTerminalRunTx(tx, input) {
1065
+ if (input.receipt) await lockReportIdTx(tx, input.receipt.reportId);
1066
+ const current = (await tx.select().from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1067
+ if (!current) return void 0;
1068
+ if (input.receipt) {
1069
+ if ((await tx.select({ id: terminalReportIncidents.id }).from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, input.receipt.reportId)).limit(1))[0]) return void 0;
1070
+ }
1071
+ const leaseState = input.leaseCheck ? await input.leaseCheck(tx) : void 0;
1072
+ if (input.leaseCheck && !leaseState) return void 0;
1073
+ const target = (await tx.select().from(runs).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, input.runPhase))).limit(1).for("update"))[0];
1074
+ if (!target) return void 0;
1075
+ const runIndex = await ensureRunIndexTx(tx, target);
1076
+ const at = typeof input.runPatch.ts === "string" ? input.runPatch.ts : nowIso$1();
1077
+ const run = (await tx.update(runs).set({
1078
+ ...input.runPatch,
1079
+ runIndex,
1080
+ updatedAt: at
1081
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, input.runPhase))).returning())[0];
1082
+ if (!run) return void 0;
1083
+ const lifecycle = leaseState === "reconciliation-only" ? {
1084
+ loop: current,
1085
+ failureStreak: 0,
1086
+ autoPaused: false
1087
+ } : await terminalLifecycleTx(tx, current, run, at, input.loopPatch, input.failureAutopauseStreak);
1088
+ if (input.leaseTokenHash) await tx.delete(runLeases).where(eq(runLeases.runId, input.runId));
1089
+ if (input.receipt) await tx.insert(runReportReceipts).values(input.receipt);
1090
+ return {
1091
+ run,
1092
+ ...lifecycle,
1093
+ ...input.reconciliation ? { reportOnly: leaseState === "reconciliation-only" } : {}
1094
+ };
1095
+ }
1096
+ async function reclaimedLeaseStateTx(tx, loopId, runId, leaseTokenHash) {
1097
+ const leaseNow = nowIso$1();
1098
+ const lease = (await tx.select({ state: runLeases.state }).from(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.runId, runId), eq(runLeases.loopId, loopId), inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), gt(runLeases.expiresAt, leaseNow))).limit(1).for("update"))[0];
1099
+ return lease?.state === "terminal-grace" || lease?.state === "reconciliation-only" ? lease.state : void 0;
1100
+ }
1101
+ /** Consume a retired tombstone when its reportId is owned by another run.
1102
+ * The run/loop may already be deleted, so only the exact incident receipt and
1103
+ * lease tombstone participate in this transaction. */
1104
+ async function acknowledgeRetiredTerminalIncident(input) {
1105
+ return db.transaction(async (tx) => {
1106
+ await lockReportIdTx(tx, input.reportId);
1107
+ const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
1108
+ const replay = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
1109
+ const lease = (await tx.select({ tokenHash: runLeases.tokenHash }).from(runLeases).where(and(eq(runLeases.tokenHash, input.leaseTokenHash), eq(runLeases.runId, input.runId), eq(runLeases.state, "retired"))).limit(1).for("update"))[0];
1110
+ if (!lease) return void 0;
1111
+ if (replay) {
1112
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
1113
+ return replay;
1114
+ }
1115
+ const receipt = (await tx.insert(terminalReportIncidents).values({
1116
+ id,
1117
+ runId: input.runId,
1118
+ reportId: input.reportId,
1119
+ payloadDigest: input.payloadDigest,
1120
+ disposition: "telemetry-rejected",
1121
+ ackBody: input.ackBody,
1122
+ createdAt: nowIso$1()
1123
+ }).returning())[0];
1124
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
1125
+ return receipt;
1126
+ });
1127
+ }
1128
+ /** Reject one correlatable terminal attempt as a durable terminal fact. Authority,
1129
+ * run/loop lifecycle, incident receipt, and lease consumption share one transaction.
1130
+ * No payload-authored cursor/task/message field is accepted here. */
1131
+ async function rejectTerminalReport(input) {
1132
+ return db.transaction(async (tx) => {
1133
+ await lockReportIdTx(tx, input.reportId);
1134
+ const current = (await tx.select().from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1135
+ if (!current) return { state: "missing-loop" };
1136
+ const normal = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
1137
+ if (normal?.runId === input.runId) return {
1138
+ state: "normal-replay",
1139
+ receipt: normal
1140
+ };
1141
+ const priorIncident = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(input.reportId, input.payloadDigest))).limit(1))[0];
1142
+ if (priorIncident?.runId === input.runId) return {
1143
+ state: "incident-replay",
1144
+ receipt: priorIncident
1145
+ };
1146
+ const now = nowIso$1();
1147
+ const leaseConditions = [
1148
+ eq(runLeases.tokenHash, input.leaseTokenHash),
1149
+ eq(runLeases.runId, input.runId),
1150
+ eq(runLeases.loopId, input.loopId),
1151
+ input.leaseState === "active" ? eq(runLeases.state, "active") : inArray(runLeases.state, ["terminal-grace", "reconciliation-only"])
1152
+ ];
1153
+ if (input.leaseState !== "active") leaseConditions.push(gt(runLeases.expiresAt, now));
1154
+ const lease = (await tx.select().from(runLeases).where(and(...leaseConditions)).limit(1).for("update"))[0];
1155
+ if (!lease) return { state: "invalid-lease" };
1156
+ const receiptInput = {
1157
+ id: terminalIncidentReceiptId(input.reportId, input.payloadDigest),
1158
+ runId: input.runId,
1159
+ reportId: input.reportId,
1160
+ payloadDigest: input.payloadDigest,
1161
+ disposition: input.disposition,
1162
+ ackBody: input.ackBody,
1163
+ createdAt: input.incident.at
1164
+ };
1165
+ if (lease.state !== "active") {
1166
+ const run = (await tx.update(runs).set({
1167
+ reportIncident: input.incident,
1168
+ updatedAt: input.incident.at
1169
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), inArray(runs.phase, [
1170
+ "done",
1171
+ "error",
1172
+ "canceled"
1173
+ ]))).returning())[0];
1174
+ if (!run) return { state: "run-not-terminalizable" };
1175
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
1176
+ return {
1177
+ state: "telemetry-rejected",
1178
+ run,
1179
+ loop: current,
1180
+ receipt: priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0],
1181
+ failureStreak: 0,
1182
+ autoPaused: false
1183
+ };
1184
+ }
1185
+ const target = (await tx.select().from(runs).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"))).limit(1).for("update"))[0];
1186
+ if (!target) return { state: "run-not-terminalizable" };
1187
+ const runIndex = await ensureRunIndexTx(tx, target);
1188
+ const run = (await tx.update(runs).set({
1189
+ phase: "error",
1190
+ runIndex,
1191
+ error: input.incident.reason,
1192
+ reportIncident: input.incident,
1193
+ ts: input.incident.at,
1194
+ updatedAt: input.incident.at
1195
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"))).returning())[0];
1196
+ if (!run) return { state: "run-not-terminalizable" };
1197
+ const lifecycle = await terminalLifecycleTx(tx, current, run, input.incident.at, {}, input.failureAutopauseStreak ?? 0);
1198
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
1199
+ const receipt = priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0];
1200
+ return {
1201
+ state: "run-error",
1202
+ run,
1203
+ ...lifecycle,
1204
+ receipt
1205
+ };
1206
+ });
1207
+ }
1208
+ /** Atomically CAS running→terminal and apply the report's loop patch.
1209
+ * The loop patch is unreachable when cancel/reclaim/another report won. */
1210
+ async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt) {
1211
+ return db.transaction(async (tx) => {
1212
+ return finalizeTerminalRunTx(tx, {
1213
+ loopId,
1214
+ runId,
1215
+ runPhase: "running",
1216
+ runPatch,
1217
+ loopPatch,
1218
+ leaseTokenHash,
1219
+ leaseCheck: leaseTokenHash ? async (lockedTx) => {
1220
+ return (await activeRunForReportTx(lockedTx, loopId, runId, leaseTokenHash)).state === "active" ? "active" : void 0;
1221
+ } : void 0,
1222
+ failureAutopauseStreak,
1223
+ receipt
1224
+ });
1225
+ });
1226
+ }
1227
+ /** Sweep reclaim under the same loop lock as claim/report/cancel. Running
1228
+ * reclaims terminalize their lease and write the provisional continuous cadence
1229
+ * fact atomically; pending reclaims have no lease. */
1230
+ async function reclaimRun(runId, expected, reason, at = nowIso$1(), graceMs = 1440 * 60 * 1e3, failureAutopauseStreak = 0) {
1231
+ return db.transaction(async (tx) => {
1232
+ const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
1233
+ if (!observed) return void 0;
1234
+ const loop = (await tx.select().from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
1235
+ if (!loop) return void 0;
1236
+ const currentRun = (await tx.select().from(runs).where(eq(runs.id, runId)).limit(1).for("update"))[0];
1237
+ if (currentRun?.phase !== expected) return void 0;
1238
+ if (expected === "running") await tx.update(runLeases).set({
1239
+ state: "terminal-grace",
1240
+ expiresAt: new Date(Date.parse(at) + graceMs).toISOString()
1241
+ }).where(and(eq(runLeases.runId, runId), eq(runLeases.state, "active")));
1242
+ const runIndex = await ensureRunIndexTx(tx, currentRun);
1243
+ const reclaimed = (await tx.update(runs).set({
1244
+ phase: "error",
1245
+ runIndex,
1246
+ error: reason,
1247
+ ts: at,
1248
+ updatedAt: at
1249
+ }).where(and(eq(runs.id, runId), eq(runs.phase, expected))).returning())[0];
1250
+ if (!reclaimed) return void 0;
1251
+ return {
1252
+ run: reclaimed,
1253
+ ...await terminalLifecycleTx(tx, loop, reclaimed, at, {}, failureAutopauseStreak)
1254
+ };
1255
+ });
1256
+ }
1257
+ /** A daemon's completed recovery snapshot is authoritative for execution
1258
+ * exclusion. Reclaimed runs absent from that snapshot retain late-report authority
1259
+ * until their original expiry, but stop blocking queued work immediately. */
1260
+ async function releaseAbsentReconciliations(machineId, recoverableRunIds, at = nowIso$1()) {
1261
+ const conditions = [
1262
+ eq(runLeases.machineId, machineId),
1263
+ eq(runLeases.state, "terminal-grace"),
1264
+ gt(runLeases.expiresAt, at)
1265
+ ];
1266
+ const ids = [...new Set(recoverableRunIds)];
1267
+ if (ids.length) conditions.push(notInArray(runLeases.runId, ids));
1268
+ return (await db.update(runLeases).set({ state: "reconciliation-only" }).where(and(...conditions)).returning({ runId: runLeases.runId })).map((row) => row.runId);
1269
+ }
1270
+ /** Consume one terminal reconciliation lease and reconcile its reclaimed error
1271
+ * exactly once. Keeping the lease check/delete beside the run + loop writes closes the
1272
+ * error→error double-report hole where a phase CAS alone cannot distinguish the
1273
+ * first real failure from a second concurrent one. */
1274
+ async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt) {
1275
+ return db.transaction(async (tx) => {
1276
+ return finalizeTerminalRunTx(tx, {
1277
+ loopId,
1278
+ runId,
1279
+ runPhase: "error",
1280
+ runPatch,
1281
+ loopPatch,
1282
+ leaseTokenHash,
1283
+ leaseCheck: (lockedTx) => reclaimedLeaseStateTx(lockedTx, loopId, runId, leaseTokenHash),
1284
+ failureAutopauseStreak,
1285
+ receipt,
1286
+ reconciliation: true
1287
+ });
1288
+ });
1289
+ }
1290
+ var TERMINAL_REPORT_GRACE_MS = 600 * 1e3;
1291
+ /**
1292
+ * The durable run-queue write seam. It serializes on the loop row and owns every
1293
+ * queue invariant callers would otherwise have to reproduce:
1294
+ * - at most one ordinary pending row per loop (also backed by a partial unique index),
1295
+ * - a running run may retain one coalesced follow-up,
1296
+ * - owner authority promotes an existing system row and never downgrades,
1297
+ * - paused loops accept owner Run-once work while recurring system work stays stopped.
1298
+ */
1299
+ async function enqueueRun(loopId, request) {
1300
+ return db.transaction(async (tx) => {
1301
+ const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1302
+ if (!loop) return {
1303
+ state: "rejected",
1304
+ reason: "loop not found"
1305
+ };
1306
+ return enqueueRunTx(tx, loop, request, nowIso$1());
1307
+ });
1308
+ }
1309
+ /** Materialize every due schedule fact. Each loop is locked and rechecked in its
1310
+ * own transaction; cadence and one-shot facts may coalesce into one system exec
1311
+ * and are consumed together. */
1312
+ async function advanceDueSchedules(at = nowIso$1(), filter = {}) {
1313
+ const due = or(and(isNotNull(loops.nextCadenceAt), sql`${loops.nextCadenceAt} <= ${at}`), and(isNotNull(loops.nextRunAt), sql`${loops.nextRunAt} <= ${at}`));
1314
+ const candidates = await db.select({ id: loops.id }).from(loops).where(and(eq(loops.enabled, true), isNull(loops.deleteRequestedAt), due, filter.loopId ? eq(loops.id, filter.loopId) : void 0, filter.machineId ? eq(loops.machineId, filter.machineId) : void 0));
1315
+ const advanced = [];
1316
+ for (const { id } of candidates) {
1317
+ const result = await db.transaction(async (tx) => {
1318
+ const loop = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1319
+ if (!loop?.enabled || loop.deleteRequestedAt) return void 0;
1320
+ const cadenceDue = !!loop.nextCadenceAt && Date.parse(loop.nextCadenceAt) <= Date.parse(at);
1321
+ const oneShotDue = !!loop.nextRunAt && Date.parse(loop.nextRunAt) <= Date.parse(at);
1322
+ if (!cadenceDue && !oneShotDue) return void 0;
1323
+ if ((await tx.select({ tokenHash: runLeases.tokenHash }).from(runLeases).where(and(eq(runLeases.loopId, id), eq(runLeases.state, "terminal-grace"), gt(runLeases.expiresAt, at))).limit(1))[0]) return void 0;
1324
+ const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
1325
+ const skipRecurring = cadenceDue && !!open && (loop.scheduleMode === "continuous" || loop.cronOverlap === "skip");
1326
+ const queued = oneShotDue || !skipRecurring ? await enqueueRunTx(tx, loop, { requestedBy: "system" }, at) : void 0;
1327
+ if (queued && !("run" in queued)) return void 0;
1328
+ const nextCadenceAt = cadenceDue ? loop.scheduleMode === "cron" ? nextCronAt(loop.cron, loop.timezone, at) : null : loop.nextCadenceAt;
1329
+ const updatedLoop = (await tx.update(loops).set({
1330
+ nextCadenceAt,
1331
+ ...oneShotDue ? { nextRunAt: null } : {},
1332
+ updatedAt: at
1333
+ }).where(eq(loops.id, id)).returning())[0];
1334
+ return queued ? {
1335
+ loop: updatedLoop,
1336
+ run: queued.run,
1337
+ state: queued.state
1338
+ } : {
1339
+ loop: updatedLoop,
1340
+ state: "skipped"
1341
+ };
1342
+ });
1343
+ if (result) advanced.push(result);
1344
+ }
1345
+ return advanced;
1346
+ }
1347
+ /** Conservative boot initialization for pre-fact cron rows. No history lookup and
1348
+ * no missed-fire catch-up: only install the next future occurrence. */
1349
+ async function initializeCronCadence(at = nowIso$1()) {
1350
+ const candidates = await db.select({ id: loops.id }).from(loops).where(and(eq(loops.enabled, true), isNull(loops.deleteRequestedAt), eq(loops.scheduleMode, "cron"), isNull(loops.nextCadenceAt)));
1351
+ const initialized = [];
1352
+ for (const { id } of candidates) {
1353
+ const loop = await db.transaction(async (tx) => {
1354
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1355
+ if (!current?.enabled || current.scheduleMode !== "cron" || current.nextCadenceAt) return void 0;
1356
+ return (await tx.update(loops).set({
1357
+ nextCadenceAt: nextCronAt(current.cron, current.timezone, at),
1358
+ updatedAt: at
1359
+ }).where(eq(loops.id, id)).returning())[0];
1360
+ });
1361
+ if (loop) initialized.push(loop);
1362
+ }
1363
+ return initialized;
1364
+ }
1365
+ /** Claim one ready loop and mint its lease atomically. Repeated polls may add
1366
+ * unlimited cross-loop concurrency; the loop index remains the final same-loop
1367
+ * serialization invariant. Reported local runs exclude their loops even if the
1368
+ * server has already terminalized an older process. */
1369
+ async function claimReadyRunForMachine(machineId, at = nowIso$1(), excludeRunIds = []) {
1370
+ return db.transaction(async (tx) => {
1371
+ if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, machineId)).for("update"))[0]) return void 0;
1372
+ const excludedLoops = /* @__PURE__ */ new Set();
1373
+ if (excludeRunIds.length) {
1374
+ const rows = await tx.select({ loopId: runs.loopId }).from(runs).where(and(eq(runs.machineId, machineId), inArray(runs.id, [...new Set(excludeRunIds)])));
1375
+ for (const row of rows) excludedLoops.add(row.loopId);
1376
+ }
1377
+ const next = (await tx.select({
1378
+ run: runs,
1379
+ loop: loops
1380
+ }).from(runs).innerJoin(loops, eq(loops.id, runs.loopId)).where(and(eq(runs.machineId, machineId), eq(runs.phase, "pending"), isNull(runs.cancelRequestedAt), or(eq(loops.enabled, true), eq(runs.requestedBy, "owner")), isNull(loops.deleteRequestedAt), excludedLoops.size ? notInArray(loops.id, [...excludedLoops]) : void 0, sql`not exists (select 1 from runs occupied where occupied.loop_id = ${loops.id} and occupied.phase = 'running')`, sql`not exists (select 1 from run_leases authority where authority.loop_id = ${loops.id} and (authority.state = 'active' or (authority.state = 'terminal-grace' and authority.expires_at > ${at})))`)).orderBy(asc(runs.createdAt), asc(runs.id)).limit(1))[0];
1381
+ if (!next) return void 0;
1382
+ let loop = (await tx.select().from(loops).where(eq(loops.id, next.loop.id)).for("update"))[0];
1383
+ const candidate = (await tx.select().from(runs).where(eq(runs.id, next.run.id)).limit(1).for("update"))[0];
1384
+ if (!loop || !candidate || candidate.phase !== "pending" || candidate.cancelRequestedAt || loop.deleteRequestedAt || !loop.enabled && candidate.requestedBy !== "owner") return void 0;
1385
+ const occupied = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "running"))).limit(1))[0];
1386
+ const authority = (await tx.select({ tokenHash: runLeases.tokenHash }).from(runLeases).where(and(eq(runLeases.loopId, loop.id), inArray(runLeases.state, ["active", "terminal-grace"]), or(eq(runLeases.state, "active"), gt(runLeases.expiresAt, at)))).limit(1))[0];
1387
+ if (occupied || authority) return void 0;
1388
+ const runIndex = await ensureRunIndexTx(tx, candidate);
1389
+ const run = (await tx.update(runs).set({
1390
+ phase: "running",
1391
+ runIndex,
1392
+ agent: loop.agent,
1393
+ model: loop.model,
1394
+ reasoningEffort: loop.reasoningEffort,
1395
+ heartbeatAt: null,
1396
+ ts: at,
1397
+ updatedAt: at
1398
+ }).where(and(eq(runs.id, candidate.id), eq(runs.phase, "pending"), isNull(runs.cancelRequestedAt))).returning())[0];
1399
+ if (!run) return void 0;
1400
+ if (loop.scheduleMode === "continuous" && loop.nextCadenceAt != null) loop = (await tx.update(loops).set({
1401
+ nextCadenceAt: null,
1402
+ updatedAt: at
1403
+ }).where(eq(loops.id, loop.id)).returning())[0];
1404
+ const runToken = `rk_${randomBytes(16).toString("hex")}`;
1405
+ const tokenHash = createHash("sha256").update(runToken).digest("hex");
1406
+ await tx.insert(runLeases).values({
1407
+ tokenHash,
1408
+ runId: run.id,
1409
+ loopId: loop.id,
1410
+ machineId,
1411
+ createdAt: at
1412
+ });
1413
+ return {
1414
+ run,
1415
+ loop,
1416
+ runToken
1417
+ };
1418
+ });
1419
+ }
1420
+ async function listRuns(loopId, limit = 30) {
1421
+ return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(limit)).reverse();
1422
+ }
1423
+ async function reconciliationStatesForRuns(loopId, runIds, at = nowIso$1()) {
1424
+ if (!runIds.length) return /* @__PURE__ */ new Map();
1425
+ const rows = await db.select({
1426
+ runId: runLeases.runId,
1427
+ state: runLeases.state
1428
+ }).from(runLeases).where(and(eq(runLeases.loopId, loopId), inArray(runLeases.runId, [...new Set(runIds)]), inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), gt(runLeases.expiresAt, at)));
1429
+ return new Map(rows.map((row) => [row.runId, row.state === "terminal-grace" ? "blocking" : "report-only"]));
1430
+ }
1431
+ /** One older page: runs strictly before `beforeTs`, newest-first then capped,
1432
+ * returned chronological (oldest-first) to match listRuns. Cursor-based (by ts,
1433
+ * not offset) so it's stable while new runs land at the head. */
1434
+ async function listRunsBefore(loopId, beforeTs, limit = 16) {
1435
+ return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), lt(runs.ts, beforeTs))).orderBy(desc(runs.ts)).limit(limit)).reverse();
1436
+ }
1437
+ async function lastRun(loopId) {
1438
+ return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(1))[0];
1439
+ }
1440
+ async function countRuns(loopId) {
1441
+ const r = (await db.select({ n: sql`count(*)` }).from(runs).where(eq(runs.loopId, loopId)))[0];
1442
+ return Number(r?.n ?? 0);
1443
+ }
1444
+ async function recentLoopUsage(loopId, since) {
1445
+ const row = (await db.select({
1446
+ runCount: sql`count(*)`,
1447
+ tokenCount: sql`coalesce(sum(
1448
+ coalesce((${runs.usage} ->> 'inputTokens')::bigint, 0) +
1449
+ coalesce((${runs.usage} ->> 'outputTokens')::bigint, 0)
1450
+ ), 0)`
1451
+ }).from(runs).where(and(eq(runs.loopId, loopId), sql`${runs.ts} >= ${since}`)))[0];
1452
+ return {
1453
+ runCount: Number(row?.runCount ?? 0),
1454
+ tokenCount: Number(row?.tokenCount ?? 0)
1455
+ };
1456
+ }
1457
+ async function openRuns() {
1458
+ return db.select().from(runs).where(inArray(runs.phase, ["pending", "running"]));
1459
+ }
1460
+ async function hasRunningRun(loopId) {
1461
+ return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
1462
+ }
1463
+ async function hasPendingRun(loopId) {
1464
+ return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "pending"))).limit(1))[0];
1465
+ }
1466
+ /** Expire one offline system row only if the sweep snapshot is still exact and
1467
+ * its immutable lifetime remains over the cap under the loop lock. */
1468
+ async function expirePendingRun(runId, expected, at, maxLifetimeMs, message) {
1469
+ return db.transaction(async (tx) => {
1470
+ const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
1471
+ if (!observed) return false;
1472
+ const loop = (await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
1473
+ if (!loop) return false;
1474
+ const current = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loop.id), eq(runs.phase, "pending"), eq(runs.requestedBy, "system"), eq(runs.requestedBy, expected.requestedBy), eq(runs.updatedAt, expected.updatedAt))).limit(1).for("update"))[0];
1475
+ if (!current || Date.parse(at) - Date.parse(current.createdAt) <= maxLifetimeMs) return false;
1476
+ if ((await tx.select({ online: machines.online }).from(machines).where(eq(machines.id, current.machineId)).limit(1))[0]?.online) return false;
1477
+ const runIndex = await ensureRunIndexTx(tx, current);
1478
+ return (await tx.update(runs).set({
1479
+ phase: "canceled",
1480
+ runIndex,
1481
+ message,
1482
+ ts: at,
1483
+ updatedAt: at
1484
+ }).where(and(eq(runs.id, runId), eq(runs.phase, "pending"), eq(runs.requestedBy, "system"), eq(runs.updatedAt, expected.updatedAt))).returning({ id: runs.id })).length > 0;
1485
+ });
1486
+ }
1487
+ async function listMachines() {
1488
+ return db.select().from(machines).orderBy(asc(machines.name), asc(machines.id));
1489
+ }
1490
+ async function getMachine(id) {
1491
+ return (await db.select().from(machines).where(eq(machines.id, id)))[0];
1492
+ }
1493
+ async function createMachine(input) {
1494
+ return (await db.insert(machines).values({
1495
+ ...input,
1496
+ createdAt: nowIso$1()
1497
+ }).returning())[0];
1498
+ }
1499
+ async function updateMachine(id, patch) {
1500
+ await db.update(machines).set(patch).where(eq(machines.id, id));
1501
+ return getMachine(id);
1502
+ }
1503
+ /** Atomically retire all loop authority, remove server-owned machine data, revoke
1504
+ * enrollment, and delete the machine. */
1505
+ async function forceDeleteMachine(id) {
1506
+ return db.transaction(async (tx) => {
1507
+ if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, id)).for("update"))[0]) return { state: "not-found" };
1508
+ const ownedLoops = await tx.select().from(loops).where(eq(loops.machineId, id)).for("update");
1509
+ for (const loop of ownedLoops) if (!await forceDeleteLoopTx(tx, loop.id)) throw new Error(`failed to delete loop ${loop.id}`);
1510
+ await tx.delete(connectKeys).where(eq(connectKeys.machineId, id));
1511
+ await tx.delete(machines).where(eq(machines.id, id));
1512
+ return {
1513
+ state: "deleted",
1514
+ loopIds: ownedLoops.map((loop) => loop.id)
1515
+ };
1516
+ });
1517
+ }
1518
+ async function setMachineOnline(id, online) {
1519
+ await db.update(machines).set({
1520
+ online,
1521
+ lastSeen: nowIso$1()
1522
+ }).where(eq(machines.id, id));
1523
+ }
1524
+ async function blobExists(hash) {
1525
+ return !!(await db.select({ hash: blobs.hash }).from(blobs).where(eq(blobs.hash, hash)))[0];
1526
+ }
1527
+ /** Verified byte lengths recorded at blob ingress, used to budget reads before
1528
+ * touching the byte store. Missing metadata is intentionally not estimated. */
1529
+ async function blobSizes(hashes) {
1530
+ if (!hashes.length) return /* @__PURE__ */ new Map();
1531
+ const rows = await db.select({
1532
+ hash: blobs.hash,
1533
+ size: blobs.size
1534
+ }).from(blobs).where(inArray(blobs.hash, [...new Set(hashes)]));
1535
+ return new Map(rows.map((row) => [row.hash, row.size]));
1536
+ }
1537
+ async function recordBlob(hash, size) {
1538
+ await db.insert(blobs).values({
1539
+ hash,
1540
+ size,
1541
+ createdAt: nowIso$1()
1542
+ }).onConflictDoNothing();
1543
+ }
1544
+ /** Does any LIVE artifact_files row on a loop bound to `machineId` point at `hash`?
1545
+ * Gates putBlob: a device may only upload bytes the sync handshake actually asked
1546
+ * it for (a row a prior sync wrote for one of ITS loops), never arbitrary
1547
+ * self-hashed blobs — otherwise any device token is an uncapped blob write channel. */
1548
+ async function machineReferencesBlob(machineId, hash) {
1549
+ return !!(await db.select({ id: artifactFiles.id }).from(artifactFiles).innerJoin(loops, eq(artifactFiles.loopId, loops.id)).where(and(eq(loops.machineId, machineId), eq(artifactFiles.hash, hash), eq(artifactFiles.deleted, false), sql`${loops.artifacts} @> jsonb_build_array(${artifactFiles.path})`)).limit(1))[0];
1550
+ }
1551
+ async function writeConfiguredArtifactTx(tx, input, ts) {
1552
+ await tx.insert(artifactFiles).values({
1553
+ id: randomUUID(),
1554
+ loopId: input.loopId,
1555
+ path: input.path,
1556
+ hash: input.hash,
1557
+ size: input.size,
1558
+ binary: input.binary,
1559
+ oversize: input.oversize,
1560
+ deleted: false,
1561
+ updatedAt: ts
1562
+ }).onConflictDoUpdate({
1563
+ target: [artifactFiles.loopId, artifactFiles.path],
1564
+ set: {
1565
+ hash: input.hash,
1566
+ size: input.size,
1567
+ binary: input.binary,
1568
+ oversize: input.oversize,
1569
+ deleted: false,
1570
+ updatedAt: ts
1571
+ }
1572
+ });
1573
+ }
1574
+ /** Upsert only while the exact literal path remains configured, serialized with
1575
+ * owner edits by the loop row lock. */
1576
+ async function upsertConfiguredArtifactFile(input) {
1577
+ return db.transaction(async (tx) => {
1578
+ const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1579
+ if (!loop || !loop.artifacts.includes(input.path)) return false;
1580
+ await writeConfiguredArtifactTx(tx, input, nowIso$1());
1581
+ return true;
1582
+ });
1583
+ }
1584
+ /** Reconcile deletions against both the request's starting allowlist and the
1585
+ * current locked loop config. Newly-added paths are never tombstoned by an older
1586
+ * in-flight manifest; removed paths can never remain live. */
1587
+ async function reconcileConfiguredArtifacts(loopId, configuredAtStart, keepPaths) {
1588
+ return db.transaction(async (tx) => {
1589
+ const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1590
+ if (!loop) return 0;
1591
+ const current = new Set(loop.artifacts);
1592
+ const started = new Set(configuredAtStart);
1593
+ const keep = new Set(keepPaths);
1594
+ const stale = (await tx.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false)))).filter((file) => !current.has(file.path) || started.has(file.path) && !keep.has(file.path));
1595
+ if (!stale.length) return 0;
1596
+ const at = nowIso$1();
1597
+ await tx.update(artifactFiles).set({
1598
+ hash: null,
1599
+ deleted: true,
1600
+ updatedAt: at
1601
+ }).where(inArray(artifactFiles.id, stale.map((file) => file.id)));
1602
+ return stale.length;
1603
+ });
1604
+ }
1605
+ async function listArtifacts(loopId) {
1606
+ return db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false))).orderBy(artifactFiles.path);
1607
+ }
1608
+ async function getArtifactFile(loopId, path) {
1609
+ return (await db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.path, path))))[0];
1610
+ }
1611
+ async function buildLoopManifest(loopId) {
1612
+ const manifest = {};
1613
+ for (const f of await listArtifacts(loopId)) manifest[f.path] = {
1614
+ hash: f.hash,
1615
+ size: f.size,
1616
+ binary: f.binary,
1617
+ oversize: f.oversize
1618
+ };
1619
+ return manifest;
1620
+ }
1621
+ /** Write/overwrite a run's snapshot (path → file metadata). Idempotent on runId
1622
+ * so a re-report of the same run just refreshes the captured artifact state. */
1623
+ async function putRunSnapshot(runId, loopId, manifest) {
1624
+ await db.insert(runSnapshots).values({
1625
+ runId,
1626
+ loopId,
1627
+ manifest,
1628
+ createdAt: nowIso$1()
1629
+ }).onConflictDoUpdate({
1630
+ target: runSnapshots.runId,
1631
+ set: {
1632
+ loopId,
1633
+ manifest,
1634
+ createdAt: nowIso$1()
1635
+ }
1636
+ });
1637
+ }
1638
+ async function getRunSnapshot(runId) {
1639
+ return (await db.select().from(runSnapshots).where(eq(runSnapshots.runId, runId)))[0];
1640
+ }
1641
+ /** The most recent indexed snapshot before one run. History order, not a
1642
+ * timestamp tie, is the diff baseline. */
1643
+ async function prevRunSnapshot(loopId, beforeRunIndex) {
1644
+ return (await db.select({ snap: runSnapshots }).from(runSnapshots).innerJoin(runs, eq(runSnapshots.runId, runs.id)).where(and(eq(runSnapshots.loopId, loopId), isNotNull(runs.runIndex), lt(runs.runIndex, beforeRunIndex))).orderBy(desc(runs.runIndex)).limit(1))[0]?.snap;
1645
+ }
1646
+ /**
1647
+ * Prune a loop's run snapshots down to the `keep` most recent (by createdAt),
1648
+ * deleting the rest. Returns the number deleted. This is what makes an old
1649
+ * snapshot's now-unreferenced blobs collectable by the blob GC. `keep <= 0`
1650
+ * means "keep none" (still bounded). Safe to call repeatedly (idempotent once
1651
+ * at/under the window).
1652
+ */
1653
+ async function pruneRunSnapshots(loopId, keep) {
1654
+ const survivors = keep > 0 ? (await db.select({ runId: runSnapshots.runId }).from(runSnapshots).where(eq(runSnapshots.loopId, loopId)).orderBy(desc(runSnapshots.createdAt), desc(runSnapshots.runId)).limit(keep)).map((r) => r.runId) : [];
1655
+ const pred = survivors.length ? and(eq(runSnapshots.loopId, loopId), notInArray(runSnapshots.runId, survivors)) : eq(runSnapshots.loopId, loopId);
1656
+ return (await db.delete(runSnapshots).where(pred).returning({ id: runSnapshots.runId })).length;
1657
+ }
1658
+ async function loopIdsWithSnapshots() {
1659
+ return (await db.selectDistinct({ loopId: runSnapshots.loopId }).from(runSnapshots)).map((r) => r.loopId);
1660
+ }
1661
+ /**
1662
+ * The full set of blob hashes still referenced by a LIVE row — the GC's keep
1663
+ * set. A hash is live if ANY artifact_files row points at it (deleted tombstones
1664
+ * carry hash=null, so they don't pin a blob) OR ANY retained run_snapshot's
1665
+ * manifest references it. Computed in one pass so the GC never deletes a blob a
1666
+ * snapshot still needs for its diff.
1667
+ */
1668
+ async function liveBlobRefs() {
1669
+ const refs = /* @__PURE__ */ new Set();
1670
+ for (const r of await db.selectDistinct({ hash: artifactFiles.hash }).from(artifactFiles).where(isNotNull(artifactFiles.hash))) if (r.hash) refs.add(r.hash);
1671
+ for (const hash of await collectSnapshotBlobRefs()) refs.add(hash);
1672
+ return refs;
1673
+ }
1674
+ /** Blob hashes whose metadata row predates `cutoffIso` (GC candidates — the grace
1675
+ * window excludes freshly-written blobs a concurrent sync may be referencing). */
1676
+ async function blobHashesOlderThan(cutoffIso) {
1677
+ return (await db.select({ hash: blobs.hash }).from(blobs).where(lt(blobs.createdAt, cutoffIso))).map((r) => r.hash);
1678
+ }
1679
+ /** Delete a blob's metadata row (the bytes are reclaimed separately via the
1680
+ * BlobStore). Idempotent. */
1681
+ async function deleteBlob(hash) {
1682
+ await db.delete(blobs).where(eq(blobs.hash, hash));
1683
+ }
1684
+ /** Indexed point check: does any LIVE artifact_files row still point at this hash?
1685
+ * The GC's cheap, always-fresh per-candidate guard (the common re-reference path) —
1686
+ * uses the artifact_files_hash index, so it stays O(1) even as candidates pile up. */
1687
+ async function artifactFileReferencesHash(hash) {
1688
+ return !!(await db.select({ id: artifactFiles.id }).from(artifactFiles).where(eq(artifactFiles.hash, hash)))[0];
1689
+ }
1690
+ /** Every blob hash referenced by ANY retained run_snapshot's manifest — the full
1691
+ * snapshot scan deserialized ONCE into a Set so the GC can answer per-candidate
1692
+ * snapshot membership in O(1) instead of re-scanning the whole table per garbage
1693
+ * hash. The GC rebuilds this only when the snapshot row count changes (a report()
1694
+ * raced the pass), so a snapshot that comes to reference a hash mid-pass is still
1695
+ * caught — closing the GC-check-time gap where a snapshot references a hash no live
1696
+ * file row does — without paying O(garbage × snapshots). */
1697
+ async function collectSnapshotBlobRefs() {
1698
+ const refs = /* @__PURE__ */ new Set();
1699
+ for (const r of await db.select({ manifest: runSnapshots.manifest }).from(runSnapshots)) for (const entry of Object.values(r.manifest)) if (entry?.hash) refs.add(entry.hash);
1700
+ return refs;
1701
+ }
1702
+ async function snapshotBlobRefs() {
1703
+ return collectSnapshotBlobRefs();
1704
+ }
1705
+ /** Count of retained run_snapshot rows — the GC's cheap change-detector for deciding
1706
+ * whether to rebuild its precomputed snapshotBlobRefs() set mid-pass. */
1707
+ async function countRunSnapshots() {
1708
+ const r = (await db.select({ n: sql`count(*)` }).from(runSnapshots))[0];
1709
+ return Number(r?.n ?? 0);
1710
+ }
1711
+ var artifacts_exports = /* @__PURE__ */ __exportAll({
1712
+ BLOB_CAP: () => BLOB_CAP,
1713
+ SYNC_BODY_CAP: () => SYNC_BODY_CAP,
1714
+ isValidHash: () => isValidHash,
1715
+ parseArtifactSyncBody: () => parseArtifactSyncBody,
1716
+ safeRelPath: () => safeRelPath,
1717
+ sha256Buf: () => sha256Buf
1718
+ });
1719
+ /** Per-file byte cap. At or under ⇒ bytes sync; over ⇒ metadata-only (no bytes). */
1720
+ var BLOB_CAP = 10 * 1024 * 1024;
1721
+ /** Hard ceiling on the exact-manifest sync POST body. */
1722
+ var SYNC_BODY_CAP = 32 * 1024 * 1024;
1723
+ var SYNC_FIELDS = new Set(["loopId", "manifest"]);
1724
+ var MANIFEST_FIELDS = new Set([
1725
+ "path",
1726
+ "hash",
1727
+ "size",
1728
+ "binary",
1729
+ "oversize"
1730
+ ]);
1731
+ var MAX_STORED_SIZE = 2147483647;
1732
+ function exactRecord(value, fields) {
1733
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1734
+ const proto = Object.getPrototypeOf(value);
1735
+ if (proto !== Object.prototype && proto !== null) return false;
1736
+ const keys = Object.keys(value);
1737
+ return keys.length === fields.size && keys.every((key) => fields.has(key));
1738
+ }
1739
+ function sha256Buf(bytes) {
1740
+ return createHash("sha256").update(bytes).digest("hex");
1741
+ }
1742
+ function isValidHash(hash) {
1743
+ return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
1744
+ }
1745
+ /**
1746
+ * Validate and preserve an untrusted, loop-folder-relative literal path. Returns
1747
+ * null if it is absolute, contains an empty/dot/traversal segment, is empty, or
1748
+ * carries a NUL (no real filesystem produces one, and Postgres rejects it).
1749
+ */
1750
+ function safeRelPath(raw) {
1751
+ if (typeof raw !== "string" || raw.length === 0 || raw.includes("\0")) return null;
1752
+ if (raw.startsWith("/") || raw.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(raw)) return null;
1753
+ if (raw.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return null;
1754
+ return raw;
1755
+ }
1756
+ /** Parse the one canonical sync envelope without coercion or omitted-field defaults. */
1757
+ function parseArtifactSyncBody(value) {
1758
+ if (!exactRecord(value, SYNC_FIELDS)) return null;
1759
+ const { loopId, manifest } = value;
1760
+ if (typeof loopId !== "string" || loopId.length === 0 || loopId.length > 200 || loopId.includes("\0") || !Array.isArray(manifest)) return null;
1761
+ const entries = [];
1762
+ const paths = /* @__PURE__ */ new Set();
1763
+ for (const raw of manifest) {
1764
+ if (!exactRecord(raw, MANIFEST_FIELDS)) return null;
1765
+ const { path, hash, size, binary, oversize } = raw;
1766
+ const rel = safeRelPath(path);
1767
+ if (rel === null || paths.has(rel) || typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > MAX_STORED_SIZE || typeof binary !== "boolean" || typeof oversize !== "boolean") return null;
1768
+ if (oversize) {
1769
+ if (size <= 10485760 || hash !== null) return null;
1770
+ entries.push({
1771
+ path: rel,
1772
+ hash: null,
1773
+ size,
1774
+ binary,
1775
+ oversize: true
1776
+ });
1777
+ } else {
1778
+ if (size > 10485760 || !isValidHash(hash)) return null;
1779
+ entries.push({
1780
+ path: rel,
1781
+ hash,
1782
+ size,
1783
+ binary,
1784
+ oversize: false
1785
+ });
1786
+ }
1787
+ paths.add(rel);
1788
+ }
1789
+ return {
1790
+ loopId,
1791
+ manifest: entries
1792
+ };
1793
+ }
1794
+ /**
1795
+ * Machine + run credential helpers. Device tokens (`dk_…`) identify a machine
1796
+ * (its id is derived from the token: `m-sha256(token)[:16]`). A run
1797
+ * LEASE (`rk_…`) is minted per delivery, bound to one run, and carries the
1798
+ * run's least-privilege caps — the CLI dispatch authorizes the `pievo` shim
1799
+ * against it. Its lifecycle is a small state machine (`active` →
1800
+ * `terminal-grace` → `reconciliation-only`/`retired`), not a mint→revoke pair;
1801
+ * see `RunLease` below.
1802
+ *
1803
+ * Leases and connect-key bindings are DURABLE (run_leases / connect_keys
1804
+ * tables): they must survive a deploy, or every restart 401s the in-flight
1805
+ * runs' callbacks/finalize and prevents a freshly connected machine from
1806
+ * enrolling. The 15-minute `new` idempotency window stays in-process;
1807
+ * losing it only degrades retry deduplication, never persisted data.
1808
+ */
1809
+ var tokens_exports = /* @__PURE__ */ __exportAll({
1810
+ CONNECT_KEY_TTL_MS: () => CONNECT_KEY_TTL_MS,
1811
+ NEW_IDEMPOTENCY_TTL_MS: () => NEW_IDEMPOTENCY_TTL_MS,
1812
+ TERMINAL_GRACE_MS: () => TERMINAL_GRACE_MS,
1813
+ connectKeyValid: () => connectKeyValid,
1814
+ isDeviceTokenShape: () => isDeviceTokenShape,
1815
+ isRunTokenShape: () => isRunTokenShape,
1816
+ machineIdFromToken: () => machineIdFromToken,
1817
+ mintDeviceToken: () => mintDeviceToken,
1818
+ pruneExpiredLeases: () => pruneExpiredLeases,
1819
+ readNewIdempotency: () => readNewIdempotency,
1820
+ recordNewIdempotency: () => recordNewIdempotency,
1821
+ rememberConnectKey: () => rememberConnectKey,
1822
+ resolveLease: () => resolveLease,
1823
+ retireLease: () => retireLease,
1824
+ sha256: () => sha256
1825
+ });
1826
+ function sha256(s) {
1827
+ return createHash("sha256").update(s).digest("hex");
1828
+ }
1829
+ function mintDeviceToken() {
1830
+ return `dk_${randomBytes(15).toString("hex")}`;
1831
+ }
1832
+ function machineIdFromToken(token) {
1833
+ return `m-${sha256(token).slice(0, 16)}`;
1834
+ }
1835
+ /**
1836
+ * Whether a string has the one current device-token wire shape. This malformed-input
1837
+ * filter runs before enrollment lookup; the connect-key gate remains the auth boundary.
1838
+ */
1839
+ var DEVICE_TOKEN_RE = /^dk_[0-9a-f]{30}$/;
1840
+ function isDeviceTokenShape(token) {
1841
+ return DEVICE_TOKEN_RE.test(token);
1842
+ }
1843
+ /** Keep bindings long enough for a leisurely paste, then drop (bounded table). */
1844
+ var CONNECT_KEY_TTL_MS = 1440 * 60 * 1e3;
1845
+ function connectKeyFresh(mintedAt, now) {
1846
+ return now - Date.parse(mintedAt) <= CONNECT_KEY_TTL_MS;
1847
+ }
1848
+ /** Record a freshly minted connect key and prune expired rows. */
1849
+ async function rememberConnectKey(connectKey) {
1850
+ const now = /* @__PURE__ */ new Date();
1851
+ await db.delete(connectKeys).where(lt(connectKeys.mintedAt, (/* @__PURE__ */ new Date(now.getTime() - CONNECT_KEY_TTL_MS)).toISOString()));
1852
+ const row = {
1853
+ machineId: machineIdFromToken(connectKey),
1854
+ mintedAt: now.toISOString()
1855
+ };
1856
+ await db.insert(connectKeys).values(row).onConflictDoUpdate({
1857
+ target: connectKeys.machineId,
1858
+ set: row
1859
+ });
1860
+ }
1861
+ async function connectKeyValid(connectKey, now = Date.now()) {
1862
+ if (!connectKey) return false;
1863
+ const row = (await db.select().from(connectKeys).where(eq(connectKeys.machineId, machineIdFromToken(connectKey))))[0];
1864
+ return !!row && connectKeyFresh(row.mintedAt, now);
1865
+ }
1866
+ /** How long a terminal-grace lease stays alive to accept one late wake-report.
1867
+ * A laptop can sleep overnight or across a weekend before the daemon resumes. */
1868
+ var TERMINAL_GRACE_MS = 1440 * 60 * 1e3;
1869
+ /** Leases live in the `run_leases` table, keyed by sha256(full `rk_…` wire
1870
+ * token), so a deploy is invisible to an in-flight run and a DB leak never hands
1871
+ * out live credentials. In rows, `expiresAt` null encodes `Infinity`. */
1872
+ function leaseFromRow(row) {
1873
+ return {
1874
+ runId: row.runId,
1875
+ loopId: row.loopId,
1876
+ machineId: row.machineId,
1877
+ state: row.state,
1878
+ expiresAt: row.expiresAt == null ? Number.POSITIVE_INFINITY : Date.parse(row.expiresAt)
1879
+ };
1880
+ }
1881
+ /** Mint a fresh run lease and return its wire token (`rk_…`, so the unified CLI
1882
+ * dispatch can tell a run credential from a device `dk_…` in O(1) before any
1883
+ * lookup). Starts `active` with no expiry. */
1884
+ var RUN_TOKEN_RE = /^rk_[0-9a-f]{32}$/;
1885
+ function isRunTokenShape(token) {
1886
+ return RUN_TOKEN_RE.test(token);
1887
+ }
1888
+ /** Resolve a run lease. An elapsed reconciliation window is atomically reduced
1889
+ * to durable, non-authorizing retired evidence rather than deleted. */
1890
+ async function resolveLease(token, now = Date.now()) {
1891
+ if (!isRunTokenShape(token)) return void 0;
1892
+ const tokenHash = sha256(token);
1893
+ let row = (await db.select().from(runLeases).where(eq(runLeases.tokenHash, tokenHash)))[0];
1894
+ if (!row) return void 0;
1895
+ if ((row.state === "terminal-grace" || row.state === "reconciliation-only") && row.expiresAt != null && now > Date.parse(row.expiresAt)) {
1896
+ row = (await db.update(runLeases).set({
1897
+ state: "retired",
1898
+ expiresAt: null
1899
+ }).where(and(eq(runLeases.tokenHash, tokenHash), inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), isNotNull(runLeases.expiresAt), lt(runLeases.expiresAt, new Date(now).toISOString()))).returning())[0] ?? (await db.select().from(runLeases).where(eq(runLeases.tokenHash, tokenHash)))[0];
1900
+ if (!row) return void 0;
1901
+ }
1902
+ return leaseFromRow(row);
1903
+ }
1904
+ /** Retire a lease outside a terminal write transaction (canceled/losing report
1905
+ * cleanup and expiry paths). Normal finalize and reclaimed-run
1906
+ * reconcile consume the row inside their store transaction so run+loop+lease are
1907
+ * one single-shot commit. */
1908
+ async function retireLease(token) {
1909
+ await db.delete(runLeases).where(eq(runLeases.tokenHash, sha256(token)));
1910
+ }
1911
+ /** State-aware lease maintenance. Expired reconciliation authority becomes a
1912
+ * durable retired tombstone. Active and already-retired rows are never age-pruned. */
1913
+ async function pruneExpiredLeases(now = Date.now()) {
1914
+ await db.update(runLeases).set({
1915
+ state: "retired",
1916
+ expiresAt: null
1917
+ }).where(and(inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), isNotNull(runLeases.expiresAt), lt(runLeases.expiresAt, new Date(now).toISOString())));
1918
+ }
1919
+ var newIdempotency = /* @__PURE__ */ new Map();
1920
+ /** Long enough to swallow a timed-out retry without collapsing later creates. */
1921
+ var NEW_IDEMPOTENCY_TTL_MS = 900 * 1e3;
1922
+ function pruneNewIdempotency(now) {
1923
+ for (const [key, rec] of newIdempotency) if (now - rec.at > 9e5) newIdempotency.delete(key);
1924
+ }
1925
+ function recordNewIdempotency(key, machineId, loopId, now = Date.now()) {
1926
+ pruneNewIdempotency(now);
1927
+ newIdempotency.set(key, {
1928
+ loopId,
1929
+ machineId,
1930
+ at: now
1931
+ });
1932
+ }
1933
+ /** The loop a still-live key already created for THIS machine, or undefined (a miss,
1934
+ * an expired key — dropped here — or a cross-machine record). NON-evicting on a hit:
1935
+ * a genuine retry may arrive more than once within the window. */
1936
+ function readNewIdempotency(key, machineId, now = Date.now()) {
1937
+ const rec = newIdempotency.get(key);
1938
+ if (!rec) return void 0;
1939
+ if (now - rec.at > 9e5) {
1940
+ newIdempotency.delete(key);
1941
+ return;
1942
+ }
1943
+ if (rec.machineId !== machineId) return void 0;
1944
+ return rec.loopId;
1945
+ }
1946
+ /**
1947
+ * Derive a machine's presence from its stored `online` flag + `lastSeen` stamp.
1948
+ * `online` requires BOTH the flag and a fresh stamp (the stored flag lags the
1949
+ * sweep by up to one interval); everything else keys off how stale `lastSeen` is.
1950
+ */
1951
+ function machinePresence(online, lastSeen, now = Date.now()) {
1952
+ const seenAt = lastSeen ? Date.parse(lastSeen) : NaN;
1953
+ if (!Number.isFinite(seenAt)) return "offline";
1954
+ const age = now - seenAt;
1955
+ if (!!online && age < 3e4) return "online";
1956
+ if (age < 216e5) return "asleep";
1957
+ return "offline";
1958
+ }
1959
+ /**
1960
+ * Tiny semver comparison — just enough to decide "is the daemon older than the
1961
+ * latest published version?" for the web's update hint. No dependency; we only
1962
+ * need numeric-core ordering (major.minor.patch), and a pre-release is treated
1963
+ * as older than its release (a "0.9.0-rc.1" daemon is still behind "0.9.0").
1964
+ */
1965
+ /** Strict SemVer validation for security/protocol gates. Unlike the update-hint
1966
+ * comparison below, this accepts no whitespace, `v` prefix, partial core, or
1967
+ * trailing garbage. */
1968
+ function isValidSemver(value) {
1969
+ const plus = value.split("+");
1970
+ if (plus.length > 2) return false;
1971
+ const [versionAndPre, build] = plus;
1972
+ if (build !== void 0 && !validIdentifiers(build, false)) return false;
1973
+ const dash = versionAndPre.indexOf("-");
1974
+ const numeric = dash < 0 ? versionAndPre : versionAndPre.slice(0, dash);
1975
+ const pre = dash < 0 ? void 0 : versionAndPre.slice(dash + 1);
1976
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(numeric)) return false;
1977
+ return pre === void 0 || validIdentifiers(pre, true);
1978
+ }
1979
+ function validIdentifiers(value, rejectNumericLeadingZero) {
1980
+ if (!value) return false;
1981
+ return value.split(".").every((identifier) => {
1982
+ if (!/^[0-9A-Za-z-]+$/.test(identifier)) return false;
1983
+ return !rejectNumericLeadingZero || !/^\d+$/.test(identifier) || identifier === "0" || !identifier.startsWith("0");
1984
+ });
1985
+ }
1986
+ function core(v) {
1987
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
1988
+ if (!m) return null;
1989
+ return [
1990
+ Number(m[1]),
1991
+ Number(m[2]),
1992
+ Number(m[3])
1993
+ ];
1994
+ }
1995
+ function isPre(v) {
1996
+ return /^\s*v?\d+\.\d+\.\d+-/.test(v);
1997
+ }
1998
+ /**
1999
+ * `true` when `current` is strictly older than `latest`. Returns `false` on any
2000
+ * unparseable/equal/newer input — the hint is opt-in, so we only ever show it
2001
+ * when we can be confident the daemon is genuinely behind.
2002
+ */
2003
+ function isOutdated(current, latest) {
2004
+ if (!current || !latest) return false;
2005
+ const a = core(current);
2006
+ const b = core(latest);
2007
+ if (!a || !b) return false;
2008
+ for (let i = 0; i < 3; i++) {
2009
+ const ai = a[i];
2010
+ const bi = b[i];
2011
+ if (ai < bi) return true;
2012
+ if (ai > bi) return false;
2013
+ }
2014
+ return isPre(current) && !isPre(latest);
2015
+ }
2016
+ var reserved = new Set(RESERVED_LOOP_TAGS);
2017
+ function normalizeLoopTag(value) {
2018
+ return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
2019
+ }
2020
+ function validateLoopTags(value) {
2021
+ if (value === void 0) return {
2022
+ ok: true,
2023
+ value: []
2024
+ };
2025
+ if (!Array.isArray(value)) return {
2026
+ ok: false,
2027
+ detail: "tags must be an array of strings"
2028
+ };
2029
+ if (value.length > 4) return {
2030
+ ok: false,
2031
+ detail: `tags must contain at most 4 values`
2032
+ };
2033
+ const tags = [];
2034
+ const seen = /* @__PURE__ */ new Set();
2035
+ for (const entry of value) {
2036
+ if (typeof entry !== "string") return {
2037
+ ok: false,
2038
+ detail: "tags must contain only strings"
2039
+ };
2040
+ if (/\p{Cc}|\p{Cf}|\p{Default_Ignorable_Code_Point}/u.test(entry)) return {
2041
+ ok: false,
2042
+ detail: "tags must not contain control or invisible characters"
2043
+ };
2044
+ const tag = normalizeLoopTag(entry);
2045
+ if (!tag) return {
2046
+ ok: false,
2047
+ detail: "tags must not contain empty values"
2048
+ };
2049
+ if ([...tag].length > 64) return {
2050
+ ok: false,
2051
+ detail: `each tag must contain at most 64 characters`
2052
+ };
2053
+ if (reserved.has(tag)) return {
2054
+ ok: false,
2055
+ detail: `reserved tag: ${tag}`
2056
+ };
2057
+ if (seen.has(tag)) return {
2058
+ ok: false,
2059
+ detail: `duplicate tag: ${tag}`
2060
+ };
2061
+ seen.add(tag);
2062
+ tags.push(tag);
2063
+ }
2064
+ return {
2065
+ ok: true,
2066
+ value: [...tags].sort()
2067
+ };
2068
+ }
2069
+ //#endregion
2070
+ //#region node_modules/.nitro/vite/services/ssr/assets/boot-CYRi-wez.js
2071
+ var boot_CYRi_wez_exports = /* @__PURE__ */ __exportAll$1({
12
2072
  _: () => validOptionalPollString,
13
2073
  a: () => POLL_HTTP_BODY_FIELDS,
14
2074
  c: () => MIN_DAEMON_VERSION,
15
- d: () => statusDefinitionsFromLoop,
2075
+ d: () => canonicalLoopSummary,
16
2076
  f: () => validateLoopEdit,
17
2077
  g: () => readJsonBody,
18
2078
  h: () => readByteBody,
19
2079
  i: () => runDiff_exports,
20
2080
  l: () => daemonNeedsUpdate,
21
- m: () => POLL_INFO_TEXT_CAP,
2081
+ m: () => WIRE_TEXT_CAP,
22
2082
  n: () => ensureServer,
23
2083
  o: () => validPollCurrentRun,
24
2084
  p: () => MACHINE_BODY_CAP,
25
2085
  r: () => getArtifactSync,
26
2086
  s: () => validPollWireId,
27
2087
  t: () => boot_exports,
28
- u: () => scheduleFromLoop
2088
+ u: () => canonicalLoopEnvelope
29
2089
  });
30
2090
  /**
31
2091
  * Machine-route ingress: read + parse a JSON request body under a hard size cap.
@@ -116,7 +2176,7 @@ function nowIso() {
116
2176
  /** Strip NUL (U+0000) from a wire string: Postgres text/jsonb columns REJECT the
117
2177
  * NUL byte (SQLite tolerated it), so a daemon-supplied string carrying one would
118
2178
  * throw mid-finalize on the DB write. The single sanitizing primitive behind
119
- * `clipText` and index.ts's `str` - and used directly by `cli.ts`
2179
+ * `clipText` - and used directly by `cli.ts`
120
2180
  * (parseFlags/validateState, the same one-chokepoint discipline). */
121
2181
  function stripNul(s) {
122
2182
  return s.replace(/\u0000/g, "");
@@ -157,13 +2217,8 @@ async function buildDelivery(loop, queuedRun, runToken, roots) {
157
2217
  * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
158
2218
  * them — preserving the server's zero-exec invariant.
159
2219
  *
160
- * Three implementations behind one small interface:
161
- * • LocalBlobStore — durable filesystem default under PIEVO_DATA_DIR/blobs.
162
- * • R2BlobStore — Cloudflare R2 (S3-compatible) when credentials are configured.
163
- * • MemoryBlobStore — explicit ephemeral selection or injected test fake.
164
- *
165
- * The S3 client is dynamic-imported inside R2BlobStore so tests (and any deploy
166
- * without R2 creds) never load the AWS SDK, and it stays out of the client bundle.
2220
+ * `LocalBlobStore` is the durable store under `<dataDir>/blobs`;
2221
+ * `MemoryBlobStore` is the injected test fake.
167
2222
  */
168
2223
  var log$4 = logger.child({ mod: "blobstore" });
169
2224
  var SHA256_HEX = /^[a-f0-9]{64}$/;
@@ -277,132 +2332,15 @@ var LocalBlobStore = class {
277
2332
  await this.syncDir(path.dirname(target));
278
2333
  }
279
2334
  };
280
- var MemoryBlobStore = class {
281
- map = /* @__PURE__ */ new Map();
282
- async has(hash) {
283
- return this.map.has(hash);
284
- }
285
- async put(hash, bytes) {
286
- this.map.set(hash, Buffer.from(bytes));
287
- }
288
- async get(hash) {
289
- return this.map.get(hash) ?? null;
290
- }
291
- async delete(hash) {
292
- this.map.delete(hash);
293
- }
294
- };
295
- function blobKey(hash) {
296
- return `blobs/${hash}`;
297
- }
298
- var R2BlobStore = class {
299
- cfg;
300
- client;
301
- constructor(cfg) {
302
- this.cfg = cfg;
303
- }
304
- async s3() {
305
- if (this.client) return this.client;
306
- const { S3Client } = await import("../_libs/@aws-sdk/client-s3+[...].mjs").then((n) => n.t);
307
- this.client = new S3Client({
308
- region: this.cfg.region,
309
- endpoint: this.cfg.endpoint,
310
- credentials: {
311
- accessKeyId: this.cfg.accessKeyId,
312
- secretAccessKey: this.cfg.secretAccessKey
313
- },
314
- forcePathStyle: true
315
- });
316
- return this.client;
317
- }
318
- async has(hash) {
319
- const { HeadObjectCommand } = await import("../_libs/@aws-sdk/client-s3+[...].mjs").then((n) => n.t);
320
- const client = await this.s3();
321
- try {
322
- await client.send(new HeadObjectCommand({
323
- Bucket: this.cfg.bucket,
324
- Key: blobKey(hash)
325
- }));
326
- return true;
327
- } catch (err) {
328
- if (isNotFound(err)) return false;
329
- throw err;
330
- }
331
- }
332
- async put(hash, bytes) {
333
- const { PutObjectCommand } = await import("../_libs/@aws-sdk/client-s3+[...].mjs").then((n) => n.t);
334
- await (await this.s3()).send(new PutObjectCommand({
335
- Bucket: this.cfg.bucket,
336
- Key: blobKey(hash),
337
- Body: bytes,
338
- ContentLength: bytes.length
339
- }));
340
- }
341
- async get(hash) {
342
- const { GetObjectCommand } = await import("../_libs/@aws-sdk/client-s3+[...].mjs").then((n) => n.t);
343
- const client = await this.s3();
344
- try {
345
- const body = (await client.send(new GetObjectCommand({
346
- Bucket: this.cfg.bucket,
347
- Key: blobKey(hash)
348
- }))).Body;
349
- if (!body?.transformToByteArray) return null;
350
- return Buffer.from(await body.transformToByteArray());
351
- } catch (err) {
352
- if (isNotFound(err)) return null;
353
- throw err;
354
- }
355
- }
356
- async delete(hash) {
357
- const { DeleteObjectCommand } = await import("../_libs/@aws-sdk/client-s3+[...].mjs").then((n) => n.t);
358
- await (await this.s3()).send(new DeleteObjectCommand({
359
- Bucket: this.cfg.bucket,
360
- Key: blobKey(hash)
361
- }));
362
- }
363
- };
364
- function isNotFound(err) {
365
- const e = err;
366
- return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
367
- }
368
- /**
369
- * The configured blob store: durable local filesystem under PIEVO_DATA_DIR by
370
- * default; complete R2 credentials preserve the existing R2 selection behavior,
371
- * while memory requires an explicit selector. Constructed once and shared by every
372
- * gateway so writes, reads, and GC use the same adapter.
373
- */
2335
+ /** The one local blob store shared by sync writes, artifact reads, and GC. */
374
2336
  function createBlobStore() {
375
- const selection = process.env.PIEVO_BLOB_STORE?.trim().toLowerCase();
376
- if (selection && ![
377
- "local",
378
- "r2",
379
- "memory"
380
- ].includes(selection)) throw new Error("PIEVO_BLOB_STORE must be local, r2, or memory");
381
- if (selection === "memory") {
382
- log$4.warn("blob store: EPHEMERAL MEMORY explicitly selected; all artifact bytes are lost on restart");
383
- return new MemoryBlobStore();
384
- }
385
- if (selection === "local") {
386
- const local = new LocalBlobStore();
387
- log$4.info({ root: local.root }, "blob store: local filesystem (explicit)");
388
- return local;
389
- }
390
- const cfg = r2Config();
391
- if (selection === "r2" && !cfg) throw new Error("PIEVO_BLOB_STORE=r2 requires complete PIEVO_R2_* configuration");
392
- if (cfg) {
393
- log$4.info({
394
- bucket: cfg.bucket,
395
- endpoint: cfg.endpoint
396
- }, "blob store: Cloudflare R2");
397
- return new R2BlobStore(cfg);
398
- }
399
2337
  const local = new LocalBlobStore();
400
2338
  log$4.info({ root: local.root }, "blob store: local filesystem");
401
2339
  return local;
402
2340
  }
403
2341
  /**
404
2342
  * Artifact-storage retention + garbage collection — the policy that keeps the
405
- * local/R2 byte store from growing monotonically.
2343
+ * local byte store from growing monotonically.
406
2344
  *
407
2345
  * The artifact pipeline is content-addressed: blob BYTES are keyed by sha256 and
408
2346
  * deduped across every loop/run. When a path is deleted or its content changes,
@@ -433,7 +2371,7 @@ var log$3 = logger.child({ mod: "retention" });
433
2371
  * (most-recent-N). Returns the total number pruned. This is what makes an old
434
2372
  * snapshot's now-unreferenced blobs collectable by gcBlobs().
435
2373
  */
436
- async function pruneSnapshots(keep = snapshotRetention()) {
2374
+ async function pruneSnapshots(keep = 20) {
437
2375
  let pruned = 0;
438
2376
  for (const loopId of await loopIdsWithSnapshots()) pruned += await pruneRunSnapshots(loopId, keep);
439
2377
  return pruned;
@@ -462,7 +2400,7 @@ async function pruneSnapshots(keep = snapshotRetention()) {
462
2400
  * so that file re-uploads its bytes on the next sync. We never leave a
463
2401
  * live blobs row pointing at deleted bytes.
464
2402
  */
465
- async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
2403
+ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
466
2404
  const refs = await liveBlobRefs();
467
2405
  const garbage = (await blobHashesOlderThan(new Date(Date.now() - graceMs).toISOString())).filter((h) => !refs.has(h));
468
2406
  let snapCount = await countRunSnapshots();
@@ -488,15 +2426,9 @@ async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
488
2426
  }
489
2427
  return reclaimed;
490
2428
  }
491
- /**
492
- * One full storage-maintenance pass: prune snapshots, then GC the blobs they
493
- * freed (plus any already-unreferenced). Order matters — pruning first lets the
494
- * same pass reclaim the blobs it just unpinned. Idempotent and safe with no
495
- * garbage (returns zeros).
496
- */
497
- async function maintainStorage(blobStore) {
498
- const snapshotsPruned = await pruneSnapshots();
499
- const blobsReclaimed = await gcBlobs(blobStore);
2429
+ async function maintainStorage(blobStore, options = {}) {
2430
+ const snapshotsPruned = await pruneSnapshots(options.keep);
2431
+ const blobsReclaimed = await gcBlobs(blobStore, options.graceMs);
500
2432
  if (snapshotsPruned || blobsReclaimed) log$3.info({
501
2433
  snapshotsPruned,
502
2434
  blobsReclaimed
@@ -911,6 +2843,15 @@ function statusDefinitionsFromLoop(loop) {
911
2843
  };
912
2844
  }
913
2845
  function canonicalLoopEnvelope(loop) {
2846
+ return {
2847
+ ...canonicalLoopSummary(loop),
2848
+ prompt: loop.prompt,
2849
+ statusDefinitions: statusDefinitionsFromLoop(loop),
2850
+ artifacts: loop.artifacts,
2851
+ enabled: loop.enabled
2852
+ };
2853
+ }
2854
+ function canonicalLoopSummary(loop) {
914
2855
  return {
915
2856
  id: loop.id,
916
2857
  name: loop.name,
@@ -919,11 +2860,7 @@ function canonicalLoopEnvelope(loop) {
919
2860
  workdir: loop.workdir,
920
2861
  agent: loop.agent,
921
2862
  model: loop.model ?? null,
922
- reasoningEffort: loop.reasoningEffort ?? null,
923
- prompt: loop.prompt,
924
- statusDefinitions: statusDefinitionsFromLoop(loop),
925
- artifacts: loop.artifacts,
926
- enabled: loop.enabled
2863
+ reasoningEffort: loop.reasoningEffort ?? null
927
2864
  };
928
2865
  }
929
2866
  function validateLoopCreate(value) {
@@ -1140,7 +3077,7 @@ var configuredRunTimeoutMs = Number(process.env.PIEVO_RUN_TIMEOUT_MS || 20 * 6e4
1140
3077
  var RUN_TIMEOUT_MS = Number.isFinite(configuredRunTimeoutMs) && configuredRunTimeoutMs > 0 ? configuredRunTimeoutMs : 20 * 6e4;
1141
3078
  /** The ONLY keys an owner `editLoop` patch may touch. A key outside this set is
1142
3079
  * rejected (400) rather than silently ignored, so a `--json` typo fails loudly
1143
- * and identity/ownership columns (id/userId/machineId/timestamps) can never be
3080
+ * and identity columns (id/machineId/timestamps) can never be
1144
3081
  * patched over the device-token edit surface. Exported for `cli.ts`
1145
3082
  * (the `new`/`edit` verb help lists these keys). */
1146
3083
  var EDITABLE_LOOP_FIELDS = LOOP_EDIT_FIELDS;
@@ -1438,7 +3375,7 @@ var MachineGateway = class {
1438
3375
  * grace window + final re-check protect freshly-written/referenced blobs) and
1439
3376
  * idempotent with no garbage. Best-effort — never throws into the caller.
1440
3377
  */
1441
- async maintainStorage() {
3378
+ async maintainStorage(options) {
1442
3379
  if (this.maintenanceRunning) {
1443
3380
  log$2.info("storage maintenance already in progress — skipping this tick");
1444
3381
  return {
@@ -1448,7 +3385,7 @@ var MachineGateway = class {
1448
3385
  }
1449
3386
  this.maintenanceRunning = true;
1450
3387
  try {
1451
- return await maintainStorage(this.blobStore);
3388
+ return await maintainStorage(this.blobStore, options);
1452
3389
  } catch (err) {
1453
3390
  log$2.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
1454
3391
  return {
@@ -1465,14 +3402,12 @@ var MachineGateway = class {
1465
3402
  const { machineId } = auth;
1466
3403
  let machine = auth.machine;
1467
3404
  if (!machine) {
1468
- const binding = await readConnectKeyBinding(deviceToken);
1469
- if (!binding) return {
3405
+ if (!await connectKeyValid(deviceToken)) return {
1470
3406
  status: 401,
1471
3407
  body: { error: "unknown device token — connect this machine first" }
1472
3408
  };
1473
3409
  machine = await createMachine({
1474
3410
  id: machineId,
1475
- userId: binding.userId,
1476
3411
  name: info?.host || `machine-${machineId.slice(2, 8)}`,
1477
3412
  tokenHash: sha256(deviceToken),
1478
3413
  token: deviceToken,
@@ -1620,7 +3555,7 @@ var MachineGateway = class {
1620
3555
  status: 200,
1621
3556
  body: {
1622
3557
  registered: false,
1623
- claimValid: await readConnectKeyBinding(deviceToken) !== void 0,
3558
+ claimValid: await connectKeyValid(deviceToken),
1624
3559
  online: false,
1625
3560
  name: null,
1626
3561
  lastSeen: null,
@@ -1650,7 +3585,6 @@ var MachineGateway = class {
1650
3585
  const auth = await authenticateDeviceToken(deviceToken);
1651
3586
  if (!auth.ok) return auth.response;
1652
3587
  const { machineId } = auth;
1653
- const machine = auth.machine;
1654
3588
  {
1655
3589
  if (body === null || typeof body !== "object" || Array.isArray(body)) return {
1656
3590
  status: 400,
@@ -1693,7 +3627,6 @@ var MachineGateway = class {
1693
3627
  }
1694
3628
  };
1695
3629
  const loop = await createLoop({
1696
- userId: machine.userId,
1697
3630
  machineId,
1698
3631
  ...row
1699
3632
  });
@@ -1916,6 +3849,51 @@ var MachineGateway = class {
1916
3849
  log$2.info({ runId: lease.runId }, "report: ignored (run was canceled)");
1917
3850
  return response;
1918
3851
  }
3852
+ async reportRaceResponse(runToken, lease, body, payloadDigest) {
3853
+ const raced = await committedReportEvidence(body.reportId, payloadDigest, lease.runId);
3854
+ if (raced.response) return raced.response;
3855
+ if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
3856
+ }
3857
+ async resolveLostTerminalRace(runToken, lease, body, payloadDigest, retryReconcile, logMessage) {
3858
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
3859
+ if (raced) return raced;
3860
+ const fresh = await getRun(lease.runId);
3861
+ if (fresh?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
3862
+ if (fresh?.phase === "done") return {
3863
+ status: 409,
3864
+ body: {
3865
+ error: "run already finalized",
3866
+ code: "REPORT_NOT_FINALIZED",
3867
+ reportId: body.reportId
3868
+ }
3869
+ };
3870
+ const refreshedLease = await resolveLease(runToken);
3871
+ if (refreshedLease?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
3872
+ if (retryReconcile && fresh?.phase === "error" && refreshedLease?.state === "terminal-grace") return this.reconcileReclaimedReport(runToken, refreshedLease, fresh, body);
3873
+ log$2.info({
3874
+ runId: lease.runId,
3875
+ phase: fresh?.phase
3876
+ }, logMessage);
3877
+ return {
3878
+ status: 409,
3879
+ body: {
3880
+ error: "terminal report was not finalized",
3881
+ code: "REPORT_NOT_FINALIZED",
3882
+ reportId: body.reportId
3883
+ }
3884
+ };
3885
+ }
3886
+ async captureRunSnapshot(runId, loopId) {
3887
+ try {
3888
+ await putRunSnapshot(runId, loopId, await buildLoopManifest(loopId));
3889
+ await pruneRunSnapshots(loopId, 20);
3890
+ } catch (err) {
3891
+ log$2.warn({
3892
+ runId,
3893
+ err: err instanceof Error ? err.message : String(err)
3894
+ }, "snapshot capture failed");
3895
+ }
3896
+ }
1919
3897
  /** Reconcile one swept run. The store consumes the terminal-grace lease in the
1920
3898
  * same loop-lock transaction as the error→done/error patch and loop state, so
1921
3899
  * concurrent late reports cannot both win (including error→error). */
@@ -1938,51 +3916,14 @@ var MachineGateway = class {
1938
3916
  ts: nowIso()
1939
3917
  }, loopPatch, AUTOPAUSE_STREAK, receipt);
1940
3918
  } catch (error) {
1941
- const raced = await committedReportEvidence(receipt.reportId, payloadDigest, lease.runId);
1942
- if (raced.response) return raced.response;
1943
- if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
3919
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
3920
+ if (raced) return raced;
1944
3921
  throw error;
1945
3922
  }
1946
- if (!reconciled) {
1947
- const raced = await committedReportEvidence(receipt.reportId, payloadDigest, lease.runId);
1948
- if (raced.response) return raced.response;
1949
- if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
1950
- const fresh = await getRun(lease.runId);
1951
- if (fresh?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
1952
- if (fresh?.phase === "done") return {
1953
- status: 409,
1954
- body: {
1955
- error: "run already finalized",
1956
- code: "REPORT_NOT_FINALIZED",
1957
- reportId: body.reportId
1958
- }
1959
- };
1960
- if ((await resolveLease(runToken))?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
1961
- log$2.info({
1962
- runId: lease.runId,
1963
- phase: fresh?.phase
1964
- }, "report: late reconcile lost terminal race");
1965
- return {
1966
- status: 409,
1967
- body: {
1968
- error: "terminal report was not finalized",
1969
- code: "REPORT_NOT_FINALIZED",
1970
- reportId: body.reportId
1971
- }
1972
- };
1973
- }
3923
+ if (!reconciled) return this.resolveLostTerminalRace(runToken, lease, body, payloadDigest, false, "report: late reconcile lost terminal race");
1974
3924
  const deleting = reconciled.loop.deleteRequestedAt != null;
1975
3925
  const reportOnly = reconciled.reportOnly === true;
1976
- if (!deleting && !reportOnly) try {
1977
- await putRunSnapshot(lease.runId, lease.loopId, await buildLoopManifest(lease.loopId));
1978
- await pruneRunSnapshots(lease.loopId, snapshotRetention());
1979
- } catch (err) {
1980
- log$2.warn({
1981
- runId: lease.runId,
1982
- err: err instanceof Error ? err.message : String(err)
1983
- }, "snapshot capture failed");
1984
- }
1985
- reconciled.run;
3926
+ if (!deleting && !reportOnly) await this.captureRunSnapshot(lease.runId, lease.loopId);
1986
3927
  if (!reportOnly) this.scheduler.addLoop(reconciled.loop);
1987
3928
  if (!reportOnly) this.applyAutopauseTimer(lease.loopId, reconciled);
1988
3929
  if (deleting) await tryDeleteLoop(lease.loopId);
@@ -2118,52 +4059,13 @@ var MachineGateway = class {
2118
4059
  ts: nowIso()
2119
4060
  }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected);
2120
4061
  } catch (error) {
2121
- const raced = await committedReportEvidence(reportId, payloadDigest, lease.runId);
2122
- if (raced.response) return raced.response;
2123
- if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
4062
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
4063
+ if (raced) return raced;
2124
4064
  throw error;
2125
4065
  }
2126
- if (!terminal) {
2127
- const raced = await committedReportEvidence(reportId, payloadDigest, lease.runId);
2128
- if (raced.response) return raced.response;
2129
- if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
2130
- const fresh = await getRun(lease.runId);
2131
- if (fresh?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
2132
- if (fresh?.phase === "done") return {
2133
- status: 409,
2134
- body: {
2135
- error: "run already finalized",
2136
- code: "REPORT_NOT_FINALIZED",
2137
- reportId
2138
- }
2139
- };
2140
- const refreshedLease = await resolveLease(runToken);
2141
- if (refreshedLease?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
2142
- if (fresh?.phase === "error" && refreshedLease?.state === "terminal-grace") return this.reconcileReclaimedReport(runToken, refreshedLease, fresh, body);
2143
- log$2.info({
2144
- runId: lease.runId,
2145
- phase: fresh?.phase
2146
- }, "report: lost terminal race");
2147
- return {
2148
- status: 409,
2149
- body: {
2150
- error: "terminal report was not finalized",
2151
- code: "REPORT_NOT_FINALIZED",
2152
- reportId
2153
- }
2154
- };
2155
- }
2156
- terminal.run;
4066
+ if (!terminal) return this.resolveLostTerminalRace(runToken, lease, body, payloadDigest, true, "report: lost terminal race");
2157
4067
  const deleting = terminal.loop.deleteRequestedAt != null;
2158
- if (!deleting) try {
2159
- await putRunSnapshot(lease.runId, lease.loopId, await buildLoopManifest(lease.loopId));
2160
- await pruneRunSnapshots(lease.loopId, snapshotRetention());
2161
- } catch (err) {
2162
- log$2.warn({
2163
- runId: lease.runId,
2164
- err: err instanceof Error ? err.message : String(err)
2165
- }, "snapshot capture failed");
2166
- }
4068
+ if (!deleting) await this.captureRunSnapshot(lease.runId, lease.loopId);
2167
4069
  this.scheduler.addLoop(terminal.loop);
2168
4070
  this.applyAutopauseTimer(lease.loopId, terminal);
2169
4071
  log$2.info({
@@ -2435,7 +4337,7 @@ var ArtifactSync = class {
2435
4337
  * process execution, so the server's zero-exec invariant holds. Binary/oversize
2436
4338
  * files emit a size-delta marker only (no inline diff).
2437
4339
  */
2438
- var runDiff_exports = /* @__PURE__ */ __exportAll$1({ computeRunDiff: () => computeRunDiff });
4340
+ var runDiff_exports = /* @__PURE__ */ __exportAll({ computeRunDiff: () => computeRunDiff });
2439
4341
  /**
2440
4342
  * Upper bound for computing an inline unified diff. jsdiff is synchronous and
2441
4343
  * roughly O(N*D); on a multi-MB or minified text file it would block the
@@ -3152,7 +5054,6 @@ var CliGateway = class {
3152
5054
  action: "force-delete",
3153
5055
  loopId: loop.id,
3154
5056
  machineId,
3155
- actorUserId: loop.userId,
3156
5057
  machineReachability: reachability
3157
5058
  });
3158
5059
  return {
@@ -3777,87 +5678,6 @@ function msg(err) {
3777
5678
  return err instanceof Error ? err.message : String(err);
3778
5679
  }
3779
5680
  /**
3780
- * DB watchdog — the real auto-recovery backstop for a wedged postgres-js pool.
3781
- *
3782
- * BACKGROUND (2026-07-12 outage, twice in one day): the runtime pool against the
3783
- * Supabase transaction pooler wedged — every connection stuck on a query that
3784
- * never returned, so `/api/health/db`'s `select 1` queued behind them forever and
3785
- * the server stopped sending response headers. Fly's health check went `critical`,
3786
- * which only PULLS the machine from load balancing (a well-known Fly footgun:
3787
- * failing `[http_service.checks]` do NOT restart the VM — only a process EXIT
3788
- * triggers `restart.policy = "on-failure"`). Result: ~9h fully down with no
3789
- * auto-recovery until a manual `fly machine restart`. The pool's own self-healing
3790
- * (`max_lifetime`/`statement_timeout`, `db/index.ts`) can't cover a busy-wedged
3791
- * connection or a dead socket that never errors.
3792
- *
3793
- * This watchdog closes that gap: it pings `select 1` on a fixed cadence under a
3794
- * HARD client-side deadline (a hung ping counts as a failure, since the underlying
3795
- * query can't be canceled). After N consecutive failures it exits the process, so
3796
- * Fly restarts the machine with a fresh pool — turning a multi-hour hang into a
3797
- * ~1-2min self-heal. A single healthy ping resets the streak, so a transient blip
3798
- * never restarts the box.
3799
- *
3800
- * Pure + injectable: `makeDbWatchdog` takes the probe / exit / clock as deps so the
3801
- * decision logic is unit-tested with no real DB, timers, or `process.exit`.
3802
- */
3803
- /** Reject if `p` hasn't settled within `ms`. The underlying promise keeps running
3804
- * (we can't cancel a wedged query) — bounded by the exit after `failureThreshold`. */
3805
- function withDeadline(p, ms, setTimer, clearTimer) {
3806
- return new Promise((resolve, reject) => {
3807
- const h = setTimer(() => reject(/* @__PURE__ */ new Error(`db ping timed out after ${ms}ms`)), ms);
3808
- p.then((v) => {
3809
- clearTimer(h);
3810
- resolve(v);
3811
- }, (e) => {
3812
- clearTimer(h);
3813
- reject(e instanceof Error ? e : new Error(String(e)));
3814
- });
3815
- });
3816
- }
3817
- function makeDbWatchdog(deps) {
3818
- const { probe, exit, timeoutMs, failureThreshold, setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (h) => clearTimeout(h), log = logger } = deps;
3819
- let consecutive = 0;
3820
- let running = false;
3821
- let done = false;
3822
- async function tick() {
3823
- if (running || done) return;
3824
- running = true;
3825
- try {
3826
- await withDeadline(Promise.resolve().then(probe), timeoutMs, setTimer, clearTimer);
3827
- consecutive = 0;
3828
- } catch (err) {
3829
- consecutive += 1;
3830
- log.warn({
3831
- consecutive,
3832
- failureThreshold,
3833
- err: String(err)
3834
- }, "db watchdog: liveness ping failed");
3835
- if (consecutive >= failureThreshold) {
3836
- done = true;
3837
- log.error({ consecutive }, "db watchdog: database unreachable for too long — exiting so the platform restarts with a fresh pool");
3838
- exit(1);
3839
- }
3840
- } finally {
3841
- running = false;
3842
- }
3843
- }
3844
- return {
3845
- tick,
3846
- failures: () => consecutive
3847
- };
3848
- }
3849
- /**
3850
- * Start the watchdog on a repeating interval. Returns a stop function (cleared on
3851
- * the boot AbortController). The interval is `unref`'d so it never keeps the
3852
- * process alive on its own.
3853
- */
3854
- function startDbWatchdog(deps) {
3855
- const wd = makeDbWatchdog(deps);
3856
- const handle = setInterval(() => void wd.tick(), deps.intervalMs);
3857
- handle.unref?.();
3858
- return () => clearInterval(handle);
3859
- }
3860
- /**
3861
5681
  * Server boot — idempotent one-time init of the in-process backend: apply
3862
5682
  * migrations, wire the machine gateway's dispatcher into the Scheduler, start
3863
5683
  * the Scheduler + an offline-sweep interval. Guarded on `globalThis` so it
@@ -3865,7 +5685,7 @@ function startDbWatchdog(deps) {
3865
5685
  * from any server-side entry (the standalone machine server / TanStack server
3866
5686
  * fns); the first call boots, the rest share the same instance.
3867
5687
  */
3868
- var boot_exports = /* @__PURE__ */ __exportAll$1({
5688
+ var boot_exports = /* @__PURE__ */ __exportAll({
3869
5689
  ensureServer: () => ensureServer,
3870
5690
  getArtifactSync: () => getArtifactSync,
3871
5691
  getCliGateway: () => getCliGateway,
@@ -3882,7 +5702,6 @@ function ensureServer() {
3882
5702
  return p;
3883
5703
  }
3884
5704
  async function boot() {
3885
- loginGateEnabled();
3886
5705
  await runMigrations();
3887
5706
  await repairTerminalRunLeases();
3888
5707
  const abort = new AbortController();
@@ -3903,27 +5722,9 @@ async function boot() {
3903
5722
  const sweep = setInterval(() => void gateway.sweep().catch((err) => logger.error({ err: String(err) }, "sweep tick failed")), ONLINE_TTL_MS);
3904
5723
  sweep.unref?.();
3905
5724
  abort.signal.addEventListener("abort", () => clearInterval(sweep), { once: true });
3906
- const gc = setInterval(() => void gateway.maintainStorage().catch((err) => logger.error({ err: String(err) }, "gc tick failed")), gcIntervalMs());
5725
+ const gc = setInterval(() => void gateway.maintainStorage().catch((err) => logger.error({ err: String(err) }, "gc tick failed")), GC_INTERVAL_MS);
3907
5726
  gc.unref?.();
3908
5727
  abort.signal.addEventListener("abort", () => clearInterval(gc), { once: true });
3909
- if (dbWatchdogEnabled()) {
3910
- const intervalMs = dbWatchdogIntervalMs();
3911
- const timeoutMs = dbWatchdogTimeoutMs();
3912
- const failureThreshold = dbWatchdogFailureThreshold();
3913
- logger.info({
3914
- intervalMs,
3915
- timeoutMs,
3916
- failureThreshold
3917
- }, "db watchdog: armed");
3918
- const stopWatchdog = startDbWatchdog({
3919
- probe: () => db.execute(sql`select 1`),
3920
- exit: (code) => process.exit(code),
3921
- intervalMs,
3922
- timeoutMs,
3923
- failureThreshold
3924
- });
3925
- abort.signal.addEventListener("abort", () => stopWatchdog(), { once: true });
3926
- }
3927
5728
  logger.info("pievo server booted");
3928
5729
  return {
3929
5730
  scheduler,
@@ -3944,4 +5745,4 @@ async function getCliGateway() {
3944
5745
  return (await ensureServer()).cliGateway;
3945
5746
  }
3946
5747
  //#endregion
3947
- export { daemonNeedsUpdate as a, readByteBody as c, statusDefinitionsFromLoop as d, validOptionalPollString as f, validateLoopEdit as h, boot_C472kLwY_exports as i, readJsonBody as l, validPollWireId as m, MIN_DAEMON_VERSION as n, ensureServer as o, validPollCurrentRun as p, POLL_HTTP_BODY_FIELDS as r, getArtifactSync as s, MACHINE_BODY_CAP as t, scheduleFromLoop as u };
5748
+ export { listMachines as A, reconciliationStatesForRuns as B, getMachine as C, isOutdated as D, hasRunningRun as E, machineIdFromToken as F, startLoop as G, requestRunCancel as H, machinePresence as I, updateLoop as J, stopLoop as K, mintDeviceToken as L, listRunsBefore as M, loopTags_C6vATFCp_exports as N, listArtifacts as O, loopsForMachine as P, pauseLoop as R, getLoop as S, hasPendingRun as T, safeRelPath as U, requestDeleteLoop as V, sha256 as W, validateLoopTags as X, updateMachine as Y, countRuns as _, canonicalLoopEnvelope as a, forceDeleteMachine as b, ensureServer as c, readJsonBody as d, validOptionalPollString as f, CODING_AGENTS as g, validateLoopEdit as h, boot_CYRi_wez_exports as i, listRuns as j, listLoops as k, getArtifactSync as l, validPollWireId as m, MIN_DAEMON_VERSION as n, canonicalLoopSummary as o, validPollCurrentRun as p, tryDeleteLoop as q, POLL_HTTP_BODY_FIELDS as r, daemonNeedsUpdate as s, MACHINE_BODY_CAP as t, readByteBody as u, createMachine as v, getRun as w, getArtifactFile as x, forceDeleteLoop as y, recentLoopUsage as z };