@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Event queue lifecycle (the twins architecture notes → "Event Intake Queue").
|
|
2
|
+
// Listener/webhook/broker deliveries land in the durable queue (events stay
|
|
3
|
+
// NON-canonical until committed). This module gives queue rows the named
|
|
4
|
+
// lifecycle the doc specifies — queued | committed | ignored | superseded |
|
|
5
|
+
// poisoned — as an append-only status ledger over the existing queue, so the
|
|
6
|
+
// queue file itself stays immutable delivery history. Current status = the latest
|
|
7
|
+
// transition (default `queued`). Deterministic: caller may supply `at`.
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { appendDurable, appendEvent, listQueuedEvents, rebuildGenericState, worldPaths } from "./storage.js";
|
|
11
|
+
function statusPath(service, root) {
|
|
12
|
+
return join(worldPaths(service, root).dir, 'event-queue-status.jsonl');
|
|
13
|
+
}
|
|
14
|
+
function readTransitions(service, root) {
|
|
15
|
+
const path = statusPath(service, root);
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return [];
|
|
18
|
+
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
19
|
+
}
|
|
20
|
+
/** Latest transition per queue row (append order is the tiebreak — last wins). */
|
|
21
|
+
function latestByRow(service, root) {
|
|
22
|
+
const latest = new Map();
|
|
23
|
+
for (const t of readTransitions(service, root))
|
|
24
|
+
latest.set(t.queueId, t); // later lines overwrite
|
|
25
|
+
return latest;
|
|
26
|
+
}
|
|
27
|
+
export function setQueueRowStatus(service, queueId, status, opts = {}) {
|
|
28
|
+
const path = statusPath(service, opts.root);
|
|
29
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
30
|
+
const transition = {
|
|
31
|
+
queueId,
|
|
32
|
+
status,
|
|
33
|
+
at: opts.at ?? new Date().toISOString(),
|
|
34
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
35
|
+
...(opts.eventId ? { eventId: opts.eventId } : {}),
|
|
36
|
+
...(opts.supersededBy ? { supersededBy: opts.supersededBy } : {}),
|
|
37
|
+
};
|
|
38
|
+
appendDurable(path, `${JSON.stringify(transition)}\n`);
|
|
39
|
+
return transition;
|
|
40
|
+
}
|
|
41
|
+
export function queueRowStatus(service, queueId, root) {
|
|
42
|
+
return latestByRow(service, root).get(queueId)?.status ?? 'queued';
|
|
43
|
+
}
|
|
44
|
+
/** Every queue row with its current lifecycle status. */
|
|
45
|
+
export function listQueueWithStatus(service, root) {
|
|
46
|
+
const latest = latestByRow(service, root);
|
|
47
|
+
return listQueuedEvents(service, root).map((row) => {
|
|
48
|
+
const transition = latest.get(row.id);
|
|
49
|
+
return { row, status: transition?.status ?? 'queued', ...(transition ? { transition } : {}) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Rows still awaiting a decision (status === 'queued'). */
|
|
53
|
+
export function pendingQueueRows(service, root) {
|
|
54
|
+
return listQueueWithStatus(service, root).filter((r) => r.status === 'queued').map((r) => r.row);
|
|
55
|
+
}
|
|
56
|
+
export function queueCounts(service, root) {
|
|
57
|
+
const counts = { queued: 0, committed: 0, ignored: 0, superseded: 0, poisoned: 0 };
|
|
58
|
+
for (const r of listQueueWithStatus(service, root))
|
|
59
|
+
counts[r.status] += 1;
|
|
60
|
+
return counts;
|
|
61
|
+
}
|
|
62
|
+
/** Commit a single queued row into the canonical event log + stamp `committed`. */
|
|
63
|
+
export function commitQueueRow(service, queueId, opts = {}) {
|
|
64
|
+
const status = queueRowStatus(service, queueId, opts.root);
|
|
65
|
+
if (status !== 'queued')
|
|
66
|
+
throw new Error(`queue row ${queueId} is ${status}, not committable`);
|
|
67
|
+
const row = listQueuedEvents(service, opts.root).find((r) => r.id === queueId);
|
|
68
|
+
if (!row)
|
|
69
|
+
throw new Error(`queue row not found: ${queueId}`);
|
|
70
|
+
const result = appendEvent(row.event, opts.root);
|
|
71
|
+
rebuildGenericState(service, opts.root);
|
|
72
|
+
setQueueRowStatus(service, queueId, 'committed', { ...opts, eventId: result.event.id });
|
|
73
|
+
return { appended: result.appended, eventId: result.event.id };
|
|
74
|
+
}
|
|
75
|
+
export function ignoreQueueRow(service, queueId, opts = {}) {
|
|
76
|
+
return setQueueRowStatus(service, queueId, 'ignored', opts);
|
|
77
|
+
}
|
|
78
|
+
export function supersedeQueueRow(service, queueId, supersededBy, opts = {}) {
|
|
79
|
+
return setQueueRowStatus(service, queueId, 'superseded', { ...opts, supersededBy });
|
|
80
|
+
}
|
|
81
|
+
export function poisonQueueRow(service, queueId, opts = {}) {
|
|
82
|
+
return setQueueRowStatus(service, queueId, 'poisoned', opts);
|
|
83
|
+
}
|
|
84
|
+
/** Commit all rows currently `queued`, in deterministic provider-time order; stamps committed. */
|
|
85
|
+
export function commitPendingQueue(service, opts = {}) {
|
|
86
|
+
const pending = pendingQueueRows(service, opts.root)
|
|
87
|
+
.sort((a, b) => a.event.occurredAt.localeCompare(b.event.occurredAt) || a.receivedAt.localeCompare(b.receivedAt) || a.id.localeCompare(b.id));
|
|
88
|
+
const eventIds = [];
|
|
89
|
+
for (const row of pending) {
|
|
90
|
+
const { eventId } = commitQueueRow(service, row.id, opts);
|
|
91
|
+
if (eventId)
|
|
92
|
+
eventIds.push(eventId);
|
|
93
|
+
}
|
|
94
|
+
return { committed: eventIds.length, eventIds };
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TwinResource } from './serve.js';
|
|
2
|
+
export type ReconcilePolicy = 'hub-wins' | 'twin-wins' | 'merge';
|
|
3
|
+
export type FieldDecision = {
|
|
4
|
+
field: string;
|
|
5
|
+
base: unknown;
|
|
6
|
+
fork: unknown;
|
|
7
|
+
real: unknown;
|
|
8
|
+
forkChanged: boolean;
|
|
9
|
+
realChanged: boolean;
|
|
10
|
+
resolution: 'unchanged' | 'take-fork' | 'take-real' | 'conflict';
|
|
11
|
+
value: unknown;
|
|
12
|
+
};
|
|
13
|
+
export type SubjectReconcile = {
|
|
14
|
+
id: string;
|
|
15
|
+
type: string;
|
|
16
|
+
inFork: boolean;
|
|
17
|
+
inReal: boolean;
|
|
18
|
+
existence: 'both' | 'fork-only' | 'real-only';
|
|
19
|
+
fields: FieldDecision[];
|
|
20
|
+
conflicts: FieldDecision[];
|
|
21
|
+
};
|
|
22
|
+
export type ReconcilePlan = {
|
|
23
|
+
policy: ReconcilePolicy;
|
|
24
|
+
subjects: SubjectReconcile[];
|
|
25
|
+
toPush: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
fields: Record<string, unknown>;
|
|
29
|
+
}>;
|
|
30
|
+
toPull: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
type: string;
|
|
33
|
+
fields: Record<string, unknown>;
|
|
34
|
+
}>;
|
|
35
|
+
conflictCount: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Compute a three-way reconciliation plan. Inputs are resource snapshots keyed
|
|
39
|
+
* by id (base = at fork time, fork = twin now, real = fresh pull). Pure.
|
|
40
|
+
*/
|
|
41
|
+
export declare function reconcile(opts: {
|
|
42
|
+
policy: ReconcilePolicy;
|
|
43
|
+
base: TwinResource[];
|
|
44
|
+
fork: TwinResource[];
|
|
45
|
+
real: TwinResource[];
|
|
46
|
+
}): ReconcilePlan;
|
|
47
|
+
/** True when the plan can be enacted without a human decision. */
|
|
48
|
+
export declare function isCleanlyReconcilable(plan: ReconcilePlan): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether enacting this plan's `toPush` requires explicit approval before
|
|
51
|
+
* `syncPush` may perform it. Nothing pushed → nothing to gate. Otherwise: any
|
|
52
|
+
* unresolved conflict anywhere in the plan requires approval (never silently
|
|
53
|
+
* applied), and so does any pushed field whose resolution is 'take-fork' while
|
|
54
|
+
* `realChanged` is true — twin-wins discarding a change that also happened on the
|
|
55
|
+
* real side, the "destructive twin-wins reconcile push" case that must not go out
|
|
56
|
+
* silently.
|
|
57
|
+
*/
|
|
58
|
+
export declare function reconcileRequiresApproval(plan: ReconcilePlan): boolean;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const META_FIELDS = new Set(['id', 'type', 'updatedAt']);
|
|
2
|
+
function eq(a, b) {
|
|
3
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
4
|
+
}
|
|
5
|
+
function dataFields(r) {
|
|
6
|
+
if (!r)
|
|
7
|
+
return {};
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [k, v] of Object.entries(r))
|
|
10
|
+
if (!META_FIELDS.has(k))
|
|
11
|
+
out[k] = v;
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function decideField(field, base, fork, real, policy) {
|
|
15
|
+
const forkChanged = !eq(base, fork);
|
|
16
|
+
const realChanged = !eq(base, real);
|
|
17
|
+
let resolution;
|
|
18
|
+
let value;
|
|
19
|
+
if (!forkChanged && !realChanged) {
|
|
20
|
+
resolution = 'unchanged';
|
|
21
|
+
value = base;
|
|
22
|
+
}
|
|
23
|
+
else if (forkChanged && !realChanged) {
|
|
24
|
+
// only the twin changed it
|
|
25
|
+
resolution = policy === 'hub-wins' ? 'take-real' : 'take-fork';
|
|
26
|
+
value = resolution === 'take-fork' ? fork : real;
|
|
27
|
+
}
|
|
28
|
+
else if (!forkChanged && realChanged) {
|
|
29
|
+
// only the real service changed it
|
|
30
|
+
resolution = policy === 'twin-wins' ? 'take-fork' : 'take-real';
|
|
31
|
+
value = resolution === 'take-fork' ? fork : real;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// BOTH changed (relative to base) — the genuine conflict case
|
|
35
|
+
if (eq(fork, real)) {
|
|
36
|
+
resolution = 'take-fork'; // converged independently to the same value
|
|
37
|
+
value = fork;
|
|
38
|
+
}
|
|
39
|
+
else if (policy === 'hub-wins') {
|
|
40
|
+
resolution = 'take-real';
|
|
41
|
+
value = real;
|
|
42
|
+
}
|
|
43
|
+
else if (policy === 'twin-wins') {
|
|
44
|
+
resolution = 'take-fork';
|
|
45
|
+
value = fork;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
resolution = 'conflict'; // merge policy surfaces it; no silent pick
|
|
49
|
+
value = real; // safe default: do NOT overwrite real on an unresolved conflict
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { field, base, fork, real, forkChanged, realChanged, resolution, value };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Compute a three-way reconciliation plan. Inputs are resource snapshots keyed
|
|
56
|
+
* by id (base = at fork time, fork = twin now, real = fresh pull). Pure.
|
|
57
|
+
*/
|
|
58
|
+
export function reconcile(opts) {
|
|
59
|
+
const { policy } = opts;
|
|
60
|
+
const baseById = new Map(opts.base.map((r) => [r.id, r]));
|
|
61
|
+
const forkById = new Map(opts.fork.map((r) => [r.id, r]));
|
|
62
|
+
const realById = new Map(opts.real.map((r) => [r.id, r]));
|
|
63
|
+
const allIds = [...new Set([...forkById.keys(), ...realById.keys()])].sort();
|
|
64
|
+
const subjects = [];
|
|
65
|
+
const toPush = [];
|
|
66
|
+
const toPull = [];
|
|
67
|
+
let conflictCount = 0;
|
|
68
|
+
for (const id of allIds) {
|
|
69
|
+
const baseR = baseById.get(id);
|
|
70
|
+
const forkR = forkById.get(id);
|
|
71
|
+
const realR = realById.get(id);
|
|
72
|
+
const inFork = !!forkR;
|
|
73
|
+
const inReal = !!realR;
|
|
74
|
+
const existence = inFork && inReal ? 'both' : inFork ? 'fork-only' : 'real-only';
|
|
75
|
+
const type = (forkR?.type ?? realR?.type ?? baseR?.type ?? 'unknown');
|
|
76
|
+
const baseF = dataFields(baseR);
|
|
77
|
+
const forkF = dataFields(forkR);
|
|
78
|
+
const realF = dataFields(realR);
|
|
79
|
+
const fieldNames = [...new Set([...Object.keys(baseF), ...Object.keys(forkF), ...Object.keys(realF)])].sort();
|
|
80
|
+
const fields = fieldNames.map((f) => decideField(f, baseF[f], forkF[f], realF[f], policy));
|
|
81
|
+
const conflicts = fields.filter((d) => d.resolution === 'conflict');
|
|
82
|
+
conflictCount += conflicts.length;
|
|
83
|
+
subjects.push({ id, type, inFork, inReal, existence, fields, conflicts });
|
|
84
|
+
// Enactment summaries (gated; not applied here):
|
|
85
|
+
const pushFields = {};
|
|
86
|
+
const pullFields = {};
|
|
87
|
+
if (existence === 'both') {
|
|
88
|
+
// field-level convergence only makes sense when the subject is on both sides:
|
|
89
|
+
// - fields resolved 'take-fork' that differ from real → push to real.
|
|
90
|
+
// - fields resolved 'take-real' that differ from fork → pull into twin.
|
|
91
|
+
for (const d of fields) {
|
|
92
|
+
if (d.resolution === 'take-fork' && !eq(d.value, d.real))
|
|
93
|
+
pushFields[d.field] = d.value;
|
|
94
|
+
if (d.resolution === 'take-real' && !eq(d.value, d.fork))
|
|
95
|
+
pullFields[d.field] = d.value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (existence === 'fork-only') {
|
|
99
|
+
// present in the twin, absent in real. Under twin-wins/merge that's a
|
|
100
|
+
// create-on-real candidate; under hub-wins (real authoritative) the fork's
|
|
101
|
+
// creation is discarded — neither pushed nor pulled.
|
|
102
|
+
if (policy !== 'hub-wins')
|
|
103
|
+
for (const [k, v] of Object.entries(forkF))
|
|
104
|
+
pushFields[k] = v;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// real-only: a create-in-twin candidate (pull) under every policy.
|
|
108
|
+
for (const [k, v] of Object.entries(realF))
|
|
109
|
+
pullFields[k] = v;
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(pushFields).length > 0)
|
|
112
|
+
toPush.push({ id, type, fields: pushFields });
|
|
113
|
+
if (Object.keys(pullFields).length > 0)
|
|
114
|
+
toPull.push({ id, type, fields: pullFields });
|
|
115
|
+
}
|
|
116
|
+
return { policy, subjects, toPush, toPull, conflictCount };
|
|
117
|
+
}
|
|
118
|
+
/** True when the plan can be enacted without a human decision. */
|
|
119
|
+
export function isCleanlyReconcilable(plan) {
|
|
120
|
+
return plan.conflictCount === 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether enacting this plan's `toPush` requires explicit approval before
|
|
124
|
+
* `syncPush` may perform it. Nothing pushed → nothing to gate. Otherwise: any
|
|
125
|
+
* unresolved conflict anywhere in the plan requires approval (never silently
|
|
126
|
+
* applied), and so does any pushed field whose resolution is 'take-fork' while
|
|
127
|
+
* `realChanged` is true — twin-wins discarding a change that also happened on the
|
|
128
|
+
* real side, the "destructive twin-wins reconcile push" case that must not go out
|
|
129
|
+
* silently.
|
|
130
|
+
*/
|
|
131
|
+
export function reconcileRequiresApproval(plan) {
|
|
132
|
+
if (plan.toPush.length === 0)
|
|
133
|
+
return false;
|
|
134
|
+
if (plan.conflictCount > 0)
|
|
135
|
+
return true;
|
|
136
|
+
return plan.subjects.some((s) => s.fields.some((f) => f.resolution === 'take-fork' && f.realChanged));
|
|
137
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type WorldRemoteRef = {
|
|
2
|
+
service: string;
|
|
3
|
+
provider: string;
|
|
4
|
+
/** "main" by default; a named checkpoint otherwise. */
|
|
5
|
+
name: string;
|
|
6
|
+
eventId?: string;
|
|
7
|
+
cursor?: string;
|
|
8
|
+
providerVersion?: string;
|
|
9
|
+
observedAt: string;
|
|
10
|
+
};
|
|
11
|
+
export type WorldLocalRef = {
|
|
12
|
+
service: string;
|
|
13
|
+
/** the fork this ref belongs to. */
|
|
14
|
+
forkId: string;
|
|
15
|
+
/** the remote ref the fork is based on (provider + name + checkpoint). */
|
|
16
|
+
baseRemoteRef: WorldRemoteRef;
|
|
17
|
+
recordedAt: string;
|
|
18
|
+
};
|
|
19
|
+
/** Record/advance a provider checkpoint (e.g. after a confirmed pull). */
|
|
20
|
+
export declare function writeRemoteRef(ref: WorldRemoteRef, root?: string): WorldRemoteRef;
|
|
21
|
+
export declare function readRemoteRef(service: string, provider: string, name?: string, root?: string): WorldRemoteRef | null;
|
|
22
|
+
export declare function listRemoteRefs(service: string, root?: string): WorldRemoteRef[];
|
|
23
|
+
export declare function writeLocalRef(ref: WorldLocalRef, root?: string): WorldLocalRef;
|
|
24
|
+
export declare function readLocalRef(service: string, forkId: string, root?: string): WorldLocalRef | null;
|
|
25
|
+
/**
|
|
26
|
+
* Is a fork's base ref still current against the live remote ref? A push against a
|
|
27
|
+
* stale base must be blocked or explicitly reconciled (rebase) first.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isBaseStale(local: WorldLocalRef, root?: string): boolean;
|
package/dist/src/refs.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Remote refs (the twins architecture notes → "Remote Refs"): the provider-side
|
|
2
|
+
// baselines a fork or push is based on. `remote/<provider>/<name>` records the last
|
|
3
|
+
// CONFIRMED provider checkpoint (cursor/version/event id/observed time); a local
|
|
4
|
+
// ref records which remote ref a fork was based on, so a stale-base push can be
|
|
5
|
+
// rejected before any provider call. Pure file I/O over the world dir; deterministic
|
|
6
|
+
// (caller supplies observedAt).
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { readJsonFile, worldPaths } from "./storage.js";
|
|
10
|
+
function refsDir(service, root) {
|
|
11
|
+
return join(worldPaths(service, root).dir, 'refs');
|
|
12
|
+
}
|
|
13
|
+
function remoteRefPath(service, provider, name, root) {
|
|
14
|
+
return join(refsDir(service, root), 'remote', provider, `${name}.json`);
|
|
15
|
+
}
|
|
16
|
+
function localRefPath(service, forkId, root) {
|
|
17
|
+
return join(refsDir(service, root), 'local', `${forkId}.json`);
|
|
18
|
+
}
|
|
19
|
+
function safe(part) {
|
|
20
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(part))
|
|
21
|
+
throw new Error(`invalid ref segment: ${part}`);
|
|
22
|
+
return part;
|
|
23
|
+
}
|
|
24
|
+
/** Record/advance a provider checkpoint (e.g. after a confirmed pull). */
|
|
25
|
+
export function writeRemoteRef(ref, root) {
|
|
26
|
+
const path = remoteRefPath(ref.service, safe(ref.provider), safe(ref.name), root);
|
|
27
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
28
|
+
writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
|
|
29
|
+
return ref;
|
|
30
|
+
}
|
|
31
|
+
export function readRemoteRef(service, provider, name = 'main', root) {
|
|
32
|
+
const path = remoteRefPath(service, safe(provider), safe(name), root);
|
|
33
|
+
return existsSync(path) ? readJsonFile(path) : null;
|
|
34
|
+
}
|
|
35
|
+
export function listRemoteRefs(service, root) {
|
|
36
|
+
const base = join(refsDir(service, root), 'remote');
|
|
37
|
+
if (!existsSync(base))
|
|
38
|
+
return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const provider of readdirSync(base)) {
|
|
41
|
+
const providerDir = join(base, provider);
|
|
42
|
+
for (const file of readdirSync(providerDir)) {
|
|
43
|
+
if (file.endsWith('.json'))
|
|
44
|
+
out.push(readJsonFile(join(providerDir, file)));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.sort((a, b) => (a.provider === b.provider ? a.name.localeCompare(b.name) : a.provider.localeCompare(b.provider)));
|
|
48
|
+
}
|
|
49
|
+
export function writeLocalRef(ref, root) {
|
|
50
|
+
const path = localRefPath(ref.service, safe(ref.forkId), root);
|
|
51
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
52
|
+
writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
|
|
53
|
+
return ref;
|
|
54
|
+
}
|
|
55
|
+
export function readLocalRef(service, forkId, root) {
|
|
56
|
+
const path = localRefPath(service, safe(forkId), root);
|
|
57
|
+
return existsSync(path) ? readJsonFile(path) : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Is a fork's base ref still current against the live remote ref? A push against a
|
|
61
|
+
* stale base must be blocked or explicitly reconciled (rebase) first.
|
|
62
|
+
*/
|
|
63
|
+
export function isBaseStale(local, root) {
|
|
64
|
+
const current = readRemoteRef(local.service, local.baseRemoteRef.provider, local.baseRemoteRef.name, root);
|
|
65
|
+
if (!current)
|
|
66
|
+
return false; // no live checkpoint recorded yet → cannot be stale
|
|
67
|
+
return (current.eventId ?? current.cursor ?? '') !== (local.baseRemoteRef.eventId ?? local.baseRemoteRef.cursor ?? '');
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const WorldActorSchema: z.ZodObject<{
|
|
3
|
+
id: z.ZodOptional<z.ZodString>;
|
|
4
|
+
name: z.ZodOptional<z.ZodString>;
|
|
5
|
+
kind: z.ZodEnum<{
|
|
6
|
+
human: "human";
|
|
7
|
+
agent: "agent";
|
|
8
|
+
bot: "bot";
|
|
9
|
+
system: "system";
|
|
10
|
+
}>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export declare const WorldSubjectSchema: z.ZodObject<{
|
|
13
|
+
type: z.ZodString;
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
}, z.core.$strip>;
|
|
16
|
+
export declare const WorldExternalRefSchema: z.ZodObject<{
|
|
17
|
+
provider: z.ZodString;
|
|
18
|
+
id: z.ZodOptional<z.ZodString>;
|
|
19
|
+
url: z.ZodOptional<z.ZodString>;
|
|
20
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
21
|
+
rawRef: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
export declare const WorldServiceEventSchema: z.ZodObject<{
|
|
24
|
+
id: z.ZodString;
|
|
25
|
+
service: z.ZodString;
|
|
26
|
+
type: z.ZodString;
|
|
27
|
+
schemaVersion: z.ZodNumber;
|
|
28
|
+
idempotencyKey: z.ZodString;
|
|
29
|
+
occurredAt: z.ZodString;
|
|
30
|
+
observedAt: z.ZodString;
|
|
31
|
+
origin: z.ZodEnum<{
|
|
32
|
+
agent: "agent";
|
|
33
|
+
virtual: "virtual";
|
|
34
|
+
external: "external";
|
|
35
|
+
connector: "connector";
|
|
36
|
+
replay: "replay";
|
|
37
|
+
migration: "migration";
|
|
38
|
+
}>;
|
|
39
|
+
actor: z.ZodOptional<z.ZodObject<{
|
|
40
|
+
id: z.ZodOptional<z.ZodString>;
|
|
41
|
+
name: z.ZodOptional<z.ZodString>;
|
|
42
|
+
kind: z.ZodEnum<{
|
|
43
|
+
human: "human";
|
|
44
|
+
agent: "agent";
|
|
45
|
+
bot: "bot";
|
|
46
|
+
system: "system";
|
|
47
|
+
}>;
|
|
48
|
+
}, z.core.$strip>>;
|
|
49
|
+
subject: z.ZodObject<{
|
|
50
|
+
type: z.ZodString;
|
|
51
|
+
id: z.ZodString;
|
|
52
|
+
}, z.core.$strip>;
|
|
53
|
+
causationId: z.ZodOptional<z.ZodString>;
|
|
54
|
+
correlationId: z.ZodOptional<z.ZodString>;
|
|
55
|
+
external: z.ZodOptional<z.ZodObject<{
|
|
56
|
+
provider: z.ZodString;
|
|
57
|
+
id: z.ZodOptional<z.ZodString>;
|
|
58
|
+
url: z.ZodOptional<z.ZodString>;
|
|
59
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
60
|
+
rawRef: z.ZodOptional<z.ZodString>;
|
|
61
|
+
}, z.core.$strip>>;
|
|
62
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
63
|
+
raw: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
64
|
+
}, z.core.$strip>;
|
|
65
|
+
export declare const GenericWorldStateSchema: z.ZodObject<{
|
|
66
|
+
version: z.ZodLiteral<1>;
|
|
67
|
+
service: z.ZodString;
|
|
68
|
+
rebuiltAt: z.ZodString;
|
|
69
|
+
eventCount: z.ZodNumber;
|
|
70
|
+
latestEventId: z.ZodOptional<z.ZodString>;
|
|
71
|
+
subjects: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
72
|
+
type: z.ZodString;
|
|
73
|
+
id: z.ZodString;
|
|
74
|
+
latestEventId: z.ZodString;
|
|
75
|
+
latestType: z.ZodString;
|
|
76
|
+
updatedAt: z.ZodString;
|
|
77
|
+
}, z.core.$strip>>;
|
|
78
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const WorldActorSchema = z.object({
|
|
3
|
+
id: z.string().min(1).optional(),
|
|
4
|
+
name: z.string().min(1).optional(),
|
|
5
|
+
kind: z.enum(['human', 'agent', 'bot', 'system']),
|
|
6
|
+
});
|
|
7
|
+
export const WorldSubjectSchema = z.object({
|
|
8
|
+
type: z.string().min(1),
|
|
9
|
+
id: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
export const WorldExternalRefSchema = z.object({
|
|
12
|
+
provider: z.string().min(1),
|
|
13
|
+
id: z.string().min(1).optional(),
|
|
14
|
+
url: z.string().min(1).optional(),
|
|
15
|
+
cursor: z.string().min(1).optional(),
|
|
16
|
+
rawRef: z.string().min(1).optional(),
|
|
17
|
+
});
|
|
18
|
+
export const WorldServiceEventSchema = z.object({
|
|
19
|
+
id: z.string().min(1),
|
|
20
|
+
service: z.string().min(1),
|
|
21
|
+
type: z.string().min(1),
|
|
22
|
+
schemaVersion: z.number().int().positive(),
|
|
23
|
+
idempotencyKey: z.string().min(1),
|
|
24
|
+
occurredAt: z.string().min(1),
|
|
25
|
+
observedAt: z.string().min(1),
|
|
26
|
+
origin: z.enum(['virtual', 'external', 'agent', 'connector', 'replay', 'migration']),
|
|
27
|
+
actor: WorldActorSchema.optional(),
|
|
28
|
+
subject: WorldSubjectSchema,
|
|
29
|
+
causationId: z.string().min(1).optional(),
|
|
30
|
+
correlationId: z.string().min(1).optional(),
|
|
31
|
+
external: WorldExternalRefSchema.optional(),
|
|
32
|
+
data: z.record(z.string(), z.unknown()),
|
|
33
|
+
raw: z.record(z.string(), z.unknown()).optional(),
|
|
34
|
+
});
|
|
35
|
+
// NOTE: WorldAnnotationSchema moved to @volter/tracker/world-annotations — annotating
|
|
36
|
+
// world events as sources/noise is a tracker (verification) concern, not the twin's.
|
|
37
|
+
export const GenericWorldStateSchema = z.object({
|
|
38
|
+
version: z.literal(1),
|
|
39
|
+
service: z.string().min(1),
|
|
40
|
+
rebuiltAt: z.string().min(1),
|
|
41
|
+
eventCount: z.number().int().nonnegative(),
|
|
42
|
+
latestEventId: z.string().min(1).optional(),
|
|
43
|
+
subjects: z.record(z.string(), z.object({
|
|
44
|
+
type: z.string().min(1),
|
|
45
|
+
id: z.string().min(1),
|
|
46
|
+
latestEventId: z.string().min(1),
|
|
47
|
+
latestType: z.string().min(1),
|
|
48
|
+
updatedAt: z.string().min(1),
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SubjectFields } from './shadow.js';
|
|
2
|
+
import type { TwinActionPrecondition } from './actions.js';
|
|
3
|
+
export type TwinResource = {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
} & Record<string, unknown>;
|
|
8
|
+
export declare function twinResources(service: string, root?: string): TwinResource[];
|
|
9
|
+
export type TwinWriteResult = {
|
|
10
|
+
status: 'performed' | 'replayed';
|
|
11
|
+
actionId: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function resolveTwinRead(service: string, pathname: string, opts?: {
|
|
14
|
+
root?: string;
|
|
15
|
+
}): {
|
|
16
|
+
status: number;
|
|
17
|
+
body: unknown;
|
|
18
|
+
};
|
|
19
|
+
export declare function applyTwinWrite(service: string, write: {
|
|
20
|
+
operation: string;
|
|
21
|
+
provider?: string;
|
|
22
|
+
subjectType: string;
|
|
23
|
+
subjectId: string;
|
|
24
|
+
fields: SubjectFields;
|
|
25
|
+
occurredAt?: string;
|
|
26
|
+
actor?: {
|
|
27
|
+
kind: 'agent' | 'human' | 'bot' | 'system';
|
|
28
|
+
id?: string;
|
|
29
|
+
};
|
|
30
|
+
preconditions?: TwinActionPrecondition[];
|
|
31
|
+
correlationId?: string;
|
|
32
|
+
}, root?: string): Promise<{
|
|
33
|
+
result: TwinWriteResult;
|
|
34
|
+
resource: TwinResource;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function createTwinServer(options: {
|
|
37
|
+
service: string;
|
|
38
|
+
root?: string;
|
|
39
|
+
port?: number;
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
}): {
|
|
42
|
+
port: number;
|
|
43
|
+
stop: () => void;
|
|
44
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { hashFieldValue } from "./shadow.js";
|
|
2
|
+
import { appendActionIfAbsent, projectResources } from "./actions.js";
|
|
3
|
+
// The twin's current view = the OBSERVED mirror (events.jsonl) with the local
|
|
4
|
+
// ACTION log (actions.jsonl) projected over it (R18). Observed facts and local
|
|
5
|
+
// actions are kept separate; this is the single projected read model.
|
|
6
|
+
export function twinResources(service, root) {
|
|
7
|
+
return projectResources(service, root);
|
|
8
|
+
}
|
|
9
|
+
// Resolve a read request against the twin's resources. Returns the matched
|
|
10
|
+
// resource(s) + an HTTP-ish status, deterministically from state.
|
|
11
|
+
// GET / -> { service, mode, resourceTypes, count }
|
|
12
|
+
// GET /<type> -> list of resources of that type
|
|
13
|
+
// GET /<type>/<id> -> one resource (id may be url-encoded)
|
|
14
|
+
export function resolveTwinRead(service, pathname, opts = {}) {
|
|
15
|
+
const resources = twinResources(service, opts.root);
|
|
16
|
+
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
17
|
+
if (parts.length === 0) {
|
|
18
|
+
const types = [...new Set(resources.map((r) => r.type))].sort();
|
|
19
|
+
return { status: 200, body: { service, resourceTypes: types, count: resources.length } };
|
|
20
|
+
}
|
|
21
|
+
const type = parts[0];
|
|
22
|
+
const ofType = resources.filter((r) => r.type === type);
|
|
23
|
+
if (parts.length === 1) {
|
|
24
|
+
return { status: 200, body: { type, count: ofType.length, items: ofType } };
|
|
25
|
+
}
|
|
26
|
+
const id = decodeURIComponent(parts.slice(1).join('/'));
|
|
27
|
+
const found = ofType.find((r) => r.id === id);
|
|
28
|
+
if (!found)
|
|
29
|
+
return { status: 404, body: { error: 'not_found', type, id } };
|
|
30
|
+
return { status: 200, body: found };
|
|
31
|
+
}
|
|
32
|
+
// Simulator/fork-mode write: the twin ACCEPTS a write as a LOCAL ACTION appended
|
|
33
|
+
// to the action log (R18) — NOT an observed event and NOT an egress/real write.
|
|
34
|
+
// State is the mirror with this action projected over it, so a subsequent read
|
|
35
|
+
// returns the change. The observed event log is untouched; the real service is
|
|
36
|
+
// provably untouched (no network I/O, no egress). Pushing the action to the real
|
|
37
|
+
// vendor is a separate, explicit step that records egress + confirms the action.
|
|
38
|
+
export async function applyTwinWrite(service, write, root) {
|
|
39
|
+
const occurredAt = write.occurredAt ?? new Date().toISOString();
|
|
40
|
+
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
41
|
+
// CONTENT (operation + subject + fields), not just the timestamp — so two DIFFERENT writes to
|
|
42
|
+
// the same subject in the same millisecond are BOTH kept (previously the second silently
|
|
43
|
+
// no-opped: e.g. a sprint "start" then "close" in the same ms lost the close). Check + append
|
|
44
|
+
// run atomically under the actions lock, so concurrent processes can't double-apply either.
|
|
45
|
+
const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields });
|
|
46
|
+
const actionId = `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}`;
|
|
47
|
+
// correlationId (D3): pass a caller-supplied request-scoped id through (e.g. propagated
|
|
48
|
+
// from an inbound HTTP request id) so it lands on the action row; appendActionIfAbsent
|
|
49
|
+
// generates one when omitted, so it's never missing.
|
|
50
|
+
const { appended } = appendActionIfAbsent({ id: actionId, service, op: 'set', operation: write.operation, subject: { type: write.subjectType, id: write.subjectId }, occurredAt, ...(write.actor ? { actor: write.actor } : {}), ...(write.preconditions?.length ? { preconditions: write.preconditions } : {}), ...(write.correlationId ? { correlationId: write.correlationId } : {}), fields: write.fields }, root);
|
|
51
|
+
// Resolve by (type, id) — an id alone is ambiguous when two resource TYPES share it.
|
|
52
|
+
const resource = projectResources(service, root).find((r) => r.type === write.subjectType && r.id === write.subjectId)
|
|
53
|
+
?? { id: write.subjectId, type: write.subjectType, updatedAt: occurredAt, ...write.fields };
|
|
54
|
+
return { result: { status: appended ? 'performed' : 'replayed', actionId }, resource };
|
|
55
|
+
}
|
|
56
|
+
export function createTwinServer(options) {
|
|
57
|
+
const readOnly = options.readOnly ?? false;
|
|
58
|
+
const server = Bun.serve({
|
|
59
|
+
port: options.port ?? 0,
|
|
60
|
+
idleTimeout: 60,
|
|
61
|
+
async fetch(request) {
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
const json = (status, body) => new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
|
|
64
|
+
if (request.method === 'GET') {
|
|
65
|
+
// Re-read per request so a concurrently-syncing twin serves fresh state.
|
|
66
|
+
const { status, body } = resolveTwinRead(options.service, url.pathname, { root: options.root });
|
|
67
|
+
return json(status, body);
|
|
68
|
+
}
|
|
69
|
+
// Writes are accepted as local actions unless this twin was started read-only.
|
|
70
|
+
if (readOnly)
|
|
71
|
+
return json(405, { error: 'read_only', hint: 'this twin was started read-only; omit readOnly to accept writes' });
|
|
72
|
+
const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
73
|
+
if (parts.length < 2)
|
|
74
|
+
return json(400, { error: 'write_needs_type_and_id', hint: 'POST /<type>/<id> with a JSON body of fields' });
|
|
75
|
+
let fields;
|
|
76
|
+
try {
|
|
77
|
+
fields = (await request.json());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return json(400, { error: 'invalid_json_body' });
|
|
81
|
+
}
|
|
82
|
+
const { result, resource } = await applyTwinWrite(options.service, {
|
|
83
|
+
operation: `${request.method.toLowerCase()}.${parts[0]}`,
|
|
84
|
+
subjectType: parts[0],
|
|
85
|
+
subjectId: decodeURIComponent(parts.slice(1).join('/')),
|
|
86
|
+
fields,
|
|
87
|
+
actor: { kind: 'agent' },
|
|
88
|
+
}, options.root);
|
|
89
|
+
return json(result.status === 'replayed' ? 200 : 201, { status: result.status, resource });
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return { port: server.port ?? 0, stop: () => server.stop(true) };
|
|
93
|
+
}
|