@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/SignIn-CIFwnZtF.js +1 -0
- package/.output/public/assets/{ui-s5IMg6RF.js → actionUi-5PtES4q0.js} +1 -1
- package/.output/public/assets/app-DvHMhs7S.css +2 -0
- package/.output/public/assets/index-CRX2pu0W.js +10 -0
- package/.output/public/assets/loops._loopId-BsuV-Rmf.js +67 -0
- package/.output/public/assets/loops._loopId_.runs._runId-DpK_fzmZ.js +3 -0
- package/.output/public/assets/{preload-helper-Bs7oCAsw.js → preload-helper-sAZOZb3G.js} +3 -3
- package/.output/public/assets/routes-Dm_3J2aV.js +1 -0
- package/.output/public/assets/routes-DxF_s3cD.js +1 -0
- package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
- package/.output/server/_ssr/{MachinesModal-BhRw7oOx.mjs → MachinesModal-BaEtzycr.mjs} +24 -57
- package/.output/server/_ssr/{SignIn-BhoZIOUp.mjs → SignIn-sHQaBQJC.mjs} +3 -3
- package/.output/server/_ssr/{ui-DGz-q9MT.mjs → actionUi-5HOCZnom.mjs} +115 -4
- package/.output/server/_ssr/{artifactFiles-ChuJ4PzC.mjs → artifactFiles-DO43wwXA.mjs} +4 -3
- package/.output/server/_ssr/{artifactKind-DXumOv7c.mjs → artifactKind-DwDr8IYc.mjs} +6 -14
- package/.output/server/_ssr/artifacts-Cd7Qx3Qp.mjs +2 -0
- package/.output/server/_ssr/{auth-LwPp_iim.mjs → auth-0AamrwHo.mjs} +44 -124
- package/.output/server/_ssr/{SocialLinks-DVCLmql0.mjs → auth-client-lSMb_Idt.mjs} +143 -109
- package/.output/server/_ssr/boot-C472kLwY.mjs +3947 -0
- package/.output/server/_ssr/{bun-sqlite-dialect-BW9W1_Ps-CHMDkKqA.mjs → bun-sqlite-dialect-BW9W1_Ps-DLIXocA0.mjs} +2 -2
- package/.output/server/_ssr/{d1-sqlite-dialect-BLC8LXE6-DYum6qky.mjs → d1-sqlite-dialect-BLC8LXE6-BoFMIPr_.mjs} +2 -2
- package/.output/server/_ssr/db-BzvMQ2kx.mjs +2 -0
- package/.output/server/_ssr/{dist-bWqs5sTy.mjs → dist-ClzqkwxD.mjs} +2 -2
- package/.output/server/_ssr/{factory-pBwrjVTI.mjs → factory-ixUjZqJn.mjs} +2 -2
- package/.output/server/_ssr/{kysely-adapter-DMf5pFTf.mjs → kysely-adapter-DkZbmKgV.mjs} +1 -1
- package/.output/server/_ssr/logger-DDjsYB2X.mjs +2 -0
- package/.output/server/_ssr/{loopApi-Im6JSK0p.mjs → loopApi-DT751TJA.mjs} +30 -90
- package/.output/server/_ssr/{loopProjection-DIpGR6rp.mjs → loopProjection-qaD2I48a.mjs} +5 -2
- package/.output/server/_ssr/loopTags-CXx5UdGf.mjs +3356 -0
- package/.output/server/_ssr/{loops._loopId-B5iPseDG.mjs → loops._loopId-CXgw2Rz1.mjs} +144 -46
- package/.output/server/_ssr/{loops._loopId_.runs._runId-DVtG0xYb.mjs → loops._loopId_.runs._runId-Dk94Z0Jq.mjs} +6 -13
- package/.output/server/_ssr/{loops._loopId_.runs._runId-BA-P6sGA.mjs → loops._loopId_.runs._runId-SASSqOfy.mjs} +31 -23
- package/.output/server/_ssr/{machineFns-Bb9KNXun.mjs → machineFns-BSrIgn3f.mjs} +42 -108
- package/.output/server/_ssr/{node-sqlite-dialect-Dho9YjXb.mjs → node-sqlite-dialect-BbUqnnYv.mjs} +2 -2
- package/.output/server/_ssr/{router-C8iotXtk.mjs → router-BlLlhfJe.mjs} +26 -40
- package/.output/server/_ssr/routes-Cr-9yDEH.mjs +491 -0
- package/.output/server/_ssr/{routes-FO0MFfYK.mjs → routes-DRBS4Zi4.mjs} +4 -9
- package/.output/server/_ssr/routes-DTBltabU.mjs +27 -0
- package/.output/server/_ssr/ssr.mjs +28 -88
- package/.output/server/_ssr/store-vDZ9INSa.mjs +2 -0
- package/.output/server/_ssr/tokens-BHiCElYD.mjs +2 -0
- package/.output/server/_tanstack-start-manifest_v-DxLIQ0cc.mjs +52 -0
- package/.output/server/drizzle/0002_remove_teams.sql +15 -0
- package/.output/server/drizzle/0003_add_loop_tags.sql +5 -0
- package/.output/server/drizzle/meta/_journal.json +14 -0
- package/.output/server/index.mjs +47 -96
- package/drizzle/0002_remove_teams.sql +15 -0
- package/drizzle/0003_add_loop_tags.sql +5 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/.output/public/assets/MachinesModal-ByHejech.js +0 -1
- package/.output/public/assets/SignIn-Cf4HOpdF.js +0 -1
- package/.output/public/assets/actionUi-yYVvoOTn.js +0 -1
- package/.output/public/assets/app-D5Hy4BcS.css +0 -2
- package/.output/public/assets/index-D504r3X9.js +0 -10
- package/.output/public/assets/invite._token-B0EKVyrU.js +0 -1
- package/.output/public/assets/invite._token-BSnGRyUw.js +0 -1
- package/.output/public/assets/lifecycleUi-C0DaC6NM.js +0 -1
- package/.output/public/assets/loops._loopId-j5EB2PLW.js +0 -67
- package/.output/public/assets/loops._loopId_.runs._runId-BICo_pRz.js +0 -3
- package/.output/public/assets/routes-4N6mrQK_.js +0 -1
- package/.output/public/assets/routes-DZU0Tqka.js +0 -1
- package/.output/public/assets/t._teamId-CAUjvMZ6.js +0 -1
- package/.output/public/assets/t._teamId-Cqzd69sO.js +0 -1
- package/.output/server/_ssr/DashboardView-DrKNkmcX.mjs +0 -867
- package/.output/server/_ssr/actionUi-Cf6DuEC2.mjs +0 -119
- package/.output/server/_ssr/artifacts-DOLHJHcd.mjs +0 -2
- package/.output/server/_ssr/boot-BIxtZHU-.mjs +0 -7466
- package/.output/server/_ssr/boot-D8cj4uZj.mjs +0 -2
- package/.output/server/_ssr/db-BphSJs66.mjs +0 -2
- package/.output/server/_ssr/invite._token-CPSvdDox.mjs +0 -27
- package/.output/server/_ssr/invite._token-CRBsTSTZ.mjs +0 -84
- package/.output/server/_ssr/invite._token-CaTLt92a.mjs +0 -20
- package/.output/server/_ssr/lifecycleUi-DauKavgx.mjs +0 -50
- package/.output/server/_ssr/logger-EinA2J6y.mjs +0 -2
- package/.output/server/_ssr/routes-CsV1weub.mjs +0 -26
- package/.output/server/_ssr/routes-fiMzFtcP.mjs +0 -39
- package/.output/server/_ssr/runDiff-BLdot9mi.mjs +0 -2
- package/.output/server/_ssr/store-CFCp_ggC.mjs +0 -2
- package/.output/server/_ssr/t._teamId-BYA4Dnjp.mjs +0 -43
- package/.output/server/_ssr/t._teamId-CKsrgN86.mjs +0 -32
- package/.output/server/_ssr/t._teamId-D1BTwhWf.mjs +0 -31
- package/.output/server/_ssr/teamCookie-B7hLOIie.mjs +0 -10
- package/.output/server/_ssr/teamFns-DfqtuBW2.mjs +0 -451
- package/.output/server/_ssr/teamFns-cI_tsiCv.mjs +0 -32
- package/.output/server/_ssr/tokens-CUz4LDLm.mjs +0 -2
- 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 };
|