@volter/twin 0.1.0 → 0.1.1

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 (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
@@ -1,201 +0,0 @@
1
- // Transaction/action log (scorecard R18) — the semantic correction that separates
2
- // OBSERVED facts from LOCAL transaction commits.
3
- //
4
- // The observed-event log (`events.jsonl`) holds only what was observed upstream
5
- // (connector pulls) or confirmed after a push. Local simulator/fork writes do NOT
6
- // go there — they are transaction commits in `actions.jsonl`, projected OVER the
7
- // observed mirror to produce the twin's current state. Undo is a `revert` commit;
8
- // a push that succeeds appends a `confirm` commit mapping the local transaction
9
- // to the observed event it produced, which SUPPRESSES the local projection (the
10
- // fact is now carried by the observed log, so it must not be double-counted).
11
- //
12
- // Projection = observed mirror, then apply each `set` transaction in order,
13
- // skipping any transaction that was reverted or confirmed.
14
- import { randomUUID } from 'node:crypto';
15
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
16
- import { dirname, join } from 'node:path';
17
- import { buildShadowState } from "./shadow.js";
18
- import { appendDurable, appendEvent, twinLog, withFileLock, worldPaths } from "./storage.js";
19
- export class TwinActionPreconditionError extends Error {
20
- actionId;
21
- failed;
22
- constructor(actionId, failed) {
23
- super(`Twin transaction precondition failed for ${actionId}: ${failed.subject.type}:${failed.subject.id}.${failed.field} ${failed.op}`);
24
- this.actionId = actionId;
25
- this.failed = failed;
26
- this.name = 'TwinActionPreconditionError';
27
- }
28
- }
29
- function actionsPath(service, root) {
30
- return join(dirname(worldPaths(service, root).events), 'actions.jsonl');
31
- }
32
- const actionsLock = (service, root) => `${actionsPath(service, root)}.lock`;
33
- /** Every appended action carries a correlationId — generate one when the caller
34
- * hasn't supplied it, so downstream joins (push ledger, logs) always have an id
35
- * to key on (D3). Preserves a caller-supplied id (e.g. propagated from an HTTP
36
- * request id) so a whole call chain can share one. */
37
- function withCorrelationId(action) {
38
- return action.correlationId ? action : { ...action, correlationId: randomUUID() };
39
- }
40
- function appendActionRaw(action, root) {
41
- const path = actionsPath(action.service, root);
42
- mkdirSync(dirname(path), { recursive: true });
43
- appendDurable(path, `${JSON.stringify(action)}\n`);
44
- twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
45
- }
46
- export function appendAction(action, root) {
47
- const stamped = withCorrelationId(action);
48
- assertPreconditions(stamped, root);
49
- // Cross-process line-atomic append (same lock the dedup path uses, so writes from a
50
- // backend + a browser proxy sharing one twin can't interleave or race a check-then-append).
51
- withFileLock(actionsLock(stamped.service, root), () => appendActionRaw(stamped, root));
52
- return stamped;
53
- }
54
- /** Append `action` only if no action with the same id already exists — the whole
55
- * check-then-append runs under the actions lock, so it's atomic across processes (two
56
- * concurrent identical writes converge to ONE action; distinct writes both land). */
57
- export function appendActionIfAbsent(action, root) {
58
- const stamped = withCorrelationId(action);
59
- assertPreconditions(stamped, root);
60
- return withFileLock(actionsLock(stamped.service, root), () => {
61
- const exists = listActions(stamped.service, root).some((a) => a.id === stamped.id);
62
- if (!exists)
63
- appendActionRaw(stamped, root);
64
- return { action: stamped, appended: !exists };
65
- });
66
- }
67
- export function appendTransactionCommit(commit, root) {
68
- return appendAction(commit, root);
69
- }
70
- export function listActions(service, root) {
71
- const path = actionsPath(service, root);
72
- if (!existsSync(path))
73
- return [];
74
- return readFileSync(path, 'utf8')
75
- .split('\n')
76
- .filter((line) => line.trim())
77
- .map((line) => JSON.parse(line));
78
- }
79
- const META = new Set(['id', 'type', 'updatedAt']);
80
- function projectedField(resource, field) {
81
- if (!resource)
82
- return undefined;
83
- if (field === 'id' || field === 'type' || field === 'updatedAt')
84
- return resource[field];
85
- return resource[field];
86
- }
87
- function assertPreconditions(action, root) {
88
- if (!action.preconditions?.length)
89
- return;
90
- const resources = projectResources(action.service, root);
91
- for (const precondition of action.preconditions) {
92
- const resource = resources.find((r) => r.type === precondition.subject.type && r.id === precondition.subject.id);
93
- const actual = projectedField(resource, precondition.field);
94
- const passes = precondition.op === 'exists' ? actual !== undefined
95
- : precondition.op === 'not_exists' ? actual === undefined
96
- : precondition.op === 'eq' || precondition.op === 'version_eq' ? Object.is(actual, precondition.value)
97
- : precondition.op === 'neq' ? !Object.is(actual, precondition.value)
98
- : false;
99
- if (!passes)
100
- throw new TwinActionPreconditionError(action.id, precondition);
101
- }
102
- }
103
- /**
104
- * Project the action log over the observed mirror → current twin resources.
105
- * `set` actions overlay fields (creating subjects that don't exist in the mirror);
106
- * reverted and confirmed actions are skipped (confirmed facts come from the
107
- * observed log instead, so they are not projected twice).
108
- */
109
- export function projectResources(service, root) {
110
- // base: observed mirror (events.jsonl only)
111
- const mirror = buildShadowState(service, () => null, root);
112
- const subjects = new Map();
113
- for (const s of Object.values(mirror.subjects)) {
114
- subjects.set(`${s.subject.type}:${s.subject.id}`, { type: s.subject.type, id: s.subject.id, updatedAt: s.updatedAt, fields: { ...s.fields } });
115
- }
116
- const actions = listActions(service, root);
117
- const reverted = new Set();
118
- const confirmed = new Set();
119
- for (const a of actions) {
120
- if (a.op === 'revert' && a.revertsActionId)
121
- reverted.add(a.revertsActionId);
122
- if (a.op === 'confirm' && a.confirmsActionId)
123
- confirmed.add(a.confirmsActionId);
124
- }
125
- const overlay = (type, id, fields, at) => {
126
- const key = `${type}:${id}`;
127
- const existing = subjects.get(key) ?? { type, id, updatedAt: at, fields: {} };
128
- subjects.set(key, { ...existing, updatedAt: at, fields: { ...existing.fields, ...fields } });
129
- };
130
- for (const a of actions) {
131
- if (a.op !== 'set')
132
- continue;
133
- if (reverted.has(a.id) || confirmed.has(a.id))
134
- continue; // suppressed
135
- // single-resource shorthand: overlay `fields` on the action's own subject.
136
- if (a.fields)
137
- overlay(a.subject.type, a.subject.id, a.fields, a.occurredAt);
138
- // richer multi-resource transaction projection (applied in order; deletes remove).
139
- if (a.projection) {
140
- for (const c of a.projection.creates ?? [])
141
- overlay(c.type, c.id, c.fields, a.occurredAt);
142
- for (const u of a.projection.updates ?? [])
143
- overlay(u.type, u.id, u.fields, a.occurredAt);
144
- for (const d of a.projection.deletes ?? [])
145
- subjects.delete(`${d.type}:${d.id}`);
146
- }
147
- }
148
- return [...subjects.values()].map((s) => {
149
- const out = { id: s.id, type: s.type, updatedAt: s.updatedAt };
150
- for (const [k, v] of Object.entries(s.fields))
151
- if (!META.has(k))
152
- out[k] = v;
153
- return out;
154
- });
155
- }
156
- /**
157
- * Confirm a local action after it was pushed to the real vendor (R18): record the
158
- * confirmed fields as an OBSERVED event (origin 'external' — it's now real) and
159
- * append a `confirm` action mapping the local action → that observed event id.
160
- * Projection then drops the local action (the fact lives in the observed log), so
161
- * the change is counted exactly once. Returns the observed event id.
162
- */
163
- export function confirmAction(opts) {
164
- const observedEventId = `confirmed:${opts.service}:${opts.subject.type}:${opts.subject.id}:${opts.actionId}`;
165
- appendEvent({
166
- id: observedEventId,
167
- service: opts.service,
168
- type: `${opts.service}.${opts.subject.type}.delta`,
169
- schemaVersion: 1,
170
- idempotencyKey: observedEventId,
171
- occurredAt: opts.occurredAt,
172
- observedAt: opts.occurredAt,
173
- origin: 'external', // confirmed by the real vendor → an observed fact
174
- subject: opts.subject,
175
- data: { changed: Object.fromEntries(Object.entries(opts.fields).map(([k, v]) => [k, { after: v }])) },
176
- }, opts.root);
177
- appendAction({ id: `confirm:${opts.actionId}`, service: opts.service, op: 'confirm', subject: opts.subject, occurredAt: opts.occurredAt, confirmsActionId: opts.actionId, observedEventId }, opts.root);
178
- return { observedEventId };
179
- }
180
- /** Local pending actions (set, not reverted, not yet confirmed) — the divergence from the mirror. */
181
- export function pendingActions(service, root) {
182
- const actions = listActions(service, root);
183
- const reverted = new Set(actions.filter((a) => a.op === 'revert').map((a) => a.revertsActionId));
184
- const confirmed = new Set(actions.filter((a) => a.op === 'confirm').map((a) => a.confirmsActionId));
185
- return actions.filter((a) => a.op === 'set' && !reverted.has(a.id) && !confirmed.has(a.id));
186
- }
187
- /**
188
- * Deliveries (webhooks/events/notifications) the active transactions want fired —
189
- * the `projection.emits` of pending `set` actions, in order. A twin's event layer
190
- * reads these to know what to deliver; projecting state ignores emits (side effects).
191
- */
192
- export function pendingEmits(service, root) {
193
- const out = [];
194
- for (const a of pendingActions(service, root)) {
195
- for (const d of a.projection?.emits ?? [])
196
- out.push({ actionId: a.id, delivery: d });
197
- }
198
- return out;
199
- }
200
- export const listTransactionCommits = listActions;
201
- export const pendingTransactionCommits = pendingActions;
@@ -1,3 +0,0 @@
1
- /** Value of `--name <value>`; fallback when the flag is absent or has no value. */
2
- export declare function optionValue(args: string[], name: string, fallback?: string): string;
3
- export declare function hasFlag(args: string[], name: string): boolean;
package/dist/src/args.js DELETED
@@ -1,12 +0,0 @@
1
- // Shared CLI argument helpers for world scripts and tools. Scripts kept
2
- // growing private copies of these (5 in the sync subsystem alone before this
3
- // file); a parsing fix that lands in one copy and not the others corrupts
4
- // flag handling silently.
5
- /** Value of `--name <value>`; fallback when the flag is absent or has no value. */
6
- export function optionValue(args, name, fallback = '') {
7
- const index = args.indexOf(name);
8
- return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback;
9
- }
10
- export function hasFlag(args, name) {
11
- return args.includes(name);
12
- }
package/dist/src/cli.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- export {};
package/dist/src/cli.js DELETED
@@ -1,425 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { readFileSync } from 'node:fs';
3
- import { hasFlag, optionValue } from "./args.js";
4
- import { appendEvent, listEgressLedger, listEvents, listUnreconciledWriteIntents, loadState, performExternalWrite, rebuildGenericState, validateWorld, worldPaths, createTwinServer, resolveTwinRead, forkTwin, forkDivergence, auditForkNoRealWrites, createVisualizerServer, renderTwinHtml, scrubService, scrubWorld, syncPull, reconcile, worldStatus, formatStatus, listRemoteRefs, pendingActions, pendingConflicts, createTwinProxy, getPack, } from "./index.js";
5
- import { readForkMeta } from "./fork.js";
6
- import { twinResources } from "./serve.js";
7
- function jsonField(stdout, path) {
8
- let parsed;
9
- try {
10
- parsed = JSON.parse(stdout);
11
- }
12
- catch {
13
- throw new Error('world egress write: command output is not JSON; omit --id-field to use raw stdout');
14
- }
15
- // Comma-separated paths compose an id from multiple fields, joined with ':'
16
- // (e.g. --id-field channel,ts → "C123:1718000.42").
17
- const parts = path.split(',').map((segmentPath) => {
18
- let value = parsed;
19
- for (const segment of segmentPath.trim().split('.')) {
20
- if (!value || typeof value !== 'object' || Array.isArray(value))
21
- return '';
22
- value = value[segment];
23
- }
24
- return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
25
- });
26
- return parts.every(Boolean) ? parts.join(':') : '';
27
- }
28
- function readJsonArg(args) {
29
- const file = optionValue(args, '--file');
30
- if (file)
31
- return JSON.parse(readFileSync(file, 'utf8'));
32
- const json = optionValue(args, '--json');
33
- if (json)
34
- return JSON.parse(json);
35
- throw new Error('Expected --file <path> or --json <json>');
36
- }
37
- function printHelp() {
38
- process.stdout.write(`Usage: world <resource> <action> [args...]
39
-
40
- Resources:
41
- events append, list
42
- state get, rebuild
43
- paths show
44
- egress write, ledger, unreconciled
45
- validate (egress reconciliation; annotations are a tracker concern now)
46
- status <service> plan <service> refs <service>
47
- scrub <service> | --all [--force] [--root <path>] delete pulled data at rest
48
-
49
- Examples:
50
- world events append --file /tmp/event.json
51
- world events list chat --json
52
- world state rebuild chat
53
- world validate [--root <path>] [--services chat,github]
54
- world egress write chat --operation message.send --provider slack \\
55
- --subject-type channel --subject-id dev --key case:ENG-1:approval-request \\
56
- --id-field id -- bash tools/external/slack/slack message send dev --user otto --text "..." --json
57
- world egress unreconciled chat
58
- world scrub chat # delete that service pulled event log + state (refuses on foreign contents)
59
- world scrub --all --force # delete the whole world state dir, even if it looks unfamiliar
60
- See docs/DATA_AT_REST.md for the full data-at-rest story (redaction, retention, purge).
61
- `);
62
- }
63
- function print(value, asJson) {
64
- if (asJson)
65
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
66
- else
67
- process.stdout.write(`${JSON.stringify(value)}\n`);
68
- }
69
- async function main() {
70
- const args = process.argv.slice(2);
71
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
72
- printHelp();
73
- return;
74
- }
75
- const [resource, action, ...rest] = args;
76
- const asJson = hasFlag(rest, '--json');
77
- if (resource === 'events' && action === 'append') {
78
- const event = readJsonArg(rest);
79
- print(appendEvent(event), asJson);
80
- return;
81
- }
82
- if (resource === 'events' && action === 'list') {
83
- const service = rest[0];
84
- if (!service)
85
- throw new Error('world events list: missing service');
86
- const events = listEvents(service);
87
- if (asJson)
88
- print(events, true);
89
- else {
90
- for (const event of events) {
91
- process.stdout.write(`${event.id}\t${event.type}\t${event.subject.type}:${event.subject.id}\n`);
92
- }
93
- }
94
- return;
95
- }
96
- if (resource === 'state' && action === 'get') {
97
- const service = rest[0];
98
- if (!service)
99
- throw new Error('world state get: missing service');
100
- print(loadState(service), true);
101
- return;
102
- }
103
- if (resource === 'state' && action === 'rebuild') {
104
- const service = rest[0];
105
- if (!service)
106
- throw new Error('world state rebuild: missing service');
107
- print(rebuildGenericState(service), true);
108
- return;
109
- }
110
- // Operator surface (R20): world status / plan / refs.
111
- if (resource === 'status') {
112
- const service = action;
113
- if (!service)
114
- throw new Error('world status: missing service (e.g. `world status linear`)');
115
- const provider = optionValue(rest, '--provider') || undefined;
116
- const forkId = optionValue(rest, '--fork') || undefined;
117
- const status = worldStatus(service, { ...(provider ? { provider } : {}), ...(forkId ? { forkId } : {}) });
118
- if (asJson)
119
- print(status, true);
120
- else
121
- process.stdout.write(`${formatStatus(status)}\n`);
122
- return;
123
- }
124
- if (resource === 'plan') {
125
- // Kernel-level (transaction) plan: the pending local commits + any conflicts.
126
- // The provider-call mapping is a vendor-pack concern (see buildApplyPlan).
127
- const service = action;
128
- if (!service)
129
- throw new Error('world plan: missing service');
130
- const transactions = pendingActions(service).map((a) => ({ id: a.id, operation: a.operation, subject: a.subject }));
131
- const conflicts = pendingConflicts(service);
132
- print({ service, transactions, conflicts, requiresApproval: conflicts.length > 0 }, true);
133
- return;
134
- }
135
- if (resource === 'refs') {
136
- const service = action;
137
- if (!service)
138
- throw new Error('world refs: missing service');
139
- print(listRemoteRefs(service), true);
140
- return;
141
- }
142
- // State INSPECTION (not the vendor API): a generic read server over a twin's mirrored
143
- // state — `volter-twin inspect <service>` answers generic `/<type>/<id>` reads for
144
- // scripting/debugging, and `--once <path>` resolves a single read and prints it.
145
- // This is NOT the vendor SDK surface — point your real SDK at `world-<vendor> serve`.
146
- if (resource === 'inspect') {
147
- const service = action; // `volter-twin inspect <service> ...`
148
- if (!service)
149
- throw new Error('volter-twin inspect: missing service (e.g. `volter-twin inspect linear`). NOTE: this serves generic state reads, not the vendor API — use `world-<vendor> serve` for that.');
150
- const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
151
- const port = Number(optionValue(rest, '--port', '0')) || undefined;
152
- const once = optionValue(rest, '--once');
153
- if (once) {
154
- print(resolveTwinRead(service, once), true);
155
- return;
156
- }
157
- const server = createTwinServer({ service, readOnly, ...(port ? { port } : {}) });
158
- process.stdout.write(`state inspection for ${service}${readOnly ? ' (read-only)' : ''} at http://127.0.0.1:${server.port} (generic reads — NOT the vendor API)\n`);
159
- await new Promise(() => { }); // serve until killed
160
- }
161
- // Browser zero-edit injection: a dev proxy in front of your app that forwards
162
- // the browser's vendor SDK calls to a twin (so browser + backend share one twin).
163
- // The proxy needs each vendor's browser routing (api path + loader host). It reads that
164
- // from the pack's TwinPack.browserRouting when the pack is registered; otherwise pass it
165
- // explicitly (the kernel itself knows nothing vendor-specific):
166
- // volter-twin proxy --target http://localhost:3000 \
167
- // --map stripe=http://127.0.0.1:12111 --route stripe=/v1/ --loader-host stripe=https://api.stripe.com
168
- if (resource === 'proxy') {
169
- const target = optionValue(rest, '--target');
170
- if (!target)
171
- throw new Error("world proxy: --target <app origin> is required (e.g. --target http://localhost:3000)");
172
- // Collect repeated --map / --route / --loader-host vendor=value flags.
173
- const collect = (flag) => {
174
- const out = {};
175
- for (let i = 0; i < rest.length; i += 1) {
176
- if (rest[i] === flag && rest[i + 1]) {
177
- const eq = rest[i + 1].indexOf('=');
178
- if (eq > 0)
179
- out[rest[i + 1].slice(0, eq).trim()] = rest[i + 1].slice(eq + 1).trim();
180
- }
181
- }
182
- return out;
183
- };
184
- const origins = collect('--map');
185
- const routeFlags = collect('--route');
186
- const loaderFlags = collect('--loader-host');
187
- if (Object.keys(origins).length === 0)
188
- throw new Error('world proxy: at least one --map <vendor>=<twin-url> is required');
189
- const map = {};
190
- for (const [vendor, origin] of Object.entries(origins)) {
191
- const reg = getPack(vendor)?.browserRouting; // vendor knowledge lives in the pack, not the kernel
192
- const apiPathPrefix = routeFlags[vendor] ?? reg?.apiPathPrefix;
193
- const loaderHost = loaderFlags[vendor] ?? reg?.loaderHost;
194
- if (!apiPathPrefix)
195
- throw new Error(`world proxy: don't know ${vendor}'s browser API path. Pass --route ${vendor}=/<prefix> ` +
196
- `(and optionally --loader-host ${vendor}=https://<host>), or register the ${vendor} pack ` +
197
- `so its TwinPack.browserRouting is used.`);
198
- map[vendor] = { origin, apiPathPrefix, ...(loaderHost ? { loaderHost } : {}) };
199
- }
200
- const port = Number(optionValue(rest, '--port', '0')) || undefined;
201
- const proxy = createTwinProxy({ target, map, ...(port ? { port } : {}) });
202
- const pairs = Object.entries(map).map(([v, r]) => `${v}(${r.apiPathPrefix})→${r.origin}`).join(', ');
203
- process.stdout.write(`twin proxy on http://127.0.0.1:${proxy.port} → ${target} (forwarding ${pairs})\n`);
204
- await new Promise(() => { }); // serve until killed
205
- }
206
- // NOTE: `world annotations` moved to the tracker — annotations are a tracker
207
- // (verification) concern. Use @volter/tracker/world-annotations.
208
- if (resource === 'egress' && action === 'write') {
209
- const service = rest[0];
210
- if (!service)
211
- throw new Error('world egress write: missing service');
212
- const splitIndex = rest.indexOf('--');
213
- if (splitIndex < 0 || splitIndex === rest.length - 1) {
214
- throw new Error('world egress write: provide the provider command after --');
215
- }
216
- const flags = rest.slice(0, splitIndex);
217
- const command = rest.slice(splitIndex + 1);
218
- const operation = optionValue(flags, '--operation');
219
- const provider = optionValue(flags, '--provider');
220
- const subjectType = optionValue(flags, '--subject-type');
221
- const subjectId = optionValue(flags, '--subject-id');
222
- const idempotencyKey = optionValue(flags, '--key');
223
- if (!operation || !provider || !subjectType || !subjectId || !idempotencyKey) {
224
- throw new Error('world egress write: --operation, --provider, --subject-type, --subject-id, and --key are required');
225
- }
226
- const idField = optionValue(flags, '--id-field');
227
- // For operations whose provider output carries no id (e.g. reaction add),
228
- // the caller supplies the deterministic external id up front.
229
- const explicitExternalId = optionValue(flags, '--external-id');
230
- const urlField = optionValue(flags, '--url-field');
231
- const dataJson = optionValue(flags, '--data');
232
- const onUnreconciled = optionValue(flags, '--unreconciled') === 'retry' ? 'retry' : 'fail';
233
- // --passthrough makes the wrapper invisible to callers: the wrapped
234
- // command's stdout is reproduced verbatim and the egress record goes to
235
- // stderr. Replays reproduce the recorded stdout.
236
- const passthrough = hasFlag(flags, '--passthrough');
237
- // On a live write, passthrough must reproduce the wrapped command's
238
- // stdout VERBATIM; the recorded copy is capped (replays of very large
239
- // outputs come back truncated, which the egress note on stderr flags).
240
- let liveStdout = null;
241
- const result = await performExternalWrite({
242
- service,
243
- operation,
244
- provider,
245
- subject: { type: subjectType, id: subjectId },
246
- idempotencyKey,
247
- ...(dataJson ? { data: JSON.parse(dataJson) } : {}),
248
- }, async () => {
249
- const proc = Bun.spawnSync(command, { stdout: 'pipe', stderr: 'pipe' });
250
- const stdout = proc.stdout.toString();
251
- const stderr = proc.stderr.toString();
252
- if (proc.exitCode !== 0) {
253
- throw new Error(`${command[0]} exited with code ${proc.exitCode}: ${stderr.trim() || stdout.trim()}`);
254
- }
255
- const externalId = explicitExternalId
256
- || (idField ? jsonField(stdout, idField) : stdout.trim().split('\n').at(-1)?.trim() ?? '');
257
- if (!externalId)
258
- throw new Error(`world egress write: could not extract external id from command output${idField ? ` (field ${idField})` : ''}`);
259
- const url = urlField ? jsonField(stdout, urlField) : '';
260
- liveStdout = stdout;
261
- return {
262
- externalId,
263
- ...(url ? { url } : {}),
264
- data: { stdout: stdout.trim().slice(0, 65536), stdoutTruncated: stdout.trim().length > 65536 },
265
- };
266
- }, { onUnreconciled });
267
- if (passthrough) {
268
- const stdout = liveStdout ?? (typeof result.outcome.data?.stdout === 'string' ? result.outcome.data.stdout : '');
269
- if (stdout)
270
- process.stdout.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`);
271
- process.stderr.write(`${JSON.stringify({ egress: { status: result.status, intentEventId: result.intentEventId, externalId: result.outcome.externalId, ...(result.outcome.data?.stdoutTruncated ? { replayStdoutTruncated: true } : {}) } })}\n`);
272
- }
273
- else {
274
- print(result, true);
275
- }
276
- return;
277
- }
278
- if (resource === 'egress' && action === 'ledger') {
279
- const service = rest[0];
280
- if (!service)
281
- throw new Error('world egress ledger: missing service');
282
- print(listEgressLedger(service), true);
283
- return;
284
- }
285
- if (resource === 'egress' && action === 'unreconciled') {
286
- const service = rest[0];
287
- if (!service)
288
- throw new Error('world egress unreconciled: missing service');
289
- const intents = listUnreconciledWriteIntents(service);
290
- print(intents, true);
291
- if (intents.length > 0)
292
- process.exitCode = 2;
293
- return;
294
- }
295
- if (resource === 'validate') {
296
- const validateArgs = [action, ...rest].filter((arg) => typeof arg === 'string');
297
- const rootOption = optionValue(validateArgs, '--root');
298
- const servicesOption = optionValue(validateArgs, '--services');
299
- const report = validateWorld({
300
- ...(rootOption ? { root: rootOption } : {}),
301
- ...(servicesOption ? { services: servicesOption.split(',').map((item) => item.trim()).filter(Boolean) } : {}),
302
- });
303
- print(report, true);
304
- if (!report.valid)
305
- process.exitCode = 1;
306
- return;
307
- }
308
- if (resource === 'paths' && action === 'show') {
309
- const service = rest[0];
310
- if (!service)
311
- throw new Error('world paths show: missing service');
312
- print(worldPaths(service), true);
313
- return;
314
- }
315
- // Scrub: delete pulled data at rest (TWIN-45 dev/02a). `world scrub <service>` removes just
316
- // that service's event log/state; `world scrub --all` removes the whole world state dir.
317
- // Both are plain `rm` (see docs/DATA_AT_REST.md) and both refuse unfamiliar contents unless
318
- // --force is passed — never rm a path that doesn't look like our own state dir.
319
- if (resource === 'scrub') {
320
- const scrubArgs = [action, ...rest].filter((arg) => typeof arg === 'string');
321
- const all = hasFlag(scrubArgs, '--all');
322
- const force = hasFlag(scrubArgs, '--force');
323
- const rootOption = optionValue(scrubArgs, '--root') || undefined;
324
- if (all) {
325
- print(scrubWorld({ ...(rootOption ? { root: rootOption } : {}), force }), true);
326
- return;
327
- }
328
- const service = action;
329
- if (!service || service === '--force' || service === '--root') {
330
- throw new Error('world scrub: missing service (e.g. `world scrub chat`), or pass --all to scrub the whole world state dir');
331
- }
332
- print(scrubService(service, { ...(rootOption ? { root: rootOption } : {}), force }), true);
333
- return;
334
- }
335
- // Sync: pull (real→twin) folds observed resources into the log; reconcile
336
- // computes a three-way merge plan (base from fork-meta, fork from twin state,
337
- // real from a provided snapshot). Push-to-real is library-only: it needs an
338
- // injected real write fn carrying the user's own auth (auth-boundary), so it
339
- // is intentionally NOT a CLI command.
340
- if (resource === 'sync' && action === 'pull') {
341
- const service = rest[0];
342
- if (!service)
343
- throw new Error('world sync pull: missing service');
344
- const file = optionValue(rest, '--resources');
345
- if (!file)
346
- throw new Error('world sync pull: missing --resources <file.json> (array of {type,id,fields})');
347
- const at = optionValue(rest, '--at') || new Date().toISOString();
348
- const resources = JSON.parse(readFileSync(file, 'utf8'));
349
- print(syncPull({ service, resources, occurredAt: at }), true);
350
- return;
351
- }
352
- if (resource === 'reconcile') {
353
- const service = action;
354
- if (!service)
355
- throw new Error('world reconcile: missing service');
356
- const forkRoot = optionValue(rest, '--root');
357
- const realFile = optionValue(rest, '--real');
358
- if (!forkRoot || !realFile)
359
- throw new Error('world reconcile <service> --root <forkRoot> --real <file> [--policy hub-wins|twin-wins|merge]');
360
- const policy = (optionValue(rest, '--policy') || 'hub-wins');
361
- const base = readForkMeta(service, forkRoot).baseline;
362
- const fork = twinResources(service, forkRoot);
363
- const realRaw = JSON.parse(readFileSync(realFile, 'utf8'));
364
- const real = realRaw.map((r) => (r.fields ? { id: r.id, type: r.type, updatedAt: '', ...r.fields } : r));
365
- print(reconcile({ policy, base, fork, real }), true);
366
- return;
367
- }
368
- // Mirror UIs moved to the per-vendor twin packages (modular). Point users there.
369
- if (resource === 'mirror') {
370
- throw new Error(`world mirror: vendor mirror UIs live in their own packages now — run \`world-${action ?? '<vendor>'} mirror\` (e.g. world-stripe / world-linear / world-jira / world-github / world-slack).`);
371
- }
372
- // Visualizer (debug inspector): a generic status view of the twin. `world visualize <service>` serves it;
373
- // `--once` prints the rendered HTML (scriptable).
374
- if (resource === 'visualize') {
375
- const service = action;
376
- if (!service)
377
- throw new Error('world visualize: missing service (e.g. `world visualize linear`)');
378
- if (hasFlag(rest, '--once')) {
379
- process.stdout.write(renderTwinHtml(service));
380
- return;
381
- }
382
- const port = Number(optionValue(rest, '--port', '0')) || undefined;
383
- const server = createVisualizerServer({ service, ...(port ? { port } : {}) });
384
- process.stdout.write(`${service} twin visualizer at http://127.0.0.1:${server.port}\n`);
385
- await new Promise(() => { });
386
- return;
387
- }
388
- // Fork mode: `world fork create <service> --to <root> [--from <root>] --at <iso>`,
389
- // `world fork divergence <service> --root <forkRoot>`,
390
- // `world fork audit <service> --root <forkRoot>`.
391
- if (resource === 'fork') {
392
- const service = rest[0];
393
- if (!service)
394
- throw new Error('world fork: missing service');
395
- if (action === 'create') {
396
- const toRoot = optionValue(rest, '--to');
397
- if (!toRoot)
398
- throw new Error('world fork create: missing --to <root>');
399
- const fromRoot = optionValue(rest, '--from') || undefined;
400
- const at = optionValue(rest, '--at') || new Date().toISOString();
401
- print(forkTwin({ service, toRoot, ...(fromRoot ? { fromRoot } : {}), occurredAt: at }), true);
402
- return;
403
- }
404
- const forkRoot = optionValue(rest, '--root');
405
- if (!forkRoot)
406
- throw new Error(`world fork ${action}: missing --root <forkRoot>`);
407
- if (action === 'divergence') {
408
- print(forkDivergence(service, forkRoot), true);
409
- return;
410
- }
411
- if (action === 'audit') {
412
- const audit = auditForkNoRealWrites(service, forkRoot);
413
- print(audit, true);
414
- if (!audit.ok)
415
- process.exitCode = 1;
416
- return;
417
- }
418
- throw new Error(`Unknown world fork action: ${action ?? ''}`);
419
- }
420
- throw new Error(`Unknown world command: ${resource ?? ''} ${action ?? ''}`.trim());
421
- }
422
- main().catch((error) => {
423
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
424
- process.exit(1);
425
- });