@volter/twin 0.1.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.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/src/actions.d.ts +138 -0
  4. package/dist/src/actions.js +201 -0
  5. package/dist/src/args.d.ts +3 -0
  6. package/dist/src/args.js +12 -0
  7. package/dist/src/cli.d.ts +2 -0
  8. package/dist/src/cli.js +425 -0
  9. package/dist/src/connector.d.ts +106 -0
  10. package/dist/src/connector.js +129 -0
  11. package/dist/src/control-plane.d.ts +21 -0
  12. package/dist/src/control-plane.js +40 -0
  13. package/dist/src/egress.d.ts +93 -0
  14. package/dist/src/egress.js +264 -0
  15. package/dist/src/fork.d.ts +126 -0
  16. package/dist/src/fork.js +206 -0
  17. package/dist/src/index.d.ts +42 -0
  18. package/dist/src/index.js +52 -0
  19. package/dist/src/lease.d.ts +50 -0
  20. package/dist/src/lease.js +80 -0
  21. package/dist/src/packRegistry.d.ts +34 -0
  22. package/dist/src/packRegistry.js +22 -0
  23. package/dist/src/plan.d.ts +97 -0
  24. package/dist/src/plan.js +151 -0
  25. package/dist/src/proxy.d.ts +25 -0
  26. package/dist/src/proxy.js +152 -0
  27. package/dist/src/pushLedger.d.ts +81 -0
  28. package/dist/src/pushLedger.js +130 -0
  29. package/dist/src/queueLifecycle.d.ts +62 -0
  30. package/dist/src/queueLifecycle.js +95 -0
  31. package/dist/src/reconcile.d.ts +58 -0
  32. package/dist/src/reconcile.js +137 -0
  33. package/dist/src/refs.d.ts +29 -0
  34. package/dist/src/refs.js +68 -0
  35. package/dist/src/schemas.d.ts +78 -0
  36. package/dist/src/schemas.js +50 -0
  37. package/dist/src/serve.d.ts +44 -0
  38. package/dist/src/serve.js +93 -0
  39. package/dist/src/shadow.d.ts +77 -0
  40. package/dist/src/shadow.js +138 -0
  41. package/dist/src/status.d.ts +31 -0
  42. package/dist/src/status.js +42 -0
  43. package/dist/src/storage.d.ts +119 -0
  44. package/dist/src/storage.js +535 -0
  45. package/dist/src/sync.d.ts +91 -0
  46. package/dist/src/sync.js +121 -0
  47. package/dist/src/types.d.ts +40 -0
  48. package/dist/src/types.js +1 -0
  49. package/dist/src/validate.d.ts +27 -0
  50. package/dist/src/validate.js +68 -0
  51. package/dist/src/visualizer.d.ts +13 -0
  52. package/dist/src/visualizer.js +133 -0
  53. package/dist/src/worldConfig.d.ts +9 -0
  54. package/dist/src/worldConfig.js +16 -0
  55. package/inject.cjs +429 -0
  56. package/package.json +81 -0
  57. package/src/actions.ts +285 -0
  58. package/src/args.ts +14 -0
  59. package/src/cli.ts +443 -0
  60. package/src/connector.ts +220 -0
  61. package/src/control-plane.ts +66 -0
  62. package/src/egress.ts +355 -0
  63. package/src/fork.ts +256 -0
  64. package/src/index.ts +222 -0
  65. package/src/lease.ts +97 -0
  66. package/src/packRegistry.ts +60 -0
  67. package/src/plan.ts +190 -0
  68. package/src/proxy.ts +180 -0
  69. package/src/pushLedger.ts +189 -0
  70. package/src/queueLifecycle.ts +130 -0
  71. package/src/reconcile.ts +192 -0
  72. package/src/refs.ts +91 -0
  73. package/src/schemas.ts +56 -0
  74. package/src/serve.ts +120 -0
  75. package/src/shadow.ts +192 -0
  76. package/src/status.ts +58 -0
  77. package/src/storage.ts +632 -0
  78. package/src/sync.ts +160 -0
  79. package/src/types.ts +50 -0
  80. package/src/validate.ts +95 -0
  81. package/src/visualizer.ts +142 -0
  82. package/src/worldConfig.ts +26 -0
@@ -0,0 +1,121 @@
1
+ // Bidirectional sync for twins (the twins architecture notes §4).
2
+ //
3
+ // Two directions, both over the kernel (events + egress); the vendor-specific
4
+ // part — the actual network call — is INJECTED by the caller, never bundled.
5
+ // This is the auth-boundary principle (hard-problem #6): the kernel touches no
6
+ // real credentials; the host supplies a fetch fn (pull) or write fn (push) that
7
+ // uses the user's own auth. The twin stays runnable fully offline (inject fakes).
8
+ //
9
+ // pull (real → twin): fold externally-observed resources into the event log
10
+ // as deltas, so twin state converges to what was observed.
11
+ // push (twin → real): enact a reconcile plan's `toPush` by calling the injected
12
+ // real write fn, idempotently, through the egress ledger.
13
+ //
14
+ // pull + reconcile + push compose into the round-trip the architecture calls for:
15
+ // pull(real) → fork → write-in-fork → reconcile(base, fork, freshPull) → push.
16
+ //
17
+ // push is gated exactly like applyPlan (TWIN-53 R-K2): approval is recomputed from
18
+ // the ReconcilePlan's own conflicts/field resolutions (reconcileRequiresApproval),
19
+ // never trusted off a stored bit — a ReconcilePlan carries no such bit at all, so
20
+ // there is nothing to tamper — and a mandatory lease (acquire → push → release)
21
+ // makes push single-writer the same way applyPlan is.
22
+ import { performExternalWrite } from "./egress.js";
23
+ import { acquireLease, releaseLease } from "./lease.js";
24
+ import { buildShadowState, recordObservedDelta } from "./shadow.js";
25
+ import { twinResources } from "./serve.js";
26
+ import { reconcileRequiresApproval } from "./reconcile.js";
27
+ /**
28
+ * Pull: fold a set of externally-observed resources into the twin's event log.
29
+ * The caller has already fetched them from the real vendor (injected I/O); this
30
+ * is the pure fold. Only changed fields produce a delta (recordObservedDelta is
31
+ * a no-op when nothing differs), so re-pulling identical state appends nothing.
32
+ *
33
+ * `redact` (TWIN-45 dev/01 — implemented, not just documented) is an optional
34
+ * transform applied to each resource BEFORE it is folded into the event log, so
35
+ * a sensitive field never lands on disk in the first place — a redact-on-pull
36
+ * hook point, not a redaction policy: this library makes zero default redaction
37
+ * decisions (no built-in "PII field" list, no defaults). The caller supplies the
38
+ * hook deliberately, shaped for their own vendor/fields. See docs/DATA_AT_REST.md
39
+ * for the full data-at-rest story (what's implemented here vs. left as
40
+ * guidance) and for why day-count retention defaults are NOT hardcoded here —
41
+ * that is a policy call for the human owner, not this library.
42
+ */
43
+ export function syncPull(opts) {
44
+ const { service, resources, occurredAt, root } = opts;
45
+ let deltasAppended = 0;
46
+ let unchanged = 0;
47
+ // Rebuild the shadow once; recordObservedDelta appends to the log and the next
48
+ // build reflects it, so process sequentially.
49
+ for (const raw of resources) {
50
+ const res = opts.redact ? opts.redact(raw) : raw;
51
+ const shadow = buildShadowState(service, () => null, root);
52
+ const result = recordObservedDelta(shadow, { service, subject: { type: res.type, id: res.id }, observed: res.fields, occurredAt, ...(opts.actor ? { actor: opts.actor } : {}) }, root);
53
+ if (result.changed)
54
+ deltasAppended += 1;
55
+ else
56
+ unchanged += 1;
57
+ }
58
+ return { observed: resources.length, deltasAppended, unchanged };
59
+ }
60
+ // Stable hash of the intended field change so identical pushes dedupe (replay)
61
+ // while a different change is a new intent.
62
+ function stableStringify(value) {
63
+ if (value === null || typeof value !== 'object')
64
+ return JSON.stringify(value);
65
+ if (Array.isArray(value))
66
+ return `[${value.map(stableStringify).join(',')}]`;
67
+ const keys = Object.keys(value).sort();
68
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
69
+ }
70
+ /**
71
+ * Push: enact a reconcile plan's twin→real changes by calling the injected real
72
+ * write fn, idempotently via the egress ledger. `write` is the ONLY place a real
73
+ * vendor API is touched; in tests/offline it's a fake. Re-pushing the same plan
74
+ * replays (no double-apply) because the egress idempotency key encodes the change.
75
+ *
76
+ * Gated the same way `applyPlan` is (TWIN-53 R-K2): `approve` and the lease fields
77
+ * (`remoteRef`/`holder`/`leaseId`/`acquiredAt`/`expiresAt`) are mandatory options —
78
+ * there is no implicit-approval, no-lease path. Approval is required when
79
+ * `reconcileRequiresApproval(plan)` is true (unresolved conflicts, or a
80
+ * twin-wins push that overwrites a real-side change) and `approve` is not set;
81
+ * refusal happens before the lease is acquired or `write` is ever called — zero
82
+ * side effects. The lease is acquired for `remoteRef` before any push and released
83
+ * (even on error) after.
84
+ */
85
+ export async function syncPush(opts) {
86
+ const { service, plan, write, root } = opts;
87
+ if (reconcileRequiresApproval(plan) && !opts.approve) {
88
+ throw new Error(`sync push for ${service} requires approval (unresolved conflicts, or a twin-wins push overwriting a real-side change); pass approve:true to push`);
89
+ }
90
+ const lease = acquireLease({
91
+ service, provider: opts.remoteRef.provider, remoteRef: opts.remoteRef, planId: `sync-push:${service}`,
92
+ holder: opts.holder, id: opts.leaseId, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt, root,
93
+ });
94
+ const pushed = [];
95
+ try {
96
+ for (const item of plan.toPush) {
97
+ const syncItem = { type: item.type, id: item.id, fields: item.fields };
98
+ const result = await performExternalWrite({
99
+ service,
100
+ operation: `sync.push.${item.type}`,
101
+ provider: service,
102
+ subject: { type: item.type, id: item.id },
103
+ idempotencyKey: `sync-push:${item.id}:${stableStringify(item.fields)}`,
104
+ ...(opts.actor ? { actor: opts.actor } : {}),
105
+ data: { fields: item.fields },
106
+ }, async () => {
107
+ const out = await write(syncItem);
108
+ return { externalId: out.externalId, data: out.data ?? { ...item.fields } };
109
+ }, { root });
110
+ pushed.push({ id: item.id, type: item.type, status: result.status, ...(result.outcome?.externalId ? { externalId: result.outcome.externalId } : {}) });
111
+ }
112
+ }
113
+ finally {
114
+ releaseLease(service, lease.id, { at: opts.acquiredAt, root });
115
+ }
116
+ return { attempted: plan.toPush.length, pushed };
117
+ }
118
+ /** Convenience: the twin's current resources as the reconcile `fork`/state input. */
119
+ export function currentResources(service, root) {
120
+ return twinResources(service, root);
121
+ }
@@ -0,0 +1,40 @@
1
+ import type { z } from 'zod';
2
+ import type { GenericWorldStateSchema, WorldServiceEventSchema } from './schemas.js';
3
+ export type WorldServiceEvent = z.infer<typeof WorldServiceEventSchema>;
4
+ export type GenericWorldState = z.infer<typeof GenericWorldStateSchema>;
5
+ export type AppendEventResult = {
6
+ event: WorldServiceEvent;
7
+ appended: boolean;
8
+ duplicateOf?: string;
9
+ };
10
+ export type QueuedWorldServiceEvent = {
11
+ id: string;
12
+ service: string;
13
+ receivedAt: string;
14
+ source: 'listener' | 'webhook' | 'broker' | 'capture' | 'manual' | 'replay';
15
+ event: WorldServiceEvent;
16
+ };
17
+ export type EnqueueEventResult = {
18
+ queued: QueuedWorldServiceEvent;
19
+ appended: boolean;
20
+ duplicateOf?: string;
21
+ };
22
+ export type CommitQueuedEventsResult = {
23
+ service: string;
24
+ queued: number;
25
+ committed: number;
26
+ skipped: number;
27
+ eventIds: string[];
28
+ };
29
+ export type WorldPaths = {
30
+ root: string;
31
+ service: string;
32
+ dir: string;
33
+ events: string;
34
+ state: string;
35
+ resources: string;
36
+ cursors: string;
37
+ ingests: string;
38
+ eventQueue: string;
39
+ };
40
+ export type WorldReducer<State> = (state: State, event: WorldServiceEvent) => State;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ export type WorldValidationFinding = {
2
+ level: 'error' | 'warning';
3
+ code: string;
4
+ message: string;
5
+ service?: string;
6
+ /** set by the tracker's annotation validator (reuses this finding shape). */
7
+ annotation?: string;
8
+ event?: string;
9
+ details?: Record<string, unknown>;
10
+ };
11
+ export type WorldValidationSummary = {
12
+ status: 'pass' | 'warn' | 'fail';
13
+ errors: number;
14
+ warnings: number;
15
+ };
16
+ export type WorldValidationReport = {
17
+ valid: boolean;
18
+ summary: WorldValidationSummary;
19
+ findings: WorldValidationFinding[];
20
+ };
21
+ export declare function discoverWorldServices(root: string): string[];
22
+ export declare function validateWorldService(root: string, service: string): WorldValidationFinding[];
23
+ export declare function summarizeWorldFindings(findings: WorldValidationFinding[]): WorldValidationReport;
24
+ export declare function validateWorld(options?: {
25
+ root?: string;
26
+ services?: string[];
27
+ }): WorldValidationReport;
@@ -0,0 +1,68 @@
1
+ // World integrity validation — the TWIN's own invariants: egress write intents
2
+ // reconcile (no write left without a recorded result past the grace window). NOTE:
3
+ // annotation integrity (quotes resolve, events annotated, …) moved to
4
+ // @volter/tracker/world-annotations — that's a verification (tracker) concern.
5
+ import { existsSync, readdirSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { listEgressLedger } from "./egress.js";
8
+ import { worldStateRoot } from "./storage.js";
9
+ function stringValue(value) {
10
+ return typeof value === 'string' ? value.trim() : '';
11
+ }
12
+ export function discoverWorldServices(root) {
13
+ const worldRoot = worldStateRoot(root);
14
+ if (!existsSync(worldRoot))
15
+ return [];
16
+ return readdirSync(worldRoot, { withFileTypes: true })
17
+ .filter((entry) => entry.isDirectory() && existsSync(join(worldRoot, entry.name, 'events.jsonl')))
18
+ .map((entry) => entry.name)
19
+ .sort();
20
+ }
21
+ export function validateWorldService(root, service) {
22
+ // Twin integrity = egress reconciliation. (Annotation integrity moved to
23
+ // @volter/tracker/world-annotations.)
24
+ return validateEgressLedger(root, service);
25
+ }
26
+ const UNRECONCILED_INTENT_GRACE_MS = 10 * 60 * 1000;
27
+ function validateEgressLedger(root, service) {
28
+ const findings = [];
29
+ for (const entry of listEgressLedger(service, root)) {
30
+ if (entry.results.length > 0)
31
+ continue;
32
+ const ageMs = Date.now() - Date.parse(entry.intent.occurredAt);
33
+ if (Number.isFinite(ageMs) && ageMs < UNRECONCILED_INTENT_GRACE_MS)
34
+ continue;
35
+ findings.push({
36
+ level: 'warning',
37
+ code: 'write_intent_unreconciled',
38
+ message: 'Egress write intent has no result event — the external write may have happened without being recorded. Verify external state and reconcile before retrying.',
39
+ service,
40
+ event: entry.intent.id,
41
+ details: {
42
+ operation: stringValue(entry.intent.data.operation),
43
+ provider: stringValue(entry.intent.data.provider),
44
+ occurredAt: entry.intent.occurredAt,
45
+ subject: entry.intent.subject,
46
+ },
47
+ });
48
+ }
49
+ return findings;
50
+ }
51
+ export function summarizeWorldFindings(findings) {
52
+ const errors = findings.filter((finding) => finding.level === 'error').length;
53
+ const warnings = findings.filter((finding) => finding.level === 'warning').length;
54
+ return {
55
+ valid: errors === 0,
56
+ summary: {
57
+ status: errors > 0 ? 'fail' : warnings > 0 ? 'warn' : 'pass',
58
+ errors,
59
+ warnings,
60
+ },
61
+ findings,
62
+ };
63
+ }
64
+ export function validateWorld(options = {}) {
65
+ const root = resolve(options.root || process.env.PROJECT_ROOT || process.cwd());
66
+ const services = options.services && options.services.length > 0 ? options.services : discoverWorldServices(root);
67
+ return summarizeWorldFindings(services.flatMap((service) => validateWorldService(root, service)));
68
+ }
@@ -0,0 +1,13 @@
1
+ /** Pure render: the twin's current state → a self-contained HTML page. */
2
+ export declare function renderTwinHtml(service: string, opts?: {
3
+ root?: string;
4
+ }): string;
5
+ /** Serve the visualizer over HTTP (re-rendered per request, so it tracks state). */
6
+ export declare function createVisualizerServer(options: {
7
+ service: string;
8
+ root?: string;
9
+ port?: number;
10
+ }): {
11
+ port: number;
12
+ stop: () => void;
13
+ };
@@ -0,0 +1,133 @@
1
+ // Twin DEBUG INSPECTOR (NOT the mirror UI / scorecard R13).
2
+ //
3
+ // This renders a generic internal status page — resource/event/egress/divergence
4
+ // tables read directly from kernel state. It's a developer inspector, useful for
5
+ // any twin, but it is explicitly NOT the per-vendor "mirror UI" R13 calls for (a
6
+ // faithful clone of the vendor's product interface — a Slack-like chat UI, a
7
+ // Linear-like board — consuming the twin's API). The real mirror UI is a separate,
8
+ // per-vendor frontend. Keeping this as the honest, narrow thing it is.
9
+ // `renderTwinHtml` is pure (state → HTML string) so it's testable without a server.
10
+ import { listEgressLedger } from "./egress.js";
11
+ import { isFork, forkDivergence } from "./fork.js";
12
+ import { twinResources } from "./serve.js";
13
+ import { listEvents } from "./storage.js";
14
+ import { listActions } from "./actions.js";
15
+ function esc(value) {
16
+ return String(value ?? '')
17
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
+ }
19
+ function fieldCell(value) {
20
+ if (value == null)
21
+ return '<span class="muted">—</span>';
22
+ if (Array.isArray(value))
23
+ return value.map((v) => `<span class="tag">${esc(v)}</span>`).join(' ');
24
+ return esc(value);
25
+ }
26
+ /** Pure render: the twin's current state → a self-contained HTML page. */
27
+ export function renderTwinHtml(service, opts = {}) {
28
+ const { root } = opts;
29
+ const resources = twinResources(service, root).sort((a, b) => (a.id < b.id ? -1 : 1));
30
+ const events = listEvents(service, root);
31
+ const ledger = listEgressLedger(service, root);
32
+ const actions = listActions(service, root); // R18: local actions (separate from observed events)
33
+ const fork = isFork(service, root ?? '') ? forkDivergence(service, root ?? '') : null;
34
+ // Union of data field names across resources (excluding identity/meta).
35
+ const fieldNames = [...new Set(resources.flatMap((r) => Object.keys(r)))].filter((k) => !['id', 'type', 'updatedAt'].includes(k)).sort();
36
+ const resourceRows = resources.map((r) => `
37
+ <tr>
38
+ <td class="mono">${esc(r.id)}</td>
39
+ ${fieldNames.map((f) => `<td>${fieldCell(r[f])}</td>`).join('')}
40
+ <td class="mono muted">${esc(r.updatedAt)}</td>
41
+ </tr>`).join('');
42
+ const timelineRows = events.slice(-25).reverse().map((e) => `
43
+ <tr>
44
+ <td class="mono muted">${esc(e.occurredAt)}</td>
45
+ <td class="mono">${esc(e.type)}</td>
46
+ <td class="mono">${esc(e.subject.type)}:${esc(e.subject.id)}</td>
47
+ <td class="mono muted">${esc(e.origin ?? '')}</td>
48
+ </tr>`).join('');
49
+ const ledgerRows = ledger.map((entry) => {
50
+ const ok = entry.results.some((r) => r.data?.status === 'success');
51
+ const op = entry.intent.data && typeof entry.intent.data === 'object' ? entry.intent.data.operation : '';
52
+ return `
53
+ <tr>
54
+ <td class="mono">${esc(entry.intent.subject.type)}:${esc(entry.intent.subject.id)}</td>
55
+ <td class="mono">${esc(op || entry.intent.type)}</td>
56
+ <td>${entry.results.length}</td>
57
+ <td>${ok ? '<span class="ok">success</span>' : '<span class="warn">unreconciled</span>'}</td>
58
+ </tr>`;
59
+ }).join('');
60
+ const actionRows = actions.slice(-25).reverse().map((a) => `
61
+ <tr>
62
+ <td class="mono muted">${esc(a.occurredAt)}</td>
63
+ <td class="mono">${esc(a.op)}</td>
64
+ <td class="mono">${esc(a.subject.type)}:${esc(a.subject.id)}</td>
65
+ <td class="mono muted">${esc(a.fields ? Object.keys(a.fields).join(',') : (a.revertsActionId ?? a.confirmsActionId ?? ''))}</td>
66
+ </tr>`).join('');
67
+ const forkSection = fork ? `
68
+ <section>
69
+ <h2>Fork divergence <span class="muted">(${fork.changed.length} changed · ${fork.created.length} created)</span></h2>
70
+ ${fork.changed.length + fork.created.length === 0 ? '<p class="muted">No divergence from base.</p>' : ''}
71
+ ${fork.changed.map((c) => `<div class="diff"><span class="mono">${esc(c.id)}</span>: ${Object.entries(c.changed).map(([f, d]) => `${esc(f)} <span class="muted">${esc(JSON.stringify(d.before))}</span> → <span class="ok">${esc(JSON.stringify(d.after))}</span>`).join(', ')}</div>`).join('')}
72
+ ${fork.created.map((c) => `<div class="diff"><span class="ok">+ ${esc(c.id)}</span> (created in fork)</div>`).join('')}
73
+ </section>` : '';
74
+ return `<!doctype html>
75
+ <html lang="en"><head><meta charset="utf-8"><title>${esc(service)} twin</title>
76
+ <style>
77
+ :root { color-scheme: light dark; }
78
+ body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 2rem; max-width: 1100px; }
79
+ h1 { margin: 0 0 .25rem; } h2 { margin: 2rem 0 .5rem; font-size: 1.05rem; }
80
+ .badges span { display: inline-block; padding: .1rem .5rem; border-radius: 1rem; background: #8884; margin-right: .4rem; font-size: .8rem; }
81
+ table { border-collapse: collapse; width: 100%; }
82
+ th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #8883; vertical-align: top; }
83
+ th { font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; opacity: .7; }
84
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
85
+ .muted { opacity: .55; } .ok { color: #1a7f37; } .warn { color: #b35900; }
86
+ .tag { background: #8884; border-radius: .4rem; padding: 0 .35rem; font-size: .8em; }
87
+ .diff { padding: .2rem 0; font-size: .9em; }
88
+ section { margin-bottom: .5rem; }
89
+ </style></head>
90
+ <body>
91
+ <h1>${esc(service)} twin</h1>
92
+ <div class="badges">
93
+ <span>twin: ${esc(service)}</span>
94
+ <span>${resources.length} resources</span>
95
+ <span>${events.length} events</span>
96
+ <span>${ledger.length} egress</span>
97
+ ${fork ? '<span>fork</span>' : ''}
98
+ </div>
99
+ ${forkSection}
100
+ <section>
101
+ <h2>Resources</h2>
102
+ <table><thead><tr><th>id</th>${fieldNames.map((f) => `<th>${esc(f)}</th>`).join('')}<th>updatedAt</th></tr></thead>
103
+ <tbody>${resourceRows || '<tr><td class="muted">no resources</td></tr>'}</tbody></table>
104
+ </section>
105
+ <section>
106
+ <h2>Local actions <span class="muted">(${actions.length} — simulator/fork writes, not yet pushed)</span></h2>
107
+ <table><thead><tr><th>occurredAt</th><th>op</th><th>subject</th><th>fields</th></tr></thead>
108
+ <tbody>${actionRows || '<tr><td class="muted">no local actions</td></tr>'}</tbody></table>
109
+ </section>
110
+ <section>
111
+ <h2>Egress ledger <span class="muted">(pushes to the real vendor)</span></h2>
112
+ <table><thead><tr><th>subject</th><th>operation</th><th>results</th><th>status</th></tr></thead>
113
+ <tbody>${ledgerRows || '<tr><td class="muted">no egress</td></tr>'}</tbody></table>
114
+ </section>
115
+ <section>
116
+ <h2>Observed event timeline <span class="muted">(latest 25 of ${events.length})</span></h2>
117
+ <table><thead><tr><th>occurredAt</th><th>type</th><th>subject</th><th>origin</th></tr></thead>
118
+ <tbody>${timelineRows || '<tr><td class="muted">no events</td></tr>'}</tbody></table>
119
+ </section>
120
+ </body></html>`;
121
+ }
122
+ /** Serve the visualizer over HTTP (re-rendered per request, so it tracks state). */
123
+ export function createVisualizerServer(options) {
124
+ const server = Bun.serve({
125
+ port: options.port ?? 0,
126
+ idleTimeout: 60,
127
+ fetch() {
128
+ const html = renderTwinHtml(options.service, { ...(options.root !== undefined ? { root: options.root } : {}) });
129
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
130
+ },
131
+ });
132
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
133
+ }
@@ -0,0 +1,9 @@
1
+ export type WorldServiceConfig = {
2
+ provider?: string;
3
+ tool?: string;
4
+ pollLimit?: number;
5
+ } & Record<string, unknown>;
6
+ export type WorldConfig = {
7
+ services: Record<string, WorldServiceConfig>;
8
+ } & Record<string, unknown>;
9
+ export declare function loadWorldConfig(root?: string): WorldConfig;
@@ -0,0 +1,16 @@
1
+ // World service config — generic per-service settings for the twin runtime
2
+ // (`.volter/world/config.json`). Kept deliberately small + open: the index
3
+ // signature lets a service carry arbitrary extra config that downstream tools
4
+ // define. (The tracker's annotation adapter defines + reads `annotationPolicy` and
5
+ // `browseUrlTemplate` on top of this — those are verification concerns, not the
6
+ // twin's, so they are NOT typed here.)
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { worldStateRoot } from "./storage.js";
10
+ export function loadWorldConfig(root) {
11
+ const path = join(worldStateRoot(root), 'config.json');
12
+ if (!existsSync(path))
13
+ return { services: {} };
14
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
15
+ return { ...parsed, services: parsed.services ?? {} };
16
+ }