knodin 0.8.7 → 0.9.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/dist/bin/cli.js +307 -85
- package/dist/fixtures/update-verification/config/application.xml +4 -0
- package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
- package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
- package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
- package/dist/fixtures/update-verification/fixture.json +147 -0
- package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
- package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
- package/dist/fixtures/update-verification/package.json +5 -0
- package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
- package/dist/fixtures/update-verification/sql/functions.sql +16 -0
- package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
- package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
- package/dist/fixtures/update-verification/src/javascript.js +7 -0
- package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
- package/dist/fixtures/update-verification/src/typescript.ts +7 -0
- package/dist/fixtures/update-verification/terraform/main.tf +7 -0
- package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
- package/dist/src/agent-integration.js +26 -3
- package/dist/src/backup-retention.js +811 -0
- package/dist/src/cli-model.js +35 -8
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/doctor.js +142 -12
- package/dist/src/engine/embeddings.js +8 -1
- package/dist/src/engine/index.js +355 -292
- package/dist/src/init.js +222 -179
- package/dist/src/manager-update.js +455 -0
- package/dist/src/release-preflight.js +106 -95
- package/dist/src/repair-lease.js +77 -8
- package/dist/src/response-budget.js +5 -1
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/compatibility.js +4 -2
- package/dist/src/tools/knodin-tools.js +69 -10
- package/dist/src/update-ceremony.js +20 -24
- package/dist/src/update-coordination.js +158 -0
- package/dist/src/update-executor.js +336 -0
- package/dist/src/update-verifier.js +358 -0
- package/docs/BACKUP-RETENTION.md +54 -0
- package/docs/CLI.md +6 -0
- package/docs/DOCTOR-AND-UPDATES.md +44 -12
- package/docs/SIGNED-UPDATES.md +29 -22
- package/docs/releases/0.9.0.md +43 -0
- package/package.json +7 -1
- package/roadmap/competitive-roadmap.md +8 -0
|
@@ -24,6 +24,7 @@ import { diagnoseFailure, } from "../failure-diagnosis.js";
|
|
|
24
24
|
import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../graph-query-health.js";
|
|
25
25
|
import { inspectRepositoryIntegrationStatus } from "../init.js";
|
|
26
26
|
import { attachLifecycleHealth, attachRepairLifecycle } from "../lifecycle-health.js";
|
|
27
|
+
import { readManagerUpdateState, readUpdateJournal, updateAttention } from "../manager-update.js";
|
|
27
28
|
import { listMirrors } from "../mirror.js";
|
|
28
29
|
import { compressOutput, compressOutputFile, deleteOutputArtifact, readOutputArtifact, } from "../output-compression.js";
|
|
29
30
|
import { appendTelemetryRecord, clearTelemetry, countOutputTokens, exportTelemetry, measureOutput, readTelemetryRecords, telemetryStatus, writeTelemetryReport, } from "../output-telemetry.js";
|
|
@@ -158,6 +159,13 @@ const GRAPH_INDEPENDENT_QUERY_PATTERNS = new Set([
|
|
|
158
159
|
"lsp_implementations",
|
|
159
160
|
"file_metrics",
|
|
160
161
|
]);
|
|
162
|
+
const BATCH_QUERY_PATTERNS = new Set([
|
|
163
|
+
"callers_of",
|
|
164
|
+
"callees_of",
|
|
165
|
+
"imports_of",
|
|
166
|
+
"importers_of",
|
|
167
|
+
"file_summary",
|
|
168
|
+
]);
|
|
161
169
|
function buildDocumentedKnodinTools() {
|
|
162
170
|
return [
|
|
163
171
|
{
|
|
@@ -514,8 +522,14 @@ function buildDocumentedKnodinTools() {
|
|
|
514
522
|
repositories: { type: "array", items: { type: "string" } },
|
|
515
523
|
scope: { type: "string", enum: ["personal", "team"] },
|
|
516
524
|
distributed: { type: "boolean" },
|
|
517
|
-
repositoryIds: {
|
|
518
|
-
|
|
525
|
+
repositoryIds: {
|
|
526
|
+
type: "object",
|
|
527
|
+
additionalProperties: { type: "string" },
|
|
528
|
+
},
|
|
529
|
+
components: {
|
|
530
|
+
type: "object",
|
|
531
|
+
additionalProperties: { type: "object" },
|
|
532
|
+
},
|
|
519
533
|
evidence: { type: "string" },
|
|
520
534
|
relationshipType: { type: "string", enum: RELATIONSHIP_TYPES },
|
|
521
535
|
requireComplete: { type: "boolean" },
|
|
@@ -814,7 +828,11 @@ async function handleRepositoriesOperation(options) {
|
|
|
814
828
|
dryRun,
|
|
815
829
|
manifestPath,
|
|
816
830
|
indexMode: (target) => indexModeForPath(loadSystemConfiguration(target), target),
|
|
817
|
-
isolatedInitialize: (target) => runRepositoryInitializationProcess({
|
|
831
|
+
isolatedInitialize: (target) => runRepositoryInitializationProcess({
|
|
832
|
+
repository: target,
|
|
833
|
+
command,
|
|
834
|
+
memoryLimitBytes,
|
|
835
|
+
}),
|
|
818
836
|
});
|
|
819
837
|
const { repositories } = await inventory();
|
|
820
838
|
return bounded({ schemaVersion: 1, ...summary, repositories }, "repositories:init");
|
|
@@ -1376,7 +1394,7 @@ async function handleDiagnosticsOperation(repo, args, bounded) {
|
|
|
1376
1394
|
async function dispatchKnodinTool(args) {
|
|
1377
1395
|
const startedAt = performance.now();
|
|
1378
1396
|
const parsedArgs = (args ?? {});
|
|
1379
|
-
const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
|
|
1397
|
+
const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
|
|
1380
1398
|
const repo = repoPath ?? process.cwd();
|
|
1381
1399
|
const selector = {
|
|
1382
1400
|
identity: expandCompactIdentity(identity),
|
|
@@ -1469,7 +1487,10 @@ async function dispatchKnodinTool(args) {
|
|
|
1469
1487
|
"search",
|
|
1470
1488
|
].includes(rootOperation);
|
|
1471
1489
|
const enriched = includeSharedIndex && result && typeof result === "object" && !Array.isArray(result)
|
|
1472
|
-
? {
|
|
1490
|
+
? {
|
|
1491
|
+
...result,
|
|
1492
|
+
sharedIndex: sharedIndexEnvelope(),
|
|
1493
|
+
}
|
|
1473
1494
|
: result;
|
|
1474
1495
|
const key = `${op}${minimal ? ":minimal" : ""}`;
|
|
1475
1496
|
const output = applyResponseBudget(enriched, op, budget, RESPONSE_DEFAULTS[key] ?? RESPONSE_DEFAULTS.default);
|
|
@@ -1527,11 +1548,16 @@ async function dispatchKnodinTool(args) {
|
|
|
1527
1548
|
return engine.status(repo, { audit: statusAudit }).then((result) => bounded({
|
|
1528
1549
|
...attachLifecycleHealth(repo, result),
|
|
1529
1550
|
integration: inspectRepositoryIntegrationStatus(repo),
|
|
1530
|
-
update:
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1551
|
+
update: {
|
|
1552
|
+
...trustedUpdateStatus({
|
|
1553
|
+
currentVersion: result.version,
|
|
1554
|
+
installMethod: "unknown",
|
|
1555
|
+
env: process.env,
|
|
1556
|
+
}),
|
|
1557
|
+
managerState: readManagerUpdateState(),
|
|
1558
|
+
attention: updateAttention(readManagerUpdateState()),
|
|
1559
|
+
journal: readUpdateJournal(),
|
|
1560
|
+
},
|
|
1535
1561
|
}, "status"));
|
|
1536
1562
|
case "wait":
|
|
1537
1563
|
return bounded(await waitForFresh(engine, repo, timeoutMs), "wait");
|
|
@@ -1719,6 +1745,39 @@ async function dispatchKnodinTool(args) {
|
|
|
1719
1745
|
expectedLogin,
|
|
1720
1746
|
}), "prs");
|
|
1721
1747
|
}
|
|
1748
|
+
case "query_batch": {
|
|
1749
|
+
if (!process.env.KNODIN_UPDATE_VERIFICATION_TOKEN)
|
|
1750
|
+
throw new Error("knodin query_batch is restricted to the isolated update verifier");
|
|
1751
|
+
if (!Array.isArray(queries) || queries.length < 1 || queries.length > 32)
|
|
1752
|
+
throw new Error("knodin query_batch requires 1-32 `queries`");
|
|
1753
|
+
for (const item of queries) {
|
|
1754
|
+
if (!item ||
|
|
1755
|
+
typeof item.symbol !== "string" ||
|
|
1756
|
+
item.symbol.length === 0 ||
|
|
1757
|
+
!BATCH_QUERY_PATTERNS.has(item.pattern))
|
|
1758
|
+
throw new Error("knodin query_batch accepts only non-empty callers/callees/imports/importers/file-summary queries");
|
|
1759
|
+
}
|
|
1760
|
+
const queryHealth = await inspectGatewayGraphHealth(repo);
|
|
1761
|
+
if (!queryHealth.available)
|
|
1762
|
+
return bounded(queryHealth, "query_batch");
|
|
1763
|
+
const outputs = [];
|
|
1764
|
+
for (const item of queries) {
|
|
1765
|
+
outputs.push({
|
|
1766
|
+
pattern: item.pattern,
|
|
1767
|
+
symbol: item.symbol,
|
|
1768
|
+
output: await engine.query(item.pattern, item.symbol, repo, undefined, Math.min(limit ?? itemBudget ?? 100, itemBudget ?? 1000)),
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
const verifiedQueryHealth = await inspectGatewayGraphHealth(repo);
|
|
1772
|
+
if (!verifiedQueryHealth.available)
|
|
1773
|
+
return bounded(verifiedQueryHealth, "query_batch");
|
|
1774
|
+
return bounded({
|
|
1775
|
+
results: outputs.map((item) => ({
|
|
1776
|
+
...item,
|
|
1777
|
+
output: decorateGraphQueryResult(item.output, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness),
|
|
1778
|
+
})),
|
|
1779
|
+
}, "query_batch");
|
|
1780
|
+
}
|
|
1722
1781
|
case "query": {
|
|
1723
1782
|
if (!pattern)
|
|
1724
1783
|
throw new Error("knodin query requires `pattern`");
|
|
@@ -40,7 +40,7 @@ export function validateRootCeremonyManifest(value) {
|
|
|
40
40
|
manifest.productionActivation !== false)
|
|
41
41
|
throw new Error("invalid fail-closed ceremony header");
|
|
42
42
|
if (!Array.isArray(manifest.custodians) ||
|
|
43
|
-
manifest.custodians.length <
|
|
43
|
+
manifest.custodians.length < 1 ||
|
|
44
44
|
new Set(manifest.custodians.map((c) => c.id)).size !== manifest.custodians.length ||
|
|
45
45
|
manifest.custodians.some((c) => {
|
|
46
46
|
exactKeys(c, c.role === "root-signer"
|
|
@@ -54,7 +54,7 @@ export function validateRootCeremonyManifest(value) {
|
|
|
54
54
|
!Array.isArray(c.recoveryKeyIds) ||
|
|
55
55
|
c.recoveryKeyIds.some((id) => !SHA256.test(id)))));
|
|
56
56
|
}))
|
|
57
|
-
throw new Error("at least
|
|
57
|
+
throw new Error("at least one named maintainer custodian is required");
|
|
58
58
|
const roles = ["root", "targets", "snapshot", "timestamp"];
|
|
59
59
|
exactKeys(manifest.roleKeyIds, roles, "roleKeyIds");
|
|
60
60
|
exactKeys(manifest.thresholds, roles, "thresholds");
|
|
@@ -68,14 +68,11 @@ export function validateRootCeremonyManifest(value) {
|
|
|
68
68
|
throw new Error("role keys must be valid and disjoint");
|
|
69
69
|
for (const id of ids)
|
|
70
70
|
all.add(id);
|
|
71
|
-
if (!Number.isInteger(threshold) ||
|
|
72
|
-
threshold < (role === "root" || role === "targets" ? 2 : 1) ||
|
|
73
|
-
threshold > ids.length)
|
|
71
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > ids.length)
|
|
74
72
|
throw new Error(`invalid ${role} threshold`);
|
|
75
73
|
}
|
|
76
|
-
if (manifest.custodians.filter((c) => c.role === "root-signer").length < manifest.thresholds.root
|
|
77
|
-
|
|
78
|
-
throw new Error("custodian roles do not satisfy threshold and recovery separation");
|
|
74
|
+
if (manifest.custodians.filter((c) => c.role === "root-signer").length < manifest.thresholds.root)
|
|
75
|
+
throw new Error("maintainer custodians do not satisfy the root threshold");
|
|
79
76
|
const assigned = manifest.custodians
|
|
80
77
|
.filter((c) => c.role === "root-signer")
|
|
81
78
|
.map((c) => c.publicKeyId)
|
|
@@ -85,15 +82,9 @@ export function validateRootCeremonyManifest(value) {
|
|
|
85
82
|
if (assigned.join("\0") !== [...manifest.roleKeyIds.root].sort(compareBytes).join("\0"))
|
|
86
83
|
throw new Error("root signer assignments must exactly match root role keys");
|
|
87
84
|
const recovery = manifest.custodians.filter((c) => c.role === "recovery");
|
|
88
|
-
if (recovery.
|
|
89
|
-
|
|
90
|
-
recovery
|
|
91
|
-
new Set(c.recoveryKeyIds).size !== c.recoveryKeyIds.length ||
|
|
92
|
-
c.recoveryKeyIds.length >= manifest.thresholds.root))
|
|
93
|
-
throw new Error("recovery sets must preserve root threshold independence");
|
|
94
|
-
const recovered = new Set(recovery.flatMap((c) => c.recoveryKeyIds ?? []));
|
|
95
|
-
if (manifest.roleKeyIds.root.some((id) => !recovered.has(id)))
|
|
96
|
-
throw new Error("recovery assignments must cover every root role key");
|
|
85
|
+
if (new Set(recovery.map((c) => c.evidenceId)).size !== recovery.length ||
|
|
86
|
+
recovery.some((c) => !c.recoveryKeyIds?.length || new Set(c.recoveryKeyIds).size !== c.recoveryKeyIds.length))
|
|
87
|
+
throw new Error("recovery assignments must be unique and non-empty when declared");
|
|
97
88
|
return manifest;
|
|
98
89
|
}
|
|
99
90
|
export function validateRootPinReviewReceipt(value, manifest) {
|
|
@@ -115,20 +106,21 @@ export function validateRootPinReviewReceipt(value, manifest) {
|
|
|
115
106
|
receipt.ceremonyId !== manifest.ceremonyId ||
|
|
116
107
|
!SHA256.test(receipt.rootEnvelopeSha256) ||
|
|
117
108
|
!ID.test(receipt.reviewerId) ||
|
|
118
|
-
manifest.custodians.some((c) => c.id === receipt.reviewerId) ||
|
|
119
109
|
receipt.decision !== "approved" ||
|
|
120
|
-
|
|
110
|
+
(manifest.purpose === "rehearsal"
|
|
111
|
+
? receipt.authentication !== "rehearsal-unsigned" ||
|
|
112
|
+
manifest.custodians.some((c) => c.id === receipt.reviewerId)
|
|
113
|
+
: receipt.authentication !== "maintainer-attested" ||
|
|
114
|
+
!manifest.custodians.some((c) => c.id === receipt.reviewerId)) ||
|
|
121
115
|
receipt.productionActivation !== false ||
|
|
122
116
|
!Number.isFinite(Date.parse(receipt.reviewedAt)) ||
|
|
123
117
|
new Date(receipt.reviewedAt).toISOString() !== receipt.reviewedAt)
|
|
124
|
-
throw new Error("invalid
|
|
118
|
+
throw new Error("invalid root-pin attestation receipt");
|
|
125
119
|
return receipt;
|
|
126
120
|
}
|
|
127
121
|
export function preparePublicRootImport(envelope, pin, manifestValue, receiptValue, now = new Date()) {
|
|
128
122
|
rejectPrivateMaterial(envelope, "rootEnvelope");
|
|
129
123
|
const manifest = validateRootCeremonyManifest(manifestValue);
|
|
130
|
-
if (manifest.purpose === "production")
|
|
131
|
-
throw new Error("production import requires externally authenticated review and remains blocked");
|
|
132
124
|
const receipt = validateRootPinReviewReceipt(receiptValue, manifest);
|
|
133
125
|
if (Date.parse(receipt.reviewedAt) > now.getTime())
|
|
134
126
|
throw new Error("pin-review receipt cannot be future-dated");
|
|
@@ -156,7 +148,11 @@ export function preparePublicRootImport(envelope, pin, manifestValue, receiptVal
|
|
|
156
148
|
}
|
|
157
149
|
const digest = createHash("sha256").update(canonicalizeUpdateMetadata(envelope)).digest("hex");
|
|
158
150
|
if (pin !== digest || receipt.rootEnvelopeSha256 !== digest)
|
|
159
|
-
throw new Error("root envelope, pin, and
|
|
151
|
+
throw new Error("root envelope, pin, and maintainer attestation disagree");
|
|
160
152
|
verifyUpdateRootChain(envelope, pin, [], now);
|
|
161
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
rootEnvelope: envelope,
|
|
155
|
+
rootEnvelopeSha256: digest,
|
|
156
|
+
productionActivation: false,
|
|
157
|
+
};
|
|
162
158
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readUpdateJournal } from "./manager-update.js";
|
|
7
|
+
function coordinationRoot(stateHome) {
|
|
8
|
+
const root = stateHome ?? process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state");
|
|
9
|
+
return path.join(root, "knodin", "coordination");
|
|
10
|
+
}
|
|
11
|
+
function processStartToken(pid) {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
return execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`], { encoding: "utf8", timeout: 2_000 }).trim();
|
|
15
|
+
}
|
|
16
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
timeout: 2_000,
|
|
19
|
+
}).trim();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function readLease(file) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = fs.lstatSync(file);
|
|
28
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
|
|
29
|
+
return null;
|
|
30
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
31
|
+
return value.schemaVersion === 1 && typeof value.nonce === "string" ? value : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function live(record) {
|
|
38
|
+
return Boolean(record && processStartToken(record.pid) === record.processStartToken);
|
|
39
|
+
}
|
|
40
|
+
function writeExclusive(file, record) {
|
|
41
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
42
|
+
const descriptor = fs.openSync(file, "wx", 0o600);
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
|
|
45
|
+
fs.fsyncSync(descriptor);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
fs.closeSync(descriptor);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function releaseOwned(file, nonce) {
|
|
52
|
+
const current = readLease(file);
|
|
53
|
+
if (current?.nonce !== nonce)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(file);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code !== "ENOENT")
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function updateLeaseFile(stateHome) {
|
|
64
|
+
return path.join(coordinationRoot(stateHome), "update.lock");
|
|
65
|
+
}
|
|
66
|
+
function lifecycleDirectory(stateHome) {
|
|
67
|
+
return path.join(coordinationRoot(stateHome), "lifecycle");
|
|
68
|
+
}
|
|
69
|
+
function tokenHash(token) {
|
|
70
|
+
return createHash("sha256").update(token).digest("hex");
|
|
71
|
+
}
|
|
72
|
+
function activeLifecycleLeases(stateHome) {
|
|
73
|
+
const directory = lifecycleDirectory(stateHome);
|
|
74
|
+
let names = [];
|
|
75
|
+
try {
|
|
76
|
+
names = fs.readdirSync(directory).slice(0, 4096);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const active = [];
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const file = path.join(directory, name);
|
|
84
|
+
const record = readLease(file);
|
|
85
|
+
if (live(record))
|
|
86
|
+
active.push(record);
|
|
87
|
+
else
|
|
88
|
+
releaseOwned(file, record?.nonce ?? "");
|
|
89
|
+
}
|
|
90
|
+
return active;
|
|
91
|
+
}
|
|
92
|
+
export function acquireLifecycleCoordination(options) {
|
|
93
|
+
const updateFile = updateLeaseFile(options.stateHome);
|
|
94
|
+
const update = readLease(updateFile);
|
|
95
|
+
if (live(update)) {
|
|
96
|
+
const permitted = options.verificationToken &&
|
|
97
|
+
update?.verificationTokenHash === tokenHash(options.verificationToken);
|
|
98
|
+
if (!permitted)
|
|
99
|
+
throw new Error("an executable update is active; retry lifecycle work after it completes");
|
|
100
|
+
}
|
|
101
|
+
else if (update) {
|
|
102
|
+
const journal = readUpdateJournal(options.stateHome);
|
|
103
|
+
if (journal && !["candidate-verified", "rollback-verified"].includes(journal.state)) {
|
|
104
|
+
throw new Error(`an interrupted update is ${journal.state}; run \`knodin update status\` to recover it`);
|
|
105
|
+
}
|
|
106
|
+
releaseOwned(updateFile, update.nonce);
|
|
107
|
+
}
|
|
108
|
+
const nonce = randomUUID();
|
|
109
|
+
const record = {
|
|
110
|
+
schemaVersion: 1,
|
|
111
|
+
kind: "lifecycle",
|
|
112
|
+
nonce,
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
processStartToken: processStartToken(process.pid) ?? "unavailable",
|
|
115
|
+
command: options.command,
|
|
116
|
+
repository: fs.realpathSync(options.repository),
|
|
117
|
+
startedAt: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
const file = path.join(lifecycleDirectory(options.stateHome), `${process.pid}-${nonce}.json`);
|
|
120
|
+
writeExclusive(file, record);
|
|
121
|
+
return { record, release: () => releaseOwned(file, nonce) };
|
|
122
|
+
}
|
|
123
|
+
export function acquireExclusiveUpdateCoordination(options) {
|
|
124
|
+
const active = activeLifecycleLeases(options.stateHome);
|
|
125
|
+
if (active.length > 0) {
|
|
126
|
+
throw new Error(`executable replacement deferred while ${active.length} lifecycle operation(s) are active`);
|
|
127
|
+
}
|
|
128
|
+
const file = updateLeaseFile(options.stateHome);
|
|
129
|
+
const existing = readLease(file);
|
|
130
|
+
if (live(existing))
|
|
131
|
+
throw new Error("another executable update is active");
|
|
132
|
+
if (existing) {
|
|
133
|
+
const journal = readUpdateJournal(options.stateHome);
|
|
134
|
+
if (journal &&
|
|
135
|
+
!["candidate-verified", "rollback-verified"].includes(journal.state) &&
|
|
136
|
+
!options.recovery) {
|
|
137
|
+
throw new Error(`interrupted update recovery required from journal state ${journal.state}`);
|
|
138
|
+
}
|
|
139
|
+
releaseOwned(file, existing.nonce);
|
|
140
|
+
}
|
|
141
|
+
const nonce = randomUUID();
|
|
142
|
+
const record = {
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
kind: "update",
|
|
145
|
+
nonce,
|
|
146
|
+
pid: process.pid,
|
|
147
|
+
processStartToken: processStartToken(process.pid) ?? "unavailable",
|
|
148
|
+
command: "update",
|
|
149
|
+
startedAt: new Date().toISOString(),
|
|
150
|
+
verificationTokenHash: tokenHash(options.verificationToken),
|
|
151
|
+
};
|
|
152
|
+
writeExclusive(file, record);
|
|
153
|
+
return { record, release: () => releaseOwned(file, nonce) };
|
|
154
|
+
}
|
|
155
|
+
export function coordinationStatus(stateHome) {
|
|
156
|
+
const update = readLease(updateLeaseFile(stateHome));
|
|
157
|
+
return { update: live(update) ? update : null, lifecycle: activeLifecycleLeases(stateHome) };
|
|
158
|
+
}
|