@volter/twin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
|
|
20
|
+
// serve/read/write surface (how a twin is driven): one twin, readable always,
|
|
21
|
+
// writable unless started read-only (writes land as local actions)
|
|
22
|
+
export { applyTwinWrite, createTwinServer, resolveTwinRead, twinResources } from './serve.ts';
|
|
23
|
+
export type { TwinResource, TwinWriteResult } from './serve.ts';
|
|
24
|
+
|
|
25
|
+
// status
|
|
26
|
+
export { formatStatus, worldStatus } from './status.ts';
|
|
27
|
+
export type { WorldStatus } from './status.ts';
|
|
28
|
+
|
|
29
|
+
// plan + apply orchestration
|
|
30
|
+
export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from './plan.ts';
|
|
31
|
+
export type { ActionMapper, ApplyResult, ProviderCall, WorldApplyPlan } from './plan.ts';
|
|
32
|
+
|
|
33
|
+
// push ledger + phases
|
|
34
|
+
export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
|
|
35
|
+
export type { PushOutcome, PushStatus, WorldPushRecord } from './pushLedger.ts';
|
|
36
|
+
|
|
37
|
+
// apply leases (single-writer per checkpoint)
|
|
38
|
+
export { acquireLease, activeLease, isLeaseActive, LeaseHeldError, listLeases, releaseLease } from './lease.ts';
|
|
39
|
+
export type { WorldApplyLease } from './lease.ts';
|
|
40
|
+
|
|
41
|
+
// remote refs / checkpoints + stale-base guard
|
|
42
|
+
export { isBaseStale, listRemoteRefs, readLocalRef, readRemoteRef, writeLocalRef, writeRemoteRef } from './refs.ts';
|
|
43
|
+
export type { WorldLocalRef, WorldRemoteRef } from './refs.ts';
|
|
44
|
+
|
|
45
|
+
// event-queue lifecycle (queued/committed/ignored/superseded/poisoned)
|
|
46
|
+
export {
|
|
47
|
+
commitPendingQueue, commitQueueRow, ignoreQueueRow, listQueueWithStatus,
|
|
48
|
+
pendingQueueRows, poisonQueueRow, queueCounts, queueRowStatus, setQueueRowStatus, supersedeQueueRow,
|
|
49
|
+
} from './queueLifecycle.ts';
|
|
50
|
+
export type { QueueCounts, QueueRowStatus, QueueRowWithStatus, QueueStatusTransition } from './queueLifecycle.ts';
|
|
51
|
+
|
|
52
|
+
// confirm/revert ops (transaction → observed-event mapping)
|
|
53
|
+
export { confirmAction } from './actions.ts';
|
|
54
|
+
|
|
55
|
+
// fork operations (rebase/merge/cherry-pick/reset/discard) + three-way reconcile
|
|
56
|
+
export {
|
|
57
|
+
auditForkNoRealWrites, cherryPickActions, discardFork, forkDivergence, forkTwin,
|
|
58
|
+
isFork, mergeForks, rebaseFork, readForkMeta, resetFork,
|
|
59
|
+
} from './fork.ts';
|
|
60
|
+
export type { CopyResult, ForkAudit, ForkDivergence, ForkMeta, SubjectDivergence } from './fork.ts';
|
|
61
|
+
export { isCleanlyReconcilable, reconcile, reconcileRequiresApproval } from './reconcile.ts';
|
|
62
|
+
export type { FieldDecision, ReconcilePlan, ReconcilePolicy, SubjectReconcile } from './reconcile.ts';
|
|
63
|
+
|
|
64
|
+
// sync direction (pull/push selected resources) — the bidirectional surface
|
|
65
|
+
export { currentResources, syncPull, syncPush } from './sync.ts';
|
|
66
|
+
export type { PullResult, PushItemResult, PushResult, SyncResource } from './sync.ts';
|
package/src/egress.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendEvent, appendEventLocked, createEvent, eventsLockPath, listEvents, withFileLock, worldPaths } from './storage.ts';
|
|
3
|
+
import type { WorldServiceEvent } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export const WRITE_INTENT_SUFFIX = '.write.intent';
|
|
6
|
+
export const WRITE_RESULT_SUFFIX = '.write.result';
|
|
7
|
+
|
|
8
|
+
export function isEgressEventType(type: string): boolean {
|
|
9
|
+
return type.endsWith(WRITE_INTENT_SUFFIX) || type.endsWith(WRITE_RESULT_SUFFIX);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type EgressActor = {
|
|
13
|
+
kind: 'human' | 'agent' | 'bot' | 'system';
|
|
14
|
+
id?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type EgressWriteRequest = {
|
|
19
|
+
service: string;
|
|
20
|
+
/** Provider operation name, e.g. 'chat.postMessage', 'issueCreate'. */
|
|
21
|
+
operation: string;
|
|
22
|
+
/** external.provider value for result events, e.g. 'slack', 'github', 'linear'. */
|
|
23
|
+
provider: string;
|
|
24
|
+
subject: { type: string; id: string };
|
|
25
|
+
/**
|
|
26
|
+
* Stable key for the logical action. Two calls with the same key are the
|
|
27
|
+
* same action: a completed write is replayed from the ledger, never re-sent.
|
|
28
|
+
*/
|
|
29
|
+
idempotencyKey: string;
|
|
30
|
+
actor?: EgressActor;
|
|
31
|
+
/** Request payload summary recorded on the intent event. Never put secrets here. */
|
|
32
|
+
data?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type EgressWriteOutcome = {
|
|
36
|
+
externalId: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
data?: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type EgressWriteResult = {
|
|
42
|
+
/** 'performed' = writeFn ran this call; 'replayed' = ledger already had a success. */
|
|
43
|
+
status: 'performed' | 'replayed';
|
|
44
|
+
intentEventId: string;
|
|
45
|
+
resultEventId: string;
|
|
46
|
+
attempt: number;
|
|
47
|
+
outcome: EgressWriteOutcome;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type EgressLedgerEntry = {
|
|
51
|
+
intent: WorldServiceEvent;
|
|
52
|
+
results: WorldServiceEvent[];
|
|
53
|
+
succeeded: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class UnreconciledWriteIntentError extends Error {
|
|
57
|
+
readonly intentEventId: string;
|
|
58
|
+
|
|
59
|
+
constructor(intentEventId: string, message: string) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'UnreconciledWriteIntentError';
|
|
62
|
+
this.intentEventId = intentEventId;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function egressHash(request: EgressWriteRequest): string {
|
|
67
|
+
return createHash('sha256')
|
|
68
|
+
.update(`${request.service}\u001f${request.operation}\u001f${request.idempotencyKey}`)
|
|
69
|
+
.digest('hex')
|
|
70
|
+
.slice(0, 16);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function intentIdempotencyKey(request: EgressWriteRequest): string {
|
|
74
|
+
return `egress:${request.service}:${request.operation}:${request.idempotencyKey}:intent`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resultIdempotencyKey(request: EgressWriteRequest, attempt: number): string {
|
|
78
|
+
return `egress:${request.service}:${request.operation}:${request.idempotencyKey}:result:${attempt}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function defaultActor(actor?: EgressActor): EgressActor {
|
|
82
|
+
return actor ?? { kind: 'agent' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ledgerFor(service: string, root?: string): Map<string, EgressLedgerEntry> {
|
|
86
|
+
const entries = new Map<string, EgressLedgerEntry>();
|
|
87
|
+
const resultsByCausation = new Map<string, WorldServiceEvent[]>();
|
|
88
|
+
for (const event of listEvents(service, root)) {
|
|
89
|
+
if (event.type.endsWith(WRITE_INTENT_SUFFIX)) {
|
|
90
|
+
entries.set(event.id, { intent: event, results: [], succeeded: false });
|
|
91
|
+
} else if (event.type.endsWith(WRITE_RESULT_SUFFIX) && event.causationId) {
|
|
92
|
+
const bucket = resultsByCausation.get(event.causationId) ?? [];
|
|
93
|
+
bucket.push(event);
|
|
94
|
+
resultsByCausation.set(event.causationId, bucket);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [intentId, results] of resultsByCausation) {
|
|
98
|
+
const entry = entries.get(intentId);
|
|
99
|
+
if (!entry) continue;
|
|
100
|
+
entry.results = results;
|
|
101
|
+
entry.succeeded = results.some((result) => result.data.status === 'success');
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildIntentEvent(request: EgressWriteRequest): WorldServiceEvent {
|
|
107
|
+
const hash = egressHash(request);
|
|
108
|
+
return createEvent({
|
|
109
|
+
id: `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`,
|
|
110
|
+
service: request.service,
|
|
111
|
+
type: `${request.service}${WRITE_INTENT_SUFFIX}`,
|
|
112
|
+
idempotencyKey: intentIdempotencyKey(request),
|
|
113
|
+
occurredAt: new Date().toISOString(),
|
|
114
|
+
origin: 'agent',
|
|
115
|
+
actor: defaultActor(request.actor),
|
|
116
|
+
subject: request.subject,
|
|
117
|
+
correlationId: `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`,
|
|
118
|
+
data: {
|
|
119
|
+
operation: request.operation,
|
|
120
|
+
provider: request.provider,
|
|
121
|
+
egressKey: request.idempotencyKey,
|
|
122
|
+
...(request.data ?? {}),
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordWriteIntent(request: EgressWriteRequest, root?: string): WorldServiceEvent {
|
|
128
|
+
return appendEvent(buildIntentEvent(request), root).event;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function recordWriteResult(input: {
|
|
132
|
+
request: EgressWriteRequest;
|
|
133
|
+
intentEventId: string;
|
|
134
|
+
attempt: number;
|
|
135
|
+
status: 'success' | 'failed';
|
|
136
|
+
outcome?: EgressWriteOutcome;
|
|
137
|
+
error?: string;
|
|
138
|
+
root?: string;
|
|
139
|
+
}): WorldServiceEvent {
|
|
140
|
+
const { request, intentEventId, attempt, status, outcome, error, root } = input;
|
|
141
|
+
const hash = egressHash(request);
|
|
142
|
+
const result = createEvent({
|
|
143
|
+
id: `${request.service}${WRITE_RESULT_SUFFIX}:${hash}:${attempt}`,
|
|
144
|
+
service: request.service,
|
|
145
|
+
type: `${request.service}${WRITE_RESULT_SUFFIX}`,
|
|
146
|
+
idempotencyKey: resultIdempotencyKey(request, attempt),
|
|
147
|
+
occurredAt: new Date().toISOString(),
|
|
148
|
+
origin: 'agent',
|
|
149
|
+
actor: defaultActor(request.actor),
|
|
150
|
+
subject: request.subject,
|
|
151
|
+
causationId: intentEventId,
|
|
152
|
+
correlationId: intentEventId,
|
|
153
|
+
...(status === 'success' && outcome
|
|
154
|
+
? {
|
|
155
|
+
external: {
|
|
156
|
+
provider: request.provider,
|
|
157
|
+
id: outcome.externalId,
|
|
158
|
+
...(outcome.url ? { url: outcome.url } : {}),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
data: {
|
|
163
|
+
operation: request.operation,
|
|
164
|
+
provider: request.provider,
|
|
165
|
+
egressKey: request.idempotencyKey,
|
|
166
|
+
status,
|
|
167
|
+
attempt,
|
|
168
|
+
...(outcome?.data ?? {}),
|
|
169
|
+
...(error ? { error } : {}),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
return appendEvent(result, root).event;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function replayOutcome(result: WorldServiceEvent): EgressWriteOutcome {
|
|
176
|
+
const { operation: _operation, provider: _provider, egressKey: _key, status: _status, attempt: _attempt, ...rest } = result.data;
|
|
177
|
+
return {
|
|
178
|
+
externalId: result.external?.id ?? '',
|
|
179
|
+
...(result.external?.url ? { url: result.external.url } : {}),
|
|
180
|
+
data: rest,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// TWIN-58: same-process in-flight de-dup, keyed by (root, service, intentId). Two
|
|
185
|
+
// callers racing the SAME idempotencyKey must invoke writeFn exactly once between
|
|
186
|
+
// them; the SECOND caller awaits the FIRST's outcome and replays it rather than
|
|
187
|
+
// redoing (or erroring out of) its own ledger-check. Populated synchronously,
|
|
188
|
+
// before the first `await` in the winning call, so a caller that starts executing
|
|
189
|
+
// immediately after (same microtask turn or later) is guaranteed to observe it.
|
|
190
|
+
const inFlightWrites = new Map<string, Promise<EgressWriteResult>>();
|
|
191
|
+
|
|
192
|
+
function inFlightKey(service: string, intentId: string, root?: string): string {
|
|
193
|
+
return `${root ?? ''}:${service}:${intentId}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Explicit discriminated-union return type for the withFileLock callback below.
|
|
197
|
+
// Left to plain inference, TS unifies the two return-statement shapes through the
|
|
198
|
+
// `withFileLock<T>(lockPath, fn: () => T): T` generic in a way that widens
|
|
199
|
+
// `claim.replay` to `EgressWriteResult | undefined` even after a `'replay' in
|
|
200
|
+
// claim` check; naming the union explicitly makes the narrowing exact.
|
|
201
|
+
type LockClaim = { replay: EgressWriteResult } | { intent: WorldServiceEvent; attempt: number };
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Perform an external write through the egress ledger.
|
|
205
|
+
*
|
|
206
|
+
* Ordering: a write.intent event is durably appended BEFORE the provider call,
|
|
207
|
+
* and a write.result event is appended after it (success or failure). A crash
|
|
208
|
+
* between the two leaves an intent without results; by default the next call
|
|
209
|
+
* with the same idempotencyKey refuses to re-send (UnreconciledWriteIntentError)
|
|
210
|
+
* because the provider may or may not have applied the write. After verifying
|
|
211
|
+
* the external state, callers retry with onUnreconciled: 'retry'.
|
|
212
|
+
*
|
|
213
|
+
* A ledger entry with a success result is terminal: the call returns the
|
|
214
|
+
* recorded outcome without invoking writeFn. Failed attempts may be retried;
|
|
215
|
+
* each attempt appends its own numbered result event.
|
|
216
|
+
*
|
|
217
|
+
* Concurrency (TWIN-58): the ledger-check + intent-append span runs under
|
|
218
|
+
* `withFileLock` on the SAME events lock `appendEvent` itself uses (egress.ts has
|
|
219
|
+
* no lock of its own to invent — reusing the events lock is what makes this
|
|
220
|
+
* atomic against a concurrent appendEvent/commitQueuedEvents too, not just a
|
|
221
|
+
* concurrent performExternalWrite). Without this, two concurrent calls with the
|
|
222
|
+
* same idempotencyKey could both read "no entry" and both invoke writeFn — the
|
|
223
|
+
* vendor receiving the write twice despite this function's contract that a
|
|
224
|
+
* completed write is replayed, never re-sent (see actions.ts:121-123 for the same
|
|
225
|
+
* check-then-append-under-one-lock reasoning applied to the action log). The
|
|
226
|
+
* `writeFn` call itself deliberately stays OUTSIDE the lock (it's arbitrary,
|
|
227
|
+
* possibly slow, real I/O — holding a file lock across it would serialize every
|
|
228
|
+
* write to the service, not just same-key ones); `inFlightWrites` covers the
|
|
229
|
+
* in-process race for the span the lock can't (an async call can't hold a
|
|
230
|
+
* synchronous lock across its own `await`).
|
|
231
|
+
*/
|
|
232
|
+
export async function performExternalWrite(
|
|
233
|
+
request: EgressWriteRequest,
|
|
234
|
+
writeFn: () => Promise<EgressWriteOutcome>,
|
|
235
|
+
options: { root?: string; onUnreconciled?: 'fail' | 'retry' } = {},
|
|
236
|
+
): Promise<EgressWriteResult> {
|
|
237
|
+
const { root, onUnreconciled = 'fail' } = options;
|
|
238
|
+
const hash = egressHash(request);
|
|
239
|
+
const intentId = `${request.service}${WRITE_INTENT_SUFFIX}:${hash}`;
|
|
240
|
+
const flightKey = inFlightKey(request.service, intentId, root);
|
|
241
|
+
|
|
242
|
+
// Another in-process call already owns this idempotencyKey's write — replay its
|
|
243
|
+
// outcome (or its failure) instead of racing it.
|
|
244
|
+
const inFlight = inFlightWrites.get(flightKey);
|
|
245
|
+
if (inFlight) {
|
|
246
|
+
const settled = await inFlight;
|
|
247
|
+
return { ...settled, status: 'replayed' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const paths = worldPaths(request.service, root);
|
|
251
|
+
const claim = withFileLock<LockClaim>(eventsLockPath(paths), () => {
|
|
252
|
+
const ledger = ledgerFor(request.service, root);
|
|
253
|
+
const existing = ledger.get(intentId);
|
|
254
|
+
|
|
255
|
+
if (existing) {
|
|
256
|
+
const success = existing.results.find((result) => result.data.status === 'success');
|
|
257
|
+
if (success) {
|
|
258
|
+
return {
|
|
259
|
+
replay: {
|
|
260
|
+
status: 'replayed' as const,
|
|
261
|
+
intentEventId: intentId,
|
|
262
|
+
resultEventId: success.id,
|
|
263
|
+
attempt: Number(success.data.attempt) || existing.results.length,
|
|
264
|
+
outcome: replayOutcome(success),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (existing.results.length === 0 && onUnreconciled === 'fail') {
|
|
269
|
+
throw new UnreconciledWriteIntentError(
|
|
270
|
+
intentId,
|
|
271
|
+
`Write intent ${intentId} has no result event — the provider call may or may not have happened. ` +
|
|
272
|
+
`Verify external state, then retry with onUnreconciled: 'retry'.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const intent = existing?.intent ?? appendEventLocked(buildIntentEvent(request), paths).event;
|
|
278
|
+
const attempt = (existing?.results.length ?? 0) + 1;
|
|
279
|
+
return { intent, attempt };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if ('replay' in claim) return claim.replay;
|
|
283
|
+
const { intent, attempt } = claim;
|
|
284
|
+
|
|
285
|
+
// Register the in-flight promise BEFORE the first await below (synchronous from
|
|
286
|
+
// here), so a same-process concurrent call for this key — however soon it runs —
|
|
287
|
+
// finds it rather than re-entering the lock/ledger-check itself.
|
|
288
|
+
let resolveFlight!: (result: EgressWriteResult) => void;
|
|
289
|
+
let rejectFlight!: (error: unknown) => void;
|
|
290
|
+
const flight = new Promise<EgressWriteResult>((resolve, reject) => {
|
|
291
|
+
resolveFlight = resolve;
|
|
292
|
+
rejectFlight = reject;
|
|
293
|
+
});
|
|
294
|
+
// A rejection here is only ever "unhandled" when no concurrent caller happened to
|
|
295
|
+
// be waiting on this exact key — the normal (non-concurrent) case. Attach a no-op
|
|
296
|
+
// handler so that case doesn't surface as an unhandled rejection; a real waiter
|
|
297
|
+
// (the `await inFlight` above) still observes the SAME rejection independently.
|
|
298
|
+
flight.catch(() => {});
|
|
299
|
+
inFlightWrites.set(flightKey, flight);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
let outcome: EgressWriteOutcome;
|
|
303
|
+
try {
|
|
304
|
+
outcome = await writeFn();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
307
|
+
recordWriteResult({ request, intentEventId: intent.id, attempt, status: 'failed', error: message, root });
|
|
308
|
+
rejectFlight(error);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const result = recordWriteResult({ request, intentEventId: intent.id, attempt, status: 'success', outcome, root });
|
|
313
|
+
const performed: EgressWriteResult = {
|
|
314
|
+
status: 'performed',
|
|
315
|
+
intentEventId: intent.id,
|
|
316
|
+
resultEventId: result.id,
|
|
317
|
+
attempt,
|
|
318
|
+
outcome,
|
|
319
|
+
};
|
|
320
|
+
resolveFlight(performed);
|
|
321
|
+
return performed;
|
|
322
|
+
} finally {
|
|
323
|
+
inFlightWrites.delete(flightKey);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Intents with no result event at all — crash candidates needing reconciliation. */
|
|
328
|
+
export function listUnreconciledWriteIntents(service: string, root?: string): WorldServiceEvent[] {
|
|
329
|
+
return Array.from(ledgerFor(service, root).values())
|
|
330
|
+
.filter((entry) => entry.results.length === 0)
|
|
331
|
+
.map((entry) => entry.intent);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Full intent/result ledger for a service, keyed by intent event id. */
|
|
335
|
+
export function listEgressLedger(service: string, root?: string): EgressLedgerEntry[] {
|
|
336
|
+
return Array.from(ledgerFor(service, root).values());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Echo lookup: did we produce this external object? Inbound events whose
|
|
341
|
+
* external id matches a recorded successful write are our own writes coming back.
|
|
342
|
+
*/
|
|
343
|
+
export function findWriteResultByExternal(
|
|
344
|
+
service: string,
|
|
345
|
+
provider: string,
|
|
346
|
+
externalId: string,
|
|
347
|
+
root?: string,
|
|
348
|
+
): WorldServiceEvent | null {
|
|
349
|
+
for (const event of listEvents(service, root)) {
|
|
350
|
+
if (!event.type.endsWith(WRITE_RESULT_SUFFIX)) continue;
|
|
351
|
+
if (event.data.status !== 'success') continue;
|
|
352
|
+
if (event.external?.provider === provider && event.external.id === externalId) return event;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
package/src/fork.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Fork mode for twins (the twins architecture notes): a deliberate,
|
|
2
|
+
// auditable divergence from a known base.
|
|
3
|
+
//
|
|
4
|
+
// simulator already accepts local writes; fork ADDS three things on top:
|
|
5
|
+
// 1. a KNOWN BASE — snapshot the source twin's state at fork time, into an
|
|
6
|
+
// isolated root, so the fork starts byte-identical to its base (copy-on-write).
|
|
7
|
+
// 2. DIVERGENCE — diff the fork's current state against that recorded base
|
|
8
|
+
// (what an agent changed while working in the fork).
|
|
9
|
+
// 3. a CHECKABLE REAL-WRITE AUDIT: local writes in a fork stay in the action
|
|
10
|
+
// log and never touch the vendor — but a fork is NOT forbidden from pushing
|
|
11
|
+
// for real (that's the whole "fork it... push selected changes back" arc,
|
|
12
|
+
// via `applyPlan`/`pushTransaction` or `syncPush`); the fork boundary is a
|
|
13
|
+
// sandbox for local work, not a guarantee no real write can ever happen from
|
|
14
|
+
// it. `auditForkNoRealWrites` is therefore a checkable REPORT — has any real
|
|
15
|
+
// write happened from this fork SO FAR? — not a structural impossibility
|
|
16
|
+
// proof, and it has to look at every channel a real write can travel
|
|
17
|
+
// through: the egress ledger (direct `performExternalWrite`/`syncPush`
|
|
18
|
+
// calls) AND the push ledger (`pushTransaction`/`applyPlan`). Missing either
|
|
19
|
+
// channel makes `ok: true` a fabricated green on a fork that pushed.
|
|
20
|
+
//
|
|
21
|
+
// Convergence (rebase onto a fresh pull / push the divergence to real) is the
|
|
22
|
+
// separate `reconcile` concern and is not implemented here.
|
|
23
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { listEgressLedger } from './egress.ts';
|
|
26
|
+
import { appendAction, listActions, pendingActions, TwinActionPreconditionError } from './actions.ts';
|
|
27
|
+
import type { TwinAction } from './actions.ts';
|
|
28
|
+
import { pendingConflicts } from './plan.ts';
|
|
29
|
+
import { listPushLedger } from './pushLedger.ts';
|
|
30
|
+
import type { PushStatus } from './pushLedger.ts';
|
|
31
|
+
import { twinResources } from './serve.ts';
|
|
32
|
+
import type { TwinResource } from './serve.ts';
|
|
33
|
+
import { appendEvent, listEvents, readJsonFile, worldPaths } from './storage.ts';
|
|
34
|
+
import type { WorldServiceEvent } from './types.ts';
|
|
35
|
+
|
|
36
|
+
export type ForkMeta = {
|
|
37
|
+
service: string;
|
|
38
|
+
forkedAt: string;
|
|
39
|
+
baseRoot: string;
|
|
40
|
+
baseEventCount: number;
|
|
41
|
+
baseLatestEventId: string | null;
|
|
42
|
+
// The base twin's resources at fork time — the divergence baseline.
|
|
43
|
+
baseline: TwinResource[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Fields that are bookkeeping, not vendor data — excluded from divergence diffs.
|
|
47
|
+
const META_FIELDS = new Set(['id', 'type', 'updatedAt']);
|
|
48
|
+
|
|
49
|
+
function forkMetaPath(service: string, root: string): string {
|
|
50
|
+
return join(dirname(worldPaths(service, root).events), 'fork-meta.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a fork of `service` from `fromRoot` into the fresh isolated `toRoot`.
|
|
55
|
+
* Copies the base event log so the fork folds to identical state, then records
|
|
56
|
+
* the baseline. `occurredAt` is supplied by the caller (deterministic; no clock).
|
|
57
|
+
*/
|
|
58
|
+
export function forkTwin(opts: {
|
|
59
|
+
service: string;
|
|
60
|
+
toRoot: string;
|
|
61
|
+
fromRoot?: string;
|
|
62
|
+
occurredAt: string;
|
|
63
|
+
}): ForkMeta {
|
|
64
|
+
const { service, toRoot, fromRoot } = opts;
|
|
65
|
+
if (existsSync(forkMetaPath(service, toRoot))) {
|
|
66
|
+
throw new Error(`fork target already initialized for ${service}: ${toRoot}`);
|
|
67
|
+
}
|
|
68
|
+
const baseEvents = listEvents(service, fromRoot);
|
|
69
|
+
// Copy the base log into the fork root (copy-on-write: the fork owns its log).
|
|
70
|
+
for (const event of baseEvents) appendEvent(event, toRoot);
|
|
71
|
+
|
|
72
|
+
const baseline = twinResources(service, toRoot).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
73
|
+
const meta: ForkMeta = {
|
|
74
|
+
service,
|
|
75
|
+
forkedAt: opts.occurredAt,
|
|
76
|
+
baseRoot: fromRoot ?? '',
|
|
77
|
+
baseEventCount: baseEvents.length,
|
|
78
|
+
baseLatestEventId: baseEvents.length ? baseEvents[baseEvents.length - 1]!.id : null,
|
|
79
|
+
baseline,
|
|
80
|
+
};
|
|
81
|
+
writeFileSync(forkMetaPath(service, toRoot), `${JSON.stringify(meta, null, 2)}\n`);
|
|
82
|
+
return meta;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readForkMeta(service: string, root: string): ForkMeta {
|
|
86
|
+
const path = forkMetaPath(service, root);
|
|
87
|
+
if (!existsSync(path)) throw new Error(`not a fork (no fork-meta.json) for ${service}: ${root}`);
|
|
88
|
+
return readJsonFile<ForkMeta>(path);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isFork(service: string, root: string): boolean {
|
|
92
|
+
return existsSync(forkMetaPath(service, root));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type FieldDivergence = { before: unknown; after: unknown };
|
|
96
|
+
export type SubjectDivergence = { id: string; type: string; changed: Record<string, FieldDivergence> };
|
|
97
|
+
export type ForkDivergence = {
|
|
98
|
+
service: string;
|
|
99
|
+
baseCount: number;
|
|
100
|
+
currentCount: number;
|
|
101
|
+
created: TwinResource[]; // present now, absent at fork time
|
|
102
|
+
changed: SubjectDivergence[]; // field-level changes to pre-existing subjects
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** What changed in the fork relative to its recorded base. */
|
|
106
|
+
export function forkDivergence(service: string, forkRoot: string): ForkDivergence {
|
|
107
|
+
const meta = readForkMeta(service, forkRoot);
|
|
108
|
+
const baseById = new Map(meta.baseline.map((r) => [r.id, r]));
|
|
109
|
+
const current = twinResources(service, forkRoot);
|
|
110
|
+
const created: TwinResource[] = [];
|
|
111
|
+
const changed: SubjectDivergence[] = [];
|
|
112
|
+
|
|
113
|
+
for (const cur of current) {
|
|
114
|
+
const base = baseById.get(cur.id);
|
|
115
|
+
if (!base) {
|
|
116
|
+
created.push(cur);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const fieldChanges: Record<string, FieldDivergence> = {};
|
|
120
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(cur)].filter((k) => !META_FIELDS.has(k)));
|
|
121
|
+
for (const key of keys) {
|
|
122
|
+
const before = (base as Record<string, unknown>)[key];
|
|
123
|
+
const after = (cur as Record<string, unknown>)[key];
|
|
124
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) fieldChanges[key] = { before, after };
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(fieldChanges).length > 0) changed.push({ id: cur.id, type: cur.type, changed: fieldChanges });
|
|
127
|
+
}
|
|
128
|
+
created.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
129
|
+
changed.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
130
|
+
return { service, baseCount: meta.baseline.length, currentCount: current.length, created, changed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ForkAudit = {
|
|
134
|
+
ok: boolean;
|
|
135
|
+
// local actions in the fork (the work done locally — never touches the vendor).
|
|
136
|
+
localActions: number;
|
|
137
|
+
// real writes seen across BOTH real-write channels: egress ledger entries
|
|
138
|
+
// (direct performExternalWrite / syncPush) plus push-ledger rows that reached
|
|
139
|
+
// (or attempted) the provider via pushTransaction / applyPlan. `ok: true` means
|
|
140
|
+
// no real write has happened from this fork YET — it is not a claim that this
|
|
141
|
+
// fork structurally cannot push (it can, by design).
|
|
142
|
+
realWrites: number;
|
|
143
|
+
breaches: string[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// A push-ledger row at any of these statuses means writeFn was actually invoked
|
|
147
|
+
// (or its outcome recorded) — a real provider call, not just a locally-queued
|
|
148
|
+
// intent. 'attempted' is written unconditionally right before writeFn runs, so
|
|
149
|
+
// its presence alone already implies every later status is also a real write;
|
|
150
|
+
// listed explicitly (rather than "anything past intent") so the set reads as a
|
|
151
|
+
// deliberate audit policy, not an artifact of the phase ordering. 'abandoned'
|
|
152
|
+
// and bare 'intent' are excluded: those mean writeFn was never called.
|
|
153
|
+
const PUSH_REAL_WRITE_STATUSES: ReadonlySet<PushStatus> = new Set([
|
|
154
|
+
'attempted', 'provider_accepted', 'observed_confirmed', 'projection_suppressed', 'confirmed', 'succeeded', 'failed',
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Report whether the fork has made any real write SO FAR (R18 model + TWIN-52):
|
|
159
|
+
* local fork work lives in the ACTION log and never reaches the vendor on its
|
|
160
|
+
* own, so a real write can only have happened through one of the two channels
|
|
161
|
+
* that touch the vendor —
|
|
162
|
+
* - the egress ledger (`performExternalWrite`, and `syncPush` which is built
|
|
163
|
+
* on it): any entry there is a completed or attempted direct write.
|
|
164
|
+
* - the push ledger (`pushTransaction`, and `applyPlan` which drives it): any
|
|
165
|
+
* row whose status reached `attempted` or further is a real provider call
|
|
166
|
+
* that was made (or is unreconciled — see UnreconciledPushError — which is
|
|
167
|
+
* itself "may have happened", so it counts as a breach too).
|
|
168
|
+
* Reading only the egress ledger (the old implementation) missed the push
|
|
169
|
+
* channel entirely: a fork that pushed via `applyPlan`/`pushTransaction` (the
|
|
170
|
+
* canonical arc in examples/lifecycle.ts) audited as `{ok:true, realWrites:0}`,
|
|
171
|
+
* a fabricated green. This is a checkable AUDIT of what happened, not a
|
|
172
|
+
* guarantee that nothing can — pushing for real from a fork is a supported
|
|
173
|
+
* operation, not a violation.
|
|
174
|
+
*/
|
|
175
|
+
export function auditForkNoRealWrites(service: string, forkRoot: string): ForkAudit {
|
|
176
|
+
const egressLedger = listEgressLedger(service, forkRoot);
|
|
177
|
+
const pushBreachActionIds = new Set<string>();
|
|
178
|
+
for (const row of listPushLedger(service, forkRoot)) {
|
|
179
|
+
if (PUSH_REAL_WRITE_STATUSES.has(row.status)) pushBreachActionIds.add(row.actionId);
|
|
180
|
+
}
|
|
181
|
+
const breaches = [...egressLedger.map((e) => e.intent.id), ...pushBreachActionIds];
|
|
182
|
+
return {
|
|
183
|
+
ok: egressLedger.length === 0 && pushBreachActionIds.size === 0,
|
|
184
|
+
localActions: pendingActions(service, forkRoot).length,
|
|
185
|
+
realWrites: egressLedger.length + pushBreachActionIds.size,
|
|
186
|
+
breaches,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Remove a fork root entirely (the fork is throwaway by design). */
|
|
191
|
+
export function discardFork(forkRoot: string): void {
|
|
192
|
+
rmSync(forkRoot, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Git-like fork operations (the twins architecture notes → "Fork Operations").
|
|
196
|
+
// rebase / merge / cherry-pick / revert / discard|reset, all over the TRANSACTION
|
|
197
|
+
// history, never canonical provider events. (revert is the action-log `revert` op;
|
|
198
|
+
// discard is discardFork above.)
|
|
199
|
+
|
|
200
|
+
export type CopyResult = { copied: TwinAction[]; conflicts: Array<{ actionId: string; reason: string }> };
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* cherry-pick: copy selected `set` transaction commits from one fork into another.
|
|
204
|
+
* A commit whose precondition fails in the target is reported as a conflict, not
|
|
205
|
+
* applied (the rest still copy).
|
|
206
|
+
*/
|
|
207
|
+
export function cherryPickActions(opts: { service: string; fromRoot: string; toRoot: string; actionIds: string[]; occurredAt: string }): CopyResult {
|
|
208
|
+
const wanted = new Set(opts.actionIds);
|
|
209
|
+
const src = listActions(opts.service, opts.fromRoot).filter((a) => a.op === 'set' && wanted.has(a.id));
|
|
210
|
+
const copied: TwinAction[] = [];
|
|
211
|
+
const conflicts: CopyResult['conflicts'] = [];
|
|
212
|
+
for (const a of src) {
|
|
213
|
+
try { copied.push(appendAction({ ...a, occurredAt: opts.occurredAt }, opts.toRoot)); }
|
|
214
|
+
catch (e) { if (e instanceof TwinActionPreconditionError) conflicts.push({ actionId: a.id, reason: e.message }); else throw e; }
|
|
215
|
+
}
|
|
216
|
+
return { copied, conflicts };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* merge: combine another fork's ACTIVE (pending) transaction history into this one.
|
|
221
|
+
* Requires compatible bases (same base latest-event id) unless `force`. Failed
|
|
222
|
+
* preconditions surface as conflicts.
|
|
223
|
+
*/
|
|
224
|
+
export function mergeForks(opts: { service: string; intoRoot: string; fromRoot: string; occurredAt: string; force?: boolean }): CopyResult {
|
|
225
|
+
const into = readForkMeta(opts.service, opts.intoRoot);
|
|
226
|
+
const from = readForkMeta(opts.service, opts.fromRoot);
|
|
227
|
+
if (!opts.force && into.baseLatestEventId !== from.baseLatestEventId) {
|
|
228
|
+
throw new Error(`merge: incompatible fork bases (${into.baseLatestEventId} vs ${from.baseLatestEventId}); rebase first or pass force`);
|
|
229
|
+
}
|
|
230
|
+
const ids = pendingActions(opts.service, opts.fromRoot).map((a) => a.id);
|
|
231
|
+
return cherryPickActions({ service: opts.service, fromRoot: opts.fromRoot, toRoot: opts.intoRoot, actionIds: ids, occurredAt: opts.occurredAt });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* reset: suppress all local projections non-destructively by appending a revert for
|
|
236
|
+
* every pending transaction (history is retained — unlike discardFork, which deletes
|
|
237
|
+
* the root). Returns how many transactions were reset.
|
|
238
|
+
*/
|
|
239
|
+
export function resetFork(opts: { service: string; root: string; occurredAt: string }): number {
|
|
240
|
+
const pending = pendingActions(opts.service, opts.root);
|
|
241
|
+
for (const a of pending) {
|
|
242
|
+
appendAction({ id: `revert:${a.id}`, service: opts.service, op: 'revert', subject: a.subject, occurredAt: opts.occurredAt, revertsActionId: a.id }, opts.root);
|
|
243
|
+
}
|
|
244
|
+
return pending.length;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* rebase: commit fresh remote observations onto the fork's base, then replay active
|
|
249
|
+
* local transactions over the new base. Transactions whose preconditions now fail
|
|
250
|
+
* are returned as conflicts (the doc's "failed preconditions become conflicts").
|
|
251
|
+
*/
|
|
252
|
+
export function rebaseFork(opts: { service: string; root: string; freshEvents: WorldServiceEvent[] }): { applied: number; conflicts: Array<{ actionId: string; reason: string }> } {
|
|
253
|
+
let applied = 0;
|
|
254
|
+
for (const ev of opts.freshEvents) if (appendEvent(ev, opts.root).appended) applied += 1;
|
|
255
|
+
return { applied, conflicts: pendingConflicts(opts.service, opts.root) };
|
|
256
|
+
}
|