knodin 0.7.5 → 0.8.2
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 +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- package/schemas/support-bundle-v2.schema.json +212 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
|
|
4
|
+
import { compareBytes } from "./compare.js";
|
|
5
|
+
import { resolveStateDir } from "./engine/state-paths.js";
|
|
6
|
+
const LEVELS = new Set(["locate", "outline", "evidence", "expand"]);
|
|
5
7
|
const DEFAULT_BYTES = 65_536;
|
|
6
8
|
const DEFAULT_TOKENS = 16_384;
|
|
7
9
|
const DEFAULT_ITEMS = 100;
|
|
@@ -37,7 +39,7 @@ function normalizeFile(repo, file) {
|
|
|
37
39
|
return { absolute, relative, root };
|
|
38
40
|
}
|
|
39
41
|
function handleSecret(root) {
|
|
40
|
-
const stateDirectory =
|
|
42
|
+
const stateDirectory = resolveStateDir(root);
|
|
41
43
|
if (fs.existsSync(stateDirectory) && fs.lstatSync(stateDirectory).isSymbolicLink())
|
|
42
44
|
throw new Error("progressive evidence refuses a symlinked .knodin directory");
|
|
43
45
|
fs.mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
|
|
@@ -96,7 +98,7 @@ function decodeHandle(value, secret) {
|
|
|
96
98
|
(candidate.relatedOffset ?? -1) < 0 ||
|
|
97
99
|
!Number.isSafeInteger(candidate.outlineOffset) ||
|
|
98
100
|
(candidate.outlineOffset ?? -1) < 0 ||
|
|
99
|
-
!LEVELS.
|
|
101
|
+
!LEVELS.has(candidate.nextLevel))
|
|
100
102
|
throw new Error("invalid continuation handle");
|
|
101
103
|
return candidate;
|
|
102
104
|
}
|
|
@@ -142,7 +144,9 @@ function findRelated(root, target, lines) {
|
|
|
142
144
|
const visit = (directory) => {
|
|
143
145
|
for (const entry of fs
|
|
144
146
|
.readdirSync(directory, { withFileTypes: true })
|
|
145
|
-
|
|
147
|
+
// Byte order: this walk order fixes the related-evidence sequence that
|
|
148
|
+
// the integrity-checked paging handle indexes into across calls.
|
|
149
|
+
.sort((a, b) => compareBytes(a.name, b.name))) {
|
|
146
150
|
if ([".git", ".knodin", "node_modules"].includes(entry.name) || entry.isSymbolicLink())
|
|
147
151
|
continue;
|
|
148
152
|
const absolute = path.join(directory, entry.name);
|
|
@@ -154,7 +158,7 @@ function findRelated(root, target, lines) {
|
|
|
154
158
|
if (!entry.isFile() || file === target || !/\.(?:[cm]?[jt]sx?|py|java|cs|go|rs)$/.test(file))
|
|
155
159
|
continue;
|
|
156
160
|
for (const [index, evidence] of fs.readFileSync(absolute, "utf8").split("\n").entries()) {
|
|
157
|
-
if (!names.some((name) => new RegExp(
|
|
161
|
+
if (!names.some((name) => new RegExp(String.raw `\b${name}\b`).test(evidence)))
|
|
158
162
|
continue;
|
|
159
163
|
results.push({
|
|
160
164
|
file,
|
|
@@ -195,7 +199,7 @@ function omissionPath(request, handle, currentHash, contentBytes, relative) {
|
|
|
195
199
|
* content and a complete byte-length assertion.
|
|
196
200
|
*/
|
|
197
201
|
export function deliverProgressiveEvidence(request) {
|
|
198
|
-
if (!LEVELS.
|
|
202
|
+
if (!LEVELS.has(request.level))
|
|
199
203
|
throw new Error("invalid progressive evidence level");
|
|
200
204
|
const { absolute, relative, root } = normalizeFile(request.repo, request.file);
|
|
201
205
|
const handleKey = handleSecret(root);
|
|
@@ -290,12 +294,12 @@ export function deliverProgressiveEvidence(request) {
|
|
|
290
294
|
returned = [...returned, "source"];
|
|
291
295
|
nextLine = last + 1;
|
|
292
296
|
more = last < lines.length || request.level !== "expand";
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
if (last < lines.length)
|
|
298
|
+
omitted = ["source-tail", "related"];
|
|
299
|
+
else if (request.level === "expand")
|
|
300
|
+
omitted = [];
|
|
301
|
+
else
|
|
302
|
+
omitted = ["related"];
|
|
299
303
|
reason = last < lines.length ? "hard-item-budget" : "requested-level";
|
|
300
304
|
}
|
|
301
305
|
totalItems = lines.length;
|
|
@@ -318,15 +322,13 @@ export function deliverProgressiveEvidence(request) {
|
|
|
318
322
|
totalItems = lines.length + allRelated.length;
|
|
319
323
|
}
|
|
320
324
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
? request.level
|
|
329
|
-
: "expand";
|
|
325
|
+
let nextLevel;
|
|
326
|
+
if (request.level === "locate")
|
|
327
|
+
nextLevel = "outline";
|
|
328
|
+
else if (request.level === "outline")
|
|
329
|
+
nextLevel = outlineOffset + (outline?.length ?? 0) < outlineTotal ? "outline" : "evidence";
|
|
330
|
+
else
|
|
331
|
+
nextLevel = more && nextLine <= lines.length ? request.level : "expand";
|
|
330
332
|
const evidenceHandle = encodeHandle({
|
|
331
333
|
v: 1,
|
|
332
334
|
path: relative,
|
|
@@ -448,11 +450,13 @@ export function deliverProgressiveEvidence(request) {
|
|
|
448
450
|
response.reason = "hard-byte-budget";
|
|
449
451
|
if (!response.omitted.includes("source-tail"))
|
|
450
452
|
response.omitted.unshift("source-tail");
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
let resumeLine;
|
|
454
|
+
if (response.sourceRange?.endLine)
|
|
455
|
+
resumeLine = response.sourceRange.endLine + 1;
|
|
456
|
+
else if (continuationMismatch)
|
|
457
|
+
resumeLine = 1;
|
|
458
|
+
else
|
|
459
|
+
resumeLine = request.startLine ?? pagingHandle?.nextLine ?? 1;
|
|
456
460
|
response.continuation = encodeHandle({
|
|
457
461
|
v: 1,
|
|
458
462
|
path: relative,
|
|
@@ -36,10 +36,11 @@ function human(result) {
|
|
|
36
36
|
const artifact = result.artifact.retained
|
|
37
37
|
? ` Retained artifact: ${result.artifact.id} (${result.artifact.path}).`
|
|
38
38
|
: " Raw retention disabled; omitted regions cannot be retrieved.";
|
|
39
|
-
|
|
39
|
+
const body = result.content ? `${result.content}\n` : "";
|
|
40
|
+
return `${body}---\n${fidelity} ${result.output.lines}/${result.input.lines} lines, ${result.output.bytes}/${result.input.bytes} bytes; exit=${result.exit.code ?? "unknown"}, signal=${result.exit.signal ?? "none"}.${artifact}\n`;
|
|
40
41
|
}
|
|
41
42
|
/** Handle only pure compression creation. Artifact reads/diagnosis retain the full CLI contract. */
|
|
42
|
-
export async function tryPureCompressionFastPath(argv, cwd
|
|
43
|
+
export async function tryPureCompressionFastPath(argv, cwd) {
|
|
43
44
|
if (argv[0] !== "compress")
|
|
44
45
|
return false;
|
|
45
46
|
const valueFlags = new Set([
|
|
@@ -74,7 +75,7 @@ export async function tryPureCompressionFastPath(argv, cwd = process.cwd()) {
|
|
|
74
75
|
"cargo",
|
|
75
76
|
].includes(adapter))
|
|
76
77
|
return false;
|
|
77
|
-
const repo = path.resolve(valueAfter(argv, "--repo") ?? cwd);
|
|
78
|
+
const repo = path.resolve(valueAfter(argv, "--repo") ?? cwd ?? process.cwd());
|
|
78
79
|
const maxInputBytes = boundedInteger(argv, "--max-input-bytes", 16 * 1024 * 1024, 1, 64 * 1024 * 1024);
|
|
79
80
|
const request = {
|
|
80
81
|
exitCode: valueAfter(argv, "--exit-code")
|
|
@@ -2,12 +2,13 @@ import childProcess from "node:child_process";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
5
6
|
import { gitExecutable } from "./git-executable.js";
|
|
6
7
|
const MAX_FILES = 5_000;
|
|
7
8
|
const MAX_CONTRACT_BYTES = 2 * 1024 * 1024;
|
|
8
9
|
export function trackedRepositoryFiles(repository) {
|
|
9
10
|
try {
|
|
10
|
-
return childProcess
|
|
11
|
+
return (childProcess
|
|
11
12
|
.execFileSync(gitExecutable(), ["ls-files", "-z"], {
|
|
12
13
|
cwd: repository,
|
|
13
14
|
encoding: "utf-8",
|
|
@@ -15,8 +16,10 @@ export function trackedRepositoryFiles(repository) {
|
|
|
15
16
|
})
|
|
16
17
|
.split("\0")
|
|
17
18
|
.filter(Boolean)
|
|
18
|
-
|
|
19
|
-
.
|
|
19
|
+
// Byte order: this sort decides which files survive the MAX_FILES cap,
|
|
20
|
+
// so collation would change which files are scanned at all.
|
|
21
|
+
.sort(compareBytes)
|
|
22
|
+
.slice(0, MAX_FILES));
|
|
20
23
|
}
|
|
21
24
|
catch {
|
|
22
25
|
return [];
|
|
@@ -41,7 +44,7 @@ function packageFindings(file, content) {
|
|
|
41
44
|
const dependencies = manifest[section];
|
|
42
45
|
if (typeof dependencies !== "object" || dependencies === null)
|
|
43
46
|
continue;
|
|
44
|
-
for (const name of Object.keys(dependencies).sort(
|
|
47
|
+
for (const name of Object.keys(dependencies).sort(compareBytes)) {
|
|
45
48
|
findings.push({
|
|
46
49
|
type: "depends_on",
|
|
47
50
|
target: `pkg:npm/${encodeURIComponent(name)}`,
|
|
@@ -279,7 +282,9 @@ export function extractRepositoryRelationships(repositoryId, repository, knownPa
|
|
|
279
282
|
});
|
|
280
283
|
}
|
|
281
284
|
}
|
|
282
|
-
|
|
285
|
+
// Byte order over a NUL-joined composite key: collation may ignore the NUL
|
|
286
|
+
// separators entirely, which blurs the field boundaries this key depends on.
|
|
287
|
+
return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.type}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.type}\0${right.to}\0${right.evidence.location}`));
|
|
283
288
|
}
|
|
284
289
|
/** Stream a file hash so duplicate detection never buffers an entire artifact. */
|
|
285
290
|
export async function hashFileStream(file) {
|
|
@@ -294,7 +299,7 @@ export async function hashFileStream(file) {
|
|
|
294
299
|
}
|
|
295
300
|
function filesGroupedBySize(repositories) {
|
|
296
301
|
const bySize = new Map();
|
|
297
|
-
const orderedRepositories = [...repositories].sort((left, right) => left.id
|
|
302
|
+
const orderedRepositories = [...repositories].sort((left, right) => compareBytes(left.id, right.id));
|
|
298
303
|
for (const repository of orderedRepositories) {
|
|
299
304
|
for (const file of trackedRepositoryFiles(repository.path)) {
|
|
300
305
|
const absolute = path.join(repository.path, file);
|
|
@@ -327,7 +332,9 @@ function duplicateRelationships(matches) {
|
|
|
327
332
|
const repositoryIds = new Set(matches.map(({ repositoryId }) => repositoryId));
|
|
328
333
|
if (repositoryIds.size < 2)
|
|
329
334
|
return [];
|
|
330
|
-
|
|
335
|
+
// Byte order: `ordered[0]` becomes the canonical side of every emitted
|
|
336
|
+
// duplicate_candidate, so collation would change the relationship direction.
|
|
337
|
+
const ordered = [...matches].sort((left, right) => compareBytes(`${left.repositoryId}\0${left.file}`, `${right.repositoryId}\0${right.file}`));
|
|
331
338
|
const canonical = ordered[0];
|
|
332
339
|
if (!canonical)
|
|
333
340
|
return [];
|
|
@@ -366,5 +373,5 @@ export async function detectDuplicateCandidates(repositories) {
|
|
|
366
373
|
relationships.push(...duplicateRelationships(matches));
|
|
367
374
|
}
|
|
368
375
|
}
|
|
369
|
-
return relationships.sort((left, right) => `${left.from}\0${left.to}\0${left.evidence.location}
|
|
376
|
+
return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.to}\0${right.evidence.location}`));
|
|
370
377
|
}
|
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash, createPublicKey } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
5
6
|
import { validateRootCeremonyManifest } from "./update-ceremony.js";
|
|
6
7
|
import { canonicalizeUpdateMetadata, verifyUpdateRootChain, } from "./update-trust.js";
|
|
7
8
|
export const RELEASE_PREFLIGHT_DEPENDENCIES = [
|
|
@@ -21,6 +22,7 @@ export const RELEASE_PREFLIGHT_REPOSITORY_COMMANDS = [
|
|
|
21
22
|
"npm run test",
|
|
22
23
|
"npm run test:pack-install",
|
|
23
24
|
"npm run check:roadmap",
|
|
25
|
+
"npm run verify:c95",
|
|
24
26
|
];
|
|
25
27
|
export const RELEASE_PREFLIGHT_REPLAY_COMMANDS = {
|
|
26
28
|
competitive: [
|
|
@@ -65,8 +67,8 @@ function record(value, label) {
|
|
|
65
67
|
return value;
|
|
66
68
|
}
|
|
67
69
|
function exactKeys(value, expected, label) {
|
|
68
|
-
const keys = Object.keys(value).sort();
|
|
69
|
-
const wanted = [...expected].sort();
|
|
70
|
+
const keys = Object.keys(value).sort(compareBytes);
|
|
71
|
+
const wanted = [...expected].sort(compareBytes);
|
|
70
72
|
if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index]))
|
|
71
73
|
fail(`${label} must contain exactly: ${wanted.join(", ")}`);
|
|
72
74
|
}
|
|
@@ -317,8 +319,8 @@ function requireTaggedBytes(git, commit, relativePath, bytes, label) {
|
|
|
317
319
|
fail(`${label} bytes do not equal the exact source-commit blob`);
|
|
318
320
|
}
|
|
319
321
|
function verifyRoadmap(roadmap) {
|
|
320
|
-
const activeLedger =
|
|
321
|
-
const parkedLedger =
|
|
322
|
+
const activeLedger = /### Track A — Active local-work ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Track B — Parked external-evidence ledger/.exec(roadmap);
|
|
323
|
+
const parkedLedger = /### Track B — Parked external-evidence ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Goal-loop ordering/.exec(roadmap);
|
|
322
324
|
if (!activeLedger || !parkedLedger)
|
|
323
325
|
fail("two-track roadmap ledger is missing or malformed");
|
|
324
326
|
const activeIds = new Set([...activeLedger[1].matchAll(/^\| (C\d+) \|/gm)].map((match) => match[1]));
|
|
@@ -330,17 +332,17 @@ function verifyRoadmap(roadmap) {
|
|
|
330
332
|
if (activeIds.has(dependency))
|
|
331
333
|
fail(`${dependency} remains in Track A`);
|
|
332
334
|
const header = dependency === "C75" ? "C69–C75" : dependency;
|
|
333
|
-
const section =
|
|
335
|
+
const section = new RegExp(String.raw `(^#{2,3} ${header} —[^\n]*\n[\s\S]*?)(?=^#{2,3} C(?:\d+|\d+–C\d+) —|(?![\s\S]))`, "m").exec(roadmap);
|
|
334
336
|
if (!section)
|
|
335
337
|
fail(`${dependency} has no authoritative item status`);
|
|
336
|
-
const statusMatch =
|
|
338
|
+
const statusMatch = /^- Status: ([^\n]+)$/m.exec(section[1]);
|
|
337
339
|
if (!statusMatch)
|
|
338
340
|
fail(`${dependency} has no authoritative item status`);
|
|
339
341
|
const status = statusMatch[1].trim();
|
|
340
342
|
if (!TERMINAL_STATUS.test(status))
|
|
341
343
|
fail(`${dependency} has nonterminal status ${JSON.stringify(status)}`);
|
|
342
344
|
const evidenceLabel = dependency === "C75" ? "C75 Evidence" : "Evidence";
|
|
343
|
-
const evidenceField =
|
|
345
|
+
const evidenceField = new RegExp(String.raw `^- ${evidenceLabel}: ([\s\S]*?)(?=\n\n|\n- [A-Z][^\n]*:|(?![\s\S]))`, "m").exec(section[1]);
|
|
344
346
|
if (!evidenceField)
|
|
345
347
|
fail(`${dependency} has no explicit authoritative evidence field`);
|
|
346
348
|
const paths = [...evidenceField[1].matchAll(/`([^`]+)`/g)].map((match) => match[1]);
|
|
@@ -396,7 +398,8 @@ function verifyProductionTrust(plan, evidence) {
|
|
|
396
398
|
for (const role of ["root", "targets", "snapshot", "timestamp"]) {
|
|
397
399
|
const rootRole = rootEnvelope.signed.roles[role];
|
|
398
400
|
if (rootRole.threshold !== manifest.thresholds[role] ||
|
|
399
|
-
[...rootRole.keyids].sort().join("\0") !==
|
|
401
|
+
[...rootRole.keyids].sort(compareBytes).join("\0") !==
|
|
402
|
+
[...manifest.roleKeyIds[role]].sort(compareBytes).join("\0"))
|
|
400
403
|
fail(`production ceremony ${role} role does not match the signed root`);
|
|
401
404
|
}
|
|
402
405
|
for (const key of Object.values(rootEnvelope.signed.keys)) {
|
|
@@ -477,8 +480,8 @@ export function evaluateReleasePreflight(options) {
|
|
|
477
480
|
const authoritative = roadmapEvidence.get(dependency) ?? [];
|
|
478
481
|
if (declared.length !== authoritative.length ||
|
|
479
482
|
[...declared]
|
|
480
|
-
.sort()
|
|
481
|
-
.some((candidate, index) => candidate !== [...authoritative].sort()[index]))
|
|
483
|
+
.sort(compareBytes)
|
|
484
|
+
.some((candidate, index) => candidate !== [...authoritative].sort(compareBytes)[index]))
|
|
482
485
|
fail(`${dependency} evidence paths must equal its complete authoritative Evidence field`);
|
|
483
486
|
for (const relativePath of plan.dependencyEvidence[dependency]) {
|
|
484
487
|
if (uniquePaths.has(relativePath))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MAX_LEASE_BYTES = 4_096;
|
|
5
|
+
function leasePath(repo) {
|
|
6
|
+
return path.join(path.resolve(repo), ".knodin", "repair.lock");
|
|
7
|
+
}
|
|
8
|
+
function processIsAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function readLease(target) {
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.statSync(target);
|
|
20
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_LEASE_BYTES)
|
|
21
|
+
return null;
|
|
22
|
+
const value = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
23
|
+
if (value.schemaVersion !== 1 ||
|
|
24
|
+
!Number.isInteger(value.pid) ||
|
|
25
|
+
(value.pid ?? 0) <= 0 ||
|
|
26
|
+
typeof value.startedAt !== "string" ||
|
|
27
|
+
!Number.isFinite(Date.parse(value.startedAt)) ||
|
|
28
|
+
typeof value.nonce !== "string" ||
|
|
29
|
+
!value.nonce)
|
|
30
|
+
return null;
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeLease(target, record) {
|
|
38
|
+
fs.writeFileSync(target, `${JSON.stringify(record)}\n`, {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
flag: "wx",
|
|
41
|
+
mode: 0o600,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function removeStaleLeaseOrThrow(target) {
|
|
45
|
+
const current = readLease(target);
|
|
46
|
+
if (current && processIsAlive(current.pid)) {
|
|
47
|
+
throw new Error(`knodin repair: another repair is already in progress (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(target);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== "ENOENT")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Atomically serialize repair post-processing across independent CLI/MCP processes. */
|
|
58
|
+
export function acquireRepairLease(repo) {
|
|
59
|
+
const target = leasePath(repo);
|
|
60
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
61
|
+
const record = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
startedAt: new Date().toISOString(),
|
|
65
|
+
nonce: crypto.randomUUID(),
|
|
66
|
+
};
|
|
67
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
68
|
+
try {
|
|
69
|
+
writeLease(target, record);
|
|
70
|
+
return {
|
|
71
|
+
release() {
|
|
72
|
+
const current = readLease(target);
|
|
73
|
+
if (current?.nonce === record.nonce)
|
|
74
|
+
fs.rmSync(target, { force: true });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== "EEXIST")
|
|
80
|
+
throw error;
|
|
81
|
+
removeStaleLeaseOrThrow(target);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error("knodin repair: could not acquire the repository repair lease; retry shortly");
|
|
85
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
export const
|
|
3
|
+
export const MINIMUM_REPOSITORY_INIT_MEMORY_LIMIT_BYTES = 128 * 1024 * 1024;
|
|
4
4
|
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
5
5
|
const TERMINATION_GRACE_MS = 1_000;
|
|
6
6
|
const RSS_POLL_MS = 250;
|
|
@@ -50,8 +50,10 @@ function isWorkerMessage(value) {
|
|
|
50
50
|
* continue.
|
|
51
51
|
*/
|
|
52
52
|
export async function runRepositoryInitializationProcess(options) {
|
|
53
|
-
const memoryLimitBytes = options
|
|
54
|
-
if (
|
|
53
|
+
const { memoryLimitBytes } = options;
|
|
54
|
+
if (memoryLimitBytes !== undefined &&
|
|
55
|
+
(!Number.isSafeInteger(memoryLimitBytes) ||
|
|
56
|
+
memoryLimitBytes < MINIMUM_REPOSITORY_INIT_MEMORY_LIMIT_BYTES))
|
|
55
57
|
throw new Error("repository init memory limit must be an integer of at least 128 MiB");
|
|
56
58
|
if (options.command.length === 0 || !options.command[0])
|
|
57
59
|
throw new Error("repository init worker command is empty");
|
|
@@ -66,10 +68,7 @@ export async function runRepositoryInitializationProcess(options) {
|
|
|
66
68
|
const child = spawn(options.command[0], [...options.command.slice(1), "__repository-init-worker", options.repository], {
|
|
67
69
|
cwd: options.repository,
|
|
68
70
|
detached: process.platform !== "win32",
|
|
69
|
-
env:
|
|
70
|
-
...process.env,
|
|
71
|
-
KNODIN_INTERNAL_INIT_MEMORY_LIMIT_BYTES: String(memoryLimitBytes),
|
|
72
|
-
},
|
|
71
|
+
env: process.env,
|
|
73
72
|
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
74
73
|
});
|
|
75
74
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -77,7 +76,7 @@ export async function runRepositoryInitializationProcess(options) {
|
|
|
77
76
|
});
|
|
78
77
|
const observeRss = (rssBytes) => {
|
|
79
78
|
peakRssBytes = Math.max(peakRssBytes, rssBytes);
|
|
80
|
-
if (rssBytes <= memoryLimitBytes || exceeded)
|
|
79
|
+
if (memoryLimitBytes === undefined || rssBytes <= memoryLimitBytes || exceeded)
|
|
81
80
|
return;
|
|
82
81
|
exceeded = true;
|
|
83
82
|
const pid = child.pid ?? 0;
|
|
@@ -155,7 +154,12 @@ export async function runRepositoryInitializationProcess(options) {
|
|
|
155
154
|
return;
|
|
156
155
|
}
|
|
157
156
|
if (result) {
|
|
158
|
-
resolve({
|
|
157
|
+
resolve({
|
|
158
|
+
...result,
|
|
159
|
+
repository: options.repository,
|
|
160
|
+
peakRssBytes,
|
|
161
|
+
...(memoryLimitBytes === undefined ? {} : { memoryLimitBytes }),
|
|
162
|
+
});
|
|
159
163
|
return;
|
|
160
164
|
}
|
|
161
165
|
const diagnostic = stderr.trim().replace(/\s+/g, " ");
|
|
@@ -2,6 +2,7 @@ import child_process from "node:child_process";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
5
6
|
import { gitExecutable } from "./git-executable.js";
|
|
6
7
|
import { initializeRepository, isRepositoryInitializationCurrent, readRepositoryIntegrationConfig, } from "./init.js";
|
|
7
8
|
import { applyResponseBudget } from "./response-budget.js";
|
|
@@ -217,7 +218,10 @@ export async function discoverRepositories(roots, options = {}) {
|
|
|
217
218
|
const mainRepositories = legacy.repositories.filter((repository) => (mainByWorktree.get(repository) ?? repository) === repository);
|
|
218
219
|
const topLevelRepositories = mainRepositories
|
|
219
220
|
.filter((repository) => !mainRepositories.some((candidate) => candidate !== repository && repository.startsWith(`${candidate}${path.sep}`)))
|
|
220
|
-
|
|
221
|
+
// Byte order: `topLevelRepositories.find(...)` below decides whether a
|
|
222
|
+
// repository is classified main-worktree or unrelated, so the order picks
|
|
223
|
+
// the winner rather than only presenting it.
|
|
224
|
+
.sort(compareBytes);
|
|
221
225
|
const repositories = legacy.repositories
|
|
222
226
|
.map((repository) => {
|
|
223
227
|
const mainWorktree = mainByWorktree.get(repository) ?? repository;
|
|
@@ -250,8 +254,11 @@ export async function discoverRepositories(roots, options = {}) {
|
|
|
250
254
|
"nested-repository": 2,
|
|
251
255
|
"unrelated-repository": 3,
|
|
252
256
|
};
|
|
257
|
+
// Byte order: this listing is item- and byte-budgeted downstream and its
|
|
258
|
+
// record order is folded into the inventory cursor hash, so collation would
|
|
259
|
+
// change which repositories a page returns.
|
|
253
260
|
repositories.sort((left, right) => classificationOrder[left.classification] - classificationOrder[right.classification] ||
|
|
254
|
-
left.path
|
|
261
|
+
compareBytes(left.path, right.path));
|
|
255
262
|
return {
|
|
256
263
|
repositories,
|
|
257
264
|
issues: legacy.issues,
|
|
@@ -291,6 +298,10 @@ function parseRepositoryPrefixedOrPositional(argument, index, cwd, command, stat
|
|
|
291
298
|
state.manifestPath = path.resolve(cwd, argument.slice("--manifest=".length));
|
|
292
299
|
return index;
|
|
293
300
|
}
|
|
301
|
+
if (argument.startsWith("--memory-limit-mib=")) {
|
|
302
|
+
state.memoryLimitBytes = repositoryMemoryLimitBytes(argument.slice("--memory-limit-mib=".length));
|
|
303
|
+
return index;
|
|
304
|
+
}
|
|
294
305
|
if (argument.startsWith("--"))
|
|
295
306
|
throw new Error(`knodin repos: unknown option ${argument}`);
|
|
296
307
|
if (command === "search" && state.query === undefined)
|
|
@@ -343,6 +354,9 @@ function parseRepositoryOption(args, index, cwd, command, state) {
|
|
|
343
354
|
case "--manifest":
|
|
344
355
|
state.manifestPath = path.resolve(cwd, requiredArgument(args, index, argument));
|
|
345
356
|
return index + 1;
|
|
357
|
+
case "--memory-limit-mib":
|
|
358
|
+
state.memoryLimitBytes = repositoryMemoryLimitBytes(requiredArgument(args, index, argument));
|
|
359
|
+
return index + 1;
|
|
346
360
|
case "--depth":
|
|
347
361
|
state.depth = Number(requiredArgument(args, index, argument));
|
|
348
362
|
return index + 1;
|
|
@@ -387,6 +401,8 @@ export function parseRepositoryCommandArgs(args, cwd) {
|
|
|
387
401
|
throw new Error(`knodin repos ${command}: --dry-run applies only to init`);
|
|
388
402
|
if (state.manifestPath && command !== "init")
|
|
389
403
|
throw new Error(`knodin repos ${command}: --manifest applies only to init`);
|
|
404
|
+
if (state.memoryLimitBytes !== undefined && command !== "init")
|
|
405
|
+
throw new Error(`knodin repos ${command}: --memory-limit-mib applies only to init`);
|
|
390
406
|
if (state.signals && command !== "discover")
|
|
391
407
|
throw new Error(`knodin repos ${command}: --signals applies only to discover`);
|
|
392
408
|
if (command !== "search" &&
|
|
@@ -405,6 +421,15 @@ export function parseRepositoryCommandArgs(args, cwd) {
|
|
|
405
421
|
throw new Error("knodin repos discover: --cursor applies only to search");
|
|
406
422
|
return { command, ...state };
|
|
407
423
|
}
|
|
424
|
+
export function repositoryMemoryLimitBytes(value) {
|
|
425
|
+
const mib = typeof value === "number" ? value : Number(value);
|
|
426
|
+
if (!Number.isSafeInteger(mib) || mib < 128)
|
|
427
|
+
throw new Error("knodin repos init: --memory-limit-mib requires an integer of at least 128");
|
|
428
|
+
const bytes = mib * 1024 * 1024;
|
|
429
|
+
if (!Number.isSafeInteger(bytes))
|
|
430
|
+
throw new Error("knodin repos init: --memory-limit-mib exceeds the supported integer range");
|
|
431
|
+
return bytes;
|
|
432
|
+
}
|
|
408
433
|
/**
|
|
409
434
|
* Find real Git worktree roots without following child symlinks. Every supplied
|
|
410
435
|
* root is resolved through realpath, so aliases are accepted but cannot create
|
|
@@ -504,7 +529,10 @@ export async function discoverGitRepositoryPaths(roots, depth = DEFAULT_DEPTH, w
|
|
|
504
529
|
emptyRoots.push(root);
|
|
505
530
|
}
|
|
506
531
|
const orderedRepositories = [...repositories];
|
|
507
|
-
|
|
532
|
+
// Byte order: this array is hashed into the repository initialization
|
|
533
|
+
// signature, which is persisted in the resume manifest and compared on a
|
|
534
|
+
// later run — a differing order rejects the manifest outright.
|
|
535
|
+
orderedRepositories.sort(compareBytes);
|
|
508
536
|
const orderedIssues = [...issues];
|
|
509
537
|
orderedIssues.sort((left, right) => left.path.localeCompare(right.path));
|
|
510
538
|
const orderedEmptyRoots = [...emptyRoots];
|
|
@@ -624,7 +652,9 @@ const LANGUAGE_BY_EXTENSION = {
|
|
|
624
652
|
".tsx": "typescript",
|
|
625
653
|
};
|
|
626
654
|
function insertBoundedCandidate(candidates, file) {
|
|
627
|
-
|
|
655
|
+
// Byte order: the list is capped at MAX_DOCUMENT_CANDIDATES and the tail is
|
|
656
|
+
// dropped, so this comparison decides which candidates survive.
|
|
657
|
+
const insertion = candidates.findIndex((candidate) => compareBytes(candidate, file) > 0);
|
|
628
658
|
if (insertion === -1)
|
|
629
659
|
candidates.push(file);
|
|
630
660
|
else
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compareBytes } from "./compare.js";
|
|
1
2
|
const encoder = new TextEncoder();
|
|
2
3
|
export const MIN_RESPONSE_BUDGET_BYTES = 256;
|
|
3
4
|
const MAX_REPORTED_TOTAL_PATHS = 128;
|
|
@@ -61,17 +62,18 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
61
62
|
}
|
|
62
63
|
const itemLimit = positiveInt(request.items, defaults.items);
|
|
63
64
|
const payload = structuredClone(value);
|
|
64
|
-
const
|
|
65
|
-
?
|
|
66
|
-
:
|
|
67
|
-
|
|
68
|
-
: { result: payload };
|
|
65
|
+
const objectRoot = payload && typeof payload === "object"
|
|
66
|
+
? payload
|
|
67
|
+
: { result: payload };
|
|
68
|
+
const root = Array.isArray(payload) ? { results: payload } : objectRoot;
|
|
69
69
|
const totals = {};
|
|
70
70
|
let truncated = false;
|
|
71
71
|
const initialArrays = collectArrays(root);
|
|
72
72
|
const reportedTotals = [...initialArrays]
|
|
73
|
+
// Byte order: this tie-break decides which paths survive the slice below,
|
|
74
|
+
// and the reported totals are serialized into the response.
|
|
73
75
|
.sort((left, right) => left.path.split("/").length - right.path.split("/").length ||
|
|
74
|
-
left.path
|
|
76
|
+
compareBytes(left.path, right.path))
|
|
75
77
|
.slice(0, MAX_REPORTED_TOTAL_PATHS);
|
|
76
78
|
for (const entry of reportedTotals)
|
|
77
79
|
totals[entry.path] = entry.value.length;
|