@listen-fire/module-runtime 0.12.0-rc.8 → 0.12.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.
@@ -42,3 +42,11 @@ export declare const secretsSlotCases: SlotConformanceCase[];
42
42
  * answers: `client` reaches one. Entries are refused by name with a reason;
43
43
  * everything a refresh needs still works. */
44
44
  export declare const secretsReadOnlySlotCases: SlotConformanceCase[];
45
+ /** The movements slot's own cases (docs/contracts/movements-slot.md): the
46
+ * list, a read of nothing, writes by compare-and-swap on the version, the
47
+ * path rule. `client` reaches a module that can change its files. */
48
+ export declare const movementsSlotCases: SlotConformanceCase[];
49
+ /** What a module that keeps the project's movements where lf cannot change
50
+ * them answers: `client` reaches one. A write is refused by name with a
51
+ * reason; listing and reading still work. */
52
+ export declare const movementsReadOnlySlotCases: SlotConformanceCase[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  // The conformance suites of the slots with no family contract of their own
3
- // (models, judge, files, wake, ocr), and the secrets slot's own cases beside
3
+ // (models, judge, files, wake, ocr), the movements slot's, and the secrets slot's own cases beside
4
4
  // the family's (`services/secrets/contract.ts`), and the cases every slot module answers alike (its
5
5
  // manifest, the envelope's refusals). Each runs against any server over the
6
6
  // wire: written once here, they hold lf's built-ins (`lf --contract-<slot>`)
@@ -10,7 +10,7 @@
10
10
  // `loop_guard/contract.ts`) driven over this same wire; the browser to
11
11
  // `services/browser/contract.ts` through its wire client.
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.secretsReadOnlySlotCases = exports.secretsSlotCases = exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
13
+ exports.movementsReadOnlySlotCases = exports.movementsSlotCases = exports.secretsReadOnlySlotCases = exports.secretsSlotCases = exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
14
14
  exports.commonSlotCases = commonSlotCases;
15
15
  const node_assert_1 = require("node:assert");
16
16
  const node_crypto_1 = require("node:crypto");
@@ -451,3 +451,129 @@ exports.secretsReadOnlySlotCases = [
451
451
  },
452
452
  },
453
453
  ];
454
+ // ── The movements slot ──────────────────────────────────────────────────────
455
+ const NEW_MOVEMENT = () => `conformance/${(0, node_crypto_1.randomUUID)()}.mvt`;
456
+ async function listedMovements(client) {
457
+ return client.read('movements.list', {}, slots_1.MovementsListAnswer);
458
+ }
459
+ const writeMovement = (client, path, text, expectedVersion) => client.read('movements.write', { path, text, expectedVersion }, slots_1.MovementsWriteAnswer);
460
+ /** Creates a file and returns the version it was written at. */
461
+ async function createdMovement(client, path, text) {
462
+ const made = await writeMovement(client, path, text, null);
463
+ node_assert_1.strict.equal(made.kind, 'written', `creating ${path} answered ${JSON.stringify(made)}`);
464
+ return made.kind === 'written' ? made.version : '';
465
+ }
466
+ /** The movements slot's own cases (docs/contracts/movements-slot.md): the
467
+ * list, a read of nothing, writes by compare-and-swap on the version, the
468
+ * path rule. `client` reaches a module that can change its files. */
469
+ exports.movementsSlotCases = [
470
+ {
471
+ name: 'movements: list names a created file, and its version is the one read answers',
472
+ async run(client) {
473
+ const path = NEW_MOVEMENT();
474
+ const version = await createdMovement(client, path, 'movement hello {}\n');
475
+ const one = (await listedMovements(client)).find((file) => file.path === path);
476
+ node_assert_1.strict.ok(one !== undefined, 'a created file is listed');
477
+ node_assert_1.strict.ok(one.version === undefined || one.version === version, 'a listed version is the one the write answered');
478
+ node_assert_1.strict.deepEqual(await client.read('movements.read', { path }, slots_1.MovementsReadAnswer), { text: 'movement hello {}\n', version });
479
+ },
480
+ },
481
+ {
482
+ name: 'movements: read of a path with nothing at it answers null',
483
+ async run(client) {
484
+ node_assert_1.strict.equal(await client.call('movements.read', { path: NEW_MOVEMENT() }), null);
485
+ },
486
+ },
487
+ {
488
+ name: 'movements: create with a null expectedVersion writes; a second create answers conflict with the current version and changes nothing',
489
+ async run(client) {
490
+ const path = NEW_MOVEMENT();
491
+ const version = await createdMovement(client, path, 'one');
492
+ node_assert_1.strict.deepEqual(await writeMovement(client, path, 'two', null), { kind: 'conflict', current: version });
493
+ node_assert_1.strict.equal((await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.text, 'one', 'the refused create changed nothing');
494
+ },
495
+ },
496
+ {
497
+ name: 'movements: replace with the current version answers a new version, and the text reads back byte for byte',
498
+ async run(client) {
499
+ const path = NEW_MOVEMENT();
500
+ const first = await createdMovement(client, path, 'one');
501
+ const text = 'movement caf\u00e9 {}\n\u00fcber \u2713\n';
502
+ const replaced = await writeMovement(client, path, text, first);
503
+ node_assert_1.strict.equal(replaced.kind, 'written');
504
+ node_assert_1.strict.ok(replaced.kind === 'written' && replaced.version !== first, 'a replace is a new version');
505
+ const read = await client.read('movements.read', { path }, slots_1.MovementsReadAnswer);
506
+ node_assert_1.strict.equal(read?.text, text);
507
+ node_assert_1.strict.equal(read?.version, replaced.kind === 'written' ? replaced.version : undefined);
508
+ },
509
+ },
510
+ {
511
+ name: 'movements: a stale version answers conflict with the current one and changes nothing; so does a version for a path with nothing at it',
512
+ async run(client) {
513
+ const path = NEW_MOVEMENT();
514
+ const first = await createdMovement(client, path, 'one');
515
+ const second = await writeMovement(client, path, 'two', first);
516
+ node_assert_1.strict.equal(second.kind, 'written');
517
+ const current = second.kind === 'written' ? second.version : '';
518
+ node_assert_1.strict.deepEqual(await writeMovement(client, path, 'three', first), { kind: 'conflict', current });
519
+ node_assert_1.strict.equal((await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.text, 'two');
520
+ node_assert_1.strict.deepEqual(await writeMovement(client, NEW_MOVEMENT(), 'x', 'no-such-version'), { kind: 'conflict', current: null });
521
+ },
522
+ },
523
+ {
524
+ name: 'movements: the same text listed twice keeps its version; a changed text changes it',
525
+ async run(client) {
526
+ const path = NEW_MOVEMENT();
527
+ await createdMovement(client, path, 'one');
528
+ const versionOf = async () => (await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.version;
529
+ const before = await versionOf();
530
+ node_assert_1.strict.equal(await versionOf(), before, 'reading twice does not change the version');
531
+ const listedVersion = async () => (await listedMovements(client)).find((file) => file.path === path)?.version;
532
+ node_assert_1.strict.equal(await listedVersion(), await listedVersion(), 'listing twice does not change it');
533
+ node_assert_1.strict.ok(before !== undefined);
534
+ await writeMovement(client, path, 'two', before);
535
+ node_assert_1.strict.notEqual(await versionOf(), before, 'a changed text is a new version');
536
+ },
537
+ },
538
+ {
539
+ name: 'movements: a path that is not relative, `/`-separated and ending .mvt is refused as bad_request on read and write',
540
+ async run(client) {
541
+ for (const path of ['/a.mvt', 'a/../b.mvt', './a.mvt', 'a//b.mvt', 'a\\b.mvt', 'a.txt', 'a']) {
542
+ await refusedWith('bad_request', client.call('movements.read', { path }));
543
+ await refusedWith('bad_request', client.call('movements.write', { path, text: 'x', expectedVersion: null }));
544
+ }
545
+ await refusedWith('bad_request', client.call('movements.read', {}));
546
+ },
547
+ },
548
+ {
549
+ name: 'movements: a write without its expectedVersion, or without its text, is refused as bad_request and writes nothing',
550
+ async run(client) {
551
+ const path = NEW_MOVEMENT();
552
+ await refusedWith('bad_request', client.call('movements.write', { path, text: 'x' }));
553
+ await refusedWith('bad_request', client.call('movements.write', { path, expectedVersion: null }));
554
+ node_assert_1.strict.equal(await client.call('movements.read', { path }), null);
555
+ },
556
+ },
557
+ ];
558
+ /** What a module that keeps the project's movements where lf cannot change
559
+ * them answers: `client` reaches one. A write is refused by name with a
560
+ * reason; listing and reading still work. */
561
+ exports.movementsReadOnlySlotCases = [
562
+ {
563
+ name: 'movements: a read-only module answers movements.write with read_only and its reason, and keeps nothing',
564
+ async run(client) {
565
+ const path = NEW_MOVEMENT();
566
+ const answer = await writeMovement(client, path, 'one', null);
567
+ node_assert_1.strict.ok(answer.kind === 'read_only' && answer.reason.length > 0, `movements.write answered ${JSON.stringify(answer)}`);
568
+ node_assert_1.strict.equal(await client.call('movements.read', { path }), null);
569
+ node_assert_1.strict.equal((await listedMovements(client)).find((file) => file.path === path), undefined);
570
+ },
571
+ },
572
+ {
573
+ name: 'movements: a read-only module still lists and reads',
574
+ async run(client) {
575
+ node_assert_1.strict.ok(Array.isArray(await client.call('movements.list', {})));
576
+ node_assert_1.strict.equal(await client.call('movements.read', { path: NEW_MOVEMENT() }), null);
577
+ },
578
+ },
579
+ ];
@@ -60,6 +60,15 @@ export declare class SlotRefusal extends Error {
60
60
  readonly data?: unknown;
61
61
  constructor(code: string, message: string, data?: unknown);
62
62
  }
63
+ /** The envelope rules for one request whose body is already read as JSON,
64
+ * without a server: the manifest answer, the protocol version refusal, an
65
+ * unknown method and a module's `SlotRefusal` codes, a malformed body.
66
+ * `serveSlot` answers every request through it, and a host router (the
67
+ * platform's routes) applies the same rules by calling it. */
68
+ export declare function answerSlotRequest(manifest: WireSlotManifestShape, answer: SlotAnswer, body: unknown): Promise<{
69
+ status: number;
70
+ body: unknown;
71
+ }>;
63
72
  export interface ServedSlot {
64
73
  address: SlotAddress;
65
74
  close(): Promise<void>;
@@ -10,6 +10,7 @@ exports.postTo = postTo;
10
10
  exports.listeningAddress = listeningAddress;
11
11
  exports.freshSocketPath = freshSocketPath;
12
12
  exports.startModule = startModule;
13
+ exports.answerSlotRequest = answerSlotRequest;
13
14
  exports.serveSlot = serveSlot;
14
15
  const node_child_process_1 = require("node:child_process");
15
16
  const node_crypto_1 = require("node:crypto");
@@ -191,6 +192,41 @@ class SlotRefusal extends Error {
191
192
  }
192
193
  }
193
194
  exports.SlotRefusal = SlotRefusal;
195
+ /** The envelope rules for one request whose body is already read as JSON,
196
+ * without a server: the manifest answer, the protocol version refusal, an
197
+ * unknown method and a module's `SlotRefusal` codes, a malformed body.
198
+ * `serveSlot` answers every request through it, and a host router (the
199
+ * platform's routes) applies the same rules by calling it. */
200
+ async function answerSlotRequest(manifest, answer, body) {
201
+ const envelope = slots_1.SlotRequestEnvelope.safeParse(body);
202
+ if (!envelope.success) {
203
+ const version = body !== null && typeof body === 'object' ? Reflect.get(body, 'protocolVersion') : undefined;
204
+ if (version !== undefined && version !== engine_contracts_1.PROTOCOL_VERSION) {
205
+ return { status: 200, body: { ok: false, error: { code: 'protocol_version', message: `this module speaks protocol ${engine_contracts_1.PROTOCOL_VERSION}`, retryable: false } } };
206
+ }
207
+ return { status: 400, body: { error: 'bad_request' } };
208
+ }
209
+ const { method, params, settings } = envelope.data;
210
+ try {
211
+ const result = method === 'manifest' ? manifest : await answer(method, params, settings ?? {});
212
+ return { status: 200, body: { ok: true, result: result ?? null } };
213
+ }
214
+ catch (error) {
215
+ const refusal = error instanceof SlotRefusal ? error : undefined;
216
+ return {
217
+ status: 200,
218
+ body: {
219
+ ok: false,
220
+ error: {
221
+ code: refusal?.code ?? 'module_error',
222
+ message: error instanceof Error ? error.message : String(error),
223
+ retryable: refusal === undefined,
224
+ ...(refusal?.data !== undefined ? { data: refusal.data } : {}),
225
+ },
226
+ },
227
+ };
228
+ }
229
+ }
194
230
  /** Serve `answer` as a module of `manifest`'s slot. `listen`: a socket path
195
231
  * or a port; default `MODULE_SOCKET`, else `PORT`, else a fresh socket.
196
232
  * `MODULE_TOKEN` (or `token`) is required on every request when set. */
@@ -214,31 +250,8 @@ function serveSlot(options) {
214
250
  catch {
215
251
  return reply(response, 400, { error: 'bad_request' });
216
252
  }
217
- const envelope = slots_1.SlotRequestEnvelope.safeParse(json);
218
- if (!envelope.success) {
219
- const version = json !== null && typeof json === 'object' ? Reflect.get(json, 'protocolVersion') : undefined;
220
- if (version !== undefined && version !== engine_contracts_1.PROTOCOL_VERSION) {
221
- return reply(response, 200, { ok: false, error: { code: 'protocol_version', message: `this module speaks protocol ${engine_contracts_1.PROTOCOL_VERSION}`, retryable: false } });
222
- }
223
- return reply(response, 400, { error: 'bad_request' });
224
- }
225
- const { method, params, settings } = envelope.data;
226
- try {
227
- const result = method === 'manifest' ? options.manifest : await options.answer(method, params, settings ?? {});
228
- reply(response, 200, { ok: true, result: result ?? null });
229
- }
230
- catch (error) {
231
- const refusal = error instanceof SlotRefusal ? error : undefined;
232
- reply(response, 200, {
233
- ok: false,
234
- error: {
235
- code: refusal?.code ?? 'module_error',
236
- message: error instanceof Error ? error.message : String(error),
237
- retryable: refusal === undefined,
238
- ...(refusal?.data !== undefined ? { data: refusal.data } : {}),
239
- },
240
- });
241
- }
253
+ const answered = await answerSlotRequest(options.manifest, options.answer, json);
254
+ reply(response, answered.status, answered.body);
242
255
  };
243
256
  const fromEnv = (process.env.MODULE_SOCKET || undefined) ?? (process.env.PORT ? Number(process.env.PORT) : undefined);
244
257
  const listen = options.listen ?? fromEnv ?? freshSocketPath() ?? 0;
@@ -6,3 +6,21 @@
6
6
  * neither spin nor stall a caller.
7
7
  */
8
8
  export declare function vendorWaitMs(e: unknown, now?: number): number | undefined;
9
+ /** What a vendor reports of its own budget on a success. */
10
+ export interface VendorBudget {
11
+ /** Calls (or tokens) the vendor says are left in its window. */
12
+ remaining: number;
13
+ /** Epoch ms when that window resets. */
14
+ resetAt: number;
15
+ }
16
+ /**
17
+ * The budget a vendor reported on a success, from its rate-limit headers
18
+ * (`headers`: a plain object or `Headers`): Anthropic's
19
+ * `anthropic-ratelimit-{requests,tokens}-{remaining,reset}` and OpenAI's
20
+ * `x-ratelimit-{remaining,reset}-{requests,tokens}`. When several limits
21
+ * report, the one that binds: an exhausted one (the latest reset among them),
22
+ * else the least remaining. Undefined when no family gave both a count and a
23
+ * reset. The reset is clamped to at most 120s ahead, as `vendorWaitMs` clamps
24
+ * a wait.
25
+ */
26
+ export declare function vendorBudget(headers: unknown, now?: number): VendorBudget | undefined;
@@ -4,6 +4,7 @@
4
4
  // limiter (`throttleAfter`, movement-engine/public/limiter) read one parser.
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.vendorWaitMs = vendorWaitMs;
7
+ exports.vendorBudget = vendorBudget;
7
8
  const SECOND = 1000;
8
9
  const MIN_WAIT_MS = 1 * SECOND;
9
10
  const MAX_WAIT_MS = 120 * SECOND;
@@ -62,3 +63,57 @@ function vendorWaitMs(e, now = Date.now()) {
62
63
  const fromBody = text === undefined ? undefined : fromRetryInfo(text);
63
64
  return fromBody === undefined ? undefined : clamp(fromBody);
64
65
  }
66
+ /** Each family of success headers: the remaining count, and when it resets. */
67
+ const BUDGET_FAMILIES = [
68
+ { remaining: 'anthropic-ratelimit-requests-remaining', reset: 'anthropic-ratelimit-requests-reset' },
69
+ { remaining: 'anthropic-ratelimit-tokens-remaining', reset: 'anthropic-ratelimit-tokens-reset' },
70
+ { remaining: 'x-ratelimit-remaining-requests', reset: 'x-ratelimit-reset-requests' },
71
+ { remaining: 'x-ratelimit-remaining-tokens', reset: 'x-ratelimit-reset-tokens' },
72
+ ];
73
+ const DURATION_UNIT_MS = { ms: 1, s: SECOND, m: 60 * SECOND, h: 3600 * SECOND };
74
+ /** OpenAI's reset form: a Go-style duration, `"1s"`, `"6m0s"`, `"20ms"`. */
75
+ function fromDuration(text) {
76
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)(ms|h|m|s)/g)];
77
+ if (parts.length === 0 || parts.map((part) => part[0]).join('') !== text)
78
+ return undefined;
79
+ return parts.reduce((total, [, amount, unit]) => total + Number(amount) * (DURATION_UNIT_MS[unit] ?? 0), 0);
80
+ }
81
+ /** A reset as an epoch ms: an ISO timestamp (Anthropic), whole or fractional
82
+ * seconds from now, or a duration from now (OpenAI). */
83
+ function resetInstant(value, now) {
84
+ const text = value.trim();
85
+ if (/^\d+(\.\d+)?$/.test(text))
86
+ return now + Number(text) * SECOND;
87
+ const duration = fromDuration(text);
88
+ if (duration !== undefined)
89
+ return now + duration;
90
+ const date = Date.parse(text);
91
+ return Number.isNaN(date) ? undefined : date;
92
+ }
93
+ /**
94
+ * The budget a vendor reported on a success, from its rate-limit headers
95
+ * (`headers`: a plain object or `Headers`): Anthropic's
96
+ * `anthropic-ratelimit-{requests,tokens}-{remaining,reset}` and OpenAI's
97
+ * `x-ratelimit-{remaining,reset}-{requests,tokens}`. When several limits
98
+ * report, the one that binds: an exhausted one (the latest reset among them),
99
+ * else the least remaining. Undefined when no family gave both a count and a
100
+ * reset. The reset is clamped to at most 120s ahead, as `vendorWaitMs` clamps
101
+ * a wait.
102
+ */
103
+ function vendorBudget(headers, now = Date.now()) {
104
+ const reports = [];
105
+ for (const family of BUDGET_FAMILIES) {
106
+ const left = readHeader(headers, family.remaining)?.trim();
107
+ const reset = readHeader(headers, family.reset);
108
+ if (left === undefined || reset === undefined || !/^\d+$/.test(left))
109
+ continue;
110
+ const resetAt = resetInstant(reset, now);
111
+ if (resetAt === undefined)
112
+ continue;
113
+ reports.push({ remaining: Number(left), resetAt: Math.min(resetAt, now + MAX_WAIT_MS) });
114
+ }
115
+ const exhausted = reports.filter((report) => report.remaining === 0);
116
+ if (exhausted.length > 0)
117
+ return exhausted.reduce((latest, report) => (report.resetAt > latest.resetAt ? report : latest));
118
+ return reports.reduce((least, report) => (least === undefined || report.remaining < least.remaining ? report : least), undefined);
119
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@listen-fire/module-runtime",
3
3
  "license": "Apache-2.0",
4
- "version": "0.12.0-rc.8",
4
+ "version": "0.12.0",
5
5
  "description": "What a module and the host that loads it share at run time on Node, beside the runtime-neutral contracts (@listen-fire/engine-contracts): a live adapter's file bytes as a stream, the files slot, the secrets family with the generic OAuth 2 refresh and connect, the in-memory vault, the browser slot and a bounded concurrency queue. The engine and the module kit both depend on it, so the kit never depends on the engine",
6
6
  "engines": {
7
- "node": "22.20.0"
7
+ "node": ">=22.23.4 <23"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "type": "commonjs",
23
23
  "dependencies": {
24
24
  "zod": "4.1.12",
25
- "@listen-fire/engine-contracts": "^0.12.0-rc.8"
25
+ "@listen-fire/engine-contracts": "^0.12.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "20.16.5",
@@ -37,8 +37,7 @@
37
37
  "dist"
38
38
  ],
39
39
  "publishConfig": {
40
- "access": "public",
41
- "tag": "next"
40
+ "access": "public"
42
41
  },
43
42
  "scripts": {
44
43
  "code:type-check": "tsc --pretty --noEmit && node ../../scripts/check-module-boundary.cjs"