@volter/twin-fal 0.1.1 → 0.1.3

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.
@@ -23,13 +23,15 @@
23
23
  // construction — loudly, never a silent fallback.
24
24
  import { serveHttp } from '@volter/world-core';
25
25
  import { handleFalTwinRequest } from "./fal-twin.js";
26
- import { createTwinFetchFromHandler, twinManifest } from '@volter/world-core';
26
+ import { createTwinFetchFromHandler, twinManifest, withRequestScopes } from '@volter/world-core';
27
27
  import { createFalScenarioEngine, loadFalScenarioDocument } from "./fal-scenario.js";
28
28
  export function createFalTwinFetch(options = {}) {
29
29
  const scenarioPath = options.scenarioPath ?? process.env.TWIN_FAL_SCENARIO;
30
30
  const scenarioEngine = scenarioPath ? createFalScenarioEngine(loadFalScenarioDocument(scenarioPath)) : undefined;
31
31
  const { scenarioPath: _scenarioPath, ...handlerOptions } = options;
32
- return createTwinFetchFromHandler(handleFalTwinRequest, {
32
+ // a read-only request (x-volter-read-only) is a request to a read-only twin: a status poll answers the
33
+ // request's current status without advancing it, nothing is written (withRequestScopes: the pack opts in)
34
+ return withRequestScopes(createTwinFetchFromHandler(handleFalTwinRequest, {
33
35
  ...handlerOptions,
34
36
  manifest: () => twinManifest({
35
37
  vendor: 'fal',
@@ -41,7 +43,7 @@ export function createFalTwinFetch(options = {}) {
41
43
  }),
42
44
  scenarioStatus: () => (scenarioEngine ? scenarioEngine.status() : { vendor: 'fal', handlers: [], misses: 0, recentMisses: [] }),
43
45
  ...(scenarioEngine ? { handlerOptions: { scenarioEngine } } : {}),
44
- });
46
+ }), { refuse: () => Response.json({ detail: 'twin is read-only; omit readOnly to accept writes' }, { status: 405 }) });
45
47
  }
46
48
  export async function createFalTwinServer(options = {}) {
47
49
  const server = await serveHttp({
@@ -301,7 +301,10 @@ async function handleStatus(modelId, requestId, includeLogs, req) {
301
301
  const existing = getRow('request', requestId, req.root);
302
302
  if (!existing)
303
303
  return detailNotFound('Request not found.');
304
- await progressOnce(requestId, req);
304
+ // ADVANCE ON POLL — one step per status check. A read-only twin (a World's read token) answers the
305
+ // request's current status and never advances it: the app's next poll sees what it would have.
306
+ if (!req.readOnly)
307
+ await progressOnce(requestId, req);
305
308
  const row = getRow('request', requestId, req.root);
306
309
  return { status: 200, body: statusView(row, modelId, requestId, includeLogs) };
307
310
  }
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
42
42
  // this module stays browser-bundle safe.
43
- import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow } from '@volter/world-core';
43
+ import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow, runAsVendorMove } from '@volter/world-core';
44
44
  function nodeCrypto() {
45
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
46
  return nodeBuiltin('node:crypto');
@@ -79,7 +79,8 @@ async function ensureFalSigningSeed(root) {
79
79
  const fast = storedSeed(projectResources(SERVICE, root));
80
80
  if (fast)
81
81
  return fast;
82
- const { value } = await applyTwinWriteAtomic(SERVICE, (resources) => {
82
+ // fal's own signing key: a vendor move, made on first use whoever asks (a read-only request too)
83
+ const { value } = await runAsVendorMove(() => applyTwinWriteAtomic(SERVICE, (resources) => {
83
84
  const existing = storedSeed(resources);
84
85
  if (existing)
85
86
  return { kind: 'skip', value: existing };
@@ -95,7 +96,7 @@ async function ensureFalSigningSeed(root) {
95
96
  actor: { kind: 'system' },
96
97
  },
97
98
  };
98
- }, root);
99
+ }, root));
99
100
  return value;
100
101
  }
101
102
  /** The per-root ed25519 keypair, derived from the PERSISTED seed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-fal",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local fal.ai twin for the model-inference queue lifecycle (submit/status/response/cancel on queue.fal.run, the sync fal.run variant, host-split routing, ed25519-signed webhooks) built on @volter/world-core.",
5
5
  "author": "Volter (https://github.com/volter-ai)",
6
6
  "license": "Apache-2.0",
@@ -35,12 +35,12 @@
35
35
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
36
36
  },
37
37
  "peerDependencies": {
38
- "@volter/world-core": "2.0.1"
38
+ "@volter/world-core": "2.0.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.2.20",
42
42
  "@types/node": "^24.0.0",
43
- "@volter/world-core": "2.0.1",
43
+ "@volter/world-core": "2.0.3",
44
44
  "@volter/world-tooling": "0.1.0",
45
45
  "@fal-ai/client": "^1.10.1",
46
46
  "typescript": "^5.9.0"
package/src/fal-server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  // construction — loudly, never a silent fallback.
24
24
  import { serveHttp } from '@volter/world-core';
25
25
  import { handleFalTwinRequest } from './fal-twin.ts';
26
- import { createTwinFetchFromHandler, twinManifest } from '@volter/world-core';
26
+ import { createTwinFetchFromHandler, twinManifest, withRequestScopes } from '@volter/world-core';
27
27
  import { createFalScenarioEngine, loadFalScenarioDocument, type FalScenarioEngine } from './fal-scenario.ts';
28
28
 
29
29
  /** Options every fal-twin HTTP surface needs, independent of who owns the socket. */
@@ -39,7 +39,9 @@ export function createFalTwinFetch(options: FalTwinFetchOptions = {}): (request:
39
39
  const scenarioPath = options.scenarioPath ?? process.env.TWIN_FAL_SCENARIO;
40
40
  const scenarioEngine: FalScenarioEngine | undefined = scenarioPath ? createFalScenarioEngine(loadFalScenarioDocument(scenarioPath)) : undefined;
41
41
  const { scenarioPath: _scenarioPath, ...handlerOptions } = options;
42
- return createTwinFetchFromHandler(handleFalTwinRequest, {
42
+ // a read-only request (x-volter-read-only) is a request to a read-only twin: a status poll answers the
43
+ // request's current status without advancing it, nothing is written (withRequestScopes: the pack opts in)
44
+ return withRequestScopes(createTwinFetchFromHandler(handleFalTwinRequest, {
43
45
  ...handlerOptions,
44
46
  manifest: () => twinManifest({
45
47
  vendor: 'fal',
@@ -51,7 +53,7 @@ export function createFalTwinFetch(options: FalTwinFetchOptions = {}): (request:
51
53
  }),
52
54
  scenarioStatus: () => (scenarioEngine ? scenarioEngine.status() : { vendor: 'fal', handlers: [], misses: 0, recentMisses: [] }),
53
55
  ...(scenarioEngine ? { handlerOptions: { scenarioEngine } } : {}),
54
- });
56
+ }), { refuse: () => Response.json({ detail: 'twin is read-only; omit readOnly to accept writes' }, { status: 405 }) });
55
57
  }
56
58
 
57
59
  export async function createFalTwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string } = {}): Promise<{ port: number; stop: () => void }> {
package/src/fal-twin.ts CHANGED
@@ -352,7 +352,9 @@ async function handleSubmit(modelId: string, rawBody: string | undefined, surfac
352
352
  async function handleStatus(modelId: string, requestId: string, includeLogs: boolean, req: FalRequest): Promise<FalResponse> {
353
353
  const existing = getRow('request', requestId, req.root);
354
354
  if (!existing) return detailNotFound('Request not found.');
355
- await progressOnce(requestId, req);
355
+ // ADVANCE ON POLL — one step per status check. A read-only twin (a World's read token) answers the
356
+ // request's current status and never advances it: the app's next poll sees what it would have.
357
+ if (!req.readOnly) await progressOnce(requestId, req);
356
358
  const row = getRow('request', requestId, req.root)!;
357
359
  return { status: 200, body: statusView(row, modelId, requestId, includeLogs) };
358
360
  }
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
42
42
  // this module stays browser-bundle safe.
43
- import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow } from '@volter/world-core';
43
+ import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow, runAsVendorMove } from '@volter/world-core';
44
44
 
45
45
  type NodeCrypto = typeof import('node:crypto');
46
46
  function nodeCrypto(): NodeCrypto {
@@ -96,7 +96,8 @@ function storedSeed(resources: readonly { type: string; id: string }[]): string
96
96
  async function ensureFalSigningSeed(root?: string): Promise<string> {
97
97
  const fast = storedSeed(projectResources(SERVICE, root));
98
98
  if (fast) return fast;
99
- const { value } = await applyTwinWriteAtomic<string>(SERVICE, (resources) => {
99
+ // fal's own signing key: a vendor move, made on first use whoever asks (a read-only request too)
100
+ const { value } = await runAsVendorMove(() => applyTwinWriteAtomic<string>(SERVICE, (resources) => {
100
101
  const existing = storedSeed(resources);
101
102
  if (existing) return { kind: 'skip', value: existing };
102
103
  const seed = nodeCrypto().randomBytes(SEED_BYTES).toString('base64');
@@ -111,7 +112,7 @@ async function ensureFalSigningSeed(root?: string): Promise<string> {
111
112
  actor: { kind: 'system' },
112
113
  },
113
114
  };
114
- }, root);
115
+ }, root));
115
116
  return value;
116
117
  }
117
118