@volter/twin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
package/src/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
|
+
}
|