@classytic/arc 2.23.0 → 2.28.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.
Files changed (147) hide show
  1. package/README.md +2 -0
  2. package/dist/{BaseController-BQ8KNvWC.mjs → BaseController-B65zbz9m.mjs} +94 -23
  3. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/{applyPermissionResult-Tw4iXtlf.mjs → applyPermissionResult-BnyVE9eD.mjs} +10 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +263 -259
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cleanup/index.d.mts +313 -0
  14. package/dist/cleanup/index.mjs +1267 -0
  15. package/dist/cli/commands/describe.d.mts +1 -1
  16. package/dist/cli/commands/docs.mjs +1 -1
  17. package/dist/cli/commands/generate.mjs +1 -1
  18. package/dist/cli/commands/init.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +5 -4
  23. package/dist/core-BGa8TXJR.mjs +221 -0
  24. package/dist/{createActionRouter-BuOv-m4Y.mjs → createActionRouter-C-N7z2EL.mjs} +3 -2
  25. package/dist/{createAggregationRouter-CgJv_BU_.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/{createApp-CjOeWJ9_.mjs → createApp-Du5G54TM.mjs} +187 -96
  27. package/dist/{core-DQ6rDTyH.mjs → defineResource-2zvVVpxg.mjs} +297 -230
  28. package/dist/docs/index.d.mts +1 -1
  29. package/dist/docs/index.mjs +2 -2
  30. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  31. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  32. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  33. package/dist/events/index.d.mts +83 -314
  34. package/dist/events/index.mjs +183 -95
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  37. package/dist/factory/index.d.mts +5 -5
  38. package/dist/factory/index.mjs +30 -8
  39. package/dist/{fastify-augmentation-g_f9omSc.d.mts → fastify-augmentation-OzYGIiU1.d.mts} +26 -2
  40. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  41. package/dist/headers-BvlQSDMB.mjs +45 -0
  42. package/dist/health-CB_m4S32.mjs +377 -0
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/idempotency/index.mjs +27 -3
  45. package/dist/{index-wMoWBLkn.d.mts → index-BMnbzvtO.d.mts} +3 -3
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/index-CZY4EGga.d.mts +107 -0
  48. package/dist/{index-CC5ruDGh.d.mts → index-CjVg-Epg.d.mts} +37 -5
  49. package/dist/{index-BBsvQt7U.d.mts → index-DPRH0Cme.d.mts} +36 -106
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +7 -6
  52. package/dist/{init-CjO4p_q1.mjs → init-tlRSt667.mjs} +206 -208
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +4 -4
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +37 -30
  63. package/dist/integrations/webhooks.mjs +51 -62
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/{mcpPlugin-BtzIL_kM.mjs → mcpPlugin-Cs6nRGCt.mjs} +12 -10
  69. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  70. package/dist/middleware/index.d.mts +17 -1
  71. package/dist/middleware/index.mjs +1 -1
  72. package/dist/migrations/index.d.mts +94 -44
  73. package/dist/migrations/index.mjs +196 -44
  74. package/dist/module-_-M7C5ES.mjs +29 -0
  75. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  76. package/dist/{openapi-jSDx-VDv.mjs → openapi-BLV4OMXb.mjs} +3 -3
  77. package/dist/permissions/index.d.mts +2 -2
  78. package/dist/permissions/index.mjs +3 -3
  79. package/dist/{permissions-tbxFHxjZ.mjs → permissions-BrtiBKGs.mjs} +229 -229
  80. package/dist/pipeline/index.d.mts +1 -1
  81. package/dist/{actionPermissions-lszoJle1.mjs → pipeline-BGRnV-To.mjs} +4 -27
  82. package/dist/plugins/index.d.mts +6 -77
  83. package/dist/plugins/index.mjs +11 -95
  84. package/dist/plugins/response-cache.mjs +1 -0
  85. package/dist/plugins/tracing-entry.mjs +1 -1
  86. package/dist/{index-Ba10fdKp.d.mts → preloadResources-CM4uUaFv.d.mts} +51 -50
  87. package/dist/preloadResources-DSY5HGpS.mjs +45 -0
  88. package/dist/presets/filesUpload.d.mts +3 -3
  89. package/dist/presets/filesUpload.mjs +3 -3
  90. package/dist/presets/index.d.mts +2 -1
  91. package/dist/presets/index.mjs +1 -1
  92. package/dist/presets/multiTenant.d.mts +1 -1
  93. package/dist/presets/multiTenant.mjs +1 -1
  94. package/dist/presets/search.d.mts +2 -2
  95. package/dist/presets/search.mjs +1 -1
  96. package/dist/{presets-BOe4Ugkl.mjs → presets-DwFDVKP0.mjs} +2 -2
  97. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  98. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  99. package/dist/{registerRoute-CWO6mdYe.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  100. package/dist/{realtime-I8OrfnMR.mjs → realtime-DEuDu9io.mjs} +3 -3
  101. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  102. package/dist/registerRoute-DTp18Ah_.mjs +108 -0
  103. package/dist/registry/index.d.mts +2 -2
  104. package/dist/registry/index.mjs +1 -1
  105. package/dist/{registry-vcOCtPij.mjs → registry-9km_w1aP.mjs} +15 -0
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/schemas/index.d.mts +67 -67
  110. package/dist/schemas/index.mjs +2 -3
  111. package/dist/scim/index.mjs +1 -2
  112. package/dist/scope/index.d.mts +2 -2
  113. package/dist/scope/index.mjs +4 -4
  114. package/dist/{scope-LONQ3mfZ.mjs → scope-DfaPntpo.mjs} +4 -5
  115. package/dist/{sse-0u4P4-OI.mjs → sse-BQ1w0dfl.mjs} +1 -1
  116. package/dist/testing/cleanupStoreContract.d.mts +10 -0
  117. package/dist/testing/cleanupStoreContract.mjs +375 -0
  118. package/dist/testing/index.d.mts +5 -3
  119. package/dist/testing/index.mjs +4 -2
  120. package/dist/testing/outboxStoreContract.d.mts +13 -0
  121. package/dist/testing/outboxStoreContract.mjs +247 -0
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/types-C_ZZRcD6.d.mts +450 -0
  126. package/dist/{module-B4u10iOJ.d.mts → types-Cu6vVJmh.d.mts} +196 -60
  127. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  128. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  129. package/dist/{types-5OuqeTDX.d.mts → types-QFGc3Cjv.d.mts} +1 -1
  130. package/dist/usage/index.d.mts +3 -52
  131. package/dist/usage/index.mjs +0 -0
  132. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  133. package/dist/utils/index.d.mts +58 -85
  134. package/dist/utils/index.mjs +4 -2
  135. package/dist/{utils-CZyeRXTh.mjs → utils-taUVD4p3.mjs} +47 -337
  136. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  137. package/dist/{versioning-D6PvCwpq.d.mts → versioning-DXgY5BUm.d.mts} +11 -29
  138. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  139. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  140. package/package.json +541 -513
  141. package/skills/arc/references/api-reference.md +3 -1
  142. package/skills/arc/references/integrations.md +20 -11
  143. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  144. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  145. package/dist/health-ScyjIVo5.mjs +0 -202
  146. package/dist/requestId-5BhTVzBv.mjs +0 -113
  147. package/dist/types-XTApa8b6.mjs +0 -24
@@ -0,0 +1,1267 @@
1
+ import { g as getUserId } from "../types-CSyCxl2V.mjs";
2
+ import { t as defineResource } from "../defineResource-2zvVVpxg.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 };