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,115 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isWorkRecord } from "../domain/work-record.ts";
|
|
4
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
5
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
6
|
+
|
|
7
|
+
const INFLIGHT_DIR_NAME = "inflight";
|
|
8
|
+
const JSON_EXT = ".json";
|
|
9
|
+
const TMP_EXT = ".tmp";
|
|
10
|
+
/** Matches `save`'s tmp filename: `<ownerPid>.json.<writerPid>.<timestamp>.tmp`. */
|
|
11
|
+
const TMP_NAME_PATTERN = /\.json\.(\d+)\.\d+\.tmp$/;
|
|
12
|
+
|
|
13
|
+
function safeUnlink(filePath: string): void {
|
|
14
|
+
try {
|
|
15
|
+
unlinkSync(filePath);
|
|
16
|
+
} catch {
|
|
17
|
+
// Best effort: another process may have already removed it.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The writer pid embedded in a `save` tmp filename, or `undefined` when it cannot be parsed. */
|
|
22
|
+
function parseTmpWriterPid(entry: string): number | undefined {
|
|
23
|
+
const match = TMP_NAME_PATTERN.exec(entry);
|
|
24
|
+
if (!match) return undefined;
|
|
25
|
+
const pid = Number(match[1]);
|
|
26
|
+
return Number.isFinite(pid) ? pid : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* {@link InflightStore} backed by one checkpoint file per process,
|
|
31
|
+
* `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
|
|
32
|
+
* it into place so a reader never observes a partially written checkpoint.
|
|
33
|
+
*/
|
|
34
|
+
export class FileInflightStore implements InflightStore {
|
|
35
|
+
private readonly dir: string;
|
|
36
|
+
private readonly pid: number;
|
|
37
|
+
|
|
38
|
+
constructor(dir: string, pid: number) {
|
|
39
|
+
this.dir = dir;
|
|
40
|
+
this.pid = pid;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private get inflightDir(): string {
|
|
44
|
+
return join(this.dir, INFLIGHT_DIR_NAME);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private filePathFor(pid: number): string {
|
|
48
|
+
return join(this.inflightDir, `${pid}${JSON_EXT}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
save(record: WorkRecord): void {
|
|
52
|
+
mkdirSync(this.inflightDir, { recursive: true });
|
|
53
|
+
const target = this.filePathFor(this.pid);
|
|
54
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
55
|
+
writeFileSync(tmp, JSON.stringify(record));
|
|
56
|
+
renameSync(tmp, target);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clear(): void {
|
|
60
|
+
safeUnlink(this.filePathFor(this.pid));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
64
|
+
if (!existsSync(this.inflightDir)) return [];
|
|
65
|
+
|
|
66
|
+
const recovered: WorkRecord[] = [];
|
|
67
|
+
const ownFileName = `${this.pid}${JSON_EXT}`;
|
|
68
|
+
|
|
69
|
+
for (const entry of readdirSync(this.inflightDir)) {
|
|
70
|
+
if (entry.endsWith(TMP_EXT)) {
|
|
71
|
+
this.sweepTmpEntry(entry, isAlive);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
|
|
76
|
+
|
|
77
|
+
const filePath = join(this.inflightDir, entry);
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
safeUnlink(filePath);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!isWorkRecord(parsed)) {
|
|
87
|
+
safeUnlink(filePath);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isAlive(parsed.pid)) continue;
|
|
92
|
+
|
|
93
|
+
recovered.push({ ...parsed, status: "interrupted" });
|
|
94
|
+
safeUnlink(filePath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return recovered;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Delete a stray `save()` tmp file left behind by a writer that crashed
|
|
102
|
+
* between the write and the rename. Deleted when the writer pid is not
|
|
103
|
+
* alive, or when the filename cannot be parsed at all (nothing to check
|
|
104
|
+
* liveness against). A tmp file written by this very process is always
|
|
105
|
+
* left alone regardless of what `isAlive` reports, since a concurrent
|
|
106
|
+
* `save()` in this process may still be renaming it into place.
|
|
107
|
+
*/
|
|
108
|
+
private sweepTmpEntry(entry: string, isAlive: (pid: number) => boolean): void {
|
|
109
|
+
const writerPid = parseTmpWriterPid(entry);
|
|
110
|
+
if (writerPid === process.pid) return;
|
|
111
|
+
if (writerPid === undefined || !isAlive(writerPid)) {
|
|
112
|
+
safeUnlink(join(this.inflightDir, entry));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every file kankaku writes under `~/.kankaku` (the machine-wide registry
|
|
5
|
+
* `run/`, the catalog cache) carries absolute project paths, session ids, or
|
|
6
|
+
* hub identifiers — restricted to the owner (F4). Not applied to a
|
|
7
|
+
* project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
|
|
8
|
+
* project-local, frequently committed alongside, and out of scope here.
|
|
9
|
+
*/
|
|
10
|
+
export const OWNER_DIR_MODE = 0o700;
|
|
11
|
+
export const OWNER_FILE_MODE = 0o600;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create `dir` (recursive) with `mode`, then best-effort tighten it when it
|
|
15
|
+
* already existed with a looser mode — `mkdirSync`'s own `mode` option only
|
|
16
|
+
* applies to a directory it actually creates in this call, never to one
|
|
17
|
+
* that already existed (e.g. left by an older kankaku build, or a
|
|
18
|
+
* permissive umask). Never throws: a permission-tightening failure must
|
|
19
|
+
* never block the write this call exists to make.
|
|
20
|
+
*/
|
|
21
|
+
export function ensureDirMode(dir: string, mode: number = OWNER_DIR_MODE): void {
|
|
22
|
+
mkdirSync(dir, { recursive: true, mode });
|
|
23
|
+
tightenMode(dir, mode);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
|
|
27
|
+
export function tightenMode(path: string, mode: number = OWNER_FILE_MODE): void {
|
|
28
|
+
try {
|
|
29
|
+
const current = statSync(path).mode & 0o777;
|
|
30
|
+
if ((current & ~mode) !== 0) chmodSync(path, mode);
|
|
31
|
+
} catch {
|
|
32
|
+
// Best effort: another process may have already removed the path, or
|
|
33
|
+
// this filesystem does not support POSIX modes at all.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared line-building for the hub-facing `/kankaku` subcommands (`sync`,
|
|
3
|
+
* `sync all`, `sync status`, `backfill`, `catalog refresh`) and the panel's
|
|
4
|
+
* `sync`/`export` screens (`adapters/panel/screens/sync.ts`), extracted from
|
|
5
|
+
* `kankaku-command.ts` so the subcommands and the panel can never drift —
|
|
6
|
+
* see odd/tasks/kankaku-panel.md P4.
|
|
7
|
+
*/
|
|
8
|
+
import type { CatalogSnapshot } from "../ports/catalog.ts";
|
|
9
|
+
import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
13
|
+
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
14
|
+
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
15
|
+
*/
|
|
16
|
+
export function buildSyncStatusLines(status: SyncStatusSnapshot): string[] {
|
|
17
|
+
const { state, pending, staleOutsideWindow } = status;
|
|
18
|
+
const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
|
|
19
|
+
if (staleOutsideWindow > 0) {
|
|
20
|
+
lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
|
|
21
|
+
}
|
|
22
|
+
if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
|
|
23
|
+
return lines;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render a {@link SyncSummary} as report lines: counts, any stop reason, the
|
|
28
|
+
* new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
|
|
29
|
+
* sync`/`sync all` and the panel's sync screen.
|
|
30
|
+
*/
|
|
31
|
+
export function formatSyncSummaryLines(summary: SyncSummary): string[] {
|
|
32
|
+
const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
|
|
33
|
+
|
|
34
|
+
if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
|
|
35
|
+
if (summary.error) lines.push(`stopped early: ${summary.error}`);
|
|
36
|
+
if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
|
|
37
|
+
|
|
38
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
39
|
+
if (unassignedEntries.length > 0) {
|
|
40
|
+
lines.push("unassigned (Sin determinar):");
|
|
41
|
+
for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (summary.failed.length > 0) {
|
|
45
|
+
lines.push("failed:");
|
|
46
|
+
for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
|
|
54
|
+
* run, grouped by their old free-text label, or "no unassigned tasks" when
|
|
55
|
+
* none were. Exact body of `handleBackfillCommand`.
|
|
56
|
+
*/
|
|
57
|
+
export function formatBackfillLines(summary: SyncSummary): string[] {
|
|
58
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
59
|
+
|
|
60
|
+
const lines =
|
|
61
|
+
unassignedEntries.length > 0
|
|
62
|
+
? [
|
|
63
|
+
...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
|
|
64
|
+
"reassign these in the hub web app's unassigned queue",
|
|
65
|
+
]
|
|
66
|
+
: ["no unassigned tasks"];
|
|
67
|
+
|
|
68
|
+
if (summary.error) lines.push(`stopped early: ${summary.error}`);
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `/kankaku catalog refresh`'s report lines: client/project counts, or an
|
|
74
|
+
* unreachable notice when the refresh failed (`snapshot` is `undefined`).
|
|
75
|
+
* The subcommand still notifies that failure as an error rather than a
|
|
76
|
+
* report (see `handleCatalogCommand`); the panel's sync screen shows
|
|
77
|
+
* whatever this returns either way.
|
|
78
|
+
*/
|
|
79
|
+
export function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[] {
|
|
80
|
+
if (!snapshot) return ["hub unreachable; catalog not refreshed"];
|
|
81
|
+
return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
|
|
82
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadHubEnvCredentials, validateHubUrl } from "../config.ts";
|
|
4
|
+
|
|
5
|
+
const CREDENTIALS_FILE = join(".kankaku", "credentials.json");
|
|
6
|
+
|
|
7
|
+
export interface HubCredentials {
|
|
8
|
+
url: string;
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HubCredentialsFile {
|
|
14
|
+
url?: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
password?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read `<homeDir>/.kankaku/credentials.json`. Tolerates a missing file,
|
|
21
|
+
* malformed JSON, a non-object document, or non-string fields — all
|
|
22
|
+
* return `{}` rather than throwing, since this file is optional and
|
|
23
|
+
* hand-edited (mirrors `project-config.ts#readProjectClient`).
|
|
24
|
+
*/
|
|
25
|
+
function readCredentialsFile(filePath: string): HubCredentialsFile {
|
|
26
|
+
if (!existsSync(filePath)) return {};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
|
|
30
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
31
|
+
const record = parsed as Record<string, unknown>;
|
|
32
|
+
return {
|
|
33
|
+
url: typeof record["url"] === "string" ? record["url"] : undefined,
|
|
34
|
+
email: typeof record["email"] === "string" ? record["email"] : undefined,
|
|
35
|
+
password: typeof record["password"] === "string" ? record["password"] : undefined,
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolveHubCredentialsDeps {
|
|
43
|
+
env: NodeJS.ProcessEnv;
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the home directory; called lazily, inside this function, and
|
|
46
|
+
* defensively. A throwing provider (no `HOME`, a sandboxed environment
|
|
47
|
+
* without a resolvable home directory) is treated the same as "no home
|
|
48
|
+
* directory" rather than propagating — env-only credentials must still
|
|
49
|
+
* resolve, and the hub-unconfigured case must stay a no-op regardless of
|
|
50
|
+
* the host environment. Injectable for tests; defaults to `os.homedir` at
|
|
51
|
+
* the call site (extension.ts) — passed as a reference, never invoked
|
|
52
|
+
* there, so a throw never escapes before this function's own try/catch.
|
|
53
|
+
*/
|
|
54
|
+
homeDir: () => string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResolveHubCredentialsResult {
|
|
58
|
+
/** `undefined` when unconfigured (no url/email/password from any source) or the URL is refused. */
|
|
59
|
+
credentials: HubCredentials | undefined;
|
|
60
|
+
/** Set only when a hub URL was given but rejected by {@link validateHubUrl}; surface it once via `ctx.ui.notify`. */
|
|
61
|
+
invalidReason?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
|
|
66
|
+
* precedence per field over `~/.kankaku/credentials.json` (so a partially
|
|
67
|
+
* set environment still combines with the file rather than failing
|
|
68
|
+
* closed). Never reads the project's own `.kankaku/config.json` — that
|
|
69
|
+
* file is project-local and frequently committed.
|
|
70
|
+
*/
|
|
71
|
+
/** Resolve `homeDir()` defensively: any failure (no `HOME`, a sandboxed environment) yields `undefined` instead of throwing. Exported so callers with their own homedir-dependent path (e.g. `extension.ts`'s catalog cache) can share the same guard. */
|
|
72
|
+
export function safeHomeDir(homeDir: () => string): string | undefined {
|
|
73
|
+
try {
|
|
74
|
+
return homeDir();
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resolveHubCredentials(deps: ResolveHubCredentialsDeps): ResolveHubCredentialsResult {
|
|
81
|
+
const fromEnv = loadHubEnvCredentials(deps.env);
|
|
82
|
+
const homeDir = safeHomeDir(deps.homeDir);
|
|
83
|
+
const fromFile = homeDir !== undefined ? readCredentialsFile(join(homeDir, CREDENTIALS_FILE)) : {};
|
|
84
|
+
|
|
85
|
+
const url = fromEnv.url ?? fromFile.url;
|
|
86
|
+
const email = fromEnv.email ?? fromFile.email;
|
|
87
|
+
const password = fromEnv.password ?? fromFile.password;
|
|
88
|
+
|
|
89
|
+
if (!url || !email || !password) return { credentials: undefined };
|
|
90
|
+
|
|
91
|
+
const validation = validateHubUrl(url);
|
|
92
|
+
if (!validation.ok) return { credentials: undefined, invalidReason: validation.reason };
|
|
93
|
+
|
|
94
|
+
return { credentials: { url, email, password } };
|
|
95
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { dedupeById } from "../domain/task-view.ts";
|
|
4
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
5
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
6
|
+
import { isWorkRecord } from "../domain/work-record.ts";
|
|
7
|
+
|
|
8
|
+
const LOG_FILE_NAME = "worklog.jsonl";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
|
|
12
|
+
* A record is written with a single `appendFileSync` call so a parent and
|
|
13
|
+
* its subagent children can write concurrently without interleaving lines.
|
|
14
|
+
*/
|
|
15
|
+
export class JsonlWorkLog implements WorkLog {
|
|
16
|
+
private readonly dir: string;
|
|
17
|
+
|
|
18
|
+
constructor(dir: string) {
|
|
19
|
+
this.dir = dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private get filePath(): string {
|
|
23
|
+
return join(this.dir, LOG_FILE_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
append(record: WorkRecord): void {
|
|
27
|
+
mkdirSync(this.dir, { recursive: true });
|
|
28
|
+
appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cheap change signal: `mtimeMs:size` of the log file, computed with a
|
|
33
|
+
* single `statSync` rather than reading the file. `"0:0"` when the file
|
|
34
|
+
* does not exist yet (before the first `append`).
|
|
35
|
+
*/
|
|
36
|
+
version(): string {
|
|
37
|
+
try {
|
|
38
|
+
const stats = statSync(this.filePath);
|
|
39
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return "0:0";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
readAll(): WorkRecord[] {
|
|
46
|
+
if (!existsSync(this.filePath)) return [];
|
|
47
|
+
const content = readFileSync(this.filePath, "utf8");
|
|
48
|
+
const records: WorkRecord[] = [];
|
|
49
|
+
for (const line of content.split("\n")) {
|
|
50
|
+
const trimmed = line.trim();
|
|
51
|
+
if (!trimmed) continue;
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
54
|
+
if (isWorkRecord(parsed)) {
|
|
55
|
+
records.push(parsed);
|
|
56
|
+
}
|
|
57
|
+
// Tolerate a structurally invalid record (e.g. an incompatible
|
|
58
|
+
// schema or a torn write that still parses as JSON); skip it.
|
|
59
|
+
} catch {
|
|
60
|
+
// Tolerate malformed lines (e.g. a torn write); skip them.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// One id can be in the file twice (written at settle, then re-appended by
|
|
64
|
+
// crash recovery): every reader gets it once — see dedupeById.
|
|
65
|
+
return dedupeById(records);
|
|
66
|
+
}
|
|
67
|
+
}
|