@tangleai/store 0.20.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * MAS durable segments over the suite queue — composition, not a queue.
3
+ *
4
+ * Enqueue is the suite's own idempotent caller-supplied-id enqueue with
5
+ * the derived segment id `<masRunId>:<segment>` and the versioned kind
6
+ * `mas:<executableRevision>`; re-enqueueing one segment is the suite's
7
+ * no-op, and an unknown versioned kind is simply never claimed by a
8
+ * worker that does not register it. The worker is
9
+ * `store.jobs.createWorker`; the checkpoint store every region hands
10
+ * `compileDag` is a thin NAMESPACE adaptation over the suite's
11
+ * `context.checkpoints` — load filters by
12
+ * `<region>/<branch>/<iteration>/`, save prefixes the lowered node id
13
+ * and delegates (keeping the suite's lease guard and JSON validation),
14
+ * a region result is a prefixed delegated save, and only a terminal
15
+ * outcome for the whole runnable segment calls the suite store's
16
+ * `complete`, which atomically records the segment result, marks the
17
+ * job done and prunes that segment's checkpoint rows. It grows no map,
18
+ * table or serialization of its own.
19
+ *
20
+ * Before any checkpoint loads, the handler reads the MAS run and
21
+ * refuses `TMAS2002` unless the job id, payload, workflow version,
22
+ * registry snapshot and executable revision all agree — Jaren DAG
23
+ * resume under different document bytes is undefined, so a mismatch
24
+ * never reaches a checkpoint.
25
+ */
26
+ import { masIssue, masJobKindOf, segmentJobIdOf, } from '@tangleai/mas';
27
+ /** Idempotent per segment: the suite keeps one row per caller-supplied id. */
28
+ export async function enqueueMasSegment(db, plan) {
29
+ const jobs = db.jobs;
30
+ if (jobs === undefined)
31
+ throw new TypeError('enqueueMasSegment: the store was opened without { jobs }');
32
+ const payload = {
33
+ runId: plan.runId,
34
+ segment: plan.segment,
35
+ workflowVersionId: plan.workflowVersionId,
36
+ registryRevision: plan.registryRevision,
37
+ executableRevision: plan.executableRevision,
38
+ resume: plan.resume ?? null,
39
+ };
40
+ return jobs.enqueue(masJobKindOf(plan.executableRevision), payload, { id: segmentJobIdOf(plan.runId, plan.segment) });
41
+ }
42
+ /**
43
+ * The namespace adaptation: one region incarnation's view of the
44
+ * segment's suite checkpoint rows. Region-level `complete` records a
45
+ * prefixed result via delegated save and deliberately does NOT mark the
46
+ * job done.
47
+ */
48
+ export function namespacedRegionCheckpoints(suite, segmentJobId, namespace) {
49
+ const prefix = `${namespace}/`;
50
+ return {
51
+ async load(runId) {
52
+ if (runId !== segmentJobId) {
53
+ throw new Error(`the region checkpoint store is bound to segment '${segmentJobId}', not '${runId}'`);
54
+ }
55
+ const stored = await Promise.resolve(suite.load(segmentJobId));
56
+ if (stored === null)
57
+ return null;
58
+ const values = {};
59
+ for (const [key, value] of Object.entries(stored.values)) {
60
+ if (key.startsWith(prefix))
61
+ values[key.slice(prefix.length)] = value;
62
+ }
63
+ return Object.keys(values).length === 0 ? null : { values };
64
+ },
65
+ save(runId, nodeId, value) {
66
+ if (runId !== segmentJobId) {
67
+ throw new Error(`the region checkpoint store is bound to segment '${segmentJobId}', not '${runId}'`);
68
+ }
69
+ return suite.save(segmentJobId, `${prefix}${nodeId}`, value);
70
+ },
71
+ complete(runId, result) {
72
+ if (runId !== segmentJobId) {
73
+ throw new Error(`the region checkpoint store is bound to segment '${segmentJobId}', not '${runId}'`);
74
+ }
75
+ return suite.save(segmentJobId, `${prefix}__region`, result);
76
+ },
77
+ };
78
+ }
79
+ /** A refusal the queue records as the job's failure value. */
80
+ export class MasSegmentRefusal extends Error {
81
+ issue;
82
+ constructor(issue) {
83
+ super(`${issue.code} ${issue.path} — ${issue.detail}`);
84
+ this.issue = issue;
85
+ }
86
+ }
87
+ /**
88
+ * The semantic resume reconciler over the published queue — not another
89
+ * queue. `respondInteraction` accepts exactly one typed response and
90
+ * moves the run to `resume_pending` with a reserved zero-padded segment
91
+ * id; the enqueue of that derived id happens OUTSIDE the semantic
92
+ * transaction (the suite queue cannot be assumed to join it), so this
93
+ * scan makes the seam idempotent: it enqueues every reserved segment
94
+ * (the suite's caller-supplied-id no-op absorbs a crash after enqueue)
95
+ * and CAS-advances `resume_pending -> queued`. The second identical
96
+ * reconciliation changes zero rows and reports zero; every enqueue,
97
+ * skip and conflict is a counted value.
98
+ */
99
+ export async function ensurePendingMasSegments(db, masStore) {
100
+ const counts = { examined: 0, enqueued: 0, queued: 0, skipped: 0 };
101
+ const runs = await masStore.listResumePendingRuns();
102
+ for (const run of runs) {
103
+ counts.examined += 1;
104
+ const trace = await masStore.readTrace(run.id);
105
+ const responded = trace?.interactions.find((interaction) => interaction.status === 'responded' && interaction.resumeSegment !== null);
106
+ if (responded === undefined || responded.resumeSegment === null) {
107
+ counts.skipped += 1;
108
+ continue;
109
+ }
110
+ await enqueueMasSegment(db, {
111
+ runId: run.id,
112
+ segment: responded.resumeSegment,
113
+ workflowVersionId: run.workflowVersionId,
114
+ registryRevision: run.registryRevision,
115
+ executableRevision: run.executableRevision,
116
+ resume: { interaction: responded.id, responseKey: responded.responseKey },
117
+ });
118
+ counts.enqueued += 1;
119
+ const advanced = await masStore.transitionRun(run.id, { kind: 'queue-segment' });
120
+ if (advanced.ok)
121
+ counts.queued += 1;
122
+ else
123
+ counts.skipped += 1;
124
+ }
125
+ return counts;
126
+ }
127
+ /**
128
+ * The versioned segment handlers a worker registers — exported so the
129
+ * crash matrix can drive one claim at a time deterministically through
130
+ * `store.jobs.claim` with an injected clock, without a polling loop.
131
+ */
132
+ export function createMasSegmentHandlers(masStore, options) {
133
+ if (options.executableRevisions.length === 0)
134
+ throw new TypeError('createMasSegmentHandlers: at least one executable revision is required');
135
+ const owner = options.owner ?? 'mas-worker';
136
+ const handlers = {};
137
+ for (const executableRevision of options.executableRevisions) {
138
+ handlers[masJobKindOf(executableRevision)] = async (rawPayload, context) => {
139
+ const payload = rawPayload;
140
+ const segmentJobId = segmentJobIdOf(payload.runId, payload.segment);
141
+ if (context.job.id !== segmentJobId) {
142
+ throw new MasSegmentRefusal(masIssue('TMAS2002', '/id', `the job id '${context.job.id}' does not derive from its payload ('${segmentJobId}')`));
143
+ }
144
+ const run = await masStore.getRun(payload.runId);
145
+ if (run === undefined) {
146
+ throw new MasSegmentRefusal(masIssue('TMAS2002', '/runId', `run '${payload.runId}' does not exist`));
147
+ }
148
+ if (run.workflowVersionId !== payload.workflowVersionId
149
+ || run.registryRevision !== payload.registryRevision
150
+ || run.executableRevision !== payload.executableRevision
151
+ || run.executableRevision !== executableRevision) {
152
+ throw new MasSegmentRefusal(masIssue('TMAS2002', '/executableRevision', 'the run identities do not agree with the queued segment; resuming under different document bytes is undefined and refused before any checkpoint loads'));
153
+ }
154
+ const suite = context.checkpoints;
155
+ // A committed terminal outcome whose job completion was lost: close
156
+ // the segment without executing a region.
157
+ if (run.segment === payload.segment
158
+ && (run.status === 'completed' || run.status === 'failed' || run.status === 'waiting_for_input' || run.status === 'resume_pending')) {
159
+ await Promise.resolve(suite.complete(segmentJobId, { status: run.status }));
160
+ return { status: run.status, reclaimed: true };
161
+ }
162
+ const claimed = await masStore.claimRunSegment(payload.runId, owner);
163
+ if (!claimed.ok)
164
+ throw new MasSegmentRefusal(claimed.issue);
165
+ let completed = false;
166
+ await options.execute({
167
+ run: claimed.value,
168
+ payload,
169
+ segmentJobId,
170
+ claimSeq: claimed.value.claim.seq,
171
+ signal: context.signal,
172
+ checkpointsFor: (namespace) => namespacedRegionCheckpoints(suite, segmentJobId, namespace),
173
+ completeSegment: async (result) => {
174
+ await Promise.resolve(suite.complete(segmentJobId, result));
175
+ completed = true;
176
+ },
177
+ });
178
+ if (!completed) {
179
+ throw new MasSegmentRefusal(masIssue('TMAS2003', '/segment', 'the segment executor returned without a terminal outcome; a runnable segment ends completed, failed or durably waiting'));
180
+ }
181
+ return null;
182
+ };
183
+ }
184
+ return handlers;
185
+ }
186
+ export function createMasSegmentWorker(db, masStore, options) {
187
+ const jobs = db.jobs;
188
+ if (jobs === undefined)
189
+ throw new TypeError('createMasSegmentWorker: the store was opened without { jobs }');
190
+ const owner = options.owner ?? 'mas-worker';
191
+ const handlers = createMasSegmentHandlers(masStore, { executableRevisions: options.executableRevisions, execute: options.execute, owner });
192
+ const { executableRevisions: _revisions, execute: _execute, ...workerOptions } = options;
193
+ return jobs.createWorker({ ...workerOptions, handlers, owner });
194
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The MAS store adapter — one transaction per semantic commit.
3
+ *
4
+ * Implements `@tangleai/mas`'s `MasStore` over the Jaren database:
5
+ * immutable content-addressed puts (a same-byte put changes nothing, a
6
+ * mutated value under a stale address refuses `TMAS2001`), workflow and
7
+ * template activation as compare-and-swap head rows, run records with a
8
+ * worker claim epoch (`TMAS2005` for a zombie's stale commit), and node
9
+ * completion as ONE transaction writing the terminal attempt, outbound
10
+ * messages, next state revision, budget snapshot and artifact rows —
11
+ * all or nothing, exactly D6. Every record validates against the
12
+ * generated runtime contracts before it is written; persistence never
13
+ * invents a shape. Semantic idempotency: `beginNodeAttempt` returns the
14
+ * stored completion for a key it has already committed and refuses an
15
+ * uncertain attempt rather than repeating external work.
16
+ */
17
+ import { type MasStore } from '@tangleai/mas';
18
+ import type { TangleDb } from './db.ts';
19
+ export interface MasStoreOptions {
20
+ /** Injected clock; deterministic ticks under conformance. */
21
+ now?: () => string;
22
+ /**
23
+ * Test-only probe invoked between the writes of one completion
24
+ * transaction; a throwing probe proves rollback leaves nothing.
25
+ */
26
+ applyProbe?: (step: string) => void;
27
+ }
28
+ export declare function createMasStore(db: TangleDb, options?: MasStoreOptions): MasStore;