@aldus-runtime/file-store 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/atomic.d.ts +65 -0
- package/dist/atomic.d.ts.map +1 -0
- package/dist/atomic.js +160 -0
- package/dist/atomic.js.map +1 -0
- package/dist/collections.d.ts +27 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +58 -0
- package/dist/collections.js.map +1 -0
- package/dist/document.d.ts +66 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +109 -0
- package/dist/document.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/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonl.d.ts +62 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +99 -0
- package/dist/jsonl.js.map +1 -0
- package/dist/layout.d.ts +54 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +86 -0
- package/dist/layout.js.map +1 -0
- package/dist/lock.d.ts +80 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +257 -0
- package/dist/lock.js.map +1 -0
- package/dist/ports.d.ts +104 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +18 -0
- package/dist/ports.js.map +1 -0
- package/dist/stores.d.ts +56 -0
- package/dist/stores.d.ts.map +1 -0
- package/dist/stores.js +210 -0
- package/dist/stores.js.map +1 -0
- package/dist/workspace.d.ts +37 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +48 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +48 -0
- package/src/atomic.ts +185 -0
- package/src/collections.ts +94 -0
- package/src/document.ts +146 -0
- package/src/errors.ts +65 -0
- package/src/index.ts +96 -0
- package/src/jsonl.ts +149 -0
- package/src/layout.ts +105 -0
- package/src/lock.ts +359 -0
- package/src/ports.ts +126 -0
- package/src/stores.ts +295 -0
- package/src/workspace.ts +68 -0
package/src/lock.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory locking for concurrent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Architecture contract §19.1 requires Aldus to define "concurrency and lease semantics" and
|
|
5
|
+
* permits simple file locking for V1 local execution, while requiring that the contract still
|
|
6
|
+
* allow stronger distributed leases later. §10.2 makes Claude Code Remote Control an ordinary
|
|
7
|
+
* interaction surface, so two sessions operating on one workspace is a normal situation rather
|
|
8
|
+
* than an edge case.
|
|
9
|
+
*
|
|
10
|
+
* Everything here sits behind {@link LockManager} precisely so a distributed lease can replace
|
|
11
|
+
* {@link FileLockManager} without any caller changing. Decisions are recorded in ADR-0005.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import { hostname } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
18
|
+
|
|
19
|
+
import { newUlid } from "@aldus-runtime/core";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createExclusive,
|
|
23
|
+
overwrite,
|
|
24
|
+
readFileOrUndefined,
|
|
25
|
+
removeIfPresent,
|
|
26
|
+
isNotFound,
|
|
27
|
+
} from "./atomic.js";
|
|
28
|
+
import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How long a lock stays valid without being renewed.
|
|
32
|
+
*
|
|
33
|
+
* A lock is held for the duration of a write, never across a human gate — contract §13 gates are
|
|
34
|
+
* durable records, not held locks — so this bounds a write, not a workflow. Long enough that a
|
|
35
|
+
* slow filesystem does not cause spurious theft; short enough that a killed process does not
|
|
36
|
+
* block an operator for minutes.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_LOCK_TTL_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/** How long {@link FileLockManager.acquire} waits for a contended lock before giving up. */
|
|
41
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
|
|
42
|
+
|
|
43
|
+
/** Delay between acquisition attempts. */
|
|
44
|
+
export const DEFAULT_LOCK_RETRY_MS = 50;
|
|
45
|
+
|
|
46
|
+
/** A held lock. Released by the holder, or reclaimed by a contender once it expires. */
|
|
47
|
+
export interface Lease {
|
|
48
|
+
/** Unique identity of this acquisition. Distinguishes a re-acquired lock from a held one. */
|
|
49
|
+
readonly id: string;
|
|
50
|
+
/** The resource this lease covers. */
|
|
51
|
+
readonly resource: string;
|
|
52
|
+
/** Extend the lease. Returns `false` if the lease was already lost. */
|
|
53
|
+
renew(): Promise<boolean>;
|
|
54
|
+
/** Release the lease. Returns `false` if it had already been lost to a contender. */
|
|
55
|
+
release(): Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Options for acquiring a lock. */
|
|
59
|
+
export interface AcquireOptions {
|
|
60
|
+
/** Give up after this long. Defaults to {@link DEFAULT_LOCK_TIMEOUT_MS}. */
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
/** Lease lifetime without renewal. Defaults to {@link DEFAULT_LOCK_TTL_MS}. */
|
|
63
|
+
ttlMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mutual exclusion over named resources.
|
|
68
|
+
*
|
|
69
|
+
* The interface is deliberately narrow: acquire, and run something while holding. Anything a
|
|
70
|
+
* distributed lease service cannot honour has no place here (contract §19.1).
|
|
71
|
+
*/
|
|
72
|
+
export interface LockManager {
|
|
73
|
+
/** Acquire `resource`, waiting up to the timeout. */
|
|
74
|
+
acquire(resource: string, options?: AcquireOptions): Promise<Lease>;
|
|
75
|
+
/** Run `body` while holding `resource`, releasing it however `body` ends. */
|
|
76
|
+
withLock<T>(
|
|
77
|
+
resource: string,
|
|
78
|
+
body: (lease: Lease) => Promise<T>,
|
|
79
|
+
options?: AcquireOptions,
|
|
80
|
+
): Promise<T>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Contents of a lockfile. Written as JSON so a stuck lock is diagnosable by reading it. */
|
|
84
|
+
interface LockRecord {
|
|
85
|
+
lockId: string;
|
|
86
|
+
resource: string;
|
|
87
|
+
pid: number;
|
|
88
|
+
host: string;
|
|
89
|
+
acquiredAt: string;
|
|
90
|
+
renewedAt: string;
|
|
91
|
+
ttlMs: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Injection points so tests can control time without sleeping. */
|
|
95
|
+
export interface FileLockManagerOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Clock used for lockfile timestamps and staleness, in milliseconds since the epoch.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately NOT used for the acquisition deadline. A caller that freezes this clock is
|
|
100
|
+
* describing when locks expire, not asking to wait forever — and since the retry loop sleeps
|
|
101
|
+
* in real time, a frozen clock would make the deadline unreachable and the loop unbounded.
|
|
102
|
+
* The deadline therefore always uses real time; see {@link FileLockManager.acquire}.
|
|
103
|
+
*/
|
|
104
|
+
now?: () => number;
|
|
105
|
+
/** Delay between acquisition attempts. Defaults to {@link DEFAULT_LOCK_RETRY_MS}. */
|
|
106
|
+
retryMs?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* File-backed advisory lock using `O_CREAT | O_EXCL`.
|
|
111
|
+
*
|
|
112
|
+
* The exclusive create is the whole mechanism: check-and-create is one syscall, so two processes
|
|
113
|
+
* racing cannot both believe they won. Everything else — TTLs, liveness probes, reclaim — exists
|
|
114
|
+
* only to stop a dead holder from blocking the workspace forever.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* Resources held by the current async scope.
|
|
118
|
+
*
|
|
119
|
+
* Tracked per async context rather than per manager, and that distinction is the whole point.
|
|
120
|
+
* Two independent tasks in one process contending for the same lock is legitimate — one waits,
|
|
121
|
+
* the other releases, both proceed. Re-acquiring a lock *inside the scope that already holds
|
|
122
|
+
* it* is not: file locks are not re-entrant, so the acquirer is waiting on itself and will spin
|
|
123
|
+
* until the acquisition deadline before failing with a misleading "held by another session".
|
|
124
|
+
*
|
|
125
|
+
* `AsyncLocalStorage` distinguishes the two exactly: a nested call inherits the scope, a sibling
|
|
126
|
+
* task does not.
|
|
127
|
+
*
|
|
128
|
+
* Entries are keyed by manager **instance** as well as resource. Two managers in one process
|
|
129
|
+
* stand for two independent holders — that is how a test simulates another machine stealing a
|
|
130
|
+
* lease — and refusing one because the other holds the resource would be wrong.
|
|
131
|
+
*/
|
|
132
|
+
const heldByScope = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
133
|
+
|
|
134
|
+
/** Scope key for one manager's hold on one resource. */
|
|
135
|
+
function scopeKey(manager: FileLockManager, resource: string): string {
|
|
136
|
+
return `${manager.instanceId}\u0000${resource}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class FileLockManager implements LockManager {
|
|
140
|
+
readonly #directory: string;
|
|
141
|
+
readonly #now: () => number;
|
|
142
|
+
readonly #retryMs: number;
|
|
143
|
+
readonly #host = hostname();
|
|
144
|
+
/** Distinguishes this manager from another in the same process. @see scopeKey */
|
|
145
|
+
readonly instanceId: string = newUlid();
|
|
146
|
+
|
|
147
|
+
constructor(lockDirectory: string, options: FileLockManagerOptions = {}) {
|
|
148
|
+
this.#directory = lockDirectory;
|
|
149
|
+
this.#now = options.now ?? Date.now;
|
|
150
|
+
this.#retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Path of the lockfile backing `resource`. */
|
|
154
|
+
pathFor(resource: string): string {
|
|
155
|
+
// Resource names contain ':' and '/' (e.g. "run:run_01J…"); neither is safe in a filename on
|
|
156
|
+
// every platform, so they are folded to '-'. Collisions between distinct resources would be
|
|
157
|
+
// a correctness bug, so the mapping is injective for the character set actually used.
|
|
158
|
+
return join(this.#directory, `${resource.replace(/[^A-Za-z0-9._-]/g, "-")}.lock`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async acquire(resource: string, options: AcquireOptions = {}): Promise<Lease> {
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
163
|
+
const ttlMs = options.ttlMs ?? DEFAULT_LOCK_TTL_MS;
|
|
164
|
+
const path = this.pathFor(resource);
|
|
165
|
+
|
|
166
|
+
// Fail immediately rather than deadlocking. `FileEventStore.append` takes the Run lock to
|
|
167
|
+
// assign a sequence (ADR-0005), so a caller that holds the Run lock and then emits an event
|
|
168
|
+
// waits on itself — which without this check surfaces after a multi-second timeout as
|
|
169
|
+
// "held by another session", pointing the reader at concurrency rather than at their own
|
|
170
|
+
// call stack.
|
|
171
|
+
if (heldByScope.getStore()?.has(scopeKey(this, resource)) === true) {
|
|
172
|
+
throw fileStoreError(
|
|
173
|
+
FileStoreErrorCodes.LOCK_REENTRANT,
|
|
174
|
+
`The lock on "${resource}" is already held by this scope, and file locks are not ` +
|
|
175
|
+
"re-entrant, so acquiring it again can never succeed. Either release the outer lock " +
|
|
176
|
+
"before this call, or give the inner operation its own lock resource.",
|
|
177
|
+
{ category: "conflict", retryable: false, details: { resource } },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Real time, not `#now`: this bounds how long we actually sleep, and the retry loop below
|
|
182
|
+
// sleeps in real milliseconds regardless of what clock the caller injected.
|
|
183
|
+
const deadline = Date.now() + timeoutMs;
|
|
184
|
+
|
|
185
|
+
for (;;) {
|
|
186
|
+
const lockId = newUlid();
|
|
187
|
+
const record = this.#record(resource, lockId, ttlMs);
|
|
188
|
+
|
|
189
|
+
if (await createExclusive(path, JSON.stringify(record, null, 2))) {
|
|
190
|
+
// Read back before trusting the acquisition. If a contender reclaimed a stale lock at
|
|
191
|
+
// the same moment, whichever record survives on disk is the real holder — and it may not
|
|
192
|
+
// be ours.
|
|
193
|
+
if (await this.#holds(path, lockId)) {
|
|
194
|
+
return this.#lease(path, resource, lockId, ttlMs);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const existing = await this.#read(path);
|
|
200
|
+
if (existing === undefined || this.#isDead(existing)) {
|
|
201
|
+
// Only remove the exact record observed as dead. Without the identity check, a slow
|
|
202
|
+
// contender could delete a lock that a third process had just legitimately acquired.
|
|
203
|
+
await this.#reclaim(path, existing?.lockId);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (Date.now() >= deadline) {
|
|
208
|
+
throw fileStoreError(
|
|
209
|
+
FileStoreErrorCodes.LOCK_TIMEOUT,
|
|
210
|
+
`Could not acquire the lock on "${resource}" within ${timeoutMs}ms. It is held by ` +
|
|
211
|
+
`another session that is still renewing it.`,
|
|
212
|
+
{
|
|
213
|
+
category: "conflict",
|
|
214
|
+
retryable: true,
|
|
215
|
+
details: {
|
|
216
|
+
resource,
|
|
217
|
+
timeoutMs,
|
|
218
|
+
heldByPid: existing.pid,
|
|
219
|
+
heldByHost: existing.host,
|
|
220
|
+
renewedAt: existing.renewedAt,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await delay(this.#retryMs);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async withLock<T>(
|
|
231
|
+
resource: string,
|
|
232
|
+
body: (lease: Lease) => Promise<T>,
|
|
233
|
+
options: AcquireOptions = {},
|
|
234
|
+
): Promise<T> {
|
|
235
|
+
const lease = await this.acquire(resource, options);
|
|
236
|
+
const scope = new Set(heldByScope.getStore() ?? []);
|
|
237
|
+
scope.add(scopeKey(this, resource));
|
|
238
|
+
let result: T;
|
|
239
|
+
try {
|
|
240
|
+
result = await heldByScope.run(scope, () => body(lease));
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Release without masking the body's failure: the original error is the useful one.
|
|
243
|
+
await lease.release().catch(() => undefined);
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const stillHeld = await lease.release();
|
|
248
|
+
if (!stillHeld) {
|
|
249
|
+
// The body ran to completion believing it held the lock, and did not. Whatever it wrote may
|
|
250
|
+
// have interleaved with another writer, so reporting success would be a lie.
|
|
251
|
+
throw fileStoreError(
|
|
252
|
+
FileStoreErrorCodes.LOCK_LOST,
|
|
253
|
+
`The lease on "${resource}" was lost while the operation was still running, so another ` +
|
|
254
|
+
"session may have written concurrently. The operation's result is not trustworthy.",
|
|
255
|
+
{ category: "conflict", retryable: true, details: { resource } },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#record(resource: string, lockId: string, ttlMs: number): LockRecord {
|
|
262
|
+
const timestamp = new Date(this.#now()).toISOString();
|
|
263
|
+
return {
|
|
264
|
+
lockId,
|
|
265
|
+
resource,
|
|
266
|
+
pid: process.pid,
|
|
267
|
+
host: this.#host,
|
|
268
|
+
acquiredAt: timestamp,
|
|
269
|
+
renewedAt: timestamp,
|
|
270
|
+
ttlMs,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#lease(path: string, resource: string, lockId: string, ttlMs: number): Lease {
|
|
275
|
+
const manager = this;
|
|
276
|
+
return {
|
|
277
|
+
id: lockId,
|
|
278
|
+
resource,
|
|
279
|
+
async renew(): Promise<boolean> {
|
|
280
|
+
if (!(await manager.#holds(path, lockId))) return false;
|
|
281
|
+
const record = manager.#record(resource, lockId, ttlMs);
|
|
282
|
+
const existing = await manager.#read(path);
|
|
283
|
+
record.acquiredAt = existing?.acquiredAt ?? record.acquiredAt;
|
|
284
|
+
await overwrite(path, JSON.stringify(record, null, 2));
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
async release(): Promise<boolean> {
|
|
288
|
+
if (!(await manager.#holds(path, lockId))) return false;
|
|
289
|
+
await removeIfPresent(path);
|
|
290
|
+
return true;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async #read(path: string): Promise<LockRecord | undefined> {
|
|
296
|
+
let contents: string | undefined;
|
|
297
|
+
try {
|
|
298
|
+
contents = await readFileOrUndefined(path);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (isNotFound(error)) return undefined;
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
if (contents === undefined) return undefined;
|
|
304
|
+
try {
|
|
305
|
+
const parsed: unknown = JSON.parse(contents);
|
|
306
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
307
|
+
return parsed as LockRecord;
|
|
308
|
+
} catch {
|
|
309
|
+
// An unparseable lockfile is treated as dead rather than as a permanent blocker: it can
|
|
310
|
+
// only arise from a crash mid-create, and refusing to proceed would wedge the workspace.
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async #holds(path: string, lockId: string): Promise<boolean> {
|
|
316
|
+
const record = await this.#read(path);
|
|
317
|
+
return record?.lockId === lockId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* True if the lock's holder can no longer be renewing it.
|
|
322
|
+
*
|
|
323
|
+
* Two independent signals. The TTL is the portable one. The liveness probe is stronger but only
|
|
324
|
+
* meaningful on the same host — a PID on another machine says nothing about a process here —
|
|
325
|
+
* so it is used only when the hostnames match.
|
|
326
|
+
*/
|
|
327
|
+
#isDead(record: LockRecord): boolean {
|
|
328
|
+
const renewedAt = Date.parse(record.renewedAt);
|
|
329
|
+
if (Number.isNaN(renewedAt)) return true;
|
|
330
|
+
|
|
331
|
+
const ttl =
|
|
332
|
+
typeof record.ttlMs === "number" && record.ttlMs > 0 ? record.ttlMs : DEFAULT_LOCK_TTL_MS;
|
|
333
|
+
if (this.#now() - renewedAt > ttl) return true;
|
|
334
|
+
|
|
335
|
+
if (record.host === this.#host && typeof record.pid === "number") {
|
|
336
|
+
try {
|
|
337
|
+
// Signal 0 performs the permission and existence checks without delivering a signal.
|
|
338
|
+
process.kill(record.pid, 0);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
341
|
+
// ESRCH: no such process. EPERM means it exists but belongs to another user, so it is
|
|
342
|
+
// alive and the lock is legitimately held.
|
|
343
|
+
if (code === "ESRCH") return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async #reclaim(path: string, observedLockId: string | undefined): Promise<void> {
|
|
350
|
+
if (observedLockId === undefined) {
|
|
351
|
+
await removeIfPresent(path);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const current = await this.#read(path);
|
|
355
|
+
if (current?.lockId === observedLockId) {
|
|
356
|
+
await removeIfPresent(path);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage ports.
|
|
3
|
+
*
|
|
4
|
+
* Architecture contract §7 names `EpisodeStore`, `RunStore`, `EventStore`, `ArtifactStore`, and
|
|
5
|
+
* `SecretResolver` as bare interfaces with no members, and requires that "core models MUST be
|
|
6
|
+
* independent of physical storage" so that the databases, object stores, and cloud drives §7
|
|
7
|
+
* lists remain possible as adapters rather than becoming assumptions. Those services are
|
|
8
|
+
* deliberately not named here: §4.2 keeps provider and platform identities out of the runtime.
|
|
9
|
+
*
|
|
10
|
+
* These are the members. Each interface is kept to operations this package actually implements
|
|
11
|
+
* and tests: an aspirational method on a port is worse than an absent one, because a second
|
|
12
|
+
* adapter is written against it and only discovers at runtime that nothing honours it.
|
|
13
|
+
*
|
|
14
|
+
* `ArtifactStore` is deliberately absent — it belongs to WP-03. `SecretResolver` is absent
|
|
15
|
+
* because nothing in this package resolves a secret.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AldusEvent,
|
|
20
|
+
ArtifactRef,
|
|
21
|
+
CostRecord,
|
|
22
|
+
EpisodeRef,
|
|
23
|
+
GateDecision,
|
|
24
|
+
ReleaseReceipt,
|
|
25
|
+
RunManifest,
|
|
26
|
+
} from "@aldus-runtime/core";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The durable content identity of a workspace (contract §6.1).
|
|
30
|
+
*
|
|
31
|
+
* Workspace-scoped rather than keyed by ID: §7's layout places exactly one `episode.json` at the
|
|
32
|
+
* root of a workspace, so an `episodeId` parameter would imply a multiplicity the layout does
|
|
33
|
+
* not have.
|
|
34
|
+
*/
|
|
35
|
+
export interface EpisodeStore {
|
|
36
|
+
/** The stored Episode, or `undefined` if the workspace has none yet. */
|
|
37
|
+
get(): Promise<EpisodeRef | undefined>;
|
|
38
|
+
/** Write the Episode, replacing any previous one. */
|
|
39
|
+
put(episode: EpisodeRef): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Read, transform, and write under a lock, preserving properties written by a newer schema
|
|
42
|
+
* version (ADR-0004 decision 3).
|
|
43
|
+
*/
|
|
44
|
+
update(mutate: (current: EpisodeRef) => EpisodeRef): Promise<EpisodeRef>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The four per-run collection files of contract §7. */
|
|
48
|
+
export type RunCollectionName = "artifacts" | "approvals" | "costs" | "release";
|
|
49
|
+
|
|
50
|
+
/** Record type stored in each per-run collection. */
|
|
51
|
+
export interface RunCollectionTypes {
|
|
52
|
+
artifacts: ArtifactRef;
|
|
53
|
+
approvals: GateDecision;
|
|
54
|
+
costs: CostRecord;
|
|
55
|
+
release: ReleaseReceipt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run manifests and their materialized side records (contract §6.2, §7).
|
|
60
|
+
*
|
|
61
|
+
* This port stores and retrieves; it does not interpret. Deciding whether a Run may advance is
|
|
62
|
+
* WP-04's, and evaluating a gate is WP-05's.
|
|
63
|
+
*/
|
|
64
|
+
export interface RunStore {
|
|
65
|
+
/** IDs of every Run in the workspace, ascending. */
|
|
66
|
+
list(): Promise<string[]>;
|
|
67
|
+
/** A Run's manifest, or `undefined` if it does not exist. */
|
|
68
|
+
get(runId: string): Promise<RunManifest | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Write a Run that does not yet exist.
|
|
71
|
+
*
|
|
72
|
+
* @throws {AldusError} `ALDUS_RECORD_IDENTITY_MISMATCH` if a manifest already exists for the ID.
|
|
73
|
+
*/
|
|
74
|
+
create(manifest: RunManifest): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Read, transform, and write under a lock, preserving properties written by a newer schema
|
|
77
|
+
* version (ADR-0004 decision 3).
|
|
78
|
+
*
|
|
79
|
+
* @throws {AldusError} `ALDUS_RECORD_NOT_FOUND` if the Run does not exist.
|
|
80
|
+
*/
|
|
81
|
+
update(runId: string, mutate: (current: RunManifest) => RunManifest): Promise<RunManifest>;
|
|
82
|
+
/** Every record in one of the Run's collection files. */
|
|
83
|
+
listRecords<C extends RunCollectionName>(
|
|
84
|
+
runId: string,
|
|
85
|
+
collection: C,
|
|
86
|
+
): Promise<RunCollectionTypes[C][]>;
|
|
87
|
+
/** Append one record to a collection file, under a lock. */
|
|
88
|
+
addRecord<C extends RunCollectionName>(
|
|
89
|
+
runId: string,
|
|
90
|
+
collection: C,
|
|
91
|
+
record: RunCollectionTypes[C],
|
|
92
|
+
): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What an event log read found, including anything it had to recover from. */
|
|
96
|
+
export interface EventReadResult {
|
|
97
|
+
/** Validated events in file order. */
|
|
98
|
+
events: AldusEvent[];
|
|
99
|
+
/** Raw text of a truncated final line, if the log had one (contract §19.1). */
|
|
100
|
+
tornTail?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Options for reading an event log. */
|
|
104
|
+
export interface EventReadOptions {
|
|
105
|
+
/** Fail on a torn tail rather than recovering from it. Default `false`. */
|
|
106
|
+
strictTail?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The append-only event log required by contract §6.4.
|
|
111
|
+
*
|
|
112
|
+
* There is no update or delete. §6.4 requires events to be immutable, and an interface that
|
|
113
|
+
* cannot express mutation is a stronger guarantee than one that merely declines to.
|
|
114
|
+
*/
|
|
115
|
+
export interface EventStore {
|
|
116
|
+
/**
|
|
117
|
+
* Append one event, assigning its `sequence` if absent (ADR-0005).
|
|
118
|
+
*
|
|
119
|
+
* @returns the event as stored, including the assigned sequence.
|
|
120
|
+
*/
|
|
121
|
+
append(runId: string, event: AldusEvent): Promise<AldusEvent>;
|
|
122
|
+
/** Read a Run's events. */
|
|
123
|
+
read(runId: string, options?: EventReadOptions): Promise<EventReadResult>;
|
|
124
|
+
/** The sequence the next appended event will receive. */
|
|
125
|
+
nextSequence(runId: string): Promise<number>;
|
|
126
|
+
}
|