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,1119 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const crypto = require('node:crypto');
|
|
6
|
-
|
|
7
|
-
const { readStableProjectFile } = require('./safe-project-file.cjs');
|
|
8
|
-
|
|
9
|
-
const INTENT_FILE = '.archive-intent.json';
|
|
10
|
-
const REBIND_FILE = '.archive-rebind.json';
|
|
11
|
-
const INTENT_VERSION = '2.0';
|
|
12
|
-
const REBIND_VERSION = '1.0';
|
|
13
|
-
const MUTATION_WORKER = path.join(__dirname, 'archive-mutation-worker.py');
|
|
14
|
-
const MAX_MUTATION_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
15
|
-
const MUTATION_PIN_FIELDS = Object.freeze({
|
|
16
|
-
mkdir_dir: Object.freeze(['directory_fd']),
|
|
17
|
-
write_atomic: Object.freeze(['directory_fd']),
|
|
18
|
-
write_rebind_atomic: Object.freeze(['directory_fd']),
|
|
19
|
-
unlink_regular: Object.freeze(['directory_fd']),
|
|
20
|
-
rename_dir: Object.freeze([
|
|
21
|
-
'source_parent_fd',
|
|
22
|
-
'destination_parent_fd',
|
|
23
|
-
'guard_parent_fd',
|
|
24
|
-
]),
|
|
25
|
-
});
|
|
26
|
-
const ACTIVE_ROOT = '.ultra/changes/active';
|
|
27
|
-
const ARCHIVE_ROOT = '.ultra/changes/archive';
|
|
28
|
-
const CORE_DIRECTORIES = Object.freeze([
|
|
29
|
-
['project', '.'],
|
|
30
|
-
['ultra', '.ultra'],
|
|
31
|
-
['changes', '.ultra/changes'],
|
|
32
|
-
['active', ACTIVE_ROOT],
|
|
33
|
-
['archive', ARCHIVE_ROOT],
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
class ArchiveJournalError extends Error {
|
|
37
|
-
constructor(code, message, details = undefined) {
|
|
38
|
-
super(message);
|
|
39
|
-
this.name = 'ArchiveJournalError';
|
|
40
|
-
this.code = code;
|
|
41
|
-
if (details !== undefined) this.details = details;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function unsafe(message, details = undefined) {
|
|
46
|
-
throw new ArchiveJournalError('ARCHIVE_PATH_UNSAFE', message, details);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function identity(stat) {
|
|
50
|
-
return {
|
|
51
|
-
dev: String(stat.dev),
|
|
52
|
-
ino: String(stat.ino),
|
|
53
|
-
mode: String(stat.mode),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function sameIdentity(left, right) {
|
|
58
|
-
return Boolean(left && right)
|
|
59
|
-
&& left.dev === right.dev
|
|
60
|
-
&& left.ino === right.ino
|
|
61
|
-
&& left.mode === right.mode;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function normalizeRelative(value, field) {
|
|
65
|
-
if (typeof value !== 'string' || !value.trim()) {
|
|
66
|
-
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} must be project-relative`);
|
|
67
|
-
}
|
|
68
|
-
const raw = value.trim().replaceAll('\\', '/');
|
|
69
|
-
if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) {
|
|
70
|
-
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} must be project-relative`);
|
|
71
|
-
}
|
|
72
|
-
const relative = path.posix.normalize(raw).replace(/^\.\//, '');
|
|
73
|
-
if (relative === '.' || relative === '..' || relative.startsWith('../')) {
|
|
74
|
-
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} escapes project root`);
|
|
75
|
-
}
|
|
76
|
-
return relative;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function inspectProject(rootDir) {
|
|
80
|
-
const root = path.resolve(rootDir);
|
|
81
|
-
let stat;
|
|
82
|
-
try { stat = fs.lstatSync(root); } catch (cause) {
|
|
83
|
-
unsafe(`project root is unavailable: ${root}`, { cause: cause.message });
|
|
84
|
-
}
|
|
85
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
86
|
-
unsafe(`project root must be a real directory: ${root}`);
|
|
87
|
-
}
|
|
88
|
-
let physicalRoot;
|
|
89
|
-
try { physicalRoot = fs.realpathSync.native(root); } catch (cause) {
|
|
90
|
-
unsafe(`project root cannot be resolved: ${root}`, { cause: cause.message });
|
|
91
|
-
}
|
|
92
|
-
return { root, physicalRoot, rootIdentity: identity(stat) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function absoluteInside(project, relative) {
|
|
96
|
-
const normalized = relative === '.' ? '.' : normalizeRelative(relative, 'path');
|
|
97
|
-
if (normalized === '.') return project.physicalRoot;
|
|
98
|
-
return path.join(project.physicalRoot, ...normalized.split('/'));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function inspectDirectory(project, relative, { missing = false } = {}) {
|
|
102
|
-
const normalized = relative === '.' ? '.' : normalizeRelative(relative, 'directory');
|
|
103
|
-
const components = normalized === '.' ? [] : normalized.split('/');
|
|
104
|
-
let current = project.physicalRoot;
|
|
105
|
-
let currentIdentity = project.rootIdentity;
|
|
106
|
-
for (let index = 0; index < components.length; index += 1) {
|
|
107
|
-
current = path.join(current, components[index]);
|
|
108
|
-
let stat;
|
|
109
|
-
try { stat = fs.lstatSync(current); } catch (cause) {
|
|
110
|
-
if (missing && cause?.code === 'ENOENT') return null;
|
|
111
|
-
unsafe(`archive directory is unavailable: ${normalized}`, {
|
|
112
|
-
component: components.slice(0, index + 1).join('/'),
|
|
113
|
-
cause: cause.message,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
117
|
-
unsafe(`archive directory must not contain links or non-directories: ${normalized}`, {
|
|
118
|
-
component: components.slice(0, index + 1).join('/'),
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
currentIdentity = identity(stat);
|
|
122
|
-
}
|
|
123
|
-
return { relative: normalized, path: current, identity: currentIdentity };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function openDirectoryPin(project, relative, { missing = false } = {}) {
|
|
127
|
-
const inspected = inspectDirectory(project, relative, { missing });
|
|
128
|
-
if (!inspected) return null;
|
|
129
|
-
let fd;
|
|
130
|
-
try {
|
|
131
|
-
fd = fs.openSync(
|
|
132
|
-
inspected.path,
|
|
133
|
-
fs.constants.O_RDONLY
|
|
134
|
-
| (fs.constants.O_DIRECTORY || 0)
|
|
135
|
-
| (fs.constants.O_NOFOLLOW || 0),
|
|
136
|
-
);
|
|
137
|
-
const opened = fs.fstatSync(fd);
|
|
138
|
-
if (!opened.isDirectory() || !sameIdentity(identity(opened), inspected.identity)) {
|
|
139
|
-
unsafe(`archive directory changed while it was pinned: ${inspected.relative}`);
|
|
140
|
-
}
|
|
141
|
-
} catch (error) {
|
|
142
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
143
|
-
if (error instanceof ArchiveJournalError) throw error;
|
|
144
|
-
unsafe(`archive directory cannot be pinned: ${inspected.relative}`, {
|
|
145
|
-
cause: error.message,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
...inspected,
|
|
150
|
-
fd,
|
|
151
|
-
verify() {
|
|
152
|
-
let named;
|
|
153
|
-
let opened;
|
|
154
|
-
try {
|
|
155
|
-
named = fs.lstatSync(inspected.path);
|
|
156
|
-
opened = fs.fstatSync(fd);
|
|
157
|
-
} catch (cause) {
|
|
158
|
-
unsafe(`archive directory pin is no longer valid: ${inspected.relative}`, {
|
|
159
|
-
cause: cause.message,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
if (named.isSymbolicLink() || !named.isDirectory() || !opened.isDirectory()
|
|
163
|
-
|| !sameIdentity(identity(named), inspected.identity)
|
|
164
|
-
|| !sameIdentity(identity(opened), inspected.identity)) {
|
|
165
|
-
unsafe(`archive directory changed identity: ${inspected.relative}`);
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
verifyFd() {
|
|
169
|
-
let opened;
|
|
170
|
-
try { opened = fs.fstatSync(fd); } catch (cause) {
|
|
171
|
-
unsafe(`archive directory descriptor is unavailable: ${inspected.relative}`, {
|
|
172
|
-
cause: cause.message,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
if (!opened.isDirectory() || !sameIdentity(identity(opened), inspected.identity)) {
|
|
176
|
-
unsafe(`archive directory descriptor changed identity: ${inspected.relative}`);
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
close() {
|
|
180
|
-
if (fd !== undefined) {
|
|
181
|
-
fs.closeSync(fd);
|
|
182
|
-
fd = undefined;
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function inspectChild(parent, name, { allowMissing = false } = {}) {
|
|
189
|
-
parent.verify();
|
|
190
|
-
const child = path.join(parent.path, name);
|
|
191
|
-
let stat;
|
|
192
|
-
try { stat = fs.lstatSync(child); } catch (cause) {
|
|
193
|
-
parent.verify();
|
|
194
|
-
if (allowMissing && cause?.code === 'ENOENT') return null;
|
|
195
|
-
unsafe(`archive entry is unavailable: ${child}`, { cause: cause.message });
|
|
196
|
-
}
|
|
197
|
-
parent.verify();
|
|
198
|
-
if (stat.isSymbolicLink()) unsafe(`archive entry is a symbolic link: ${child}`);
|
|
199
|
-
return { path: child, stat, identity: identity(stat) };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function pinChildDirectory(parent, name, { allowMissing = false } = {}) {
|
|
203
|
-
const entry = inspectChild(parent, name, { allowMissing });
|
|
204
|
-
if (!entry) return null;
|
|
205
|
-
if (!entry.stat.isDirectory()) unsafe(`archive entry is not a directory: ${entry.path}`);
|
|
206
|
-
const pin = openDirectoryPin(
|
|
207
|
-
{ physicalRoot: parent.path, rootIdentity: parent.identity },
|
|
208
|
-
name,
|
|
209
|
-
);
|
|
210
|
-
parent.verify();
|
|
211
|
-
return pin;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function runPinnedMutation(request, pins) {
|
|
215
|
-
const expectedPinFields = MUTATION_PIN_FIELDS[request.operation];
|
|
216
|
-
if (!expectedPinFields
|
|
217
|
-
|| Object.keys(pins).length !== expectedPinFields.length
|
|
218
|
-
|| expectedPinFields.some((field) => !Object.hasOwn(pins, field))) {
|
|
219
|
-
throw new ArchiveJournalError(
|
|
220
|
-
'ARCHIVE_MUTATION_INVALID',
|
|
221
|
-
`archive mutation has an invalid operation or descriptor contract: ${request.operation}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
const stdio = ['pipe', 'pipe', 'pipe'];
|
|
225
|
-
const payload = { ...request };
|
|
226
|
-
for (const field of expectedPinFields) {
|
|
227
|
-
const pin = pins[field];
|
|
228
|
-
if (!pin || !Number.isInteger(pin.fd)) {
|
|
229
|
-
unsafe(`archive mutation is missing a pinned directory: ${field}`);
|
|
230
|
-
}
|
|
231
|
-
payload[field] = stdio.length;
|
|
232
|
-
stdio.push(pin.fd);
|
|
233
|
-
}
|
|
234
|
-
const serialized = JSON.stringify(payload);
|
|
235
|
-
if (Buffer.byteLength(serialized) > MAX_MUTATION_REQUEST_BYTES) {
|
|
236
|
-
throw new ArchiveJournalError(
|
|
237
|
-
'ARCHIVE_MUTATION_INVALID',
|
|
238
|
-
'archive mutation request exceeds the bounded worker protocol',
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
const result = require('node:child_process').spawnSync(
|
|
242
|
-
'python3',
|
|
243
|
-
[MUTATION_WORKER],
|
|
244
|
-
{
|
|
245
|
-
input: serialized,
|
|
246
|
-
encoding: 'utf8',
|
|
247
|
-
maxBuffer: 1024 * 1024,
|
|
248
|
-
stdio,
|
|
249
|
-
},
|
|
250
|
-
);
|
|
251
|
-
if (result.error || result.status !== 0) {
|
|
252
|
-
let workerPayload = null;
|
|
253
|
-
const workerStderr = String(result.stderr || '').trim();
|
|
254
|
-
try {
|
|
255
|
-
workerPayload = JSON.parse(workerStderr);
|
|
256
|
-
} catch { /* preserve the worker's exact stderr */ }
|
|
257
|
-
const workerError = workerPayload?.message || workerStderr;
|
|
258
|
-
if (result.error?.code === 'ENOENT'
|
|
259
|
-
|| result.error?.code === 'EACCES'
|
|
260
|
-
|| result.error?.code === 'ENOEXEC'
|
|
261
|
-
|| workerPayload?.code === 'ARCHIVE_RUNTIME_UNAVAILABLE') {
|
|
262
|
-
throw new ArchiveJournalError(
|
|
263
|
-
'ARCHIVE_RUNTIME_UNAVAILABLE',
|
|
264
|
-
'inode-pinned archive mutation requires Python 3 with POSIX dir_fd support',
|
|
265
|
-
{
|
|
266
|
-
operation: request.operation,
|
|
267
|
-
cause: result.error?.message || workerError || 'archive runtime unavailable',
|
|
268
|
-
},
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
unsafe('archive pinned mutation failed', {
|
|
272
|
-
operation: request.operation,
|
|
273
|
-
cause: result.error?.message || workerError || `worker exited ${result.status}`,
|
|
274
|
-
signal: result.signal || null,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function ensureArchiveRoot(project) {
|
|
280
|
-
const changes = openDirectoryPin(project, '.ultra/changes');
|
|
281
|
-
try {
|
|
282
|
-
const existing = inspectChild(changes, 'archive', { allowMissing: true });
|
|
283
|
-
if (existing) {
|
|
284
|
-
if (!existing.stat.isDirectory()) unsafe(`archive root is not a directory: ${existing.path}`);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
changes.verify();
|
|
288
|
-
runPinnedMutation({
|
|
289
|
-
operation: 'mkdir_dir',
|
|
290
|
-
directory_identity: changes.identity,
|
|
291
|
-
name: 'archive',
|
|
292
|
-
}, { directory_fd: changes });
|
|
293
|
-
changes.verify();
|
|
294
|
-
const created = inspectChild(changes, 'archive');
|
|
295
|
-
if (!created.stat.isDirectory()) unsafe('created archive root is not a directory');
|
|
296
|
-
} finally {
|
|
297
|
-
changes.close();
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function openCoreLayout(rootDir, { ensureArchive = false } = {}) {
|
|
302
|
-
const project = inspectProject(rootDir);
|
|
303
|
-
if (ensureArchive) ensureArchiveRoot(project);
|
|
304
|
-
const pins = {};
|
|
305
|
-
try {
|
|
306
|
-
for (const [name, relative] of CORE_DIRECTORIES) {
|
|
307
|
-
pins[name] = openDirectoryPin(project, relative);
|
|
308
|
-
}
|
|
309
|
-
} catch (error) {
|
|
310
|
-
for (const pin of Object.values(pins)) pin?.close();
|
|
311
|
-
throw error;
|
|
312
|
-
}
|
|
313
|
-
return {
|
|
314
|
-
project,
|
|
315
|
-
pins,
|
|
316
|
-
identities: Object.fromEntries(
|
|
317
|
-
Object.entries(pins).map(([name, pin]) => [name, pin.identity]),
|
|
318
|
-
),
|
|
319
|
-
verify() {
|
|
320
|
-
for (const pin of Object.values(pins)) pin.verify();
|
|
321
|
-
},
|
|
322
|
-
close() {
|
|
323
|
-
for (const pin of Object.values(pins).reverse()) pin.close();
|
|
324
|
-
},
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function verifyStoredLayout(intent, layout) {
|
|
329
|
-
for (const [name] of CORE_DIRECTORIES) {
|
|
330
|
-
if (!sameIdentity(intent.path_identities?.[name], layout.identities[name])) {
|
|
331
|
-
unsafe(`archive ${name} directory no longer matches the prepared intent`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function assertIntent(intent, rootDir) {
|
|
337
|
-
if (!intent || typeof intent !== 'object' || Array.isArray(intent)
|
|
338
|
-
|| intent.schema_version !== INTENT_VERSION
|
|
339
|
-
|| typeof intent.change_id !== 'string' || !intent.change_id
|
|
340
|
-
|| !Array.isArray(intent.baseline_updates)
|
|
341
|
-
|| typeof intent.reconciliation_path !== 'string' || !intent.reconciliation_path
|
|
342
|
-
|| !/^[0-9a-f]{64}$/.test(String(intent.reconciliation_digest || ''))
|
|
343
|
-
|| !intent.reconciliation_manifest || typeof intent.reconciliation_manifest !== 'object'
|
|
344
|
-
|| typeof intent.summary !== 'string' || intent.summary.trim().length < 3
|
|
345
|
-
|| !intent.path_identities || typeof intent.path_identities !== 'object'
|
|
346
|
-
|| !intent.source_identity || typeof intent.source_identity !== 'object') {
|
|
347
|
-
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent shape is invalid');
|
|
348
|
-
}
|
|
349
|
-
const sourceRelative = normalizeRelative(intent.source, 'source');
|
|
350
|
-
const destinationRelative = normalizeRelative(intent.destination, 'destination');
|
|
351
|
-
if (path.posix.dirname(sourceRelative) !== ACTIVE_ROOT
|
|
352
|
-
|| path.posix.basename(sourceRelative) !== intent.change_id) {
|
|
353
|
-
throw new ArchiveJournalError(
|
|
354
|
-
'ARCHIVE_INTENT_INVALID', 'archive intent source is not the active change root',
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
if (path.posix.dirname(destinationRelative) !== ARCHIVE_ROOT
|
|
358
|
-
|| !path.posix.basename(destinationRelative).endsWith(`-${intent.change_id}`)) {
|
|
359
|
-
throw new ArchiveJournalError(
|
|
360
|
-
'ARCHIVE_INTENT_INVALID', 'archive intent destination is not the change archive root',
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
for (const update of intent.baseline_updates) normalizeRelative(update, 'baseline update');
|
|
364
|
-
normalizeRelative(intent.reconciliation_path, 'reconciliation path');
|
|
365
|
-
if (intent.previous_summary !== null && typeof intent.previous_summary !== 'string') {
|
|
366
|
-
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'previous_summary must be string or null');
|
|
367
|
-
}
|
|
368
|
-
const project = inspectProject(rootDir);
|
|
369
|
-
return {
|
|
370
|
-
intent,
|
|
371
|
-
sourceRelative,
|
|
372
|
-
destinationRelative,
|
|
373
|
-
source: absoluteInside(project, sourceRelative),
|
|
374
|
-
destination: absoluteInside(project, destinationRelative),
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function readStableText(rootDir, relative, code) {
|
|
379
|
-
try {
|
|
380
|
-
return readStableProjectFile(rootDir, relative, { encoding: 'utf8' });
|
|
381
|
-
} catch (error) {
|
|
382
|
-
throw new ArchiveJournalError(code, `cannot read ${relative} safely: ${error.message}`, {
|
|
383
|
-
cause: error.code || error.message,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function rebindJournalRelative(intent, location = 'destination') {
|
|
389
|
-
const root = location === 'source' ? intent.source : intent.destination;
|
|
390
|
-
return `${root}/${REBIND_FILE}`;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function normalizeRebindEntry(entry, index) {
|
|
394
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
395
|
-
throw new ArchiveJournalError(
|
|
396
|
-
'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} is invalid`,
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
const relativePath = normalizeRelative(entry.relative_path, `rebind entry ${index} path`);
|
|
400
|
-
if (relativePath === INTENT_FILE || relativePath === REBIND_FILE
|
|
401
|
-
|| relativePath.startsWith(`${INTENT_FILE}/`)
|
|
402
|
-
|| relativePath.startsWith(`${REBIND_FILE}/`)
|
|
403
|
-
|| !/^[0-9a-f]{64}$/.test(String(entry.before_digest || ''))
|
|
404
|
-
|| !/^[0-9a-f]{64}$/.test(String(entry.after_digest || ''))
|
|
405
|
-
|| typeof entry.before_base64 !== 'string'
|
|
406
|
-
|| typeof entry.after_base64 !== 'string') {
|
|
407
|
-
throw new ArchiveJournalError(
|
|
408
|
-
'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} is invalid`,
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
const before = Buffer.from(entry.before_base64, 'base64');
|
|
412
|
-
const after = Buffer.from(entry.after_base64, 'base64');
|
|
413
|
-
if (before.toString('base64') !== entry.before_base64
|
|
414
|
-
|| after.toString('base64') !== entry.after_base64
|
|
415
|
-
|| crypto.createHash('sha256').update(before).digest('hex') !== entry.before_digest
|
|
416
|
-
|| crypto.createHash('sha256').update(after).digest('hex') !== entry.after_digest) {
|
|
417
|
-
throw new ArchiveJournalError(
|
|
418
|
-
'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} payload is invalid`,
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
relative_path: relativePath,
|
|
423
|
-
before_digest: entry.before_digest,
|
|
424
|
-
after_digest: entry.after_digest,
|
|
425
|
-
before_base64: entry.before_base64,
|
|
426
|
-
after_base64: entry.after_base64,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function normalizeRebindJournal(value, intent) {
|
|
431
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
432
|
-
|| value.schema_version !== REBIND_VERSION
|
|
433
|
-
|| value.change_id !== intent.change_id
|
|
434
|
-
|| value.source !== intent.source
|
|
435
|
-
|| value.destination !== intent.destination
|
|
436
|
-
|| !Array.isArray(value.entries)) {
|
|
437
|
-
throw new ArchiveJournalError(
|
|
438
|
-
'ARCHIVE_REBIND_INVALID', 'archive rebind journal shape is invalid',
|
|
439
|
-
);
|
|
440
|
-
}
|
|
441
|
-
const entries = value.entries.map(normalizeRebindEntry);
|
|
442
|
-
if (new Set(entries.map((entry) => entry.relative_path)).size !== entries.length) {
|
|
443
|
-
throw new ArchiveJournalError(
|
|
444
|
-
'ARCHIVE_REBIND_INVALID', 'archive rebind journal contains duplicate paths',
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
return { ...value, entries };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function readArchiveRebind(rootDir, intent, { location = 'destination' } = {}) {
|
|
451
|
-
const relative = rebindJournalRelative(intent, location);
|
|
452
|
-
const file = path.join(path.resolve(rootDir), ...relative.split('/'));
|
|
453
|
-
if (!fs.existsSync(file)) return null;
|
|
454
|
-
const stable = readStableText(rootDir, relative, 'ARCHIVE_REBIND_INVALID');
|
|
455
|
-
let value;
|
|
456
|
-
try { value = JSON.parse(stable.text); } catch (error) {
|
|
457
|
-
throw new ArchiveJournalError(
|
|
458
|
-
'ARCHIVE_REBIND_INVALID', `cannot parse ${relative}: ${error.message}`,
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
return normalizeRebindJournal(value, intent);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function writeRebindTarget(rootDir, rootRelative, entry, bytes) {
|
|
465
|
-
const parentRelative = path.posix.dirname(`${rootRelative}/${entry.relative_path}`);
|
|
466
|
-
const name = path.posix.basename(entry.relative_path);
|
|
467
|
-
const project = inspectProject(rootDir);
|
|
468
|
-
const parent = openDirectoryPin(project, parentRelative);
|
|
469
|
-
try {
|
|
470
|
-
const current = inspectChild(parent, name);
|
|
471
|
-
if (!current.stat.isFile()) {
|
|
472
|
-
unsafe(`archive rebind target is not a regular file: ${entry.relative_path}`);
|
|
473
|
-
}
|
|
474
|
-
writeAtomicRebindEntry(parent, name, bytes);
|
|
475
|
-
} finally {
|
|
476
|
-
parent.close();
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function verifyRebindTarget(rootDir, rootRelative, entry, expectedDigest) {
|
|
481
|
-
const stable = readStableText(
|
|
482
|
-
rootDir,
|
|
483
|
-
`${rootRelative}/${entry.relative_path}`,
|
|
484
|
-
'ARCHIVE_REBIND_INVALID',
|
|
485
|
-
);
|
|
486
|
-
if (stable.digest !== expectedDigest) {
|
|
487
|
-
throw new ArchiveJournalError(
|
|
488
|
-
'ARCHIVE_REBIND_CONFLICT',
|
|
489
|
-
`archive rebind target has unexpected bytes: ${entry.relative_path}`,
|
|
490
|
-
{ expected: expectedDigest, actual: stable.digest },
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function prepareArchiveRebind(rootDir, intent, entries = null) {
|
|
496
|
-
verifyArchiveIntent(rootDir, intent, { location: 'destination' });
|
|
497
|
-
let journal = readArchiveRebind(rootDir, intent);
|
|
498
|
-
if (!journal) {
|
|
499
|
-
if (!Array.isArray(entries)) {
|
|
500
|
-
throw new ArchiveJournalError(
|
|
501
|
-
'ARCHIVE_REBIND_INVALID', 'archive rebind entries are required for a new journal',
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
const normalized = entries.map(normalizeRebindEntry);
|
|
505
|
-
journal = normalizeRebindJournal({
|
|
506
|
-
schema_version: REBIND_VERSION,
|
|
507
|
-
change_id: intent.change_id,
|
|
508
|
-
source: intent.source,
|
|
509
|
-
destination: intent.destination,
|
|
510
|
-
entries: normalized,
|
|
511
|
-
created_at: new Date().toISOString(),
|
|
512
|
-
}, intent);
|
|
513
|
-
for (const entry of journal.entries) {
|
|
514
|
-
verifyRebindTarget(rootDir, intent.destination, entry, entry.before_digest);
|
|
515
|
-
}
|
|
516
|
-
const project = inspectProject(rootDir);
|
|
517
|
-
const destination = openDirectoryPin(project, intent.destination);
|
|
518
|
-
try {
|
|
519
|
-
writeAtomicEntry(
|
|
520
|
-
destination,
|
|
521
|
-
REBIND_FILE,
|
|
522
|
-
`${JSON.stringify(journal, null, 2)}\n`,
|
|
523
|
-
);
|
|
524
|
-
} finally {
|
|
525
|
-
destination.close();
|
|
526
|
-
}
|
|
527
|
-
} else if (entries) {
|
|
528
|
-
const requested = entries.map(normalizeRebindEntry);
|
|
529
|
-
const comparable = (values) => values.map((entry) => ({
|
|
530
|
-
relative_path: entry.relative_path,
|
|
531
|
-
before_digest: entry.before_digest,
|
|
532
|
-
after_digest: entry.after_digest,
|
|
533
|
-
}));
|
|
534
|
-
if (JSON.stringify(comparable(journal.entries))
|
|
535
|
-
!== JSON.stringify(comparable(requested))) {
|
|
536
|
-
throw new ArchiveJournalError(
|
|
537
|
-
'ARCHIVE_REBIND_CONFLICT', 'existing archive rebind journal differs from retry',
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
for (const entry of journal.entries) {
|
|
542
|
-
const relative = `${intent.destination}/${entry.relative_path}`;
|
|
543
|
-
const current = readStableText(rootDir, relative, 'ARCHIVE_REBIND_INVALID');
|
|
544
|
-
if (current.digest === entry.after_digest) continue;
|
|
545
|
-
if (current.digest !== entry.before_digest) {
|
|
546
|
-
throw new ArchiveJournalError(
|
|
547
|
-
'ARCHIVE_REBIND_CONFLICT',
|
|
548
|
-
`archive rebind target changed outside the transaction: ${entry.relative_path}`,
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
writeRebindTarget(
|
|
552
|
-
rootDir,
|
|
553
|
-
intent.destination,
|
|
554
|
-
entry,
|
|
555
|
-
Buffer.from(entry.after_base64, 'base64'),
|
|
556
|
-
);
|
|
557
|
-
verifyRebindTarget(rootDir, intent.destination, entry, entry.after_digest);
|
|
558
|
-
}
|
|
559
|
-
return journal;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function rollbackArchiveRebind(rootDir, intent, { location = 'destination' } = {}) {
|
|
563
|
-
const journal = readArchiveRebind(rootDir, intent, { location });
|
|
564
|
-
if (!journal) return;
|
|
565
|
-
const rootRelative = location === 'source' ? intent.source : intent.destination;
|
|
566
|
-
for (const entry of [...journal.entries].reverse()) {
|
|
567
|
-
const current = readStableText(
|
|
568
|
-
rootDir,
|
|
569
|
-
`${rootRelative}/${entry.relative_path}`,
|
|
570
|
-
'ARCHIVE_REBIND_INVALID',
|
|
571
|
-
);
|
|
572
|
-
if (current.digest !== entry.before_digest) {
|
|
573
|
-
if (current.digest !== entry.after_digest) {
|
|
574
|
-
throw new ArchiveJournalError(
|
|
575
|
-
'ARCHIVE_REBIND_CONFLICT',
|
|
576
|
-
`archive rebind rollback found unknown bytes: ${entry.relative_path}`,
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
writeRebindTarget(
|
|
580
|
-
rootDir,
|
|
581
|
-
rootRelative,
|
|
582
|
-
entry,
|
|
583
|
-
Buffer.from(entry.before_base64, 'base64'),
|
|
584
|
-
);
|
|
585
|
-
}
|
|
586
|
-
verifyRebindTarget(rootDir, rootRelative, entry, entry.before_digest);
|
|
587
|
-
}
|
|
588
|
-
const project = inspectProject(rootDir);
|
|
589
|
-
const parent = openDirectoryPin(project, rootRelative);
|
|
590
|
-
try { removePinnedRegularFile(parent, REBIND_FILE); } finally { parent.close(); }
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function readIntentAt(rootDir, relative, expectedLocation = null) {
|
|
594
|
-
const stable = readStableText(rootDir, relative, 'ARCHIVE_INTENT_CORRUPT');
|
|
595
|
-
let intent;
|
|
596
|
-
try { intent = JSON.parse(stable.text); } catch (error) {
|
|
597
|
-
throw new ArchiveJournalError(
|
|
598
|
-
'ARCHIVE_INTENT_CORRUPT', `cannot parse ${relative}: ${error.message}`,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
const asserted = assertIntent(intent, rootDir);
|
|
602
|
-
const layout = openCoreLayout(rootDir);
|
|
603
|
-
let child;
|
|
604
|
-
try {
|
|
605
|
-
verifyStoredLayout(intent, layout);
|
|
606
|
-
const activeName = path.posix.basename(asserted.sourceRelative);
|
|
607
|
-
const archiveName = path.posix.basename(asserted.destinationRelative);
|
|
608
|
-
const sourceEntry = inspectChild(layout.pins.active, activeName, { allowMissing: true });
|
|
609
|
-
const destinationEntry = inspectChild(
|
|
610
|
-
layout.pins.archive, archiveName, { allowMissing: true },
|
|
611
|
-
);
|
|
612
|
-
const sourceExists = Boolean(sourceEntry);
|
|
613
|
-
const destinationExists = Boolean(destinationEntry);
|
|
614
|
-
if (sourceEntry && !sourceEntry.stat.isDirectory()) {
|
|
615
|
-
unsafe(`active archive source is not a directory: ${asserted.sourceRelative}`);
|
|
616
|
-
}
|
|
617
|
-
if (destinationEntry && !destinationEntry.stat.isDirectory()) {
|
|
618
|
-
unsafe(`archive destination is not a directory: ${asserted.destinationRelative}`);
|
|
619
|
-
}
|
|
620
|
-
if ((sourceEntry && !sameIdentity(sourceEntry.identity, intent.source_identity))
|
|
621
|
-
|| (destinationEntry && !sameIdentity(destinationEntry.identity, intent.source_identity))) {
|
|
622
|
-
unsafe(`archive change directory does not match the prepared inode: ${intent.change_id}`);
|
|
623
|
-
}
|
|
624
|
-
const location = sourceExists && !destinationExists
|
|
625
|
-
? 'source'
|
|
626
|
-
: destinationExists && !sourceExists
|
|
627
|
-
? 'destination'
|
|
628
|
-
: 'conflict';
|
|
629
|
-
if (expectedLocation && location !== expectedLocation) {
|
|
630
|
-
unsafe(`archive intent is not at the expected ${expectedLocation} location`);
|
|
631
|
-
}
|
|
632
|
-
child = {
|
|
633
|
-
...asserted,
|
|
634
|
-
source_exists: sourceExists,
|
|
635
|
-
destination_exists: destinationExists,
|
|
636
|
-
location,
|
|
637
|
-
};
|
|
638
|
-
} finally {
|
|
639
|
-
layout.close();
|
|
640
|
-
}
|
|
641
|
-
return child;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function writeAtomicEntry(parent, name, contents, { replace = false } = {}) {
|
|
645
|
-
const current = inspectChild(parent, name, { allowMissing: true });
|
|
646
|
-
if (current && (!replace || !current.stat.isFile())) {
|
|
647
|
-
unsafe(`archive file cannot be replaced safely: ${current.path}`);
|
|
648
|
-
}
|
|
649
|
-
parent.verify();
|
|
650
|
-
runPinnedMutation({
|
|
651
|
-
operation: 'write_atomic',
|
|
652
|
-
directory_identity: parent.identity,
|
|
653
|
-
name,
|
|
654
|
-
temp_name: `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
655
|
-
replace,
|
|
656
|
-
data_base64: Buffer.from(contents).toString('base64'),
|
|
657
|
-
}, { directory_fd: parent });
|
|
658
|
-
parent.verify();
|
|
659
|
-
const published = inspectChild(parent, name);
|
|
660
|
-
if (!published.stat.isFile()) {
|
|
661
|
-
unsafe(`archive file is not regular after publication: ${published.path}`);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function writeAtomicRebindEntry(parent, name, contents) {
|
|
666
|
-
const current = inspectChild(parent, name);
|
|
667
|
-
if (!current.stat.isFile()) {
|
|
668
|
-
unsafe(`archive rebind target is not a regular file: ${current.path}`);
|
|
669
|
-
}
|
|
670
|
-
parent.verify();
|
|
671
|
-
runPinnedMutation({
|
|
672
|
-
operation: 'write_rebind_atomic',
|
|
673
|
-
directory_identity: parent.identity,
|
|
674
|
-
name,
|
|
675
|
-
temp_name: `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
676
|
-
replace: true,
|
|
677
|
-
data_base64: Buffer.from(contents).toString('base64'),
|
|
678
|
-
}, { directory_fd: parent });
|
|
679
|
-
parent.verify();
|
|
680
|
-
const published = inspectChild(parent, name);
|
|
681
|
-
if (!published.stat.isFile()) {
|
|
682
|
-
unsafe(`archive rebind target is not regular after publication: ${published.path}`);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function renderSummary(change, summary, baselineUpdates, noBaselineChangeReason) {
|
|
687
|
-
return [
|
|
688
|
-
`# Archived change: ${change.title}`, '', summary.trim(), '',
|
|
689
|
-
'## Baseline reconciliation', '',
|
|
690
|
-
...(baselineUpdates.length > 0
|
|
691
|
-
? baselineUpdates.map((file) => `- ${file}`)
|
|
692
|
-
: [noBaselineChangeReason]), '',
|
|
693
|
-
].join('\n');
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function sameOperation(intent, {
|
|
697
|
-
change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath,
|
|
698
|
-
reconciliationDigest,
|
|
699
|
-
}) {
|
|
700
|
-
return intent.change_id === change.id
|
|
701
|
-
&& intent.source === normalizeRelative(change.artifact_root, 'source')
|
|
702
|
-
&& intent.summary === summary.trim()
|
|
703
|
-
&& JSON.stringify(intent.baseline_updates) === JSON.stringify(baselineUpdates)
|
|
704
|
-
&& (intent.no_baseline_change_reason || null) === (noBaselineChangeReason || null)
|
|
705
|
-
&& intent.reconciliation_path === normalizeRelative(reconciliationPath, 'reconciliation path')
|
|
706
|
-
&& intent.reconciliation_digest === reconciliationDigest;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function prepareArchiveMove({
|
|
710
|
-
rootDir, change, summary, baselineUpdates, noBaselineChangeReason,
|
|
711
|
-
reconciliationPath, reconciliationDigest, reconciliationManifest, now = new Date(),
|
|
712
|
-
}) {
|
|
713
|
-
const sourceRelative = normalizeRelative(change.artifact_root, 'source');
|
|
714
|
-
const date = now.toISOString().slice(0, 10);
|
|
715
|
-
const destinationRelative = `${ARCHIVE_ROOT}/${date}-${change.id}`;
|
|
716
|
-
const sourceName = path.posix.basename(sourceRelative);
|
|
717
|
-
const destinationName = path.posix.basename(destinationRelative);
|
|
718
|
-
const layout = openCoreLayout(rootDir, { ensureArchive: true });
|
|
719
|
-
let sourcePin;
|
|
720
|
-
try {
|
|
721
|
-
if (path.posix.dirname(sourceRelative) !== ACTIVE_ROOT || sourceName !== change.id) {
|
|
722
|
-
throw new ArchiveJournalError(
|
|
723
|
-
'ARCHIVE_INTENT_INVALID', 'change artifact root is not the active change root',
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
const sourceEntry = inspectChild(layout.pins.active, sourceName, { allowMissing: true });
|
|
727
|
-
const destinationEntry = inspectChild(
|
|
728
|
-
layout.pins.archive, destinationName, { allowMissing: true },
|
|
729
|
-
);
|
|
730
|
-
if (!sourceEntry) {
|
|
731
|
-
if (!destinationEntry) {
|
|
732
|
-
throw new ArchiveJournalError(
|
|
733
|
-
'ARCHIVE_SOURCE_MISSING', `active change root missing: ${sourceRelative}`,
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
const resumed = readIntentAt(
|
|
737
|
-
rootDir, `${destinationRelative}/${INTENT_FILE}`, 'destination',
|
|
738
|
-
);
|
|
739
|
-
if (!sameOperation(resumed.intent, {
|
|
740
|
-
change, summary, baselineUpdates, noBaselineChangeReason,
|
|
741
|
-
reconciliationPath, reconciliationDigest,
|
|
742
|
-
})) {
|
|
743
|
-
throw new ArchiveJournalError(
|
|
744
|
-
'ARCHIVE_INTENT_CONFLICT', 'existing archive intent differs from retry input',
|
|
745
|
-
);
|
|
746
|
-
}
|
|
747
|
-
return {
|
|
748
|
-
...resumed,
|
|
749
|
-
source: path.resolve(rootDir, sourceRelative),
|
|
750
|
-
destination: path.resolve(rootDir, destinationRelative),
|
|
751
|
-
resumed: true,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
if (!sourceEntry.stat.isDirectory()) unsafe(`active change root is not a directory: ${sourceRelative}`);
|
|
755
|
-
if (destinationEntry) {
|
|
756
|
-
if (!destinationEntry.stat.isDirectory()) {
|
|
757
|
-
unsafe(`archive destination is not a directory: ${destinationRelative}`);
|
|
758
|
-
}
|
|
759
|
-
throw new ArchiveJournalError('ARCHIVE_EXISTS', `archive already exists: ${destinationRelative}`);
|
|
760
|
-
}
|
|
761
|
-
sourcePin = pinChildDirectory(layout.pins.active, sourceName);
|
|
762
|
-
const sourceIdentity = sourcePin.identity;
|
|
763
|
-
const sourceIntentRelative = `${sourceRelative}/${INTENT_FILE}`;
|
|
764
|
-
const existingIntent = inspectChild(sourcePin, INTENT_FILE, { allowMissing: true });
|
|
765
|
-
let prepared;
|
|
766
|
-
if (existingIntent) {
|
|
767
|
-
if (!existingIntent.stat.isFile()) unsafe(`archive intent is not a regular file: ${sourceIntentRelative}`);
|
|
768
|
-
prepared = readIntentAt(rootDir, sourceIntentRelative, 'source');
|
|
769
|
-
if (!sameOperation(prepared.intent, {
|
|
770
|
-
change, summary, baselineUpdates, noBaselineChangeReason,
|
|
771
|
-
reconciliationPath, reconciliationDigest,
|
|
772
|
-
})) {
|
|
773
|
-
throw new ArchiveJournalError(
|
|
774
|
-
'ARCHIVE_INTENT_CONFLICT', 'prepared archive intent differs from retry input',
|
|
775
|
-
);
|
|
776
|
-
}
|
|
777
|
-
} else {
|
|
778
|
-
const summaryEntry = inspectChild(sourcePin, 'archive-summary.md', { allowMissing: true });
|
|
779
|
-
if (summaryEntry && !summaryEntry.stat.isFile()) {
|
|
780
|
-
unsafe('archive summary is not a regular file');
|
|
781
|
-
}
|
|
782
|
-
const previousSummary = summaryEntry
|
|
783
|
-
? readStableText(
|
|
784
|
-
rootDir, `${sourceRelative}/archive-summary.md`, 'ARCHIVE_PATH_UNSAFE',
|
|
785
|
-
).text
|
|
786
|
-
: null;
|
|
787
|
-
const intent = {
|
|
788
|
-
schema_version: INTENT_VERSION,
|
|
789
|
-
change_id: change.id,
|
|
790
|
-
source: sourceRelative,
|
|
791
|
-
destination: destinationRelative,
|
|
792
|
-
summary: summary.trim(),
|
|
793
|
-
baseline_updates: baselineUpdates,
|
|
794
|
-
no_baseline_change_reason: noBaselineChangeReason || null,
|
|
795
|
-
reconciliation_path: normalizeRelative(reconciliationPath, 'reconciliation path'),
|
|
796
|
-
reconciliation_digest: reconciliationDigest,
|
|
797
|
-
reconciliation_manifest: reconciliationManifest,
|
|
798
|
-
previous_summary: previousSummary,
|
|
799
|
-
path_identities: layout.identities,
|
|
800
|
-
source_identity: sourceIdentity,
|
|
801
|
-
created_at: now.toISOString(),
|
|
802
|
-
};
|
|
803
|
-
writeAtomicEntry(sourcePin, INTENT_FILE, `${JSON.stringify(intent, null, 2)}\n`);
|
|
804
|
-
layout.verify();
|
|
805
|
-
sourcePin.verify();
|
|
806
|
-
prepared = { ...assertIntent(intent, rootDir), location: 'source', intent };
|
|
807
|
-
}
|
|
808
|
-
writeAtomicEntry(
|
|
809
|
-
sourcePin,
|
|
810
|
-
'archive-summary.md',
|
|
811
|
-
renderSummary(change, summary, baselineUpdates, noBaselineChangeReason),
|
|
812
|
-
{ replace: true },
|
|
813
|
-
);
|
|
814
|
-
layout.verify();
|
|
815
|
-
sourcePin.verify();
|
|
816
|
-
if (inspectChild(layout.pins.archive, destinationName, { allowMissing: true })) {
|
|
817
|
-
throw new ArchiveJournalError(
|
|
818
|
-
'ARCHIVE_EXISTS', `archive appeared before rename: ${destinationRelative}`,
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
runPinnedMutation({
|
|
822
|
-
operation: 'rename_dir',
|
|
823
|
-
source_parent_identity: layout.pins.active.identity,
|
|
824
|
-
destination_parent_identity: layout.pins.archive.identity,
|
|
825
|
-
guard_parent_identity: layout.pins.changes.identity,
|
|
826
|
-
source_parent_name: 'active',
|
|
827
|
-
destination_parent_name: 'archive',
|
|
828
|
-
source_name: sourceName,
|
|
829
|
-
destination_name: destinationName,
|
|
830
|
-
source_identity: sourceIdentity,
|
|
831
|
-
}, {
|
|
832
|
-
source_parent_fd: layout.pins.active,
|
|
833
|
-
destination_parent_fd: layout.pins.archive,
|
|
834
|
-
guard_parent_fd: layout.pins.changes,
|
|
835
|
-
});
|
|
836
|
-
layout.verify();
|
|
837
|
-
sourcePin.verifyFd();
|
|
838
|
-
if (inspectChild(layout.pins.active, sourceName, { allowMissing: true })) {
|
|
839
|
-
unsafe(`active source still exists after archive rename: ${sourceRelative}`);
|
|
840
|
-
}
|
|
841
|
-
const moved = inspectChild(layout.pins.archive, destinationName);
|
|
842
|
-
if (!moved.stat.isDirectory() || !sameIdentity(moved.identity, sourceIdentity)) {
|
|
843
|
-
unsafe(`archive destination does not match the prepared source: ${destinationRelative}`);
|
|
844
|
-
}
|
|
845
|
-
return {
|
|
846
|
-
...prepared,
|
|
847
|
-
source: path.resolve(rootDir, sourceRelative),
|
|
848
|
-
destination: path.resolve(rootDir, destinationRelative),
|
|
849
|
-
sourceRelative,
|
|
850
|
-
destinationRelative,
|
|
851
|
-
location: 'destination',
|
|
852
|
-
resumed: false,
|
|
853
|
-
};
|
|
854
|
-
} finally {
|
|
855
|
-
sourcePin?.close();
|
|
856
|
-
layout.close();
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function listArchiveIntents(rootDir) {
|
|
861
|
-
let project;
|
|
862
|
-
try { project = inspectProject(rootDir); } catch (error) {
|
|
863
|
-
return [{ file: path.resolve(rootDir), error }];
|
|
864
|
-
}
|
|
865
|
-
const found = [];
|
|
866
|
-
for (const relative of [ACTIVE_ROOT, ARCHIVE_ROOT]) {
|
|
867
|
-
let parent;
|
|
868
|
-
try { parent = openDirectoryPin(project, relative, { missing: true }); } catch (error) {
|
|
869
|
-
found.push({ file: absoluteInside(project, relative), error });
|
|
870
|
-
continue;
|
|
871
|
-
}
|
|
872
|
-
if (!parent) continue;
|
|
873
|
-
try {
|
|
874
|
-
parent.verify();
|
|
875
|
-
const entries = fs.readdirSync(parent.path, { withFileTypes: true });
|
|
876
|
-
parent.verify();
|
|
877
|
-
for (const entry of entries) {
|
|
878
|
-
if (['.gitkeep', '.DS_Store'].includes(entry.name)) continue;
|
|
879
|
-
let child;
|
|
880
|
-
try {
|
|
881
|
-
child = inspectChild(parent, entry.name);
|
|
882
|
-
if (!child.stat.isDirectory()) {
|
|
883
|
-
found.push({
|
|
884
|
-
file: child.path,
|
|
885
|
-
error: new ArchiveJournalError(
|
|
886
|
-
'ARCHIVE_PATH_UNSAFE', `archive recovery residue is not a directory: ${child.path}`,
|
|
887
|
-
),
|
|
888
|
-
});
|
|
889
|
-
continue;
|
|
890
|
-
}
|
|
891
|
-
} catch (error) {
|
|
892
|
-
found.push({ file: path.join(parent.path, entry.name), error });
|
|
893
|
-
continue;
|
|
894
|
-
}
|
|
895
|
-
const intentRelative = `${relative}/${entry.name}/${INTENT_FILE}`;
|
|
896
|
-
let intentEntry;
|
|
897
|
-
const childPin = pinChildDirectory(parent, entry.name);
|
|
898
|
-
try {
|
|
899
|
-
intentEntry = inspectChild(childPin, INTENT_FILE, { allowMissing: true });
|
|
900
|
-
} catch (error) {
|
|
901
|
-
found.push({ file: path.join(child.path, INTENT_FILE), error });
|
|
902
|
-
childPin.close();
|
|
903
|
-
continue;
|
|
904
|
-
}
|
|
905
|
-
childPin.close();
|
|
906
|
-
if (!intentEntry) continue;
|
|
907
|
-
if (!intentEntry.stat.isFile()) {
|
|
908
|
-
found.push({
|
|
909
|
-
file: intentEntry.path,
|
|
910
|
-
error: new ArchiveJournalError(
|
|
911
|
-
'ARCHIVE_PATH_UNSAFE', `archive intent residue is not a regular file: ${intentEntry.path}`,
|
|
912
|
-
),
|
|
913
|
-
});
|
|
914
|
-
continue;
|
|
915
|
-
}
|
|
916
|
-
try {
|
|
917
|
-
const record = readIntentAt(rootDir, intentRelative);
|
|
918
|
-
found.push({ ...record, file: intentEntry.path, error: null });
|
|
919
|
-
} catch (error) {
|
|
920
|
-
found.push({ file: intentEntry.path, error });
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
} catch (error) {
|
|
924
|
-
found.push({ file: parent.path, error });
|
|
925
|
-
} finally {
|
|
926
|
-
parent.close();
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
return found;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
function inspectArchiveIntentState(rootDir, intent) {
|
|
933
|
-
const asserted = assertIntent(intent, rootDir);
|
|
934
|
-
const layout = openCoreLayout(rootDir);
|
|
935
|
-
try {
|
|
936
|
-
verifyStoredLayout(intent, layout);
|
|
937
|
-
const source = inspectChild(
|
|
938
|
-
layout.pins.active, path.posix.basename(asserted.sourceRelative), { allowMissing: true },
|
|
939
|
-
);
|
|
940
|
-
const destination = inspectChild(
|
|
941
|
-
layout.pins.archive,
|
|
942
|
-
path.posix.basename(asserted.destinationRelative),
|
|
943
|
-
{ allowMissing: true },
|
|
944
|
-
);
|
|
945
|
-
for (const entry of [source, destination].filter(Boolean)) {
|
|
946
|
-
if (!entry.stat.isDirectory() || !sameIdentity(entry.identity, intent.source_identity)) {
|
|
947
|
-
unsafe(`archive change directory has an unexpected identity: ${entry.path}`);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
return {
|
|
951
|
-
...asserted,
|
|
952
|
-
source_exists: Boolean(source),
|
|
953
|
-
destination_exists: Boolean(destination),
|
|
954
|
-
location: source && !destination ? 'source' : destination && !source ? 'destination' : 'conflict',
|
|
955
|
-
};
|
|
956
|
-
} finally {
|
|
957
|
-
layout.close();
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function verifyArchiveIntent(rootDir, intent, { location = 'destination' } = {}) {
|
|
962
|
-
const state = inspectArchiveIntentState(rootDir, intent);
|
|
963
|
-
if (state.location !== location) {
|
|
964
|
-
unsafe(`archive intent is not at the expected ${location} location`);
|
|
965
|
-
}
|
|
966
|
-
const relative = location === 'source' ? state.sourceRelative : state.destinationRelative;
|
|
967
|
-
const record = readIntentAt(rootDir, `${relative}/${INTENT_FILE}`, location);
|
|
968
|
-
if (JSON.stringify(record.intent) !== JSON.stringify(intent)) {
|
|
969
|
-
throw new ArchiveJournalError(
|
|
970
|
-
'ARCHIVE_INTENT_CONFLICT', 'archive intent bytes differ from the supplied operation',
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
return state;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
function removePinnedRegularFile(parent, name, { allowMissing = false } = {}) {
|
|
977
|
-
const entry = inspectChild(parent, name, { allowMissing });
|
|
978
|
-
if (!entry) return;
|
|
979
|
-
if (!entry.stat.isFile()) unsafe(`archive cleanup target is not a regular file: ${entry.path}`);
|
|
980
|
-
parent.verify();
|
|
981
|
-
runPinnedMutation({
|
|
982
|
-
operation: 'unlink_regular',
|
|
983
|
-
directory_identity: parent.identity,
|
|
984
|
-
name,
|
|
985
|
-
allow_missing: allowMissing,
|
|
986
|
-
}, { directory_fd: parent });
|
|
987
|
-
parent.verify();
|
|
988
|
-
if (inspectChild(parent, name, { allowMissing: true })) {
|
|
989
|
-
unsafe(`archive cleanup target still exists: ${entry.path}`);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
function completeArchiveIntent(rootDir, intent) {
|
|
994
|
-
const state = verifyArchiveIntent(rootDir, intent, { location: 'destination' });
|
|
995
|
-
const rebind = readArchiveRebind(rootDir, intent);
|
|
996
|
-
if (rebind) {
|
|
997
|
-
for (const entry of rebind.entries) {
|
|
998
|
-
verifyRebindTarget(
|
|
999
|
-
rootDir, intent.destination, entry, entry.after_digest,
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
const layout = openCoreLayout(rootDir);
|
|
1004
|
-
let destination;
|
|
1005
|
-
try {
|
|
1006
|
-
verifyStoredLayout(intent, layout);
|
|
1007
|
-
destination = pinChildDirectory(
|
|
1008
|
-
layout.pins.archive, path.posix.basename(state.destinationRelative),
|
|
1009
|
-
);
|
|
1010
|
-
if (!sameIdentity(destination.identity, intent.source_identity)) {
|
|
1011
|
-
unsafe('archive destination changed before journal cleanup');
|
|
1012
|
-
}
|
|
1013
|
-
removePinnedRegularFile(destination, REBIND_FILE, { allowMissing: true });
|
|
1014
|
-
removePinnedRegularFile(destination, INTENT_FILE);
|
|
1015
|
-
layout.verify();
|
|
1016
|
-
} finally {
|
|
1017
|
-
destination?.close();
|
|
1018
|
-
layout.close();
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function rollbackArchiveIntent(rootDir, intent) {
|
|
1023
|
-
const asserted = assertIntent(intent, rootDir);
|
|
1024
|
-
let state = inspectArchiveIntentState(rootDir, intent);
|
|
1025
|
-
if (state.location === 'destination') {
|
|
1026
|
-
rollbackArchiveRebind(rootDir, intent, { location: 'destination' });
|
|
1027
|
-
const layout = openCoreLayout(rootDir);
|
|
1028
|
-
let destination;
|
|
1029
|
-
try {
|
|
1030
|
-
verifyStoredLayout(intent, layout);
|
|
1031
|
-
destination = pinChildDirectory(
|
|
1032
|
-
layout.pins.archive, path.posix.basename(asserted.destinationRelative),
|
|
1033
|
-
);
|
|
1034
|
-
if (!sameIdentity(destination.identity, intent.source_identity)) {
|
|
1035
|
-
unsafe('archive destination changed before rollback');
|
|
1036
|
-
}
|
|
1037
|
-
if (inspectChild(
|
|
1038
|
-
layout.pins.active, path.posix.basename(asserted.sourceRelative), { allowMissing: true },
|
|
1039
|
-
)) {
|
|
1040
|
-
throw new ArchiveJournalError(
|
|
1041
|
-
'ARCHIVE_ROLLBACK_FAILED', 'archive source already exists during rollback',
|
|
1042
|
-
);
|
|
1043
|
-
}
|
|
1044
|
-
layout.verify();
|
|
1045
|
-
destination.verify();
|
|
1046
|
-
runPinnedMutation({
|
|
1047
|
-
operation: 'rename_dir',
|
|
1048
|
-
source_parent_identity: layout.pins.archive.identity,
|
|
1049
|
-
destination_parent_identity: layout.pins.active.identity,
|
|
1050
|
-
guard_parent_identity: layout.pins.changes.identity,
|
|
1051
|
-
source_parent_name: 'archive',
|
|
1052
|
-
destination_parent_name: 'active',
|
|
1053
|
-
source_name: path.posix.basename(asserted.destinationRelative),
|
|
1054
|
-
destination_name: path.posix.basename(asserted.sourceRelative),
|
|
1055
|
-
source_identity: intent.source_identity,
|
|
1056
|
-
}, {
|
|
1057
|
-
source_parent_fd: layout.pins.archive,
|
|
1058
|
-
destination_parent_fd: layout.pins.active,
|
|
1059
|
-
guard_parent_fd: layout.pins.changes,
|
|
1060
|
-
});
|
|
1061
|
-
layout.verify();
|
|
1062
|
-
destination.verifyFd();
|
|
1063
|
-
const restored = inspectChild(
|
|
1064
|
-
layout.pins.active, path.posix.basename(asserted.sourceRelative),
|
|
1065
|
-
);
|
|
1066
|
-
if (!restored.stat.isDirectory()
|
|
1067
|
-
|| !sameIdentity(restored.identity, intent.source_identity)) {
|
|
1068
|
-
unsafe('restored archive source does not match the prepared inode');
|
|
1069
|
-
}
|
|
1070
|
-
} finally {
|
|
1071
|
-
destination?.close();
|
|
1072
|
-
layout.close();
|
|
1073
|
-
}
|
|
1074
|
-
state = inspectArchiveIntentState(rootDir, intent);
|
|
1075
|
-
}
|
|
1076
|
-
if (state.location !== 'source') {
|
|
1077
|
-
throw new ArchiveJournalError(
|
|
1078
|
-
'ARCHIVE_ROLLBACK_FAILED', 'archive roots are not in a recoverable state',
|
|
1079
|
-
);
|
|
1080
|
-
}
|
|
1081
|
-
rollbackArchiveRebind(rootDir, intent, { location: 'source' });
|
|
1082
|
-
const layout = openCoreLayout(rootDir);
|
|
1083
|
-
let source;
|
|
1084
|
-
try {
|
|
1085
|
-
verifyStoredLayout(intent, layout);
|
|
1086
|
-
source = pinChildDirectory(
|
|
1087
|
-
layout.pins.active, path.posix.basename(asserted.sourceRelative),
|
|
1088
|
-
);
|
|
1089
|
-
if (!sameIdentity(source.identity, intent.source_identity)) {
|
|
1090
|
-
unsafe('archive source changed before rollback cleanup');
|
|
1091
|
-
}
|
|
1092
|
-
if (intent.previous_summary === null) {
|
|
1093
|
-
removePinnedRegularFile(source, 'archive-summary.md', { allowMissing: true });
|
|
1094
|
-
} else {
|
|
1095
|
-
writeAtomicEntry(
|
|
1096
|
-
source, 'archive-summary.md', intent.previous_summary, { replace: true },
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
removePinnedRegularFile(source, INTENT_FILE);
|
|
1100
|
-
layout.verify();
|
|
1101
|
-
} finally {
|
|
1102
|
-
source?.close();
|
|
1103
|
-
layout.close();
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
module.exports = {
|
|
1108
|
-
ArchiveJournalError,
|
|
1109
|
-
INTENT_FILE,
|
|
1110
|
-
REBIND_FILE,
|
|
1111
|
-
prepareArchiveMove,
|
|
1112
|
-
prepareArchiveRebind,
|
|
1113
|
-
readArchiveRebind,
|
|
1114
|
-
listArchiveIntents,
|
|
1115
|
-
inspectArchiveIntentState,
|
|
1116
|
-
verifyArchiveIntent,
|
|
1117
|
-
completeArchiveIntent,
|
|
1118
|
-
rollbackArchiveIntent,
|
|
1119
|
-
};
|