@vercel/factory 0.0.11 → 0.0.13
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/dist/approval-CwhTP1XB.mjs +269 -0
- package/dist/approval-CwhTP1XB.mjs.map +1 -0
- package/dist/blob/index.d.mts +1 -1
- package/dist/build-factory.d.mts +1 -1
- package/dist/build-factory.mjs +1 -1
- package/dist/change-CzEAoFVE.mjs +620 -0
- package/dist/change-CzEAoFVE.mjs.map +1 -0
- package/dist/connectors/slack.d.mts +4 -4
- package/dist/connectors/slack.mjs +81 -30
- package/dist/connectors/slack.mjs.map +1 -1
- package/dist/{approval-CDiBco2V.d.mts → dispatch-B77cuPxh.d.mts} +293 -99
- package/dist/{driver-CrZsy7sm.d.mts → driver-BJutB0PE.d.mts} +2 -2
- package/dist/driver-Bbd25rNM.mjs.map +1 -1
- package/dist/eve/index.d.mts +546 -120
- package/dist/eve/index.mjs +358 -82
- package/dist/eve/index.mjs.map +1 -1
- package/dist/{factory-config-ifd3JxiU.mjs → factory-config-BJcjsPKl.mjs} +42 -5
- package/dist/factory-config-BJcjsPKl.mjs.map +1 -0
- package/dist/{factory-config-Bu4mklm9.d.mts → factory-config-D2yU3G0c.d.mts} +21 -2
- package/dist/{github-wThBfbm-.d.mts → github-DXsxboUL.d.mts} +25 -2
- package/dist/github.d.mts +2 -2
- package/dist/github.mjs +47 -1
- package/dist/github.mjs.map +1 -1
- package/dist/index.d.mts +129 -59
- package/dist/index.mjs +336 -333
- package/dist/index.mjs.map +1 -1
- package/dist/postgres/index.d.mts +1 -1
- package/package.json +1 -1
- package/dist/approval-DdvEKLiE.mjs +0 -132
- package/dist/approval-DdvEKLiE.mjs.map +0 -1
- package/dist/change-BPYhWKuL.mjs +0 -230
- package/dist/change-BPYhWKuL.mjs.map +0 -1
- package/dist/factory-config-ifd3JxiU.mjs.map +0 -1
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { d as schemaVersionSchema, i as repositoryIdSchema, l as isoDateTimeSchema, n as factoryIdSchema, s as taskIdSchema, t as changeIdSchema } from "./id-DwCihQr4.mjs";
|
|
2
|
+
import { _ as taskExecutionRepositoryIdsSchema } from "./approval-CwhTP1XB.mjs";
|
|
3
|
+
import { n as RecordNotFoundError, r as VersionConflictError } from "./driver-Bbd25rNM.mjs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
//#region src/budget.ts
|
|
7
|
+
/**
|
|
8
|
+
* Stable factory identity for budget rows. Nothing mints factory ids yet, so
|
|
9
|
+
* the app derives one from a deployment-stable seed such as the Vercel
|
|
10
|
+
* project id.
|
|
11
|
+
*/
|
|
12
|
+
function deriveFactoryId(seed) {
|
|
13
|
+
return factoryIdSchema.parse(`fac_${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`);
|
|
14
|
+
}
|
|
15
|
+
/** The budget month ("YYYY-MM", UTC) a timestamp falls into. */
|
|
16
|
+
function budgetMonthOf(isoDateTime) {
|
|
17
|
+
return isoDateTime.slice(0, 7);
|
|
18
|
+
}
|
|
19
|
+
function microsOf(usd) {
|
|
20
|
+
return Math.round(usd * 1e6);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Monthly spend is derived from task rows, never stored: each task counts its
|
|
24
|
+
* settled cost once that exists, otherwise its held reservation. A task
|
|
25
|
+
* settles once, so a settled row never also counts its reservation.
|
|
26
|
+
* Attribution uses the reservation time so tasks that wait across a month
|
|
27
|
+
* boundary charge the month they actually ran in.
|
|
28
|
+
*/
|
|
29
|
+
function computeMonthSpendUsd(tasks, month) {
|
|
30
|
+
let spendMicros = 0;
|
|
31
|
+
for (const task of tasks) {
|
|
32
|
+
if (budgetMonthOf(task.reservedAt ?? task.createdAt) !== month) continue;
|
|
33
|
+
spendMicros += microsOf(task.settledUsd ?? task.reservedUsd ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return spendMicros / 1e6;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* True when one more per-task slice fits under the monthly total. Dispatch
|
|
39
|
+
* refuses on this exact predicate and budgetStatus reports "exhausted" on its
|
|
40
|
+
* negation, so refusing and reporting can never disagree.
|
|
41
|
+
*/
|
|
42
|
+
function canReserve(spendUsd, limits) {
|
|
43
|
+
return microsOf(spendUsd) + microsOf(limits.perTaskReservationUsd) <= microsOf(limits.totalUsd);
|
|
44
|
+
}
|
|
45
|
+
function budgetStatus(spendUsd, limits) {
|
|
46
|
+
if (!canReserve(spendUsd, limits)) return "exhausted";
|
|
47
|
+
if (microsOf(spendUsd) * 100 >= microsOf(limits.totalUsd) * limits.warningPercent) return "warning";
|
|
48
|
+
return "ok";
|
|
49
|
+
}
|
|
50
|
+
const TERMINAL_TASK_STATES = /* @__PURE__ */ new Set([
|
|
51
|
+
"succeeded",
|
|
52
|
+
"failed",
|
|
53
|
+
"cancelled"
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* Settle reserved terminal tasks the session hook did not: a task that ended
|
|
57
|
+
* after its session (a change verified later) or whose session died. Settles
|
|
58
|
+
* at the recorded usage, or at zero with a warning reason when none was
|
|
59
|
+
* recorded within the grace window.
|
|
60
|
+
*/
|
|
61
|
+
async function sweepSettlements(stores, options = {}) {
|
|
62
|
+
const graceMs = (options.graceMinutes ?? 10) * 6e4;
|
|
63
|
+
const nowMs = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
64
|
+
const settled = [];
|
|
65
|
+
for (const task of await stores.tasks.list()) {
|
|
66
|
+
if (task.reservedUsd === void 0 || task.settledUsd !== void 0) continue;
|
|
67
|
+
if (!TERMINAL_TASK_STATES.has(task.state)) continue;
|
|
68
|
+
if (nowMs - Date.parse(task.updatedAt) < graceMs) continue;
|
|
69
|
+
const totalUsd = task.usage?.costUsd ?? 0;
|
|
70
|
+
try {
|
|
71
|
+
const result = await stores.tasks.settle(task.id, { totalUsd }, { reason: task.usage === void 0 ? "usage missing after ten minutes; settled at zero" : "measured usage settled by sweep" });
|
|
72
|
+
settled.push({
|
|
73
|
+
taskId: result.id,
|
|
74
|
+
totalUsd
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { settled };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Ensure the month's budget row, derive spend, and record threshold crossings
|
|
84
|
+
* write-once. Delivery comes first and the stamp only lands after it, so a
|
|
85
|
+
* failed notification retries on the next sweep instead of being lost; the
|
|
86
|
+
* write-once stamp still keeps each threshold to one message per month.
|
|
87
|
+
*/
|
|
88
|
+
async function sweepBudget(stores, options) {
|
|
89
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
90
|
+
const month = options.month ?? budgetMonthOf(now().toISOString());
|
|
91
|
+
const budget = await stores.budgets.ensure({
|
|
92
|
+
factoryId: options.factoryId,
|
|
93
|
+
month,
|
|
94
|
+
limits: options.limits
|
|
95
|
+
});
|
|
96
|
+
const spendUsd = computeMonthSpendUsd(await stores.tasks.list(), month);
|
|
97
|
+
const status = budgetStatus(spendUsd, options.limits);
|
|
98
|
+
async function crossThreshold(notice, outcome) {
|
|
99
|
+
const field = notice === "warning" ? "warningAt" : "exhaustedAt";
|
|
100
|
+
if (budget.notices[field] !== void 0) return false;
|
|
101
|
+
if (options.notify !== void 0) try {
|
|
102
|
+
await options.notify({
|
|
103
|
+
outcome,
|
|
104
|
+
month,
|
|
105
|
+
spendUsd,
|
|
106
|
+
limits: options.limits
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return (await stores.budgets.stampNotice(month, notice)).stamped;
|
|
112
|
+
}
|
|
113
|
+
let warned = false;
|
|
114
|
+
let exhausted = false;
|
|
115
|
+
if (status === "exhausted") {
|
|
116
|
+
exhausted = await crossThreshold("exhausted", "budget_exhausted");
|
|
117
|
+
if (budget.notices.warningAt === void 0) await stores.budgets.stampNotice(month, "warning");
|
|
118
|
+
} else if (status === "warning") warned = await crossThreshold("warning", "budget_warning");
|
|
119
|
+
return {
|
|
120
|
+
month,
|
|
121
|
+
spendUsd,
|
|
122
|
+
status,
|
|
123
|
+
warned,
|
|
124
|
+
exhausted
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/coordination.ts
|
|
129
|
+
/** Create one non-executable parent and one independently executable child per repository. */
|
|
130
|
+
async function createCoordination(stores, input) {
|
|
131
|
+
const repositoryIds = [...new Set(input.repositoryIds)];
|
|
132
|
+
if (repositoryIds.length < 2) throw new Error("coordination requires at least two distinct repositories");
|
|
133
|
+
const parent = await stores.tasks.create({
|
|
134
|
+
repositoryIds,
|
|
135
|
+
kind: "coordination",
|
|
136
|
+
origin: input.signalId === void 0 ? { operator: input.operator } : { signalId: input.signalId },
|
|
137
|
+
replyTo: input.replyTo,
|
|
138
|
+
dedupeKey: input.dedupeKey
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
parent: parent.state === "queued" ? await stores.tasks.transition(parent.id, "running", { reason: "repository-scoped child tasks created" }) : parent,
|
|
142
|
+
children: await Promise.all(repositoryIds.map((repositoryId) => stores.tasks.create({
|
|
143
|
+
repositoryIds: [repositoryId],
|
|
144
|
+
kind: "code_change",
|
|
145
|
+
origin: { parentTaskId: parent.id },
|
|
146
|
+
replyTo: input.replyTo,
|
|
147
|
+
dedupeKey: input.dedupeKey === void 0 ? void 0 : `${input.dedupeKey}:${repositoryId}`
|
|
148
|
+
})))
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Reconcile a coordination parent from its direct children without ever dispatching the parent. */
|
|
152
|
+
async function reconcileCoordination(stores, parentTaskId, options) {
|
|
153
|
+
const parent = await stores.tasks.get(parentTaskId);
|
|
154
|
+
if (parent === null) throw new RecordNotFoundError("tasks", parentTaskId);
|
|
155
|
+
if (parent.kind !== "coordination") throw new Error(`task ${parentTaskId} is ${parent.kind}, not coordination`);
|
|
156
|
+
const children = (await stores.tasks.list()).filter((task) => task.origin.parentTaskId === parentTaskId);
|
|
157
|
+
if (children.length !== parent.repositoryIds.length) return {
|
|
158
|
+
status: "pending",
|
|
159
|
+
parent,
|
|
160
|
+
children
|
|
161
|
+
};
|
|
162
|
+
if (parent.state === "succeeded" || parent.state === "failed") return {
|
|
163
|
+
status: parent.state,
|
|
164
|
+
transitioned: false,
|
|
165
|
+
parent,
|
|
166
|
+
children
|
|
167
|
+
};
|
|
168
|
+
if (children.some((child) => child.state === "failed" || child.state === "cancelled")) {
|
|
169
|
+
await options.present({
|
|
170
|
+
status: "failed",
|
|
171
|
+
parent,
|
|
172
|
+
children
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
status: "failed",
|
|
176
|
+
transitioned: true,
|
|
177
|
+
parent: await stores.tasks.transition(parent.id, "failed", { reason: "one or more repository tasks failed" }),
|
|
178
|
+
children
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (!children.every((child) => child.state === "succeeded")) return {
|
|
182
|
+
status: "pending",
|
|
183
|
+
parent,
|
|
184
|
+
children
|
|
185
|
+
};
|
|
186
|
+
await options.present({
|
|
187
|
+
status: "succeeded",
|
|
188
|
+
parent,
|
|
189
|
+
children
|
|
190
|
+
});
|
|
191
|
+
await stores.tasks.transition(parent.id, "verifying", { reason: "all repository tasks succeeded" });
|
|
192
|
+
return {
|
|
193
|
+
status: "succeeded",
|
|
194
|
+
transitioned: true,
|
|
195
|
+
parent: await stores.tasks.transition(parent.id, "succeeded", { reason: "aggregate multi-repository handoff is ready" }),
|
|
196
|
+
children
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/schema/transitions.ts
|
|
201
|
+
const taskInitialState = "queued";
|
|
202
|
+
const taskTransitions = [
|
|
203
|
+
{
|
|
204
|
+
from: "queued",
|
|
205
|
+
to: "running",
|
|
206
|
+
trigger: "dependencies have a change, reservation succeeded, devbox created",
|
|
207
|
+
actor: "reconciler"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
from: "queued",
|
|
211
|
+
to: "failed",
|
|
212
|
+
trigger: "reservation refused",
|
|
213
|
+
actor: "reconciler"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
from: "queued",
|
|
217
|
+
to: "needs_human",
|
|
218
|
+
trigger: "kind is question",
|
|
219
|
+
actor: "system"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
from: "queued",
|
|
223
|
+
to: "cancelled",
|
|
224
|
+
trigger: "cancel or kill",
|
|
225
|
+
actor: "operator"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
from: "running",
|
|
229
|
+
to: "verifying",
|
|
230
|
+
trigger: "devbox task reported complete",
|
|
231
|
+
actor: "reconciler"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
from: "running",
|
|
235
|
+
to: "queued",
|
|
236
|
+
trigger: "launch failed below attempt limit",
|
|
237
|
+
actor: "reconciler"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
from: "running",
|
|
241
|
+
to: "failed",
|
|
242
|
+
trigger: "devbox task errored, deadline exceeded, or launch failed at attempt limit",
|
|
243
|
+
actor: "reconciler"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
from: "running",
|
|
247
|
+
to: "needs_human",
|
|
248
|
+
trigger: "an open QuestionTask requests human input or action",
|
|
249
|
+
actor: "reconciler"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
from: "running",
|
|
253
|
+
to: "cancelled",
|
|
254
|
+
trigger: "cancel or kill",
|
|
255
|
+
actor: "operator"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
from: "running",
|
|
259
|
+
to: "stalled",
|
|
260
|
+
trigger: "no progress within the stall window",
|
|
261
|
+
actor: "reconciler"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
from: "stalled",
|
|
265
|
+
to: "queued",
|
|
266
|
+
trigger: "replacement attempt below attempt limit, or operator retry",
|
|
267
|
+
actor: "reconciler"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
from: "stalled",
|
|
271
|
+
to: "failed",
|
|
272
|
+
trigger: "attempt limit exhausted",
|
|
273
|
+
actor: "reconciler"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
from: "stalled",
|
|
277
|
+
to: "cancelled",
|
|
278
|
+
trigger: "cancel or kill",
|
|
279
|
+
actor: "operator"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
from: "verifying",
|
|
283
|
+
to: "succeeded",
|
|
284
|
+
trigger: "kind criterion true",
|
|
285
|
+
actor: "reconciler"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
from: "verifying",
|
|
289
|
+
to: "running",
|
|
290
|
+
trigger: "verification failed below attempt limit",
|
|
291
|
+
actor: "reconciler"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
from: "verifying",
|
|
295
|
+
to: "failed",
|
|
296
|
+
trigger: "verification failed at attempt limit",
|
|
297
|
+
actor: "reconciler"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
from: "verifying",
|
|
301
|
+
to: "cancelled",
|
|
302
|
+
trigger: "cancel or kill",
|
|
303
|
+
actor: "operator"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
from: "needs_human",
|
|
307
|
+
to: "succeeded",
|
|
308
|
+
trigger: "question answered",
|
|
309
|
+
actor: "operator"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
from: "needs_human",
|
|
313
|
+
to: "queued",
|
|
314
|
+
trigger: "operator retry",
|
|
315
|
+
actor: "operator"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
from: "needs_human",
|
|
319
|
+
to: "cancelled",
|
|
320
|
+
trigger: "cancel or kill",
|
|
321
|
+
actor: "operator"
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
from: "failed",
|
|
325
|
+
to: "queued",
|
|
326
|
+
trigger: "operator retry",
|
|
327
|
+
actor: "operator"
|
|
328
|
+
}
|
|
329
|
+
];
|
|
330
|
+
const changeInitialState = "verifying";
|
|
331
|
+
const changeTransitions = [
|
|
332
|
+
{
|
|
333
|
+
from: "verifying",
|
|
334
|
+
to: "ready",
|
|
335
|
+
trigger: "configured verification policy completed, risk and confidence written",
|
|
336
|
+
actor: "reconciler"
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
from: "verifying",
|
|
340
|
+
to: "closed",
|
|
341
|
+
trigger: "verification failed at attempt limit or pr closed upstream",
|
|
342
|
+
actor: "reconciler"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
from: "ready",
|
|
346
|
+
to: "merged",
|
|
347
|
+
trigger: "pr merged on github",
|
|
348
|
+
actor: "webhook"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
from: "ready",
|
|
352
|
+
to: "closed",
|
|
353
|
+
trigger: "pr closed without merge",
|
|
354
|
+
actor: "webhook"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
from: "merged",
|
|
358
|
+
to: "deployed",
|
|
359
|
+
trigger: "production deployment ready containing the merge sha",
|
|
360
|
+
actor: "webhook"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
from: "deployed",
|
|
364
|
+
to: "rolled_out",
|
|
365
|
+
trigger: "no flag, or flag ramped to full and held",
|
|
366
|
+
actor: "foreman"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
from: "deployed",
|
|
370
|
+
to: "rolled_back",
|
|
371
|
+
trigger: "rollback rule crossed",
|
|
372
|
+
actor: "foreman"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
from: "rolled_out",
|
|
376
|
+
to: "rolled_back",
|
|
377
|
+
trigger: "rollback rule crossed or flag flipped off",
|
|
378
|
+
actor: "foreman"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
from: "rolled_back",
|
|
382
|
+
to: "deployed",
|
|
383
|
+
trigger: "human retried the ramp",
|
|
384
|
+
actor: "human"
|
|
385
|
+
}
|
|
386
|
+
];
|
|
387
|
+
const signalInitialOutcome = "pending";
|
|
388
|
+
const signalOutcomeTransitions = [
|
|
389
|
+
{
|
|
390
|
+
from: "pending",
|
|
391
|
+
to: "accepted",
|
|
392
|
+
trigger: "classified as actionable",
|
|
393
|
+
actor: "reconciler"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
from: "pending",
|
|
397
|
+
to: "duplicate",
|
|
398
|
+
trigger: "classified as duplicate",
|
|
399
|
+
actor: "reconciler"
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
from: "pending",
|
|
403
|
+
to: "unactionable",
|
|
404
|
+
trigger: "classified as unactionable",
|
|
405
|
+
actor: "reconciler"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
from: "pending",
|
|
409
|
+
to: "low_confidence",
|
|
410
|
+
trigger: "classified below confidence threshold",
|
|
411
|
+
actor: "reconciler"
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
from: "pending",
|
|
415
|
+
to: "ignored",
|
|
416
|
+
trigger: "classified as ignorable",
|
|
417
|
+
actor: "reconciler"
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
function isTransitionAllowed(transitions, from, to) {
|
|
421
|
+
return transitions.some((transition) => transition.from === from && transition.to === to);
|
|
422
|
+
}
|
|
423
|
+
function legalNextStates(transitions, from) {
|
|
424
|
+
return [...new Set(transitions.filter((transition) => transition.from === from).map((t) => t.to))];
|
|
425
|
+
}
|
|
426
|
+
var InvalidTransitionError = class extends Error {
|
|
427
|
+
from;
|
|
428
|
+
to;
|
|
429
|
+
legalNextStates;
|
|
430
|
+
constructor(from, to, legal) {
|
|
431
|
+
super(legal.length > 0 ? `invalid transition from "${from}" to "${to}"; legal next states: ${legal.map((state) => `"${state}"`).join(", ")}` : `invalid transition from "${from}" to "${to}"; "${from}" is terminal`);
|
|
432
|
+
this.name = "InvalidTransitionError";
|
|
433
|
+
this.from = from;
|
|
434
|
+
this.to = to;
|
|
435
|
+
this.legalNextStates = legal;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
function assertTransition(transitions, from, to) {
|
|
439
|
+
if (!isTransitionAllowed(transitions, from, to)) throw new InvalidTransitionError(from, to, legalNextStates(transitions, from));
|
|
440
|
+
}
|
|
441
|
+
function terminalStates(transitions) {
|
|
442
|
+
const states = /* @__PURE__ */ new Set();
|
|
443
|
+
for (const transition of transitions) {
|
|
444
|
+
states.add(transition.from);
|
|
445
|
+
states.add(transition.to);
|
|
446
|
+
}
|
|
447
|
+
return [...states].filter((state) => !transitions.some((transition) => transition.from === state));
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/dispatch.ts
|
|
451
|
+
var DispatchRefusedError = class extends Error {
|
|
452
|
+
taskId;
|
|
453
|
+
reason;
|
|
454
|
+
constructor(taskId, reason) {
|
|
455
|
+
super(`dispatch of "${taskId}" refused: ${reason}`);
|
|
456
|
+
this.name = "DispatchRefusedError";
|
|
457
|
+
this.taskId = taskId;
|
|
458
|
+
this.reason = reason;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* An exhausted budget is definitive, not a retryable refusal: the task has
|
|
463
|
+
* already been failed and receipted when this throws, so it never re-enters
|
|
464
|
+
* the sweep. Operator retry recovers it once budget frees up.
|
|
465
|
+
*/
|
|
466
|
+
var BudgetExhaustedError = class extends Error {
|
|
467
|
+
taskId;
|
|
468
|
+
month;
|
|
469
|
+
spendUsd;
|
|
470
|
+
constructor(taskId, month, spendUsd) {
|
|
471
|
+
super(`dispatch of "${taskId}" refused: budget for ${month} is exhausted at $${spendUsd}`);
|
|
472
|
+
this.name = "BudgetExhaustedError";
|
|
473
|
+
this.taskId = taskId;
|
|
474
|
+
this.month = month;
|
|
475
|
+
this.spendUsd = spendUsd;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;
|
|
479
|
+
/**
|
|
480
|
+
* The handshake between recorded work and execution: win the store-gated
|
|
481
|
+
* transition (at most one dispatcher can succeed), then start the run. A
|
|
482
|
+
* launch failure re-queues the task (bumping its attempt) until the attempt
|
|
483
|
+
* limit, then fails it; a crash between the transition and the launch leaves
|
|
484
|
+
* a running task with no execution, which the sweep reaps.
|
|
485
|
+
*/
|
|
486
|
+
async function dispatchTask(stores, taskId, options) {
|
|
487
|
+
const task = await stores.tasks.get(taskId);
|
|
488
|
+
if (task === null) throw new RecordNotFoundError("tasks", taskId);
|
|
489
|
+
if (task.kind === "question") throw new DispatchRefusedError(taskId, "question tasks are answered, not dispatched");
|
|
490
|
+
if (task.kind === "coordination") throw new DispatchRefusedError(taskId, "coordination tasks are reconciled, not dispatched");
|
|
491
|
+
if (!taskExecutionRepositoryIdsSchema.safeParse(task.repositoryIds).success) throw new DispatchRefusedError(taskId, "task execution requires exactly one repository");
|
|
492
|
+
if (task.approval === "required") throw new DispatchRefusedError(taskId, "awaiting human approval");
|
|
493
|
+
if (task.approval === "denied") throw new DispatchRefusedError(taskId, "approval was denied");
|
|
494
|
+
for (const dependencyId of task.dependsOn ?? []) {
|
|
495
|
+
const dependency = await stores.tasks.get(dependencyId);
|
|
496
|
+
if (dependency === null) throw new DispatchRefusedError(taskId, `dependency "${dependencyId}" does not exist`);
|
|
497
|
+
if (dependency.changeId === void 0) throw new DispatchRefusedError(taskId, `dependency "${dependencyId}" has not produced a change`);
|
|
498
|
+
}
|
|
499
|
+
let reservation;
|
|
500
|
+
if (options.budget !== void 0 && task.state === "queued") {
|
|
501
|
+
const budgetNow = (options.budget.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
502
|
+
const month = options.budget.month ?? budgetMonthOf(budgetNow);
|
|
503
|
+
const spendUsd = computeMonthSpendUsd((await stores.tasks.list()).map((row) => row.id === taskId ? {
|
|
504
|
+
...row,
|
|
505
|
+
reservedUsd: void 0
|
|
506
|
+
} : row), month);
|
|
507
|
+
const { totalUsd, perTaskReservationUsd } = options.budget.limits;
|
|
508
|
+
if (!canReserve(spendUsd, options.budget.limits)) {
|
|
509
|
+
const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;
|
|
510
|
+
try {
|
|
511
|
+
await stores.tasks.transition(taskId, "failed", {
|
|
512
|
+
reason,
|
|
513
|
+
expectFrom: "queued"
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error instanceof InvalidTransitionError || error instanceof VersionConflictError) throw new DispatchRefusedError(taskId, "another dispatcher took the task");
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
if (task.repositoryIds !== void 0) await stores.receipts.append({
|
|
520
|
+
repositoryIds: task.repositoryIds,
|
|
521
|
+
taskId,
|
|
522
|
+
kind: task.kind,
|
|
523
|
+
state: "reservation_refused",
|
|
524
|
+
phase: "budget",
|
|
525
|
+
reason,
|
|
526
|
+
attempt: task.attempt,
|
|
527
|
+
dedupeKey: `task:${taskId}:reservation_refused:${task.attempt}`
|
|
528
|
+
}).catch(() => void 0);
|
|
529
|
+
throw new BudgetExhaustedError(taskId, month, spendUsd);
|
|
530
|
+
}
|
|
531
|
+
reservation = {
|
|
532
|
+
reservedUsd: perTaskReservationUsd,
|
|
533
|
+
reservedAt: budgetNow
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const running = await stores.tasks.transition(taskId, "running", reservation === void 0 ? void 0 : { patch: {
|
|
537
|
+
reservedUsd: reservation.reservedUsd,
|
|
538
|
+
reservedAt: reservation.reservedAt
|
|
539
|
+
} });
|
|
540
|
+
let execution;
|
|
541
|
+
try {
|
|
542
|
+
execution = await options.launch(running);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const reason = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
545
|
+
const limit = options.maxLaunchAttempts ?? 45;
|
|
546
|
+
if (running.attempt < limit) {
|
|
547
|
+
const retryReason = `launch failed (attempt ${running.attempt} of ${limit}), will retry: ${reason}`;
|
|
548
|
+
await stores.tasks.transition(taskId, "queued", { reason: retryReason }).catch(() => void 0);
|
|
549
|
+
if (running.repositoryIds !== void 0) await stores.receipts.append({
|
|
550
|
+
repositoryIds: running.repositoryIds,
|
|
551
|
+
taskId,
|
|
552
|
+
kind: "task",
|
|
553
|
+
state: "launch_retry",
|
|
554
|
+
reason: retryReason,
|
|
555
|
+
dedupeKey: `task:${taskId}:launch_retry:${running.attempt}`
|
|
556
|
+
}).catch(() => void 0);
|
|
557
|
+
} else await stores.tasks.transition(taskId, "failed", { reason: `launch failed after ${running.attempt} attempts: ${reason}` }).catch(() => void 0);
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
if (execution === void 0) return running;
|
|
561
|
+
return stores.tasks.recordExecution(taskId, execution).catch(() => running);
|
|
562
|
+
}
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/schema/change.ts
|
|
565
|
+
const levelSchema = z.enum([
|
|
566
|
+
"low",
|
|
567
|
+
"medium",
|
|
568
|
+
"high"
|
|
569
|
+
]);
|
|
570
|
+
const changeStateSchema = z.enum([
|
|
571
|
+
"verifying",
|
|
572
|
+
"ready",
|
|
573
|
+
"merged",
|
|
574
|
+
"deployed",
|
|
575
|
+
"rolled_out",
|
|
576
|
+
"rolled_back",
|
|
577
|
+
"closed"
|
|
578
|
+
]);
|
|
579
|
+
const postMergeStates = [
|
|
580
|
+
"merged",
|
|
581
|
+
"deployed",
|
|
582
|
+
"rolled_out",
|
|
583
|
+
"rolled_back"
|
|
584
|
+
];
|
|
585
|
+
const changeEvidenceSchema = z.strictObject({
|
|
586
|
+
reproVerified: z.boolean(),
|
|
587
|
+
testsAdded: z.number().int().min(0),
|
|
588
|
+
checksGreen: z.boolean(),
|
|
589
|
+
attackPassed: z.boolean(),
|
|
590
|
+
scopeMatches: z.boolean(),
|
|
591
|
+
previewUrl: z.url().optional()
|
|
592
|
+
});
|
|
593
|
+
const changeSchema = z.strictObject({
|
|
594
|
+
schemaVersion: schemaVersionSchema,
|
|
595
|
+
id: changeIdSchema,
|
|
596
|
+
repositoryId: repositoryIdSchema,
|
|
597
|
+
taskId: taskIdSchema.optional(),
|
|
598
|
+
groupId: taskIdSchema.optional(),
|
|
599
|
+
prNumber: z.number().int().min(1),
|
|
600
|
+
prUrl: z.url(),
|
|
601
|
+
author: z.string().min(1),
|
|
602
|
+
risk: levelSchema,
|
|
603
|
+
confidence: levelSchema,
|
|
604
|
+
evidence: changeEvidenceSchema,
|
|
605
|
+
state: changeStateSchema,
|
|
606
|
+
mergeSha: z.string().regex(/^[0-9a-f]{40}$/).optional().describe("Merge commit sha recorded when the pull request merges, matched against production deployments."),
|
|
607
|
+
decidedBy: z.string().min(1).optional(),
|
|
608
|
+
createdAt: isoDateTimeSchema,
|
|
609
|
+
updatedAt: isoDateTimeSchema
|
|
610
|
+
}).superRefine((change, ctx) => {
|
|
611
|
+
if (postMergeStates.includes(change.state) && change.decidedBy === void 0) ctx.addIssue({
|
|
612
|
+
code: "custom",
|
|
613
|
+
path: ["decidedBy"],
|
|
614
|
+
message: `${change.state} changes require decidedBy`
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
//#endregion
|
|
618
|
+
export { budgetStatus as C, sweepBudget as D, deriveFactoryId as E, sweepSettlements as O, budgetMonthOf as S, computeMonthSpendUsd as T, taskInitialState as _, BudgetExhaustedError as a, createCoordination as b, dispatchTask as c, changeInitialState as d, changeTransitions as f, signalOutcomeTransitions as g, signalInitialOutcome as h, levelSchema as i, InvalidTransitionError as l, legalNextStates as m, changeSchema as n, DEFAULT_MAX_LAUNCH_ATTEMPTS as o, isTransitionAllowed as p, changeStateSchema as r, DispatchRefusedError as s, changeEvidenceSchema as t, assertTransition as u, taskTransitions as v, canReserve as w, reconcileCoordination as x, terminalStates as y };
|
|
619
|
+
|
|
620
|
+
//# sourceMappingURL=change-CzEAoFVE.mjs.map
|