@lmzhen/dsh-evolution-merge 0.48.0 → 0.49.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 +49 -17
- package/lib/types/domain/state-migrate.d.ts +27 -0
- package/lib/types/domain/state-migrate.js +29 -0
- package/lib/types/index.js +8 -13
- package/package.json +10 -4
- package/scripts/merge-backup.mjs +31 -0
- package/scripts/merge-migrate.mjs +163 -0
- package/scripts/merge-restore.mjs +36 -0
package/lib/index.js
CHANGED
|
@@ -4874,6 +4874,48 @@ async function resolveBody(io, home, ref, rowBody) {
|
|
|
4874
4874
|
return await rowBody();
|
|
4875
4875
|
}
|
|
4876
4876
|
//#endregion
|
|
4877
|
+
//#region lib/types/domain/state-migrate.js
|
|
4878
|
+
/**
|
|
4879
|
+
* Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
|
|
4880
|
+
* before attempting anything, so a newer state is never overwritten.
|
|
4881
|
+
* @param io - the evolution IO seam.
|
|
4882
|
+
* @param home - the evolution home.
|
|
4883
|
+
* @param fence - optional home write fence used by the host.
|
|
4884
|
+
* @returns the migration outcome.
|
|
4885
|
+
*/
|
|
4886
|
+
async function migrateMergeStateToV4(io, home, fence) {
|
|
4887
|
+
const v4 = await readV4Readiness(io, home);
|
|
4888
|
+
if (v4.ok) return {
|
|
4889
|
+
ok: true,
|
|
4890
|
+
migrated: false
|
|
4891
|
+
};
|
|
4892
|
+
if (v4.code === "merge-state-forward-only" || v4.code === "merge-state-needs-repair") return {
|
|
4893
|
+
ok: false,
|
|
4894
|
+
code: v4.code,
|
|
4895
|
+
message: v4.message
|
|
4896
|
+
};
|
|
4897
|
+
const v3 = await ensureMergeStateReady(io, home, fence);
|
|
4898
|
+
if (!v3.ok) return {
|
|
4899
|
+
ok: false,
|
|
4900
|
+
code: v3.code,
|
|
4901
|
+
message: v3.message
|
|
4902
|
+
};
|
|
4903
|
+
const normalized = await normalizePairLedger(io, home);
|
|
4904
|
+
if (!normalized.ok) return {
|
|
4905
|
+
ok: false,
|
|
4906
|
+
code: "merge-state-needs-repair",
|
|
4907
|
+
message: normalized.message ?? "v3->v4 normalize failed"
|
|
4908
|
+
};
|
|
4909
|
+
return normalized.backupRef === void 0 ? {
|
|
4910
|
+
ok: true,
|
|
4911
|
+
migrated: normalized.migrated
|
|
4912
|
+
} : {
|
|
4913
|
+
ok: true,
|
|
4914
|
+
migrated: normalized.migrated,
|
|
4915
|
+
backupRef: normalized.backupRef
|
|
4916
|
+
};
|
|
4917
|
+
}
|
|
4918
|
+
//#endregion
|
|
4877
4919
|
//#region lib/types/domain/evidence-hash.js
|
|
4878
4920
|
/** Canonical evidence JSON hash shared with core unit receipts. */
|
|
4879
4921
|
function canonical(value) {
|
|
@@ -10461,37 +10503,27 @@ function apply(ctx, rawConfig = {}) {
|
|
|
10461
10503
|
if (registry === void 0) return;
|
|
10462
10504
|
const io = evolutionIoAdapter(() => registry.provider());
|
|
10463
10505
|
const home = evolutionHome();
|
|
10506
|
+
const homeFence = ctx.get("evolutionMutationAuthority")?.homeFence;
|
|
10464
10507
|
const ready = (async () => {
|
|
10465
|
-
const
|
|
10466
|
-
if (
|
|
10508
|
+
const migration = await migrateMergeStateToV4(io, home, homeFence);
|
|
10509
|
+
if (!migration.ok) return migration;
|
|
10510
|
+
const loaded = await loadMergeState(io, home);
|
|
10511
|
+
return {
|
|
10467
10512
|
ok: true,
|
|
10468
10513
|
schema: 3,
|
|
10469
|
-
migrated:
|
|
10514
|
+
migrated: migration.migrated,
|
|
10470
10515
|
state: {
|
|
10471
10516
|
schemaVersion: 3,
|
|
10472
|
-
rows:
|
|
10517
|
+
rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null)
|
|
10473
10518
|
},
|
|
10474
10519
|
quarantine: [],
|
|
10475
10520
|
report: null
|
|
10476
10521
|
};
|
|
10477
|
-
const state = await ensureMergeStateReady(io, home, ctx.get("evolutionMutationAuthority")?.homeFence);
|
|
10478
|
-
if (!state.ok) return v4.code === "merge-state-forward-only" || v4.code === "merge-state-needs-repair" ? {
|
|
10479
|
-
ok: false,
|
|
10480
|
-
code: v4.code,
|
|
10481
|
-
message: v4.message
|
|
10482
|
-
} : state;
|
|
10483
|
-
const normalized = await normalizePairLedger(io, home);
|
|
10484
|
-
return normalized.ok ? state : {
|
|
10485
|
-
ok: false,
|
|
10486
|
-
code: "merge-state-needs-repair",
|
|
10487
|
-
message: normalized.message ?? "v4 normalize failed"
|
|
10488
|
-
};
|
|
10489
10522
|
})().catch((error) => ({
|
|
10490
10523
|
ok: false,
|
|
10491
10524
|
code: "merge-state-migration-blocked",
|
|
10492
10525
|
message: error instanceof Error ? error.message : String(error)
|
|
10493
10526
|
}));
|
|
10494
|
-
const homeFence = ctx.get("evolutionMutationAuthority")?.homeFence;
|
|
10495
10527
|
const runs = newRunRegistry({
|
|
10496
10528
|
io,
|
|
10497
10529
|
home,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one v2/v3→v4 migration seam. The startup gate and the operator CLI both call this,
|
|
3
|
+
* so a backup/journal fix in one path cannot drift from the other.
|
|
4
|
+
*/
|
|
5
|
+
import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
|
|
6
|
+
/** Outcome of the one-time migration. */
|
|
7
|
+
export type MigrateToV4Result = {
|
|
8
|
+
readonly ok: true;
|
|
9
|
+
readonly migrated: boolean;
|
|
10
|
+
readonly backupRef?: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
|
|
18
|
+
* before attempting anything, so a newer state is never overwritten.
|
|
19
|
+
* @param io - the evolution IO seam.
|
|
20
|
+
* @param home - the evolution home.
|
|
21
|
+
* @param fence - optional home write fence used by the host.
|
|
22
|
+
* @returns the migration outcome.
|
|
23
|
+
*/
|
|
24
|
+
export declare function migrateMergeStateToV4(io: EvolutionIoLike, home: string, fence?: {
|
|
25
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
26
|
+
}): Promise<MigrateToV4Result>;
|
|
27
|
+
//# sourceMappingURL=state-migrate.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ensureMergeStateReady } from "./state-bootstrap.js";
|
|
2
|
+
import { normalizePairLedger } from "./pair-ledger.js";
|
|
3
|
+
import { readV4Readiness } from "./schema4.js";
|
|
4
|
+
/**
|
|
5
|
+
* Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
|
|
6
|
+
* before attempting anything, so a newer state is never overwritten.
|
|
7
|
+
* @param io - the evolution IO seam.
|
|
8
|
+
* @param home - the evolution home.
|
|
9
|
+
* @param fence - optional home write fence used by the host.
|
|
10
|
+
* @returns the migration outcome.
|
|
11
|
+
*/
|
|
12
|
+
export async function migrateMergeStateToV4(io, home, fence) {
|
|
13
|
+
const v4 = await readV4Readiness(io, home);
|
|
14
|
+
if (v4.ok)
|
|
15
|
+
return { ok: true, migrated: false };
|
|
16
|
+
if (v4.code === 'merge-state-forward-only' || v4.code === 'merge-state-needs-repair') {
|
|
17
|
+
return { ok: false, code: v4.code, message: v4.message };
|
|
18
|
+
}
|
|
19
|
+
const v3 = await ensureMergeStateReady(io, home, fence);
|
|
20
|
+
if (!v3.ok)
|
|
21
|
+
return { ok: false, code: v3.code, message: v3.message };
|
|
22
|
+
const normalized = await normalizePairLedger(io, home);
|
|
23
|
+
if (!normalized.ok)
|
|
24
|
+
return { ok: false, code: 'merge-state-needs-repair', message: normalized.message ?? 'v3->v4 normalize failed' };
|
|
25
|
+
return normalized.backupRef === undefined
|
|
26
|
+
? { ok: true, migrated: normalized.migrated }
|
|
27
|
+
: { ok: true, migrated: normalized.migrated, backupRef: normalized.backupRef };
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=state-migrate.js.map
|
package/lib/types/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { authorityFrom, createSubagentChannel, evolutionHome, evolutionIoAdapter
|
|
|
8
8
|
import { newSkillLibrary } from '@lmzhen/dsh-evolution-core/skills';
|
|
9
9
|
import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
|
|
10
10
|
import { loadMergeState } from "./state.js";
|
|
11
|
-
import { pairLedger
|
|
11
|
+
import { pairLedger } from "./domain/pair-ledger.js";
|
|
12
12
|
import { legacyToV3 } from "./domain/row-v3-bridge.js";
|
|
13
13
|
import { readV4Readiness } from "./domain/schema4.js";
|
|
14
14
|
import { readMergeSnapshot } from "./domain/merge-snapshot.js";
|
|
@@ -27,7 +27,7 @@ import { buildDoctorView } from "./surface/merge-view.js";
|
|
|
27
27
|
import { mergeDoctorFact } from "./doctor.js";
|
|
28
28
|
import { commandError, formatMergeError } from "./domain/errors.js";
|
|
29
29
|
import { resolveBody } from "./surface/body.js";
|
|
30
|
-
import {
|
|
30
|
+
import { migrateMergeStateToV4 } from "./domain/state-migrate.js";
|
|
31
31
|
import { recoverCommitUnits } from "./recovery-coordinator.js";
|
|
32
32
|
import { pairKey, rowIdOf } from "./contracts.js";
|
|
33
33
|
import { makeMergeRoutes } from "./merge-routes.js";
|
|
@@ -171,19 +171,14 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
171
171
|
return;
|
|
172
172
|
const io = evolutionIoAdapter(() => registry.provider());
|
|
173
173
|
const home = evolutionHome();
|
|
174
|
+
const homeFence = ctx.get('evolutionMutationAuthority')?.homeFence;
|
|
174
175
|
const ready = (async () => {
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
const state = await ensureMergeStateReady(io, home, ctx.get('evolutionMutationAuthority')?.homeFence);
|
|
181
|
-
if (!state.ok)
|
|
182
|
-
return v4.code === 'merge-state-forward-only' || v4.code === 'merge-state-needs-repair' ? { ok: false, code: v4.code, message: v4.message } : state;
|
|
183
|
-
const normalized = await normalizePairLedger(io, home);
|
|
184
|
-
return normalized.ok ? state : { ok: false, code: 'merge-state-needs-repair', message: normalized.message ?? 'v4 normalize failed' };
|
|
176
|
+
const migration = await migrateMergeStateToV4(io, home, homeFence);
|
|
177
|
+
if (!migration.ok)
|
|
178
|
+
return migration;
|
|
179
|
+
const loaded = await loadMergeState(io, home);
|
|
180
|
+
return { ok: true, schema: 3, migrated: migration.migrated, state: { schemaVersion: 3, rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null) }, quarantine: [], report: null };
|
|
185
181
|
})().catch(error => ({ ok: false, code: 'merge-state-migration-blocked', message: error instanceof Error ? error.message : String(error) }));
|
|
186
|
-
const homeFence = ctx.get('evolutionMutationAuthority')?.homeFence;
|
|
187
182
|
const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); }, ...(homeFence === undefined ? {} : { fence: homeFence }) });
|
|
188
183
|
ctx.effect(() => {
|
|
189
184
|
const host = ctx;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmzhen/dsh-evolution-merge",
|
|
3
3
|
"description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.49.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "lib/index.js",
|
|
15
15
|
"types": "lib/types/index.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"dsh-evolution-merge-migrate": "scripts/merge-migrate.mjs"
|
|
18
|
+
},
|
|
16
19
|
"exports": {
|
|
17
20
|
".": {
|
|
18
21
|
"types": "./lib/types/index.d.ts",
|
|
@@ -26,6 +29,9 @@
|
|
|
26
29
|
"lib/**/*.js",
|
|
27
30
|
"lib/types/**/*.d.ts",
|
|
28
31
|
"data/**/*",
|
|
32
|
+
"scripts/merge-migrate.mjs",
|
|
33
|
+
"scripts/merge-restore.mjs",
|
|
34
|
+
"scripts/merge-backup.mjs",
|
|
29
35
|
"package.json"
|
|
30
36
|
],
|
|
31
37
|
"license": "MIT",
|
|
@@ -34,9 +40,9 @@
|
|
|
34
40
|
},
|
|
35
41
|
"dependencies": {
|
|
36
42
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
37
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
38
|
-
"@lmzhen/dsh-evolution-commands": "^0.
|
|
39
|
-
"@lmzhen/dsh-evolution-merge-engine": "0.
|
|
43
|
+
"@lmzhen/dsh-evolution-core": "^0.49.0",
|
|
44
|
+
"@lmzhen/dsh-evolution-commands": "^0.49.0",
|
|
45
|
+
"@lmzhen/dsh-evolution-merge-engine": "0.49.0"
|
|
40
46
|
},
|
|
41
47
|
"peerDependencies": {
|
|
42
48
|
"@deepseek-ai/cordis": "^4.0.5"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative } from 'node:path'
|
|
4
|
+
const args = new Map()
|
|
5
|
+
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1])
|
|
6
|
+
const home = args.get('--home')
|
|
7
|
+
const out = args.get('--out')
|
|
8
|
+
if (!home || !out) { console.error('usage: node scripts/merge-backup.mjs --home <home> --out <dir> [--quiesce <json>]'); process.exit(2) }
|
|
9
|
+
const quiesce = args.get('--quiesce')
|
|
10
|
+
if (!quiesce) { console.error('backup refused: --quiesce <json> required (stop plugin or fence writer)'); process.exit(2) }
|
|
11
|
+
try { if (JSON.parse(readFileSync(quiesce, 'utf8')).stopped !== true) { console.error('backup refused: writer not stopped'); process.exit(3) } } catch { console.error('backup refused: unreadable quiesce marker'); process.exit(3) }
|
|
12
|
+
const sha = file => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
13
|
+
const roots = ['merge/merge-state.json', 'runs.json', 'merge/receipts', 'merge/journals', 'merge/runs', 'merge/snapshots']
|
|
14
|
+
const files = []
|
|
15
|
+
function walk(path) {
|
|
16
|
+
if (!existsSync(path)) return
|
|
17
|
+
const stat = statSync(path)
|
|
18
|
+
if (stat.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); return }
|
|
19
|
+
const rel = relative(home, path).replace(/\\/g, '/')
|
|
20
|
+
files.push({ path: rel, bytes: stat.size, sha256: sha(path) })
|
|
21
|
+
}
|
|
22
|
+
for (const root of roots) walk(join(home, root))
|
|
23
|
+
mkdirSync(out, { recursive: true })
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const target = join(out, file.path)
|
|
26
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
27
|
+
copyFileSync(join(home, file.path), target)
|
|
28
|
+
}
|
|
29
|
+
const manifest = { schemaVersion: 1, createdAt: new Date().toISOString(), home, quiesce: { path: quiesce, sha256: sha(quiesce) }, files }
|
|
30
|
+
writeFileSync(join(out, 'backup-manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
|
31
|
+
console.log('MERGE BACKUP OK files=' + files.length + ' out=' + out)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One-time merge state migration: v1/v2 -> v3 -> v4, with a home-level lock.
|
|
4
|
+
*
|
|
5
|
+
* The runtime used to do v2->v3 on mount and v3->v4 on the first write. That left the old
|
|
6
|
+
* readers in the hot path and gave an operator no backup/dry-run/restore story. This CLI is
|
|
7
|
+
* the explicit entry: it reuses the SAME domain migrations (ensureMergeStateReady and
|
|
8
|
+
* normalizePairLedger), so it cannot drift from the runtime.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]
|
|
12
|
+
*
|
|
13
|
+
* Stop every host that has this home open before --apply/--restore: the lock only serializes
|
|
14
|
+
* this CLI, not a mounted plugin.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const argv = process.argv.slice(2)
|
|
23
|
+
const value = (flag) => {
|
|
24
|
+
const index = argv.indexOf(flag)
|
|
25
|
+
return index >= 0 ? argv[index + 1] : undefined
|
|
26
|
+
}
|
|
27
|
+
const homeArg = value('--home')
|
|
28
|
+
if (homeArg === undefined || homeArg === '') {
|
|
29
|
+
console.error('usage: merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]')
|
|
30
|
+
process.exit(2)
|
|
31
|
+
}
|
|
32
|
+
const home = resolve(homeArg)
|
|
33
|
+
const apply = argv.includes('--apply')
|
|
34
|
+
const restoreAt = argv.indexOf('--restore')
|
|
35
|
+
const restore = restoreAt >= 0 ? argv[restoreAt + 1] : undefined
|
|
36
|
+
if (restoreAt >= 0 && (restore === undefined || restore === '' || restore.startsWith('--'))) {
|
|
37
|
+
console.error('usage: merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]')
|
|
38
|
+
process.exit(2)
|
|
39
|
+
}
|
|
40
|
+
const dryRun = argv.includes('--dry-run') || (!apply && restoreAt < 0)
|
|
41
|
+
const asJson = argv.includes('--json')
|
|
42
|
+
const lockDir = join(home, 'merge', '.migrate-lock')
|
|
43
|
+
const LOCK_TTL_MS = 60 * 60 * 1000
|
|
44
|
+
|
|
45
|
+
function pidAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0)
|
|
48
|
+
return true
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return error?.code === 'EPERM'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function acquireLock() {
|
|
55
|
+
mkdirSync(join(home, 'merge'), { recursive: true })
|
|
56
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(lockDir)
|
|
59
|
+
writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n')
|
|
60
|
+
return
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code !== 'EEXIST') throw error
|
|
63
|
+
let owner = null
|
|
64
|
+
try { owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')) } catch { /* stale/incomplete */ }
|
|
65
|
+
const age = Date.now() - statSync(lockDir).mtimeMs
|
|
66
|
+
if (owner !== null && pidAlive(owner.pid) && age < LOCK_TTL_MS) {
|
|
67
|
+
const failure = { ok: false, code: 'migration-locked', message: 'another process owns ' + lockDir + ' (pid ' + String(owner.pid) + ')' }
|
|
68
|
+
if (asJson) console.log(JSON.stringify(failure, null, 2))
|
|
69
|
+
else console.error('merge-migrate: ' + failure.message)
|
|
70
|
+
process.exit(3)
|
|
71
|
+
}
|
|
72
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error('could not acquire ' + lockDir)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (restore !== undefined && restore !== '') {
|
|
79
|
+
if (dryRun && !apply) {
|
|
80
|
+
const report = { ok: true, dryRun: true, restore }
|
|
81
|
+
if (asJson) console.log(JSON.stringify(report, null, 2))
|
|
82
|
+
else console.log('DRY RUN: would restore ' + restore + ' into ' + home + ' (stop the host first)')
|
|
83
|
+
process.exit(0)
|
|
84
|
+
}
|
|
85
|
+
acquireLock()
|
|
86
|
+
let restoreStatus = 1
|
|
87
|
+
try {
|
|
88
|
+
const result = spawnSync(process.execPath, [join(scriptDir, 'merge-restore.mjs'), '--home', home, '--backup', restore], { stdio: 'inherit' })
|
|
89
|
+
restoreStatus = result.status ?? 1
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
92
|
+
}
|
|
93
|
+
process.exit(restoreStatus)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function inspect() {
|
|
97
|
+
let raw = null
|
|
98
|
+
try { raw = readFileSync(join(home, 'merge', 'merge-state.json'), 'utf8') } catch { /* unreadable is reported below */ }
|
|
99
|
+
let schema = null
|
|
100
|
+
let rows = 0
|
|
101
|
+
try {
|
|
102
|
+
const parsed = raw === null ? null : JSON.parse(raw)
|
|
103
|
+
schema = parsed?.schemaVersion ?? null
|
|
104
|
+
rows = Array.isArray(parsed?.rows) ? parsed.rows.length : 0
|
|
105
|
+
} catch { /* malformed is a blocker */ }
|
|
106
|
+
const v4 = schema === 4
|
|
107
|
+
? { ok: true, rows }
|
|
108
|
+
: { ok: false, code: 'merge-state-migration-required', message: 'state is not v4' }
|
|
109
|
+
const v3 = schema === 3
|
|
110
|
+
? { ok: true, rows }
|
|
111
|
+
: { ok: false, code: 'merge-state-migration-required', message: 'state is not v3 readable' }
|
|
112
|
+
return { home, state: { present: raw !== null, schema, rows }, v4, v3 }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (dryRun && !apply) {
|
|
116
|
+
const report = { ok: true, dryRun: true, ...await inspect() }
|
|
117
|
+
if (asJson) console.log(JSON.stringify(report, null, 2))
|
|
118
|
+
else console.log('DRY RUN ' + JSON.stringify(report.state) + ' v4=' + JSON.stringify(report.v4) + ' v3=' + JSON.stringify(report.v3))
|
|
119
|
+
process.exit(0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const core = await import('@lmzhen/dsh-evolution-core')
|
|
123
|
+
const io = core.nodeEvolutionIo()
|
|
124
|
+
const { migrateMergeStateToV4 } = await import('../lib/types/domain/state-migrate.js')
|
|
125
|
+
const { readV4Readiness } = await import('../lib/types/domain/schema4.js')
|
|
126
|
+
|
|
127
|
+
acquireLock()
|
|
128
|
+
let exitCode = 0
|
|
129
|
+
try {
|
|
130
|
+
const before = await inspect()
|
|
131
|
+
const beforeV4 = await readV4Readiness(io, home)
|
|
132
|
+
if (before.state.schema === 4 && beforeV4.ok) {
|
|
133
|
+
const report = { ok: true, applied: false, reason: 'already-v4', before }
|
|
134
|
+
if (asJson) console.log(JSON.stringify(report, null, 2))
|
|
135
|
+
else console.log('MIGRATE OK already v4 rows=' + String(beforeV4.state.rows.length))
|
|
136
|
+
} else {
|
|
137
|
+
const backupDir = value('--backup') ?? join(home, 'merge', '.backup', 'pre-migrate-' + new Date().toISOString().replace(/[:.]/g, '-'))
|
|
138
|
+
const quiesce = join(home, 'merge', '.migrate-quiesce.json')
|
|
139
|
+
writeFileSync(quiesce, JSON.stringify({ stopped: true, at: new Date().toISOString() }) + '\n')
|
|
140
|
+
try {
|
|
141
|
+
const backedUp = spawnSync(process.execPath, [join(scriptDir, 'merge-backup.mjs'), '--home', home, '--out', backupDir, '--quiesce', quiesce], { stdio: 'inherit' })
|
|
142
|
+
if (backedUp.status !== 0) throw new Error('backup failed with exit ' + String(backedUp.status))
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(quiesce, { force: true })
|
|
145
|
+
}
|
|
146
|
+
const migration = await migrateMergeStateToV4(io, home)
|
|
147
|
+
if (!migration.ok) throw new Error(migration.code + ': ' + migration.message)
|
|
148
|
+
const after = await inspect()
|
|
149
|
+
const afterV4 = await readV4Readiness(io, home)
|
|
150
|
+
if (!afterV4.ok) throw new Error(afterV4.code + ': ' + afterV4.message)
|
|
151
|
+
const report = { ok: true, applied: true, migrated: migration.migrated, backupRef: migration.backupRef, backupDir, before, after }
|
|
152
|
+
if (asJson) console.log(JSON.stringify(report, null, 2))
|
|
153
|
+
else console.log('MIGRATE OK v2/v3 -> v4 rows=' + String(afterV4.state.rows.length) + ' backup=' + backupDir + (migration.backupRef === undefined ? '' : ' pre1b=' + migration.backupRef))
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const failure = { ok: false, code: 'migration-failed', message: error instanceof Error ? error.message : String(error) }
|
|
157
|
+
if (asJson) console.log(JSON.stringify(failure, null, 2))
|
|
158
|
+
else console.error('merge-migrate: ' + failure.message)
|
|
159
|
+
exitCode = 1
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
162
|
+
}
|
|
163
|
+
process.exit(exitCode)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
const args = new Map()
|
|
6
|
+
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1])
|
|
7
|
+
const home = args.get('--home')
|
|
8
|
+
const backup = args.get('--backup')
|
|
9
|
+
if (!home || !backup) { console.error('usage: node scripts/merge-restore.mjs --home <home> --backup <dir>'); process.exit(2) }
|
|
10
|
+
const manifest = JSON.parse(readFileSync(join(backup, 'backup-manifest.json'), 'utf8'))
|
|
11
|
+
const sha = file => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
12
|
+
const safe = rel => { const target = resolve(home, rel); const base = resolve(home) + sep; if (!target.startsWith(base)) throw new Error('unsafe backup path: ' + rel); return target }
|
|
13
|
+
for (const file of manifest.files) {
|
|
14
|
+
const source = join(backup, file.path)
|
|
15
|
+
if (!existsSync(source)) throw new Error('missing backup file: ' + file.path)
|
|
16
|
+
if (sha(source) !== file.sha256 || statSync(source).size !== file.bytes) throw new Error('backup hash mismatch: ' + file.path)
|
|
17
|
+
safe(file.path)
|
|
18
|
+
}
|
|
19
|
+
const temp = mkdtempSync(join(tmpdir(), 'dsh-merge-restore-'))
|
|
20
|
+
try {
|
|
21
|
+
for (const file of manifest.files) {
|
|
22
|
+
const source = join(backup, file.path)
|
|
23
|
+
const staged = join(temp, file.path)
|
|
24
|
+
mkdirSync(dirname(staged), { recursive: true })
|
|
25
|
+
copyFileSync(source, staged)
|
|
26
|
+
}
|
|
27
|
+
for (const file of manifest.files) {
|
|
28
|
+
const target = safe(file.path)
|
|
29
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
30
|
+
if (existsSync(target)) renameSync(target, target + '.pre-restore')
|
|
31
|
+
copyFileSync(join(temp, file.path), target)
|
|
32
|
+
}
|
|
33
|
+
console.log('MERGE RESTORE OK files=' + manifest.files.length)
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(temp, { recursive: true, force: true })
|
|
36
|
+
}
|