@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.
@@ -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): PairLedger;
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 { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
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): Promise<void>;
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 attached = await pairLedger(io, home).transact(tx => {
5
- const current = tx.rows().find(row => row.id === rowId);
6
- if (current === undefined)
7
- return false;
8
- const moved = tx.transition(rowId, { type: 'run-attached', runId }, current.version);
9
- return moved.ok;
10
- });
11
- if (!attached)
12
- throw new Error('row is not attachable to run: ' + rowId);
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): Promise<void>;
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
- await transactIo(io, runMetaPath(home, meta.runId), () => JSON.stringify(meta, null, 2) + '\n');
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>): 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
- await transactIo(io, schema4StatePath(home), async (current) => {
75
- if (current === null)
76
- throw new V4BlockedError(blocked('merge-state-v4-required', 'schema4 state is missing'));
77
- let value;
78
- try {
79
- value = JSON.parse(current);
80
- }
81
- catch {
82
- throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not JSON'));
83
- }
84
- if (!object(value))
85
- throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not an object'));
86
- if (typeof value.schemaVersion === 'number' && value.schemaVersion > 4)
87
- throw new V4BlockedError(blocked('merge-state-forward-only', 'state schema ' + String(value.schemaVersion) + ' is newer than this build'));
88
- if (value.schemaVersion === 3)
89
- throw new V4BlockedError(blocked('merge-state-normalize-required', 'schema3 state requires v4 normalize'));
90
- if (value.schemaVersion !== 4)
91
- throw new V4BlockedError(blocked('merge-state-needs-repair', 'merge state is not schema4'));
92
- const parsed = parse(current);
93
- if (parsed === null)
94
- throw new V4BlockedError(blocked('merge-state-needs-repair', 'schema4 state has an invalid row'));
95
- const ready = await inspect(io, home, parsed.body, parsed.rows);
96
- if (!ready.ok)
97
- throw new V4BlockedError(ready);
98
- let rows = parsed.rows;
99
- const apply = (write) => {
100
- const next = applyV4Write(rows, write, new Date().toISOString());
101
- if (next.ok)
102
- rows = next.rows;
103
- return next;
104
- };
105
- const tx = {
106
- rows: () => rows,
107
- createVerify: row => apply({ kind: 'createVerify', row }),
108
- createSkipped: row => apply({ kind: 'createSkipped', row }),
109
- transition: (id, event, expectedVersion) => apply({ kind: 'transition', id, event, expectedVersion }),
110
- remove: (id, terminal, unit, expectedVersion) => apply({ kind: 'remove', id, terminal, ...(unit === undefined ? {} : { unit }), expectedVersion }),
111
- };
112
- result = await fn(tx);
113
- return JSON.stringify({ ...parsed.body, schemaVersion: 4, rows }, null, 2) + '\n';
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 = join(home, 'merge', 'runs', ref);
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
- await io.remove(join(runRoot, run)).catch(error => fail(join(runRoot, run), error));
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): Promise<StateReadiness>;
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
- await transactIo(io, mergeStatePath(home), async (current) => {
152
- if (current === null) {
153
- outcome = { ok: true, schema: 3, migrated: false, state: { schemaVersion: 3, rows: [] }, quarantine: [], report: await readReport(io, home) };
154
- return null;
155
- }
156
- const body = parseBody(current);
157
- if (body === null) {
158
- outcome = blocked('merge-state-needs-repair', 'state file is not a JSON object');
159
- return current;
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 journal = await readJournal(io, home);
173
- if (journal !== null) {
174
- outcome = await completeJournal(io, home, journal, current);
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
- const report = await readReport(io, home);
178
- const marker = readMarker(body);
179
- if (body.migration !== undefined && marker === null) {
180
- outcome = blocked('merge-state-needs-repair', 'migration marker is invalid');
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
- if (report !== null && report.ready !== true) {
184
- outcome = blocked('merge-state-migration-blocked', 'migration report is not ready');
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
- if (marker !== null && rowsSha(state.rows) === marker.targetSha256) {
188
- if (report === null) {
189
- outcome = blocked('merge-state-migration-blocked', 'migration report is missing');
190
- return current;
191
- }
192
- for (const entry of marker.staging)
193
- if (!(await io.exists(stagingIndexPath(home, entry.rowId))))
194
- await writeStagingIndex(io, home, entry);
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
- outcome = { ok: true, schema: 3, migrated: false, state, quarantine: [], report };
197
- return current;
198
- }
199
- const mapped = migrateV2ToV3(body);
200
- if (!mapped.ok) {
201
- outcome = blocked('merge-state-migration-blocked', mapped.message);
202
- return current;
203
- }
204
- const sourceSha256 = hash(current);
205
- const backup = v2BackupPath(home);
206
- const existingBackup = await readTextOrNull(io, backup);
207
- if (existingBackup !== null && existingBackup !== current) {
208
- outcome = blocked('merge-state-migration-blocked', 'v2 backup exists with different bytes');
209
- return current;
210
- }
211
- if (existingBackup === null)
212
- await transactIo(io, backup, () => current);
213
- try {
214
- await chmod(backup, 0o444);
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)));