@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.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SubjectFields } from './shadow.js';
|
|
2
|
+
import type { WorldServiceEvent } from './types.js';
|
|
3
|
+
export type ConnectorObservation = {
|
|
4
|
+
subject: {
|
|
5
|
+
type: string;
|
|
6
|
+
id: string;
|
|
7
|
+
};
|
|
8
|
+
/** Current field values; the runner diffs them against the shadow state. */
|
|
9
|
+
observed: SubjectFields;
|
|
10
|
+
/** Provider update timestamp (ISO); drives cursor advancement. */
|
|
11
|
+
occurredAt: string;
|
|
12
|
+
external?: {
|
|
13
|
+
provider: string;
|
|
14
|
+
id: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Extra append-only events tied to this observation (comments, reviews,
|
|
19
|
+
* …). Appended idempotently — the connector encodes identity (including
|
|
20
|
+
* content hashes for mutable bodies) in id/idempotencyKey.
|
|
21
|
+
*/
|
|
22
|
+
events?: WorldServiceEvent[];
|
|
23
|
+
};
|
|
24
|
+
export type WorldConnector = {
|
|
25
|
+
service: string;
|
|
26
|
+
/** Cursor filename under the service's cursors/ dir (default poll.json). */
|
|
27
|
+
cursorFile?: string;
|
|
28
|
+
/** Seed the shadow from historical event types (default: deltas only). */
|
|
29
|
+
shadowExtractor?: (event: WorldServiceEvent) => SubjectFields | null;
|
|
30
|
+
/**
|
|
31
|
+
* Fetch observations whose provider timestamp advanced past the cursor
|
|
32
|
+
* (empty cursor = from the beginning). `limit` is a runaway-fetch safety
|
|
33
|
+
* cap, not a page size: the connector reports `truncated: true` when the
|
|
34
|
+
* cap was hit so the runner holds the cursor.
|
|
35
|
+
*/
|
|
36
|
+
fetchSince(input: {
|
|
37
|
+
cursor: string;
|
|
38
|
+
limit: number;
|
|
39
|
+
root: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
observations: ConnectorObservation[];
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
export type ConnectorPollResult = {
|
|
46
|
+
service: string;
|
|
47
|
+
cursorBefore: string | null;
|
|
48
|
+
cursorAfter: string | null;
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
fetched: number;
|
|
51
|
+
deltasAppended: number;
|
|
52
|
+
eventsAppended: number;
|
|
53
|
+
changedSubjects: string[];
|
|
54
|
+
};
|
|
55
|
+
export declare function pollCursorPath(service: string, root?: string, cursorFile?: string): string;
|
|
56
|
+
export declare function loadPollCursor(service: string, root?: string, cursorFile?: string): string;
|
|
57
|
+
export declare function savePollCursor(service: string, after: string, root?: string, cursorFile?: string): void;
|
|
58
|
+
export declare function runConnectorPoll(connector: WorldConnector, options?: {
|
|
59
|
+
root?: string;
|
|
60
|
+
cursor?: string;
|
|
61
|
+
limit?: number;
|
|
62
|
+
}): Promise<ConnectorPollResult>;
|
|
63
|
+
import type { ShadowState } from './shadow.js';
|
|
64
|
+
export type SweepConnector = {
|
|
65
|
+
service: string;
|
|
66
|
+
shadowExtractor?: (event: WorldServiceEvent) => SubjectFields | null;
|
|
67
|
+
/** Which tracked subjects to re-observe this sweep. */
|
|
68
|
+
selectSubjects(input: {
|
|
69
|
+
subjects: Array<{
|
|
70
|
+
type: string;
|
|
71
|
+
id: string;
|
|
72
|
+
}>;
|
|
73
|
+
shadow: ShadowState;
|
|
74
|
+
sweepAll: boolean;
|
|
75
|
+
}): Array<{
|
|
76
|
+
type: string;
|
|
77
|
+
id: string;
|
|
78
|
+
}>;
|
|
79
|
+
/** Fetch the subject's current field state; null = fetch error. */
|
|
80
|
+
fetchSubject(subject: {
|
|
81
|
+
type: string;
|
|
82
|
+
id: string;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
observed: SubjectFields;
|
|
85
|
+
occurredAt: string;
|
|
86
|
+
external?: {
|
|
87
|
+
provider: string;
|
|
88
|
+
id: string;
|
|
89
|
+
url?: string;
|
|
90
|
+
};
|
|
91
|
+
} | null>;
|
|
92
|
+
};
|
|
93
|
+
export type ConnectorSweepResult = {
|
|
94
|
+
service: string;
|
|
95
|
+
subjects: number;
|
|
96
|
+
polled: number;
|
|
97
|
+
deltasAppended: number;
|
|
98
|
+
errors: number;
|
|
99
|
+
changedSubjects: string[];
|
|
100
|
+
sweepAll: boolean;
|
|
101
|
+
};
|
|
102
|
+
export declare function runConnectorSweep(connector: SweepConnector, options?: {
|
|
103
|
+
root?: string;
|
|
104
|
+
limit?: number;
|
|
105
|
+
sweepAll?: boolean;
|
|
106
|
+
}): Promise<ConnectorSweepResult>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Connector contract + poll runner for cursor-based world pollers.
|
|
2
|
+
//
|
|
3
|
+
// A connector owns only the provider-specific parts: fetching observations
|
|
4
|
+
// whose provider timestamp advanced past the cursor, mapping them to subject
|
|
5
|
+
// fields, and constructing any extra append-only events (comments, …). The
|
|
6
|
+
// runner owns the invariants every poller must respect:
|
|
7
|
+
// - shadow-diff recording: an unchanged re-observation appends nothing
|
|
8
|
+
// - extra events append idempotently (id/idempotencyKey carry identity)
|
|
9
|
+
// - the cursor only advances after every observation in the batch is
|
|
10
|
+
// recorded, and NEVER advances on a truncated batch — results arrive
|
|
11
|
+
// newest-first, so a capped batch cut off OLDER updates and advancing
|
|
12
|
+
// would skip them forever
|
|
13
|
+
// - the cursor saves 1ms behind the max observed timestamp so an update
|
|
14
|
+
// landing at exactly that timestamp after the poll is not excluded by a
|
|
15
|
+
// strictly-greater filter; the overlap re-fetch is free (unchanged
|
|
16
|
+
// observations append nothing)
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { buildShadowState, recordObservedDelta } from "./shadow.js";
|
|
20
|
+
import { appendEvent, readJsonFile, rebuildGenericState, worldPaths } from "./storage.js";
|
|
21
|
+
export function pollCursorPath(service, root, cursorFile = 'poll.json') {
|
|
22
|
+
return join(worldPaths(service, root).cursors, cursorFile);
|
|
23
|
+
}
|
|
24
|
+
export function loadPollCursor(service, root, cursorFile) {
|
|
25
|
+
const path = pollCursorPath(service, root, cursorFile);
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return '';
|
|
28
|
+
return readJsonFile(path).after ?? '';
|
|
29
|
+
}
|
|
30
|
+
export function savePollCursor(service, after, root, cursorFile) {
|
|
31
|
+
const path = pollCursorPath(service, root, cursorFile);
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify({ after, updatedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
export async function runConnectorPoll(connector, options = {}) {
|
|
36
|
+
const root = options.root;
|
|
37
|
+
const service = connector.service;
|
|
38
|
+
const limit = options.limit ?? 10000;
|
|
39
|
+
const cursorBefore = options.cursor ?? loadPollCursor(service, root, connector.cursorFile);
|
|
40
|
+
const { observations, truncated } = await connector.fetchSince({ cursor: cursorBefore, limit, root: root ?? process.env.PROJECT_ROOT ?? process.cwd() });
|
|
41
|
+
// Shadow folds from the event log; subject state arrives via deltas, so no
|
|
42
|
+
// snapshot extractor is needed.
|
|
43
|
+
const shadow = buildShadowState(service, connector.shadowExtractor ?? (() => null), root);
|
|
44
|
+
let deltasAppended = 0;
|
|
45
|
+
let eventsAppended = 0;
|
|
46
|
+
let maxOccurredAt = cursorBefore;
|
|
47
|
+
const changedSubjects = [];
|
|
48
|
+
for (const observation of observations) {
|
|
49
|
+
const result = recordObservedDelta(shadow, {
|
|
50
|
+
service,
|
|
51
|
+
subject: observation.subject,
|
|
52
|
+
observed: observation.observed,
|
|
53
|
+
occurredAt: observation.occurredAt,
|
|
54
|
+
...(observation.external ? { external: observation.external } : {}),
|
|
55
|
+
}, root);
|
|
56
|
+
if (result.changed && result.append.appended) {
|
|
57
|
+
deltasAppended += 1;
|
|
58
|
+
changedSubjects.push(observation.subject.id);
|
|
59
|
+
}
|
|
60
|
+
for (const event of observation.events ?? []) {
|
|
61
|
+
if (appendEvent(event, root).appended)
|
|
62
|
+
eventsAppended += 1;
|
|
63
|
+
}
|
|
64
|
+
if (!maxOccurredAt || observation.occurredAt > maxOccurredAt)
|
|
65
|
+
maxOccurredAt = observation.occurredAt;
|
|
66
|
+
}
|
|
67
|
+
if (!truncated && maxOccurredAt && maxOccurredAt !== cursorBefore) {
|
|
68
|
+
savePollCursor(service, new Date(Date.parse(maxOccurredAt) - 1).toISOString(), root, connector.cursorFile);
|
|
69
|
+
}
|
|
70
|
+
rebuildGenericState(service, root);
|
|
71
|
+
return {
|
|
72
|
+
service,
|
|
73
|
+
cursorBefore: cursorBefore || null,
|
|
74
|
+
cursorAfter: truncated ? cursorBefore || null : (maxOccurredAt || null),
|
|
75
|
+
truncated,
|
|
76
|
+
fetched: observations.length,
|
|
77
|
+
deltasAppended,
|
|
78
|
+
eventsAppended,
|
|
79
|
+
changedSubjects,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Sweep mode: for providers polled by SUBJECT rather than by time cursor
|
|
84
|
+
// (e.g. re-observing the current state of every tracked pull request). The
|
|
85
|
+
// runner owns subject enumeration from the generic world state, the
|
|
86
|
+
// shadow-diff invariant (unchanged re-observations append nothing), and
|
|
87
|
+
// error accounting; the connector owns selection and the per-subject fetch
|
|
88
|
+
// (null = fetch error, counted and reported, never silently dropped).
|
|
89
|
+
import { loadState } from "./storage.js";
|
|
90
|
+
export async function runConnectorSweep(connector, options = {}) {
|
|
91
|
+
const root = options.root;
|
|
92
|
+
const service = connector.service;
|
|
93
|
+
const limit = options.limit ?? 200;
|
|
94
|
+
const sweepAll = options.sweepAll ?? false;
|
|
95
|
+
const generic = loadState(service, root);
|
|
96
|
+
const allSubjects = Object.values(generic?.subjects ?? {}).map((subject) => {
|
|
97
|
+
const record = subject;
|
|
98
|
+
return { type: String(record.type ?? ''), id: String(record.id ?? '') };
|
|
99
|
+
});
|
|
100
|
+
const shadow = buildShadowState(service, connector.shadowExtractor ?? (() => null), root);
|
|
101
|
+
const selected = connector.selectSubjects({ subjects: allSubjects, shadow, sweepAll });
|
|
102
|
+
let polled = 0;
|
|
103
|
+
let deltasAppended = 0;
|
|
104
|
+
let errors = 0;
|
|
105
|
+
const changedSubjects = [];
|
|
106
|
+
for (const subject of selected) {
|
|
107
|
+
if (polled >= limit)
|
|
108
|
+
break;
|
|
109
|
+
const observation = await connector.fetchSubject(subject);
|
|
110
|
+
polled += 1;
|
|
111
|
+
if (!observation) {
|
|
112
|
+
errors += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const result = recordObservedDelta(shadow, {
|
|
116
|
+
service,
|
|
117
|
+
subject,
|
|
118
|
+
observed: observation.observed,
|
|
119
|
+
occurredAt: observation.occurredAt,
|
|
120
|
+
...(observation.external ? { external: observation.external } : {}),
|
|
121
|
+
}, root);
|
|
122
|
+
if (result.changed && result.append.appended) {
|
|
123
|
+
deltasAppended += 1;
|
|
124
|
+
changedSubjects.push(subject.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
rebuildGenericState(service, root);
|
|
128
|
+
return { service, subjects: allSubjects.length, polled, deltasAppended, errors, changedSubjects, sweepAll };
|
|
129
|
+
}
|