@aldus-runtime/release 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/dist/adapter.d.ts +140 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +104 -0
- package/dist/adapter.js.map +1 -0
- package/dist/authorization.d.ts +55 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +42 -0
- package/dist/authorization.js.map +1 -0
- package/dist/bundle.d.ts +56 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +67 -0
- package/dist/bundle.js.map +1 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +56 -0
- package/dist/errors.js.map +1 -0
- package/dist/executor.d.ts +171 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +342 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/operation.d.ts +102 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/operation.js +23 -0
- package/dist/operation.js.map +1 -0
- package/dist/ports.d.ts +76 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +80 -0
- package/dist/ports.js.map +1 -0
- package/package.json +50 -0
- package/src/adapter.ts +202 -0
- package/src/authorization.ts +74 -0
- package/src/bundle.ts +101 -0
- package/src/errors.ts +65 -0
- package/src/executor.ts +538 -0
- package/src/index.ts +75 -0
- package/src/operation.ts +112 -0
- package/src/ports.ts +114 -0
package/src/executor.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release execution, resumption, and reconciliation (architecture contract §17, §19.1).
|
|
3
|
+
*
|
|
4
|
+
* Three properties drive the design, and each rules out the obvious implementation:
|
|
5
|
+
*
|
|
6
|
+
* 1. **§17: every operation is independently idempotent and resumable.** So execution is never a
|
|
7
|
+
* transaction over a bundle. Each operation carries its own derived idempotency key and its
|
|
8
|
+
* own receipt, and a half-executed bundle is resumed by skipping what already succeeded.
|
|
9
|
+
*
|
|
10
|
+
* 2. **§17: pre-release hard gates and post-upload best-effort operations are distinguished.**
|
|
11
|
+
* The first failure among required operations stops the bundle — a failed media upload must
|
|
12
|
+
* not be followed by a visibility transition making nothing public. A best-effort failure is
|
|
13
|
+
* recorded and execution continues.
|
|
14
|
+
*
|
|
15
|
+
* 3. **§17: external state must be reconciled, not guessed.** A response lost after the
|
|
16
|
+
* destination accepted a request leaves local records saying nothing happened while something
|
|
17
|
+
* did. Retrying then publishes twice. So an operation whose outcome was recorded as `pending`
|
|
18
|
+
* is never retried; it is looked up, and if the destination cannot be queried the executor
|
|
19
|
+
* refuses rather than risking the duplicate.
|
|
20
|
+
*
|
|
21
|
+
* Nothing about a bundle's progress is stored beyond its receipts. State is derived on every
|
|
22
|
+
* call, for the reason ADR-0009 derives gate state: a stored progress flag survives a crash that
|
|
23
|
+
* the work it describes did not.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ActorRef, AldusEvent, ReleaseReceipt } from "@aldus-runtime/core";
|
|
27
|
+
import { SCHEMA_VERSION, newEventId, newReleaseId } from "@aldus-runtime/core";
|
|
28
|
+
|
|
29
|
+
import type { AdapterRegistry, AdapterOutcome, ReleaseRequest } from "./adapter.js";
|
|
30
|
+
import { assertBundleValid, deriveIdempotencyKey, type ReleaseBundle } from "./bundle.js";
|
|
31
|
+
import { ReleaseErrorCodes, releaseError } from "./errors.js";
|
|
32
|
+
import type { OperationCriticality, ReleaseOperation } from "./operation.js";
|
|
33
|
+
import { latestByKey, type ReleaseEventSink, type ReleaseReceiptStore } from "./ports.js";
|
|
34
|
+
import { permitAllAuthorizer, type ReleaseAuthorizer } from "./authorization.js";
|
|
35
|
+
|
|
36
|
+
/** Where one operation stands. */
|
|
37
|
+
export const OPERATION_STATES = [
|
|
38
|
+
/** No receipt exists; it has not been attempted. */
|
|
39
|
+
"not_started",
|
|
40
|
+
/** Attempted, outcome unconfirmed. MUST be reconciled, never retried (contract §17). */
|
|
41
|
+
"pending",
|
|
42
|
+
/** Completed at the destination. */
|
|
43
|
+
"succeeded",
|
|
44
|
+
/** Attempted and rejected by the destination. */
|
|
45
|
+
"failed",
|
|
46
|
+
/** Deliberately not attempted — no authority, or nothing to do. */
|
|
47
|
+
"skipped",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/** @see OPERATION_STATES */
|
|
51
|
+
export type OperationState = (typeof OPERATION_STATES)[number];
|
|
52
|
+
|
|
53
|
+
/** The derived state of one operation in a bundle. */
|
|
54
|
+
export interface OperationStatus {
|
|
55
|
+
operationId: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
destination: string;
|
|
58
|
+
/** Which §17 category the bundle placed it in. */
|
|
59
|
+
criticality: OperationCriticality;
|
|
60
|
+
idempotencyKey: string;
|
|
61
|
+
state: OperationState;
|
|
62
|
+
/** The receipt this state was derived from, if any. */
|
|
63
|
+
receipt?: ReleaseReceipt;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where a whole bundle stands (contract §19.1 "recovery from partial success"). */
|
|
67
|
+
export interface BundleStatus {
|
|
68
|
+
bundleId: string;
|
|
69
|
+
runId: string;
|
|
70
|
+
/**
|
|
71
|
+
* `succeeded` once every required operation has succeeded, whatever became of the best-effort
|
|
72
|
+
* ones — that is what §17's distinction means in practice.
|
|
73
|
+
*/
|
|
74
|
+
state: "not_started" | "in_progress" | "pending" | "failed" | "succeeded";
|
|
75
|
+
operations: OperationStatus[];
|
|
76
|
+
/** Operation ids that still need to run for the release to complete. */
|
|
77
|
+
remaining: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What reconciliation did about one operation. */
|
|
81
|
+
export interface ReconciliationFinding {
|
|
82
|
+
operationId: string;
|
|
83
|
+
idempotencyKey: string;
|
|
84
|
+
action:
|
|
85
|
+
/** A terminal receipt already existed; nothing to do. */
|
|
86
|
+
| "already_recorded"
|
|
87
|
+
/** The destination holds the result, so a missing or pending receipt was repaired. */
|
|
88
|
+
| "repaired"
|
|
89
|
+
/** The destination does not hold it, so the operation genuinely still needs to run. */
|
|
90
|
+
| "confirmed_absent"
|
|
91
|
+
/** The adapter cannot query the destination (contract §17 "where the platform allows it"). */
|
|
92
|
+
| "unavailable";
|
|
93
|
+
explanation?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a reconciliation pass found. */
|
|
97
|
+
export interface ReconciliationReport {
|
|
98
|
+
bundleId: string;
|
|
99
|
+
runId: string;
|
|
100
|
+
findings: ReconciliationFinding[];
|
|
101
|
+
/** Receipts written to repair the local record. */
|
|
102
|
+
repaired: ReleaseReceipt[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The result of executing a bundle. */
|
|
106
|
+
export interface ReleaseOutcome {
|
|
107
|
+
bundleId: string;
|
|
108
|
+
runId: string;
|
|
109
|
+
/**
|
|
110
|
+
* `succeeded` when every required operation succeeded. A best-effort failure does not change
|
|
111
|
+
* it (contract §17).
|
|
112
|
+
*/
|
|
113
|
+
state: "succeeded" | "failed" | "pending";
|
|
114
|
+
status: BundleStatus;
|
|
115
|
+
/** Receipts written by this call, in order. */
|
|
116
|
+
written: ReleaseReceipt[];
|
|
117
|
+
/** Operator-facing notes: best-effort failures, skipped operations, reconciliation actions. */
|
|
118
|
+
warnings: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Options for one reconciliation.
|
|
123
|
+
*
|
|
124
|
+
* Reconciliation writes receipts, which makes it a mutating action, and §19.2 requires a
|
|
125
|
+
* mutating action to record actor identity. A repair attributed to no one is a record an
|
|
126
|
+
* operator cannot question later, so the actor is required rather than defaulted to a system
|
|
127
|
+
* placeholder.
|
|
128
|
+
*/
|
|
129
|
+
export interface ReconcileOptions {
|
|
130
|
+
/** Who is reconciling (contract §19.2). */
|
|
131
|
+
actor: ActorRef;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Options for one execution. */
|
|
135
|
+
export interface ExecuteOptions {
|
|
136
|
+
/** Who is releasing (contract §19.2 "mutating actions MUST record actor identity"). */
|
|
137
|
+
actor: ActorRef;
|
|
138
|
+
/**
|
|
139
|
+
* Reconcile before executing. Default `true`.
|
|
140
|
+
*
|
|
141
|
+
* Turning it off is what a blind retry looks like, and it is the unsafe path — it exists so
|
|
142
|
+
* the tests can demonstrate the duplicate publish that reconciliation prevents.
|
|
143
|
+
*/
|
|
144
|
+
reconcile?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Wiring for a {@link ReleaseExecutor}. */
|
|
148
|
+
export interface ReleaseExecutorOptions {
|
|
149
|
+
adapters: AdapterRegistry;
|
|
150
|
+
receipts: ReleaseReceiptStore;
|
|
151
|
+
events: ReleaseEventSink;
|
|
152
|
+
/** Defaults to permitting everything, which is only correct when no operation needs authority. */
|
|
153
|
+
authorizer?: ReleaseAuthorizer;
|
|
154
|
+
/** Injected for deterministic tests. */
|
|
155
|
+
now?: () => Date;
|
|
156
|
+
/** Injected for deterministic tests. */
|
|
157
|
+
nextReleaseId?: () => string;
|
|
158
|
+
/** Injected for deterministic tests. */
|
|
159
|
+
nextEventId?: () => string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Executes release bundles, resumably. */
|
|
163
|
+
export class ReleaseExecutor {
|
|
164
|
+
readonly #adapters: AdapterRegistry;
|
|
165
|
+
readonly #receipts: ReleaseReceiptStore;
|
|
166
|
+
readonly #events: ReleaseEventSink;
|
|
167
|
+
readonly #authorizer: ReleaseAuthorizer;
|
|
168
|
+
readonly #now: () => Date;
|
|
169
|
+
readonly #nextReleaseId: () => string;
|
|
170
|
+
readonly #nextEventId: () => string;
|
|
171
|
+
|
|
172
|
+
constructor(options: ReleaseExecutorOptions) {
|
|
173
|
+
this.#adapters = options.adapters;
|
|
174
|
+
this.#receipts = options.receipts;
|
|
175
|
+
this.#events = options.events;
|
|
176
|
+
this.#authorizer = options.authorizer ?? permitAllAuthorizer();
|
|
177
|
+
this.#now = options.now ?? (() => new Date());
|
|
178
|
+
this.#nextReleaseId = options.nextReleaseId ?? newReleaseId;
|
|
179
|
+
this.#nextEventId = options.nextEventId ?? newEventId;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Derive where a bundle stands from its receipts (contract §19.1).
|
|
184
|
+
*
|
|
185
|
+
* Reads only; safe to call at any time, including on a bundle that has never run.
|
|
186
|
+
*/
|
|
187
|
+
async status(bundle: ReleaseBundle): Promise<BundleStatus> {
|
|
188
|
+
assertBundleValid(bundle);
|
|
189
|
+
const latest = latestByKey(await this.#receipts.list(bundle.runId));
|
|
190
|
+
const operations = this.#plan(bundle).map(({ operation, criticality, idempotencyKey }) =>
|
|
191
|
+
toStatus(operation, criticality, idempotencyKey, latest.get(idempotencyKey)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const required = operations.filter((entry) => entry.criticality === "required");
|
|
195
|
+
const remaining = operations
|
|
196
|
+
.filter((entry) => entry.state !== "succeeded" && entry.state !== "skipped")
|
|
197
|
+
.map((entry) => entry.operationId);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
bundleId: bundle.bundleId,
|
|
201
|
+
runId: bundle.runId,
|
|
202
|
+
state: bundleState(required),
|
|
203
|
+
operations,
|
|
204
|
+
remaining,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Repair the local record against the destinations (contract §17).
|
|
210
|
+
*
|
|
211
|
+
* Only operations without a terminal local receipt are looked up: an operation already
|
|
212
|
+
* recorded as succeeded or failed has an answer, and asking again would turn reconciliation
|
|
213
|
+
* into polling.
|
|
214
|
+
*/
|
|
215
|
+
async reconcile(bundle: ReleaseBundle, options: ReconcileOptions): Promise<ReconciliationReport> {
|
|
216
|
+
assertBundleValid(bundle);
|
|
217
|
+
const latest = latestByKey(await this.#receipts.list(bundle.runId));
|
|
218
|
+
const findings: ReconciliationFinding[] = [];
|
|
219
|
+
const repaired: ReleaseReceipt[] = [];
|
|
220
|
+
|
|
221
|
+
for (const { operation, idempotencyKey } of this.#plan(bundle)) {
|
|
222
|
+
const receipt = latest.get(idempotencyKey);
|
|
223
|
+
if (receipt !== undefined && receipt.status !== "pending") {
|
|
224
|
+
findings.push({
|
|
225
|
+
operationId: operation.operationId,
|
|
226
|
+
idempotencyKey,
|
|
227
|
+
action: "already_recorded",
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const adapter = this.#adapters.require(operation.destination);
|
|
233
|
+
if (adapter.lookup === undefined) {
|
|
234
|
+
findings.push({
|
|
235
|
+
operationId: operation.operationId,
|
|
236
|
+
idempotencyKey,
|
|
237
|
+
action: "unavailable",
|
|
238
|
+
explanation:
|
|
239
|
+
`Destination "${operation.destination}" cannot be queried, so whether ` +
|
|
240
|
+
`"${operation.operationId}" already happened is unknowable from here.`,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const request: ReleaseRequest = {
|
|
246
|
+
operation,
|
|
247
|
+
idempotencyKey,
|
|
248
|
+
runId: bundle.runId,
|
|
249
|
+
};
|
|
250
|
+
const remote = await adapter.lookup(request);
|
|
251
|
+
if (!remote.exists) {
|
|
252
|
+
findings.push({
|
|
253
|
+
operationId: operation.operationId,
|
|
254
|
+
idempotencyKey,
|
|
255
|
+
action: "confirmed_absent",
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const written = await this.#record(
|
|
261
|
+
bundle,
|
|
262
|
+
operation,
|
|
263
|
+
idempotencyKey,
|
|
264
|
+
{
|
|
265
|
+
status: "succeeded",
|
|
266
|
+
...(remote.remoteId === undefined ? {} : { remoteId: remote.remoteId }),
|
|
267
|
+
...(remote.remoteUrl === undefined ? {} : { remoteUrl: remote.remoteUrl }),
|
|
268
|
+
},
|
|
269
|
+
options.actor,
|
|
270
|
+
);
|
|
271
|
+
repaired.push(written);
|
|
272
|
+
findings.push({
|
|
273
|
+
operationId: operation.operationId,
|
|
274
|
+
idempotencyKey,
|
|
275
|
+
action: "repaired",
|
|
276
|
+
explanation:
|
|
277
|
+
`The destination already holds "${operation.operationId}". The local record was ` +
|
|
278
|
+
"repaired rather than the operation repeated.",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { bundleId: bundle.bundleId, runId: bundle.runId, findings, repaired };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Execute a bundle, resuming whatever has already succeeded (contract §17, §19.1).
|
|
287
|
+
*
|
|
288
|
+
* Reconciles first by default, so the safe path is the one a caller gets without asking.
|
|
289
|
+
*/
|
|
290
|
+
async execute(bundle: ReleaseBundle, options: ExecuteOptions): Promise<ReleaseOutcome> {
|
|
291
|
+
assertBundleValid(bundle);
|
|
292
|
+
const warnings: string[] = [];
|
|
293
|
+
const written: ReleaseReceipt[] = [];
|
|
294
|
+
|
|
295
|
+
if (options.reconcile !== false) {
|
|
296
|
+
const report = await this.reconcile(bundle, { actor: options.actor });
|
|
297
|
+
written.push(...report.repaired);
|
|
298
|
+
for (const finding of report.findings) {
|
|
299
|
+
if (finding.explanation !== undefined && finding.action !== "confirmed_absent") {
|
|
300
|
+
warnings.push(finding.explanation);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let latest = latestByKey(await this.#receipts.list(bundle.runId));
|
|
306
|
+
let halted = false;
|
|
307
|
+
let haltState: "failed" | "pending" | undefined;
|
|
308
|
+
|
|
309
|
+
for (const { operation, criticality, idempotencyKey } of this.#plan(bundle)) {
|
|
310
|
+
// §17 calls the best-effort operations "post-upload": they run only once every required
|
|
311
|
+
// one has succeeded. Attempting a notification about a release that failed would announce
|
|
312
|
+
// something that does not exist.
|
|
313
|
+
if (halted) {
|
|
314
|
+
warnings.push(
|
|
315
|
+
`"${operation.operationId}" was not attempted because a required operation did not ` +
|
|
316
|
+
"succeed.",
|
|
317
|
+
);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const existing = latest.get(idempotencyKey);
|
|
322
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped") continue;
|
|
323
|
+
|
|
324
|
+
// An operation whose outcome was never confirmed must not be retried (contract §17). If
|
|
325
|
+
// reconciliation could have resolved it, it already ran above and this receipt would be
|
|
326
|
+
// terminal; reaching here means the destination cannot be queried.
|
|
327
|
+
if (existing?.status === "pending") {
|
|
328
|
+
throw releaseError(
|
|
329
|
+
ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE,
|
|
330
|
+
`"${operation.operationId}" was attempted and its outcome was never confirmed, and ` +
|
|
331
|
+
`destination "${operation.destination}" cannot be queried to find out. Retrying ` +
|
|
332
|
+
"would risk performing it twice, so it is refused until the outcome is established.",
|
|
333
|
+
{
|
|
334
|
+
category: "conflict",
|
|
335
|
+
retryable: false,
|
|
336
|
+
details: {
|
|
337
|
+
runId: bundle.runId,
|
|
338
|
+
operationId: operation.operationId,
|
|
339
|
+
destination: operation.destination,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const verdict =
|
|
346
|
+
operation.requiresAuthority === undefined
|
|
347
|
+
? { authorized: true }
|
|
348
|
+
: await this.#authorizer.check(bundle.runId, operation.requiresAuthority);
|
|
349
|
+
|
|
350
|
+
if (!verdict.authorized) {
|
|
351
|
+
const explanation =
|
|
352
|
+
`"${operation.operationId}" requires authority "${operation.requiresAuthority}", ` +
|
|
353
|
+
`which is not held. ${verdict.explanation ?? ""}`.trim();
|
|
354
|
+
|
|
355
|
+
// A required operation without authority is a refusal, not a warning: §13.4 binds
|
|
356
|
+
// release approval to exact inputs, and continuing past it would publish unapproved.
|
|
357
|
+
if (criticality === "required") {
|
|
358
|
+
throw releaseError(ReleaseErrorCodes.RELEASE_NOT_AUTHORIZED, explanation, {
|
|
359
|
+
category: "policy",
|
|
360
|
+
retryable: false,
|
|
361
|
+
details: {
|
|
362
|
+
runId: bundle.runId,
|
|
363
|
+
operationId: operation.operationId,
|
|
364
|
+
authority: operation.requiresAuthority,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A best-effort operation is recorded as skipped rather than failed. It was never
|
|
370
|
+
// attempted, and a `failed` receipt would claim the destination rejected it.
|
|
371
|
+
written.push(
|
|
372
|
+
await this.#record(
|
|
373
|
+
bundle,
|
|
374
|
+
operation,
|
|
375
|
+
idempotencyKey,
|
|
376
|
+
{ status: "skipped", message: explanation },
|
|
377
|
+
options.actor,
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
warnings.push(explanation);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const adapter = this.#adapters.require(operation.destination);
|
|
385
|
+
const outcome = await adapter.execute({ operation, idempotencyKey, runId: bundle.runId });
|
|
386
|
+
written.push(await this.#record(bundle, operation, idempotencyKey, outcome, options.actor));
|
|
387
|
+
|
|
388
|
+
if (outcome.status === "succeeded") continue;
|
|
389
|
+
|
|
390
|
+
if (criticality === "best_effort") {
|
|
391
|
+
warnings.push(
|
|
392
|
+
`Best-effort operation "${operation.operationId}" did not succeed ` +
|
|
393
|
+
`(${outcome.status}). The release is unaffected.`,
|
|
394
|
+
);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
halted = true;
|
|
399
|
+
haltState = outcome.status === "pending" ? "pending" : "failed";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
latest = latestByKey(await this.#receipts.list(bundle.runId));
|
|
403
|
+
const status = await this.status(bundle);
|
|
404
|
+
const state: ReleaseOutcome["state"] =
|
|
405
|
+
haltState ?? (status.state === "succeeded" ? "succeeded" : "pending");
|
|
406
|
+
|
|
407
|
+
return { bundleId: bundle.bundleId, runId: bundle.runId, state, status, written, warnings };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Operations paired with their category and derived key, in execution order. */
|
|
411
|
+
#plan(bundle: ReleaseBundle): {
|
|
412
|
+
operation: ReleaseOperation;
|
|
413
|
+
criticality: OperationCriticality;
|
|
414
|
+
idempotencyKey: string;
|
|
415
|
+
}[] {
|
|
416
|
+
const entries = [
|
|
417
|
+
...bundle.required.map((operation) => ({
|
|
418
|
+
operation: operation as ReleaseOperation,
|
|
419
|
+
criticality: "required" as const,
|
|
420
|
+
})),
|
|
421
|
+
...bundle.bestEffort.map((operation) => ({
|
|
422
|
+
operation: operation as ReleaseOperation,
|
|
423
|
+
criticality: "best_effort" as const,
|
|
424
|
+
})),
|
|
425
|
+
];
|
|
426
|
+
return entries.map((entry) => ({
|
|
427
|
+
...entry,
|
|
428
|
+
idempotencyKey: deriveIdempotencyKey(bundle.bundleId, entry.operation),
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Write a receipt and emit its event (contract §6.4, §17). */
|
|
433
|
+
async #record(
|
|
434
|
+
bundle: ReleaseBundle,
|
|
435
|
+
operation: ReleaseOperation,
|
|
436
|
+
idempotencyKey: string,
|
|
437
|
+
outcome: AdapterOutcome | { status: "skipped"; message: string },
|
|
438
|
+
actor: ActorRef,
|
|
439
|
+
): Promise<ReleaseReceipt> {
|
|
440
|
+
const at = this.#now().toISOString();
|
|
441
|
+
const receipt: ReleaseReceipt = {
|
|
442
|
+
schemaVersion: SCHEMA_VERSION,
|
|
443
|
+
releaseId: this.#nextReleaseId(),
|
|
444
|
+
runId: bundle.runId,
|
|
445
|
+
destination: operation.destination,
|
|
446
|
+
operation: operation.kind,
|
|
447
|
+
idempotencyKey,
|
|
448
|
+
status: outcome.status,
|
|
449
|
+
inputHashes: [...operation.inputHashes],
|
|
450
|
+
...(outcome.status === "succeeded" && outcome.remoteId !== undefined
|
|
451
|
+
? { remoteId: outcome.remoteId }
|
|
452
|
+
: {}),
|
|
453
|
+
...(outcome.status === "succeeded" && outcome.remoteUrl !== undefined
|
|
454
|
+
? { remoteUrl: outcome.remoteUrl }
|
|
455
|
+
: {}),
|
|
456
|
+
// `pending` is not terminal, so it carries no completion time (contract §17).
|
|
457
|
+
...(outcome.status === "pending" ? {} : { completedAt: at }),
|
|
458
|
+
...(outcome.status === "failed"
|
|
459
|
+
? {
|
|
460
|
+
error: {
|
|
461
|
+
code: "ALDUS_RELEASE_OPERATION_FAILED",
|
|
462
|
+
category: "provider" as const,
|
|
463
|
+
message: outcome.message,
|
|
464
|
+
retryable: outcome.retryable ?? true,
|
|
465
|
+
occurredAt: at,
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
: {}),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
await this.#receipts.append(bundle.runId, receipt);
|
|
472
|
+
await this.#events.emit(this.#event(bundle, operation, receipt, at, outcome, actor));
|
|
473
|
+
return receipt;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The §6.4 event for one recorded outcome. */
|
|
477
|
+
#event(
|
|
478
|
+
bundle: ReleaseBundle,
|
|
479
|
+
operation: ReleaseOperation,
|
|
480
|
+
receipt: ReleaseReceipt,
|
|
481
|
+
at: string,
|
|
482
|
+
outcome: AdapterOutcome | { status: "skipped"; message: string },
|
|
483
|
+
actor: ActorRef,
|
|
484
|
+
): AldusEvent {
|
|
485
|
+
return {
|
|
486
|
+
schemaVersion: SCHEMA_VERSION,
|
|
487
|
+
eventId: this.#nextEventId(),
|
|
488
|
+
occurredAt: at,
|
|
489
|
+
episodeId: bundle.episodeId,
|
|
490
|
+
runId: bundle.runId,
|
|
491
|
+
action: `release.operation.${receipt.status}`,
|
|
492
|
+
actor,
|
|
493
|
+
inputRefs: [],
|
|
494
|
+
outputRefs: [],
|
|
495
|
+
idempotencyKey: receipt.idempotencyKey,
|
|
496
|
+
details: {
|
|
497
|
+
bundleId: bundle.bundleId,
|
|
498
|
+
operationId: operation.operationId,
|
|
499
|
+
kind: operation.kind,
|
|
500
|
+
destination: operation.destination,
|
|
501
|
+
releaseId: receipt.releaseId,
|
|
502
|
+
...("message" in outcome && outcome.message !== undefined
|
|
503
|
+
? { message: outcome.message }
|
|
504
|
+
: {}),
|
|
505
|
+
},
|
|
506
|
+
...(receipt.error === undefined ? {} : { error: receipt.error }),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Derive one operation's state from its latest receipt. */
|
|
512
|
+
function toStatus(
|
|
513
|
+
operation: ReleaseOperation,
|
|
514
|
+
criticality: OperationCriticality,
|
|
515
|
+
idempotencyKey: string,
|
|
516
|
+
receipt: ReleaseReceipt | undefined,
|
|
517
|
+
): OperationStatus {
|
|
518
|
+
return {
|
|
519
|
+
operationId: operation.operationId,
|
|
520
|
+
kind: operation.kind,
|
|
521
|
+
destination: operation.destination,
|
|
522
|
+
criticality,
|
|
523
|
+
idempotencyKey,
|
|
524
|
+
state: receipt === undefined ? "not_started" : receipt.status,
|
|
525
|
+
...(receipt === undefined ? {} : { receipt }),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** A bundle's state is the state of its required operations (contract §17). */
|
|
530
|
+
function bundleState(required: readonly OperationStatus[]): BundleStatus["state"] {
|
|
531
|
+
if (required.every((entry) => entry.state === "succeeded" || entry.state === "skipped")) {
|
|
532
|
+
return "succeeded";
|
|
533
|
+
}
|
|
534
|
+
if (required.some((entry) => entry.state === "failed")) return "failed";
|
|
535
|
+
if (required.some((entry) => entry.state === "pending")) return "pending";
|
|
536
|
+
if (required.every((entry) => entry.state === "not_started")) return "not_started";
|
|
537
|
+
return "in_progress";
|
|
538
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@aldus-runtime/release` — release bundles, resumable operations, and external-state reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Implements architecture contract §22 **WP-12 Release adapters** and §17.
|
|
5
|
+
*
|
|
6
|
+
* Contract §22's own wording for this work package names two publishing platforms. This package
|
|
7
|
+
* names none, and that is deliberate: §4.2 keeps publishing platforms out of the runtime, §1.2
|
|
8
|
+
* explicitly rules out prescribing particular release targets, and §4.3 places release
|
|
9
|
+
* configuration in Integration. What ships here is the **adapter contract and the resumable
|
|
10
|
+
* machinery**; an adopter supplies the client that talks to a destination.
|
|
11
|
+
*
|
|
12
|
+
* Not implemented here, by design: any real platform client, the CLI (WP-08), the Production MCP
|
|
13
|
+
* (WP-11), and the TTS ledger (WP-07).
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
AdapterRegistry,
|
|
20
|
+
RecordingReleaseAdapter,
|
|
21
|
+
type AdapterOutcome,
|
|
22
|
+
type RecordingAdapterOptions,
|
|
23
|
+
type ReleaseAdapter,
|
|
24
|
+
type ReleaseRequest,
|
|
25
|
+
type RemoteState,
|
|
26
|
+
} from "./adapter.js";
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
gateEngineAuthorizer,
|
|
30
|
+
permitAllAuthorizer,
|
|
31
|
+
type AuthorityVerdict,
|
|
32
|
+
type ReleaseAuthorizer,
|
|
33
|
+
} from "./authorization.js";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
assertBundleValid,
|
|
37
|
+
deriveIdempotencyKey,
|
|
38
|
+
operationsOf,
|
|
39
|
+
type ReleaseBundle,
|
|
40
|
+
} from "./bundle.js";
|
|
41
|
+
|
|
42
|
+
export { ReleaseErrorCodes, releaseError, type ReleaseErrorCode } from "./errors.js";
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
OPERATION_STATES,
|
|
46
|
+
ReleaseExecutor,
|
|
47
|
+
type BundleStatus,
|
|
48
|
+
type ExecuteOptions,
|
|
49
|
+
type OperationState,
|
|
50
|
+
type OperationStatus,
|
|
51
|
+
type ReconciliationFinding,
|
|
52
|
+
type ReconciliationReport,
|
|
53
|
+
type ReleaseExecutorOptions,
|
|
54
|
+
type ReleaseOutcome,
|
|
55
|
+
} from "./executor.js";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
bestEffortOperation,
|
|
59
|
+
requiredOperation,
|
|
60
|
+
type BestEffortOperation,
|
|
61
|
+
type OperationCriticality,
|
|
62
|
+
type ReleaseOperation,
|
|
63
|
+
type ReleaseOperationBase,
|
|
64
|
+
type RequiredOperation,
|
|
65
|
+
} from "./operation.js";
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
MemoryReleaseEventSink,
|
|
69
|
+
MemoryReleaseReceiptStore,
|
|
70
|
+
eventStoreSink,
|
|
71
|
+
latestByKey,
|
|
72
|
+
runStoreReceipts,
|
|
73
|
+
type ReleaseEventSink,
|
|
74
|
+
type ReleaseReceiptStore,
|
|
75
|
+
} from "./ports.js";
|