@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/package.json +84 -0
- package/src/db.d.ts +27 -0
- package/src/db.js +28 -0
- package/src/document-store.d.ts +3 -0
- package/src/document-store.js +124 -0
- package/src/identities.d.ts +27 -0
- package/src/identities.js +43 -0
- package/src/index.d.ts +16 -0
- package/src/index.js +10 -0
- package/src/ledger-storage.d.ts +3 -0
- package/src/ledger-storage.js +46 -0
- package/src/mas-jobs.d.ts +121 -0
- package/src/mas-jobs.js +194 -0
- package/src/mas-store.d.ts +28 -0
- package/src/mas-store.js +524 -0
- package/src/memory-store.d.ts +28 -0
- package/src/memory-store.js +52 -0
- package/src/model.d.ts +446 -0
- package/src/model.js +242 -0
- package/src/runs.d.ts +74 -0
- package/src/runs.js +104 -0
package/src/mas-jobs.js
ADDED
|
@@ -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;
|