taskchef 7.28.1 → 8.0.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.
@@ -0,0 +1,99 @@
1
+ import { open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { taskChefError } from "./state-store.js";
4
+
5
+ export async function appendAuditUnlocked(workspace, record) {
6
+ const filePath = path.join(workspace, "config-audit.jsonl");
7
+ const size = await stat(filePath).then((details) => details.size).catch((error) => {
8
+ if (error.code === "ENOENT") return 0;
9
+ throw error;
10
+ });
11
+ if (size >= 10 * 1024 * 1024) {
12
+ const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
13
+ await rename(filePath, path.join(workspace, `config-audit.${timestamp}.jsonl`));
14
+ const archives = (await readdir(workspace))
15
+ .filter((name) => /^config-audit\..+\.jsonl$/.test(name))
16
+ .sort()
17
+ .reverse();
18
+ for (const name of archives.slice(10)) await unlink(path.join(workspace, name));
19
+ }
20
+ const handle = await open(filePath, "a", 0o600);
21
+ try {
22
+ await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...record })}\n`);
23
+ await handle.sync();
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ async function readLatestAuditRecordsUnlocked(workspace) {
30
+ const filePath = path.join(workspace, "config-audit.jsonl");
31
+ const content = await readFile(filePath, "utf8").catch((error) => {
32
+ if (error.code === "ENOENT") return "";
33
+ throw error;
34
+ });
35
+ const latest = new Map();
36
+ for (const [index, line] of content.trimEnd().split("\n").entries()) {
37
+ if (line.length === 0) continue;
38
+ let record;
39
+ try {
40
+ record = JSON.parse(line);
41
+ } catch {
42
+ throw taskChefError(
43
+ "STATE_RECOVERY_REQUIRED",
44
+ `configuration audit is malformed at line ${index + 1}`,
45
+ );
46
+ }
47
+ if (record.transactionId) latest.set(record.transactionId, record);
48
+ }
49
+ return latest;
50
+ }
51
+
52
+ export async function validateAuditUnlocked(workspace) {
53
+ await readLatestAuditRecordsUnlocked(workspace);
54
+ }
55
+
56
+ export async function reconcileAuditUnlocked(workspace, currentHash) {
57
+ const latest = await readLatestAuditRecordsUnlocked(workspace);
58
+ for (const record of latest.values()) {
59
+ if (record.phase !== "prepared" && record.phase !== "recovery-required") continue;
60
+ if (currentHash === record.beforeHash) {
61
+ await appendAuditUnlocked(workspace, {
62
+ ...record,
63
+ timestamp: new Date().toISOString(),
64
+ phase: "aborted",
65
+ reconciliation: "matched-before",
66
+ });
67
+ } else if (currentHash === record.afterHash) {
68
+ await appendAuditUnlocked(workspace, {
69
+ ...record,
70
+ timestamp: new Date().toISOString(),
71
+ phase: "committed",
72
+ reconciliation: "matched-after",
73
+ });
74
+ } else {
75
+ throw taskChefError(
76
+ "STATE_RECOVERY_REQUIRED",
77
+ `configuration does not match either side of transaction ${record.transactionId}`,
78
+ { transactionId: record.transactionId },
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ export async function supersedeAuditTransactionsUnlocked(workspace, recoveryTransactionId) {
85
+ const latest = await readLatestAuditRecordsUnlocked(workspace);
86
+ const superseded = [];
87
+ for (const record of latest.values()) {
88
+ if (record.transactionId === recoveryTransactionId
89
+ || !new Set(["prepared", "recovery-required"]).has(record.phase)) continue;
90
+ await appendAuditUnlocked(workspace, {
91
+ ...record,
92
+ timestamp: new Date().toISOString(),
93
+ phase: "superseded",
94
+ supersededBy: recoveryTransactionId,
95
+ });
96
+ superseded.push(record.transactionId);
97
+ }
98
+ return superseded;
99
+ }
@@ -0,0 +1,98 @@
1
+ import { semanticHash, taskChefError } from "./state-store.js";
2
+
3
+ function fieldsChanged(before, after) {
4
+ const fields = {};
5
+ for (const key of ["name", "description", "isGitRepository", "githubRepos"]) {
6
+ if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
7
+ fields[key] = { before: before[key] ?? null, after: after[key] ?? null };
8
+ }
9
+ }
10
+ return fields;
11
+ }
12
+
13
+ export function projectSetHash(projects) {
14
+ return semanticHash({ schemaVersion: 1, projects }, "taskchef-project-set-v1");
15
+ }
16
+
17
+ export function configHash(config) {
18
+ return semanticHash(config, "taskchef-config-v1");
19
+ }
20
+
21
+ export function projectDiff(beforeProjects, afterProjects) {
22
+ const before = new Map(beforeProjects.map((project) => [project.path, project]));
23
+ const after = new Map(afterProjects.map((project) => [project.path, project]));
24
+ const added = [];
25
+ const changed = [];
26
+ const removed = [];
27
+ for (const project of afterProjects) {
28
+ const prior = before.get(project.path);
29
+ if (!prior) added.push(project);
30
+ else {
31
+ const fields = fieldsChanged(prior, project);
32
+ if (Object.keys(fields).length) changed.push({ path: project.path, before: prior, after: project, fields });
33
+ }
34
+ }
35
+ for (const project of beforeProjects) {
36
+ if (!after.has(project.path)) removed.push(project);
37
+ }
38
+ return { added, changed, removed };
39
+ }
40
+
41
+ export function mutationPreview(workspace, operation, beforeConfig, afterConfig) {
42
+ const diff = projectDiff(beforeConfig.projects, afterConfig.projects);
43
+ const preview = {
44
+ schemaVersion: 1,
45
+ operation,
46
+ beforeConfigHash: configHash(beforeConfig),
47
+ afterConfigHash: configHash(afterConfig),
48
+ beforeProjectSetHash: projectSetHash(beforeConfig.projects),
49
+ afterProjectSetHash: projectSetHash(afterConfig.projects),
50
+ beforeCount: beforeConfig.projects.length,
51
+ afterCount: afterConfig.projects.length,
52
+ diff,
53
+ projects: afterConfig.projects,
54
+ };
55
+ return {
56
+ ...preview,
57
+ planHash: semanticHash({
58
+ schemaVersion: 1,
59
+ workspace,
60
+ operation,
61
+ beforeConfigHash: preview.beforeConfigHash,
62
+ afterConfigHash: preview.afterConfigHash,
63
+ diff,
64
+ }, "taskchef-mutation-plan-v1"),
65
+ };
66
+ }
67
+
68
+ function exactStringSet(actual, expected) {
69
+ if (!Array.isArray(actual) || new Set(actual).size !== actual.length) return false;
70
+ return actual.length === expected.length
71
+ && [...actual].sort().every((value, index) => value === [...expected].sort()[index]);
72
+ }
73
+
74
+ export function assertMutationAuthorization(preview, authorization = {}, {
75
+ destructive = preview.diff.removed.length > 0,
76
+ replacement = false,
77
+ } = {}) {
78
+ if (authorization.expectConfigHash !== preview.beforeConfigHash) {
79
+ throw taskChefError("STALE_PREVIEW", "configuration changed or --expect-config-hash is missing", preview);
80
+ }
81
+ if (!destructive && !replacement) return;
82
+ const removedNames = preview.diff.removed.map((project) => project.name);
83
+ if (authorization.expectProjectCount !== preview.beforeCount
84
+ || authorization.expectAfterCount !== preview.afterCount
85
+ || authorization.confirmPlan !== preview.planHash
86
+ || !exactStringSet(authorization.confirmRemoved, removedNames)) {
87
+ throw taskChefError(
88
+ "CONFIRMATION_REQUIRED",
89
+ "destructive project mutation requires exact preview-bound count, hash, plan, and removed-name confirmation",
90
+ preview,
91
+ );
92
+ }
93
+ const collapses = preview.beforeCount > 0
94
+ && (preview.afterCount === 0 || preview.afterCount / preview.beforeCount <= 0.5);
95
+ if (collapses && authorization.confirmCountCollapse !== `${preview.beforeCount}:${preview.afterCount}`) {
96
+ throw taskChefError("CONFIRMATION_REQUIRED", "project-count collapse requires exact before:after confirmation", preview);
97
+ }
98
+ }