ultra-builder-pro-cli 0.25.1 → 0.26.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/.ultra-template/changes/abandoned/.gitkeep +0 -0
- package/.ultra-template/contexts/TEMPLATE.md +94 -0
- package/.ultra-template/decisions/.gitkeep +1 -0
- package/.ultra-template/evidence/.gitkeep +1 -0
- package/.ultra-template/north-star.md +46 -0
- package/.ultra-template/research/.gitkeep +1 -0
- package/.ultra-template/specs/discovery.md +2 -2
- package/.ultra-template/tasks.json +3 -0
- package/.ultra-template/test-report.json +27 -0
- package/CHANGELOG.md +107 -1
- package/README.md +217 -579
- package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
- package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
- package/adapters/_shared/file-ops.cjs +76 -2
- package/adapters/_shared/host-profile.cjs +97 -0
- package/adapters/_shared/plugin-core.cjs +204 -0
- package/adapters/_shared/runtime-assets.cjs +31 -96
- package/adapters/claude.js +92 -291
- package/adapters/codex.js +309 -842
- package/adapters/grok.js +143 -1469
- package/adapters/kimi.js +124 -1053
- package/adapters/opencode.js +230 -742
- package/bin/delegate-worker.cjs +328 -0
- package/bin/delegate.cjs +463 -0
- package/bin/install.js +38 -11
- package/docs/ARCHITECTURE.md +125 -216
- package/docs/ARTIFACT-AUTHORITY.md +90 -268
- package/docs/DECISIONS.md +77 -87
- package/docs/PHILOSOPHY.md +208 -0
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +65 -39
- package/docs/RUNTIME-COMPAT-MATRIX.md +140 -155
- package/docs/SKILL-AUTHORING.md +94 -73
- package/docs/WORKFLOW-LIFECYCLE.md +97 -189
- package/hooks/README.md +19 -37
- package/hooks/_common.py +113 -0
- package/hooks/adapters/codex.py +9 -16
- package/hooks/adapters/grok.py +5 -5
- package/hooks/adapters/kimi.py +8 -14
- package/hooks/block_dangerous_commands.py +201 -0
- package/hooks/compact_context.py +65 -0
- package/hooks/mid_workflow_recall.py +25 -0
- package/hooks/post_edit_guard.py +105 -0
- package/hooks/session_context.py +30 -0
- package/package.json +17 -52
- package/skills/ultra-change/SKILL.md +115 -50
- package/skills/ultra-change/references/change-contract.md +68 -3
- package/skills/ultra-delegate/SKILL.md +89 -0
- package/skills/ultra-delegate/references/delegation-contract.md +59 -0
- package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
- package/skills/ultra-deliver/SKILL.md +98 -42
- package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
- package/skills/ultra-dev/SKILL.md +115 -62
- package/skills/ultra-dev/references/debugging.md +22 -0
- package/skills/ultra-domain-modeling/SKILL.md +79 -0
- package/skills/ultra-grilling/SKILL.md +118 -0
- package/skills/ultra-grilling/references/reframing.md +72 -0
- package/skills/ultra-init/SKILL.md +102 -37
- package/skills/ultra-init/references/brownfield-adoption.md +33 -83
- package/skills/ultra-init/scripts/init_project.cjs +84 -0
- package/skills/ultra-plan/SKILL.md +101 -68
- package/skills/ultra-research/SKILL.md +68 -48
- package/skills/ultra-research/references/00-problem-validation.md +5 -5
- package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
- package/skills/ultra-research/references/02-market-assessment.md +5 -4
- package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
- package/skills/ultra-research/references/04-product-strategy.md +3 -3
- package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
- package/skills/ultra-research/references/10-user-personas.md +3 -2
- package/skills/ultra-research/references/11-user-scenarios.md +3 -3
- package/skills/ultra-research/references/20-user-stories.md +4 -3
- package/skills/ultra-research/references/21-features-scope.md +3 -2
- package/skills/ultra-research/references/22-success-metrics.md +3 -3
- package/skills/ultra-research/references/30-architecture-context.md +3 -3
- package/skills/ultra-research/references/31-solution-strategy.md +3 -3
- package/skills/ultra-research/references/32-building-blocks.md +3 -3
- package/skills/ultra-research/references/40-deployment.md +3 -3
- package/skills/ultra-research/references/41-quality-risks.md +3 -3
- package/skills/ultra-research/references/99-synthesis.md +7 -7
- package/skills/ultra-review/SKILL.md +84 -32
- package/skills/ultra-review/references/code.md +17 -0
- package/skills/ultra-review/references/comments.md +12 -0
- package/skills/ultra-review/references/design.md +12 -0
- package/skills/ultra-review/references/errors.md +13 -0
- package/skills/ultra-review/references/spec.md +13 -0
- package/skills/ultra-review/references/tests.md +12 -0
- package/skills/ultra-review/references/worker-packet.md +40 -0
- package/skills/ultra-review/scripts/review_wait.py +26 -15
- package/skills/ultra-status/SKILL.md +61 -36
- package/skills/ultra-tdd/SKILL.md +81 -0
- package/skills/ultra-tdd/references/templates/README.md +20 -0
- package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
- package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
- package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
- package/skills/ultra-tdd/references/test-execution.md +18 -0
- package/skills/ultra-test/SKILL.md +75 -30
- package/skills/ultra-test/references/export-syntax.md +18 -0
- package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
- package/skills/ultra-think/SKILL.md +49 -27
- package/skills/ultra-think/references/autonomy-boundary.md +48 -0
- package/skills/ultra-think/references/interaction-boundary.md +18 -33
- package/.ultra-template/docs/research/README.md +0 -55
- package/.ultra-template/reports/templates/delivery-report.json +0 -17
- package/.ultra-template/reports/templates/test-report.json +0 -18
- package/.ultra-template/tasks/tasks.json +0 -15
- package/.ultra-template/templates/task-context.md +0 -19
- package/adapters/_shared/codex-assets.cjs +0 -725
- package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
- package/adapters/_shared/interaction-contract.cjs +0 -129
- package/adapters/_shared/path-rewrite.cjs +0 -79
- package/adapters/_shared/runtime-native.cjs +0 -132
- package/adapters/_shared/settings-merge.cjs +0 -76
- package/agents/code-reviewer.md +0 -42
- package/agents/debugger.md +0 -40
- package/agents/review-code.md +0 -49
- package/agents/review-comments.md +0 -38
- package/agents/review-coordinator.md +0 -56
- package/agents/review-design.md +0 -40
- package/agents/review-errors.md +0 -43
- package/agents/review-spec.md +0 -39
- package/agents/review-tests.md +0 -40
- package/agents/tdd-runner.md +0 -38
- package/bin/orchestrator.js +0 -383
- package/commands/ultra-change.md +0 -10
- package/commands/ultra-deliver.md +0 -10
- package/commands/ultra-dev.md +0 -10
- package/commands/ultra-doctor.md +0 -10
- package/commands/ultra-init.md +0 -10
- package/commands/ultra-plan.md +0 -10
- package/commands/ultra-research.md +0 -10
- package/commands/ultra-review.md +0 -10
- package/commands/ultra-status.md +0 -10
- package/commands/ultra-test.md +0 -10
- package/commands/ultra-think.md +0 -10
- package/docs/AGENT-CONTEXT.md +0 -191
- package/docs/COMMIT-HASH-BACKFILL.md +0 -51
- package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
- package/docs/ROADMAP.md +0 -101
- package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
- package/hooks/active_task_context.py +0 -152
- package/hooks/context_envelope.py +0 -100
- package/hooks/health_check.py +0 -196
- package/hooks/pre_stop_check.py +0 -66
- package/hooks/runtime_paths.py +0 -396
- package/hooks/subagent_tracker.py +0 -88
- package/hooks/workflow_checkpoint.py +0 -76
- package/hooks/workflow_context.py +0 -48
- package/hooks/workflow_resume.py +0 -84
- package/mcp-server/hook-context.cjs +0 -38
- package/mcp-server/hook-event.cjs +0 -34
- package/mcp-server/lib/archive-journal.cjs +0 -1119
- package/mcp-server/lib/archive-mutation-worker.py +0 -365
- package/mcp-server/lib/artifact-registry.cjs +0 -1232
- package/mcp-server/lib/baseline-workflow.cjs +0 -1193
- package/mcp-server/lib/canonical-json.cjs +0 -19
- package/mcp-server/lib/change-authority.cjs +0 -26
- package/mcp-server/lib/change-packet.cjs +0 -790
- package/mcp-server/lib/change-workflow.cjs +0 -65
- package/mcp-server/lib/context-envelope.cjs +0 -581
- package/mcp-server/lib/context-paths.cjs +0 -142
- package/mcp-server/lib/decision-records.cjs +0 -423
- package/mcp-server/lib/delivery-transaction.cjs +0 -471
- package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
- package/mcp-server/lib/doctor.cjs +0 -627
- package/mcp-server/lib/git-bootstrap.cjs +0 -651
- package/mcp-server/lib/init-project.cjs +0 -1021
- package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
- package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
- package/mcp-server/lib/managed-file-write.cjs +0 -172
- package/mcp-server/lib/plan-checkpoint.cjs +0 -177
- package/mcp-server/lib/plan-publication-worker.cjs +0 -830
- package/mcp-server/lib/plan-store.cjs +0 -794
- package/mcp-server/lib/prd-parser.cjs +0 -83
- package/mcp-server/lib/pricing.cjs +0 -44
- package/mcp-server/lib/project-context.cjs +0 -178
- package/mcp-server/lib/projector.cjs +0 -386
- package/mcp-server/lib/provider-refs.cjs +0 -42
- package/mcp-server/lib/runtime-paths.cjs +0 -1714
- package/mcp-server/lib/runtime-state.cjs +0 -222
- package/mcp-server/lib/safe-project-file.cjs +0 -385
- package/mcp-server/lib/stage-checkpoints.cjs +0 -463
- package/mcp-server/lib/state-authority.cjs +0 -95
- package/mcp-server/lib/state-db.cjs +0 -1580
- package/mcp-server/lib/state-ops.cjs +0 -1428
- package/mcp-server/lib/task-contract.cjs +0 -404
- package/mcp-server/lib/task-expander.cjs +0 -105
- package/mcp-server/lib/task-ledger.cjs +0 -1964
- package/mcp-server/lib/telemetry.cjs +0 -85
- package/mcp-server/lib/topo.cjs +0 -131
- package/mcp-server/lib/ultra-facade.cjs +0 -2684
- package/mcp-server/lib/worker-packet.cjs +0 -358
- package/mcp-server/server.cjs +0 -437
- package/orchestrator/auto-merge.cjs +0 -128
- package/orchestrator/daemon.cjs +0 -264
- package/orchestrator/dispatch-rules.cjs +0 -78
- package/orchestrator/parallel-orchestrator.cjs +0 -903
- package/orchestrator/planner/plan-builder.cjs +0 -125
- package/orchestrator/recovery.cjs +0 -188
- package/orchestrator/session-close-journal-worker.cjs +0 -5
- package/orchestrator/session-close-journal.cjs +0 -1014
- package/orchestrator/session-runner.cjs +0 -1153
- package/orchestrator/worktree-manager.cjs +0 -282
- package/settings.json +0 -7
- package/skills/cc-collab/SKILL.md +0 -42
- package/skills/code-review-expert/SKILL.md +0 -43
- package/skills/code-review-expert/references/correctness-reliability.md +0 -30
- package/skills/code-review-expert/references/design-boundaries.md +0 -26
- package/skills/code-review-expert/references/removal-plan.md +0 -24
- package/skills/codex-collab/SKILL.md +0 -49
- package/skills/codex-collab/evals/evals.json +0 -35
- package/skills/integration-rules/SKILL.md +0 -32
- package/skills/security-rules/SKILL.md +0 -38
- package/skills/testing-rules/SKILL.md +0 -32
- package/skills/ultra-doctor/SKILL.md +0 -41
- package/skills/ultra-plan/references/semantic-preflight.md +0 -20
- package/skills/ultra-verify/SKILL.md +0 -46
- package/skills/ultra-verify/evals/evals.json +0 -46
- package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
- package/skills/ultra-verify/references/evidence-status.md +0 -21
- package/skills/ultra-verify/scripts/verify_wait.py +0 -94
- package/spec/cli-protocol.md +0 -102
- package/spec/mcp-tools.yaml +0 -312
- package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
- package/spec/schemas/change-delta.v1.schema.json +0 -153
- package/spec/schemas/command-manifest.schema.json +0 -29
- package/spec/schemas/context-file.v4.5.schema.json +0 -54
- package/spec/schemas/delivery-report.v1.schema.json +0 -66
- package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
- package/spec/schemas/mcp-tools.schema.json +0 -136
- package/spec/schemas/skill-manifest.schema.json +0 -24
- package/spec/schemas/state-db.sql +0 -641
- package/spec/schemas/task-ledger.v2.schema.json +0 -296
- package/spec/schemas/tasks.v4.5.schema.json +0 -96
- package/spec/schemas/test-report.v1.schema.json +0 -225
- package/ultra-tools/cli.cjs +0 -129
- package/ultra-tools/commands/db.cjs +0 -226
- package/ultra-tools/commands/legacy-memory.cjs +0 -159
- package/ultra-tools/commands/migrate.cjs +0 -1386
- package/ultra-tools/commands/session.cjs +0 -213
- package/ultra-tools/commands/status.cjs +0 -559
- package/ultra-tools/commands/system.cjs +0 -498
- package/ultra-tools/commands/task.cjs +0 -105
|
@@ -1,1386 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { createHash } = require('node:crypto');
|
|
6
|
-
const Database = require('better-sqlite3');
|
|
7
|
-
|
|
8
|
-
const {
|
|
9
|
-
EXPECTED_VERSION, initStateDb, openStateDb, closeStateDb, MIGRATED_GAPS,
|
|
10
|
-
} = require('../../mcp-server/lib/state-db.cjs');
|
|
11
|
-
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
12
|
-
const projector = require('../../mcp-server/lib/projector.cjs');
|
|
13
|
-
const runtimePaths = require('../../mcp-server/lib/runtime-paths.cjs');
|
|
14
|
-
const contextPaths = require('../../mcp-server/lib/context-paths.cjs');
|
|
15
|
-
|
|
16
|
-
const DEFAULT_FROM = '4.4';
|
|
17
|
-
const BACKUP_MANIFEST = 'backup-manifest.json';
|
|
18
|
-
const BACKUP_MANIFEST_KIND = 'ultra-projection-backup';
|
|
19
|
-
const SUPPORTED_TRANSITIONS = Object.freeze({
|
|
20
|
-
'4.4': '4.5',
|
|
21
|
-
'4.5': EXPECTED_VERSION,
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// Frozen SQL — values flow through parameter bindings.
|
|
25
|
-
const RECORD_MIGRATION_SQL = "INSERT INTO migration_history (from_version, to_version, direction, status, notes) VALUES (@from, @to, @direction, @status, @notes)";
|
|
26
|
-
|
|
27
|
-
function emit(envelope) {
|
|
28
|
-
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function parseFlags(args) {
|
|
32
|
-
const flags = { _: [] };
|
|
33
|
-
// Accept both "--from 4.4" and "--from=4.4" styles.
|
|
34
|
-
const valueOf = (token, i) => {
|
|
35
|
-
const eq = token.indexOf('=');
|
|
36
|
-
return eq >= 0 ? { value: token.slice(eq + 1), nextI: i } : { value: args[i + 1], nextI: i + 1 };
|
|
37
|
-
};
|
|
38
|
-
for (let i = 0; i < args.length; i++) {
|
|
39
|
-
const a = args[i];
|
|
40
|
-
if (a === '--dry') { flags.dry = true; continue; }
|
|
41
|
-
if (a === '--rollback') { flags.rollback = true; continue; }
|
|
42
|
-
if (a === '--help' || a === '-h') { flags.help = true; continue; }
|
|
43
|
-
if (a.startsWith('--from')) { const r = valueOf(a, i); flags.from = r.value; i = r.nextI; continue; }
|
|
44
|
-
if (a.startsWith('--to')) { const r = valueOf(a, i); flags.to = r.value; i = r.nextI; continue; }
|
|
45
|
-
if (a.startsWith('--source-dir')) { const r = valueOf(a, i); flags.sourceDir = r.value; i = r.nextI; continue; }
|
|
46
|
-
if (a.startsWith('--db-path')) { const r = valueOf(a, i); flags.dbPath = r.value; i = r.nextI; continue; }
|
|
47
|
-
flags._.push(a);
|
|
48
|
-
}
|
|
49
|
-
return flags;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseFrontmatter(text) {
|
|
53
|
-
if (!text.startsWith('---')) return null;
|
|
54
|
-
const end = text.indexOf('\n---', 3);
|
|
55
|
-
if (end === -1) return null;
|
|
56
|
-
const block = text.slice(3, end).trim();
|
|
57
|
-
const out = {};
|
|
58
|
-
for (const line of block.split('\n')) {
|
|
59
|
-
const idx = line.indexOf(':');
|
|
60
|
-
if (idx === -1) continue;
|
|
61
|
-
const key = line.slice(0, idx).trim();
|
|
62
|
-
const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
|
|
63
|
-
out[key] = value;
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function readJsonOptional(p) {
|
|
69
|
-
if (!fs.existsSync(p)) return null;
|
|
70
|
-
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function findContexts(rootDir, ultraDir = path.join(rootDir, '.ultra')) {
|
|
74
|
-
const dir = path.join(ultraDir, 'tasks', 'contexts');
|
|
75
|
-
if (!fs.existsSync(dir)) return [];
|
|
76
|
-
return fs.readdirSync(dir)
|
|
77
|
-
.filter((f) => f.endsWith('.md'))
|
|
78
|
-
.map((f) => path.join(dir, f));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function lstatOrNull(candidate) {
|
|
82
|
-
try {
|
|
83
|
-
return fs.lstatSync(candidate);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (error.code === 'ENOENT') return null;
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function assertRealDirectory(candidate, label) {
|
|
91
|
-
const stat = lstatOrNull(candidate);
|
|
92
|
-
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
93
|
-
throw new Error(`migrate: ${label} must be a real directory: ${candidate}`);
|
|
94
|
-
}
|
|
95
|
-
return stat;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function assertRegularTree(root, label) {
|
|
99
|
-
assertRealDirectory(root, label);
|
|
100
|
-
const pending = fs.readdirSync(root).map((entry) => path.join(root, entry));
|
|
101
|
-
while (pending.length > 0) {
|
|
102
|
-
const candidate = pending.pop();
|
|
103
|
-
const stat = fs.lstatSync(candidate);
|
|
104
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
105
|
-
throw new Error(
|
|
106
|
-
`migrate: ${label} contains a symlink or non-regular entry: ${candidate}`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
if (stat.isDirectory()) {
|
|
110
|
-
for (const entry of fs.readdirSync(candidate)) {
|
|
111
|
-
pending.push(path.join(candidate, entry));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function copyDirSync(src, dst, {
|
|
118
|
-
copyFileSync = fs.copyFileSync,
|
|
119
|
-
excludeAuthorityRoot = true,
|
|
120
|
-
} = {}) {
|
|
121
|
-
assertRealDirectory(src, 'copy source');
|
|
122
|
-
fs.mkdirSync(dst, { recursive: true });
|
|
123
|
-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
124
|
-
const s = path.join(src, entry.name);
|
|
125
|
-
const d = path.join(dst, entry.name);
|
|
126
|
-
if (excludeAuthorityRoot && entry.name === '.runtime') continue;
|
|
127
|
-
if (excludeAuthorityRoot
|
|
128
|
-
&& ['state.db', 'state.db-wal', 'state.db-shm'].includes(entry.name)) continue;
|
|
129
|
-
if (excludeAuthorityRoot && /^backup-v[^/]+-/.test(entry.name)) continue;
|
|
130
|
-
if (entry.isDirectory()) {
|
|
131
|
-
copyDirSync(s, d, { copyFileSync, excludeAuthorityRoot: false });
|
|
132
|
-
}
|
|
133
|
-
else if (entry.isFile()) copyFileSync(s, d);
|
|
134
|
-
else {
|
|
135
|
-
throw new Error(
|
|
136
|
-
`migrate: copy source contains a symlink or non-regular entry: ${s}`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function fileDigest(candidate) {
|
|
143
|
-
return createHash('sha256').update(fs.readFileSync(candidate)).digest('hex');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function regularTreeInventory(root, { excludeRootEntries = [] } = {}) {
|
|
147
|
-
assertRegularTree(root, 'inventory tree');
|
|
148
|
-
const inventory = [];
|
|
149
|
-
const excluded = new Set(excludeRootEntries);
|
|
150
|
-
const pending = fs.readdirSync(root)
|
|
151
|
-
.filter((entry) => !excluded.has(entry))
|
|
152
|
-
.map((entry) => path.join(root, entry));
|
|
153
|
-
while (pending.length > 0) {
|
|
154
|
-
const candidate = pending.pop();
|
|
155
|
-
const stat = fs.lstatSync(candidate);
|
|
156
|
-
const relative = path.relative(root, candidate).split(path.sep).join('/');
|
|
157
|
-
if (stat.isDirectory()) {
|
|
158
|
-
inventory.push({ path: `${relative}/`, kind: 'directory' });
|
|
159
|
-
for (const entry of fs.readdirSync(candidate)) pending.push(path.join(candidate, entry));
|
|
160
|
-
} else {
|
|
161
|
-
inventory.push({
|
|
162
|
-
path: relative,
|
|
163
|
-
kind: 'file',
|
|
164
|
-
size: stat.size,
|
|
165
|
-
sha256: fileDigest(candidate),
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return inventory.sort((left, right) => left.path.localeCompare(right.path));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function jsonDigest(value) {
|
|
173
|
-
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function backupInventory(root) {
|
|
177
|
-
return regularTreeInventory(root, { excludeRootEntries: [BACKUP_MANIFEST] });
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function backupManifest(inventory, fromVersion, createdAt) {
|
|
181
|
-
return {
|
|
182
|
-
version: 1,
|
|
183
|
-
kind: BACKUP_MANIFEST_KIND,
|
|
184
|
-
source_version: String(fromVersion),
|
|
185
|
-
created_at: createdAt,
|
|
186
|
-
inventory,
|
|
187
|
-
inventory_sha256: jsonDigest(inventory),
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function validateBackupManifestDocument(manifest, fromVersion, label) {
|
|
192
|
-
if (!manifest
|
|
193
|
-
|| manifest.version !== 1
|
|
194
|
-
|| manifest.kind !== BACKUP_MANIFEST_KIND
|
|
195
|
-
|| manifest.source_version !== String(fromVersion)
|
|
196
|
-
|| !Array.isArray(manifest.inventory)
|
|
197
|
-
|| !/^[a-f0-9]{64}$/.test(manifest.inventory_sha256 || '')
|
|
198
|
-
|| manifest.inventory_sha256 !== jsonDigest(manifest.inventory)) {
|
|
199
|
-
throw new Error(`migrate: ${label} has an invalid backup manifest`);
|
|
200
|
-
}
|
|
201
|
-
return {
|
|
202
|
-
manifest,
|
|
203
|
-
manifestDigest: jsonDigest(manifest),
|
|
204
|
-
inventoryDigest: manifest.inventory_sha256,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function projectionInventory(root) {
|
|
209
|
-
const stage = fs.mkdtempSync(path.join(path.dirname(root), '.inventory-stage-'));
|
|
210
|
-
try {
|
|
211
|
-
copyDirSync(root, stage);
|
|
212
|
-
return regularTreeInventory(stage);
|
|
213
|
-
} finally {
|
|
214
|
-
fs.rmSync(stage, { recursive: true, force: true });
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function isoTimestamp(value, fallback) {
|
|
219
|
-
const candidate = value || fallback;
|
|
220
|
-
const date = new Date(candidate);
|
|
221
|
-
if (Number.isNaN(date.getTime())) {
|
|
222
|
-
throw new Error(`migrate: invalid timestamp ${JSON.stringify(candidate)}`);
|
|
223
|
-
}
|
|
224
|
-
return date.toISOString();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function projectRelativeContextPath(sourceDir, value) {
|
|
228
|
-
if (!value) return null;
|
|
229
|
-
try {
|
|
230
|
-
return contextPaths.resolveContextPath(sourceDir, value, {
|
|
231
|
-
allowLegacyAliases: true,
|
|
232
|
-
}).relative;
|
|
233
|
-
} catch (error) {
|
|
234
|
-
if (error.code !== 'CONTEXT_PATH_INVALID') throw error;
|
|
235
|
-
throw new Error(`migrate: context_file rejected: ${error.message}`, { cause: error });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function sanitizeLegacyContextTemplate(sourceDir) {
|
|
240
|
-
const file = path.join(sourceDir, '.ultra', 'tasks', 'contexts', 'TEMPLATE.md');
|
|
241
|
-
if (!fs.existsSync(file)) return false;
|
|
242
|
-
const current = fs.readFileSync(file, 'utf8');
|
|
243
|
-
const next = current
|
|
244
|
-
.replace(/^>\s*\*\*Status\*\*:.*(?:\r?\n|$)(?:\r?\n)?/mi, '')
|
|
245
|
-
.replace(
|
|
246
|
-
/Read by mid_workflow_recall\.py and session_context\.py and injected into agent context\./g,
|
|
247
|
-
'Used as task-local acceptance criteria by the active Ultra workflow.',
|
|
248
|
-
);
|
|
249
|
-
const target = path.join(sourceDir, '.ultra', 'templates', 'task-context.md');
|
|
250
|
-
if (fs.existsSync(target)) return false;
|
|
251
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
252
|
-
fs.writeFileSync(target, next);
|
|
253
|
-
fs.rmSync(file);
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function normalizeLegacyTask(task, tasksJson, contextHeaders, sourceDir) {
|
|
258
|
-
if (!task || typeof task !== 'object') throw new Error('migrate: every task must be an object');
|
|
259
|
-
for (const field of ['id', 'title', 'type', 'priority', 'status']) {
|
|
260
|
-
if (task[field] === undefined || task[field] === null || task[field] === '') {
|
|
261
|
-
throw new Error(`migrate: task ${task.id || '(unknown)'} missing ${field}`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
const deps = task.dependencies ?? task.deps ?? [];
|
|
265
|
-
if (!Array.isArray(deps)) throw new Error(`migrate: task ${task.id} dependencies must be an array`);
|
|
266
|
-
if (task.estimated_days !== undefined
|
|
267
|
-
&& (!Number.isFinite(task.estimated_days) || task.estimated_days <= 0)) {
|
|
268
|
-
throw new Error(`migrate: task ${task.id} estimated_days must be a positive number`);
|
|
269
|
-
}
|
|
270
|
-
const createdAt = isoTimestamp(task.created_at, tasksJson.created);
|
|
271
|
-
const updatedAt = isoTimestamp(task.updated_at, tasksJson.updated || tasksJson.created);
|
|
272
|
-
const ctx = contextHeaders[task.id];
|
|
273
|
-
const contextFile = projectRelativeContextPath(
|
|
274
|
-
sourceDir,
|
|
275
|
-
task.context_file || (ctx && ctx._file),
|
|
276
|
-
);
|
|
277
|
-
return {
|
|
278
|
-
...task,
|
|
279
|
-
deps,
|
|
280
|
-
estimated_days: task.estimated_days ?? null,
|
|
281
|
-
context_file: contextFile,
|
|
282
|
-
created_at: createdAt,
|
|
283
|
-
updated_at: updatedAt,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function planForward(sourceDir, expectedFrom = DEFAULT_FROM, {
|
|
288
|
-
ultraDir = path.join(sourceDir, '.ultra'),
|
|
289
|
-
} = {}) {
|
|
290
|
-
const tasksPath = path.join(ultraDir, 'tasks', 'tasks.json');
|
|
291
|
-
const tasksJson = readJsonOptional(tasksPath);
|
|
292
|
-
if (!tasksJson || !Array.isArray(tasksJson.tasks)) {
|
|
293
|
-
throw new Error(`migrate: tasks.json missing or malformed at ${tasksPath}`);
|
|
294
|
-
}
|
|
295
|
-
const version = String(tasksJson.version || tasksJson.schema_version || '');
|
|
296
|
-
if (version !== expectedFrom) {
|
|
297
|
-
throw new Error(`migrate: expected v${expectedFrom} tasks.json, found ${version || '(missing version)'}`);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const contextHeaders = {};
|
|
301
|
-
for (const file of findContexts(sourceDir, ultraDir)) {
|
|
302
|
-
const fm = parseFrontmatter(fs.readFileSync(file, 'utf8'));
|
|
303
|
-
if (fm && fm.task_id) {
|
|
304
|
-
contextHeaders[fm.task_id] = {
|
|
305
|
-
...fm,
|
|
306
|
-
_file: path.join(
|
|
307
|
-
sourceDir,
|
|
308
|
-
'.ultra',
|
|
309
|
-
path.relative(ultraDir, file),
|
|
310
|
-
),
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const warnings = [];
|
|
316
|
-
for (const task of tasksJson.tasks) {
|
|
317
|
-
const ctx = contextHeaders[task.id];
|
|
318
|
-
if (ctx && ctx.status && ctx.status !== task.status) {
|
|
319
|
-
warnings.push({
|
|
320
|
-
task_id: task.id,
|
|
321
|
-
json_status: task.status,
|
|
322
|
-
context_status: ctx.status,
|
|
323
|
-
resolution: `tasks.json wins (v${expectedFrom} projection migration rule)`,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const eventsPath = path.join(ultraDir, 'activity-log.json');
|
|
329
|
-
const events = readJsonOptional(eventsPath);
|
|
330
|
-
const eventList = Array.isArray(events) ? events : [];
|
|
331
|
-
const tasks = tasksJson.tasks.map((task) => (
|
|
332
|
-
normalizeLegacyTask(task, tasksJson, contextHeaders, sourceDir)
|
|
333
|
-
));
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
tasks,
|
|
337
|
-
events: eventList,
|
|
338
|
-
contextHeaders,
|
|
339
|
-
warnings,
|
|
340
|
-
projectName: tasksJson.project?.name || path.basename(sourceDir),
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function applyForward(db, plan) {
|
|
345
|
-
const insertTask = db.prepare(
|
|
346
|
-
"INSERT INTO tasks (id, title, type, priority, complexity, estimated_days, status, deps, tag, trace_to, context_file, created_at, updated_at) VALUES (@id, @title, @type, @priority, @complexity, @estimated_days, @status, @deps, @tag, @trace_to, @context_file, @created_at, @updated_at)",
|
|
347
|
-
);
|
|
348
|
-
const insertEvent = db.prepare(
|
|
349
|
-
"INSERT INTO events (ts, type, task_id, session_id, runtime, payload_json) VALUES (@ts, @type, @task_id, @session_id, @runtime, @payload)",
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
let taskInserted = 0;
|
|
353
|
-
for (const t of plan.tasks) {
|
|
354
|
-
insertTask.run({
|
|
355
|
-
id: t.id,
|
|
356
|
-
title: t.title,
|
|
357
|
-
type: t.type,
|
|
358
|
-
priority: t.priority,
|
|
359
|
-
complexity: t.complexity ?? null,
|
|
360
|
-
estimated_days: t.estimated_days,
|
|
361
|
-
status: t.status,
|
|
362
|
-
deps: JSON.stringify(t.deps),
|
|
363
|
-
tag: t.tag ?? null,
|
|
364
|
-
trace_to: t.trace_to ?? null,
|
|
365
|
-
context_file: t.context_file,
|
|
366
|
-
created_at: t.created_at,
|
|
367
|
-
updated_at: t.updated_at,
|
|
368
|
-
});
|
|
369
|
-
taskInserted++;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
let eventsInserted = 0;
|
|
373
|
-
for (const e of plan.events) {
|
|
374
|
-
insertEvent.run({
|
|
375
|
-
ts: e.ts || new Date().toISOString(),
|
|
376
|
-
type: e.type,
|
|
377
|
-
task_id: e.task_id ?? null,
|
|
378
|
-
session_id: e.session_id ?? null,
|
|
379
|
-
runtime: e.runtime ?? null,
|
|
380
|
-
payload: e.payload === undefined ? null : JSON.stringify(e.payload),
|
|
381
|
-
});
|
|
382
|
-
eventsInserted++;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return { taskInserted, eventsInserted };
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function recordMigration(db, { from, to, direction, status, notes }) {
|
|
389
|
-
db.prepare(RECORD_MIGRATION_SQL).run({ from, to, direction, status, notes });
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function ensureBackupName(sourceDir, fromVersion = DEFAULT_FROM) {
|
|
393
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
394
|
-
const parent = runtimePaths.pathsFor(sourceDir).backupsDir;
|
|
395
|
-
const prefix = `projection-v${fromVersion}-${ts}`;
|
|
396
|
-
let candidate = path.join(parent, prefix);
|
|
397
|
-
let attempt = 0;
|
|
398
|
-
while (lstatOrNull(candidate)) {
|
|
399
|
-
attempt += 1;
|
|
400
|
-
candidate = path.join(parent, `${prefix}-${attempt}`);
|
|
401
|
-
}
|
|
402
|
-
return candidate;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function createProjectionBackup(sourceDir, fromVersion, {
|
|
406
|
-
copyFileSync = fs.copyFileSync,
|
|
407
|
-
renameSync = fs.renameSync,
|
|
408
|
-
} = {}) {
|
|
409
|
-
const paths = runtimePaths.pathsFor(sourceDir);
|
|
410
|
-
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
411
|
-
const stage = path.join(sourceDir, `.ultra-migrate-stage-${token}`);
|
|
412
|
-
const finalPath = ensureBackupName(sourceDir, fromVersion);
|
|
413
|
-
try {
|
|
414
|
-
copyDirSync(paths.ultraDir, stage, { copyFileSync });
|
|
415
|
-
const expected = projectionInventory(paths.ultraDir);
|
|
416
|
-
const actual = regularTreeInventory(stage);
|
|
417
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
418
|
-
throw new Error('migrate: staged projection backup inventory does not match source');
|
|
419
|
-
}
|
|
420
|
-
writeJsonAtomic(
|
|
421
|
-
path.join(stage, BACKUP_MANIFEST),
|
|
422
|
-
backupManifest(actual, fromVersion, new Date().toISOString()),
|
|
423
|
-
);
|
|
424
|
-
assertRollbackBackup(sourceDir, stage, fromVersion, { staged: true });
|
|
425
|
-
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
426
|
-
renameSync(stage, finalPath);
|
|
427
|
-
return finalPath;
|
|
428
|
-
} catch (error) {
|
|
429
|
-
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* recovery path remains */ }
|
|
430
|
-
throw error;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function prepareForwardSnapshot(sourceDir, fromVersion, {
|
|
435
|
-
afterBackup = null,
|
|
436
|
-
...backupOptions
|
|
437
|
-
} = {}) {
|
|
438
|
-
const backupDir = createProjectionBackup(sourceDir, fromVersion, backupOptions);
|
|
439
|
-
if (afterBackup) afterBackup({ sourceDir, backupDir });
|
|
440
|
-
const bound = assertRollbackBackup(sourceDir, backupDir, fromVersion);
|
|
441
|
-
const current = projectionInventory(runtimePaths.pathsFor(sourceDir).ultraDir);
|
|
442
|
-
if (JSON.stringify(current) !== JSON.stringify(bound.manifest.inventory)) {
|
|
443
|
-
throw new Error(
|
|
444
|
-
'migrate: semantic source changed after the immutable backup snapshot; replan required',
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
const plan = planForward(sourceDir, fromVersion, { ultraDir: backupDir });
|
|
448
|
-
return { backupDir, plan, manifest: bound.manifest };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function backupTimestampFromName(candidate) {
|
|
452
|
-
const match = path.basename(candidate).match(
|
|
453
|
-
/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z(?:-\d+)?$/,
|
|
454
|
-
);
|
|
455
|
-
if (!match) return null;
|
|
456
|
-
const value = `${match[1]}:${match[2]}:${match[3]}.${match[4]}Z`;
|
|
457
|
-
const timestamp = Date.parse(value);
|
|
458
|
-
return Number.isFinite(timestamp) ? timestamp : null;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function backupTimestampFromMetadata(candidate) {
|
|
462
|
-
for (const name of ['.backup-metadata.json', 'backup-metadata.json']) {
|
|
463
|
-
const metadataPath = path.join(candidate, name);
|
|
464
|
-
const stat = lstatOrNull(metadataPath);
|
|
465
|
-
if (!stat) continue;
|
|
466
|
-
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
467
|
-
try {
|
|
468
|
-
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
|
469
|
-
const timestamp = Date.parse(metadata.created_at || metadata.createdAt || '');
|
|
470
|
-
if (Number.isFinite(timestamp)) return timestamp;
|
|
471
|
-
} catch {
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return null;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function backupSortKey(candidate) {
|
|
479
|
-
const stat = fs.lstatSync(candidate);
|
|
480
|
-
return [
|
|
481
|
-
backupTimestampFromMetadata(candidate)
|
|
482
|
-
?? backupTimestampFromName(candidate)
|
|
483
|
-
?? stat.mtimeMs,
|
|
484
|
-
stat.mtimeMs,
|
|
485
|
-
candidate,
|
|
486
|
-
];
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function findLatestBackup(sourceDir, fromVersion = DEFAULT_FROM) {
|
|
490
|
-
runtimePaths.validateProjectLayout(sourceDir, {
|
|
491
|
-
env: {},
|
|
492
|
-
validateRuntimeTree: true,
|
|
493
|
-
});
|
|
494
|
-
const roots = [
|
|
495
|
-
{
|
|
496
|
-
dir: runtimePaths.pathsFor(sourceDir).backupsDir,
|
|
497
|
-
prefix: `projection-v${fromVersion}-`,
|
|
498
|
-
},
|
|
499
|
-
{
|
|
500
|
-
dir: path.join(sourceDir, '.ultra'),
|
|
501
|
-
prefix: `backup-v${fromVersion}-`,
|
|
502
|
-
},
|
|
503
|
-
];
|
|
504
|
-
const candidates = roots.flatMap(({ dir, prefix }) => (
|
|
505
|
-
fs.existsSync(dir)
|
|
506
|
-
? fs.readdirSync(dir)
|
|
507
|
-
.filter((name) => name.startsWith(prefix))
|
|
508
|
-
.map((name) => path.join(dir, name))
|
|
509
|
-
: []
|
|
510
|
-
));
|
|
511
|
-
const valid = [];
|
|
512
|
-
const invalid = [];
|
|
513
|
-
for (const candidate of candidates) {
|
|
514
|
-
try {
|
|
515
|
-
assertRollbackBackup(sourceDir, candidate, fromVersion);
|
|
516
|
-
valid.push(candidate);
|
|
517
|
-
} catch (error) {
|
|
518
|
-
invalid.push(error);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (valid.length === 0) {
|
|
522
|
-
if (invalid.length > 0) throw invalid[0];
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
valid.sort((left, right) => {
|
|
526
|
-
const a = backupSortKey(left);
|
|
527
|
-
const b = backupSortKey(right);
|
|
528
|
-
return a[0] - b[0] || a[1] - b[1] || a[2].localeCompare(b[2]);
|
|
529
|
-
});
|
|
530
|
-
return valid.at(-1);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function assertRollbackBackup(sourceDir, backupDir, fromVersion, { staged = false } = {}) {
|
|
534
|
-
const paths = runtimePaths.validateProjectLayout(sourceDir, { env: {} });
|
|
535
|
-
if (!staged) {
|
|
536
|
-
const allowedParents = [
|
|
537
|
-
paths.backupsDir,
|
|
538
|
-
paths.ultraDir,
|
|
539
|
-
];
|
|
540
|
-
const parent = path.dirname(path.resolve(backupDir));
|
|
541
|
-
if (!allowedParents.includes(parent)) {
|
|
542
|
-
throw new Error(`migrate: rollback backup is outside a managed backup root: ${backupDir}`);
|
|
543
|
-
}
|
|
544
|
-
if (parent === paths.backupsDir) {
|
|
545
|
-
assertRealDirectory(paths.backupsDir, 'runtime backup root');
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
assertRegularTree(backupDir, 'rollback backup');
|
|
549
|
-
for (const entry of fs.readdirSync(backupDir, { withFileTypes: true })) {
|
|
550
|
-
const candidate = path.join(backupDir, entry.name);
|
|
551
|
-
if (entry.name === '.runtime'
|
|
552
|
-
|| entry.name === 'state.db'
|
|
553
|
-
|| entry.name === 'state.db-wal'
|
|
554
|
-
|| entry.name === 'state.db-shm') {
|
|
555
|
-
throw new Error(`migrate: rollback backup contains a runtime authority entry: ${candidate}`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// Traversing the source with the same copier used for staging proves every
|
|
559
|
-
// nested entry is a real directory or regular file before current state moves.
|
|
560
|
-
const tasksPath = path.join(backupDir, 'tasks', 'tasks.json');
|
|
561
|
-
const tasksStat = lstatOrNull(tasksPath);
|
|
562
|
-
if (!tasksStat || tasksStat.isSymbolicLink() || !tasksStat.isFile()) {
|
|
563
|
-
throw new Error(`migrate: rollback backup has no regular tasks/tasks.json: ${tasksPath}`);
|
|
564
|
-
}
|
|
565
|
-
const tasks = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
|
|
566
|
-
const version = String(tasks.version || tasks.schema_version || '');
|
|
567
|
-
if (version !== String(fromVersion) || !Array.isArray(tasks.tasks)) {
|
|
568
|
-
throw new Error(
|
|
569
|
-
`migrate: rollback backup is not a valid v${fromVersion} projection snapshot`,
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
const inventory = backupInventory(backupDir);
|
|
573
|
-
const manifestPath = path.join(backupDir, BACKUP_MANIFEST);
|
|
574
|
-
const manifestStat = lstatOrNull(manifestPath);
|
|
575
|
-
const requiresManifest = path.basename(backupDir).startsWith(`projection-v${fromVersion}-`);
|
|
576
|
-
let manifest;
|
|
577
|
-
if (manifestStat) {
|
|
578
|
-
if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) {
|
|
579
|
-
throw new Error(`migrate: rollback backup manifest is unsafe: ${manifestPath}`);
|
|
580
|
-
}
|
|
581
|
-
try {
|
|
582
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
583
|
-
} catch (error) {
|
|
584
|
-
throw new Error(`migrate: rollback backup manifest is malformed: ${manifestPath}`, {
|
|
585
|
-
cause: error,
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
} else if (requiresManifest) {
|
|
589
|
-
throw new Error(`migrate: projection backup manifest is missing: ${manifestPath}`);
|
|
590
|
-
} else {
|
|
591
|
-
// Legacy backup-v snapshots predate cryptographic manifests. Adopt their
|
|
592
|
-
// complete current image once, before mutation, and bind that inventory to
|
|
593
|
-
// the new durable recovery journal.
|
|
594
|
-
manifest = backupManifest(
|
|
595
|
-
inventory,
|
|
596
|
-
fromVersion,
|
|
597
|
-
new Date(fs.lstatSync(backupDir).mtimeMs).toISOString(),
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
const admitted = validateBackupManifestDocument(
|
|
601
|
-
manifest,
|
|
602
|
-
fromVersion,
|
|
603
|
-
'rollback backup',
|
|
604
|
-
);
|
|
605
|
-
if (JSON.stringify(inventory) !== JSON.stringify(manifest.inventory)) {
|
|
606
|
-
throw new Error(
|
|
607
|
-
`migrate: rollback backup inventory does not match its manifest: ${backupDir}`,
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
return {
|
|
611
|
-
paths,
|
|
612
|
-
manifest: admitted.manifest,
|
|
613
|
-
manifestDigest: admitted.manifestDigest,
|
|
614
|
-
inventoryDigest: admitted.inventoryDigest,
|
|
615
|
-
manifestPath: manifestStat ? manifestPath : null,
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function assertCurrentSemanticTree(ultraDir) {
|
|
620
|
-
assertRealDirectory(ultraDir, '.ultra');
|
|
621
|
-
const pending = fs.readdirSync(ultraDir, { withFileTypes: true })
|
|
622
|
-
.filter((entry) => entry.name !== '.runtime'
|
|
623
|
-
&& entry.name !== 'state.db'
|
|
624
|
-
&& entry.name !== 'state.db-wal'
|
|
625
|
-
&& entry.name !== 'state.db-shm')
|
|
626
|
-
.map((entry) => path.join(ultraDir, entry.name));
|
|
627
|
-
while (pending.length > 0) {
|
|
628
|
-
const candidate = pending.pop();
|
|
629
|
-
const stat = fs.lstatSync(candidate);
|
|
630
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
631
|
-
throw new Error(
|
|
632
|
-
`migrate: current semantic tree contains a symlink or non-regular entry: ${candidate}`,
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
if (stat.isDirectory()) {
|
|
636
|
-
for (const entry of fs.readdirSync(candidate)) {
|
|
637
|
-
pending.push(path.join(candidate, entry));
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function writeJsonAtomic(file, value) {
|
|
644
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
645
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
646
|
-
try {
|
|
647
|
-
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
648
|
-
fs.renameSync(tmp, file);
|
|
649
|
-
} catch (error) {
|
|
650
|
-
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
651
|
-
throw error;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function updateRollbackJournal(journalPath, patch) {
|
|
656
|
-
const current = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
|
657
|
-
const next = {
|
|
658
|
-
...current,
|
|
659
|
-
...patch,
|
|
660
|
-
updated_at: new Date().toISOString(),
|
|
661
|
-
};
|
|
662
|
-
writeJsonAtomic(journalPath, next);
|
|
663
|
-
return next;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function rollbackRecoveryBase(paths) {
|
|
667
|
-
return path.join(paths.runtimeDir, 'recovery', 'migrate-rollback');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function assertRollbackRecoveryJournal(sourceDir, paths, recoveryDir, journal) {
|
|
671
|
-
const resolvedRecovery = path.resolve(recoveryDir);
|
|
672
|
-
const recoveryBase = path.resolve(rollbackRecoveryBase(paths));
|
|
673
|
-
if (path.dirname(resolvedRecovery) !== recoveryBase) {
|
|
674
|
-
throw new Error(`migrate: rollback recovery is outside its managed root: ${recoveryDir}`);
|
|
675
|
-
}
|
|
676
|
-
if (!journal || journal.version !== 1
|
|
677
|
-
|| path.resolve(journal.source_dir || '') !== path.resolve(sourceDir)
|
|
678
|
-
|| path.resolve(journal.recovery_dir || '') !== resolvedRecovery
|
|
679
|
-
|| path.dirname(path.resolve(journal.stage_dir || '')) !== path.resolve(paths.runtimeDir)
|
|
680
|
-
|| !path.basename(journal.stage_dir || '').startsWith('.rollback-stage-')
|
|
681
|
-
|| path.resolve(journal.backup_manifest_path || '')
|
|
682
|
-
!== path.resolve(recoveryDir, BACKUP_MANIFEST)
|
|
683
|
-
|| !/^[a-f0-9]{64}$/.test(journal.backup_manifest_sha256 || '')
|
|
684
|
-
|| !/^[a-f0-9]{64}$/.test(journal.backup_inventory_sha256 || '')
|
|
685
|
-
|| ![
|
|
686
|
-
'prepared',
|
|
687
|
-
'installed',
|
|
688
|
-
'cleanup_pending',
|
|
689
|
-
'complete',
|
|
690
|
-
'rolled_back',
|
|
691
|
-
'recovery_failed',
|
|
692
|
-
].includes(journal.phase)) {
|
|
693
|
-
throw new Error(`migrate: invalid rollback recovery journal: ${recoveryDir}`);
|
|
694
|
-
}
|
|
695
|
-
const hasStateInventory = journal.previous_state_inventory !== undefined
|
|
696
|
-
|| journal.previous_state_inventory_sha256 !== undefined;
|
|
697
|
-
if (hasStateInventory) {
|
|
698
|
-
if (!Array.isArray(journal.previous_state_inventory)
|
|
699
|
-
|| !/^[a-f0-9]{64}$/.test(journal.previous_state_inventory_sha256 || '')
|
|
700
|
-
|| jsonDigest(journal.previous_state_inventory)
|
|
701
|
-
!== journal.previous_state_inventory_sha256) {
|
|
702
|
-
throw new Error(`migrate: invalid rollback state inventory: ${recoveryDir}`);
|
|
703
|
-
}
|
|
704
|
-
const actual = regularTreeInventory(path.join(recoveryDir, 'previous-state'));
|
|
705
|
-
if (JSON.stringify(actual) !== JSON.stringify(journal.previous_state_inventory)) {
|
|
706
|
-
throw new Error(`migrate: rollback state snapshot changed after publication: ${recoveryDir}`);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
return journal;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function boundRecoveryManifest(recoveryDir, journal) {
|
|
713
|
-
const manifestPath = path.join(recoveryDir, BACKUP_MANIFEST);
|
|
714
|
-
const stat = lstatOrNull(manifestPath);
|
|
715
|
-
if (!stat || stat.isSymbolicLink() || !stat.isFile()) {
|
|
716
|
-
throw new Error(`migrate: bound recovery manifest is missing or unsafe: ${manifestPath}`);
|
|
717
|
-
}
|
|
718
|
-
let manifest;
|
|
719
|
-
try {
|
|
720
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
721
|
-
} catch (error) {
|
|
722
|
-
throw new Error(`migrate: bound recovery manifest is malformed: ${manifestPath}`, {
|
|
723
|
-
cause: error,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
const admitted = validateBackupManifestDocument(
|
|
727
|
-
manifest,
|
|
728
|
-
journal.from_version,
|
|
729
|
-
'bound recovery',
|
|
730
|
-
);
|
|
731
|
-
if (admitted.manifestDigest !== journal.backup_manifest_sha256
|
|
732
|
-
|| admitted.inventoryDigest !== journal.backup_inventory_sha256) {
|
|
733
|
-
throw new Error('migrate: recovery manifest digest does not match its journal binding');
|
|
734
|
-
}
|
|
735
|
-
return admitted;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
function assertInstalledRollback(sourceDir, paths, journal) {
|
|
739
|
-
const bound = boundRecoveryManifest(journal.recovery_dir, journal);
|
|
740
|
-
assertCurrentSemanticTree(paths.ultraDir);
|
|
741
|
-
const installedInventory = projectionInventory(paths.ultraDir);
|
|
742
|
-
if (JSON.stringify(installedInventory) !== JSON.stringify(bound.manifest.inventory)) {
|
|
743
|
-
throw new Error('migrate: installed rollback inventory does not match its bound manifest');
|
|
744
|
-
}
|
|
745
|
-
const tasksPath = path.join(paths.ultraDir, 'tasks', 'tasks.json');
|
|
746
|
-
const tasks = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
|
|
747
|
-
const version = String(tasks.version || tasks.schema_version || '');
|
|
748
|
-
if (version !== String(journal.from_version)) {
|
|
749
|
-
throw new Error(
|
|
750
|
-
`migrate: installed rollback does not match v${journal.from_version}: ${tasksPath}`,
|
|
751
|
-
);
|
|
752
|
-
}
|
|
753
|
-
for (const candidate of [
|
|
754
|
-
paths.stateDbPath,
|
|
755
|
-
`${paths.stateDbPath}-wal`,
|
|
756
|
-
`${paths.stateDbPath}-shm`,
|
|
757
|
-
paths.legacyStateDbPath,
|
|
758
|
-
`${paths.legacyStateDbPath}-wal`,
|
|
759
|
-
`${paths.legacyStateDbPath}-shm`,
|
|
760
|
-
]) {
|
|
761
|
-
if (lstatOrNull(candidate)) {
|
|
762
|
-
throw new Error(`migrate: installed rollback still exposes state authority: ${candidate}`);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
return sourceDir;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function resumeRollbackRecovery(sourceDir, io = {}) {
|
|
769
|
-
const rmSync = io.rmSync || fs.rmSync;
|
|
770
|
-
const paths = runtimePaths.validateProjectLayout(sourceDir, {
|
|
771
|
-
env: {},
|
|
772
|
-
validateRuntimeTree: true,
|
|
773
|
-
});
|
|
774
|
-
const base = rollbackRecoveryBase(paths);
|
|
775
|
-
const stat = lstatOrNull(base);
|
|
776
|
-
if (!stat) return { resumed: 0, pending: 0, recoveries: [] };
|
|
777
|
-
assertRealDirectory(base, 'rollback recovery root');
|
|
778
|
-
const recoveries = [];
|
|
779
|
-
let pending = 0;
|
|
780
|
-
for (const name of fs.readdirSync(base).sort()) {
|
|
781
|
-
const recoveryDir = path.join(base, name);
|
|
782
|
-
assertRealDirectory(recoveryDir, 'rollback recovery');
|
|
783
|
-
const journalPath = path.join(recoveryDir, 'journal.json');
|
|
784
|
-
const journalStat = lstatOrNull(journalPath);
|
|
785
|
-
if (!journalStat || journalStat.isSymbolicLink() || !journalStat.isFile()) {
|
|
786
|
-
throw new Error(`migrate: rollback recovery journal is missing or unsafe: ${journalPath}`);
|
|
787
|
-
}
|
|
788
|
-
const journal = assertRollbackRecoveryJournal(
|
|
789
|
-
sourceDir,
|
|
790
|
-
paths,
|
|
791
|
-
recoveryDir,
|
|
792
|
-
JSON.parse(fs.readFileSync(journalPath, 'utf8')),
|
|
793
|
-
);
|
|
794
|
-
if (journal.phase === 'complete' || journal.phase === 'rolled_back') continue;
|
|
795
|
-
if (!['installed', 'cleanup_pending'].includes(journal.phase)) {
|
|
796
|
-
pending += 1;
|
|
797
|
-
recoveries.push({
|
|
798
|
-
recovery_dir: recoveryDir,
|
|
799
|
-
phase: journal.phase,
|
|
800
|
-
resumed: false,
|
|
801
|
-
});
|
|
802
|
-
continue;
|
|
803
|
-
}
|
|
804
|
-
assertInstalledRollback(sourceDir, paths, journal);
|
|
805
|
-
if (lstatOrNull(journal.stage_dir)) {
|
|
806
|
-
rmSync(journal.stage_dir, { recursive: true, force: true });
|
|
807
|
-
}
|
|
808
|
-
updateRollbackJournal(journalPath, {
|
|
809
|
-
phase: 'complete',
|
|
810
|
-
cleanup_error: null,
|
|
811
|
-
completed_at: new Date().toISOString(),
|
|
812
|
-
});
|
|
813
|
-
recoveries.push({
|
|
814
|
-
recovery_dir: recoveryDir,
|
|
815
|
-
phase: 'complete',
|
|
816
|
-
resumed: true,
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
return {
|
|
820
|
-
resumed: recoveries.filter((entry) => entry.resumed).length,
|
|
821
|
-
pending,
|
|
822
|
-
recoveries,
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function sqliteFile(candidate) {
|
|
827
|
-
const stat = lstatOrNull(candidate);
|
|
828
|
-
if (!stat || stat.isSymbolicLink() || !stat.isFile()) return false;
|
|
829
|
-
const descriptor = fs.openSync(candidate, 'r');
|
|
830
|
-
try {
|
|
831
|
-
const header = Buffer.alloc(16);
|
|
832
|
-
return fs.readSync(descriptor, header, 0, header.length, 0) === header.length
|
|
833
|
-
&& header.equals(Buffer.from('SQLite format 3\0'));
|
|
834
|
-
} finally {
|
|
835
|
-
fs.closeSync(descriptor);
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function quoteSqlString(value) {
|
|
840
|
-
return `'${String(value).replaceAll("'", "''")}'`;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function immutableStateSnapshot(resolvedDb, previousState, {
|
|
844
|
-
onSnapshot = () => {},
|
|
845
|
-
afterVacuumBeforeFence = () => {},
|
|
846
|
-
maxAttempts = 5,
|
|
847
|
-
} = {}) {
|
|
848
|
-
const copied = [];
|
|
849
|
-
const main = lstatOrNull(resolvedDb);
|
|
850
|
-
if (!main) {
|
|
851
|
-
onSnapshot([], { source_data_version: null, snapshot_attempts: 0 });
|
|
852
|
-
return copied;
|
|
853
|
-
}
|
|
854
|
-
if (main.isSymbolicLink() || !main.isFile()) {
|
|
855
|
-
throw new Error(`migrate: current state entry is unsafe: ${resolvedDb}`);
|
|
856
|
-
}
|
|
857
|
-
let liveDb = null;
|
|
858
|
-
let exclusive = false;
|
|
859
|
-
let sourceDataVersion = null;
|
|
860
|
-
let snapshotAttempts = 0;
|
|
861
|
-
try {
|
|
862
|
-
const snapshot = path.join(previousState, 'state.db');
|
|
863
|
-
if (sqliteFile(resolvedDb)) {
|
|
864
|
-
liveDb = new Database(resolvedDb, { fileMustExist: true });
|
|
865
|
-
liveDb.pragma('busy_timeout = 0');
|
|
866
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
867
|
-
snapshotAttempts = attempt;
|
|
868
|
-
const before = Number(liveDb.pragma('data_version', { simple: true }));
|
|
869
|
-
fs.rmSync(snapshot, { force: true });
|
|
870
|
-
// VACUUM cannot run inside the exclusive transaction. Bind its read
|
|
871
|
-
// image to the later writer fence with data_version: if any other
|
|
872
|
-
// connection commits in this gap, discard the candidate and retry.
|
|
873
|
-
liveDb.exec(`VACUUM INTO ${quoteSqlString(snapshot)}`);
|
|
874
|
-
afterVacuumBeforeFence({ attempt, source_data_version: before });
|
|
875
|
-
liveDb.exec('BEGIN EXCLUSIVE');
|
|
876
|
-
exclusive = true;
|
|
877
|
-
const fenced = Number(liveDb.pragma('data_version', { simple: true }));
|
|
878
|
-
if (fenced === before) {
|
|
879
|
-
sourceDataVersion = fenced;
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
liveDb.exec('ROLLBACK');
|
|
883
|
-
exclusive = false;
|
|
884
|
-
fs.rmSync(snapshot, { force: true });
|
|
885
|
-
}
|
|
886
|
-
if (!exclusive) {
|
|
887
|
-
throw new Error(
|
|
888
|
-
`migrate: current state changed during ${maxAttempts} snapshot attempts`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
} else {
|
|
892
|
-
throw new Error(`migrate: current state is not a SQLite database: ${resolvedDb}`);
|
|
893
|
-
}
|
|
894
|
-
const verification = new Database(snapshot, { readonly: true, fileMustExist: true });
|
|
895
|
-
try {
|
|
896
|
-
const integrity = verification.pragma('integrity_check');
|
|
897
|
-
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') {
|
|
898
|
-
throw new Error('migrate: current state recovery snapshot failed integrity_check');
|
|
899
|
-
}
|
|
900
|
-
} finally {
|
|
901
|
-
verification.close();
|
|
902
|
-
}
|
|
903
|
-
fs.chmodSync(snapshot, 0o444);
|
|
904
|
-
copied.push({ current: resolvedDb, prior: snapshot, kind: 'sqlite' });
|
|
905
|
-
const inventory = regularTreeInventory(previousState);
|
|
906
|
-
onSnapshot(inventory, {
|
|
907
|
-
source_data_version: sourceDataVersion,
|
|
908
|
-
snapshot_attempts: snapshotAttempts,
|
|
909
|
-
});
|
|
910
|
-
for (const suffix of ['-wal', '-shm', '']) {
|
|
911
|
-
fs.rmSync(`${resolvedDb}${suffix}`, { force: true });
|
|
912
|
-
}
|
|
913
|
-
if (exclusive) {
|
|
914
|
-
try { liveDb.exec('ROLLBACK'); } catch { /* the unlinked inode is already fenced */ }
|
|
915
|
-
exclusive = false;
|
|
916
|
-
}
|
|
917
|
-
} finally {
|
|
918
|
-
if (liveDb) {
|
|
919
|
-
if (exclusive) {
|
|
920
|
-
try { liveDb.exec('ROLLBACK'); } catch { /* best effort */ }
|
|
921
|
-
}
|
|
922
|
-
liveDb.close();
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
return copied;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
function rollbackFromBackupLocked({
|
|
929
|
-
sourceDir,
|
|
930
|
-
dbPath,
|
|
931
|
-
backupDir,
|
|
932
|
-
fromVersion,
|
|
933
|
-
toVersion,
|
|
934
|
-
}, io = {}) {
|
|
935
|
-
const copyFileSync = io.copyFileSync || fs.copyFileSync;
|
|
936
|
-
const renameSync = io.renameSync || fs.renameSync;
|
|
937
|
-
const rmSync = io.rmSync || fs.rmSync;
|
|
938
|
-
const afterPublish = io.afterPublish || (() => {});
|
|
939
|
-
const backupAdmission = assertRollbackBackup(sourceDir, backupDir, fromVersion);
|
|
940
|
-
const paths = backupAdmission.paths;
|
|
941
|
-
const resolvedDb = path.resolve(dbPath);
|
|
942
|
-
if (![paths.stateDbPath, paths.legacyStateDbPath].includes(resolvedDb)) {
|
|
943
|
-
throw new Error(
|
|
944
|
-
`migrate: rollback state.db must be project-owned: ${resolvedDb}`,
|
|
945
|
-
);
|
|
946
|
-
}
|
|
947
|
-
assertCurrentSemanticTree(paths.ultraDir);
|
|
948
|
-
|
|
949
|
-
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
950
|
-
const stageRoot = path.join(paths.runtimeDir, `.rollback-stage-${token}`);
|
|
951
|
-
const restoreRoot = path.join(stageRoot, 'restore');
|
|
952
|
-
const recoveryDir = path.join(rollbackRecoveryBase(paths), token);
|
|
953
|
-
const previousSemantic = path.join(recoveryDir, 'previous-semantic');
|
|
954
|
-
const previousState = path.join(recoveryDir, 'previous-state');
|
|
955
|
-
const journalPath = path.join(recoveryDir, 'journal.json');
|
|
956
|
-
const movedCurrent = [];
|
|
957
|
-
const installed = [];
|
|
958
|
-
const movedState = [];
|
|
959
|
-
const cleanupStage = () => {
|
|
960
|
-
rmSync(stageRoot, { recursive: true, force: true });
|
|
961
|
-
};
|
|
962
|
-
let published = false;
|
|
963
|
-
try {
|
|
964
|
-
if (!lstatOrNull(paths.runtimeDir)) {
|
|
965
|
-
fs.mkdirSync(paths.runtimeDir);
|
|
966
|
-
}
|
|
967
|
-
fs.mkdirSync(stageRoot, { recursive: false });
|
|
968
|
-
|
|
969
|
-
// Complete and validate the restore image before moving any current file.
|
|
970
|
-
copyDirSync(backupDir, restoreRoot, { copyFileSync });
|
|
971
|
-
if (!lstatOrNull(path.join(restoreRoot, BACKUP_MANIFEST))) {
|
|
972
|
-
writeJsonAtomic(path.join(restoreRoot, BACKUP_MANIFEST), backupAdmission.manifest);
|
|
973
|
-
}
|
|
974
|
-
const stagedAdmission = assertRollbackBackup(
|
|
975
|
-
sourceDir,
|
|
976
|
-
restoreRoot,
|
|
977
|
-
fromVersion,
|
|
978
|
-
{ staged: true },
|
|
979
|
-
);
|
|
980
|
-
if (stagedAdmission.manifestDigest !== backupAdmission.manifestDigest
|
|
981
|
-
|| stagedAdmission.inventoryDigest !== backupAdmission.inventoryDigest) {
|
|
982
|
-
throw new Error('migrate: staged rollback image does not match the admitted backup manifest');
|
|
983
|
-
}
|
|
984
|
-
fs.mkdirSync(recoveryDir, { recursive: true });
|
|
985
|
-
fs.mkdirSync(previousSemantic);
|
|
986
|
-
fs.mkdirSync(previousState);
|
|
987
|
-
const recoveryManifestPath = path.join(recoveryDir, BACKUP_MANIFEST);
|
|
988
|
-
writeJsonAtomic(recoveryManifestPath, stagedAdmission.manifest);
|
|
989
|
-
const now = new Date().toISOString();
|
|
990
|
-
let preparedJournal = {
|
|
991
|
-
version: 1,
|
|
992
|
-
source_dir: path.resolve(sourceDir),
|
|
993
|
-
from_version: fromVersion,
|
|
994
|
-
to_version: toVersion,
|
|
995
|
-
backup_dir: path.resolve(backupDir),
|
|
996
|
-
backup_manifest_path: recoveryManifestPath,
|
|
997
|
-
backup_manifest_sha256: stagedAdmission.manifestDigest,
|
|
998
|
-
backup_inventory_sha256: stagedAdmission.inventoryDigest,
|
|
999
|
-
stage_dir: stageRoot,
|
|
1000
|
-
recovery_dir: recoveryDir,
|
|
1001
|
-
phase: 'prepared',
|
|
1002
|
-
cleanup_error: null,
|
|
1003
|
-
created_at: now,
|
|
1004
|
-
updated_at: now,
|
|
1005
|
-
};
|
|
1006
|
-
writeJsonAtomic(journalPath, preparedJournal);
|
|
1007
|
-
|
|
1008
|
-
if (runtimePaths.isManagedLegacyStateTombstone?.(paths.legacyStateDbPath)) {
|
|
1009
|
-
const prior = path.join(previousState, 'legacy-state-tombstone');
|
|
1010
|
-
copyFileSync(paths.legacyStateDbPath, prior);
|
|
1011
|
-
fs.chmodSync(prior, 0o444);
|
|
1012
|
-
fs.rmSync(paths.legacyStateDbPath);
|
|
1013
|
-
movedState.push({
|
|
1014
|
-
current: paths.legacyStateDbPath,
|
|
1015
|
-
prior,
|
|
1016
|
-
kind: 'tombstone',
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
movedState.push(...immutableStateSnapshot(resolvedDb, previousState, {
|
|
1021
|
-
afterVacuumBeforeFence: io.afterStateSnapshotBeforeFence,
|
|
1022
|
-
onSnapshot(inventory, snapshotEvidence) {
|
|
1023
|
-
preparedJournal = updateRollbackJournal(journalPath, {
|
|
1024
|
-
previous_state_inventory: inventory,
|
|
1025
|
-
previous_state_inventory_sha256: jsonDigest(inventory),
|
|
1026
|
-
previous_state_data_version: snapshotEvidence.source_data_version,
|
|
1027
|
-
previous_state_snapshot_attempts: snapshotEvidence.snapshot_attempts,
|
|
1028
|
-
});
|
|
1029
|
-
},
|
|
1030
|
-
}));
|
|
1031
|
-
|
|
1032
|
-
for (const entry of fs.readdirSync(paths.ultraDir, { withFileTypes: true })) {
|
|
1033
|
-
if (entry.name === '.runtime'
|
|
1034
|
-
|| entry.name === 'state.db'
|
|
1035
|
-
|| entry.name === 'state.db-wal'
|
|
1036
|
-
|| entry.name === 'state.db-shm'
|
|
1037
|
-
|| path.resolve(paths.ultraDir, entry.name) === path.resolve(backupDir)) {
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
const current = path.join(paths.ultraDir, entry.name);
|
|
1041
|
-
const prior = path.join(previousSemantic, entry.name);
|
|
1042
|
-
renameSync(current, prior);
|
|
1043
|
-
movedCurrent.push({ current, prior });
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
for (const entry of fs.readdirSync(restoreRoot)) {
|
|
1047
|
-
if (entry === BACKUP_MANIFEST) continue;
|
|
1048
|
-
const staged = path.join(restoreRoot, entry);
|
|
1049
|
-
const target = path.join(paths.ultraDir, entry);
|
|
1050
|
-
renameSync(staged, target);
|
|
1051
|
-
installed.push({ staged, target });
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
assertInstalledRollback(sourceDir, paths, preparedJournal);
|
|
1055
|
-
updateRollbackJournal(journalPath, {
|
|
1056
|
-
phase: 'installed',
|
|
1057
|
-
installed_at: new Date().toISOString(),
|
|
1058
|
-
});
|
|
1059
|
-
published = true;
|
|
1060
|
-
// This seam also models a process interruption after the rollback is
|
|
1061
|
-
// authoritative but before disposable staging cleanup begins. Recovery
|
|
1062
|
-
// must trust the installed tree plus the independent journal, never the
|
|
1063
|
-
// potentially partial staging directory.
|
|
1064
|
-
afterPublish();
|
|
1065
|
-
} catch (error) {
|
|
1066
|
-
if (published) throw error;
|
|
1067
|
-
const rollbackErrors = [];
|
|
1068
|
-
for (const item of [...installed].reverse()) {
|
|
1069
|
-
try {
|
|
1070
|
-
fs.mkdirSync(path.dirname(item.staged), { recursive: true });
|
|
1071
|
-
renameSync(item.target, item.staged);
|
|
1072
|
-
} catch (rollbackError) {
|
|
1073
|
-
rollbackErrors.push(`${item.target}: ${rollbackError.message}`);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
for (const item of [...movedCurrent].reverse()) {
|
|
1077
|
-
try {
|
|
1078
|
-
fs.mkdirSync(path.dirname(item.current), { recursive: true });
|
|
1079
|
-
renameSync(item.prior, item.current);
|
|
1080
|
-
} catch (rollbackError) {
|
|
1081
|
-
rollbackErrors.push(`${item.prior}: ${rollbackError.message}`);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
for (const item of [...movedState].reverse()) {
|
|
1085
|
-
try {
|
|
1086
|
-
fs.mkdirSync(path.dirname(item.current), { recursive: true });
|
|
1087
|
-
copyFileSync(item.prior, item.current);
|
|
1088
|
-
fs.chmodSync(item.current, item.kind === 'tombstone' ? 0o444 : 0o600);
|
|
1089
|
-
} catch (rollbackError) {
|
|
1090
|
-
rollbackErrors.push(`${item.prior}: ${rollbackError.message}`);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (rollbackErrors.length === 0) {
|
|
1094
|
-
try { cleanupStage(); }
|
|
1095
|
-
catch (cleanupError) {
|
|
1096
|
-
rollbackErrors.push(`${stageRoot}: ${cleanupError.message}`);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
if (rollbackErrors.length > 0) {
|
|
1100
|
-
if (lstatOrNull(journalPath)) {
|
|
1101
|
-
try {
|
|
1102
|
-
updateRollbackJournal(journalPath, {
|
|
1103
|
-
phase: 'recovery_failed',
|
|
1104
|
-
rollback_error: rollbackErrors.join('; '),
|
|
1105
|
-
});
|
|
1106
|
-
} catch { /* preserve original rollback evidence */ }
|
|
1107
|
-
}
|
|
1108
|
-
throw new Error(
|
|
1109
|
-
`${error.message}; rollback restore failed: ${rollbackErrors.join('; ')}`,
|
|
1110
|
-
{ cause: error },
|
|
1111
|
-
);
|
|
1112
|
-
}
|
|
1113
|
-
if (lstatOrNull(journalPath)) {
|
|
1114
|
-
updateRollbackJournal(journalPath, {
|
|
1115
|
-
phase: 'rolled_back',
|
|
1116
|
-
rollback_error: null,
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
throw error;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
let cleanupError = null;
|
|
1123
|
-
try {
|
|
1124
|
-
cleanupStage();
|
|
1125
|
-
} catch (error) {
|
|
1126
|
-
cleanupError = error;
|
|
1127
|
-
updateRollbackJournal(journalPath, {
|
|
1128
|
-
phase: 'cleanup_pending',
|
|
1129
|
-
cleanup_error: error.message,
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
if (!cleanupError) {
|
|
1133
|
-
updateRollbackJournal(journalPath, {
|
|
1134
|
-
phase: 'complete',
|
|
1135
|
-
cleanup_error: null,
|
|
1136
|
-
completed_at: new Date().toISOString(),
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
return {
|
|
1140
|
-
mode: 'rollback',
|
|
1141
|
-
from: toVersion,
|
|
1142
|
-
to: fromVersion,
|
|
1143
|
-
backup_dir: backupDir,
|
|
1144
|
-
source_dir: sourceDir,
|
|
1145
|
-
recovery_dir: recoveryDir,
|
|
1146
|
-
journal_path: journalPath,
|
|
1147
|
-
stage_dir: stageRoot,
|
|
1148
|
-
cleanup_pending: Boolean(cleanupError),
|
|
1149
|
-
...(cleanupError ? { cleanup_error: cleanupError.message } : {}),
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
function rollbackFromBackup(input, io = {}) {
|
|
1154
|
-
const paths = runtimePaths.validateProjectLayout(input.sourceDir, {
|
|
1155
|
-
env: {},
|
|
1156
|
-
forMutation: true,
|
|
1157
|
-
validateRuntimeTree: true,
|
|
1158
|
-
});
|
|
1159
|
-
fs.mkdirSync(paths.runtimeDir, { recursive: true });
|
|
1160
|
-
const releaseGate = runtimePaths._internal.acquireStateMigrationGate(paths);
|
|
1161
|
-
try {
|
|
1162
|
-
return rollbackFromBackupLocked(input, io);
|
|
1163
|
-
} finally {
|
|
1164
|
-
releaseGate();
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
function cmdForward(flags) {
|
|
1169
|
-
const sourceDir = path.resolve(flags.sourceDir || '.');
|
|
1170
|
-
const fromVersion = flags.from || DEFAULT_FROM;
|
|
1171
|
-
const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
|
|
1172
|
-
let plan;
|
|
1173
|
-
let dbPath;
|
|
1174
|
-
try {
|
|
1175
|
-
const paths = runtimePaths.validateProjectLayout(sourceDir, {
|
|
1176
|
-
env: {},
|
|
1177
|
-
validateRuntimeTree: true,
|
|
1178
|
-
});
|
|
1179
|
-
dbPath = flags.dbPath ? path.resolve(flags.dbPath) : paths.stateDbPath;
|
|
1180
|
-
if (dbPath !== paths.stateDbPath) {
|
|
1181
|
-
throw new Error(
|
|
1182
|
-
`migrate: state.db destination must be the project canonical authority: ${paths.stateDbPath}`,
|
|
1183
|
-
);
|
|
1184
|
-
}
|
|
1185
|
-
assertCurrentSemanticTree(paths.ultraDir);
|
|
1186
|
-
// Validate the live source before creating any backup. Apply mode discards
|
|
1187
|
-
// this provisional plan and rebuilds it from the immutable backup image.
|
|
1188
|
-
plan = planForward(sourceDir, fromVersion);
|
|
1189
|
-
} catch (err) {
|
|
1190
|
-
emit({ ok: false, error: { code: 'MIGRATE_FAILED', message: err.message, retriable: false } });
|
|
1191
|
-
return 2;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
if (flags.dry) {
|
|
1195
|
-
emit({
|
|
1196
|
-
ok: true,
|
|
1197
|
-
data: {
|
|
1198
|
-
mode: 'dry',
|
|
1199
|
-
from: fromVersion,
|
|
1200
|
-
to: toVersion,
|
|
1201
|
-
source_dir: sourceDir,
|
|
1202
|
-
db_path: dbPath,
|
|
1203
|
-
tasks_to_insert: plan.tasks.length,
|
|
1204
|
-
events_to_insert: plan.events.length,
|
|
1205
|
-
warnings: plan.warnings,
|
|
1206
|
-
},
|
|
1207
|
-
});
|
|
1208
|
-
return 0;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
let db;
|
|
1212
|
-
let backupDir;
|
|
1213
|
-
try {
|
|
1214
|
-
// Validate and publish the complete projection snapshot before authority
|
|
1215
|
-
// migration can relocate any legacy DB or mutable runtime artifact.
|
|
1216
|
-
const prepared = prepareForwardSnapshot(sourceDir, fromVersion);
|
|
1217
|
-
backupDir = prepared.backupDir;
|
|
1218
|
-
plan = prepared.plan;
|
|
1219
|
-
dbPath = runtimePaths.ensureRuntimeState(sourceDir).stateDbPath;
|
|
1220
|
-
db = initStateDb(dbPath).db;
|
|
1221
|
-
const counts = ops.tx(db, () => {
|
|
1222
|
-
const existing = db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count;
|
|
1223
|
-
if (existing > 0) {
|
|
1224
|
-
throw new Error(`migrate: refusing to merge into non-empty state.db (tasks=${existing})`);
|
|
1225
|
-
}
|
|
1226
|
-
const inserted = applyForward(db, plan);
|
|
1227
|
-
db.prepare(
|
|
1228
|
-
`INSERT INTO baselines
|
|
1229
|
-
(id, project_name, mode, status, gaps_json, approval_note)
|
|
1230
|
-
VALUES ('migrated-baseline', ?, 'migrated', 'adopting', ?, ?)`,
|
|
1231
|
-
).run(
|
|
1232
|
-
plan.projectName,
|
|
1233
|
-
JSON.stringify(MIGRATED_GAPS),
|
|
1234
|
-
`Legacy v${fromVersion} projection imported; evidence-backed owner re-adoption is required`,
|
|
1235
|
-
);
|
|
1236
|
-
recordMigration(db, {
|
|
1237
|
-
from: fromVersion,
|
|
1238
|
-
to: toVersion,
|
|
1239
|
-
direction: 'forward',
|
|
1240
|
-
status: 'success',
|
|
1241
|
-
notes: `tasks=${inserted.taskInserted} events=${inserted.eventsInserted} warnings=${plan.warnings.length}`,
|
|
1242
|
-
});
|
|
1243
|
-
return inserted;
|
|
1244
|
-
});
|
|
1245
|
-
projector.projectAll(db, { rootDir: sourceDir });
|
|
1246
|
-
const contextTemplateSanitized = sanitizeLegacyContextTemplate(sourceDir);
|
|
1247
|
-
emit({
|
|
1248
|
-
ok: true,
|
|
1249
|
-
data: {
|
|
1250
|
-
mode: 'apply',
|
|
1251
|
-
from: fromVersion,
|
|
1252
|
-
to: toVersion,
|
|
1253
|
-
source_dir: sourceDir,
|
|
1254
|
-
db_path: dbPath,
|
|
1255
|
-
backup_dir: backupDir,
|
|
1256
|
-
tasks_inserted: counts.taskInserted,
|
|
1257
|
-
events_inserted: counts.eventsInserted,
|
|
1258
|
-
warnings: plan.warnings,
|
|
1259
|
-
context_template_sanitized: contextTemplateSanitized,
|
|
1260
|
-
},
|
|
1261
|
-
});
|
|
1262
|
-
return 0;
|
|
1263
|
-
} catch (err) {
|
|
1264
|
-
if (db) {
|
|
1265
|
-
try {
|
|
1266
|
-
recordMigration(db, {
|
|
1267
|
-
from: fromVersion,
|
|
1268
|
-
to: toVersion,
|
|
1269
|
-
direction: 'forward',
|
|
1270
|
-
status: 'failed',
|
|
1271
|
-
notes: err.message,
|
|
1272
|
-
});
|
|
1273
|
-
} catch (_) { /* swallow secondary failure */ }
|
|
1274
|
-
}
|
|
1275
|
-
emit({ ok: false, error: { code: 'MIGRATE_FAILED', message: err.message, retriable: false } });
|
|
1276
|
-
return 2;
|
|
1277
|
-
} finally {
|
|
1278
|
-
if (db) closeStateDb(db);
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
function cmdRollback(flags) {
|
|
1283
|
-
const sourceDir = path.resolve(flags.sourceDir || '.');
|
|
1284
|
-
const fromVersion = flags.from || DEFAULT_FROM;
|
|
1285
|
-
const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
|
|
1286
|
-
try {
|
|
1287
|
-
const resumed = resumeRollbackRecovery(sourceDir);
|
|
1288
|
-
if (resumed.pending > 0) {
|
|
1289
|
-
throw new Error(
|
|
1290
|
-
`migrate: ${resumed.pending} rollback recovery transaction(s) require manual recovery`,
|
|
1291
|
-
);
|
|
1292
|
-
}
|
|
1293
|
-
if (resumed.resumed > 0) {
|
|
1294
|
-
emit({
|
|
1295
|
-
ok: true,
|
|
1296
|
-
data: {
|
|
1297
|
-
mode: 'rollback-resumed',
|
|
1298
|
-
from: toVersion,
|
|
1299
|
-
to: fromVersion,
|
|
1300
|
-
source_dir: sourceDir,
|
|
1301
|
-
...resumed,
|
|
1302
|
-
},
|
|
1303
|
-
});
|
|
1304
|
-
return 0;
|
|
1305
|
-
}
|
|
1306
|
-
const dbPath = flags.dbPath
|
|
1307
|
-
? path.resolve(flags.dbPath)
|
|
1308
|
-
: runtimePaths.locateStateDb(sourceDir, { env: {} });
|
|
1309
|
-
const backupDir = findLatestBackup(sourceDir, fromVersion);
|
|
1310
|
-
if (!backupDir) {
|
|
1311
|
-
emit({
|
|
1312
|
-
ok: false,
|
|
1313
|
-
error: {
|
|
1314
|
-
code: 'NO_BACKUP',
|
|
1315
|
-
message: `no projection-v${fromVersion}-* or backup-v${fromVersion}-* directory found`,
|
|
1316
|
-
},
|
|
1317
|
-
});
|
|
1318
|
-
return 2;
|
|
1319
|
-
}
|
|
1320
|
-
emit({
|
|
1321
|
-
ok: true,
|
|
1322
|
-
data: rollbackFromBackup({
|
|
1323
|
-
sourceDir,
|
|
1324
|
-
dbPath,
|
|
1325
|
-
backupDir,
|
|
1326
|
-
fromVersion,
|
|
1327
|
-
toVersion,
|
|
1328
|
-
}),
|
|
1329
|
-
});
|
|
1330
|
-
return 0;
|
|
1331
|
-
} catch (err) {
|
|
1332
|
-
emit({ ok: false, error: { code: 'ROLLBACK_FAILED', message: err.message, retriable: false } });
|
|
1333
|
-
return 2;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
function dispatch(args) {
|
|
1338
|
-
const flags = parseFlags(args);
|
|
1339
|
-
if (flags.help) {
|
|
1340
|
-
process.stdout.write(USAGE);
|
|
1341
|
-
return 0;
|
|
1342
|
-
}
|
|
1343
|
-
const fromVersion = flags.from || DEFAULT_FROM;
|
|
1344
|
-
const expectedTo = SUPPORTED_TRANSITIONS[fromVersion];
|
|
1345
|
-
if (!expectedTo) {
|
|
1346
|
-
emit({ ok: false, error: { code: 'UNSUPPORTED_VERSION', message: `--from ${fromVersion} unsupported (supported: ${Object.keys(SUPPORTED_TRANSITIONS).join(', ')})` } });
|
|
1347
|
-
return 1;
|
|
1348
|
-
}
|
|
1349
|
-
if (flags.to && flags.to !== expectedTo) {
|
|
1350
|
-
emit({ ok: false, error: { code: 'UNSUPPORTED_VERSION', message: `--to ${flags.to} unsupported for --from ${fromVersion} (expected ${expectedTo})` } });
|
|
1351
|
-
return 1;
|
|
1352
|
-
}
|
|
1353
|
-
return flags.rollback ? cmdRollback(flags) : cmdForward(flags);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
const USAGE = `ultra-tools migrate --from=<version> --to=<version> [flags]
|
|
1357
|
-
|
|
1358
|
-
Supported transitions:
|
|
1359
|
-
4.4 -> 4.5 import the legacy task projection into authoritative state
|
|
1360
|
-
4.5 -> ${EXPECTED_VERSION} import a projection-only project into current authoritative state
|
|
1361
|
-
|
|
1362
|
-
Flags:
|
|
1363
|
-
--source-dir <dir> project root containing .ultra/ (default: .)
|
|
1364
|
-
--db-path <path> state.db destination (default: <source-dir>/.ultra/.runtime/state.db)
|
|
1365
|
-
--dry print the migration plan without writing
|
|
1366
|
-
--rollback restore the most recent matching backup-v<from>-* and drop state.db
|
|
1367
|
-
|
|
1368
|
-
The forward flow: backup .ultra/ → init state.db → insert tasks from
|
|
1369
|
-
tasks.json → merge context md status (tasks.json wins on conflict, warnings
|
|
1370
|
-
recorded) → insert activity-log events → record migration_history.
|
|
1371
|
-
Rollback restores from the latest matching backup-v<from>-* directory through a
|
|
1372
|
-
prevalidated staged image, then removes the newer state.db only as part of the
|
|
1373
|
-
rollback transaction.
|
|
1374
|
-
`;
|
|
1375
|
-
|
|
1376
|
-
module.exports = {
|
|
1377
|
-
dispatch,
|
|
1378
|
-
USAGE,
|
|
1379
|
-
parseFlags,
|
|
1380
|
-
planForward,
|
|
1381
|
-
prepareForwardSnapshot,
|
|
1382
|
-
parseFrontmatter,
|
|
1383
|
-
sanitizeLegacyContextTemplate,
|
|
1384
|
-
rollbackFromBackup,
|
|
1385
|
-
resumeRollbackRecovery,
|
|
1386
|
-
};
|