@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,3356 +0,0 @@
|
|
|
1
|
-
import { o as __toESM } from "../_runtime.mjs";
|
|
2
|
-
import { t as Ve } from "../_libs/electric-sql__pglite.mjs";
|
|
3
|
-
import { A as boolean, C as notInArray, D as text, E as timestamp, O as jsonb, S as ne, T as pgTable, _ as isNotNull, a as uniqueIndex, b as lt, c as relations, d as and, f as eq, g as inArray, i as index, j as sql, k as integer, l as asc, n as src_default, o as check, p as gt, r as drizzle$1, t as drizzle, u as desc, v as isNull, w as or } from "../_libs/drizzle-orm+postgres.mjs";
|
|
4
|
-
import { t as require_pino } from "../_libs/pino+[...].mjs";
|
|
5
|
-
import { t as require_pino_pretty } from "../_libs/pino-pretty+[...].mjs";
|
|
6
|
-
import { t as E } from "../_libs/croner.mjs";
|
|
7
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
-
import fs from "node:fs";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import os from "node:os";
|
|
12
|
-
//#region node_modules/.nitro/vite/services/ssr/assets/loopTags-CXx5UdGf.js
|
|
13
|
-
var import_pino = /* @__PURE__ */ __toESM(require_pino());
|
|
14
|
-
var import_pino_pretty = /* @__PURE__ */ __toESM(require_pino_pretty());
|
|
15
|
-
function defineErrorCodes(codes) {
|
|
16
|
-
return Object.fromEntries(Object.entries(codes).map(([key, value]) => [key, {
|
|
17
|
-
code: key,
|
|
18
|
-
message: value,
|
|
19
|
-
toString: () => key
|
|
20
|
-
}]));
|
|
21
|
-
}
|
|
22
|
-
var BASE_ERROR_CODES = defineErrorCodes({
|
|
23
|
-
USER_NOT_FOUND: "User not found",
|
|
24
|
-
FAILED_TO_CREATE_USER: "Failed to create user",
|
|
25
|
-
FAILED_TO_CREATE_SESSION: "Failed to create session",
|
|
26
|
-
FAILED_TO_UPDATE_USER: "Failed to update user",
|
|
27
|
-
FAILED_TO_GET_SESSION: "Failed to get session",
|
|
28
|
-
INVALID_PASSWORD: "Invalid password",
|
|
29
|
-
INVALID_EMAIL: "Invalid email",
|
|
30
|
-
INVALID_EMAIL_OR_PASSWORD: "Invalid email or password",
|
|
31
|
-
INVALID_USER: "Invalid user",
|
|
32
|
-
SOCIAL_ACCOUNT_ALREADY_LINKED: "Social account already linked",
|
|
33
|
-
PROVIDER_NOT_FOUND: "Provider not found",
|
|
34
|
-
INVALID_TOKEN: "Invalid token",
|
|
35
|
-
TOKEN_EXPIRED: "Token expired",
|
|
36
|
-
ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
|
|
37
|
-
FAILED_TO_GET_USER_INFO: "Failed to get user info",
|
|
38
|
-
USER_EMAIL_NOT_FOUND: "User email not found",
|
|
39
|
-
EMAIL_NOT_VERIFIED: "Email not verified",
|
|
40
|
-
PASSWORD_TOO_SHORT: "Password too short",
|
|
41
|
-
PASSWORD_TOO_LONG: "Password too long",
|
|
42
|
-
USER_ALREADY_EXISTS: "User already exists.",
|
|
43
|
-
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: "User already exists. Use another email.",
|
|
44
|
-
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
|
|
45
|
-
CHANGE_EMAIL_DISABLED: "Change email is disabled",
|
|
46
|
-
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
|
|
47
|
-
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
|
|
48
|
-
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
|
49
|
-
ACCOUNT_NOT_FOUND: "Account not found",
|
|
50
|
-
USER_ALREADY_HAS_PASSWORD: "User already has a password. Provide that to delete the account.",
|
|
51
|
-
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
|
|
52
|
-
VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled",
|
|
53
|
-
EMAIL_ALREADY_VERIFIED: "Email is already verified",
|
|
54
|
-
EMAIL_MISMATCH: "Email mismatch",
|
|
55
|
-
SESSION_NOT_FRESH: "Session is not fresh",
|
|
56
|
-
LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists",
|
|
57
|
-
INVALID_ORIGIN: "Invalid origin",
|
|
58
|
-
INVALID_CALLBACK_URL: "Invalid callbackURL",
|
|
59
|
-
INVALID_REDIRECT_URL: "Invalid redirectURL",
|
|
60
|
-
INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL",
|
|
61
|
-
INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL",
|
|
62
|
-
MISSING_OR_NULL_ORIGIN: "Missing or null Origin",
|
|
63
|
-
CALLBACK_URL_REQUIRED: "callbackURL is required",
|
|
64
|
-
FAILED_TO_CREATE_VERIFICATION: "Unable to create verification",
|
|
65
|
-
FIELD_NOT_ALLOWED: "Field not allowed to be set",
|
|
66
|
-
ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported",
|
|
67
|
-
VALIDATION_ERROR: "Validation Error",
|
|
68
|
-
MISSING_FIELD: "Field is required",
|
|
69
|
-
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED: "POST method requires deferSessionRefresh to be enabled in session config",
|
|
70
|
-
BODY_MUST_BE_AN_OBJECT: "Body must be an object",
|
|
71
|
-
PASSWORD_ALREADY_SET: "User already has a password set"
|
|
72
|
-
});
|
|
73
|
-
function isErrorStackTraceLimitWritable() {
|
|
74
|
-
const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit");
|
|
75
|
-
if (desc === void 0) return Object.isExtensible(Error);
|
|
76
|
-
return Object.prototype.hasOwnProperty.call(desc, "writable") ? desc.writable : desc.set !== void 0;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Hide internal stack frames from the error stack trace.
|
|
80
|
-
*/
|
|
81
|
-
function hideInternalStackFrames(stack) {
|
|
82
|
-
const lines = stack.split("\n at ");
|
|
83
|
-
if (lines.length <= 1) return stack;
|
|
84
|
-
lines.splice(1, 1);
|
|
85
|
-
return lines.join("\n at ");
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Creates a custom error class that hides stack frames.
|
|
89
|
-
*/
|
|
90
|
-
function makeErrorForHideStackFrame(Base, clazz) {
|
|
91
|
-
class HideStackFramesError extends Base {
|
|
92
|
-
#hiddenStack;
|
|
93
|
-
constructor(...args) {
|
|
94
|
-
if (isErrorStackTraceLimitWritable()) {
|
|
95
|
-
const limit = Error.stackTraceLimit;
|
|
96
|
-
Error.stackTraceLimit = 0;
|
|
97
|
-
super(...args);
|
|
98
|
-
Error.stackTraceLimit = limit;
|
|
99
|
-
} else super(...args);
|
|
100
|
-
const stack = (/* @__PURE__ */ new Error()).stack;
|
|
101
|
-
if (stack) this.#hiddenStack = hideInternalStackFrames(stack.replace(/^Error/, this.name));
|
|
102
|
-
}
|
|
103
|
-
get errorStack() {
|
|
104
|
-
return this.#hiddenStack;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
Object.defineProperty(HideStackFramesError.prototype, "constructor", {
|
|
108
|
-
get() {
|
|
109
|
-
return clazz;
|
|
110
|
-
},
|
|
111
|
-
enumerable: false,
|
|
112
|
-
configurable: true
|
|
113
|
-
});
|
|
114
|
-
return HideStackFramesError;
|
|
115
|
-
}
|
|
116
|
-
var statusCodes = {
|
|
117
|
-
OK: 200,
|
|
118
|
-
CREATED: 201,
|
|
119
|
-
ACCEPTED: 202,
|
|
120
|
-
NO_CONTENT: 204,
|
|
121
|
-
MULTIPLE_CHOICES: 300,
|
|
122
|
-
MOVED_PERMANENTLY: 301,
|
|
123
|
-
FOUND: 302,
|
|
124
|
-
SEE_OTHER: 303,
|
|
125
|
-
NOT_MODIFIED: 304,
|
|
126
|
-
TEMPORARY_REDIRECT: 307,
|
|
127
|
-
BAD_REQUEST: 400,
|
|
128
|
-
UNAUTHORIZED: 401,
|
|
129
|
-
PAYMENT_REQUIRED: 402,
|
|
130
|
-
FORBIDDEN: 403,
|
|
131
|
-
NOT_FOUND: 404,
|
|
132
|
-
METHOD_NOT_ALLOWED: 405,
|
|
133
|
-
NOT_ACCEPTABLE: 406,
|
|
134
|
-
PROXY_AUTHENTICATION_REQUIRED: 407,
|
|
135
|
-
REQUEST_TIMEOUT: 408,
|
|
136
|
-
CONFLICT: 409,
|
|
137
|
-
GONE: 410,
|
|
138
|
-
LENGTH_REQUIRED: 411,
|
|
139
|
-
PRECONDITION_FAILED: 412,
|
|
140
|
-
PAYLOAD_TOO_LARGE: 413,
|
|
141
|
-
URI_TOO_LONG: 414,
|
|
142
|
-
UNSUPPORTED_MEDIA_TYPE: 415,
|
|
143
|
-
RANGE_NOT_SATISFIABLE: 416,
|
|
144
|
-
EXPECTATION_FAILED: 417,
|
|
145
|
-
"I'M_A_TEAPOT": 418,
|
|
146
|
-
MISDIRECTED_REQUEST: 421,
|
|
147
|
-
UNPROCESSABLE_ENTITY: 422,
|
|
148
|
-
LOCKED: 423,
|
|
149
|
-
FAILED_DEPENDENCY: 424,
|
|
150
|
-
TOO_EARLY: 425,
|
|
151
|
-
UPGRADE_REQUIRED: 426,
|
|
152
|
-
PRECONDITION_REQUIRED: 428,
|
|
153
|
-
TOO_MANY_REQUESTS: 429,
|
|
154
|
-
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
|
|
155
|
-
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
|
|
156
|
-
INTERNAL_SERVER_ERROR: 500,
|
|
157
|
-
NOT_IMPLEMENTED: 501,
|
|
158
|
-
BAD_GATEWAY: 502,
|
|
159
|
-
SERVICE_UNAVAILABLE: 503,
|
|
160
|
-
GATEWAY_TIMEOUT: 504,
|
|
161
|
-
HTTP_VERSION_NOT_SUPPORTED: 505,
|
|
162
|
-
VARIANT_ALSO_NEGOTIATES: 506,
|
|
163
|
-
INSUFFICIENT_STORAGE: 507,
|
|
164
|
-
LOOP_DETECTED: 508,
|
|
165
|
-
NOT_EXTENDED: 510,
|
|
166
|
-
NETWORK_AUTHENTICATION_REQUIRED: 511
|
|
167
|
-
};
|
|
168
|
-
var InternalAPIError = class extends Error {
|
|
169
|
-
constructor(status = "INTERNAL_SERVER_ERROR", body = void 0, headers = {}, statusCode = typeof status === "number" ? status : statusCodes[status]) {
|
|
170
|
-
super(body?.message, body?.cause ? { cause: body.cause } : void 0);
|
|
171
|
-
this.status = status;
|
|
172
|
-
this.body = body;
|
|
173
|
-
this.headers = headers;
|
|
174
|
-
this.statusCode = statusCode;
|
|
175
|
-
this.name = "APIError";
|
|
176
|
-
this.status = status;
|
|
177
|
-
this.headers = headers;
|
|
178
|
-
this.statusCode = statusCode;
|
|
179
|
-
this.body = body;
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
var ValidationError$1 = class extends InternalAPIError {
|
|
183
|
-
constructor(message, issues) {
|
|
184
|
-
super(400, {
|
|
185
|
-
message,
|
|
186
|
-
code: "VALIDATION_ERROR"
|
|
187
|
-
});
|
|
188
|
-
this.message = message;
|
|
189
|
-
this.issues = issues;
|
|
190
|
-
this.issues = issues;
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
var BetterCallError = class extends Error {
|
|
194
|
-
constructor(message) {
|
|
195
|
-
super(message);
|
|
196
|
-
this.name = "BetterCallError";
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
var kAPIErrorHeaderSymbol = Symbol.for("better-call:api-error-headers");
|
|
200
|
-
var APIError$1 = makeErrorForHideStackFrame(InternalAPIError, Error);
|
|
201
|
-
var BetterAuthError = class extends Error {
|
|
202
|
-
constructor(message, options) {
|
|
203
|
-
super(message, options);
|
|
204
|
-
this.name = "BetterAuthError";
|
|
205
|
-
this.message = message;
|
|
206
|
-
this.stack = "";
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
var APIError = class APIError extends APIError$1 {
|
|
210
|
-
constructor(...args) {
|
|
211
|
-
super(...args);
|
|
212
|
-
}
|
|
213
|
-
static fromStatus(status, body) {
|
|
214
|
-
return new APIError(status, body);
|
|
215
|
-
}
|
|
216
|
-
static from(status, error) {
|
|
217
|
-
return new APIError(status, {
|
|
218
|
-
message: error.message,
|
|
219
|
-
code: error.code
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
var _envShim = Object.create(null);
|
|
224
|
-
var _getEnv = (useShim) => globalThis.process?.env || globalThis.Deno?.env.toObject() || globalThis.__env__ || (useShim ? _envShim : globalThis);
|
|
225
|
-
var env = new Proxy(_envShim, {
|
|
226
|
-
get(_, prop) {
|
|
227
|
-
return _getEnv()[prop] ?? _envShim[prop];
|
|
228
|
-
},
|
|
229
|
-
has(_, prop) {
|
|
230
|
-
return prop in _getEnv() || prop in _envShim;
|
|
231
|
-
},
|
|
232
|
-
set(_, prop, value) {
|
|
233
|
-
const env = _getEnv(true);
|
|
234
|
-
env[prop] = value;
|
|
235
|
-
return true;
|
|
236
|
-
},
|
|
237
|
-
deleteProperty(_, prop) {
|
|
238
|
-
if (!prop) return false;
|
|
239
|
-
const env = _getEnv(true);
|
|
240
|
-
delete env[prop];
|
|
241
|
-
return true;
|
|
242
|
-
},
|
|
243
|
-
ownKeys() {
|
|
244
|
-
const env = _getEnv(true);
|
|
245
|
-
return Object.keys(env);
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
function toBoolean(val) {
|
|
249
|
-
return val ? val !== "false" : false;
|
|
250
|
-
}
|
|
251
|
-
var nodeENV = env.NODE_ENV ?? "";
|
|
252
|
-
/** Detect if `NODE_ENV` environment variable is `production` */
|
|
253
|
-
var isProduction = nodeENV === "production";
|
|
254
|
-
/** Detect if `NODE_ENV` environment variable is `dev` or `development` */
|
|
255
|
-
var isDevelopment = () => nodeENV === "dev" || nodeENV === "development";
|
|
256
|
-
/** Detect if `NODE_ENV` environment variable is `test` */
|
|
257
|
-
var isTest = () => nodeENV === "test" || toBoolean(env.TEST);
|
|
258
|
-
/**
|
|
259
|
-
* Get environment variable with fallback
|
|
260
|
-
*/
|
|
261
|
-
function getEnvVar(key, fallback) {
|
|
262
|
-
if (typeof process !== "undefined" && process.env) return process.env[key] ?? fallback;
|
|
263
|
-
if (typeof Deno !== "undefined") return Deno.env.get(key) ?? fallback;
|
|
264
|
-
if (typeof Bun !== "undefined") return Bun.env[key] ?? fallback;
|
|
265
|
-
return fallback;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Get boolean environment variable
|
|
269
|
-
*/
|
|
270
|
-
function getBooleanEnvVar(key, fallback = true) {
|
|
271
|
-
const value = getEnvVar(key);
|
|
272
|
-
if (!value) return fallback;
|
|
273
|
-
return value !== "0" && value.toLowerCase() !== "false" && value !== "";
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Common environment variables used in Better Auth
|
|
277
|
-
*/
|
|
278
|
-
var ENV = Object.freeze({
|
|
279
|
-
get BETTER_AUTH_SECRET() {
|
|
280
|
-
return getEnvVar("BETTER_AUTH_SECRET");
|
|
281
|
-
},
|
|
282
|
-
get AUTH_SECRET() {
|
|
283
|
-
return getEnvVar("AUTH_SECRET");
|
|
284
|
-
},
|
|
285
|
-
get BETTER_AUTH_TELEMETRY() {
|
|
286
|
-
return getEnvVar("BETTER_AUTH_TELEMETRY");
|
|
287
|
-
},
|
|
288
|
-
get BETTER_AUTH_TELEMETRY_ID() {
|
|
289
|
-
return getEnvVar("BETTER_AUTH_TELEMETRY_ID");
|
|
290
|
-
},
|
|
291
|
-
get NODE_ENV() {
|
|
292
|
-
return getEnvVar("NODE_ENV", "development");
|
|
293
|
-
},
|
|
294
|
-
get PACKAGE_VERSION() {
|
|
295
|
-
return getEnvVar("PACKAGE_VERSION", "0.0.0");
|
|
296
|
-
},
|
|
297
|
-
get BETTER_AUTH_TELEMETRY_ENDPOINT() {
|
|
298
|
-
return getEnvVar("BETTER_AUTH_TELEMETRY_ENDPOINT", "");
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
function capitalizeFirstLetter(str) {
|
|
302
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
303
|
-
}
|
|
304
|
-
var WORD_PATTERN = /[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
|
|
305
|
-
var APOSTROPHE_PATTERN = /['\u2019]/g;
|
|
306
|
-
function splitWords(input) {
|
|
307
|
-
return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
|
|
308
|
-
}
|
|
309
|
-
function toKebabCase(input) {
|
|
310
|
-
return splitWords(input).map((word) => word.toLowerCase()).join("-");
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Escapes a character if it has a special meaning in regular expressions
|
|
314
|
-
* and returns the character as is if it doesn't
|
|
315
|
-
*/
|
|
316
|
-
function escapeRegExpChar(char) {
|
|
317
|
-
if (char === "-" || char === "^" || char === "$" || char === "+" || char === "." || char === "(" || char === ")" || char === "|" || char === "[" || char === "]" || char === "{" || char === "}" || char === "*" || char === "?" || char === "\\") return `\\${char}`;
|
|
318
|
-
else return char;
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Escapes all characters in a given string that have a special meaning in regular expressions
|
|
322
|
-
*/
|
|
323
|
-
function escapeRegExpString(str) {
|
|
324
|
-
let result = "";
|
|
325
|
-
for (let i = 0; i < str.length; i++) result += escapeRegExpChar(str[i]);
|
|
326
|
-
return result;
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Transforms one or more glob patterns into a RegExp pattern
|
|
330
|
-
*/
|
|
331
|
-
function transform(pattern, separator = true) {
|
|
332
|
-
if (Array.isArray(pattern)) return `(?:${pattern.map((p) => `^${transform(p, separator)}$`).join("|")})`;
|
|
333
|
-
let separatorSplitter = "";
|
|
334
|
-
let separatorMatcher = "";
|
|
335
|
-
let wildcard = ".";
|
|
336
|
-
if (separator === true) {
|
|
337
|
-
separatorSplitter = "/";
|
|
338
|
-
separatorMatcher = "[/\\\\]";
|
|
339
|
-
wildcard = "[^/\\\\]";
|
|
340
|
-
} else if (separator) {
|
|
341
|
-
separatorSplitter = separator;
|
|
342
|
-
separatorMatcher = escapeRegExpString(separatorSplitter);
|
|
343
|
-
if (separatorMatcher.length > 1) {
|
|
344
|
-
separatorMatcher = `(?:${separatorMatcher})`;
|
|
345
|
-
wildcard = `((?!${separatorMatcher}).)`;
|
|
346
|
-
} else wildcard = `[^${separatorMatcher}]`;
|
|
347
|
-
}
|
|
348
|
-
const requiredSeparator = separator ? `${separatorMatcher}+?` : "";
|
|
349
|
-
const optionalSeparator = separator ? `${separatorMatcher}*?` : "";
|
|
350
|
-
const segments = separator ? pattern.split(separatorSplitter) : [pattern];
|
|
351
|
-
let result = "";
|
|
352
|
-
for (let s = 0; s < segments.length; s++) {
|
|
353
|
-
const segment = segments[s];
|
|
354
|
-
const nextSegment = segments[s + 1];
|
|
355
|
-
let currentSeparator = "";
|
|
356
|
-
if (!segment && s > 0) continue;
|
|
357
|
-
if (separator) if (s === segments.length - 1) currentSeparator = optionalSeparator;
|
|
358
|
-
else if (nextSegment !== "**") currentSeparator = requiredSeparator;
|
|
359
|
-
else currentSeparator = "";
|
|
360
|
-
if (separator && segment === "**") {
|
|
361
|
-
if (currentSeparator) {
|
|
362
|
-
result += s === 0 ? "" : currentSeparator;
|
|
363
|
-
result += `(?:${wildcard}*?${currentSeparator})*?`;
|
|
364
|
-
}
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
for (let c = 0; c < segment.length; c++) {
|
|
368
|
-
const char = segment[c];
|
|
369
|
-
if (char === "\\") {
|
|
370
|
-
if (c < segment.length - 1) {
|
|
371
|
-
result += escapeRegExpChar(segment[c + 1]);
|
|
372
|
-
c++;
|
|
373
|
-
}
|
|
374
|
-
} else if (char === "?") result += wildcard;
|
|
375
|
-
else if (char === "*") result += `${wildcard}*?`;
|
|
376
|
-
else result += escapeRegExpChar(char);
|
|
377
|
-
}
|
|
378
|
-
result += currentSeparator;
|
|
379
|
-
}
|
|
380
|
-
return result;
|
|
381
|
-
}
|
|
382
|
-
function isMatch(regexp, sample) {
|
|
383
|
-
if (typeof sample !== "string") throw new TypeError(`Sample must be a string, but ${typeof sample} given`);
|
|
384
|
-
return regexp.test(sample);
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Compiles one or more glob patterns into a RegExp and returns an isMatch function.
|
|
388
|
-
* The isMatch function takes a sample string as its only argument and returns `true`
|
|
389
|
-
* if the string matches the pattern(s).
|
|
390
|
-
*
|
|
391
|
-
* ```js
|
|
392
|
-
* wildcardMatch('src/*.js')('src/index.js') //=> true
|
|
393
|
-
* ```
|
|
394
|
-
*
|
|
395
|
-
* ```js
|
|
396
|
-
* const isMatch = wildcardMatch('*.example.com', '.')
|
|
397
|
-
* isMatch('foo.example.com') //=> true
|
|
398
|
-
* isMatch('foo.bar.com') //=> false
|
|
399
|
-
* ```
|
|
400
|
-
*/
|
|
401
|
-
function wildcardMatch(pattern, options) {
|
|
402
|
-
if (typeof pattern !== "string" && !Array.isArray(pattern)) throw new TypeError(`The first argument must be a single pattern string or an array of patterns, but ${typeof pattern} given`);
|
|
403
|
-
if (typeof options === "string" || typeof options === "boolean") options = { separator: options };
|
|
404
|
-
if (arguments.length === 2 && !(typeof options === "undefined" || typeof options === "object" && options !== null && !Array.isArray(options))) throw new TypeError(`The second argument must be an options object or a string/boolean separator, but ${typeof options} given`);
|
|
405
|
-
options = options || {};
|
|
406
|
-
if (options.separator === "\\") throw new Error("\\ is not a valid separator because it is used for escaping. Try setting the separator to `true` instead");
|
|
407
|
-
const regexpPattern = transform(pattern, options.separator);
|
|
408
|
-
const regexp = new RegExp(`^${regexpPattern}$`, options.flags);
|
|
409
|
-
const fn = isMatch.bind(null, regexp);
|
|
410
|
-
fn.options = options;
|
|
411
|
-
fn.pattern = pattern;
|
|
412
|
-
fn.regexp = regexp;
|
|
413
|
-
return fn;
|
|
414
|
-
}
|
|
415
|
-
var SLASH_CHAR_CODE = "/".charCodeAt(0);
|
|
416
|
-
/**
|
|
417
|
-
* Minimal loopback check for dev scheme inference only. Reachable from
|
|
418
|
-
* `client/config.ts` via `getBaseURL`, so we MUST NOT import the full
|
|
419
|
-
* `@better-auth/core/utils/host` classifier here: its `utils/ip` dependency
|
|
420
|
-
* on zod would leak into the client bundle (see `e2e/smoke/test/vite.spec.ts`).
|
|
421
|
-
*
|
|
422
|
-
* Server-side SSRF/loopback checks (oauth redirect matching, trusted-origin
|
|
423
|
-
* resolution, electron fetch gate) continue to use the authoritative
|
|
424
|
-
* `isLoopbackHost` from `@better-auth/core/utils/host`. This helper's only
|
|
425
|
-
* job is picking `http` vs `https` for dev ergonomics.
|
|
426
|
-
*/
|
|
427
|
-
function isLoopbackForDevScheme(host) {
|
|
428
|
-
const hostname = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
|
|
429
|
-
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "::1" || hostname.startsWith("127.");
|
|
430
|
-
}
|
|
431
|
-
function trimTrailingSlashes(value) {
|
|
432
|
-
let end = value.length;
|
|
433
|
-
while (end > 0 && value.charCodeAt(end - 1) === SLASH_CHAR_CODE) end--;
|
|
434
|
-
return end === value.length ? value : value.slice(0, end);
|
|
435
|
-
}
|
|
436
|
-
function checkHasPath(url) {
|
|
437
|
-
try {
|
|
438
|
-
return (trimTrailingSlashes(new URL(url).pathname) || "/") !== "/";
|
|
439
|
-
} catch {
|
|
440
|
-
throw new BetterAuthError(`Invalid base URL: ${url}. Please provide a valid base URL.`);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
function assertHasProtocol(url) {
|
|
444
|
-
try {
|
|
445
|
-
const parsedUrl = new URL(url);
|
|
446
|
-
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new BetterAuthError(`Invalid base URL: ${url}. URL must include 'http://' or 'https://'`);
|
|
447
|
-
} catch (error) {
|
|
448
|
-
if (error instanceof BetterAuthError) throw error;
|
|
449
|
-
throw new BetterAuthError(`Invalid base URL: ${url}. Please provide a valid base URL.`, { cause: error });
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
function withPath(url, path = "/api/auth") {
|
|
453
|
-
assertHasProtocol(url);
|
|
454
|
-
if (checkHasPath(url)) return url;
|
|
455
|
-
const trimmedUrl = trimTrailingSlashes(url);
|
|
456
|
-
if (!path || path === "/") return trimmedUrl;
|
|
457
|
-
path = path.startsWith("/") ? path : `/${path}`;
|
|
458
|
-
return `${trimmedUrl}${path}`;
|
|
459
|
-
}
|
|
460
|
-
function validateProxyHeader(header, type) {
|
|
461
|
-
if (!header || header.trim() === "") return false;
|
|
462
|
-
if (type === "proto") return header === "http" || header === "https";
|
|
463
|
-
if (type === "host") {
|
|
464
|
-
if ([
|
|
465
|
-
/\.\./,
|
|
466
|
-
/\0/,
|
|
467
|
-
/[\s]/,
|
|
468
|
-
/^[.]/,
|
|
469
|
-
/[<>'"]/,
|
|
470
|
-
/javascript:/i,
|
|
471
|
-
/file:/i,
|
|
472
|
-
/data:/i
|
|
473
|
-
].some((pattern) => pattern.test(header))) return false;
|
|
474
|
-
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$/.test(header) || /^(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?$/.test(header) || /^\[[0-9a-fA-F:]+\](:[0-9]{1,5})?$/.test(header) || /^localhost(:[0-9]{1,5})?$/i.test(header);
|
|
475
|
-
}
|
|
476
|
-
return false;
|
|
477
|
-
}
|
|
478
|
-
function getBaseURL(url, path, request, loadEnv, trustedProxyHeaders) {
|
|
479
|
-
if (url) return withPath(url, path);
|
|
480
|
-
if (loadEnv !== false) {
|
|
481
|
-
const fromEnv = env.BETTER_AUTH_URL || env.NEXT_PUBLIC_BETTER_AUTH_URL || env.PUBLIC_BETTER_AUTH_URL || env.NUXT_PUBLIC_BETTER_AUTH_URL || env.NUXT_PUBLIC_AUTH_URL || (env.BASE_URL !== "/" ? env.BASE_URL : void 0);
|
|
482
|
-
if (fromEnv) return withPath(fromEnv, path);
|
|
483
|
-
}
|
|
484
|
-
const fromRequest = request?.headers.get("x-forwarded-host");
|
|
485
|
-
const fromRequestProto = request?.headers.get("x-forwarded-proto");
|
|
486
|
-
if (fromRequest && fromRequestProto && trustedProxyHeaders) {
|
|
487
|
-
if (validateProxyHeader(fromRequestProto, "proto") && validateProxyHeader(fromRequest, "host")) try {
|
|
488
|
-
return withPath(`${fromRequestProto}://${fromRequest}`, path);
|
|
489
|
-
} catch (_error) {}
|
|
490
|
-
}
|
|
491
|
-
if (request) {
|
|
492
|
-
const url = getOrigin(request.url);
|
|
493
|
-
if (!url) throw new BetterAuthError("Could not get origin from request. Please provide a valid base URL.");
|
|
494
|
-
return withPath(url, path);
|
|
495
|
-
}
|
|
496
|
-
if (typeof window !== "undefined" && window.location) return withPath(window.location.origin, path);
|
|
497
|
-
}
|
|
498
|
-
function getOrigin(url) {
|
|
499
|
-
try {
|
|
500
|
-
const parsedUrl = new URL(url);
|
|
501
|
-
return parsedUrl.origin === "null" ? null : parsedUrl.origin;
|
|
502
|
-
} catch {
|
|
503
|
-
return null;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
function getProtocol(url) {
|
|
507
|
-
try {
|
|
508
|
-
return new URL(url).protocol;
|
|
509
|
-
} catch {
|
|
510
|
-
return null;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
function getHost(url) {
|
|
514
|
-
try {
|
|
515
|
-
return new URL(url).host;
|
|
516
|
-
} catch {
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
/**
|
|
521
|
-
* Checks if the baseURL config is a dynamic config object
|
|
522
|
-
*/
|
|
523
|
-
function isDynamicBaseURLConfig(config) {
|
|
524
|
-
return typeof config === "object" && config !== null && "allowedHosts" in config && Array.isArray(config.allowedHosts);
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* Check if a value is a `Request`
|
|
528
|
-
* - `instanceof`: works for native Request instances
|
|
529
|
-
* - `toString`: handles where instanceof check fails but the object is still a
|
|
530
|
-
* valid Request (e.g. cross-realm, polyfills). Paired with a shape check so
|
|
531
|
-
* an object that only spoofs `Symbol.toStringTag` without the real shape is
|
|
532
|
-
* rejected before downstream code tries to read `.headers` / `.url`.
|
|
533
|
-
*
|
|
534
|
-
* @param value The value to check
|
|
535
|
-
* @returns `true` if the value is a Request instance
|
|
536
|
-
*/
|
|
537
|
-
function isRequestLike(value) {
|
|
538
|
-
if (value instanceof Request) return true;
|
|
539
|
-
if (typeof value !== "object" || value === null || Object.prototype.toString.call(value) !== "[object Request]") return false;
|
|
540
|
-
const v = value;
|
|
541
|
-
return typeof v.url === "string" && typeof v.headers === "object" && v.headers !== null && typeof v.headers.get === "function";
|
|
542
|
-
}
|
|
543
|
-
/**
|
|
544
|
-
* Extracts the host from a `Request` or `Headers`.
|
|
545
|
-
* Honors `x-forwarded-host` only when `trustedProxyHeaders` is enabled,
|
|
546
|
-
* then falls back to the `host` header and finally the request URL.
|
|
547
|
-
*/
|
|
548
|
-
function getHostFromSource(source, trustedProxyHeaders) {
|
|
549
|
-
const headers = isRequestLike(source) ? source.headers : source;
|
|
550
|
-
if (trustedProxyHeaders) {
|
|
551
|
-
const forwardedHost = headers.get("x-forwarded-host");
|
|
552
|
-
if (forwardedHost && validateProxyHeader(forwardedHost, "host")) return forwardedHost;
|
|
553
|
-
}
|
|
554
|
-
const host = headers.get("host");
|
|
555
|
-
if (host && validateProxyHeader(host, "host")) return host;
|
|
556
|
-
if (isRequestLike(source)) try {
|
|
557
|
-
return new URL(source.url).host;
|
|
558
|
-
} catch {
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Extracts the protocol from a `Request` or `Headers`.
|
|
565
|
-
* Honors `x-forwarded-proto` only when `trustedProxyHeaders` is enabled,
|
|
566
|
-
* then falls back to the request URL, then to "https".
|
|
567
|
-
*/
|
|
568
|
-
function getProtocolFromSource(source, configProtocol, trustedProxyHeaders) {
|
|
569
|
-
if (configProtocol === "http" || configProtocol === "https") return configProtocol;
|
|
570
|
-
const headers = isRequestLike(source) ? source.headers : source;
|
|
571
|
-
if (trustedProxyHeaders) {
|
|
572
|
-
const forwardedProto = headers.get("x-forwarded-proto");
|
|
573
|
-
if (forwardedProto && validateProxyHeader(forwardedProto, "proto")) return forwardedProto;
|
|
574
|
-
}
|
|
575
|
-
if (isRequestLike(source)) try {
|
|
576
|
-
const url = new URL(source.url);
|
|
577
|
-
if (url.protocol === "http:" || url.protocol === "https:") return url.protocol.slice(0, -1);
|
|
578
|
-
} catch {}
|
|
579
|
-
const host = getHostFromSource(source, trustedProxyHeaders);
|
|
580
|
-
if (host && isLoopbackForDevScheme(host)) return "http";
|
|
581
|
-
return "https";
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Matches a hostname against a host pattern.
|
|
585
|
-
* Supports wildcard patterns like `*.vercel.app` or `preview-*.myapp.com`.
|
|
586
|
-
*
|
|
587
|
-
* @param host The hostname to test (e.g., "myapp.com", "preview-123.vercel.app")
|
|
588
|
-
* @param pattern The host pattern (e.g., "myapp.com", "*.vercel.app")
|
|
589
|
-
* @returns {boolean} true if the host matches the pattern, false otherwise.
|
|
590
|
-
*
|
|
591
|
-
* @example
|
|
592
|
-
* ```ts
|
|
593
|
-
* matchesHostPattern("myapp.com", "myapp.com") // true
|
|
594
|
-
* matchesHostPattern("preview-123.vercel.app", "*.vercel.app") // true
|
|
595
|
-
* matchesHostPattern("preview-123.myapp.com", "preview-*.myapp.com") // true
|
|
596
|
-
* matchesHostPattern("evil.com", "myapp.com") // false
|
|
597
|
-
* ```
|
|
598
|
-
*/
|
|
599
|
-
var matchesHostPattern = (host, pattern) => {
|
|
600
|
-
if (!host || !pattern) return false;
|
|
601
|
-
const normalizedHost = host.replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
|
|
602
|
-
const normalizedPattern = pattern.replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
|
|
603
|
-
if (normalizedPattern.includes("*") || normalizedPattern.includes("?")) return wildcardMatch(normalizedPattern)(normalizedHost);
|
|
604
|
-
return normalizedHost.toLowerCase() === normalizedPattern.toLowerCase();
|
|
605
|
-
};
|
|
606
|
-
/**
|
|
607
|
-
* Resolves the base URL from a dynamic config based on the incoming request.
|
|
608
|
-
* Validates the derived host against the allowedHosts allowlist.
|
|
609
|
-
*
|
|
610
|
-
* @param config The dynamic base URL config
|
|
611
|
-
* @param request The incoming request
|
|
612
|
-
* @param basePath The base path to append
|
|
613
|
-
* @returns The resolved base URL with path
|
|
614
|
-
* @throws BetterAuthError if host is not in allowedHosts and no fallback is set
|
|
615
|
-
*/
|
|
616
|
-
function resolveDynamicBaseURL(config, source, basePath, trustedProxyHeaders) {
|
|
617
|
-
const host = getHostFromSource(source, trustedProxyHeaders);
|
|
618
|
-
if (!host) {
|
|
619
|
-
if (config.fallback) return withPath(config.fallback, basePath);
|
|
620
|
-
throw new BetterAuthError("Could not determine host from request headers. Please provide a fallback URL in your baseURL config.");
|
|
621
|
-
}
|
|
622
|
-
if (config.allowedHosts.some((pattern) => matchesHostPattern(host, pattern))) return withPath(`${getProtocolFromSource(source, config.protocol, trustedProxyHeaders)}://${host}`, basePath);
|
|
623
|
-
if (config.fallback) return withPath(config.fallback, basePath);
|
|
624
|
-
throw new BetterAuthError(`Host "${host}" is not in the allowed hosts list. Allowed hosts: ${config.allowedHosts.join(", ")}. Add this host to your allowedHosts config or provide a fallback URL.`);
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* Resolves the base URL from any config type (static string or dynamic object).
|
|
628
|
-
* This is the main entry point for base URL resolution.
|
|
629
|
-
*
|
|
630
|
-
* @param config The base URL config (string or object)
|
|
631
|
-
* @param basePath The base path to append
|
|
632
|
-
* @param request Optional request for dynamic resolution
|
|
633
|
-
* @param loadEnv Whether to load from environment variables
|
|
634
|
-
* @param trustedProxyHeaders Whether to trust proxy headers (for legacy behavior)
|
|
635
|
-
* @returns The resolved base URL with path
|
|
636
|
-
*/
|
|
637
|
-
function resolveBaseURL(config, basePath, source, loadEnv, trustedProxyHeaders) {
|
|
638
|
-
if (isDynamicBaseURLConfig(config)) {
|
|
639
|
-
if (source) return resolveDynamicBaseURL(config, source, basePath, trustedProxyHeaders);
|
|
640
|
-
if (config.fallback) return withPath(config.fallback, basePath);
|
|
641
|
-
return getBaseURL(void 0, basePath, void 0, loadEnv, trustedProxyHeaders);
|
|
642
|
-
}
|
|
643
|
-
const request = isRequestLike(source) ? source : void 0;
|
|
644
|
-
if (typeof config === "string") return getBaseURL(config, basePath, request, loadEnv, trustedProxyHeaders);
|
|
645
|
-
return getBaseURL(void 0, basePath, request, loadEnv, trustedProxyHeaders);
|
|
646
|
-
}
|
|
647
|
-
/**
|
|
648
|
-
* Normalizes a request pathname by removing the basePath prefix and trailing slashes.
|
|
649
|
-
* This is useful for matching paths against configured path lists.
|
|
650
|
-
*
|
|
651
|
-
* @param requestUrl - The full request URL
|
|
652
|
-
* @param basePath - The base path of the auth API (e.g., "/api/auth")
|
|
653
|
-
* @returns The normalized path without basePath prefix or trailing slashes,
|
|
654
|
-
* or "/" if URL parsing fails
|
|
655
|
-
*
|
|
656
|
-
* @example
|
|
657
|
-
* normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
|
|
658
|
-
* // Returns: "/sso/saml2/callback/provider1"
|
|
659
|
-
*
|
|
660
|
-
* normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
|
|
661
|
-
* // Returns: "/sso/saml2/callback/provider1"
|
|
662
|
-
*/
|
|
663
|
-
function normalizePathname(requestUrl, basePath) {
|
|
664
|
-
let pathname;
|
|
665
|
-
try {
|
|
666
|
-
pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
|
|
667
|
-
} catch {
|
|
668
|
-
return "/";
|
|
669
|
-
}
|
|
670
|
-
const normalizedBasePath = basePath.replace(/\/+$/, "");
|
|
671
|
-
if (normalizedBasePath === "") return pathname;
|
|
672
|
-
if (pathname === normalizedBasePath) return "/";
|
|
673
|
-
if (pathname.startsWith(normalizedBasePath + "/")) return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
|
|
674
|
-
return pathname;
|
|
675
|
-
}
|
|
676
|
-
/**
|
|
677
|
-
* Schemes that execute or embed code when navigated to or accepted as a
|
|
678
|
-
* redirect target. These are never safe as an OAuth `redirect_uri` or as a
|
|
679
|
-
* client-side navigation target (`window.location.href`, `location.assign`, ...).
|
|
680
|
-
*/
|
|
681
|
-
var DANGEROUS_URL_SCHEMES = [
|
|
682
|
-
"javascript:",
|
|
683
|
-
"data:",
|
|
684
|
-
"vbscript:"
|
|
685
|
-
];
|
|
686
|
-
/**
|
|
687
|
-
* Returns `false` only when `value` is an absolute URL using a dangerous scheme
|
|
688
|
-
* (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
|
|
689
|
-
* safe absolute schemes (`http`, `https`, custom app schemes such as
|
|
690
|
-
* `myapp://`) return `true`.
|
|
691
|
-
*
|
|
692
|
-
* Use this to guard browser navigation sinks and any redirect target that may
|
|
693
|
-
* originate from untrusted input. It is intentionally narrow: it blocks code
|
|
694
|
-
* execution schemes without rejecting relative paths or mobile deep links.
|
|
695
|
-
*/
|
|
696
|
-
function isSafeUrlScheme(value) {
|
|
697
|
-
let parsed;
|
|
698
|
-
try {
|
|
699
|
-
parsed = new URL(value);
|
|
700
|
-
} catch {
|
|
701
|
-
return true;
|
|
702
|
-
}
|
|
703
|
-
return !DANGEROUS_URL_SCHEMES.includes(parsed.protocol);
|
|
704
|
-
}
|
|
705
|
-
function isPlainObject(value) {
|
|
706
|
-
if (value === null || typeof value !== "object") return false;
|
|
707
|
-
const prototype = Object.getPrototypeOf(value);
|
|
708
|
-
if (prototype !== null && prototype !== Object.prototype && Object.getPrototypeOf(prototype) !== null) return false;
|
|
709
|
-
if (Symbol.iterator in value) return false;
|
|
710
|
-
if (Symbol.toStringTag in value) return Object.prototype.toString.call(value) === "[object Module]";
|
|
711
|
-
return true;
|
|
712
|
-
}
|
|
713
|
-
function _defu(baseObject, defaults, namespace = ".", merger) {
|
|
714
|
-
if (!isPlainObject(defaults)) return _defu(baseObject, {}, namespace, merger);
|
|
715
|
-
const object = { ...defaults };
|
|
716
|
-
for (const key of Object.keys(baseObject)) {
|
|
717
|
-
if (key === "__proto__" || key === "constructor") continue;
|
|
718
|
-
const value = baseObject[key];
|
|
719
|
-
if (value === null || value === void 0) continue;
|
|
720
|
-
if (merger && merger(object, key, value, namespace)) continue;
|
|
721
|
-
if (Array.isArray(value) && Array.isArray(object[key])) object[key] = [...value, ...object[key]];
|
|
722
|
-
else if (isPlainObject(value) && isPlainObject(object[key])) object[key] = _defu(value, object[key], (namespace ? `${namespace}.` : "") + key.toString(), merger);
|
|
723
|
-
else object[key] = value;
|
|
724
|
-
}
|
|
725
|
-
return object;
|
|
726
|
-
}
|
|
727
|
-
function createDefu(merger) {
|
|
728
|
-
return (...arguments_) => arguments_.reduce((p, c) => _defu(p, c, "", merger), {});
|
|
729
|
-
}
|
|
730
|
-
var defu = createDefu();
|
|
731
|
-
var __defProp = Object.defineProperty;
|
|
732
|
-
var __defProps = Object.defineProperties;
|
|
733
|
-
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
|
|
734
|
-
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
735
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
736
|
-
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
737
|
-
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
738
|
-
enumerable: true,
|
|
739
|
-
configurable: true,
|
|
740
|
-
writable: true,
|
|
741
|
-
value
|
|
742
|
-
}) : obj[key] = value;
|
|
743
|
-
var __spreadValues = (a, b) => {
|
|
744
|
-
for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]);
|
|
745
|
-
if (__getOwnPropSymbols) {
|
|
746
|
-
for (var prop of __getOwnPropSymbols(b)) if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]);
|
|
747
|
-
}
|
|
748
|
-
return a;
|
|
749
|
-
};
|
|
750
|
-
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
|
751
|
-
var BetterFetchError = class extends Error {
|
|
752
|
-
constructor(status, statusText, error) {
|
|
753
|
-
super(statusText || status.toString(), { cause: error });
|
|
754
|
-
this.status = status;
|
|
755
|
-
this.statusText = statusText;
|
|
756
|
-
this.error = error;
|
|
757
|
-
Error.captureStackTrace(this, this.constructor);
|
|
758
|
-
}
|
|
759
|
-
};
|
|
760
|
-
var initializePlugins = async (url, options) => {
|
|
761
|
-
var _a, _b, _c, _d, _e, _f;
|
|
762
|
-
let opts = options || {};
|
|
763
|
-
const hooks = {
|
|
764
|
-
onRequest: [options == null ? void 0 : options.onRequest],
|
|
765
|
-
onResponse: [options == null ? void 0 : options.onResponse],
|
|
766
|
-
onSuccess: [options == null ? void 0 : options.onSuccess],
|
|
767
|
-
onError: [options == null ? void 0 : options.onError],
|
|
768
|
-
onRetry: [options == null ? void 0 : options.onRetry]
|
|
769
|
-
};
|
|
770
|
-
if (!options || !(options == null ? void 0 : options.plugins)) return {
|
|
771
|
-
url,
|
|
772
|
-
options: opts,
|
|
773
|
-
hooks
|
|
774
|
-
};
|
|
775
|
-
for (const plugin of (options == null ? void 0 : options.plugins) || []) {
|
|
776
|
-
if (plugin.init) {
|
|
777
|
-
const pluginRes = await ((_a = plugin.init) == null ? void 0 : _a.call(plugin, url.toString(), options));
|
|
778
|
-
opts = pluginRes.options || opts;
|
|
779
|
-
url = pluginRes.url;
|
|
780
|
-
}
|
|
781
|
-
hooks.onRequest.push((_b = plugin.hooks) == null ? void 0 : _b.onRequest);
|
|
782
|
-
hooks.onResponse.push((_c = plugin.hooks) == null ? void 0 : _c.onResponse);
|
|
783
|
-
hooks.onSuccess.push((_d = plugin.hooks) == null ? void 0 : _d.onSuccess);
|
|
784
|
-
hooks.onError.push((_e = plugin.hooks) == null ? void 0 : _e.onError);
|
|
785
|
-
hooks.onRetry.push((_f = plugin.hooks) == null ? void 0 : _f.onRetry);
|
|
786
|
-
}
|
|
787
|
-
return {
|
|
788
|
-
url,
|
|
789
|
-
options: opts,
|
|
790
|
-
hooks
|
|
791
|
-
};
|
|
792
|
-
};
|
|
793
|
-
var LinearRetryStrategy = class {
|
|
794
|
-
constructor(options) {
|
|
795
|
-
this.options = options;
|
|
796
|
-
}
|
|
797
|
-
shouldAttemptRetry(attempt, response) {
|
|
798
|
-
if (this.options.shouldRetry) return Promise.resolve(attempt < this.options.attempts && this.options.shouldRetry(response));
|
|
799
|
-
return Promise.resolve(attempt < this.options.attempts);
|
|
800
|
-
}
|
|
801
|
-
getDelay() {
|
|
802
|
-
return this.options.delay;
|
|
803
|
-
}
|
|
804
|
-
};
|
|
805
|
-
var ExponentialRetryStrategy = class {
|
|
806
|
-
constructor(options) {
|
|
807
|
-
this.options = options;
|
|
808
|
-
}
|
|
809
|
-
shouldAttemptRetry(attempt, response) {
|
|
810
|
-
if (this.options.shouldRetry) return Promise.resolve(attempt < this.options.attempts && this.options.shouldRetry(response));
|
|
811
|
-
return Promise.resolve(attempt < this.options.attempts);
|
|
812
|
-
}
|
|
813
|
-
getDelay(attempt) {
|
|
814
|
-
return Math.min(this.options.maxDelay, this.options.baseDelay * 2 ** attempt);
|
|
815
|
-
}
|
|
816
|
-
};
|
|
817
|
-
function createRetryStrategy(options) {
|
|
818
|
-
if (typeof options === "number") return new LinearRetryStrategy({
|
|
819
|
-
type: "linear",
|
|
820
|
-
attempts: options,
|
|
821
|
-
delay: 1e3
|
|
822
|
-
});
|
|
823
|
-
switch (options.type) {
|
|
824
|
-
case "linear": return new LinearRetryStrategy(options);
|
|
825
|
-
case "exponential": return new ExponentialRetryStrategy(options);
|
|
826
|
-
default: throw new Error("Invalid retry strategy");
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
var getAuthHeader = async (options) => {
|
|
830
|
-
const headers = {};
|
|
831
|
-
const getValue = async (value) => typeof value === "function" ? await value() : value;
|
|
832
|
-
if (options == null ? void 0 : options.auth) {
|
|
833
|
-
if (options.auth.type === "Bearer") {
|
|
834
|
-
const token = await getValue(options.auth.token);
|
|
835
|
-
if (!token) return headers;
|
|
836
|
-
headers["authorization"] = `Bearer ${token}`;
|
|
837
|
-
} else if (options.auth.type === "Basic") {
|
|
838
|
-
const [username, password] = await Promise.all([getValue(options.auth.username), getValue(options.auth.password)]);
|
|
839
|
-
if (!username || !password) return headers;
|
|
840
|
-
headers["authorization"] = `Basic ${btoa(`${username}:${password}`)}`;
|
|
841
|
-
} else if (options.auth.type === "Custom") {
|
|
842
|
-
const [prefix, value] = await Promise.all([getValue(options.auth.prefix), getValue(options.auth.value)]);
|
|
843
|
-
if (!value) return headers;
|
|
844
|
-
headers["authorization"] = `${prefix != null ? prefix : ""} ${value}`;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return headers;
|
|
848
|
-
};
|
|
849
|
-
var JSON_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;
|
|
850
|
-
function detectResponseType(request) {
|
|
851
|
-
const _contentType = request.headers.get("content-type");
|
|
852
|
-
const textTypes = /* @__PURE__ */ new Set([
|
|
853
|
-
"image/svg",
|
|
854
|
-
"application/xml",
|
|
855
|
-
"application/xhtml",
|
|
856
|
-
"application/html"
|
|
857
|
-
]);
|
|
858
|
-
if (!_contentType) return "json";
|
|
859
|
-
const contentType = _contentType.split(";").shift() || "";
|
|
860
|
-
if (JSON_RE.test(contentType)) return "json";
|
|
861
|
-
if (textTypes.has(contentType) || contentType.startsWith("text/")) return "text";
|
|
862
|
-
return "blob";
|
|
863
|
-
}
|
|
864
|
-
function isJSONParsable(value) {
|
|
865
|
-
try {
|
|
866
|
-
JSON.parse(value);
|
|
867
|
-
return true;
|
|
868
|
-
} catch (error) {
|
|
869
|
-
return false;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
function isJSONSerializable(value) {
|
|
873
|
-
if (value === void 0) return false;
|
|
874
|
-
const t = typeof value;
|
|
875
|
-
if (t === "string" || t === "number" || t === "boolean" || t === null) return true;
|
|
876
|
-
if (t !== "object") return false;
|
|
877
|
-
if (Array.isArray(value)) return true;
|
|
878
|
-
if (value.buffer) return false;
|
|
879
|
-
return value.constructor && value.constructor.name === "Object" || typeof value.toJSON === "function";
|
|
880
|
-
}
|
|
881
|
-
function jsonParse(text) {
|
|
882
|
-
try {
|
|
883
|
-
return JSON.parse(text);
|
|
884
|
-
} catch (error) {
|
|
885
|
-
return text;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
function isFunction(value) {
|
|
889
|
-
return typeof value === "function";
|
|
890
|
-
}
|
|
891
|
-
function getFetch(options) {
|
|
892
|
-
if (options == null ? void 0 : options.customFetchImpl) return options.customFetchImpl;
|
|
893
|
-
if (typeof globalThis !== "undefined" && isFunction(globalThis.fetch)) return globalThis.fetch;
|
|
894
|
-
if (typeof window !== "undefined" && isFunction(window.fetch)) return window.fetch;
|
|
895
|
-
throw new Error("No fetch implementation found");
|
|
896
|
-
}
|
|
897
|
-
function mergeHeaders(...sources) {
|
|
898
|
-
const merged = {};
|
|
899
|
-
for (const source of sources) {
|
|
900
|
-
if (!source) continue;
|
|
901
|
-
if (source instanceof Headers) source.forEach((value, key) => {
|
|
902
|
-
merged[key] = value;
|
|
903
|
-
});
|
|
904
|
-
else {
|
|
905
|
-
const entries = Array.isArray(source) ? source : Object.entries(source);
|
|
906
|
-
for (const [key, value] of entries) if (value !== null && value !== void 0) merged[key] = value;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
return merged;
|
|
910
|
-
}
|
|
911
|
-
async function getHeaders(opts) {
|
|
912
|
-
const headers = new Headers(mergeHeaders(opts == null ? void 0 : opts.headers, await getAuthHeader(opts)));
|
|
913
|
-
if (!headers.has("content-type")) {
|
|
914
|
-
const contentType = detectContentType(opts == null ? void 0 : opts.body);
|
|
915
|
-
if (contentType) headers.set("content-type", contentType);
|
|
916
|
-
}
|
|
917
|
-
return headers;
|
|
918
|
-
}
|
|
919
|
-
function detectContentType(body) {
|
|
920
|
-
if (isJSONSerializable(body)) return "application/json";
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
function getMediaType(headers) {
|
|
924
|
-
const contentType = headers.get("content-type");
|
|
925
|
-
return contentType ? contentType.split(";")[0].trim().toLowerCase() : null;
|
|
926
|
-
}
|
|
927
|
-
function getBody(options, headers) {
|
|
928
|
-
const { body } = options;
|
|
929
|
-
if (!body) return null;
|
|
930
|
-
if (!isJSONSerializable(body)) return body;
|
|
931
|
-
if (typeof body === "string") return body;
|
|
932
|
-
if (getMediaType(headers) === "application/x-www-form-urlencoded") return new URLSearchParams(body).toString();
|
|
933
|
-
return JSON.stringify(body);
|
|
934
|
-
}
|
|
935
|
-
function getMethod(url, options) {
|
|
936
|
-
var _a;
|
|
937
|
-
if (options == null ? void 0 : options.method) return options.method.toUpperCase();
|
|
938
|
-
if (url.startsWith("@")) {
|
|
939
|
-
const pMethod = (_a = url.split("@")[1]) == null ? void 0 : _a.split("/")[0];
|
|
940
|
-
if (!methods.includes(pMethod)) return (options == null ? void 0 : options.body) ? "POST" : "GET";
|
|
941
|
-
return pMethod.toUpperCase();
|
|
942
|
-
}
|
|
943
|
-
return (options == null ? void 0 : options.body) ? "POST" : "GET";
|
|
944
|
-
}
|
|
945
|
-
function getTimeout(options, controller) {
|
|
946
|
-
let abortTimeout;
|
|
947
|
-
if (!(options == null ? void 0 : options.signal) && (options == null ? void 0 : options.timeout)) abortTimeout = setTimeout(() => controller == null ? void 0 : controller.abort(), options == null ? void 0 : options.timeout);
|
|
948
|
-
return {
|
|
949
|
-
abortTimeout,
|
|
950
|
-
clearTimeout: () => {
|
|
951
|
-
if (abortTimeout) clearTimeout(abortTimeout);
|
|
952
|
-
}
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
var ValidationError = class _ValidationError extends Error {
|
|
956
|
-
constructor(issues, message) {
|
|
957
|
-
super(message || JSON.stringify(issues, null, 2));
|
|
958
|
-
this.issues = issues;
|
|
959
|
-
Object.setPrototypeOf(this, _ValidationError.prototype);
|
|
960
|
-
}
|
|
961
|
-
};
|
|
962
|
-
async function parseStandardSchema(schema, input) {
|
|
963
|
-
const result = await schema["~standard"].validate(input);
|
|
964
|
-
if (result.issues) throw new ValidationError(result.issues);
|
|
965
|
-
return result.value;
|
|
966
|
-
}
|
|
967
|
-
var methods = [
|
|
968
|
-
"get",
|
|
969
|
-
"post",
|
|
970
|
-
"put",
|
|
971
|
-
"patch",
|
|
972
|
-
"delete"
|
|
973
|
-
];
|
|
974
|
-
var applySchemaPlugin = (config) => ({
|
|
975
|
-
id: "apply-schema",
|
|
976
|
-
name: "Apply Schema",
|
|
977
|
-
version: "1.0.0",
|
|
978
|
-
async init(url, options) {
|
|
979
|
-
var _a, _b, _c, _d;
|
|
980
|
-
const schema = ((_b = (_a = config.plugins) == null ? void 0 : _a.find((plugin) => {
|
|
981
|
-
var _a2;
|
|
982
|
-
return ((_a2 = plugin.schema) == null ? void 0 : _a2.config) ? url.startsWith(plugin.schema.config.baseURL || "") || url.startsWith(plugin.schema.config.prefix || "") : false;
|
|
983
|
-
})) == null ? void 0 : _b.schema) || config.schema;
|
|
984
|
-
if (schema) {
|
|
985
|
-
let urlKey = url;
|
|
986
|
-
if ((_c = schema.config) == null ? void 0 : _c.prefix) {
|
|
987
|
-
if (urlKey.startsWith(schema.config.prefix)) {
|
|
988
|
-
urlKey = urlKey.replace(schema.config.prefix, "");
|
|
989
|
-
if (schema.config.baseURL) url = url.replace(schema.config.prefix, schema.config.baseURL);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
if ((_d = schema.config) == null ? void 0 : _d.baseURL) {
|
|
993
|
-
if (urlKey.startsWith(schema.config.baseURL)) urlKey = urlKey.replace(schema.config.baseURL, "");
|
|
994
|
-
}
|
|
995
|
-
if (urlKey.startsWith("/") && urlKey.charAt(1) === "@") urlKey = urlKey.substring(1);
|
|
996
|
-
const keySchema = schema.schema[urlKey];
|
|
997
|
-
if (keySchema) {
|
|
998
|
-
let validatedHeaders = options == null ? void 0 : options.headers;
|
|
999
|
-
if (keySchema.headers && !(options == null ? void 0 : options.disableValidation)) {
|
|
1000
|
-
const normalizedHeaders = {};
|
|
1001
|
-
if (options == null ? void 0 : options.headers) {
|
|
1002
|
-
if (options.headers instanceof Headers) options.headers.forEach((value, key) => {
|
|
1003
|
-
normalizedHeaders[key.toLowerCase()] = value;
|
|
1004
|
-
});
|
|
1005
|
-
else if (typeof options.headers === "object") {
|
|
1006
|
-
for (const [key, value] of Object.entries(options.headers)) if (value !== null && value !== void 0) normalizedHeaders[key.toLowerCase()] = value;
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
const validated = await parseStandardSchema(keySchema.headers, normalizedHeaders);
|
|
1010
|
-
const finalHeaders = {};
|
|
1011
|
-
for (const [key, value] of Object.entries(validated)) finalHeaders[key.toLowerCase()] = value;
|
|
1012
|
-
validatedHeaders = finalHeaders;
|
|
1013
|
-
}
|
|
1014
|
-
let opts = __spreadProps(__spreadValues({}, options), {
|
|
1015
|
-
method: keySchema.method,
|
|
1016
|
-
output: keySchema.output,
|
|
1017
|
-
headers: validatedHeaders
|
|
1018
|
-
});
|
|
1019
|
-
if (!(options == null ? void 0 : options.disableValidation)) opts = __spreadProps(__spreadValues({}, opts), {
|
|
1020
|
-
body: keySchema.input ? await parseStandardSchema(keySchema.input, options == null ? void 0 : options.body) : options == null ? void 0 : options.body,
|
|
1021
|
-
params: keySchema.params ? await parseStandardSchema(keySchema.params, options == null ? void 0 : options.params) : options == null ? void 0 : options.params,
|
|
1022
|
-
query: keySchema.query ? await parseStandardSchema(keySchema.query, options == null ? void 0 : options.query) : options == null ? void 0 : options.query
|
|
1023
|
-
});
|
|
1024
|
-
return {
|
|
1025
|
-
url,
|
|
1026
|
-
options: opts
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
return {
|
|
1031
|
-
url,
|
|
1032
|
-
options
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
});
|
|
1036
|
-
var createFetch = (config) => {
|
|
1037
|
-
async function $fetch(url, options) {
|
|
1038
|
-
const opts = __spreadProps(__spreadValues(__spreadValues({}, config), options), {
|
|
1039
|
-
headers: mergeHeaders(config == null ? void 0 : config.headers, options == null ? void 0 : options.headers),
|
|
1040
|
-
plugins: [
|
|
1041
|
-
...(config == null ? void 0 : config.plugins) || [],
|
|
1042
|
-
applySchemaPlugin(config || {}),
|
|
1043
|
-
...(options == null ? void 0 : options.plugins) || []
|
|
1044
|
-
]
|
|
1045
|
-
});
|
|
1046
|
-
if (config == null ? void 0 : config.catchAllError) try {
|
|
1047
|
-
return await betterFetch(url, opts);
|
|
1048
|
-
} catch (error) {
|
|
1049
|
-
return {
|
|
1050
|
-
data: null,
|
|
1051
|
-
error: {
|
|
1052
|
-
status: 500,
|
|
1053
|
-
statusText: "Fetch Error",
|
|
1054
|
-
message: "Fetch related error. Captured by catchAllError option. See error property for more details.",
|
|
1055
|
-
error
|
|
1056
|
-
}
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
return await betterFetch(url, opts);
|
|
1060
|
-
}
|
|
1061
|
-
return $fetch;
|
|
1062
|
-
};
|
|
1063
|
-
var isReservedPathSegment = (value) => value === "." || value === "..";
|
|
1064
|
-
function encodePathSegment(segment, pathParams) {
|
|
1065
|
-
let pathSegment = segment;
|
|
1066
|
-
for (const [key, value] of pathParams) pathSegment = pathSegment.replace(key, value);
|
|
1067
|
-
if (isReservedPathSegment(pathSegment)) throw new TypeError("Path parameters cannot be reserved path segments");
|
|
1068
|
-
return encodeURIComponent(pathSegment);
|
|
1069
|
-
}
|
|
1070
|
-
function getURL2(url, option) {
|
|
1071
|
-
const { baseURL, params, query } = option || {
|
|
1072
|
-
query: {},
|
|
1073
|
-
params: {},
|
|
1074
|
-
baseURL: ""
|
|
1075
|
-
};
|
|
1076
|
-
let basePath = url.startsWith("http") ? url.split("/").slice(0, 3).join("/") : baseURL || "";
|
|
1077
|
-
if (url.startsWith("@")) {
|
|
1078
|
-
const m = url.toString().split("@")[1].split("/")[0];
|
|
1079
|
-
if (methods.includes(m)) url = url.replace(`@${m}/`, "/");
|
|
1080
|
-
}
|
|
1081
|
-
if (!basePath.endsWith("/")) basePath += "/";
|
|
1082
|
-
let [path, urlQuery] = url.replace(basePath, "").split("?");
|
|
1083
|
-
const queryParams = new URLSearchParams(urlQuery);
|
|
1084
|
-
for (const [key, value] of Object.entries(query || {})) {
|
|
1085
|
-
if (value == null) continue;
|
|
1086
|
-
let serializedValue;
|
|
1087
|
-
if (typeof value === "string") serializedValue = value;
|
|
1088
|
-
else if (Array.isArray(value)) {
|
|
1089
|
-
for (const val of value) queryParams.append(key, val);
|
|
1090
|
-
continue;
|
|
1091
|
-
} else serializedValue = JSON.stringify(value);
|
|
1092
|
-
queryParams.set(key, serializedValue);
|
|
1093
|
-
}
|
|
1094
|
-
const pathParams = /* @__PURE__ */ new Map();
|
|
1095
|
-
if (params) if (Array.isArray(params)) {
|
|
1096
|
-
const paramPaths = path.split("/").filter((p) => p.startsWith(":"));
|
|
1097
|
-
for (const [index, key] of paramPaths.entries()) {
|
|
1098
|
-
const value = params[index];
|
|
1099
|
-
pathParams.set(key, String(value));
|
|
1100
|
-
}
|
|
1101
|
-
} else for (const [key, value] of Object.entries(params)) pathParams.set(`:${key}`, String(value));
|
|
1102
|
-
path = path.split("/").map((segment) => encodePathSegment(segment, pathParams)).join("/");
|
|
1103
|
-
path = path.replace(/^\/+/, "");
|
|
1104
|
-
let queryParamString = queryParams.toString();
|
|
1105
|
-
queryParamString = queryParamString.length > 0 ? `?${queryParamString}`.replace(/\+/g, "%20") : "";
|
|
1106
|
-
if (!basePath.startsWith("http")) return `${basePath}${path}${queryParamString}`;
|
|
1107
|
-
return new URL(`${path}${queryParamString}`, basePath);
|
|
1108
|
-
}
|
|
1109
|
-
var betterFetch = async (url, options) => {
|
|
1110
|
-
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
1111
|
-
const { hooks, url: __url, options: opts } = await initializePlugins(url, options);
|
|
1112
|
-
const fetch = getFetch(opts);
|
|
1113
|
-
const controller = new AbortController();
|
|
1114
|
-
const signal = (_a = opts.signal) != null ? _a : controller.signal;
|
|
1115
|
-
const _url = getURL2(__url, opts);
|
|
1116
|
-
const headers = await getHeaders(opts);
|
|
1117
|
-
const body = getBody(opts, headers);
|
|
1118
|
-
const method = getMethod(__url, opts);
|
|
1119
|
-
const context = __spreadProps(__spreadValues({}, opts), {
|
|
1120
|
-
url: _url,
|
|
1121
|
-
headers,
|
|
1122
|
-
body,
|
|
1123
|
-
method,
|
|
1124
|
-
signal
|
|
1125
|
-
});
|
|
1126
|
-
for (const onRequest of hooks.onRequest) if (onRequest) {
|
|
1127
|
-
const res = await onRequest(context);
|
|
1128
|
-
if (typeof res === "object" && res !== null) Object.assign(context, res);
|
|
1129
|
-
}
|
|
1130
|
-
if ("pipeTo" in context && typeof context.pipeTo === "function" || typeof ((_b = options == null ? void 0 : options.body) == null ? void 0 : _b.pipe) === "function") {
|
|
1131
|
-
if (!("duplex" in context)) context.duplex = "half";
|
|
1132
|
-
}
|
|
1133
|
-
const { clearTimeout: clearTimeout2 } = getTimeout(opts, controller);
|
|
1134
|
-
let response = await fetch(context.url, context);
|
|
1135
|
-
clearTimeout2();
|
|
1136
|
-
const responseContext = {
|
|
1137
|
-
response,
|
|
1138
|
-
request: context
|
|
1139
|
-
};
|
|
1140
|
-
for (const onResponse of hooks.onResponse) if (onResponse) {
|
|
1141
|
-
const r = await onResponse(__spreadProps(__spreadValues({}, responseContext), { response: ((_c = options == null ? void 0 : options.hookOptions) == null ? void 0 : _c.cloneResponse) ? response.clone() : response }));
|
|
1142
|
-
if (r instanceof Response) response = r;
|
|
1143
|
-
else if (typeof r === "object" && r !== null) response = r.response;
|
|
1144
|
-
}
|
|
1145
|
-
if (response.ok) {
|
|
1146
|
-
if (!(context.method !== "HEAD")) return {
|
|
1147
|
-
data: "",
|
|
1148
|
-
error: null
|
|
1149
|
-
};
|
|
1150
|
-
const responseType = detectResponseType(response);
|
|
1151
|
-
const successContext = {
|
|
1152
|
-
data: null,
|
|
1153
|
-
response,
|
|
1154
|
-
request: context
|
|
1155
|
-
};
|
|
1156
|
-
if (responseType === "json" || responseType === "text") {
|
|
1157
|
-
const text = await response.text();
|
|
1158
|
-
successContext.data = await ((_d = context.jsonParser) != null ? _d : jsonParse)(text);
|
|
1159
|
-
} else successContext.data = await response[responseType]();
|
|
1160
|
-
if (context == null ? void 0 : context.output) {
|
|
1161
|
-
if (context.output && !context.disableValidation) successContext.data = await parseStandardSchema(context.output, successContext.data);
|
|
1162
|
-
}
|
|
1163
|
-
for (const onSuccess of hooks.onSuccess) if (onSuccess) await onSuccess(__spreadProps(__spreadValues({}, successContext), { response: ((_e = options == null ? void 0 : options.hookOptions) == null ? void 0 : _e.cloneResponse) ? response.clone() : response }));
|
|
1164
|
-
if (options == null ? void 0 : options.throw) return successContext.data;
|
|
1165
|
-
return {
|
|
1166
|
-
data: successContext.data,
|
|
1167
|
-
error: null
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
const parser = (_f = options == null ? void 0 : options.jsonParser) != null ? _f : jsonParse;
|
|
1171
|
-
const responseText = await response.text();
|
|
1172
|
-
const isJSONResponse = isJSONParsable(responseText);
|
|
1173
|
-
const errorObject = isJSONResponse ? await parser(responseText) : null;
|
|
1174
|
-
const errorContext = {
|
|
1175
|
-
response,
|
|
1176
|
-
responseText,
|
|
1177
|
-
request: context,
|
|
1178
|
-
error: __spreadProps(__spreadValues({}, errorObject), {
|
|
1179
|
-
status: response.status,
|
|
1180
|
-
statusText: response.statusText
|
|
1181
|
-
})
|
|
1182
|
-
};
|
|
1183
|
-
for (const onError of hooks.onError) if (onError) await onError(__spreadProps(__spreadValues({}, errorContext), { response: ((_g = options == null ? void 0 : options.hookOptions) == null ? void 0 : _g.cloneResponse) ? response.clone() : response }));
|
|
1184
|
-
if (options == null ? void 0 : options.retry) {
|
|
1185
|
-
const retryStrategy = createRetryStrategy(options.retry);
|
|
1186
|
-
const _retryAttempt = (_h = options.retryAttempt) != null ? _h : 0;
|
|
1187
|
-
if (await retryStrategy.shouldAttemptRetry(_retryAttempt, response)) {
|
|
1188
|
-
for (const onRetry of hooks.onRetry) if (onRetry) await onRetry(responseContext);
|
|
1189
|
-
const delay = retryStrategy.getDelay(_retryAttempt);
|
|
1190
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1191
|
-
return await betterFetch(url, __spreadProps(__spreadValues({}, options), { retryAttempt: _retryAttempt + 1 }));
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
if (options == null ? void 0 : options.throw) throw new BetterFetchError(response.status, response.statusText, isJSONResponse ? errorObject : responseText);
|
|
1195
|
-
return {
|
|
1196
|
-
data: null,
|
|
1197
|
-
error: __spreadProps(__spreadValues({}, errorObject), {
|
|
1198
|
-
status: response.status,
|
|
1199
|
-
statusText: response.statusText
|
|
1200
|
-
})
|
|
1201
|
-
};
|
|
1202
|
-
};
|
|
1203
|
-
/**
|
|
1204
|
-
* Pievo server data directory — server-side file state, and (for the embedded
|
|
1205
|
-
* pglite tier) the on-disk Postgres data dir at `<dataDir>/pgdata`. Locally it
|
|
1206
|
-
* defaults to `~/.pievo`. Override with `PIEVO_DATA_DIR`. A hosted-Postgres tier
|
|
1207
|
-
* still uses `<dataDir>/blobs` unless R2 is configured.
|
|
1208
|
-
*/
|
|
1209
|
-
function dataDir() {
|
|
1210
|
-
return process.env.PIEVO_DATA_DIR?.trim() || path.join(os.homedir(), ".pievo");
|
|
1211
|
-
}
|
|
1212
|
-
/**
|
|
1213
|
-
* Supabase (or any Postgres) connection string for the RUNTIME app process. When
|
|
1214
|
-
* set, the server uses the postgres-js driver (hosted prod/staging). Pool options
|
|
1215
|
-
* are MODE-AWARE (`db/poolOptions.ts`): a Supabase TRANSACTION pooler URL
|
|
1216
|
-
* (`:6543`) gets `prepare:false`; a SESSION pooler / direct URL (`:5432`) gets
|
|
1217
|
-
* `prepare:true` (backend pinned per connection — safe and faster). Either form
|
|
1218
|
-
* is supported; `dbPoolMode()` below overrides the port-based detection.
|
|
1219
|
-
* When UNSET, the server falls back to the embedded, file-backed pglite database
|
|
1220
|
-
* at `<dataDir>/pgdata` (local dev + light self-host + tests) — zero external DB.
|
|
1221
|
-
*/
|
|
1222
|
-
function databaseUrl() {
|
|
1223
|
-
return process.env.DATABASE_URL?.trim() || void 0;
|
|
1224
|
-
}
|
|
1225
|
-
/**
|
|
1226
|
-
* Explicit pool-mode override for the runtime postgres-js pool
|
|
1227
|
-
* (`PIEVO_DB_POOL_MODE=transaction|session`). Normally UNSET — the mode is
|
|
1228
|
-
* detected from `DATABASE_URL`'s port (`:6543` ⇒ transaction pooler; see
|
|
1229
|
-
* `db/poolOptions.ts` `isTransactionPooler`). This is the escape hatch for a
|
|
1230
|
-
* NONSTANDARD-PORT or self-hosted pooler the port heuristic can't classify.
|
|
1231
|
-
* Getting it wrong on a real transaction pooler (`session` when the pooler is
|
|
1232
|
-
* transaction-mode) re-arms the prepared-statement breakage the detection
|
|
1233
|
-
* exists to prevent — set it only when you know the pooler's mode.
|
|
1234
|
-
*/
|
|
1235
|
-
function dbPoolMode() {
|
|
1236
|
-
const raw = process.env.PIEVO_DB_POOL_MODE?.trim().toLowerCase();
|
|
1237
|
-
if (raw === "transaction" || raw === "session") return raw;
|
|
1238
|
-
}
|
|
1239
|
-
function r2Config() {
|
|
1240
|
-
const accountId = process.env.PIEVO_R2_ACCOUNT_ID?.trim();
|
|
1241
|
-
const bucket = process.env.PIEVO_R2_BUCKET?.trim();
|
|
1242
|
-
const accessKeyId = process.env.PIEVO_R2_ACCESS_KEY_ID?.trim();
|
|
1243
|
-
const secretAccessKey = process.env.PIEVO_R2_SECRET_ACCESS_KEY?.trim();
|
|
1244
|
-
const explicitEndpoint = process.env.PIEVO_R2_ENDPOINT?.trim();
|
|
1245
|
-
const region = process.env.PIEVO_R2_REGION?.trim();
|
|
1246
|
-
const endpoint = explicitEndpoint || (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : void 0);
|
|
1247
|
-
if (!!!(accountId || bucket || accessKeyId || secretAccessKey || explicitEndpoint || region)) return null;
|
|
1248
|
-
const missing = [
|
|
1249
|
-
!bucket && "PIEVO_R2_BUCKET",
|
|
1250
|
-
!accessKeyId && "PIEVO_R2_ACCESS_KEY_ID",
|
|
1251
|
-
!secretAccessKey && "PIEVO_R2_SECRET_ACCESS_KEY",
|
|
1252
|
-
!endpoint && "PIEVO_R2_ACCOUNT_ID or PIEVO_R2_ENDPOINT"
|
|
1253
|
-
].filter(Boolean);
|
|
1254
|
-
if (missing.length) throw new Error(`incomplete R2 configuration — missing ${missing.join(", ")}`);
|
|
1255
|
-
return {
|
|
1256
|
-
bucket,
|
|
1257
|
-
endpoint,
|
|
1258
|
-
accessKeyId,
|
|
1259
|
-
secretAccessKey,
|
|
1260
|
-
region: region || "auto"
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
/**
|
|
1264
|
-
* Artifact-storage retention / GC knobs (see gateway/retention.ts). Read lazily
|
|
1265
|
-
* from env at call time (not at module load) so tests can set them per-case and
|
|
1266
|
-
* a deploy can tune them without a rebuild. Each has a safe, generous default —
|
|
1267
|
-
* the bias is "keep storage bounded without ever surprising a healthy loop".
|
|
1268
|
-
*/
|
|
1269
|
-
function posIntEnv(name, fallback) {
|
|
1270
|
-
const raw = process.env[name]?.trim();
|
|
1271
|
-
if (!raw) return fallback;
|
|
1272
|
-
const n = Number(raw);
|
|
1273
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
1274
|
-
}
|
|
1275
|
-
/**
|
|
1276
|
-
* How many of the most recent run snapshots to keep per loop. Older ones are
|
|
1277
|
-
* pruned (their now-unreferenced blobs then become GC-collectable). The per-run
|
|
1278
|
-
* diff only needs the immediately-prior snapshot, so 20 keeps ample history for
|
|
1279
|
-
* the "Changes" view while bounding the table + blob retention. Default 20.
|
|
1280
|
-
*/
|
|
1281
|
-
function snapshotRetention() {
|
|
1282
|
-
return posIntEnv("PIEVO_SNAPSHOT_RETENTION", 20);
|
|
1283
|
-
}
|
|
1284
|
-
/**
|
|
1285
|
-
* GC grace window: a blob whose metadata row is younger than this is NEVER
|
|
1286
|
-
* collected, even if currently unreferenced. This is the concurrency guard —
|
|
1287
|
-
* a blob a sync just wrote (and is about to reference) can't be swept out from
|
|
1288
|
-
* under it. Generous default (1h) since a leaked blob is only a cost bug that
|
|
1289
|
-
* the next GC pass reclaims anyway. Default 3,600,000ms (1 hour).
|
|
1290
|
-
*/
|
|
1291
|
-
function blobGcGraceMs() {
|
|
1292
|
-
return posIntEnv("PIEVO_BLOB_GC_GRACE_MS", 3600 * 1e3);
|
|
1293
|
-
}
|
|
1294
|
-
/**
|
|
1295
|
-
* How often the periodic storage-maintenance pass runs (prune snapshots → GC
|
|
1296
|
-
* blobs). Default 15 minutes. Kept independent of the faster offline-sweep tick.
|
|
1297
|
-
*/
|
|
1298
|
-
function gcIntervalMs() {
|
|
1299
|
-
return posIntEnv("PIEVO_GC_INTERVAL_MS", 900 * 1e3);
|
|
1300
|
-
}
|
|
1301
|
-
/**
|
|
1302
|
-
* DB watchdog (`server/dbWatchdog.ts`) — the ACTUAL auto-recovery backstop for a
|
|
1303
|
-
* wedged postgres-js pool. Fly's `[http_service.checks]` failing only pulls the
|
|
1304
|
-
* machine from load balancing; it never restarts the VM (the 2026-07-12 outage:
|
|
1305
|
-
* the pool wedged, `/api/health/db` hung, the check went critical, and the box
|
|
1306
|
-
* stayed down ~9h with no auto-restart). The watchdog turns a HANG into a process
|
|
1307
|
-
* EXIT so Fly's `restart.policy = "on-failure"` kicks in with a fresh pool.
|
|
1308
|
-
*
|
|
1309
|
-
* Enabled by default ONLY on the hosted postgres tier (a wedged transaction-pooler
|
|
1310
|
-
* connection is the failure mode); OFF for the embedded pglite tier and under
|
|
1311
|
-
* vitest (a test must never `process.exit`). Force either way with
|
|
1312
|
-
* `PIEVO_DB_WATCHDOG=on|off`.
|
|
1313
|
-
*/
|
|
1314
|
-
function dbWatchdogEnabled() {
|
|
1315
|
-
const raw = process.env.PIEVO_DB_WATCHDOG?.trim().toLowerCase();
|
|
1316
|
-
if (raw === "on") return true;
|
|
1317
|
-
if (raw === "off") return false;
|
|
1318
|
-
if (process.env.VITEST || false) return false;
|
|
1319
|
-
return Boolean(databaseUrl());
|
|
1320
|
-
}
|
|
1321
|
-
function dbWatchdogIntervalMs() {
|
|
1322
|
-
return posIntEnv("PIEVO_DB_WATCHDOG_INTERVAL_MS", 2e4);
|
|
1323
|
-
}
|
|
1324
|
-
function dbWatchdogTimeoutMs() {
|
|
1325
|
-
return posIntEnv("PIEVO_DB_WATCHDOG_TIMEOUT_MS", 5e3);
|
|
1326
|
-
}
|
|
1327
|
-
/**
|
|
1328
|
-
* Consecutive failed/timed-out pings before the watchdog exits the process. A
|
|
1329
|
-
* healthy ping resets the counter, so this only trips on a SUSTAINED wedge — a
|
|
1330
|
-
* brief blip never restarts the box. Default 3 (≈60-75s to detect at 20s cadence).
|
|
1331
|
-
*/
|
|
1332
|
-
function dbWatchdogFailureThreshold() {
|
|
1333
|
-
return posIntEnv("PIEVO_DB_WATCHDOG_FAILURES", 3);
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Uses pino-pretty as a *synchronous* destination stream (not a worker-thread
|
|
1337
|
-
* transport) so logs flush before process.exit() in the one-shot CLIs. Writes to
|
|
1338
|
-
* stderr (fd 2) to keep stdout clean for QR codes and human CLI output.
|
|
1339
|
-
*/
|
|
1340
|
-
var level = (process.env.PIEVO_LOG_LEVEL || "info").toLowerCase();
|
|
1341
|
-
var stream = (0, import_pino_pretty.default)({
|
|
1342
|
-
destination: 2,
|
|
1343
|
-
colorize: true,
|
|
1344
|
-
translateTime: "SYS:HH:MM:ss",
|
|
1345
|
-
ignore: "pid,hostname"
|
|
1346
|
-
});
|
|
1347
|
-
var logger = (0, import_pino.default)({ level }, stream);
|
|
1348
|
-
/**
|
|
1349
|
-
* Pure pool-option derivation for the hosted postgres-js tier. LEAF module by
|
|
1350
|
-
* design: no env reads, no DB opens — `db/index.ts` supplies the URL (and the
|
|
1351
|
-
* optional `PIEVO_DB_POOL_MODE` override via `env.ts`), tests import this
|
|
1352
|
-
* directly without booting a database.
|
|
1353
|
-
*/
|
|
1354
|
-
/**
|
|
1355
|
-
* Whether `url` points at a pgBouncer/Supavisor TRANSACTION pooler. Detection
|
|
1356
|
-
* is by the conventional port `:6543` (Supabase's transaction-mode port).
|
|
1357
|
-
*
|
|
1358
|
-
* Why it matters: postgres-js pipelines queries and caches prepared statements
|
|
1359
|
-
* per connection, but a transaction pooler reassigns the backend connection per
|
|
1360
|
-
* transaction, which both breaks cached prepared statements and is implicated
|
|
1361
|
-
* in the pool-wedge failure mode (porsager/postgres#970). Session mode (:5432)
|
|
1362
|
-
* pins one backend per client connection, so neither problem exists there.
|
|
1363
|
-
*
|
|
1364
|
-
* LIMITATION: a nonstandard-port or self-hosted transaction pooler will NOT be
|
|
1365
|
-
* detected here — set `PIEVO_DB_POOL_MODE=transaction` (see `env.ts`
|
|
1366
|
-
* `dbPoolMode`) to override. Getting that wrong on a real transaction pooler
|
|
1367
|
-
* re-arms the prepared-statement breakage.
|
|
1368
|
-
*/
|
|
1369
|
-
function isTransactionPooler(url) {
|
|
1370
|
-
try {
|
|
1371
|
-
return new URL(url).port === "6543";
|
|
1372
|
-
} catch {
|
|
1373
|
-
return false;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
/**
|
|
1377
|
-
* Mode-aware pool options:
|
|
1378
|
-
*
|
|
1379
|
-
* - TRANSACTION pooler (:6543): `prepare:false` is mandatory — the backend is
|
|
1380
|
-
* reassigned per transaction, so cached prepared statements break.
|
|
1381
|
-
* - SESSION pooler / direct (:5432): the backend is pinned per connection, so
|
|
1382
|
-
* `prepare:true` is safe AND faster (cached plans), and the session-scoped
|
|
1383
|
-
* `statement_timeout` is actually enforced on the executing backend.
|
|
1384
|
-
*
|
|
1385
|
-
* The shared knobs are the conservative self-healing ring for one always-on
|
|
1386
|
-
* machine (see `db/index.ts` + api.health.db.ts for the incident history).
|
|
1387
|
-
* Pass `transactionPooler` explicitly to override the port-based detection
|
|
1388
|
-
* (the `PIEVO_DB_POOL_MODE` escape hatch).
|
|
1389
|
-
*/
|
|
1390
|
-
function poolOptionsFor(url, transactionPooler = isTransactionPooler(url)) {
|
|
1391
|
-
return {
|
|
1392
|
-
prepare: !transactionPooler,
|
|
1393
|
-
max: 10,
|
|
1394
|
-
idle_timeout: 30,
|
|
1395
|
-
connect_timeout: 15,
|
|
1396
|
-
max_lifetime: 1800,
|
|
1397
|
-
connection: { statement_timeout: 3e4 }
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1400
|
-
/** The coding agent a loop is bound to AND executed with (BYOA on the owner's
|
|
1401
|
-
* machine): `claude-code` → Claude Code, `codex` → `codex exec`, `pi` → Pi.
|
|
1402
|
-
* Non-Claude agents may still have thinner daemon telemetry until stream
|
|
1403
|
-
* integration lands; execution itself is real for every value.
|
|
1404
|
-
*
|
|
1405
|
-
* Runtime SINGLE SOURCE (anti-drift): every server consumer DERIVES from this
|
|
1406
|
-
* array — the `CodingAgent` type here, the `db/schema.ts` `CodingAgent` type AND
|
|
1407
|
-
* the `loops.agent` column enum, the edit validator (`coerceCodingAgent`), and the
|
|
1408
|
-
* web agent `<select>` (LoopForm). So widening the set is a one-line edit HERE with
|
|
1409
|
-
* no other server change (the daemon's own enum in `packages/daemon/src/create.ts`
|
|
1410
|
-
* is a separate package, widened alongside). */
|
|
1411
|
-
var CODING_AGENTS = [
|
|
1412
|
-
"claude-code",
|
|
1413
|
-
"codex",
|
|
1414
|
-
"pi"
|
|
1415
|
-
];
|
|
1416
|
-
function coerceCodingAgent(value) {
|
|
1417
|
-
return typeof value === "string" && CODING_AGENTS.includes(value) ? value : null;
|
|
1418
|
-
}
|
|
1419
|
-
var RESERVED_LOOP_TAGS = [
|
|
1420
|
-
"all loops",
|
|
1421
|
-
"active",
|
|
1422
|
-
"paused",
|
|
1423
|
-
"blocked"
|
|
1424
|
-
];
|
|
1425
|
-
/**
|
|
1426
|
-
* Pievo business schema (Drizzle, Postgres `pg-core` dialect).
|
|
1427
|
-
*
|
|
1428
|
-
* Core machine / loop / run tables live alongside Better Auth ownership data.
|
|
1429
|
-
* `userId` is the owning user's id. We use
|
|
1430
|
-
* Drizzle (not raw SQL) so the store is single-sourced across the driver tiers
|
|
1431
|
-
* (postgres-js on Supabase; embedded pglite for local/self-host + tests) — the
|
|
1432
|
-
* query-builder API is identical, only `db/index.ts` branches the driver.
|
|
1433
|
-
*
|
|
1434
|
-
* Timestamp values are represented as ISO strings across both drivers. JSON
|
|
1435
|
-
* columns use `jsonb().$type<>()`; booleans use native `boolean()`.
|
|
1436
|
-
*/
|
|
1437
|
-
var machines = pgTable("machines", {
|
|
1438
|
-
id: text("id").primaryKey(),
|
|
1439
|
-
userId: text("user_id").notNull(),
|
|
1440
|
-
name: text("name").notNull(),
|
|
1441
|
-
hostname: text("hostname"),
|
|
1442
|
-
platform: text("platform"),
|
|
1443
|
-
arch: text("arch"),
|
|
1444
|
-
daemonVersion: text("daemon_version"),
|
|
1445
|
-
/** Breaking machine protocol last observed on poll. */
|
|
1446
|
-
daemonProtocol: integer("daemon_protocol"),
|
|
1447
|
-
tokenHash: text("token_hash").notNull(),
|
|
1448
|
-
/**
|
|
1449
|
-
* Plaintext device token. Stored so the UI can re-show the connect command
|
|
1450
|
-
* anytime (MVP convenience — deviates from "store only the hash"; acceptable
|
|
1451
|
-
* for a self-hosted control plane where the DB is already the trust root).
|
|
1452
|
-
*/
|
|
1453
|
-
token: text("token"),
|
|
1454
|
-
roots: jsonb("roots").$type(),
|
|
1455
|
-
lastSeen: text("last_seen"),
|
|
1456
|
-
online: boolean("online").notNull().default(false),
|
|
1457
|
-
createdAt: text("created_at").notNull()
|
|
1458
|
-
}, (t) => [index("machines_user_idx").on(t.userId)]);
|
|
1459
|
-
var loops = pgTable("loops", {
|
|
1460
|
-
id: text("id").primaryKey(),
|
|
1461
|
-
userId: text("user_id").notNull(),
|
|
1462
|
-
/** Execution machine (set at creation; no cross-machine fallback). */
|
|
1463
|
-
machineId: text("machine_id").notNull(),
|
|
1464
|
-
name: text("name").notNull(),
|
|
1465
|
-
tags: text("tags").array().notNull().default(sql`ARRAY[]::text[]`),
|
|
1466
|
-
prompt: text("prompt").notNull(),
|
|
1467
|
-
statusKeep: text("status_keep").notNull(),
|
|
1468
|
-
statusNoChange: text("status_no_change").notNull(),
|
|
1469
|
-
statusBlock: text("status_block").notNull(),
|
|
1470
|
-
/** Exact workdir-relative artifact paths collected by the daemon after provider exit. */
|
|
1471
|
-
artifacts: jsonb("artifacts").$type().notNull().default([]),
|
|
1472
|
-
cron: text("cron").notNull(),
|
|
1473
|
-
scheduleMode: text("schedule_mode", { enum: ["cron", "continuous"] }).notNull(),
|
|
1474
|
-
/** Cron-only overlap behavior. Continuous runs never overlap. */
|
|
1475
|
-
cronOverlap: text("cron_overlap", { enum: ["skip", "queue-one"] }).notNull(),
|
|
1476
|
-
continuousDelayMinutes: integer("continuous_delay_minutes").notNull(),
|
|
1477
|
-
timezone: text("timezone"),
|
|
1478
|
-
workdir: text("workdir").notNull(),
|
|
1479
|
-
/** Durable Stop-before-delete marker. A deleting loop is never claimable. */
|
|
1480
|
-
deleteRequestedAt: timestamp("delete_requested_at", {
|
|
1481
|
-
withTimezone: true,
|
|
1482
|
-
mode: "string"
|
|
1483
|
-
}),
|
|
1484
|
-
pauseCause: jsonb("pause_cause").$type(),
|
|
1485
|
-
model: text("model"),
|
|
1486
|
-
reasoningEffort: text("reasoning_effort"),
|
|
1487
|
-
/** Coding agent this loop is BOUND TO and EXECUTED with: the daemon's
|
|
1488
|
-
* `buildAgentSpawn` branches on this value (`claude-code` → claude,
|
|
1489
|
-
* `codex` → `codex exec`, `pi` → Pi print mode). Measured from the creating CLI's env when
|
|
1490
|
-
* detectable, else the declared/selected value. The text column carries
|
|
1491
|
-
* both a TypeScript enum and a database CHECK constraint. */
|
|
1492
|
-
agent: text("agent", { enum: CODING_AGENTS }).notNull(),
|
|
1493
|
-
enabled: boolean("enabled").notNull().default(true),
|
|
1494
|
-
nextRunAt: text("next_run_at"),
|
|
1495
|
-
/** The next recurring cadence occurrence not yet materialized as a run. Cron
|
|
1496
|
-
* advances this fact after each due occurrence; continuous clears it on due
|
|
1497
|
-
* and restores it from a successful/error terminal. */
|
|
1498
|
-
nextCadenceAt: text("next_cadence_at"),
|
|
1499
|
-
lastRunIndex: integer("last_run_index").notNull().default(0),
|
|
1500
|
-
createdAt: text("created_at").notNull(),
|
|
1501
|
-
updatedAt: text("updated_at").notNull()
|
|
1502
|
-
}, (t) => [
|
|
1503
|
-
index("loops_user_idx").on(t.userId),
|
|
1504
|
-
index("loops_machine_idx").on(t.machineId),
|
|
1505
|
-
index("delete_requested_loops").on(t.deleteRequestedAt).where(sql`${t.deleteRequestedAt} IS NOT NULL`),
|
|
1506
|
-
check("loops_agent_check", sql`${t.agent} IN ('claude-code', 'codex', 'pi')`),
|
|
1507
|
-
check("loops_tags_count_check", sql`cardinality(${t.tags}) <= 4`),
|
|
1508
|
-
check("loops_tags_reserved_check", sql`NOT (${t.tags} && ARRAY['all loops', 'active', 'paused', 'blocked']::text[])`),
|
|
1509
|
-
check("loops_schedule_mode_check", sql`${t.scheduleMode} IN ('cron', 'continuous')`),
|
|
1510
|
-
check("loops_cron_overlap_check", sql`${t.cronOverlap} IN ('skip', 'queue-one')`)
|
|
1511
|
-
]);
|
|
1512
|
-
var runs = pgTable("runs", {
|
|
1513
|
-
id: text("id").primaryKey(),
|
|
1514
|
-
loopId: text("loop_id").notNull(),
|
|
1515
|
-
machineId: text("machine_id").notNull(),
|
|
1516
|
-
agent: text("agent", { enum: CODING_AGENTS }),
|
|
1517
|
-
model: text("model"),
|
|
1518
|
-
reasoningEffort: text("reasoning_effort"),
|
|
1519
|
-
runIndex: integer("run_index"),
|
|
1520
|
-
phase: text("phase", { enum: [
|
|
1521
|
-
"pending",
|
|
1522
|
-
"running",
|
|
1523
|
-
"done",
|
|
1524
|
-
"error",
|
|
1525
|
-
"canceled"
|
|
1526
|
-
] }).notNull(),
|
|
1527
|
-
/** Durable queue authority. It only promotes system→owner; diagnostic trigger
|
|
1528
|
-
* reasons, if added later, must never drive lifecycle state. */
|
|
1529
|
-
requestedBy: text("requested_by", { enum: ["owner", "system"] }).notNull().default("system"),
|
|
1530
|
-
ts: text("ts").notNull(),
|
|
1531
|
-
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
|
|
1532
|
-
updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP::text`),
|
|
1533
|
-
status: text("status", { enum: [
|
|
1534
|
-
"keep",
|
|
1535
|
-
"no-change",
|
|
1536
|
-
"block"
|
|
1537
|
-
] }),
|
|
1538
|
-
message: text("message"),
|
|
1539
|
-
durationMs: integer("duration_ms"),
|
|
1540
|
-
exitCode: integer("exit_code"),
|
|
1541
|
-
finalText: text("final_text"),
|
|
1542
|
-
error: text("error"),
|
|
1543
|
-
sessionId: text("session_id"),
|
|
1544
|
-
usage: jsonb("usage").$type(),
|
|
1545
|
-
/** Monotonic cancellation intent. Running becomes canceled only on daemon report. */
|
|
1546
|
-
cancelRequestedAt: timestamp("cancel_requested_at", {
|
|
1547
|
-
withTimezone: true,
|
|
1548
|
-
mode: "string"
|
|
1549
|
-
}),
|
|
1550
|
-
heartbeatAt: text("heartbeat_at"),
|
|
1551
|
-
reportIncident: jsonb("report_incident").$type()
|
|
1552
|
-
}, (t) => [
|
|
1553
|
-
index("runs_loop_idx").on(t.loopId),
|
|
1554
|
-
index("runs_phase_idx").on(t.phase),
|
|
1555
|
-
index("runs_machine_phase_ready_idx").on(t.machineId, t.phase),
|
|
1556
|
-
index("runs_loop_ts_idx").on(t.loopId, t.ts),
|
|
1557
|
-
uniqueIndex("runs_loop_run_index_idx").on(t.loopId, t.runIndex).where(sql`${t.runIndex} IS NOT NULL`),
|
|
1558
|
-
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')`),
|
|
1559
|
-
uniqueIndex("runs_loop_pending_idx").on(t.loopId).where(sql`${t.phase} = 'pending'`),
|
|
1560
|
-
uniqueIndex("one_running_run_per_loop").on(t.loopId).where(sql`${t.phase} = 'running'`),
|
|
1561
|
-
check("runs_agent_check", sql`${t.agent} IS NULL OR ${t.agent} IN ('claude-code', 'codex', 'pi')`),
|
|
1562
|
-
check("runs_phase_check", sql`${t.phase} IN ('pending', 'running', 'done', 'error', 'canceled')`),
|
|
1563
|
-
check("runs_requested_by_check", sql`${t.requestedBy} IN ('owner', 'system')`),
|
|
1564
|
-
check("runs_status_check", sql`${t.status} IS NULL OR ${t.status} IN ('keep', 'no-change', 'block')`)
|
|
1565
|
-
]);
|
|
1566
|
-
var runLeases = pgTable("run_leases", {
|
|
1567
|
-
tokenHash: text("token_hash").primaryKey(),
|
|
1568
|
-
runId: text("run_id").notNull(),
|
|
1569
|
-
loopId: text("loop_id").notNull(),
|
|
1570
|
-
machineId: text("machine_id").notNull(),
|
|
1571
|
-
state: text("state", { enum: [
|
|
1572
|
-
"active",
|
|
1573
|
-
"terminal-grace",
|
|
1574
|
-
"reconciliation-only",
|
|
1575
|
-
"retired"
|
|
1576
|
-
] }).notNull().default("active"),
|
|
1577
|
-
/** Null while active/retired; ISO only during terminal-grace. */
|
|
1578
|
-
expiresAt: text("expires_at"),
|
|
1579
|
-
createdAt: text("created_at").notNull()
|
|
1580
|
-
}, (t) => [
|
|
1581
|
-
index("run_leases_run_idx").on(t.runId),
|
|
1582
|
-
index("run_leases_loop_idx").on(t.loopId),
|
|
1583
|
-
check("run_leases_state_check", sql`${t.state} IN ('active', 'terminal-grace', 'reconciliation-only', 'retired')`)
|
|
1584
|
-
]);
|
|
1585
|
-
var runReportReceipts = pgTable("run_report_receipts", {
|
|
1586
|
-
reportId: text("report_id").primaryKey(),
|
|
1587
|
-
runId: text("run_id").notNull(),
|
|
1588
|
-
payloadDigest: text("payload_digest").notNull(),
|
|
1589
|
-
ackStatus: integer("ack_status").notNull(),
|
|
1590
|
-
ackBody: jsonb("ack_body").$type().notNull(),
|
|
1591
|
-
createdAt: timestamp("created_at", {
|
|
1592
|
-
withTimezone: true,
|
|
1593
|
-
mode: "string"
|
|
1594
|
-
}).notNull()
|
|
1595
|
-
}, (t) => [index("report_receipts_created").on(t.createdAt)]);
|
|
1596
|
-
/** Durable evidence for a rejected terminal attempt. It is keyed by
|
|
1597
|
-
* sha256(reportId + canonical-payload-digest), is never loop-owned, and therefore
|
|
1598
|
-
* survives loop deletion just like normal report receipts. */
|
|
1599
|
-
var terminalReportIncidents = pgTable("terminal_report_incidents", {
|
|
1600
|
-
id: text("id").primaryKey(),
|
|
1601
|
-
runId: text("run_id").notNull(),
|
|
1602
|
-
reportId: text("report_id").notNull(),
|
|
1603
|
-
payloadDigest: text("payload_digest").notNull(),
|
|
1604
|
-
disposition: text("disposition", { enum: ["run-error", "telemetry-rejected"] }).$type().notNull(),
|
|
1605
|
-
ackBody: jsonb("ack_body").$type().notNull(),
|
|
1606
|
-
createdAt: timestamp("created_at", {
|
|
1607
|
-
withTimezone: true,
|
|
1608
|
-
mode: "string"
|
|
1609
|
-
}).notNull()
|
|
1610
|
-
}, (t) => [
|
|
1611
|
-
index("terminal_report_incidents_report_id").on(t.reportId),
|
|
1612
|
-
index("terminal_report_incidents_created").on(t.createdAt),
|
|
1613
|
-
check("terminal_report_incidents_disposition_check", sql`${t.disposition} IN ('run-error', 'telemetry-rejected')`)
|
|
1614
|
-
]);
|
|
1615
|
-
var connectKeys = pgTable("connect_keys", {
|
|
1616
|
-
machineId: text("machine_id").primaryKey(),
|
|
1617
|
-
userId: text("user_id").notNull(),
|
|
1618
|
-
/** Mint time (ISO) — drives the TTL. */
|
|
1619
|
-
mintedAt: text("minted_at").notNull()
|
|
1620
|
-
});
|
|
1621
|
-
/**
|
|
1622
|
-
* One content-addressed blob (deduped across every loop/run). The bytes live in
|
|
1623
|
-
* the byte store under the hash; this row records verified byte presence and size.
|
|
1624
|
-
*/
|
|
1625
|
-
var blobs = pgTable("blobs", {
|
|
1626
|
-
hash: text("hash").primaryKey(),
|
|
1627
|
-
size: integer("size").notNull(),
|
|
1628
|
-
createdAt: text("created_at").notNull()
|
|
1629
|
-
});
|
|
1630
|
-
/**
|
|
1631
|
-
* The current configured file set of each loop — one row per live or tombstoned
|
|
1632
|
-
* relative path. `hash` → `blobs.hash`; null when the file is deleted or oversize
|
|
1633
|
-
* (metadata-only, no bytes synced). The
|
|
1634
|
-
* unique (loopId, path) index is the upsert key the sync reconciliation drives.
|
|
1635
|
-
*/
|
|
1636
|
-
var artifactFiles = pgTable("artifact_files", {
|
|
1637
|
-
id: text("id").primaryKey(),
|
|
1638
|
-
loopId: text("loop_id").notNull(),
|
|
1639
|
-
/** Normalized workdir-relative path (never absolute or escaping the workdir). */
|
|
1640
|
-
path: text("path").notNull(),
|
|
1641
|
-
hash: text("hash"),
|
|
1642
|
-
size: integer("size"),
|
|
1643
|
-
/** Bytes contain a NUL (set even for oversize metadata-only files). */
|
|
1644
|
-
binary: boolean("binary").notNull().default(false),
|
|
1645
|
-
/** File exceeds the per-file byte cap → metadata-only (path + size), no blob. */
|
|
1646
|
-
oversize: boolean("oversize").notNull().default(false),
|
|
1647
|
-
deleted: boolean("deleted").notNull().default(false),
|
|
1648
|
-
updatedAt: text("updated_at").notNull()
|
|
1649
|
-
}, (t) => [
|
|
1650
|
-
index("artifact_files_loop_idx").on(t.loopId),
|
|
1651
|
-
uniqueIndex("artifact_files_loop_path_idx").on(t.loopId, t.path),
|
|
1652
|
-
index("artifact_files_hash_idx").on(t.hash)
|
|
1653
|
-
]);
|
|
1654
|
-
/**
|
|
1655
|
-
* The loop's full artifact manifest captured at each run's finalize — the input
|
|
1656
|
-
* to the per-run diff. Written cheaply on report (no diff computed on write);
|
|
1657
|
-
* `getRunDiff` lazily compares run N's snapshot with the preceding snapshot.
|
|
1658
|
-
* One row per run (runId primary key).
|
|
1659
|
-
*/
|
|
1660
|
-
var runSnapshots = pgTable("run_snapshots", {
|
|
1661
|
-
runId: text("run_id").primaryKey(),
|
|
1662
|
-
loopId: text("loop_id").notNull(),
|
|
1663
|
-
manifest: jsonb("manifest").$type().notNull(),
|
|
1664
|
-
createdAt: text("created_at").notNull()
|
|
1665
|
-
}, (t) => [index("run_snapshots_loop_idx").on(t.loopId)]);
|
|
1666
|
-
var user = pgTable("user", {
|
|
1667
|
-
id: text("id").primaryKey(),
|
|
1668
|
-
name: text("name").notNull(),
|
|
1669
|
-
email: text("email").notNull().unique(),
|
|
1670
|
-
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
1671
|
-
image: text("image"),
|
|
1672
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
1673
|
-
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => /* @__PURE__ */ new Date()).notNull()
|
|
1674
|
-
});
|
|
1675
|
-
var session = pgTable("session", {
|
|
1676
|
-
id: text("id").primaryKey(),
|
|
1677
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
1678
|
-
token: text("token").notNull().unique(),
|
|
1679
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
1680
|
-
updatedAt: timestamp("updated_at").$onUpdate(() => /* @__PURE__ */ new Date()).notNull(),
|
|
1681
|
-
ipAddress: text("ip_address"),
|
|
1682
|
-
userAgent: text("user_agent"),
|
|
1683
|
-
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" })
|
|
1684
|
-
}, (table) => [index("session_userId_idx").on(table.userId)]);
|
|
1685
|
-
var account = pgTable("account", {
|
|
1686
|
-
id: text("id").primaryKey(),
|
|
1687
|
-
accountId: text("account_id").notNull(),
|
|
1688
|
-
providerId: text("provider_id").notNull(),
|
|
1689
|
-
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
|
1690
|
-
accessToken: text("access_token"),
|
|
1691
|
-
refreshToken: text("refresh_token"),
|
|
1692
|
-
idToken: text("id_token"),
|
|
1693
|
-
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
1694
|
-
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
1695
|
-
scope: text("scope"),
|
|
1696
|
-
password: text("password"),
|
|
1697
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
1698
|
-
updatedAt: timestamp("updated_at").$onUpdate(() => /* @__PURE__ */ new Date()).notNull()
|
|
1699
|
-
}, (table) => [index("account_userId_idx").on(table.userId)]);
|
|
1700
|
-
var verification = pgTable("verification", {
|
|
1701
|
-
id: text("id").primaryKey(),
|
|
1702
|
-
identifier: text("identifier").notNull(),
|
|
1703
|
-
value: text("value").notNull(),
|
|
1704
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
1705
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
1706
|
-
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => /* @__PURE__ */ new Date()).notNull()
|
|
1707
|
-
}, (table) => [index("verification_identifier_idx").on(table.identifier)]);
|
|
1708
|
-
relations(user, ({ many }) => ({
|
|
1709
|
-
sessions: many(session),
|
|
1710
|
-
accounts: many(account)
|
|
1711
|
-
}));
|
|
1712
|
-
relations(session, ({ one }) => ({ user: one(user, {
|
|
1713
|
-
fields: [session.userId],
|
|
1714
|
-
references: [user.id]
|
|
1715
|
-
}) }));
|
|
1716
|
-
relations(account, ({ one }) => ({ user: one(user, {
|
|
1717
|
-
fields: [account.userId],
|
|
1718
|
-
references: [user.id]
|
|
1719
|
-
}) }));
|
|
1720
|
-
/**
|
|
1721
|
-
* Postgres connection + Drizzle handle. The driver is chosen at boot from env
|
|
1722
|
-
* from environment configuration:
|
|
1723
|
-
*
|
|
1724
|
-
* - `DATABASE_URL` SET → postgres-js against Supabase (hosted prod/staging).
|
|
1725
|
-
* Pool options are mode-aware (`db/poolOptions.ts`):
|
|
1726
|
-
* `prepare:false` on the `:6543` transaction pooler,
|
|
1727
|
-
* `prepare:true` on a `:5432` session/direct URL.
|
|
1728
|
-
* - `DATABASE_URL` UNSET → embedded pglite, file-backed at `<dataDir>/pgdata`
|
|
1729
|
-
* (local dev + light self-host + tests). Same pg
|
|
1730
|
-
* dialect, same migrations, zero external database.
|
|
1731
|
-
*
|
|
1732
|
-
* `store.ts` is single-sourced across both — the drizzle query-builder API is
|
|
1733
|
-
* identical; only this file branches. A `globalThis` guard keeps the handle a
|
|
1734
|
-
* true singleton across dev HMR reloads (the Scheduler shares it in-process).
|
|
1735
|
-
*
|
|
1736
|
-
* The fresh-deploy baseline lives in `packages/server/drizzle`. In
|
|
1737
|
-
* the HOSTED tier they're applied out-of-process via `pnpm db:migrate` before
|
|
1738
|
-
* `start` (over the DIRECT `:5432` URL); `runMigrations()` also applies them
|
|
1739
|
-
* in-process (the pglite tier's only path, plus dev boot + tests).
|
|
1740
|
-
*/
|
|
1741
|
-
var schema = {
|
|
1742
|
-
machines,
|
|
1743
|
-
loops,
|
|
1744
|
-
runs,
|
|
1745
|
-
blobs,
|
|
1746
|
-
artifactFiles,
|
|
1747
|
-
runSnapshots,
|
|
1748
|
-
runLeases,
|
|
1749
|
-
runReportReceipts,
|
|
1750
|
-
terminalReportIncidents,
|
|
1751
|
-
connectKeys,
|
|
1752
|
-
user,
|
|
1753
|
-
session,
|
|
1754
|
-
account,
|
|
1755
|
-
verification
|
|
1756
|
-
};
|
|
1757
|
-
var g = globalThis;
|
|
1758
|
-
/** Host (host:port, NEVER credentials) of a Postgres URL, for boot logging. */
|
|
1759
|
-
function pgHost(url) {
|
|
1760
|
-
try {
|
|
1761
|
-
return new URL(url).host || "unknown";
|
|
1762
|
-
} catch {
|
|
1763
|
-
return "unparseable";
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
function open() {
|
|
1767
|
-
if (g.__pievoDb && g.__pievoClient && g.__pievoDriver) return {
|
|
1768
|
-
db: g.__pievoDb,
|
|
1769
|
-
client: g.__pievoClient,
|
|
1770
|
-
driver: g.__pievoDriver
|
|
1771
|
-
};
|
|
1772
|
-
const url = databaseUrl();
|
|
1773
|
-
if (url) {
|
|
1774
|
-
logger.info({
|
|
1775
|
-
tier: "postgres",
|
|
1776
|
-
host: pgHost(url)
|
|
1777
|
-
}, "database tier: postgres");
|
|
1778
|
-
if (!/sslmode=/i.test(url)) logger.warn({ host: pgHost(url) }, "DATABASE_URL has no sslmode — the connection may be plaintext; append ?sslmode=require");
|
|
1779
|
-
const mode = dbPoolMode();
|
|
1780
|
-
const client = src_default(url, poolOptionsFor(url, mode ? mode === "transaction" : isTransactionPooler(url)));
|
|
1781
|
-
const db = drizzle(client, { schema });
|
|
1782
|
-
g.__pievoClient = client;
|
|
1783
|
-
g.__pievoDb = db;
|
|
1784
|
-
g.__pievoDriver = "postgres";
|
|
1785
|
-
return {
|
|
1786
|
-
db,
|
|
1787
|
-
client,
|
|
1788
|
-
driver: "postgres"
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
if (process.env.PIEVO_DB !== "pglite") throw new Error("refusing to boot the ephemeral embedded database in production — set DATABASE_URL, or PIEVO_DB=pglite to opt in");
|
|
1792
|
-
const dir = path.join(dataDir(), "pgdata");
|
|
1793
|
-
logger.info({
|
|
1794
|
-
tier: "pglite",
|
|
1795
|
-
pgdata: dir
|
|
1796
|
-
}, "database tier: pglite");
|
|
1797
|
-
fs.mkdirSync(dataDir(), { recursive: true });
|
|
1798
|
-
const client = new Ve(dir);
|
|
1799
|
-
const db = drizzle$1(client, { schema });
|
|
1800
|
-
g.__pievoClient = client;
|
|
1801
|
-
g.__pievoDb = db;
|
|
1802
|
-
g.__pievoDriver = "pglite";
|
|
1803
|
-
return {
|
|
1804
|
-
db,
|
|
1805
|
-
client,
|
|
1806
|
-
driver: "pglite"
|
|
1807
|
-
};
|
|
1808
|
-
}
|
|
1809
|
-
var opened = open();
|
|
1810
|
-
var db = opened.db;
|
|
1811
|
-
opened.client;
|
|
1812
|
-
/**
|
|
1813
|
-
* Migrations dir. In DEV this file lives at `src/db`, so the folder is at the
|
|
1814
|
-
* package root (`../../drizzle`). In the BUILT server the module is bundled under
|
|
1815
|
-
* `.output/server/{_ssr,_libs}` and `copy-pglite-assets.mjs` lands the folder at
|
|
1816
|
-
* `.output/server/drizzle` (`../drizzle`). Return the first candidate that exists
|
|
1817
|
-
* so the embedded pglite tier migrates in-process from either layout.
|
|
1818
|
-
*/
|
|
1819
|
-
function migrationsDir() {
|
|
1820
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1821
|
-
const devDir = path.resolve(here, "..", "..", "drizzle");
|
|
1822
|
-
const builtDir = path.resolve(here, "..", "drizzle");
|
|
1823
|
-
if (fs.existsSync(devDir)) return devDir;
|
|
1824
|
-
if (fs.existsSync(builtDir)) return builtDir;
|
|
1825
|
-
return devDir;
|
|
1826
|
-
}
|
|
1827
|
-
/**
|
|
1828
|
-
* Apply generated migrations IN-PROCESS (idempotent). This is the PGLITE tier's
|
|
1829
|
-
* only migration path (also dev boot + tests). The HOSTED postgres tier is a
|
|
1830
|
-
* NO-OP here: `prestart.mjs` (and `pnpm db:migrate`) own hosted migrations, run
|
|
1831
|
-
* once out-of-process over the DIRECT (:5432) URL before serving — re-migrating
|
|
1832
|
-
* over a second connection on every boot only risks a wedged connection hanging
|
|
1833
|
-
* boot and double-running the advisory lock.
|
|
1834
|
-
*/
|
|
1835
|
-
async function runMigrations() {
|
|
1836
|
-
if (opened.driver === "postgres") return;
|
|
1837
|
-
const dir = migrationsDir();
|
|
1838
|
-
if (!fs.existsSync(dir)) return;
|
|
1839
|
-
const { migrate } = await import("../_libs/drizzle-orm+postgres.mjs").then((n) => n.M);
|
|
1840
|
-
await migrate(opened.db, { migrationsFolder: dir });
|
|
1841
|
-
}
|
|
1842
|
-
/**
|
|
1843
|
-
* Best-effort clean shutdown of the runtime database client (called from the
|
|
1844
|
-
* boot AbortController on SIGINT/SIGTERM). PGlite must be closed explicitly:
|
|
1845
|
-
* after migrations it owns a referenced WASM timer that otherwise keeps Node
|
|
1846
|
-
* alive until the managed launcher's graceful-stop deadline. Never throws —
|
|
1847
|
-
* shutdown must not wedge on a slow drain.
|
|
1848
|
-
*/
|
|
1849
|
-
async function closeClient() {
|
|
1850
|
-
try {
|
|
1851
|
-
if (opened.driver === "postgres") await opened.client.end({ timeout: 5 });
|
|
1852
|
-
else await opened.client.close();
|
|
1853
|
-
} catch {}
|
|
1854
|
-
}
|
|
1855
|
-
/**
|
|
1856
|
-
* The GitHub login-gate condition, in one leaf module. `auth.ts` derives its
|
|
1857
|
-
* load-time `authEnabled` value from this, and server boot calls it before any
|
|
1858
|
-
* migration or scheduler work.
|
|
1859
|
-
*
|
|
1860
|
-
* The gate is ON exactly when a complete GitHub OAuth app is configured. Open
|
|
1861
|
-
* mode requires both values to be absent; a partial configuration fails closed.
|
|
1862
|
-
*/
|
|
1863
|
-
function loginGateEnabled() {
|
|
1864
|
-
const hasClientId = !!process.env.GITHUB_CLIENT_ID?.trim();
|
|
1865
|
-
if (hasClientId !== !!process.env.GITHUB_CLIENT_SECRET?.trim()) throw new Error("incomplete GitHub auth configuration: set both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, or unset both for open mode");
|
|
1866
|
-
return hasClientId;
|
|
1867
|
-
}
|
|
1868
|
-
/**
|
|
1869
|
-
* Data-access layer over Drizzle — replaces c0's file-per-loop store. The API is
|
|
1870
|
-
* function-style; persistence is relational (loops and runs are separate tables,
|
|
1871
|
-
* and `owner: PeerRef` is gone → `userId` + `machineId`).
|
|
1872
|
-
*
|
|
1873
|
-
* Under Postgres (postgres-js / pglite) the drizzle session is ASYNC, so every
|
|
1874
|
-
* DB-touching function returns a Promise. The 5 pure, DB-free helpers stay sync.
|
|
1875
|
-
* Using Drizzle (not raw SQL) keeps the dialect swap a swap, not a rewrite.
|
|
1876
|
-
*/
|
|
1877
|
-
function newLoopId() {
|
|
1878
|
-
return `loop-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
1879
|
-
}
|
|
1880
|
-
function nowIso() {
|
|
1881
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1882
|
-
}
|
|
1883
|
-
function nextCronAt(cron, timezone, after) {
|
|
1884
|
-
if (!timezone) throw new Error("invariant: cron schedule has no timezone");
|
|
1885
|
-
const probe = new E(cron, {
|
|
1886
|
-
paused: true,
|
|
1887
|
-
timezone
|
|
1888
|
-
});
|
|
1889
|
-
try {
|
|
1890
|
-
const next = probe.nextRun(new Date(after));
|
|
1891
|
-
if (!next) throw new Error(`cron expression never fires again: ${cron}`);
|
|
1892
|
-
return next.toISOString();
|
|
1893
|
-
} finally {
|
|
1894
|
-
probe.stop();
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
function runTimes(at = nowIso()) {
|
|
1898
|
-
return {
|
|
1899
|
-
ts: at,
|
|
1900
|
-
createdAt: at,
|
|
1901
|
-
updatedAt: at
|
|
1902
|
-
};
|
|
1903
|
-
}
|
|
1904
|
-
async function listLoops() {
|
|
1905
|
-
return db.select().from(loops);
|
|
1906
|
-
}
|
|
1907
|
-
async function listLoopsForUser(userId) {
|
|
1908
|
-
return db.select().from(loops).where(eq(loops.userId, userId));
|
|
1909
|
-
}
|
|
1910
|
-
async function getLoop(id) {
|
|
1911
|
-
return (await db.select().from(loops).where(eq(loops.id, id)))[0];
|
|
1912
|
-
}
|
|
1913
|
-
async function loopsForMachine(machineId) {
|
|
1914
|
-
return db.select().from(loops).where(eq(loops.machineId, machineId));
|
|
1915
|
-
}
|
|
1916
|
-
async function createLoop(input) {
|
|
1917
|
-
const ts = nowIso();
|
|
1918
|
-
const enabled = input.enabled ?? true;
|
|
1919
|
-
if (input.scheduleMode === "cron" && !input.timezone) throw new Error("invariant: cron loop creation requires a timezone");
|
|
1920
|
-
if (input.scheduleMode === "continuous" && input.timezone !== null) throw new Error("invariant: continuous loop creation requires a null timezone");
|
|
1921
|
-
const nextCadenceAt = !enabled ? null : input.scheduleMode === "continuous" ? ts : nextCronAt(input.cron, input.timezone, ts);
|
|
1922
|
-
const row = {
|
|
1923
|
-
...input,
|
|
1924
|
-
enabled,
|
|
1925
|
-
pauseCause: input.pauseCause ?? (!enabled ? {
|
|
1926
|
-
kind: "owner",
|
|
1927
|
-
at: ts
|
|
1928
|
-
} : null),
|
|
1929
|
-
nextRunAt: enabled ? input.nextRunAt ?? null : null,
|
|
1930
|
-
nextCadenceAt,
|
|
1931
|
-
id: input.id ?? newLoopId(),
|
|
1932
|
-
createdAt: ts,
|
|
1933
|
-
updatedAt: ts
|
|
1934
|
-
};
|
|
1935
|
-
return db.transaction(async (tx) => {
|
|
1936
|
-
const machine = (await tx.select({
|
|
1937
|
-
id: machines.id,
|
|
1938
|
-
userId: machines.userId
|
|
1939
|
-
}).from(machines).where(eq(machines.id, input.machineId)).for("key share"))[0];
|
|
1940
|
-
if (!machine) throw new Error(`machine ${input.machineId} does not exist`);
|
|
1941
|
-
if (machine.userId !== input.userId) throw new Error(`machine ${input.machineId} is owned by a different user`);
|
|
1942
|
-
return (await tx.insert(loops).values(row).returning())[0];
|
|
1943
|
-
});
|
|
1944
|
-
}
|
|
1945
|
-
/** Allocate the next 1-based history number. Every caller already holds the
|
|
1946
|
-
* owning loop row lock, so the counter and run transition share one commit. */
|
|
1947
|
-
async function allocateRunIndexTx(tx, loopId) {
|
|
1948
|
-
const updated = (await tx.update(loops).set({ lastRunIndex: sql`${loops.lastRunIndex} + 1` }).where(eq(loops.id, loopId)).returning({ runIndex: loops.lastRunIndex }))[0];
|
|
1949
|
-
if (!updated) throw new Error(`cannot allocate history index for missing loop ${loopId}`);
|
|
1950
|
-
return updated.runIndex;
|
|
1951
|
-
}
|
|
1952
|
-
async function ensureRunIndexTx(tx, run) {
|
|
1953
|
-
return run.runIndex ?? allocateRunIndexTx(tx, run.loopId);
|
|
1954
|
-
}
|
|
1955
|
-
async function cancelPendingTx(tx, where, message, at) {
|
|
1956
|
-
const pending = await tx.select({
|
|
1957
|
-
id: runs.id,
|
|
1958
|
-
loopId: runs.loopId,
|
|
1959
|
-
runIndex: runs.runIndex
|
|
1960
|
-
}).from(runs).where(where).orderBy(asc(runs.createdAt), asc(runs.id)).for("update");
|
|
1961
|
-
for (const run of pending) {
|
|
1962
|
-
const runIndex = await ensureRunIndexTx(tx, run);
|
|
1963
|
-
await tx.update(runs).set({
|
|
1964
|
-
phase: "canceled",
|
|
1965
|
-
runIndex,
|
|
1966
|
-
message,
|
|
1967
|
-
ts: at,
|
|
1968
|
-
updatedAt: at
|
|
1969
|
-
}).where(and(eq(runs.id, run.id), eq(runs.phase, "pending")));
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
/** Apply one loop patch while its row lock is held. This is shared by owner edits
|
|
1973
|
-
* and run-authorized mutations, so cadence semantics cannot drift. */
|
|
1974
|
-
async function updateLoopTx(tx, current, patch, at) {
|
|
1975
|
-
const extra = {};
|
|
1976
|
-
if (patch.enabled === true) extra.pauseCause = null;
|
|
1977
|
-
else if (patch.enabled === false && current.enabled && patch.pauseCause === void 0) extra.pauseCause = {
|
|
1978
|
-
kind: "owner",
|
|
1979
|
-
at
|
|
1980
|
-
};
|
|
1981
|
-
const effective = {
|
|
1982
|
-
...current,
|
|
1983
|
-
...patch,
|
|
1984
|
-
...extra
|
|
1985
|
-
};
|
|
1986
|
-
if (current.deleteRequestedAt) extra.enabled = false;
|
|
1987
|
-
const pausing = !effective.enabled || current.deleteRequestedAt != null;
|
|
1988
|
-
const activating = effective.enabled && (patch.enabled === true && !current.enabled || patch.scheduleMode !== void 0 && patch.scheduleMode !== current.scheduleMode);
|
|
1989
|
-
if (pausing) {
|
|
1990
|
-
extra.nextCadenceAt = null;
|
|
1991
|
-
extra.nextRunAt = null;
|
|
1992
|
-
} else if (activating) if (effective.scheduleMode === "cron") extra.nextCadenceAt = nextCronAt(effective.cron, effective.timezone, at);
|
|
1993
|
-
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;
|
|
1994
|
-
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);
|
|
1995
|
-
else if (effective.enabled && effective.scheduleMode === "continuous" && current.nextCadenceAt && Date.parse(current.nextCadenceAt) > Date.parse(at) && patch.continuousDelayMinutes !== void 0 && patch.continuousDelayMinutes !== current.continuousDelayMinutes) {
|
|
1996
|
-
const terminalAt = Date.parse(current.nextCadenceAt) - current.continuousDelayMinutes * 6e4;
|
|
1997
|
-
extra.nextCadenceAt = new Date(terminalAt + effective.continuousDelayMinutes * 6e4).toISOString();
|
|
1998
|
-
}
|
|
1999
|
-
const loop = (await tx.update(loops).set({
|
|
2000
|
-
...patch,
|
|
2001
|
-
...extra,
|
|
2002
|
-
updatedAt: at
|
|
2003
|
-
}).where(eq(loops.id, current.id)).returning())[0];
|
|
2004
|
-
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);
|
|
2005
|
-
if (patch.artifacts !== void 0) {
|
|
2006
|
-
const allowed = new Set(patch.artifacts ?? []);
|
|
2007
|
-
const removed = (await tx.select({
|
|
2008
|
-
id: artifactFiles.id,
|
|
2009
|
-
path: artifactFiles.path
|
|
2010
|
-
}).from(artifactFiles).where(and(eq(artifactFiles.loopId, current.id), eq(artifactFiles.deleted, false)))).filter((file) => !allowed.has(file.path)).map((file) => file.id);
|
|
2011
|
-
if (removed.length) await tx.update(artifactFiles).set({
|
|
2012
|
-
hash: null,
|
|
2013
|
-
deleted: true,
|
|
2014
|
-
updatedAt: at
|
|
2015
|
-
}).where(inArray(artifactFiles.id, removed));
|
|
2016
|
-
}
|
|
2017
|
-
return loop;
|
|
2018
|
-
}
|
|
2019
|
-
async function updateLoop(id, patch) {
|
|
2020
|
-
return db.transaction(async (tx) => {
|
|
2021
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2022
|
-
return current ? updateLoopTx(tx, current, patch, nowIso()) : void 0;
|
|
2023
|
-
});
|
|
2024
|
-
}
|
|
2025
|
-
/** Delete loop-owned history/content without erasing durable late-report evidence.
|
|
2026
|
-
* A retired lease is non-authorizing and never blocks deletion; only the matching
|
|
2027
|
-
* report→410 receipt transaction may consume it. */
|
|
2028
|
-
async function deleteLoopDataTx(tx, id) {
|
|
2029
|
-
if (!(await tx.delete(loops).where(eq(loops.id, id)).returning({ id: loops.id })).length) return false;
|
|
2030
|
-
await tx.delete(runs).where(eq(runs.loopId, id));
|
|
2031
|
-
await tx.delete(runLeases).where(and(eq(runLeases.loopId, id), ne(runLeases.state, "retired")));
|
|
2032
|
-
await tx.delete(artifactFiles).where(eq(artifactFiles.loopId, id));
|
|
2033
|
-
await tx.delete(runSnapshots).where(eq(runSnapshots.loopId, id));
|
|
2034
|
-
return true;
|
|
2035
|
-
}
|
|
2036
|
-
/** Stable Pause plus the post-transition running fact, observed under the same loop
|
|
2037
|
-
* lock. Callers use this result for truthful wording without a pre-lock race. */
|
|
2038
|
-
async function pauseLoopState(id) {
|
|
2039
|
-
return db.transaction(async (tx) => {
|
|
2040
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2041
|
-
if (!current) return void 0;
|
|
2042
|
-
const loop = await updateLoopTx(tx, current, { enabled: false }, nowIso());
|
|
2043
|
-
const running = (await tx.select().from(runs).where(and(eq(runs.loopId, id), eq(runs.phase, "running"))).limit(1))[0];
|
|
2044
|
-
return {
|
|
2045
|
-
loop,
|
|
2046
|
-
...running ? { running } : {}
|
|
2047
|
-
};
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
async function pauseLoop(id) {
|
|
2051
|
-
return (await pauseLoopState(id))?.loop;
|
|
2052
|
-
}
|
|
2053
|
-
async function startLoop(id) {
|
|
2054
|
-
return db.transaction(async (tx) => {
|
|
2055
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2056
|
-
if (!current || current.deleteRequestedAt) return void 0;
|
|
2057
|
-
return updateLoopTx(tx, current, { enabled: true }, nowIso());
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
async function stopLoopTx(tx, current, at) {
|
|
2061
|
-
const loop = (await tx.update(loops).set({
|
|
2062
|
-
enabled: false,
|
|
2063
|
-
pauseCause: {
|
|
2064
|
-
kind: "owner",
|
|
2065
|
-
at
|
|
2066
|
-
},
|
|
2067
|
-
nextCadenceAt: null,
|
|
2068
|
-
nextRunAt: null,
|
|
2069
|
-
updatedAt: at
|
|
2070
|
-
}).where(eq(loops.id, current.id)).returning())[0];
|
|
2071
|
-
await cancelPendingTx(tx, and(eq(runs.loopId, current.id), eq(runs.phase, "pending")), "canceled - loop stopped before this queued run was claimed", at);
|
|
2072
|
-
const running = (await tx.select().from(runs).where(and(eq(runs.loopId, current.id), eq(runs.phase, "running"))).limit(1).for("update"))[0];
|
|
2073
|
-
if (!running) return { loop };
|
|
2074
|
-
return {
|
|
2075
|
-
loop,
|
|
2076
|
-
running: running.cancelRequestedAt ? running : (await tx.update(runs).set({
|
|
2077
|
-
cancelRequestedAt: at,
|
|
2078
|
-
updatedAt: at
|
|
2079
|
-
}).where(and(eq(runs.id, running.id), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? running
|
|
2080
|
-
};
|
|
2081
|
-
}
|
|
2082
|
-
async function stopLoop(id) {
|
|
2083
|
-
return db.transaction(async (tx) => {
|
|
2084
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2085
|
-
return current ? stopLoopTx(tx, current, nowIso()) : void 0;
|
|
2086
|
-
});
|
|
2087
|
-
}
|
|
2088
|
-
async function requestRunCancel(loopId, runId) {
|
|
2089
|
-
return db.transaction(async (tx) => {
|
|
2090
|
-
if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, loopId)).for("update"))[0]) return void 0;
|
|
2091
|
-
const run = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId))).limit(1).for("update"))[0];
|
|
2092
|
-
if (!run) return void 0;
|
|
2093
|
-
if (run.phase === "pending") {
|
|
2094
|
-
const at = nowIso();
|
|
2095
|
-
const runIndex = await ensureRunIndexTx(tx, run);
|
|
2096
|
-
return (await tx.update(runs).set({
|
|
2097
|
-
phase: "canceled",
|
|
2098
|
-
runIndex,
|
|
2099
|
-
error: "stopped by user",
|
|
2100
|
-
ts: at,
|
|
2101
|
-
updatedAt: at
|
|
2102
|
-
}).where(and(eq(runs.id, runId), eq(runs.phase, "pending"))).returning())[0] ?? run;
|
|
2103
|
-
}
|
|
2104
|
-
if (run.phase === "running" && !run.cancelRequestedAt) {
|
|
2105
|
-
const at = nowIso();
|
|
2106
|
-
return (await tx.update(runs).set({
|
|
2107
|
-
cancelRequestedAt: at,
|
|
2108
|
-
updatedAt: at
|
|
2109
|
-
}).where(and(eq(runs.id, runId), eq(runs.phase, "running"), isNull(runs.cancelRequestedAt))).returning())[0] ?? run;
|
|
2110
|
-
}
|
|
2111
|
-
return run;
|
|
2112
|
-
});
|
|
2113
|
-
}
|
|
2114
|
-
async function requestDeleteLoop(id) {
|
|
2115
|
-
return db.transaction(async (tx) => {
|
|
2116
|
-
let current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2117
|
-
if (!current) return void 0;
|
|
2118
|
-
const at = current.deleteRequestedAt ?? nowIso();
|
|
2119
|
-
if (!current.deleteRequestedAt) current = (await tx.update(loops).set({
|
|
2120
|
-
deleteRequestedAt: at,
|
|
2121
|
-
updatedAt: at
|
|
2122
|
-
}).where(eq(loops.id, id)).returning())[0];
|
|
2123
|
-
return stopLoopTx(tx, current, at);
|
|
2124
|
-
});
|
|
2125
|
-
}
|
|
2126
|
-
async function tryDeleteLoop(id) {
|
|
2127
|
-
return db.transaction(async (tx) => {
|
|
2128
|
-
if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]?.deleteRequestedAt) return false;
|
|
2129
|
-
const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
|
|
2130
|
-
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];
|
|
2131
|
-
if (open || authority) return false;
|
|
2132
|
-
await tx.update(runLeases).set({
|
|
2133
|
-
state: "retired",
|
|
2134
|
-
expiresAt: null
|
|
2135
|
-
}).where(and(eq(runLeases.loopId, id), eq(runLeases.state, "reconciliation-only")));
|
|
2136
|
-
return deleteLoopDataTx(tx, id);
|
|
2137
|
-
});
|
|
2138
|
-
}
|
|
2139
|
-
async function forceDeleteLoopTx(tx, id) {
|
|
2140
|
-
if (!(await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0]) return false;
|
|
2141
|
-
await tx.update(runLeases).set({
|
|
2142
|
-
state: "retired",
|
|
2143
|
-
expiresAt: null
|
|
2144
|
-
}).where(and(eq(runLeases.loopId, id), inArray(runLeases.state, [
|
|
2145
|
-
"active",
|
|
2146
|
-
"terminal-grace",
|
|
2147
|
-
"reconciliation-only",
|
|
2148
|
-
"retired"
|
|
2149
|
-
])));
|
|
2150
|
-
return deleteLoopDataTx(tx, id);
|
|
2151
|
-
}
|
|
2152
|
-
async function forceDeleteLoop(id) {
|
|
2153
|
-
return db.transaction((tx) => forceDeleteLoopTx(tx, id));
|
|
2154
|
-
}
|
|
2155
|
-
async function enqueueRunTx(tx, loop, request, at) {
|
|
2156
|
-
if (loop.deleteRequestedAt) return {
|
|
2157
|
-
state: "rejected",
|
|
2158
|
-
reason: "loop is being deleted"
|
|
2159
|
-
};
|
|
2160
|
-
if (request.requestedBy !== "owner" && !loop.enabled) return {
|
|
2161
|
-
state: "skipped",
|
|
2162
|
-
reason: "loop is paused"
|
|
2163
|
-
};
|
|
2164
|
-
const pending = (await tx.select().from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "pending"))).limit(1).for("update"))[0];
|
|
2165
|
-
if (pending) {
|
|
2166
|
-
const requestedBy = pending.requestedBy === "owner" || request.requestedBy === "owner" ? "owner" : "system";
|
|
2167
|
-
const updated = (await tx.update(runs).set({
|
|
2168
|
-
requestedBy,
|
|
2169
|
-
ts: at,
|
|
2170
|
-
updatedAt: at
|
|
2171
|
-
}).where(and(eq(runs.id, pending.id), eq(runs.phase, "pending"))).returning())[0];
|
|
2172
|
-
return updated ? {
|
|
2173
|
-
state: "coalesced",
|
|
2174
|
-
run: updated
|
|
2175
|
-
} : {
|
|
2176
|
-
state: "skipped",
|
|
2177
|
-
reason: "queued run was claimed concurrently"
|
|
2178
|
-
};
|
|
2179
|
-
}
|
|
2180
|
-
const row = {
|
|
2181
|
-
id: randomUUID(),
|
|
2182
|
-
loopId: loop.id,
|
|
2183
|
-
machineId: loop.machineId,
|
|
2184
|
-
phase: "pending",
|
|
2185
|
-
requestedBy: request.requestedBy,
|
|
2186
|
-
...runTimes(at)
|
|
2187
|
-
};
|
|
2188
|
-
return {
|
|
2189
|
-
state: "queued",
|
|
2190
|
-
run: (await tx.insert(runs).values(row).returning())[0]
|
|
2191
|
-
};
|
|
2192
|
-
}
|
|
2193
|
-
async function failureStreakTx(tx, loopId) {
|
|
2194
|
-
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];
|
|
2195
|
-
const conds = [
|
|
2196
|
-
eq(runs.loopId, loopId),
|
|
2197
|
-
eq(runs.phase, "error"),
|
|
2198
|
-
isNotNull(runs.runIndex)
|
|
2199
|
-
];
|
|
2200
|
-
if (lastOk?.runIndex != null) conds.push(gt(runs.runIndex, lastOk.runIndex));
|
|
2201
|
-
const row = (await tx.select({ n: sql`count(*)` }).from(runs).where(and(...conds)))[0];
|
|
2202
|
-
return Number(row?.n ?? 0);
|
|
2203
|
-
}
|
|
2204
|
-
async function terminalLifecycleTx(tx, currentLoop, run, terminalAt, loopPatch = {}, failureAutopauseStreak = 0) {
|
|
2205
|
-
const effective = {
|
|
2206
|
-
...currentLoop,
|
|
2207
|
-
...loopPatch
|
|
2208
|
-
};
|
|
2209
|
-
const update = { ...loopPatch };
|
|
2210
|
-
if ((run.phase === "done" || run.phase === "error") && effective.scheduleMode === "continuous") {
|
|
2211
|
-
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];
|
|
2212
|
-
update.nextCadenceAt = effective.enabled && !openExec ? new Date(Date.parse(terminalAt) + Math.max(1, effective.continuousDelayMinutes) * 6e4).toISOString() : null;
|
|
2213
|
-
}
|
|
2214
|
-
let loop = Object.keys(update).length ? (await tx.update(loops).set({
|
|
2215
|
-
...update,
|
|
2216
|
-
updatedAt: terminalAt
|
|
2217
|
-
}).where(eq(loops.id, currentLoop.id)).returning())[0] : currentLoop;
|
|
2218
|
-
let failureStreak = 0;
|
|
2219
|
-
let autoPaused = false;
|
|
2220
|
-
if (run.phase === "done" && run.status === "block") {
|
|
2221
|
-
loop = (await tx.update(loops).set({
|
|
2222
|
-
enabled: false,
|
|
2223
|
-
pauseCause: {
|
|
2224
|
-
kind: "blocked",
|
|
2225
|
-
at: terminalAt,
|
|
2226
|
-
runId: run.id
|
|
2227
|
-
},
|
|
2228
|
-
nextCadenceAt: null,
|
|
2229
|
-
nextRunAt: null,
|
|
2230
|
-
updatedAt: terminalAt
|
|
2231
|
-
}).where(eq(loops.id, currentLoop.id)).returning())[0];
|
|
2232
|
-
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);
|
|
2233
|
-
autoPaused = true;
|
|
2234
|
-
}
|
|
2235
|
-
if (run.phase === "error") {
|
|
2236
|
-
failureStreak = await failureStreakTx(tx, currentLoop.id);
|
|
2237
|
-
if (failureAutopauseStreak > 0 && failureStreak >= failureAutopauseStreak && loop.enabled) {
|
|
2238
|
-
loop = (await tx.update(loops).set({
|
|
2239
|
-
enabled: false,
|
|
2240
|
-
pauseCause: {
|
|
2241
|
-
kind: "failure-streak",
|
|
2242
|
-
at: terminalAt,
|
|
2243
|
-
runId: run.id,
|
|
2244
|
-
count: failureStreak
|
|
2245
|
-
},
|
|
2246
|
-
nextCadenceAt: null,
|
|
2247
|
-
nextRunAt: null,
|
|
2248
|
-
updatedAt: terminalAt
|
|
2249
|
-
}).where(eq(loops.id, currentLoop.id)).returning())[0];
|
|
2250
|
-
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);
|
|
2251
|
-
autoPaused = true;
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
return {
|
|
2255
|
-
loop,
|
|
2256
|
-
failureStreak,
|
|
2257
|
-
autoPaused
|
|
2258
|
-
};
|
|
2259
|
-
}
|
|
2260
|
-
async function getRun(id) {
|
|
2261
|
-
return (await db.select().from(runs).where(eq(runs.id, id)))[0];
|
|
2262
|
-
}
|
|
2263
|
-
async function runningRunsForMachine(machineId) {
|
|
2264
|
-
return db.select().from(runs).where(and(eq(runs.machineId, machineId), eq(runs.phase, "running"))).orderBy(asc(runs.createdAt), asc(runs.id));
|
|
2265
|
-
}
|
|
2266
|
-
async function runningRunForLoop(loopId) {
|
|
2267
|
-
return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
|
|
2268
|
-
}
|
|
2269
|
-
async function getReportReceipt(reportId) {
|
|
2270
|
-
return (await db.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, reportId)))[0];
|
|
2271
|
-
}
|
|
2272
|
-
function terminalIncidentReceiptId(reportId, payloadDigest) {
|
|
2273
|
-
return createHash("sha256").update(reportId + payloadDigest).digest("hex");
|
|
2274
|
-
}
|
|
2275
|
-
async function getTerminalReportIncidents(reportId) {
|
|
2276
|
-
return db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, reportId)).orderBy(asc(terminalReportIncidents.createdAt));
|
|
2277
|
-
}
|
|
2278
|
-
async function getExactTerminalReportIncident(reportId, payloadDigest) {
|
|
2279
|
-
return (await db.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(reportId, payloadDigest))))[0];
|
|
2280
|
-
}
|
|
2281
|
-
async function putTerminalReportIncidentIfAbsent(input) {
|
|
2282
|
-
return db.transaction(async (tx) => {
|
|
2283
|
-
await lockReportIdTx(tx, input.reportId);
|
|
2284
|
-
const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
|
|
2285
|
-
const existing = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
|
|
2286
|
-
if (existing) return existing;
|
|
2287
|
-
return (await tx.insert(terminalReportIncidents).values({
|
|
2288
|
-
id,
|
|
2289
|
-
...input,
|
|
2290
|
-
createdAt: nowIso()
|
|
2291
|
-
}).returning())[0];
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
2294
|
-
/** Serialize all terminal handling for one reportId, even when the competing
|
|
2295
|
-
* runs belong to different loop locks or one terminal result lands in the incident table. */
|
|
2296
|
-
async function lockReportIdTx(tx, reportId) {
|
|
2297
|
-
const unsigned = BigInt(`0x${createHash("sha256").update(reportId).digest("hex").slice(0, 16)}`);
|
|
2298
|
-
const key = BigInt.asIntN(64, unsigned).toString();
|
|
2299
|
-
await tx.execute(sql`select pg_advisory_xact_lock(${key}::bigint)`);
|
|
2300
|
-
}
|
|
2301
|
-
/** Race-safe receipt reservation for definitive non-finalization ACKs (RETIRED).
|
|
2302
|
-
* Returns the row that owns reportId, whether inserted here or concurrently. */
|
|
2303
|
-
async function putReportReceiptIfAbsent(input) {
|
|
2304
|
-
return db.transaction(async (tx) => {
|
|
2305
|
-
await lockReportIdTx(tx, input.reportId);
|
|
2306
|
-
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];
|
|
2307
|
-
});
|
|
2308
|
-
}
|
|
2309
|
-
/** Idempotent startup repair for a crash across terminalization.
|
|
2310
|
-
* Terminal runs can never retain active authority. */
|
|
2311
|
-
async function repairTerminalRunLeases(now = Date.now()) {
|
|
2312
|
-
const repaired = await db.update(runLeases).set({
|
|
2313
|
-
state: "terminal-grace",
|
|
2314
|
-
expiresAt: new Date(now + TERMINAL_REPORT_GRACE_MS).toISOString()
|
|
2315
|
-
}).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 });
|
|
2316
|
-
await db.update(runLeases).set({ expiresAt: null }).where(eq(runLeases.state, "retired"));
|
|
2317
|
-
return repaired.length;
|
|
2318
|
-
}
|
|
2319
|
-
/** Atomically persist a definitive 410 and consume only the matching retired
|
|
2320
|
-
* tombstone. If the HTTP response is lost, the independent receipt replays it. */
|
|
2321
|
-
async function acknowledgeRetiredReport(leaseTokenHash, input) {
|
|
2322
|
-
return db.transaction(async (tx) => {
|
|
2323
|
-
await lockReportIdTx(tx, input.reportId);
|
|
2324
|
-
if (!(await tx.select().from(runLeases).where(and(eq(runLeases.tokenHash, leaseTokenHash), eq(runLeases.state, "retired"))).limit(1).for("update"))[0]) return void 0;
|
|
2325
|
-
await tx.insert(runReportReceipts).values(input).onConflictDoNothing({ target: runReportReceipts.reportId });
|
|
2326
|
-
const stored = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
|
|
2327
|
-
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")));
|
|
2328
|
-
return stored;
|
|
2329
|
-
});
|
|
2330
|
-
}
|
|
2331
|
-
/** Refresh provider-neutral liveness in one machine-scoped UPDATE. Untrusted ids
|
|
2332
|
-
* cannot stamp another machine or a non-running row; fresh stamps stay untouched. */
|
|
2333
|
-
async function refreshRunHeartbeats(machineId, runIds, at, staleBefore) {
|
|
2334
|
-
if (!runIds.length) return 0;
|
|
2335
|
-
return (await db.update(runs).set({
|
|
2336
|
-
heartbeatAt: at,
|
|
2337
|
-
updatedAt: at
|
|
2338
|
-
}).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;
|
|
2339
|
-
}
|
|
2340
|
-
async function activeRunForReportTx(tx, loopId, runId, leaseTokenHash) {
|
|
2341
|
-
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];
|
|
2342
|
-
if (!lease) return { state: "invalid-lease" };
|
|
2343
|
-
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];
|
|
2344
|
-
return run ? {
|
|
2345
|
-
state: "active",
|
|
2346
|
-
run
|
|
2347
|
-
} : { state: "run-not-running" };
|
|
2348
|
-
}
|
|
2349
|
-
/** Record the agent-authored status/message exactly once. The loop row is the
|
|
2350
|
-
* serialization point, so sequential and concurrent duplicate callbacks both
|
|
2351
|
-
* lose before any field can be overwritten. */
|
|
2352
|
-
async function recordRunReportOnce(input) {
|
|
2353
|
-
return db.transaction(async (tx) => {
|
|
2354
|
-
if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0]) return { state: "missing-loop" };
|
|
2355
|
-
const active = await activeRunForReportTx(tx, input.loopId, input.runId, input.leaseTokenHash);
|
|
2356
|
-
if (active.state !== "active") return active;
|
|
2357
|
-
if (active.run.status != null || active.run.message != null) return { state: "already-reported" };
|
|
2358
|
-
const at = nowIso();
|
|
2359
|
-
const run = (await tx.update(runs).set({
|
|
2360
|
-
status: input.status,
|
|
2361
|
-
message: input.message,
|
|
2362
|
-
updatedAt: at
|
|
2363
|
-
}).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"), isNull(runs.status), isNull(runs.message))).returning())[0];
|
|
2364
|
-
return run ? {
|
|
2365
|
-
state: "applied",
|
|
2366
|
-
run
|
|
2367
|
-
} : { state: "already-reported" };
|
|
2368
|
-
});
|
|
2369
|
-
}
|
|
2370
|
-
/** Consume a retired tombstone when its reportId is owned by another run.
|
|
2371
|
-
* The run/loop may already be deleted, so only the exact incident receipt and
|
|
2372
|
-
* lease tombstone participate in this transaction. */
|
|
2373
|
-
async function acknowledgeRetiredTerminalIncident(input) {
|
|
2374
|
-
return db.transaction(async (tx) => {
|
|
2375
|
-
await lockReportIdTx(tx, input.reportId);
|
|
2376
|
-
const id = terminalIncidentReceiptId(input.reportId, input.payloadDigest);
|
|
2377
|
-
const replay = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, id)).limit(1))[0];
|
|
2378
|
-
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];
|
|
2379
|
-
if (!lease) return void 0;
|
|
2380
|
-
if (replay) {
|
|
2381
|
-
await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
|
|
2382
|
-
return replay;
|
|
2383
|
-
}
|
|
2384
|
-
const receipt = (await tx.insert(terminalReportIncidents).values({
|
|
2385
|
-
id,
|
|
2386
|
-
runId: input.runId,
|
|
2387
|
-
reportId: input.reportId,
|
|
2388
|
-
payloadDigest: input.payloadDigest,
|
|
2389
|
-
disposition: "telemetry-rejected",
|
|
2390
|
-
ackBody: input.ackBody,
|
|
2391
|
-
createdAt: nowIso()
|
|
2392
|
-
}).returning())[0];
|
|
2393
|
-
await tx.delete(runLeases).where(eq(runLeases.tokenHash, lease.tokenHash));
|
|
2394
|
-
return receipt;
|
|
2395
|
-
});
|
|
2396
|
-
}
|
|
2397
|
-
/** Reject one correlatable terminal attempt as a durable terminal fact. Authority,
|
|
2398
|
-
* run/loop lifecycle, incident receipt, and lease consumption share one transaction.
|
|
2399
|
-
* No payload-authored cursor/task/message field is accepted here. */
|
|
2400
|
-
async function rejectTerminalReport(input) {
|
|
2401
|
-
return db.transaction(async (tx) => {
|
|
2402
|
-
await lockReportIdTx(tx, input.reportId);
|
|
2403
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
|
|
2404
|
-
if (!current) return { state: "missing-loop" };
|
|
2405
|
-
const normal = (await tx.select().from(runReportReceipts).where(eq(runReportReceipts.reportId, input.reportId)))[0];
|
|
2406
|
-
if (normal?.runId === input.runId) return {
|
|
2407
|
-
state: "normal-replay",
|
|
2408
|
-
receipt: normal
|
|
2409
|
-
};
|
|
2410
|
-
const priorIncident = (await tx.select().from(terminalReportIncidents).where(eq(terminalReportIncidents.id, terminalIncidentReceiptId(input.reportId, input.payloadDigest))).limit(1))[0];
|
|
2411
|
-
if (priorIncident?.runId === input.runId) return {
|
|
2412
|
-
state: "incident-replay",
|
|
2413
|
-
receipt: priorIncident
|
|
2414
|
-
};
|
|
2415
|
-
const now = nowIso();
|
|
2416
|
-
const leaseConditions = [
|
|
2417
|
-
eq(runLeases.tokenHash, input.leaseTokenHash),
|
|
2418
|
-
eq(runLeases.runId, input.runId),
|
|
2419
|
-
eq(runLeases.loopId, input.loopId),
|
|
2420
|
-
input.leaseState === "active" ? eq(runLeases.state, "active") : inArray(runLeases.state, ["terminal-grace", "reconciliation-only"])
|
|
2421
|
-
];
|
|
2422
|
-
if (input.leaseState !== "active") leaseConditions.push(gt(runLeases.expiresAt, now));
|
|
2423
|
-
const lease = (await tx.select().from(runLeases).where(and(...leaseConditions)).limit(1).for("update"))[0];
|
|
2424
|
-
if (!lease) return { state: "invalid-lease" };
|
|
2425
|
-
const receiptInput = {
|
|
2426
|
-
id: terminalIncidentReceiptId(input.reportId, input.payloadDigest),
|
|
2427
|
-
runId: input.runId,
|
|
2428
|
-
reportId: input.reportId,
|
|
2429
|
-
payloadDigest: input.payloadDigest,
|
|
2430
|
-
disposition: input.disposition,
|
|
2431
|
-
ackBody: input.ackBody,
|
|
2432
|
-
createdAt: input.incident.at
|
|
2433
|
-
};
|
|
2434
|
-
if (lease.state !== "active") {
|
|
2435
|
-
const run = (await tx.update(runs).set({
|
|
2436
|
-
reportIncident: input.incident,
|
|
2437
|
-
updatedAt: input.incident.at
|
|
2438
|
-
}).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), inArray(runs.phase, [
|
|
2439
|
-
"done",
|
|
2440
|
-
"error",
|
|
2441
|
-
"canceled"
|
|
2442
|
-
]))).returning())[0];
|
|
2443
|
-
if (!run) return { state: "run-not-terminalizable" };
|
|
2444
|
-
await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
|
|
2445
|
-
return {
|
|
2446
|
-
state: "telemetry-rejected",
|
|
2447
|
-
run,
|
|
2448
|
-
loop: current,
|
|
2449
|
-
receipt: priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0],
|
|
2450
|
-
failureStreak: 0,
|
|
2451
|
-
autoPaused: false
|
|
2452
|
-
};
|
|
2453
|
-
}
|
|
2454
|
-
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];
|
|
2455
|
-
if (!target) return { state: "run-not-terminalizable" };
|
|
2456
|
-
const runIndex = await ensureRunIndexTx(tx, target);
|
|
2457
|
-
const run = (await tx.update(runs).set({
|
|
2458
|
-
phase: "error",
|
|
2459
|
-
runIndex,
|
|
2460
|
-
error: input.incident.reason,
|
|
2461
|
-
reportIncident: input.incident,
|
|
2462
|
-
ts: input.incident.at,
|
|
2463
|
-
updatedAt: input.incident.at
|
|
2464
|
-
}).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"))).returning())[0];
|
|
2465
|
-
if (!run) return { state: "run-not-terminalizable" };
|
|
2466
|
-
const lifecycle = await terminalLifecycleTx(tx, current, run, input.incident.at, {}, input.failureAutopauseStreak ?? 0);
|
|
2467
|
-
await tx.delete(runLeases).where(eq(runLeases.tokenHash, input.leaseTokenHash));
|
|
2468
|
-
const receipt = priorIncident ?? (await tx.insert(terminalReportIncidents).values(receiptInput).returning())[0];
|
|
2469
|
-
return {
|
|
2470
|
-
state: "run-error",
|
|
2471
|
-
run,
|
|
2472
|
-
...lifecycle,
|
|
2473
|
-
receipt
|
|
2474
|
-
};
|
|
2475
|
-
});
|
|
2476
|
-
}
|
|
2477
|
-
/** Atomically CAS running→terminal and apply the report's loop patch.
|
|
2478
|
-
* The loop patch is unreachable when cancel/reclaim/another report won. */
|
|
2479
|
-
async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt) {
|
|
2480
|
-
return db.transaction(async (tx) => {
|
|
2481
|
-
if (receipt) await lockReportIdTx(tx, receipt.reportId);
|
|
2482
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
|
|
2483
|
-
if (!current) return void 0;
|
|
2484
|
-
if (receipt) {
|
|
2485
|
-
if ((await tx.select({ id: terminalReportIncidents.id }).from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, receipt.reportId)).limit(1))[0]) return void 0;
|
|
2486
|
-
}
|
|
2487
|
-
if (leaseTokenHash) {
|
|
2488
|
-
if ((await activeRunForReportTx(tx, loopId, runId, leaseTokenHash)).state !== "active") return void 0;
|
|
2489
|
-
}
|
|
2490
|
-
const target = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1).for("update"))[0];
|
|
2491
|
-
if (!target) return void 0;
|
|
2492
|
-
const runIndex = await ensureRunIndexTx(tx, target);
|
|
2493
|
-
const at = typeof runPatch.ts === "string" ? runPatch.ts : nowIso();
|
|
2494
|
-
const run = (await tx.update(runs).set({
|
|
2495
|
-
...runPatch,
|
|
2496
|
-
runIndex,
|
|
2497
|
-
updatedAt: at
|
|
2498
|
-
}).where(and(eq(runs.id, runId), eq(runs.loopId, loopId), eq(runs.phase, "running"))).returning())[0];
|
|
2499
|
-
if (!run) return void 0;
|
|
2500
|
-
const lifecycle = await terminalLifecycleTx(tx, current, run, at, loopPatch, failureAutopauseStreak);
|
|
2501
|
-
if (leaseTokenHash) await tx.delete(runLeases).where(eq(runLeases.runId, runId));
|
|
2502
|
-
if (receipt) await tx.insert(runReportReceipts).values(receipt);
|
|
2503
|
-
return {
|
|
2504
|
-
run,
|
|
2505
|
-
...lifecycle
|
|
2506
|
-
};
|
|
2507
|
-
});
|
|
2508
|
-
}
|
|
2509
|
-
/** Sweep reclaim under the same loop lock as claim/report/cancel. Running
|
|
2510
|
-
* reclaims terminalize their lease and write the provisional continuous cadence
|
|
2511
|
-
* fact atomically; pending reclaims have no lease. */
|
|
2512
|
-
async function reclaimRun(runId, expected, reason, at = nowIso(), graceMs = 1440 * 60 * 1e3, failureAutopauseStreak = 0) {
|
|
2513
|
-
return db.transaction(async (tx) => {
|
|
2514
|
-
const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
|
|
2515
|
-
if (!observed) return void 0;
|
|
2516
|
-
const loop = (await tx.select().from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
|
|
2517
|
-
if (!loop) return void 0;
|
|
2518
|
-
const currentRun = (await tx.select().from(runs).where(eq(runs.id, runId)).limit(1).for("update"))[0];
|
|
2519
|
-
if (currentRun?.phase !== expected) return void 0;
|
|
2520
|
-
if (expected === "running") await tx.update(runLeases).set({
|
|
2521
|
-
state: "terminal-grace",
|
|
2522
|
-
expiresAt: new Date(Date.parse(at) + graceMs).toISOString()
|
|
2523
|
-
}).where(and(eq(runLeases.runId, runId), eq(runLeases.state, "active")));
|
|
2524
|
-
const runIndex = await ensureRunIndexTx(tx, currentRun);
|
|
2525
|
-
const reclaimed = (await tx.update(runs).set({
|
|
2526
|
-
phase: "error",
|
|
2527
|
-
runIndex,
|
|
2528
|
-
error: reason,
|
|
2529
|
-
ts: at,
|
|
2530
|
-
updatedAt: at
|
|
2531
|
-
}).where(and(eq(runs.id, runId), eq(runs.phase, expected))).returning())[0];
|
|
2532
|
-
if (!reclaimed) return void 0;
|
|
2533
|
-
return {
|
|
2534
|
-
run: reclaimed,
|
|
2535
|
-
...await terminalLifecycleTx(tx, loop, reclaimed, at, {}, failureAutopauseStreak)
|
|
2536
|
-
};
|
|
2537
|
-
});
|
|
2538
|
-
}
|
|
2539
|
-
/** A daemon's completed recovery snapshot is authoritative for execution
|
|
2540
|
-
* exclusion. Reclaimed runs absent from that snapshot retain late-report authority
|
|
2541
|
-
* until their original expiry, but stop blocking queued work immediately. */
|
|
2542
|
-
async function releaseAbsentReconciliations(machineId, recoverableRunIds, at = nowIso()) {
|
|
2543
|
-
const conditions = [
|
|
2544
|
-
eq(runLeases.machineId, machineId),
|
|
2545
|
-
eq(runLeases.state, "terminal-grace"),
|
|
2546
|
-
gt(runLeases.expiresAt, at)
|
|
2547
|
-
];
|
|
2548
|
-
const ids = [...new Set(recoverableRunIds)];
|
|
2549
|
-
if (ids.length) conditions.push(notInArray(runLeases.runId, ids));
|
|
2550
|
-
return (await db.update(runLeases).set({ state: "reconciliation-only" }).where(and(...conditions)).returning({ runId: runLeases.runId })).map((row) => row.runId);
|
|
2551
|
-
}
|
|
2552
|
-
/** Consume one terminal reconciliation lease and reconcile its reclaimed error
|
|
2553
|
-
* exactly once. Keeping the lease check/delete beside the run + loop writes closes the
|
|
2554
|
-
* error→error double-report hole where a phase CAS alone cannot distinguish the
|
|
2555
|
-
* first real failure from a second concurrent one. */
|
|
2556
|
-
async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt) {
|
|
2557
|
-
return db.transaction(async (tx) => {
|
|
2558
|
-
if (receipt) await lockReportIdTx(tx, receipt.reportId);
|
|
2559
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
|
|
2560
|
-
if (!current) return void 0;
|
|
2561
|
-
if (receipt) {
|
|
2562
|
-
if ((await tx.select({ id: terminalReportIncidents.id }).from(terminalReportIncidents).where(eq(terminalReportIncidents.reportId, receipt.reportId)).limit(1))[0]) return void 0;
|
|
2563
|
-
}
|
|
2564
|
-
const leaseNow = nowIso();
|
|
2565
|
-
const lease = (await tx.select({
|
|
2566
|
-
tokenHash: runLeases.tokenHash,
|
|
2567
|
-
state: runLeases.state
|
|
2568
|
-
}).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];
|
|
2569
|
-
if (!lease) return void 0;
|
|
2570
|
-
const target = (await tx.select().from(runs).where(and(eq(runs.id, runId), eq(runs.loopId, loopId), eq(runs.phase, "error"))).limit(1).for("update"))[0];
|
|
2571
|
-
if (!target) return void 0;
|
|
2572
|
-
const runIndex = await ensureRunIndexTx(tx, target);
|
|
2573
|
-
const at = typeof runPatch.ts === "string" ? runPatch.ts : nowIso();
|
|
2574
|
-
const run = (await tx.update(runs).set({
|
|
2575
|
-
...runPatch,
|
|
2576
|
-
runIndex,
|
|
2577
|
-
updatedAt: at
|
|
2578
|
-
}).where(and(eq(runs.id, runId), eq(runs.loopId, loopId), eq(runs.phase, "error"))).returning())[0];
|
|
2579
|
-
if (!run) return void 0;
|
|
2580
|
-
const lifecycle = lease.state === "reconciliation-only" ? {
|
|
2581
|
-
loop: current,
|
|
2582
|
-
failureStreak: 0,
|
|
2583
|
-
autoPaused: false
|
|
2584
|
-
} : await terminalLifecycleTx(tx, current, run, at, loopPatch, failureAutopauseStreak);
|
|
2585
|
-
await tx.delete(runLeases).where(eq(runLeases.runId, runId));
|
|
2586
|
-
if (receipt) await tx.insert(runReportReceipts).values(receipt);
|
|
2587
|
-
return {
|
|
2588
|
-
run,
|
|
2589
|
-
...lifecycle,
|
|
2590
|
-
reportOnly: lease.state === "reconciliation-only"
|
|
2591
|
-
};
|
|
2592
|
-
});
|
|
2593
|
-
}
|
|
2594
|
-
var TERMINAL_REPORT_GRACE_MS = 600 * 1e3;
|
|
2595
|
-
/**
|
|
2596
|
-
* The durable run-queue write seam. It serializes on the loop row and owns every
|
|
2597
|
-
* queue invariant callers would otherwise have to reproduce:
|
|
2598
|
-
* - at most one ordinary pending row per loop (also backed by a partial unique index),
|
|
2599
|
-
* - a running run may retain one coalesced follow-up,
|
|
2600
|
-
* - owner authority promotes an existing system row and never downgrades,
|
|
2601
|
-
* - paused loops accept owner Run-once work while recurring system work stays stopped.
|
|
2602
|
-
*/
|
|
2603
|
-
async function enqueueRun(loopId, request) {
|
|
2604
|
-
return db.transaction(async (tx) => {
|
|
2605
|
-
const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
|
|
2606
|
-
if (!loop) return {
|
|
2607
|
-
state: "rejected",
|
|
2608
|
-
reason: "loop not found"
|
|
2609
|
-
};
|
|
2610
|
-
return enqueueRunTx(tx, loop, request, nowIso());
|
|
2611
|
-
});
|
|
2612
|
-
}
|
|
2613
|
-
/** Materialize every due schedule fact. Each loop is locked and rechecked in its
|
|
2614
|
-
* own transaction; cadence and one-shot facts may coalesce into one system exec
|
|
2615
|
-
* and are consumed together. */
|
|
2616
|
-
async function advanceDueSchedules(at = nowIso(), filter = {}) {
|
|
2617
|
-
const due = or(and(isNotNull(loops.nextCadenceAt), sql`${loops.nextCadenceAt} <= ${at}`), and(isNotNull(loops.nextRunAt), sql`${loops.nextRunAt} <= ${at}`));
|
|
2618
|
-
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));
|
|
2619
|
-
const advanced = [];
|
|
2620
|
-
for (const { id } of candidates) {
|
|
2621
|
-
const result = await db.transaction(async (tx) => {
|
|
2622
|
-
const loop = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2623
|
-
if (!loop?.enabled || loop.deleteRequestedAt) return void 0;
|
|
2624
|
-
const cadenceDue = !!loop.nextCadenceAt && Date.parse(loop.nextCadenceAt) <= Date.parse(at);
|
|
2625
|
-
const oneShotDue = !!loop.nextRunAt && Date.parse(loop.nextRunAt) <= Date.parse(at);
|
|
2626
|
-
if (!cadenceDue && !oneShotDue) return void 0;
|
|
2627
|
-
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;
|
|
2628
|
-
const open = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, id), inArray(runs.phase, ["pending", "running"]))).limit(1))[0];
|
|
2629
|
-
const skipRecurring = cadenceDue && !!open && (loop.scheduleMode === "continuous" || loop.cronOverlap === "skip");
|
|
2630
|
-
const queued = oneShotDue || !skipRecurring ? await enqueueRunTx(tx, loop, { requestedBy: "system" }, at) : void 0;
|
|
2631
|
-
if (queued && !("run" in queued)) return void 0;
|
|
2632
|
-
const nextCadenceAt = cadenceDue ? loop.scheduleMode === "cron" ? nextCronAt(loop.cron, loop.timezone, at) : null : loop.nextCadenceAt;
|
|
2633
|
-
const updatedLoop = (await tx.update(loops).set({
|
|
2634
|
-
nextCadenceAt,
|
|
2635
|
-
...oneShotDue ? { nextRunAt: null } : {},
|
|
2636
|
-
updatedAt: at
|
|
2637
|
-
}).where(eq(loops.id, id)).returning())[0];
|
|
2638
|
-
return queued ? {
|
|
2639
|
-
loop: updatedLoop,
|
|
2640
|
-
run: queued.run,
|
|
2641
|
-
state: queued.state
|
|
2642
|
-
} : {
|
|
2643
|
-
loop: updatedLoop,
|
|
2644
|
-
state: "skipped"
|
|
2645
|
-
};
|
|
2646
|
-
});
|
|
2647
|
-
if (result) advanced.push(result);
|
|
2648
|
-
}
|
|
2649
|
-
return advanced;
|
|
2650
|
-
}
|
|
2651
|
-
/** Conservative boot initialization for pre-fact cron rows. No history lookup and
|
|
2652
|
-
* no missed-fire catch-up: only install the next future occurrence. */
|
|
2653
|
-
async function initializeCronCadence(at = nowIso()) {
|
|
2654
|
-
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)));
|
|
2655
|
-
const initialized = [];
|
|
2656
|
-
for (const { id } of candidates) {
|
|
2657
|
-
const loop = await db.transaction(async (tx) => {
|
|
2658
|
-
const current = (await tx.select().from(loops).where(eq(loops.id, id)).for("update"))[0];
|
|
2659
|
-
if (!current?.enabled || current.scheduleMode !== "cron" || current.nextCadenceAt) return void 0;
|
|
2660
|
-
return (await tx.update(loops).set({
|
|
2661
|
-
nextCadenceAt: nextCronAt(current.cron, current.timezone, at),
|
|
2662
|
-
updatedAt: at
|
|
2663
|
-
}).where(eq(loops.id, id)).returning())[0];
|
|
2664
|
-
});
|
|
2665
|
-
if (loop) initialized.push(loop);
|
|
2666
|
-
}
|
|
2667
|
-
return initialized;
|
|
2668
|
-
}
|
|
2669
|
-
/** Claim one ready loop and mint its lease atomically. Repeated polls may add
|
|
2670
|
-
* unlimited cross-loop concurrency; the loop index remains the final same-loop
|
|
2671
|
-
* serialization invariant. Reported local runs exclude their loops even if the
|
|
2672
|
-
* server has already terminalized an older process. */
|
|
2673
|
-
async function claimReadyRunForMachine(machineId, at = nowIso(), excludeRunIds = []) {
|
|
2674
|
-
return db.transaction(async (tx) => {
|
|
2675
|
-
if (!(await tx.select({ id: machines.id }).from(machines).where(eq(machines.id, machineId)).for("update"))[0]) return void 0;
|
|
2676
|
-
const excludedLoops = /* @__PURE__ */ new Set();
|
|
2677
|
-
if (excludeRunIds.length) {
|
|
2678
|
-
const rows = await tx.select({ loopId: runs.loopId }).from(runs).where(and(eq(runs.machineId, machineId), inArray(runs.id, [...new Set(excludeRunIds)])));
|
|
2679
|
-
for (const row of rows) excludedLoops.add(row.loopId);
|
|
2680
|
-
}
|
|
2681
|
-
const next = (await tx.select({
|
|
2682
|
-
run: runs,
|
|
2683
|
-
loop: loops
|
|
2684
|
-
}).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];
|
|
2685
|
-
if (!next) return void 0;
|
|
2686
|
-
let loop = (await tx.select().from(loops).where(eq(loops.id, next.loop.id)).for("update"))[0];
|
|
2687
|
-
const candidate = (await tx.select().from(runs).where(eq(runs.id, next.run.id)).limit(1).for("update"))[0];
|
|
2688
|
-
if (!loop || !candidate || candidate.phase !== "pending" || candidate.cancelRequestedAt || loop.deleteRequestedAt || !loop.enabled && candidate.requestedBy !== "owner") return void 0;
|
|
2689
|
-
const occupied = (await tx.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loop.id), eq(runs.phase, "running"))).limit(1))[0];
|
|
2690
|
-
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];
|
|
2691
|
-
if (occupied || authority) return void 0;
|
|
2692
|
-
const runIndex = await ensureRunIndexTx(tx, candidate);
|
|
2693
|
-
const run = (await tx.update(runs).set({
|
|
2694
|
-
phase: "running",
|
|
2695
|
-
runIndex,
|
|
2696
|
-
agent: loop.agent,
|
|
2697
|
-
model: loop.model,
|
|
2698
|
-
reasoningEffort: loop.reasoningEffort,
|
|
2699
|
-
heartbeatAt: null,
|
|
2700
|
-
ts: at,
|
|
2701
|
-
updatedAt: at
|
|
2702
|
-
}).where(and(eq(runs.id, candidate.id), eq(runs.phase, "pending"), isNull(runs.cancelRequestedAt))).returning())[0];
|
|
2703
|
-
if (!run) return void 0;
|
|
2704
|
-
if (loop.scheduleMode === "continuous" && loop.nextCadenceAt != null) loop = (await tx.update(loops).set({
|
|
2705
|
-
nextCadenceAt: null,
|
|
2706
|
-
updatedAt: at
|
|
2707
|
-
}).where(eq(loops.id, loop.id)).returning())[0];
|
|
2708
|
-
const runToken = `rk_${randomBytes(16).toString("hex")}`;
|
|
2709
|
-
const tokenHash = createHash("sha256").update(runToken).digest("hex");
|
|
2710
|
-
await tx.insert(runLeases).values({
|
|
2711
|
-
tokenHash,
|
|
2712
|
-
runId: run.id,
|
|
2713
|
-
loopId: loop.id,
|
|
2714
|
-
machineId,
|
|
2715
|
-
createdAt: at
|
|
2716
|
-
});
|
|
2717
|
-
return {
|
|
2718
|
-
run,
|
|
2719
|
-
loop,
|
|
2720
|
-
runToken
|
|
2721
|
-
};
|
|
2722
|
-
});
|
|
2723
|
-
}
|
|
2724
|
-
async function listRuns(loopId, limit = 30) {
|
|
2725
|
-
return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(limit)).reverse();
|
|
2726
|
-
}
|
|
2727
|
-
async function reconciliationStatesForRuns(loopId, runIds, at = nowIso()) {
|
|
2728
|
-
if (!runIds.length) return /* @__PURE__ */ new Map();
|
|
2729
|
-
const rows = await db.select({
|
|
2730
|
-
runId: runLeases.runId,
|
|
2731
|
-
state: runLeases.state
|
|
2732
|
-
}).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)));
|
|
2733
|
-
return new Map(rows.map((row) => [row.runId, row.state === "terminal-grace" ? "blocking" : "report-only"]));
|
|
2734
|
-
}
|
|
2735
|
-
/** One older page: runs strictly before `beforeTs`, newest-first then capped,
|
|
2736
|
-
* returned chronological (oldest-first) to match listRuns. Cursor-based (by ts,
|
|
2737
|
-
* not offset) so it's stable while new runs land at the head. */
|
|
2738
|
-
async function listRunsBefore(loopId, beforeTs, limit = 16) {
|
|
2739
|
-
return (await db.select().from(runs).where(and(eq(runs.loopId, loopId), lt(runs.ts, beforeTs))).orderBy(desc(runs.ts)).limit(limit)).reverse();
|
|
2740
|
-
}
|
|
2741
|
-
async function lastRun(loopId) {
|
|
2742
|
-
return (await db.select().from(runs).where(eq(runs.loopId, loopId)).orderBy(desc(runs.ts)).limit(1))[0];
|
|
2743
|
-
}
|
|
2744
|
-
async function countRuns(loopId) {
|
|
2745
|
-
const r = (await db.select({ n: sql`count(*)` }).from(runs).where(eq(runs.loopId, loopId)))[0];
|
|
2746
|
-
return Number(r?.n ?? 0);
|
|
2747
|
-
}
|
|
2748
|
-
async function recentLoopUsage(loopId, since) {
|
|
2749
|
-
const row = (await db.select({
|
|
2750
|
-
runCount: sql`count(*)`,
|
|
2751
|
-
tokenCount: sql`coalesce(sum(
|
|
2752
|
-
coalesce((${runs.usage} ->> 'inputTokens')::bigint, 0) +
|
|
2753
|
-
coalesce((${runs.usage} ->> 'outputTokens')::bigint, 0)
|
|
2754
|
-
), 0)`
|
|
2755
|
-
}).from(runs).where(and(eq(runs.loopId, loopId), sql`${runs.ts} >= ${since}`)))[0];
|
|
2756
|
-
return {
|
|
2757
|
-
runCount: Number(row?.runCount ?? 0),
|
|
2758
|
-
tokenCount: Number(row?.tokenCount ?? 0)
|
|
2759
|
-
};
|
|
2760
|
-
}
|
|
2761
|
-
async function openRuns() {
|
|
2762
|
-
return db.select().from(runs).where(inArray(runs.phase, ["pending", "running"]));
|
|
2763
|
-
}
|
|
2764
|
-
async function hasRunningRun(loopId) {
|
|
2765
|
-
return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "running"))).limit(1))[0];
|
|
2766
|
-
}
|
|
2767
|
-
async function hasPendingRun(loopId) {
|
|
2768
|
-
return !!(await db.select({ id: runs.id }).from(runs).where(and(eq(runs.loopId, loopId), eq(runs.phase, "pending"))).limit(1))[0];
|
|
2769
|
-
}
|
|
2770
|
-
/** Expire one offline system row only if the sweep snapshot is still exact and
|
|
2771
|
-
* its immutable lifetime remains over the cap under the loop lock. */
|
|
2772
|
-
async function expirePendingRun(runId, expected, at, maxLifetimeMs, message) {
|
|
2773
|
-
return db.transaction(async (tx) => {
|
|
2774
|
-
const observed = (await tx.select({ loopId: runs.loopId }).from(runs).where(eq(runs.id, runId)).limit(1))[0];
|
|
2775
|
-
if (!observed) return false;
|
|
2776
|
-
const loop = (await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, observed.loopId)).for("update"))[0];
|
|
2777
|
-
if (!loop) return false;
|
|
2778
|
-
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];
|
|
2779
|
-
if (!current || Date.parse(at) - Date.parse(current.createdAt) <= maxLifetimeMs) return false;
|
|
2780
|
-
if ((await tx.select({ online: machines.online }).from(machines).where(eq(machines.id, current.machineId)).limit(1))[0]?.online) return false;
|
|
2781
|
-
const runIndex = await ensureRunIndexTx(tx, current);
|
|
2782
|
-
return (await tx.update(runs).set({
|
|
2783
|
-
phase: "canceled",
|
|
2784
|
-
runIndex,
|
|
2785
|
-
message,
|
|
2786
|
-
ts: at,
|
|
2787
|
-
updatedAt: at
|
|
2788
|
-
}).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;
|
|
2789
|
-
});
|
|
2790
|
-
}
|
|
2791
|
-
async function listMachines() {
|
|
2792
|
-
return db.select().from(machines).orderBy(asc(machines.name), asc(machines.id));
|
|
2793
|
-
}
|
|
2794
|
-
async function listMachinesForUser(userId) {
|
|
2795
|
-
return db.select().from(machines).where(eq(machines.userId, userId)).orderBy(asc(machines.name), asc(machines.id));
|
|
2796
|
-
}
|
|
2797
|
-
async function getMachine(id) {
|
|
2798
|
-
return (await db.select().from(machines).where(eq(machines.id, id)))[0];
|
|
2799
|
-
}
|
|
2800
|
-
async function createMachine(input) {
|
|
2801
|
-
return (await db.insert(machines).values({
|
|
2802
|
-
...input,
|
|
2803
|
-
createdAt: nowIso()
|
|
2804
|
-
}).returning())[0];
|
|
2805
|
-
}
|
|
2806
|
-
async function updateMachine(id, patch) {
|
|
2807
|
-
await db.update(machines).set(patch).where(eq(machines.id, id));
|
|
2808
|
-
return getMachine(id);
|
|
2809
|
-
}
|
|
2810
|
-
/** Atomically retire all loop authority, remove server-owned machine data, revoke
|
|
2811
|
-
* enrollment, and delete the machine. Auth-mode callers pass `ownerUserId` so
|
|
2812
|
-
* ownership is rechecked under the same lock as deletion; open/server callers omit it. */
|
|
2813
|
-
async function forceDeleteMachine(id, ownerUserId) {
|
|
2814
|
-
return db.transaction(async (tx) => {
|
|
2815
|
-
const machine = (await tx.select({
|
|
2816
|
-
id: machines.id,
|
|
2817
|
-
userId: machines.userId
|
|
2818
|
-
}).from(machines).where(eq(machines.id, id)).for("update"))[0];
|
|
2819
|
-
if (!machine) return { state: "not-found" };
|
|
2820
|
-
if (ownerUserId !== void 0 && machine.userId !== ownerUserId) return { state: "forbidden" };
|
|
2821
|
-
const ownedLoops = await tx.select().from(loops).where(eq(loops.machineId, id)).for("update");
|
|
2822
|
-
if (ownerUserId !== void 0 && ownedLoops.some((loop) => loop.userId !== ownerUserId)) return { state: "forbidden" };
|
|
2823
|
-
for (const loop of ownedLoops) if (!await forceDeleteLoopTx(tx, loop.id)) throw new Error(`failed to delete loop ${loop.id}`);
|
|
2824
|
-
await tx.delete(connectKeys).where(eq(connectKeys.machineId, id));
|
|
2825
|
-
await tx.delete(machines).where(eq(machines.id, id));
|
|
2826
|
-
return {
|
|
2827
|
-
state: "deleted",
|
|
2828
|
-
loopIds: ownedLoops.map((loop) => loop.id)
|
|
2829
|
-
};
|
|
2830
|
-
});
|
|
2831
|
-
}
|
|
2832
|
-
async function setMachineOnline(id, online) {
|
|
2833
|
-
await db.update(machines).set({
|
|
2834
|
-
online,
|
|
2835
|
-
lastSeen: nowIso()
|
|
2836
|
-
}).where(eq(machines.id, id));
|
|
2837
|
-
}
|
|
2838
|
-
async function blobExists(hash) {
|
|
2839
|
-
return !!(await db.select({ hash: blobs.hash }).from(blobs).where(eq(blobs.hash, hash)))[0];
|
|
2840
|
-
}
|
|
2841
|
-
/** Verified byte lengths recorded at blob ingress, used to budget reads before
|
|
2842
|
-
* touching the byte store. Missing metadata is intentionally not estimated. */
|
|
2843
|
-
async function blobSizes(hashes) {
|
|
2844
|
-
if (!hashes.length) return /* @__PURE__ */ new Map();
|
|
2845
|
-
const rows = await db.select({
|
|
2846
|
-
hash: blobs.hash,
|
|
2847
|
-
size: blobs.size
|
|
2848
|
-
}).from(blobs).where(inArray(blobs.hash, [...new Set(hashes)]));
|
|
2849
|
-
return new Map(rows.map((row) => [row.hash, row.size]));
|
|
2850
|
-
}
|
|
2851
|
-
async function recordBlob(hash, size) {
|
|
2852
|
-
await db.insert(blobs).values({
|
|
2853
|
-
hash,
|
|
2854
|
-
size,
|
|
2855
|
-
createdAt: nowIso()
|
|
2856
|
-
}).onConflictDoNothing();
|
|
2857
|
-
}
|
|
2858
|
-
/** Does any LIVE artifact_files row on a loop bound to `machineId` point at `hash`?
|
|
2859
|
-
* Gates putBlob: a device may only upload bytes the sync handshake actually asked
|
|
2860
|
-
* it for (a row a prior sync wrote for one of ITS loops), never arbitrary
|
|
2861
|
-
* self-hashed blobs — otherwise any device token is an uncapped blob write channel. */
|
|
2862
|
-
async function machineReferencesBlob(machineId, hash) {
|
|
2863
|
-
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];
|
|
2864
|
-
}
|
|
2865
|
-
async function writeConfiguredArtifactTx(tx, input, ts) {
|
|
2866
|
-
await tx.insert(artifactFiles).values({
|
|
2867
|
-
id: randomUUID(),
|
|
2868
|
-
loopId: input.loopId,
|
|
2869
|
-
path: input.path,
|
|
2870
|
-
hash: input.hash,
|
|
2871
|
-
size: input.size,
|
|
2872
|
-
binary: input.binary,
|
|
2873
|
-
oversize: input.oversize,
|
|
2874
|
-
deleted: false,
|
|
2875
|
-
updatedAt: ts
|
|
2876
|
-
}).onConflictDoUpdate({
|
|
2877
|
-
target: [artifactFiles.loopId, artifactFiles.path],
|
|
2878
|
-
set: {
|
|
2879
|
-
hash: input.hash,
|
|
2880
|
-
size: input.size,
|
|
2881
|
-
binary: input.binary,
|
|
2882
|
-
oversize: input.oversize,
|
|
2883
|
-
deleted: false,
|
|
2884
|
-
updatedAt: ts
|
|
2885
|
-
}
|
|
2886
|
-
});
|
|
2887
|
-
}
|
|
2888
|
-
/** Upsert only while the exact literal path remains configured, serialized with
|
|
2889
|
-
* owner edits by the loop row lock. */
|
|
2890
|
-
async function upsertConfiguredArtifactFile(input) {
|
|
2891
|
-
return db.transaction(async (tx) => {
|
|
2892
|
-
const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
|
|
2893
|
-
if (!loop || !loop.artifacts.includes(input.path)) return false;
|
|
2894
|
-
await writeConfiguredArtifactTx(tx, input, nowIso());
|
|
2895
|
-
return true;
|
|
2896
|
-
});
|
|
2897
|
-
}
|
|
2898
|
-
/** Reconcile deletions against both the request's starting allowlist and the
|
|
2899
|
-
* current locked loop config. Newly-added paths are never tombstoned by an older
|
|
2900
|
-
* in-flight manifest; removed paths can never remain live. */
|
|
2901
|
-
async function reconcileConfiguredArtifacts(loopId, configuredAtStart, keepPaths) {
|
|
2902
|
-
return db.transaction(async (tx) => {
|
|
2903
|
-
const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, loopId)).for("update"))[0];
|
|
2904
|
-
if (!loop) return 0;
|
|
2905
|
-
const current = new Set(loop.artifacts);
|
|
2906
|
-
const started = new Set(configuredAtStart);
|
|
2907
|
-
const keep = new Set(keepPaths);
|
|
2908
|
-
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));
|
|
2909
|
-
if (!stale.length) return 0;
|
|
2910
|
-
const at = nowIso();
|
|
2911
|
-
await tx.update(artifactFiles).set({
|
|
2912
|
-
hash: null,
|
|
2913
|
-
deleted: true,
|
|
2914
|
-
updatedAt: at
|
|
2915
|
-
}).where(inArray(artifactFiles.id, stale.map((file) => file.id)));
|
|
2916
|
-
return stale.length;
|
|
2917
|
-
});
|
|
2918
|
-
}
|
|
2919
|
-
async function listArtifacts(loopId) {
|
|
2920
|
-
return db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false))).orderBy(artifactFiles.path);
|
|
2921
|
-
}
|
|
2922
|
-
async function getArtifactFile(loopId, path) {
|
|
2923
|
-
return (await db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.path, path))))[0];
|
|
2924
|
-
}
|
|
2925
|
-
async function buildLoopManifest(loopId) {
|
|
2926
|
-
const manifest = {};
|
|
2927
|
-
for (const f of await listArtifacts(loopId)) manifest[f.path] = {
|
|
2928
|
-
hash: f.hash,
|
|
2929
|
-
size: f.size,
|
|
2930
|
-
binary: f.binary,
|
|
2931
|
-
oversize: f.oversize
|
|
2932
|
-
};
|
|
2933
|
-
return manifest;
|
|
2934
|
-
}
|
|
2935
|
-
/** Write/overwrite a run's snapshot (path → file metadata). Idempotent on runId
|
|
2936
|
-
* so a re-report of the same run just refreshes the captured artifact state. */
|
|
2937
|
-
async function putRunSnapshot(runId, loopId, manifest) {
|
|
2938
|
-
await db.insert(runSnapshots).values({
|
|
2939
|
-
runId,
|
|
2940
|
-
loopId,
|
|
2941
|
-
manifest,
|
|
2942
|
-
createdAt: nowIso()
|
|
2943
|
-
}).onConflictDoUpdate({
|
|
2944
|
-
target: runSnapshots.runId,
|
|
2945
|
-
set: {
|
|
2946
|
-
loopId,
|
|
2947
|
-
manifest,
|
|
2948
|
-
createdAt: nowIso()
|
|
2949
|
-
}
|
|
2950
|
-
});
|
|
2951
|
-
}
|
|
2952
|
-
async function getRunSnapshot(runId) {
|
|
2953
|
-
return (await db.select().from(runSnapshots).where(eq(runSnapshots.runId, runId)))[0];
|
|
2954
|
-
}
|
|
2955
|
-
/** The most recent indexed snapshot before one run. History order, not a
|
|
2956
|
-
* timestamp tie, is the diff baseline. */
|
|
2957
|
-
async function prevRunSnapshot(loopId, beforeRunIndex) {
|
|
2958
|
-
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;
|
|
2959
|
-
}
|
|
2960
|
-
/**
|
|
2961
|
-
* Prune a loop's run snapshots down to the `keep` most recent (by createdAt),
|
|
2962
|
-
* deleting the rest. Returns the number deleted. This is what makes an old
|
|
2963
|
-
* snapshot's now-unreferenced blobs collectable by the blob GC. `keep <= 0`
|
|
2964
|
-
* means "keep none" (still bounded). Safe to call repeatedly (idempotent once
|
|
2965
|
-
* at/under the window).
|
|
2966
|
-
*/
|
|
2967
|
-
async function pruneRunSnapshots(loopId, keep) {
|
|
2968
|
-
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) : [];
|
|
2969
|
-
const pred = survivors.length ? and(eq(runSnapshots.loopId, loopId), notInArray(runSnapshots.runId, survivors)) : eq(runSnapshots.loopId, loopId);
|
|
2970
|
-
return (await db.delete(runSnapshots).where(pred).returning({ id: runSnapshots.runId })).length;
|
|
2971
|
-
}
|
|
2972
|
-
async function loopIdsWithSnapshots() {
|
|
2973
|
-
return (await db.selectDistinct({ loopId: runSnapshots.loopId }).from(runSnapshots)).map((r) => r.loopId);
|
|
2974
|
-
}
|
|
2975
|
-
/**
|
|
2976
|
-
* The full set of blob hashes still referenced by a LIVE row — the GC's keep
|
|
2977
|
-
* set. A hash is live if ANY artifact_files row points at it (deleted tombstones
|
|
2978
|
-
* carry hash=null, so they don't pin a blob) OR ANY retained run_snapshot's
|
|
2979
|
-
* manifest references it. Computed in one pass so the GC never deletes a blob a
|
|
2980
|
-
* snapshot still needs for its diff.
|
|
2981
|
-
*/
|
|
2982
|
-
async function liveBlobRefs() {
|
|
2983
|
-
const refs = /* @__PURE__ */ new Set();
|
|
2984
|
-
for (const r of await db.selectDistinct({ hash: artifactFiles.hash }).from(artifactFiles).where(isNotNull(artifactFiles.hash))) if (r.hash) refs.add(r.hash);
|
|
2985
|
-
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);
|
|
2986
|
-
return refs;
|
|
2987
|
-
}
|
|
2988
|
-
/** Blob hashes whose metadata row predates `cutoffIso` (GC candidates — the grace
|
|
2989
|
-
* window excludes freshly-written blobs a concurrent sync may be referencing). */
|
|
2990
|
-
async function blobHashesOlderThan(cutoffIso) {
|
|
2991
|
-
return (await db.select({ hash: blobs.hash }).from(blobs).where(lt(blobs.createdAt, cutoffIso))).map((r) => r.hash);
|
|
2992
|
-
}
|
|
2993
|
-
/** Delete a blob's metadata row (the bytes are reclaimed separately via the
|
|
2994
|
-
* BlobStore). Idempotent. */
|
|
2995
|
-
async function deleteBlob(hash) {
|
|
2996
|
-
await db.delete(blobs).where(eq(blobs.hash, hash));
|
|
2997
|
-
}
|
|
2998
|
-
/** Indexed point check: does any LIVE artifact_files row still point at this hash?
|
|
2999
|
-
* The GC's cheap, always-fresh per-candidate guard (the common re-reference path) —
|
|
3000
|
-
* uses the artifact_files_hash index, so it stays O(1) even as candidates pile up. */
|
|
3001
|
-
async function artifactFileReferencesHash(hash) {
|
|
3002
|
-
return !!(await db.select({ id: artifactFiles.id }).from(artifactFiles).where(eq(artifactFiles.hash, hash)))[0];
|
|
3003
|
-
}
|
|
3004
|
-
/** Every blob hash referenced by ANY retained run_snapshot's manifest — the full
|
|
3005
|
-
* snapshot scan deserialized ONCE into a Set so the GC can answer per-candidate
|
|
3006
|
-
* snapshot membership in O(1) instead of re-scanning the whole table per garbage
|
|
3007
|
-
* hash. The GC rebuilds this only when the snapshot row count changes (a report()
|
|
3008
|
-
* raced the pass), so a snapshot that comes to reference a hash mid-pass is still
|
|
3009
|
-
* caught — closing the GC-check-time gap where a snapshot references a hash no live
|
|
3010
|
-
* file row does — without paying O(garbage × snapshots). */
|
|
3011
|
-
async function snapshotBlobRefs() {
|
|
3012
|
-
const refs = /* @__PURE__ */ new Set();
|
|
3013
|
-
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);
|
|
3014
|
-
return refs;
|
|
3015
|
-
}
|
|
3016
|
-
/** Count of retained run_snapshot rows — the GC's cheap change-detector for deciding
|
|
3017
|
-
* whether to rebuild its precomputed snapshotBlobRefs() set mid-pass. */
|
|
3018
|
-
async function countRunSnapshots() {
|
|
3019
|
-
const r = (await db.select({ n: sql`count(*)` }).from(runSnapshots))[0];
|
|
3020
|
-
return Number(r?.n ?? 0);
|
|
3021
|
-
}
|
|
3022
|
-
/** Per-file byte cap. At or under ⇒ bytes sync; over ⇒ metadata-only (no bytes). */
|
|
3023
|
-
var BLOB_CAP = 10 * 1024 * 1024;
|
|
3024
|
-
/** Hard ceiling on the exact-manifest sync POST body. */
|
|
3025
|
-
var SYNC_BODY_CAP = 32 * 1024 * 1024;
|
|
3026
|
-
var SYNC_FIELDS = new Set(["loopId", "manifest"]);
|
|
3027
|
-
var MANIFEST_FIELDS = new Set([
|
|
3028
|
-
"path",
|
|
3029
|
-
"hash",
|
|
3030
|
-
"size",
|
|
3031
|
-
"binary",
|
|
3032
|
-
"oversize"
|
|
3033
|
-
]);
|
|
3034
|
-
var MAX_STORED_SIZE = 2147483647;
|
|
3035
|
-
function exactRecord(value, fields) {
|
|
3036
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
3037
|
-
const proto = Object.getPrototypeOf(value);
|
|
3038
|
-
if (proto !== Object.prototype && proto !== null) return false;
|
|
3039
|
-
const keys = Object.keys(value);
|
|
3040
|
-
return keys.length === fields.size && keys.every((key) => fields.has(key));
|
|
3041
|
-
}
|
|
3042
|
-
function sha256Buf(bytes) {
|
|
3043
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
3044
|
-
}
|
|
3045
|
-
function isValidHash(hash) {
|
|
3046
|
-
return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
|
|
3047
|
-
}
|
|
3048
|
-
/**
|
|
3049
|
-
* Validate and preserve an untrusted, loop-folder-relative literal path. Returns
|
|
3050
|
-
* null if it is absolute, contains an empty/dot/traversal segment, is empty, or
|
|
3051
|
-
* carries a NUL (no real filesystem produces one, and Postgres rejects it).
|
|
3052
|
-
*/
|
|
3053
|
-
function safeRelPath(raw) {
|
|
3054
|
-
if (typeof raw !== "string" || raw.length === 0 || raw.includes("\0")) return null;
|
|
3055
|
-
if (raw.startsWith("/") || raw.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(raw)) return null;
|
|
3056
|
-
if (raw.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return null;
|
|
3057
|
-
return raw;
|
|
3058
|
-
}
|
|
3059
|
-
/** Parse the one canonical sync envelope without coercion or omitted-field defaults. */
|
|
3060
|
-
function parseArtifactSyncBody(value) {
|
|
3061
|
-
if (!exactRecord(value, SYNC_FIELDS)) return null;
|
|
3062
|
-
const { loopId, manifest } = value;
|
|
3063
|
-
if (typeof loopId !== "string" || loopId.length === 0 || loopId.length > 200 || loopId.includes("\0") || !Array.isArray(manifest)) return null;
|
|
3064
|
-
const entries = [];
|
|
3065
|
-
const paths = /* @__PURE__ */ new Set();
|
|
3066
|
-
for (const raw of manifest) {
|
|
3067
|
-
if (!exactRecord(raw, MANIFEST_FIELDS)) return null;
|
|
3068
|
-
const { path, hash, size, binary, oversize } = raw;
|
|
3069
|
-
const rel = safeRelPath(path);
|
|
3070
|
-
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;
|
|
3071
|
-
if (oversize) {
|
|
3072
|
-
if (size <= 10485760 || hash !== null) return null;
|
|
3073
|
-
entries.push({
|
|
3074
|
-
path: rel,
|
|
3075
|
-
hash: null,
|
|
3076
|
-
size,
|
|
3077
|
-
binary,
|
|
3078
|
-
oversize: true
|
|
3079
|
-
});
|
|
3080
|
-
} else {
|
|
3081
|
-
if (size > 10485760 || !isValidHash(hash)) return null;
|
|
3082
|
-
entries.push({
|
|
3083
|
-
path: rel,
|
|
3084
|
-
hash,
|
|
3085
|
-
size,
|
|
3086
|
-
binary,
|
|
3087
|
-
oversize: false
|
|
3088
|
-
});
|
|
3089
|
-
}
|
|
3090
|
-
paths.add(rel);
|
|
3091
|
-
}
|
|
3092
|
-
return {
|
|
3093
|
-
loopId,
|
|
3094
|
-
manifest: entries
|
|
3095
|
-
};
|
|
3096
|
-
}
|
|
3097
|
-
/**
|
|
3098
|
-
* Machine + run credential helpers. Device tokens (`dk_…`) identify a machine
|
|
3099
|
-
* (its id is derived from the token: `m-sha256(token)[:16]`). A run
|
|
3100
|
-
* LEASE (`rk_…`) is minted per delivery, bound to one run, and carries the
|
|
3101
|
-
* run's least-privilege caps — the CLI dispatch authorizes the `pievo` shim
|
|
3102
|
-
* against it. Its lifecycle is a small state machine (`active` →
|
|
3103
|
-
* `terminal-grace` → `reconciliation-only`/`retired`), not a mint→revoke pair;
|
|
3104
|
-
* see `RunLease` below.
|
|
3105
|
-
*
|
|
3106
|
-
* Leases and connect-key bindings are DURABLE (run_leases / connect_keys
|
|
3107
|
-
* tables): they must survive a deploy, or every restart 401s the in-flight
|
|
3108
|
-
* runs' callbacks/finalize and prevents a freshly connected machine from
|
|
3109
|
-
* enrolling under its owner. The 15-minute `new` idempotency window stays in-process;
|
|
3110
|
-
* losing it only degrades retry deduplication, never persisted data.
|
|
3111
|
-
*/
|
|
3112
|
-
function sha256(s) {
|
|
3113
|
-
return createHash("sha256").update(s).digest("hex");
|
|
3114
|
-
}
|
|
3115
|
-
function mintDeviceToken() {
|
|
3116
|
-
return `dk_${randomBytes(15).toString("hex")}`;
|
|
3117
|
-
}
|
|
3118
|
-
function machineIdFromToken(token) {
|
|
3119
|
-
return `m-${sha256(token).slice(0, 16)}`;
|
|
3120
|
-
}
|
|
3121
|
-
/**
|
|
3122
|
-
* Whether a string has the one current device-token wire shape. This malformed-input
|
|
3123
|
-
* filter runs before enrollment lookup; the connect-key gate remains the auth boundary.
|
|
3124
|
-
*/
|
|
3125
|
-
var DEVICE_TOKEN_RE = /^dk_[0-9a-f]{30}$/;
|
|
3126
|
-
function isDeviceTokenShape(token) {
|
|
3127
|
-
return DEVICE_TOKEN_RE.test(token);
|
|
3128
|
-
}
|
|
3129
|
-
/** Keep bindings long enough for a leisurely paste, then drop (bounded table). */
|
|
3130
|
-
var CONNECT_KEY_TTL_MS = 1440 * 60 * 1e3;
|
|
3131
|
-
function connectKeyFresh(mintedAt, now) {
|
|
3132
|
-
return now - Date.parse(mintedAt) <= CONNECT_KEY_TTL_MS;
|
|
3133
|
-
}
|
|
3134
|
-
/** Bind a freshly minted connect key to its owner and prune expired rows. */
|
|
3135
|
-
async function rememberConnectKey(connectKey, binding) {
|
|
3136
|
-
const now = /* @__PURE__ */ new Date();
|
|
3137
|
-
await db.delete(connectKeys).where(lt(connectKeys.mintedAt, (/* @__PURE__ */ new Date(now.getTime() - CONNECT_KEY_TTL_MS)).toISOString()));
|
|
3138
|
-
const row = {
|
|
3139
|
-
machineId: machineIdFromToken(connectKey),
|
|
3140
|
-
userId: binding.userId,
|
|
3141
|
-
mintedAt: now.toISOString()
|
|
3142
|
-
};
|
|
3143
|
-
await db.insert(connectKeys).values(row).onConflictDoUpdate({
|
|
3144
|
-
target: connectKeys.machineId,
|
|
3145
|
-
set: row
|
|
3146
|
-
});
|
|
3147
|
-
}
|
|
3148
|
-
async function readConnectKeyBinding(connectKey, now = Date.now()) {
|
|
3149
|
-
if (!connectKey) return void 0;
|
|
3150
|
-
const row = (await db.select().from(connectKeys).where(eq(connectKeys.machineId, machineIdFromToken(connectKey))))[0];
|
|
3151
|
-
if (!row || !connectKeyFresh(row.mintedAt, now)) return void 0;
|
|
3152
|
-
return { userId: row.userId };
|
|
3153
|
-
}
|
|
3154
|
-
/** How long a terminal-grace lease stays alive to accept one late wake-report.
|
|
3155
|
-
* A laptop can sleep overnight or across a weekend before the daemon resumes. */
|
|
3156
|
-
var TERMINAL_GRACE_MS = 1440 * 60 * 1e3;
|
|
3157
|
-
/** Leases live in the `run_leases` table, keyed by sha256(full `rk_…` wire
|
|
3158
|
-
* token), so a deploy is invisible to an in-flight run and a DB leak never hands
|
|
3159
|
-
* out live credentials. In rows, `expiresAt` null encodes `Infinity`. */
|
|
3160
|
-
function leaseFromRow(row) {
|
|
3161
|
-
return {
|
|
3162
|
-
runId: row.runId,
|
|
3163
|
-
loopId: row.loopId,
|
|
3164
|
-
machineId: row.machineId,
|
|
3165
|
-
state: row.state,
|
|
3166
|
-
expiresAt: row.expiresAt == null ? Number.POSITIVE_INFINITY : Date.parse(row.expiresAt)
|
|
3167
|
-
};
|
|
3168
|
-
}
|
|
3169
|
-
/** Mint a fresh run lease and return its wire token (`rk_…`, so the unified CLI
|
|
3170
|
-
* dispatch can tell a run credential from a device `dk_…` in O(1) before any
|
|
3171
|
-
* lookup). Starts `active` with no expiry. */
|
|
3172
|
-
var RUN_TOKEN_RE = /^rk_[0-9a-f]{32}$/;
|
|
3173
|
-
function isRunTokenShape(token) {
|
|
3174
|
-
return RUN_TOKEN_RE.test(token);
|
|
3175
|
-
}
|
|
3176
|
-
/** Resolve a run lease. An elapsed reconciliation window is atomically reduced
|
|
3177
|
-
* to durable, non-authorizing retired evidence rather than deleted. */
|
|
3178
|
-
async function resolveLease(token, now = Date.now()) {
|
|
3179
|
-
if (!isRunTokenShape(token)) return void 0;
|
|
3180
|
-
const tokenHash = sha256(token);
|
|
3181
|
-
let row = (await db.select().from(runLeases).where(eq(runLeases.tokenHash, tokenHash)))[0];
|
|
3182
|
-
if (!row) return void 0;
|
|
3183
|
-
if ((row.state === "terminal-grace" || row.state === "reconciliation-only") && row.expiresAt != null && now > Date.parse(row.expiresAt)) {
|
|
3184
|
-
row = (await db.update(runLeases).set({
|
|
3185
|
-
state: "retired",
|
|
3186
|
-
expiresAt: null
|
|
3187
|
-
}).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];
|
|
3188
|
-
if (!row) return void 0;
|
|
3189
|
-
}
|
|
3190
|
-
return leaseFromRow(row);
|
|
3191
|
-
}
|
|
3192
|
-
/** Retire a lease outside a terminal write transaction (canceled/losing report
|
|
3193
|
-
* cleanup and expiry paths). Normal finalize and reclaimed-run
|
|
3194
|
-
* reconcile consume the row inside their store transaction so run+loop+lease are
|
|
3195
|
-
* one single-shot commit. */
|
|
3196
|
-
async function retireLease(token) {
|
|
3197
|
-
await db.delete(runLeases).where(eq(runLeases.tokenHash, sha256(token)));
|
|
3198
|
-
}
|
|
3199
|
-
/** State-aware lease maintenance. Expired reconciliation authority becomes a
|
|
3200
|
-
* durable retired tombstone. Active and already-retired rows are never age-pruned. */
|
|
3201
|
-
async function pruneExpiredLeases(now = Date.now()) {
|
|
3202
|
-
await db.update(runLeases).set({
|
|
3203
|
-
state: "retired",
|
|
3204
|
-
expiresAt: null
|
|
3205
|
-
}).where(and(inArray(runLeases.state, ["terminal-grace", "reconciliation-only"]), isNotNull(runLeases.expiresAt), lt(runLeases.expiresAt, new Date(now).toISOString())));
|
|
3206
|
-
}
|
|
3207
|
-
var newIdempotency = /* @__PURE__ */ new Map();
|
|
3208
|
-
function pruneNewIdempotency(now) {
|
|
3209
|
-
for (const [key, rec] of newIdempotency) if (now - rec.at > 9e5) newIdempotency.delete(key);
|
|
3210
|
-
}
|
|
3211
|
-
function recordNewIdempotency(key, machineId, loopId, now = Date.now()) {
|
|
3212
|
-
pruneNewIdempotency(now);
|
|
3213
|
-
newIdempotency.set(key, {
|
|
3214
|
-
loopId,
|
|
3215
|
-
machineId,
|
|
3216
|
-
at: now
|
|
3217
|
-
});
|
|
3218
|
-
}
|
|
3219
|
-
/** The loop a still-live key already created for THIS machine, or undefined (a miss,
|
|
3220
|
-
* an expired key — dropped here — or a cross-machine record). NON-evicting on a hit:
|
|
3221
|
-
* a genuine retry may arrive more than once within the window. */
|
|
3222
|
-
function readNewIdempotency(key, machineId, now = Date.now()) {
|
|
3223
|
-
const rec = newIdempotency.get(key);
|
|
3224
|
-
if (!rec) return void 0;
|
|
3225
|
-
if (now - rec.at > 9e5) {
|
|
3226
|
-
newIdempotency.delete(key);
|
|
3227
|
-
return;
|
|
3228
|
-
}
|
|
3229
|
-
if (rec.machineId !== machineId) return void 0;
|
|
3230
|
-
return rec.loopId;
|
|
3231
|
-
}
|
|
3232
|
-
/**
|
|
3233
|
-
* Derive a machine's presence from its stored `online` flag + `lastSeen` stamp.
|
|
3234
|
-
* `online` requires BOTH the flag and a fresh stamp (the stored flag lags the
|
|
3235
|
-
* sweep by up to one interval); everything else keys off how stale `lastSeen` is.
|
|
3236
|
-
*/
|
|
3237
|
-
function machinePresence(online, lastSeen, now = Date.now()) {
|
|
3238
|
-
const seenAt = lastSeen ? Date.parse(lastSeen) : NaN;
|
|
3239
|
-
if (!Number.isFinite(seenAt)) return "offline";
|
|
3240
|
-
const age = now - seenAt;
|
|
3241
|
-
if (!!online && age < 3e4) return "online";
|
|
3242
|
-
if (age < 216e5) return "asleep";
|
|
3243
|
-
return "offline";
|
|
3244
|
-
}
|
|
3245
|
-
/**
|
|
3246
|
-
* Tiny semver comparison — just enough to decide "is the daemon older than the
|
|
3247
|
-
* latest published version?" for the web's update hint. No dependency; we only
|
|
3248
|
-
* need numeric-core ordering (major.minor.patch), and a pre-release is treated
|
|
3249
|
-
* as older than its release (a "0.9.0-rc.1" daemon is still behind "0.9.0").
|
|
3250
|
-
*/
|
|
3251
|
-
/** Strict SemVer validation for security/protocol gates. Unlike the update-hint
|
|
3252
|
-
* comparison below, this accepts no whitespace, `v` prefix, partial core, or
|
|
3253
|
-
* trailing garbage. */
|
|
3254
|
-
function isValidSemver(value) {
|
|
3255
|
-
const plus = value.split("+");
|
|
3256
|
-
if (plus.length > 2) return false;
|
|
3257
|
-
const [versionAndPre, build] = plus;
|
|
3258
|
-
if (build !== void 0 && !validIdentifiers(build, false)) return false;
|
|
3259
|
-
const dash = versionAndPre.indexOf("-");
|
|
3260
|
-
const numeric = dash < 0 ? versionAndPre : versionAndPre.slice(0, dash);
|
|
3261
|
-
const pre = dash < 0 ? void 0 : versionAndPre.slice(dash + 1);
|
|
3262
|
-
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(numeric)) return false;
|
|
3263
|
-
return pre === void 0 || validIdentifiers(pre, true);
|
|
3264
|
-
}
|
|
3265
|
-
function validIdentifiers(value, rejectNumericLeadingZero) {
|
|
3266
|
-
if (!value) return false;
|
|
3267
|
-
return value.split(".").every((identifier) => {
|
|
3268
|
-
if (!/^[0-9A-Za-z-]+$/.test(identifier)) return false;
|
|
3269
|
-
return !rejectNumericLeadingZero || !/^\d+$/.test(identifier) || identifier === "0" || !identifier.startsWith("0");
|
|
3270
|
-
});
|
|
3271
|
-
}
|
|
3272
|
-
function core(v) {
|
|
3273
|
-
const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
3274
|
-
if (!m) return null;
|
|
3275
|
-
return [
|
|
3276
|
-
Number(m[1]),
|
|
3277
|
-
Number(m[2]),
|
|
3278
|
-
Number(m[3])
|
|
3279
|
-
];
|
|
3280
|
-
}
|
|
3281
|
-
function isPre(v) {
|
|
3282
|
-
return /^\s*v?\d+\.\d+\.\d+-/.test(v);
|
|
3283
|
-
}
|
|
3284
|
-
/**
|
|
3285
|
-
* `true` when `current` is strictly older than `latest`. Returns `false` on any
|
|
3286
|
-
* unparseable/equal/newer input — the hint is opt-in, so we only ever show it
|
|
3287
|
-
* when we can be confident the daemon is genuinely behind.
|
|
3288
|
-
*/
|
|
3289
|
-
function isOutdated(current, latest) {
|
|
3290
|
-
if (!current || !latest) return false;
|
|
3291
|
-
const a = core(current);
|
|
3292
|
-
const b = core(latest);
|
|
3293
|
-
if (!a || !b) return false;
|
|
3294
|
-
for (let i = 0; i < 3; i++) {
|
|
3295
|
-
const ai = a[i];
|
|
3296
|
-
const bi = b[i];
|
|
3297
|
-
if (ai < bi) return true;
|
|
3298
|
-
if (ai > bi) return false;
|
|
3299
|
-
}
|
|
3300
|
-
return isPre(current) && !isPre(latest);
|
|
3301
|
-
}
|
|
3302
|
-
var reserved = new Set(RESERVED_LOOP_TAGS);
|
|
3303
|
-
function normalizeLoopTag(value) {
|
|
3304
|
-
return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
3305
|
-
}
|
|
3306
|
-
function validateLoopTags(value) {
|
|
3307
|
-
if (value === void 0) return {
|
|
3308
|
-
ok: true,
|
|
3309
|
-
value: []
|
|
3310
|
-
};
|
|
3311
|
-
if (!Array.isArray(value)) return {
|
|
3312
|
-
ok: false,
|
|
3313
|
-
detail: "tags must be an array of strings"
|
|
3314
|
-
};
|
|
3315
|
-
if (value.length > 4) return {
|
|
3316
|
-
ok: false,
|
|
3317
|
-
detail: `tags must contain at most 4 values`
|
|
3318
|
-
};
|
|
3319
|
-
const tags = [];
|
|
3320
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3321
|
-
for (const entry of value) {
|
|
3322
|
-
if (typeof entry !== "string") return {
|
|
3323
|
-
ok: false,
|
|
3324
|
-
detail: "tags must contain only strings"
|
|
3325
|
-
};
|
|
3326
|
-
if (/\p{Cc}|\p{Cf}|\p{Default_Ignorable_Code_Point}/u.test(entry)) return {
|
|
3327
|
-
ok: false,
|
|
3328
|
-
detail: "tags must not contain control or invisible characters"
|
|
3329
|
-
};
|
|
3330
|
-
const tag = normalizeLoopTag(entry);
|
|
3331
|
-
if (!tag) return {
|
|
3332
|
-
ok: false,
|
|
3333
|
-
detail: "tags must not contain empty values"
|
|
3334
|
-
};
|
|
3335
|
-
if ([...tag].length > 64) return {
|
|
3336
|
-
ok: false,
|
|
3337
|
-
detail: `each tag must contain at most 64 characters`
|
|
3338
|
-
};
|
|
3339
|
-
if (reserved.has(tag)) return {
|
|
3340
|
-
ok: false,
|
|
3341
|
-
detail: `reserved tag: ${tag}`
|
|
3342
|
-
};
|
|
3343
|
-
if (seen.has(tag)) return {
|
|
3344
|
-
ok: false,
|
|
3345
|
-
detail: `duplicate tag: ${tag}`
|
|
3346
|
-
};
|
|
3347
|
-
seen.add(tag);
|
|
3348
|
-
tags.push(tag);
|
|
3349
|
-
}
|
|
3350
|
-
return {
|
|
3351
|
-
ok: true,
|
|
3352
|
-
value: [...tags].sort()
|
|
3353
|
-
};
|
|
3354
|
-
}
|
|
3355
|
-
//#endregion
|
|
3356
|
-
export { getLoop as $, reclaimRun as $t, createLoop as A, updateLoop as An, logger as At, enqueueRun as B, parseArtifactSyncBody as Bt, claimReadyRunForMachine as C, sha256Buf as Cn, listLoops as Ct, countRuns as D, stopLoop as Dn, listRuns as Dt, countRunSnapshots as E, startLoop as En, listMachinesForUser as Et, dbWatchdogFailureThreshold as F, machinePresence as Ft, forceDeleteMachine as G, pruneRunSnapshots as Gt, expirePendingRun as H, pauseLoopState as Ht, dbWatchdogIntervalMs as I, machineReferencesBlob as It, getBaseURL as J, putTerminalReportIncidentIfAbsent as Jt, gcIntervalMs as K, putReportReceiptIfAbsent as Kt, dbWatchdogTimeoutMs as L, mintDeviceToken as Lt, dataDir as M, upsertConfiguredArtifactFile as Mn, loopIdsWithSnapshots as Mt, db as N, validateLoopTags as Nn, loopsForMachine as Nt, createDefu as O, toKebabCase as On, listRunsBefore as Ot, dbWatchdogEnabled as P, wildcardMatch as Pn, machineIdFromToken as Pt, getHost as Q, recentLoopUsage as Qt, defu as R, normalizePathname as Rt, capitalizeFirstLetter as S, sha256 as Sn, listArtifacts as St, coerceCodingAgent as T, snapshotRetention as Tn, listMachines as Tt, finalizeRunningRun as U, prevRunSnapshot as Ut, env as V, pauseLoop as Vt, forceDeleteLoop as W, pruneExpiredLeases as Wt, getEnvVar as X, readConnectKeyBinding as Xt, getBooleanEnvVar as Y, r2Config as Yt, getExactTerminalReportIncident as Z, readNewIdempotency as Zt, blobExists as _, runningRunForLoop as _n, isTest as _t, BetterAuthError as a, recordRunReportOnce as an, getRunSnapshot as at, blobSizes as b, safeRelPath as bn, kAPIErrorHeaderSymbol as bt, ENV as c, releaseAbsentReconciliations as cn, hasRunningRun as ct, ValidationError$1 as d, requestDeleteLoop as dn, isDeviceTokenShape as dt, reconcileConfiguredArtifacts as en, getMachine as et, acknowledgeRetiredReport as f, requestRunCancel as fn, isDynamicBaseURLConfig as ft, betterFetch as g, runMigrations as gn, isSafeUrlScheme as gt, artifactFileReferencesHash as h, retireLease as hn, isRequestLike as ht, BLOB_CAP as i, recordNewIdempotency as in, getRun as it, createMachine as j, updateMachine as jn, loginGateEnabled as jt, createFetch as k, tryDeleteLoop as kn, liveBlobRefs as kt, SYNC_BODY_CAP as l, rememberConnectKey as ln, initializeCronCadence as lt, advanceDueSchedules as m, resolveLease as mn, isProduction as mt, APIError$1 as n, reconciliationStatesForRuns as nn, getProtocol as nt, BetterCallError as o, refreshRunHeartbeats as on, getTerminalReportIncidents as ot, acknowledgeRetiredTerminalIncident as p, resolveBaseURL as pn, isOutdated as pt, getArtifactFile as q, putRunSnapshot as qt, BASE_ERROR_CODES as r, recordBlob as rn, getReportReceipt as rt, CODING_AGENTS as s, rejectTerminalReport as sn, hasPendingRun as st, APIError as t, reconcileReclaimedRun as tn, getOrigin as tt, TERMINAL_GRACE_MS as u, repairTerminalRunLeases as un, isDevelopment as ut, blobGcGraceMs as v, runningRunsForMachine as vn, isValidHash as vt, closeClient as w, snapshotBlobRefs as wn, listLoopsForUser as wt, buildLoopManifest as x, setMachineOnline as xn, lastRun as xt, blobHashesOlderThan as y, runs as yn, isValidSemver as yt, deleteBlob as z, openRuns as zt };
|