@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,524 @@
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 { equalsJson } from '@jarenjs/core/object';
18
+ import { JarenValidator } from '@jarenjs/validate';
19
+ import { masIssue, masWorkflowVersionIdOf, planInteractionTransition, planNodeCompletion, planRunTransition, validateRuntimeRecord, validateWorkflowShape, } from '@tangleai/mas';
20
+ import { asRows } from "./memory-store.js";
21
+ /** Predicates and ordering reach the suite planner before rows cross the host boundary. */
22
+ const matching = (fields, order = 'id') => ({
23
+ $for: { r: '$[*]' },
24
+ $where: { $and: Object.entries(fields).map(([field, value]) => ({ $eq: [`$r.${field}`, { $const: value }] })) },
25
+ $orderby: `$r.${order}`, $return: '$r',
26
+ });
27
+ /** A refusal raised after a write inside a transaction: rolls everything back, then answers as a value. */
28
+ class MasRollback extends Error {
29
+ outcome;
30
+ constructor(outcome) {
31
+ super('mas transaction rollback');
32
+ this.outcome = outcome;
33
+ }
34
+ }
35
+ export function createMasStore(db, options = {}) {
36
+ const now = options.now ?? (() => new Date().toISOString());
37
+ const probe = options.applyProbe ?? (() => undefined);
38
+ const refuse = (code, path, detail) => ({ ok: false, issue: masIssue(code, path, detail) });
39
+ /** Run one transaction; a MasRollback aborts every write and returns its outcome. */
40
+ async function atomically(fn) {
41
+ try {
42
+ return await db.transaction(fn);
43
+ }
44
+ catch (error) {
45
+ if (error instanceof MasRollback)
46
+ return error.outcome;
47
+ throw error;
48
+ }
49
+ }
50
+ async function putImmutable(collection, key, value, keyMember) {
51
+ return atomically(async (txn) => {
52
+ const handle = txn.collection(collection);
53
+ const existing = await handle.get(key);
54
+ if (existing !== undefined) {
55
+ if (!equalsJson(existing, value)) {
56
+ return refuse('TMAS2001', `/${keyMember}`, `'${key.slice(0, 12)}…' is immutable; a different value cannot reuse its address`);
57
+ }
58
+ return { ok: true, value: { [keyMember]: key } };
59
+ }
60
+ await handle.put(value);
61
+ return { ok: true, value: { [keyMember]: key } };
62
+ });
63
+ }
64
+ async function activateHead(collection, id, nextVersion, expectedActiveVersion, versionsCollection) {
65
+ return atomically(async (txn) => {
66
+ const version = await txn.collection(versionsCollection).get(nextVersion);
67
+ if (version === undefined) {
68
+ return { applied: false, conflict: masIssue('TMAS2001', '/activeVersion', `'${nextVersion.slice(0, 12)}…' names no stored immutable version`) };
69
+ }
70
+ const heads = txn.collection(collection);
71
+ const current = await heads.get(id);
72
+ const active = current?.activeVersion ?? null;
73
+ if (active !== expectedActiveVersion) {
74
+ return {
75
+ applied: false,
76
+ conflict: masIssue('TMAS2001', '/activeVersion', `the expected active version does not hold (expected ${expectedActiveVersion === null ? 'none' : `${expectedActiveVersion.slice(0, 12)}…`}, found ${active === null ? 'none' : `${active.slice(0, 12)}…`})`),
77
+ };
78
+ }
79
+ await heads.put({
80
+ id,
81
+ activeVersion: nextVersion,
82
+ archived: current?.archived ?? false,
83
+ revision: (current?.revision ?? 0) + 1,
84
+ });
85
+ return { applied: true, activeVersion: nextVersion };
86
+ });
87
+ }
88
+ const runs = () => db.collection('mas_runs');
89
+ const attempts = () => db.collection('mas_node_attempts');
90
+ async function readRun(txn, runId) {
91
+ return txn.collection('mas_runs').get(runId);
92
+ }
93
+ async function writeRun(txn, run) {
94
+ const next = { ...run, revision: run.revision + 1, updatedAt: now() };
95
+ const outcome = validateRuntimeRecord('masRun', next);
96
+ if (!outcome.valid) {
97
+ return refuse('TMAS2004', `/run${outcome.issues[0]?.path ?? ''}`, `the run record does not validate: ${outcome.issues[0]?.detail ?? ''}`);
98
+ }
99
+ await txn.collection('mas_runs').put(next);
100
+ return { ok: true, value: next };
101
+ }
102
+ const responseValidators = new Map();
103
+ function responseValidator(schema, key) {
104
+ let validate = responseValidators.get(key);
105
+ if (validate === undefined) {
106
+ const validator = new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' });
107
+ validate = validator.compile(schema);
108
+ responseValidators.set(key, validate);
109
+ }
110
+ return validate;
111
+ }
112
+ return {
113
+ async putWorkflowVersion(workflow) {
114
+ const shape = validateWorkflowShape(workflow);
115
+ if (!shape.valid) {
116
+ return refuse('TMAS2004', shape.issues[0]?.path ?? '', `the workflow does not validate: ${shape.issues[0]?.detail ?? ''}`);
117
+ }
118
+ const recomputed = await masWorkflowVersionIdOf(workflow);
119
+ if (recomputed !== workflow.versionId) {
120
+ return refuse('TMAS2001', '/versionId', 'the workflow does not hash to its claimed version; a mutated document cannot be stored under a stale address');
121
+ }
122
+ return putImmutable('mas_workflow_versions', workflow.versionId, workflow, 'versionId');
123
+ },
124
+ async getWorkflowVersion(versionId) {
125
+ const row = await db.collection('mas_workflow_versions').get(versionId);
126
+ return row === undefined ? undefined : structuredClone(row);
127
+ },
128
+ async putRegistrySnapshot(document, revision) {
129
+ return putImmutable('mas_registry_snapshots', revision, { revision, document }, 'revision');
130
+ },
131
+ async getRegistrySnapshot(revision) {
132
+ const row = await db.collection('mas_registry_snapshots').get(revision);
133
+ return row === undefined ? undefined : structuredClone(row.document);
134
+ },
135
+ async putTemplateVersion(template, versionId, templateId) {
136
+ return putImmutable('mas_template_versions', versionId, { versionId, templateId, template }, 'versionId');
137
+ },
138
+ async getTemplateVersion(versionId) {
139
+ const row = await db.collection('mas_template_versions').get(versionId);
140
+ return row === undefined ? undefined : structuredClone(row.template);
141
+ },
142
+ activateWorkflow(workflowId, nextVersion, expectedActiveVersion) {
143
+ return activateHead('mas_workflows', workflowId, nextVersion, expectedActiveVersion, 'mas_workflow_versions');
144
+ },
145
+ async getActiveWorkflow(workflowId) {
146
+ const row = await db.collection('mas_workflows').get(workflowId);
147
+ return row === undefined ? undefined : { workflowId: row.id, activeVersion: row.activeVersion };
148
+ },
149
+ activateTemplate(templateId, nextVersion, expectedActiveVersion) {
150
+ return activateHead('mas_templates', templateId, nextVersion, expectedActiveVersion, 'mas_template_versions');
151
+ },
152
+ async createRun(plan) {
153
+ return atomically(async (txn) => {
154
+ const existing = await readRun(txn, plan.runId);
155
+ if (existing !== undefined) {
156
+ return refuse('TMAS2001', '/id', `run '${plan.runId}' already exists`);
157
+ }
158
+ const tick = now();
159
+ const run = {
160
+ id: plan.runId,
161
+ workflowId: plan.workflowId,
162
+ workflowVersionId: plan.workflowVersionId,
163
+ registryRevision: plan.registryRevision,
164
+ executableRevision: plan.executableRevision,
165
+ configRegistryRevision: plan.configRegistryRevision,
166
+ profile: plan.profile,
167
+ status: 'queued',
168
+ revision: 0,
169
+ segment: 0,
170
+ claim: { owner: null, seq: 0 },
171
+ traceSeq: 0,
172
+ jobId: null,
173
+ input: plan.input,
174
+ output: null,
175
+ failure: null,
176
+ fsm: {},
177
+ budget: { limits: plan.limits, spent: { turns: 0, tokens: 0, ms: 0 } },
178
+ createdAt: tick,
179
+ updatedAt: tick,
180
+ };
181
+ const outcome = validateRuntimeRecord('masRun', run);
182
+ if (!outcome.valid) {
183
+ return refuse('TMAS2004', `/run${outcome.issues[0]?.path ?? ''}`, `the run record does not validate: ${outcome.issues[0]?.detail ?? ''}`);
184
+ }
185
+ await txn.collection('mas_runs').put(run);
186
+ return { ok: true, value: run };
187
+ });
188
+ },
189
+ async getRun(runId) {
190
+ const row = await runs().get(runId);
191
+ return row === undefined ? undefined : structuredClone(row);
192
+ },
193
+ async claimRunSegment(runId, owner) {
194
+ return atomically(async (txn) => {
195
+ const run = await readRun(txn, runId);
196
+ if (run === undefined)
197
+ return refuse('TMAS2002', '/id', `run '${runId}' does not exist`);
198
+ let status = run.status;
199
+ if (status === 'queued') {
200
+ const transition = planRunTransition(status, { kind: 'start' });
201
+ if (!transition.ok)
202
+ return { ok: false, issue: transition.issue };
203
+ status = transition.status;
204
+ }
205
+ else if (status !== 'running') {
206
+ return refuse('TMAS2003', '/status', `a segment cannot be claimed while the run is '${status}'`);
207
+ }
208
+ return writeRun(txn, {
209
+ ...run,
210
+ status,
211
+ claim: { owner, seq: run.claim.seq + 1 },
212
+ });
213
+ });
214
+ },
215
+ async transitionRun(runId, command) {
216
+ return atomically(async (txn) => {
217
+ const run = await readRun(txn, runId);
218
+ if (run === undefined)
219
+ return refuse('TMAS2002', '/id', `run '${runId}' does not exist`);
220
+ const transition = planRunTransition(run.status, command);
221
+ if (!transition.ok)
222
+ return { ok: false, issue: transition.issue };
223
+ const next = { ...run, status: transition.status };
224
+ if (command.kind === 'complete')
225
+ next.output = command.output;
226
+ if (command.kind === 'fail')
227
+ next.failure = command.failure;
228
+ if (command.kind === 'queue-segment')
229
+ next.segment = run.segment + 1;
230
+ return writeRun(txn, next);
231
+ });
232
+ },
233
+ async putRunFsm(runId, controlId, snapshot) {
234
+ return atomically(async (txn) => {
235
+ const run = await readRun(txn, runId);
236
+ if (run === undefined)
237
+ return refuse('TMAS2002', '/id', `run '${runId}' does not exist`);
238
+ return writeRun(txn, { ...run, fsm: { ...run.fsm, [controlId]: snapshot } });
239
+ });
240
+ },
241
+ async beginNodeAttempt(plan) {
242
+ return atomically(async (txn) => {
243
+ const run = await readRun(txn, plan.runId);
244
+ if (run === undefined) {
245
+ return { kind: 'refused', issue: masIssue('TMAS2002', '/id', `run '${plan.runId}' does not exist`) };
246
+ }
247
+ if (plan.claimSeq !== run.claim.seq) {
248
+ return { kind: 'refused', issue: masIssue('TMAS2005', '/claim', `the claim epoch moved (held ${plan.claimSeq}, current ${run.claim.seq}); the lease was lost`) };
249
+ }
250
+ const handle = txn.collection('mas_node_attempts');
251
+ const rows = asRows(await handle.execute(matching({ runId: plan.runId, idempotencyKey: plan.idempotencyKey })));
252
+ const latest = rows.at(-1);
253
+ if (latest !== undefined && latest.status === 'completed') {
254
+ const messages = asRows(await txn.collection('mas_messages').execute(matching({ runId: plan.runId, 'from.path': latest.path }, 'seq')));
255
+ return { kind: 'completed', attempt: structuredClone(latest), messages: structuredClone(messages) };
256
+ }
257
+ if (latest !== undefined && latest.status === 'uncertain') {
258
+ return {
259
+ kind: 'uncertain',
260
+ attempt: structuredClone(latest),
261
+ issue: masIssue('TMAS2006', '/status', `'${plan.idempotencyKey}' has an external success with no durable outcome; automatic repetition is refused until operator resolution`),
262
+ };
263
+ }
264
+ if (latest !== undefined && latest.status === 'running') {
265
+ // A previous incarnation crashed after beginning; its claim epoch is
266
+ // gone, so the stale row is closed as aborted before a new attempt.
267
+ await handle.put({ ...latest, status: 'aborted', finishedAt: now() });
268
+ }
269
+ const seq = run.traceSeq + 1;
270
+ const attempt = {
271
+ id: `${plan.runId}:a:${String(seq).padStart(6, '0')}`,
272
+ runId: plan.runId,
273
+ seq,
274
+ path: plan.path,
275
+ invocationId: plan.invocationId,
276
+ kind: plan.kind,
277
+ attempt: (latest?.attempt ?? 0) + 1,
278
+ status: 'running',
279
+ idempotencyKey: plan.idempotencyKey,
280
+ output: null,
281
+ error: null,
282
+ usage: { calls: 0, toolCalls: 0, contextReads: 0, promptTokens: 0, completionTokens: 0 },
283
+ spend: { turns: 0, tokens: 0, ms: 0 },
284
+ stopReason: null,
285
+ transcript: { state: 'not-run', text: null, size: 0, artifact: null },
286
+ toolSteps: [],
287
+ contextReads: [],
288
+ restored: false,
289
+ startedAt: now(),
290
+ finishedAt: null,
291
+ };
292
+ const outcome = validateRuntimeRecord('masNodeAttempt', attempt);
293
+ if (!outcome.valid) {
294
+ throw new MasRollback({ kind: 'refused', issue: masIssue('TMAS2004', `/attempt${outcome.issues[0]?.path ?? ''}`, outcome.issues[0]?.detail ?? 'the attempt does not validate') });
295
+ }
296
+ await handle.put(attempt);
297
+ const written = await writeRun(txn, { ...run, traceSeq: seq });
298
+ if (!written.ok)
299
+ throw new MasRollback({ kind: 'refused', issue: written.issue });
300
+ return { kind: 'started', attempt: structuredClone(attempt) };
301
+ });
302
+ },
303
+ async commitNodeCompletion(plan) {
304
+ return atomically(async (txn) => {
305
+ const run = await readRun(txn, plan.runId);
306
+ if (run === undefined)
307
+ return refuse('TMAS2002', '/id', `run '${plan.runId}' does not exist`);
308
+ if (plan.claimSeq !== run.claim.seq) {
309
+ return refuse('TMAS2005', '/claim', `the claim epoch moved (held ${plan.claimSeq}, current ${run.claim.seq}); a lost lease cannot commit a completion`);
310
+ }
311
+ const handle = txn.collection('mas_node_attempts');
312
+ const current = await handle.get(plan.attemptId);
313
+ if (current === undefined)
314
+ return refuse('TMAS2003', '/attemptId', `attempt '${plan.attemptId}' does not exist`);
315
+ if (current.status === 'completed') {
316
+ if (equalsJson(current.output, plan.output)) {
317
+ const messages = asRows(await txn.collection('mas_messages').execute(matching({ runId: plan.runId, 'from.path': current.path }, 'seq')));
318
+ return { ok: true, value: { attempt: structuredClone(current), messages: structuredClone(messages), stateRevision: null } };
319
+ }
320
+ return refuse('TMAS2001', '/output', 'a different payload cannot re-commit under an already committed idempotency key');
321
+ }
322
+ const stateRows = plan.state === null ? [] : asRows(await txn.collection('mas_state_revisions').execute(matching({ runId: plan.runId, namespace: plan.state.namespace }, 'seq')));
323
+ const planned = planNodeCompletion(current, plan, {
324
+ nextSeq: run.traceSeq + 1,
325
+ now: now(),
326
+ stateParent: stateRows.at(-1)?.id ?? null,
327
+ });
328
+ if (!planned.ok)
329
+ return { ok: false, issue: planned.issue };
330
+ probe('attempt');
331
+ await handle.put(planned.value.attempt);
332
+ probe('messages');
333
+ for (const message of planned.value.messages) {
334
+ await txn.collection('mas_messages').put(message);
335
+ }
336
+ probe('state');
337
+ if (planned.value.stateRevision !== null) {
338
+ await txn.collection('mas_state_revisions').put(planned.value.stateRevision);
339
+ }
340
+ probe('artifacts');
341
+ for (const artifact of planned.value.artifacts) {
342
+ await txn.collection('mas_trace_artifacts').put(artifact);
343
+ }
344
+ probe('budget');
345
+ const spent = run.budget.spent;
346
+ const written = await writeRun(txn, {
347
+ ...run,
348
+ traceSeq: run.traceSeq + planned.value.messages.length + (planned.value.stateRevision === null ? 0 : 1) + planned.value.artifacts.length,
349
+ budget: {
350
+ limits: run.budget.limits,
351
+ spent: {
352
+ turns: spent.turns + plan.spend.turns,
353
+ tokens: spent.tokens + plan.spend.tokens,
354
+ ms: spent.ms + plan.spend.ms,
355
+ },
356
+ },
357
+ });
358
+ if (!written.ok)
359
+ throw new MasRollback({ ok: false, issue: written.issue });
360
+ return {
361
+ ok: true,
362
+ value: {
363
+ attempt: structuredClone(planned.value.attempt),
364
+ messages: structuredClone(planned.value.messages),
365
+ stateRevision: planned.value.stateRevision === null ? null : structuredClone(planned.value.stateRevision),
366
+ },
367
+ };
368
+ });
369
+ },
370
+ async failNodeAttempt(plan) {
371
+ return atomically(async (txn) => {
372
+ const run = await readRun(txn, plan.runId);
373
+ if (run === undefined)
374
+ return refuse('TMAS2002', '/id', `run '${plan.runId}' does not exist`);
375
+ if (plan.claimSeq !== run.claim.seq) {
376
+ return refuse('TMAS2005', '/claim', 'the claim epoch moved; a lost lease cannot fail an attempt');
377
+ }
378
+ const handle = txn.collection('mas_node_attempts');
379
+ const current = await handle.get(plan.attemptId);
380
+ if (current === undefined)
381
+ return refuse('TMAS2003', '/attemptId', `attempt '${plan.attemptId}' does not exist`);
382
+ if (current.status !== 'running') {
383
+ return refuse('TMAS2003', '/status', `only a running attempt can move to '${plan.status}'; '${plan.attemptId}' is '${current.status}'`);
384
+ }
385
+ const next = { ...current, status: plan.status, error: plan.error, finishedAt: now() };
386
+ const outcome = validateRuntimeRecord('masNodeAttempt', next);
387
+ if (!outcome.valid) {
388
+ return refuse('TMAS2004', `/attempt${outcome.issues[0]?.path ?? ''}`, outcome.issues[0]?.detail ?? '');
389
+ }
390
+ await handle.put(next);
391
+ return { ok: true, value: structuredClone(next) };
392
+ });
393
+ },
394
+ async createInteraction(plan) {
395
+ return atomically(async (txn) => {
396
+ const run = await readRun(txn, plan.runId);
397
+ if (run === undefined)
398
+ return refuse('TMAS2002', '/id', `run '${plan.runId}' does not exist`);
399
+ const id = `${plan.runId}:i:${plan.node}`;
400
+ const handle = txn.collection('mas_interactions');
401
+ const existing = await handle.get(id);
402
+ if (existing !== undefined)
403
+ return { ok: true, value: structuredClone(existing) };
404
+ const interaction = {
405
+ id,
406
+ runId: plan.runId,
407
+ node: plan.node,
408
+ path: plan.path,
409
+ status: 'waiting',
410
+ revision: 0,
411
+ prompt: plan.prompt,
412
+ responseSchema: plan.responseSchema,
413
+ expiry: plan.expiry,
414
+ response: null,
415
+ responseKey: null,
416
+ segment: plan.segment,
417
+ resumeSegment: null,
418
+ requestedAt: now(),
419
+ resolvedAt: null,
420
+ };
421
+ const outcome = validateRuntimeRecord('masInteraction', interaction);
422
+ if (!outcome.valid) {
423
+ return refuse('TMAS2004', `/interaction${outcome.issues[0]?.path ?? ''}`, outcome.issues[0]?.detail ?? '');
424
+ }
425
+ await handle.put(interaction);
426
+ return { ok: true, value: structuredClone(interaction) };
427
+ });
428
+ },
429
+ async getInteraction(id) {
430
+ const row = await db.collection('mas_interactions').get(id);
431
+ return row === undefined ? undefined : structuredClone(row);
432
+ },
433
+ async respondInteraction(id, response, expectedRevision, responseKey) {
434
+ return atomically(async (txn) => {
435
+ const handle = txn.collection('mas_interactions');
436
+ const current = await handle.get(id);
437
+ if (current === undefined)
438
+ return refuse('TMAS2007', '/id', `interaction '${id}' does not exist`);
439
+ if (current.status === 'responded' && current.responseKey === responseKey) {
440
+ return { ok: true, value: structuredClone(current) };
441
+ }
442
+ const transition = planInteractionTransition(current.status, 'responded');
443
+ if (!transition.ok)
444
+ return { ok: false, issue: transition.issue };
445
+ if (current.revision !== expectedRevision) {
446
+ return refuse('TMAS2007', '/revision', `the interaction moved (expected revision ${expectedRevision}, found ${current.revision}); a conflicting second response is refused`);
447
+ }
448
+ if (current.expiry !== null && now() > current.expiry.deadline) {
449
+ const expired = { ...current, status: 'expired', revision: current.revision + 1, resolvedAt: now() };
450
+ await handle.put(expired);
451
+ return refuse('TMAS2007', '/expiry', 'the interaction expired before the response arrived');
452
+ }
453
+ const validate = responseValidator(current.responseSchema, id);
454
+ if (!validate(response).valid) {
455
+ return refuse('TMAS2007', '/response', 'the response does not validate against the stored response schema');
456
+ }
457
+ const run = await readRun(txn, current.runId);
458
+ if (run === undefined)
459
+ return refuse('TMAS2002', '/runId', `run '${current.runId}' does not exist`);
460
+ const runTransition = planRunTransition(run.status, { kind: 'resume-pending' });
461
+ if (!runTransition.ok)
462
+ return { ok: false, issue: runTransition.issue };
463
+ const next = {
464
+ ...current,
465
+ status: 'responded',
466
+ revision: current.revision + 1,
467
+ response,
468
+ responseKey,
469
+ resumeSegment: run.segment + 1,
470
+ resolvedAt: now(),
471
+ };
472
+ const outcome = validateRuntimeRecord('masInteraction', next);
473
+ if (!outcome.valid) {
474
+ return refuse('TMAS2004', `/interaction${outcome.issues[0]?.path ?? ''}`, outcome.issues[0]?.detail ?? '');
475
+ }
476
+ await handle.put(next);
477
+ const written = await writeRun(txn, { ...run, status: runTransition.status });
478
+ if (!written.ok)
479
+ throw new MasRollback({ ok: false, issue: written.issue });
480
+ return { ok: true, value: structuredClone(next) };
481
+ });
482
+ },
483
+ async resolveInteraction(id, status, expectedRevision) {
484
+ return atomically(async (txn) => {
485
+ const handle = txn.collection('mas_interactions');
486
+ const current = await handle.get(id);
487
+ if (current === undefined)
488
+ return refuse('TMAS2007', '/id', `interaction '${id}' does not exist`);
489
+ const transition = planInteractionTransition(current.status, status);
490
+ if (!transition.ok)
491
+ return { ok: false, issue: transition.issue };
492
+ if (current.revision !== expectedRevision) {
493
+ return refuse('TMAS2007', '/revision', 'the interaction moved under this resolution');
494
+ }
495
+ const next = { ...current, status, revision: current.revision + 1, resolvedAt: now() };
496
+ await handle.put(next);
497
+ return { ok: true, value: structuredClone(next) };
498
+ });
499
+ },
500
+ async listResumePendingRuns() {
501
+ const rows = asRows(await db.collection('mas_runs').execute(matching({ status: 'resume_pending' })));
502
+ return rows.map((row) => structuredClone(row));
503
+ },
504
+ async latestState(runId, namespace) {
505
+ const rows = asRows(await db.collection('mas_state_revisions').execute(matching({ runId, namespace })));
506
+ const latest = rows.at(-1);
507
+ return latest === undefined ? undefined : { id: latest.id, value: structuredClone(latest.value) };
508
+ },
509
+ async readTrace(runId) {
510
+ const run = await runs().get(runId);
511
+ if (run === undefined)
512
+ return undefined;
513
+ const view = {
514
+ run: structuredClone(run),
515
+ attempts: asRows(await attempts().execute(matching({ runId }))).map((row) => structuredClone(row)),
516
+ messages: asRows(await db.collection('mas_messages').execute(matching({ runId }))).map((row) => structuredClone(row)),
517
+ stateRevisions: asRows(await db.collection('mas_state_revisions').execute(matching({ runId }))).map((row) => structuredClone(row)),
518
+ interactions: asRows(await db.collection('mas_interactions').execute(matching({ runId }))).map((row) => structuredClone(row)),
519
+ artifacts: asRows(await db.collection('mas_trace_artifacts').execute(matching({ runId }))).map((row) => structuredClone(row)),
520
+ };
521
+ return view;
522
+ },
523
+ };
524
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The MemoryStore contract over a @jarenjs/db collection.
3
+ *
4
+ * Same four methods, same write gate, same isolation guarantees as
5
+ * `createMemoryUnitStore` — the policies in @tangleai/memory cannot tell
6
+ * the difference, which is the whole point of the seam: the in-memory
7
+ * store is replaced behind the SAME 4-method contract, node:sqlite first.
8
+ *
9
+ * Isolation comes free: every read is a fresh parse out of SQLite, every
10
+ * write serializes the document — no caller ever holds a live reference
11
+ * into the store.
12
+ */
13
+ import { JarenValidator } from '@jarenjs/validate';
14
+ import { type MemoryUnit } from '@tangleai/core/schemas/memory';
15
+ import type { MemoryStore } from '@tangleai/memory/store';
16
+ import type { SequenceResult } from '@jarenjs/db';
17
+ import type { DbCollection } from './db.ts';
18
+ /**
19
+ * @jarenjs/db's `execute` answers in the engine's result shape,
20
+ * `SequenceResult<T>`: `undefined` for no rows, the item itself for
21
+ * exactly one, an array for more. Every Tangle row is an object, so
22
+ * the disambiguation is total (an array-valued item would not be).
23
+ */
24
+ export declare function asRows<T>(result: SequenceResult<T>): T[];
25
+ export interface DbMemoryStoreOptions {
26
+ validator?: JarenValidator<true>;
27
+ }
28
+ export declare function createDbMemoryStore(collection: DbCollection<MemoryUnit>, options?: DbMemoryStoreOptions): MemoryStore;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The MemoryStore contract over a @jarenjs/db collection.
3
+ *
4
+ * Same four methods, same write gate, same isolation guarantees as
5
+ * `createMemoryUnitStore` — the policies in @tangleai/memory cannot tell
6
+ * the difference, which is the whole point of the seam: the in-memory
7
+ * store is replaced behind the SAME 4-method contract, node:sqlite first.
8
+ *
9
+ * Isolation comes free: every read is a fresh parse out of SQLite, every
10
+ * write serializes the document — no caller ever holds a live reference
11
+ * into the store.
12
+ */
13
+ import { JarenValidator } from '@jarenjs/validate';
14
+ import { callerError } from '@tangleai/core/errors';
15
+ import { MEMORY_UNIT_SCHEMA, MEMORY_RELATION_SCHEMA, } from '@tangleai/core/schemas/memory';
16
+ const LIST_ALL = { $for: { u: '$[*]' }, $return: '$u' };
17
+ /**
18
+ * @jarenjs/db's `execute` answers in the engine's result shape,
19
+ * `SequenceResult<T>`: `undefined` for no rows, the item itself for
20
+ * exactly one, an array for more. Every Tangle row is an object, so
21
+ * the disambiguation is total (an array-valued item would not be).
22
+ */
23
+ export function asRows(result) {
24
+ if (result === undefined)
25
+ return [];
26
+ const rows = [];
27
+ return rows.concat(result);
28
+ }
29
+ export function createDbMemoryStore(collection, options = {}) {
30
+ const validator = options.validator
31
+ ?? new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' });
32
+ validator.addSchema(MEMORY_RELATION_SCHEMA);
33
+ const validate = validator.compile(MEMORY_UNIT_SCHEMA);
34
+ return {
35
+ async get(id) {
36
+ return collection.get(id);
37
+ },
38
+ async put(unit) {
39
+ const outcome = validate(unit);
40
+ if (outcome.valid !== true) {
41
+ throw callerError(`memory unit rejected by MEMORY_UNIT_SCHEMA: ${JSON.stringify(outcome.errors)}`);
42
+ }
43
+ await collection.put(unit);
44
+ },
45
+ async delete(id) {
46
+ await collection.delete(id);
47
+ },
48
+ async list() {
49
+ return asRows(await collection.execute(LIST_ALL));
50
+ },
51
+ };
52
+ }