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