@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,21 @@
1
+ export { applyTwinWrite, createTwinServer, resolveTwinRead, twinResources } from './serve.js';
2
+ export type { TwinResource, TwinWriteResult } from './serve.js';
3
+ export { formatStatus, worldStatus } from './status.js';
4
+ export type { WorldStatus } from './status.js';
5
+ export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from './plan.js';
6
+ export type { ActionMapper, ApplyResult, ProviderCall, WorldApplyPlan } from './plan.js';
7
+ export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.js';
8
+ export type { PushOutcome, PushStatus, WorldPushRecord } from './pushLedger.js';
9
+ export { acquireLease, activeLease, isLeaseActive, LeaseHeldError, listLeases, releaseLease } from './lease.js';
10
+ export type { WorldApplyLease } from './lease.js';
11
+ export { isBaseStale, listRemoteRefs, readLocalRef, readRemoteRef, writeLocalRef, writeRemoteRef } from './refs.js';
12
+ export type { WorldLocalRef, WorldRemoteRef } from './refs.js';
13
+ export { commitPendingQueue, commitQueueRow, ignoreQueueRow, listQueueWithStatus, pendingQueueRows, poisonQueueRow, queueCounts, queueRowStatus, setQueueRowStatus, supersedeQueueRow, } from './queueLifecycle.js';
14
+ export type { QueueCounts, QueueRowStatus, QueueRowWithStatus, QueueStatusTransition } from './queueLifecycle.js';
15
+ export { confirmAction } from './actions.js';
16
+ export { auditForkNoRealWrites, cherryPickActions, discardFork, forkDivergence, forkTwin, isFork, mergeForks, rebaseFork, readForkMeta, resetFork, } from './fork.js';
17
+ export type { CopyResult, ForkAudit, ForkDivergence, ForkMeta, SubjectDivergence } from './fork.js';
18
+ export { isCleanlyReconcilable, reconcile, reconcileRequiresApproval } from './reconcile.js';
19
+ export type { FieldDecision, ReconcilePlan, ReconcilePolicy, SubjectReconcile } from './reconcile.js';
20
+ export { currentResources, syncPull, syncPush } from './sync.js';
21
+ export type { PullResult, PushItemResult, PushResult, SyncResource } from './sync.js';
@@ -0,0 +1,40 @@
1
+ // The CONTROL PLANE — the legitimately-shared thing across twins.
2
+ //
3
+ // This is the uniform contract + operator surface every twin is *driven* through:
4
+ // the same way to serve/read/write, switch modes, see `status`, build a `plan`,
5
+ // `push`/apply (with explicit phases), hold a `lease`, manage the event-queue
6
+ // lifecycle, track remote refs, and run Git-like fork operations. An operator or
7
+ // agent learns ONE way to drive any twin.
8
+ //
9
+ // What this is NOT: a "kernel" that makes every twin *implement its world the same
10
+ // way*. Twins are implemented per-vendor (their exact API + query semantics). The
11
+ // data-plane mechanics they reuse (event log, projection, shadow/delta, egress,
12
+ // connectors — exported from the package root) are **optional libraries**, like a
13
+ // UI library a visualizer may pick — not a mandate. See
14
+ // `the twins architecture notes` → "share the control plane, not the
15
+ // implementation".
16
+ //
17
+ // Barrel only: re-exports the control-plane surface as one cohesive thing. Consume
18
+ // it as a namespace via `import { controlPlane } from '@volter/twin'`.
19
+ // serve/read/write surface (how a twin is driven): one twin, readable always,
20
+ // writable unless started read-only (writes land as local actions)
21
+ export { applyTwinWrite, createTwinServer, resolveTwinRead, twinResources } from "./serve.js";
22
+ // status
23
+ export { formatStatus, worldStatus } from "./status.js";
24
+ // plan + apply orchestration
25
+ export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from "./plan.js";
26
+ // push ledger + phases
27
+ export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from "./pushLedger.js";
28
+ // apply leases (single-writer per checkpoint)
29
+ export { acquireLease, activeLease, isLeaseActive, LeaseHeldError, listLeases, releaseLease } from "./lease.js";
30
+ // remote refs / checkpoints + stale-base guard
31
+ export { isBaseStale, listRemoteRefs, readLocalRef, readRemoteRef, writeLocalRef, writeRemoteRef } from "./refs.js";
32
+ // event-queue lifecycle (queued/committed/ignored/superseded/poisoned)
33
+ export { commitPendingQueue, commitQueueRow, ignoreQueueRow, listQueueWithStatus, pendingQueueRows, poisonQueueRow, queueCounts, queueRowStatus, setQueueRowStatus, supersedeQueueRow, } from "./queueLifecycle.js";
34
+ // confirm/revert ops (transaction → observed-event mapping)
35
+ export { confirmAction } from "./actions.js";
36
+ // fork operations (rebase/merge/cherry-pick/reset/discard) + three-way reconcile
37
+ export { auditForkNoRealWrites, cherryPickActions, discardFork, forkDivergence, forkTwin, isFork, mergeForks, rebaseFork, readForkMeta, resetFork, } from "./fork.js";
38
+ export { isCleanlyReconcilable, reconcile, reconcileRequiresApproval } from "./reconcile.js";
39
+ // sync direction (pull/push selected resources) — the bidirectional surface
40
+ export { currentResources, syncPull, syncPush } from "./sync.js";
@@ -0,0 +1,93 @@
1
+ import type { WorldServiceEvent } from './types.js';
2
+ export declare const WRITE_INTENT_SUFFIX = ".write.intent";
3
+ export declare const WRITE_RESULT_SUFFIX = ".write.result";
4
+ export declare function isEgressEventType(type: string): boolean;
5
+ export type EgressActor = {
6
+ kind: 'human' | 'agent' | 'bot' | 'system';
7
+ id?: string;
8
+ name?: string;
9
+ };
10
+ export type EgressWriteRequest = {
11
+ service: string;
12
+ /** Provider operation name, e.g. 'chat.postMessage', 'issueCreate'. */
13
+ operation: string;
14
+ /** external.provider value for result events, e.g. 'slack', 'github', 'linear'. */
15
+ provider: string;
16
+ subject: {
17
+ type: string;
18
+ id: string;
19
+ };
20
+ /**
21
+ * Stable key for the logical action. Two calls with the same key are the
22
+ * same action: a completed write is replayed from the ledger, never re-sent.
23
+ */
24
+ idempotencyKey: string;
25
+ actor?: EgressActor;
26
+ /** Request payload summary recorded on the intent event. Never put secrets here. */
27
+ data?: Record<string, unknown>;
28
+ };
29
+ export type EgressWriteOutcome = {
30
+ externalId: string;
31
+ url?: string;
32
+ data?: Record<string, unknown>;
33
+ };
34
+ export type EgressWriteResult = {
35
+ /** 'performed' = writeFn ran this call; 'replayed' = ledger already had a success. */
36
+ status: 'performed' | 'replayed';
37
+ intentEventId: string;
38
+ resultEventId: string;
39
+ attempt: number;
40
+ outcome: EgressWriteOutcome;
41
+ };
42
+ export type EgressLedgerEntry = {
43
+ intent: WorldServiceEvent;
44
+ results: WorldServiceEvent[];
45
+ succeeded: boolean;
46
+ };
47
+ export declare class UnreconciledWriteIntentError extends Error {
48
+ readonly intentEventId: string;
49
+ constructor(intentEventId: string, message: string);
50
+ }
51
+ export declare function recordWriteIntent(request: EgressWriteRequest, root?: string): WorldServiceEvent;
52
+ /**
53
+ * Perform an external write through the egress ledger.
54
+ *
55
+ * Ordering: a write.intent event is durably appended BEFORE the provider call,
56
+ * and a write.result event is appended after it (success or failure). A crash
57
+ * between the two leaves an intent without results; by default the next call
58
+ * with the same idempotencyKey refuses to re-send (UnreconciledWriteIntentError)
59
+ * because the provider may or may not have applied the write. After verifying
60
+ * the external state, callers retry with onUnreconciled: 'retry'.
61
+ *
62
+ * A ledger entry with a success result is terminal: the call returns the
63
+ * recorded outcome without invoking writeFn. Failed attempts may be retried;
64
+ * each attempt appends its own numbered result event.
65
+ *
66
+ * Concurrency (TWIN-58): the ledger-check + intent-append span runs under
67
+ * `withFileLock` on the SAME events lock `appendEvent` itself uses (egress.ts has
68
+ * no lock of its own to invent — reusing the events lock is what makes this
69
+ * atomic against a concurrent appendEvent/commitQueuedEvents too, not just a
70
+ * concurrent performExternalWrite). Without this, two concurrent calls with the
71
+ * same idempotencyKey could both read "no entry" and both invoke writeFn — the
72
+ * vendor receiving the write twice despite this function's contract that a
73
+ * completed write is replayed, never re-sent (see actions.ts:121-123 for the same
74
+ * check-then-append-under-one-lock reasoning applied to the action log). The
75
+ * `writeFn` call itself deliberately stays OUTSIDE the lock (it's arbitrary,
76
+ * possibly slow, real I/O — holding a file lock across it would serialize every
77
+ * write to the service, not just same-key ones); `inFlightWrites` covers the
78
+ * in-process race for the span the lock can't (an async call can't hold a
79
+ * synchronous lock across its own `await`).
80
+ */
81
+ export declare function performExternalWrite(request: EgressWriteRequest, writeFn: () => Promise<EgressWriteOutcome>, options?: {
82
+ root?: string;
83
+ onUnreconciled?: 'fail' | 'retry';
84
+ }): Promise<EgressWriteResult>;
85
+ /** Intents with no result event at all — crash candidates needing reconciliation. */
86
+ export declare function listUnreconciledWriteIntents(service: string, root?: string): WorldServiceEvent[];
87
+ /** Full intent/result ledger for a service, keyed by intent event id. */
88
+ export declare function listEgressLedger(service: string, root?: string): EgressLedgerEntry[];
89
+ /**
90
+ * Echo lookup: did we produce this external object? Inbound events whose
91
+ * external id matches a recorded successful write are our own writes coming back.
92
+ */
93
+ export declare function findWriteResultByExternal(service: string, provider: string, externalId: string, root?: string): WorldServiceEvent | null;
@@ -0,0 +1,264 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendEvent, appendEventLocked, createEvent, eventsLockPath, listEvents, withFileLock, worldPaths } from "./storage.js";
3
+ export const WRITE_INTENT_SUFFIX = '.write.intent';
4
+ export const WRITE_RESULT_SUFFIX = '.write.result';
5
+ export function isEgressEventType(type) {
6
+ return type.endsWith(WRITE_INTENT_SUFFIX) || type.endsWith(WRITE_RESULT_SUFFIX);
7
+ }
8
+ export class UnreconciledWriteIntentError extends Error {
9
+ intentEventId;
10
+ constructor(intentEventId, message) {
11
+ super(message);
12
+ this.name = 'UnreconciledWriteIntentError';
13
+ this.intentEventId = intentEventId;
14
+ }
15
+ }
16
+ function egressHash(request) {
17
+ return createHash('sha256')
18
+ .update(`${request.service}\u001f${request.operation}\u001f${request.idempotencyKey}`)
19
+ .digest('hex')
20
+ .slice(0, 16);
21
+ }
22
+ function intentIdempotencyKey(request) {
23
+ return `egress:${request.service}:${request.operation}:${request.idempotencyKey}:intent`;
24
+ }
25
+ function resultIdempotencyKey(request, attempt) {
26
+ return `egress:${request.service}:${request.operation}:${request.idempotencyKey}:result:${attempt}`;
27
+ }
28
+ function defaultActor(actor) {
29
+ return actor ?? { kind: 'agent' };
30
+ }
31
+ function ledgerFor(service, root) {
32
+ const entries = new Map();
33
+ const resultsByCausation = new Map();
34
+ for (const event of listEvents(service, root)) {
35
+ if (event.type.endsWith(WRITE_INTENT_SUFFIX)) {
36
+ entries.set(event.id, { intent: event, results: [], succeeded: false });
37
+ }
38
+ else if (event.type.endsWith(WRITE_RESULT_SUFFIX) && event.causationId) {
39
+ const bucket = resultsByCausation.get(event.causationId) ?? [];
40
+ bucket.push(event);
41
+ resultsByCausation.set(event.causationId, bucket);
42
+ }
43
+ }
44
+ for (const [intentId, results] of resultsByCausation) {
45
+ const entry = entries.get(intentId);
46
+ if (!entry)
47
+ continue;
48
+ entry.results = results;
49
+ entry.succeeded = results.some((result) => result.data.status === 'success');
50
+ }
51
+ return entries;
52
+ }
53
+ function buildIntentEvent(request) {
54
+ const hash = egressHash(request);
55
+ return createEvent({
56
+ id: `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`,
57
+ service: request.service,
58
+ type: `${request.service}${WRITE_INTENT_SUFFIX}`,
59
+ idempotencyKey: intentIdempotencyKey(request),
60
+ occurredAt: new Date().toISOString(),
61
+ origin: 'agent',
62
+ actor: defaultActor(request.actor),
63
+ subject: request.subject,
64
+ correlationId: `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`,
65
+ data: {
66
+ operation: request.operation,
67
+ provider: request.provider,
68
+ egressKey: request.idempotencyKey,
69
+ ...(request.data ?? {}),
70
+ },
71
+ });
72
+ }
73
+ export function recordWriteIntent(request, root) {
74
+ return appendEvent(buildIntentEvent(request), root).event;
75
+ }
76
+ function recordWriteResult(input) {
77
+ const { request, intentEventId, attempt, status, outcome, error, root } = input;
78
+ const hash = egressHash(request);
79
+ const result = createEvent({
80
+ id: `${request.service}${WRITE_RESULT_SUFFIX}:${hash}:${attempt}`,
81
+ service: request.service,
82
+ type: `${request.service}${WRITE_RESULT_SUFFIX}`,
83
+ idempotencyKey: resultIdempotencyKey(request, attempt),
84
+ occurredAt: new Date().toISOString(),
85
+ origin: 'agent',
86
+ actor: defaultActor(request.actor),
87
+ subject: request.subject,
88
+ causationId: intentEventId,
89
+ correlationId: intentEventId,
90
+ ...(status === 'success' && outcome
91
+ ? {
92
+ external: {
93
+ provider: request.provider,
94
+ id: outcome.externalId,
95
+ ...(outcome.url ? { url: outcome.url } : {}),
96
+ },
97
+ }
98
+ : {}),
99
+ data: {
100
+ operation: request.operation,
101
+ provider: request.provider,
102
+ egressKey: request.idempotencyKey,
103
+ status,
104
+ attempt,
105
+ ...(outcome?.data ?? {}),
106
+ ...(error ? { error } : {}),
107
+ },
108
+ });
109
+ return appendEvent(result, root).event;
110
+ }
111
+ function replayOutcome(result) {
112
+ const { operation: _operation, provider: _provider, egressKey: _key, status: _status, attempt: _attempt, ...rest } = result.data;
113
+ return {
114
+ externalId: result.external?.id ?? '',
115
+ ...(result.external?.url ? { url: result.external.url } : {}),
116
+ data: rest,
117
+ };
118
+ }
119
+ // TWIN-58: same-process in-flight de-dup, keyed by (root, service, intentId). Two
120
+ // callers racing the SAME idempotencyKey must invoke writeFn exactly once between
121
+ // them; the SECOND caller awaits the FIRST's outcome and replays it rather than
122
+ // redoing (or erroring out of) its own ledger-check. Populated synchronously,
123
+ // before the first `await` in the winning call, so a caller that starts executing
124
+ // immediately after (same microtask turn or later) is guaranteed to observe it.
125
+ const inFlightWrites = new Map();
126
+ function inFlightKey(service, intentId, root) {
127
+ return `${root ?? ''}:${service}:${intentId}`;
128
+ }
129
+ /**
130
+ * Perform an external write through the egress ledger.
131
+ *
132
+ * Ordering: a write.intent event is durably appended BEFORE the provider call,
133
+ * and a write.result event is appended after it (success or failure). A crash
134
+ * between the two leaves an intent without results; by default the next call
135
+ * with the same idempotencyKey refuses to re-send (UnreconciledWriteIntentError)
136
+ * because the provider may or may not have applied the write. After verifying
137
+ * the external state, callers retry with onUnreconciled: 'retry'.
138
+ *
139
+ * A ledger entry with a success result is terminal: the call returns the
140
+ * recorded outcome without invoking writeFn. Failed attempts may be retried;
141
+ * each attempt appends its own numbered result event.
142
+ *
143
+ * Concurrency (TWIN-58): the ledger-check + intent-append span runs under
144
+ * `withFileLock` on the SAME events lock `appendEvent` itself uses (egress.ts has
145
+ * no lock of its own to invent — reusing the events lock is what makes this
146
+ * atomic against a concurrent appendEvent/commitQueuedEvents too, not just a
147
+ * concurrent performExternalWrite). Without this, two concurrent calls with the
148
+ * same idempotencyKey could both read "no entry" and both invoke writeFn — the
149
+ * vendor receiving the write twice despite this function's contract that a
150
+ * completed write is replayed, never re-sent (see actions.ts:121-123 for the same
151
+ * check-then-append-under-one-lock reasoning applied to the action log). The
152
+ * `writeFn` call itself deliberately stays OUTSIDE the lock (it's arbitrary,
153
+ * possibly slow, real I/O — holding a file lock across it would serialize every
154
+ * write to the service, not just same-key ones); `inFlightWrites` covers the
155
+ * in-process race for the span the lock can't (an async call can't hold a
156
+ * synchronous lock across its own `await`).
157
+ */
158
+ export async function performExternalWrite(request, writeFn, options = {}) {
159
+ const { root, onUnreconciled = 'fail' } = options;
160
+ const hash = egressHash(request);
161
+ const intentId = `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`;
162
+ const flightKey = inFlightKey(request.service, intentId, root);
163
+ // Another in-process call already owns this idempotencyKey's write — replay its
164
+ // outcome (or its failure) instead of racing it.
165
+ const inFlight = inFlightWrites.get(flightKey);
166
+ if (inFlight) {
167
+ const settled = await inFlight;
168
+ return { ...settled, status: 'replayed' };
169
+ }
170
+ const paths = worldPaths(request.service, root);
171
+ const claim = withFileLock(eventsLockPath(paths), () => {
172
+ const ledger = ledgerFor(request.service, root);
173
+ const existing = ledger.get(intentId);
174
+ if (existing) {
175
+ const success = existing.results.find((result) => result.data.status === 'success');
176
+ if (success) {
177
+ return {
178
+ replay: {
179
+ status: 'replayed',
180
+ intentEventId: intentId,
181
+ resultEventId: success.id,
182
+ attempt: Number(success.data.attempt) || existing.results.length,
183
+ outcome: replayOutcome(success),
184
+ },
185
+ };
186
+ }
187
+ if (existing.results.length === 0 && onUnreconciled === 'fail') {
188
+ throw new UnreconciledWriteIntentError(intentId, `Write intent ${intentId} has no result event — the provider call may or may not have happened. ` +
189
+ `Verify external state, then retry with onUnreconciled: 'retry'.`);
190
+ }
191
+ }
192
+ const intent = existing?.intent ?? appendEventLocked(buildIntentEvent(request), paths).event;
193
+ const attempt = (existing?.results.length ?? 0) + 1;
194
+ return { intent, attempt };
195
+ });
196
+ if ('replay' in claim)
197
+ return claim.replay;
198
+ const { intent, attempt } = claim;
199
+ // Register the in-flight promise BEFORE the first await below (synchronous from
200
+ // here), so a same-process concurrent call for this key — however soon it runs —
201
+ // finds it rather than re-entering the lock/ledger-check itself.
202
+ let resolveFlight;
203
+ let rejectFlight;
204
+ const flight = new Promise((resolve, reject) => {
205
+ resolveFlight = resolve;
206
+ rejectFlight = reject;
207
+ });
208
+ // A rejection here is only ever "unhandled" when no concurrent caller happened to
209
+ // be waiting on this exact key — the normal (non-concurrent) case. Attach a no-op
210
+ // handler so that case doesn't surface as an unhandled rejection; a real waiter
211
+ // (the `await inFlight` above) still observes the SAME rejection independently.
212
+ flight.catch(() => { });
213
+ inFlightWrites.set(flightKey, flight);
214
+ try {
215
+ let outcome;
216
+ try {
217
+ outcome = await writeFn();
218
+ }
219
+ catch (error) {
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ recordWriteResult({ request, intentEventId: intent.id, attempt, status: 'failed', error: message, root });
222
+ rejectFlight(error);
223
+ throw error;
224
+ }
225
+ const result = recordWriteResult({ request, intentEventId: intent.id, attempt, status: 'success', outcome, root });
226
+ const performed = {
227
+ status: 'performed',
228
+ intentEventId: intent.id,
229
+ resultEventId: result.id,
230
+ attempt,
231
+ outcome,
232
+ };
233
+ resolveFlight(performed);
234
+ return performed;
235
+ }
236
+ finally {
237
+ inFlightWrites.delete(flightKey);
238
+ }
239
+ }
240
+ /** Intents with no result event at all — crash candidates needing reconciliation. */
241
+ export function listUnreconciledWriteIntents(service, root) {
242
+ return Array.from(ledgerFor(service, root).values())
243
+ .filter((entry) => entry.results.length === 0)
244
+ .map((entry) => entry.intent);
245
+ }
246
+ /** Full intent/result ledger for a service, keyed by intent event id. */
247
+ export function listEgressLedger(service, root) {
248
+ return Array.from(ledgerFor(service, root).values());
249
+ }
250
+ /**
251
+ * Echo lookup: did we produce this external object? Inbound events whose
252
+ * external id matches a recorded successful write are our own writes coming back.
253
+ */
254
+ export function findWriteResultByExternal(service, provider, externalId, root) {
255
+ for (const event of listEvents(service, root)) {
256
+ if (!event.type.endsWith(WRITE_RESULT_SUFFIX))
257
+ continue;
258
+ if (event.data.status !== 'success')
259
+ continue;
260
+ if (event.external?.provider === provider && event.external.id === externalId)
261
+ return event;
262
+ }
263
+ return null;
264
+ }
@@ -0,0 +1,126 @@
1
+ import type { TwinAction } from './actions.js';
2
+ import type { TwinResource } from './serve.js';
3
+ import type { WorldServiceEvent } from './types.js';
4
+ export type ForkMeta = {
5
+ service: string;
6
+ forkedAt: string;
7
+ baseRoot: string;
8
+ baseEventCount: number;
9
+ baseLatestEventId: string | null;
10
+ baseline: TwinResource[];
11
+ };
12
+ /**
13
+ * Create a fork of `service` from `fromRoot` into the fresh isolated `toRoot`.
14
+ * Copies the base event log so the fork folds to identical state, then records
15
+ * the baseline. `occurredAt` is supplied by the caller (deterministic; no clock).
16
+ */
17
+ export declare function forkTwin(opts: {
18
+ service: string;
19
+ toRoot: string;
20
+ fromRoot?: string;
21
+ occurredAt: string;
22
+ }): ForkMeta;
23
+ export declare function readForkMeta(service: string, root: string): ForkMeta;
24
+ export declare function isFork(service: string, root: string): boolean;
25
+ export type FieldDivergence = {
26
+ before: unknown;
27
+ after: unknown;
28
+ };
29
+ export type SubjectDivergence = {
30
+ id: string;
31
+ type: string;
32
+ changed: Record<string, FieldDivergence>;
33
+ };
34
+ export type ForkDivergence = {
35
+ service: string;
36
+ baseCount: number;
37
+ currentCount: number;
38
+ created: TwinResource[];
39
+ changed: SubjectDivergence[];
40
+ };
41
+ /** What changed in the fork relative to its recorded base. */
42
+ export declare function forkDivergence(service: string, forkRoot: string): ForkDivergence;
43
+ export type ForkAudit = {
44
+ ok: boolean;
45
+ localActions: number;
46
+ realWrites: number;
47
+ breaches: string[];
48
+ };
49
+ /**
50
+ * Report whether the fork has made any real write SO FAR (R18 model + TWIN-52):
51
+ * local fork work lives in the ACTION log and never reaches the vendor on its
52
+ * own, so a real write can only have happened through one of the two channels
53
+ * that touch the vendor —
54
+ * - the egress ledger (`performExternalWrite`, and `syncPush` which is built
55
+ * on it): any entry there is a completed or attempted direct write.
56
+ * - the push ledger (`pushTransaction`, and `applyPlan` which drives it): any
57
+ * row whose status reached `attempted` or further is a real provider call
58
+ * that was made (or is unreconciled — see UnreconciledPushError — which is
59
+ * itself "may have happened", so it counts as a breach too).
60
+ * Reading only the egress ledger (the old implementation) missed the push
61
+ * channel entirely: a fork that pushed via `applyPlan`/`pushTransaction` (the
62
+ * canonical arc in examples/lifecycle.ts) audited as `{ok:true, realWrites:0}`,
63
+ * a fabricated green. This is a checkable AUDIT of what happened, not a
64
+ * guarantee that nothing can — pushing for real from a fork is a supported
65
+ * operation, not a violation.
66
+ */
67
+ export declare function auditForkNoRealWrites(service: string, forkRoot: string): ForkAudit;
68
+ /** Remove a fork root entirely (the fork is throwaway by design). */
69
+ export declare function discardFork(forkRoot: string): void;
70
+ export type CopyResult = {
71
+ copied: TwinAction[];
72
+ conflicts: Array<{
73
+ actionId: string;
74
+ reason: string;
75
+ }>;
76
+ };
77
+ /**
78
+ * cherry-pick: copy selected `set` transaction commits from one fork into another.
79
+ * A commit whose precondition fails in the target is reported as a conflict, not
80
+ * applied (the rest still copy).
81
+ */
82
+ export declare function cherryPickActions(opts: {
83
+ service: string;
84
+ fromRoot: string;
85
+ toRoot: string;
86
+ actionIds: string[];
87
+ occurredAt: string;
88
+ }): CopyResult;
89
+ /**
90
+ * merge: combine another fork's ACTIVE (pending) transaction history into this one.
91
+ * Requires compatible bases (same base latest-event id) unless `force`. Failed
92
+ * preconditions surface as conflicts.
93
+ */
94
+ export declare function mergeForks(opts: {
95
+ service: string;
96
+ intoRoot: string;
97
+ fromRoot: string;
98
+ occurredAt: string;
99
+ force?: boolean;
100
+ }): CopyResult;
101
+ /**
102
+ * reset: suppress all local projections non-destructively by appending a revert for
103
+ * every pending transaction (history is retained — unlike discardFork, which deletes
104
+ * the root). Returns how many transactions were reset.
105
+ */
106
+ export declare function resetFork(opts: {
107
+ service: string;
108
+ root: string;
109
+ occurredAt: string;
110
+ }): number;
111
+ /**
112
+ * rebase: commit fresh remote observations onto the fork's base, then replay active
113
+ * local transactions over the new base. Transactions whose preconditions now fail
114
+ * are returned as conflicts (the doc's "failed preconditions become conflicts").
115
+ */
116
+ export declare function rebaseFork(opts: {
117
+ service: string;
118
+ root: string;
119
+ freshEvents: WorldServiceEvent[];
120
+ }): {
121
+ applied: number;
122
+ conflicts: Array<{
123
+ actionId: string;
124
+ reason: string;
125
+ }>;
126
+ };