@lmzhen/dsh-evolution-merge 0.39.1 → 0.40.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/lib/index.js +406 -289
- package/lib/types/approve-command.d.ts +4 -0
- package/lib/types/approve-command.js +5 -4
- package/lib/types/domain/pair-ledger.d.ts +3 -1
- package/lib/types/domain/pair-ledger.js +2 -2
- package/lib/types/domain/row-run.d.ts +4 -2
- package/lib/types/domain/row-run.js +22 -10
- package/lib/types/domain/run-coordinator.d.ts +5 -1
- package/lib/types/domain/run-coordinator.js +35 -2
- package/lib/types/domain/schema4.d.ts +3 -1
- package/lib/types/domain/schema4.js +48 -42
- package/lib/types/domain/stage-aggregate.js +21 -3
- package/lib/types/domain/state-bootstrap.d.ts +3 -1
- package/lib/types/domain/state-bootstrap.js +95 -89
- package/lib/types/index.js +13 -4
- package/lib/types/pair-command.d.ts +3 -0
- package/lib/types/pair-command.js +1 -0
- package/lib/types/pending-actions.d.ts +6 -2
- package/lib/types/pending-actions.js +6 -6
- package/lib/types/resume-command.d.ts +3 -0
- package/lib/types/resume-command.js +4 -4
- package/lib/types/round2-outcome.d.ts +3 -1
- package/lib/types/round2-outcome.js +7 -7
- package/lib/types/run-shell.d.ts +8 -0
- package/lib/types/run-shell.js +41 -19
- package/lib/types/runtime.d.ts +16 -1
- package/lib/types/runtime.js +23 -17
- package/lib/types/scan-command.d.ts +3 -0
- package/lib/types/scan-command.js +1 -0
- package/lib/types/target-claim.js +8 -2
- package/lib/types/undo-operation.d.ts +3 -0
- package/package.json +4 -4
|
@@ -19,6 +19,9 @@ export interface ApprovePorts {
|
|
|
19
19
|
}
|
|
20
20
|
export interface RunApproveCommandOptions {
|
|
21
21
|
readonly io: EvolutionIoLike;
|
|
22
|
+
readonly fence?: {
|
|
23
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
24
|
+
};
|
|
22
25
|
readonly home: string;
|
|
23
26
|
readonly registry: MergeRegistryPort;
|
|
24
27
|
readonly ports: ApprovePorts;
|
|
@@ -30,6 +33,7 @@ export interface RunApproveCommandOptions {
|
|
|
30
33
|
readonly now: () => number;
|
|
31
34
|
/** The invocation carried no agent at all (E-305), as opposed to an agent with no parent run. */
|
|
32
35
|
readonly agentless?: boolean;
|
|
36
|
+
readonly isOwnerActive?: (runId: string) => boolean | Promise<boolean>;
|
|
33
37
|
}
|
|
34
38
|
export declare function runApproveCommand(options: RunApproveCommandOptions): Promise<WithMergeRunResult>;
|
|
35
39
|
//# sourceMappingURL=approve-command.d.ts.map
|
|
@@ -24,7 +24,7 @@ export async function runApproveCommand(options) {
|
|
|
24
24
|
const parentRunId = options.parentRunId ?? before.runId;
|
|
25
25
|
if (parentRunId === undefined)
|
|
26
26
|
return failure('approve', 'merge-parent-required', '缺 parent run id(E-305 提示:该行可能由无会话调用创建,或父 run 已丢失)', '在 agent 会话中执行 /evolution merge approve ' + before.id, before.runId);
|
|
27
|
-
const taken = await approvePending(options.io, options.home, options.now, options.rowId);
|
|
27
|
+
const taken = await approvePending(options.io, options.home, options.now, options.rowId, options.fence);
|
|
28
28
|
if (!taken.ok)
|
|
29
29
|
return failure('approve', 'approve-' + taken.reason, taken.message, '重试;若行状态已变化,运行 /evolution merge status ' + options.rowId, before.runId);
|
|
30
30
|
let row = taken.row;
|
|
@@ -35,7 +35,7 @@ export async function runApproveCommand(options) {
|
|
|
35
35
|
const staged = await options.ports.stage(row, decision);
|
|
36
36
|
if (!staged.ok)
|
|
37
37
|
return failure('approve', 'stage-failed', staged.message, '检查暂存目录后重试;不影响已记录的决策', row.runId);
|
|
38
|
-
const fused = await pairLedger(options.io, options.home).transact(tx => {
|
|
38
|
+
const fused = await pairLedger(options.io, options.home, options.fence).transact(tx => {
|
|
39
39
|
const current = tx.rows().find(item => item.id === row.id);
|
|
40
40
|
if (current === undefined)
|
|
41
41
|
return null;
|
|
@@ -51,6 +51,7 @@ export async function runApproveCommand(options) {
|
|
|
51
51
|
io: options.io,
|
|
52
52
|
home: options.home,
|
|
53
53
|
registry: options.registry,
|
|
54
|
+
...(options.fence === undefined ? {} : { fence: options.fence }),
|
|
54
55
|
command: 'approve',
|
|
55
56
|
approval: { platformWrite: options.platformWrite, mergeApproval: true },
|
|
56
57
|
pairKey: row.pairKey,
|
|
@@ -59,7 +60,7 @@ export async function runApproveCommand(options) {
|
|
|
59
60
|
now: options.now,
|
|
60
61
|
...(options.detached === true ? { detached: true } : {}),
|
|
61
62
|
async body(ctx) {
|
|
62
|
-
await attachRunToRow(options.io, options.home, row.id, ctx.runId);
|
|
63
|
+
await attachRunToRow(options.io, options.home, row.id, ctx.runId, options.isOwnerActive, options.fence);
|
|
63
64
|
const stage = (name) => ctx.stage(name, { done: 0, total: 1 });
|
|
64
65
|
await stage('author');
|
|
65
66
|
let outcome;
|
|
@@ -70,7 +71,7 @@ export async function runApproveCommand(options) {
|
|
|
70
71
|
outcome = { kind: 'failed', failure: { stage: 'author', code: 'merge-failed', message: error instanceof Error ? error.message : String(error), retryable: true, retryFrom: 'agent2' } };
|
|
71
72
|
}
|
|
72
73
|
const base = { scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 1, skipped: 0 } }, notes: [] };
|
|
73
|
-
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome, true);
|
|
74
|
+
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome, true, options.fence);
|
|
74
75
|
return {
|
|
75
76
|
...base,
|
|
76
77
|
stopReason: record.merged ? 'completed' : 'failed',
|
|
@@ -23,7 +23,9 @@ export interface NormalizeResult {
|
|
|
23
23
|
/** Read-only 1b-1 bypass. It does not normalize, back up, or write state. */
|
|
24
24
|
export declare function readPairLedger(io: EvolutionIoLike, home: string): Promise<PairLedgerRead>;
|
|
25
25
|
/** 1b-2a write skeleton. Production stays on the v3 gate until 1b-2b. */
|
|
26
|
-
export declare function pairLedger(io: EvolutionIoLike, home: string
|
|
26
|
+
export declare function pairLedger(io: EvolutionIoLike, home: string, fence?: {
|
|
27
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
28
|
+
}): PairLedger;
|
|
27
29
|
/** v3-to-v4 normalize: backup -> production stage adopt -> v4 state. Not called in 1b-2a. */
|
|
28
30
|
export declare function normalizePairLedger(io: EvolutionIoLike, home: string): Promise<NormalizeResult>;
|
|
29
31
|
/** Read schema4 rows only; no legacy projection. */
|
|
@@ -31,7 +31,7 @@ export async function readPairLedger(io, home) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
/** 1b-2a write skeleton. Production stays on the v3 gate until 1b-2b. */
|
|
34
|
-
export function pairLedger(io, home) {
|
|
34
|
+
export function pairLedger(io, home, fence) {
|
|
35
35
|
return {
|
|
36
36
|
read: () => readPairLedger(io, home),
|
|
37
37
|
transact: async (fn) => {
|
|
@@ -41,7 +41,7 @@ export function pairLedger(io, home) {
|
|
|
41
41
|
if (!normalized.ok)
|
|
42
42
|
throw new Error(normalized.message ?? 'normalize failed');
|
|
43
43
|
}
|
|
44
|
-
return await transactMergeState(io, home, fn);
|
|
44
|
+
return await transactMergeState(io, home, fn, fence);
|
|
45
45
|
},
|
|
46
46
|
normalize: () => normalizePairLedger(io, home),
|
|
47
47
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
|
|
2
2
|
/** Attach the in-flight child run to a row before its round2 starts (P1-8). */
|
|
3
|
-
export declare function attachRunToRow(io: EvolutionIoLike, home: string, rowId: string, runId: string
|
|
3
|
+
export declare function attachRunToRow(io: EvolutionIoLike, home: string, rowId: string, runId: string, isActive?: (id: string) => boolean | Promise<boolean>, fence?: {
|
|
4
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
5
|
+
}): Promise<void>;
|
|
4
6
|
//# sourceMappingURL=row-run.d.ts.map
|
|
@@ -1,14 +1,26 @@
|
|
|
1
|
+
import { LeaseStore } from '@lmzhen/dsh-evolution-core';
|
|
1
2
|
import { pairLedger } from "./pair-ledger.js";
|
|
2
3
|
/** Attach the in-flight child run to a row before its round2 starts (P1-8). */
|
|
3
|
-
export async function attachRunToRow(io, home, rowId, runId) {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
export async function attachRunToRow(io, home, rowId, runId, isActive, fence) {
|
|
5
|
+
const leaseStore = new LeaseStore({ io, home, ttlMs: 30_000 });
|
|
6
|
+
const lease = await leaseStore.acquire('foreground', 'row-attach:' + rowId);
|
|
7
|
+
if (!lease.ok)
|
|
8
|
+
throw new Error('row attach is busy: ' + rowId);
|
|
9
|
+
try {
|
|
10
|
+
const attached = await pairLedger(io, home, fence).transact(async (tx) => {
|
|
11
|
+
const current = tx.rows().find(row => row.id === rowId);
|
|
12
|
+
if (current === undefined)
|
|
13
|
+
return false;
|
|
14
|
+
if (current.runId !== undefined && current.runId !== runId && (await isActive?.(current.runId)) === true)
|
|
15
|
+
return false;
|
|
16
|
+
const moved = tx.transition(rowId, { type: 'run-attached', runId }, current.version);
|
|
17
|
+
return moved.ok;
|
|
18
|
+
});
|
|
19
|
+
if (!attached)
|
|
20
|
+
throw new Error('row is not attachable to run: ' + rowId);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await leaseStore.release(lease.token);
|
|
24
|
+
}
|
|
13
25
|
}
|
|
14
26
|
//# sourceMappingURL=row-run.js.map
|
|
@@ -23,6 +23,8 @@ export interface MergeRunMeta {
|
|
|
23
23
|
readonly undonePairs?: readonly string[];
|
|
24
24
|
readonly undoneAt?: string;
|
|
25
25
|
readonly degraded?: boolean;
|
|
26
|
+
/** Monotone run-meta revision; callers pass the current value for CAS. */
|
|
27
|
+
readonly revision?: number;
|
|
26
28
|
readonly createdAt: string;
|
|
27
29
|
readonly updatedAt: string;
|
|
28
30
|
}
|
|
@@ -50,7 +52,9 @@ export interface RunCoordinator {
|
|
|
50
52
|
read(runId: string): Promise<RunCoordinatorRead>;
|
|
51
53
|
}
|
|
52
54
|
export declare function runMetaPath(home: string, runId: string): string;
|
|
53
|
-
export declare function writeRunMeta(io: EvolutionIoLike, home: string, meta: MergeRunMeta
|
|
55
|
+
export declare function writeRunMeta(io: EvolutionIoLike, home: string, meta: MergeRunMeta, fence?: {
|
|
56
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
57
|
+
}): Promise<void>;
|
|
54
58
|
export declare function readRunMeta(io: EvolutionIoLike, home: string, runId: string): Promise<MergeRunMeta | null>;
|
|
55
59
|
/** Every run-meta record on disk (the in-flight index for pair/target serialisation). */
|
|
56
60
|
export declare function readRunMetas(io: EvolutionIoLike, home: string): Promise<readonly MergeRunMeta[]>;
|
|
@@ -3,8 +3,41 @@ import { readTextOrNull, listOrEmpty } from "./read-probe.js";
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { transactIo } from '@lmzhen/dsh-evolution-core';
|
|
5
5
|
export function runMetaPath(home, runId) { return join(home, 'merge', 'run-meta', encodeURIComponent(runId) + '.json'); }
|
|
6
|
-
export async function writeRunMeta(io, home, meta) {
|
|
7
|
-
|
|
6
|
+
export async function writeRunMeta(io, home, meta, fence) {
|
|
7
|
+
if (meta.revision !== undefined) {
|
|
8
|
+
const current = await readRunMeta(io, home, meta.runId);
|
|
9
|
+
const currentRevision = current?.revision ?? 0;
|
|
10
|
+
if (currentRevision !== meta.revision)
|
|
11
|
+
throw new Error('run-meta revision conflict: expected ' + String(meta.revision) + ', found ' + String(currentRevision));
|
|
12
|
+
}
|
|
13
|
+
const write = async () => {
|
|
14
|
+
await transactIo(io, runMetaPath(home, meta.runId), current => {
|
|
15
|
+
let next = meta;
|
|
16
|
+
let previousRevision = 0;
|
|
17
|
+
if (current !== null) {
|
|
18
|
+
try {
|
|
19
|
+
const prev = JSON.parse(current);
|
|
20
|
+
previousRevision = typeof prev.revision === 'number' ? prev.revision : 0;
|
|
21
|
+
if (meta.revision !== undefined && meta.revision !== previousRevision)
|
|
22
|
+
throw new Error('run-meta revision conflict: expected ' + String(meta.revision) + ', found ' + String(previousRevision));
|
|
23
|
+
if (prev.degraded === true)
|
|
24
|
+
next = { ...next, degraded: true };
|
|
25
|
+
const terminal = prev.phase === 'settled' || prev.phase === 'cancelled' || prev.phase === 'undone';
|
|
26
|
+
// Undo is the one allowed transition out of a settled record; every other
|
|
27
|
+
// terminal phase is immutable (a late body write must not reopen a run).
|
|
28
|
+
const undoFromSettled = prev.phase === 'settled' && next.phase === 'undone';
|
|
29
|
+
if (terminal && next.phase !== prev.phase && !undoFromSettled)
|
|
30
|
+
next = { ...next, phase: prev.phase, ...(prev.stopReason === undefined ? {} : { stopReason: prev.stopReason }) };
|
|
31
|
+
}
|
|
32
|
+
catch { /* unreadable old meta is replaced by the caller's record */ }
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify({ ...next, revision: previousRevision + 1 }, null, 2) + '\n';
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
if (fence !== undefined)
|
|
38
|
+
await fence.run(write);
|
|
39
|
+
else
|
|
40
|
+
await write();
|
|
8
41
|
}
|
|
9
42
|
export async function readRunMeta(io, home, runId) {
|
|
10
43
|
const raw = await readTextOrNull(io, runMetaPath(home, runId));
|
|
@@ -39,7 +39,9 @@ export interface V4RowTx {
|
|
|
39
39
|
remove(id: string, terminal: 'committed' | 'superseded' | 'restored', unit: CommitUnitRef | undefined, expectedVersion: number): V4WriteResult;
|
|
40
40
|
}
|
|
41
41
|
/** Atomic readiness + V4 row write under one state-file lock. No production caller in 1b-2a. */
|
|
42
|
-
export declare function transactMergeState<T>(io: EvolutionIoLike, home: string, fn: (tx: V4RowTx) => T | Promise<T
|
|
42
|
+
export declare function transactMergeState<T>(io: EvolutionIoLike, home: string, fn: (tx: V4RowTx) => T | Promise<T>, fence?: {
|
|
43
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
44
|
+
}): Promise<T>;
|
|
43
45
|
/** Pure v3-to-v4 projection for fixtures; 1b-2b wires it to the production normalize. */
|
|
44
46
|
export declare function normalizeV3State(state: {
|
|
45
47
|
readonly schemaVersion: 3;
|
|
@@ -69,49 +69,55 @@ export async function readV4Readiness(io, home) {
|
|
|
69
69
|
return await inspect(io, home, parsed.body, parsed.rows);
|
|
70
70
|
}
|
|
71
71
|
/** Atomic readiness + V4 row write under one state-file lock. No production caller in 1b-2a. */
|
|
72
|
-
export async function transactMergeState(io, home, fn) {
|
|
72
|
+
export async function transactMergeState(io, home, fn, fence) {
|
|
73
73
|
let result;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
74
|
+
const run = async () => {
|
|
75
|
+
await transactIo(io, schema4StatePath(home), async (current) => {
|
|
76
|
+
if (current === null)
|
|
77
|
+
throw new V4BlockedError(blocked('merge-state-v4-required', 'schema4 state is missing'));
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = JSON.parse(current);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not JSON'));
|
|
84
|
+
}
|
|
85
|
+
if (!object(value))
|
|
86
|
+
throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not an object'));
|
|
87
|
+
if (typeof value.schemaVersion === 'number' && value.schemaVersion > 4)
|
|
88
|
+
throw new V4BlockedError(blocked('merge-state-forward-only', 'state schema ' + String(value.schemaVersion) + ' is newer than this build'));
|
|
89
|
+
if (value.schemaVersion === 3)
|
|
90
|
+
throw new V4BlockedError(blocked('merge-state-normalize-required', 'schema3 state requires v4 normalize'));
|
|
91
|
+
if (value.schemaVersion !== 4)
|
|
92
|
+
throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not schema4'));
|
|
93
|
+
const parsed = parse(current);
|
|
94
|
+
if (parsed === null)
|
|
95
|
+
throw new V4BlockedError(blocked('merge-state-needs-repair', 'schema4 state has an invalid row'));
|
|
96
|
+
const ready = await inspect(io, home, parsed.body, parsed.rows);
|
|
97
|
+
if (!ready.ok)
|
|
98
|
+
throw new V4BlockedError(ready);
|
|
99
|
+
let rows = parsed.rows;
|
|
100
|
+
const apply = (write) => {
|
|
101
|
+
const next = applyV4Write(rows, write, new Date().toISOString());
|
|
102
|
+
if (next.ok)
|
|
103
|
+
rows = next.rows;
|
|
104
|
+
return next;
|
|
105
|
+
};
|
|
106
|
+
const tx = {
|
|
107
|
+
rows: () => rows,
|
|
108
|
+
createVerify: row => apply({ kind: 'createVerify', row }),
|
|
109
|
+
createSkipped: row => apply({ kind: 'createSkipped', row }),
|
|
110
|
+
transition: (id, event, expectedVersion) => apply({ kind: 'transition', id, event, expectedVersion }),
|
|
111
|
+
remove: (id, terminal, unit, expectedVersion) => apply({ kind: 'remove', id, terminal, ...(unit === undefined ? {} : { unit }), expectedVersion }),
|
|
112
|
+
};
|
|
113
|
+
result = await fn(tx);
|
|
114
|
+
return JSON.stringify({ ...parsed.body, schemaVersion: 4, rows }, null, 2) + '\n';
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
if (fence !== undefined)
|
|
118
|
+
await fence.run(run);
|
|
119
|
+
else
|
|
120
|
+
await run();
|
|
115
121
|
if (result === undefined)
|
|
116
122
|
throw new V4BlockedError(blocked('merge-state-needs-repair', 'v4 transaction did not produce a result'));
|
|
117
123
|
return result;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readTextOrNull, listOrEmpty } from "./read-probe.js";
|
|
2
2
|
/** 1b-1 shadow staging adoption. Production staging is still owned by the old staged writers. */
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import { readPairLedger } from "./pair-ledger.js";
|
|
5
5
|
import { readStagingIndex, writeStagingIndex } from "./staging-index.js";
|
|
6
6
|
import { createStaging, dropStaging, ensureStaging, readStaging, setStagingTarget } from "../staging.js";
|
|
@@ -205,11 +205,24 @@ export async function reserveStage(io, home, entry) {
|
|
|
205
205
|
export async function presentStage(io, home, entry) {
|
|
206
206
|
await writeStageIndex(io, home, { rowId: entry.rowId, pairKey: entry.pairKey, ref: entry.ref, expiresAt: entry.expiresAt, ...(entry.runId === undefined ? {} : { runId: entry.runId }), createdAt: entry.createdAt, state: 'present' });
|
|
207
207
|
}
|
|
208
|
+
function safeRunTree(home, ref) {
|
|
209
|
+
const name = ref.trim();
|
|
210
|
+
if (name === '' || name.includes('\\') || isAbsolute(name))
|
|
211
|
+
return null;
|
|
212
|
+
const parts = name.split('/');
|
|
213
|
+
if (parts.some(part => part === '' || part === '.' || part === '..'))
|
|
214
|
+
return null;
|
|
215
|
+
return join(home, 'merge', 'runs', ...parts);
|
|
216
|
+
}
|
|
208
217
|
export async function dropStage(io, home, rowId, ref, onError) {
|
|
209
218
|
const index = join(home, 'merge', 'staging-index', encodeURIComponent(rowId) + '.json');
|
|
210
219
|
await io.remove(index).catch(error => onError?.(index, error));
|
|
211
220
|
if (ref !== undefined && ref !== '') {
|
|
212
|
-
const tree =
|
|
221
|
+
const tree = safeRunTree(home, ref);
|
|
222
|
+
if (tree === null) {
|
|
223
|
+
onError?.(join(home, 'merge', 'runs'), new Error('invalid staging ref: ' + JSON.stringify(ref)));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
213
226
|
await io.remove(tree).catch(error => onError?.(tree, error));
|
|
214
227
|
}
|
|
215
228
|
}
|
|
@@ -275,7 +288,12 @@ export async function cleanupStage(io, home, now, rows, limit = 100) {
|
|
|
275
288
|
const prefix = run + '/';
|
|
276
289
|
if ([...refs].some(ref => ref === run || ref.startsWith(prefix)))
|
|
277
290
|
continue;
|
|
278
|
-
|
|
291
|
+
const tree = safeRunTree(home, run);
|
|
292
|
+
if (tree === null) {
|
|
293
|
+
fail(join(runRoot, run), new Error('invalid run dir name: ' + JSON.stringify(run)));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
await io.remove(tree).catch(error => fail(tree, error));
|
|
279
297
|
orphans.push(run);
|
|
280
298
|
}
|
|
281
299
|
return { removed, expired, kept, orphans, failures };
|
|
@@ -39,5 +39,7 @@ export declare function readStateReadiness(io: EvolutionIoLike, home: string): P
|
|
|
39
39
|
/** Compatibility name for the v3 gate; 1b-2b switches the lifecycle to v4. */
|
|
40
40
|
export declare const ensureV3Ready: typeof ensureMergeStateReady;
|
|
41
41
|
/** One readiness/migration gate; used by startup and every write entry. */
|
|
42
|
-
export declare function ensureMergeStateReady(io: EvolutionIoLike, home: string
|
|
42
|
+
export declare function ensureMergeStateReady(io: EvolutionIoLike, home: string, fence?: {
|
|
43
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
44
|
+
}): Promise<StateReadiness>;
|
|
43
45
|
//# sourceMappingURL=state-bootstrap.d.ts.map
|
|
@@ -144,107 +144,113 @@ export async function readStateReadiness(io, home) {
|
|
|
144
144
|
/** Compatibility name for the v3 gate; 1b-2b switches the lifecycle to v4. */
|
|
145
145
|
export const ensureV3Ready = ensureMergeStateReady;
|
|
146
146
|
/** One readiness/migration gate; used by startup and every write entry. */
|
|
147
|
-
export async function ensureMergeStateReady(io, home) {
|
|
147
|
+
export async function ensureMergeStateReady(io, home, fence) {
|
|
148
148
|
let outcome;
|
|
149
149
|
let migrated = false;
|
|
150
150
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
const version = body.schemaVersion;
|
|
162
|
-
if (typeof version === 'number' && version > 3) {
|
|
163
|
-
outcome = blocked('merge-state-forward-only', 'state schema ' + String(version) + ' is newer than this build');
|
|
164
|
-
return current;
|
|
165
|
-
}
|
|
166
|
-
if (version === 3) {
|
|
167
|
-
const state = v3State(body);
|
|
168
|
-
if (state === null) {
|
|
169
|
-
outcome = blocked('merge-state-needs-repair', 'v3 state has an invalid row');
|
|
151
|
+
const run = async () => {
|
|
152
|
+
await transactIo(io, mergeStatePath(home), async (current) => {
|
|
153
|
+
if (current === null) {
|
|
154
|
+
outcome = { ok: true, schema: 3, migrated: false, state: { schemaVersion: 3, rows: [] }, quarantine: [], report: await readReport(io, home) };
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const body = parseBody(current);
|
|
158
|
+
if (body === null) {
|
|
159
|
+
outcome = blocked('merge-state-needs-repair', 'state file is not a JSON object');
|
|
170
160
|
return current;
|
|
171
161
|
}
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
outcome =
|
|
162
|
+
const version = body.schemaVersion;
|
|
163
|
+
if (typeof version === 'number' && version > 3) {
|
|
164
|
+
outcome = blocked('merge-state-forward-only', 'state schema ' + String(version) + ' is newer than this build');
|
|
175
165
|
return current;
|
|
176
166
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
167
|
+
if (version === 3) {
|
|
168
|
+
const state = v3State(body);
|
|
169
|
+
if (state === null) {
|
|
170
|
+
outcome = blocked('merge-state-needs-repair', 'v3 state has an invalid row');
|
|
171
|
+
return current;
|
|
172
|
+
}
|
|
173
|
+
const journal = await readJournal(io, home);
|
|
174
|
+
if (journal !== null) {
|
|
175
|
+
outcome = await completeJournal(io, home, journal, current);
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
const report = await readReport(io, home);
|
|
179
|
+
const marker = readMarker(body);
|
|
180
|
+
if (body.migration !== undefined && marker === null) {
|
|
181
|
+
outcome = blocked('merge-state-needs-repair', 'migration marker is invalid');
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
if (report !== null && report.ready !== true) {
|
|
185
|
+
outcome = blocked('merge-state-migration-blocked', 'migration report is not ready');
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
if (marker !== null && rowsSha(state.rows) === marker.targetSha256) {
|
|
189
|
+
if (report === null) {
|
|
190
|
+
outcome = blocked('merge-state-migration-blocked', 'migration report is missing');
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
for (const entry of marker.staging)
|
|
194
|
+
if (!(await io.exists(stagingIndexPath(home, entry.rowId))))
|
|
195
|
+
await writeStagingIndex(io, home, entry);
|
|
196
|
+
}
|
|
197
|
+
outcome = { ok: true, schema: 3, migrated: false, state, quarantine: [], report };
|
|
181
198
|
return current;
|
|
182
199
|
}
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
const mapped = migrateV2ToV3(body);
|
|
201
|
+
if (!mapped.ok) {
|
|
202
|
+
outcome = blocked('merge-state-migration-blocked', mapped.message);
|
|
185
203
|
return current;
|
|
186
204
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
const sourceSha256 = hash(current);
|
|
206
|
+
const backup = v2BackupPath(home);
|
|
207
|
+
const existingBackup = await readTextOrNull(io, backup);
|
|
208
|
+
if (existingBackup !== null && existingBackup !== current) {
|
|
209
|
+
outcome = blocked('merge-state-migration-blocked', 'v2 backup exists with different bytes');
|
|
210
|
+
return current;
|
|
211
|
+
}
|
|
212
|
+
if (existingBackup === null)
|
|
213
|
+
await transactIo(io, backup, () => current);
|
|
214
|
+
try {
|
|
215
|
+
await chmod(backup, 0o444);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
outcome = blocked('merge-state-migration-blocked', 'v2 backup is not read-only');
|
|
219
|
+
return current;
|
|
220
|
+
}
|
|
221
|
+
const staging = [];
|
|
222
|
+
for (const row of body.rows) {
|
|
223
|
+
if (typeof row.id !== 'string' || typeof row.pairKey !== 'string' || typeof row.stagingRef !== 'string')
|
|
224
|
+
continue;
|
|
225
|
+
if (row.state !== 'pending' && row.state !== 'approved')
|
|
226
|
+
continue;
|
|
227
|
+
staging.push({
|
|
228
|
+
rowId: row.id, pairKey: row.pairKey, ref: row.stagingRef,
|
|
229
|
+
expiresAt: typeof row.stagingExpiresAt === 'number' ? row.stagingExpiresAt : Date.now() + 7 * 86_400_000,
|
|
230
|
+
...(typeof row.runId === 'string' ? { runId: row.runId } : {}),
|
|
231
|
+
createdAt: typeof row.updatedAt === 'string' && !Number.isNaN(Date.parse(row.updatedAt)) ? Date.parse(row.updatedAt) : Date.now(),
|
|
232
|
+
});
|
|
195
233
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
outcome =
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
catch {
|
|
217
|
-
outcome = blocked('merge-state-migration-blocked', 'v2 backup is not read-only');
|
|
218
|
-
return current;
|
|
219
|
-
}
|
|
220
|
-
const staging = [];
|
|
221
|
-
for (const row of body.rows) {
|
|
222
|
-
if (typeof row.id !== 'string' || typeof row.pairKey !== 'string' || typeof row.stagingRef !== 'string')
|
|
223
|
-
continue;
|
|
224
|
-
if (row.state !== 'pending' && row.state !== 'approved')
|
|
225
|
-
continue;
|
|
226
|
-
staging.push({
|
|
227
|
-
rowId: row.id, pairKey: row.pairKey, ref: row.stagingRef,
|
|
228
|
-
expiresAt: typeof row.stagingExpiresAt === 'number' ? row.stagingExpiresAt : Date.now() + 7 * 86_400_000,
|
|
229
|
-
...(typeof row.runId === 'string' ? { runId: row.runId } : {}),
|
|
230
|
-
createdAt: typeof row.updatedAt === 'string' && !Number.isNaN(Date.parse(row.updatedAt)) ? Date.parse(row.updatedAt) : Date.now(),
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
const marker = { migratedFromV2: true, sourceSha256, targetSha256: rowsSha(mapped.state.rows), staging, at: now() };
|
|
234
|
-
const targetText = json({ ...mapped.state, migration: marker });
|
|
235
|
-
const targetSha256 = hash(targetText);
|
|
236
|
-
const journal = { version: 1, sourceSha256, targetSha256, rows: mapped.state.rows.length, quarantine: mapped.quarantine, staging, backupPath: backup, at: now() };
|
|
237
|
-
await transactIo(io, migrationJournalPath(home), () => json(journal));
|
|
238
|
-
for (const entry of staging)
|
|
239
|
-
await writeStagingIndex(io, home, entry);
|
|
240
|
-
if (mapped.quarantine.length > 0)
|
|
241
|
-
await transactIo(io, quarantinePath(home), () => json(mapped.quarantine));
|
|
242
|
-
const prepared = { version: 1, ready: false, migrated: true, sourceSha256, targetSha256, rows: mapped.state.rows.length, quarantined: mapped.quarantine.length, backupReadOnly: true, at: now(), steps: ['backup', 'journal', 'staging-index', 'quarantine', 'v3-write'] };
|
|
243
|
-
await writeReport(io, home, prepared);
|
|
244
|
-
migrated = true;
|
|
245
|
-
outcome = { ok: true, schema: 3, migrated: true, state: mapped.state, quarantine: mapped.quarantine, report: prepared };
|
|
246
|
-
return targetText;
|
|
247
|
-
});
|
|
234
|
+
const marker = { migratedFromV2: true, sourceSha256, targetSha256: rowsSha(mapped.state.rows), staging, at: now() };
|
|
235
|
+
const targetText = json({ ...mapped.state, migration: marker });
|
|
236
|
+
const targetSha256 = hash(targetText);
|
|
237
|
+
const journal = { version: 1, sourceSha256, targetSha256, rows: mapped.state.rows.length, quarantine: mapped.quarantine, staging, backupPath: backup, at: now() };
|
|
238
|
+
await transactIo(io, migrationJournalPath(home), () => json(journal));
|
|
239
|
+
for (const entry of staging)
|
|
240
|
+
await writeStagingIndex(io, home, entry);
|
|
241
|
+
if (mapped.quarantine.length > 0)
|
|
242
|
+
await transactIo(io, quarantinePath(home), () => json(mapped.quarantine));
|
|
243
|
+
const prepared = { version: 1, ready: false, migrated: true, sourceSha256, targetSha256, rows: mapped.state.rows.length, quarantined: mapped.quarantine.length, backupReadOnly: true, at: now(), steps: ['backup', 'journal', 'staging-index', 'quarantine', 'v3-write'] };
|
|
244
|
+
await writeReport(io, home, prepared);
|
|
245
|
+
migrated = true;
|
|
246
|
+
outcome = { ok: true, schema: 3, migrated: true, state: mapped.state, quarantine: mapped.quarantine, report: prepared };
|
|
247
|
+
return targetText;
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
if (fence !== undefined)
|
|
251
|
+
await fence.run(run);
|
|
252
|
+
else
|
|
253
|
+
await run();
|
|
248
254
|
}
|
|
249
255
|
catch (error) {
|
|
250
256
|
return blocked('merge-state-migration-blocked', 'state migration failed: ' + (error instanceof Error ? error.message : String(error)));
|