knodin 0.8.6 → 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/README.md +6 -0
- package/dist/bin/cli.js +459 -95
- package/dist/bin/launcher.js +0 -0
- 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 +66 -9
- 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 +223 -164
- 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/repository-management.js +219 -3
- 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/skill-management.js +149 -0
- package/dist/src/system-config.js +176 -16
- package/dist/src/system-management.js +508 -0
- package/dist/src/tools/knodin-tools.js +174 -22
- 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 +13 -0
- package/docs/DOCTOR-AND-UPDATES.md +44 -12
- package/docs/SIGNED-UPDATES.md +29 -22
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +27 -6
- package/docs/prompts/declare-multi-repository-system.md +18 -0
- package/docs/releases/0.8.7.md +27 -0
- package/docs/releases/0.9.0.md +43 -0
- package/package.json +11 -2
- package/roadmap/competitive-roadmap.md +8 -0
- package/skills/knodin-debug/SKILL.md +12 -0
- package/skills/knodin-debug/agents/openai.yaml +4 -0
- package/skills/knodin-explore/SKILL.md +11 -0
- package/skills/knodin-explore/agents/openai.yaml +4 -0
- package/skills/knodin-impact/SKILL.md +11 -0
- package/skills/knodin-impact/agents/openai.yaml +4 -0
- package/skills/knodin-lifecycle/SKILL.md +12 -0
- package/skills/knodin-lifecycle/agents/openai.yaml +4 -0
- package/skills/knodin-refactor/SKILL.md +12 -0
- package/skills/knodin-refactor/agents/openai.yaml +4 -0
- package/skills/knodin-review/SKILL.md +11 -0
- package/skills/knodin-review/agents/openai.yaml +4 -0
- package/skills/knodin-systems/SKILL.md +14 -0
- package/skills/knodin-systems/agents/openai.yaml +4 -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";
|
|
@@ -35,7 +36,8 @@ import { discoverRepositories, initializeRepositories, inventoryRepository, sear
|
|
|
35
36
|
import { applyResponseBudget } from "../response-budget.js";
|
|
36
37
|
import { loadSharedIndexConfig } from "../shared-index/config.js";
|
|
37
38
|
import { EMPTY_OVERLAY, readSharedIndexProvenance, readSharedRestoreAttempt, } from "../shared-index/provenance.js";
|
|
38
|
-
import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../system-config.js";
|
|
39
|
+
import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, RELATIONSHIP_TYPES, systemMembershipsForPath, validateSystemHealth, } from "../system-config.js";
|
|
40
|
+
import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, planSystemUnlink, planSystemUnrelate, } from "../system-management.js";
|
|
39
41
|
import { trustedUpdateStatus } from "../update-policy.js";
|
|
40
42
|
import { waitForFresh } from "../wait-for-fresh.js";
|
|
41
43
|
import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../worktree-lifecycle.js";
|
|
@@ -157,6 +159,13 @@ const GRAPH_INDEPENDENT_QUERY_PATTERNS = new Set([
|
|
|
157
159
|
"lsp_implementations",
|
|
158
160
|
"file_metrics",
|
|
159
161
|
]);
|
|
162
|
+
const BATCH_QUERY_PATTERNS = new Set([
|
|
163
|
+
"callers_of",
|
|
164
|
+
"callees_of",
|
|
165
|
+
"imports_of",
|
|
166
|
+
"importers_of",
|
|
167
|
+
"file_summary",
|
|
168
|
+
]);
|
|
160
169
|
function buildDocumentedKnodinTools() {
|
|
161
170
|
return [
|
|
162
171
|
{
|
|
@@ -317,7 +326,7 @@ function buildDocumentedKnodinTools() {
|
|
|
317
326
|
},
|
|
318
327
|
from: {
|
|
319
328
|
type: "string",
|
|
320
|
-
description: "review: older ref of
|
|
329
|
+
description: "review: older ref of a revision pair; system relate/unrelate: source component id.",
|
|
321
330
|
},
|
|
322
331
|
toRevision: {
|
|
323
332
|
type: "string",
|
|
@@ -494,9 +503,36 @@ function buildDocumentedKnodinTools() {
|
|
|
494
503
|
},
|
|
495
504
|
systemAction: {
|
|
496
505
|
type: "string",
|
|
497
|
-
enum: [
|
|
498
|
-
|
|
506
|
+
enum: [
|
|
507
|
+
"list",
|
|
508
|
+
"show",
|
|
509
|
+
"validate",
|
|
510
|
+
"query",
|
|
511
|
+
"link",
|
|
512
|
+
"relate",
|
|
513
|
+
"unlink",
|
|
514
|
+
"unrelate",
|
|
515
|
+
"import",
|
|
516
|
+
],
|
|
499
517
|
},
|
|
518
|
+
planDigest: {
|
|
519
|
+
type: "string",
|
|
520
|
+
description: "system writes: digest returned by the approved preview",
|
|
521
|
+
},
|
|
522
|
+
repositories: { type: "array", items: { type: "string" } },
|
|
523
|
+
scope: { type: "string", enum: ["personal", "team"] },
|
|
524
|
+
distributed: { type: "boolean" },
|
|
525
|
+
repositoryIds: {
|
|
526
|
+
type: "object",
|
|
527
|
+
additionalProperties: { type: "string" },
|
|
528
|
+
},
|
|
529
|
+
components: {
|
|
530
|
+
type: "object",
|
|
531
|
+
additionalProperties: { type: "object" },
|
|
532
|
+
},
|
|
533
|
+
evidence: { type: "string" },
|
|
534
|
+
relationshipType: { type: "string", enum: RELATIONSHIP_TYPES },
|
|
535
|
+
requireComplete: { type: "boolean" },
|
|
500
536
|
repositoryAction: {
|
|
501
537
|
type: "string",
|
|
502
538
|
enum: ["discover", "init", "status", "doctor", "search"],
|
|
@@ -731,14 +767,14 @@ const RESPONSE_DEFAULTS = {
|
|
|
731
767
|
"diagnostics:archive": { bytes: 65_536, tokens: 16_384, items: 100 },
|
|
732
768
|
default: { bytes: 65_536, tokens: 16_384, items: 100 },
|
|
733
769
|
};
|
|
734
|
-
async function populateRepositoryDiagnoses(records, repositories
|
|
770
|
+
async function populateRepositoryDiagnoses(records, repositories) {
|
|
735
771
|
repositories.length = 0;
|
|
736
772
|
for (const record of records) {
|
|
737
773
|
try {
|
|
738
774
|
const graph = attachLifecycleHealth(record.path, await engine.status(record.path, { audit: "deep" }));
|
|
739
775
|
const repository = await inventoryRepository(record, {
|
|
740
776
|
status: async () => graph,
|
|
741
|
-
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(
|
|
777
|
+
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
|
|
742
778
|
});
|
|
743
779
|
repositories.push({
|
|
744
780
|
...repository,
|
|
@@ -770,12 +806,11 @@ async function handleRepositoriesOperation(options) {
|
|
|
770
806
|
};
|
|
771
807
|
const inventory = async () => {
|
|
772
808
|
const discovery = await discoverRepositories(discoveryRoots, discoveryOptions);
|
|
773
|
-
const systemConfig = loadSystemConfiguration(discoveryRoots[0] ?? repo);
|
|
774
809
|
const repositories = [];
|
|
775
810
|
for (const record of discovery.repositories) {
|
|
776
811
|
repositories.push(await inventoryRepository(record, {
|
|
777
812
|
status: async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })),
|
|
778
|
-
systemMemberships: (target) => systemMembershipsForPath(
|
|
813
|
+
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
|
|
779
814
|
}));
|
|
780
815
|
}
|
|
781
816
|
return { discovery, repositories };
|
|
@@ -792,8 +827,12 @@ async function handleRepositoriesOperation(options) {
|
|
|
792
827
|
worktrees: linkedWorktrees,
|
|
793
828
|
dryRun,
|
|
794
829
|
manifestPath,
|
|
795
|
-
indexMode: (target) => indexModeForPath(
|
|
796
|
-
isolatedInitialize: (target) => runRepositoryInitializationProcess({
|
|
830
|
+
indexMode: (target) => indexModeForPath(loadSystemConfiguration(target), target),
|
|
831
|
+
isolatedInitialize: (target) => runRepositoryInitializationProcess({
|
|
832
|
+
repository: target,
|
|
833
|
+
command,
|
|
834
|
+
memoryLimitBytes,
|
|
835
|
+
}),
|
|
797
836
|
});
|
|
798
837
|
const { repositories } = await inventory();
|
|
799
838
|
return bounded({ schemaVersion: 1, ...summary, repositories }, "repositories:init");
|
|
@@ -801,7 +840,6 @@ async function handleRepositoriesOperation(options) {
|
|
|
801
840
|
if (repositoryAction === "search") {
|
|
802
841
|
if (!query?.trim())
|
|
803
842
|
throw new Error("knodin repositories search requires a non-empty `query`");
|
|
804
|
-
const systemConfig = loadSystemConfiguration(discoveryRoots[0] ?? repo);
|
|
805
843
|
return searchRepositories(discoveryRoots, query, {
|
|
806
844
|
...discoveryOptions,
|
|
807
845
|
include,
|
|
@@ -817,12 +855,12 @@ async function handleRepositoriesOperation(options) {
|
|
|
817
855
|
includeSource: true,
|
|
818
856
|
federate: false,
|
|
819
857
|
}),
|
|
820
|
-
systemMemberships: (target) => systemMembershipsForPath(
|
|
858
|
+
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
|
|
821
859
|
});
|
|
822
860
|
}
|
|
823
861
|
const { discovery, repositories } = await inventory();
|
|
824
862
|
if (repositoryAction === "doctor")
|
|
825
|
-
await populateRepositoryDiagnoses(discovery.repositories, repositories
|
|
863
|
+
await populateRepositoryDiagnoses(discovery.repositories, repositories);
|
|
826
864
|
return bounded({
|
|
827
865
|
schemaVersion: 1,
|
|
828
866
|
linkedWorktrees: discoveryOptions.linkedWorktrees,
|
|
@@ -833,7 +871,67 @@ async function handleRepositoriesOperation(options) {
|
|
|
833
871
|
}, `repositories:${repositoryAction}`);
|
|
834
872
|
}
|
|
835
873
|
async function handleSystemOperation(options) {
|
|
836
|
-
const { systemAction, symbol, repo,
|
|
874
|
+
const { systemAction, symbol, repo, bounded } = options;
|
|
875
|
+
if (["link", "relate", "unlink", "unrelate", "import"].includes(systemAction)) {
|
|
876
|
+
if (systemAction !== "import" && !symbol)
|
|
877
|
+
throw new Error(`knodin system ${systemAction} requires \`symbol\``);
|
|
878
|
+
const systemId = symbol ?? "";
|
|
879
|
+
let plan;
|
|
880
|
+
if (systemAction === "import") {
|
|
881
|
+
if (!options.manifestPath)
|
|
882
|
+
throw new Error("system import requires manifestPath");
|
|
883
|
+
plan = planSystemImport({
|
|
884
|
+
manifestPath: options.manifestPath,
|
|
885
|
+
scope: options.scope,
|
|
886
|
+
repoPath: repo,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
else if (systemAction === "link")
|
|
890
|
+
plan = planSystemLink({
|
|
891
|
+
systemId,
|
|
892
|
+
repositories: options.repositories ?? [],
|
|
893
|
+
scope: options.scope,
|
|
894
|
+
distributed: options.distributed,
|
|
895
|
+
repositoryIds: options.repositoryIds,
|
|
896
|
+
components: options.components,
|
|
897
|
+
});
|
|
898
|
+
else if (systemAction === "relate") {
|
|
899
|
+
if (!options.relationshipType || !options.from || !options.to || !options.evidence)
|
|
900
|
+
throw new Error("system relate requires relationshipType, from, to, and evidence");
|
|
901
|
+
plan = planSystemRelate({
|
|
902
|
+
systemId,
|
|
903
|
+
type: options.relationshipType,
|
|
904
|
+
from: options.from,
|
|
905
|
+
to: options.to,
|
|
906
|
+
evidence: options.evidence,
|
|
907
|
+
repoPath: repo,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
else if (systemAction === "unlink")
|
|
911
|
+
plan = planSystemUnlink({
|
|
912
|
+
systemId,
|
|
913
|
+
repositories: options.repositories,
|
|
914
|
+
repoPath: repo,
|
|
915
|
+
});
|
|
916
|
+
else {
|
|
917
|
+
if (!options.relationshipType || !options.from || !options.to)
|
|
918
|
+
throw new Error("system unrelate requires relationshipType, from, and to");
|
|
919
|
+
plan = planSystemUnrelate({
|
|
920
|
+
systemId,
|
|
921
|
+
type: options.relationshipType,
|
|
922
|
+
from: options.from,
|
|
923
|
+
to: options.to,
|
|
924
|
+
repoPath: repo,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
if (options.apply === true) {
|
|
928
|
+
if (!options.planDigest)
|
|
929
|
+
throw new Error("system writes require planDigest from an approved preview");
|
|
930
|
+
if (options.planDigest !== plan.planDigest)
|
|
931
|
+
throw new Error("system plan changed after preview; preview again");
|
|
932
|
+
}
|
|
933
|
+
return bounded(options.apply === true ? applySystemPlan(plan) : plan, `system:${systemAction}`);
|
|
934
|
+
}
|
|
837
935
|
const config = await enrichSystemRelationships(loadSystemConfiguration(repo));
|
|
838
936
|
if (systemAction === "list") {
|
|
839
937
|
return bounded({
|
|
@@ -859,7 +957,7 @@ async function handleSystemOperation(options) {
|
|
|
859
957
|
const validation = await validateSystemHealth(config, symbol, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })), repo);
|
|
860
958
|
if (systemAction === "validate")
|
|
861
959
|
return bounded({ systemId: symbol, ...validation }, "system:validate");
|
|
862
|
-
return bounded(queryConfiguredSystem(config, symbol,
|
|
960
|
+
return bounded(queryConfiguredSystem(config, symbol, options.requireComplete === true, validation), "system:query");
|
|
863
961
|
}
|
|
864
962
|
async function handlePullRequestsOperation(options) {
|
|
865
963
|
const { repo, engine, limit, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, } = options;
|
|
@@ -1296,7 +1394,7 @@ async function handleDiagnosticsOperation(repo, args, bounded) {
|
|
|
1296
1394
|
async function dispatchKnodinTool(args) {
|
|
1297
1395
|
const startedAt = performance.now();
|
|
1298
1396
|
const parsedArgs = (args ?? {});
|
|
1299
|
-
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, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, 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;
|
|
1300
1398
|
const repo = repoPath ?? process.cwd();
|
|
1301
1399
|
const selector = {
|
|
1302
1400
|
identity: expandCompactIdentity(identity),
|
|
@@ -1389,7 +1487,10 @@ async function dispatchKnodinTool(args) {
|
|
|
1389
1487
|
"search",
|
|
1390
1488
|
].includes(rootOperation);
|
|
1391
1489
|
const enriched = includeSharedIndex && result && typeof result === "object" && !Array.isArray(result)
|
|
1392
|
-
? {
|
|
1490
|
+
? {
|
|
1491
|
+
...result,
|
|
1492
|
+
sharedIndex: sharedIndexEnvelope(),
|
|
1493
|
+
}
|
|
1393
1494
|
: result;
|
|
1394
1495
|
const key = `${op}${minimal ? ":minimal" : ""}`;
|
|
1395
1496
|
const output = applyResponseBudget(enriched, op, budget, RESPONSE_DEFAULTS[key] ?? RESPONSE_DEFAULTS.default);
|
|
@@ -1447,11 +1548,16 @@ async function dispatchKnodinTool(args) {
|
|
|
1447
1548
|
return engine.status(repo, { audit: statusAudit }).then((result) => bounded({
|
|
1448
1549
|
...attachLifecycleHealth(repo, result),
|
|
1449
1550
|
integration: inspectRepositoryIntegrationStatus(repo),
|
|
1450
|
-
update:
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
+
},
|
|
1455
1561
|
}, "status"));
|
|
1456
1562
|
case "wait":
|
|
1457
1563
|
return bounded(await waitForFresh(engine, repo, timeoutMs), "wait");
|
|
@@ -1519,6 +1625,19 @@ async function dispatchKnodinTool(args) {
|
|
|
1519
1625
|
symbol,
|
|
1520
1626
|
repo,
|
|
1521
1627
|
allowPartial,
|
|
1628
|
+
apply,
|
|
1629
|
+
repositories,
|
|
1630
|
+
scope,
|
|
1631
|
+
distributed,
|
|
1632
|
+
repositoryIds,
|
|
1633
|
+
components,
|
|
1634
|
+
relationshipType,
|
|
1635
|
+
from,
|
|
1636
|
+
to,
|
|
1637
|
+
evidence,
|
|
1638
|
+
requireComplete,
|
|
1639
|
+
manifestPath,
|
|
1640
|
+
planDigest,
|
|
1522
1641
|
bounded,
|
|
1523
1642
|
});
|
|
1524
1643
|
}
|
|
@@ -1626,6 +1745,39 @@ async function dispatchKnodinTool(args) {
|
|
|
1626
1745
|
expectedLogin,
|
|
1627
1746
|
}), "prs");
|
|
1628
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
|
+
}
|
|
1629
1781
|
case "query": {
|
|
1630
1782
|
if (!pattern)
|
|
1631
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
|
+
}
|