@kky42/pievo-server 2.4.12 → 2.4.14

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 (88) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/SignIn-CIFwnZtF.js +1 -0
  3. package/.output/public/assets/{ui-s5IMg6RF.js → actionUi-5PtES4q0.js} +1 -1
  4. package/.output/public/assets/app-DvHMhs7S.css +2 -0
  5. package/.output/public/assets/index-CRX2pu0W.js +10 -0
  6. package/.output/public/assets/loops._loopId-BsuV-Rmf.js +67 -0
  7. package/.output/public/assets/loops._loopId_.runs._runId-DpK_fzmZ.js +3 -0
  8. package/.output/public/assets/{preload-helper-Bs7oCAsw.js → preload-helper-sAZOZb3G.js} +3 -3
  9. package/.output/public/assets/routes-Dm_3J2aV.js +1 -0
  10. package/.output/public/assets/routes-DxF_s3cD.js +1 -0
  11. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  12. package/.output/server/_ssr/{MachinesModal-BhRw7oOx.mjs → MachinesModal-BaEtzycr.mjs} +24 -57
  13. package/.output/server/_ssr/{SignIn-BhoZIOUp.mjs → SignIn-sHQaBQJC.mjs} +3 -3
  14. package/.output/server/_ssr/{ui-DGz-q9MT.mjs → actionUi-5HOCZnom.mjs} +115 -4
  15. package/.output/server/_ssr/{artifactFiles-ChuJ4PzC.mjs → artifactFiles-DO43wwXA.mjs} +4 -3
  16. package/.output/server/_ssr/{artifactKind-DXumOv7c.mjs → artifactKind-DwDr8IYc.mjs} +6 -14
  17. package/.output/server/_ssr/artifacts-Cd7Qx3Qp.mjs +2 -0
  18. package/.output/server/_ssr/{auth-LwPp_iim.mjs → auth-0AamrwHo.mjs} +44 -124
  19. package/.output/server/_ssr/{SocialLinks-DVCLmql0.mjs → auth-client-lSMb_Idt.mjs} +143 -109
  20. package/.output/server/_ssr/boot-C472kLwY.mjs +3947 -0
  21. package/.output/server/_ssr/{bun-sqlite-dialect-BW9W1_Ps-CHMDkKqA.mjs → bun-sqlite-dialect-BW9W1_Ps-DLIXocA0.mjs} +2 -2
  22. package/.output/server/_ssr/{d1-sqlite-dialect-BLC8LXE6-DYum6qky.mjs → d1-sqlite-dialect-BLC8LXE6-BoFMIPr_.mjs} +2 -2
  23. package/.output/server/_ssr/db-BzvMQ2kx.mjs +2 -0
  24. package/.output/server/_ssr/{dist-bWqs5sTy.mjs → dist-ClzqkwxD.mjs} +2 -2
  25. package/.output/server/_ssr/{factory-pBwrjVTI.mjs → factory-ixUjZqJn.mjs} +2 -2
  26. package/.output/server/_ssr/{kysely-adapter-DMf5pFTf.mjs → kysely-adapter-DkZbmKgV.mjs} +1 -1
  27. package/.output/server/_ssr/logger-DDjsYB2X.mjs +2 -0
  28. package/.output/server/_ssr/{loopApi-Im6JSK0p.mjs → loopApi-DT751TJA.mjs} +30 -90
  29. package/.output/server/_ssr/{loopProjection-DIpGR6rp.mjs → loopProjection-qaD2I48a.mjs} +5 -2
  30. package/.output/server/_ssr/loopTags-CXx5UdGf.mjs +3356 -0
  31. package/.output/server/_ssr/{loops._loopId-B5iPseDG.mjs → loops._loopId-CXgw2Rz1.mjs} +144 -46
  32. package/.output/server/_ssr/{loops._loopId_.runs._runId-DVtG0xYb.mjs → loops._loopId_.runs._runId-Dk94Z0Jq.mjs} +6 -13
  33. package/.output/server/_ssr/{loops._loopId_.runs._runId-BA-P6sGA.mjs → loops._loopId_.runs._runId-SASSqOfy.mjs} +31 -23
  34. package/.output/server/_ssr/{machineFns-Bb9KNXun.mjs → machineFns-BSrIgn3f.mjs} +42 -108
  35. package/.output/server/_ssr/{node-sqlite-dialect-Dho9YjXb.mjs → node-sqlite-dialect-BbUqnnYv.mjs} +2 -2
  36. package/.output/server/_ssr/{router-C8iotXtk.mjs → router-BlLlhfJe.mjs} +26 -40
  37. package/.output/server/_ssr/routes-Cr-9yDEH.mjs +491 -0
  38. package/.output/server/_ssr/{routes-FO0MFfYK.mjs → routes-DRBS4Zi4.mjs} +4 -9
  39. package/.output/server/_ssr/routes-DTBltabU.mjs +27 -0
  40. package/.output/server/_ssr/ssr.mjs +28 -88
  41. package/.output/server/_ssr/store-vDZ9INSa.mjs +2 -0
  42. package/.output/server/_ssr/tokens-BHiCElYD.mjs +2 -0
  43. package/.output/server/_tanstack-start-manifest_v-DxLIQ0cc.mjs +52 -0
  44. package/.output/server/drizzle/0002_remove_teams.sql +15 -0
  45. package/.output/server/drizzle/0003_add_loop_tags.sql +5 -0
  46. package/.output/server/drizzle/meta/_journal.json +14 -0
  47. package/.output/server/index.mjs +47 -96
  48. package/drizzle/0002_remove_teams.sql +15 -0
  49. package/drizzle/0003_add_loop_tags.sql +5 -0
  50. package/drizzle/meta/_journal.json +14 -0
  51. package/package.json +2 -2
  52. package/.output/public/assets/MachinesModal-ByHejech.js +0 -1
  53. package/.output/public/assets/SignIn-Cf4HOpdF.js +0 -1
  54. package/.output/public/assets/actionUi-yYVvoOTn.js +0 -1
  55. package/.output/public/assets/app-D5Hy4BcS.css +0 -2
  56. package/.output/public/assets/index-D504r3X9.js +0 -10
  57. package/.output/public/assets/invite._token-B0EKVyrU.js +0 -1
  58. package/.output/public/assets/invite._token-BSnGRyUw.js +0 -1
  59. package/.output/public/assets/lifecycleUi-C0DaC6NM.js +0 -1
  60. package/.output/public/assets/loops._loopId-j5EB2PLW.js +0 -67
  61. package/.output/public/assets/loops._loopId_.runs._runId-BICo_pRz.js +0 -3
  62. package/.output/public/assets/routes-4N6mrQK_.js +0 -1
  63. package/.output/public/assets/routes-DZU0Tqka.js +0 -1
  64. package/.output/public/assets/t._teamId-CAUjvMZ6.js +0 -1
  65. package/.output/public/assets/t._teamId-Cqzd69sO.js +0 -1
  66. package/.output/server/_ssr/DashboardView-DrKNkmcX.mjs +0 -867
  67. package/.output/server/_ssr/actionUi-Cf6DuEC2.mjs +0 -119
  68. package/.output/server/_ssr/artifacts-DOLHJHcd.mjs +0 -2
  69. package/.output/server/_ssr/boot-BIxtZHU-.mjs +0 -7466
  70. package/.output/server/_ssr/boot-D8cj4uZj.mjs +0 -2
  71. package/.output/server/_ssr/db-BphSJs66.mjs +0 -2
  72. package/.output/server/_ssr/invite._token-CPSvdDox.mjs +0 -27
  73. package/.output/server/_ssr/invite._token-CRBsTSTZ.mjs +0 -84
  74. package/.output/server/_ssr/invite._token-CaTLt92a.mjs +0 -20
  75. package/.output/server/_ssr/lifecycleUi-DauKavgx.mjs +0 -50
  76. package/.output/server/_ssr/logger-EinA2J6y.mjs +0 -2
  77. package/.output/server/_ssr/routes-CsV1weub.mjs +0 -26
  78. package/.output/server/_ssr/routes-fiMzFtcP.mjs +0 -39
  79. package/.output/server/_ssr/runDiff-BLdot9mi.mjs +0 -2
  80. package/.output/server/_ssr/store-CFCp_ggC.mjs +0 -2
  81. package/.output/server/_ssr/t._teamId-BYA4Dnjp.mjs +0 -43
  82. package/.output/server/_ssr/t._teamId-CKsrgN86.mjs +0 -32
  83. package/.output/server/_ssr/t._teamId-D1BTwhWf.mjs +0 -31
  84. package/.output/server/_ssr/teamCookie-B7hLOIie.mjs +0 -10
  85. package/.output/server/_ssr/teamFns-DfqtuBW2.mjs +0 -451
  86. package/.output/server/_ssr/teamFns-cI_tsiCv.mjs +0 -32
  87. package/.output/server/_ssr/tokens-CUz4LDLm.mjs +0 -2
  88. package/.output/server/_tanstack-start-manifest_v-xSxVtI1J.mjs +0 -86
@@ -0,0 +1,3947 @@
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";
3
+ 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";
6
+ import { t as createTwoFilesPatch } from "../_libs/diff.mjs";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import path from "node:path";
9
+ 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({
12
+ _: () => validOptionalPollString,
13
+ a: () => POLL_HTTP_BODY_FIELDS,
14
+ c: () => MIN_DAEMON_VERSION,
15
+ d: () => statusDefinitionsFromLoop,
16
+ f: () => validateLoopEdit,
17
+ g: () => readJsonBody,
18
+ h: () => readByteBody,
19
+ i: () => runDiff_exports,
20
+ l: () => daemonNeedsUpdate,
21
+ m: () => POLL_INFO_TEXT_CAP,
22
+ n: () => ensureServer,
23
+ o: () => validPollCurrentRun,
24
+ p: () => MACHINE_BODY_CAP,
25
+ r: () => getArtifactSync,
26
+ s: () => validPollWireId,
27
+ t: () => boot_exports,
28
+ u: () => scheduleFromLoop
29
+ });
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
+ /** Read raw request bytes under a hard cap. Declared oversize requests fail
44
+ * before reading; chunked/lying streams are canceled on the first chunk that
45
+ * takes the observed count over the cap. */
46
+ async function readByteBody(request, maxBytes) {
47
+ const declared = Number(request.headers.get("content-length") ?? "");
48
+ if (Number.isFinite(declared) && declared > maxBytes) return { kind: "too-large" };
49
+ if (!request.body) return {
50
+ kind: "ok",
51
+ bytes: new Uint8Array()
52
+ };
53
+ const reader = request.body.getReader();
54
+ const chunks = [];
55
+ let size = 0;
56
+ try {
57
+ while (true) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ size += value.byteLength;
61
+ if (size > maxBytes) {
62
+ reader.cancel("request body exceeds byte cap").catch(() => void 0);
63
+ return { kind: "too-large" };
64
+ }
65
+ chunks.push(value);
66
+ }
67
+ } catch {
68
+ return { kind: "invalid" };
69
+ } finally {
70
+ reader.releaseLock();
71
+ }
72
+ const bytes = new Uint8Array(size);
73
+ let offset = 0;
74
+ for (const chunk of chunks) {
75
+ bytes.set(chunk, offset);
76
+ offset += chunk.byteLength;
77
+ }
78
+ return {
79
+ kind: "ok",
80
+ bytes
81
+ };
82
+ }
83
+ /**
84
+ * Read + parse a JSON body under an actual byte cap. An empty body parses as
85
+ * `{}`; unreadable streams and parse failures are invalid.
86
+ */
87
+ async function readJsonBody(request, maxBytes) {
88
+ const read = await readByteBody(request, maxBytes);
89
+ if (read.kind !== "ok") return read;
90
+ if (read.bytes.byteLength === 0) return {
91
+ kind: "ok",
92
+ body: {}
93
+ };
94
+ const text = new TextDecoder().decode(read.bytes);
95
+ if (!text) return {
96
+ kind: "ok",
97
+ body: {}
98
+ };
99
+ try {
100
+ return {
101
+ kind: "ok",
102
+ body: JSON.parse(text)
103
+ };
104
+ } catch {
105
+ return { kind: "invalid" };
106
+ }
107
+ }
108
+ var WIRE_TEXT_CAP = 512 * 1024;
109
+ /** Validate an optional poll string without coercion, clipping, or NUL removal. */
110
+ function validOptionalPollString(value, cap) {
111
+ return value === void 0 || typeof value === "string" && value.length <= cap && !value.includes("\0");
112
+ }
113
+ function nowIso() {
114
+ return (/* @__PURE__ */ new Date()).toISOString();
115
+ }
116
+ /** Strip NUL (U+0000) from a wire string: Postgres text/jsonb columns REJECT the
117
+ * NUL byte (SQLite tolerated it), so a daemon-supplied string carrying one would
118
+ * 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`
120
+ * (parseFlags/validateState, the same one-chokepoint discipline). */
121
+ function stripNul(s) {
122
+ return s.replace(/\u0000/g, "");
123
+ }
124
+ function clipText(s, cap) {
125
+ return stripNul(s.slice(0, cap));
126
+ }
127
+ /**
128
+ * Deliver the user's prompt unchanged, followed only by the complete report
129
+ * contract. The run index intentionally remains an internal history fact.
130
+ */
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>`;
133
+ }
134
+ async function buildDelivery(loop, queuedRun, runToken, roots) {
135
+ if (queuedRun.runIndex == null) throw new Error(`claimed run ${queuedRun.id} has no history index`);
136
+ return {
137
+ runId: queuedRun.id,
138
+ runIndex: queuedRun.runIndex,
139
+ runToken,
140
+ roots,
141
+ loop: {
142
+ id: loop.id,
143
+ name: loop.name,
144
+ workdir: loop.workdir,
145
+ model: loop.model ?? null,
146
+ reasoningEffort: loop.reasoningEffort ?? null,
147
+ agent: loop.agent
148
+ },
149
+ task: buildRunTask(loop),
150
+ artifacts: loop.artifacts
151
+ };
152
+ }
153
+ /**
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.
406
+ *
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,
409
+ * the OLD blob is no longer pointed at by the current file row — but nothing was
410
+ * reclaiming it, so the byte store grew forever. This module closes that gap with two pieces:
411
+ *
412
+ * 1. pruneSnapshots() — bound the per-loop run-snapshot history to a window, so
413
+ * old snapshots stop pinning the blobs they referenced for diffs.
414
+ * 2. gcBlobs() — delete blob bytes no LIVE row needs: a blob is collected
415
+ * only when NO artifact_files row AND NO retained run_snapshot references its
416
+ * hash. CORRECTNESS over aggressiveness — a still-referenced blob is never
417
+ * deleted; a leaked blob is only a cost bug the next pass reclaims.
418
+ *
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
423
+ * re-referenced before we touch it keeps both its bytes and metadata), and a
424
+ * bytes-before-metadata delete ordering: each candidate's bytes are reclaimed
425
+ * 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
427
+ * delete) re-uploads the bytes (self-healing). We never leave a live blobs row
428
+ * pointing at deleted bytes.
429
+ */
430
+ var log$3 = logger.child({ mod: "retention" });
431
+ /**
432
+ * Prune every loop's run snapshots down to the configured retention window
433
+ * (most-recent-N). Returns the total number pruned. This is what makes an old
434
+ * snapshot's now-unreferenced blobs collectable by gcBlobs().
435
+ */
436
+ async function pruneSnapshots(keep = snapshotRetention()) {
437
+ let pruned = 0;
438
+ for (const loopId of await loopIdsWithSnapshots()) pruned += await pruneRunSnapshots(loopId, keep);
439
+ return pruned;
440
+ }
441
+ /**
442
+ * Reclaim unreferenced blob bytes. Returns the number of blobs collected.
443
+ *
444
+ * Algorithm:
445
+ * 1. Compute the live keep-set (all hashes any artifact_files row / retained
446
+ * snapshot references) and the grace-windowed candidate list — both reads are
447
+ * synchronous (interleave-free under the single-threaded event loop).
448
+ * 2. Garbage = candidates not in the keep-set.
449
+ * 3. For each garbage hash, in order:
450
+ * a. Pre-delete guard: if a sync re-referenced it since the keep-set was
451
+ * computed, skip it — leaving both its bytes AND metadata row intact
452
+ * (a consistent live blob, nothing to heal). The guard is two O(1) checks:
453
+ * an indexed artifact_files point query (always fresh) plus membership in a
454
+ * snapshot-hash Set deserialized ONCE per pass and rebuilt only when the
455
+ * snapshot row count changes (a report() raced us) — never the old
456
+ * per-candidate full snapshot scan (which was O(garbage × snapshots), since
457
+ * genuine garbage always misses the file check and fell through to it).
458
+ * b. Otherwise delete the BYTES (the await), then drop the metadata row
459
+ * UNCONDITIONALLY. Bytes-before-metadata is the data-loss fix: if a sync
460
+ * raced the byte delete (re-referencing the hash + recreating the blobs
461
+ * row mid-await), dropping the metadata still leaves blobExists()=false,
462
+ * so that file re-uploads its bytes on the next sync. We never leave a
463
+ * live blobs row pointing at deleted bytes.
464
+ */
465
+ async function gcBlobs(blobStore, graceMs = blobGcGraceMs()) {
466
+ const refs = await liveBlobRefs();
467
+ const garbage = (await blobHashesOlderThan(new Date(Date.now() - graceMs).toISOString())).filter((h) => !refs.has(h));
468
+ let snapCount = await countRunSnapshots();
469
+ let snapRefs = await snapshotBlobRefs();
470
+ let reclaimed = 0;
471
+ for (const hash of garbage) {
472
+ const count = await countRunSnapshots();
473
+ if (count !== snapCount) {
474
+ snapCount = count;
475
+ snapRefs = await snapshotBlobRefs();
476
+ }
477
+ if (await artifactFileReferencesHash(hash) || snapRefs.has(hash)) continue;
478
+ try {
479
+ await blobStore.delete(hash);
480
+ await deleteBlob(hash);
481
+ reclaimed++;
482
+ } catch (err) {
483
+ log$3.warn({
484
+ hash,
485
+ err: err instanceof Error ? err.message : String(err)
486
+ }, "gc: blob byte-delete failed");
487
+ }
488
+ }
489
+ return reclaimed;
490
+ }
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({
501
+ snapshotsPruned,
502
+ blobsReclaimed
503
+ }, "storage maintenance");
504
+ return {
505
+ snapshotsPruned,
506
+ blobsReclaimed
507
+ };
508
+ }
509
+ /**
510
+ * Resolve a device credential and verify the complete token hash stored on the
511
+ * machine row. The derived, truncated machine id is only a lookup key; it is
512
+ * never sufficient authentication.
513
+ *
514
+ * Enrollment/status/home may allow a genuinely unknown token, but a token that
515
+ * collides with an existing machine id still fails the full-hash check.
516
+ */
517
+ async function authenticateDeviceToken(deviceToken, options = {}) {
518
+ if (!isDeviceTokenShape(deviceToken)) return {
519
+ ok: false,
520
+ reason: "invalid",
521
+ response: {
522
+ status: 401,
523
+ body: { error: "invalid device token" }
524
+ }
525
+ };
526
+ const machineId = machineIdFromToken(deviceToken);
527
+ const machine = await getMachine(machineId);
528
+ if (!machine) return options.allowUnknown ? {
529
+ ok: true,
530
+ machineId,
531
+ machine: void 0
532
+ } : {
533
+ ok: false,
534
+ reason: "unknown",
535
+ response: {
536
+ status: 401,
537
+ body: { error: "unknown machine (token not registered)" }
538
+ }
539
+ };
540
+ if (!machine.tokenHash || machine.tokenHash !== sha256(deviceToken)) return {
541
+ ok: false,
542
+ reason: "mismatch",
543
+ response: {
544
+ status: 401,
545
+ body: { error: "device token mismatch" }
546
+ }
547
+ };
548
+ return {
549
+ ok: true,
550
+ machineId,
551
+ machine
552
+ };
553
+ }
554
+ function needsQuote(s) {
555
+ return s === "" || /[\s,:"]/.test(s);
556
+ }
557
+ function quote(s) {
558
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\r?\n/g, "\\n")}"`;
559
+ }
560
+ function scalar(v) {
561
+ if (v === null || v === void 0) return "—";
562
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "—";
563
+ if (typeof v === "boolean") return v ? "true" : "false";
564
+ return needsQuote(v) ? quote(v) : v;
565
+ }
566
+ function kvLine(key, v) {
567
+ return `${key}: ${scalar(v)}`;
568
+ }
569
+ function detailBlock(topKey, rows) {
570
+ const lines = [`${topKey}:`];
571
+ for (const [k, v] of rows) {
572
+ const rendered = v !== null && typeof v === "object" && "raw" in v ? v.raw : scalar(v);
573
+ lines.push(` ${k}: ${rendered}`);
574
+ }
575
+ return lines.join("\n");
576
+ }
577
+ function countLine(count, opts = {}) {
578
+ if (opts.total !== void 0) return `count: ${count} of ${opts.total} total`;
579
+ if (opts.showing !== void 0) return `count: ${count} (showing first ${opts.showing})`;
580
+ return `count: ${count}`;
581
+ }
582
+ function listBlock(name, fields, rows) {
583
+ const lines = [`${name}[${rows.length}]{${fields.join(",")}}:`];
584
+ for (const row of rows) lines.push(` ${row.map(scalar).join(",")}`);
585
+ return lines.join("\n");
586
+ }
587
+ function emptyList(name) {
588
+ return `${name}: []`;
589
+ }
590
+ function helpBlock(lines) {
591
+ return [`help[${lines.length}]:`, ...lines.map((l) => ` ${l}`)].join("\n");
592
+ }
593
+ function errorBlock(message, code) {
594
+ return `error: ${quote(message)}\ncode: ${code}`;
595
+ }
596
+ function codeForStatus(status) {
597
+ switch (status) {
598
+ case 400: return "VALIDATION_ERROR";
599
+ case 401: return "UNAUTHORIZED";
600
+ case 403: return "FORBIDDEN";
601
+ case 404: return "NOT_FOUND";
602
+ case 409: return "CONFLICT";
603
+ case 429: return "RATE_LIMITED";
604
+ default: return "ERROR";
605
+ }
606
+ }
607
+ function truncate(text, cap, tail = "use --full to see complete body") {
608
+ if (text.length <= cap) return {
609
+ value: text,
610
+ truncated: false
611
+ };
612
+ return {
613
+ value: `${text.slice(0, cap)} (truncated, ${text.length} chars total — ${tail})`,
614
+ truncated: true
615
+ };
616
+ }
617
+ function doc(...sections) {
618
+ return sections.filter((s) => typeof s === "string" && s.length > 0).join("\n");
619
+ }
620
+ var LOOP_CONFIG_FIELDS = [
621
+ "name",
622
+ "tags",
623
+ "schedule",
624
+ "workdir",
625
+ "agent",
626
+ "model",
627
+ "reasoningEffort",
628
+ "prompt",
629
+ "statusDefinitions",
630
+ "artifacts",
631
+ "enabled"
632
+ ];
633
+ var LOOP_EDIT_FIELDS = new Set(LOOP_CONFIG_FIELDS);
634
+ var SAFE_RETAINED_CRON = "0 0 1 1 *";
635
+ function object(value) {
636
+ return value != null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
637
+ }
638
+ function requiredText(value, field, preserve = false) {
639
+ if (typeof value !== "string" || !value.trim()) return {
640
+ ok: false,
641
+ detail: `${field} is required`
642
+ };
643
+ if (value.includes("\0")) return {
644
+ ok: false,
645
+ detail: `${field} must not contain NUL`
646
+ };
647
+ if (value.length > 524288) return {
648
+ ok: false,
649
+ detail: `${field} exceeds ${WIRE_TEXT_CAP} characters`
650
+ };
651
+ return {
652
+ ok: true,
653
+ value: preserve ? value : value.trim()
654
+ };
655
+ }
656
+ /** The server may validate a workdir for a daemon on another OS. Accept only
657
+ * absolute POSIX or Windows syntax; never resolve a relative path against the
658
+ * server process cwd. */
659
+ function isAbsoluteWorkdir(value) {
660
+ return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
661
+ }
662
+ function validateWorkdir(value) {
663
+ const workdir = requiredText(value, "workdir");
664
+ if (!workdir.ok) return workdir;
665
+ return isAbsoluteWorkdir(workdir.value) ? workdir : {
666
+ ok: false,
667
+ detail: "workdir must be an absolute path"
668
+ };
669
+ }
670
+ function normalizeProviderSetting(input) {
671
+ if (typeof input !== "string") return null;
672
+ return input.replaceAll("\0", "").trim() || null;
673
+ }
674
+ function optionalProvider(value, field) {
675
+ if (value !== void 0 && value !== null && typeof value !== "string") return {
676
+ ok: false,
677
+ detail: `${field} must be a string or null`
678
+ };
679
+ return {
680
+ ok: true,
681
+ value: normalizeProviderSetting(value)
682
+ };
683
+ }
684
+ var PI_THINKING = new Set([
685
+ "off",
686
+ "minimal",
687
+ "low",
688
+ "medium",
689
+ "high",
690
+ "xhigh",
691
+ "max"
692
+ ]);
693
+ function validateReasoningEffort(value, agent) {
694
+ const result = optionalProvider(value, "reasoningEffort");
695
+ if (!result.ok) return result;
696
+ if (agent === "pi" && result.value !== null && !PI_THINKING.has(result.value)) return {
697
+ ok: false,
698
+ detail: "reasoningEffort for pi must be one of off, minimal, low, medium, high, xhigh, max, or null"
699
+ };
700
+ return result;
701
+ }
702
+ function validTimezone(timezone) {
703
+ try {
704
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
705
+ return true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+ function validateCron(cron, timezone) {
711
+ try {
712
+ const probe = new E(cron, {
713
+ paused: true,
714
+ timezone
715
+ });
716
+ try {
717
+ const first = probe.nextRun();
718
+ const second = first ? probe.nextRun(first) : null;
719
+ if (!first || !second) return {
720
+ ok: false,
721
+ detail: "schedule.cron must have future occurrences"
722
+ };
723
+ if (second.getTime() - first.getTime() < 6e4) return {
724
+ ok: false,
725
+ detail: "schedule.cron must not run more than once per minute"
726
+ };
727
+ } finally {
728
+ probe.stop();
729
+ }
730
+ return {
731
+ ok: true,
732
+ value: cron
733
+ };
734
+ } catch (error) {
735
+ return {
736
+ ok: false,
737
+ detail: `invalid schedule.cron: ${error instanceof Error ? error.message : String(error)}`
738
+ };
739
+ }
740
+ }
741
+ function validateSchedule(value) {
742
+ const raw = object(value);
743
+ if (!raw) return {
744
+ ok: false,
745
+ detail: "schedule is required"
746
+ };
747
+ if (raw.mode === "cron") {
748
+ const unknown = Object.keys(raw).filter((key) => ![
749
+ "mode",
750
+ "cron",
751
+ "timezone",
752
+ "overlap"
753
+ ].includes(key));
754
+ if (unknown.length) return {
755
+ ok: false,
756
+ detail: `cron schedule has unknown field(s): ${unknown.join(", ")}`
757
+ };
758
+ const cron = requiredText(raw.cron, "schedule.cron");
759
+ if (!cron.ok) return cron;
760
+ const timezone = requiredText(raw.timezone, "schedule.timezone");
761
+ if (!timezone.ok) return timezone;
762
+ if (!validTimezone(timezone.value)) return {
763
+ ok: false,
764
+ detail: `invalid schedule.timezone: ${timezone.value}`
765
+ };
766
+ const validCron = validateCron(cron.value, timezone.value);
767
+ if (!validCron.ok) return validCron;
768
+ if (raw.overlap !== "skip" && raw.overlap !== "queue-one") return {
769
+ ok: false,
770
+ detail: "schedule.overlap must be skip|queue-one"
771
+ };
772
+ return {
773
+ ok: true,
774
+ value: {
775
+ mode: "cron",
776
+ cron: validCron.value,
777
+ timezone: timezone.value,
778
+ overlap: raw.overlap
779
+ }
780
+ };
781
+ }
782
+ if (raw.mode === "continuous") {
783
+ const unknown = Object.keys(raw).filter((key) => !["mode", "delayMinutes"].includes(key));
784
+ if (unknown.length) return {
785
+ ok: false,
786
+ detail: `continuous schedule has unknown field(s): ${unknown.join(", ")}`
787
+ };
788
+ if (typeof raw.delayMinutes !== "number" || !Number.isInteger(raw.delayMinutes) || raw.delayMinutes < 1) return {
789
+ ok: false,
790
+ detail: "schedule.delayMinutes must be a JSON integer >= 1"
791
+ };
792
+ return {
793
+ ok: true,
794
+ value: {
795
+ mode: "continuous",
796
+ delayMinutes: raw.delayMinutes
797
+ }
798
+ };
799
+ }
800
+ return {
801
+ ok: false,
802
+ detail: "schedule.mode must be cron|continuous"
803
+ };
804
+ }
805
+ function validateStatusDefinitions(value) {
806
+ const raw = object(value);
807
+ if (!raw) return {
808
+ ok: false,
809
+ detail: "statusDefinitions is required"
810
+ };
811
+ const unknown = Object.keys(raw).filter((key) => ![
812
+ "keep",
813
+ "noChange",
814
+ "block"
815
+ ].includes(key));
816
+ if (unknown.length) return {
817
+ ok: false,
818
+ detail: `statusDefinitions has unknown field(s): ${unknown.join(", ")}`
819
+ };
820
+ const keep = requiredText(raw.keep, "statusDefinitions.keep", true);
821
+ if (!keep.ok) return keep;
822
+ const noChange = requiredText(raw.noChange, "statusDefinitions.noChange", true);
823
+ if (!noChange.ok) return noChange;
824
+ const block = requiredText(raw.block, "statusDefinitions.block", true);
825
+ if (!block.ok) return block;
826
+ return {
827
+ ok: true,
828
+ value: {
829
+ keep: keep.value,
830
+ noChange: noChange.value,
831
+ block: block.value
832
+ }
833
+ };
834
+ }
835
+ function validateArtifactPaths(value) {
836
+ if (value === void 0 || value === null) return {
837
+ ok: true,
838
+ value: []
839
+ };
840
+ if (!Array.isArray(value)) return {
841
+ ok: false,
842
+ detail: "artifacts must be an array of exact relative file paths"
843
+ };
844
+ const paths = [];
845
+ const seen = /* @__PURE__ */ new Set();
846
+ 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 {
856
+ ok: false,
857
+ detail: `artifact path must be relative to workdir: ${entry}`
858
+ };
859
+ if (entry.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return {
860
+ ok: false,
861
+ detail: `artifact path must not traverse or alias another path: ${entry}`
862
+ };
863
+ if (seen.has(entry)) return {
864
+ ok: false,
865
+ detail: `duplicate artifact path: ${entry}`
866
+ };
867
+ seen.add(entry);
868
+ paths.push(entry);
869
+ }
870
+ return {
871
+ ok: true,
872
+ value: paths
873
+ };
874
+ }
875
+ function rowSchedule(schedule, retainedCron = SAFE_RETAINED_CRON) {
876
+ return schedule.mode === "cron" ? {
877
+ cron: schedule.cron,
878
+ scheduleMode: "cron",
879
+ cronOverlap: schedule.overlap,
880
+ continuousDelayMinutes: 1,
881
+ timezone: schedule.timezone
882
+ } : {
883
+ cron: retainedCron,
884
+ scheduleMode: "continuous",
885
+ cronOverlap: "queue-one",
886
+ continuousDelayMinutes: schedule.delayMinutes,
887
+ timezone: null
888
+ };
889
+ }
890
+ function scheduleFromLoop(loop) {
891
+ if (loop.scheduleMode === "continuous") return {
892
+ mode: "continuous",
893
+ delayMinutes: loop.continuousDelayMinutes
894
+ };
895
+ if (loop.scheduleMode === "cron") {
896
+ if (!loop.timezone) throw new Error(`invariant: cron loop ${loop.id} has no timezone`);
897
+ return {
898
+ mode: "cron",
899
+ cron: loop.cron,
900
+ timezone: loop.timezone,
901
+ overlap: loop.cronOverlap
902
+ };
903
+ }
904
+ throw new Error(`invariant: loop ${loop.id} has unknown schedule mode: ${String(loop.scheduleMode)}`);
905
+ }
906
+ function statusDefinitionsFromLoop(loop) {
907
+ return {
908
+ keep: loop.statusKeep,
909
+ noChange: loop.statusNoChange,
910
+ block: loop.statusBlock
911
+ };
912
+ }
913
+ function canonicalLoopEnvelope(loop) {
914
+ return {
915
+ id: loop.id,
916
+ name: loop.name,
917
+ tags: loop.tags,
918
+ schedule: scheduleFromLoop(loop),
919
+ workdir: loop.workdir,
920
+ agent: loop.agent,
921
+ 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
927
+ };
928
+ }
929
+ function validateLoopCreate(value) {
930
+ const raw = object(value);
931
+ if (!raw) return {
932
+ ok: false,
933
+ detail: "loop config must be an object"
934
+ };
935
+ const unknown = Object.keys(raw).filter((key) => !LOOP_CONFIG_FIELDS.includes(key));
936
+ if (unknown.length) return {
937
+ ok: false,
938
+ detail: `unknown field(s): ${unknown.join(", ")} — allowed: ${LOOP_CONFIG_FIELDS.join(", ")}`
939
+ };
940
+ const name = requiredText(raw.name, "name");
941
+ if (!name.ok) return name;
942
+ const tags = validateLoopTags(raw.tags);
943
+ if (!tags.ok) return tags;
944
+ const workdir = validateWorkdir(raw.workdir);
945
+ if (!workdir.ok) return workdir;
946
+ const prompt = requiredText(raw.prompt, "prompt", true);
947
+ if (!prompt.ok) return prompt;
948
+ const schedule = validateSchedule(raw.schedule);
949
+ if (!schedule.ok) return schedule;
950
+ const statusDefinitions = validateStatusDefinitions(raw.statusDefinitions);
951
+ if (!statusDefinitions.ok) return statusDefinitions;
952
+ const artifacts = validateArtifactPaths(raw.artifacts);
953
+ if (!artifacts.ok) return artifacts;
954
+ const model = optionalProvider(raw.model, "model");
955
+ if (!model.ok) return model;
956
+ const agent = coerceCodingAgent(raw.agent);
957
+ if (!agent) return {
958
+ ok: false,
959
+ detail: `agent is required and must be one of ${CODING_AGENTS.join(", ")}`
960
+ };
961
+ const reasoningEffort = validateReasoningEffort(raw.reasoningEffort, agent);
962
+ if (!reasoningEffort.ok) return reasoningEffort;
963
+ if (raw.enabled !== void 0 && typeof raw.enabled !== "boolean") return {
964
+ ok: false,
965
+ detail: "enabled must be boolean"
966
+ };
967
+ const config = {
968
+ name: name.value,
969
+ tags: tags.value,
970
+ schedule: schedule.value,
971
+ workdir: workdir.value,
972
+ agent,
973
+ model: model.value,
974
+ reasoningEffort: reasoningEffort.value,
975
+ prompt: prompt.value,
976
+ statusDefinitions: statusDefinitions.value,
977
+ artifacts: artifacts.value,
978
+ enabled: raw.enabled !== false
979
+ };
980
+ return {
981
+ ok: true,
982
+ value: {
983
+ config,
984
+ row: {
985
+ name: config.name,
986
+ tags: config.tags,
987
+ ...rowSchedule(config.schedule),
988
+ workdir: config.workdir,
989
+ agent: config.agent,
990
+ model: config.model,
991
+ reasoningEffort: config.reasoningEffort,
992
+ prompt: config.prompt,
993
+ statusKeep: config.statusDefinitions.keep,
994
+ statusNoChange: config.statusDefinitions.noChange,
995
+ statusBlock: config.statusDefinitions.block,
996
+ artifacts: config.artifacts,
997
+ enabled: config.enabled
998
+ }
999
+ }
1000
+ };
1001
+ }
1002
+ function validateLoopEdit(loop, value) {
1003
+ const raw = object(value);
1004
+ if (!raw) return {
1005
+ ok: false,
1006
+ detail: "loop patch must be an object"
1007
+ };
1008
+ const unknown = Object.keys(raw).filter((key) => !LOOP_EDIT_FIELDS.has(key));
1009
+ if (unknown.length) return {
1010
+ ok: false,
1011
+ detail: `unknown field(s): ${unknown.join(", ")} — allowed: ${[...LOOP_EDIT_FIELDS].join(", ")}`
1012
+ };
1013
+ const update = {};
1014
+ if (raw.name !== void 0) {
1015
+ const result = requiredText(raw.name, "name");
1016
+ if (!result.ok) return result;
1017
+ update.name = result.value;
1018
+ }
1019
+ if (raw.tags !== void 0) {
1020
+ const result = validateLoopTags(raw.tags);
1021
+ if (!result.ok) return result;
1022
+ update.tags = result.value;
1023
+ }
1024
+ if (raw.workdir !== void 0) {
1025
+ const result = validateWorkdir(raw.workdir);
1026
+ if (!result.ok) return result;
1027
+ update.workdir = result.value;
1028
+ }
1029
+ if (raw.prompt !== void 0) {
1030
+ const result = requiredText(raw.prompt, "prompt", true);
1031
+ if (!result.ok) return result;
1032
+ update.prompt = result.value;
1033
+ }
1034
+ if (raw.schedule !== void 0) {
1035
+ const result = validateSchedule(raw.schedule);
1036
+ if (!result.ok) return result;
1037
+ Object.assign(update, rowSchedule(result.value, loop.cron));
1038
+ }
1039
+ if (raw.statusDefinitions !== void 0) {
1040
+ const result = validateStatusDefinitions(raw.statusDefinitions);
1041
+ if (!result.ok) return result;
1042
+ update.statusKeep = result.value.keep;
1043
+ update.statusNoChange = result.value.noChange;
1044
+ update.statusBlock = result.value.block;
1045
+ }
1046
+ if (raw.artifacts !== void 0) {
1047
+ const result = validateArtifactPaths(raw.artifacts);
1048
+ if (!result.ok) return result;
1049
+ update.artifacts = result.value;
1050
+ }
1051
+ if (raw.model !== void 0) {
1052
+ const result = optionalProvider(raw.model, "model");
1053
+ if (!result.ok) return result;
1054
+ update.model = result.value;
1055
+ }
1056
+ let effectiveAgent = loop.agent;
1057
+ if (raw.agent !== void 0) {
1058
+ const agent = coerceCodingAgent(raw.agent);
1059
+ if (!agent) return {
1060
+ ok: false,
1061
+ detail: `agent must be one of ${CODING_AGENTS.join(", ")}`
1062
+ };
1063
+ effectiveAgent = agent;
1064
+ update.agent = agent;
1065
+ }
1066
+ if (raw.reasoningEffort !== void 0 || raw.agent !== void 0) {
1067
+ const result = validateReasoningEffort(raw.reasoningEffort !== void 0 ? raw.reasoningEffort : loop.reasoningEffort, effectiveAgent);
1068
+ if (!result.ok) return result;
1069
+ if (raw.reasoningEffort !== void 0) update.reasoningEffort = result.value;
1070
+ }
1071
+ if (raw.enabled !== void 0) {
1072
+ if (typeof raw.enabled !== "boolean") return {
1073
+ ok: false,
1074
+ detail: "enabled must be boolean"
1075
+ };
1076
+ update.enabled = raw.enabled;
1077
+ }
1078
+ return {
1079
+ ok: true,
1080
+ value: update
1081
+ };
1082
+ }
1083
+ var MIN_DAEMON_VERSION = "2.4.0";
1084
+ function daemonNeedsUpdate(current, required = MIN_DAEMON_VERSION) {
1085
+ if (!current || !isValidSemver(current) || !isValidSemver(required)) return true;
1086
+ if (current === required) return false;
1087
+ return isOutdated(current, required);
1088
+ }
1089
+ function daemonUpgradeCommand() {
1090
+ return "npm install -g @kky42/pievo@latest && pievo daemon restart";
1091
+ }
1092
+ var POLL_INFO_FIELDS = new Set([
1093
+ "host",
1094
+ "platform",
1095
+ "arch",
1096
+ "version"
1097
+ ]);
1098
+ var POLL_V4_REQUEST_FIELDS = new Set([
1099
+ "protocolVersion",
1100
+ "currentRuns",
1101
+ "daemonInstanceId",
1102
+ "recoveryComplete",
1103
+ "info"
1104
+ ]);
1105
+ var POLL_HTTP_BODY_FIELDS = new Set([
1106
+ ...POLL_INFO_FIELDS,
1107
+ "protocolVersion",
1108
+ "currentRuns",
1109
+ "daemonInstanceId",
1110
+ "recoveryComplete"
1111
+ ]);
1112
+ var validPollWireId = (value) => typeof value === "string" && value.length <= 200 && value.trim().length > 0 && !value.includes("\0");
1113
+ var validPollCurrentRun = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 2 && Object.prototype.hasOwnProperty.call(value, "runId") && Object.prototype.hasOwnProperty.call(value, "stage") && validPollWireId(value.runId) && (value.stage === "executing" || value.stage === "reporting");
1114
+ var validPollInfo = (value) => value === void 0 || value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => POLL_INFO_FIELDS.has(key)) && validOptionalPollString(value.host, 524288) && validOptionalPollString(value.platform, 524288) && validOptionalPollString(value.arch, 524288) && validOptionalPollString(value.version, 64);
1115
+ /**
1116
+ * Machine gateway - the run-lifecycle core of the HTTP surface the daemon talks
1117
+ * to (poll transport: short-poll while a run is in flight, opt-in server-held
1118
+ * long-poll while idle). Framework-agnostic like the rest of the gateway
1119
+ * (return `{ status, body }` so the methods can be mounted on a plain http
1120
+ * server or TanStack server routes):
1121
+ *
1122
+ * POST /api/machine/poll (Bearer device token) → claim pending runs, deliver
1123
+ * POST /machine/report (Bearer run token) → finalize a run
1124
+ *
1125
+ * plus owner create/list/edit methods and retention.
1126
+ * Also exposes `dispatcher` (a `Dispatcher` for the Scheduler: "is the machine
1127
+ * online?") and `sweep()` (mark stale machines offline, reclaim stuck runs).
1128
+ * CLI verb dispatch at `/api/machine/cli` lives in `gateway/cli.ts`
1129
+ * (`CliGateway`), which reuses this class's owner methods;
1130
+ * canonical loop configuration validation lives in `gateway/loopConfig.ts`.
1131
+ */
1132
+ var log$2 = logger.child({ mod: "gateway" });
1133
+ var ONLINE_TTL_MS = 3e4;
1134
+ /** Auto-pause after this many consecutive errors. Canceled rows are transparent;
1135
+ * zero disables the breaker. */
1136
+ var AUTOPAUSE_STREAK = Math.max(0, Number(process.env.PIEVO_FAILURE_AUTOPAUSE_STREAK ?? 3));
1137
+ var DEFERRED_MAX_MS = 7 * 864e5;
1138
+ /** A claimed run that never reports within this window is reclaimed as timed out. */
1139
+ var configuredRunTimeoutMs = Number(process.env.PIEVO_RUN_TIMEOUT_MS || 20 * 6e4);
1140
+ var RUN_TIMEOUT_MS = Number.isFinite(configuredRunTimeoutMs) && configuredRunTimeoutMs > 0 ? configuredRunTimeoutMs : 20 * 6e4;
1141
+ /** The ONLY keys an owner `editLoop` patch may touch. A key outside this set is
1142
+ * rejected (400) rather than silently ignored, so a `--json` typo fails loudly
1143
+ * and identity/ownership columns (id/userId/machineId/timestamps) can never be
1144
+ * patched over the device-token edit surface. Exported for `cli.ts`
1145
+ * (the `new`/`edit` verb help lists these keys). */
1146
+ 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. */
1150
+ var MESSAGE_CAP = 2e3;
1151
+ /** Provider session IDs are opaque short tokens; this rejects malformed payloads
1152
+ * without constraining known provider formats. */
1153
+ var SESSION_ID_CAP = 200;
1154
+ function heartbeatRefreshMs(runTimeoutMs) {
1155
+ if (!Number.isFinite(runTimeoutMs) || runTimeoutMs <= 0) return 1;
1156
+ return Math.max(1, Math.min(6e4, runTimeoutMs / 3));
1157
+ }
1158
+ var HEARTBEAT_STAMP_REFRESH_MS = heartbeatRefreshMs(RUN_TIMEOUT_MS);
1159
+ /** How often the poll hot path re-stamps `machines.lastSeen`. Only the sweep
1160
+ * (ONLINE_TTL_MS granularity) and presence reads consume the stamp, so an
1161
+ * every-poll UPDATE is pure write amplification on Postgres — refresh at 10s
1162
+ * and an idle poll becomes read-only, with worst-case staleness well inside
1163
+ * the 30s TTL (max stamp gap = refresh + one poll interval). */
1164
+ var LAST_SEEN_REFRESH_MS = 1e4;
1165
+ var MACHINE_REPORT_FIELD_SET = new Set([
1166
+ "reportId",
1167
+ "runId",
1168
+ "result",
1169
+ "exitCode",
1170
+ "durationMs",
1171
+ "sessionId",
1172
+ "usage",
1173
+ "error",
1174
+ "finalText"
1175
+ ]);
1176
+ var MACHINE_REPORT_USAGE_FIELDS = new Set([
1177
+ "inputTokens",
1178
+ "outputTokens",
1179
+ "cacheReadTokens",
1180
+ "cacheCreationTokens"
1181
+ ]);
1182
+ /** How long an idle poll is held open for work before returning empty.
1183
+ * Bounded under the daemon's 30s fetch timeout AND under ONLINE_TTL_MS
1184
+ * (with the end-of-wait re-stamp) so a parked long-poll never looks offline. */
1185
+ var LONG_POLL_WAIT_MS = 2e4;
1186
+ var USAGE_MAX = 0xe8d4a51000;
1187
+ var REPORT_ID_CAP = 200;
1188
+ function receiptFor(body, runId, ackStatus = 200, ackBody) {
1189
+ if (typeof body.reportId !== "string") return void 0;
1190
+ return {
1191
+ reportId: body.reportId,
1192
+ runId,
1193
+ payloadDigest: sha256(canonicalJson(body)),
1194
+ ackStatus,
1195
+ ackBody: ackBody ?? {
1196
+ ok: true,
1197
+ reportId: body.reportId
1198
+ },
1199
+ createdAt: nowIso()
1200
+ };
1201
+ }
1202
+ function receiptResponse(receipt, expected) {
1203
+ if (!receipt || receipt.runId !== expected.runId) return void 0;
1204
+ if (receipt.payloadDigest !== expected.payloadDigest) log$2.warn({
1205
+ reportId: expected.reportId,
1206
+ runId: expected.runId
1207
+ }, "report: same-run payload changed after commit; replaying authoritative ACK");
1208
+ return {
1209
+ status: receipt.ackStatus,
1210
+ body: receipt.ackBody
1211
+ };
1212
+ }
1213
+ function incidentReceiptResponse(receipt) {
1214
+ return receipt ? {
1215
+ status: 200,
1216
+ body: receipt.ackBody
1217
+ } : void 0;
1218
+ }
1219
+ async function committedReportEvidence(reportId, payloadDigest, authoritativeRunId, allowExactIncidentReplay = false) {
1220
+ const normal = await getReportReceipt(reportId);
1221
+ const exactIncident = await getExactTerminalReportIncident(reportId, payloadDigest);
1222
+ if (allowExactIncidentReplay && exactIncident) return {
1223
+ response: incidentReceiptResponse(exactIncident),
1224
+ foreignRun: false
1225
+ };
1226
+ if (typeof authoritativeRunId === "string") {
1227
+ if (exactIncident?.runId === authoritativeRunId) return {
1228
+ response: incidentReceiptResponse(exactIncident),
1229
+ foreignRun: false
1230
+ };
1231
+ if (normal?.runId === authoritativeRunId) {
1232
+ if (normal.payloadDigest !== payloadDigest) log$2.warn({
1233
+ reportId,
1234
+ runId: authoritativeRunId
1235
+ }, "report: same-run payload changed after commit; replaying authoritative ACK");
1236
+ return {
1237
+ response: {
1238
+ status: normal.ackStatus,
1239
+ body: normal.ackBody
1240
+ },
1241
+ foreignRun: false
1242
+ };
1243
+ }
1244
+ }
1245
+ const incidents = await getTerminalReportIncidents(reportId);
1246
+ return { foreignRun: !!normal || !!exactIncident || incidents.length > 0 };
1247
+ }
1248
+ function correlatableReportId(value) {
1249
+ return typeof value === "string" && value.length > 0 && value.length <= REPORT_ID_CAP && !value.includes("\0");
1250
+ }
1251
+ function incidentDiagnosis(code, issues, reportId, payloadDigest) {
1252
+ const faultDomain = code === "REPORT_CONFLICT" ? "internal" : issues.some((issue) => issue.includes("runId does not match")) ? "daemon" : "protocol";
1253
+ const reason = code === "REPORT_CONFLICT" ? "Terminal report rejected because its reportId was already committed for another run." : `Terminal report rejected: ${issues.join("; ")}.`;
1254
+ const recommendedAction = faultDomain === "internal" ? "Inspect pievo show and pievo log; retry only after confirming the daemon and server agree on the active run." : "Upgrade Pievo to the latest version and restart the daemon, then inspect pievo show and pievo log.";
1255
+ return {
1256
+ at: nowIso(),
1257
+ code,
1258
+ reason,
1259
+ issues,
1260
+ reportId,
1261
+ payloadDigest,
1262
+ faultDomain,
1263
+ recommendedAction
1264
+ };
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;
1274
+ }
1275
+ function validateTerminalReport(body) {
1276
+ const issues = [];
1277
+ const has = (key) => Object.prototype.hasOwnProperty.call(body, key);
1278
+ const unknown = Object.keys(body).filter((key) => !MACHINE_REPORT_FIELD_SET.has(key)).sort();
1279
+ if (unknown.length) issues.push(`unknown fields: ${unknown.join(", ")}`);
1280
+ if (![
1281
+ "success",
1282
+ "failure",
1283
+ "canceled",
1284
+ "timeout"
1285
+ ].includes(body.result)) issues.push("result must be success, failure, canceled, or timeout");
1286
+ if (has("durationMs") && (typeof body.durationMs !== "number" || !Number.isInteger(body.durationMs) || body.durationMs < 0 || body.durationMs > 2147483647)) issues.push("durationMs must be a non-negative 32-bit integer");
1287
+ if (has("exitCode") && body.exitCode !== null && (typeof body.exitCode !== "number" || !Number.isInteger(body.exitCode) || body.exitCode < 0 || body.exitCode > 2147483647)) issues.push("exitCode must be a non-negative 32-bit integer or null");
1288
+ for (const key of [
1289
+ "sessionId",
1290
+ "error",
1291
+ "finalText"
1292
+ ]) if (has(key) && typeof body[key] !== "string") issues.push(`${key} must be a string`);
1293
+ if (has("usage")) if (body.usage === null || typeof body.usage !== "object" || Array.isArray(body.usage)) issues.push("usage must be an object");
1294
+ else {
1295
+ const usage = body.usage;
1296
+ const unknownUsage = Object.keys(usage).filter((key) => !MACHINE_REPORT_USAGE_FIELDS.has(key)).sort();
1297
+ if (unknownUsage.length) issues.push(`unknown usage fields: ${unknownUsage.join(", ")}`);
1298
+ for (const key of MACHINE_REPORT_USAGE_FIELDS) {
1299
+ if (!Object.prototype.hasOwnProperty.call(usage, key)) continue;
1300
+ const value = usage[key];
1301
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > USAGE_MAX) issues.push(`usage.${key} must be a non-negative integer no greater than ${USAGE_MAX}`);
1302
+ }
1303
+ }
1304
+ return issues;
1305
+ }
1306
+ function coerceTelemetry(body) {
1307
+ const whole = (v, max) => typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= max ? v : void 0;
1308
+ const usageRaw = body.usage && typeof body.usage === "object" && !Array.isArray(body.usage) ? body.usage : {};
1309
+ const usage = {};
1310
+ for (const key of [
1311
+ "inputTokens",
1312
+ "outputTokens",
1313
+ "cacheReadTokens",
1314
+ "cacheCreationTokens"
1315
+ ]) {
1316
+ const value = whole(usageRaw[key], USAGE_MAX);
1317
+ if (value !== void 0) usage[key] = value;
1318
+ }
1319
+ return {
1320
+ ...whole(body.durationMs, 2147483647) !== void 0 ? { durationMs: body.durationMs } : {},
1321
+ ...whole(body.exitCode, 2147483647) !== void 0 ? { exitCode: body.exitCode } : {},
1322
+ ...typeof body.sessionId === "string" ? { sessionId: clipText(body.sessionId, SESSION_ID_CAP) } : {},
1323
+ ...typeof body.finalText === "string" ? { finalText: clipText(body.finalText, WIRE_TEXT_CAP) } : {},
1324
+ ...Object.keys(usage).length ? { usage } : {}
1325
+ };
1326
+ }
1327
+ var MachineGateway = class {
1328
+ scheduler;
1329
+ blobStore;
1330
+ constructor(scheduler, blobStore = createBlobStore()) {
1331
+ this.scheduler = scheduler;
1332
+ this.blobStore = blobStore;
1333
+ }
1334
+ /** In-flight latch: the maintenance pass is sequential and the first post-deploy
1335
+ * backlog reclamation can overrun the interval, so a fresh tick skips rather than
1336
+ * running a second pass concurrently (idempotent but wasteful + double-counts). */
1337
+ maintenanceRunning = false;
1338
+ applyAutopauseTimer(loopId, terminal) {
1339
+ if (!terminal.autoPaused) return;
1340
+ this.scheduler.removeLoop(loopId);
1341
+ log$2.warn({
1342
+ loopId,
1343
+ streak: terminal.failureStreak
1344
+ }, "loop auto-paused after block or consecutive errors");
1345
+ }
1346
+ /**
1347
+ * Dispatcher for the Scheduler. The pending run row IS the queue (the daemon's
1348
+ * next poll claims it, so nothing is ever lost); dispatch additionally WAKES
1349
+ * the machine's parked long-poll, so an opted-in idle daemon claims the run
1350
+ * immediately instead of on its next cadence tick.
1351
+ */
1352
+ dispatcher = { dispatch: (loop) => this.wakeMachine(loop.machineId) };
1353
+ /** One parked long-poll waiter per machine (the pidfile enforces one daemon).
1354
+ * The stored settle fn resolves `true` on wake (new pending run) and `false`
1355
+ * on timeout / supersede / cancel, then disarms itself. Poll waiters are
1356
+ * process-local; unlike durable run leases, a deploy drops them and the daemon
1357
+ * simply re-polls. */
1358
+ pollWaiters = /* @__PURE__ */ new Map();
1359
+ wakeMachine(machineId) {
1360
+ this.pollWaiters.get(machineId)?.(true);
1361
+ }
1362
+ /** Arm this machine's long-poll waiter: the promise resolves `true` when
1363
+ * `wakeMachine` fires (a run went pending), `false` on timeout or cancel.
1364
+ * A pre-existing waiter is superseded (woken) first — a dangling held
1365
+ * request must never strand a newer one. */
1366
+ armPollWaiter(machineId, waitMs) {
1367
+ this.pollWaiters.get(machineId)?.(true);
1368
+ let settle;
1369
+ return {
1370
+ promise: new Promise((resolve) => {
1371
+ let done = false;
1372
+ const timer = setTimeout(() => settle(false), waitMs);
1373
+ timer.unref?.();
1374
+ settle = (woken) => {
1375
+ if (done) return;
1376
+ done = true;
1377
+ clearTimeout(timer);
1378
+ if (this.pollWaiters.get(machineId) === settle) this.pollWaiters.delete(machineId);
1379
+ resolve(woken);
1380
+ };
1381
+ this.pollWaiters.set(machineId, settle);
1382
+ }),
1383
+ cancel: () => settle(false)
1384
+ };
1385
+ }
1386
+ /**
1387
+ * Periodic maintenance: mark stale machines offline, and reclaim stuck runs.
1388
+ * A RUNNING run that went silent is reclaimed as timed out; a PENDING run on
1389
+ * an OFFLINE machine is NOT failed — it remains in the durable queue, bounded
1390
+ * by DEFERRED_MAX_MS while offline.
1391
+ * Online pending rows never synthesize an execution error.
1392
+ */
1393
+ async sweep() {
1394
+ const now = Date.now();
1395
+ for (const m of await listMachines()) if (m.online && (!m.lastSeen || now - Date.parse(m.lastSeen) > 3e4)) await updateMachine(m.id, { online: false });
1396
+ for (const run of await openRuns()) {
1397
+ const pendingLifetime = now - Date.parse(run.createdAt);
1398
+ if (run.phase === "pending") {
1399
+ if ((await getMachine(run.machineId))?.online) {} else if (run.requestedBy === "system" && pendingLifetime > DEFERRED_MAX_MS) await expirePendingRun(run.id, {
1400
+ requestedBy: run.requestedBy,
1401
+ updatedAt: run.updatedAt
1402
+ }, nowIso(), DEFERRED_MAX_MS, "skipped - the machine stayed offline past the catch-up window");
1403
+ } else if (run.phase === "running") {
1404
+ if (now - Math.max(Date.parse(run.ts), run.heartbeatAt ? Date.parse(run.heartbeatAt) || 0 : 0) > RUN_TIMEOUT_MS) await this.reclaimRun(run, "machine timed out / disconnected");
1405
+ }
1406
+ }
1407
+ await pruneExpiredLeases(now);
1408
+ for (const loop of await listLoops()) if (loop.deleteRequestedAt) await tryDeleteLoop(loop.id);
1409
+ }
1410
+ /** Finalize one stuck run as an error (the sweep's reclaim path): persist the
1411
+ * failure, TERMINALIZE its run lease (flip it to `terminal-grace` rather than
1412
+ * retiring it outright), while preserving a bounded reconciliation window.
1413
+ *
1414
+ * Why terminalize, not retire: the usual cause is a laptop that merely fell
1415
+ * ASLEEP mid-run. When it wakes, claude finishes and the daemon delivers the real
1416
+ * (often SUCCESSFUL) result. Retiring the lease here would 401 that late report
1417
+ * and strand the run as a permanent false failure with its message lost (the
1418
+ * investigated bug). So the lease survives a bounded grace window
1419
+ * (`TERMINAL_GRACE_MS`) during which exactly ONE late wake-report may reconcile
1420
+ * the run — see `report()`'s terminal-grace branch. The credential is still
1421
+ * bounded: CLI mutations are refused while terminal-grace, and the
1422
+ * reconciliation retires the lease single-shot. Pending rows are durable inbox
1423
+ * entries and are never reclaimed by this path. */
1424
+ async reclaimRun(run, reason) {
1425
+ if (run.phase !== "running") return;
1426
+ const at = nowIso();
1427
+ const reclaimed = await reclaimRun(run.id, "running", reason, at, TERMINAL_GRACE_MS);
1428
+ if (!reclaimed) return;
1429
+ this.applyAutopauseTimer(run.loopId, reclaimed);
1430
+ if (reclaimed.loop.enabled) this.scheduler.addLoop(reclaimed.loop);
1431
+ else this.scheduler.removeLoop(reclaimed.loop.id);
1432
+ }
1433
+ /**
1434
+ * Periodic storage maintenance: prune each loop's run snapshots to the
1435
+ * retention window, then GC blob bytes no live row needs. Wired to its own
1436
+ * 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.
1440
+ */
1441
+ async maintainStorage() {
1442
+ if (this.maintenanceRunning) {
1443
+ log$2.info("storage maintenance already in progress — skipping this tick");
1444
+ return {
1445
+ snapshotsPruned: 0,
1446
+ blobsReclaimed: 0
1447
+ };
1448
+ }
1449
+ this.maintenanceRunning = true;
1450
+ try {
1451
+ return await maintainStorage(this.blobStore);
1452
+ } catch (err) {
1453
+ log$2.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
1454
+ return {
1455
+ snapshotsPruned: 0,
1456
+ blobsReclaimed: 0
1457
+ };
1458
+ } finally {
1459
+ this.maintenanceRunning = false;
1460
+ }
1461
+ }
1462
+ async pollCore(deviceToken, info, currentRunIds, claimWork = true, recoveryComplete = false) {
1463
+ const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1464
+ if (!auth.ok) return auth.response;
1465
+ const { machineId } = auth;
1466
+ let machine = auth.machine;
1467
+ if (!machine) {
1468
+ const binding = await readConnectKeyBinding(deviceToken);
1469
+ if (!binding) return {
1470
+ status: 401,
1471
+ body: { error: "unknown device token — connect this machine first" }
1472
+ };
1473
+ machine = await createMachine({
1474
+ id: machineId,
1475
+ userId: binding.userId,
1476
+ name: info?.host || `machine-${machineId.slice(2, 8)}`,
1477
+ tokenHash: sha256(deviceToken),
1478
+ token: deviceToken,
1479
+ online: true
1480
+ });
1481
+ log$2.info({
1482
+ machineId,
1483
+ host: info?.host
1484
+ }, "poll: self-registered machine");
1485
+ }
1486
+ if (!machine.online || !machine.lastSeen || Date.now() - Date.parse(machine.lastSeen) > LAST_SEEN_REFRESH_MS) await setMachineOnline(machineId, true);
1487
+ if (info) {
1488
+ const version = typeof info.version === "string" && info.version.length <= 64 && isValidSemver(info.version) ? info.version : void 0;
1489
+ const patch = {
1490
+ ...info.host && info.host !== machine.hostname ? { hostname: info.host } : {},
1491
+ ...info.platform && info.platform !== machine.platform ? { platform: info.platform } : {},
1492
+ ...info.arch && info.arch !== machine.arch ? { arch: info.arch } : {},
1493
+ ...version && version !== machine.daemonVersion ? { daemonVersion: version } : {},
1494
+ ...info.host && !machine.name?.trim() ? { name: info.host } : {}
1495
+ };
1496
+ if (Object.keys(patch).length) await updateMachine(machineId, patch);
1497
+ }
1498
+ if (Array.isArray(currentRunIds)) {
1499
+ const ids = /* @__PURE__ */ new Set();
1500
+ for (const value of currentRunIds) if (typeof value === "string") ids.add(value);
1501
+ if (ids.size) {
1502
+ const now = Date.now();
1503
+ await refreshRunHeartbeats(machineId, [...ids], new Date(now).toISOString(), new Date(now - HEARTBEAT_STAMP_REFRESH_MS).toISOString());
1504
+ }
1505
+ }
1506
+ if (recoveryComplete) await releaseAbsentReconciliations(machineId, currentRunIds ?? []);
1507
+ let delivery = null;
1508
+ if (claimWork) {
1509
+ await this.scheduler.advanceDueSchedules(machineId);
1510
+ const claimed = await claimReadyRunForMachine(machineId, void 0, currentRunIds ?? []);
1511
+ if (claimed) delivery = await buildDelivery(claimed.loop, claimed.run, claimed.runToken, machine.roots ?? []);
1512
+ }
1513
+ if (delivery) log$2.info({
1514
+ machineId,
1515
+ runId: delivery.runId
1516
+ }, "poll: delivered");
1517
+ return {
1518
+ status: 200,
1519
+ body: { delivery }
1520
+ };
1521
+ }
1522
+ async pollV4(deviceToken, request) {
1523
+ const rawRequest = request;
1524
+ if (request === null || typeof request !== "object" || Array.isArray(request) || Object.keys(rawRequest).some((key) => !POLL_V4_REQUEST_FIELDS.has(key)) || !validPollInfo(rawRequest?.info) || rawRequest.protocolVersion !== void 0 && (typeof rawRequest.protocolVersion !== "number" || !Number.isInteger(rawRequest.protocolVersion)) || rawRequest.currentRuns !== void 0 && (!Array.isArray(rawRequest.currentRuns) || rawRequest.currentRuns.some((run) => !validPollCurrentRun(run))) || rawRequest.daemonInstanceId !== void 0 && !validPollWireId(rawRequest.daemonInstanceId) || rawRequest.recoveryComplete !== void 0 && typeof rawRequest.recoveryComplete !== "boolean") return {
1525
+ status: 400,
1526
+ body: {
1527
+ error: "invalid poll request",
1528
+ code: "VALIDATION_ERROR"
1529
+ }
1530
+ };
1531
+ if (request.protocolVersion !== 4) {
1532
+ const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1533
+ if (!auth.ok && auth.reason === "mismatch") return auth.response;
1534
+ if (auth.ok && auth.machine) {
1535
+ const reported = typeof request.protocolVersion === "number" && Number.isInteger(request.protocolVersion) ? request.protocolVersion : null;
1536
+ await updateMachine(auth.machineId, { daemonProtocol: reported });
1537
+ }
1538
+ return {
1539
+ status: 426,
1540
+ body: {
1541
+ error: "daemon upgrade required; run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`",
1542
+ code: "UPGRADE_REQUIRED",
1543
+ requiredProtocol: 4
1544
+ }
1545
+ };
1546
+ }
1547
+ if (!Array.isArray(request.currentRuns) || request.currentRuns.some((run) => !validPollCurrentRun(run))) return {
1548
+ status: 400,
1549
+ body: {
1550
+ error: "invalid currentRuns",
1551
+ code: "VALIDATION_ERROR"
1552
+ }
1553
+ };
1554
+ if (request.recoveryComplete !== true) return {
1555
+ status: 400,
1556
+ body: {
1557
+ error: "recoveryComplete must be true",
1558
+ code: "VALIDATION_ERROR"
1559
+ }
1560
+ };
1561
+ if (!validPollWireId(request.daemonInstanceId)) return {
1562
+ status: 400,
1563
+ body: {
1564
+ error: "a valid daemonInstanceId is required",
1565
+ code: "VALIDATION_ERROR"
1566
+ }
1567
+ };
1568
+ const currentIds = [...new Set(request.currentRuns.map((run) => run.runId))];
1569
+ const priorAuth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1570
+ const machineId = priorAuth.ok ? priorAuth.machineId : "";
1571
+ const reportedVersion = typeof request.info?.version === "string" ? request.info.version : void 0;
1572
+ const needsUpdate = daemonNeedsUpdate(reportedVersion);
1573
+ const base = await this.pollCore(deviceToken, request.info, currentIds, !needsUpdate, true);
1574
+ if (base.status !== 200) return base;
1575
+ if ((await getMachine(machineId))?.daemonProtocol !== 4) await updateMachine(machineId, { daemonProtocol: 4 });
1576
+ const running = await runningRunsForMachine(machineId);
1577
+ const currentSet = new Set(currentIds);
1578
+ return {
1579
+ status: 200,
1580
+ body: {
1581
+ delivery: base.body.delivery,
1582
+ cancelRunIds: running.filter((run) => currentSet.has(run.id) && run.cancelRequestedAt).map((run) => run.id),
1583
+ ...needsUpdate ? { needsUpdate: {
1584
+ current: reportedVersion ?? null,
1585
+ required: MIN_DAEMON_VERSION,
1586
+ command: daemonUpgradeCommand()
1587
+ } } : {}
1588
+ }
1589
+ };
1590
+ }
1591
+ async pollV4Wait(deviceToken, request, waitMs = LONG_POLL_WAIT_MS) {
1592
+ if (request.currentRuns?.length || request.protocolVersion !== 4) return this.pollV4(deviceToken, request);
1593
+ const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1594
+ if (!auth.ok) return auth.response;
1595
+ const machineId = auth.machineId;
1596
+ const waiter = this.armPollWaiter(machineId, Math.min(Math.max(waitMs, 0), LONG_POLL_WAIT_MS));
1597
+ try {
1598
+ const first = await this.pollV4(deviceToken, request);
1599
+ if (first.status !== 200) return first;
1600
+ if (first.body.delivery) return first;
1601
+ if (!await waiter.promise) {
1602
+ await setMachineOnline(machineId, true);
1603
+ return first;
1604
+ }
1605
+ return this.pollV4(deviceToken, request);
1606
+ } finally {
1607
+ waiter.cancel();
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Whether this machine (by device token) currently has a live daemon — so
1612
+ * Claude Code can avoid starting a duplicate. `online` is fresh-checked against
1613
+ * the poll TTL, not just the stored flag.
1614
+ */
1615
+ async status(deviceToken) {
1616
+ const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
1617
+ if (!auth.ok) return auth.response;
1618
+ const { machineId, machine } = auth;
1619
+ if (!machine) return {
1620
+ status: 200,
1621
+ body: {
1622
+ registered: false,
1623
+ claimValid: await readConnectKeyBinding(deviceToken) !== void 0,
1624
+ online: false,
1625
+ name: null,
1626
+ lastSeen: null,
1627
+ daemonProtocol: null
1628
+ }
1629
+ };
1630
+ const fresh = !!machine.lastSeen && Date.now() - Date.parse(machine.lastSeen) < 3e4;
1631
+ const online = !!machine.online && fresh;
1632
+ const currentRuns = (await runningRunsForMachine(machineId)).map((run) => ({
1633
+ runId: run.id,
1634
+ stage: "executing",
1635
+ cancelPending: run.cancelRequestedAt != null
1636
+ }));
1637
+ return {
1638
+ status: 200,
1639
+ body: {
1640
+ registered: true,
1641
+ online,
1642
+ name: machine.name || null,
1643
+ lastSeen: machine.lastSeen ?? null,
1644
+ daemonProtocol: machine.daemonProtocol ?? null,
1645
+ currentRuns: online ? currentRuns : []
1646
+ }
1647
+ };
1648
+ }
1649
+ async createLoop(deviceToken, body) {
1650
+ const auth = await authenticateDeviceToken(deviceToken);
1651
+ if (!auth.ok) return auth.response;
1652
+ const { machineId } = auth;
1653
+ const machine = auth.machine;
1654
+ {
1655
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return {
1656
+ status: 400,
1657
+ body: { error: "create body must be an object" }
1658
+ };
1659
+ const { dryRun, idempotencyKey, ...rawConfig } = body;
1660
+ if (dryRun !== void 0 && typeof dryRun !== "boolean") return {
1661
+ status: 400,
1662
+ body: { error: "dryRun must be boolean when provided" }
1663
+ };
1664
+ if (typeof idempotencyKey !== "string" || !/^[0-9a-f]{64}$/.test(idempotencyKey)) return {
1665
+ status: 400,
1666
+ body: { error: "idempotencyKey is required and must be exactly 64 lowercase hex characters" }
1667
+ };
1668
+ const validated = validateLoopCreate(rawConfig);
1669
+ if (!validated.ok) return {
1670
+ status: 400,
1671
+ body: { error: validated.detail }
1672
+ };
1673
+ const { config, row } = validated.value;
1674
+ if (dryRun === true) return {
1675
+ status: 200,
1676
+ body: {
1677
+ ok: true,
1678
+ dryRun: true,
1679
+ config,
1680
+ text: JSON.stringify(config, null, 2)
1681
+ }
1682
+ };
1683
+ const existingId = readNewIdempotency(idempotencyKey, machineId);
1684
+ const existing = existingId ? await getLoop(existingId) : void 0;
1685
+ if (existing?.machineId === machineId) return {
1686
+ status: 200,
1687
+ body: {
1688
+ ok: true,
1689
+ id: existing.id,
1690
+ name: existing.name,
1691
+ idempotent: true,
1692
+ text: `loop: ${existing.name}\nid: ${existing.id}\nreplayed: true`
1693
+ }
1694
+ };
1695
+ const loop = await createLoop({
1696
+ userId: machine.userId,
1697
+ machineId,
1698
+ ...row
1699
+ });
1700
+ this.scheduler.addLoop(loop);
1701
+ recordNewIdempotency(idempotencyKey, machineId, loop.id);
1702
+ log$2.info({
1703
+ machineId,
1704
+ loopId: loop.id,
1705
+ agent: loop.agent
1706
+ }, "createLoop: canonical prompt runner created");
1707
+ return {
1708
+ status: 200,
1709
+ body: {
1710
+ ok: true,
1711
+ id: loop.id,
1712
+ name: loop.name,
1713
+ text: `loop: ${loop.name}\nid: ${loop.id}`
1714
+ }
1715
+ };
1716
+ }
1717
+ }
1718
+ async listLoops(deviceToken, fieldsFlag, json) {
1719
+ const auth = await authenticateDeviceToken(deviceToken);
1720
+ if (!auth.ok) return auth.response;
1721
+ const { machineId } = auth;
1722
+ const extras = [];
1723
+ if (fieldsFlag !== void 0) {
1724
+ const requested = String(fieldsFlag).split(",").map((s) => s.trim()).filter(Boolean);
1725
+ const unknown = requested.filter((f) => !LIST_OPTIONAL_FIELDS.includes(f));
1726
+ if (unknown.length) return {
1727
+ status: 400,
1728
+ body: { error: `unknown field(s): ${unknown.join(", ")} — available: ${LIST_OPTIONAL_FIELDS.join(", ")}` }
1729
+ };
1730
+ for (const f of requested) if (!extras.includes(f)) extras.push(f);
1731
+ }
1732
+ const fields = [...LIST_DEFAULT_FIELDS, ...extras];
1733
+ const wantRuns = fields.includes("runs");
1734
+ const wantLastResult = fields.includes("lastResult");
1735
+ const loopRows = await loopsForMachine(machineId);
1736
+ const loops = await Promise.all(loopRows.map(async (l) => {
1737
+ const schedule = scheduleFromLoop(l);
1738
+ const nextFire = l.enabled && schedule.mode === "cron" ? nextFires(schedule.cron, schedule.timezone, 1)[0] ?? null : null;
1739
+ const last = wantLastResult ? await lastRun(l.id) : void 0;
1740
+ return {
1741
+ id: l.id,
1742
+ name: l.name,
1743
+ schedule,
1744
+ enabled: l.enabled,
1745
+ model: l.model ?? null,
1746
+ reasoningEffort: l.reasoningEffort ?? null,
1747
+ workdir: l.workdir,
1748
+ nextFire,
1749
+ runs: wantRuns ? await countRuns(l.id) : 0,
1750
+ lastResult: last ? runResultToken(last) : null
1751
+ };
1752
+ }));
1753
+ const envelopes = loopRows.map(canonicalLoopEnvelope);
1754
+ return {
1755
+ status: 200,
1756
+ body: {
1757
+ ok: true,
1758
+ loops: envelopes,
1759
+ text: json ? JSON.stringify(envelopes, null, 2) : renderLoopsText(loops, fields)
1760
+ }
1761
+ };
1762
+ }
1763
+ async editLoop(deviceToken, id, patch, dryRun = false) {
1764
+ const auth = await authenticateDeviceToken(deviceToken);
1765
+ if (!auth.ok) return auth.response;
1766
+ const { machineId } = auth;
1767
+ if (typeof id !== "string" || !id) return {
1768
+ status: 400,
1769
+ body: { error: "loop id required" }
1770
+ };
1771
+ const loop = await getLoop(id);
1772
+ if (!loop || loop.machineId !== machineId) return {
1773
+ status: 404,
1774
+ body: { error: "no such loop on this machine" }
1775
+ };
1776
+ {
1777
+ const validated = validateLoopEdit(loop, patch);
1778
+ if (!validated.ok) return {
1779
+ status: 400,
1780
+ body: { error: validated.detail }
1781
+ };
1782
+ const update = validated.value;
1783
+ const applied = Object.keys(patch ?? {});
1784
+ if (dryRun) {
1785
+ const before = canonicalLoopEnvelope(loop);
1786
+ const after = canonicalLoopEnvelope({
1787
+ ...loop,
1788
+ ...update
1789
+ });
1790
+ const { id: _readOnlyId, ...config } = after;
1791
+ const preview = {
1792
+ id: loop.id,
1793
+ applied,
1794
+ before,
1795
+ after
1796
+ };
1797
+ return {
1798
+ status: 200,
1799
+ body: {
1800
+ ok: true,
1801
+ dryRun: true,
1802
+ ...preview,
1803
+ config,
1804
+ text: JSON.stringify(preview, null, 2)
1805
+ }
1806
+ };
1807
+ }
1808
+ if (Object.keys(update).length === 0) return {
1809
+ status: 200,
1810
+ body: {
1811
+ ok: true,
1812
+ id: loop.id,
1813
+ name: loop.name,
1814
+ applied: [],
1815
+ nothingToChange: true,
1816
+ text: `loop: ${loop.name}\nid: ${loop.id}\napplied[0]:`
1817
+ }
1818
+ };
1819
+ const updated = await updateLoop(loop.id, update);
1820
+ if (!updated) return {
1821
+ status: 404,
1822
+ body: { error: "loop not found" }
1823
+ };
1824
+ if (updated.enabled) this.scheduler.addLoop(updated);
1825
+ else this.scheduler.removeLoop(updated.id);
1826
+ return {
1827
+ status: 200,
1828
+ body: {
1829
+ ok: true,
1830
+ id: updated.id,
1831
+ name: updated.name,
1832
+ applied,
1833
+ text: `loop: ${updated.name}\nid: ${updated.id}\napplied: ${applied.join(",")}`
1834
+ }
1835
+ };
1836
+ }
1837
+ }
1838
+ async rejectRetiredConflict(runToken, lease, body, payloadDigest) {
1839
+ const ackBody = {
1840
+ ok: true,
1841
+ accepted: false,
1842
+ terminal: true,
1843
+ reportId: body.reportId,
1844
+ code: "REPORT_CONFLICT",
1845
+ issues: ["reportId was already committed for another run"],
1846
+ disposition: "telemetry-rejected",
1847
+ payloadDigest
1848
+ };
1849
+ const receipt = await acknowledgeRetiredTerminalIncident({
1850
+ runId: lease.runId,
1851
+ leaseTokenHash: sha256(runToken),
1852
+ reportId: body.reportId,
1853
+ payloadDigest,
1854
+ ackBody
1855
+ });
1856
+ return receipt ? {
1857
+ status: 200,
1858
+ body: receipt.ackBody
1859
+ } : {
1860
+ status: 401,
1861
+ body: { error: "invalid or expired token" }
1862
+ };
1863
+ }
1864
+ async retiredReport(runToken, body, runId) {
1865
+ const expected = receiptFor(body, runId, 410, {
1866
+ error: "execution authority retired",
1867
+ code: "RETIRED",
1868
+ reportId: body.reportId
1869
+ });
1870
+ const stored = await acknowledgeRetiredReport(sha256(runToken), expected);
1871
+ if (!stored) {
1872
+ const winner = await getReportReceipt(expected.reportId);
1873
+ if (winner && winner.runId !== runId) return this.rejectRetiredConflict(runToken, { runId }, body, sha256(JSON.stringify(body)));
1874
+ return receiptResponse(winner, expected) ?? {
1875
+ status: 401,
1876
+ body: { error: "invalid or expired token" }
1877
+ };
1878
+ }
1879
+ if (stored.runId !== runId) return this.rejectRetiredConflict(runToken, { runId }, body, sha256(JSON.stringify(body)));
1880
+ return receiptResponse(stored, expected);
1881
+ }
1882
+ async ignoreCanceledReport(runToken, lease, body) {
1883
+ const expected = receiptFor(body, lease.runId);
1884
+ const stored = await putReportReceiptIfAbsent(expected);
1885
+ if (!stored) return {
1886
+ status: 401,
1887
+ body: { error: "invalid or expired token" }
1888
+ };
1889
+ if (stored.runId !== lease.runId) {
1890
+ const payloadDigest = sha256(JSON.stringify(body));
1891
+ const ackBody = {
1892
+ ok: true,
1893
+ accepted: false,
1894
+ terminal: true,
1895
+ reportId: body.reportId,
1896
+ code: "REPORT_CONFLICT",
1897
+ issues: ["reportId was already committed for another run"],
1898
+ disposition: "telemetry-rejected",
1899
+ payloadDigest
1900
+ };
1901
+ const incident = await putTerminalReportIncidentIfAbsent({
1902
+ runId: lease.runId,
1903
+ reportId: body.reportId,
1904
+ payloadDigest,
1905
+ disposition: "telemetry-rejected",
1906
+ ackBody
1907
+ });
1908
+ await retireLease(runToken);
1909
+ return {
1910
+ status: 200,
1911
+ body: incident.ackBody
1912
+ };
1913
+ }
1914
+ const response = receiptResponse(stored, expected);
1915
+ if (response.status < 300) await retireLease(runToken);
1916
+ log$2.info({ runId: lease.runId }, "report: ignored (run was canceled)");
1917
+ return response;
1918
+ }
1919
+ /** Reconcile one swept run. The store consumes the terminal-grace lease in the
1920
+ * same loop-lock transaction as the error→done/error patch and loop state, so
1921
+ * concurrent late reports cannot both win (including error→error). */
1922
+ 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;
1928
+ const loopPatch = {};
1929
+ const receipt = receiptFor(body, lease.runId);
1930
+ const payloadDigest = sha256(JSON.stringify(body));
1931
+ let reconciled;
1932
+ try {
1933
+ reconciled = await reconcileReclaimedRun(lease.loopId, lease.runId, sha256(runToken), {
1934
+ phase: canceled ? "canceled" : ok ? "done" : "error",
1935
+ ...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 },
1938
+ ts: nowIso()
1939
+ }, loopPatch, AUTOPAUSE_STREAK, receipt);
1940
+ } 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"]);
1944
+ throw error;
1945
+ }
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
+ }
1974
+ const deleting = reconciled.loop.deleteRequestedAt != null;
1975
+ 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;
1986
+ if (!reportOnly) this.scheduler.addLoop(reconciled.loop);
1987
+ if (!reportOnly) this.applyAutopauseTimer(lease.loopId, reconciled);
1988
+ if (deleting) await tryDeleteLoop(lease.loopId);
1989
+ log$2.info({
1990
+ runId: lease.runId,
1991
+ ok,
1992
+ reclaimed: true
1993
+ }, canceled ? "report: reconciled a reclaimed run to canceled" : ok ? "report: reconciled a reclaimed run to done (machine woke)" : "report: recorded a reclaimed run's real error");
1994
+ return {
1995
+ status: 200,
1996
+ body: {
1997
+ ok: true,
1998
+ reportId: body.reportId
1999
+ }
2000
+ };
2001
+ }
2002
+ async rejectTerminalAttempt(runToken, lease, body, payloadDigest, code, issues) {
2003
+ const reportId = body.reportId;
2004
+ const run = await getRun(lease.runId);
2005
+ const disposition = lease.state !== "active" && (run?.phase === "done" || run?.phase === "error" || run?.phase === "canceled") ? "telemetry-rejected" : "run-error";
2006
+ const incident = incidentDiagnosis(code, issues, reportId, payloadDigest);
2007
+ const ackBody = {
2008
+ ok: true,
2009
+ accepted: false,
2010
+ terminal: true,
2011
+ reportId,
2012
+ code,
2013
+ issues,
2014
+ disposition,
2015
+ payloadDigest
2016
+ };
2017
+ if (lease.state === "retired") return this.retiredReport(runToken, body, lease.runId);
2018
+ const rejected = await rejectTerminalReport({
2019
+ loopId: lease.loopId,
2020
+ runId: lease.runId,
2021
+ leaseTokenHash: sha256(runToken),
2022
+ leaseState: lease.state,
2023
+ reportId,
2024
+ payloadDigest,
2025
+ disposition,
2026
+ incident,
2027
+ ackBody,
2028
+ failureAutopauseStreak: AUTOPAUSE_STREAK
2029
+ });
2030
+ if (rejected.state === "normal-replay") return {
2031
+ status: rejected.receipt.ackStatus,
2032
+ body: rejected.receipt.ackBody
2033
+ };
2034
+ if (rejected.state === "incident-replay") return {
2035
+ status: 200,
2036
+ body: rejected.receipt.ackBody
2037
+ };
2038
+ if (rejected.state === "run-error" || rejected.state === "telemetry-rejected") {
2039
+ this.scheduler.addLoop(rejected.loop);
2040
+ if (rejected.state === "run-error") this.applyAutopauseTimer(lease.loopId, rejected);
2041
+ if (rejected.loop.deleteRequestedAt) await tryDeleteLoop(lease.loopId);
2042
+ log$2.warn({
2043
+ runId: lease.runId,
2044
+ reportId,
2045
+ code,
2046
+ disposition
2047
+ }, "report: rejected terminal attempt durably handled");
2048
+ return {
2049
+ status: 200,
2050
+ body: rejected.receipt.ackBody
2051
+ };
2052
+ }
2053
+ const evidence = await committedReportEvidence(reportId, payloadDigest, body.runId);
2054
+ if (evidence.response) return evidence.response;
2055
+ if ((await resolveLease(runToken))?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
2056
+ return {
2057
+ status: 401,
2058
+ body: { error: "invalid or expired token" }
2059
+ };
2060
+ }
2061
+ async report(runToken, body) {
2062
+ if (!correlatableReportId(body.reportId)) {
2063
+ if (!await resolveLease(runToken)) return {
2064
+ status: 401,
2065
+ body: { error: "invalid or expired token" }
2066
+ };
2067
+ return {
2068
+ status: 400,
2069
+ body: {
2070
+ error: "reportId must be a non-empty NUL-free string of at most 200 characters",
2071
+ code: "VALIDATION_ERROR"
2072
+ }
2073
+ };
2074
+ }
2075
+ const reportId = body.reportId;
2076
+ const payloadDigest = sha256(JSON.stringify(body));
2077
+ const lease = await resolveLease(runToken);
2078
+ if (!lease) return (await committedReportEvidence(reportId, payloadDigest, body.runId, true)).response ?? {
2079
+ status: 401,
2080
+ body: { error: "invalid or expired token" }
2081
+ };
2082
+ const evidence = await committedReportEvidence(reportId, payloadDigest, lease.runId);
2083
+ if (evidence.response) return evidence.response;
2084
+ if (lease.state === "retired") {
2085
+ if (evidence.foreignRun) return this.rejectRetiredConflict(runToken, lease, body, payloadDigest);
2086
+ return this.retiredReport(runToken, body, lease.runId);
2087
+ }
2088
+ const issues = [];
2089
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId)) issues.push("reportId must be a valid UUID");
2090
+ if (typeof body.runId !== "string" || !body.runId) issues.push("runId is required");
2091
+ else if (body.runId !== lease.runId) issues.push("runId does not match this run lease");
2092
+ issues.push(...validateTerminalReport(body));
2093
+ if (evidence.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
2094
+ if (issues.length) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_INVALID", issues);
2095
+ const expected = receiptFor(body, lease.runId);
2096
+ const ok = body.result === "success";
2097
+ const canceled = body.result === "canceled";
2098
+ const run = await getRun(lease.runId);
2099
+ if (run?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
2100
+ if (run?.phase === "done") return {
2101
+ status: 409,
2102
+ body: {
2103
+ error: "run already finalized",
2104
+ code: "REPORT_NOT_FINALIZED",
2105
+ reportId
2106
+ }
2107
+ };
2108
+ 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;
2111
+ const loopPatch = {};
2112
+ let terminal;
2113
+ try {
2114
+ terminal = await finalizeRunningRun(lease.loopId, lease.runId, {
2115
+ phase: canceled ? "canceled" : effectiveOk ? "done" : "error",
2116
+ ...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" },
2118
+ ts: nowIso()
2119
+ }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected);
2120
+ } 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"]);
2124
+ throw error;
2125
+ }
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;
2157
+ 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
+ }
2167
+ this.scheduler.addLoop(terminal.loop);
2168
+ this.applyAutopauseTimer(lease.loopId, terminal);
2169
+ log$2.info({
2170
+ runId: lease.runId,
2171
+ ok: effectiveOk
2172
+ }, "report: finalized");
2173
+ if (deleting) await tryDeleteLoop(lease.loopId);
2174
+ return {
2175
+ status: 200,
2176
+ body: {
2177
+ ok: true,
2178
+ reportId
2179
+ }
2180
+ };
2181
+ }
2182
+ };
2183
+ /** Compact a stored ISO timestamp to `YYYY-MM-DD HH:MM` (UTC, as stored) for a TOON
2184
+ * cell — a date the agent reads at a glance without the `T`/seconds/zone noise. */
2185
+ function fmtTime(iso) {
2186
+ return iso.slice(0, 16).replace("T", " ");
2187
+ }
2188
+ /** Format an instant in a loop's OWN timezone with a short zone name
2189
+ * (`2026-07-08 05:00 GMT+8`), so cadence previews read in the schedule the owner set
2190
+ * rather than raw UTC. `seconds` optionally adds `:SS`. Falls back to the bare
2191
+ * `fmtTime` slice only if the stored timezone is invalid. */
2192
+ function fmtTimeZoned(iso, timezone, opts = {}) {
2193
+ if (!timezone) throw new Error("invariant: cron schedule has no timezone");
2194
+ try {
2195
+ const parts = new Intl.DateTimeFormat("en-CA", {
2196
+ timeZone: timezone,
2197
+ year: "numeric",
2198
+ month: "2-digit",
2199
+ day: "2-digit",
2200
+ hour: "2-digit",
2201
+ minute: "2-digit",
2202
+ ...opts.seconds ? { second: "2-digit" } : {},
2203
+ hour12: false,
2204
+ timeZoneName: "short"
2205
+ }).formatToParts(new Date(iso));
2206
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
2207
+ const sec = opts.seconds ? `:${get("second")}` : "";
2208
+ return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}${sec} ${get("timeZoneName")}`;
2209
+ } catch {
2210
+ return fmtTime(iso);
2211
+ }
2212
+ }
2213
+ var LIST_DEFAULT_FIELDS = [
2214
+ "id",
2215
+ "name",
2216
+ "cron",
2217
+ "enabled",
2218
+ "nextFire"
2219
+ ];
2220
+ var LIST_OPTIONAL_FIELDS = [
2221
+ "timezone",
2222
+ "model",
2223
+ "reasoningEffort",
2224
+ "runs",
2225
+ "lastResult"
2226
+ ];
2227
+ function loopCell(rec, field) {
2228
+ switch (field) {
2229
+ case "id": return rec.id;
2230
+ case "name": return rec.name;
2231
+ case "cron": return rec.schedule.mode === "continuous" ? `continuous +${rec.schedule.delayMinutes}m` : rec.schedule.cron;
2232
+ case "enabled": return rec.enabled ? "on" : "paused";
2233
+ case "nextFire": return rec.nextFire ? fmtTime(rec.nextFire) : null;
2234
+ case "timezone": return rec.schedule.mode === "cron" ? rec.schedule.timezone : null;
2235
+ case "model": return rec.model;
2236
+ case "reasoningEffort": return rec.reasoningEffort;
2237
+ case "runs": return rec.runs;
2238
+ case "lastResult": return rec.lastResult;
2239
+ default: return null;
2240
+ }
2241
+ }
2242
+ function renderLoopsText(loops, fields) {
2243
+ if (!loops.length) return doc(countLine(0), emptyList("loops"), helpBlock(["Run `pievo new --json '{...}'` to create your first loop", "Run `pievo daemon connect` if this machine isn't connected yet"]));
2244
+ return doc(countLine(loops.length), listBlock("loops", fields, loops.map((l) => fields.map((f) => loopCell(l, f)))), helpBlock(["Run `pievo show <id>` to see a loop's full config", "Run `pievo log <id>` to see a loop's recent runs"]));
2245
+ }
2246
+ function runResultToken(r) {
2247
+ if (r.status === "block") return `block/${r.phase}`;
2248
+ if (r.phase === "canceled") return "canceled";
2249
+ const base = r.phase === "error" ? "failed" : r.phase === "done" ? "ok" : r.phase;
2250
+ return r.status ? `${base}/${r.status}` : `${base}/missing-status`;
2251
+ }
2252
+ /** Stable JSON with recursively sorted object keys — so two structurally-equal values
2253
+ * serialize identically regardless of key ordering (pg `jsonb` normalizes key order). */
2254
+ function canonicalJson(v) {
2255
+ return JSON.stringify(v, (_k, val) => val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([x], [y]) => x < y ? -1 : x > y ? 1 : 0)) : val);
2256
+ }
2257
+ /** The next N fire times of a cron, probed IN the loop's timezone (fire times shift
2258
+ * with it — matching how the scheduler arms the loop), as ISO strings. Empty when
2259
+ * the expression is invalid (the caller has already run validCadence). Powers the
2260
+ * `--dry-run` fire preview. */
2261
+ function nextFires(cron, timezone, n) {
2262
+ if (!timezone) throw new Error("invariant: cron schedule has no timezone");
2263
+ try {
2264
+ const c = new E(cron, {
2265
+ paused: true,
2266
+ timezone
2267
+ });
2268
+ const out = [];
2269
+ let prev;
2270
+ for (let i = 0; i < n; i++) {
2271
+ const next = prev ? c.nextRun(prev) : c.nextRun();
2272
+ if (!next) break;
2273
+ out.push(next.toISOString());
2274
+ prev = next;
2275
+ }
2276
+ c.stop();
2277
+ return out;
2278
+ } catch {
2279
+ return [];
2280
+ }
2281
+ }
2282
+ /**
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
2432
+ * (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
2434
+ * compute a unified line diff with `diff` (jsdiff) — a pure-string library, no
2435
+ * process execution, so the server's zero-exec invariant holds. Binary/oversize
2436
+ * files emit a size-delta marker only (no inline diff).
2437
+ */
2438
+ var runDiff_exports = /* @__PURE__ */ __exportAll$1({ computeRunDiff: () => computeRunDiff });
2439
+ /**
2440
+ * Upper bound for computing an inline unified diff. jsdiff is synchronous and
2441
+ * roughly O(N*D); on a multi-MB or minified text file it would block the
2442
+ * single-process server (which also owns the scheduler) for seconds. Above this
2443
+ * a changed text file degrades to the size-delta marker, like binary/oversize.
2444
+ */
2445
+ var MAX_DIFF_BYTES = 512 * 1024;
2446
+ function isText(e) {
2447
+ return !!e && !e.binary && !e.oversize && !!e.hash;
2448
+ }
2449
+ function withinDiffCap(e) {
2450
+ return !!e && (e.size == null || e.size <= MAX_DIFF_BYTES);
2451
+ }
2452
+ async function bytesOf(e) {
2453
+ if (!e.hash) return null;
2454
+ return await (await getArtifactSync()).readBlob(e.hash) ?? null;
2455
+ }
2456
+ function unified(path, oldText, newText, maxEditLength) {
2457
+ return maxEditLength === void 0 ? createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run") : createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run", { maxEditLength });
2458
+ }
2459
+ function budgetValue(value) {
2460
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : Number.MAX_SAFE_INTEGER;
2461
+ }
2462
+ function expectedInputBytes(change, verifiedSizes) {
2463
+ const sizes = [change.previous, change.current].filter((entry) => !!entry).map((entry) => entry.hash ? verifiedSizes.get(entry.hash) : void 0);
2464
+ if (sizes.some((size) => size === void 0)) return null;
2465
+ return sizes.reduce((sum, size) => sum + size, 0);
2466
+ }
2467
+ /** Compute one run's artifact diff. The default remains the web UI's complete
2468
+ * view. Budgeted callers get explicit totals and stop before blob reads/jsdiff
2469
+ * once any work limit is spent. */
2470
+ async function computeRunDiff(runId, budget) {
2471
+ const bounded = budget !== void 0;
2472
+ const limits = budget ? {
2473
+ maxFiles: budgetValue(budget.maxFiles),
2474
+ maxInputBytes: budgetValue(budget.maxInputBytes),
2475
+ maxDiffChars: budgetValue(budget.maxDiffChars)
2476
+ } : {
2477
+ maxFiles: Number.MAX_SAFE_INTEGER,
2478
+ maxInputBytes: Number.MAX_SAFE_INTEGER,
2479
+ maxDiffChars: Number.MAX_SAFE_INTEGER
2480
+ };
2481
+ const unavailable = () => bounded ? {
2482
+ hasSnapshot: false,
2483
+ files: [],
2484
+ totalFiles: 0,
2485
+ truncated: false,
2486
+ truncation: {
2487
+ files: false,
2488
+ inputBytes: false,
2489
+ diffChars: false
2490
+ },
2491
+ work: {
2492
+ filesProcessed: 0,
2493
+ inputBytes: 0,
2494
+ emittedDiffChars: 0
2495
+ }
2496
+ } : {
2497
+ hasSnapshot: false,
2498
+ files: []
2499
+ };
2500
+ const run = await getRun(runId);
2501
+ if (!run) return unavailable();
2502
+ const snap = await getRunSnapshot(runId);
2503
+ if (!snap) return unavailable();
2504
+ const curr = snap.manifest ?? {};
2505
+ const prev = run.runIndex == null ? {} : (await prevRunSnapshot(run.loopId, run.runIndex))?.manifest ?? {};
2506
+ const changes = [];
2507
+ for (const path of [...new Set([...Object.keys(curr), ...Object.keys(prev)])].sort()) {
2508
+ const current = curr[path];
2509
+ const previous = prev[path];
2510
+ if (current && previous && current.hash === previous.hash && current.oversize === previous.oversize && current.size === previous.size) continue;
2511
+ changes.push({
2512
+ path,
2513
+ current,
2514
+ previous
2515
+ });
2516
+ }
2517
+ const selected = changes.slice(0, limits.maxFiles);
2518
+ const verifiedSizes = bounded ? await blobSizes(selected.flatMap((change) => [change.previous?.hash, change.current?.hash].filter((hash) => !!hash))) : /* @__PURE__ */ new Map();
2519
+ const files = [];
2520
+ let inputBytes = 0;
2521
+ let emittedDiffChars = 0;
2522
+ let inputTruncated = false;
2523
+ let diffTruncated = false;
2524
+ for (const change of selected) {
2525
+ const { path, current: c, previous: p } = change;
2526
+ let file;
2527
+ let diffable = false;
2528
+ if (c && !p) {
2529
+ const text = isText(c);
2530
+ const tooLarge = text && !withinDiffCap(c);
2531
+ file = {
2532
+ path,
2533
+ status: "added",
2534
+ binary: !text,
2535
+ tooLarge,
2536
+ sizeDelta: c.size ?? null
2537
+ };
2538
+ diffable = text && !tooLarge;
2539
+ } else if (!c && p) {
2540
+ const text = isText(p);
2541
+ const tooLarge = text && !withinDiffCap(p);
2542
+ file = {
2543
+ path,
2544
+ status: "removed",
2545
+ binary: !text,
2546
+ tooLarge,
2547
+ sizeDelta: p.size != null ? -p.size : null
2548
+ };
2549
+ diffable = text && !tooLarge;
2550
+ } else {
2551
+ const bothText = isText(c) && isText(p);
2552
+ const tooLarge = bothText && !(withinDiffCap(c) && withinDiffCap(p));
2553
+ file = {
2554
+ path,
2555
+ status: "modified",
2556
+ binary: !bothText,
2557
+ tooLarge,
2558
+ sizeDelta: c.size != null && p.size != null ? c.size - p.size : null
2559
+ };
2560
+ diffable = bothText && !tooLarge;
2561
+ }
2562
+ if (diffable) {
2563
+ const expectedBytes = bounded ? expectedInputBytes(change, verifiedSizes) : [p?.size ?? 0, c?.size ?? 0].reduce((sum, size) => sum + size, 0);
2564
+ if (expectedBytes == null || expectedBytes > limits.maxInputBytes - inputBytes) {
2565
+ file.diffOmitted = "input-budget";
2566
+ inputTruncated = true;
2567
+ } else if (emittedDiffChars >= limits.maxDiffChars) {
2568
+ file.diffOmitted = "diff-budget";
2569
+ diffTruncated = true;
2570
+ } else {
2571
+ const [oldBytes, newBytes] = await Promise.all([p ? bytesOf(p) : Promise.resolve(Buffer.alloc(0)), c ? bytesOf(c) : Promise.resolve(Buffer.alloc(0))]);
2572
+ inputBytes += (oldBytes?.length ?? 0) + (newBytes?.length ?? 0);
2573
+ if (oldBytes == null || newBytes == null) file.binary = true;
2574
+ else if (inputBytes > limits.maxInputBytes) {
2575
+ file.diffOmitted = "input-budget";
2576
+ inputTruncated = true;
2577
+ } else {
2578
+ const remaining = limits.maxDiffChars - emittedDiffChars;
2579
+ const full = unified(path, oldBytes.toString("utf8"), newBytes.toString("utf8"), bounded ? remaining : void 0);
2580
+ if (full === void 0) {
2581
+ file.diffOmitted = "diff-budget";
2582
+ diffTruncated = true;
2583
+ } else {
2584
+ file.diff = full.slice(0, remaining);
2585
+ emittedDiffChars += file.diff.length;
2586
+ if (file.diff.length < full.length) {
2587
+ file.diffTruncated = true;
2588
+ file.diffTotalChars = full.length;
2589
+ diffTruncated = true;
2590
+ }
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ files.push(file);
2596
+ }
2597
+ if (!bounded) return {
2598
+ hasSnapshot: true,
2599
+ files
2600
+ };
2601
+ const truncation = {
2602
+ files: changes.length > selected.length,
2603
+ inputBytes: inputTruncated,
2604
+ diffChars: diffTruncated
2605
+ };
2606
+ return {
2607
+ hasSnapshot: true,
2608
+ files,
2609
+ totalFiles: changes.length,
2610
+ truncated: truncation.files || truncation.inputBytes || truncation.diffChars,
2611
+ truncation,
2612
+ work: {
2613
+ filesProcessed: selected.length,
2614
+ inputBytes,
2615
+ emittedDiffChars
2616
+ }
2617
+ };
2618
+ }
2619
+ var HISTORY_DETAIL_TEXT_CAP = 32 * 1024;
2620
+ var HISTORY_DIFF_TEXT_CAP = 96 * 1024;
2621
+ var HISTORY_DIFF_INPUT_BYTES_MAX = 2 * 1024 * 1024;
2622
+ var HISTORY_JSON_TEXT_CAP = 512 * 1024;
2623
+ var TERMINAL_PHASES = [
2624
+ "done",
2625
+ "error",
2626
+ "canceled"
2627
+ ];
2628
+ var STATUSES = [
2629
+ "keep",
2630
+ "no-change",
2631
+ "block"
2632
+ ];
2633
+ var LOG_FLAGS = new Set([
2634
+ "_",
2635
+ "loop",
2636
+ "help",
2637
+ "run",
2638
+ "diff",
2639
+ "since",
2640
+ "until",
2641
+ "status",
2642
+ "phase",
2643
+ "limit",
2644
+ "json"
2645
+ ]);
2646
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2647
+ function bool(v) {
2648
+ return v === true || v === "true";
2649
+ }
2650
+ function positiveInt(name, value, max) {
2651
+ if (value === void 0) return void 0;
2652
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return `--${name} must be a positive integer`;
2653
+ const n = Number(value);
2654
+ if (!Number.isSafeInteger(n) || n < 1 || max !== void 0 && n > max) return `--${name} must be between 1 and ${max ?? Number.MAX_SAFE_INTEGER}`;
2655
+ return n;
2656
+ }
2657
+ function iso(name, value) {
2658
+ if (value === void 0) return void 0;
2659
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value))) return { error: `--${name} must be an ISO timestamp` };
2660
+ return new Date(value).toISOString();
2661
+ }
2662
+ function parseHistoryFlags(flags) {
2663
+ const unknown = Object.keys(flags).filter((key) => !LOG_FLAGS.has(key));
2664
+ if (unknown.length) return {
2665
+ ok: false,
2666
+ error: `pievo: unknown flag --${unknown[0]} for log`
2667
+ };
2668
+ const limit = positiveInt("limit", flags.limit, 20);
2669
+ if (typeof limit === "string") return {
2670
+ ok: false,
2671
+ error: limit
2672
+ };
2673
+ const since = iso("since", flags.since);
2674
+ if (since && typeof since === "object") return {
2675
+ ok: false,
2676
+ error: since.error
2677
+ };
2678
+ const until = iso("until", flags.until);
2679
+ if (until && typeof until === "object") return {
2680
+ ok: false,
2681
+ error: until.error
2682
+ };
2683
+ if (since !== void 0 && until !== void 0 && since > until) return {
2684
+ ok: false,
2685
+ error: "--since must not be after --until"
2686
+ };
2687
+ const status = flags.status;
2688
+ if (status !== void 0 && (typeof status !== "string" || !STATUSES.includes(status))) return {
2689
+ ok: false,
2690
+ error: "--status must be keep|no-change|block"
2691
+ };
2692
+ const phase = flags.phase;
2693
+ if (phase !== void 0 && (typeof phase !== "string" || !TERMINAL_PHASES.includes(phase))) return {
2694
+ ok: false,
2695
+ error: "--phase must be done|error|canceled"
2696
+ };
2697
+ const runRaw = flags.run;
2698
+ if (bool(flags.diff) && runRaw === void 0) return {
2699
+ ok: false,
2700
+ error: "--diff requires --run"
2701
+ };
2702
+ if (runRaw !== void 0 && (since !== void 0 || until !== void 0 || status !== void 0 || phase !== void 0 || flags.limit !== void 0)) return {
2703
+ ok: false,
2704
+ error: "--run cannot be combined with time windows, list filters, or --limit"
2705
+ };
2706
+ let run;
2707
+ if (runRaw !== void 0) {
2708
+ if (typeof runRaw !== "string") return {
2709
+ ok: false,
2710
+ error: "--run needs a run index or full UUID"
2711
+ };
2712
+ if (/^\d+$/.test(runRaw)) {
2713
+ const parsed = positiveInt("run", runRaw);
2714
+ if (typeof parsed !== "number") return {
2715
+ ok: false,
2716
+ error: typeof parsed === "string" ? parsed : "--run needs a run index"
2717
+ };
2718
+ run = parsed;
2719
+ } else if (UUID.test(runRaw)) run = runRaw;
2720
+ else return {
2721
+ ok: false,
2722
+ error: "--run needs a positive run index or full UUID"
2723
+ };
2724
+ }
2725
+ return {
2726
+ ok: true,
2727
+ value: {
2728
+ mode: run === void 0 ? "list" : "detail",
2729
+ json: bool(flags.json),
2730
+ diff: bool(flags.diff),
2731
+ ...run !== void 0 ? { run } : {},
2732
+ ...since !== void 0 ? { since } : {},
2733
+ ...until !== void 0 ? { until } : {},
2734
+ ...status !== void 0 ? { status } : {},
2735
+ ...phase !== void 0 ? { phase } : {},
2736
+ limit: limit ?? 10
2737
+ }
2738
+ };
2739
+ }
2740
+ function totalTokenUsage(usage) {
2741
+ if (!usage || usage.inputTokens === void 0 && usage.outputTokens === void 0) return null;
2742
+ return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
2743
+ }
2744
+ function fmt(iso) {
2745
+ return iso.replace("T", " ").replace(".000Z", "Z");
2746
+ }
2747
+ function boundedText(value, cap = HISTORY_DETAIL_TEXT_CAP) {
2748
+ if (value == null) return {
2749
+ value: null,
2750
+ truncated: false
2751
+ };
2752
+ return {
2753
+ value: value.slice(0, cap),
2754
+ truncated: value.length > cap
2755
+ };
2756
+ }
2757
+ function normalizedListRun(run) {
2758
+ const message = boundedText(run.message ?? null, 500);
2759
+ return {
2760
+ runIndex: run.runIndex,
2761
+ terminalAt: run.ts,
2762
+ phase: run.phase,
2763
+ status: run.status ?? null,
2764
+ durationMs: run.durationMs ?? null,
2765
+ tokenUsage: totalTokenUsage(run.usage ?? null),
2766
+ message: message.value,
2767
+ messageTruncated: message.truncated,
2768
+ finalTextAvailable: !!run.finalText?.trim()
2769
+ };
2770
+ }
2771
+ function jsonText(value) {
2772
+ const text = JSON.stringify(value, null, 2);
2773
+ return Buffer.byteLength(text, "utf8") <= 524288 ? text : void 0;
2774
+ }
2775
+ function response(text, channel = {}) {
2776
+ const body = {
2777
+ ...channel,
2778
+ text
2779
+ };
2780
+ if (Buffer.byteLength(JSON.stringify(body), "utf8") > 524288) return {
2781
+ status: 413,
2782
+ body: { error: `history response exceeds ${HISTORY_JSON_TEXT_CAP} bytes; narrow the window` }
2783
+ };
2784
+ return {
2785
+ status: 200,
2786
+ body
2787
+ };
2788
+ }
2789
+ async function listHistory(loop, q) {
2790
+ const conditions = [
2791
+ eq(runs.loopId, loop.id),
2792
+ isNotNull(runs.runIndex),
2793
+ inArray(runs.phase, [...TERMINAL_PHASES]),
2794
+ q.since ? gte(runs.ts, q.since) : void 0,
2795
+ q.until ? lte(runs.ts, q.until) : void 0,
2796
+ q.status ? eq(runs.status, q.status) : void 0,
2797
+ q.phase ? eq(runs.phase, q.phase) : void 0
2798
+ ];
2799
+ const [rows, counted] = await Promise.all([db.select().from(runs).where(and(...conditions)).orderBy(desc(runs.runIndex)).limit(q.limit), db.select({ n: sql`count(*)` }).from(runs).where(and(...conditions))]);
2800
+ const normalized = rows.map(normalizedListRun);
2801
+ const data = {
2802
+ count: normalized.length,
2803
+ total: Number(counted[0]?.n ?? 0),
2804
+ runs: normalized
2805
+ };
2806
+ if (q.json) {
2807
+ const text = jsonText(data);
2808
+ return text ? response(text) : {
2809
+ status: 413,
2810
+ body: { error: "history JSON exceeds the response cap; lower --limit" }
2811
+ };
2812
+ }
2813
+ const table = rows.length ? listBlock("runs", [
2814
+ "index",
2815
+ "terminal",
2816
+ "phase",
2817
+ "status",
2818
+ "durationMs",
2819
+ "tokenUsage",
2820
+ "message",
2821
+ "finalTextAvailable"
2822
+ ], rows.map((run) => [
2823
+ run.runIndex,
2824
+ fmt(run.ts),
2825
+ run.phase,
2826
+ run.status ?? null,
2827
+ run.durationMs ?? null,
2828
+ totalTokenUsage(run.usage ?? null),
2829
+ run.message ? truncate(run.message, 500, "use --run for detail").value : null,
2830
+ !!run.finalText?.trim()
2831
+ ])) : emptyList("runs");
2832
+ return response(doc(`loop: ${scalar(loop.name)} (${loop.id})`, `count: ${rows.length} of ${Number(counted[0]?.n ?? 0)} matching`, table, helpBlock(["Use `pievo log --run <index>` for one run"])));
2833
+ }
2834
+ async function boundedDiff(runId) {
2835
+ const raw = await computeRunDiff(runId, {
2836
+ maxFiles: 100,
2837
+ maxInputBytes: HISTORY_DIFF_INPUT_BYTES_MAX,
2838
+ maxDiffChars: HISTORY_DIFF_TEXT_CAP
2839
+ });
2840
+ if (!raw.hasSnapshot) return {
2841
+ included: true,
2842
+ available: false,
2843
+ reason: "snapshot-unavailable",
2844
+ truncated: false,
2845
+ files: []
2846
+ };
2847
+ return {
2848
+ included: true,
2849
+ available: true,
2850
+ reason: null,
2851
+ truncated: raw.truncated ?? false,
2852
+ totalFiles: raw.totalFiles ?? raw.files.length,
2853
+ truncation: raw.truncation,
2854
+ work: raw.work,
2855
+ files: raw.files
2856
+ };
2857
+ }
2858
+ async function detailHistory(loop, q) {
2859
+ const selector = typeof q.run === "number" ? eq(runs.runIndex, q.run) : eq(runs.id, q.run);
2860
+ const run = (await db.select().from(runs).where(and(eq(runs.loopId, loop.id), selector)).limit(1))[0];
2861
+ if (!run) return {
2862
+ status: 404,
2863
+ body: { error: "no such run in this loop" }
2864
+ };
2865
+ const snapshot = await getRunSnapshot(run.id);
2866
+ const message = boundedText(run.message ?? null);
2867
+ const error = boundedText(run.error ?? null);
2868
+ const finalText = boundedText(run.finalText ?? null);
2869
+ const detail = {
2870
+ runIndex: run.runIndex ?? null,
2871
+ terminalAt: TERMINAL_PHASES.includes(run.phase) ? run.ts : null,
2872
+ phase: run.phase,
2873
+ status: run.status ?? null,
2874
+ durationMs: run.durationMs ?? null,
2875
+ tokenUsage: totalTokenUsage(run.usage ?? null),
2876
+ message: message.value,
2877
+ messageTruncated: message.truncated,
2878
+ error: error.value,
2879
+ errorTruncated: error.truncated,
2880
+ finalText: finalText.value,
2881
+ finalTextTruncated: finalText.truncated,
2882
+ diffAvailable: !!snapshot,
2883
+ diff: q.diff ? await boundedDiff(run.id) : {
2884
+ included: false,
2885
+ available: !!snapshot
2886
+ }
2887
+ };
2888
+ const json = jsonText(detail);
2889
+ if (!json) return {
2890
+ status: 413,
2891
+ body: { error: "run detail exceeds the response cap" }
2892
+ };
2893
+ if (q.json) return response(json);
2894
+ return response(detailBlock("run", [
2895
+ ["index", detail.runIndex],
2896
+ ["terminalAt", detail.terminalAt],
2897
+ ["phase", detail.phase],
2898
+ ["status", detail.status],
2899
+ ["durationMs", detail.durationMs],
2900
+ ["tokenUsage", detail.tokenUsage],
2901
+ ["message", detail.message],
2902
+ ["messageTruncated", detail.messageTruncated],
2903
+ ["error", detail.error],
2904
+ ["errorTruncated", detail.errorTruncated],
2905
+ ["finalText", detail.finalText],
2906
+ ["finalTextTruncated", detail.finalTextTruncated],
2907
+ ["diffAvailable", detail.diffAvailable],
2908
+ ["diff", { raw: JSON.stringify(detail.diff) }]
2909
+ ]));
2910
+ }
2911
+ /** Authorized owner history: bounded ordinary terminal list or one run detail. */
2912
+ async function readLoopHistory(loop, flags) {
2913
+ const parsed = parseHistoryFlags(flags);
2914
+ if (!parsed.ok) return {
2915
+ status: 400,
2916
+ body: { error: parsed.error }
2917
+ };
2918
+ return parsed.value.mode === "detail" ? detailHistory(loop, parsed.value) : listHistory(loop, parsed.value);
2919
+ }
2920
+ /**
2921
+ * CLI dispatch - the credential-keyed verb router half of the machine gateway,
2922
+ * split out of `MachineGateway` (which keeps poll/report/sweep/owner verbs).
2923
+ * Same wire surface, framework-agnostic like the rest of the gateway
2924
+ * (`{ status, body }` results):
2925
+ *
2926
+ * POST /api/machine/cli (Bearer device OR run credential) → unified dispatch
2927
+ *
2928
+ * The verb router keys authority on credential type first (`dk_` device prefix
2929
+ * vs `rk_` run lease) and reuses the core gateway
2930
+ * methods (`createLoop`/`editLoop`) through the injected `MachineGateway`,
2931
+ * plus the credential-neutral history module. Run credentials expose only the
2932
+ * exactly-once report callback.
2933
+ */
2934
+ var cliLog = logger.child({ mod: "cli" });
2935
+ var CliGateway = class {
2936
+ gateway;
2937
+ pauseLoopState;
2938
+ forceDeleteLoop;
2939
+ destructiveLog;
2940
+ constructor(gateway, deps = {}) {
2941
+ this.gateway = gateway;
2942
+ this.pauseLoopState = deps.pauseLoopState ?? pauseLoopState;
2943
+ this.forceDeleteLoop = deps.forceDeleteLoop ?? forceDeleteLoop;
2944
+ this.destructiveLog = deps.destructiveLog ?? ((event) => cliLog.warn(event, "force-delete: destructive server authority removal"));
2945
+ }
2946
+ /**
2947
+ * The unified CLI endpoint. It is a ROUTER in front of the gateway logic that
2948
+ * already exists — never a rewrite — that keys authority on the credential type:
2949
+ * · DEVICE credential (`dk_`-prefixed) → owner authority over any loop bound to
2950
+ * 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.
2954
+ */
2955
+ async cli(token, argv) {
2956
+ return finalizeCli(token.startsWith("dk_") ? await this.deviceCli(token, argv) : await this.runCli(token, argv));
2957
+ }
2958
+ async deviceCli(deviceToken, argv) {
2959
+ const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
2960
+ if (!auth.ok) return auth.response;
2961
+ const { machineId, machine } = auth;
2962
+ const verb = argv[0] ?? "";
2963
+ if (verb === "home") return {
2964
+ status: 200,
2965
+ body: {
2966
+ ok: true,
2967
+ text: await this.homeDevice(machineId, parseFlags(argv.slice(1)))
2968
+ }
2969
+ };
2970
+ if (!machine) return {
2971
+ status: 401,
2972
+ body: { error: "unknown machine (token not registered)" }
2973
+ };
2974
+ const flags = parseFlags(argv.slice(1));
2975
+ const loopArg = typeof flags["loop"] === "string" ? flags["loop"] : typeof flags["_"] === "string" ? flags["_"] : "";
2976
+ if (flags["help"] === true) {
2977
+ const h = verbHelpText(verb);
2978
+ if (h) return {
2979
+ status: 200,
2980
+ body: {
2981
+ ok: true,
2982
+ text: h
2983
+ }
2984
+ };
2985
+ }
2986
+ switch (verb) {
2987
+ case "new": {
2988
+ const parsed = parseJsonFlag(flags["json"]);
2989
+ if (!parsed.ok) return {
2990
+ status: 400,
2991
+ body: { error: parsed.error }
2992
+ };
2993
+ const config = { ...parsed.value };
2994
+ if (flags["dry-run"] === true) config.dryRun = true;
2995
+ return this.gateway.createLoop(deviceToken, config);
2996
+ }
2997
+ case "loops": return this.gateway.listLoops(deviceToken, typeof flags["fields"] === "string" ? flags["fields"] : void 0, flags["json"] === true);
2998
+ case "pause": return this.pauseOwnerLoop(machineId, loopArg);
2999
+ case "start": return this.startOwnerLoop(machineId, loopArg);
3000
+ case "stop": return this.stopOwnerLoop(machineId, loopArg);
3001
+ case "delete": return this.deleteOwnerLoop(machineId, loopArg, flags["force"] === true, typeof flags["confirmation"] === "string" ? flags["confirmation"] : void 0);
3002
+ case "run": return this.stopOwnerRun(machineId, argv);
3003
+ case "edit": {
3004
+ const parsed = parseJsonFlag(flags["json"]);
3005
+ if (!parsed.ok) return {
3006
+ status: 400,
3007
+ body: { error: parsed.error }
3008
+ };
3009
+ return this.gateway.editLoop(deviceToken, loopArg || void 0, parsed.value, flags["dry-run"] === true);
3010
+ }
3011
+ case "log": {
3012
+ const loop = await this.ownedLoop(machineId, loopArg);
3013
+ if (!loop) return {
3014
+ status: 404,
3015
+ body: { error: "no such loop on this machine" }
3016
+ };
3017
+ return readLoopHistory(loop, flags);
3018
+ }
3019
+ case "show": {
3020
+ const loop = loopArg ? await getLoop(loopArg) : void 0;
3021
+ if (!loop || loop.machineId !== machineId) return {
3022
+ status: 404,
3023
+ body: { error: "no such loop on this machine" }
3024
+ };
3025
+ if (flags["json"] === true) {
3026
+ const env = loopEnvelope(loop);
3027
+ return {
3028
+ status: 200,
3029
+ body: {
3030
+ ok: true,
3031
+ loop: env,
3032
+ text: JSON.stringify(env, null, 2)
3033
+ }
3034
+ };
3035
+ }
3036
+ return {
3037
+ status: 200,
3038
+ body: {
3039
+ ok: true,
3040
+ text: await this.describe(loop.id, { full: flags["full"] === true })
3041
+ }
3042
+ };
3043
+ }
3044
+ case "report": return {
3045
+ status: 403,
3046
+ body: { error: "pievo: report is a run-only verb; the owner edits via edit" }
3047
+ };
3048
+ default: return {
3049
+ 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)` }
3051
+ };
3052
+ }
3053
+ }
3054
+ async ownedLoop(machineId, loopId) {
3055
+ if (!loopId) return void 0;
3056
+ const loop = await getLoop(loopId);
3057
+ return loop?.machineId === machineId ? loop : void 0;
3058
+ }
3059
+ async pauseOwnerLoop(machineId, loopId) {
3060
+ const loop = await this.ownedLoop(machineId, loopId);
3061
+ if (!loop) return {
3062
+ status: 404,
3063
+ body: { error: "no such loop on this machine" }
3064
+ };
3065
+ const paused = await this.pauseLoopState(loop.id);
3066
+ if (!paused) return {
3067
+ status: 404,
3068
+ body: { error: "no such loop on this machine" }
3069
+ };
3070
+ this.gateway.scheduler.removeLoop(loop.id);
3071
+ return {
3072
+ status: 200,
3073
+ body: { text: paused.running ? PAUSED_FINISHING : "loop paused; future runs disabled" }
3074
+ };
3075
+ }
3076
+ async startOwnerLoop(machineId, loopId) {
3077
+ const loop = await this.ownedLoop(machineId, loopId);
3078
+ if (!loop) return {
3079
+ status: 404,
3080
+ body: { error: "no such loop on this machine" }
3081
+ };
3082
+ if (loop.deleteRequestedAt) return {
3083
+ status: 409,
3084
+ body: { error: "loop is being deleted and cannot be started" }
3085
+ };
3086
+ const started = await startLoop(loop.id);
3087
+ if (!started) return {
3088
+ status: 409,
3089
+ body: { error: "loop could not be started" }
3090
+ };
3091
+ this.gateway.scheduler.addLoop(started);
3092
+ return {
3093
+ status: 200,
3094
+ body: { text: "loop started; preserved queued work is now eligible" }
3095
+ };
3096
+ }
3097
+ async requireStopProtocol(machineId, running) {
3098
+ if (!running) return void 0;
3099
+ if ((await getMachine(machineId))?.daemonProtocol === 4) return void 0;
3100
+ return {
3101
+ status: 426,
3102
+ body: {
3103
+ text: STOP_UPGRADE_REQUIRED,
3104
+ exitCode: 1
3105
+ }
3106
+ };
3107
+ }
3108
+ async stopOwnerLoop(machineId, loopId) {
3109
+ const loop = await this.ownedLoop(machineId, loopId);
3110
+ if (!loop) return {
3111
+ status: 404,
3112
+ body: { error: "no such loop on this machine" }
3113
+ };
3114
+ const target = await runningRunForLoop(loop.id);
3115
+ const upgrade = await this.requireStopProtocol(machineId, target);
3116
+ if (upgrade) return upgrade;
3117
+ const result = await stopLoop(loop.id);
3118
+ this.gateway.scheduler.removeLoop(loop.id);
3119
+ if (!result) return {
3120
+ status: 404,
3121
+ body: { error: "no such loop on this machine" }
3122
+ };
3123
+ const machine = await getMachine(machineId);
3124
+ return {
3125
+ status: 200,
3126
+ body: { text: result.running ? `stop requested; waiting for ${machine?.name || "machine"}` : "loop paused; queued work canceled; no current run" }
3127
+ };
3128
+ }
3129
+ async deleteOwnerLoop(machineId, loopId, force, confirmation) {
3130
+ const loop = await this.ownedLoop(machineId, loopId);
3131
+ if (!loop) return {
3132
+ status: 404,
3133
+ body: { error: "no such loop on this machine" }
3134
+ };
3135
+ if (force) {
3136
+ if (!loop.deleteRequestedAt) return {
3137
+ status: 409,
3138
+ body: { error: "delete must be requested first" }
3139
+ };
3140
+ if (confirmation !== FORCE_DELETE_CONFIRMATION) return {
3141
+ status: 400,
3142
+ body: { error: "force delete confirmation required" }
3143
+ };
3144
+ const machine = await getMachine(machineId);
3145
+ const reachability = machinePresence(!!machine?.online, machine?.lastSeen ?? null);
3146
+ if (!await this.forceDeleteLoop(loop.id)) return {
3147
+ status: 409,
3148
+ body: { error: "force delete failed; server data was not deleted" }
3149
+ };
3150
+ this.gateway.scheduler.removeLoop(loop.id);
3151
+ this.destructiveLog({
3152
+ action: "force-delete",
3153
+ loopId: loop.id,
3154
+ machineId,
3155
+ actorUserId: loop.userId,
3156
+ machineReachability: reachability
3157
+ });
3158
+ return {
3159
+ status: 200,
3160
+ body: { text: forceDeleteWarning(reachability) }
3161
+ };
3162
+ }
3163
+ const target = await runningRunForLoop(loop.id);
3164
+ const upgrade = await this.requireStopProtocol(machineId, target);
3165
+ if (upgrade) return upgrade;
3166
+ const result = await requestDeleteLoop(loop.id);
3167
+ this.gateway.scheduler.removeLoop(loop.id);
3168
+ if (!result) return {
3169
+ status: 404,
3170
+ body: { error: "no such loop on this machine" }
3171
+ };
3172
+ if (await tryDeleteLoop(loop.id)) return {
3173
+ status: 200,
3174
+ body: { text: "loop deleted; local project files were not deleted" }
3175
+ };
3176
+ return {
3177
+ status: 200,
3178
+ body: { text: `delete requested; stop requested; waiting for ${(await getMachine(machineId))?.name || "machine"}` }
3179
+ };
3180
+ }
3181
+ async stopOwnerRun(machineId, argv) {
3182
+ if (argv[1] !== "stop" || !argv[2]) return {
3183
+ status: 400,
3184
+ body: { error: "usage: pievo run stop <run>" }
3185
+ };
3186
+ const run = await getRun(argv[2]);
3187
+ if (!run || run.machineId !== machineId) return {
3188
+ status: 404,
3189
+ body: { error: "no such run on this machine" }
3190
+ };
3191
+ const upgrade = await this.requireStopProtocol(machineId, run.phase === "running" ? run : void 0);
3192
+ if (upgrade) return upgrade;
3193
+ const stopped = await requestRunCancel(run.loopId, run.id);
3194
+ if (!stopped) return {
3195
+ status: 404,
3196
+ body: { error: "no such run on this machine" }
3197
+ };
3198
+ if (stopped.phase === "running") return {
3199
+ status: 200,
3200
+ body: { text: `stop requested; waiting for ${(await getMachine(machineId))?.name || "machine"}` }
3201
+ };
3202
+ if (stopped.phase === "canceled") return {
3203
+ status: 200,
3204
+ body: { text: "run canceled before it started" }
3205
+ };
3206
+ return {
3207
+ status: 200,
3208
+ body: { text: `run already finished: ${runResultToken(stopped)}` }
3209
+ };
3210
+ }
3211
+ async runCli(runToken, argv) {
3212
+ const lease = await resolveLease(runToken);
3213
+ if (!lease) return {
3214
+ status: 401,
3215
+ body: {
3216
+ text: errorBlock("invalid or expired token", "UNAUTHORIZED"),
3217
+ exitCode: 1
3218
+ }
3219
+ };
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 {
3230
+ status: 403,
3231
+ body: {
3232
+ text: errorBlock(`"${verb}" needs the device credential (owner authority); a run may only report`, "FORBIDDEN"),
3233
+ exitCode: 1
3234
+ }
3235
+ };
3236
+ if (flags.help === true && verb === "report") return {
3237
+ status: 200,
3238
+ body: {
3239
+ text: verbHelpText("report", lease),
3240
+ exitCode: 0
3241
+ }
3242
+ };
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
+ }
3249
+ };
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
+ }
3257
+ };
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"
3308
+ };
3309
+ if (result.state === "missing-loop") return {
3310
+ ok: false,
3311
+ detail: "loop not found",
3312
+ code: "NOT_FOUND",
3313
+ status: 404
3314
+ };
3315
+ if (result.state === "already-reported") return {
3316
+ ok: false,
3317
+ detail: "this run already reported",
3318
+ code: "CONFLICT",
3319
+ status: 409
3320
+ };
3321
+ return {
3322
+ ok: false,
3323
+ detail: "this run is no longer active",
3324
+ code: "CONFLICT",
3325
+ status: 409
3326
+ };
3327
+ }
3328
+ async describe(loopId, opts = {}) {
3329
+ const loop = await getLoop(loopId);
3330
+ if (!loop) return "loop not found";
3331
+ const recent = (await listRuns(loop.id, 8)).slice().reverse();
3332
+ return renderShowText(loop, loopEnvelope(loop), await countRuns(loop.id), recent[0] ?? null, opts);
3333
+ }
3334
+ /**
3335
+ * Bare `pievo` renders the home for a device credential. The
3336
+ * daemon passes the local facts it alone knows as context flags (`--cwd`/`--home`
3337
+ * for directory scoping, `--bin`/`--pid`/`--server` for the header); the server owns
3338
+ * the whole TOON render (text-sink). An unregistered machine renders a DEFINITIVE
3339
+ * "not connected" state (never empty, never an error).
3340
+ */
3341
+ async homeDevice(machineId, flags) {
3342
+ const machine = await getMachine(machineId);
3343
+ const ctx = {
3344
+ bin: typeof flags["bin"] === "string" ? flags["bin"] : null,
3345
+ pid: typeof flags["pid"] === "string" ? flags["pid"] : null,
3346
+ server: typeof flags["server"] === "string" ? flags["server"] : null,
3347
+ cwd: typeof flags["cwd"] === "string" ? flags["cwd"] : null,
3348
+ home: typeof flags["home"] === "string" ? flags["home"] : null
3349
+ };
3350
+ if (!machine) return renderHomeText(ctx, null, [], 0, []);
3351
+ const presence = machinePresence(machine.online, machine.lastSeen);
3352
+ const loops = await loopsForMachine(machineId);
3353
+ const scoped = scopeLoopsByCwd(loops, ctx.cwd, ctx.home);
3354
+ return renderHomeText(ctx, presence, await Promise.all(scoped.here.map(async (l) => {
3355
+ const schedule = scheduleFromLoop(l);
3356
+ return {
3357
+ id: l.id,
3358
+ name: l.name,
3359
+ cron: schedule.mode === "continuous" ? `continuous +${schedule.delayMinutes}m` : schedule.cron,
3360
+ enabled: l.enabled,
3361
+ nextFire: l.enabled && schedule.mode === "cron" ? nextFires(schedule.cron, schedule.timezone, 1)[0] ?? null : null,
3362
+ lastResult: await (async () => {
3363
+ const last = await lastRun(l.id);
3364
+ return last ? runResultToken(last) : null;
3365
+ })()
3366
+ };
3367
+ })), scoped.elsewhere, await recentMachineRuns(loops, 3));
3368
+ }
3369
+ };
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
+ ]);
3381
+ var PAUSED_FINISHING = "loop paused; current run is finishing";
3382
+ var STOP_UPGRADE_REQUIRED = "Daemon upgrade required to stop a running process. Run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`.";
3383
+ var FORCE_DELETE_CONFIRMATION = "delete-server-data-anyway";
3384
+ function forceDeleteWarning(reachability) {
3385
+ return `The ${reachability === "online" ? "machine is online" : reachability === "asleep" ? "machine is asleep" : "machine is unreachable"}. Its local process may still be running. This removes Pievo authority and server data only. Local project files are not deleted.`;
3386
+ }
3387
+ /** Parse a `--json '<obj>'` flag into an object. Absent → an empty object (the
3388
+ * downstream createLoop/editLoop validators then produce the precise error, e.g.
3389
+ * "cron required"). Present-but-not-a-JSON-object → a legible 400. Shared by the
3390
+ * device-credential `new`/`edit` verbs of the unified CLI. */
3391
+ function parseJsonFlag(raw) {
3392
+ if (raw === void 0 || raw === true) return {
3393
+ ok: true,
3394
+ value: {}
3395
+ };
3396
+ if (typeof raw !== "string") return {
3397
+ ok: false,
3398
+ error: "pievo: --json must be a JSON object string"
3399
+ };
3400
+ let obj;
3401
+ try {
3402
+ obj = JSON.parse(raw);
3403
+ } catch {
3404
+ return {
3405
+ ok: false,
3406
+ error: "pievo: --json must be valid JSON (an object)"
3407
+ };
3408
+ }
3409
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {
3410
+ ok: false,
3411
+ error: "pievo: --json must be a JSON object"
3412
+ };
3413
+ return {
3414
+ ok: true,
3415
+ value: obj
3416
+ };
3417
+ }
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
+ /** The structured keys a `/api/machine/cli` body may carry. The daemon renders
3428
+ * `text` and exits with `exitCode`; two structured channels remain because the
3429
+ * current daemon reads them as data:
3430
+ * - `loops`: the daemon resolves cwd→loop CLIENT-side (`log`/`show`/`home`) from this
3431
+ * list; the server's `log`/`show` dispatch needs an explicit id.
3432
+ * - `runs`: the `log --json` normalized-data escape hatch. */
3433
+ var CLI_RETAINED_KEYS = new Set([
3434
+ "text",
3435
+ "exitCode",
3436
+ "loops",
3437
+ "runs"
3438
+ ]);
3439
+ /** Finalize a `/api/machine/cli` body with `text` + `exitCode` and only the
3440
+ * structured fields used by the daemon. A structured `{error}` is first
3441
+ * rendered to `error:`/`code:` TOON so the daemon prints it to stdout. */
3442
+ function finalizeCli(res) {
3443
+ const b = res.body;
3444
+ if (b && typeof b === "object" && !Array.isArray(b)) {
3445
+ const body = b;
3446
+ if (typeof body.text !== "string" && typeof body.error === "string") body.text = errorBlock(body.error, codeForStatus(res.status));
3447
+ if (typeof body.exitCode !== "number") body.exitCode = res.status >= 200 && res.status < 300 ? 0 : 1;
3448
+ if (typeof body.text === "string") {
3449
+ for (const k of Object.keys(body)) if (!CLI_RETAINED_KEYS.has(k)) delete body[k];
3450
+ }
3451
+ }
3452
+ return res;
3453
+ }
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
+ var DEVICE_VERB_HELP = {
3467
+ new: {
3468
+ syntax: "new --json '<config>' [--dry-run]",
3469
+ summary: `create a loop from the canonical envelope (keys: ${[...EDITABLE_LOOP_FIELDS].join(", ")}; agent required)`,
3470
+ help: ["Run `pievo new --json '{\"name\":\"Daily check\",\"schedule\":{\"mode\":\"cron\",\"cron\":\"0 8 * * *\",\"timezone\":\"UTC\",\"overlap\":\"skip\"},\"workdir\":\"<path>\",\"agent\":\"claude-code\",\"prompt\":\"Check the project.\",\"statusDefinitions\":{\"keep\":\"result retained\",\"noChange\":\"nothing changed\",\"block\":\"owner input required\"}}'` to create a loop", "Run `pievo new --json '{...}' --dry-run` to validate without creating"]
3471
+ },
3472
+ loops: {
3473
+ syntax: "loops [--fields a,b] [--json]",
3474
+ summary: "list every loop bound to this machine",
3475
+ help: ["Run `pievo show <id>` to see a loop's full config", "Run `pievo log <id>` to see a loop's recent runs"]
3476
+ },
3477
+ edit: {
3478
+ syntax: "edit <id> --json '<patch>' [--dry-run]",
3479
+ summary: `change a loop's config using one JSON patch (keys: ${[...EDITABLE_LOOP_FIELDS].join(", ")})`,
3480
+ help: ["Run `pievo edit <id> --json '{\"schedule\":{\"mode\":\"continuous\",\"delayMinutes\":5}}'` to change the schedule", "Run `pievo edit <id> --json '{...}' --dry-run` to preview the change"]
3481
+ },
3482
+ show: {
3483
+ syntax: "show [<id|unique-name>] [--full] [--json]",
3484
+ summary: "print a loop's full config + recent runs (defaults from the current directory)",
3485
+ help: ["Run `pievo loops` to list loops on this machine", "Run `pievo log <id>` to see the loop's recent runs"]
3486
+ },
3487
+ pause: {
3488
+ syntax: "pause <id>",
3489
+ summary: "pause future runs; the current run continues",
3490
+ help: ["Pause future runs? The current run will continue."]
3491
+ },
3492
+ start: {
3493
+ syntax: "start <id>",
3494
+ summary: "enable a paused loop and re-arm its existing cadence",
3495
+ help: ["Completed loops must be reopened explicitly; deleting loops cannot be started"]
3496
+ },
3497
+ stop: {
3498
+ syntax: "stop <id>",
3499
+ summary: "pause the loop, cancel queued work, and request process termination",
3500
+ help: ["Pause this loop, cancel queued work, and stop the current run if it is still running?"]
3501
+ },
3502
+ delete: {
3503
+ 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"]
3506
+ },
3507
+ run: {
3508
+ syntax: "run stop <run-id>",
3509
+ summary: "stop one pending or running run without pausing its loop",
3510
+ help: ["A running run remains running until the daemon confirms cancellation"]
3511
+ },
3512
+ log: {
3513
+ 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
+ summary: "bounded terminal history or one detailed run",
3515
+ help: ["Run `pievo log <id> --json` for structured history", "Run `pievo log <id> --run 12 --diff` for bounded run detail and artifact diff"]
3516
+ }
3517
+ };
3518
+ function verbHelpText(verb, lease) {
3519
+ const spec = lease ? RUN_VERB_HELP[verb] : DEVICE_VERB_HELP[verb];
3520
+ 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));
3522
+ }
3523
+ function loopEnvelope(loop) {
3524
+ return { ...canonicalLoopEnvelope(loop) };
3525
+ }
3526
+ function contentField(value, full) {
3527
+ if (value == null) return "absent";
3528
+ if (full) return value;
3529
+ return { raw: `present, ${value.length} bytes — use --full to see` };
3530
+ }
3531
+ function nextFireDisplay(loop) {
3532
+ const schedule = scheduleFromLoop(loop);
3533
+ if (schedule.mode === "continuous") return `after exec terminal + ${schedule.delayMinutes}m`;
3534
+ const iso = nextFires(schedule.cron, schedule.timezone, 1)[0];
3535
+ if (!iso) return "(never)";
3536
+ return fmtTimeZoned(iso, schedule.timezone, { seconds: true });
3537
+ }
3538
+ function renderShowText(loop, env, totalRuns, lastRun, opts = {}) {
3539
+ const full = opts.full === true;
3540
+ const block = detailBlock("loop", [
3541
+ ["id", env.id],
3542
+ ["name", env.name],
3543
+ ["tags", { raw: JSON.stringify(env.tags) }],
3544
+ ["schedule", { raw: JSON.stringify(env.schedule) }],
3545
+ ["workdir", env.workdir],
3546
+ ["agent", env.agent],
3547
+ ["model", env.model == null ? { raw: "default" } : env.model],
3548
+ ["reasoningEffort", env.reasoningEffort == null ? { raw: "default" } : env.reasoningEffort],
3549
+ ["prompt", contentField(loop.prompt, full)],
3550
+ ["statusDefinitions", { raw: JSON.stringify(env.statusDefinitions) }],
3551
+ ["artifacts", { raw: JSON.stringify(env.artifacts) }],
3552
+ ["enabled", env.enabled]
3553
+ ]);
3554
+ const runsTally = lastRun ? `${totalRuns} total · last ${runResultToken(lastRun)} ${fmtTime(lastRun.ts)}` : `${totalRuns} total`;
3555
+ const help = [
3556
+ `Run \`pievo show ${loop.id} --full\` to see the complete prompt`,
3557
+ `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`
3559
+ ];
3560
+ 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
+ }
3562
+ var HOME_DESCRIPTION = "Run your scheduled Pievo agent loops on this machine with your own coding agent.";
3563
+ /** Expand a leading `~/` against the daemon-supplied home dir (the SERVER's own home
3564
+ * is irrelevant — a loop's paths are the daemon machine's). Absent home ⇒ unchanged. */
3565
+ function expandHome(p, home) {
3566
+ return home && p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
3567
+ }
3568
+ function scopeLoopDir(workdir, home) {
3569
+ return path.resolve(expandHome(workdir, home));
3570
+ }
3571
+ /**
3572
+ * Partition a machine's loops into the ones rooted at (or under) `cwd` — the
3573
+ * directory-scoped ambient context and a count of the rest. With no cwd
3574
+ * (or none matching), ALL loops are "here" (elsewhere 0): a home run from an
3575
+ * unrelated directory still shows the whole machine rather than nothing.
3576
+ */
3577
+ function scopeLoopsByCwd(loops, cwd, home) {
3578
+ if (!cwd) return {
3579
+ here: loops,
3580
+ elsewhere: 0
3581
+ };
3582
+ const here = path.resolve(cwd);
3583
+ const matched = loops.filter((l) => {
3584
+ const dir = scopeLoopDir(l.workdir, home);
3585
+ return here === dir || here.startsWith(dir + path.sep);
3586
+ });
3587
+ if (matched.length === 0) return {
3588
+ here: loops,
3589
+ elsewhere: 0
3590
+ };
3591
+ return {
3592
+ here: matched,
3593
+ elsewhere: loops.length - matched.length
3594
+ };
3595
+ }
3596
+ async function recentMachineRuns(loops, n) {
3597
+ const rows = [];
3598
+ for (const l of loops) for (const r of await listRuns(l.id, n)) rows.push({
3599
+ ts: r.ts,
3600
+ loop: l.name,
3601
+ result: runResultToken(r)
3602
+ });
3603
+ rows.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
3604
+ return rows.slice(0, n);
3605
+ }
3606
+ /** The device home: `bin:`/`description:`/`machine:` header, the cwd-scoped
3607
+ * loop list, recent runs, and a `help[]`. `presence` null ⇒ the machine is not
3608
+ * registered → a DEFINITIVE "not connected" state (never empty, never an error). */
3609
+ function renderHomeText(ctx, presence, here, elsewhere, recent) {
3610
+ const machineLine = presence === null ? "machine: not connected — run `pievo daemon connect`" : `machine: ${[
3611
+ presence,
3612
+ ctx.pid ? `daemon pid ${ctx.pid}` : null,
3613
+ ctx.server
3614
+ ].filter(Boolean).join(" · ")}`;
3615
+ const binLineText = ctx.bin ? kvLine("bin", ctx.bin) : "bin: (not on PATH — run `npm install -g @kky42/pievo@latest`)";
3616
+ if (presence === null) return doc(binLineText, kvLine("description", HOME_DESCRIPTION), machineLine, helpBlock(["Run `pievo daemon connect --server-url <url> --connect-key <dk_…>` to connect this machine", "Run `pievo --help` to see every command"]));
3617
+ const loopsName = elsewhere > 0 ? "loops here" : "loops";
3618
+ const loopsBlock = here.length ? listBlock(loopsName, [
3619
+ "name",
3620
+ "cron",
3621
+ "enabled",
3622
+ "nextFire",
3623
+ "lastResult"
3624
+ ], here.map((l) => [
3625
+ l.name,
3626
+ l.cron,
3627
+ l.enabled ? "on" : "paused",
3628
+ l.nextFire ? fmtTime(l.nextFire) : null,
3629
+ l.lastResult
3630
+ ])) : emptyList(loopsName);
3631
+ const recentBlock = recent.length ? listBlock("recent", [
3632
+ "ts",
3633
+ "loop",
3634
+ "result"
3635
+ ], recent.map((r) => [
3636
+ fmtTime(r.ts),
3637
+ r.loop,
3638
+ r.result
3639
+ ])) : null;
3640
+ return doc(binLineText, kvLine("description", HOME_DESCRIPTION), machineLine, loopsBlock, elsewhere > 0 ? `loops elsewhere: ${elsewhere} more on this machine` : null, recentBlock, helpBlock([
3641
+ "Run `pievo loops` to list every loop on this machine",
3642
+ "Run `pievo show <id>` to inspect a loop, `pievo log <id>` for its runs",
3643
+ "Run `pievo new --json '{...}'` to create a loop"
3644
+ ]));
3645
+ }
3646
+ function reportStatus(s) {
3647
+ return s === "keep" || s === "no-change" || s === "block" ? s : void 0;
3648
+ }
3649
+ /** Tiny flag parser: `--k v` pairs, bare `--flag` → true, first positional under `_`.
3650
+ * Every key/value is NUL-stripped at this wire boundary before validation or
3651
+ * persistence because Postgres text rejects NUL. */
3652
+ function parseFlags(args) {
3653
+ const out = {};
3654
+ for (let i = 0; i < args.length; i++) {
3655
+ const a = args[i];
3656
+ if (a.startsWith("--")) {
3657
+ const key = stripNul(a.slice(2));
3658
+ const next = args[i + 1];
3659
+ if (next !== void 0 && !next.startsWith("--")) {
3660
+ out[key] = stripNul(next);
3661
+ i++;
3662
+ } else out[key] = true;
3663
+ } else if (out["_"] === void 0) out["_"] = stripNul(a);
3664
+ }
3665
+ return out;
3666
+ }
3667
+ var log = logger.child({ mod: "scheduler" });
3668
+ var MAX_TIMER_MS = 2147483647;
3669
+ var DUE_RETRY_MS = 2e4;
3670
+ /** Thin timer layer over durable schedule facts. All state transitions live in
3671
+ * the store; this class only arms hints and wakes parked machine polls. */
3672
+ var Scheduler = class {
3673
+ dispatcher;
3674
+ timers = /* @__PURE__ */ new Map();
3675
+ constructor(dispatcher) {
3676
+ this.dispatcher = dispatcher;
3677
+ }
3678
+ async start(signal) {
3679
+ await initializeCronCadence();
3680
+ const all = await listLoops();
3681
+ for (const loop of all) this.addLoop(loop);
3682
+ await this.advanceDueSchedules();
3683
+ signal.addEventListener("abort", () => this.stopAll(), { once: true });
3684
+ log.info({
3685
+ loops: all.length,
3686
+ timers: this.timers.size
3687
+ }, "scheduler started");
3688
+ }
3689
+ static nextRun(expr) {
3690
+ const probe = new E(expr, { paused: true });
3691
+ try {
3692
+ const next = probe.nextRun();
3693
+ if (!next) throw new Error(`cron expression never fires again: ${expr}`);
3694
+ return next;
3695
+ } finally {
3696
+ probe.stop();
3697
+ }
3698
+ }
3699
+ /** Re-arm this loop's next durable fact. Queue rows are never changed here. */
3700
+ addLoop(loop) {
3701
+ this.armLoop(loop, false);
3702
+ }
3703
+ armLoop(loop, retryDue) {
3704
+ const old = this.timers.get(loop.id);
3705
+ if (old) clearTimeout(old);
3706
+ this.timers.delete(loop.id);
3707
+ if (!loop.enabled) return;
3708
+ const targets = [loop.nextCadenceAt, loop.nextRunAt].filter((v) => v != null).map(Date.parse).filter(Number.isFinite);
3709
+ if (!targets.length) return;
3710
+ const rawDelay = Math.min(...targets) - Date.now();
3711
+ const timer = setTimeout(() => void this.fire(loop.id), rawDelay <= 0 ? retryDue ? DUE_RETRY_MS : 0 : Math.min(rawDelay, MAX_TIMER_MS));
3712
+ timer.unref?.();
3713
+ this.timers.set(loop.id, timer);
3714
+ }
3715
+ removeLoop(id) {
3716
+ const timer = this.timers.get(id);
3717
+ if (timer) clearTimeout(timer);
3718
+ this.timers.delete(id);
3719
+ }
3720
+ runNow(id) {
3721
+ return this.enqueue(id, { requestedBy: "owner" });
3722
+ }
3723
+ enqueueInitialExec(id) {
3724
+ return this.enqueue(id, { requestedBy: "system" });
3725
+ }
3726
+ async advanceDueSchedules(machineId, loopId) {
3727
+ const advanced = await advanceDueSchedules((/* @__PURE__ */ new Date()).toISOString(), {
3728
+ machineId,
3729
+ loopId
3730
+ });
3731
+ for (const item of advanced) {
3732
+ this.addLoop(item.loop);
3733
+ if (item.run) await this.wake(item.loop, item.run);
3734
+ }
3735
+ return advanced;
3736
+ }
3737
+ async enqueue(id, request) {
3738
+ const result = await enqueueRun(id, request);
3739
+ if ("run" in result) {
3740
+ const loop = await getLoop(id);
3741
+ if (loop) await this.wake(loop, result.run);
3742
+ }
3743
+ return result;
3744
+ }
3745
+ async fire(loopId) {
3746
+ this.timers.delete(loopId);
3747
+ try {
3748
+ await this.advanceDueSchedules(void 0, loopId);
3749
+ } catch (err) {
3750
+ log.warn({
3751
+ loopId,
3752
+ err: msg(err)
3753
+ }, "schedule hint failed; polling will recover");
3754
+ } finally {
3755
+ const loop = await getLoop(loopId).catch(() => void 0);
3756
+ if (loop && !this.timers.has(loopId)) this.armLoop(loop, true);
3757
+ }
3758
+ }
3759
+ async wake(loop, run) {
3760
+ try {
3761
+ await this.dispatcher.dispatch(loop, run);
3762
+ } catch (err) {
3763
+ log.warn({
3764
+ loopId: loop.id,
3765
+ runId: run.id,
3766
+ err: msg(err)
3767
+ }, "queue wake failed; polling will recover");
3768
+ }
3769
+ }
3770
+ stopAll() {
3771
+ for (const timer of this.timers.values()) clearTimeout(timer);
3772
+ this.timers.clear();
3773
+ log.info("scheduler stopped");
3774
+ }
3775
+ };
3776
+ function msg(err) {
3777
+ return err instanceof Error ? err.message : String(err);
3778
+ }
3779
+ /**
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
+ * Server boot — idempotent one-time init of the in-process backend: apply
3862
+ * migrations, wire the machine gateway's dispatcher into the Scheduler, start
3863
+ * the Scheduler + an offline-sweep interval. Guarded on `globalThis` so it
3864
+ * survives dev HMR and runs at most once per process. Call `ensureServer()`
3865
+ * from any server-side entry (the standalone machine server / TanStack server
3866
+ * fns); the first call boots, the rest share the same instance.
3867
+ */
3868
+ var boot_exports = /* @__PURE__ */ __exportAll$1({
3869
+ ensureServer: () => ensureServer,
3870
+ getArtifactSync: () => getArtifactSync,
3871
+ getCliGateway: () => getCliGateway,
3872
+ getGateway: () => getGateway
3873
+ });
3874
+ var g = globalThis;
3875
+ function ensureServer() {
3876
+ if (g.__pievoBooted) return g.__pievoBooted;
3877
+ const p = boot();
3878
+ g.__pievoBooted = p;
3879
+ p.catch(() => {
3880
+ if (g.__pievoBooted === p) g.__pievoBooted = void 0;
3881
+ });
3882
+ return p;
3883
+ }
3884
+ async function boot() {
3885
+ loginGateEnabled();
3886
+ await runMigrations();
3887
+ await repairTerminalRunLeases();
3888
+ const abort = new AbortController();
3889
+ let closePromise;
3890
+ const close = () => closePromise ??= closeClient();
3891
+ abort.signal.addEventListener("abort", () => void close(), { once: true });
3892
+ const shutdown = async () => {
3893
+ abort.abort();
3894
+ await close();
3895
+ };
3896
+ let gateway;
3897
+ 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);
3901
+ const cliGateway = new CliGateway(gateway);
3902
+ await scheduler.start(abort.signal);
3903
+ const sweep = setInterval(() => void gateway.sweep().catch((err) => logger.error({ err: String(err) }, "sweep tick failed")), ONLINE_TTL_MS);
3904
+ sweep.unref?.();
3905
+ 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());
3907
+ gc.unref?.();
3908
+ 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
+ logger.info("pievo server booted");
3928
+ return {
3929
+ scheduler,
3930
+ gateway,
3931
+ artifactSync,
3932
+ cliGateway,
3933
+ abort,
3934
+ shutdown
3935
+ };
3936
+ }
3937
+ async function getGateway() {
3938
+ return (await ensureServer()).gateway;
3939
+ }
3940
+ async function getArtifactSync() {
3941
+ return (await ensureServer()).artifactSync;
3942
+ }
3943
+ async function getCliGateway() {
3944
+ return (await ensureServer()).cliGateway;
3945
+ }
3946
+ //#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 };