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