@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/src/reconcile.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Reconciliation for twins (the twins architecture notes, the hard part).
|
|
2
|
+
//
|
|
3
|
+
// A fork diverges from a known base. Meanwhile the REAL service may have changed
|
|
4
|
+
// too (someone edited the issue in Linear while the agent worked in the fork).
|
|
5
|
+
// Reconciliation is the THREE-WAY merge that decides, per field, what the
|
|
6
|
+
// converged value should be — exactly like a VCS merge:
|
|
7
|
+
//
|
|
8
|
+
// base = the value at fork time (the common ancestor)
|
|
9
|
+
// fork = the value now in the twin (what the agent changed)
|
|
10
|
+
// real = the value now in the real service (a fresh pull)
|
|
11
|
+
//
|
|
12
|
+
// This module is PURE and deterministic: it takes three resource snapshots and a
|
|
13
|
+
// policy, and returns a plan (per-field decisions + conflicts). It performs NO
|
|
14
|
+
// I/O and touches NO real service — applying the plan (pushing twin-wins fields
|
|
15
|
+
// back to real, or pulling real-wins fields into the twin) is a separate, gated
|
|
16
|
+
// step. Computing the plan safely is the foot-gun surface; we make it inspectable
|
|
17
|
+
// BEFORE anything is enacted.
|
|
18
|
+
import type { TwinResource } from './serve.ts';
|
|
19
|
+
|
|
20
|
+
export type ReconcilePolicy = 'hub-wins' | 'twin-wins' | 'merge';
|
|
21
|
+
|
|
22
|
+
// What happened to a single field across the three versions.
|
|
23
|
+
export type FieldDecision = {
|
|
24
|
+
field: string;
|
|
25
|
+
base: unknown;
|
|
26
|
+
fork: unknown;
|
|
27
|
+
real: unknown;
|
|
28
|
+
// forkChanged / realChanged relative to base.
|
|
29
|
+
forkChanged: boolean;
|
|
30
|
+
realChanged: boolean;
|
|
31
|
+
// The chosen value + why. 'conflict' means both sides changed to different
|
|
32
|
+
// values and the policy could not auto-resolve (merge policy only).
|
|
33
|
+
resolution: 'unchanged' | 'take-fork' | 'take-real' | 'conflict';
|
|
34
|
+
value: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type SubjectReconcile = {
|
|
38
|
+
id: string;
|
|
39
|
+
type: string;
|
|
40
|
+
// present: in which versions the subject exists.
|
|
41
|
+
inFork: boolean;
|
|
42
|
+
inReal: boolean;
|
|
43
|
+
existence: 'both' | 'fork-only' | 'real-only';
|
|
44
|
+
fields: FieldDecision[];
|
|
45
|
+
conflicts: FieldDecision[]; // subset of fields with resolution === 'conflict'
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ReconcilePlan = {
|
|
49
|
+
policy: ReconcilePolicy;
|
|
50
|
+
subjects: SubjectReconcile[];
|
|
51
|
+
// Direction summaries to enact later (still gated, never auto-applied here).
|
|
52
|
+
toPush: Array<{ id: string; type: string; fields: Record<string, unknown> }>; // twin → real
|
|
53
|
+
toPull: Array<{ id: string; type: string; fields: Record<string, unknown> }>; // real → twin
|
|
54
|
+
conflictCount: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const META_FIELDS = new Set(['id', 'type', 'updatedAt']);
|
|
58
|
+
|
|
59
|
+
function eq(a: unknown, b: unknown): boolean {
|
|
60
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dataFields(r: TwinResource | undefined): Record<string, unknown> {
|
|
64
|
+
if (!r) return {};
|
|
65
|
+
const out: Record<string, unknown> = {};
|
|
66
|
+
for (const [k, v] of Object.entries(r)) if (!META_FIELDS.has(k)) out[k] = v;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function decideField(field: string, base: unknown, fork: unknown, real: unknown, policy: ReconcilePolicy): FieldDecision {
|
|
71
|
+
const forkChanged = !eq(base, fork);
|
|
72
|
+
const realChanged = !eq(base, real);
|
|
73
|
+
let resolution: FieldDecision['resolution'];
|
|
74
|
+
let value: unknown;
|
|
75
|
+
|
|
76
|
+
if (!forkChanged && !realChanged) {
|
|
77
|
+
resolution = 'unchanged';
|
|
78
|
+
value = base;
|
|
79
|
+
} else if (forkChanged && !realChanged) {
|
|
80
|
+
// only the twin changed it
|
|
81
|
+
resolution = policy === 'hub-wins' ? 'take-real' : 'take-fork';
|
|
82
|
+
value = resolution === 'take-fork' ? fork : real;
|
|
83
|
+
} else if (!forkChanged && realChanged) {
|
|
84
|
+
// only the real service changed it
|
|
85
|
+
resolution = policy === 'twin-wins' ? 'take-fork' : 'take-real';
|
|
86
|
+
value = resolution === 'take-fork' ? fork : real;
|
|
87
|
+
} else {
|
|
88
|
+
// BOTH changed (relative to base) — the genuine conflict case
|
|
89
|
+
if (eq(fork, real)) {
|
|
90
|
+
resolution = 'take-fork'; // converged independently to the same value
|
|
91
|
+
value = fork;
|
|
92
|
+
} else if (policy === 'hub-wins') {
|
|
93
|
+
resolution = 'take-real';
|
|
94
|
+
value = real;
|
|
95
|
+
} else if (policy === 'twin-wins') {
|
|
96
|
+
resolution = 'take-fork';
|
|
97
|
+
value = fork;
|
|
98
|
+
} else {
|
|
99
|
+
resolution = 'conflict'; // merge policy surfaces it; no silent pick
|
|
100
|
+
value = real; // safe default: do NOT overwrite real on an unresolved conflict
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { field, base, fork, real, forkChanged, realChanged, resolution, value };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compute a three-way reconciliation plan. Inputs are resource snapshots keyed
|
|
108
|
+
* by id (base = at fork time, fork = twin now, real = fresh pull). Pure.
|
|
109
|
+
*/
|
|
110
|
+
export function reconcile(opts: {
|
|
111
|
+
policy: ReconcilePolicy;
|
|
112
|
+
base: TwinResource[];
|
|
113
|
+
fork: TwinResource[];
|
|
114
|
+
real: TwinResource[];
|
|
115
|
+
}): ReconcilePlan {
|
|
116
|
+
const { policy } = opts;
|
|
117
|
+
const baseById = new Map(opts.base.map((r) => [r.id, r]));
|
|
118
|
+
const forkById = new Map(opts.fork.map((r) => [r.id, r]));
|
|
119
|
+
const realById = new Map(opts.real.map((r) => [r.id, r]));
|
|
120
|
+
const allIds = [...new Set([...forkById.keys(), ...realById.keys()])].sort();
|
|
121
|
+
|
|
122
|
+
const subjects: SubjectReconcile[] = [];
|
|
123
|
+
const toPush: ReconcilePlan['toPush'] = [];
|
|
124
|
+
const toPull: ReconcilePlan['toPull'] = [];
|
|
125
|
+
let conflictCount = 0;
|
|
126
|
+
|
|
127
|
+
for (const id of allIds) {
|
|
128
|
+
const baseR = baseById.get(id);
|
|
129
|
+
const forkR = forkById.get(id);
|
|
130
|
+
const realR = realById.get(id);
|
|
131
|
+
const inFork = !!forkR;
|
|
132
|
+
const inReal = !!realR;
|
|
133
|
+
const existence: SubjectReconcile['existence'] = inFork && inReal ? 'both' : inFork ? 'fork-only' : 'real-only';
|
|
134
|
+
const type = (forkR?.type ?? realR?.type ?? baseR?.type ?? 'unknown') as string;
|
|
135
|
+
|
|
136
|
+
const baseF = dataFields(baseR);
|
|
137
|
+
const forkF = dataFields(forkR);
|
|
138
|
+
const realF = dataFields(realR);
|
|
139
|
+
const fieldNames = [...new Set([...Object.keys(baseF), ...Object.keys(forkF), ...Object.keys(realF)])].sort();
|
|
140
|
+
|
|
141
|
+
const fields = fieldNames.map((f) => decideField(f, baseF[f], forkF[f], realF[f], policy));
|
|
142
|
+
const conflicts = fields.filter((d) => d.resolution === 'conflict');
|
|
143
|
+
conflictCount += conflicts.length;
|
|
144
|
+
subjects.push({ id, type, inFork, inReal, existence, fields, conflicts });
|
|
145
|
+
|
|
146
|
+
// Enactment summaries (gated; not applied here):
|
|
147
|
+
const pushFields: Record<string, unknown> = {};
|
|
148
|
+
const pullFields: Record<string, unknown> = {};
|
|
149
|
+
if (existence === 'both') {
|
|
150
|
+
// field-level convergence only makes sense when the subject is on both sides:
|
|
151
|
+
// - fields resolved 'take-fork' that differ from real → push to real.
|
|
152
|
+
// - fields resolved 'take-real' that differ from fork → pull into twin.
|
|
153
|
+
for (const d of fields) {
|
|
154
|
+
if (d.resolution === 'take-fork' && !eq(d.value, d.real)) pushFields[d.field] = d.value;
|
|
155
|
+
if (d.resolution === 'take-real' && !eq(d.value, d.fork)) pullFields[d.field] = d.value;
|
|
156
|
+
}
|
|
157
|
+
} else if (existence === 'fork-only') {
|
|
158
|
+
// present in the twin, absent in real. Under twin-wins/merge that's a
|
|
159
|
+
// create-on-real candidate; under hub-wins (real authoritative) the fork's
|
|
160
|
+
// creation is discarded — neither pushed nor pulled.
|
|
161
|
+
if (policy !== 'hub-wins') for (const [k, v] of Object.entries(forkF)) pushFields[k] = v;
|
|
162
|
+
} else {
|
|
163
|
+
// real-only: a create-in-twin candidate (pull) under every policy.
|
|
164
|
+
for (const [k, v] of Object.entries(realF)) pullFields[k] = v;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Object.keys(pushFields).length > 0) toPush.push({ id, type, fields: pushFields });
|
|
168
|
+
if (Object.keys(pullFields).length > 0) toPull.push({ id, type, fields: pullFields });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { policy, subjects, toPush, toPull, conflictCount };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** True when the plan can be enacted without a human decision. */
|
|
175
|
+
export function isCleanlyReconcilable(plan: ReconcilePlan): boolean {
|
|
176
|
+
return plan.conflictCount === 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether enacting this plan's `toPush` requires explicit approval before
|
|
181
|
+
* `syncPush` may perform it. Nothing pushed → nothing to gate. Otherwise: any
|
|
182
|
+
* unresolved conflict anywhere in the plan requires approval (never silently
|
|
183
|
+
* applied), and so does any pushed field whose resolution is 'take-fork' while
|
|
184
|
+
* `realChanged` is true — twin-wins discarding a change that also happened on the
|
|
185
|
+
* real side, the "destructive twin-wins reconcile push" case that must not go out
|
|
186
|
+
* silently.
|
|
187
|
+
*/
|
|
188
|
+
export function reconcileRequiresApproval(plan: ReconcilePlan): boolean {
|
|
189
|
+
if (plan.toPush.length === 0) return false;
|
|
190
|
+
if (plan.conflictCount > 0) return true;
|
|
191
|
+
return plan.subjects.some((s) => s.fields.some((f) => f.resolution === 'take-fork' && f.realChanged));
|
|
192
|
+
}
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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.ts';
|
|
10
|
+
|
|
11
|
+
export type WorldRemoteRef = {
|
|
12
|
+
service: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
/** "main" by default; a named checkpoint otherwise. */
|
|
15
|
+
name: string;
|
|
16
|
+
eventId?: string;
|
|
17
|
+
cursor?: string;
|
|
18
|
+
providerVersion?: string;
|
|
19
|
+
observedAt: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WorldLocalRef = {
|
|
23
|
+
service: string;
|
|
24
|
+
/** the fork this ref belongs to. */
|
|
25
|
+
forkId: string;
|
|
26
|
+
/** the remote ref the fork is based on (provider + name + checkpoint). */
|
|
27
|
+
baseRemoteRef: WorldRemoteRef;
|
|
28
|
+
recordedAt: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function refsDir(service: string, root?: string): string {
|
|
32
|
+
return join(worldPaths(service, root).dir, 'refs');
|
|
33
|
+
}
|
|
34
|
+
function remoteRefPath(service: string, provider: string, name: string, root?: string): string {
|
|
35
|
+
return join(refsDir(service, root), 'remote', provider, `${name}.json`);
|
|
36
|
+
}
|
|
37
|
+
function localRefPath(service: string, forkId: string, root?: string): string {
|
|
38
|
+
return join(refsDir(service, root), 'local', `${forkId}.json`);
|
|
39
|
+
}
|
|
40
|
+
function safe(part: string): string {
|
|
41
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(part)) throw new Error(`invalid ref segment: ${part}`);
|
|
42
|
+
return part;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Record/advance a provider checkpoint (e.g. after a confirmed pull). */
|
|
46
|
+
export function writeRemoteRef(ref: WorldRemoteRef, root?: string): WorldRemoteRef {
|
|
47
|
+
const path = remoteRefPath(ref.service, safe(ref.provider), safe(ref.name), root);
|
|
48
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
49
|
+
writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
|
|
50
|
+
return ref;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readRemoteRef(service: string, provider: string, name = 'main', root?: string): WorldRemoteRef | null {
|
|
54
|
+
const path = remoteRefPath(service, safe(provider), safe(name), root);
|
|
55
|
+
return existsSync(path) ? readJsonFile<WorldRemoteRef>(path) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listRemoteRefs(service: string, root?: string): WorldRemoteRef[] {
|
|
59
|
+
const base = join(refsDir(service, root), 'remote');
|
|
60
|
+
if (!existsSync(base)) return [];
|
|
61
|
+
const out: WorldRemoteRef[] = [];
|
|
62
|
+
for (const provider of readdirSync(base)) {
|
|
63
|
+
const providerDir = join(base, provider);
|
|
64
|
+
for (const file of readdirSync(providerDir)) {
|
|
65
|
+
if (file.endsWith('.json')) out.push(readJsonFile<WorldRemoteRef>(join(providerDir, file)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out.sort((a, b) => (a.provider === b.provider ? a.name.localeCompare(b.name) : a.provider.localeCompare(b.provider)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function writeLocalRef(ref: WorldLocalRef, root?: string): WorldLocalRef {
|
|
72
|
+
const path = localRefPath(ref.service, safe(ref.forkId), root);
|
|
73
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
74
|
+
writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
|
|
75
|
+
return ref;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readLocalRef(service: string, forkId: string, root?: string): WorldLocalRef | null {
|
|
79
|
+
const path = localRefPath(service, safe(forkId), root);
|
|
80
|
+
return existsSync(path) ? readJsonFile<WorldLocalRef>(path) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is a fork's base ref still current against the live remote ref? A push against a
|
|
85
|
+
* stale base must be blocked or explicitly reconciled (rebase) first.
|
|
86
|
+
*/
|
|
87
|
+
export function isBaseStale(local: WorldLocalRef, root?: string): boolean {
|
|
88
|
+
const current = readRemoteRef(local.service, local.baseRemoteRef.provider, local.baseRemoteRef.name, root);
|
|
89
|
+
if (!current) return false; // no live checkpoint recorded yet → cannot be stale
|
|
90
|
+
return (current.eventId ?? current.cursor ?? '') !== (local.baseRemoteRef.eventId ?? local.baseRemoteRef.cursor ?? '');
|
|
91
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const WorldActorSchema = z.object({
|
|
4
|
+
id: z.string().min(1).optional(),
|
|
5
|
+
name: z.string().min(1).optional(),
|
|
6
|
+
kind: z.enum(['human', 'agent', 'bot', 'system']),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const WorldSubjectSchema = z.object({
|
|
10
|
+
type: z.string().min(1),
|
|
11
|
+
id: z.string().min(1),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const WorldExternalRefSchema = z.object({
|
|
15
|
+
provider: z.string().min(1),
|
|
16
|
+
id: z.string().min(1).optional(),
|
|
17
|
+
url: z.string().min(1).optional(),
|
|
18
|
+
cursor: z.string().min(1).optional(),
|
|
19
|
+
rawRef: z.string().min(1).optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const WorldServiceEventSchema = z.object({
|
|
23
|
+
id: z.string().min(1),
|
|
24
|
+
service: z.string().min(1),
|
|
25
|
+
type: z.string().min(1),
|
|
26
|
+
schemaVersion: z.number().int().positive(),
|
|
27
|
+
idempotencyKey: z.string().min(1),
|
|
28
|
+
occurredAt: z.string().min(1),
|
|
29
|
+
observedAt: z.string().min(1),
|
|
30
|
+
origin: z.enum(['virtual', 'external', 'agent', 'connector', 'replay', 'migration']),
|
|
31
|
+
actor: WorldActorSchema.optional(),
|
|
32
|
+
subject: WorldSubjectSchema,
|
|
33
|
+
causationId: z.string().min(1).optional(),
|
|
34
|
+
correlationId: z.string().min(1).optional(),
|
|
35
|
+
external: WorldExternalRefSchema.optional(),
|
|
36
|
+
data: z.record(z.string(), z.unknown()),
|
|
37
|
+
raw: z.record(z.string(), z.unknown()).optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// NOTE: WorldAnnotationSchema moved to @volter/tracker/world-annotations — annotating
|
|
41
|
+
// world events as sources/noise is a tracker (verification) concern, not the twin's.
|
|
42
|
+
|
|
43
|
+
export const GenericWorldStateSchema = z.object({
|
|
44
|
+
version: z.literal(1),
|
|
45
|
+
service: z.string().min(1),
|
|
46
|
+
rebuiltAt: z.string().min(1),
|
|
47
|
+
eventCount: z.number().int().nonnegative(),
|
|
48
|
+
latestEventId: z.string().min(1).optional(),
|
|
49
|
+
subjects: z.record(z.string(), z.object({
|
|
50
|
+
type: z.string().min(1),
|
|
51
|
+
id: z.string().min(1),
|
|
52
|
+
latestEventId: z.string().min(1),
|
|
53
|
+
latestType: z.string().min(1),
|
|
54
|
+
updatedAt: z.string().min(1),
|
|
55
|
+
})),
|
|
56
|
+
});
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Twin serve layer (the twins architecture notes, increment 1): answer vendor-shaped
|
|
2
|
+
// READS from the local event-sourced state — the "local twin" read path. An app
|
|
3
|
+
// or agent points at this instead of the real vendor; it responds from local
|
|
4
|
+
// state with the real service unreachable.
|
|
5
|
+
//
|
|
6
|
+
// There is ONE twin, not a set of "modes". Pulling reality, writing locally, and
|
|
7
|
+
// forking are operations on the same substrate (events + local actions + the
|
|
8
|
+
// projection over them), not exclusive modes — see the architecture doc, "a twin
|
|
9
|
+
// is a repo". The only serve-time policy here is `readOnly`: a twin accepts local
|
|
10
|
+
// writes (as actions) unless you start it read-only (a pure mirror of pulled
|
|
11
|
+
// reality). Forking is a separate data operation (fork.ts), never a serve mode.
|
|
12
|
+
import type { SubjectFields } from './shadow.ts';
|
|
13
|
+
import { hashFieldValue } from './shadow.ts';
|
|
14
|
+
import { appendActionIfAbsent, projectResources } from './actions.ts';
|
|
15
|
+
import type { TwinActionPrecondition } from './actions.ts';
|
|
16
|
+
|
|
17
|
+
// A subject rendered as a vendor resource: its folded current fields + identity.
|
|
18
|
+
export type TwinResource = { id: string; type: string; updatedAt: string } & Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
// The twin's current view = the OBSERVED mirror (events.jsonl) with the local
|
|
21
|
+
// ACTION log (actions.jsonl) projected over it (R18). Observed facts and local
|
|
22
|
+
// actions are kept separate; this is the single projected read model.
|
|
23
|
+
export function twinResources(service: string, root?: string): TwinResource[] {
|
|
24
|
+
return projectResources(service, root);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Result of a local twin write — it is an ACTION, not an egress/real write.
|
|
28
|
+
export type TwinWriteResult = { status: 'performed' | 'replayed'; actionId: string };
|
|
29
|
+
|
|
30
|
+
// Resolve a read request against the twin's resources. Returns the matched
|
|
31
|
+
// resource(s) + an HTTP-ish status, deterministically from state.
|
|
32
|
+
// GET / -> { service, mode, resourceTypes, count }
|
|
33
|
+
// GET /<type> -> list of resources of that type
|
|
34
|
+
// GET /<type>/<id> -> one resource (id may be url-encoded)
|
|
35
|
+
export function resolveTwinRead(
|
|
36
|
+
service: string,
|
|
37
|
+
pathname: string,
|
|
38
|
+
opts: { root?: string } = {},
|
|
39
|
+
): { status: number; body: unknown } {
|
|
40
|
+
const resources = twinResources(service, opts.root);
|
|
41
|
+
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
42
|
+
|
|
43
|
+
if (parts.length === 0) {
|
|
44
|
+
const types = [...new Set(resources.map((r) => r.type))].sort();
|
|
45
|
+
return { status: 200, body: { service, resourceTypes: types, count: resources.length } };
|
|
46
|
+
}
|
|
47
|
+
const type = parts[0]!;
|
|
48
|
+
const ofType = resources.filter((r) => r.type === type);
|
|
49
|
+
if (parts.length === 1) {
|
|
50
|
+
return { status: 200, body: { type, count: ofType.length, items: ofType } };
|
|
51
|
+
}
|
|
52
|
+
const id = decodeURIComponent(parts.slice(1).join('/'));
|
|
53
|
+
const found = ofType.find((r) => r.id === id);
|
|
54
|
+
if (!found) return { status: 404, body: { error: 'not_found', type, id } };
|
|
55
|
+
return { status: 200, body: found };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Simulator/fork-mode write: the twin ACCEPTS a write as a LOCAL ACTION appended
|
|
59
|
+
// to the action log (R18) — NOT an observed event and NOT an egress/real write.
|
|
60
|
+
// State is the mirror with this action projected over it, so a subsequent read
|
|
61
|
+
// returns the change. The observed event log is untouched; the real service is
|
|
62
|
+
// provably untouched (no network I/O, no egress). Pushing the action to the real
|
|
63
|
+
// vendor is a separate, explicit step that records egress + confirms the action.
|
|
64
|
+
export async function applyTwinWrite(
|
|
65
|
+
service: string,
|
|
66
|
+
write: { operation: string; provider?: string; subjectType: string; subjectId: string; fields: SubjectFields; occurredAt?: string; actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string }; preconditions?: TwinActionPrecondition[]; correlationId?: string },
|
|
67
|
+
root?: string,
|
|
68
|
+
): Promise<{ result: TwinWriteResult; resource: TwinResource }> {
|
|
69
|
+
const occurredAt = write.occurredAt ?? new Date().toISOString();
|
|
70
|
+
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
71
|
+
// CONTENT (operation + subject + fields), not just the timestamp — so two DIFFERENT writes to
|
|
72
|
+
// the same subject in the same millisecond are BOTH kept (previously the second silently
|
|
73
|
+
// no-opped: e.g. a sprint "start" then "close" in the same ms lost the close). Check + append
|
|
74
|
+
// run atomically under the actions lock, so concurrent processes can't double-apply either.
|
|
75
|
+
const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields });
|
|
76
|
+
const actionId = `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}`;
|
|
77
|
+
// correlationId (D3): pass a caller-supplied request-scoped id through (e.g. propagated
|
|
78
|
+
// from an inbound HTTP request id) so it lands on the action row; appendActionIfAbsent
|
|
79
|
+
// generates one when omitted, so it's never missing.
|
|
80
|
+
const { appended } = appendActionIfAbsent(
|
|
81
|
+
{ 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 },
|
|
82
|
+
root,
|
|
83
|
+
);
|
|
84
|
+
// Resolve by (type, id) — an id alone is ambiguous when two resource TYPES share it.
|
|
85
|
+
const resource = projectResources(service, root).find((r) => r.type === write.subjectType && r.id === write.subjectId)
|
|
86
|
+
?? ({ id: write.subjectId, type: write.subjectType, updatedAt: occurredAt, ...write.fields } as TwinResource);
|
|
87
|
+
return { result: { status: appended ? 'performed' : 'replayed', actionId }, resource };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createTwinServer(options: { service: string; root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
|
|
91
|
+
const readOnly = options.readOnly ?? false;
|
|
92
|
+
const server = Bun.serve({
|
|
93
|
+
port: options.port ?? 0,
|
|
94
|
+
idleTimeout: 60,
|
|
95
|
+
async fetch(request) {
|
|
96
|
+
const url = new URL(request.url);
|
|
97
|
+
const json = (status: number, body: unknown) => new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
|
|
98
|
+
if (request.method === 'GET') {
|
|
99
|
+
// Re-read per request so a concurrently-syncing twin serves fresh state.
|
|
100
|
+
const { status, body } = resolveTwinRead(options.service, url.pathname, { root: options.root });
|
|
101
|
+
return json(status, body);
|
|
102
|
+
}
|
|
103
|
+
// Writes are accepted as local actions unless this twin was started read-only.
|
|
104
|
+
if (readOnly) return json(405, { error: 'read_only', hint: 'this twin was started read-only; omit readOnly to accept writes' });
|
|
105
|
+
const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
106
|
+
if (parts.length < 2) return json(400, { error: 'write_needs_type_and_id', hint: 'POST /<type>/<id> with a JSON body of fields' });
|
|
107
|
+
let fields: SubjectFields;
|
|
108
|
+
try { fields = (await request.json()) as SubjectFields; } catch { return json(400, { error: 'invalid_json_body' }); }
|
|
109
|
+
const { result, resource } = await applyTwinWrite(options.service, {
|
|
110
|
+
operation: `${request.method.toLowerCase()}.${parts[0]}`,
|
|
111
|
+
subjectType: parts[0]!,
|
|
112
|
+
subjectId: decodeURIComponent(parts.slice(1).join('/')),
|
|
113
|
+
fields,
|
|
114
|
+
actor: { kind: 'agent' },
|
|
115
|
+
}, options.root);
|
|
116
|
+
return json(result.status === 'replayed' ? 200 : 201, { status: result.status, resource });
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return { port: server.port ?? 0, stop: () => server.stop(true) };
|
|
120
|
+
}
|
package/src/shadow.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isEgressEventType } from './egress.ts';
|
|
3
|
+
import { appendEvent, createEvent, listEvents } from './storage.ts';
|
|
4
|
+
import type { AppendEventResult, WorldServiceEvent } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export const DELTA_TYPE_SUFFIX = '.delta';
|
|
7
|
+
|
|
8
|
+
export type SubjectFields = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export type SubjectShadow = {
|
|
11
|
+
subject: { type: string; id: string };
|
|
12
|
+
fields: SubjectFields;
|
|
13
|
+
fieldHashes: Record<string, string>;
|
|
14
|
+
latestEventId: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ShadowState = {
|
|
19
|
+
version: 1;
|
|
20
|
+
service: string;
|
|
21
|
+
rebuiltAt: string;
|
|
22
|
+
subjects: Record<string, SubjectShadow>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps a world event to the subject fields it observes, or null when the
|
|
27
|
+
* event says nothing about remote subject state (comments, egress records, …).
|
|
28
|
+
* Extractors are provider-specific; the shadow engine is not.
|
|
29
|
+
*/
|
|
30
|
+
export type SubjectFieldExtractor = (event: WorldServiceEvent) => SubjectFields | null;
|
|
31
|
+
|
|
32
|
+
export type FieldChange = { before: unknown; after: unknown };
|
|
33
|
+
|
|
34
|
+
function canonicalJson(value: unknown): string {
|
|
35
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
38
|
+
.filter(([, item]) => item !== undefined)
|
|
39
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
40
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`);
|
|
41
|
+
return `{${entries.join(',')}}`;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(value) ?? 'null';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hashFieldValue(value: unknown): string {
|
|
47
|
+
return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function subjectKey(subject: { type: string; id: string }): string {
|
|
51
|
+
return `${subject.type}:${subject.id}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isDeltaEvent(event: WorldServiceEvent): boolean {
|
|
55
|
+
return event.type.endsWith(DELTA_TYPE_SUFFIX) && typeof event.data.changed === 'object';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deltaAfterFields(event: WorldServiceEvent): SubjectFields {
|
|
59
|
+
const changed = event.data.changed as Record<string, FieldChange>;
|
|
60
|
+
const fields: SubjectFields = {};
|
|
61
|
+
for (const [field, change] of Object.entries(changed)) fields[field] = change.after;
|
|
62
|
+
return fields;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function applyFields(state: ShadowState, event: WorldServiceEvent, fields: SubjectFields): void {
|
|
66
|
+
const key = subjectKey(event.subject);
|
|
67
|
+
const existing = state.subjects[key];
|
|
68
|
+
// A late-arriving observation of OLDER provider state must not regress the
|
|
69
|
+
// shadow. Comparable occurredAt timestamps win over log append order.
|
|
70
|
+
if (existing) {
|
|
71
|
+
const incoming = Date.parse(event.occurredAt);
|
|
72
|
+
const current = Date.parse(existing.updatedAt);
|
|
73
|
+
if (Number.isFinite(incoming) && Number.isFinite(current) && incoming < current) return;
|
|
74
|
+
}
|
|
75
|
+
const target = existing ?? {
|
|
76
|
+
subject: { ...event.subject },
|
|
77
|
+
fields: {},
|
|
78
|
+
fieldHashes: {},
|
|
79
|
+
latestEventId: event.id,
|
|
80
|
+
updatedAt: event.occurredAt,
|
|
81
|
+
};
|
|
82
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
83
|
+
if (value === undefined) continue;
|
|
84
|
+
target.fields[field] = value;
|
|
85
|
+
target.fieldHashes[field] = hashFieldValue(value);
|
|
86
|
+
}
|
|
87
|
+
target.latestEventId = event.id;
|
|
88
|
+
target.updatedAt = event.occurredAt || target.updatedAt;
|
|
89
|
+
state.subjects[key] = target;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fold the service event log into per-subject materialized remote state.
|
|
94
|
+
* Delta events apply their `after` values directly; every other event goes
|
|
95
|
+
* through the extractor. The log is the source of truth — the shadow is
|
|
96
|
+
* always reproducible from a replay.
|
|
97
|
+
*/
|
|
98
|
+
export function buildShadowState(
|
|
99
|
+
service: string,
|
|
100
|
+
extractor: SubjectFieldExtractor,
|
|
101
|
+
root?: string,
|
|
102
|
+
): ShadowState {
|
|
103
|
+
const state: ShadowState = { version: 1, service, rebuiltAt: new Date().toISOString(), subjects: {} };
|
|
104
|
+
for (const event of listEvents(service, root)) {
|
|
105
|
+
if (isEgressEventType(event.type)) continue;
|
|
106
|
+
if (isDeltaEvent(event)) {
|
|
107
|
+
applyFields(state, event, deltaAfterFields(event));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const fields = extractor(event);
|
|
111
|
+
if (fields) applyFields(state, event, fields);
|
|
112
|
+
}
|
|
113
|
+
return state;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Field-level diff of freshly observed subject fields against the shadow. */
|
|
117
|
+
export function diffSubjectFields(
|
|
118
|
+
shadow: SubjectShadow | undefined,
|
|
119
|
+
observed: SubjectFields,
|
|
120
|
+
): Record<string, FieldChange> {
|
|
121
|
+
const changed: Record<string, FieldChange> = {};
|
|
122
|
+
for (const [field, after] of Object.entries(observed)) {
|
|
123
|
+
if (after === undefined) continue;
|
|
124
|
+
const beforeHash = shadow?.fieldHashes[field];
|
|
125
|
+
if (beforeHash !== undefined && beforeHash === hashFieldValue(after)) continue;
|
|
126
|
+
changed[field] = { before: shadow?.fields[field], after };
|
|
127
|
+
}
|
|
128
|
+
return changed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type DeltaObservation = {
|
|
132
|
+
service: string;
|
|
133
|
+
subject: { type: string; id: string };
|
|
134
|
+
/** Freshly fetched provider-side fields for the subject. */
|
|
135
|
+
observed: SubjectFields;
|
|
136
|
+
/** Provider timestamp of the observed state when available. */
|
|
137
|
+
occurredAt?: string;
|
|
138
|
+
external?: { provider: string; id?: string; url?: string; cursor?: string };
|
|
139
|
+
actor?: { kind: 'human' | 'agent' | 'bot' | 'system'; id?: string; name?: string };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type DeltaResult =
|
|
143
|
+
| { changed: false }
|
|
144
|
+
| { changed: true; event: WorldServiceEvent; changes: Record<string, FieldChange>; append: AppendEventResult };
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compare an observed provider snapshot against the shadow and append a
|
|
148
|
+
* single delta event carrying only the changed fields. An unchanged
|
|
149
|
+
* re-observation appends nothing — this is the fix for re-snapshot noise.
|
|
150
|
+
* The delta idempotencyKey is derived from the after-state hashes, so the
|
|
151
|
+
* same observed change never appends twice.
|
|
152
|
+
*/
|
|
153
|
+
export function recordObservedDelta(
|
|
154
|
+
state: ShadowState,
|
|
155
|
+
observation: DeltaObservation,
|
|
156
|
+
root?: string,
|
|
157
|
+
): DeltaResult {
|
|
158
|
+
const shadow = state.subjects[subjectKey(observation.subject)];
|
|
159
|
+
const changes = diffSubjectFields(shadow, observation.observed);
|
|
160
|
+
if (Object.keys(changes).length === 0) return { changed: false };
|
|
161
|
+
|
|
162
|
+
// The hash covers the provider timestamp as well as the after-state:
|
|
163
|
+
// A→B→A→B transitions revisit an after-state, and an id derived from
|
|
164
|
+
// content alone would collide with the earlier delta and make appendEvent
|
|
165
|
+
// throw (same id, different occurredAt). Re-observing the SAME provider
|
|
166
|
+
// state still dedupes — occurredAt comes from the provider, not the poll.
|
|
167
|
+
const afterHash = createHash('sha256')
|
|
168
|
+
.update(observation.occurredAt ?? '')
|
|
169
|
+
.update('\u001f')
|
|
170
|
+
.update(canonicalJson(Object.fromEntries(Object.entries(changes).map(([field, change]) => [field, change.after]))))
|
|
171
|
+
.digest('hex')
|
|
172
|
+
.slice(0, 16);
|
|
173
|
+
const type = `${observation.service}.${observation.subject.type}${DELTA_TYPE_SUFFIX}`;
|
|
174
|
+
const event = createEvent({
|
|
175
|
+
id: `${type}:${observation.subject.id}:${afterHash}`,
|
|
176
|
+
service: observation.service,
|
|
177
|
+
type,
|
|
178
|
+
idempotencyKey: `${observation.service}:delta:${observation.subject.type}:${observation.subject.id}:${afterHash}`,
|
|
179
|
+
occurredAt: observation.occurredAt ?? new Date().toISOString(),
|
|
180
|
+
origin: 'connector',
|
|
181
|
+
...(observation.actor ? { actor: observation.actor } : {}),
|
|
182
|
+
subject: observation.subject,
|
|
183
|
+
...(observation.external ? { external: observation.external } : {}),
|
|
184
|
+
data: {
|
|
185
|
+
changedFields: Object.keys(changes),
|
|
186
|
+
changed: changes,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const append = appendEvent(event, root);
|
|
190
|
+
if (append.appended) applyFields(state, append.event, deltaAfterFields(append.event));
|
|
191
|
+
return { changed: true, event: append.event, changes, append };
|
|
192
|
+
}
|