@volter/twin 0.1.0 → 0.1.1
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/README.md +16 -2
- package/inject.cjs +453 -59
- package/package.json +12 -22
- package/src/actions.ts +234 -49
- package/src/blob-store.ts +136 -0
- package/src/changeset.ts +807 -0
- package/src/cli.ts +60 -10
- package/src/connector.ts +30 -7
- package/src/control-plane.ts +17 -1
- package/src/emit.ts +242 -0
- package/src/fork.ts +19 -7
- package/src/index.ts +139 -6
- package/src/lease.ts +4 -6
- package/src/lifecycle.ts +8 -0
- package/src/packRegistry.ts +248 -2
- package/src/plan.ts +131 -23
- package/src/proxy.ts +5 -2
- package/src/pushLedger.ts +116 -11
- package/src/queueLifecycle.ts +3 -4
- package/src/rateBudget.ts +1115 -0
- package/src/refs.ts +9 -10
- package/src/remote-execute.ts +16 -0
- package/src/scenario.ts +387 -0
- package/src/serve.ts +397 -15
- package/src/shadow.ts +86 -7
- package/src/storage.ts +76 -147
- package/src/sync.ts +63 -17
- package/src/twin-fetch.ts +115 -0
- package/src/validate.ts +6 -5
- package/src/world-clock.ts +33 -0
- package/src/world-store.ts +482 -0
- package/src/worldConfig.ts +4 -3
- package/dist/src/actions.d.ts +0 -138
- package/dist/src/actions.js +0 -201
- package/dist/src/args.d.ts +0 -3
- package/dist/src/args.js +0 -12
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -425
- package/dist/src/connector.d.ts +0 -106
- package/dist/src/connector.js +0 -129
- package/dist/src/control-plane.d.ts +0 -21
- package/dist/src/control-plane.js +0 -40
- package/dist/src/egress.d.ts +0 -93
- package/dist/src/egress.js +0 -264
- package/dist/src/fork.d.ts +0 -126
- package/dist/src/fork.js +0 -206
- package/dist/src/index.d.ts +0 -42
- package/dist/src/index.js +0 -52
- package/dist/src/lease.d.ts +0 -50
- package/dist/src/lease.js +0 -80
- package/dist/src/packRegistry.d.ts +0 -34
- package/dist/src/packRegistry.js +0 -22
- package/dist/src/plan.d.ts +0 -97
- package/dist/src/plan.js +0 -151
- package/dist/src/proxy.d.ts +0 -25
- package/dist/src/proxy.js +0 -152
- package/dist/src/pushLedger.d.ts +0 -81
- package/dist/src/pushLedger.js +0 -130
- package/dist/src/queueLifecycle.d.ts +0 -62
- package/dist/src/queueLifecycle.js +0 -95
- package/dist/src/reconcile.d.ts +0 -58
- package/dist/src/reconcile.js +0 -137
- package/dist/src/refs.d.ts +0 -29
- package/dist/src/refs.js +0 -68
- package/dist/src/schemas.d.ts +0 -78
- package/dist/src/schemas.js +0 -50
- package/dist/src/serve.d.ts +0 -44
- package/dist/src/serve.js +0 -93
- package/dist/src/shadow.d.ts +0 -77
- package/dist/src/shadow.js +0 -138
- package/dist/src/status.d.ts +0 -31
- package/dist/src/status.js +0 -42
- package/dist/src/storage.d.ts +0 -119
- package/dist/src/storage.js +0 -535
- package/dist/src/sync.d.ts +0 -91
- package/dist/src/sync.js +0 -121
- package/dist/src/types.d.ts +0 -40
- package/dist/src/types.js +0 -1
- package/dist/src/validate.d.ts +0 -27
- package/dist/src/validate.js +0 -68
- package/dist/src/visualizer.d.ts +0 -13
- package/dist/src/visualizer.js +0 -133
- package/dist/src/worldConfig.d.ts +0 -9
- package/dist/src/worldConfig.js +0 -16
package/src/plan.ts
CHANGED
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
// orchestrator: recompute requiresApproval from the plan's own data (never trust
|
|
8
8
|
// the stored bit) → refuse a stale base → acquire lease → push each call through
|
|
9
9
|
// phases → release.
|
|
10
|
-
import {
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
12
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
13
|
+
import { checkPrecondition, pendingActions, projectResources } from './actions.ts';
|
|
14
|
+
import type { TwinAction } from './actions.ts';
|
|
14
15
|
import { isBaseStale } from './refs.ts';
|
|
15
16
|
import type { WorldLocalRef, WorldRemoteRef } from './refs.ts';
|
|
16
17
|
import { acquireLease, releaseLease } from './lease.ts';
|
|
17
18
|
import { pushTransaction } from './pushLedger.ts';
|
|
18
19
|
import type { PushOutcome, WorldPushRecord } from './pushLedger.ts';
|
|
19
20
|
import type { TwinResource } from './serve.ts';
|
|
20
|
-
import { readJsonFile, worldPaths } from './storage.ts';
|
|
21
|
+
import { appendDurable, readJsonFile, worldPaths } from './storage.ts';
|
|
21
22
|
|
|
22
23
|
export type ProviderCall = {
|
|
23
24
|
actionId: string;
|
|
@@ -41,6 +42,121 @@ export type WorldApplyPlan = {
|
|
|
41
42
|
createdAt: string;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
export type PlanReviewDecision = 'approved' | 'rejected';
|
|
46
|
+
export type PlanReviewRecord = {
|
|
47
|
+
id: string;
|
|
48
|
+
service: string;
|
|
49
|
+
transactionSetId: string;
|
|
50
|
+
transactions: string[];
|
|
51
|
+
decision: PlanReviewDecision;
|
|
52
|
+
actor: { kind: 'agent' | 'human'; id: string };
|
|
53
|
+
reason?: string;
|
|
54
|
+
occurredAt: string;
|
|
55
|
+
};
|
|
56
|
+
export type LocalActionPlan = {
|
|
57
|
+
id: string;
|
|
58
|
+
service: string;
|
|
59
|
+
transactions: Array<{ id: string; operation?: string; subject: { type: string; id: string } }>;
|
|
60
|
+
conflicts: Array<{ actionId: string; reason: string }>;
|
|
61
|
+
review: PlanReviewRecord | null;
|
|
62
|
+
requiresApproval: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Stable identity for the exact ordered pending-action set being reviewed. */
|
|
66
|
+
export function transactionSetId(service: string, transactions: string[]): string {
|
|
67
|
+
const digest = createHash('sha256').update(JSON.stringify([service, transactions])).digest('hex').slice(0, 16);
|
|
68
|
+
return `plan:${service}:${digest}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reviewsPath(service: string, root?: string): string {
|
|
72
|
+
return join(worldPaths(service, root).dir, 'plan-reviews.jsonl');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function listPlanReviews(service: string, root?: string): PlanReviewRecord[] {
|
|
76
|
+
const path = reviewsPath(service, root);
|
|
77
|
+
return getActiveWorldStore().readLines(path).filter(Boolean).map((line) => JSON.parse(line) as PlanReviewRecord);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function latestPlanReview(service: string, transactions: string[], root?: string): PlanReviewRecord | null {
|
|
81
|
+
const id = transactionSetId(service, transactions);
|
|
82
|
+
return listPlanReviews(service, root).filter((review) => review.transactionSetId === id).at(-1) ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Review view over the same pending actions written by SDK-backed twin traffic. */
|
|
86
|
+
export function buildLocalActionPlan(service: string, root?: string): LocalActionPlan {
|
|
87
|
+
const pending = pendingActions(service, root);
|
|
88
|
+
const ids = pending.map((action) => action.id);
|
|
89
|
+
const review = latestPlanReview(service, ids, root);
|
|
90
|
+
return {
|
|
91
|
+
id: transactionSetId(service, ids),
|
|
92
|
+
service,
|
|
93
|
+
transactions: pending.map((action) => ({ id: action.id, operation: action.operation, subject: action.subject })),
|
|
94
|
+
conflicts: pendingConflicts(service, root),
|
|
95
|
+
review,
|
|
96
|
+
requiresApproval: ids.length > 0 && review?.decision !== 'approved',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function recordPlanReview(opts: {
|
|
101
|
+
service: string;
|
|
102
|
+
expectedTransactionSetId: string;
|
|
103
|
+
decision: PlanReviewDecision;
|
|
104
|
+
actor: { kind: 'agent' | 'human'; id: string };
|
|
105
|
+
reason?: string;
|
|
106
|
+
occurredAt?: string;
|
|
107
|
+
root?: string;
|
|
108
|
+
}): PlanReviewRecord {
|
|
109
|
+
const transactions = pendingActions(opts.service, opts.root).map((action) => action.id);
|
|
110
|
+
if (transactions.length === 0) throw new Error(`cannot review ${opts.service}: no pending transactions`);
|
|
111
|
+
const currentTransactionSetId = transactionSetId(opts.service, transactions);
|
|
112
|
+
if (opts.expectedTransactionSetId !== currentTransactionSetId) {
|
|
113
|
+
throw new Error(`cannot review ${opts.service}: transaction set changed (expected ${opts.expectedTransactionSetId}, current ${currentTransactionSetId})`);
|
|
114
|
+
}
|
|
115
|
+
if (!opts.actor.id.trim()) throw new Error('plan review actor id is required');
|
|
116
|
+
if (opts.decision === 'rejected' && !opts.reason?.trim()) throw new Error('rejected plan review requires a reason');
|
|
117
|
+
const record: PlanReviewRecord = {
|
|
118
|
+
id: `review:${randomUUID()}`,
|
|
119
|
+
service: opts.service,
|
|
120
|
+
transactionSetId: currentTransactionSetId,
|
|
121
|
+
transactions,
|
|
122
|
+
decision: opts.decision,
|
|
123
|
+
actor: opts.actor,
|
|
124
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
125
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
const path = reviewsPath(opts.service, opts.root);
|
|
128
|
+
getActiveWorldStore().mkdir(join(path, '..'));
|
|
129
|
+
appendDurable(path, `${JSON.stringify(record)}\n`);
|
|
130
|
+
return record;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ApprovedPendingActionSet = {
|
|
134
|
+
review: PlanReviewRecord | null;
|
|
135
|
+
actions: TwinAction[];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Credential-bound connector snapshot: refuse an unapproved exact set and return only
|
|
140
|
+
* the actions that were compared with that approval. Callers must consume this snapshot
|
|
141
|
+
* instead of rereading pending actions, or a concurrent append could enter the push after
|
|
142
|
+
* the approval check.
|
|
143
|
+
*/
|
|
144
|
+
export function approvedPendingActionSet(service: string, root?: string): ApprovedPendingActionSet {
|
|
145
|
+
const actions = pendingActions(service, root);
|
|
146
|
+
const transactions = actions.map((action) => action.id);
|
|
147
|
+
if (transactions.length === 0) return { review: null, actions };
|
|
148
|
+
const review = latestPlanReview(service, transactions, root);
|
|
149
|
+
if (review?.decision !== 'approved') {
|
|
150
|
+
throw new Error(`push refused: transaction set ${transactionSetId(service, transactions)} requires a durable approval`);
|
|
151
|
+
}
|
|
152
|
+
return { review, actions };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Credential-bound connector guard retained for callers that only need the verdict. */
|
|
156
|
+
export function assertPendingActionsApproved(service: string, root?: string): PlanReviewRecord | null {
|
|
157
|
+
return approvedPendingActionSet(service, root).review;
|
|
158
|
+
}
|
|
159
|
+
|
|
44
160
|
/** Map a local transaction commit → the provider call that materializes it (vendor-specific). Return null to skip. */
|
|
45
161
|
export type ActionMapper = (action: TwinAction) => Omit<ProviderCall, 'actionId'> | null;
|
|
46
162
|
|
|
@@ -52,20 +168,11 @@ function preconditionFailure(action: TwinAction, byId: Map<string, TwinResource>
|
|
|
52
168
|
for (const p of action.preconditions ?? []) {
|
|
53
169
|
const resource = byId.get(`${p.subject.type}:${p.subject.id}`);
|
|
54
170
|
const actual = resource ? (resource as Record<string, unknown>)[p.field] : undefined;
|
|
55
|
-
const ok =
|
|
171
|
+
const ok = checkPrecondition(p, actual);
|
|
56
172
|
if (!ok) return `${p.subject.type}:${p.subject.id}.${p.field} ${p.op}${p.value !== undefined ? ` ${JSON.stringify(p.value)}` : ''}`;
|
|
57
173
|
}
|
|
58
174
|
return null;
|
|
59
175
|
}
|
|
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
176
|
|
|
70
177
|
/** Pending transactions whose preconditions would fail against current projected state. */
|
|
71
178
|
export function pendingConflicts(service: string, root?: string): Array<{ actionId: string; reason: string }> {
|
|
@@ -106,7 +213,7 @@ export function buildApplyPlan(opts: {
|
|
|
106
213
|
return {
|
|
107
214
|
id: opts.id, service: opts.service, forkId: opts.forkId, baseRemoteRef: opts.baseRemoteRef,
|
|
108
215
|
transactions: pending.map((a) => a.id), providerCalls, conflicts,
|
|
109
|
-
requiresApproval:
|
|
216
|
+
requiresApproval: pending.length > 0,
|
|
110
217
|
createdAt: opts.createdAt,
|
|
111
218
|
};
|
|
112
219
|
}
|
|
@@ -117,18 +224,16 @@ function plansDir(service: string, root?: string): string {
|
|
|
117
224
|
export function writePlan(plan: WorldApplyPlan, root?: string): WorldApplyPlan {
|
|
118
225
|
if (!/^[A-Za-z0-9_.-]+$/.test(plan.id)) throw new Error(`invalid plan id: ${plan.id}`);
|
|
119
226
|
const path = join(plansDir(plan.service, root), `${plan.id}.json`);
|
|
120
|
-
|
|
121
|
-
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`);
|
|
227
|
+
getActiveWorldStore().write(path, `${JSON.stringify(plan, null, 2)}\n`);
|
|
122
228
|
return plan;
|
|
123
229
|
}
|
|
124
230
|
export function readPlan(service: string, id: string, root?: string): WorldApplyPlan | null {
|
|
125
231
|
const path = join(plansDir(service, root), `${id}.json`);
|
|
126
|
-
return
|
|
232
|
+
return getActiveWorldStore().exists(path) ? readJsonFile<WorldApplyPlan>(path) : null;
|
|
127
233
|
}
|
|
128
234
|
export function listPlans(service: string, root?: string): WorldApplyPlan[] {
|
|
129
235
|
const dir = plansDir(service, root);
|
|
130
|
-
|
|
131
|
-
return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyPlan>(join(dir, f)));
|
|
236
|
+
return getActiveWorldStore().list(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyPlan>(join(dir, f)));
|
|
132
237
|
}
|
|
133
238
|
|
|
134
239
|
export type ApplyResult = { planId: string; leaseId: string; pushed: WorldPushRecord[]; skipped: string[] };
|
|
@@ -143,7 +248,7 @@ export type ApplyResult = { planId: string; leaseId: string; pushed: WorldPushRe
|
|
|
143
248
|
* removing the destructive calls/conflicts themselves does.
|
|
144
249
|
*/
|
|
145
250
|
export function planRequiresApproval(plan: WorldApplyPlan): boolean {
|
|
146
|
-
return plan.
|
|
251
|
+
return plan.transactions.length > 0;
|
|
147
252
|
}
|
|
148
253
|
|
|
149
254
|
/**
|
|
@@ -159,9 +264,12 @@ export function planRequiresApproval(plan: WorldApplyPlan): boolean {
|
|
|
159
264
|
export async function applyPlan(
|
|
160
265
|
plan: WorldApplyPlan,
|
|
161
266
|
writeFn: (call: ProviderCall) => Promise<PushOutcome>,
|
|
162
|
-
opts: { holder: { kind: 'agent' | 'human' | 'system'; id: string }; leaseId: string; acquiredAt: string; expiresAt: string; occurredAt: string;
|
|
267
|
+
opts: { holder: { kind: 'agent' | 'human' | 'system'; id: string }; leaseId: string; acquiredAt: string; expiresAt: string; occurredAt: string; overrideStaleBase?: boolean; root?: string },
|
|
163
268
|
): Promise<ApplyResult> {
|
|
164
|
-
|
|
269
|
+
const review = latestPlanReview(plan.service, plan.transactions, opts.root);
|
|
270
|
+
if (planRequiresApproval(plan) && review?.decision !== 'approved') {
|
|
271
|
+
throw new Error(`plan ${plan.id} requires approval: record a durable approval for transaction set ${transactionSetId(plan.service, plan.transactions)}`);
|
|
272
|
+
}
|
|
165
273
|
const localRefView: WorldLocalRef = { service: plan.service, forkId: plan.forkId, baseRemoteRef: plan.baseRemoteRef, recordedAt: plan.createdAt };
|
|
166
274
|
if (isBaseStale(localRefView, opts.root) && !opts.overrideStaleBase) {
|
|
167
275
|
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`);
|
package/src/proxy.ts
CHANGED
|
@@ -154,9 +154,12 @@ export function createTwinProxy(opts: TwinProxyOptions): TwinProxy {
|
|
|
154
154
|
}, redirectOrigins);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
// WebSocket / HTTP upgrade passthrough
|
|
157
|
+
// WebSocket / HTTP upgrade passthrough — routed by the SAME vendor map as plain
|
|
158
|
+
// requests, so a vendor whose browser surface speaks WebSocket (a media SFU's
|
|
159
|
+
// signal path, a realtime API) reaches its twin instead of the app.
|
|
158
160
|
server.on('upgrade', (req, socket, head) => {
|
|
159
|
-
const
|
|
161
|
+
const upgradeVendor = vendorForPath(req.url || '/', map);
|
|
162
|
+
const target = new URL(req.url || '/', upgradeVendor ? map[upgradeVendor].origin : opts.target);
|
|
160
163
|
const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
|
|
161
164
|
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
|
|
162
165
|
for (const [name, value] of Object.entries(req.headers)) upstream.write(`${name}: ${value}\r\n`);
|
package/src/pushLedger.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// (UnreconciledPushError) unless the caller verifies external state and passes
|
|
17
17
|
// onUnreconciled: 'retry' (mirrors egress.ts's UnreconciledWriteIntentError).
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
|
-
import {
|
|
19
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
-
import { confirmAction } from './actions.ts';
|
|
21
|
+
import { confirmAction, listActions } from './actions.ts';
|
|
22
|
+
import { remoteRefs } from './shadow.ts';
|
|
22
23
|
import type { SubjectFields } from './shadow.ts';
|
|
23
24
|
import { appendDurable, twinLog, withFileLock, worldPaths } from './storage.ts';
|
|
24
25
|
|
|
@@ -70,7 +71,7 @@ function ledgerLockPath(service: string, root?: string): string {
|
|
|
70
71
|
|
|
71
72
|
export function appendPushRecord(record: WorldPushRecord, root?: string): WorldPushRecord {
|
|
72
73
|
const path = ledgerPath(record.service, root);
|
|
73
|
-
|
|
74
|
+
getActiveWorldStore().mkdir(join(path, '..'));
|
|
74
75
|
appendDurable(path, `${JSON.stringify(record)}\n`);
|
|
75
76
|
twinLog('push.append', { service: record.service, id: record.id, actionId: record.actionId, status: record.status, correlationId: record.correlationId });
|
|
76
77
|
return record;
|
|
@@ -78,8 +79,7 @@ export function appendPushRecord(record: WorldPushRecord, root?: string): WorldP
|
|
|
78
79
|
|
|
79
80
|
export function listPushLedger(service: string, root?: string): WorldPushRecord[] {
|
|
80
81
|
const path = ledgerPath(service, root);
|
|
81
|
-
|
|
82
|
-
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as WorldPushRecord);
|
|
82
|
+
return getActiveWorldStore().readLines(path).filter((l) => l.trim()).map((l) => JSON.parse(l) as WorldPushRecord);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** Latest phase per push id (append order is the tiebreak). */
|
|
@@ -106,6 +106,86 @@ export class UnreconciledPushError extends Error {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Runtime contract R14: the remote moved since this action was authored — a push now
|
|
110
|
+
* would overwrite changes we have already observed but never merged (the lost-update
|
|
111
|
+
* class two §9 rounds paid for). Fetch + reconcile (reconcile.ts, three-way over the
|
|
112
|
+
* stamped base), then revert or re-author the action; there is no rebase. */
|
|
113
|
+
export class NonFastForwardPushError extends Error {
|
|
114
|
+
readonly pushId: string;
|
|
115
|
+
/** subjectKey → { base: ref at authoring, current: ref now } for every drifted subject. */
|
|
116
|
+
readonly drifted: Record<string, { base: string | null; current: string | null }>;
|
|
117
|
+
|
|
118
|
+
constructor(pushId: string, drifted: Record<string, { base: string | null; current: string | null }>) {
|
|
119
|
+
const subjects = Object.keys(drifted).join(', ');
|
|
120
|
+
super(
|
|
121
|
+
`Push ${pushId} is not a fast-forward: the observed mirror moved past this action's shadow basis for ${subjects}. ` +
|
|
122
|
+
`Fetch + reconcile, then revert or re-author the action (or pass onDrift: 'force' only after a reviewed reconcile).`,
|
|
123
|
+
);
|
|
124
|
+
this.name = 'NonFastForwardPushError';
|
|
125
|
+
this.pushId = pushId;
|
|
126
|
+
this.drifted = drifted;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Compare an action's stamped shadow basis against the mirror's current remote refs.
|
|
131
|
+
* Returns the drifted subjects (empty = fast-forward). Unstamped actions (pre-R14
|
|
132
|
+
* ledgers, hand-built rows) have nothing to compare — the stamp is the opt-in; the
|
|
133
|
+
* skip is logged so ungated pushes stay visible in the audit trail.
|
|
134
|
+
*
|
|
135
|
+
* Ancestry rule (the git semantics): a confirmation of an action authored BEFORE the
|
|
136
|
+
* pushed one is a fast-forward (our own earlier push moved the remote — pushing in
|
|
137
|
+
* order stacks). A confirmation of an action authored AFTER it counts as DRIFT:
|
|
138
|
+
* pushing a stale action once a newer one already landed remotely is the out-of-order
|
|
139
|
+
* lost update, and the gate refuses it. */
|
|
140
|
+
function shadowDrift(
|
|
141
|
+
service: string,
|
|
142
|
+
actionId: string,
|
|
143
|
+
root?: string,
|
|
144
|
+
): Record<string, { base: string | null; current: string | null }> {
|
|
145
|
+
const actions = listActions(service, root);
|
|
146
|
+
const index = actions.findIndex((a) => a.id === actionId);
|
|
147
|
+
const basis = index === -1 ? undefined : actions[index]!.shadowBasis;
|
|
148
|
+
if (!basis) {
|
|
149
|
+
twinLog('push.drift.unstamped', { service, actionId, found: index !== -1 });
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
// Descendant confirmations count as drift; ancestor (and the pushed action's own)
|
|
153
|
+
// confirmations are fast-forward, and ORPHANED confirm events (no confirm row —
|
|
154
|
+
// confirmAction crash artifacts) stay excluded like they were at stamp time.
|
|
155
|
+
const ancestorIds = new Set(actions.slice(0, index + 1).map((a) => a.id));
|
|
156
|
+
const countAsDrift = new Set<string>();
|
|
157
|
+
for (const a of actions) {
|
|
158
|
+
if (a.op !== 'confirm' || !a.confirmsActionId || ancestorIds.has(a.confirmsActionId)) continue;
|
|
159
|
+
for (const id of a.observedEventIds ?? (a.observedEventId ? [a.observedEventId] : [])) countAsDrift.add(id);
|
|
160
|
+
}
|
|
161
|
+
const subjects = Object.keys(basis).map((key) => {
|
|
162
|
+
const sep = key.indexOf(':');
|
|
163
|
+
return { type: key.slice(0, sep), id: key.slice(sep + 1) };
|
|
164
|
+
});
|
|
165
|
+
const current = remoteRefs(service, subjects, root, { countAsDrift });
|
|
166
|
+
const drifted: Record<string, { base: string | null; current: string | null }> = {};
|
|
167
|
+
for (const [key, base] of Object.entries(basis)) {
|
|
168
|
+
if (current[key] !== base) drifted[key] = { base, current: current[key] ?? null };
|
|
169
|
+
}
|
|
170
|
+
return drifted;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The R14 gate as a standalone choke, for push paths that do their vendor I/O
|
|
175
|
+
* themselves: connectors doing `pendingActions → vendor HTTP → confirmAction` call this
|
|
176
|
+
* IMMEDIATELY BEFORE the vendor write (the sweep enrolling all of them is tracked in the
|
|
177
|
+
* roadmap; pushTransaction and syncPush are wired already). Throws
|
|
178
|
+
* NonFastForwardPushError on drift; silent for unstamped actions (logged).
|
|
179
|
+
*
|
|
180
|
+
* Honest bound: this check narrows the lost-update window, it cannot close it — a delta
|
|
181
|
+
* observed between this check and the vendor call still loses; only vendor-side
|
|
182
|
+
* preconditions (etags, versions) inside the write itself close that residue.
|
|
183
|
+
*/
|
|
184
|
+
export function assertFastForward(service: string, actionId: string, root?: string): void {
|
|
185
|
+
const drifted = shadowDrift(service, actionId, root);
|
|
186
|
+
if (Object.keys(drifted).length > 0) throw new NonFastForwardPushError(`push:${service}:${actionId}`, drifted);
|
|
187
|
+
}
|
|
188
|
+
|
|
109
189
|
/**
|
|
110
190
|
* Drive one local transaction commit through the push phases against the real
|
|
111
191
|
* provider via the injected writeFn (the ONLY real I/O — kernel holds no creds).
|
|
@@ -132,10 +212,14 @@ export async function pushTransaction(
|
|
|
132
212
|
occurredAt: string;
|
|
133
213
|
root?: string;
|
|
134
214
|
onUnreconciled?: 'fail' | 'retry';
|
|
215
|
+
/** R14 non-fast-forward posture. 'refuse' (default): drifted remote refs abort the
|
|
216
|
+
* push with NonFastForwardPushError before any ledger row lands. 'force': push
|
|
217
|
+
* anyway — only after a reviewed reconcile has decided the twin's value wins. */
|
|
218
|
+
onDrift?: 'refuse' | 'force';
|
|
135
219
|
},
|
|
136
220
|
writeFn: () => Promise<PushOutcome>,
|
|
137
221
|
): Promise<WorldPushRecord> {
|
|
138
|
-
const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail' } = opts;
|
|
222
|
+
const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail', onDrift = 'refuse' } = opts;
|
|
139
223
|
const pushId = `push:${service}:${action.id}`;
|
|
140
224
|
// Inherit the action's correlationId (D3) so every phase row this push writes joins
|
|
141
225
|
// back to the action row on that id alone. Actions appended after D3 always carry
|
|
@@ -147,9 +231,18 @@ export async function pushTransaction(
|
|
|
147
231
|
// Read (prior status) + decide (terminal replay / unreconciled refusal) + the
|
|
148
232
|
// fresh intent/attempted append, all under one lock — the atomic critical
|
|
149
233
|
// section a cross-process race would otherwise be able to interleave (TWIN-56).
|
|
234
|
+
let resumeFrom: WorldPushRecord | null = null;
|
|
150
235
|
const early = withFileLock(ledgerLockPath(service, root), (): WorldPushRecord | null => {
|
|
151
236
|
const prior = latestPushByActionId(service, root).get(action.id);
|
|
152
237
|
if (prior && TERMINAL.includes(prior.status)) return prior; // idempotent replay
|
|
238
|
+
if (prior && (prior.status === 'provider_accepted' || prior.status === 'observed_confirmed')) {
|
|
239
|
+
// The provider call DEFINITELY landed (final audit M9): a crash between
|
|
240
|
+
// provider_accepted and the confirm chain must resume the CONFIRM — re-invoking
|
|
241
|
+
// writeFn here would double-apply against a non-idempotent vendor, strictly worse
|
|
242
|
+
// than the 'attempted' ambiguity this function already refuses.
|
|
243
|
+
resumeFrom = prior;
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
153
246
|
if (prior && prior.status === 'attempted' && onUnreconciled === 'fail') {
|
|
154
247
|
throw new UnreconciledPushError(
|
|
155
248
|
pushId,
|
|
@@ -157,6 +250,12 @@ export async function pushTransaction(
|
|
|
157
250
|
`Verify external state, then retry with onUnreconciled: 'retry'.`,
|
|
158
251
|
);
|
|
159
252
|
}
|
|
253
|
+
// R14 non-fast-forward gate, inside the same critical section as the intent append:
|
|
254
|
+
// the drift decision and the rows it guards cannot interleave with another pusher.
|
|
255
|
+
if (onDrift === 'refuse') {
|
|
256
|
+
const drifted = shadowDrift(service, action.id, root);
|
|
257
|
+
if (Object.keys(drifted).length > 0) throw new NonFastForwardPushError(pushId, drifted);
|
|
258
|
+
}
|
|
160
259
|
appendPushRecord({ ...base, status: 'intent' }, root);
|
|
161
260
|
appendPushRecord({ ...base, status: 'attempted' }, root);
|
|
162
261
|
return null;
|
|
@@ -164,12 +263,16 @@ export async function pushTransaction(
|
|
|
164
263
|
if (early) return early;
|
|
165
264
|
|
|
166
265
|
let outcome: PushOutcome;
|
|
167
|
-
|
|
168
|
-
outcome =
|
|
169
|
-
}
|
|
170
|
-
|
|
266
|
+
if (resumeFrom !== null) {
|
|
267
|
+
outcome = { externalId: (resumeFrom as WorldPushRecord).external?.id ?? '', ...((resumeFrom as WorldPushRecord).data ? { data: (resumeFrom as WorldPushRecord).data } : {}) };
|
|
268
|
+
} else {
|
|
269
|
+
try {
|
|
270
|
+
outcome = await writeFn();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return appendPushRecord({ ...base, status: 'failed', error: error instanceof Error ? error.message : String(error) }, root);
|
|
273
|
+
}
|
|
274
|
+
appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
|
|
171
275
|
}
|
|
172
|
-
appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
|
|
173
276
|
|
|
174
277
|
// Confirm: append the observed provider fact + map it back, suppressing the local projection.
|
|
175
278
|
const { observedEventId } = confirmAction({ service, actionId: action.id, subject: action.subject, fields: action.fields, occurredAt, root });
|
|
@@ -180,10 +283,12 @@ export async function pushTransaction(
|
|
|
180
283
|
|
|
181
284
|
/** Mark a push intent abandoned (e.g. operator decided not to push). */
|
|
182
285
|
export function abandonPush(service: string, actionId: string, opts: { root?: string; at?: string; reason?: string } = {}): WorldPushRecord {
|
|
286
|
+
return withFileLock(ledgerLockPath(service, opts.root), () => {
|
|
183
287
|
const prior = latestPushByActionId(service, opts.root).get(actionId);
|
|
184
288
|
return appendPushRecord({
|
|
185
289
|
id: `push:${service}:${actionId}`, service, actionId, provider: prior?.provider ?? '', operation: prior?.operation ?? '',
|
|
186
290
|
idempotencyKey: prior?.idempotencyKey ?? `abandon:${actionId}`, createdAt: opts.at ?? new Date().toISOString(),
|
|
187
291
|
status: 'abandoned', ...(opts.reason ? { error: opts.reason } : {}),
|
|
188
292
|
}, opts.root);
|
|
293
|
+
});
|
|
189
294
|
}
|
package/src/queueLifecycle.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// poisoned — as an append-only status ledger over the existing queue, so the
|
|
6
6
|
// queue file itself stays immutable delivery history. Current status = the latest
|
|
7
7
|
// transition (default `queued`). Deterministic: caller may supply `at`.
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
8
|
import { join } from 'node:path';
|
|
10
9
|
import { appendDurable, appendEvent, listQueuedEvents, rebuildGenericState, worldPaths } from './storage.ts';
|
|
10
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
11
11
|
import type { AppendEventResult, QueuedWorldServiceEvent } from './types.ts';
|
|
12
12
|
|
|
13
13
|
export type QueueRowStatus = 'queued' | 'committed' | 'ignored' | 'superseded' | 'poisoned';
|
|
@@ -35,8 +35,7 @@ function statusPath(service: string, root?: string): string {
|
|
|
35
35
|
|
|
36
36
|
function readTransitions(service: string, root?: string): QueueStatusTransition[] {
|
|
37
37
|
const path = statusPath(service, root);
|
|
38
|
-
|
|
39
|
-
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as QueueStatusTransition);
|
|
38
|
+
return getActiveWorldStore().readLines(path).filter((l) => l.trim()).map((l) => JSON.parse(l) as QueueStatusTransition);
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/** Latest transition per queue row (append order is the tiebreak — last wins). */
|
|
@@ -53,7 +52,7 @@ export function setQueueRowStatus(
|
|
|
53
52
|
opts: { root?: string; at?: string; reason?: string; eventId?: string; supersededBy?: string } = {},
|
|
54
53
|
): QueueStatusTransition {
|
|
55
54
|
const path = statusPath(service, opts.root);
|
|
56
|
-
|
|
55
|
+
getActiveWorldStore().mkdir(join(path, '..'));
|
|
57
56
|
const transition: QueueStatusTransition = {
|
|
58
57
|
queueId,
|
|
59
58
|
status,
|