@kky42/pievo-server 2.4.14 → 4.1.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 (95) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/MarkdownView-BuFKr9Jg.js +66 -0
  3. package/.output/public/assets/{actionUi-5PtES4q0.js → actionUi-BZrLpgHT.js} +1 -1
  4. package/.output/public/assets/app-x1CeeY7N.css +2 -0
  5. package/.output/public/assets/index-D0FIKiiW.js +10 -0
  6. package/.output/public/assets/loops._loopId-DTkIz4C0.js +2 -0
  7. package/.output/public/assets/loops._loopId_.runs._runId-CsV2Ih4Z.js +3 -0
  8. package/.output/public/assets/preload-helper-BSxGu4d0.js +3 -0
  9. package/.output/public/assets/{routes-Dm_3J2aV.js → routes-BIOJZsig.js} +1 -1
  10. package/.output/public/assets/routes-m4Z90DTR.js +1 -0
  11. package/.output/server/_chunks/ssr-renderer.mjs +1 -1
  12. package/.output/server/_libs/{drizzle-orm+postgres.mjs → drizzle-orm.mjs} +93 -2218
  13. package/.output/server/_ssr/{MachinesModal-BaEtzycr.mjs → MachinesModal-CLBZxZkq.mjs} +47 -47
  14. package/.output/server/_ssr/MarkdownView-D1J7ImYF.mjs +70 -0
  15. package/.output/server/_ssr/{actionUi-5HOCZnom.mjs → actionUi-DE-Pb2tv.mjs} +1 -2
  16. package/.output/server/_ssr/artifactFiles-B0NPRDOR.mjs +52 -0
  17. package/.output/server/_ssr/{artifactKind-DwDr8IYc.mjs → artifactKind-a2oxT2wQ.mjs} +4 -10
  18. package/.output/server/_ssr/{boot-C472kLwY.mjs → boot-DmpKtb0A.mjs} +2659 -890
  19. package/.output/server/_ssr/lifecycleUi-DEjM8Etm.mjs +106 -0
  20. package/.output/server/_ssr/{loopApi-DT751TJA.mjs → loopApi-DK1lQPrx.mjs} +53 -69
  21. package/.output/server/_ssr/{loopProjection-qaD2I48a.mjs → loopProjection-DdFivf7-.mjs} +6 -24
  22. package/.output/server/_ssr/{loops._loopId-CXgw2Rz1.mjs → loops._loopId-DVCvGSEc.mjs} +36 -116
  23. package/.output/server/_ssr/{loops._loopId_.runs._runId-SASSqOfy.mjs → loops._loopId_.runs._runId-BXbTfLlC.mjs} +35 -39
  24. package/.output/server/_ssr/{loops._loopId_.runs._runId-Dk94Z0Jq.mjs → loops._loopId_.runs._runId-Dr6E-FA5.mjs} +3 -8
  25. package/.output/server/_ssr/{machineFns-BSrIgn3f.mjs → machineFns-DLRHgDdS.mjs} +13 -50
  26. package/.output/server/_ssr/networkAccess-CIlK3ION.mjs +30 -0
  27. package/.output/server/_ssr/router-BhHsTGqu.mjs +355 -0
  28. package/.output/server/_ssr/routes-CCallyCT.mjs +12 -0
  29. package/.output/server/_ssr/{routes-Cr-9yDEH.mjs → routes-CL2Kef-Q.mjs} +91 -50
  30. package/.output/server/_ssr/{routes-DRBS4Zi4.mjs → routes-ngpX7h8D.mjs} +2 -4
  31. package/.output/server/_ssr/ssr.mjs +25 -34
  32. package/.output/server/_ssr/start-B5_9Z5W-.mjs +35 -0
  33. package/.output/server/{_tanstack-start-manifest_v-DxLIQ0cc.mjs → _tanstack-start-manifest_v-BPiJ4yWT.mjs} +9 -15
  34. package/.output/server/drizzle/0000_baseline.sql +6 -97
  35. package/.output/server/drizzle/0001_loop_files.sql +9 -0
  36. package/.output/server/drizzle/meta/_journal.json +3 -17
  37. package/.output/server/index.mjs +50 -50
  38. package/drizzle/0000_baseline.sql +6 -97
  39. package/drizzle/0001_loop_files.sql +9 -0
  40. package/drizzle/meta/_journal.json +3 -17
  41. package/package.json +4 -8
  42. package/scripts/pievo-server.mjs +49 -47
  43. package/scripts/server-cli-lib.mjs +21 -12
  44. package/scripts/server-network.mjs +139 -0
  45. package/.output/public/assets/SignIn-CIFwnZtF.js +0 -1
  46. package/.output/public/assets/app-DvHMhs7S.css +0 -2
  47. package/.output/public/assets/index-CRX2pu0W.js +0 -10
  48. package/.output/public/assets/loops._loopId-BsuV-Rmf.js +0 -67
  49. package/.output/public/assets/loops._loopId_.runs._runId-DpK_fzmZ.js +0 -3
  50. package/.output/public/assets/preload-helper-sAZOZb3G.js +0 -5
  51. package/.output/public/assets/routes-DxF_s3cD.js +0 -1
  52. package/.output/server/_libs/@aws-crypto/crc32+[...].mjs +0 -419
  53. package/.output/server/_libs/@aws-sdk/checksums+[...].mjs +0 -10732
  54. package/.output/server/_libs/@aws-sdk/client-s3+[...].mjs +0 -9475
  55. package/.output/server/_libs/@aws-sdk/credential-provider-http+[...].mjs +0 -137
  56. package/.output/server/_libs/@aws-sdk/credential-provider-ini+[...].mjs +0 -440
  57. package/.output/server/_libs/@aws-sdk/credential-provider-process+[...].mjs +0 -62
  58. package/.output/server/_libs/@aws-sdk/credential-provider-sso+[...].mjs +0 -818
  59. package/.output/server/_libs/@aws-sdk/credential-provider-web-identity+[...].mjs +0 -59
  60. package/.output/server/_libs/@smithy/credential-provider-imds+[...].mjs +0 -342
  61. package/.output/server/_libs/aws-crypto__crc32c.mjs +0 -314
  62. package/.output/server/_libs/aws-sdk__nested-clients.mjs +0 -2671
  63. package/.output/server/_ssr/SignIn-sHQaBQJC.mjs +0 -53
  64. package/.output/server/_ssr/artifactFiles-DO43wwXA.mjs +0 -60
  65. package/.output/server/_ssr/artifacts-Cd7Qx3Qp.mjs +0 -2
  66. package/.output/server/_ssr/auth-0AamrwHo.mjs +0 -38355
  67. package/.output/server/_ssr/auth-client-lSMb_Idt.mjs +0 -1070
  68. package/.output/server/_ssr/bun-sqlite-dialect-BW9W1_Ps-DLIXocA0.mjs +0 -162
  69. package/.output/server/_ssr/core-RrmWoKne.mjs +0 -5
  70. package/.output/server/_ssr/d1-sqlite-dialect-BLC8LXE6-BoFMIPr_.mjs +0 -112
  71. package/.output/server/_ssr/db-BzvMQ2kx.mjs +0 -2
  72. package/.output/server/_ssr/dist-ClzqkwxD.mjs +0 -342
  73. package/.output/server/_ssr/factory-ixUjZqJn.mjs +0 -6903
  74. package/.output/server/_ssr/kysely-adapter-DkZbmKgV.mjs +0 -2
  75. package/.output/server/_ssr/kysely-migration-tables-JkVUjPF_-bPAX9LR_.mjs +0 -19
  76. package/.output/server/_ssr/logger-DDjsYB2X.mjs +0 -2
  77. package/.output/server/_ssr/loopTags-CXx5UdGf.mjs +0 -3356
  78. package/.output/server/_ssr/node-sqlite-dialect-BbUqnnYv.mjs +0 -162
  79. package/.output/server/_ssr/router-BlLlhfJe.mjs +0 -543
  80. package/.output/server/_ssr/routes-DTBltabU.mjs +0 -27
  81. package/.output/server/_ssr/start-4ifArtKx.mjs +0 -4
  82. package/.output/server/_ssr/store-vDZ9INSa.mjs +0 -2
  83. package/.output/server/_ssr/tokens-BHiCElYD.mjs +0 -2
  84. package/.output/server/drizzle/0001_add_pi_agent.sql +0 -9
  85. package/.output/server/drizzle/0002_remove_teams.sql +0 -15
  86. package/.output/server/drizzle/0003_add_loop_tags.sql +0 -5
  87. package/.output/server/node_modules/tslib/modules/index.js +0 -70
  88. package/.output/server/node_modules/tslib/modules/package.json +0 -3
  89. package/.output/server/node_modules/tslib/package.json +0 -47
  90. package/.output/server/node_modules/tslib/tslib.js +0 -484
  91. package/.output/server/package.json +0 -9
  92. package/drizzle/0001_add_pi_agent.sql +0 -9
  93. package/drizzle/0002_remove_teams.sql +0 -15
  94. package/drizzle/0003_add_loop_tags.sql +0 -5
  95. package/scripts/prestart.mjs +0 -66
@@ -1,45 +1,2228 @@
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-CnVLi70h.js
15
+ var loopTags_CnVLi70h_exports = /* @__PURE__ */ __exportAll$1({
16
+ $: () => loopIdsWithSnapshots,
17
+ $t: () => BLOB_GC_GRACE_MS,
18
+ A: () => enqueueRun,
19
+ At: () => updateMachine,
20
+ B: () => getRun,
21
+ Bt: () => loopFileNames,
22
+ C: () => buildLoopManifest,
23
+ Ct: () => setMachineOnline,
24
+ D: () => createLoop,
25
+ Dt: () => store_exports,
26
+ E: () => countRuns,
27
+ Et: () => stopLoop,
28
+ F: () => getArtifactFile,
29
+ Ft: () => MEMORY_CHAR_CAP,
30
+ G: () => initializeCronCadence,
31
+ Gt: () => closeClient,
32
+ H: () => getTerminalReportIncidents,
33
+ Ht: () => sha256Buf,
34
+ I: () => getExactTerminalReportIncident,
35
+ It: () => MEMORY_FILE,
36
+ J: () => listLoops,
37
+ Jt: () => runs,
38
+ K: () => lastRun,
39
+ Kt: () => db,
40
+ L: () => getLoop,
41
+ Lt: () => fileLimitLabel,
42
+ M: () => finalizeRunningRun,
43
+ Mt: () => EMPTY_SHA256,
44
+ N: () => forceDeleteLoop,
45
+ Nt: () => LOOP_FILE_BYTE_CAP,
46
+ O: () => createMachine,
47
+ Ot: () => tryDeleteLoop,
48
+ P: () => forceDeleteMachine,
49
+ Pt: () => LoopFiles,
50
+ Q: () => liveBlobRefs,
51
+ Qt: () => logger_exports,
52
+ R: () => getMachine,
53
+ Rt: () => isValidHash,
54
+ S: () => blobSizes,
55
+ St: () => runningRunsForMachine,
56
+ T: () => countRunSnapshots,
57
+ Tt: () => startLoop,
58
+ U: () => hasPendingRun,
59
+ Ut: () => validArtifactName,
60
+ V: () => getRunSnapshot,
61
+ Vt: () => loopFiles_exports,
62
+ W: () => hasRunningRun,
63
+ Wt: () => createBlobStore,
64
+ X: () => listRuns,
65
+ Xt: () => coerceCodingAgent,
66
+ Y: () => listMachines,
67
+ Yt: () => CODING_AGENTS,
68
+ Z: () => listRunsBefore,
69
+ Zt: () => logger,
70
+ _: () => acknowledgeRetiredReport,
71
+ _t: () => releaseAbsentReconciliations,
72
+ a: () => TERMINAL_GRACE_MS,
73
+ at: () => previousRunFileNotices,
74
+ b: () => artifactFileReferencesHash,
75
+ bt: () => requestRunCancel,
76
+ c: () => machineIdFromToken,
77
+ ct: () => putRunSnapshot,
78
+ d: () => readNewIdempotency,
79
+ dt: () => reclaimRun,
80
+ en: () => GC_INTERVAL_MS,
81
+ et: () => loopsForMachine,
82
+ f: () => recordNewIdempotency,
83
+ ft: () => reconcileReclaimedRun,
84
+ g: () => tokens_exports,
85
+ gt: () => rejectTerminalReport,
86
+ h: () => sha256,
87
+ ht: () => refreshRunHeartbeats,
88
+ i: () => machinePresence,
89
+ it: () => prevRunSnapshot,
90
+ j: () => expirePendingRun,
91
+ jt: () => writeLoopFile,
92
+ k: () => deleteBlob,
93
+ kt: () => updateLoop,
94
+ l: () => mintDeviceToken,
95
+ lt: () => putTerminalReportIncidentIfAbsent,
96
+ m: () => retireLease,
97
+ mt: () => recordBlob,
98
+ n: () => isOutdated,
99
+ nt: () => pauseLoop,
100
+ o: () => connectKeyValid,
101
+ ot: () => pruneRunSnapshots,
102
+ p: () => resolveLease,
103
+ pt: () => reconciliationStatesForRuns,
104
+ q: () => listArtifacts,
105
+ qt: () => runMigrations,
106
+ r: () => isValidSemver,
107
+ rt: () => pauseLoopState,
108
+ s: () => isDeviceTokenShape,
109
+ st: () => putReportReceiptIfAbsent,
110
+ t: () => validateLoopTags,
111
+ tn: () => __exportAll,
112
+ tt: () => openRuns,
113
+ u: () => pruneExpiredLeases,
114
+ ut: () => recentLoopUsage,
115
+ v: () => acknowledgeRetiredTerminalIncident,
116
+ vt: () => repairTerminalRunLeases,
117
+ w: () => claimReadyRunForMachine,
118
+ wt: () => snapshotBlobRefs,
119
+ x: () => blobHashesOlderThan,
120
+ xt: () => runningRunForLoop,
121
+ y: () => advanceDueSchedules,
122
+ yt: () => requestDeleteLoop,
123
+ z: () => getReportReceipt,
124
+ zt: () => loopFileIssue
125
+ });
126
+ var import_pino = /* @__PURE__ */ __toESM(require_pino());
127
+ var import_pino_pretty = /* @__PURE__ */ __toESM(require_pino_pretty());
128
+ var __defProp = Object.defineProperty;
129
+ var __exportAll = (all, no_symbols) => {
130
+ let target = {};
131
+ for (var name in all) __defProp(target, name, {
132
+ get: all[name],
133
+ enumerable: true
134
+ });
135
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
136
+ return target;
137
+ };
138
+ /**
139
+ * Server data directory: the embedded database (`pgdata/`), loop-file bytes
140
+ * (`blobs/`), and the launcher's pid/lock/log files. Kept apart from the
141
+ * daemon's `~/.pievo` so a device running both never mixes their state.
142
+ * Override with `PIEVO_DATA_DIR` (absolute; `~` is not expanded).
143
+ */
144
+ function dataDir() {
145
+ return process.env.PIEVO_DATA_DIR?.trim() || path.join(os.homedir(), ".pievo-server");
146
+ }
147
+ /**
148
+ * A blob whose metadata row is younger than this is never collected, so a
149
+ * sync that just wrote a blob can still reference it. A leaked blob is only a
150
+ * storage cost that the next pass reclaims. 1 hour.
151
+ */
152
+ var BLOB_GC_GRACE_MS = 3600 * 1e3;
153
+ /** Storage-maintenance cadence (prune snapshots, then GC blobs). 15 minutes. */
154
+ var GC_INTERVAL_MS = 900 * 1e3;
155
+ /**
156
+ * Uses pino-pretty as a *synchronous* destination stream (not a worker-thread
157
+ * transport) so logs flush before process.exit() in the one-shot CLIs. Writes to
158
+ * stderr (fd 2) to keep stdout clean for QR codes and human CLI output.
159
+ */
160
+ var logger_exports = /* @__PURE__ */ __exportAll({ logger: () => logger });
161
+ var level = (process.env.PIEVO_LOG_LEVEL || "info").toLowerCase();
162
+ var stream = (0, import_pino_pretty.default)({
163
+ destination: 2,
164
+ colorize: true,
165
+ translateTime: "SYS:HH:MM:ss",
166
+ ignore: "pid,hostname"
167
+ });
168
+ var logger = (0, import_pino.default)({ level }, stream);
169
+ /** The coding agent a loop is bound to AND executed with (BYOA on the owner's
170
+ * machine): `claude-code` → Claude Code, `codex` → `codex exec`, `pi` → Pi.
171
+ * Non-Claude agents may still have thinner daemon telemetry until stream
172
+ * integration lands; execution itself is real for every value.
173
+ *
174
+ * Runtime SINGLE SOURCE (anti-drift): every server consumer DERIVES from this
175
+ * array — the `CodingAgent` type here, the `db/schema.ts` `CodingAgent` type AND
176
+ * the `loops.agent` column enum, the edit validator (`coerceCodingAgent`), and the
177
+ * web agent `<select>` (LoopForm). So widening the set is a one-line edit HERE with
178
+ * no other server change (the daemon's own enum in `packages/daemon/src/create.ts`
179
+ * is a separate package, widened alongside). */
180
+ var CODING_AGENTS = [
181
+ "claude-code",
182
+ "codex",
183
+ "pi"
184
+ ];
185
+ function coerceCodingAgent(value) {
186
+ return typeof value === "string" && CODING_AGENTS.includes(value) ? value : null;
187
+ }
188
+ var RESERVED_LOOP_TAGS = [
189
+ "all loops",
190
+ "active",
191
+ "paused",
192
+ "blocked"
193
+ ];
194
+ /**
195
+ * Pievo schema (Drizzle, Postgres `pg-core` dialect, served by embedded PGlite).
196
+ * Pievo is single-user: no row carries an owner. Timestamp values are ISO
197
+ * strings; JSON columns use `jsonb().$type<>()`.
198
+ */
199
+ /** `skipped`: a pending run retired without executing (pause/retention cleanup,
200
+ * queue coalescing, or the offline catch-up window elapsed).
201
+ * Neither success nor failure — excluded from the failure streak (it rides
202
+ * phase `canceled`, and the streak counts only phase `error`). */
203
+ var machines = pgTable("machines", {
204
+ id: text("id").primaryKey(),
205
+ name: text("name").notNull(),
206
+ hostname: text("hostname"),
207
+ platform: text("platform"),
208
+ arch: text("arch"),
209
+ daemonVersion: text("daemon_version"),
210
+ /** Breaking machine protocol last observed on poll. */
211
+ daemonProtocol: integer("daemon_protocol"),
212
+ tokenHash: text("token_hash").notNull(),
213
+ /**
214
+ * Plaintext device token. Stored so the UI can re-show the connect command
215
+ * anytime (MVP convenience — deviates from "store only the hash"; acceptable
216
+ * for a self-hosted control plane where the DB is already the trust root).
217
+ */
218
+ token: text("token"),
219
+ roots: jsonb("roots").$type(),
220
+ lastSeen: text("last_seen"),
221
+ online: boolean("online").notNull().default(false),
222
+ createdAt: text("created_at").notNull()
223
+ });
224
+ var loops = pgTable("loops", {
225
+ id: text("id").primaryKey(),
226
+ /** Execution machine (set at creation; no cross-machine fallback). */
227
+ machineId: text("machine_id").notNull(),
228
+ name: text("name").notNull(),
229
+ tags: text("tags").array().notNull().default(sql`ARRAY[]::text[]`),
230
+ prompt: text("prompt").notNull(),
231
+ statusKeep: text("status_keep").notNull(),
232
+ statusNoChange: text("status_no_change").notNull(),
233
+ statusBlock: text("status_block").notNull(),
234
+ /** Configured artifact file names (`.md`/`.html`), in addition to the
235
+ * always-present MEMORY.md. See `gateway/loopFiles.ts`. */
236
+ artifacts: jsonb("artifacts").$type().notNull().default([]),
237
+ cron: text("cron").notNull(),
238
+ scheduleMode: text("schedule_mode", { enum: ["cron", "continuous"] }).notNull(),
239
+ /** Cron-only overlap behavior. Continuous runs never overlap. */
240
+ cronOverlap: text("cron_overlap", { enum: ["skip", "queue-one"] }).notNull(),
241
+ continuousDelayMinutes: integer("continuous_delay_minutes").notNull(),
242
+ timezone: text("timezone"),
243
+ workdir: text("workdir").notNull(),
244
+ /** Durable Stop-before-delete marker. A deleting loop is never claimable. */
245
+ deleteRequestedAt: timestamp("delete_requested_at", {
246
+ withTimezone: true,
247
+ mode: "string"
248
+ }),
249
+ pauseCause: jsonb("pause_cause").$type(),
250
+ model: text("model"),
251
+ reasoningEffort: text("reasoning_effort"),
252
+ /** Coding agent this loop is BOUND TO and EXECUTED with: the daemon's
253
+ * `buildAgentSpawn` branches on this value (`claude-code` → claude,
254
+ * `codex` → `codex exec`, `pi` → Pi print mode). Measured from the creating CLI's env when
255
+ * detectable, else the declared/selected value. The text column carries
256
+ * both a TypeScript enum and a database CHECK constraint. */
257
+ agent: text("agent", { enum: CODING_AGENTS }).notNull(),
258
+ enabled: boolean("enabled").notNull().default(true),
259
+ nextRunAt: text("next_run_at"),
260
+ /** The next recurring cadence occurrence not yet materialized as a run. Cron
261
+ * advances this fact after each due occurrence; continuous clears it on due
262
+ * and restores it from a successful/error terminal. */
263
+ nextCadenceAt: text("next_cadence_at"),
264
+ lastRunIndex: integer("last_run_index").notNull().default(0),
265
+ createdAt: text("created_at").notNull(),
266
+ updatedAt: text("updated_at").notNull()
267
+ }, (t) => [
268
+ index("loops_machine_idx").on(t.machineId),
269
+ index("delete_requested_loops").on(t.deleteRequestedAt).where(sql`${t.deleteRequestedAt} IS NOT NULL`),
270
+ check("loops_agent_check", sql`${t.agent} IN ('claude-code', 'codex', 'pi')`),
271
+ check("loops_tags_count_check", sql`cardinality(${t.tags}) <= 4`),
272
+ check("loops_tags_reserved_check", sql`NOT (${t.tags} && ARRAY['all loops', 'active', 'paused', 'blocked']::text[])`),
273
+ check("loops_schedule_mode_check", sql`${t.scheduleMode} IN ('cron', 'continuous')`),
274
+ check("loops_cron_overlap_check", sql`${t.cronOverlap} IN ('skip', 'queue-one')`)
275
+ ]);
276
+ var runs = pgTable("runs", {
277
+ id: text("id").primaryKey(),
278
+ loopId: text("loop_id").notNull(),
279
+ machineId: text("machine_id").notNull(),
280
+ agent: text("agent", { enum: CODING_AGENTS }),
281
+ model: text("model"),
282
+ reasoningEffort: text("reasoning_effort"),
283
+ runIndex: integer("run_index"),
284
+ phase: text("phase", { enum: [
285
+ "pending",
286
+ "running",
287
+ "done",
288
+ "error",
289
+ "canceled"
290
+ ] }).notNull(),
291
+ /** Durable queue authority. It only promotes system→owner; diagnostic trigger
292
+ * reasons, if added later, must never drive lifecycle state. */
293
+ requestedBy: text("requested_by", { enum: ["owner", "system"] }).notNull().default("system"),
294
+ ts: text("ts").notNull(),
295
+ createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
296
+ updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
297
+ status: text("status", { enum: [
298
+ "keep",
299
+ "no-change",
300
+ "block"
301
+ ] }),
302
+ message: text("message"),
303
+ durationMs: integer("duration_ms"),
304
+ exitCode: integer("exit_code"),
305
+ finalText: text("final_text"),
306
+ error: text("error"),
307
+ sessionId: text("session_id"),
308
+ usage: jsonb("usage").$type(),
309
+ /** Monotonic cancellation intent. Running becomes canceled only on daemon report. */
310
+ cancelRequestedAt: timestamp("cancel_requested_at", {
311
+ withTimezone: true,
312
+ mode: "string"
313
+ }),
314
+ heartbeatAt: text("heartbeat_at"),
315
+ reportIncident: jsonb("report_incident").$type(),
316
+ /** Loop-file versions this run produced but that were not kept (over limit,
317
+ * owner edited during the run, upload lost). Shown on the run and repeated in
318
+ * the next run's prompt so the agent can correct course. */
319
+ fileNotices: jsonb("file_notices").$type()
320
+ }, (t) => [
321
+ index("runs_loop_idx").on(t.loopId),
322
+ index("runs_phase_idx").on(t.phase),
323
+ index("runs_machine_phase_ready_idx").on(t.machineId, t.phase),
324
+ index("runs_loop_ts_idx").on(t.loopId, t.ts),
325
+ uniqueIndex("runs_loop_run_index_idx").on(t.loopId, t.runIndex).where(sql`${t.runIndex} IS NOT NULL`),
326
+ 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')`),
327
+ uniqueIndex("runs_loop_pending_idx").on(t.loopId).where(sql`${t.phase} = 'pending'`),
328
+ uniqueIndex("one_running_run_per_loop").on(t.loopId).where(sql`${t.phase} = 'running'`),
329
+ check("runs_agent_check", sql`${t.agent} IS NULL OR ${t.agent} IN ('claude-code', 'codex', 'pi')`),
330
+ check("runs_phase_check", sql`${t.phase} IN ('pending', 'running', 'done', 'error', 'canceled')`),
331
+ check("runs_requested_by_check", sql`${t.requestedBy} IN ('owner', 'system')`),
332
+ check("runs_status_check", sql`${t.status} IS NULL OR ${t.status} IN ('keep', 'no-change', 'block')`)
333
+ ]);
334
+ var runLeases = pgTable("run_leases", {
335
+ tokenHash: text("token_hash").primaryKey(),
336
+ runId: text("run_id").notNull(),
337
+ loopId: text("loop_id").notNull(),
338
+ machineId: text("machine_id").notNull(),
339
+ state: text("state", { enum: [
340
+ "active",
341
+ "terminal-grace",
342
+ "reconciliation-only",
343
+ "retired"
344
+ ] }).notNull().default("active"),
345
+ /** Null while active/retired; ISO only during terminal-grace. */
346
+ expiresAt: text("expires_at"),
347
+ createdAt: text("created_at").notNull()
348
+ }, (t) => [
349
+ index("run_leases_run_idx").on(t.runId),
350
+ index("run_leases_loop_idx").on(t.loopId),
351
+ check("run_leases_state_check", sql`${t.state} IN ('active', 'terminal-grace', 'reconciliation-only', 'retired')`)
352
+ ]);
353
+ var runReportReceipts = pgTable("run_report_receipts", {
354
+ reportId: text("report_id").primaryKey(),
355
+ runId: text("run_id").notNull(),
356
+ payloadDigest: text("payload_digest").notNull(),
357
+ ackStatus: integer("ack_status").notNull(),
358
+ ackBody: jsonb("ack_body").$type().notNull(),
359
+ createdAt: timestamp("created_at", {
360
+ withTimezone: true,
361
+ mode: "string"
362
+ }).notNull()
363
+ }, (t) => [index("report_receipts_created").on(t.createdAt)]);
364
+ /** Durable evidence for a rejected terminal attempt. It is keyed by
365
+ * sha256(reportId + canonical-payload-digest), is never loop-owned, and therefore
366
+ * survives loop deletion just like normal report receipts. */
367
+ var terminalReportIncidents = pgTable("terminal_report_incidents", {
368
+ id: text("id").primaryKey(),
369
+ runId: text("run_id").notNull(),
370
+ reportId: text("report_id").notNull(),
371
+ payloadDigest: text("payload_digest").notNull(),
372
+ disposition: text("disposition", { enum: ["run-error", "telemetry-rejected"] }).$type().notNull(),
373
+ ackBody: jsonb("ack_body").$type().notNull(),
374
+ createdAt: timestamp("created_at", {
375
+ withTimezone: true,
376
+ mode: "string"
377
+ }).notNull()
378
+ }, (t) => [
379
+ index("terminal_report_incidents_report_id").on(t.reportId),
380
+ index("terminal_report_incidents_created").on(t.createdAt),
381
+ check("terminal_report_incidents_disposition_check", sql`${t.disposition} IN ('run-error', 'telemetry-rejected')`)
382
+ ]);
383
+ var connectKeys = pgTable("connect_keys", {
384
+ machineId: text("machine_id").primaryKey(),
385
+ /** Mint time (ISO) — drives the TTL. */
386
+ mintedAt: text("minted_at").notNull()
387
+ });
388
+ /**
389
+ * One content-addressed blob (deduped across every loop/run). The bytes live in
390
+ * the byte store under the hash; this row records verified byte presence and size.
391
+ */
392
+ var blobs = pgTable("blobs", {
393
+ hash: text("hash").primaryKey(),
394
+ size: integer("size").notNull(),
395
+ createdAt: text("created_at").notNull()
396
+ });
397
+ /**
398
+ * The current file set of each loop — one row per live or tombstoned name.
399
+ * `hash` → `blobs.hash`; null for a tombstoned (deleted or unconfigured) file.
400
+ * The unique (loopId, path) index is the upsert key owner writes and run
401
+ * finalization share.
402
+ */
403
+ var artifactFiles = pgTable("artifact_files", {
404
+ id: text("id").primaryKey(),
405
+ loopId: text("loop_id").notNull(),
406
+ /** Loop file name: MEMORY.md or a configured artifact name. */
407
+ path: text("path").notNull(),
408
+ hash: text("hash"),
409
+ size: integer("size"),
410
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects NUL, so new rows
411
+ * always store false. */
412
+ binary: boolean("binary").notNull().default(false),
413
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects over-limit versions,
414
+ * so new rows always store false. */
415
+ oversize: boolean("oversize").notNull().default(false),
416
+ deleted: boolean("deleted").notNull().default(false),
417
+ updatedAt: text("updated_at").notNull()
418
+ }, (t) => [
419
+ index("artifact_files_loop_idx").on(t.loopId),
420
+ uniqueIndex("artifact_files_loop_path_idx").on(t.loopId, t.path),
421
+ index("artifact_files_hash_idx").on(t.hash)
422
+ ]);
423
+ /**
424
+ * The loop's full loop-file manifest captured at each run's finalize — the input
425
+ * to the per-run diff. Written cheaply on report (no diff computed on write);
426
+ * `getRunDiff` lazily compares run N's snapshot with the preceding snapshot.
427
+ * One row per run (runId primary key).
428
+ */
429
+ var runSnapshots = pgTable("run_snapshots", {
430
+ runId: text("run_id").primaryKey(),
431
+ loopId: text("loop_id").notNull(),
432
+ manifest: jsonb("manifest").$type().notNull(),
433
+ createdAt: text("created_at").notNull()
434
+ }, (t) => [index("run_snapshots_loop_idx").on(t.loopId)]);
435
+ /**
436
+ * Embedded PGlite (WASM Postgres) database, file-backed at `<dataDir>/pgdata`,
437
+ * plus the Drizzle handle. A `globalThis` guard keeps the handle a singleton
438
+ * across dev HMR reloads (the Scheduler shares it in-process). Exactly one
439
+ * server process may own a data directory.
440
+ */
441
+ var schema = {
442
+ machines,
443
+ loops,
444
+ runs,
445
+ blobs,
446
+ artifactFiles,
447
+ runSnapshots,
448
+ runLeases,
449
+ runReportReceipts,
450
+ terminalReportIncidents,
451
+ connectKeys
452
+ };
453
+ var g$1 = globalThis;
454
+ function open() {
455
+ if (g$1.__pievoDb && g$1.__pievoClient) return {
456
+ db: g$1.__pievoDb,
457
+ client: g$1.__pievoClient
458
+ };
459
+ const dir = path.join(dataDir(), "pgdata");
460
+ logger.info({ pgdata: dir }, "database: pglite");
461
+ fs.mkdirSync(dataDir(), { recursive: true });
462
+ const client = new Ve(dir);
463
+ const db = drizzle(client, { schema });
464
+ g$1.__pievoClient = client;
465
+ g$1.__pievoDb = db;
466
+ return {
467
+ db,
468
+ client
469
+ };
470
+ }
471
+ var opened = open();
472
+ var db = opened.db;
473
+ opened.client;
474
+ /**
475
+ * Migrations dir. In DEV this file lives at `src/db`, so the folder is at the
476
+ * package root (`../../drizzle`). In the BUILT server the module is bundled under
477
+ * `.output/server/{_ssr,_libs}` and `copy-pglite-assets.mjs` lands the folder at
478
+ * `.output/server/drizzle` (`../drizzle`). Return the first candidate that exists
479
+ * so the database migrates in-process from either layout.
480
+ */
481
+ function migrationsDir() {
482
+ const here = path.dirname(fileURLToPath(import.meta.url));
483
+ const devDir = path.resolve(here, "..", "..", "drizzle");
484
+ const builtDir = path.resolve(here, "..", "drizzle");
485
+ if (fs.existsSync(devDir)) return devDir;
486
+ if (fs.existsSync(builtDir)) return builtDir;
487
+ return devDir;
488
+ }
489
+ /** Apply generated migrations in-process (idempotent). */
490
+ async function runMigrations() {
491
+ const dir = migrationsDir();
492
+ if (!fs.existsSync(dir)) return;
493
+ const { migrate } = await import("../_libs/drizzle-orm.mjs").then((n) => n.E);
494
+ await migrate(opened.db, { migrationsFolder: dir });
495
+ }
496
+ /**
497
+ * Best-effort clean shutdown of the database client (called from the
498
+ * boot AbortController on SIGINT/SIGTERM). PGlite must be closed explicitly:
499
+ * after migrations it owns a referenced WASM timer that otherwise keeps Node
500
+ * alive until the managed launcher's graceful-stop deadline. Never throws —
501
+ * shutdown must not wedge on a slow drain.
502
+ */
503
+ async function closeClient() {
504
+ try {
505
+ await opened.client.close();
506
+ } catch {}
507
+ }
508
+ /**
509
+ * Content-addressed blob byte store for loop files.
510
+ *
511
+ * Bytes are keyed by their sha256 hash (content-addressed ⇒ deduped across every
512
+ * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
513
+ * them — preserving the server's zero-exec invariant.
514
+ *
515
+ * `LocalBlobStore` is the durable store under `<dataDir>/blobs`;
516
+ * `MemoryBlobStore` is the injected test fake.
517
+ */
518
+ var log$3 = logger.child({ mod: "blobstore" });
519
+ var SHA256_HEX = /^[a-f0-9]{64}$/;
520
+ /** Durable filesystem adapter. Hash sharding bounds entries per directory; atomic
521
+ * temp-file renames ensure readers see either complete bytes or no object. */
522
+ var LocalBlobStore = class {
523
+ root;
524
+ initialized;
525
+ constructor(root = path.join(dataDir(), "blobs")) {
526
+ this.root = root;
527
+ }
528
+ file(hash) {
529
+ if (!SHA256_HEX.test(hash)) throw new Error("invalid sha256 blob key");
530
+ return path.join(this.root, hash.slice(0, 2), hash);
531
+ }
532
+ async has(hash) {
533
+ const target = this.file(hash);
534
+ await this.ensureInitialized();
535
+ try {
536
+ return (await fs$1.stat(target)).isFile();
537
+ } catch (err) {
538
+ if (err.code === "ENOENT") return false;
539
+ throw err;
540
+ }
541
+ }
542
+ async syncDir(dir) {
543
+ let handle;
544
+ try {
545
+ handle = await fs$1.open(dir, "r");
546
+ await handle.sync();
547
+ } catch (err) {
548
+ const code = err.code;
549
+ if (![
550
+ "EINVAL",
551
+ "ENOTSUP",
552
+ "EBADF",
553
+ "EPERM",
554
+ "ENOENT"
555
+ ].includes(code)) throw err;
556
+ } finally {
557
+ await handle?.close().catch(() => {});
558
+ }
559
+ }
560
+ ensureInitialized() {
561
+ return this.initialized ??= (async () => {
562
+ await fs$1.mkdir(this.root, { recursive: true });
563
+ await this.syncDir(path.dirname(path.dirname(this.root)));
564
+ await this.syncDir(path.dirname(this.root));
565
+ await this.syncDir(this.root);
566
+ for (const shard of await fs$1.readdir(this.root, { withFileTypes: true })) {
567
+ if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
568
+ const dir = path.join(this.root, shard.name);
569
+ let removed = false;
570
+ for (const name of await fs$1.readdir(dir)) {
571
+ if (!/^\.[a-f0-9]{64}\.\d+\.[0-9a-f-]+\.tmp$/.test(name)) continue;
572
+ await fs$1.rm(path.join(dir, name), { force: true });
573
+ removed = true;
574
+ }
575
+ if (removed) await this.syncDir(dir);
576
+ }
577
+ })();
578
+ }
579
+ async put(hash, bytes) {
580
+ const target = this.file(hash);
581
+ await this.ensureInitialized();
582
+ const dir = path.dirname(target);
583
+ await fs$1.mkdir(dir, { recursive: true });
584
+ await this.syncDir(this.root);
585
+ const temp = path.join(dir, `.${hash}.${process.pid}.${randomUUID()}.tmp`);
586
+ let handle;
587
+ try {
588
+ handle = await fs$1.open(temp, "wx", 384);
589
+ await handle.writeFile(bytes);
590
+ await handle.sync();
591
+ await handle.close();
592
+ handle = void 0;
593
+ try {
594
+ await fs$1.rename(temp, target);
595
+ await this.syncDir(dir);
596
+ } catch (err) {
597
+ if ([
598
+ "EEXIST",
599
+ "EACCES",
600
+ "EPERM"
601
+ ].includes(err.code ?? "") && await this.has(hash)) {
602
+ await fs$1.rm(temp, { force: true });
603
+ return;
604
+ }
605
+ throw err;
606
+ }
607
+ } catch (err) {
608
+ await handle?.close().catch(() => {});
609
+ await fs$1.rm(temp, { force: true }).catch(() => {});
610
+ throw err;
611
+ }
612
+ }
613
+ async get(hash) {
614
+ const target = this.file(hash);
615
+ await this.ensureInitialized();
616
+ try {
617
+ return await fs$1.readFile(target);
618
+ } catch (err) {
619
+ if (err.code === "ENOENT") return null;
620
+ throw err;
621
+ }
622
+ }
623
+ async delete(hash) {
624
+ const target = this.file(hash);
625
+ await this.ensureInitialized();
626
+ await fs$1.rm(target, { force: true });
627
+ await this.syncDir(path.dirname(target));
628
+ }
629
+ };
630
+ /** The one local blob store shared by loop-file writes/reads and GC. */
631
+ function createBlobStore() {
632
+ const local = new LocalBlobStore();
633
+ log$3.info({ root: local.root }, "blob store: local filesystem");
634
+ return local;
635
+ }
636
+ /**
637
+ * Loop files: a loop's server-owned memory and display artifacts.
638
+ *
639
+ * Every loop has the required `MEMORY.md` plus up to MAX_ARTIFACTS configured
640
+ * `.md`/`.html` artifact names. The server is authoritative: bytes live in the
641
+ * content-addressed BlobStore and the current version of each name is an
642
+ * `artifact_files` row. For each run the daemon downloads the current files into
643
+ * a private run directory, and the terminal report carries the changed versions
644
+ * back with the hash each edit started from, so an owner edit made during the run
645
+ * wins over the agent's (base-hash check under the loop lock).
646
+ *
647
+ * Limits are part of the agent-facing prompt contract and are mirrored in the
648
+ * daemon (`packages/daemon/src/loop-files.ts`); change both together. An over-limit
649
+ * version is rejected and the previous version kept, never truncated, because a cut
650
+ * HTML file renders broken and a cut memory file silently loses its newest notes.
651
+ */
652
+ var loopFiles_exports = /* @__PURE__ */ __exportAll({
653
+ ARTIFACT_BYTE_CAP: () => ARTIFACT_BYTE_CAP,
654
+ EMPTY_SHA256: () => EMPTY_SHA256,
655
+ LOOP_FILE_BYTE_CAP: () => LOOP_FILE_BYTE_CAP,
656
+ LoopFiles: () => LoopFiles,
657
+ MAX_ARTIFACTS: () => 4,
658
+ MEMORY_CHAR_CAP: () => MEMORY_CHAR_CAP,
659
+ MEMORY_FILE: () => MEMORY_FILE,
660
+ fileLimitLabel: () => fileLimitLabel,
661
+ isValidHash: () => isValidHash,
662
+ loopFileIssue: () => loopFileIssue,
663
+ loopFileNames: () => loopFileNames,
664
+ sha256Buf: () => sha256Buf,
665
+ validArtifactName: () => validArtifactName
666
+ });
667
+ var MEMORY_FILE = "MEMORY.md";
668
+ /** Memory is re-read by every run, so it is bounded in characters (Unicode code
669
+ * points), roughly 2k tokens of prompt context per run. */
670
+ var MEMORY_CHAR_CAP = 8e3;
671
+ /** HTML reports are bulky; 2 MB keeps a run's upload and the dashboard iframe cheap. */
672
+ var ARTIFACT_BYTE_CAP = 2 * 1024 * 1024;
673
+ /** sha256 of zero bytes: the version of a loop file that has never been written. */
674
+ var EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
675
+ var ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.(md|html)$/;
676
+ function sha256Buf(bytes) {
677
+ return createHash("sha256").update(bytes).digest("hex");
678
+ }
679
+ function isValidHash(hash) {
680
+ return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
681
+ }
682
+ /** A plain file name (no directories) ending in .md or .html, other than MEMORY.md. */
683
+ function validArtifactName(name) {
684
+ return typeof name === "string" && ARTIFACT_NAME_RE.test(name) && name.toLowerCase() !== "MEMORY.md".toLowerCase();
685
+ }
686
+ function loopFileNames(loop) {
687
+ return [MEMORY_FILE, ...loop.artifacts];
688
+ }
689
+ function fileLimitLabel(name) {
690
+ return name === "MEMORY.md" ? `${MEMORY_CHAR_CAP} characters` : "2 MB";
691
+ }
692
+ /** Why `bytes` cannot become the next version of `name`, or undefined if it can. */
693
+ function loopFileIssue(name, bytes) {
694
+ if (bytes.includes(0)) return `${name}: binary content is not allowed`;
695
+ if (name === "MEMORY.md") {
696
+ const chars = Array.from(bytes.toString("utf8")).length;
697
+ if (chars > 8e3) return `${name}: ${chars} characters exceeds the ${MEMORY_CHAR_CAP}-character limit`;
698
+ return;
699
+ }
700
+ if (bytes.length > 2097152) return `${name}: ${(bytes.length / 1024 / 1024).toFixed(1)} MB exceeds the 2 MB limit`;
701
+ }
702
+ /** The largest byte size any loop file can have (a code point is at most 4 bytes). */
703
+ var LOOP_FILE_BYTE_CAP = Math.max(ARTIFACT_BYTE_CAP, MEMORY_CHAR_CAP * 4);
704
+ var LoopFiles = class {
705
+ blobStore;
706
+ constructor(blobStore = createBlobStore()) {
707
+ this.blobStore = blobStore;
708
+ }
709
+ readBlob(hash) {
710
+ if (!isValidHash(hash)) return Promise.resolve(null);
711
+ if (hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") return Promise.resolve(Buffer.alloc(0));
712
+ return this.blobStore.get(hash);
713
+ }
714
+ /** Persist bytes under their own hash. Idempotent; callers record the metadata row. */
715
+ async putBlob(bytes) {
716
+ const hash = sha256Buf(bytes);
717
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" && !await this.blobStore.has(hash)) await this.blobStore.put(hash, bytes);
718
+ return hash;
719
+ }
720
+ async hasBlob(hash) {
721
+ return hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" || this.blobStore.has(hash);
722
+ }
723
+ };
724
+ /**
725
+ * Data-access layer over Drizzle. Loop-locked state transitions live here; every
726
+ * DB-touching function is async, while the pure helpers stay sync.
727
+ */
728
+ var store_exports = /* @__PURE__ */ __exportAll({
729
+ TERMINAL_REPORT_GRACE_MS: () => TERMINAL_REPORT_GRACE_MS,
730
+ acknowledgeRetiredReport: () => acknowledgeRetiredReport,
731
+ acknowledgeRetiredTerminalIncident: () => acknowledgeRetiredTerminalIncident,
732
+ advanceDueSchedules: () => advanceDueSchedules,
733
+ artifactFileReferencesHash: () => artifactFileReferencesHash,
734
+ blobHashesOlderThan: () => blobHashesOlderThan,
735
+ blobSizes: () => blobSizes,
736
+ buildLoopManifest: () => buildLoopManifest,
737
+ claimReadyRunForMachine: () => claimReadyRunForMachine,
738
+ countRunSnapshots: () => countRunSnapshots,
739
+ countRuns: () => countRuns,
740
+ createLoop: () => createLoop,
741
+ createMachine: () => createMachine,
742
+ deleteBlob: () => deleteBlob,
743
+ enqueueRun: () => enqueueRun,
744
+ expirePendingRun: () => expirePendingRun,
745
+ finalizeRunningRun: () => finalizeRunningRun,
746
+ forceDeleteLoop: () => forceDeleteLoop,
747
+ forceDeleteMachine: () => forceDeleteMachine,
748
+ getArtifactFile: () => getArtifactFile,
749
+ getExactTerminalReportIncident: () => getExactTerminalReportIncident,
750
+ getLoop: () => getLoop,
751
+ getMachine: () => getMachine,
752
+ getReportReceipt: () => getReportReceipt,
753
+ getRun: () => getRun,
754
+ getRunSnapshot: () => getRunSnapshot,
755
+ getTerminalReportIncidents: () => getTerminalReportIncidents,
756
+ hasPendingRun: () => hasPendingRun,
757
+ hasRunningRun: () => hasRunningRun,
758
+ initializeCronCadence: () => initializeCronCadence,
759
+ lastRun: () => lastRun,
760
+ listArtifacts: () => listArtifacts,
761
+ listLoops: () => listLoops,
762
+ listMachines: () => listMachines,
763
+ listRuns: () => listRuns,
764
+ listRunsBefore: () => listRunsBefore,
765
+ liveBlobRefs: () => liveBlobRefs,
766
+ loopIdsWithSnapshots: () => loopIdsWithSnapshots,
767
+ loopsForMachine: () => loopsForMachine,
768
+ newLoopId: () => newLoopId,
769
+ nextCronAt: () => nextCronAt,
770
+ openRuns: () => openRuns,
771
+ pauseLoop: () => pauseLoop,
772
+ pauseLoopState: () => pauseLoopState,
773
+ prevRunSnapshot: () => prevRunSnapshot,
774
+ previousRunFileNotices: () => previousRunFileNotices,
775
+ pruneRunSnapshots: () => pruneRunSnapshots,
776
+ putReportReceiptIfAbsent: () => putReportReceiptIfAbsent,
777
+ putRunSnapshot: () => putRunSnapshot,
778
+ putTerminalReportIncidentIfAbsent: () => putTerminalReportIncidentIfAbsent,
779
+ recentLoopUsage: () => recentLoopUsage,
780
+ reclaimRun: () => reclaimRun,
781
+ reconcileReclaimedRun: () => reconcileReclaimedRun,
782
+ reconciliationStatesForRuns: () => reconciliationStatesForRuns,
783
+ recordBlob: () => recordBlob,
784
+ refreshRunHeartbeats: () => refreshRunHeartbeats,
785
+ rejectTerminalReport: () => rejectTerminalReport,
786
+ releaseAbsentReconciliations: () => releaseAbsentReconciliations,
787
+ repairTerminalRunLeases: () => repairTerminalRunLeases,
788
+ requestDeleteLoop: () => requestDeleteLoop,
789
+ requestRunCancel: () => requestRunCancel,
790
+ runningRunForLoop: () => runningRunForLoop,
791
+ runningRunsForMachine: () => runningRunsForMachine,
792
+ setMachineOnline: () => setMachineOnline,
793
+ snapshotBlobRefs: () => snapshotBlobRefs,
794
+ startLoop: () => startLoop,
795
+ stopLoop: () => stopLoop,
796
+ terminalIncidentReceiptId: () => terminalIncidentReceiptId,
797
+ tryDeleteLoop: () => tryDeleteLoop,
798
+ updateLoop: () => updateLoop,
799
+ updateMachine: () => updateMachine,
800
+ writeLoopFile: () => writeLoopFile
801
+ });
802
+ function newLoopId() {
803
+ return `loop-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
804
+ }
805
+ function nowIso$1() {
806
+ return (/* @__PURE__ */ new Date()).toISOString();
807
+ }
808
+ function nextCronAt(cron, timezone, after) {
809
+ if (!timezone) throw new Error("invariant: cron schedule has no timezone");
810
+ const probe = new E(cron, {
811
+ paused: true,
812
+ timezone
813
+ });
814
+ try {
815
+ const next = probe.nextRun(new Date(after));
816
+ if (!next) throw new Error(`cron expression never fires again: ${cron}`);
817
+ return next.toISOString();
818
+ } finally {
819
+ probe.stop();
820
+ }
821
+ }
822
+ function runTimes(at = nowIso$1()) {
823
+ return {
824
+ ts: at,
825
+ createdAt: at,
826
+ updatedAt: at
827
+ };
828
+ }
829
+ async function listLoops() {
830
+ return db.select().from(loops);
831
+ }
832
+ async function getLoop(id) {
833
+ return (await db.select().from(loops).where(eq(loops.id, id)))[0];
834
+ }
835
+ async function loopsForMachine(machineId) {
836
+ return db.select().from(loops).where(eq(loops.machineId, machineId));
837
+ }
838
+ async function createLoop(input) {
839
+ const ts = nowIso$1();
840
+ const enabled = input.enabled ?? true;
841
+ if (input.scheduleMode === "cron" && !input.timezone) throw new Error("invariant: cron loop creation requires a timezone");
842
+ if (input.scheduleMode === "continuous" && input.timezone !== null) throw new Error("invariant: continuous loop creation requires a null timezone");
843
+ const nextCadenceAt = !enabled ? null : input.scheduleMode === "continuous" ? ts : nextCronAt(input.cron, input.timezone, ts);
844
+ const row = {
845
+ ...input,
846
+ enabled,
847
+ pauseCause: input.pauseCause ?? (!enabled ? {
848
+ kind: "owner",
849
+ at: ts
850
+ } : null),
851
+ nextRunAt: enabled ? input.nextRunAt ?? null : null,
852
+ nextCadenceAt,
853
+ id: input.id ?? newLoopId(),
854
+ createdAt: ts,
855
+ updatedAt: ts
856
+ };
857
+ return db.transaction(async (tx) => {
858
+ 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`);
859
+ return (await tx.insert(loops).values(row).returning())[0];
860
+ });
861
+ }
862
+ /** Allocate the next 1-based history number. Every caller already holds the
863
+ * owning loop row lock, so the counter and run transition share one commit. */
864
+ async function allocateRunIndexTx(tx, loopId) {
865
+ const updated = (await tx.update(loops).set({ lastRunIndex: sql`${loops.lastRunIndex} + 1` }).where(eq(loops.id, loopId)).returning({ runIndex: loops.lastRunIndex }))[0];
866
+ if (!updated) throw new Error(`cannot allocate history index for missing loop ${loopId}`);
867
+ return updated.runIndex;
868
+ }
869
+ async function ensureRunIndexTx(tx, run) {
870
+ return run.runIndex ?? allocateRunIndexTx(tx, run.loopId);
871
+ }
872
+ async function cancelPendingTx(tx, where, message, at) {
873
+ const pending = await tx.select({
874
+ id: runs.id,
875
+ loopId: runs.loopId,
876
+ runIndex: runs.runIndex
877
+ }).from(runs).where(where).orderBy(asc(runs.createdAt), asc(runs.id)).for("update");
878
+ for (const run of pending) {
879
+ const runIndex = await ensureRunIndexTx(tx, run);
880
+ await tx.update(runs).set({
881
+ phase: "canceled",
882
+ runIndex,
883
+ message,
884
+ ts: at,
885
+ updatedAt: at
886
+ }).where(and(eq(runs.id, run.id), eq(runs.phase, "pending")));
887
+ }
888
+ }
889
+ /** Apply one loop patch while its row lock is held. This is shared by owner edits
890
+ * and run-authorized mutations, so cadence semantics cannot drift. */
891
+ async function updateLoopTx(tx, current, patch, at) {
892
+ const extra = {};
893
+ if (patch.enabled === true) extra.pauseCause = null;
894
+ else if (patch.enabled === false && current.enabled && patch.pauseCause === void 0) extra.pauseCause = {
895
+ kind: "owner",
896
+ at
897
+ };
898
+ const effective = {
899
+ ...current,
900
+ ...patch,
901
+ ...extra
902
+ };
903
+ if (current.deleteRequestedAt) extra.enabled = false;
904
+ const pausing = !effective.enabled || current.deleteRequestedAt != null;
905
+ const activating = effective.enabled && (patch.enabled === true && !current.enabled || patch.scheduleMode !== void 0 && patch.scheduleMode !== current.scheduleMode);
906
+ if (pausing) {
907
+ extra.nextCadenceAt = null;
908
+ extra.nextRunAt = null;
909
+ } else if (activating) if (effective.scheduleMode === "cron") extra.nextCadenceAt = nextCronAt(effective.cron, effective.timezone, at);
910
+ 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;
911
+ 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);
912
+ else if (effective.enabled && effective.scheduleMode === "continuous" && current.nextCadenceAt && Date.parse(current.nextCadenceAt) > Date.parse(at) && patch.continuousDelayMinutes !== void 0 && patch.continuousDelayMinutes !== current.continuousDelayMinutes) {
913
+ const terminalAt = Date.parse(current.nextCadenceAt) - current.continuousDelayMinutes * 6e4;
914
+ extra.nextCadenceAt = new Date(terminalAt + effective.continuousDelayMinutes * 6e4).toISOString();
915
+ }
916
+ const loop = (await tx.update(loops).set({
917
+ ...patch,
918
+ ...extra,
919
+ updatedAt: at
920
+ }).where(eq(loops.id, current.id)).returning())[0];
921
+ 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);
922
+ if (patch.artifacts !== void 0) {
923
+ const allowed = new Set([MEMORY_FILE, ...patch.artifacts ?? []]);
924
+ const removed = (await tx.select({
925
+ id: artifactFiles.id,
926
+ path: artifactFiles.path
927
+ }).from(artifactFiles).where(and(eq(artifactFiles.loopId, current.id), eq(artifactFiles.deleted, false)))).filter((file) => !allowed.has(file.path)).map((file) => file.id);
928
+ if (removed.length) await tx.update(artifactFiles).set({
929
+ hash: null,
930
+ deleted: true,
931
+ updatedAt: at
932
+ }).where(inArray(artifactFiles.id, removed));
933
+ }
934
+ return loop;
935
+ }
936
+ async function updateLoop(id, patch) {
937
+ return db.transaction(async (tx) => {
938
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
939
+ return current ? updateLoopTx(tx, current, patch, nowIso$1()) : void 0;
940
+ });
941
+ }
942
+ /** Delete loop-owned history/content without erasing durable late-report evidence.
943
+ * A retired lease is non-authorizing and never blocks deletion; only the matching
944
+ * report→410 receipt transaction may consume it. */
945
+ async function deleteLoopDataTx(tx, id) {
946
+ if (!(await tx.delete(loops).where(eq(loops.id, id)).returning({ id: loops.id })).length) return false;
947
+ await tx.delete(runs).where(eq(runs.loopId, id));
948
+ await tx.delete(runLeases).where(and(eq(runLeases.loopId, id), ne(runLeases.state, "retired")));
949
+ await tx.delete(artifactFiles).where(eq(artifactFiles.loopId, id));
950
+ await tx.delete(runSnapshots).where(eq(runSnapshots.loopId, id));
951
+ return true;
952
+ }
953
+ /** Stable Pause plus the post-transition running fact, observed under the same loop
954
+ * lock. Callers use this result for truthful wording without a pre-lock race. */
955
+ async function pauseLoopState(id) {
956
+ return db.transaction(async (tx) => {
957
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
958
+ if (!current) return void 0;
959
+ const loop = await updateLoopTx(tx, current, { enabled: false }, nowIso$1());
960
+ const running = (await tx.select().from(runs).where(and(eq(runs.loopId, id), eq(runs.phase, "running"))).limit(1))[0];
961
+ return {
962
+ loop,
963
+ ...running ? { running } : {}
964
+ };
965
+ });
966
+ }
967
+ async function pauseLoop(id) {
968
+ return (await pauseLoopState(id))?.loop;
969
+ }
970
+ async function startLoop(id) {
971
+ return db.transaction(async (tx) => {
972
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
973
+ if (!current || current.deleteRequestedAt) return void 0;
974
+ return updateLoopTx(tx, current, { enabled: true }, nowIso$1());
975
+ });
976
+ }
977
+ async function stopLoopTx(tx, current, at) {
978
+ const loop = (await tx.update(loops).set({
979
+ enabled: false,
980
+ pauseCause: {
981
+ kind: "owner",
982
+ at
983
+ },
984
+ nextCadenceAt: null,
985
+ nextRunAt: null,
986
+ updatedAt: at
987
+ }).where(eq(loops.id, current.id)).returning())[0];
988
+ await cancelPendingTx(tx, and(eq(runs.loopId, current.id), eq(runs.phase, "pending")), "canceled - loop stopped before this queued run was claimed", at);
989
+ const running = (await tx.select().from(runs).where(and(eq(runs.loopId, current.id), eq(runs.phase, "running"))).limit(1).for("update"))[0];
990
+ if (!running) return { loop };
991
+ return {
992
+ loop,
993
+ running: running.cancelRequestedAt ? running : (await tx.update(runs).set({
994
+ cancelRequestedAt: at,
995
+ updatedAt: at
996
+ }).where(and(eq(runs.id, running.id), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? running
997
+ };
998
+ }
999
+ async function stopLoop(id) {
1000
+ return db.transaction(async (tx) => {
1001
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1002
+ return current ? stopLoopTx(tx, current, nowIso$1()) : void 0;
1003
+ });
1004
+ }
1005
+ async function requestRunCancel(loopId, runId) {
1006
+ return db.transaction(async (tx) => {
1007
+ if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, loopId)).for("update"))[0]) return void 0;
1008
+ const run = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId))).limit(1).for("update"))[0];
1009
+ if (!run) return void 0;
1010
+ if (run.phase === "pending") {
1011
+ const at = nowIso$1();
1012
+ const runIndex = await ensureRunIndexTx(tx, run);
1013
+ return (await tx.update(runs).set({
1014
+ phase: "canceled",
1015
+ runIndex,
1016
+ error: "stopped by user",
1017
+ ts: at,
1018
+ updatedAt: at
1019
+ }).where(and(eq(runs.id, runId), eq(runs.phase, "pending"))).returning())[0] ?? run;
1020
+ }
1021
+ if (run.phase === "running" && !run.cancelRequestedAt) {
1022
+ const at = nowIso$1();
1023
+ return (await tx.update(runs).set({
1024
+ cancelRequestedAt: at,
1025
+ updatedAt: at
1026
+ }).where(and(eq(runs.id, runId), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? run;
1027
+ }
1028
+ return run;
1029
+ });
1030
+ }
1031
+ async function requestDeleteLoop(id) {
1032
+ return db.transaction(async (tx) => {
1033
+ let current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1034
+ if (!current) return void 0;
1035
+ const at = current.deleteRequestedAt ?? nowIso$1();
1036
+ if (!current.deleteRequestedAt) current = (await tx.update(loops).set({
1037
+ deleteRequestedAt: at,
1038
+ updatedAt: at
1039
+ }).where(eq(loops.id, id)).returning())[0];
1040
+ return stopLoopTx(tx, current, at);
1041
+ });
1042
+ }
1043
+ async function tryDeleteLoop(id) {
1044
+ return db.transaction(async (tx) => {
1045
+ if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]?.deleteRequestedAt) return false;
1046
+ const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
1047
+ 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];
1048
+ if (open || authority) return false;
1049
+ await tx.update(runLeases).set({
1050
+ state: "retired",
1051
+ expiresAt: null
1052
+ }).where(and(eq(runLeases.loopId, id), eq(runLeases.state, "reconciliation-only")));
1053
+ return deleteLoopDataTx(tx, id);
1054
+ });
1055
+ }
1056
+ async function forceDeleteLoopTx(tx, id) {
1057
+ if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]) return false;
1058
+ await tx.update(runLeases).set({
1059
+ state: "retired",
1060
+ expiresAt: null
1061
+ }).where(and(eq(runLeases.loopId, id), inArray(runLeases.state, [
1062
+ "active",
1063
+ "terminal-grace",
1064
+ "reconciliation-only",
1065
+ "retired"
1066
+ ])));
1067
+ return deleteLoopDataTx(tx, id);
1068
+ }
1069
+ async function forceDeleteLoop(id) {
1070
+ return db.transaction((tx) => forceDeleteLoopTx(tx, id));
1071
+ }
1072
+ async function enqueueRunTx(tx, loop, request, at) {
1073
+ if (loop.deleteRequestedAt) return {
1074
+ state: "rejected",
1075
+ reason: "loop is being deleted"
1076
+ };
1077
+ if (request.requestedBy !== "owner" && !loop.enabled) return {
1078
+ state: "skipped",
1079
+ reason: "loop is paused"
1080
+ };
1081
+ const pending = (await tx.select().from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "pending"))).limit(1).for("update"))[0];
1082
+ if (pending) {
1083
+ const requestedBy = pending.requestedBy === "owner" || request.requestedBy === "owner" ? "owner" : "system";
1084
+ const updated = (await tx.update(runs).set({
1085
+ requestedBy,
1086
+ ts: at,
1087
+ updatedAt: at
1088
+ }).where(and(eq(runs.id, pending.id), eq(runs.phase, "pending"))).returning())[0];
1089
+ return updated ? {
1090
+ state: "coalesced",
1091
+ run: updated
1092
+ } : {
1093
+ state: "skipped",
1094
+ reason: "queued run was claimed concurrently"
1095
+ };
1096
+ }
1097
+ const row = {
1098
+ id: randomUUID(),
1099
+ loopId: loop.id,
1100
+ machineId: loop.machineId,
1101
+ phase: "pending",
1102
+ requestedBy: request.requestedBy,
1103
+ ...runTimes(at)
1104
+ };
1105
+ return {
1106
+ state: "queued",
1107
+ run: (await tx.insert(runs).values(row).returning())[0]
1108
+ };
1109
+ }
1110
+ async function failureStreakTx(tx, loopId) {
1111
+ 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];
1112
+ const conds = [
1113
+ eq(runs.loopId, loopId),
1114
+ eq(runs.phase, "error"),
1115
+ isNotNull(runs.runIndex)
1116
+ ];
1117
+ if (lastOk?.runIndex != null) conds.push(gt(runs.runIndex, lastOk.runIndex));
1118
+ const row = (await tx.select({ n: sql`count(*)` }).from(runs).where(and(...conds)))[0];
1119
+ return Number(row?.n ?? 0);
1120
+ }
1121
+ async function terminalLifecycleTx(tx, currentLoop, run, terminalAt, loopPatch = {}, failureAutopauseStreak = 0) {
1122
+ const effective = {
1123
+ ...currentLoop,
1124
+ ...loopPatch
1125
+ };
1126
+ const update = { ...loopPatch };
1127
+ if ((run.phase === "done" || run.phase === "error") && effective.scheduleMode === "continuous") {
1128
+ 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];
1129
+ update.nextCadenceAt = effective.enabled && !openExec ? new Date(Date.parse(terminalAt) + Math.max(1, effective.continuousDelayMinutes) * 6e4).toISOString() : null;
1130
+ }
1131
+ let loop = Object.keys(update).length ? (await tx.update(loops).set({
1132
+ ...update,
1133
+ updatedAt: terminalAt
1134
+ }).where(eq(loops.id, currentLoop.id)).returning())[0] : currentLoop;
1135
+ let failureStreak = 0;
1136
+ let autoPaused = false;
1137
+ if (run.phase === "done" && run.status === "block") {
1138
+ loop = (await tx.update(loops).set({
1139
+ enabled: false,
1140
+ pauseCause: {
1141
+ kind: "blocked",
1142
+ at: terminalAt,
1143
+ runId: run.id
1144
+ },
1145
+ nextCadenceAt: null,
1146
+ nextRunAt: null,
1147
+ updatedAt: terminalAt
1148
+ }).where(eq(loops.id, currentLoop.id)).returning())[0];
1149
+ 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);
1150
+ autoPaused = true;
1151
+ }
1152
+ if (run.phase === "error") {
1153
+ failureStreak = await failureStreakTx(tx, currentLoop.id);
1154
+ if (failureAutopauseStreak > 0 && failureStreak >= failureAutopauseStreak && loop.enabled) {
1155
+ loop = (await tx.update(loops).set({
1156
+ enabled: false,
1157
+ pauseCause: {
1158
+ kind: "failure-streak",
1159
+ at: terminalAt,
1160
+ runId: run.id,
1161
+ count: failureStreak
1162
+ },
1163
+ nextCadenceAt: null,
1164
+ nextRunAt: null,
1165
+ updatedAt: terminalAt
1166
+ }).where(eq(loops.id, currentLoop.id)).returning())[0];
1167
+ 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);
1168
+ autoPaused = true;
1169
+ }
1170
+ }
1171
+ return {
1172
+ loop,
1173
+ failureStreak,
1174
+ autoPaused
1175
+ };
1176
+ }
1177
+ async function getRun(id) {
1178
+ return (await db.select().from(runs).where(eq(runs.id, id)))[0];
1179
+ }
1180
+ async function runningRunsForMachine(machineId) {
1181
+ return db.select().from(runs).where(and(eq(runs.machineId, machineId), eq(runs.phase, "running"))).orderBy(asc(runs.createdAt), asc(runs.id));
1182
+ }
1183
+ async function runningRunForLoop(loopId) {
1184
+ return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
1185
+ }
1186
+ async function getReportReceipt(reportId) {
1187
+ return (await db.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, reportId)))[0];
1188
+ }
1189
+ function terminalIncidentReceiptId(reportId, payloadDigest) {
1190
+ return createHash("sha256").update(reportId + payloadDigest).digest("hex");
1191
+ }
1192
+ async function getTerminalReportIncidents(reportId) {
1193
+ return db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, reportId)).orderBy(asc(terminalReportIncidents.createdAt));
1194
+ }
1195
+ async function getExactTerminalReportIncident(reportId, payloadDigest) {
1196
+ return (await db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(reportId, payloadDigest))))[0];
1197
+ }
1198
+ async function putTerminalReportIncidentIfAbsent(input) {
1199
+ return db.transaction(async (tx) => {
1200
+ await lockReportIdTx(tx, input.reportId);
1201
+ const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
1202
+ const existing = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
1203
+ if (existing) return existing;
1204
+ return (await tx.insert(terminalReportIncidents).values({
1205
+ id,
1206
+ ...input,
1207
+ createdAt: nowIso$1()
1208
+ }).returning())[0];
1209
+ });
1210
+ }
1211
+ /** Serialize all terminal handling for one reportId, even when the competing
1212
+ * runs belong to different loop locks or one terminal result lands in the incident table. */
1213
+ async function lockReportIdTx(tx, reportId) {
1214
+ const unsigned = BigInt(`0x${createHash("sha256").update(reportId).digest("hex").slice(0, 16)}`);
1215
+ const key = BigInt.asIntN(64, unsigned).toString();
1216
+ await tx.execute(sql`select pg_advisory_xact_lock(${key}::bigint)`);
1217
+ }
1218
+ /** Race-safe receipt reservation for definitive non-finalization ACKs (RETIRED).
1219
+ * Returns the row that owns reportId, whether inserted here or concurrently. */
1220
+ async function putReportReceiptIfAbsent(input) {
1221
+ return db.transaction(async (tx) => {
1222
+ await lockReportIdTx(tx, input.reportId);
1223
+ 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];
1224
+ });
1225
+ }
1226
+ /** Idempotent startup repair for a crash across terminalization.
1227
+ * Terminal runs can never retain active authority. */
1228
+ async function repairTerminalRunLeases(now = Date.now()) {
1229
+ const repaired = await db.update(runLeases).set({
1230
+ state: "terminal-grace",
1231
+ expiresAt: new Date(now + TERMINAL_REPORT_GRACE_MS).toISOString()
1232
+ }).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 });
1233
+ await db.update(runLeases).set({ expiresAt: null }).where(eq(runLeases.state, "retired"));
1234
+ return repaired.length;
1235
+ }
1236
+ /** Atomically persist a definitive 410 and consume only the matching retired
1237
+ * tombstone. If the HTTP response is lost, the independent receipt replays it. */
1238
+ async function acknowledgeRetiredReport(leaseTokenHash, input) {
1239
+ return db.transaction(async (tx) => {
1240
+ await lockReportIdTx(tx, input.reportId);
1241
+ if (!(await tx.select().from(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.state, "retired"))).limit(1).for("update"))[0]) return void 0;
1242
+ await tx.insert(runReportReceipts).values(input).onConflictDoNothing({ target: runReportReceipts.reportId });
1243
+ const stored = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
1244
+ 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")));
1245
+ return stored;
1246
+ });
1247
+ }
1248
+ /** Refresh provider-neutral liveness in one machine-scoped UPDATE. Untrusted ids
1249
+ * cannot stamp another machine or a non-running row; fresh stamps stay untouched. */
1250
+ async function refreshRunHeartbeats(machineId, runIds, at, staleBefore) {
1251
+ if (!runIds.length) return 0;
1252
+ return (await db.update(runs).set({
1253
+ heartbeatAt: at,
1254
+ updatedAt: at
1255
+ }).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;
1256
+ }
1257
+ async function activeRunForReportTx(tx, loopId, runId, leaseTokenHash) {
1258
+ 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];
1259
+ if (!lease) return { state: "invalid-lease" };
1260
+ 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];
1261
+ return run ? {
1262
+ state: "active",
1263
+ run
1264
+ } : { state: "run-not-running" };
1265
+ }
1266
+ /** Apply a run's loop-file versions under the loop lock. A file whose current
1267
+ * version is no longer the one the run started from was changed by the owner (or a
1268
+ * newer run) meanwhile; that version wins and the agent's edit becomes a notice. */
1269
+ async function applyLoopFileUpdatesTx(tx, loop, updates, at) {
1270
+ const notices = [];
1271
+ const configured = new Set(loopFileNames(loop));
1272
+ for (const update of updates) {
1273
+ if (!configured.has(update.name)) {
1274
+ notices.push(`${update.name}: no longer configured on this loop; this run's version was discarded`);
1275
+ continue;
1276
+ }
1277
+ const row = (await tx.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loop.id), eq(artifactFiles.path, update.name))).limit(1).for("update"))[0];
1278
+ if ((row && !row.deleted && row.hash ? row.hash : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") !== update.baseHash) {
1279
+ notices.push(`${update.name}: changed outside this run while it was running; that version was kept and this run's edit was discarded`);
1280
+ continue;
1281
+ }
1282
+ await writeConfiguredArtifactTx(tx, {
1283
+ loopId: loop.id,
1284
+ path: update.name,
1285
+ hash: update.hash,
1286
+ size: update.size,
1287
+ binary: false,
1288
+ oversize: false
1289
+ }, at);
1290
+ }
1291
+ return notices;
1292
+ }
1293
+ async function finalizeTerminalRunTx(tx, input) {
1294
+ if (input.receipt) await lockReportIdTx(tx, input.receipt.reportId);
1295
+ const current = (await tx.select().from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1296
+ if (!current) return void 0;
1297
+ if (input.receipt) {
1298
+ if ((await tx.select({ id: terminalReportIncidents.id }).from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, input.receipt.reportId)).limit(1))[0]) return void 0;
1299
+ }
1300
+ const leaseState = input.leaseCheck ? await input.leaseCheck(tx) : void 0;
1301
+ if (input.leaseCheck && !leaseState) return void 0;
1302
+ 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];
1303
+ if (!target) return void 0;
1304
+ const runIndex = await ensureRunIndexTx(tx, target);
1305
+ const at = typeof input.runPatch.ts === "string" ? input.runPatch.ts : nowIso$1();
1306
+ const fileNotices = [...input.runPatch.fileNotices ?? [], ...input.fileUpdates?.length ? await applyLoopFileUpdatesTx(tx, current, input.fileUpdates, at) : []];
1307
+ const run = (await tx.update(runs).set({
1308
+ ...input.runPatch,
1309
+ fileNotices: fileNotices.length ? fileNotices : null,
1310
+ runIndex,
1311
+ updatedAt: at
1312
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, input.runPhase))).returning())[0];
1313
+ if (!run) return void 0;
1314
+ const lifecycle = leaseState === "reconciliation-only" ? {
1315
+ loop: current,
1316
+ failureStreak: 0,
1317
+ autoPaused: false
1318
+ } : await terminalLifecycleTx(tx, current, run, at, input.loopPatch, input.failureAutopauseStreak);
1319
+ if (input.leaseTokenHash) await tx.delete(runLeases).where(eq(runLeases.runId, input.runId));
1320
+ if (input.receipt) await tx.insert(runReportReceipts).values(input.receipt);
1321
+ return {
1322
+ run,
1323
+ ...lifecycle,
1324
+ ...input.reconciliation ? { reportOnly: leaseState === "reconciliation-only" } : {}
1325
+ };
1326
+ }
1327
+ async function reclaimedLeaseStateTx(tx, loopId, runId, leaseTokenHash) {
1328
+ const leaseNow = nowIso$1();
1329
+ 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];
1330
+ return lease?.state === "terminal-grace" || lease?.state === "reconciliation-only" ? lease.state : void 0;
1331
+ }
1332
+ /** Consume a retired tombstone when its reportId is owned by another run.
1333
+ * The run/loop may already be deleted, so only the exact incident receipt and
1334
+ * lease tombstone participate in this transaction. */
1335
+ async function acknowledgeRetiredTerminalIncident(input) {
1336
+ return db.transaction(async (tx) => {
1337
+ await lockReportIdTx(tx, input.reportId);
1338
+ const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
1339
+ const replay = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
1340
+ 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];
1341
+ if (!lease) return void 0;
1342
+ if (replay) {
1343
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
1344
+ return replay;
1345
+ }
1346
+ const receipt = (await tx.insert(terminalReportIncidents).values({
1347
+ id,
1348
+ runId: input.runId,
1349
+ reportId: input.reportId,
1350
+ payloadDigest: input.payloadDigest,
1351
+ disposition: "telemetry-rejected",
1352
+ ackBody: input.ackBody,
1353
+ createdAt: nowIso$1()
1354
+ }).returning())[0];
1355
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
1356
+ return receipt;
1357
+ });
1358
+ }
1359
+ /** Reject one correlatable terminal attempt as a durable terminal fact. Authority,
1360
+ * run/loop lifecycle, incident receipt, and lease consumption share one transaction.
1361
+ * No payload-authored cursor/task/message field is accepted here. */
1362
+ async function rejectTerminalReport(input) {
1363
+ return db.transaction(async (tx) => {
1364
+ await lockReportIdTx(tx, input.reportId);
1365
+ const current = (await tx.select().from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1366
+ if (!current) return { state: "missing-loop" };
1367
+ const normal = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
1368
+ if (normal?.runId === input.runId) return {
1369
+ state: "normal-replay",
1370
+ receipt: normal
1371
+ };
1372
+ const priorIncident = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(input.reportId, input.payloadDigest))).limit(1))[0];
1373
+ if (priorIncident?.runId === input.runId) return {
1374
+ state: "incident-replay",
1375
+ receipt: priorIncident
1376
+ };
1377
+ const now = nowIso$1();
1378
+ const leaseConditions = [
1379
+ eq(runLeases.tokenHash, input.leaseTokenHash),
1380
+ eq(runLeases.runId, input.runId),
1381
+ eq(runLeases.loopId, input.loopId),
1382
+ input.leaseState === "active" ? eq(runLeases.state, "active") : inArray(runLeases.state, ["terminal-grace", "reconciliation-only"])
1383
+ ];
1384
+ if (input.leaseState !== "active") leaseConditions.push(gt(runLeases.expiresAt, now));
1385
+ const lease = (await tx.select().from(runLeases).where(and(...leaseConditions)).limit(1).for("update"))[0];
1386
+ if (!lease) return { state: "invalid-lease" };
1387
+ const receiptInput = {
1388
+ id: terminalIncidentReceiptId(input.reportId, input.payloadDigest),
1389
+ runId: input.runId,
1390
+ reportId: input.reportId,
1391
+ payloadDigest: input.payloadDigest,
1392
+ disposition: input.disposition,
1393
+ ackBody: input.ackBody,
1394
+ createdAt: input.incident.at
1395
+ };
1396
+ if (lease.state !== "active") {
1397
+ const run = (await tx.update(runs).set({
1398
+ reportIncident: input.incident,
1399
+ updatedAt: input.incident.at
1400
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), inArray(runs.phase, [
1401
+ "done",
1402
+ "error",
1403
+ "canceled"
1404
+ ]))).returning())[0];
1405
+ if (!run) return { state: "run-not-terminalizable" };
1406
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
1407
+ return {
1408
+ state: "telemetry-rejected",
1409
+ run,
1410
+ loop: current,
1411
+ receipt: priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0],
1412
+ failureStreak: 0,
1413
+ autoPaused: false
1414
+ };
1415
+ }
1416
+ 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];
1417
+ if (!target) return { state: "run-not-terminalizable" };
1418
+ const runIndex = await ensureRunIndexTx(tx, target);
1419
+ const run = (await tx.update(runs).set({
1420
+ phase: "error",
1421
+ runIndex,
1422
+ error: input.incident.reason,
1423
+ reportIncident: input.incident,
1424
+ ts: input.incident.at,
1425
+ updatedAt: input.incident.at
1426
+ }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"))).returning())[0];
1427
+ if (!run) return { state: "run-not-terminalizable" };
1428
+ const lifecycle = await terminalLifecycleTx(tx, current, run, input.incident.at, {}, input.failureAutopauseStreak ?? 0);
1429
+ await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
1430
+ const receipt = priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0];
1431
+ return {
1432
+ state: "run-error",
1433
+ run,
1434
+ ...lifecycle,
1435
+ receipt
1436
+ };
1437
+ });
1438
+ }
1439
+ /** Atomically CAS running→terminal and apply the report's loop patch.
1440
+ * The loop patch is unreachable when cancel/reclaim/another report won. */
1441
+ async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt, fileUpdates) {
1442
+ return db.transaction(async (tx) => {
1443
+ return finalizeTerminalRunTx(tx, {
1444
+ loopId,
1445
+ runId,
1446
+ runPhase: "running",
1447
+ fileUpdates,
1448
+ runPatch,
1449
+ loopPatch,
1450
+ leaseTokenHash,
1451
+ leaseCheck: leaseTokenHash ? async (lockedTx) => {
1452
+ return (await activeRunForReportTx(lockedTx, loopId, runId, leaseTokenHash)).state === "active" ? "active" : void 0;
1453
+ } : void 0,
1454
+ failureAutopauseStreak,
1455
+ receipt
1456
+ });
1457
+ });
1458
+ }
1459
+ /** Sweep reclaim under the same loop lock as claim/report/cancel. Running
1460
+ * reclaims terminalize their lease and write the provisional continuous cadence
1461
+ * fact atomically; pending reclaims have no lease. */
1462
+ async function reclaimRun(runId, expected, reason, at = nowIso$1(), graceMs = 1440 * 60 * 1e3, failureAutopauseStreak = 0) {
1463
+ return db.transaction(async (tx) => {
1464
+ const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
1465
+ if (!observed) return void 0;
1466
+ const loop = (await tx.select().from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
1467
+ if (!loop) return void 0;
1468
+ const currentRun = (await tx.select().from(runs).where(eq(runs.id, runId)).limit(1).for("update"))[0];
1469
+ if (currentRun?.phase !== expected) return void 0;
1470
+ if (expected === "running") await tx.update(runLeases).set({
1471
+ state: "terminal-grace",
1472
+ expiresAt: new Date(Date.parse(at) + graceMs).toISOString()
1473
+ }).where(and(eq(runLeases.runId, runId), eq(runLeases.state, "active")));
1474
+ const runIndex = await ensureRunIndexTx(tx, currentRun);
1475
+ const reclaimed = (await tx.update(runs).set({
1476
+ phase: "error",
1477
+ runIndex,
1478
+ error: reason,
1479
+ ts: at,
1480
+ updatedAt: at
1481
+ }).where(and(eq(runs.id, runId), eq(runs.phase, expected))).returning())[0];
1482
+ if (!reclaimed) return void 0;
1483
+ return {
1484
+ run: reclaimed,
1485
+ ...await terminalLifecycleTx(tx, loop, reclaimed, at, {}, failureAutopauseStreak)
1486
+ };
1487
+ });
1488
+ }
1489
+ /** A daemon's completed recovery snapshot is authoritative for execution
1490
+ * exclusion. Reclaimed runs absent from that snapshot retain late-report authority
1491
+ * until their original expiry, but stop blocking queued work immediately. */
1492
+ async function releaseAbsentReconciliations(machineId, recoverableRunIds, at = nowIso$1()) {
1493
+ const conditions = [
1494
+ eq(runLeases.machineId, machineId),
1495
+ eq(runLeases.state, "terminal-grace"),
1496
+ gt(runLeases.expiresAt, at)
1497
+ ];
1498
+ const ids = [...new Set(recoverableRunIds)];
1499
+ if (ids.length) conditions.push(notInArray(runLeases.runId, ids));
1500
+ return (await db.update(runLeases).set({ state: "reconciliation-only" }).where(and(...conditions)).returning({ runId: runLeases.runId })).map((row) => row.runId);
1501
+ }
1502
+ /** Consume one terminal reconciliation lease and reconcile its reclaimed error
1503
+ * exactly once. Keeping the lease check/delete beside the run + loop writes closes the
1504
+ * error→error double-report hole where a phase CAS alone cannot distinguish the
1505
+ * first real failure from a second concurrent one. */
1506
+ async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt, fileUpdates) {
1507
+ return db.transaction(async (tx) => {
1508
+ return finalizeTerminalRunTx(tx, {
1509
+ loopId,
1510
+ runId,
1511
+ runPhase: "error",
1512
+ fileUpdates,
1513
+ runPatch,
1514
+ loopPatch,
1515
+ leaseTokenHash,
1516
+ leaseCheck: (lockedTx) => reclaimedLeaseStateTx(lockedTx, loopId, runId, leaseTokenHash),
1517
+ failureAutopauseStreak,
1518
+ receipt,
1519
+ reconciliation: true
1520
+ });
1521
+ });
1522
+ }
1523
+ var TERMINAL_REPORT_GRACE_MS = 600 * 1e3;
1524
+ /**
1525
+ * The durable run-queue write seam. It serializes on the loop row and owns every
1526
+ * queue invariant callers would otherwise have to reproduce:
1527
+ * - at most one ordinary pending row per loop (also backed by a partial unique index),
1528
+ * - a running run may retain one coalesced follow-up,
1529
+ * - owner authority promotes an existing system row and never downgrades,
1530
+ * - paused loops accept owner Run-once work while recurring system work stays stopped.
1531
+ */
1532
+ async function enqueueRun(loopId, request) {
1533
+ return db.transaction(async (tx) => {
1534
+ const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1535
+ if (!loop) return {
1536
+ state: "rejected",
1537
+ reason: "loop not found"
1538
+ };
1539
+ return enqueueRunTx(tx, loop, request, nowIso$1());
1540
+ });
1541
+ }
1542
+ /** Materialize every due schedule fact. Each loop is locked and rechecked in its
1543
+ * own transaction; cadence and one-shot facts may coalesce into one system exec
1544
+ * and are consumed together. */
1545
+ async function advanceDueSchedules(at = nowIso$1(), filter = {}) {
1546
+ const due = or(and(isNotNull(loops.nextCadenceAt), sql`${loops.nextCadenceAt} <= ${at}`), and(isNotNull(loops.nextRunAt), sql`${loops.nextRunAt} <= ${at}`));
1547
+ 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));
1548
+ const advanced = [];
1549
+ for (const { id } of candidates) {
1550
+ const result = await db.transaction(async (tx) => {
1551
+ const loop = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1552
+ if (!loop?.enabled || loop.deleteRequestedAt) return void 0;
1553
+ const cadenceDue = !!loop.nextCadenceAt && Date.parse(loop.nextCadenceAt) <= Date.parse(at);
1554
+ const oneShotDue = !!loop.nextRunAt && Date.parse(loop.nextRunAt) <= Date.parse(at);
1555
+ if (!cadenceDue && !oneShotDue) return void 0;
1556
+ 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;
1557
+ const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
1558
+ const skipRecurring = cadenceDue && !!open && (loop.scheduleMode === "continuous" || loop.cronOverlap === "skip");
1559
+ const queued = oneShotDue || !skipRecurring ? await enqueueRunTx(tx, loop, { requestedBy: "system" }, at) : void 0;
1560
+ if (queued && !("run" in queued)) return void 0;
1561
+ const nextCadenceAt = cadenceDue ? loop.scheduleMode === "cron" ? nextCronAt(loop.cron, loop.timezone, at) : null : loop.nextCadenceAt;
1562
+ const updatedLoop = (await tx.update(loops).set({
1563
+ nextCadenceAt,
1564
+ ...oneShotDue ? { nextRunAt: null } : {},
1565
+ updatedAt: at
1566
+ }).where(eq(loops.id, id)).returning())[0];
1567
+ return queued ? {
1568
+ loop: updatedLoop,
1569
+ run: queued.run,
1570
+ state: queued.state
1571
+ } : {
1572
+ loop: updatedLoop,
1573
+ state: "skipped"
1574
+ };
1575
+ });
1576
+ if (result) advanced.push(result);
1577
+ }
1578
+ return advanced;
1579
+ }
1580
+ /** Conservative boot initialization for pre-fact cron rows. No history lookup and
1581
+ * no missed-fire catch-up: only install the next future occurrence. */
1582
+ async function initializeCronCadence(at = nowIso$1()) {
1583
+ 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)));
1584
+ const initialized = [];
1585
+ for (const { id } of candidates) {
1586
+ const loop = await db.transaction(async (tx) => {
1587
+ const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
1588
+ if (!current?.enabled || current.scheduleMode !== "cron" || current.nextCadenceAt) return void 0;
1589
+ return (await tx.update(loops).set({
1590
+ nextCadenceAt: nextCronAt(current.cron, current.timezone, at),
1591
+ updatedAt: at
1592
+ }).where(eq(loops.id, id)).returning())[0];
1593
+ });
1594
+ if (loop) initialized.push(loop);
1595
+ }
1596
+ return initialized;
1597
+ }
1598
+ /** Claim one ready loop and mint its lease atomically. Repeated polls may add
1599
+ * unlimited cross-loop concurrency; the loop index remains the final same-loop
1600
+ * serialization invariant. Reported local runs exclude their loops even if the
1601
+ * server has already terminalized an older process. */
1602
+ async function claimReadyRunForMachine(machineId, at = nowIso$1(), excludeRunIds = []) {
1603
+ return db.transaction(async (tx) => {
1604
+ if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, machineId)).for("update"))[0]) return void 0;
1605
+ const excludedLoops = /* @__PURE__ */ new Set();
1606
+ if (excludeRunIds.length) {
1607
+ const rows = await tx.select({ loopId: runs.loopId }).from(runs).where(and(eq(runs.machineId, machineId), inArray(runs.id, [...new Set(excludeRunIds)])));
1608
+ for (const row of rows) excludedLoops.add(row.loopId);
1609
+ }
1610
+ const next = (await tx.select({
1611
+ run: runs,
1612
+ loop: loops
1613
+ }).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];
1614
+ if (!next) return void 0;
1615
+ let loop = (await tx.select().from(loops).where(eq(loops.id, next.loop.id)).for("update"))[0];
1616
+ const candidate = (await tx.select().from(runs).where(eq(runs.id, next.run.id)).limit(1).for("update"))[0];
1617
+ if (!loop || !candidate || candidate.phase !== "pending" || candidate.cancelRequestedAt || loop.deleteRequestedAt || !loop.enabled && candidate.requestedBy !== "owner") return void 0;
1618
+ const occupied = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "running"))).limit(1))[0];
1619
+ 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];
1620
+ if (occupied || authority) return void 0;
1621
+ const runIndex = await ensureRunIndexTx(tx, candidate);
1622
+ const run = (await tx.update(runs).set({
1623
+ phase: "running",
1624
+ runIndex,
1625
+ agent: loop.agent,
1626
+ model: loop.model,
1627
+ reasoningEffort: loop.reasoningEffort,
1628
+ heartbeatAt: null,
1629
+ ts: at,
1630
+ updatedAt: at
1631
+ }).where(and(eq(runs.id, candidate.id), eq(runs.phase, "pending"), isNull(runs.cancelRequestedAt))).returning())[0];
1632
+ if (!run) return void 0;
1633
+ if (loop.scheduleMode === "continuous" && loop.nextCadenceAt != null) loop = (await tx.update(loops).set({
1634
+ nextCadenceAt: null,
1635
+ updatedAt: at
1636
+ }).where(eq(loops.id, loop.id)).returning())[0];
1637
+ const runToken = `rk_${randomBytes(16).toString("hex")}`;
1638
+ const tokenHash = createHash("sha256").update(runToken).digest("hex");
1639
+ await tx.insert(runLeases).values({
1640
+ tokenHash,
1641
+ runId: run.id,
1642
+ loopId: loop.id,
1643
+ machineId,
1644
+ createdAt: at
1645
+ });
1646
+ return {
1647
+ run,
1648
+ loop,
1649
+ runToken
1650
+ };
1651
+ });
1652
+ }
1653
+ async function listRuns(loopId, limit = 30) {
1654
+ return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(limit)).reverse();
1655
+ }
1656
+ async function reconciliationStatesForRuns(loopId, runIds, at = nowIso$1()) {
1657
+ if (!runIds.length) return /* @__PURE__ */ new Map();
1658
+ const rows = await db.select({
1659
+ runId: runLeases.runId,
1660
+ state: runLeases.state
1661
+ }).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)));
1662
+ return new Map(rows.map((row) => [row.runId, row.state === "terminal-grace" ? "blocking" : "report-only"]));
1663
+ }
1664
+ /** One older page: runs strictly before `beforeTs`, newest-first then capped,
1665
+ * returned chronological (oldest-first) to match listRuns. Cursor-based (by ts,
1666
+ * not offset) so it's stable while new runs land at the head. */
1667
+ async function listRunsBefore(loopId, beforeTs, limit = 16) {
1668
+ return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), lt(runs.ts, beforeTs))).orderBy(desc(runs.ts)).limit(limit)).reverse();
1669
+ }
1670
+ async function lastRun(loopId) {
1671
+ return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(1))[0];
1672
+ }
1673
+ async function countRuns(loopId) {
1674
+ const r = (await db.select({ n: sql`count(*)` }).from(runs).where(eq(runs.loopId, loopId)))[0];
1675
+ return Number(r?.n ?? 0);
1676
+ }
1677
+ async function recentLoopUsage(loopId, since) {
1678
+ const row = (await db.select({
1679
+ runCount: sql`count(*)`,
1680
+ tokenCount: sql`coalesce(sum(
1681
+ coalesce((${runs.usage} ->> 'inputTokens')::bigint, 0) +
1682
+ coalesce((${runs.usage} ->> 'outputTokens')::bigint, 0)
1683
+ ), 0)`
1684
+ }).from(runs).where(and(eq(runs.loopId, loopId), sql`${runs.ts} >= ${since}`)))[0];
1685
+ return {
1686
+ runCount: Number(row?.runCount ?? 0),
1687
+ tokenCount: Number(row?.tokenCount ?? 0)
1688
+ };
1689
+ }
1690
+ async function openRuns() {
1691
+ return db.select().from(runs).where(inArray(runs.phase, ["pending", "running"]));
1692
+ }
1693
+ async function hasRunningRun(loopId) {
1694
+ return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
1695
+ }
1696
+ async function hasPendingRun(loopId) {
1697
+ return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "pending"))).limit(1))[0];
1698
+ }
1699
+ /** Expire one offline system row only if the sweep snapshot is still exact and
1700
+ * its immutable lifetime remains over the cap under the loop lock. */
1701
+ async function expirePendingRun(runId, expected, at, maxLifetimeMs, message) {
1702
+ return db.transaction(async (tx) => {
1703
+ const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
1704
+ if (!observed) return false;
1705
+ const loop = (await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
1706
+ if (!loop) return false;
1707
+ 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];
1708
+ if (!current || Date.parse(at) - Date.parse(current.createdAt) <= maxLifetimeMs) return false;
1709
+ if ((await tx.select({ online: machines.online }).from(machines).where(eq(machines.id, current.machineId)).limit(1))[0]?.online) return false;
1710
+ const runIndex = await ensureRunIndexTx(tx, current);
1711
+ return (await tx.update(runs).set({
1712
+ phase: "canceled",
1713
+ runIndex,
1714
+ message,
1715
+ ts: at,
1716
+ updatedAt: at
1717
+ }).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;
1718
+ });
1719
+ }
1720
+ async function listMachines() {
1721
+ return db.select().from(machines).orderBy(asc(machines.name), asc(machines.id));
1722
+ }
1723
+ async function getMachine(id) {
1724
+ return (await db.select().from(machines).where(eq(machines.id, id)))[0];
1725
+ }
1726
+ async function createMachine(input) {
1727
+ return (await db.insert(machines).values({
1728
+ ...input,
1729
+ createdAt: nowIso$1()
1730
+ }).returning())[0];
1731
+ }
1732
+ async function updateMachine(id, patch) {
1733
+ await db.update(machines).set(patch).where(eq(machines.id, id));
1734
+ return getMachine(id);
1735
+ }
1736
+ /** Atomically retire all loop authority, remove server-owned machine data, revoke
1737
+ * enrollment, and delete the machine. */
1738
+ async function forceDeleteMachine(id) {
1739
+ return db.transaction(async (tx) => {
1740
+ if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, id)).for("update"))[0]) return { state: "not-found" };
1741
+ const ownedLoops = await tx.select().from(loops).where(eq(loops.machineId, id)).for("update");
1742
+ for (const loop of ownedLoops) if (!await forceDeleteLoopTx(tx, loop.id)) throw new Error(`failed to delete loop ${loop.id}`);
1743
+ await tx.delete(connectKeys).where(eq(connectKeys.machineId, id));
1744
+ await tx.delete(machines).where(eq(machines.id, id));
1745
+ return {
1746
+ state: "deleted",
1747
+ loopIds: ownedLoops.map((loop) => loop.id)
1748
+ };
1749
+ });
1750
+ }
1751
+ async function setMachineOnline(id, online) {
1752
+ await db.update(machines).set({
1753
+ online,
1754
+ lastSeen: nowIso$1()
1755
+ }).where(eq(machines.id, id));
1756
+ }
1757
+ /** Verified byte lengths recorded at blob ingress, used to budget reads before
1758
+ * touching the byte store. Missing metadata is intentionally not estimated. */
1759
+ async function blobSizes(hashes) {
1760
+ if (!hashes.length) return /* @__PURE__ */ new Map();
1761
+ const rows = await db.select({
1762
+ hash: blobs.hash,
1763
+ size: blobs.size
1764
+ }).from(blobs).where(inArray(blobs.hash, [...new Set(hashes)]));
1765
+ return new Map(rows.map((row) => [row.hash, row.size]));
1766
+ }
1767
+ async function recordBlob(hash, size) {
1768
+ await db.insert(blobs).values({
1769
+ hash,
1770
+ size,
1771
+ createdAt: nowIso$1()
1772
+ }).onConflictDoNothing();
1773
+ }
1774
+ async function writeConfiguredArtifactTx(tx, input, ts) {
1775
+ await tx.insert(artifactFiles).values({
1776
+ id: randomUUID(),
1777
+ loopId: input.loopId,
1778
+ path: input.path,
1779
+ hash: input.hash,
1780
+ size: input.size,
1781
+ binary: input.binary,
1782
+ oversize: input.oversize,
1783
+ deleted: false,
1784
+ updatedAt: ts
1785
+ }).onConflictDoUpdate({
1786
+ target: [artifactFiles.loopId, artifactFiles.path],
1787
+ set: {
1788
+ hash: input.hash,
1789
+ size: input.size,
1790
+ binary: input.binary,
1791
+ oversize: input.oversize,
1792
+ deleted: false,
1793
+ updatedAt: ts
1794
+ }
1795
+ });
1796
+ }
1797
+ /** Owner replacement of one loop file. Serialized with run finalization by the
1798
+ * loop lock, so a run that started from the previous version cannot overwrite it. */
1799
+ async function writeLoopFile(loopId, name, hash, size) {
1800
+ return db.transaction(async (tx) => {
1801
+ const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1802
+ if (!loop || !loopFileNames(loop).includes(name)) return false;
1803
+ await writeConfiguredArtifactTx(tx, {
1804
+ loopId,
1805
+ path: name,
1806
+ hash,
1807
+ size,
1808
+ binary: false,
1809
+ oversize: false
1810
+ }, nowIso$1());
1811
+ return true;
1812
+ });
1813
+ }
1814
+ /** File notices of the immediately previous terminal run, for the next prompt.
1815
+ * Canceled runs are terminal too: their notices (edits discarded) are repeated,
1816
+ * and an older run's notice must not masquerade as the previous run's. */
1817
+ async function previousRunFileNotices(loopId, beforeRunIndex) {
1818
+ return (await db.select({ fileNotices: runs.fileNotices }).from(runs).where(and(eq(runs.loopId, loopId), isNotNull(runs.runIndex), lt(runs.runIndex, beforeRunIndex), inArray(runs.phase, [
1819
+ "done",
1820
+ "error",
1821
+ "canceled"
1822
+ ]))).orderBy(desc(runs.runIndex)).limit(1))[0]?.fileNotices ?? [];
1823
+ }
1824
+ async function listArtifacts(loopId) {
1825
+ return db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false))).orderBy(artifactFiles.path);
1826
+ }
1827
+ async function getArtifactFile(loopId, path) {
1828
+ return (await db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.path, path))))[0];
1829
+ }
1830
+ async function buildLoopManifest(loopId) {
1831
+ const manifest = {};
1832
+ for (const f of await listArtifacts(loopId)) manifest[f.path] = {
1833
+ hash: f.hash,
1834
+ size: f.size,
1835
+ binary: f.binary,
1836
+ oversize: f.oversize
1837
+ };
1838
+ return manifest;
1839
+ }
1840
+ /** Write/overwrite a run's snapshot (name → file metadata). Idempotent on runId
1841
+ * so a re-report of the same run just refreshes the captured loop-file state. */
1842
+ async function putRunSnapshot(runId, loopId, manifest) {
1843
+ await db.insert(runSnapshots).values({
1844
+ runId,
1845
+ loopId,
1846
+ manifest,
1847
+ createdAt: nowIso$1()
1848
+ }).onConflictDoUpdate({
1849
+ target: runSnapshots.runId,
1850
+ set: {
1851
+ loopId,
1852
+ manifest,
1853
+ createdAt: nowIso$1()
1854
+ }
1855
+ });
1856
+ }
1857
+ async function getRunSnapshot(runId) {
1858
+ return (await db.select().from(runSnapshots).where(eq(runSnapshots.runId, runId)))[0];
1859
+ }
1860
+ /** The most recent indexed snapshot before one run. History order, not a
1861
+ * timestamp tie, is the diff baseline. */
1862
+ async function prevRunSnapshot(loopId, beforeRunIndex) {
1863
+ 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;
1864
+ }
1865
+ /**
1866
+ * Prune a loop's run snapshots down to the `keep` most recent (by createdAt),
1867
+ * deleting the rest. Returns the number deleted. This is what makes an old
1868
+ * snapshot's now-unreferenced blobs collectable by the blob GC. `keep <= 0`
1869
+ * means "keep none" (still bounded). Safe to call repeatedly (idempotent once
1870
+ * at/under the window).
1871
+ */
1872
+ async function pruneRunSnapshots(loopId, keep) {
1873
+ 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) : [];
1874
+ const pred = survivors.length ? and(eq(runSnapshots.loopId, loopId), notInArray(runSnapshots.runId, survivors)) : eq(runSnapshots.loopId, loopId);
1875
+ return (await db.delete(runSnapshots).where(pred).returning({ id: runSnapshots.runId })).length;
1876
+ }
1877
+ async function loopIdsWithSnapshots() {
1878
+ return (await db.selectDistinct({ loopId: runSnapshots.loopId }).from(runSnapshots)).map((r) => r.loopId);
1879
+ }
1880
+ /**
1881
+ * The full set of blob hashes still referenced by a LIVE row — the GC's keep
1882
+ * set. A hash is live if ANY artifact_files row points at it (deleted tombstones
1883
+ * carry hash=null, so they don't pin a blob) OR ANY retained run_snapshot's
1884
+ * manifest references it. Computed in one pass so the GC never deletes a blob a
1885
+ * snapshot still needs for its diff.
1886
+ */
1887
+ async function liveBlobRefs() {
1888
+ const refs = /* @__PURE__ */ new Set();
1889
+ for (const r of await db.selectDistinct({ hash: artifactFiles.hash }).from(artifactFiles).where(isNotNull(artifactFiles.hash))) if (r.hash) refs.add(r.hash);
1890
+ for (const hash of await collectSnapshotBlobRefs()) refs.add(hash);
1891
+ return refs;
1892
+ }
1893
+ /** Blob hashes whose metadata row predates `cutoffIso` (GC candidates — the grace
1894
+ * window excludes freshly-written blobs a concurrent sync may be referencing). */
1895
+ async function blobHashesOlderThan(cutoffIso) {
1896
+ return (await db.select({ hash: blobs.hash }).from(blobs).where(lt(blobs.createdAt, cutoffIso))).map((r) => r.hash);
1897
+ }
1898
+ /** Delete a blob's metadata row (the bytes are reclaimed separately via the
1899
+ * BlobStore). Idempotent. */
1900
+ async function deleteBlob(hash) {
1901
+ await db.delete(blobs).where(eq(blobs.hash, hash));
1902
+ }
1903
+ /** Indexed point check: does any LIVE artifact_files row still point at this hash?
1904
+ * The GC's cheap, always-fresh per-candidate guard (the common re-reference path) —
1905
+ * uses the artifact_files_hash index, so it stays O(1) even as candidates pile up. */
1906
+ async function artifactFileReferencesHash(hash) {
1907
+ return !!(await db.select({ id: artifactFiles.id }).from(artifactFiles).where(eq(artifactFiles.hash, hash)))[0];
1908
+ }
1909
+ /** Every blob hash referenced by ANY retained run_snapshot's manifest — the full
1910
+ * snapshot scan deserialized ONCE into a Set so the GC can answer per-candidate
1911
+ * snapshot membership in O(1) instead of re-scanning the whole table per garbage
1912
+ * hash. The GC rebuilds this only when the snapshot row count changes (a report()
1913
+ * raced the pass), so a snapshot that comes to reference a hash mid-pass is still
1914
+ * caught — closing the GC-check-time gap where a snapshot references a hash no live
1915
+ * file row does — without paying O(garbage × snapshots). */
1916
+ async function collectSnapshotBlobRefs() {
1917
+ const refs = /* @__PURE__ */ new Set();
1918
+ 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);
1919
+ return refs;
1920
+ }
1921
+ async function snapshotBlobRefs() {
1922
+ return collectSnapshotBlobRefs();
1923
+ }
1924
+ /** Count of retained run_snapshot rows — the GC's cheap change-detector for deciding
1925
+ * whether to rebuild its precomputed snapshotBlobRefs() set mid-pass. */
1926
+ async function countRunSnapshots() {
1927
+ const r = (await db.select({ n: sql`count(*)` }).from(runSnapshots))[0];
1928
+ return Number(r?.n ?? 0);
1929
+ }
1930
+ /**
1931
+ * Machine + run credential helpers. Device tokens (`dk_…`) identify a machine
1932
+ * (its id is derived from the token: `m-sha256(token)[:16]`). A run
1933
+ * LEASE (`rk_…`) is minted per delivery, bound to one run, and carries the
1934
+ * run's least-privilege caps — the CLI dispatch authorizes the `pievo` shim
1935
+ * against it. Its lifecycle is a small state machine (`active` →
1936
+ * `terminal-grace` → `reconciliation-only`/`retired`), not a mint→revoke pair;
1937
+ * see `RunLease` below.
1938
+ *
1939
+ * Leases and connect-key bindings are DURABLE (run_leases / connect_keys
1940
+ * tables): they must survive a deploy, or every restart 401s the in-flight
1941
+ * runs' callbacks/finalize and prevents a freshly connected machine from
1942
+ * enrolling. The 15-minute `new` idempotency window stays in-process;
1943
+ * losing it only degrades retry deduplication, never persisted data.
1944
+ */
1945
+ var tokens_exports = /* @__PURE__ */ __exportAll({
1946
+ CONNECT_KEY_TTL_MS: () => CONNECT_KEY_TTL_MS,
1947
+ NEW_IDEMPOTENCY_TTL_MS: () => NEW_IDEMPOTENCY_TTL_MS,
1948
+ TERMINAL_GRACE_MS: () => TERMINAL_GRACE_MS,
1949
+ connectKeyValid: () => connectKeyValid,
1950
+ isDeviceTokenShape: () => isDeviceTokenShape,
1951
+ isRunTokenShape: () => isRunTokenShape,
1952
+ machineIdFromToken: () => machineIdFromToken,
1953
+ mintDeviceToken: () => mintDeviceToken,
1954
+ pruneExpiredLeases: () => pruneExpiredLeases,
1955
+ readNewIdempotency: () => readNewIdempotency,
1956
+ recordNewIdempotency: () => recordNewIdempotency,
1957
+ rememberConnectKey: () => rememberConnectKey,
1958
+ resolveLease: () => resolveLease,
1959
+ retireLease: () => retireLease,
1960
+ sha256: () => sha256
1961
+ });
1962
+ function sha256(s) {
1963
+ return createHash("sha256").update(s).digest("hex");
1964
+ }
1965
+ function mintDeviceToken() {
1966
+ return `dk_${randomBytes(15).toString("hex")}`;
1967
+ }
1968
+ function machineIdFromToken(token) {
1969
+ return `m-${sha256(token).slice(0, 16)}`;
1970
+ }
1971
+ /**
1972
+ * Whether a string has the one current device-token wire shape. This malformed-input
1973
+ * filter runs before enrollment lookup; the connect-key gate remains the auth boundary.
1974
+ */
1975
+ var DEVICE_TOKEN_RE = /^dk_[0-9a-f]{30}$/;
1976
+ function isDeviceTokenShape(token) {
1977
+ return DEVICE_TOKEN_RE.test(token);
1978
+ }
1979
+ /** Keep bindings long enough for a leisurely paste, then drop (bounded table). */
1980
+ var CONNECT_KEY_TTL_MS = 1440 * 60 * 1e3;
1981
+ function connectKeyFresh(mintedAt, now) {
1982
+ return now - Date.parse(mintedAt) <= CONNECT_KEY_TTL_MS;
1983
+ }
1984
+ /** Record a freshly minted connect key and prune expired rows. */
1985
+ async function rememberConnectKey(connectKey) {
1986
+ const now = /* @__PURE__ */ new Date();
1987
+ await db.delete(connectKeys).where(lt(connectKeys.mintedAt, (/* @__PURE__ */ new Date(now.getTime() - CONNECT_KEY_TTL_MS)).toISOString()));
1988
+ const row = {
1989
+ machineId: machineIdFromToken(connectKey),
1990
+ mintedAt: now.toISOString()
1991
+ };
1992
+ await db.insert(connectKeys).values(row).onConflictDoUpdate({
1993
+ target: connectKeys.machineId,
1994
+ set: row
1995
+ });
1996
+ }
1997
+ async function connectKeyValid(connectKey, now = Date.now()) {
1998
+ if (!connectKey) return false;
1999
+ const row = (await db.select().from(connectKeys).where(eq(connectKeys.machineId, machineIdFromToken(connectKey))))[0];
2000
+ return !!row && connectKeyFresh(row.mintedAt, now);
2001
+ }
2002
+ /** How long a terminal-grace lease stays alive to accept one late wake-report.
2003
+ * A laptop can sleep overnight or across a weekend before the daemon resumes. */
2004
+ var TERMINAL_GRACE_MS = 1440 * 60 * 1e3;
2005
+ /** Leases live in the `run_leases` table, keyed by sha256(full `rk_…` wire
2006
+ * token), so a deploy is invisible to an in-flight run and a DB leak never hands
2007
+ * out live credentials. In rows, `expiresAt` null encodes `Infinity`. */
2008
+ function leaseFromRow(row) {
2009
+ return {
2010
+ runId: row.runId,
2011
+ loopId: row.loopId,
2012
+ machineId: row.machineId,
2013
+ state: row.state,
2014
+ expiresAt: row.expiresAt == null ? Number.POSITIVE_INFINITY : Date.parse(row.expiresAt)
2015
+ };
2016
+ }
2017
+ /** Mint a fresh run lease and return its wire token (`rk_…`, so the unified CLI
2018
+ * dispatch can tell a run credential from a device `dk_…` in O(1) before any
2019
+ * lookup). Starts `active` with no expiry. */
2020
+ var RUN_TOKEN_RE = /^rk_[0-9a-f]{32}$/;
2021
+ function isRunTokenShape(token) {
2022
+ return RUN_TOKEN_RE.test(token);
2023
+ }
2024
+ /** Resolve a run lease. An elapsed reconciliation window is atomically reduced
2025
+ * to durable, non-authorizing retired evidence rather than deleted. */
2026
+ async function resolveLease(token, now = Date.now()) {
2027
+ if (!isRunTokenShape(token)) return void 0;
2028
+ const tokenHash = sha256(token);
2029
+ let row = (await db.select().from(runLeases).where(eq(runLeases.tokenHash, tokenHash)))[0];
2030
+ if (!row) return void 0;
2031
+ if ((row.state === "terminal-grace" || row.state === "reconciliation-only") && row.expiresAt != null && now > Date.parse(row.expiresAt)) {
2032
+ row = (await db.update(runLeases).set({
2033
+ state: "retired",
2034
+ expiresAt: null
2035
+ }).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];
2036
+ if (!row) return void 0;
2037
+ }
2038
+ return leaseFromRow(row);
2039
+ }
2040
+ /** Retire a lease outside a terminal write transaction (canceled/losing report
2041
+ * cleanup and expiry paths). Normal finalize and reclaimed-run
2042
+ * reconcile consume the row inside their store transaction so run+loop+lease are
2043
+ * one single-shot commit. */
2044
+ async function retireLease(token) {
2045
+ await db.delete(runLeases).where(eq(runLeases.tokenHash, sha256(token)));
2046
+ }
2047
+ /** State-aware lease maintenance. Expired reconciliation authority becomes a
2048
+ * durable retired tombstone. Active and already-retired rows are never age-pruned. */
2049
+ async function pruneExpiredLeases(now = Date.now()) {
2050
+ await db.update(runLeases).set({
2051
+ state: "retired",
2052
+ expiresAt: null
2053
+ }).where(and(inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), isNotNull(runLeases.expiresAt), lt(runLeases.expiresAt, new Date(now).toISOString())));
2054
+ }
2055
+ var newIdempotency = /* @__PURE__ */ new Map();
2056
+ /** Long enough to swallow a timed-out retry without collapsing later creates. */
2057
+ var NEW_IDEMPOTENCY_TTL_MS = 900 * 1e3;
2058
+ function pruneNewIdempotency(now) {
2059
+ for (const [key, rec] of newIdempotency) if (now - rec.at > 9e5) newIdempotency.delete(key);
2060
+ }
2061
+ function recordNewIdempotency(key, machineId, loopId, now = Date.now()) {
2062
+ pruneNewIdempotency(now);
2063
+ newIdempotency.set(key, {
2064
+ loopId,
2065
+ machineId,
2066
+ at: now
2067
+ });
2068
+ }
2069
+ /** The loop a still-live key already created for THIS machine, or undefined (a miss,
2070
+ * an expired key — dropped here — or a cross-machine record). NON-evicting on a hit:
2071
+ * a genuine retry may arrive more than once within the window. */
2072
+ function readNewIdempotency(key, machineId, now = Date.now()) {
2073
+ const rec = newIdempotency.get(key);
2074
+ if (!rec) return void 0;
2075
+ if (now - rec.at > 9e5) {
2076
+ newIdempotency.delete(key);
2077
+ return;
2078
+ }
2079
+ if (rec.machineId !== machineId) return void 0;
2080
+ return rec.loopId;
2081
+ }
2082
+ /**
2083
+ * Derive a machine's presence from its stored `online` flag + `lastSeen` stamp.
2084
+ * `online` requires BOTH the flag and a fresh stamp (the stored flag lags the
2085
+ * sweep by up to one interval); everything else keys off how stale `lastSeen` is.
2086
+ */
2087
+ function machinePresence(online, lastSeen, now = Date.now()) {
2088
+ const seenAt = lastSeen ? Date.parse(lastSeen) : NaN;
2089
+ if (!Number.isFinite(seenAt)) return "offline";
2090
+ const age = now - seenAt;
2091
+ if (!!online && age < 3e4) return "online";
2092
+ if (age < 216e5) return "asleep";
2093
+ return "offline";
2094
+ }
2095
+ /**
2096
+ * Tiny semver comparison — just enough to decide "is the daemon older than the
2097
+ * latest published version?" for the web's update hint. No dependency; we only
2098
+ * need numeric-core ordering (major.minor.patch), and a pre-release is treated
2099
+ * as older than its release (a "0.9.0-rc.1" daemon is still behind "0.9.0").
2100
+ */
2101
+ /** Strict SemVer validation for security/protocol gates. Unlike the update-hint
2102
+ * comparison below, this accepts no whitespace, `v` prefix, partial core, or
2103
+ * trailing garbage. */
2104
+ function isValidSemver(value) {
2105
+ const plus = value.split("+");
2106
+ if (plus.length > 2) return false;
2107
+ const [versionAndPre, build] = plus;
2108
+ if (build !== void 0 && !validIdentifiers(build, false)) return false;
2109
+ const dash = versionAndPre.indexOf("-");
2110
+ const numeric = dash < 0 ? versionAndPre : versionAndPre.slice(0, dash);
2111
+ const pre = dash < 0 ? void 0 : versionAndPre.slice(dash + 1);
2112
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(numeric)) return false;
2113
+ return pre === void 0 || validIdentifiers(pre, true);
2114
+ }
2115
+ function validIdentifiers(value, rejectNumericLeadingZero) {
2116
+ if (!value) return false;
2117
+ return value.split(".").every((identifier) => {
2118
+ if (!/^[0-9A-Za-z-]+$/.test(identifier)) return false;
2119
+ return !rejectNumericLeadingZero || !/^\d+$/.test(identifier) || identifier === "0" || !identifier.startsWith("0");
2120
+ });
2121
+ }
2122
+ function core(v) {
2123
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
2124
+ if (!m) return null;
2125
+ return [
2126
+ Number(m[1]),
2127
+ Number(m[2]),
2128
+ Number(m[3])
2129
+ ];
2130
+ }
2131
+ function isPre(v) {
2132
+ return /^\s*v?\d+\.\d+\.\d+-/.test(v);
2133
+ }
2134
+ /**
2135
+ * `true` when `current` is strictly older than `latest`. Returns `false` on any
2136
+ * unparseable/equal/newer input — the hint is opt-in, so we only ever show it
2137
+ * when we can be confident the daemon is genuinely behind.
2138
+ */
2139
+ function isOutdated(current, latest) {
2140
+ if (!current || !latest) return false;
2141
+ const a = core(current);
2142
+ const b = core(latest);
2143
+ if (!a || !b) return false;
2144
+ for (let i = 0; i < 3; i++) {
2145
+ const ai = a[i];
2146
+ const bi = b[i];
2147
+ if (ai < bi) return true;
2148
+ if (ai > bi) return false;
2149
+ }
2150
+ return isPre(current) && !isPre(latest);
2151
+ }
2152
+ var reserved = new Set(RESERVED_LOOP_TAGS);
2153
+ function normalizeLoopTag(value) {
2154
+ return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
2155
+ }
2156
+ function validateLoopTags(value) {
2157
+ if (value === void 0) return {
2158
+ ok: true,
2159
+ value: []
2160
+ };
2161
+ if (!Array.isArray(value)) return {
2162
+ ok: false,
2163
+ detail: "tags must be an array of strings"
2164
+ };
2165
+ if (value.length > 4) return {
2166
+ ok: false,
2167
+ detail: `tags must contain at most 4 values`
2168
+ };
2169
+ const tags = [];
2170
+ const seen = /* @__PURE__ */ new Set();
2171
+ for (const entry of value) {
2172
+ if (typeof entry !== "string") return {
2173
+ ok: false,
2174
+ detail: "tags must contain only strings"
2175
+ };
2176
+ if (/\p{Cc}|\p{Cf}|\p{Default_Ignorable_Code_Point}/u.test(entry)) return {
2177
+ ok: false,
2178
+ detail: "tags must not contain control or invisible characters"
2179
+ };
2180
+ const tag = normalizeLoopTag(entry);
2181
+ if (!tag) return {
2182
+ ok: false,
2183
+ detail: "tags must not contain empty values"
2184
+ };
2185
+ if ([...tag].length > 64) return {
2186
+ ok: false,
2187
+ detail: `each tag must contain at most 64 characters`
2188
+ };
2189
+ if (reserved.has(tag)) return {
2190
+ ok: false,
2191
+ detail: `reserved tag: ${tag}`
2192
+ };
2193
+ if (seen.has(tag)) return {
2194
+ ok: false,
2195
+ detail: `duplicate tag: ${tag}`
2196
+ };
2197
+ seen.add(tag);
2198
+ tags.push(tag);
2199
+ }
2200
+ return {
2201
+ ok: true,
2202
+ value: [...tags].sort()
2203
+ };
2204
+ }
2205
+ //#endregion
2206
+ //#region node_modules/.nitro/vite/services/ssr/assets/boot-DmpKtb0A.js
2207
+ var boot_DmpKtb0A_exports = /* @__PURE__ */ __exportAll$1({
12
2208
  _: () => validOptionalPollString,
13
2209
  a: () => POLL_HTTP_BODY_FIELDS,
14
2210
  c: () => MIN_DAEMON_VERSION,
15
- d: () => statusDefinitionsFromLoop,
2211
+ d: () => canonicalLoopSummary,
16
2212
  f: () => validateLoopEdit,
17
2213
  g: () => readJsonBody,
18
2214
  h: () => readByteBody,
19
2215
  i: () => runDiff_exports,
20
2216
  l: () => daemonNeedsUpdate,
21
- m: () => POLL_INFO_TEXT_CAP,
2217
+ m: () => WIRE_TEXT_CAP,
22
2218
  n: () => ensureServer,
23
2219
  o: () => validPollCurrentRun,
24
2220
  p: () => MACHINE_BODY_CAP,
25
- r: () => getArtifactSync,
2221
+ r: () => getLoopFiles,
26
2222
  s: () => validPollWireId,
27
2223
  t: () => boot_exports,
28
- u: () => scheduleFromLoop
2224
+ u: () => canonicalLoopEnvelope
29
2225
  });
30
- /**
31
- * Machine-route ingress: read + parse a JSON request body under a hard size cap.
32
- * The gateway's per-field wire caps (WIRE_TEXT_CAP etc.) clip individual strings
33
- * AFTER parse — without this, an unbounded `request.json()` still buffers an
34
- * arbitrarily large body first. Framework-free (plain Request) so any machine
35
- * route can share it.
36
- */
37
- /**
38
- * Body cap for standard machine routes (poll / report / CLI).
39
- * 2MB — generously above the largest legitimate terminal report or loop edit.
40
- * The exact-manifest sync route has its own 32 MB JSON-body cap.
41
- */
42
- var MACHINE_BODY_CAP = 2 * 1024 * 1024;
43
2226
  /** Read raw request bytes under a hard cap. Declared oversize requests fail
44
2227
  * before reading; chunked/lying streams are canceled on the first chunk that
45
2228
  * takes the observed count over the cap. */
@@ -116,7 +2299,7 @@ function nowIso() {
116
2299
  /** Strip NUL (U+0000) from a wire string: Postgres text/jsonb columns REJECT the
117
2300
  * NUL byte (SQLite tolerated it), so a daemon-supplied string carrying one would
118
2301
  * 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`
2302
+ * `clipText` - and used directly by `cli.ts`
120
2303
  * (parseFlags/validateState, the same one-chokepoint discipline). */
121
2304
  function stripNul(s) {
122
2305
  return s.replace(/\u0000/g, "");
@@ -124,15 +2307,54 @@ function stripNul(s) {
124
2307
  function clipText(s, cap) {
125
2308
  return stripNul(s.slice(0, cap));
126
2309
  }
2310
+ /** Literal placeholder the daemon replaces with the run's absolute loop-file
2311
+ * directory before launch (and exports as an env var of the same name). */
2312
+ var LOOP_DIR_PLACEHOLDER = "$PIEVO_LOOP_DIR";
2313
+ var STATUS_LINE_RE = /^STATUS:\s*(keep|no-change|block)\.?$/i;
127
2314
  /**
128
- * Deliver the user's prompt unchanged, followed only by the complete report
129
- * contract. The run index intentionally remains an internal history fact.
2315
+ * Deliver the user's prompt unchanged, followed by the status definitions, the
2316
+ * loop-file contract, and the final-line status contract. The run index
2317
+ * intentionally remains an internal history fact.
130
2318
  */
131
- function buildRunTask(loop) {
132
- return `${loop.prompt}\n\nStatus definitions:\n- keep: ${loop.statusKeep}\n- no-change: ${loop.statusNoChange}\n- block: ${loop.statusBlock}\n\nBefore finishing, call exactly once:\npievo report --message "<summary>" --status <keep|no-change|block>`;
2319
+ function buildRunTask(loop, previousNotices = []) {
2320
+ const files = [`- ${MEMORY_FILE}: this loop's memory, kept across runs (max ${MEMORY_CHAR_CAP} characters). Read it before you start. Before finishing, update it with anything that would help future runs avoid mistakes or work faster. Keep it concise: merge similar notes, delete stale or wrong ones, no run logs.`, ...loop.artifacts.map((name) => `- ${name}: artifact shown on the Pievo dashboard (max 2 MB). Update it only as the task above asks.`)];
2321
+ const notices = previousNotices.length ? `\n\nNotices from the previous run:\n${previousNotices.map((notice) => `- ${notice}`).join("\n")}` : "";
2322
+ return `${loop.prompt}
2323
+
2324
+ Status definitions:
2325
+ - keep: ${loop.statusKeep}
2326
+ - no-change: ${loop.statusNoChange}
2327
+ - block: ${loop.statusBlock}
2328
+
2329
+ Loop files in ${LOOP_DIR_PLACEHOLDER}:
2330
+ ${files.join("\n")}
2331
+ A file over its limit is rejected and its previous version kept. Other files in that directory are discarded.${notices}
2332
+
2333
+ End your final response with a short summary, then exactly one last line:
2334
+ STATUS: keep|no-change|block`;
2335
+ }
2336
+ /**
2337
+ * Split a provider's final response into the reported status and summary. The
2338
+ * final non-empty line must be the status line; markdown emphasis or code ticks
2339
+ * around it are tolerated because models add them unprompted.
2340
+ */
2341
+ function parseFinalStatus(finalText) {
2342
+ if (!finalText) return void 0;
2343
+ const lines = finalText.split(/\r?\n/);
2344
+ let index = lines.length - 1;
2345
+ while (index >= 0 && lines[index].trim() === "") index--;
2346
+ if (index < 0) return void 0;
2347
+ const bare = lines[index].trim().replace(/^[*_`>\s]+|[*_`\s]+$/g, "");
2348
+ const match = STATUS_LINE_RE.exec(bare);
2349
+ if (!match) return void 0;
2350
+ return {
2351
+ status: match[1].toLowerCase(),
2352
+ message: lines.slice(0, index).join("\n").trim()
2353
+ };
133
2354
  }
134
2355
  async function buildDelivery(loop, queuedRun, runToken, roots) {
135
2356
  if (queuedRun.runIndex == null) throw new Error(`claimed run ${queuedRun.id} has no history index`);
2357
+ const notices = await previousRunFileNotices(loop.id, queuedRun.runIndex);
136
2358
  return {
137
2359
  runId: queuedRun.id,
138
2360
  runIndex: queuedRun.runIndex,
@@ -146,266 +2368,16 @@ async function buildDelivery(loop, queuedRun, runToken, roots) {
146
2368
  reasoningEffort: loop.reasoningEffort ?? null,
147
2369
  agent: loop.agent
148
2370
  },
149
- task: buildRunTask(loop),
150
- artifacts: loop.artifacts
2371
+ task: buildRunTask(loop, notices),
2372
+ files: loopFileNames(loop)
151
2373
  };
152
2374
  }
153
2375
  /**
154
- * Content-addressed blob byte store for live-synced loop artifacts.
155
- *
156
- * Bytes are keyed by their sha256 hash (content-addressed ⇒ deduped across every
157
- * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
158
- * them — preserving the server's zero-exec invariant.
159
- *
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.
167
- */
168
- var log$4 = logger.child({ mod: "blobstore" });
169
- var SHA256_HEX = /^[a-f0-9]{64}$/;
170
- /** Durable filesystem adapter. Hash sharding bounds entries per directory; atomic
171
- * temp-file renames ensure readers see either complete bytes or no object. */
172
- var LocalBlobStore = class {
173
- root;
174
- initialized;
175
- constructor(root = path.join(dataDir(), "blobs")) {
176
- this.root = root;
177
- }
178
- file(hash) {
179
- if (!SHA256_HEX.test(hash)) throw new Error("invalid sha256 blob key");
180
- return path.join(this.root, hash.slice(0, 2), hash);
181
- }
182
- async has(hash) {
183
- const target = this.file(hash);
184
- await this.ensureInitialized();
185
- try {
186
- return (await fs$1.stat(target)).isFile();
187
- } catch (err) {
188
- if (err.code === "ENOENT") return false;
189
- throw err;
190
- }
191
- }
192
- async syncDir(dir) {
193
- let handle;
194
- try {
195
- handle = await fs$1.open(dir, "r");
196
- await handle.sync();
197
- } catch (err) {
198
- const code = err.code;
199
- if (![
200
- "EINVAL",
201
- "ENOTSUP",
202
- "EBADF",
203
- "EPERM",
204
- "ENOENT"
205
- ].includes(code)) throw err;
206
- } finally {
207
- await handle?.close().catch(() => {});
208
- }
209
- }
210
- ensureInitialized() {
211
- return this.initialized ??= (async () => {
212
- await fs$1.mkdir(this.root, { recursive: true });
213
- await this.syncDir(path.dirname(path.dirname(this.root)));
214
- await this.syncDir(path.dirname(this.root));
215
- await this.syncDir(this.root);
216
- for (const shard of await fs$1.readdir(this.root, { withFileTypes: true })) {
217
- if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
218
- const dir = path.join(this.root, shard.name);
219
- let removed = false;
220
- for (const name of await fs$1.readdir(dir)) {
221
- if (!/^\.[a-f0-9]{64}\.\d+\.[0-9a-f-]+\.tmp$/.test(name)) continue;
222
- await fs$1.rm(path.join(dir, name), { force: true });
223
- removed = true;
224
- }
225
- if (removed) await this.syncDir(dir);
226
- }
227
- })();
228
- }
229
- async put(hash, bytes) {
230
- const target = this.file(hash);
231
- await this.ensureInitialized();
232
- const dir = path.dirname(target);
233
- await fs$1.mkdir(dir, { recursive: true });
234
- await this.syncDir(this.root);
235
- const temp = path.join(dir, `.${hash}.${process.pid}.${randomUUID()}.tmp`);
236
- let handle;
237
- try {
238
- handle = await fs$1.open(temp, "wx", 384);
239
- await handle.writeFile(bytes);
240
- await handle.sync();
241
- await handle.close();
242
- handle = void 0;
243
- try {
244
- await fs$1.rename(temp, target);
245
- await this.syncDir(dir);
246
- } catch (err) {
247
- if ([
248
- "EEXIST",
249
- "EACCES",
250
- "EPERM"
251
- ].includes(err.code ?? "") && await this.has(hash)) {
252
- await fs$1.rm(temp, { force: true });
253
- return;
254
- }
255
- throw err;
256
- }
257
- } catch (err) {
258
- await handle?.close().catch(() => {});
259
- await fs$1.rm(temp, { force: true }).catch(() => {});
260
- throw err;
261
- }
262
- }
263
- async get(hash) {
264
- const target = this.file(hash);
265
- await this.ensureInitialized();
266
- try {
267
- return await fs$1.readFile(target);
268
- } catch (err) {
269
- if (err.code === "ENOENT") return null;
270
- throw err;
271
- }
272
- }
273
- async delete(hash) {
274
- const target = this.file(hash);
275
- await this.ensureInitialized();
276
- await fs$1.rm(target, { force: true });
277
- await this.syncDir(path.dirname(target));
278
- }
279
- };
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
- */
374
- 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
- const local = new LocalBlobStore();
400
- log$4.info({ root: local.root }, "blob store: local filesystem");
401
- return local;
402
- }
403
- /**
404
- * Artifact-storage retention + garbage collection — the policy that keeps the
405
- * local/R2 byte store from growing monotonically.
2376
+ * Loop-file storage retention + garbage collection — the policy that keeps the
2377
+ * local byte store from growing monotonically.
406
2378
  *
407
- * The artifact pipeline is content-addressed: blob BYTES are keyed by sha256 and
408
- * deduped across every loop/run. When a path is deleted or its content changes,
2379
+ * The loop-file pipeline is content-addressed: blob BYTES are keyed by sha256 and
2380
+ * deduped across every loop/run. When a file is deleted or its content changes,
409
2381
  * the OLD blob is no longer pointed at by the current file row — but nothing was
410
2382
  * reclaiming it, so the byte store grew forever. This module closes that gap with two pieces:
411
2383
  *
@@ -416,24 +2388,24 @@ function createBlobStore() {
416
2388
  * hash. CORRECTNESS over aggressiveness — a still-referenced blob is never
417
2389
  * deleted; a leaked blob is only a cost bug the next pass reclaims.
418
2390
  *
419
- * Concurrency: gcBlobs is safe to run alongside active syncs. Three guards combine —
420
- * a grace window (a blob whose metadata row is younger than the window is never
421
- * collected, so a blob a sync just wrote/referenced is untouchable), a point
422
- * re-check of each candidate immediately before its bytes are deleted (so a blob
2391
+ * Concurrency: gcBlobs is safe alongside owner writes and run uploads. Three guards
2392
+ * combine — a grace window (a blob whose metadata row is younger than the window is
2393
+ * never collected, so a blob a run just uploaded is untouchable), a point re-check
2394
+ * of each candidate immediately before its bytes are deleted (so a blob
423
2395
  * re-referenced before we touch it keeps both its bytes and metadata), and a
424
2396
  * bytes-before-metadata delete ordering: each candidate's bytes are reclaimed
425
2397
  * FIRST, then its metadata row is dropped unconditionally — so blobExists() goes
426
- * false and any sync that re-references the hash (even one that raced the byte
2398
+ * false and any writer that re-references the hash (even one that raced the byte
427
2399
  * delete) re-uploads the bytes (self-healing). We never leave a live blobs row
428
2400
  * pointing at deleted bytes.
429
2401
  */
430
- var log$3 = logger.child({ mod: "retention" });
2402
+ var log$2 = logger.child({ mod: "retention" });
431
2403
  /**
432
2404
  * Prune every loop's run snapshots down to the configured retention window
433
2405
  * (most-recent-N). Returns the total number pruned. This is what makes an old
434
2406
  * snapshot's now-unreferenced blobs collectable by gcBlobs().
435
2407
  */
436
- async function pruneSnapshots(keep = snapshotRetention()) {
2408
+ async function pruneSnapshots(keep = 20) {
437
2409
  let pruned = 0;
438
2410
  for (const loopId of await loopIdsWithSnapshots()) pruned += await pruneRunSnapshots(loopId, keep);
439
2411
  return pruned;
@@ -462,7 +2434,7 @@ async function pruneSnapshots(keep = snapshotRetention()) {
462
2434
  * so that file re-uploads its bytes on the next sync. We never leave a
463
2435
  * live blobs row pointing at deleted bytes.
464
2436
  */
465
- async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
2437
+ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
466
2438
  const refs = await liveBlobRefs();
467
2439
  const garbage = (await blobHashesOlderThan(new Date(Date.now() - graceMs).toISOString())).filter((h) => !refs.has(h));
468
2440
  let snapCount = await countRunSnapshots();
@@ -480,7 +2452,7 @@ async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
480
2452
  await deleteBlob(hash);
481
2453
  reclaimed++;
482
2454
  } catch (err) {
483
- log$3.warn({
2455
+ log$2.warn({
484
2456
  hash,
485
2457
  err: err instanceof Error ? err.message : String(err)
486
2458
  }, "gc: blob byte-delete failed");
@@ -488,16 +2460,10 @@ async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
488
2460
  }
489
2461
  return reclaimed;
490
2462
  }
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);
500
- if (snapshotsPruned || blobsReclaimed) log$3.info({
2463
+ async function maintainStorage(blobStore, options = {}) {
2464
+ const snapshotsPruned = await pruneSnapshots(options.keep);
2465
+ const blobsReclaimed = await gcBlobs(blobStore, options.graceMs);
2466
+ if (snapshotsPruned || blobsReclaimed) log$2.info({
501
2467
  snapshotsPruned,
502
2468
  blobsReclaimed
503
2469
  }, "storage maintenance");
@@ -832,44 +2798,37 @@ function validateStatusDefinitions(value) {
832
2798
  }
833
2799
  };
834
2800
  }
835
- function validateArtifactPaths(value) {
2801
+ function validateArtifactNames(value) {
836
2802
  if (value === void 0 || value === null) return {
837
2803
  ok: true,
838
2804
  value: []
839
2805
  };
840
2806
  if (!Array.isArray(value)) return {
841
2807
  ok: false,
842
- detail: "artifacts must be an array of exact relative file paths"
2808
+ detail: "artifacts must be an array of file names"
2809
+ };
2810
+ if (value.length > 4) return {
2811
+ ok: false,
2812
+ detail: `at most 4 artifacts are allowed`
843
2813
  };
844
- const paths = [];
2814
+ const names = [];
845
2815
  const seen = /* @__PURE__ */ new Set();
846
2816
  for (const entry of value) {
847
- if (typeof entry !== "string" || entry.length === 0) return {
848
- ok: false,
849
- detail: "artifact paths must be non-empty strings"
850
- };
851
- if (entry.includes("\0")) return {
852
- ok: false,
853
- detail: "artifact path must not contain NUL"
854
- };
855
- if (entry.startsWith("/") || entry.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(entry)) return {
2817
+ if (!validArtifactName(entry)) return {
856
2818
  ok: false,
857
- detail: `artifact path must be relative to workdir: ${entry}`
2819
+ detail: `artifact must be a plain .md or .html file name other than ${MEMORY_FILE} (letters, digits, . _ -; at most 64 characters before the extension): ${JSON.stringify(entry)}`
858
2820
  };
859
- if (entry.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return {
2821
+ const key = entry.toLowerCase();
2822
+ if (seen.has(key)) return {
860
2823
  ok: false,
861
- detail: `artifact path must not traverse or alias another path: ${entry}`
2824
+ detail: `duplicate artifact name: ${entry}`
862
2825
  };
863
- if (seen.has(entry)) return {
864
- ok: false,
865
- detail: `duplicate artifact path: ${entry}`
866
- };
867
- seen.add(entry);
868
- paths.push(entry);
2826
+ seen.add(key);
2827
+ names.push(entry);
869
2828
  }
870
2829
  return {
871
2830
  ok: true,
872
- value: paths
2831
+ value: names
873
2832
  };
874
2833
  }
875
2834
  function rowSchedule(schedule, retainedCron = SAFE_RETAINED_CRON) {
@@ -911,6 +2870,15 @@ function statusDefinitionsFromLoop(loop) {
911
2870
  };
912
2871
  }
913
2872
  function canonicalLoopEnvelope(loop) {
2873
+ return {
2874
+ ...canonicalLoopSummary(loop),
2875
+ prompt: loop.prompt,
2876
+ statusDefinitions: statusDefinitionsFromLoop(loop),
2877
+ artifacts: loop.artifacts,
2878
+ enabled: loop.enabled
2879
+ };
2880
+ }
2881
+ function canonicalLoopSummary(loop) {
914
2882
  return {
915
2883
  id: loop.id,
916
2884
  name: loop.name,
@@ -919,11 +2887,7 @@ function canonicalLoopEnvelope(loop) {
919
2887
  workdir: loop.workdir,
920
2888
  agent: loop.agent,
921
2889
  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
2890
+ reasoningEffort: loop.reasoningEffort ?? null
927
2891
  };
928
2892
  }
929
2893
  function validateLoopCreate(value) {
@@ -949,7 +2913,7 @@ function validateLoopCreate(value) {
949
2913
  if (!schedule.ok) return schedule;
950
2914
  const statusDefinitions = validateStatusDefinitions(raw.statusDefinitions);
951
2915
  if (!statusDefinitions.ok) return statusDefinitions;
952
- const artifacts = validateArtifactPaths(raw.artifacts);
2916
+ const artifacts = validateArtifactNames(raw.artifacts);
953
2917
  if (!artifacts.ok) return artifacts;
954
2918
  const model = optionalProvider(raw.model, "model");
955
2919
  if (!model.ok) return model;
@@ -1044,7 +3008,7 @@ function validateLoopEdit(loop, value) {
1044
3008
  update.statusBlock = result.value.block;
1045
3009
  }
1046
3010
  if (raw.artifacts !== void 0) {
1047
- const result = validateArtifactPaths(raw.artifacts);
3011
+ const result = validateArtifactNames(raw.artifacts);
1048
3012
  if (!result.ok) return result;
1049
3013
  update.artifacts = result.value;
1050
3014
  }
@@ -1080,7 +3044,7 @@ function validateLoopEdit(loop, value) {
1080
3044
  value: update
1081
3045
  };
1082
3046
  }
1083
- var MIN_DAEMON_VERSION = "2.4.0";
3047
+ var MIN_DAEMON_VERSION = "4.0.0";
1084
3048
  function daemonNeedsUpdate(current, required = MIN_DAEMON_VERSION) {
1085
3049
  if (!current || !isValidSemver(current) || !isValidSemver(required)) return true;
1086
3050
  if (current === required) return false;
@@ -1121,6 +3085,7 @@ var validPollInfo = (value) => value === void 0 || value !== null && typeof valu
1121
3085
  *
1122
3086
  * POST /api/machine/poll (Bearer device token) → claim pending runs, deliver
1123
3087
  * POST /machine/report (Bearer run token) → finalize a run
3088
+ * GET|PUT /api/machine/run-files/:name (Bearer run token) → loop-file transfer
1124
3089
  *
1125
3090
  * plus owner create/list/edit methods and retention.
1126
3091
  * Also exposes `dispatcher` (a `Dispatcher` for the Scheduler: "is the machine
@@ -1129,7 +3094,7 @@ var validPollInfo = (value) => value === void 0 || value !== null && typeof valu
1129
3094
  * (`CliGateway`), which reuses this class's owner methods;
1130
3095
  * canonical loop configuration validation lives in `gateway/loopConfig.ts`.
1131
3096
  */
1132
- var log$2 = logger.child({ mod: "gateway" });
3097
+ var log$1 = logger.child({ mod: "gateway" });
1133
3098
  var ONLINE_TTL_MS = 3e4;
1134
3099
  /** Auto-pause after this many consecutive errors. Canceled rows are transparent;
1135
3100
  * zero disables the breaker. */
@@ -1140,14 +3105,19 @@ var configuredRunTimeoutMs = Number(process.env.PIEVO_RUN_TIMEOUT_MS || 20 * 6e4
1140
3105
  var RUN_TIMEOUT_MS = Number.isFinite(configuredRunTimeoutMs) && configuredRunTimeoutMs > 0 ? configuredRunTimeoutMs : 20 * 6e4;
1141
3106
  /** The ONLY keys an owner `editLoop` patch may touch. A key outside this set is
1142
3107
  * rejected (400) rather than silently ignored, so a `--json` typo fails loudly
1143
- * and identity/ownership columns (id/userId/machineId/timestamps) can never be
3108
+ * and identity columns (id/machineId/timestamps) can never be
1144
3109
  * patched over the device-token edit surface. Exported for `cli.ts`
1145
3110
  * (the `new`/`edit` verb help lists these keys). */
1146
3111
  var EDITABLE_LOOP_FIELDS = LOOP_EDIT_FIELDS;
1147
- /** Formal `report --message` text. Provider finalText is stored separately;
1148
- * it never satisfies the successful-run reporting protocol. Run errors share
1149
- * this cap. Exported for `cli.ts` so the report verb uses the same budget. */
3112
+ /** Run summary (the final response minus its STATUS line) and run errors share
3113
+ * this cap; the full final response is stored separately as finalText. */
1150
3114
  var MESSAGE_CAP = 2e3;
3115
+ /** A run reports at most one version per loop file (MEMORY.md + 4 artifacts). */
3116
+ var REPORT_FILES_MAX = 5;
3117
+ /** One notice per file plus a few upload/rejection reasons; bounds the prompt echo. */
3118
+ var REPORT_NOTICES_MAX = 10;
3119
+ /** One short sentence (name + reason); 500 chars keeps the next prompt small. */
3120
+ var NOTICE_CAP = 500;
1151
3121
  /** Provider session IDs are opaque short tokens; this rejects malformed payloads
1152
3122
  * without constraining known provider formats. */
1153
3123
  var SESSION_ID_CAP = 200;
@@ -1162,7 +3132,7 @@ var HEARTBEAT_STAMP_REFRESH_MS = heartbeatRefreshMs(RUN_TIMEOUT_MS);
1162
3132
  * and an idle poll becomes read-only, with worst-case staleness well inside
1163
3133
  * the 30s TTL (max stamp gap = refresh + one poll interval). */
1164
3134
  var LAST_SEEN_REFRESH_MS = 1e4;
1165
- var MACHINE_REPORT_FIELD_SET = new Set([
3135
+ var MACHINE_REPORT_FIELDS = [
1166
3136
  "reportId",
1167
3137
  "runId",
1168
3138
  "result",
@@ -1171,8 +3141,17 @@ var MACHINE_REPORT_FIELD_SET = new Set([
1171
3141
  "sessionId",
1172
3142
  "usage",
1173
3143
  "error",
1174
- "finalText"
1175
- ]);
3144
+ "finalText",
3145
+ "files",
3146
+ "fileNotices"
3147
+ ];
3148
+ var REPORT_FILE_FIELDS = [
3149
+ "baseHash",
3150
+ "hash",
3151
+ "name",
3152
+ "size"
3153
+ ];
3154
+ var MACHINE_REPORT_FIELD_SET = new Set(MACHINE_REPORT_FIELDS);
1176
3155
  var MACHINE_REPORT_USAGE_FIELDS = new Set([
1177
3156
  "inputTokens",
1178
3157
  "outputTokens",
@@ -1201,7 +3180,7 @@ function receiptFor(body, runId, ackStatus = 200, ackBody) {
1201
3180
  }
1202
3181
  function receiptResponse(receipt, expected) {
1203
3182
  if (!receipt || receipt.runId !== expected.runId) return void 0;
1204
- if (receipt.payloadDigest !== expected.payloadDigest) log$2.warn({
3183
+ if (receipt.payloadDigest !== expected.payloadDigest) log$1.warn({
1205
3184
  reportId: expected.reportId,
1206
3185
  runId: expected.runId
1207
3186
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -1229,7 +3208,7 @@ async function committedReportEvidence(reportId, payloadDigest, authoritativeRun
1229
3208
  foreignRun: false
1230
3209
  };
1231
3210
  if (normal?.runId === authoritativeRunId) {
1232
- if (normal.payloadDigest !== payloadDigest) log$2.warn({
3211
+ if (normal.payloadDigest !== payloadDigest) log$1.warn({
1233
3212
  reportId,
1234
3213
  runId: authoritativeRunId
1235
3214
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -1262,15 +3241,37 @@ function incidentDiagnosis(code, issues, reportId, payloadDigest) {
1262
3241
  faultDomain,
1263
3242
  recommendedAction
1264
3243
  };
1265
- }
1266
- /** Validate the durable report facts before a successful provider process may
1267
- * become a successful run. The CLI validates first so an agent can retry; this
1268
- * terminal seam prevents an ignored 400 from silently producing an empty run. */
1269
- function runProtocolMissing(run) {
1270
- const missing = [];
1271
- if (run.status !== "keep" && run.status !== "no-change" && run.status !== "block") missing.push("status");
1272
- if (!run.message?.trim()) missing.push("message");
1273
- return missing;
3244
+ }
3245
+ var MISSING_STATUS_ERROR = "run protocol incomplete: the final response has no \"STATUS: keep|no-change|block\" line";
3246
+ /** The run outcome a terminal report establishes. Only a successful provider
3247
+ * exit is read for a status; a successful exit without one is a protocol error. */
3248
+ function reportOutcome(body) {
3249
+ const canceled = body.result === "canceled";
3250
+ if (canceled) return {
3251
+ ok: false,
3252
+ canceled,
3253
+ patch: { error: "stopped by user" }
3254
+ };
3255
+ if (body.result !== "success") return {
3256
+ ok: false,
3257
+ canceled,
3258
+ patch: { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" }
3259
+ };
3260
+ const parsed = parseFinalStatus(body.finalText);
3261
+ if (!parsed) return {
3262
+ ok: false,
3263
+ canceled,
3264
+ patch: { error: MISSING_STATUS_ERROR }
3265
+ };
3266
+ return {
3267
+ ok: true,
3268
+ canceled,
3269
+ patch: {
3270
+ status: parsed.status,
3271
+ message: clipText(parsed.message, 2e3) || null,
3272
+ error: null
3273
+ }
3274
+ };
1274
3275
  }
1275
3276
  function validateTerminalReport(body) {
1276
3277
  const issues = [];
@@ -1290,6 +3291,25 @@ function validateTerminalReport(body) {
1290
3291
  "error",
1291
3292
  "finalText"
1292
3293
  ]) if (has(key) && typeof body[key] !== "string") issues.push(`${key} must be a string`);
3294
+ if (has("files")) {
3295
+ const files = body.files;
3296
+ if (!Array.isArray(files) || files.length > REPORT_FILES_MAX) issues.push(`files must be an array of at most ${REPORT_FILES_MAX} entries`);
3297
+ else {
3298
+ const names = /* @__PURE__ */ new Set();
3299
+ for (const entry of files) {
3300
+ const file = entry;
3301
+ if (!(file !== null && typeof file === "object" && !Array.isArray(file) && Object.keys(file).sort().join(",") === REPORT_FILE_FIELDS.join(",") && typeof file.name === "string" && file.name.length > 0 && file.name.length <= 100 && !names.has(file.name) && isValidHash(file.baseHash) && isValidHash(file.hash) && typeof file.size === "number" && Number.isInteger(file.size) && file.size >= 0 && file.size <= LOOP_FILE_BYTE_CAP)) {
3302
+ issues.push("files entries must be exactly {name, baseHash, hash, size} with unique names, sha256 hashes, and an in-cap size");
3303
+ break;
3304
+ }
3305
+ names.add(file.name);
3306
+ }
3307
+ }
3308
+ }
3309
+ if (has("fileNotices")) {
3310
+ const notices = body.fileNotices;
3311
+ if (!Array.isArray(notices) || notices.length > REPORT_NOTICES_MAX || notices.some((notice) => typeof notice !== "string" || notice.length > NOTICE_CAP)) issues.push(`fileNotices must be an array of at most ${REPORT_NOTICES_MAX} strings of at most ${NOTICE_CAP} characters`);
3312
+ }
1293
3313
  if (has("usage")) if (body.usage === null || typeof body.usage !== "object" || Array.isArray(body.usage)) issues.push("usage must be an object");
1294
3314
  else {
1295
3315
  const usage = body.usage;
@@ -1330,7 +3350,10 @@ var MachineGateway = class {
1330
3350
  constructor(scheduler, blobStore = createBlobStore()) {
1331
3351
  this.scheduler = scheduler;
1332
3352
  this.blobStore = blobStore;
3353
+ this.loopFiles = new LoopFiles(blobStore);
1333
3354
  }
3355
+ /** Loop-file bytes over the same blob store that maintenance garbage-collects. */
3356
+ loopFiles;
1334
3357
  /** In-flight latch: the maintenance pass is sequential and the first post-deploy
1335
3358
  * backlog reclamation can overrun the interval, so a fresh tick skips rather than
1336
3359
  * running a second pass concurrently (idempotent but wasteful + double-counts). */
@@ -1338,7 +3361,7 @@ var MachineGateway = class {
1338
3361
  applyAutopauseTimer(loopId, terminal) {
1339
3362
  if (!terminal.autoPaused) return;
1340
3363
  this.scheduler.removeLoop(loopId);
1341
- log$2.warn({
3364
+ log$1.warn({
1342
3365
  loopId,
1343
3366
  streak: terminal.failureStreak
1344
3367
  }, "loop auto-paused after block or consecutive errors");
@@ -1434,13 +3457,14 @@ var MachineGateway = class {
1434
3457
  * Periodic storage maintenance: prune each loop's run snapshots to the
1435
3458
  * retention window, then GC blob bytes no live row needs. Wired to its own
1436
3459
  * interval in boot (independent of the faster offline-sweep) and exposed for
1437
- * tests / on-demand triggers. Safe to run concurrently with active syncs (a
1438
- * grace window + final re-check protect freshly-written/referenced blobs) and
1439
- * idempotent with no garbage. Best-effort — never throws into the caller.
3460
+ * tests / on-demand triggers. Safe to run concurrently with owner writes and
3461
+ * run uploads (a grace window + final re-check protect freshly-written or
3462
+ * referenced blobs) and idempotent with no garbage. Best-effort — never throws
3463
+ * into the caller.
1440
3464
  */
1441
- async maintainStorage() {
3465
+ async maintainStorage(options) {
1442
3466
  if (this.maintenanceRunning) {
1443
- log$2.info("storage maintenance already in progress — skipping this tick");
3467
+ log$1.info("storage maintenance already in progress — skipping this tick");
1444
3468
  return {
1445
3469
  snapshotsPruned: 0,
1446
3470
  blobsReclaimed: 0
@@ -1448,9 +3472,9 @@ var MachineGateway = class {
1448
3472
  }
1449
3473
  this.maintenanceRunning = true;
1450
3474
  try {
1451
- return await maintainStorage(this.blobStore);
3475
+ return await maintainStorage(this.blobStore, options);
1452
3476
  } catch (err) {
1453
- log$2.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
3477
+ log$1.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
1454
3478
  return {
1455
3479
  snapshotsPruned: 0,
1456
3480
  blobsReclaimed: 0
@@ -1465,20 +3489,18 @@ var MachineGateway = class {
1465
3489
  const { machineId } = auth;
1466
3490
  let machine = auth.machine;
1467
3491
  if (!machine) {
1468
- const binding = await readConnectKeyBinding(deviceToken);
1469
- if (!binding) return {
3492
+ if (!await connectKeyValid(deviceToken)) return {
1470
3493
  status: 401,
1471
3494
  body: { error: "unknown device token — connect this machine first" }
1472
3495
  };
1473
3496
  machine = await createMachine({
1474
3497
  id: machineId,
1475
- userId: binding.userId,
1476
3498
  name: info?.host || `machine-${machineId.slice(2, 8)}`,
1477
3499
  tokenHash: sha256(deviceToken),
1478
3500
  token: deviceToken,
1479
3501
  online: true
1480
3502
  });
1481
- log$2.info({
3503
+ log$1.info({
1482
3504
  machineId,
1483
3505
  host: info?.host
1484
3506
  }, "poll: self-registered machine");
@@ -1510,7 +3532,7 @@ var MachineGateway = class {
1510
3532
  const claimed = await claimReadyRunForMachine(machineId, void 0, currentRunIds ?? []);
1511
3533
  if (claimed) delivery = await buildDelivery(claimed.loop, claimed.run, claimed.runToken, machine.roots ?? []);
1512
3534
  }
1513
- if (delivery) log$2.info({
3535
+ if (delivery) log$1.info({
1514
3536
  machineId,
1515
3537
  runId: delivery.runId
1516
3538
  }, "poll: delivered");
@@ -1528,7 +3550,7 @@ var MachineGateway = class {
1528
3550
  code: "VALIDATION_ERROR"
1529
3551
  }
1530
3552
  };
1531
- if (request.protocolVersion !== 4) {
3553
+ if (request.protocolVersion !== 5) {
1532
3554
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1533
3555
  if (!auth.ok && auth.reason === "mismatch") return auth.response;
1534
3556
  if (auth.ok && auth.machine) {
@@ -1540,7 +3562,7 @@ var MachineGateway = class {
1540
3562
  body: {
1541
3563
  error: "daemon upgrade required; run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`",
1542
3564
  code: "UPGRADE_REQUIRED",
1543
- requiredProtocol: 4
3565
+ requiredProtocol: 5
1544
3566
  }
1545
3567
  };
1546
3568
  }
@@ -1572,7 +3594,7 @@ var MachineGateway = class {
1572
3594
  const needsUpdate = daemonNeedsUpdate(reportedVersion);
1573
3595
  const base = await this.pollCore(deviceToken, request.info, currentIds, !needsUpdate, true);
1574
3596
  if (base.status !== 200) return base;
1575
- if ((await getMachine(machineId))?.daemonProtocol !== 4) await updateMachine(machineId, { daemonProtocol: 4 });
3597
+ if ((await getMachine(machineId))?.daemonProtocol !== 5) await updateMachine(machineId, { daemonProtocol: 5 });
1576
3598
  const running = await runningRunsForMachine(machineId);
1577
3599
  const currentSet = new Set(currentIds);
1578
3600
  return {
@@ -1589,7 +3611,7 @@ var MachineGateway = class {
1589
3611
  };
1590
3612
  }
1591
3613
  async pollV4Wait(deviceToken, request, waitMs = LONG_POLL_WAIT_MS) {
1592
- if (request.currentRuns?.length || request.protocolVersion !== 4) return this.pollV4(deviceToken, request);
3614
+ if (request.currentRuns?.length || request.protocolVersion !== 5) return this.pollV4(deviceToken, request);
1593
3615
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1594
3616
  if (!auth.ok) return auth.response;
1595
3617
  const machineId = auth.machineId;
@@ -1620,7 +3642,7 @@ var MachineGateway = class {
1620
3642
  status: 200,
1621
3643
  body: {
1622
3644
  registered: false,
1623
- claimValid: await readConnectKeyBinding(deviceToken) !== void 0,
3645
+ claimValid: await connectKeyValid(deviceToken),
1624
3646
  online: false,
1625
3647
  name: null,
1626
3648
  lastSeen: null,
@@ -1650,7 +3672,6 @@ var MachineGateway = class {
1650
3672
  const auth = await authenticateDeviceToken(deviceToken);
1651
3673
  if (!auth.ok) return auth.response;
1652
3674
  const { machineId } = auth;
1653
- const machine = auth.machine;
1654
3675
  {
1655
3676
  if (body === null || typeof body !== "object" || Array.isArray(body)) return {
1656
3677
  status: 400,
@@ -1693,13 +3714,12 @@ var MachineGateway = class {
1693
3714
  }
1694
3715
  };
1695
3716
  const loop = await createLoop({
1696
- userId: machine.userId,
1697
3717
  machineId,
1698
3718
  ...row
1699
3719
  });
1700
3720
  this.scheduler.addLoop(loop);
1701
3721
  recordNewIdempotency(idempotencyKey, machineId, loop.id);
1702
- log$2.info({
3722
+ log$1.info({
1703
3723
  machineId,
1704
3724
  loopId: loop.id,
1705
3725
  agent: loop.agent
@@ -1913,18 +3933,156 @@ var MachineGateway = class {
1913
3933
  }
1914
3934
  const response = receiptResponse(stored, expected);
1915
3935
  if (response.status < 300) await retireLease(runToken);
1916
- log$2.info({ runId: lease.runId }, "report: ignored (run was canceled)");
3936
+ log$1.info({ runId: lease.runId }, "report: ignored (run was canceled)");
1917
3937
  return response;
1918
3938
  }
3939
+ async reportRaceResponse(runToken, lease, body, payloadDigest) {
3940
+ const raced = await committedReportEvidence(body.reportId, payloadDigest, lease.runId);
3941
+ if (raced.response) return raced.response;
3942
+ if (raced.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
3943
+ }
3944
+ async resolveLostTerminalRace(runToken, lease, body, payloadDigest, retryReconcile, logMessage) {
3945
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
3946
+ if (raced) return raced;
3947
+ const fresh = await getRun(lease.runId);
3948
+ if (fresh?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
3949
+ if (fresh?.phase === "done") return {
3950
+ status: 409,
3951
+ body: {
3952
+ error: "run already finalized",
3953
+ code: "REPORT_NOT_FINALIZED",
3954
+ reportId: body.reportId
3955
+ }
3956
+ };
3957
+ const refreshedLease = await resolveLease(runToken);
3958
+ if (refreshedLease?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
3959
+ if (retryReconcile && fresh?.phase === "error" && refreshedLease?.state === "terminal-grace") return this.reconcileReclaimedReport(runToken, refreshedLease, fresh, body);
3960
+ log$1.info({
3961
+ runId: lease.runId,
3962
+ phase: fresh?.phase
3963
+ }, logMessage);
3964
+ return {
3965
+ status: 409,
3966
+ body: {
3967
+ error: "terminal report was not finalized",
3968
+ code: "REPORT_NOT_FINALIZED",
3969
+ reportId: body.reportId
3970
+ }
3971
+ };
3972
+ }
3973
+ /** Reported loop-file versions whose uploaded bytes are present. A canceled run
3974
+ * was killed mid-edit, so none of its files are applied. */
3975
+ async terminalFiles(body) {
3976
+ const notices = (body.fileNotices ?? []).map((notice) => clipText(notice, NOTICE_CAP));
3977
+ const updates = [];
3978
+ if (body.result === "canceled") return {
3979
+ updates,
3980
+ notices
3981
+ };
3982
+ const files = body.files ?? [];
3983
+ const sizes = await blobSizes(files.map((file) => file.hash).filter((hash) => hash !== EMPTY_SHA256));
3984
+ for (const file of files) {
3985
+ if (!await this.loopFiles.hasBlob(file.hash)) {
3986
+ notices.push(`${file.name}: the upload was lost; the previous version was kept`);
3987
+ continue;
3988
+ }
3989
+ const size = file.hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ? 0 : sizes.get(file.hash);
3990
+ if (size === void 0 || size !== file.size) {
3991
+ notices.push(`${file.name}: reported size did not match the stored version; the previous version was kept`);
3992
+ continue;
3993
+ }
3994
+ updates.push({
3995
+ name: file.name,
3996
+ baseHash: file.baseHash,
3997
+ hash: file.hash,
3998
+ size
3999
+ });
4000
+ }
4001
+ return {
4002
+ updates,
4003
+ notices
4004
+ };
4005
+ }
4006
+ /** Current bytes of one loop file for an executing run (the run's starting version). */
4007
+ async readRunFile(runToken, name) {
4008
+ const lease = await resolveLease(runToken);
4009
+ if (!lease) return {
4010
+ status: 401,
4011
+ body: { error: "invalid or expired token" }
4012
+ };
4013
+ if (lease.state !== "active") return {
4014
+ status: 409,
4015
+ body: { error: "run is no longer executing" }
4016
+ };
4017
+ const loop = await getLoop(lease.loopId);
4018
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4019
+ status: 404,
4020
+ body: { error: "no such loop file" }
4021
+ };
4022
+ const row = await getArtifactFile(loop.id, name);
4023
+ const hash = row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256;
4024
+ const bytes = await this.loopFiles.readBlob(hash);
4025
+ if (!bytes) return {
4026
+ status: 500,
4027
+ body: { error: "loop file bytes are missing" }
4028
+ };
4029
+ return {
4030
+ status: 200,
4031
+ body: {},
4032
+ bytes,
4033
+ hash
4034
+ };
4035
+ }
4036
+ /** Store one loop-file version produced by a run. Accepted until the run's
4037
+ * authority retires, so the durable report outbox can retry after a crash.
4038
+ * Applying it to the loop happens only in the terminal report transaction. */
4039
+ async storeRunFile(runToken, name, bytes, expectedHash) {
4040
+ const lease = await resolveLease(runToken);
4041
+ if (!lease || lease.state === "retired") return {
4042
+ status: 401,
4043
+ body: { error: "invalid or expired token" }
4044
+ };
4045
+ const loop = await getLoop(lease.loopId);
4046
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4047
+ status: 404,
4048
+ body: { error: "no such loop file" }
4049
+ };
4050
+ const issue = loopFileIssue(name, bytes);
4051
+ if (issue) return {
4052
+ status: 413,
4053
+ body: { error: issue }
4054
+ };
4055
+ if (expectedHash !== null && expectedHash !== sha256Buf(bytes)) return {
4056
+ status: 400,
4057
+ body: { error: "hash mismatch" }
4058
+ };
4059
+ const hash = await this.loopFiles.putBlob(bytes);
4060
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
4061
+ return {
4062
+ status: 200,
4063
+ body: {
4064
+ ok: true,
4065
+ hash
4066
+ }
4067
+ };
4068
+ }
4069
+ async captureRunSnapshot(runId, loopId) {
4070
+ try {
4071
+ await putRunSnapshot(runId, loopId, await buildLoopManifest(loopId));
4072
+ await pruneRunSnapshots(loopId, 20);
4073
+ } catch (err) {
4074
+ log$1.warn({
4075
+ runId,
4076
+ err: err instanceof Error ? err.message : String(err)
4077
+ }, "snapshot capture failed");
4078
+ }
4079
+ }
1919
4080
  /** Reconcile one swept run. The store consumes the terminal-grace lease in the
1920
4081
  * same loop-lock transaction as the error→done/error patch and loop state, so
1921
4082
  * concurrent late reports cannot both win (including error→error). */
1922
4083
  async reconcileReclaimedReport(runToken, lease, run, body) {
1923
- const providerOk = body.result === "success";
1924
- const canceled = body.result === "canceled";
1925
- const protocolMissing = providerOk ? runProtocolMissing(run) : [];
1926
- const ok = providerOk && protocolMissing.length === 0;
1927
- const message = run.message ?? void 0;
4084
+ const { ok, canceled, patch } = reportOutcome(body);
4085
+ const files = await this.terminalFiles(body);
1928
4086
  const loopPatch = {};
1929
4087
  const receipt = receiptFor(body, lease.runId);
1930
4088
  const payloadDigest = sha256(JSON.stringify(body));
@@ -1933,60 +4091,23 @@ var MachineGateway = class {
1933
4091
  reconciled = await reconcileReclaimedRun(lease.loopId, lease.runId, sha256(runToken), {
1934
4092
  phase: canceled ? "canceled" : ok ? "done" : "error",
1935
4093
  ...coerceTelemetry(body),
1936
- ...message !== void 0 ? { message } : {},
1937
- ...canceled ? { error: "stopped by user" } : protocolMissing.length ? { error: `run protocol incomplete: missing ${protocolMissing.join(", ")}` } : ok ? { error: null } : { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : run.error },
4094
+ ...patch,
4095
+ fileNotices: files.notices,
1938
4096
  ts: nowIso()
1939
- }, loopPatch, AUTOPAUSE_STREAK, receipt);
4097
+ }, loopPatch, AUTOPAUSE_STREAK, receipt, files.updates);
1940
4098
  } 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"]);
4099
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
4100
+ if (raced) return raced;
1944
4101
  throw error;
1945
4102
  }
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
- }
4103
+ if (!reconciled) return this.resolveLostTerminalRace(runToken, lease, body, payloadDigest, false, "report: late reconcile lost terminal race");
1974
4104
  const deleting = reconciled.loop.deleteRequestedAt != null;
1975
4105
  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;
4106
+ if (!deleting && !reportOnly) await this.captureRunSnapshot(lease.runId, lease.loopId);
1986
4107
  if (!reportOnly) this.scheduler.addLoop(reconciled.loop);
1987
4108
  if (!reportOnly) this.applyAutopauseTimer(lease.loopId, reconciled);
1988
4109
  if (deleting) await tryDeleteLoop(lease.loopId);
1989
- log$2.info({
4110
+ log$1.info({
1990
4111
  runId: lease.runId,
1991
4112
  ok,
1992
4113
  reclaimed: true
@@ -2039,7 +4160,7 @@ var MachineGateway = class {
2039
4160
  this.scheduler.addLoop(rejected.loop);
2040
4161
  if (rejected.state === "run-error") this.applyAutopauseTimer(lease.loopId, rejected);
2041
4162
  if (rejected.loop.deleteRequestedAt) await tryDeleteLoop(lease.loopId);
2042
- log$2.warn({
4163
+ log$1.warn({
2043
4164
  runId: lease.runId,
2044
4165
  reportId,
2045
4166
  code,
@@ -2093,8 +4214,6 @@ var MachineGateway = class {
2093
4214
  if (evidence.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
2094
4215
  if (issues.length) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_INVALID", issues);
2095
4216
  const expected = receiptFor(body, lease.runId);
2096
- const ok = body.result === "success";
2097
- const canceled = body.result === "canceled";
2098
4217
  const run = await getRun(lease.runId);
2099
4218
  if (run?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
2100
4219
  if (run?.phase === "done") return {
@@ -2106,67 +4225,29 @@ var MachineGateway = class {
2106
4225
  }
2107
4226
  };
2108
4227
  if (run?.phase === "error" && (lease.state === "terminal-grace" || lease.state === "reconciliation-only")) return this.reconcileReclaimedReport(runToken, lease, run, body);
2109
- const protocolMissing = ok && run ? runProtocolMissing(run) : [];
2110
- const effectiveOk = ok && protocolMissing.length === 0;
4228
+ const { ok: effectiveOk, canceled, patch } = reportOutcome(body);
4229
+ const files = await this.terminalFiles(body);
2111
4230
  const loopPatch = {};
2112
4231
  let terminal;
2113
4232
  try {
2114
4233
  terminal = await finalizeRunningRun(lease.loopId, lease.runId, {
2115
4234
  phase: canceled ? "canceled" : effectiveOk ? "done" : "error",
2116
4235
  ...coerceTelemetry(body),
2117
- ...canceled ? { error: "stopped by user" } : protocolMissing.length ? { error: `run protocol incomplete: missing ${protocolMissing.join(", ")}` } : effectiveOk ? {} : { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" },
4236
+ ...patch,
4237
+ fileNotices: files.notices,
2118
4238
  ts: nowIso()
2119
- }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected);
4239
+ }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected, files.updates);
2120
4240
  } 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"]);
4241
+ const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
4242
+ if (raced) return raced;
2124
4243
  throw error;
2125
4244
  }
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;
4245
+ if (!terminal) return this.resolveLostTerminalRace(runToken, lease, body, payloadDigest, true, "report: lost terminal race");
2157
4246
  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
- }
4247
+ if (!deleting) await this.captureRunSnapshot(lease.runId, lease.loopId);
2167
4248
  this.scheduler.addLoop(terminal.loop);
2168
4249
  this.applyAutopauseTimer(lease.loopId, terminal);
2169
- log$2.info({
4250
+ log$1.info({
2170
4251
  runId: lease.runId,
2171
4252
  ok: effectiveOk
2172
4253
  }, "report: finalized");
@@ -2280,162 +4361,14 @@ function nextFires(cron, timezone, n) {
2280
4361
  }
2281
4362
  }
2282
4363
  /**
2283
- * Artifact sync - the byte-ingress half of the machine gateway, split out of
2284
- * `MachineGateway` (which keeps poll/report/CLI/retention). Same wire surface,
2285
- * framework-agnostic like the rest of the gateway (`{ status, body }` results):
2286
- *
2287
- * POST /api/machine/sync (Bearer device token) → manifest reconcile
2288
- * PUT /api/machine/blob/:hash (Bearer device token) → negotiated blob upload
2289
- *
2290
- * plus `readBlob` (the download seam the artifact/diff readers resolve bytes
2291
- * through). Boot constructs ONE BlobStore and hands the SAME instance to this
2292
- * class and to `MachineGateway` (whose `maintainStorage` GC deletes the bytes
2293
- * written here).
2294
- */
2295
- var log$1 = logger.child({ mod: "gateway" });
2296
- var BLOB_PRESENCE_CONCURRENCY = 16;
2297
- async function mapConcurrent(items, limit, visit) {
2298
- let next = 0;
2299
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
2300
- while (next < items.length) {
2301
- const item = items[next++];
2302
- await visit(item);
2303
- }
2304
- }));
2305
- }
2306
- var ArtifactSync = class {
2307
- blobStore;
2308
- constructor(blobStore = createBlobStore()) {
2309
- this.blobStore = blobStore;
2310
- }
2311
- /**
2312
- * Reconcile one complete configured-path manifest after a run. Missing hashes
2313
- * are returned for upload through the raw blob PUT endpoint.
2314
- */
2315
- async sync(deviceToken, rawBody) {
2316
- const auth = await authenticateDeviceToken(deviceToken);
2317
- if (!auth.ok) return auth.response;
2318
- const { machineId } = auth;
2319
- const body = parseArtifactSyncBody(rawBody);
2320
- if (!body) return {
2321
- status: 400,
2322
- body: { error: "invalid sync body" }
2323
- };
2324
- const { loopId, manifest } = body;
2325
- const loop = await getLoop(loopId);
2326
- if (!loop || loop.machineId !== machineId) return {
2327
- status: 404,
2328
- body: { error: "no such loop on this machine" }
2329
- };
2330
- const allowedPaths = new Set(loop.artifacts);
2331
- if (manifest.some((entry) => !allowedPaths.has(entry.path))) return {
2332
- status: 400,
2333
- body: { error: "manifest path is not configured on this loop" }
2334
- };
2335
- const keepPaths = [];
2336
- const needHashes = /* @__PURE__ */ new Set();
2337
- const storedState = /* @__PURE__ */ new Map();
2338
- const stateFor = async (hash) => {
2339
- const cached = storedState.get(hash);
2340
- if (cached) return cached;
2341
- const metadata = await blobExists(hash);
2342
- const state = {
2343
- metadata,
2344
- bytes: metadata && await this.blobStore.has(hash)
2345
- };
2346
- storedState.set(hash, state);
2347
- return state;
2348
- };
2349
- await mapConcurrent([...new Set(manifest.flatMap((entry) => entry.oversize ? [] : [entry.hash]))], BLOB_PRESENCE_CONCURRENCY, async (hash) => {
2350
- await stateFor(hash);
2351
- });
2352
- for (const entry of manifest) {
2353
- if (entry.oversize) {
2354
- if (await upsertConfiguredArtifactFile({
2355
- loopId,
2356
- path: entry.path,
2357
- hash: null,
2358
- size: entry.size,
2359
- binary: entry.binary,
2360
- oversize: true
2361
- })) keepPaths.push(entry.path);
2362
- continue;
2363
- }
2364
- const hash = entry.hash;
2365
- const stored = await stateFor(hash);
2366
- if (!await upsertConfiguredArtifactFile({
2367
- loopId,
2368
- path: entry.path,
2369
- hash,
2370
- size: entry.size,
2371
- binary: entry.binary,
2372
- oversize: false
2373
- })) continue;
2374
- if (!stored.bytes) needHashes.add(hash);
2375
- keepPaths.push(entry.path);
2376
- }
2377
- const tombstoned = await reconcileConfiguredArtifacts(loopId, [...allowedPaths], keepPaths);
2378
- log$1.info({
2379
- machineId,
2380
- loopId,
2381
- files: keepPaths.length,
2382
- need: needHashes.size,
2383
- tombstoned
2384
- }, "sync: reconciled");
2385
- return {
2386
- status: 200,
2387
- body: {
2388
- ok: true,
2389
- needHashes: [...needHashes]
2390
- }
2391
- };
2392
- }
2393
- /**
2394
- * Upload one content-addressed blob's raw bytes (Bearer device token). The
2395
- * server recomputes sha256(body) and rejects any mismatch before storing —
2396
- * integrity + anti-poisoning, so a blob's bytes always match its key.
2397
- */
2398
- async putBlob(deviceToken, hash, bytes) {
2399
- const auth = await authenticateDeviceToken(deviceToken);
2400
- if (!auth.ok) return auth.response;
2401
- const { machineId } = auth;
2402
- if (!isValidHash(hash)) return {
2403
- status: 400,
2404
- body: { error: "invalid hash (expect sha256 hex)" }
2405
- };
2406
- if (bytes.length > 10485760) return {
2407
- status: 413,
2408
- body: { error: "blob exceeds size cap" }
2409
- };
2410
- if (sha256Buf(bytes) !== hash) return {
2411
- status: 400,
2412
- body: { error: "hash mismatch (sha256(body) !== :hash)" }
2413
- };
2414
- if (!await machineReferencesBlob(machineId, hash)) return {
2415
- status: 403,
2416
- body: { error: "hash was not requested for this machine (sync a manifest first)" }
2417
- };
2418
- await this.blobStore.put(hash, bytes);
2419
- await recordBlob(hash, bytes.length);
2420
- return {
2421
- status: 200,
2422
- body: { ok: true }
2423
- };
2424
- }
2425
- readBlob(hash) {
2426
- if (!isValidHash(hash)) return Promise.resolve(null);
2427
- return this.blobStore.get(hash);
2428
- }
2429
- };
2430
- /**
2431
- * Per-run artifact diff. Computed lazily on the server at read time
4364
+ * Per-run loop-file diff. Computed lazily on the server at read time
2432
4365
  * (no stored diffs): load run N's snapshot + the previous run's snapshot and diff
2433
- * the two path → metadata maps. For changed TEXT files we load both blobs and
4366
+ * the two name → metadata maps. For changed TEXT files we load both blobs and
2434
4367
  * compute a unified line diff with `diff` (jsdiff) — a pure-string library, no
2435
4368
  * process execution, so the server's zero-exec invariant holds. Binary/oversize
2436
4369
  * files emit a size-delta marker only (no inline diff).
2437
4370
  */
2438
- var runDiff_exports = /* @__PURE__ */ __exportAll$1({ computeRunDiff: () => computeRunDiff });
4371
+ var runDiff_exports = /* @__PURE__ */ __exportAll({ computeRunDiff: () => computeRunDiff });
2439
4372
  /**
2440
4373
  * Upper bound for computing an inline unified diff. jsdiff is synchronous and
2441
4374
  * roughly O(N*D); on a multi-MB or minified text file it would block the
@@ -2451,7 +4384,7 @@ function withinDiffCap(e) {
2451
4384
  }
2452
4385
  async function bytesOf(e) {
2453
4386
  if (!e.hash) return null;
2454
- return await (await getArtifactSync()).readBlob(e.hash) ?? null;
4387
+ return await (await getLoopFiles()).readBlob(e.hash) ?? null;
2455
4388
  }
2456
4389
  function unified(path, oldText, newText, maxEditLength) {
2457
4390
  return maxEditLength === void 0 ? createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run") : createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run", { maxEditLength });
@@ -2464,7 +4397,7 @@ function expectedInputBytes(change, verifiedSizes) {
2464
4397
  if (sizes.some((size) => size === void 0)) return null;
2465
4398
  return sizes.reduce((sum, size) => sum + size, 0);
2466
4399
  }
2467
- /** Compute one run's artifact diff. The default remains the web UI's complete
4400
+ /** Compute one run's loop-file diff. The default remains the web UI's complete
2468
4401
  * view. Budgeted callers get explicit totals and stop before blob reads/jsdiff
2469
4402
  * once any work limit is spent. */
2470
4403
  async function computeRunDiff(runId, budget) {
@@ -2879,6 +4812,7 @@ async function detailHistory(loop, q) {
2879
4812
  errorTruncated: error.truncated,
2880
4813
  finalText: finalText.value,
2881
4814
  finalTextTruncated: finalText.truncated,
4815
+ fileNotices: run.fileNotices ?? [],
2882
4816
  diffAvailable: !!snapshot,
2883
4817
  diff: q.diff ? await boundedDiff(run.id) : {
2884
4818
  included: false,
@@ -2904,6 +4838,7 @@ async function detailHistory(loop, q) {
2904
4838
  ["errorTruncated", detail.errorTruncated],
2905
4839
  ["finalText", detail.finalText],
2906
4840
  ["finalTextTruncated", detail.finalTextTruncated],
4841
+ ["fileNotices", { raw: JSON.stringify(detail.fileNotices) }],
2907
4842
  ["diffAvailable", detail.diffAvailable],
2908
4843
  ["diff", { raw: JSON.stringify(detail.diff) }]
2909
4844
  ]));
@@ -2928,8 +4863,8 @@ async function readLoopHistory(loop, flags) {
2928
4863
  * The verb router keys authority on credential type first (`dk_` device prefix
2929
4864
  * vs `rk_` run lease) and reuses the core gateway
2930
4865
  * methods (`createLoop`/`editLoop`) through the injected `MachineGateway`,
2931
- * plus the credential-neutral history module. Run credentials expose only the
2932
- * exactly-once report callback.
4866
+ * plus the credential-neutral history module. Run credentials authorize no CLI
4867
+ * verb: a run reports through its final response, not a callback.
2933
4868
  */
2934
4869
  var cliLog = logger.child({ mod: "cli" });
2935
4870
  var CliGateway = class {
@@ -2948,9 +4883,8 @@ var CliGateway = class {
2948
4883
  * already exists — never a rewrite — that keys authority on the credential type:
2949
4884
  * · DEVICE credential (`dk_`-prefixed) → owner authority over any loop bound to
2950
4885
  * the machine: `new`→createLoop, `loops`→listLoops, `edit`→editLoop,
2951
- * `log`→readLoopHistory, `show`→describe. `report` is RUN-only (403).
2952
- * · RUN credential (`rk_` run lease) → report and report help only.
2953
- * Every owner verb is rejected.
4886
+ * `log`→readLoopHistory, `show`→describe, `file`→loop files.
4887
+ * · RUN credential (`rk_` run lease) → rejected; runs have no CLI.
2954
4888
  */
2955
4889
  async cli(token, argv) {
2956
4890
  return finalizeCli(token.startsWith("dk_") ? await this.deviceCli(token, argv) : await this.runCli(token, argv));
@@ -3041,13 +4975,10 @@ var CliGateway = class {
3041
4975
  }
3042
4976
  };
3043
4977
  }
3044
- case "report": return {
3045
- status: 403,
3046
- body: { error: "pievo: report is a run-only verb; the owner edits via edit" }
3047
- };
4978
+ case "file": return this.ownerFile(machineId, argv);
3048
4979
  default: return {
3049
4980
  status: 400,
3050
- body: { error: `pievo: unknown command "${verb}" for the device credential (try: new, loops, pause, start, stop, delete, run stop, edit, log, show)` }
4981
+ body: { error: `pievo: unknown command "${verb}" for the device credential (try: new, loops, pause, start, stop, delete, run stop, edit, log, show, file)` }
3051
4982
  };
3052
4983
  }
3053
4984
  }
@@ -3096,7 +5027,7 @@ var CliGateway = class {
3096
5027
  }
3097
5028
  async requireStopProtocol(machineId, running) {
3098
5029
  if (!running) return void 0;
3099
- if ((await getMachine(machineId))?.daemonProtocol === 4) return void 0;
5030
+ if ((await getMachine(machineId))?.daemonProtocol === 5) return void 0;
3100
5031
  return {
3101
5032
  status: 426,
3102
5033
  body: {
@@ -3152,7 +5083,6 @@ var CliGateway = class {
3152
5083
  action: "force-delete",
3153
5084
  loopId: loop.id,
3154
5085
  machineId,
3155
- actorUserId: loop.userId,
3156
5086
  machineReachability: reachability
3157
5087
  });
3158
5088
  return {
@@ -3208,121 +5138,90 @@ var CliGateway = class {
3208
5138
  body: { text: `run already finished: ${runResultToken(stopped)}` }
3209
5139
  };
3210
5140
  }
3211
- async runCli(runToken, argv) {
3212
- const lease = await resolveLease(runToken);
3213
- if (!lease) return {
5141
+ async runCli(runToken, _argv) {
5142
+ if (!await resolveLease(runToken)) return {
3214
5143
  status: 401,
3215
5144
  body: {
3216
5145
  text: errorBlock("invalid or expired token", "UNAUTHORIZED"),
3217
5146
  exitCode: 1
3218
5147
  }
3219
5148
  };
3220
- if (lease.state === "terminal-grace" || lease.state === "reconciliation-only") return {
3221
- status: 409,
3222
- body: {
3223
- text: errorBlock(TERMINAL_GRACE_MSG, "CONFLICT"),
3224
- exitCode: 1
3225
- }
3226
- };
3227
- const verb = argv[0] ?? "";
3228
- const flags = parseFlags(argv.slice(1));
3229
- if (DEVICE_ONLY_VERBS.has(verb)) return {
5149
+ return {
3230
5150
  status: 403,
3231
5151
  body: {
3232
- text: errorBlock(`"${verb}" needs the device credential (owner authority); a run may only report`, "FORBIDDEN"),
5152
+ text: errorBlock(RUN_HAS_NO_CLI, "FORBIDDEN"),
3233
5153
  exitCode: 1
3234
5154
  }
3235
5155
  };
3236
- if (flags.help === true && verb === "report") return {
3237
- status: 200,
3238
- body: {
3239
- text: verbHelpText("report", lease),
3240
- exitCode: 0
3241
- }
5156
+ }
5157
+ /** `file list|get|set` over one loop's files. `set` takes base64 content so
5158
+ * arbitrary text (including leading `--`) survives argv transport. */
5159
+ async ownerFile(machineId, argv) {
5160
+ const [, sub, loopId, name] = argv;
5161
+ const loop = loopId ? await this.ownedLoop(machineId, loopId) : void 0;
5162
+ if (sub !== "list" && sub !== "get" && sub !== "set") return {
5163
+ status: 400,
5164
+ body: { error: `usage: ${FILE_USAGE}` }
3242
5165
  };
3243
- if (verb !== "report" && verb !== "help" && verb !== "--help" && verb !== "-h" && verb !== "") return {
3244
- status: 403,
3245
- body: {
3246
- text: errorBlock("run credentials authorize only pievo report", "FORBIDDEN"),
3247
- exitCode: 1
3248
- }
5166
+ if (!loop) return {
5167
+ status: 404,
5168
+ body: { error: "no such loop on this machine" }
3249
5169
  };
3250
- const out = await this.dispatch(lease, createHash("sha256").update(runToken).digest("hex"), argv);
3251
- return {
3252
- status: out.code,
3253
- body: {
3254
- text: out.text,
3255
- exitCode: out.code === 200 ? 0 : 1
3256
- }
5170
+ const names = loopFileNames(loop);
5171
+ if (sub === "list") return {
5172
+ status: 200,
5173
+ body: { text: doc(listBlock("files", [
5174
+ "name",
5175
+ "size",
5176
+ "limit",
5177
+ "updatedAt"
5178
+ ], await Promise.all(names.map(async (file) => {
5179
+ const row = await getArtifactFile(loop.id, file);
5180
+ const live = row && !row.deleted && row.hash ? row : void 0;
5181
+ return [
5182
+ file,
5183
+ live?.size ?? 0,
5184
+ fileLimitLabel(file),
5185
+ live ? fmtTime(live.updatedAt) : null
5186
+ ];
5187
+ }))), helpBlock([`Run \`pievo file get ${loop.id} <name>\` to print a file`, `Run \`pievo file set ${loop.id} <name> --from <path>\` to replace a file`])) }
3257
5188
  };
3258
- }
3259
- async dispatch(lease, leaseTokenHash, argv) {
3260
- const verb = argv[0];
3261
- const flags = parseFlags(argv.slice(1));
3262
- const str = (key) => typeof flags[key] === "string" ? flags[key] : void 0;
3263
- if (verb === void 0 || verb === "" || verb === "-h" || verb === "--help" || verb === "help") return {
3264
- code: 200,
3265
- text: this.helpText(lease)
3266
- };
3267
- if (verb !== "report") return derr(403, "run credentials authorize only pievo report", "FORBIDDEN");
3268
- if (flags.help === true) return {
3269
- code: 200,
3270
- text: verbHelpText("report", lease)
3271
- };
3272
- const rawStatus = str("status");
3273
- const status = reportStatus(rawStatus);
3274
- if (!status) return derr(400, `status must be keep|no-change|block${rawStatus === void 0 ? "" : ` (got "${rawStatus}")`}`, "VALIDATION_ERROR");
3275
- const message = str("message")?.trim();
3276
- if (!message) return derr(400, "report requires a non-empty --message", "VALIDATION_ERROR");
3277
- const acceptedFlags = [
3278
- "status",
3279
- "message",
3280
- "help"
3281
- ];
3282
- const unknown = Object.keys(flags).filter((key) => !acceptedFlags.includes(key));
3283
- if (unknown.length) return derr(400, `report does not accept --${unknown[0]}`, "VALIDATION_ERROR");
3284
- if (lease.state === "retired") return {
3285
- code: 200,
3286
- text: "reported: run retired · result discarded"
3287
- };
3288
- const applied = await this.applyReportMutation(lease, leaseTokenHash, status, message.slice(0, MESSAGE_CAP));
3289
- return applied.ok ? {
3290
- code: 200,
3291
- text: renderReportedText(status, true)
3292
- } : derr(applied.status ?? 409, applied.detail ?? "this run is no longer active", applied.code);
3293
- }
3294
- helpText(_lease) {
3295
- return doc(listBlock("verbs", ["verb", "syntax"], [["report", "--status keep|no-change|block --message <summary>"]]), helpBlock(["Call exactly one pievo report before finishing", "No other command is accepted from a run credential"]));
3296
- }
3297
- async applyReportMutation(lease, leaseTokenHash, status, message) {
3298
- const result = await recordRunReportOnce({
3299
- loopId: lease.loopId,
3300
- runId: lease.runId,
3301
- leaseTokenHash,
3302
- status,
3303
- message
3304
- });
3305
- if (result.state === "applied") return {
3306
- ok: true,
3307
- detail: "reported"
5189
+ if (!name || !names.includes(name)) return {
5190
+ status: 404,
5191
+ body: { error: `no such loop file: ${name ?? ""} (files: ${names.join(", ")})` }
3308
5192
  };
3309
- if (result.state === "missing-loop") return {
3310
- ok: false,
3311
- detail: "loop not found",
3312
- code: "NOT_FOUND",
3313
- status: 404
5193
+ if (sub === "get") {
5194
+ const row = await getArtifactFile(loop.id, name);
5195
+ const bytes = await this.gateway.loopFiles.readBlob(row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256);
5196
+ if (!bytes) return {
5197
+ status: 500,
5198
+ body: { error: "loop file bytes are missing" }
5199
+ };
5200
+ return {
5201
+ status: 200,
5202
+ body: { text: bytes.length ? bytes.toString("utf8") : "\n" }
5203
+ };
5204
+ }
5205
+ const encoded = parseFlags(argv.slice(4))["content-base64"];
5206
+ if (typeof encoded !== "string") return {
5207
+ status: 400,
5208
+ body: { error: `usage: ${FILE_USAGE}` }
3314
5209
  };
3315
- if (result.state === "already-reported") return {
3316
- ok: false,
3317
- detail: "this run already reported",
3318
- code: "CONFLICT",
3319
- status: 409
5210
+ const bytes = Buffer.from(encoded, "base64");
5211
+ const issue = loopFileIssue(name, bytes);
5212
+ if (issue) return {
5213
+ status: 413,
5214
+ body: { error: `${issue}; nothing was changed` }
5215
+ };
5216
+ const hash = await this.gateway.loopFiles.putBlob(bytes);
5217
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
5218
+ if (!await writeLoopFile(loop.id, name, hash, bytes.length)) return {
5219
+ status: 404,
5220
+ body: { error: "no such loop file" }
3320
5221
  };
3321
5222
  return {
3322
- ok: false,
3323
- detail: "this run is no longer active",
3324
- code: "CONFLICT",
3325
- status: 409
5223
+ status: 200,
5224
+ body: { text: `file: ${name}\nsize: ${bytes.length}\nupdated: true` }
3326
5225
  };
3327
5226
  }
3328
5227
  async describe(loopId, opts = {}) {
@@ -3367,17 +5266,8 @@ var CliGateway = class {
3367
5266
  })), scoped.elsewhere, await recentMachineRuns(loops, 3));
3368
5267
  }
3369
5268
  };
3370
- var TERMINAL_GRACE_MSG = "this run is terminal and no longer accepts commands; its final result is delivered via the terminal report";
3371
- /** Verbs that require device authority. `report` is run-only and rejected there. */
3372
- var DEVICE_ONLY_VERBS = new Set([
3373
- "new",
3374
- "edit",
3375
- "loops",
3376
- "start",
3377
- "stop",
3378
- "delete",
3379
- "run"
3380
- ]);
5269
+ var RUN_HAS_NO_CLI = "pievo commands are not available inside a run; end your final response with a summary and a last line `STATUS: keep|no-change|block`";
5270
+ var FILE_USAGE = "pievo file list <loop> | file get <loop> <name> | file set <loop> <name> --from <path>";
3381
5271
  var PAUSED_FINISHING = "loop paused; current run is finishing";
3382
5272
  var STOP_UPGRADE_REQUIRED = "Daemon upgrade required to stop a running process. Run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`.";
3383
5273
  var FORCE_DELETE_CONFIRMATION = "delete-server-data-anyway";
@@ -3415,15 +5305,6 @@ function parseJsonFlag(raw) {
3415
5305
  value: obj
3416
5306
  };
3417
5307
  }
3418
- /** A structured `error:`/`code:` TOON result for stdout.
3419
- * Mirrors the `{code, text}` shape `dispatch` returns; the slug defaults from the
3420
- * HTTP status but a caller may pin it (e.g. CONFLICT). */
3421
- function derr(code, message, slug) {
3422
- return {
3423
- code,
3424
- text: errorBlock(message, slug ?? codeForStatus(code))
3425
- };
3426
- }
3427
5308
  /** The structured keys a `/api/machine/cli` body may carry. The daemon renders
3428
5309
  * `text` and exits with `exitCode`; two structured channels remain because the
3429
5310
  * current daemon reads them as data:
@@ -3451,18 +5332,6 @@ function finalizeCli(res) {
3451
5332
  }
3452
5333
  return res;
3453
5334
  }
3454
- function renderReportedText(status, hasMessage) {
3455
- const parts = [];
3456
- if (status) parts.push(`status=${status}`);
3457
- if (hasMessage) parts.push("message recorded");
3458
- return `reported: ${parts.length ? parts.join(" · ") : "recorded"}`;
3459
- }
3460
- var RUN_VERB_HELP = { report: {
3461
- syntax: "report --status keep|no-change|block --message <text>",
3462
- summary: "record this run's required status and non-empty summary",
3463
- avail: () => "always available",
3464
- help: ["Run `pievo report --status no-change --message \"no actionable change\"` to close this run", "Run `pievo report --status keep --message \"completed the requested work\"` to retain a result"]
3465
- } };
3466
5335
  var DEVICE_VERB_HELP = {
3467
5336
  new: {
3468
5337
  syntax: "new --json '<config>' [--dry-run]",
@@ -3501,24 +5370,29 @@ var DEVICE_VERB_HELP = {
3501
5370
  },
3502
5371
  delete: {
3503
5372
  syntax: "delete <id> [--force]",
3504
- summary: "stop first, then delete server history and synced artifact metadata",
3505
- help: ["Stop this loop and delete its Pievo history and synced artifacts? Local project files are not deleted.", "--force requires a prior Delete request and explicit local confirmation; the local process may still be running"]
5373
+ summary: "stop first, then delete server history, memory, and artifacts",
5374
+ help: ["Stop this loop and delete its Pievo history, memory, and artifacts? Local project files are not deleted.", "--force requires a prior Delete request and explicit local confirmation; the local process may still be running"]
3506
5375
  },
3507
5376
  run: {
3508
5377
  syntax: "run stop <run-id>",
3509
5378
  summary: "stop one pending or running run without pausing its loop",
3510
5379
  help: ["A running run remains running until the daemon confirms cancellation"]
3511
5380
  },
5381
+ file: {
5382
+ syntax: "file list <id> | file get <id> <name> | file set <id> <name> --from <path>",
5383
+ summary: "inspect or replace a loop's MEMORY.md and artifact files",
5384
+ help: ["Run `pievo file get <id> MEMORY.md` to read the loop's memory", "An edit made while a run is executing wins over that run's version of the file"]
5385
+ },
3512
5386
  log: {
3513
5387
  syntax: "log [<id>] [--limit 1..20] [--since ISO] [--until ISO] [--status keep|no-change|block] [--phase done|error|canceled] [--run <index|UUID> [--diff]] [--json]",
3514
5388
  summary: "bounded terminal history or one detailed run",
3515
5389
  help: ["Run `pievo log <id> --json` for structured history", "Run `pievo log <id> --run 12 --diff` for bounded run detail and artifact diff"]
3516
5390
  }
3517
5391
  };
3518
- function verbHelpText(verb, lease) {
3519
- const spec = lease ? RUN_VERB_HELP[verb] : DEVICE_VERB_HELP[verb];
5392
+ function verbHelpText(verb) {
5393
+ const spec = DEVICE_VERB_HELP[verb];
3520
5394
  if (!spec) return void 0;
3521
- return doc(kvLine("verb", verb), kvLine("syntax", spec.syntax), kvLine("summary", spec.summary), lease && spec.avail ? kvLine("availability", spec.avail(lease)) : null, helpBlock(spec.help));
5395
+ return doc(kvLine("verb", verb), kvLine("syntax", spec.syntax), kvLine("summary", spec.summary), helpBlock(spec.help));
3522
5396
  }
3523
5397
  function loopEnvelope(loop) {
3524
5398
  return { ...canonicalLoopEnvelope(loop) };
@@ -3555,7 +5429,8 @@ function renderShowText(loop, env, totalRuns, lastRun, opts = {}) {
3555
5429
  const help = [
3556
5430
  `Run \`pievo show ${loop.id} --full\` to see the complete prompt`,
3557
5431
  `Run \`pievo edit ${loop.id} --json '{"schedule":{"mode":"continuous","delayMinutes":5}}'\` to change the schedule`,
3558
- `Run \`pievo log ${loop.id}\` to see recent run results`
5432
+ `Run \`pievo log ${loop.id}\` to see recent run results`,
5433
+ `Run \`pievo file list ${loop.id}\` to see the loop's memory and artifact files`
3559
5434
  ];
3560
5435
  return doc(block, kvLine("nextFire", nextFireDisplay(loop)), `lifecycle: ${loop.enabled ? "active" : "paused"}`, `runs: ${runsTally}`, !loop.enabled ? kvLine("pauseCause", loop.pauseCause?.kind === "failure-streak" ? `failure-streak (run ${loop.pauseCause.runId}, count ${loop.pauseCause.count})` : loop.pauseCause?.kind === "blocked" ? `blocked (run ${loop.pauseCause.runId})` : loop.pauseCause?.kind === "owner" ? "owner" : "unknown") : null, lastRun?.reportIncident ? kvLine("reportIncident", `${lastRun.reportIncident.code} · ${lastRun.reportIncident.faultDomain} · ${lastRun.reportIncident.reason}`) : null, helpBlock(help));
3561
5436
  }
@@ -3643,9 +5518,6 @@ function renderHomeText(ctx, presence, here, elsewhere, recent) {
3643
5518
  "Run `pievo new --json '{...}'` to create a loop"
3644
5519
  ]));
3645
5520
  }
3646
- function reportStatus(s) {
3647
- return s === "keep" || s === "no-change" || s === "block" ? s : void 0;
3648
- }
3649
5521
  /** Tiny flag parser: `--k v` pairs, bare `--flag` → true, first positional under `_`.
3650
5522
  * Every key/value is NUL-stripped at this wire boundary before validation or
3651
5523
  * persistence because Postgres text rejects NUL. */
@@ -3777,87 +5649,6 @@ function msg(err) {
3777
5649
  return err instanceof Error ? err.message : String(err);
3778
5650
  }
3779
5651
  /**
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
5652
  * Server boot — idempotent one-time init of the in-process backend: apply
3862
5653
  * migrations, wire the machine gateway's dispatcher into the Scheduler, start
3863
5654
  * the Scheduler + an offline-sweep interval. Guarded on `globalThis` so it
@@ -3865,11 +5656,11 @@ function startDbWatchdog(deps) {
3865
5656
  * from any server-side entry (the standalone machine server / TanStack server
3866
5657
  * fns); the first call boots, the rest share the same instance.
3867
5658
  */
3868
- var boot_exports = /* @__PURE__ */ __exportAll$1({
5659
+ var boot_exports = /* @__PURE__ */ __exportAll({
3869
5660
  ensureServer: () => ensureServer,
3870
- getArtifactSync: () => getArtifactSync,
3871
5661
  getCliGateway: () => getCliGateway,
3872
- getGateway: () => getGateway
5662
+ getGateway: () => getGateway,
5663
+ getLoopFiles: () => getLoopFiles
3873
5664
  });
3874
5665
  var g = globalThis;
3875
5666
  function ensureServer() {
@@ -3882,7 +5673,6 @@ function ensureServer() {
3882
5673
  return p;
3883
5674
  }
3884
5675
  async function boot() {
3885
- loginGateEnabled();
3886
5676
  await runMigrations();
3887
5677
  await repairTerminalRunLeases();
3888
5678
  const abort = new AbortController();
@@ -3895,40 +5685,19 @@ async function boot() {
3895
5685
  };
3896
5686
  let gateway;
3897
5687
  const scheduler = new Scheduler({ dispatch: (loop) => gateway.dispatcher.dispatch(loop) });
3898
- const blobStore = createBlobStore();
3899
- gateway = new MachineGateway(scheduler, blobStore);
3900
- const artifactSync = new ArtifactSync(blobStore);
5688
+ gateway = new MachineGateway(scheduler, createBlobStore());
3901
5689
  const cliGateway = new CliGateway(gateway);
3902
5690
  await scheduler.start(abort.signal);
3903
5691
  const sweep = setInterval(() => void gateway.sweep().catch((err) => logger.error({ err: String(err) }, "sweep tick failed")), ONLINE_TTL_MS);
3904
5692
  sweep.unref?.();
3905
5693
  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());
5694
+ const gc = setInterval(() => void gateway.maintainStorage().catch((err) => logger.error({ err: String(err) }, "gc tick failed")), GC_INTERVAL_MS);
3907
5695
  gc.unref?.();
3908
5696
  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
5697
  logger.info("pievo server booted");
3928
5698
  return {
3929
5699
  scheduler,
3930
5700
  gateway,
3931
- artifactSync,
3932
5701
  cliGateway,
3933
5702
  abort,
3934
5703
  shutdown
@@ -3937,11 +5706,11 @@ async function boot() {
3937
5706
  async function getGateway() {
3938
5707
  return (await ensureServer()).gateway;
3939
5708
  }
3940
- async function getArtifactSync() {
3941
- return (await ensureServer()).artifactSync;
5709
+ async function getLoopFiles() {
5710
+ return (await ensureServer()).gateway.loopFiles;
3942
5711
  }
3943
5712
  async function getCliGateway() {
3944
5713
  return (await ensureServer()).cliGateway;
3945
5714
  }
3946
5715
  //#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 };
5716
+ export { listRuns as A, requestDeleteLoop as B, getRun as C, listArtifacts as D, isOutdated as E, machinePresence as F, tryDeleteLoop as G, sha256 as H, mintDeviceToken as I, validateLoopTags as J, updateLoop as K, pauseLoop as L, loopTags_CnVLi70h_exports as M, loopsForMachine as N, listLoops as O, machineIdFromToken as P, recentLoopUsage as R, getMachine as S, hasRunningRun as T, startLoop as U, requestRunCancel as V, stopLoop as W, createMachine as _, canonicalLoopSummary as a, getArtifactFile as b, getLoopFiles as c, validOptionalPollString as d, validPollCurrentRun as f, countRuns as g, CODING_AGENTS as h, canonicalLoopEnvelope as i, listRunsBefore as j, listMachines as k, readByteBody as l, validateLoopEdit as m, POLL_HTTP_BODY_FIELDS as n, daemonNeedsUpdate as o, validPollWireId as p, updateMachine as q, boot_DmpKtb0A_exports as r, ensureServer as s, MIN_DAEMON_VERSION as t, readJsonBody as u, forceDeleteLoop as v, hasPendingRun as w, getLoop as x, forceDeleteMachine as y, reconciliationStatesForRuns as z };