@volter/twin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/src/actions.d.ts +138 -0
  4. package/dist/src/actions.js +201 -0
  5. package/dist/src/args.d.ts +3 -0
  6. package/dist/src/args.js +12 -0
  7. package/dist/src/cli.d.ts +2 -0
  8. package/dist/src/cli.js +425 -0
  9. package/dist/src/connector.d.ts +106 -0
  10. package/dist/src/connector.js +129 -0
  11. package/dist/src/control-plane.d.ts +21 -0
  12. package/dist/src/control-plane.js +40 -0
  13. package/dist/src/egress.d.ts +93 -0
  14. package/dist/src/egress.js +264 -0
  15. package/dist/src/fork.d.ts +126 -0
  16. package/dist/src/fork.js +206 -0
  17. package/dist/src/index.d.ts +42 -0
  18. package/dist/src/index.js +52 -0
  19. package/dist/src/lease.d.ts +50 -0
  20. package/dist/src/lease.js +80 -0
  21. package/dist/src/packRegistry.d.ts +34 -0
  22. package/dist/src/packRegistry.js +22 -0
  23. package/dist/src/plan.d.ts +97 -0
  24. package/dist/src/plan.js +151 -0
  25. package/dist/src/proxy.d.ts +25 -0
  26. package/dist/src/proxy.js +152 -0
  27. package/dist/src/pushLedger.d.ts +81 -0
  28. package/dist/src/pushLedger.js +130 -0
  29. package/dist/src/queueLifecycle.d.ts +62 -0
  30. package/dist/src/queueLifecycle.js +95 -0
  31. package/dist/src/reconcile.d.ts +58 -0
  32. package/dist/src/reconcile.js +137 -0
  33. package/dist/src/refs.d.ts +29 -0
  34. package/dist/src/refs.js +68 -0
  35. package/dist/src/schemas.d.ts +78 -0
  36. package/dist/src/schemas.js +50 -0
  37. package/dist/src/serve.d.ts +44 -0
  38. package/dist/src/serve.js +93 -0
  39. package/dist/src/shadow.d.ts +77 -0
  40. package/dist/src/shadow.js +138 -0
  41. package/dist/src/status.d.ts +31 -0
  42. package/dist/src/status.js +42 -0
  43. package/dist/src/storage.d.ts +119 -0
  44. package/dist/src/storage.js +535 -0
  45. package/dist/src/sync.d.ts +91 -0
  46. package/dist/src/sync.js +121 -0
  47. package/dist/src/types.d.ts +40 -0
  48. package/dist/src/types.js +1 -0
  49. package/dist/src/validate.d.ts +27 -0
  50. package/dist/src/validate.js +68 -0
  51. package/dist/src/visualizer.d.ts +13 -0
  52. package/dist/src/visualizer.js +133 -0
  53. package/dist/src/worldConfig.d.ts +9 -0
  54. package/dist/src/worldConfig.js +16 -0
  55. package/inject.cjs +429 -0
  56. package/package.json +81 -0
  57. package/src/actions.ts +285 -0
  58. package/src/args.ts +14 -0
  59. package/src/cli.ts +443 -0
  60. package/src/connector.ts +220 -0
  61. package/src/control-plane.ts +66 -0
  62. package/src/egress.ts +355 -0
  63. package/src/fork.ts +256 -0
  64. package/src/index.ts +222 -0
  65. package/src/lease.ts +97 -0
  66. package/src/packRegistry.ts +60 -0
  67. package/src/plan.ts +190 -0
  68. package/src/proxy.ts +180 -0
  69. package/src/pushLedger.ts +189 -0
  70. package/src/queueLifecycle.ts +130 -0
  71. package/src/reconcile.ts +192 -0
  72. package/src/refs.ts +91 -0
  73. package/src/schemas.ts +56 -0
  74. package/src/serve.ts +120 -0
  75. package/src/shadow.ts +192 -0
  76. package/src/status.ts +58 -0
  77. package/src/storage.ts +632 -0
  78. package/src/sync.ts +160 -0
  79. package/src/types.ts +50 -0
  80. package/src/validate.ts +95 -0
  81. package/src/visualizer.ts +142 -0
  82. package/src/worldConfig.ts +26 -0
@@ -0,0 +1,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
+ }