@sentropic/artifacts 0.1.3 → 0.2.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 (75) hide show
  1. package/README.md +74 -4
  2. package/dist/adapters/postgres-commit.d.ts +15 -0
  3. package/dist/adapters/postgres-commit.js +80 -0
  4. package/dist/adapters/postgres.d.ts +5 -2
  5. package/dist/adapters/postgres.js +39 -0
  6. package/dist/content/handler.js +79 -63
  7. package/dist/http.d.ts +42 -29
  8. package/dist/http.js +168 -491
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +2 -0
  11. package/dist/mcp-core.d.ts +52 -0
  12. package/dist/mcp-core.js +111 -0
  13. package/dist/mcp.d.ts +4 -60
  14. package/dist/mcp.js +7 -276
  15. package/dist/profile/contracts.d.ts +67 -0
  16. package/dist/profile/contracts.js +31 -0
  17. package/dist/profile/index.d.ts +6 -0
  18. package/dist/profile/index.js +117 -0
  19. package/dist/profile/json.d.ts +10 -0
  20. package/dist/profile/json.js +76 -0
  21. package/dist/profile/radar.d.ts +3 -0
  22. package/dist/profile/radar.js +21 -0
  23. package/dist/profile/schemas.d.ts +6 -0
  24. package/dist/profile/schemas.js +76 -0
  25. package/dist/profile/validation.d.ts +5 -0
  26. package/dist/profile/validation.js +104 -0
  27. package/dist/profile/validators.d.ts +3 -0
  28. package/dist/profile/validators.js +51 -0
  29. package/dist/server/authorized-replay.d.ts +33 -0
  30. package/dist/server/authorized-replay.js +31 -0
  31. package/dist/server/command-identity.d.ts +52 -0
  32. package/dist/server/command-identity.js +71 -0
  33. package/dist/server/commit/brands.d.ts +7 -0
  34. package/dist/server/commit/brands.js +3 -0
  35. package/dist/server/commit/coordinator.d.ts +2 -0
  36. package/dist/server/commit/coordinator.js +729 -0
  37. package/dist/server/commit/types.d.ts +379 -0
  38. package/dist/server/commit/types.js +94 -0
  39. package/dist/server/commit-contract.d.ts +4 -0
  40. package/dist/server/commit-contract.js +2 -0
  41. package/dist/server/failures.d.ts +84 -0
  42. package/dist/server/failures.js +133 -0
  43. package/dist/server/gc.d.ts +42 -1
  44. package/dist/server/gc.js +168 -5
  45. package/dist/server/idempotency.d.ts +4 -0
  46. package/dist/server/idempotency.js +14 -0
  47. package/dist/server/lifecycle.d.ts +2 -1
  48. package/dist/server/lifecycle.js +15 -1
  49. package/dist/server/object-intents.d.ts +14 -1
  50. package/dist/server/object-intents.js +31 -0
  51. package/dist/server/outcome-contract.d.ts +3 -0
  52. package/dist/server/outcome-contract.js +3 -0
  53. package/dist/server/outcome-projection.d.ts +21 -0
  54. package/dist/server/outcome-projection.js +29 -0
  55. package/dist/server/preparation-cleanup.d.ts +31 -0
  56. package/dist/server/preparation-cleanup.js +106 -0
  57. package/dist/server/publication-limits.d.ts +10 -0
  58. package/dist/server/publication-limits.js +38 -0
  59. package/dist/server/publication.d.ts +85 -1
  60. package/dist/server/publication.js +288 -31
  61. package/dist/server/service.d.ts +64 -1
  62. package/dist/server/service.js +187 -1
  63. package/dist/server/uploads.d.ts +4 -1
  64. package/dist/server/uploads.js +63 -35
  65. package/dist/testing.d.ts +50 -0
  66. package/dist/testing.js +86 -0
  67. package/docs/migration-l6.md +129 -0
  68. package/examples/radar-commit-setup.ts +299 -0
  69. package/l0-freeze/command-identity-contract.ts +303 -0
  70. package/l0-freeze/commit-contract.ts +421 -0
  71. package/l0-freeze/failure-contract.ts +174 -0
  72. package/l0-freeze/profile-contract.ts +219 -0
  73. package/migrations/0003_coordinator.sql +28 -0
  74. package/package.json +31 -1
  75. package/src/contracts.ts +125 -0
@@ -0,0 +1,129 @@
1
+ # Artifacts 0.2.0 transport migration
2
+
3
+ The registry baseline checked for this change is `@sentropic/artifacts@0.1.4`.
4
+ Radar 0.1.2 and upstream 0.1.4 hosts must migrate their composition before
5
+ loading the 0.2.0 HTTP or MCP surface. Publication is a separate owner-approved
6
+ release step; this branch does not publish packages.
7
+
8
+ ## Required host composition
9
+
10
+ Compile an explicit immutable profile with `compileArtifactsProfile`. Radar
11
+ selects `artifacts_ensure`, `artifacts_publish`, `artifacts_list` and
12
+ `artifacts_get`. Pass the same compiled profile to `createHttpArtifactApp`,
13
+ the MCP adapter/executor and `createArtifactsRegistryDefinition`. Empty
14
+ selection exposes nothing. Missing configuration fails construction.
15
+
16
+ Supply a raw PostgreSQL pool to `createPostgresCommitExecutor` and a
17
+ transaction-bound policy participant factory to `createArtifactCommitCoordinator`.
18
+ The coordinator owns BEGIN, key locking, COMMIT, rollback and release.
19
+ Remove session advisory-lock wrappers, pool-routing proxies, nested BEGIN
20
+ and no-op release wrappers from the old provider composition.
21
+
22
+ Create one trusted `ArtifactInvocationContext` for every request, containing
23
+ the verified actor, trusted scope, invocation ID and `authorizeCommit` guard.
24
+ Pass it to `app.execute(request, invocation)` or `app.handle(request, invocation)`.
25
+ Accounting hosts call `execute` once and project its typed outcome themselves.
26
+ Do not call both methods for the same operation. The MCP adapter requires
27
+ `createInvocation` and verifies its actor against `ctx.principal.sub`.
28
+ HTTP and MCP must use the identical canonical verified actor string.
29
+
30
+ Supply one qualified `createRadarKeyDerivation` binding to both transports.
31
+ Caller arguments contain the original caller key; actor, scope, internal key,
32
+ hash and guard come from the host. An optional platform envelope must exactly
33
+ match the argument key. Keep publication URLs under one shared
34
+ `publicationUrls` policy so HTTP and MCP return identical receipts.
35
+
36
+ ## Public coordinator example and cleanup
37
+
38
+ The packed `examples/radar-commit-setup.ts` uses public entrypoints only:
39
+ `@sentropic/artifacts/adapters/postgres-commit`, `/server/commit-contract`
40
+ and `/mcp-core`. The commit contract exports `CommitExecutor`,
41
+ `CommitCoordinatorOptions`, `ArtifactPolicyDriver` and `CoordinatorTestPoint`.
42
+ The example compiles the frozen `ArtifactProviderProfile` shape, binds policy
43
+ reads/quota to the acquired PostgreSQL transaction and mints its guard fence.
44
+
45
+ Before activation, configure `cleanupWindowMs` on guarded publication and
46
+ start a cleanup scheduler; the example supplies a 30-second preparation
47
+ lifetime and a one-second serialized sweep. Each sweep processes at most
48
+ 100 attempts. Capacity, sweep cadence and storage availability determine
49
+ whether the configured deadline is met; failed cleanup remains observable debt.
50
+ Reads are limited to the same batch size, with unsettled debt first. Settled
51
+ attempts receive metadata-only existence checks for 60 seconds after their
52
+ deadline; subsequent orphan recovery uses the host's ordinary GC. No cleanup
53
+ or inspection downloads blob contents. Invalid metadata is retained as failed
54
+ debt, excluded from subsequent sweeps, and returned in inspection's
55
+ `invalidIntents`; the sweep reports it after processing the remaining batch.
56
+
57
+ `/server/object-intents` exposes `inspectPreparations(host, artifactId)` and
58
+ `scheduleBoundedPreparationCleanup(host, throughMs)`. Bind `host.intents`
59
+ to the PostgreSQL artifact store, plus `blobs`, `refs` and `claims`.
60
+ Keep the claim store's transaction open through external DELETE, then commit
61
+ or roll back and release it, as `startRadarPreparationCleanup` demonstrates.
62
+ Inspection returns `attempts`, `protectedKeys` and `abandonedKeys`; every
63
+ attempt retains its ID, invocation, blob keys, preparation time and deadline.
64
+ Cleanup invalidates expired preparations before removing their pins, retains
65
+ other references/pins, and never promotes an abandoned attempt to success.
66
+
67
+ `/testing` exports `pausePurgeBeforeDelete(host, artifactId)` and
68
+ `inspectDeletionClaims(host)`. Bind the test host's `storageScopeId`, `blobs`,
69
+ inspectable `claims`, and `purge(artifactId, deletingBlobs)` callback. The
70
+ callback must commit its durable `purge:<artifactId>` claims and use the
71
+ supplied deletion adapter for that purge only. The pause returns `claimId`,
72
+ `blobKeys`, `release()` and `finished`; release it in a `finally` block.
73
+ Inspection reads durable claim history without releasing exclusion or
74
+ granting publication authority. The seams do not alter L3 ports or profiles.
75
+
76
+ ## Domain adapters and transaction boundaries
77
+
78
+ `ensureHost.lockBinding` and `resolveBinding` receive the coordinator's
79
+ transaction client. Resolve the binding and write ACL/lifecycle state on that
80
+ same client. Concurrent commands must serialize on the binding identity,
81
+ including when their caller keys differ. Replay resolves current identity
82
+ under this lock and compares it to the stored receipt. Missing or changed
83
+ targets deny before the host guard; explicit replay requires a binding.
84
+
85
+ Use `createGuardedEnsureService` and `createGuardedPublicationService` for
86
+ custom services. The canonical coordinator exposes `finalizeOperation` and
87
+ `replayOperation` with the frozen L0 inputs. Bind fresh domain request plans
88
+ through its `operations` option; no process-local replay cache is used.
89
+ Provider adapters use `finalizePrepared` and `replayPrepared` on that same
90
+ coordinator. Guards run after all blocking waits and must be side-effect-free.
91
+ Policy/quota registration is trusted construction, never a public argument.
92
+
93
+ `ArtifactCommitParticipant.readAdmission` reads current approval/revision on
94
+ the leased transaction. Keep policy locks through COMMIT. Quota reservation
95
+ is provisional and rolls back with denial. Object authorization also runs
96
+ for replay; a stored success alone confers no authority.
97
+
98
+ Use `createPostgresGcService(executor, refsForTransaction)` for PostgreSQL GC.
99
+ The reference-store factory must bind its reads to the supplied client.
100
+ GC keeps the transaction and digest exclusion through external deletion and
101
+ claim completion. Do not pass a raw pool to transaction-scoped deletion claims.
102
+
103
+ ## Durable state and failures
104
+
105
+ Stop old writers before enabling the new composition. Apply migrations
106
+ `0001_artifacts.sql`, `0002_lifecycle.sql` and additive `0003_coordinator.sql`.
107
+ Preserve historical rows and objects. Supply `legacyRecords`, using
108
+ `createPostgresLegacyArtifactRecords(pool)` for PostgreSQL. An empty inventory
109
+ reader is appropriate only for a verified fresh store.
110
+
111
+ Ambiguous legacy keys and unknown command codecs return a stable
112
+ 503 `capability_unavailable`, non-retriable migration condition. They are
113
+ never promoted to newly authorized successes and never trigger allocation
114
+ under another key. Reconcile actual pilot inventory separately; unknown
115
+ historical array order cannot be recovered from a sorted manifest.
116
+
117
+ New receipts store the ordered `artifacts-command/v1` identity in
118
+ `artifacts.operation_receipts`. Manifest digests retain their historical
119
+ codec. A denied attempt has no success receipt; reauthorization may retry the
120
+ same key. An unknown COMMIT outcome requires the same key and a fresh guarded
121
+ lookup. Private failure causes remain internal. HTTP and MCP project the same
122
+ public code and retry classification; status 503 alone does not imply retry.
123
+
124
+ Public host imports are `@sentropic/artifacts/server/commit-contract`,
125
+ `@sentropic/artifacts/server/outcome-contract` and `@sentropic/artifacts/testing`.
126
+ The `/mcp-core` runtime stays independent of the MCP platform runtime.
127
+ For tests that subscribe after arrival, explicitly arm barrier points with
128
+ `createArtifactCommitBarrier({ points: ['before_commit'] })`. Other arrivals
129
+ remain observational; a barrier cannot grant authority or certify success.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * L3 (immo #861) — typed Radar construction example (conductor-directed).
3
+ *
4
+ * Shows the exact construction Radar wires: a real coordinator over the
5
+ * PostgreSQL executor, a transaction-bound PG policy/quota participant
6
+ * registered at construction, fence minting in the host guard, and the
7
+ * preparation-abandonment queue bound to a cleanup sweeper. Imports only
8
+ * public package entrypoints. Pass this coordinator plus the compiled profile to
9
+ * `createHttpArtifactApp` (r2 section 3.2); nothing here invents that seam.
10
+ * The `radar_commit_example` tables below are illustrative host tables so
11
+ * the example runs standalone; Radar's real adapter owns its own tables.
12
+ */
13
+ import type { Pool } from 'pg';
14
+ import { createPostgresCommitExecutor } from '@sentropic/artifacts/adapters/postgres-commit';
15
+ import { createArtifactCommitCoordinator } from '@sentropic/artifacts/server/commit-contract';
16
+ import { compileArtifactsProfile, RADAR_ARTIFACTS_PROFILE } from '@sentropic/artifacts/mcp-core';
17
+ import { createPostgresArtifactStore } from '@sentropic/artifacts/adapters/postgres';
18
+ import { createPostgresDeletionClaimStore } from '@sentropic/artifacts/server/gc';
19
+ import { scheduleBoundedPreparationCleanup } from '@sentropic/artifacts/server/object-intents';
20
+ import type { PublicationBlobStore } from '@sentropic/artifacts/server/publication';
21
+ import type {
22
+ ArtifactAuthorizationDecision,
23
+ ArtifactCommitContext,
24
+ ArtifactCommitTransaction,
25
+ ArtifactPolicyDriver,
26
+ CommitPolicyParticipantFactory,
27
+ CommitCoordinatorOptions,
28
+ CoordinatorTestPoint,
29
+ ArtifactCommitCoordinator,
30
+ FinalizeRequest,
31
+ FinalizeResult,
32
+ InternalIdempotencyKey,
33
+ } from '@sentropic/artifacts/server/commit-contract';
34
+
35
+ /** One abandoned preparation awaiting host cleanup. */
36
+ export interface AbandonedAttempt {
37
+ readonly attemptId: string;
38
+ readonly scopeId: string;
39
+ readonly internalKey: string;
40
+ readonly atMs: number;
41
+ }
42
+
43
+ /** Configure publication with this lifetime; sweep cadence is part of the activation bound. */
44
+ export const RADAR_PREPARATION_LIFETIME_MS = 30_000;
45
+
46
+ /** Bind durable intent cleanup before activation; observe failures as preparation debt. */
47
+ export function startRadarPreparationCleanup(options: {
48
+ pool: Pool;
49
+ blobs: PublicationBlobStore;
50
+ onError(error: unknown): void;
51
+ intervalMs?: number;
52
+ }): { runOnce(): Promise<void>; stop(): void } {
53
+ const intervalMs = options.intervalMs ?? 1000;
54
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < 1 || intervalMs > RADAR_PREPARATION_LIFETIME_MS) {
55
+ throw new Error('invalid preparation cleanup interval');
56
+ }
57
+ const executor = createPostgresCommitExecutor(options.pool);
58
+ const store = createPostgresArtifactStore(options.pool);
59
+ let running: Promise<void> | undefined;
60
+ const runOnce = (): Promise<void> => {
61
+ if (running !== undefined) return running;
62
+ running = (async () => {
63
+ const client = await executor.beginFinalization();
64
+ let failure: unknown;
65
+ try {
66
+ await client.beginReadCommitted();
67
+ await scheduleBoundedPreparationCleanup({ intents: store, refs: store,
68
+ claims: createPostgresDeletionClaimStore(client), blobs: options.blobs }, Date.now());
69
+ await client.commit();
70
+ } catch (error) {
71
+ failure = error;
72
+ try { await client.rollback(); } catch (rollbackError) { failure = rollbackError; }
73
+ throw error;
74
+ } finally { client.release(failure); }
75
+ })().finally(() => { running = undefined; });
76
+ return running;
77
+ };
78
+ const timer = setInterval(() => { void runOnce().catch(options.onError); }, intervalMs);
79
+ return { runOnce, stop: () => clearInterval(timer) };
80
+ }
81
+
82
+ /** Create the illustrative host tables and seed one approved actor plus quota. */
83
+ export async function prepareRadarCommitExample(pool: Pool): Promise<void> {
84
+ await pool.query('CREATE SCHEMA IF NOT EXISTS radar_commit_example');
85
+ await pool.query(
86
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.policy_revision
87
+ (id INT PRIMARY KEY CHECK (id = 1), revision TEXT NOT NULL)`,
88
+ );
89
+ await pool.query(
90
+ `INSERT INTO radar_commit_example.policy_revision (id, revision)
91
+ VALUES (1, 'rev-1') ON CONFLICT (id) DO UPDATE SET revision = 'rev-1'`,
92
+ );
93
+ await pool.query(
94
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.approvals
95
+ (actor_id TEXT PRIMARY KEY, approved BOOLEAN NOT NULL, expires_at_ms BIGINT NOT NULL)`,
96
+ );
97
+ await pool.query(
98
+ `INSERT INTO radar_commit_example.approvals (actor_id, approved, expires_at_ms)
99
+ VALUES ('radar-actor', true, 9999999999999)
100
+ ON CONFLICT (actor_id) DO UPDATE SET approved = true, expires_at_ms = 9999999999999`,
101
+ );
102
+ await pool.query(
103
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.quotas
104
+ (quota_key TEXT PRIMARY KEY, total INT NOT NULL, used INT NOT NULL)`,
105
+ );
106
+ await pool.query(
107
+ `INSERT INTO radar_commit_example.quotas (quota_key, total, used)
108
+ VALUES ('radar-quota', 100, 0) ON CONFLICT (quota_key) DO UPDATE SET used = 0`,
109
+ );
110
+ }
111
+
112
+ /** The host guard Radar wires: current revision, approval and fence minting. */
113
+ export async function allowRadarGuard(
114
+ context: ArtifactCommitContext,
115
+ transaction: ArtifactCommitTransaction,
116
+ ): Promise<ArtifactAuthorizationDecision> {
117
+ const revision = await transaction.participant.readRevision();
118
+ if (context.policyRevision !== revision) {
119
+ return {
120
+ allowed: false, reason: 'revoked', publicCode: 'access_denied', status: 403, retriable: false,
121
+ };
122
+ }
123
+ const check = await transaction.participant.checkApproval({
124
+ actorId: context.actorId,
125
+ operation: context.operation,
126
+ });
127
+ if (!check.approved) {
128
+ return {
129
+ allowed: false, reason: check.reason ?? 'denied',
130
+ publicCode: 'access_denied', status: 403, retriable: false,
131
+ };
132
+ }
133
+ return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
134
+ }
135
+
136
+ const createParticipantFactory = (
137
+ quotaKey: string,
138
+ clock: { nowMs: number },
139
+ ): CommitPolicyParticipantFactory => ({
140
+ createParticipant: (client, transactionToken): ArtifactPolicyDriver => ({
141
+ async readRevision(): Promise<string> {
142
+ const { rows } = await client.query<{ revision: string }>(
143
+ 'SELECT revision FROM radar_commit_example.policy_revision WHERE id = 1 FOR SHARE',
144
+ [],
145
+ );
146
+ const row = rows[0];
147
+ if (row === undefined) throw new Error('missing policy revision');
148
+ return row.revision;
149
+ },
150
+ async checkApproval(input: { actorId: string }) {
151
+ const { rows } = await client.query<{ approved: boolean; expires_at_ms: string }>(
152
+ 'SELECT approved, expires_at_ms FROM radar_commit_example.approvals WHERE actor_id = $1 FOR SHARE',
153
+ [input.actorId],
154
+ );
155
+ const row = rows[0];
156
+ if (row === undefined || !row.approved) return { approved: false as const, reason: 'denied' as const };
157
+ if (Number(row.expires_at_ms) <= clock.nowMs) {
158
+ return { approved: false as const, reason: 'expired' as const };
159
+ }
160
+ return { approved: true as const };
161
+ },
162
+ async reserveQuota(): Promise<{ reserved: boolean }> {
163
+ const { rowCount } = await client.query(
164
+ 'UPDATE radar_commit_example.quotas SET used = used + 1 WHERE quota_key = $1 AND used < total',
165
+ [quotaKey],
166
+ );
167
+ return { reserved: rowCount > 0 };
168
+ },
169
+ mintFence: (revision: string, invocationId: string) => ({
170
+ fenceId: `fence-${transactionToken}`,
171
+ transactionId: transactionToken,
172
+ invocationId,
173
+ policyRevision: revision,
174
+ }),
175
+ }),
176
+ });
177
+
178
+ export interface RadarCommitInput {
179
+ readonly scopeId: string;
180
+ readonly internalKey: string;
181
+ readonly commandHash: string;
182
+ readonly invocationId: string;
183
+ readonly operation: string;
184
+ readonly receiptData: unknown;
185
+ }
186
+
187
+ export interface RadarCommitSetup {
188
+ readonly coordinator: ArtifactCommitCoordinator;
189
+ readonly profile: ReturnType<typeof compileArtifactsProfile>;
190
+ readonly abandonedAttempts: AbandonedAttempt[];
191
+ finalizePrepared(input: RadarCommitInput): Promise<FinalizeResult>;
192
+ replayPrepared(input: RadarCommitInput): Promise<FinalizeResult>;
193
+ drainAbandonedAttempts(onSweep: (batch: AbandonedAttempt[]) => void): void;
194
+ startAbandonedAttemptSweep(
195
+ onSweep: (batch: AbandonedAttempt[]) => void | Promise<void>,
196
+ intervalMs: number,
197
+ ): () => void;
198
+ }
199
+
200
+ /**
201
+ * Build the exact Radar construction over one pool. The optional
202
+ * `testObserver` forwards barrier observations to the coordinator;
203
+ * `testPoints` optionally filters them (see `testing.ts`).
204
+ */
205
+ export function createRadarCommitSetup(options: {
206
+ pool: Pool;
207
+ clock: { nowMs: number };
208
+ quotaKey?: string;
209
+ testObserver?: CommitCoordinatorOptions['testObserver'];
210
+ testPoints?: readonly CoordinatorTestPoint[];
211
+ }): RadarCommitSetup {
212
+ const quotaKey = options.quotaKey ?? 'radar-quota';
213
+ const abandonedAttempts: AbandonedAttempt[] = [];
214
+ const coordinator = createArtifactCommitCoordinator({
215
+ executor: createPostgresCommitExecutor(options.pool),
216
+ policy: createParticipantFactory(quotaKey, options.clock),
217
+ nowMs: () => options.clock.nowMs,
218
+ testObserver: options.testObserver,
219
+ testPoints: options.testPoints,
220
+ });
221
+ const toRequest = (
222
+ input: RadarCommitInput,
223
+ ): FinalizeRequest & { attemptId: string } => ({
224
+ scopeId: input.scopeId,
225
+ internalKey: input.internalKey as InternalIdempotencyKey,
226
+ commandHash: input.commandHash,
227
+ codecVersion: 'artifacts-command/v1',
228
+ invocationId: input.invocationId,
229
+ actorId: 'radar-actor',
230
+ operation: input.operation,
231
+ artifactId: null,
232
+ bindingName: null,
233
+ policyRevision: 'rev-1',
234
+ guard: allowRadarGuard,
235
+ receiptData: input.receiptData,
236
+ attemptId: input.invocationId,
237
+ noteAbandonedAttempt: async (attemptId: string) => {
238
+ abandonedAttempts.push({
239
+ attemptId,
240
+ scopeId: input.scopeId,
241
+ internalKey: input.internalKey,
242
+ atMs: options.clock.nowMs,
243
+ });
244
+ },
245
+ });
246
+ const drainAbandonedAttempts = (onSweep: (batch: AbandonedAttempt[]) => void): void => {
247
+ if (abandonedAttempts.length === 0) return;
248
+ // Retain-then-remove: a throwing sweeper keeps the batch for the next
249
+ // sweep instead of losing it. Durable cleanup state, per-attempt
250
+ // deadlines and restart recovery stay L4-owned (intent metadata).
251
+ const batch = abandonedAttempts.slice();
252
+ onSweep(batch);
253
+ abandonedAttempts.splice(0, batch.length);
254
+ };
255
+ return {
256
+ coordinator,
257
+ profile: compileArtifactsProfile(RADAR_ARTIFACTS_PROFILE),
258
+ abandonedAttempts,
259
+ finalizePrepared: (input: RadarCommitInput) => coordinator.finalizePrepared(toRequest(input)),
260
+ replayPrepared: (input: RadarCommitInput) => coordinator.replayPrepared(toRequest(input)),
261
+ drainAbandonedAttempts,
262
+ startAbandonedAttemptSweep: (onSweep, intervalMs) => {
263
+ // Serialized sweeps: an overlapping tick queues behind the running
264
+ // sweep instead of sweeping concurrently, and only the swept entries
265
+ // are removed, so arrivals landing mid-sweep are never deleted.
266
+ let sweeping = false;
267
+ let queued = false;
268
+ const runSweep = (): void => {
269
+ if (sweeping) {
270
+ queued = true;
271
+ return;
272
+ }
273
+ sweeping = true;
274
+ void (async () => {
275
+ try {
276
+ do {
277
+ queued = false;
278
+ const batch = abandonedAttempts.slice();
279
+ try {
280
+ await onSweep(batch);
281
+ } catch {
282
+ /* retained for the next sweep */
283
+ break;
284
+ }
285
+ for (const entry of batch) {
286
+ const index = abandonedAttempts.indexOf(entry);
287
+ if (index !== -1) abandonedAttempts.splice(index, 1);
288
+ }
289
+ } while (queued);
290
+ } finally {
291
+ sweeping = false;
292
+ }
293
+ })();
294
+ };
295
+ const timer = setInterval(runSweep, intervalMs);
296
+ return () => clearInterval(timer);
297
+ },
298
+ };
299
+ }