kankaku-pi 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the kankaku data directory: an absolute `dirOrRelative` is used
|
|
6
|
+
* as-is, a relative one is joined against `cwd`. Shared by every lazy
|
|
7
|
+
* adapter so the rule lives in exactly one place.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveKankakuDir(dirOrRelative: string, cwd: string): string {
|
|
10
|
+
return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WritableTargetResult {
|
|
14
|
+
dir: string;
|
|
15
|
+
/** `true` when `candidateDir` failed its writability probe and `dir` is `fallbackDir` instead. */
|
|
16
|
+
usedFallback: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolveWritableTargetDeps {
|
|
20
|
+
/**
|
|
21
|
+
* Proves this process can actually write to `dir` — not merely that it
|
|
22
|
+
* exists — and throws on any failure. Injectable for tests (never touch
|
|
23
|
+
* real disk to simulate an unwritable directory); defaults to
|
|
24
|
+
* {@link defaultWritabilityProbe}.
|
|
25
|
+
*/
|
|
26
|
+
probe?: (dir: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Matches this probe's own marker filename: `.kankaku-write-probe.<pid>.<timestamp>.tmp`. */
|
|
30
|
+
const PROBE_NAME_PATTERN = /^\.kankaku-write-probe\.\d+\.(\d+)\.tmp$/;
|
|
31
|
+
/** A marker older than this is assumed abandoned (its writer was SIGKILLed between the write and its own unlink) — R4. */
|
|
32
|
+
const PROBE_STALE_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort removal of a stale writability-probe marker (R4): a probe
|
|
36
|
+
* that gets SIGKILLed between its `writeFileSync` and its own `unlinkSync`
|
|
37
|
+
* leaves `.kankaku-write-probe.<pid>.<timestamp>.tmp` behind forever —
|
|
38
|
+
* nothing else in `dir` ever looks at it again otherwise. Runs
|
|
39
|
+
* opportunistically every time the probe itself runs (mirrors
|
|
40
|
+
* `machine-process-registry.ts`'s own-write-time sweep pattern), so no
|
|
41
|
+
* separate cleanup process is needed. Only removes a marker whose
|
|
42
|
+
* filename-embedded timestamp (not the file's mtime, so this stays
|
|
43
|
+
* deterministic and easy to test) is older than a minute — never a fresh
|
|
44
|
+
* one, including this call's own marker (written after this sweep runs) or
|
|
45
|
+
* a concurrent process's own in-flight probe.
|
|
46
|
+
*/
|
|
47
|
+
function sweepStaleWriteProbes(dir: string, now: number): void {
|
|
48
|
+
let names: string[];
|
|
49
|
+
try {
|
|
50
|
+
names = readdirSync(dir);
|
|
51
|
+
} catch {
|
|
52
|
+
return; // dir just created and already empty, or unreadable — nothing to sweep.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const match = PROBE_NAME_PATTERN.exec(name);
|
|
57
|
+
if (!match) continue;
|
|
58
|
+
const timestamp = Number(match[1]);
|
|
59
|
+
if (!Number.isFinite(timestamp) || now - timestamp <= PROBE_STALE_MS) continue;
|
|
60
|
+
try {
|
|
61
|
+
unlinkSync(join(dir, name));
|
|
62
|
+
} catch {
|
|
63
|
+
// Best effort: already gone, or a concurrent sweep got there first.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `mkdirSync` + a temp marker file write/unlink: proves both creatability and write permission, not just existence (a read-only *existing* directory would otherwise pass a bare `mkdirSync` check silently, since recursive mkdir on an existing dir never fails). Also sweeps any stale marker left behind by an earlier, SIGKILLed probe (R4) before writing its own. */
|
|
69
|
+
function defaultWritabilityProbe(dir: string): void {
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
sweepStaleWriteProbes(dir, Date.now());
|
|
72
|
+
const marker = join(dir, `.kankaku-write-probe.${process.pid}.${Date.now()}.tmp`);
|
|
73
|
+
writeFileSync(marker, "");
|
|
74
|
+
unlinkSync(marker);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the target directory for a write that would normally go to
|
|
79
|
+
* `candidateDir` (F1: routing a verified subagent's work log/inflight
|
|
80
|
+
* checkpoints to its orchestrator's kankaku directory instead of its own
|
|
81
|
+
* cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
|
|
82
|
+
* proven writable — created if it does not exist yet — `fallbackDir` (the
|
|
83
|
+
* process's own local directory) otherwise, so a parent directory that no
|
|
84
|
+
* longer exists, or that this process lacks permission to write to,
|
|
85
|
+
* degrades to a safe, local fallback instead of throwing and losing the
|
|
86
|
+
* record entirely. `usedFallback` lets the caller surface this (`/kankaku
|
|
87
|
+
* doctor`) so a human can notice and reunite the record manually — the
|
|
88
|
+
* append-only log can never be rewritten to fix it after the fact. Never
|
|
89
|
+
* throws: `fallbackDir` itself is never probed here — its own writability
|
|
90
|
+
* is handled the ordinary way, by whichever adapter eventually writes to
|
|
91
|
+
* it, exactly as it always has been for a process that never needed to
|
|
92
|
+
* route anywhere.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveWritableTarget(candidateDir: string, fallbackDir: string, deps: ResolveWritableTargetDeps = {}): WritableTargetResult {
|
|
95
|
+
const probe = deps.probe ?? defaultWritabilityProbe;
|
|
96
|
+
try {
|
|
97
|
+
probe(candidateDir);
|
|
98
|
+
return { dir: candidateDir, usedFallback: false };
|
|
99
|
+
} catch {
|
|
100
|
+
return { dir: fallbackDir, usedFallback: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
3
|
+
import { FileInflightStore } from "./file-inflight-store.ts";
|
|
4
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* {@link InflightStore} that resolves a relative checkpoint directory
|
|
8
|
+
* lazily, mirroring {@link LazyJsonlWorkLog}: against the project of the
|
|
9
|
+
* first saved record, or against `fallbackCwd()` when `clear` or
|
|
10
|
+
* `recoverStale` happens before any record was saved in this process
|
|
11
|
+
* (e.g. `session_start`, which runs before `before_agent_start`).
|
|
12
|
+
*/
|
|
13
|
+
export class LazyFileInflightStore implements InflightStore {
|
|
14
|
+
private readonly dirOrRelative: string;
|
|
15
|
+
private readonly pid: number;
|
|
16
|
+
private readonly fallbackCwd: () => string;
|
|
17
|
+
private resolved: FileInflightStore | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(dirOrRelative: string, pid: number, fallbackCwd: () => string = () => process.cwd()) {
|
|
20
|
+
this.dirOrRelative = dirOrRelative;
|
|
21
|
+
this.pid = pid;
|
|
22
|
+
this.fallbackCwd = fallbackCwd;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private resolveFor(cwd: string): FileInflightStore {
|
|
26
|
+
if (!this.resolved) {
|
|
27
|
+
this.resolved = new FileInflightStore(resolveKankakuDir(this.dirOrRelative, cwd), this.pid);
|
|
28
|
+
}
|
|
29
|
+
return this.resolved;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
save(record: WorkRecord): void {
|
|
33
|
+
this.resolveFor(record.project).save(record);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear(): void {
|
|
37
|
+
this.resolveFor(this.fallbackCwd()).clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
41
|
+
return this.resolveFor(this.fallbackCwd()).recoverStale(isAlive);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
3
|
+
import { JsonlWorkLog } from "./jsonl-work-log.ts";
|
|
4
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* {@link WorkLog} that resolves a relative log directory lazily: against the
|
|
8
|
+
* project of the first appended record, or against `fallbackCwd()` when a
|
|
9
|
+
* read happens before any record was written in this process.
|
|
10
|
+
*/
|
|
11
|
+
export class LazyJsonlWorkLog implements WorkLog {
|
|
12
|
+
private readonly dirOrRelative: string;
|
|
13
|
+
private readonly fallbackCwd: () => string;
|
|
14
|
+
private resolved: JsonlWorkLog | undefined;
|
|
15
|
+
|
|
16
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
17
|
+
this.dirOrRelative = dirOrRelative;
|
|
18
|
+
this.fallbackCwd = fallbackCwd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private resolveFor(cwd: string): JsonlWorkLog {
|
|
22
|
+
if (!this.resolved) {
|
|
23
|
+
this.resolved = new JsonlWorkLog(resolveKankakuDir(this.dirOrRelative, cwd));
|
|
24
|
+
}
|
|
25
|
+
return this.resolved;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
append(record: WorkRecord): void {
|
|
29
|
+
this.resolveFor(record.project).append(record);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
readAll(): WorkRecord[] {
|
|
33
|
+
return this.resolveFor(this.fallbackCwd()).readAll();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
version(): string {
|
|
37
|
+
return this.resolveFor(this.fallbackCwd()).version();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { classifyRegistryEntries, DEFAULT_MAX_ENTRY_AGE_MS } from "../domain/registry-health.ts";
|
|
4
|
+
import type { RegistryClassification } from "../domain/registry-health.ts";
|
|
5
|
+
import type { ProcessRegistry, RegistryEntry, RegistrySweepDeps } from "../ports/process-registry.ts";
|
|
6
|
+
import { ensureDirMode, OWNER_FILE_MODE, tightenMode } from "./file-modes.ts";
|
|
7
|
+
|
|
8
|
+
const RUN_DIR_NAME = "run";
|
|
9
|
+
const JSON_EXT = ".json";
|
|
10
|
+
|
|
11
|
+
function isOrchestratorRef(value: unknown): boolean {
|
|
12
|
+
if (value === undefined) return true;
|
|
13
|
+
if (!value || typeof value !== "object") return false;
|
|
14
|
+
const ref = value as Record<string, unknown>;
|
|
15
|
+
return (
|
|
16
|
+
typeof ref["pid"] === "number" &&
|
|
17
|
+
typeof ref["project"] === "string" &&
|
|
18
|
+
typeof ref["startedAt"] === "string" &&
|
|
19
|
+
(ref["dir"] === undefined || typeof ref["dir"] === "string")
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRegistryEntry(value: unknown): value is RegistryEntry {
|
|
24
|
+
if (!value || typeof value !== "object") return false;
|
|
25
|
+
const record = value as Record<string, unknown>;
|
|
26
|
+
return (
|
|
27
|
+
typeof record["pid"] === "number" &&
|
|
28
|
+
typeof record["parentPid"] === "number" &&
|
|
29
|
+
(record["role"] === "orchestrator" || record["role"] === "subagent") &&
|
|
30
|
+
typeof record["project"] === "string" &&
|
|
31
|
+
typeof record["dir"] === "string" &&
|
|
32
|
+
typeof record["startedAt"] === "string" &&
|
|
33
|
+
isOrchestratorRef(record["orchestratorRef"]) &&
|
|
34
|
+
(record["processStartId"] === undefined || typeof record["processStartId"] === "number")
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeUnlink(filePath: string): void {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(filePath);
|
|
41
|
+
} catch {
|
|
42
|
+
// Best effort: another process (or a concurrent sweep) may have already removed it.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Delete `filePath` only when its on-disk bytes, re-read right now, are
|
|
48
|
+
* still byte-for-byte `expectedText` (F4, TOCTOU fix). Between this sweep's
|
|
49
|
+
* directory scan (where `expectedText` was captured) and this call, the pid
|
|
50
|
+
* this file is named after may have been reused by a brand new process that
|
|
51
|
+
* already wrote its own fresh entry to the very same path — deleting it then
|
|
52
|
+
* would destroy a live registration this sweep never actually judged.
|
|
53
|
+
* Skipping (rather than deleting) on any mismatch, a read failure, or the
|
|
54
|
+
* file already being gone is always the safe choice: a file that is
|
|
55
|
+
* genuinely stale gets a further chance on the next sweep.
|
|
56
|
+
*/
|
|
57
|
+
function safeUnlinkIfUnchanged(filePath: string, expectedText: string): void {
|
|
58
|
+
let current: string;
|
|
59
|
+
try {
|
|
60
|
+
current = readFileSync(filePath, "utf8");
|
|
61
|
+
} catch {
|
|
62
|
+
return; // already gone, or unreadable — nothing this call should touch.
|
|
63
|
+
}
|
|
64
|
+
if (current !== expectedText) return; // raced: a fresh entry now lives at this path.
|
|
65
|
+
safeUnlink(filePath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Default `isAlive`: probe with signal 0 — mirrors `pi-tracker.ts`/`sync-state-store.ts`'s own default. */
|
|
69
|
+
function defaultIsAlive(pid: number): boolean {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* {@link ProcessRegistry} backed by `<homeDir>/.kankaku/run/<pid>.json`,
|
|
80
|
+
* machine-wide and independent of any project's `KANKAKU_DIR` (ADR 0023).
|
|
81
|
+
* `record()` writes atomically (tmp file + rename), mirroring
|
|
82
|
+
* `file-inflight-store.ts`. Every operation degrades to "registry
|
|
83
|
+
* unavailable" (a no-op write, an empty read) on any failure — including a
|
|
84
|
+
* `homeDir()` that throws (no `HOME`, a sandboxed environment) — rather
|
|
85
|
+
* than propagating, since this is an enhancement to subagent detection and
|
|
86
|
+
* must never block or crash the extension it improves.
|
|
87
|
+
*/
|
|
88
|
+
export class MachineProcessRegistry implements ProcessRegistry {
|
|
89
|
+
private readonly homeDir: () => string;
|
|
90
|
+
|
|
91
|
+
constructor(homeDir: () => string) {
|
|
92
|
+
this.homeDir = homeDir;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private runDir(): string | undefined {
|
|
96
|
+
try {
|
|
97
|
+
return join(this.homeDir(), ".kankaku", RUN_DIR_NAME);
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
record(entry: RegistryEntry, isAlive: (pid: number) => boolean = defaultIsAlive, sweepDeps: RegistrySweepDeps = {}): void {
|
|
104
|
+
const dir = this.runDir();
|
|
105
|
+
if (dir === undefined) return;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
ensureDirMode(dir);
|
|
109
|
+
const target = join(dir, `${entry.pid}${JSON_EXT}`);
|
|
110
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
111
|
+
writeFileSync(tmp, JSON.stringify(entry), { mode: OWNER_FILE_MODE });
|
|
112
|
+
renameSync(tmp, target);
|
|
113
|
+
} catch {
|
|
114
|
+
// Best effort: a write failure (no permission, disk full) must not
|
|
115
|
+
// block or fail the run it would otherwise track.
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.sweep(dir, entry.pid, isAlive, sweepDeps);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
removeOwn(pid: number, processStartId: number | undefined): void {
|
|
123
|
+
const dir = this.runDir();
|
|
124
|
+
if (dir === undefined) return;
|
|
125
|
+
|
|
126
|
+
const target = join(dir, `${pid}${JSON_EXT}`);
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(readFileSync(target, "utf8"));
|
|
130
|
+
} catch {
|
|
131
|
+
return; // nothing to remove, or unreadable — best effort.
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isRegistryEntry(parsed) || parsed.pid !== pid || parsed.processStartId !== processStartId) {
|
|
135
|
+
// Not verifiably this process's own entry (already overwritten by a
|
|
136
|
+
// pid-reuse successor, or a mismatched identity) — never touch it.
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
safeUnlink(target);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
readAll(): RegistryEntry[] {
|
|
144
|
+
const dir = this.runDir();
|
|
145
|
+
if (dir === undefined || !existsSync(dir)) return [];
|
|
146
|
+
|
|
147
|
+
let names: string[];
|
|
148
|
+
try {
|
|
149
|
+
names = readdirSync(dir);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entries: RegistryEntry[] = [];
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
if (!name.endsWith(JSON_EXT)) continue; // skips .tmp files too (they never end in plain .json)
|
|
157
|
+
try {
|
|
158
|
+
const parsed: unknown = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
159
|
+
if (isRegistryEntry(parsed)) entries.push(parsed);
|
|
160
|
+
// Tolerate a structurally invalid entry (e.g. a torn write); skip it.
|
|
161
|
+
} catch {
|
|
162
|
+
// Tolerate malformed/corrupt files; skip them.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return entries;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read-only registry health snapshot for `/kankaku doctor`
|
|
170
|
+
* (SUBAGENT-REQ-017): every currently-persisted entry, classified as kept
|
|
171
|
+
* or discarded-and-why via the same pure classifier a real sweep uses —
|
|
172
|
+
* but without deleting anything, and (by default) without re-verifying
|
|
173
|
+
* any pid's live start identity, so this stays a cheap, no-extra-spawn
|
|
174
|
+
* diagnostic: a `stale-reuse` verdict therefore only ever shows up here
|
|
175
|
+
* when the caller explicitly injects a fresh `liveStartId` (e.g. from an
|
|
176
|
+
* ancestry snapshot it already has); otherwise such entries simply read
|
|
177
|
+
* as "kept" here even though a real `record()` sweep, run by a process
|
|
178
|
+
* that *does* have that live data, would already have discarded them.
|
|
179
|
+
*/
|
|
180
|
+
health(deps: { isAlive?: (pid: number) => boolean; liveStartId?: (pid: number) => number | undefined; now?: number } = {}): RegistryClassification {
|
|
181
|
+
const entries = this.readAll();
|
|
182
|
+
return classifyRegistryEntries(entries, -1, {
|
|
183
|
+
isAlive: deps.isAlive ?? defaultIsAlive,
|
|
184
|
+
liveStartId: deps.liveStartId ?? (() => undefined),
|
|
185
|
+
now: deps.now ?? Date.now(),
|
|
186
|
+
maxAgeMs: DEFAULT_MAX_ENTRY_AGE_MS,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Opportunistic sweep, run whenever this process writes its own entry
|
|
192
|
+
* (mirrors `file-inflight-store.ts`'s stray-tmp-file sweep pattern), so
|
|
193
|
+
* `run/` does not grow unbounded and never keeps serving a stale
|
|
194
|
+
* identity (SUBAGENT-REQ-010, and the PID-reuse fix). Delegates the
|
|
195
|
+
* actual keep/discard decision to the pure
|
|
196
|
+
* `domain/registry-health.ts#classifyRegistryEntries`, fed with every
|
|
197
|
+
* *structurally valid* on-disk entry (a corrupt/torn file is simply
|
|
198
|
+
* skipped here, same as `readAll`) plus whatever this call's own
|
|
199
|
+
* `isAlive`/`liveStartId` can tell it. Never removes the entry this call
|
|
200
|
+
* itself just wrote, regardless of what those report for it.
|
|
201
|
+
*/
|
|
202
|
+
private sweep(dir: string, ownPid: number, isAlive: (pid: number) => boolean, sweepDeps: RegistrySweepDeps): void {
|
|
203
|
+
let names: string[];
|
|
204
|
+
try {
|
|
205
|
+
names = readdirSync(dir);
|
|
206
|
+
} catch {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const entries: RegistryEntry[] = [];
|
|
211
|
+
const pathByPid = new Map<number, string>();
|
|
212
|
+
// Raw file text as read during this scan, keyed by pid — kept so a
|
|
213
|
+
// later deletion can re-verify byte-for-byte against what was actually
|
|
214
|
+
// judged stale (see the TOCTOU re-check below), never against a
|
|
215
|
+
// re-parsed/re-serialized copy that could mask a real change.
|
|
216
|
+
const textByPid = new Map<number, string>();
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
if (!name.endsWith(JSON_EXT)) continue;
|
|
219
|
+
const pid = Number(name.slice(0, -JSON_EXT.length));
|
|
220
|
+
if (!Number.isFinite(pid)) continue;
|
|
221
|
+
const path = join(dir, name);
|
|
222
|
+
// Best-effort mode tightening for every entry file this sweep
|
|
223
|
+
// encounters (F4), not just this process's own — an older kankaku
|
|
224
|
+
// build, or a filesystem with a permissive default, may have left one
|
|
225
|
+
// world/group-readable.
|
|
226
|
+
tightenMode(path, OWNER_FILE_MODE);
|
|
227
|
+
try {
|
|
228
|
+
const text = readFileSync(path, "utf8");
|
|
229
|
+
const parsed: unknown = JSON.parse(text);
|
|
230
|
+
if (isRegistryEntry(parsed)) {
|
|
231
|
+
entries.push(parsed);
|
|
232
|
+
pathByPid.set(pid, path);
|
|
233
|
+
textByPid.set(pid, text);
|
|
234
|
+
}
|
|
235
|
+
// A structurally invalid/corrupt file is left alone here — readAll
|
|
236
|
+
// already tolerates it by skipping, and this sweep only acts on
|
|
237
|
+
// entries it can positively classify.
|
|
238
|
+
} catch {
|
|
239
|
+
// Tolerate malformed/corrupt files; skip them.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { discard } = classifyRegistryEntries(entries, ownPid, {
|
|
244
|
+
isAlive,
|
|
245
|
+
liveStartId: sweepDeps.liveStartId ?? (() => undefined),
|
|
246
|
+
now: sweepDeps.now ?? Date.now(),
|
|
247
|
+
maxAgeMs: DEFAULT_MAX_ENTRY_AGE_MS,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
for (const { entry } of discard) {
|
|
251
|
+
const path = pathByPid.get(entry.pid);
|
|
252
|
+
const expectedText = textByPid.get(entry.pid);
|
|
253
|
+
if (path && expectedText !== undefined) safeUnlinkIfUnchanged(path, expectedText);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|