@classytic/arc 2.25.0 → 2.30.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/README.md +2 -0
- package/dist/{BaseController--M8EMzt0.mjs → BaseController-B5DwufbB.mjs} +15 -11
- package/dist/EventTransport-C6YA8xY9.mjs +103 -0
- package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
- package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +1 -1
- package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
- package/dist/cleanup/index.d.mts +313 -0
- package/dist/cleanup/index.mjs +1267 -0
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +5 -3
- package/dist/core-poalJLvp.mjs +175 -0
- package/dist/{createActionRouter-B6GvAuAX.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
- package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
- package/dist/{preloadResources-DfyngIN9.mjs → createApp-CXrKFciH.mjs} +25 -76
- package/dist/defineAggregation-ufWjUwml.mjs +47 -0
- package/dist/{core-EUNu0u07.mjs → defineResource-CxWqDCAy.mjs} +36 -261
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
- package/dist/events/index.d.mts +28 -2
- package/dist/events/index.mjs +24 -343
- package/dist/factory/index.d.mts +4 -3
- package/dist/factory/index.mjs +3 -1
- package/dist/{fastify-augmentation-dPNBVeIj.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -2
- package/dist/{applyPermissionResult-Tw4iXtlf.mjs → fields-CQ_7KQWw.mjs} +1 -97
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.mjs +1 -1
- package/dist/index-CCzVR-N3.d.mts +107 -0
- package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
- package/dist/{index-f4PTrk3B.d.mts → index-Cx7bro4R.d.mts} +4 -4
- package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +8 -6
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +6 -6
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/{mcpPlugin-Cb7olw0j.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
- package/dist/middleware/index.d.mts +1 -1
- package/dist/migrations/index.d.mts +0 -3
- package/dist/migrations/index.mjs +0 -3
- package/dist/module-_-M7C5ES.mjs +29 -0
- package/dist/{openapi-DrLtog_B.mjs → openapi-B8Vkbxbn.mjs} +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +4 -3
- package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/{pipeline-BN5G05rd.mjs → pipeline-BJnpkY9i.mjs} +4 -18
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{preloadResources-0oe-JSSS.d.mts → preloadResources-DCfa2egz.d.mts} +3 -2
- package/dist/preloadResources-DSY5HGpS.mjs +45 -0
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +14 -2
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
- package/dist/projection-UBDgfMwu.mjs +17 -0
- package/dist/{realtime-Cuw_deA-.mjs → realtime-B9nmIY5V.mjs} +2 -1
- package/dist/{registerRoute-v0Wb6Plt.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/{registry-vcOCtPij.mjs → registry-BYXUUg21.mjs} +16 -1
- package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
- package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
- package/dist/testing/cleanupStoreContract.d.mts +10 -0
- package/dist/testing/cleanupStoreContract.mjs +375 -0
- package/dist/testing/index.d.mts +34 -5
- package/dist/testing/index.mjs +316 -211
- package/dist/testing/outboxStoreContract.d.mts +13 -0
- package/dist/testing/outboxStoreContract.mjs +247 -0
- package/dist/types/index.d.mts +3 -3
- package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
- package/dist/types-C_ZZRcD6.d.mts +450 -0
- package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
- package/dist/{index-KUoxjIq1.d.mts → types-D_n8L6VY.d.mts} +33 -106
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/utils/index.d.mts +2 -5
- package/package.json +539 -524
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
import { g as getUserId } from "../types-CSyCxl2V.mjs";
|
|
2
|
+
import { t as defineResource } from "../defineResource-CxWqDCAy.mjs";
|
|
3
|
+
import { t as defineModule } from "../module-_-M7C5ES.mjs";
|
|
4
|
+
import { createPurgeEvidence } from "@classytic/primitives/retention";
|
|
5
|
+
import { canonicalJson, sha256Hex } from "@classytic/primitives/canonical";
|
|
6
|
+
//#region src/cleanup/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* A framework error carrying BOTH `status` (read directly in code/tests) and
|
|
9
|
+
* `statusCode` (the Fastify convention arc's global error handler recognizes,
|
|
10
|
+
* step 4 of its classifier — a numeric `statusCode` + a separatored domain
|
|
11
|
+
* `code` maps to that exact status + code on the wire, never `arc.internal_error`).
|
|
12
|
+
*/
|
|
13
|
+
var CleanupError = class extends Error {
|
|
14
|
+
code;
|
|
15
|
+
status;
|
|
16
|
+
/** Alias of {@link status} — the property arc's error handler classifies on. */
|
|
17
|
+
statusCode;
|
|
18
|
+
meta;
|
|
19
|
+
constructor(code, status, message, meta) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "CleanupError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.statusCode = status;
|
|
25
|
+
if (meta) this.meta = meta;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Internal control-flow signal thrown by `throwIfCancelled()` when a run's
|
|
30
|
+
* durable `cancelRequested` flag is set. NOT a wire error — `processRun`
|
|
31
|
+
* catches it and CAS-transitions the run to `cancelled`.
|
|
32
|
+
*/
|
|
33
|
+
var CleanupCancelled = class extends Error {
|
|
34
|
+
runId;
|
|
35
|
+
constructor(runId) {
|
|
36
|
+
super(`Cleanup run ${runId} was cancelled.`);
|
|
37
|
+
this.name = "CleanupCancelled";
|
|
38
|
+
this.runId = runId;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const CleanupErrors = {
|
|
42
|
+
unknownRecipe(id) {
|
|
43
|
+
return new CleanupError("CLEANUP_UNKNOWN_RECIPE", 404, `No cleanup recipe registered under id '${id}'.`, { id });
|
|
44
|
+
},
|
|
45
|
+
duplicateRecipe(id) {
|
|
46
|
+
return new CleanupError("CLEANUP_DUPLICATE_RECIPE", 500, `Duplicate cleanup recipe id '${id}' — recipe ids must be unique in a registry.`, { id });
|
|
47
|
+
},
|
|
48
|
+
unavailable(id, reason) {
|
|
49
|
+
return new CleanupError("CLEANUP_RECIPE_UNAVAILABLE", 409, `Cleanup recipe '${id}' is unavailable: ${reason}`, {
|
|
50
|
+
id,
|
|
51
|
+
reason
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
confirmationRequired(phrase) {
|
|
55
|
+
return new CleanupError("CLEANUP_CONFIRMATION_REQUIRED", 400, "This destructive cleanup requires the exact confirmation phrase.", { confirmationPhrase: phrase });
|
|
56
|
+
},
|
|
57
|
+
reasonRequired() {
|
|
58
|
+
return new CleanupError("CLEANUP_REASON_REQUIRED", 400, "A non-empty reason is required.");
|
|
59
|
+
},
|
|
60
|
+
blocked(blockers) {
|
|
61
|
+
return new CleanupError("CLEANUP_BLOCKED", 409, `Cleanup plan has unresolved blockers and cannot run: ${blockers.join(", ")}.`, { blockers: [...blockers] });
|
|
62
|
+
},
|
|
63
|
+
planTooLarge(detail) {
|
|
64
|
+
return new CleanupError("CLEANUP_PLAN_TOO_LARGE", 413, `Cleanup plan exceeds a size limit: ${detail}.`, { detail });
|
|
65
|
+
},
|
|
66
|
+
actorRequired() {
|
|
67
|
+
return new CleanupError("CLEANUP_ACTOR_REQUIRED", 401, "A destructive cleanup requires an authenticated actor; none could be resolved.");
|
|
68
|
+
},
|
|
69
|
+
planChanged(expected, actual) {
|
|
70
|
+
return new CleanupError("CLEANUP_PLAN_CHANGED", 409, "The cleanup plan changed since preview — re-preview and confirm the new plan.", {
|
|
71
|
+
expectedDigest: expected,
|
|
72
|
+
actualDigest: actual
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
alreadyRunning(runId) {
|
|
76
|
+
return new CleanupError("CLEANUP_ALREADY_RUNNING", 409, "Another destructive cleanup run is already in progress. Only one runs at a time.", { runId });
|
|
77
|
+
},
|
|
78
|
+
runNotFound(id) {
|
|
79
|
+
return new CleanupError("CLEANUP_RUN_NOT_FOUND", 404, `Cleanup run '${id}' not found.`, { id });
|
|
80
|
+
},
|
|
81
|
+
invalidAction(action, status) {
|
|
82
|
+
return new CleanupError("CLEANUP_INVALID_ACTION", 409, `Action '${action}' is not valid for a run in status '${status}'.`, {
|
|
83
|
+
action,
|
|
84
|
+
status
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
forbidden(operation) {
|
|
88
|
+
return new CleanupError("CLEANUP_FORBIDDEN", 403, `Not authorized to ${operation} cleanup.`, { operation });
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/cleanup/compose.ts
|
|
93
|
+
const ALWAYS_AVAILABLE = { available: true };
|
|
94
|
+
/** Build a step context from an arc cleanup context + the sealed parameters. */
|
|
95
|
+
function stepContextOf(ctx, parameters) {
|
|
96
|
+
return {
|
|
97
|
+
now: ctx.now,
|
|
98
|
+
signal: ctx.signal,
|
|
99
|
+
ambient: ctx.ambient,
|
|
100
|
+
parameters,
|
|
101
|
+
logger: ctx.logger
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function errMessage(err) {
|
|
105
|
+
return err instanceof Error ? err.message : String(err);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Infrastructure failure inside the adapter's own progress plumbing (e.g. the
|
|
109
|
+
* run store rejecting a progress write). Distinguished from a DOMAIN step
|
|
110
|
+
* failure: it propagates out of `execute` so the service records an
|
|
111
|
+
* infrastructure failure for the run, instead of blaming the domain step.
|
|
112
|
+
*/
|
|
113
|
+
var CleanupProgressInfraError = class extends Error {
|
|
114
|
+
constructor(cause) {
|
|
115
|
+
super(`progress persistence failed: ${errMessage(cause)}`);
|
|
116
|
+
this.name = "CleanupProgressInfraError";
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
function recipeFromSteps(input) {
|
|
120
|
+
const { id, label, version, confirmationPhrase, steps } = input;
|
|
121
|
+
const destructive = input.destructive ?? steps.some((s) => s.destructive);
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
label,
|
|
125
|
+
destructive,
|
|
126
|
+
evidenceStrategy: input.evidenceStrategy ?? (destructive ? "hard" : "soft"),
|
|
127
|
+
...version !== void 0 ? { version } : {},
|
|
128
|
+
available(ctx) {
|
|
129
|
+
return input.available ? input.available(ctx) : Promise.resolve(ALWAYS_AVAILABLE);
|
|
130
|
+
},
|
|
131
|
+
async plan(cleanupInput, ctx) {
|
|
132
|
+
const stepCtx = stepContextOf(ctx, cleanupInput.parameters ?? {});
|
|
133
|
+
const items = [];
|
|
134
|
+
const blockers = /* @__PURE__ */ new Set();
|
|
135
|
+
const retains = new Set(input.retains ?? []);
|
|
136
|
+
const rebuildActions = /* @__PURE__ */ new Set();
|
|
137
|
+
const warnings = new Set(input.warnings ?? []);
|
|
138
|
+
for (const step of steps) {
|
|
139
|
+
const est = await step.estimate(stepCtx);
|
|
140
|
+
items.push({
|
|
141
|
+
resource: est.resource,
|
|
142
|
+
estimated: est.estimated,
|
|
143
|
+
...est.retained !== void 0 ? { retained: est.retained } : {},
|
|
144
|
+
...est.blockers && est.blockers.length > 0 ? { blockers: est.blockers } : {}
|
|
145
|
+
});
|
|
146
|
+
for (const b of est.blockers ?? []) blockers.add(b);
|
|
147
|
+
for (const w of est.warnings ?? []) warnings.add(w);
|
|
148
|
+
if (est.retained) retains.add(est.retained);
|
|
149
|
+
for (const r of step.rebuildActions ?? []) rebuildActions.add(r);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
items,
|
|
153
|
+
blockers: [...blockers],
|
|
154
|
+
retains: [...retains],
|
|
155
|
+
rebuildActions: [...rebuildActions],
|
|
156
|
+
warnings: [...warnings],
|
|
157
|
+
...confirmationPhrase !== void 0 ? { confirmationPhrase } : {}
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
async execute(plan, ctx) {
|
|
161
|
+
const parameters = plan.parameters ?? {};
|
|
162
|
+
const results = [];
|
|
163
|
+
for (const step of steps) {
|
|
164
|
+
await ctx.throwIfCancelled();
|
|
165
|
+
let reported = 0;
|
|
166
|
+
const execCtx = {
|
|
167
|
+
...stepContextOf(ctx, parameters),
|
|
168
|
+
throwIfCancelled: () => ctx.throwIfCancelled(),
|
|
169
|
+
async onProgress(update) {
|
|
170
|
+
const delta = update.processed - reported;
|
|
171
|
+
if (delta > 0) {
|
|
172
|
+
reported = update.processed;
|
|
173
|
+
try {
|
|
174
|
+
await ctx.onStep({
|
|
175
|
+
resource: update.resource,
|
|
176
|
+
processed: delta,
|
|
177
|
+
ok: true,
|
|
178
|
+
...update.cursor !== void 0 ? { cursor: update.cursor } : {}
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
throw new CleanupProgressInfraError(err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
let outcome;
|
|
187
|
+
try {
|
|
188
|
+
outcome = await step.execute(execCtx);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof CleanupCancelled || err instanceof CleanupProgressInfraError) throw err;
|
|
191
|
+
outcome = {
|
|
192
|
+
resource: step.resource,
|
|
193
|
+
processed: reported,
|
|
194
|
+
ok: false,
|
|
195
|
+
error: errMessage(err)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const tail = outcome.processed - reported;
|
|
199
|
+
if (tail > 0 || !outcome.ok) await ctx.onStep({
|
|
200
|
+
resource: outcome.resource,
|
|
201
|
+
processed: tail > 0 ? tail : 0,
|
|
202
|
+
ok: outcome.ok,
|
|
203
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {},
|
|
204
|
+
...outcome.cursor !== void 0 ? { cursor: outcome.cursor } : {}
|
|
205
|
+
});
|
|
206
|
+
results.push({
|
|
207
|
+
resource: outcome.resource,
|
|
208
|
+
processed: outcome.processed,
|
|
209
|
+
ok: outcome.ok,
|
|
210
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {},
|
|
211
|
+
...outcome.cursor !== void 0 ? { cursor: outcome.cursor } : {}
|
|
212
|
+
});
|
|
213
|
+
if (!outcome.ok) break;
|
|
214
|
+
}
|
|
215
|
+
const allOk = results.every((r) => r.ok);
|
|
216
|
+
const anyOk = results.some((r) => r.ok);
|
|
217
|
+
return {
|
|
218
|
+
status: allOk ? "completed" : anyOk ? "partial" : "failed",
|
|
219
|
+
results
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
async verify(plan, ctx) {
|
|
223
|
+
const stepCtx = stepContextOf(ctx, plan.parameters ?? {});
|
|
224
|
+
const checks = [];
|
|
225
|
+
for (const step of steps) {
|
|
226
|
+
if (!step.verify) continue;
|
|
227
|
+
const stepChecks = await step.verify(stepCtx);
|
|
228
|
+
for (const c of stepChecks) checks.push({
|
|
229
|
+
name: c.name,
|
|
230
|
+
ok: c.ok,
|
|
231
|
+
...c.detail !== void 0 ? { detail: c.detail } : {}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
ok: checks.every((c) => c.ok),
|
|
236
|
+
checks
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/cleanup/types.ts
|
|
243
|
+
/** Terminal states — no further transition is valid. */
|
|
244
|
+
const CLEANUP_TERMINAL_STATUSES = [
|
|
245
|
+
"completed",
|
|
246
|
+
"failed",
|
|
247
|
+
"cancelled"
|
|
248
|
+
];
|
|
249
|
+
const DEFAULT_CLEANUP_LIMITS = {
|
|
250
|
+
maxPlanItems: 500,
|
|
251
|
+
maxResults: 1e3,
|
|
252
|
+
maxChecks: 200,
|
|
253
|
+
maxReasonLength: 2e3,
|
|
254
|
+
maxParamDepth: 8
|
|
255
|
+
};
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/cleanup/memory.ts
|
|
258
|
+
/**
|
|
259
|
+
* In-memory {@link CleanupRunStore}. Single-process only — see the module
|
|
260
|
+
* docblock for the production caveat.
|
|
261
|
+
*/
|
|
262
|
+
var MemoryCleanupRunStore = class {
|
|
263
|
+
/** Live run state, keyed by run id. Exposed for tests/inspection. */
|
|
264
|
+
runs = /* @__PURE__ */ new Map();
|
|
265
|
+
#now;
|
|
266
|
+
constructor(now = () => /* @__PURE__ */ new Date()) {
|
|
267
|
+
this.#now = now;
|
|
268
|
+
}
|
|
269
|
+
/** Another NON-TERMINAL run already holding the same concurrency key. */
|
|
270
|
+
#keyConflict(run) {
|
|
271
|
+
if (!run.concurrencyKey) return void 0;
|
|
272
|
+
for (const other of this.runs.values()) if (other.id !== run.id && other.concurrencyKey === run.concurrencyKey && !CLEANUP_TERMINAL_STATUSES.includes(other.status)) return other;
|
|
273
|
+
}
|
|
274
|
+
#read(id) {
|
|
275
|
+
return this.runs.get(id);
|
|
276
|
+
}
|
|
277
|
+
/** Store `next` and hand back a copy the caller may freely mutate. */
|
|
278
|
+
#commit(next) {
|
|
279
|
+
this.runs.set(next.id, next);
|
|
280
|
+
return { ...next };
|
|
281
|
+
}
|
|
282
|
+
async createIfPermitted(run) {
|
|
283
|
+
const conflict = this.#keyConflict(run);
|
|
284
|
+
if (conflict) return {
|
|
285
|
+
created: false,
|
|
286
|
+
activeRunId: conflict.id
|
|
287
|
+
};
|
|
288
|
+
this.runs.set(run.id, run);
|
|
289
|
+
return { created: true };
|
|
290
|
+
}
|
|
291
|
+
async get(id) {
|
|
292
|
+
const run = this.#read(id);
|
|
293
|
+
return run ? { ...run } : null;
|
|
294
|
+
}
|
|
295
|
+
async claim(id, lease) {
|
|
296
|
+
const current = this.#read(id);
|
|
297
|
+
if (!current || CLEANUP_TERMINAL_STATUSES.includes(current.status)) return null;
|
|
298
|
+
const leaseLive = current.leaseExpiresAt !== void 0 && current.leaseExpiresAt.getTime() > this.#now().getTime();
|
|
299
|
+
if (!(current.status === "queued" || (current.status === "running" || current.status === "finalizing") && !leaseLive)) return null;
|
|
300
|
+
return this.#commit({
|
|
301
|
+
...current,
|
|
302
|
+
leaseToken: lease.token,
|
|
303
|
+
leaseExpiresAt: lease.expiresAt,
|
|
304
|
+
attempt: current.attempt + 1
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async compareAndTransition(id, expected, to, patch, leaseToken) {
|
|
308
|
+
const current = this.#read(id);
|
|
309
|
+
if (!current || !expected.includes(current.status)) return null;
|
|
310
|
+
if (leaseToken !== void 0 && current.leaseToken !== leaseToken) return null;
|
|
311
|
+
return this.#commit({
|
|
312
|
+
...current,
|
|
313
|
+
...patch ?? {},
|
|
314
|
+
status: to
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async reArmIfPermitted(id, patch) {
|
|
318
|
+
const current = this.#read(id);
|
|
319
|
+
if (!current || current.status !== "failed" && current.status !== "cancelled") return null;
|
|
320
|
+
if (this.#keyConflict(current)) return null;
|
|
321
|
+
return this.#commit({
|
|
322
|
+
...current,
|
|
323
|
+
...patch,
|
|
324
|
+
status: "queued",
|
|
325
|
+
cancelRequested: false,
|
|
326
|
+
cancelRequestedBy: void 0,
|
|
327
|
+
cancelReason: void 0,
|
|
328
|
+
cancelRequestedAt: void 0,
|
|
329
|
+
leaseToken: void 0,
|
|
330
|
+
leaseExpiresAt: void 0,
|
|
331
|
+
finalization: void 0
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async requestCancel(id, request) {
|
|
335
|
+
const current = this.#read(id);
|
|
336
|
+
if (!current) return null;
|
|
337
|
+
return this.#commit({
|
|
338
|
+
...current,
|
|
339
|
+
cancelRequested: true,
|
|
340
|
+
cancelRequestedBy: current.cancelRequestedBy ?? request?.actor,
|
|
341
|
+
cancelReason: current.cancelReason ?? request?.reason,
|
|
342
|
+
cancelRequestedAt: current.cancelRequestedAt ?? request?.requestedAt
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async saveProgress(id, progress, lease) {
|
|
346
|
+
const current = this.#read(id);
|
|
347
|
+
if (!current) return;
|
|
348
|
+
if (lease && current.leaseToken !== lease.token) return;
|
|
349
|
+
this.#commit({
|
|
350
|
+
...current,
|
|
351
|
+
progress,
|
|
352
|
+
...lease ? { leaseExpiresAt: lease.expiresAt } : {}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
/**
|
|
357
|
+
* In-memory {@link CleanupEvidenceStore} — idempotent by
|
|
358
|
+
* `evidence.operationId`, exactly as the port requires.
|
|
359
|
+
*/
|
|
360
|
+
var MemoryCleanupEvidenceStore = class {
|
|
361
|
+
evidence = [];
|
|
362
|
+
manifests = [];
|
|
363
|
+
#seenOperations = /* @__PURE__ */ new Set();
|
|
364
|
+
async finalize(input) {
|
|
365
|
+
const operationId = input.evidence.operationId ?? "";
|
|
366
|
+
if (this.#seenOperations.has(operationId)) return;
|
|
367
|
+
this.#seenOperations.add(operationId);
|
|
368
|
+
this.evidence.push(input.evidence);
|
|
369
|
+
this.manifests.push(input.manifest);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* A MANUAL in-memory {@link CleanupJobQueue}: it records enqueued jobs and runs
|
|
374
|
+
* nothing on its own, so a test drives the worker deterministically
|
|
375
|
+
* (`for (const job of queue.jobs) await service.processRun(job.runId)`).
|
|
376
|
+
*
|
|
377
|
+
* Note this is NOT the service's default queue — omitting `jobQueue` entirely
|
|
378
|
+
* gives you a microtask-deferred in-process queue that DOES auto-run
|
|
379
|
+
* `processRun` off the request path.
|
|
380
|
+
*/
|
|
381
|
+
var MemoryCleanupJobQueue = class {
|
|
382
|
+
jobs = [];
|
|
383
|
+
async enqueue(job) {
|
|
384
|
+
this.jobs.push(job);
|
|
385
|
+
}
|
|
386
|
+
/** Take everything enqueued so far, clearing the buffer. */
|
|
387
|
+
drain() {
|
|
388
|
+
return this.jobs.splice(0, this.jobs.length);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/cleanup/registry.ts
|
|
393
|
+
/**
|
|
394
|
+
* Cleanup recipe registry — the framework's boot-time integrity gate.
|
|
395
|
+
*
|
|
396
|
+
* Validates recipe-id uniqueness at construction (fail fast, never at run
|
|
397
|
+
* time), exposes typed lookup, and produces the introspection payload the
|
|
398
|
+
* UI/SDK renders as recipe cards. Pure — no I/O, no Fastify.
|
|
399
|
+
*/
|
|
400
|
+
/**
|
|
401
|
+
* Build a registry from a recipe list. Throws `CLEANUP_DUPLICATE_RECIPE` at
|
|
402
|
+
* construction if two recipes share an id — a boot-time failure, never a
|
|
403
|
+
* silent last-wins overwrite.
|
|
404
|
+
*/
|
|
405
|
+
function createCleanupRegistry(recipes) {
|
|
406
|
+
const byId = /* @__PURE__ */ new Map();
|
|
407
|
+
for (const recipe of recipes) {
|
|
408
|
+
if (byId.has(recipe.id)) throw CleanupErrors.duplicateRecipe(recipe.id);
|
|
409
|
+
byId.set(recipe.id, recipe);
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
get(id) {
|
|
413
|
+
const recipe = byId.get(id);
|
|
414
|
+
if (!recipe) throw CleanupErrors.unknownRecipe(id);
|
|
415
|
+
return recipe;
|
|
416
|
+
},
|
|
417
|
+
find(id) {
|
|
418
|
+
return byId.get(id);
|
|
419
|
+
},
|
|
420
|
+
all() {
|
|
421
|
+
return [...byId.values()];
|
|
422
|
+
},
|
|
423
|
+
introspect() {
|
|
424
|
+
return [...byId.values()].map((r) => ({
|
|
425
|
+
id: r.id,
|
|
426
|
+
label: r.label,
|
|
427
|
+
destructive: r.destructive
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/cleanup/plan-digest.ts
|
|
434
|
+
/**
|
|
435
|
+
* Deterministic integrity checksums for plans + manifests.
|
|
436
|
+
*
|
|
437
|
+
* The operator previews a plan, then confirms by echoing its digest. At execute
|
|
438
|
+
* time the server RE-PLANS and compares digests: if the world changed between
|
|
439
|
+
* preview and confirm (more rows, a new blocker), the digest differs and the
|
|
440
|
+
* run is refused with `CLEANUP_PLAN_CHANGED` (§7 "Execute"). So the digest MUST
|
|
441
|
+
* be a stable function of the plan's MATERIAL content only — invariant to
|
|
442
|
+
* object key order, and excluding volatile/advisory fields.
|
|
443
|
+
*
|
|
444
|
+
* These are integrity CHECKSUMS (accidental-corruption + drift detection), NOT
|
|
445
|
+
* tamper-proof seals: plain SHA-256 does not stop an actor with write access
|
|
446
|
+
* from replacing a record and recomputing its digest. Pair with access control
|
|
447
|
+
* / keyed signatures where that matters.
|
|
448
|
+
*
|
|
449
|
+
* The canonicalizer + hash are the shared `@classytic/primitives/canonical`
|
|
450
|
+
* implementation (`Date`-explicit, strict-reject of `undefined`/`NaN`/`BigInt`/
|
|
451
|
+
* `function`/`symbol`/`Map`/`Set`/cyclic). Two domains consume it — cleanup
|
|
452
|
+
* manifests here + financial-close evidence manifests in arc-accounting — so it
|
|
453
|
+
* lives in primitives, not duplicated per package. `canonicalJson` is re-exported
|
|
454
|
+
* for existing importers; peer floor `@classytic/primitives >= 0.17.0`.
|
|
455
|
+
*/
|
|
456
|
+
/**
|
|
457
|
+
* The material content of a plan — everything an operator is consenting to.
|
|
458
|
+
* Deliberately EXCLUDES `digest` (self-reference) and advisory `warnings`.
|
|
459
|
+
*/
|
|
460
|
+
function planMaterial(plan) {
|
|
461
|
+
return {
|
|
462
|
+
recipeId: plan.recipeId,
|
|
463
|
+
parameters: plan.parameters,
|
|
464
|
+
items: plan.items.map((i) => ({
|
|
465
|
+
resource: i.resource,
|
|
466
|
+
estimated: i.estimated,
|
|
467
|
+
retained: i.retained ?? null,
|
|
468
|
+
blockers: [...i.blockers ?? []].sort()
|
|
469
|
+
})).sort((a, b) => a.resource < b.resource ? -1 : a.resource > b.resource ? 1 : a.estimated - b.estimated),
|
|
470
|
+
retains: [...plan.retains].sort(),
|
|
471
|
+
blockers: [...plan.blockers].sort(),
|
|
472
|
+
rebuildActions: [...plan.rebuildActions].sort(),
|
|
473
|
+
estimatedTotal: plan.estimatedTotal,
|
|
474
|
+
confirmationPhrase: plan.confirmationPhrase
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** `sha256(canonical(planMaterial))` as a hex digest. */
|
|
478
|
+
function computePlanDigest(plan) {
|
|
479
|
+
return sha256Hex(canonicalJson(planMaterial(plan)));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Integrity checksum of a completed manifest (§5). Excludes the digest field
|
|
483
|
+
* itself. Dates (e.g. `completedAt`) participate via the strict canonicalizer.
|
|
484
|
+
*/
|
|
485
|
+
function computeManifestDigest(manifest) {
|
|
486
|
+
const { manifestDigest: _drop, ...rest } = manifest;
|
|
487
|
+
return sha256Hex(canonicalJson(rest));
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/cleanup/service.ts
|
|
491
|
+
/**
|
|
492
|
+
* Cleanup orchestration service — the framework's use-case core.
|
|
493
|
+
*
|
|
494
|
+
* Split into a REQUEST path and a WORKER path so the durability guarantees the
|
|
495
|
+
* ports promise actually hold:
|
|
496
|
+
*
|
|
497
|
+
* preview() → re-plan, seal a digest, surface blockers/retains/confirmation
|
|
498
|
+
* execute() → validate (availability, digest, confirmation, reason,
|
|
499
|
+
* blockers, limits), ATOMICALLY create the run (admission
|
|
500
|
+
* guard by concurrencyKey), persist the sealed operation,
|
|
501
|
+
* ENQUEUE `{ runId }`, and RETURN. It does NOT run the recipe.
|
|
502
|
+
* processRun() → the worker entrypoint. Takes an EXCLUSIVE LEASE on the run
|
|
503
|
+
* (`claim` — a live lease refuses duplicate workers), then
|
|
504
|
+
* branches: a `finalizing` run RECOVERS finalization from the
|
|
505
|
+
* persisted payload (never re-executes); a `queued` run (or a
|
|
506
|
+
* crashed `running` run whose lease expired) executes the
|
|
507
|
+
* recipe with cooperative cancellation, verifies, persists
|
|
508
|
+
* the finalization payload, and finalizes idempotently.
|
|
509
|
+
* cancel() → durable cancel request (+ actor/reason audit) + CAS a
|
|
510
|
+
* not-yet-running run straight to `cancelled` WITH evidence.
|
|
511
|
+
* A running run stops cooperatively and leaves evidence of
|
|
512
|
+
* the committed chunks.
|
|
513
|
+
* retry() → reload the SAME sealed operation, re-check availability +
|
|
514
|
+
* recipe version + digest against a fresh plan (refuse if the
|
|
515
|
+
* world changed), re-arm ATOMICALLY under the same admission
|
|
516
|
+
* guard as creation, re-enqueue.
|
|
517
|
+
*
|
|
518
|
+
* Every status change goes through `compareAndTransition` (CAS) carrying the
|
|
519
|
+
* worker's lease token, so a late `completed` write from a stalled ex-owner can
|
|
520
|
+
* never clobber the new owner's `cancelled`/`running` state.
|
|
521
|
+
*/
|
|
522
|
+
const EMPTY_PROGRESS = {
|
|
523
|
+
processed: 0,
|
|
524
|
+
steps: 0
|
|
525
|
+
};
|
|
526
|
+
/** Admission key serializing every destructive run behind one slot. */
|
|
527
|
+
const GLOBAL_DESTRUCTIVE_KEY = "global-destructive";
|
|
528
|
+
function paramDepth(value, depth = 0) {
|
|
529
|
+
if (value === null || typeof value !== "object" || value instanceof Date) return depth;
|
|
530
|
+
let max = depth;
|
|
531
|
+
for (const v of Array.isArray(value) ? value : Object.values(value)) max = Math.max(max, paramDepth(v, depth + 1));
|
|
532
|
+
return max;
|
|
533
|
+
}
|
|
534
|
+
/** Failures first, so a `maxChecks` cap can never hide a failing check. */
|
|
535
|
+
function orderChecksFailuresFirst(verification, maxChecks) {
|
|
536
|
+
if (verification.checks.length <= maxChecks) return {
|
|
537
|
+
verification,
|
|
538
|
+
checksTruncated: 0
|
|
539
|
+
};
|
|
540
|
+
const ordered = [...verification.checks].sort((a, b) => Number(a.ok) - Number(b.ok));
|
|
541
|
+
return {
|
|
542
|
+
verification: {
|
|
543
|
+
ok: verification.ok,
|
|
544
|
+
checks: ordered.slice(0, maxChecks)
|
|
545
|
+
},
|
|
546
|
+
checksTruncated: verification.checks.length - maxChecks
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/** The terminal run status a finalization payload deterministically maps to. */
|
|
550
|
+
function terminalStatusOf(payload) {
|
|
551
|
+
if (payload.status === "cancelled") return "cancelled";
|
|
552
|
+
if (payload.status === "completed" && payload.verification.ok && !payload.failureReason) return "completed";
|
|
553
|
+
return "failed";
|
|
554
|
+
}
|
|
555
|
+
function createCleanupService(deps) {
|
|
556
|
+
const { registry, runStore, evidenceStore } = deps;
|
|
557
|
+
const writeFence = deps.writeFence;
|
|
558
|
+
const logger = deps.logger;
|
|
559
|
+
const generateId = deps.generateId ?? (() => globalThis.crypto.randomUUID());
|
|
560
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
561
|
+
const limits = {
|
|
562
|
+
...DEFAULT_CLEANUP_LIMITS,
|
|
563
|
+
...deps.limits
|
|
564
|
+
};
|
|
565
|
+
const leaseMs = deps.leaseMs ?? 300 * 1e3;
|
|
566
|
+
const progressThrottleMs = deps.progressThrottleMs ?? 0;
|
|
567
|
+
const jobQueue = deps.jobQueue ?? { enqueue: async (job) => {
|
|
568
|
+
queueMicrotask(() => {
|
|
569
|
+
processRun(job.runId).catch(() => {});
|
|
570
|
+
});
|
|
571
|
+
} };
|
|
572
|
+
function context(input, signal) {
|
|
573
|
+
return {
|
|
574
|
+
actor: input.actor,
|
|
575
|
+
now: now(),
|
|
576
|
+
signal,
|
|
577
|
+
ambient: input.ambient,
|
|
578
|
+
logger
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function freshLease() {
|
|
582
|
+
return {
|
|
583
|
+
token: generateId(),
|
|
584
|
+
expiresAt: new Date(now().getTime() + leaseMs)
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
async function seal(recipe, input, ctx) {
|
|
588
|
+
const draft = await recipe.plan({ parameters: input.parameters }, ctx);
|
|
589
|
+
const items = draft.items;
|
|
590
|
+
if (items.length > limits.maxPlanItems) throw CleanupErrors.planTooLarge(`${items.length} items > maxPlanItems ${limits.maxPlanItems}`);
|
|
591
|
+
const estimatedTotal = items.reduce((sum, i) => sum + i.estimated, 0);
|
|
592
|
+
const blockers = new Set(draft.blockers ?? []);
|
|
593
|
+
for (const item of items) for (const b of item.blockers ?? []) blockers.add(b);
|
|
594
|
+
const unsealed = {
|
|
595
|
+
recipeId: recipe.id,
|
|
596
|
+
parameters: input.parameters ?? {},
|
|
597
|
+
items,
|
|
598
|
+
retains: [...draft.retains ?? []],
|
|
599
|
+
blockers: [...blockers],
|
|
600
|
+
rebuildActions: [...draft.rebuildActions ?? []],
|
|
601
|
+
warnings: [...draft.warnings ?? []],
|
|
602
|
+
estimatedTotal,
|
|
603
|
+
confirmationPhrase: draft.confirmationPhrase ?? recipe.id
|
|
604
|
+
};
|
|
605
|
+
return {
|
|
606
|
+
...unsealed,
|
|
607
|
+
digest: computePlanDigest(unsealed)
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
async function ensureAvailable(recipe, ctx) {
|
|
611
|
+
const availability = await recipe.available(ctx);
|
|
612
|
+
if (!availability.available) throw CleanupErrors.unavailable(recipe.id, availability.reason ?? "unavailable");
|
|
613
|
+
}
|
|
614
|
+
async function preview(input) {
|
|
615
|
+
const recipe = registry.get(input.recipeId);
|
|
616
|
+
const ctx = context(input);
|
|
617
|
+
await ensureAvailable(recipe, ctx);
|
|
618
|
+
return seal(recipe, input, ctx);
|
|
619
|
+
}
|
|
620
|
+
async function enqueueOrFail(run) {
|
|
621
|
+
try {
|
|
622
|
+
await jobQueue.enqueue({ runId: run.id });
|
|
623
|
+
} catch (err) {
|
|
624
|
+
await runStore.compareAndTransition(run.id, ["queued"], "failed", {
|
|
625
|
+
completedAt: now(),
|
|
626
|
+
failureReason: `enqueue failed: ${errMsg(err)}`
|
|
627
|
+
});
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async function execute(input) {
|
|
632
|
+
const recipe = registry.get(input.recipeId);
|
|
633
|
+
const ctx = context(input);
|
|
634
|
+
if ((input.reason ?? "").length > limits.maxReasonLength) throw CleanupErrors.planTooLarge(`reason length > ${limits.maxReasonLength}`);
|
|
635
|
+
if (input.parameters && paramDepth(input.parameters) > limits.maxParamDepth) throw CleanupErrors.planTooLarge(`parameters nested deeper than ${limits.maxParamDepth}`);
|
|
636
|
+
await ensureAvailable(recipe, ctx);
|
|
637
|
+
const sealed = await seal(recipe, input, ctx);
|
|
638
|
+
if (sealed.digest !== input.planDigest) throw CleanupErrors.planChanged(sealed.digest, input.planDigest);
|
|
639
|
+
if (recipe.destructive && input.confirmation !== sealed.confirmationPhrase) throw CleanupErrors.confirmationRequired(sealed.confirmationPhrase);
|
|
640
|
+
if (!input.reason || input.reason.trim().length === 0) throw CleanupErrors.reasonRequired();
|
|
641
|
+
if (sealed.blockers.length > 0) throw CleanupErrors.blocked(sealed.blockers);
|
|
642
|
+
const run = {
|
|
643
|
+
id: generateId(),
|
|
644
|
+
recipeId: recipe.id,
|
|
645
|
+
recipeVersion: recipe.version ?? "1",
|
|
646
|
+
status: "queued",
|
|
647
|
+
planDigest: sealed.digest,
|
|
648
|
+
sealedPlan: sealed,
|
|
649
|
+
parameters: input.parameters ?? {},
|
|
650
|
+
actor: input.actor,
|
|
651
|
+
ambient: input.ambient,
|
|
652
|
+
requestedBy: input.actor.ref,
|
|
653
|
+
reason: input.reason,
|
|
654
|
+
operationId: generateId(),
|
|
655
|
+
destructive: recipe.destructive,
|
|
656
|
+
...recipe.destructive ? { concurrencyKey: GLOBAL_DESTRUCTIVE_KEY } : {},
|
|
657
|
+
attempt: 0,
|
|
658
|
+
progress: EMPTY_PROGRESS,
|
|
659
|
+
cancelRequested: false,
|
|
660
|
+
queuedAt: now()
|
|
661
|
+
};
|
|
662
|
+
const created = await runStore.createIfPermitted(run);
|
|
663
|
+
if (!created.created) throw CleanupErrors.alreadyRunning(created.activeRunId);
|
|
664
|
+
await enqueueOrFail(run);
|
|
665
|
+
return await runStore.get(run.id) ?? run;
|
|
666
|
+
}
|
|
667
|
+
async function processRun(runId) {
|
|
668
|
+
const existing = await runStore.get(runId);
|
|
669
|
+
if (!existing) throw CleanupErrors.runNotFound(runId);
|
|
670
|
+
if (CLEANUP_TERMINAL_STATUSES.includes(existing.status)) return;
|
|
671
|
+
const lease = freshLease();
|
|
672
|
+
const run = await runStore.claim(runId, lease);
|
|
673
|
+
if (!run) return;
|
|
674
|
+
const recipe = registry.get(run.recipeId);
|
|
675
|
+
if (run.status === "finalizing") {
|
|
676
|
+
await recoverFinalization(run, recipe, lease);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const running = await runStore.compareAndTransition(run.id, ["queued", "running"], "running", { startedAt: run.startedAt ?? now() }, lease.token);
|
|
680
|
+
if (!running) return;
|
|
681
|
+
if (running.cancelRequested) {
|
|
682
|
+
await settleCancelled(running, recipe, lease, []);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
let fenceToken;
|
|
686
|
+
let fenceHeld = false;
|
|
687
|
+
if (writeFence) try {
|
|
688
|
+
const token = await writeFence.acquire(run.operationId);
|
|
689
|
+
fenceToken = typeof token === "string" ? token : void 0;
|
|
690
|
+
fenceHeld = true;
|
|
691
|
+
} catch (err) {
|
|
692
|
+
await failRun(running, recipe, lease, `write fence acquire failed: ${errMsg(err)}`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
await runOperation(running, recipe, running.sealedPlan, lease);
|
|
697
|
+
} finally {
|
|
698
|
+
if (writeFence && fenceHeld) try {
|
|
699
|
+
await writeFence.release(run.operationId, fenceToken);
|
|
700
|
+
} catch (relErr) {
|
|
701
|
+
logger?.error?.("cleanup write fence release failed", {
|
|
702
|
+
runId: run.id,
|
|
703
|
+
operationId: run.operationId,
|
|
704
|
+
error: errMsg(relErr)
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Execute → verify → persist finalization payload → finalize, all leased. */
|
|
710
|
+
async function runOperation(run, recipe, plan, lease) {
|
|
711
|
+
const controller = new AbortController();
|
|
712
|
+
let processed = 0;
|
|
713
|
+
let steps = 0;
|
|
714
|
+
let failed = 0;
|
|
715
|
+
let cancelled = false;
|
|
716
|
+
let lastSummary = run.progress ?? EMPTY_PROGRESS;
|
|
717
|
+
let lastPersistMs = 0;
|
|
718
|
+
const refreshCancel = async () => {
|
|
719
|
+
if ((await runStore.get(run.id))?.cancelRequested) {
|
|
720
|
+
cancelled = true;
|
|
721
|
+
controller.abort();
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
return false;
|
|
725
|
+
};
|
|
726
|
+
const renewedLease = () => ({
|
|
727
|
+
token: lease.token,
|
|
728
|
+
expiresAt: new Date(now().getTime() + leaseMs)
|
|
729
|
+
});
|
|
730
|
+
const execCtx = {
|
|
731
|
+
actor: run.actor,
|
|
732
|
+
now: now(),
|
|
733
|
+
signal: controller.signal,
|
|
734
|
+
ambient: run.ambient,
|
|
735
|
+
logger,
|
|
736
|
+
runId: run.id,
|
|
737
|
+
operationId: run.operationId,
|
|
738
|
+
async onStep(step) {
|
|
739
|
+
steps += 1;
|
|
740
|
+
processed += step.processed;
|
|
741
|
+
if (!step.ok) failed += 1;
|
|
742
|
+
const summary = {
|
|
743
|
+
processed,
|
|
744
|
+
steps,
|
|
745
|
+
failed,
|
|
746
|
+
currentResource: step.resource,
|
|
747
|
+
...step.cursor !== void 0 ? { lastCursor: step.cursor } : {},
|
|
748
|
+
heartbeatAt: now()
|
|
749
|
+
};
|
|
750
|
+
lastSummary = summary;
|
|
751
|
+
const nowMs = now().getTime();
|
|
752
|
+
if (!step.ok || progressThrottleMs === 0 || nowMs - lastPersistMs >= progressThrottleMs) {
|
|
753
|
+
lastPersistMs = nowMs;
|
|
754
|
+
await runStore.saveProgress(run.id, summary, renewedLease());
|
|
755
|
+
await refreshCancel();
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
async throwIfCancelled() {
|
|
759
|
+
if (cancelled || controller.signal.aborted || await refreshCancel()) throw new CleanupCancelled(run.id);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
let result;
|
|
763
|
+
try {
|
|
764
|
+
result = await recipe.execute(plan, execCtx);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
if (err instanceof CleanupCancelled || cancelled || controller.signal.aborted) {
|
|
767
|
+
await settleCancelled(run, recipe, lease, [], lastSummary);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
await failRun(run, recipe, lease, `execute threw: ${errMsg(err)}`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (cancelled || await refreshCancel()) {
|
|
774
|
+
await settleCancelled(run, recipe, lease, result.results, lastSummary);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const results = result.results.length > limits.maxResults ? result.results.slice(0, limits.maxResults) : result.results;
|
|
778
|
+
const anyFailed = results.some((r) => !r.ok) || result.results.length > results.length;
|
|
779
|
+
const effectiveStatus = anyFailed && result.status === "completed" ? "partial" : result.status;
|
|
780
|
+
let verification = {
|
|
781
|
+
ok: !anyFailed,
|
|
782
|
+
checks: []
|
|
783
|
+
};
|
|
784
|
+
if (effectiveStatus !== "failed") try {
|
|
785
|
+
verification = await recipe.verify(plan, {
|
|
786
|
+
actor: run.actor,
|
|
787
|
+
now: now(),
|
|
788
|
+
signal: controller.signal,
|
|
789
|
+
ambient: run.ambient,
|
|
790
|
+
logger
|
|
791
|
+
});
|
|
792
|
+
} catch (err) {
|
|
793
|
+
await failRun(run, recipe, lease, `verify threw: ${errMsg(err)}`, results);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const capped = orderChecksFailuresFirst(verification, limits.maxChecks);
|
|
797
|
+
const success = effectiveStatus === "completed" && verification.ok;
|
|
798
|
+
const payload = {
|
|
799
|
+
status: effectiveStatus,
|
|
800
|
+
results,
|
|
801
|
+
verification: capped.verification,
|
|
802
|
+
...capped.checksTruncated > 0 ? { checksTruncated: capped.checksTruncated } : {},
|
|
803
|
+
...success ? {} : { failureReason: effectiveStatus === "partial" ? "partial results" : effectiveStatus === "failed" ? "execution failed" : "verification failed" }
|
|
804
|
+
};
|
|
805
|
+
const finalizing = await runStore.compareAndTransition(run.id, ["running"], "finalizing", { finalization: payload }, lease.token);
|
|
806
|
+
if (!finalizing) return;
|
|
807
|
+
await settleFromPayload(finalizing, recipe, lease, payload, { progress: {
|
|
808
|
+
...lastSummary,
|
|
809
|
+
processed,
|
|
810
|
+
steps,
|
|
811
|
+
failed,
|
|
812
|
+
heartbeatAt: now()
|
|
813
|
+
} });
|
|
814
|
+
}
|
|
815
|
+
/** Record a hard failure with failure evidence, CAS → failed (leased). */
|
|
816
|
+
async function failRun(run, recipe, lease, reason, results = []) {
|
|
817
|
+
const payload = {
|
|
818
|
+
status: "failed",
|
|
819
|
+
results,
|
|
820
|
+
verification: {
|
|
821
|
+
ok: false,
|
|
822
|
+
checks: []
|
|
823
|
+
},
|
|
824
|
+
failureReason: reason
|
|
825
|
+
};
|
|
826
|
+
const failing = await runStore.compareAndTransition(run.id, ["running"], "finalizing", {
|
|
827
|
+
failureReason: reason,
|
|
828
|
+
finalization: payload
|
|
829
|
+
}, lease.token);
|
|
830
|
+
if (!failing) return;
|
|
831
|
+
await settleFromPayload(failing, recipe, lease, payload);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Cancel settlement — cancellation is an AUDITED outcome, not a silent state
|
|
835
|
+
* flip: committed chunks remain in the database, so the run leaves evidence
|
|
836
|
+
* recording how much work had been committed, who cancelled, and that
|
|
837
|
+
* verification never ran.
|
|
838
|
+
*/
|
|
839
|
+
async function settleCancelled(run, recipe, lease, results, summary) {
|
|
840
|
+
const fresh = await runStore.get(run.id) ?? run;
|
|
841
|
+
const payload = {
|
|
842
|
+
status: "cancelled",
|
|
843
|
+
results,
|
|
844
|
+
verification: {
|
|
845
|
+
ok: false,
|
|
846
|
+
checks: [{
|
|
847
|
+
name: "verification.skipped",
|
|
848
|
+
ok: false,
|
|
849
|
+
detail: `run cancelled${fresh.cancelRequestedBy ? ` by ${fresh.cancelRequestedBy.ref}` : ""}${fresh.cancelReason ? `: ${fresh.cancelReason}` : ""} — post-state not verified; committed chunks remain`
|
|
850
|
+
}]
|
|
851
|
+
},
|
|
852
|
+
failureReason: "cancelled"
|
|
853
|
+
};
|
|
854
|
+
const finalizing = await runStore.compareAndTransition(run.id, ["running"], "finalizing", {
|
|
855
|
+
finalization: payload,
|
|
856
|
+
...summary ? { progress: summary } : {}
|
|
857
|
+
}, lease.token);
|
|
858
|
+
if (!finalizing) return;
|
|
859
|
+
await settleFromPayload(finalizing, recipe, lease, payload);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* ── Failure domain 3: finalization ──────────────────────────────────────────
|
|
863
|
+
* Persist evidence + manifest from the DURABLE payload, then CAS to the
|
|
864
|
+
* terminal status. On an evidence-store failure the run STAYS `finalizing`
|
|
865
|
+
* (payload intact) and is re-enqueued so a worker replays finalization only.
|
|
866
|
+
*/
|
|
867
|
+
async function settleFromPayload(run, recipe, lease, payload, extraPatch = {}) {
|
|
868
|
+
try {
|
|
869
|
+
await finalize(run, recipe, run.sealedPlan, payload);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
logger?.error?.("cleanup finalization failed — run left recoverable in finalizing", {
|
|
872
|
+
runId: run.id,
|
|
873
|
+
operationId: run.operationId,
|
|
874
|
+
error: errMsg(err)
|
|
875
|
+
});
|
|
876
|
+
try {
|
|
877
|
+
await jobQueue.enqueue({ runId: run.id });
|
|
878
|
+
} catch {}
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const terminal = terminalStatusOf(payload);
|
|
882
|
+
await runStore.compareAndTransition(run.id, ["finalizing"], terminal, {
|
|
883
|
+
completedAt: now(),
|
|
884
|
+
...payload.failureReason && terminal !== "completed" ? { failureReason: payload.failureReason } : {},
|
|
885
|
+
...extraPatch.progress ? { progress: extraPatch.progress } : {}
|
|
886
|
+
}, lease.token);
|
|
887
|
+
}
|
|
888
|
+
/** Recovery entry for a claimed `finalizing` run: NEVER re-executes. */
|
|
889
|
+
async function recoverFinalization(run, recipe, lease) {
|
|
890
|
+
const payload = run.finalization;
|
|
891
|
+
if (!payload) {
|
|
892
|
+
await settleFromPayload(run, recipe, lease, {
|
|
893
|
+
status: "failed",
|
|
894
|
+
results: [],
|
|
895
|
+
verification: {
|
|
896
|
+
ok: false,
|
|
897
|
+
checks: []
|
|
898
|
+
},
|
|
899
|
+
failureReason: "finalizing run has no persisted finalization payload"
|
|
900
|
+
});
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
await settleFromPayload(run, recipe, lease, payload);
|
|
904
|
+
if (writeFence && run.destructive) try {
|
|
905
|
+
await writeFence.release(run.operationId);
|
|
906
|
+
} catch (err) {
|
|
907
|
+
logger?.error?.("cleanup fence release during finalization recovery failed", {
|
|
908
|
+
runId: run.id,
|
|
909
|
+
operationId: run.operationId,
|
|
910
|
+
error: errMsg(err)
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/** Idempotently persist evidence + manifest for the run (keyed by operationId). */
|
|
915
|
+
async function finalize(run, recipe, plan, payload) {
|
|
916
|
+
const completedAt = now();
|
|
917
|
+
const { status, results, verification, checksTruncated, failureReason } = payload;
|
|
918
|
+
const processed = results.reduce((sum, r) => sum + r.processed, 0);
|
|
919
|
+
const evidence = createPurgeEvidence({
|
|
920
|
+
operationId: run.operationId,
|
|
921
|
+
subject: {
|
|
922
|
+
ref: `recipe:${recipe.id}`,
|
|
923
|
+
model: "CleanupRun"
|
|
924
|
+
},
|
|
925
|
+
scope: `recipe:${recipe.id}`,
|
|
926
|
+
strategy: recipe.evidenceStrategy ?? "hard",
|
|
927
|
+
status: status === "cancelled" ? "partial" : status,
|
|
928
|
+
measuresRetained: plan.retains.length > 0,
|
|
929
|
+
processed,
|
|
930
|
+
startedAt: run.startedAt ?? run.queuedAt,
|
|
931
|
+
completedAt,
|
|
932
|
+
occurredAt: completedAt,
|
|
933
|
+
actor: run.actor,
|
|
934
|
+
reason: buildEvidenceReason(run, status, failureReason),
|
|
935
|
+
results: results.map((r) => ({
|
|
936
|
+
resource: r.resource,
|
|
937
|
+
processed: r.processed,
|
|
938
|
+
ok: r.ok,
|
|
939
|
+
...r.error ? { error: r.error } : {}
|
|
940
|
+
})),
|
|
941
|
+
verification: {
|
|
942
|
+
ok: verification.ok,
|
|
943
|
+
checks: verification.checks.length,
|
|
944
|
+
...verification.checks.length || checksTruncated ? { note: [verification.checks.map((c) => `${c.name}:${c.ok ? "ok" : "fail"}`).join(", "), checksTruncated ? `(+${checksTruncated} checks truncated)` : ""].filter(Boolean).join(" ") } : {}
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
const manifestBase = {
|
|
948
|
+
runId: run.id,
|
|
949
|
+
recipeId: recipe.id,
|
|
950
|
+
recipeVersion: run.recipeVersion,
|
|
951
|
+
operationId: run.operationId,
|
|
952
|
+
planDigest: plan.digest,
|
|
953
|
+
status,
|
|
954
|
+
actor: run.actor,
|
|
955
|
+
reason: run.reason,
|
|
956
|
+
results,
|
|
957
|
+
verification,
|
|
958
|
+
...checksTruncated ? { checksTruncated } : {},
|
|
959
|
+
completedAt
|
|
960
|
+
};
|
|
961
|
+
const manifest = {
|
|
962
|
+
...manifestBase,
|
|
963
|
+
manifestDigest: computeManifestDigest(manifestBase)
|
|
964
|
+
};
|
|
965
|
+
await evidenceStore.finalize({
|
|
966
|
+
evidence,
|
|
967
|
+
manifest
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
function buildEvidenceReason(run, status, failureReason) {
|
|
971
|
+
const parts = [run.reason];
|
|
972
|
+
if (status === "cancelled") {
|
|
973
|
+
const by = run.cancelRequestedBy ? ` by ${run.cancelRequestedBy.ref}` : "";
|
|
974
|
+
const why = run.cancelReason ? `: ${run.cancelReason}` : "";
|
|
975
|
+
parts.push(`(cancelled${by}${why})`);
|
|
976
|
+
} else if (failureReason) parts.push(`(${failureReason})`);
|
|
977
|
+
return parts.join(" ");
|
|
978
|
+
}
|
|
979
|
+
async function getRun(id) {
|
|
980
|
+
const run = await runStore.get(id);
|
|
981
|
+
if (!run) throw CleanupErrors.runNotFound(id);
|
|
982
|
+
return run;
|
|
983
|
+
}
|
|
984
|
+
async function cancel(id, input) {
|
|
985
|
+
const run = await getRun(id);
|
|
986
|
+
if (CLEANUP_TERMINAL_STATUSES.includes(run.status)) throw CleanupErrors.invalidAction("cancel", run.status);
|
|
987
|
+
await runStore.requestCancel(id, {
|
|
988
|
+
actor: input?.actor,
|
|
989
|
+
reason: input?.reason,
|
|
990
|
+
requestedAt: now()
|
|
991
|
+
});
|
|
992
|
+
const immediate = await runStore.compareAndTransition(id, ["queued", "planned"], "cancelled", { completedAt: now() });
|
|
993
|
+
if (immediate) {
|
|
994
|
+
const recipe = registry.get(immediate.recipeId);
|
|
995
|
+
const payload = {
|
|
996
|
+
status: "cancelled",
|
|
997
|
+
results: [],
|
|
998
|
+
verification: {
|
|
999
|
+
ok: false,
|
|
1000
|
+
checks: [{
|
|
1001
|
+
name: "verification.skipped",
|
|
1002
|
+
ok: false,
|
|
1003
|
+
detail: "run cancelled before execution — no data was changed"
|
|
1004
|
+
}]
|
|
1005
|
+
},
|
|
1006
|
+
failureReason: "cancelled"
|
|
1007
|
+
};
|
|
1008
|
+
try {
|
|
1009
|
+
await finalize(immediate, recipe, immediate.sealedPlan, payload);
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
logger?.error?.("cleanup cancel evidence write failed", {
|
|
1012
|
+
runId: id,
|
|
1013
|
+
error: errMsg(err)
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return getRun(id);
|
|
1018
|
+
}
|
|
1019
|
+
async function retry(id) {
|
|
1020
|
+
const run = await getRun(id);
|
|
1021
|
+
if (run.status !== "failed" && run.status !== "cancelled") throw CleanupErrors.invalidAction("retry", run.status);
|
|
1022
|
+
const recipe = registry.get(run.recipeId);
|
|
1023
|
+
const ctx = {
|
|
1024
|
+
actor: run.actor,
|
|
1025
|
+
now: now(),
|
|
1026
|
+
ambient: run.ambient,
|
|
1027
|
+
logger
|
|
1028
|
+
};
|
|
1029
|
+
await ensureAvailable(recipe, ctx);
|
|
1030
|
+
if ((recipe.version ?? "1") !== run.recipeVersion) throw CleanupErrors.planChanged(recipe.version ?? "1", run.recipeVersion);
|
|
1031
|
+
const fresh = await seal(recipe, {
|
|
1032
|
+
recipeId: run.recipeId,
|
|
1033
|
+
actor: run.actor,
|
|
1034
|
+
parameters: run.parameters,
|
|
1035
|
+
ambient: run.ambient
|
|
1036
|
+
}, ctx);
|
|
1037
|
+
if (fresh.digest !== run.planDigest) throw CleanupErrors.planChanged(fresh.digest, run.planDigest);
|
|
1038
|
+
const requeued = await runStore.reArmIfPermitted(run.id, {
|
|
1039
|
+
queuedAt: now(),
|
|
1040
|
+
cancelRequested: false
|
|
1041
|
+
});
|
|
1042
|
+
if (!requeued) {
|
|
1043
|
+
const current = await getRun(id);
|
|
1044
|
+
if (current.status !== "failed" && current.status !== "cancelled") throw CleanupErrors.invalidAction("retry", current.status);
|
|
1045
|
+
throw CleanupErrors.alreadyRunning("another-active-run");
|
|
1046
|
+
}
|
|
1047
|
+
await enqueueOrFail(requeued);
|
|
1048
|
+
return getRun(id);
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
preview,
|
|
1052
|
+
execute,
|
|
1053
|
+
processRun,
|
|
1054
|
+
getRun,
|
|
1055
|
+
cancel,
|
|
1056
|
+
retry
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function errMsg(err) {
|
|
1060
|
+
return err instanceof Error ? err.message : String(err);
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/cleanup/module.ts
|
|
1064
|
+
function defaultResolveActor(req) {
|
|
1065
|
+
const id = (req.scope ? getUserId(req.scope) : void 0) ?? req.user?.id;
|
|
1066
|
+
if (!id) throw CleanupErrors.actorRequired();
|
|
1067
|
+
return {
|
|
1068
|
+
ref: `user:${id}`,
|
|
1069
|
+
kind: "user"
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
const recipeIdSchema = {
|
|
1073
|
+
type: "string",
|
|
1074
|
+
minLength: 1,
|
|
1075
|
+
maxLength: 200
|
|
1076
|
+
};
|
|
1077
|
+
const parametersSchema = {
|
|
1078
|
+
type: "object",
|
|
1079
|
+
additionalProperties: true
|
|
1080
|
+
};
|
|
1081
|
+
const previewBodySchema = {
|
|
1082
|
+
type: "object",
|
|
1083
|
+
required: ["recipe"],
|
|
1084
|
+
additionalProperties: false,
|
|
1085
|
+
properties: {
|
|
1086
|
+
recipe: recipeIdSchema,
|
|
1087
|
+
parameters: parametersSchema
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
const runsBodySchema = {
|
|
1091
|
+
type: "object",
|
|
1092
|
+
required: [
|
|
1093
|
+
"recipe",
|
|
1094
|
+
"planDigest",
|
|
1095
|
+
"reason"
|
|
1096
|
+
],
|
|
1097
|
+
additionalProperties: false,
|
|
1098
|
+
properties: {
|
|
1099
|
+
recipe: recipeIdSchema,
|
|
1100
|
+
parameters: parametersSchema,
|
|
1101
|
+
planDigest: {
|
|
1102
|
+
type: "string",
|
|
1103
|
+
minLength: 1,
|
|
1104
|
+
maxLength: 128
|
|
1105
|
+
},
|
|
1106
|
+
reason: {
|
|
1107
|
+
type: "string",
|
|
1108
|
+
minLength: 1,
|
|
1109
|
+
maxLength: 2e3
|
|
1110
|
+
},
|
|
1111
|
+
confirmation: {
|
|
1112
|
+
type: "string",
|
|
1113
|
+
maxLength: 500
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
const runIdParamsSchema = {
|
|
1118
|
+
type: "object",
|
|
1119
|
+
required: ["id"],
|
|
1120
|
+
properties: { id: {
|
|
1121
|
+
type: "string",
|
|
1122
|
+
minLength: 1,
|
|
1123
|
+
maxLength: 128
|
|
1124
|
+
} }
|
|
1125
|
+
};
|
|
1126
|
+
const actionBodySchema = {
|
|
1127
|
+
type: "object",
|
|
1128
|
+
required: ["action"],
|
|
1129
|
+
additionalProperties: false,
|
|
1130
|
+
properties: {
|
|
1131
|
+
action: {
|
|
1132
|
+
type: "string",
|
|
1133
|
+
enum: ["cancel", "retry"]
|
|
1134
|
+
},
|
|
1135
|
+
/** Optional cancel justification — persisted on the run + evidence. */
|
|
1136
|
+
reason: {
|
|
1137
|
+
type: "string",
|
|
1138
|
+
maxLength: 2e3
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
/**
|
|
1143
|
+
* Build the Data Cleanup Center Arc module. The module's public export is the
|
|
1144
|
+
* live {@link CleanupService} (so other modules / tests can drive it directly).
|
|
1145
|
+
*/
|
|
1146
|
+
function createDataCleanupModule(deps) {
|
|
1147
|
+
const registry = createCleanupRegistry(deps.recipes);
|
|
1148
|
+
const service = createCleanupService({
|
|
1149
|
+
registry,
|
|
1150
|
+
runStore: deps.runStore,
|
|
1151
|
+
evidenceStore: deps.evidenceStore,
|
|
1152
|
+
writeFence: deps.writeFence,
|
|
1153
|
+
jobQueue: deps.jobQueue,
|
|
1154
|
+
limits: deps.limits,
|
|
1155
|
+
leaseMs: deps.leaseMs,
|
|
1156
|
+
progressThrottleMs: deps.progressThrottleMs,
|
|
1157
|
+
generateId: deps.generateId,
|
|
1158
|
+
now: deps.now
|
|
1159
|
+
});
|
|
1160
|
+
const prefix = deps.prefix ?? "/governance/data-cleanup";
|
|
1161
|
+
const resolveActor = deps.resolveActor ?? defaultResolveActor;
|
|
1162
|
+
const resolveAmbient = deps.resolveAmbient;
|
|
1163
|
+
const view = deps.permissions.view;
|
|
1164
|
+
const execute = deps.permissions.execute;
|
|
1165
|
+
const manage = deps.permissions.manage ?? deps.permissions.execute;
|
|
1166
|
+
const resource = defineResource({
|
|
1167
|
+
name: deps.moduleName ?? "data-cleanup",
|
|
1168
|
+
displayName: "Data Cleanup Center",
|
|
1169
|
+
tag: "Governance",
|
|
1170
|
+
prefix,
|
|
1171
|
+
customRoutesOnly: true,
|
|
1172
|
+
routes: [
|
|
1173
|
+
{
|
|
1174
|
+
method: "GET",
|
|
1175
|
+
path: "/recipes",
|
|
1176
|
+
summary: "List available cleanup recipes",
|
|
1177
|
+
permissions: view,
|
|
1178
|
+
raw: true,
|
|
1179
|
+
handler: async (_req, reply) => {
|
|
1180
|
+
return reply.send({ recipes: registry.introspect() });
|
|
1181
|
+
}
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
method: "POST",
|
|
1185
|
+
path: "/preview",
|
|
1186
|
+
summary: "Preview a cleanup plan (no mutation)",
|
|
1187
|
+
permissions: view,
|
|
1188
|
+
raw: true,
|
|
1189
|
+
schema: { body: previewBodySchema },
|
|
1190
|
+
handler: async (req, reply) => {
|
|
1191
|
+
const r = req;
|
|
1192
|
+
const body = req.body;
|
|
1193
|
+
const plan = await service.preview({
|
|
1194
|
+
recipeId: body.recipe,
|
|
1195
|
+
parameters: body.parameters,
|
|
1196
|
+
actor: resolveActor(r),
|
|
1197
|
+
ambient: resolveAmbient?.(r)
|
|
1198
|
+
});
|
|
1199
|
+
return reply.send(plan);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
method: "POST",
|
|
1204
|
+
path: "/runs",
|
|
1205
|
+
summary: "Execute a confirmed cleanup plan",
|
|
1206
|
+
permissions: execute,
|
|
1207
|
+
raw: true,
|
|
1208
|
+
schema: { body: runsBodySchema },
|
|
1209
|
+
handler: async (req, reply) => {
|
|
1210
|
+
const r = req;
|
|
1211
|
+
const body = req.body;
|
|
1212
|
+
const run = await service.execute({
|
|
1213
|
+
recipeId: body.recipe,
|
|
1214
|
+
parameters: body.parameters,
|
|
1215
|
+
planDigest: body.planDigest,
|
|
1216
|
+
reason: body.reason,
|
|
1217
|
+
confirmation: body.confirmation,
|
|
1218
|
+
actor: resolveActor(r),
|
|
1219
|
+
ambient: resolveAmbient?.(r)
|
|
1220
|
+
});
|
|
1221
|
+
return reply.status(202).send(run);
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
method: "GET",
|
|
1226
|
+
path: "/runs/:id",
|
|
1227
|
+
summary: "Observe a cleanup run",
|
|
1228
|
+
permissions: view,
|
|
1229
|
+
raw: true,
|
|
1230
|
+
schema: { params: runIdParamsSchema },
|
|
1231
|
+
handler: async (req, reply) => {
|
|
1232
|
+
const { id } = req.params;
|
|
1233
|
+
return reply.send(await service.getRun(id));
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
method: "POST",
|
|
1238
|
+
path: "/runs/:id/action",
|
|
1239
|
+
summary: "Cancel or retry a cleanup run",
|
|
1240
|
+
permissions: manage,
|
|
1241
|
+
raw: true,
|
|
1242
|
+
schema: {
|
|
1243
|
+
params: runIdParamsSchema,
|
|
1244
|
+
body: actionBodySchema
|
|
1245
|
+
},
|
|
1246
|
+
handler: async (req, reply) => {
|
|
1247
|
+
const actionActor = resolveActor(req);
|
|
1248
|
+
const { id } = req.params;
|
|
1249
|
+
const { action, reason } = req.body;
|
|
1250
|
+
if (action === "cancel") return reply.send(await service.cancel(id, {
|
|
1251
|
+
actor: actionActor,
|
|
1252
|
+
reason
|
|
1253
|
+
}));
|
|
1254
|
+
return reply.send(await service.retry(id));
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
]
|
|
1258
|
+
});
|
|
1259
|
+
return defineModule({
|
|
1260
|
+
name: deps.moduleName ?? "data-cleanup",
|
|
1261
|
+
...deps.owns ? { owns: deps.owns } : {},
|
|
1262
|
+
bootstrap: () => service,
|
|
1263
|
+
resources: () => [resource]
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
//#endregion
|
|
1267
|
+
export { CLEANUP_TERMINAL_STATUSES, CleanupCancelled, CleanupError, CleanupErrors, DEFAULT_CLEANUP_LIMITS, GLOBAL_DESTRUCTIVE_KEY, MemoryCleanupEvidenceStore, MemoryCleanupJobQueue, MemoryCleanupRunStore, canonicalJson, computeManifestDigest, computePlanDigest, createCleanupRegistry, createCleanupService, createDataCleanupModule, recipeFromSteps };
|