agent-skillboard 0.2.17 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +128 -260
- package/bin/postinstall.mjs +2 -2
- package/docs/adapters.md +37 -113
- package/docs/ai-skill-routing-goal.md +35 -109
- package/docs/capabilities.md +17 -104
- package/docs/install.md +39 -493
- package/docs/policy-model.md +50 -280
- package/docs/positioning.md +19 -86
- package/docs/profiles.md +21 -153
- package/docs/reference.md +117 -356
- package/docs/rollout-runbook.md +23 -25
- package/docs/routing.md +23 -90
- package/docs/user-flow.md +60 -292
- package/docs/value-proof.md +23 -181
- package/docs/variant-lifecycle.md +47 -67
- package/docs/versioning.md +31 -264
- package/examples/v2-multi-source.config.yaml +35 -0
- package/examples/v2-policy-error.config.yaml +6 -0
- package/package.json +1 -1
- package/src/advisor/actions.mjs +102 -6
- package/src/advisor/application-commands.mjs +10 -9
- package/src/advisor/apply-action.mjs +74 -1
- package/src/advisor/guidance.mjs +24 -16
- package/src/advisor/schema.mjs +17 -6
- package/src/advisor/skills.mjs +18 -5
- package/src/advisor.mjs +27 -9
- package/src/agent-integration-cli.mjs +13 -4
- package/src/agent-integration-content.mjs +22 -12
- package/src/agent-integration-files.mjs +1 -1
- package/src/agent-inventory-platforms.mjs +10 -0
- package/src/agent-inventory.mjs +23 -1
- package/src/agent-skill-import.mjs +2 -2
- package/src/audit-paths.mjs +42 -0
- package/src/brief-cli.mjs +3 -2
- package/src/brief-renderer.mjs +1 -0
- package/src/cli.mjs +398 -127
- package/src/compatibility.mjs +24 -0
- package/src/control/can-use-guard.mjs +21 -1
- package/src/control/config-write.mjs +32 -2
- package/src/control/skill-crud.mjs +5 -0
- package/src/control/v2-guard.mjs +175 -0
- package/src/control/v2-skill-crud.mjs +32 -0
- package/src/control/v2-skill-forget.mjs +38 -0
- package/src/control/variant-status.mjs +47 -1
- package/src/control.mjs +55 -0
- package/src/doctor.mjs +65 -6
- package/src/domain/v2-policy.mjs +111 -0
- package/src/hook-plan.mjs +33 -3
- package/src/impact.mjs +52 -29
- package/src/index.mjs +25 -1
- package/src/init.mjs +50 -34
- package/src/inventory-install-units.mjs +63 -0
- package/src/inventory-json.mjs +279 -0
- package/src/inventory-refresh.mjs +163 -18
- package/src/lifecycle-cli.mjs +40 -12
- package/src/lifecycle-content.mjs +52 -67
- package/src/migration/v1-to-v2.mjs +212 -0
- package/src/migration/v2-files.mjs +211 -0
- package/src/migration/v2-journal.mjs +169 -0
- package/src/migration/v2-projection.mjs +108 -0
- package/src/migration/v2-transaction.mjs +205 -0
- package/src/policy.mjs +3 -0
- package/src/reconcile.mjs +139 -111
- package/src/report.mjs +168 -148
- package/src/review.mjs +2 -0
- package/src/route-advisory.mjs +47 -2
- package/src/route-selection.mjs +38 -2
- package/src/route.mjs +62 -2
- package/src/shared-skill.mjs +301 -0
- package/src/source-digest.mjs +42 -0
- package/src/source-profiles.mjs +27 -0
- package/src/source-verification.mjs +32 -48
- package/src/uninstall.mjs +22 -0
- package/src/user-state-paths.mjs +19 -0
- package/src/user-uninstall.mjs +146 -0
- package/src/workspace.mjs +41 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
atomicWrite,
|
|
5
|
+
optionalRead,
|
|
6
|
+
relativeInventoryPath,
|
|
7
|
+
removeBackupSet,
|
|
8
|
+
removeDurably,
|
|
9
|
+
restoreSnapshot,
|
|
10
|
+
sha256
|
|
11
|
+
} from "./v2-files.mjs";
|
|
12
|
+
|
|
13
|
+
const JOURNAL_FORMAT_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
export async function beginMigrationTransaction(configPath, inventoryPath, backup) {
|
|
16
|
+
const journalPath = journalPathFor(configPath);
|
|
17
|
+
const journal = {
|
|
18
|
+
format_version: JOURNAL_FORMAT_VERSION,
|
|
19
|
+
pid: process.pid,
|
|
20
|
+
phase: "prepared",
|
|
21
|
+
target_config: basename(configPath),
|
|
22
|
+
target_inventory: relativeInventoryPath(configPath, inventoryPath),
|
|
23
|
+
config_backup: basename(backup.configBackupPath),
|
|
24
|
+
inventory_backup: backup.inventoryBackupPath === null ? null : basename(backup.inventoryBackupPath),
|
|
25
|
+
inventory_existed: backup.inventoryExisted,
|
|
26
|
+
config_mode: backup.configMode,
|
|
27
|
+
inventory_mode: backup.inventoryMode,
|
|
28
|
+
input_sha256: sha256(await readFile(backup.configBackupPath)),
|
|
29
|
+
inventory_input_sha256: backup.inventoryBackupPath === null
|
|
30
|
+
? null
|
|
31
|
+
: sha256(await readFile(backup.inventoryBackupPath))
|
|
32
|
+
};
|
|
33
|
+
await atomicWrite(journalPath, Buffer.from(`${JSON.stringify(journal, null, 2)}\n`), 0o600);
|
|
34
|
+
return journalPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function commitMigrationTransaction(journalPath) {
|
|
38
|
+
const journal = parseJournal(await readFile(journalPath, "utf8"));
|
|
39
|
+
await atomicWrite(
|
|
40
|
+
journalPath,
|
|
41
|
+
Buffer.from(`${JSON.stringify({ ...journal, phase: "committed" }, null, 2)}\n`),
|
|
42
|
+
0o600
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function finishMigrationTransaction(journalPath) {
|
|
47
|
+
await removeDurably(journalPath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function completeMigrationRecovery(journalPath, backup) {
|
|
51
|
+
const journal = parseJournal(await readFile(journalPath, "utf8"));
|
|
52
|
+
await writePhase(journalPath, journal, "recovered");
|
|
53
|
+
await removeBackupSet(backup);
|
|
54
|
+
await removeDurably(journalPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function recoverInterruptedMigration(configPath, inventoryPath, failpoint) {
|
|
58
|
+
const journalPath = journalPathFor(configPath);
|
|
59
|
+
const bytes = await optionalRead(journalPath);
|
|
60
|
+
if (bytes === null) return false;
|
|
61
|
+
const journal = parseJournal(bytes.toString("utf8"));
|
|
62
|
+
validateTargets(journal, configPath, inventoryPath);
|
|
63
|
+
if (processIsAlive(journal.pid)) {
|
|
64
|
+
throw new Error("Another migration is already using this config.");
|
|
65
|
+
}
|
|
66
|
+
const backup = backupFromJournal(journal, configPath);
|
|
67
|
+
if (journal.phase === "prepared") {
|
|
68
|
+
await validateBackupHashes(backup, journal);
|
|
69
|
+
await restoreSnapshot(configPath, inventoryPath, backup, {
|
|
70
|
+
afterConfigRename: () => terminateRecoveryAt(failpoint, "after-config-rename"),
|
|
71
|
+
afterInventoryRename: () => terminateRecoveryAt(failpoint, "after-inventory-rename")
|
|
72
|
+
});
|
|
73
|
+
await completeMigrationRecovery(journalPath, backup);
|
|
74
|
+
} else if (journal.phase === "recovered") {
|
|
75
|
+
await removeBackupSet(backup);
|
|
76
|
+
await removeDurably(journalPath);
|
|
77
|
+
} else {
|
|
78
|
+
await removeDurably(journalPath);
|
|
79
|
+
}
|
|
80
|
+
await removeDurably(`${configPath}.migrate.lock`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function journalPathFor(configPath) {
|
|
85
|
+
return `${configPath}.migrate.transaction.json`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJournal(text) {
|
|
89
|
+
const value = JSON.parse(text);
|
|
90
|
+
if (value?.format_version !== JOURNAL_FORMAT_VERSION
|
|
91
|
+
|| !Number.isInteger(value.pid)
|
|
92
|
+
|| value.pid <= 0
|
|
93
|
+
|| !["prepared", "committed", "recovered"].includes(value.phase)) {
|
|
94
|
+
throw new Error("Invalid migration transaction journal; recovery was not attempted.");
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writePhase(journalPath, journal, phase) {
|
|
100
|
+
await atomicWrite(
|
|
101
|
+
journalPath,
|
|
102
|
+
Buffer.from(`${JSON.stringify({ ...journal, phase }, null, 2)}\n`),
|
|
103
|
+
0o600
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function terminateRecoveryAt(failpoint, point) {
|
|
108
|
+
if (failpoint === `terminate-recovery-${point}`) process.exit(87);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateTargets(journal, configPath, inventoryPath) {
|
|
112
|
+
if (journal.target_config !== basename(configPath)
|
|
113
|
+
|| journal.target_inventory !== relativeInventoryPath(configPath, inventoryPath)) {
|
|
114
|
+
throw new Error("Migration transaction target mismatch; recovery was not attempted.");
|
|
115
|
+
}
|
|
116
|
+
if (typeof journal.config_backup !== "string"
|
|
117
|
+
|| basename(journal.config_backup) !== journal.config_backup
|
|
118
|
+
|| !journal.config_backup.startsWith(`${basename(configPath)}.`)) {
|
|
119
|
+
throw new Error("Invalid migration transaction backup reference; recovery was not attempted.");
|
|
120
|
+
}
|
|
121
|
+
const validInventoryBackup = journal.inventory_existed === false && journal.inventory_backup === null
|
|
122
|
+
|| journal.inventory_existed === true
|
|
123
|
+
&& typeof journal.inventory_backup === "string"
|
|
124
|
+
&& basename(journal.inventory_backup) === journal.inventory_backup;
|
|
125
|
+
const validModes = Number.isInteger(journal.config_mode)
|
|
126
|
+
&& journal.config_mode >= 0
|
|
127
|
+
&& journal.config_mode <= 0o777
|
|
128
|
+
&& (journal.inventory_existed === false
|
|
129
|
+
? journal.inventory_mode === null
|
|
130
|
+
: Number.isInteger(journal.inventory_mode)
|
|
131
|
+
&& journal.inventory_mode >= 0
|
|
132
|
+
&& journal.inventory_mode <= 0o777);
|
|
133
|
+
if (!validInventoryBackup || !validModes) {
|
|
134
|
+
throw new Error("Invalid migration transaction backup metadata; recovery was not attempted.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function backupFromJournal(journal, configPath) {
|
|
139
|
+
const directory = dirname(configPath);
|
|
140
|
+
return {
|
|
141
|
+
configBackupPath: join(directory, journal.config_backup),
|
|
142
|
+
inventoryBackupPath: journal.inventory_backup === null ? null : join(directory, journal.inventory_backup),
|
|
143
|
+
inventoryExisted: journal.inventory_existed,
|
|
144
|
+
configMode: journal.config_mode,
|
|
145
|
+
inventoryMode: journal.inventory_mode,
|
|
146
|
+
manifestPath: `${join(directory, journal.config_backup)}.manifest.json`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function validateBackupHashes(backup, journal) {
|
|
151
|
+
if (sha256(await readFile(backup.configBackupPath)) !== journal.input_sha256) {
|
|
152
|
+
throw new Error("Migration transaction backup hash mismatch; recovery was not attempted.");
|
|
153
|
+
}
|
|
154
|
+
if (backup.inventoryExisted
|
|
155
|
+
&& sha256(await readFile(backup.inventoryBackupPath)) !== journal.inventory_input_sha256) {
|
|
156
|
+
throw new Error("Migration transaction inventory hash mismatch; recovery was not attempted.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function processIsAlive(pid) {
|
|
161
|
+
try {
|
|
162
|
+
process.kill(pid, 0);
|
|
163
|
+
return true;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code === "ESRCH") return false;
|
|
166
|
+
if (error?.code === "EPERM") return true;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import { sha256 } from "./v2-files.mjs";
|
|
3
|
+
|
|
4
|
+
const TARGET_VERSION = 2;
|
|
5
|
+
const INVENTORY_FORMAT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export function parseMigrationConfig(text) {
|
|
8
|
+
const document = YAML.parseDocument(text);
|
|
9
|
+
if (document.errors.length > 0) {
|
|
10
|
+
throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
|
|
11
|
+
}
|
|
12
|
+
const parsed = document.toJS();
|
|
13
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
14
|
+
throw new Error("Config root must be an object mapping");
|
|
15
|
+
}
|
|
16
|
+
return { document, parsed };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderMigratedPolicy(policy, sourceText, document) {
|
|
20
|
+
document.set("version", TARGET_VERSION);
|
|
21
|
+
document.delete("workflows");
|
|
22
|
+
let skills = document.get("skills", true);
|
|
23
|
+
if (!YAML.isMap(skills)) {
|
|
24
|
+
skills = document.createNode({});
|
|
25
|
+
document.set("skills", skills);
|
|
26
|
+
}
|
|
27
|
+
const retainedIds = new Set(Object.keys(policy.skills));
|
|
28
|
+
for (const item of [...skills.items]) {
|
|
29
|
+
const id = String(item.key?.value ?? item.key);
|
|
30
|
+
if (!retainedIds.has(id)) skills.delete(id);
|
|
31
|
+
}
|
|
32
|
+
for (const [id, entry] of Object.entries(policy.skills)) {
|
|
33
|
+
skills.set(id, document.createNode(entry));
|
|
34
|
+
}
|
|
35
|
+
for (const key of ["defaults", "capabilities", "harnesses", "install_units"]) {
|
|
36
|
+
document.delete(key);
|
|
37
|
+
}
|
|
38
|
+
const text = String(document);
|
|
39
|
+
return sourceText.includes("\r\n") ? text.replace(/(?<!\r)\n/g, "\r\n") : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function migrationInventory(mapped, sourceBytes, losses) {
|
|
43
|
+
return {
|
|
44
|
+
format_version: INVENTORY_FORMAT_VERSION,
|
|
45
|
+
generated: true,
|
|
46
|
+
authoritative_for_availability: false,
|
|
47
|
+
skills: mapped.skills,
|
|
48
|
+
install_units: mapped.install_units,
|
|
49
|
+
migration: {
|
|
50
|
+
source_version: 1,
|
|
51
|
+
target_version: TARGET_VERSION,
|
|
52
|
+
policy_projection_version: TARGET_VERSION,
|
|
53
|
+
invalidates: ["v1-action-id", "v1-guard-hook", "v1-lock-projection"],
|
|
54
|
+
input_sha256: sha256(sourceBytes),
|
|
55
|
+
losses
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function migrationReport(before, mapped, inputBytes, configBytes, inventoryBytes) {
|
|
61
|
+
const skillEntries = Object.entries(before.skills ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
62
|
+
return {
|
|
63
|
+
target_version: TARGET_VERSION,
|
|
64
|
+
input_sha256: sha256(inputBytes),
|
|
65
|
+
config_sha256: sha256(configBytes),
|
|
66
|
+
inventory_sha256: sha256(inventoryBytes),
|
|
67
|
+
counts: {
|
|
68
|
+
skills: skillEntries.length,
|
|
69
|
+
enabled: Object.values(mapped.policy.skills).filter((skill) => skill.enabled).length,
|
|
70
|
+
disabled: Object.values(mapped.policy.skills).filter((skill) => !skill.enabled).length,
|
|
71
|
+
warnings: mapped.warnings.length,
|
|
72
|
+
losses: mapped.losses.length
|
|
73
|
+
},
|
|
74
|
+
skills: skillEntries.map(([id, skill]) => ({
|
|
75
|
+
id,
|
|
76
|
+
before: { status: skill.status, invocation: skill.invocation, exposure: skill.exposure },
|
|
77
|
+
after: mapped.policy.skills[id]
|
|
78
|
+
})),
|
|
79
|
+
warnings: [...mapped.warnings],
|
|
80
|
+
losses: mapped.losses.map(({ path, disposition }) => ({ path, disposition })),
|
|
81
|
+
ambiguities: mapped.ambiguities,
|
|
82
|
+
grouped_decision: mapped.ambiguities.length === 0 ? null : {
|
|
83
|
+
action: "apply_v2_migration",
|
|
84
|
+
confirmation_option: "--yes",
|
|
85
|
+
ambiguity_count: mapped.ambiguities.length,
|
|
86
|
+
skill_count: mapped.ambiguities.reduce((count, ambiguity) => count + ambiguity.skill_ids.length, 0)
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function unchangedMigrationResult(inputBytes) {
|
|
92
|
+
return {
|
|
93
|
+
mode: "apply",
|
|
94
|
+
changed: false,
|
|
95
|
+
target_version: TARGET_VERSION,
|
|
96
|
+
input_sha256: sha256(inputBytes),
|
|
97
|
+
config_sha256: sha256(inputBytes),
|
|
98
|
+
inventory_sha256: null,
|
|
99
|
+
counts: { skills: 0, enabled: 0, disabled: 0, warnings: 0, losses: 0 },
|
|
100
|
+
skills: [],
|
|
101
|
+
warnings: [],
|
|
102
|
+
losses: [],
|
|
103
|
+
ambiguities: [],
|
|
104
|
+
grouped_decision: null,
|
|
105
|
+
backup: null,
|
|
106
|
+
manifest: null
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { loadWorkspace } from "../workspace.mjs";
|
|
4
|
+
import { mapV1ConfigToV2 } from "./v1-to-v2.mjs";
|
|
5
|
+
import {
|
|
6
|
+
atomicWrite,
|
|
7
|
+
canonicalMigrationPaths,
|
|
8
|
+
createBackup,
|
|
9
|
+
optionalRead,
|
|
10
|
+
relativeInventoryPath,
|
|
11
|
+
restoreSnapshot,
|
|
12
|
+
sha256,
|
|
13
|
+
withConfigLock
|
|
14
|
+
} from "./v2-files.mjs";
|
|
15
|
+
import {
|
|
16
|
+
beginMigrationTransaction,
|
|
17
|
+
commitMigrationTransaction,
|
|
18
|
+
completeMigrationRecovery,
|
|
19
|
+
finishMigrationTransaction,
|
|
20
|
+
recoverInterruptedMigration
|
|
21
|
+
} from "./v2-journal.mjs";
|
|
22
|
+
import {
|
|
23
|
+
migrationInventory,
|
|
24
|
+
migrationReport,
|
|
25
|
+
parseMigrationConfig,
|
|
26
|
+
renderMigratedPolicy,
|
|
27
|
+
unchangedMigrationResult
|
|
28
|
+
} from "./v2-projection.mjs";
|
|
29
|
+
|
|
30
|
+
const TARGET_VERSION = 2;
|
|
31
|
+
|
|
32
|
+
export async function migrateV2(options) {
|
|
33
|
+
const { configPath, inventoryPath } = await canonicalMigrationPaths(options.configPath, options.inventoryPath);
|
|
34
|
+
await recoverInterruptedMigration(configPath, inventoryPath, options.failpoint);
|
|
35
|
+
return await withConfigLock(configPath, async () => {
|
|
36
|
+
if (options.rollbackPath !== undefined) {
|
|
37
|
+
const requestedRollbackPath = isAbsolute(options.rollbackPath)
|
|
38
|
+
? resolve(options.rollbackPath)
|
|
39
|
+
: resolve(dirname(configPath), options.rollbackPath);
|
|
40
|
+
const rollbackPath = await realpath(requestedRollbackPath);
|
|
41
|
+
return await rollbackMigration(configPath, inventoryPath, rollbackPath, options);
|
|
42
|
+
}
|
|
43
|
+
return await migrateForward(configPath, inventoryPath, options);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function migrateForward(configPath, inventoryPath, options) {
|
|
48
|
+
const inputBytes = await readFile(configPath);
|
|
49
|
+
const inputText = inputBytes.toString("utf8");
|
|
50
|
+
const { document, parsed } = parseMigrationConfig(inputText);
|
|
51
|
+
const version = parsed.version ?? 1;
|
|
52
|
+
if (version === TARGET_VERSION) {
|
|
53
|
+
await validateCurrentV2(configPath, inventoryPath, options.skillsRoot);
|
|
54
|
+
return unchangedMigrationResult(inputBytes);
|
|
55
|
+
}
|
|
56
|
+
if (version !== 1) {
|
|
57
|
+
throw new Error(`Unsupported config version: ${String(version)}. No files were changed.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mapped = mapV1ConfigToV2(parsed);
|
|
61
|
+
const configBytes = Buffer.from(renderMigratedPolicy(mapped.policy, inputText, document));
|
|
62
|
+
const inventory = migrationInventory(mapped.inventory, inputBytes, mapped.losses);
|
|
63
|
+
const inventoryBytes = Buffer.from(`${JSON.stringify(inventory, null, 2)}\n`);
|
|
64
|
+
const report = migrationReport(parsed, mapped, inputBytes, configBytes, inventoryBytes);
|
|
65
|
+
if (options.apply !== true) {
|
|
66
|
+
return { mode: "preview", changed: true, backup: null, ...report };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const previousInventory = await optionalRead(inventoryPath);
|
|
70
|
+
const backup = await createBackup(configPath, inputBytes, inventoryPath, previousInventory);
|
|
71
|
+
const journalPath = await beginMigrationTransaction(configPath, inventoryPath, backup);
|
|
72
|
+
try {
|
|
73
|
+
await atomicWrite(configPath, configBytes, backup.configMode);
|
|
74
|
+
failAt(options, "after-config-rename");
|
|
75
|
+
failAt(options, "after-config-write");
|
|
76
|
+
await atomicWrite(inventoryPath, inventoryBytes, backup.inventoryMode ?? 0o600);
|
|
77
|
+
failAt(options, "after-inventory-rename");
|
|
78
|
+
failAt(options, "before-validation");
|
|
79
|
+
await validateCurrentV2(configPath, inventoryPath, options.skillsRoot);
|
|
80
|
+
await commitMigrationTransaction(journalPath);
|
|
81
|
+
failAt(options, "after-commit-journal");
|
|
82
|
+
await finishMigrationTransaction(journalPath);
|
|
83
|
+
return {
|
|
84
|
+
mode: "apply",
|
|
85
|
+
changed: true,
|
|
86
|
+
backup: basename(backup.configBackupPath),
|
|
87
|
+
manifest: basename(backup.manifestPath),
|
|
88
|
+
...report
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await restoreSnapshot(configPath, inventoryPath, backup);
|
|
92
|
+
await completeMigrationRecovery(journalPath, backup);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function rollbackMigration(configPath, inventoryPath, configBackupPath, options) {
|
|
98
|
+
assertAdjacentBackup(configPath, configBackupPath);
|
|
99
|
+
const manifestPath = `${configBackupPath}.manifest.json`;
|
|
100
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
101
|
+
validateManifest(manifest, configPath, inventoryPath, configBackupPath);
|
|
102
|
+
const currentConfig = await readFile(configPath);
|
|
103
|
+
const currentInventory = await optionalRead(inventoryPath);
|
|
104
|
+
const snapshot = {
|
|
105
|
+
configBackupPath,
|
|
106
|
+
manifestPath,
|
|
107
|
+
inventoryBackupPath: manifest.inventory_backup === null
|
|
108
|
+
? null
|
|
109
|
+
: join(dirname(configBackupPath), manifest.inventory_backup),
|
|
110
|
+
inventoryExisted: manifest.inventory_existed,
|
|
111
|
+
configMode: manifest.config_mode,
|
|
112
|
+
inventoryMode: manifest.inventory_mode
|
|
113
|
+
};
|
|
114
|
+
const backupBytes = await readFile(configBackupPath);
|
|
115
|
+
if (sha256(backupBytes) !== manifest.input_sha256) {
|
|
116
|
+
throw new Error("Migration backup hash mismatch; rollback was not attempted.");
|
|
117
|
+
}
|
|
118
|
+
if (snapshot.inventoryExisted) {
|
|
119
|
+
const inventoryBytes = await readFile(snapshot.inventoryBackupPath);
|
|
120
|
+
if (sha256(inventoryBytes) !== manifest.inventory_input_sha256) {
|
|
121
|
+
throw new Error("Migration inventory backup hash mismatch; rollback was not attempted.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const safety = await createBackup(configPath, currentConfig, inventoryPath, currentInventory, "rollback-safety");
|
|
126
|
+
const journalPath = await beginMigrationTransaction(configPath, inventoryPath, safety);
|
|
127
|
+
try {
|
|
128
|
+
await restoreSnapshot(configPath, inventoryPath, snapshot, {
|
|
129
|
+
afterConfigRename: () => failAt(options, "after-config-rename"),
|
|
130
|
+
afterInventoryRename: () => failAt(options, "after-inventory-rename")
|
|
131
|
+
});
|
|
132
|
+
await commitMigrationTransaction(journalPath);
|
|
133
|
+
failAt(options, "after-commit-journal");
|
|
134
|
+
await finishMigrationTransaction(journalPath);
|
|
135
|
+
return {
|
|
136
|
+
mode: "rollback",
|
|
137
|
+
changed: true,
|
|
138
|
+
target_version: manifest.source_version,
|
|
139
|
+
input_sha256: sha256(currentConfig),
|
|
140
|
+
config_sha256: sha256(await readFile(configPath)),
|
|
141
|
+
inventory_sha256: (await optionalRead(inventoryPath)) === null
|
|
142
|
+
? null
|
|
143
|
+
: sha256(await readFile(inventoryPath)),
|
|
144
|
+
backup: basename(configBackupPath),
|
|
145
|
+
safety_backup: basename(safety.configBackupPath)
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await restoreSnapshot(configPath, inventoryPath, safety);
|
|
149
|
+
await completeMigrationRecovery(journalPath, safety);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function validateCurrentV2(configPath, inventoryPath, skillsRoot) {
|
|
155
|
+
const workspace = await loadWorkspace({ configPath, inventoryPath, skillsRoot });
|
|
156
|
+
if (workspace.version !== TARGET_VERSION) {
|
|
157
|
+
throw new Error("Post-write validation did not load version 2 policy.");
|
|
158
|
+
}
|
|
159
|
+
if (workspace.inventory?.integrityErrors?.length > 0) {
|
|
160
|
+
throw new Error("Post-write inventory validation failed.");
|
|
161
|
+
}
|
|
162
|
+
const policyIds = workspace.skills.map(({ id }) => id).sort((left, right) => left.localeCompare(right));
|
|
163
|
+
const inventoryIds = [...(workspace.inventory?.skillIds ?? [])].sort((left, right) => left.localeCompare(right));
|
|
164
|
+
if (policyIds.length !== inventoryIds.length
|
|
165
|
+
|| policyIds.some((id, index) => id !== inventoryIds[index])) {
|
|
166
|
+
throw new Error("Post-write policy and inventory skill-set agreement failed.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateManifest(manifest, configPath, inventoryPath, backupPath) {
|
|
171
|
+
if (manifest?.format_version !== 1 || manifest.config_backup !== basename(backupPath)) {
|
|
172
|
+
throw new Error("Invalid migration backup manifest; rollback was not attempted.");
|
|
173
|
+
}
|
|
174
|
+
if (manifest.target_config !== basename(configPath)) {
|
|
175
|
+
throw new Error("Migration backup belongs to a different config; rollback was not attempted.");
|
|
176
|
+
}
|
|
177
|
+
const requestedInventory = relativeInventoryPath(configPath, inventoryPath);
|
|
178
|
+
if (manifest.target_inventory !== requestedInventory) {
|
|
179
|
+
throw new Error("Requested inventory target does not match the migration manifest; rollback was not attempted.");
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isInteger(manifest.config_mode) || manifest.config_mode < 0 || manifest.config_mode > 0o777
|
|
182
|
+
|| (manifest.inventory_existed === true
|
|
183
|
+
&& (!Number.isInteger(manifest.inventory_mode) || manifest.inventory_mode < 0 || manifest.inventory_mode > 0o777))) {
|
|
184
|
+
throw new Error("Invalid migration backup mode metadata; rollback was not attempted.");
|
|
185
|
+
}
|
|
186
|
+
if (manifest.inventory_existed === true) {
|
|
187
|
+
const expected = `${basename(backupPath)}.inventory`;
|
|
188
|
+
if (manifest.inventory_backup !== expected || basename(manifest.inventory_backup) !== manifest.inventory_backup) {
|
|
189
|
+
throw new Error("Invalid migration inventory backup reference; rollback was not attempted.");
|
|
190
|
+
}
|
|
191
|
+
} else if (manifest.inventory_existed !== false || manifest.inventory_backup !== null) {
|
|
192
|
+
throw new Error("Invalid migration inventory backup state; rollback was not attempted.");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assertAdjacentBackup(configPath, backupPath) {
|
|
197
|
+
if (dirname(configPath) !== dirname(backupPath) || !basename(backupPath).startsWith(`${basename(configPath)}.`)) {
|
|
198
|
+
throw new Error("Migration rollback backup must be adjacent to the selected config.");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function failAt(options, point) {
|
|
203
|
+
if (options.failpoint === `terminate-${point}`) process.exit(86);
|
|
204
|
+
if (options.failpoint === point) throw new Error(`Injected migration failure at ${point}.`);
|
|
205
|
+
}
|
package/src/policy.mjs
CHANGED