@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
package/src/index.ts ADDED
@@ -0,0 +1,222 @@
1
+ // @volter/twin — SHARED LIBRARIES a twin is built FROM. This is a library, NOT a
2
+ // framework/"kernel": it does not own the request lifecycle and a pack is not required to
3
+ // implement any contract here — a pack COMPOSES the vendor-INDEPENDENT mechanics it needs and
4
+ // presents its own vendor's exact API + semantics (see ARCHITECTURE.md; "share the
5
+ // vendor-independent mechanics, not the implementation — no false cross-vendor uniformity").
6
+ // Two groupings, kept distinct on purpose:
7
+ // 1. the OPERATOR CONTROL PLANE — what you DRIVE A TWIN'S STATE WITH (status/plan/push+phases/
8
+ // lease/refs/queue-lifecycle/fork-ops). Operator-facing (the `volter-twin` CLI), NOT a
9
+ // contract packs implement. Available cohesively as the `controlPlane` namespace below.
10
+ // 2. the DATA-PLANE LIBRARIES a twin reuses (event log, projection, shadow/delta, egress,
11
+ // connectors). Vendor-independent mechanics; reuse them rather than reimplement (the
12
+ // durable log + projection ARE the product's state core, the same for every vendor).
13
+ // (Annotations + source books are tracker concerns — @volter/tracker, not here.)
14
+ // The flat exports below remain for back-compat; new code can lean on the split.
15
+ export * as controlPlane from './control-plane.ts';
16
+ // Conformance/validation tooling (capability + spec + recorded-diff + UI harnesses and
17
+ // the spec derivers) is NOT part of the runtime kernel — it lives in @volter/twin-tooling,
18
+ // a dev dependency. A twin runs without it; only tests and the conformance scripts use it.
19
+ // Pack registry — vendor twins self-describe (TwinPack) so tooling discovers them.
20
+ export { clearRegistry, getPack, hasPack, listPacks, registerPack } from './packRegistry.ts';
21
+ export type { PackTransport, TwinPack } from './packRegistry.ts';
22
+
23
+ export {
24
+ GenericWorldStateSchema,
25
+ WorldActorSchema,
26
+ WorldExternalRefSchema,
27
+ WorldServiceEventSchema,
28
+ WorldSubjectSchema,
29
+ } from './schemas.ts';
30
+ // NOTE: source books (event→tracker-source mapping) moved OUT of the world — it was
31
+ // the last tracker coupling. It now lives at `@volter/tracker/world-source-books`,
32
+ // so `@volter/twin` is a pure twin runtime.
33
+ export {
34
+ findWriteResultByExternal,
35
+ isEgressEventType,
36
+ listEgressLedger,
37
+ listUnreconciledWriteIntents,
38
+ performExternalWrite,
39
+ recordWriteIntent,
40
+ UnreconciledWriteIntentError,
41
+ WRITE_INTENT_SUFFIX,
42
+ WRITE_RESULT_SUFFIX,
43
+ } from './egress.ts';
44
+ export type {
45
+ EgressActor,
46
+ EgressLedgerEntry,
47
+ EgressWriteOutcome,
48
+ EgressWriteRequest,
49
+ EgressWriteResult,
50
+ } from './egress.ts';
51
+ export {
52
+ loadWorldConfig,
53
+ } from './worldConfig.ts';
54
+ export type {
55
+ WorldConfig,
56
+ WorldServiceConfig,
57
+ } from './worldConfig.ts';
58
+ export {
59
+ buildShadowState,
60
+ DELTA_TYPE_SUFFIX,
61
+ diffSubjectFields,
62
+ hashFieldValue,
63
+ recordObservedDelta,
64
+ } from './shadow.ts';
65
+ export type {
66
+ DeltaObservation,
67
+ DeltaResult,
68
+ FieldChange,
69
+ ShadowState,
70
+ SubjectFieldExtractor,
71
+ SubjectFields,
72
+ SubjectShadow,
73
+ } from './shadow.ts';
74
+ export {
75
+ appendDurable,
76
+ appendEvent,
77
+ commitQueuedEvents,
78
+ createEvent,
79
+ emptyGenericState,
80
+ enqueueEvent,
81
+ genericWorldReducer,
82
+ listEvents,
83
+ listQueuedEvents,
84
+ loadState,
85
+ readJsonFile,
86
+ rebuildGenericState,
87
+ rebuildState,
88
+ // Scrub: delete pulled data at rest (TWIN-45) — the honest, plain-`rm` counterpart to
89
+ // sync pull's fold-into-the-log. See docs/DATA_AT_REST.md for the full data-at-rest story.
90
+ scrubService,
91
+ scrubWorld,
92
+ stateDirName,
93
+ // The kernel's cross-process mutual-exclusion primitive (exclusive-create lockfile with
94
+ // stale-holder reclaim). Public so world-runtime can guard concurrent `upWorld` claims of
95
+ // one instance dir with the SAME lock semantics the event log uses (TWIN-36).
96
+ withFileLock,
97
+ worldPaths,
98
+ worldStateRoot,
99
+ } from './storage.ts';
100
+ export type {
101
+ AppendEventResult,
102
+ CommitQueuedEventsResult,
103
+ GenericWorldState,
104
+ EnqueueEventResult,
105
+ QueuedWorldServiceEvent,
106
+ WorldPaths,
107
+ WorldReducer,
108
+ WorldServiceEvent,
109
+ } from './types.ts';
110
+ export type { ScrubResult } from './storage.ts';
111
+ export {
112
+ loadPollCursor,
113
+ pollCursorPath,
114
+ runConnectorPoll,
115
+ runConnectorSweep,
116
+ savePollCursor,
117
+ } from './connector.ts';
118
+ export type {
119
+ ConnectorObservation,
120
+ ConnectorPollResult,
121
+ ConnectorSweepResult,
122
+ SweepConnector,
123
+ WorldConnector,
124
+ } from './connector.ts';
125
+ export {
126
+ discoverWorldServices,
127
+ summarizeWorldFindings,
128
+ validateWorld,
129
+ validateWorldService,
130
+ } from './validate.ts';
131
+ export type {
132
+ WorldValidationFinding,
133
+ WorldValidationReport,
134
+ WorldValidationSummary,
135
+ } from './validate.ts';
136
+ export {
137
+ applyTwinWrite,
138
+ createTwinServer,
139
+ resolveTwinRead,
140
+ twinResources,
141
+ } from './serve.ts';
142
+ export type {
143
+ TwinResource,
144
+ } from './serve.ts';
145
+ export { createTwinProxy } from './proxy.ts';
146
+ export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.ts';
147
+ export {
148
+ auditForkNoRealWrites,
149
+ cherryPickActions,
150
+ discardFork,
151
+ forkDivergence,
152
+ forkTwin,
153
+ isFork,
154
+ mergeForks,
155
+ rebaseFork,
156
+ readForkMeta,
157
+ resetFork,
158
+ } from './fork.ts';
159
+ export type { CopyResult, ForkAudit, ForkDivergence, ForkMeta, SubjectDivergence } from './fork.ts';
160
+ export { isCleanlyReconcilable, reconcile, reconcileRequiresApproval } from './reconcile.ts';
161
+ export type {
162
+ FieldDecision,
163
+ ReconcilePlan,
164
+ ReconcilePolicy,
165
+ SubjectReconcile,
166
+ } from './reconcile.ts';
167
+ export { currentResources, syncPull, syncPush } from './sync.ts';
168
+ export type { PullResult, PushItemResult, PushResult, SyncResource } from './sync.ts';
169
+ export { createVisualizerServer, renderTwinHtml } from './visualizer.ts';
170
+ export {
171
+ appendAction,
172
+ appendTransactionCommit,
173
+ confirmAction,
174
+ listActions,
175
+ listTransactionCommits,
176
+ pendingActions,
177
+ pendingEmits,
178
+ pendingTransactionCommits,
179
+ projectResources,
180
+ TwinActionPreconditionError,
181
+ } from './actions.ts';
182
+ export type {
183
+ ActionProjection,
184
+ ProjectedDelivery,
185
+ ProjectedResource,
186
+ ProjectedResourcePatch,
187
+ ProjectedResourceRef,
188
+ TwinAction,
189
+ TwinActionOp,
190
+ TwinActionPrecondition,
191
+ TwinActionPreconditionOp,
192
+ TwinActionRevertSpec,
193
+ TwinTransactionCommit,
194
+ TwinTransactionCommitOp,
195
+ TwinTransactionPrecondition,
196
+ TwinTransactionRevertSpec,
197
+ } from './actions.ts';
198
+ // ── Operator control plane (R19/R20): remote refs, queue lifecycle, push ledger,
199
+ // apply leases, plan, status. (the twins architecture notes)
200
+ export { isBaseStale, listRemoteRefs, readLocalRef, readRemoteRef, writeLocalRef, writeRemoteRef } from './refs.ts';
201
+ export type { WorldLocalRef, WorldRemoteRef } from './refs.ts';
202
+ export {
203
+ commitPendingQueue,
204
+ commitQueueRow,
205
+ ignoreQueueRow,
206
+ listQueueWithStatus,
207
+ pendingQueueRows,
208
+ poisonQueueRow,
209
+ queueCounts,
210
+ queueRowStatus,
211
+ setQueueRowStatus,
212
+ supersedeQueueRow,
213
+ } from './queueLifecycle.ts';
214
+ export type { QueueCounts, QueueRowStatus, QueueRowWithStatus, QueueStatusTransition } from './queueLifecycle.ts';
215
+ export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
216
+ export type { PushOutcome, PushStatus, WorldPushRecord } from './pushLedger.ts';
217
+ export { acquireLease, activeLease, isLeaseActive, LeaseHeldError, listLeases, releaseLease } from './lease.ts';
218
+ export type { WorldApplyLease } from './lease.ts';
219
+ export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from './plan.ts';
220
+ export type { ActionMapper, ApplyResult, ProviderCall, WorldApplyPlan } from './plan.ts';
221
+ export { formatStatus, worldStatus } from './status.ts';
222
+ export type { WorldStatus } from './status.ts';
package/src/lease.ts ADDED
@@ -0,0 +1,97 @@
1
+ // Apply leases (the twins architecture notes → "Leases"). Before a push/apply, an
2
+ // operator acquires a lease against a provider/checkpoint so two agents cannot push
3
+ // divergent histories onto the same remote baseline. Invariant: at most one active
4
+ // (unreleased, unexpired) lease per (provider, remoteRef.name). Deterministic:
5
+ // caller supplies id/acquiredAt/expiresAt and `now` for expiry checks (no clock).
6
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import type { WorldRemoteRef } from './refs.ts';
9
+ import { readJsonFile, withFileLock, worldPaths } from './storage.ts';
10
+
11
+ export type WorldApplyLease = {
12
+ id: string;
13
+ service: string;
14
+ provider: string;
15
+ remoteRef: WorldRemoteRef;
16
+ planId: string;
17
+ holder: { kind: 'agent' | 'human' | 'system'; id: string };
18
+ acquiredAt: string;
19
+ expiresAt: string;
20
+ releasedAt?: string;
21
+ };
22
+
23
+ export class LeaseHeldError extends Error {
24
+ constructor(readonly held: WorldApplyLease) {
25
+ super(`apply lease already held for ${held.provider}/${held.remoteRef.name} by ${held.holder.kind}:${held.holder.id} (lease ${held.id})`);
26
+ this.name = 'LeaseHeldError';
27
+ }
28
+ }
29
+
30
+ function leasesDir(service: string, root?: string): string {
31
+ return join(worldPaths(service, root).dir, 'leases');
32
+ }
33
+ function leasePath(service: string, id: string, root?: string): string {
34
+ if (!/^[A-Za-z0-9_.-]+$/.test(id)) throw new Error(`invalid lease id: ${id}`);
35
+ return join(leasesDir(service, root), `${id}.json`);
36
+ }
37
+ /** The cross-process mutual-exclusion lockfile guarding acquireLease's whole
38
+ * active-check + write critical section (see acquireLease). One lock per
39
+ * service's leases dir — sufficient because the active-lease check itself is
40
+ * scoped to (service, provider, refName) and different services never share a dir. */
41
+ function acquireLockPath(service: string, root?: string): string {
42
+ return join(leasesDir(service, root), 'acquire.lock');
43
+ }
44
+
45
+ export function listLeases(service: string, root?: string): WorldApplyLease[] {
46
+ const dir = leasesDir(service, root);
47
+ if (!existsSync(dir)) return [];
48
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyLease>(join(dir, f)));
49
+ }
50
+
51
+ export function isLeaseActive(lease: WorldApplyLease, now: string): boolean {
52
+ return !lease.releasedAt && lease.expiresAt > now;
53
+ }
54
+
55
+ /** The active lease (if any) for a provider/checkpoint, given the current time. */
56
+ export function activeLease(service: string, provider: string, refName: string, now: string, root?: string): WorldApplyLease | null {
57
+ return listLeases(service, root).find((l) => l.provider === provider && l.remoteRef.name === refName && isLeaseActive(l, now)) ?? null;
58
+ }
59
+
60
+ function writeLease(lease: WorldApplyLease, root?: string): WorldApplyLease {
61
+ const path = leasePath(lease.service, lease.id, root);
62
+ mkdirSync(join(path, '..'), { recursive: true });
63
+ writeFileSync(path, `${JSON.stringify(lease, null, 2)}\n`);
64
+ return lease;
65
+ }
66
+
67
+ /**
68
+ * Acquire an apply lease. Throws LeaseHeldError if another active lease already
69
+ * targets the same provider/checkpoint (single-writer). `now` is used only for the
70
+ * active-conflict check; `acquiredAt`/`expiresAt` are caller-supplied.
71
+ *
72
+ * The active-check + write is a single critical section run under `withFileLock`
73
+ * (the kernel's cross-process mutual-exclusion primitive — see storage.ts) so two
74
+ * concurrent acquirers targeting the same (service, provider, refName) cannot both
75
+ * observe "no active lease" and both write: only one holds the lock at a time, and
76
+ * by the time the second gets it, the first's lease (if it won) is already on disk.
77
+ */
78
+ export function acquireLease(
79
+ opts: { service: string; provider: string; remoteRef: WorldRemoteRef; planId: string; holder: WorldApplyLease['holder']; id: string; acquiredAt: string; expiresAt: string; now?: string; root?: string },
80
+ ): WorldApplyLease {
81
+ const now = opts.now ?? opts.acquiredAt;
82
+ return withFileLock(acquireLockPath(opts.service, opts.root), () => {
83
+ const held = activeLease(opts.service, opts.provider, opts.remoteRef.name, now, opts.root);
84
+ if (held && held.id !== opts.id) throw new LeaseHeldError(held);
85
+ return writeLease({
86
+ id: opts.id, service: opts.service, provider: opts.provider, remoteRef: opts.remoteRef,
87
+ planId: opts.planId, holder: opts.holder, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt,
88
+ }, opts.root);
89
+ });
90
+ }
91
+
92
+ export function releaseLease(service: string, id: string, opts: { root?: string; at?: string } = {}): WorldApplyLease {
93
+ const path = leasePath(service, id, opts.root);
94
+ if (!existsSync(path)) throw new Error(`no such lease: ${id}`);
95
+ const lease = readJsonFile<WorldApplyLease>(path);
96
+ return writeLease({ ...lease, releasedAt: opts.at ?? new Date().toISOString() }, opts.root);
97
+ }
@@ -0,0 +1,60 @@
1
+ // Pack registry (the twins architecture notes → "Optional shared
2
+ // libraries"; #1). A lightweight, dependency-free way for vendor twins to *declare*
3
+ // themselves so tooling can discover them — "add a vendor" gets cheaper. A pack
4
+ // EXPORTS a `TwinPack` descriptor (no import side-effects); a consumer that imports
5
+ // the packs it wants registers them and queries the registry. The kernel never
6
+ // imports packs (no dependency inversion) — discovery is the consumer's choice.
7
+ //
8
+ // This is an optional convenience, NOT a kernel that defines the twin: a pack can
9
+ // ignore the registry entirely. Pure + deterministic.
10
+ export type PackTransport = 'rest' | 'graphql' | 'web-api';
11
+
12
+ export type TwinPack = {
13
+ /** vendor id / service, e.g. 'stripe'. */
14
+ vendor: string;
15
+ transport: PackTransport;
16
+ /** subject types the twin serves, e.g. ['customer','charge','payment_intent']. */
17
+ resources: string[];
18
+ /** the `world-<vendor>` operator bin, if any. */
19
+ bin?: string;
20
+ /** conformance field map { object: { field: type } } — vendored or derived from the spec. */
21
+ conformanceFields?: Record<string, Record<string, string>>;
22
+ /** where the exact surface came from (a spec path) — provenance for re-derivation. */
23
+ specSource?: string;
24
+ /** one-line human description. */
25
+ description?: string;
26
+ /** How this vendor's BROWSER SDK addresses its API — used by the zero-edit dev proxy so
27
+ * the kernel proxy stays vendor-agnostic (it forwards/rewrites by these values, never by a
28
+ * hardcoded vendor table). `apiPathPrefix`: the same-origin path the browser SDK calls
29
+ * (e.g. Stripe.js → '/v1/'). `loaderHost`: the absolute API host to strip from the loaded
30
+ * SDK so its calls become same-origin (e.g. 'https://api.stripe.com'). Omit for vendors
31
+ * with no browser SDK. */
32
+ browserRouting?: { apiPathPrefix: string; loaderHost?: string };
33
+ };
34
+
35
+ const registry = new Map<string, TwinPack>();
36
+
37
+ /** Register (or replace) a pack descriptor. Returns it. */
38
+ export function registerPack(pack: TwinPack): TwinPack {
39
+ if (!/^[a-z0-9-]+$/.test(pack.vendor)) throw new Error(`invalid pack vendor id: ${pack.vendor}`);
40
+ registry.set(pack.vendor, pack);
41
+ return pack;
42
+ }
43
+
44
+ export function getPack(vendor: string): TwinPack | undefined {
45
+ return registry.get(vendor);
46
+ }
47
+
48
+ /** All registered packs, sorted by vendor (deterministic). */
49
+ export function listPacks(): TwinPack[] {
50
+ return [...registry.values()].sort((a, b) => a.vendor.localeCompare(b.vendor));
51
+ }
52
+
53
+ export function hasPack(vendor: string): boolean {
54
+ return registry.has(vendor);
55
+ }
56
+
57
+ /** Clear the registry (tests). */
58
+ export function clearRegistry(): void {
59
+ registry.clear();
60
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,190 @@
1
+ // Apply plan (the twins architecture notes → "Plan"). The Terraform-plan /
2
+ // pull-request object for non-code systems: a reviewable, replayable proposal of
3
+ // exactly what a push/apply will do — which local transactions, the provider
4
+ // calls + idempotency keys, destructive flags, expected confirmations, conflicts
5
+ // (failed preconditions), and whether approval is required. No real push happens
6
+ // from an implicit action list; it executes a plan. `applyPlan` is the gated
7
+ // orchestrator: recompute requiresApproval from the plan's own data (never trust
8
+ // the stored bit) → refuse a stale base → acquire lease → push each call through
9
+ // phases → release.
10
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { pendingActions, projectResources } from './actions.ts';
13
+ import type { TwinAction, TwinActionPrecondition } from './actions.ts';
14
+ import { isBaseStale } from './refs.ts';
15
+ import type { WorldLocalRef, WorldRemoteRef } from './refs.ts';
16
+ import { acquireLease, releaseLease } from './lease.ts';
17
+ import { pushTransaction } from './pushLedger.ts';
18
+ import type { PushOutcome, WorldPushRecord } from './pushLedger.ts';
19
+ import type { TwinResource } from './serve.ts';
20
+ import { readJsonFile, worldPaths } from './storage.ts';
21
+
22
+ export type ProviderCall = {
23
+ actionId: string;
24
+ provider: string;
25
+ operation: string;
26
+ input: Record<string, unknown>;
27
+ idempotencyKey: string;
28
+ destructive: boolean;
29
+ expectedConfirmation: { subject: { type: string; id: string }; eventType: string; matcher: Record<string, unknown> };
30
+ };
31
+
32
+ export type WorldApplyPlan = {
33
+ id: string;
34
+ service: string;
35
+ forkId: string;
36
+ baseRemoteRef: WorldRemoteRef;
37
+ transactions: string[];
38
+ providerCalls: ProviderCall[];
39
+ conflicts: Array<{ actionId: string; reason: string }>;
40
+ requiresApproval: boolean;
41
+ createdAt: string;
42
+ };
43
+
44
+ /** Map a local transaction commit → the provider call that materializes it (vendor-specific). Return null to skip. */
45
+ export type ActionMapper = (action: TwinAction) => Omit<ProviderCall, 'actionId'> | null;
46
+
47
+ function resourcesById(service: string, root?: string): Map<string, TwinResource> {
48
+ return new Map(projectResources(service, root).map((r) => [`${r.type}:${r.id}`, r]));
49
+ }
50
+
51
+ function preconditionFailure(action: TwinAction, byId: Map<string, TwinResource>): string | null {
52
+ for (const p of action.preconditions ?? []) {
53
+ const resource = byId.get(`${p.subject.type}:${p.subject.id}`);
54
+ const actual = resource ? (resource as Record<string, unknown>)[p.field] : undefined;
55
+ const ok = evalPrecondition(p, actual);
56
+ if (!ok) return `${p.subject.type}:${p.subject.id}.${p.field} ${p.op}${p.value !== undefined ? ` ${JSON.stringify(p.value)}` : ''}`;
57
+ }
58
+ return null;
59
+ }
60
+ function evalPrecondition(p: TwinActionPrecondition, actual: unknown): boolean {
61
+ switch (p.op) {
62
+ case 'exists': return actual !== undefined;
63
+ case 'not_exists': return actual === undefined;
64
+ case 'eq': case 'version_eq': return Object.is(actual, p.value);
65
+ case 'neq': return !Object.is(actual, p.value);
66
+ default: return false;
67
+ }
68
+ }
69
+
70
+ /** Pending transactions whose preconditions would fail against current projected state. */
71
+ export function pendingConflicts(service: string, root?: string): Array<{ actionId: string; reason: string }> {
72
+ const byId = resourcesById(service, root);
73
+ const out: Array<{ actionId: string; reason: string }> = [];
74
+ for (const action of pendingActions(service, root)) {
75
+ const failure = preconditionFailure(action, byId);
76
+ if (failure) out.push({ actionId: action.id, reason: `precondition would fail: ${failure}` });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Build a reviewable apply plan from a fork's pending transactions. `mapper` is the
83
+ * vendor-specific action→provider-call mapping; actions it returns null for are
84
+ * omitted. Conflicts are pending actions whose preconditions fail against current
85
+ * projected state. Approval is required if any call is destructive or any conflict exists.
86
+ */
87
+ export function buildApplyPlan(opts: {
88
+ service: string;
89
+ forkId: string;
90
+ baseRemoteRef: WorldRemoteRef;
91
+ mapper: ActionMapper;
92
+ id: string;
93
+ createdAt: string;
94
+ root?: string;
95
+ }): WorldApplyPlan {
96
+ const pending = pendingActions(opts.service, opts.root);
97
+ const byId = resourcesById(opts.service, opts.root);
98
+ const providerCalls: ProviderCall[] = [];
99
+ const conflicts: WorldApplyPlan['conflicts'] = [];
100
+ for (const action of pending) {
101
+ const failure = preconditionFailure(action, byId);
102
+ if (failure) { conflicts.push({ actionId: action.id, reason: `precondition would fail: ${failure}` }); continue; }
103
+ const mapped = opts.mapper(action);
104
+ if (mapped) providerCalls.push({ actionId: action.id, ...mapped });
105
+ }
106
+ return {
107
+ id: opts.id, service: opts.service, forkId: opts.forkId, baseRemoteRef: opts.baseRemoteRef,
108
+ transactions: pending.map((a) => a.id), providerCalls, conflicts,
109
+ requiresApproval: conflicts.length > 0 || providerCalls.some((c) => c.destructive),
110
+ createdAt: opts.createdAt,
111
+ };
112
+ }
113
+
114
+ function plansDir(service: string, root?: string): string {
115
+ return join(worldPaths(service, root).dir, 'plans');
116
+ }
117
+ export function writePlan(plan: WorldApplyPlan, root?: string): WorldApplyPlan {
118
+ if (!/^[A-Za-z0-9_.-]+$/.test(plan.id)) throw new Error(`invalid plan id: ${plan.id}`);
119
+ const path = join(plansDir(plan.service, root), `${plan.id}.json`);
120
+ mkdirSync(join(path, '..'), { recursive: true });
121
+ writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`);
122
+ return plan;
123
+ }
124
+ export function readPlan(service: string, id: string, root?: string): WorldApplyPlan | null {
125
+ const path = join(plansDir(service, root), `${id}.json`);
126
+ return existsSync(path) ? readJsonFile<WorldApplyPlan>(path) : null;
127
+ }
128
+ export function listPlans(service: string, root?: string): WorldApplyPlan[] {
129
+ const dir = plansDir(service, root);
130
+ if (!existsSync(dir)) return [];
131
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyPlan>(join(dir, f)));
132
+ }
133
+
134
+ export type ApplyResult = { planId: string; leaseId: string; pushed: WorldPushRecord[]; skipped: string[] };
135
+
136
+ /**
137
+ * Whether a plan requires approval before it may be applied — recomputed from the
138
+ * plan's OWN `providerCalls`/`conflicts`, the exact rule `buildApplyPlan` uses to set
139
+ * the field in the first place. `applyPlan` calls this instead of trusting
140
+ * `plan.requiresApproval`: that field lives on plan JSON that is neither signed nor
141
+ * otherwise authenticated, so a hand-edited file (or a hand-built plan object) that
142
+ * flips the boolean to `false` must not be able to skip the approval gate — only
143
+ * removing the destructive calls/conflicts themselves does.
144
+ */
145
+ export function planRequiresApproval(plan: WorldApplyPlan): boolean {
146
+ return plan.conflicts.length > 0 || plan.providerCalls.some((c) => c.destructive);
147
+ }
148
+
149
+ /**
150
+ * Enact a plan against the real provider — the gated push path. Refuses an
151
+ * unapproved plan (approval requirement recomputed from the plan's own data, never
152
+ * trusted off the stored bit) and a push whose base has gone stale (the live
153
+ * remote ref moved since `plan.baseRemoteRef` was recorded) before any provider
154
+ * call or lease acquisition — zero side effects either way. Then acquires an apply
155
+ * lease for the plan's baseRemoteRef (single-writer), drives every provider call
156
+ * through the push phases via the injected writeFn (the only real I/O), then
157
+ * releases the lease.
158
+ */
159
+ export async function applyPlan(
160
+ plan: WorldApplyPlan,
161
+ writeFn: (call: ProviderCall) => Promise<PushOutcome>,
162
+ opts: { holder: { kind: 'agent' | 'human' | 'system'; id: string }; leaseId: string; acquiredAt: string; expiresAt: string; occurredAt: string; approve?: boolean; overrideStaleBase?: boolean; root?: string },
163
+ ): Promise<ApplyResult> {
164
+ if (planRequiresApproval(plan) && !opts.approve) throw new Error(`plan ${plan.id} requires approval (conflicts or destructive calls); pass approve:true to apply`);
165
+ const localRefView: WorldLocalRef = { service: plan.service, forkId: plan.forkId, baseRemoteRef: plan.baseRemoteRef, recordedAt: plan.createdAt };
166
+ if (isBaseStale(localRefView, opts.root) && !opts.overrideStaleBase) {
167
+ throw new Error(`plan ${plan.id} base (${plan.baseRemoteRef.provider}/${plan.baseRemoteRef.name}) is stale — the remote ref advanced since this plan's base was recorded; rebase and rebuild the plan, or pass overrideStaleBase:true to apply anyway`);
168
+ }
169
+ const lease = acquireLease({
170
+ service: plan.service, provider: plan.baseRemoteRef.provider, remoteRef: plan.baseRemoteRef, planId: plan.id,
171
+ holder: opts.holder, id: opts.leaseId, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt, now: opts.acquiredAt, root: opts.root,
172
+ });
173
+ const pushed: WorldPushRecord[] = [];
174
+ const skipped: string[] = [];
175
+ const actionsById = new Map(pendingActions(plan.service, opts.root).map((a) => [a.id, a]));
176
+ try {
177
+ for (const call of plan.providerCalls) {
178
+ const action = actionsById.get(call.actionId);
179
+ if (!action) { skipped.push(call.actionId); continue; }
180
+ const record = await pushTransaction(
181
+ { service: plan.service, action: { id: action.id, subject: action.subject, fields: action.fields ?? {}, correlationId: action.correlationId }, provider: call.provider, operation: call.operation, idempotencyKey: call.idempotencyKey, occurredAt: opts.occurredAt, root: opts.root },
182
+ () => writeFn(call),
183
+ );
184
+ pushed.push(record);
185
+ }
186
+ } finally {
187
+ releaseLease(plan.service, lease.id, { at: opts.occurredAt, root: opts.root });
188
+ }
189
+ return { planId: plan.id, leaseId: lease.id, pushed, skipped };
190
+ }