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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,85 @@
1
+ import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ /**
4
+ * Resolve the kankaku data directory: an absolute `dirOrRelative` is used
5
+ * as-is, a relative one is joined against `cwd`. Shared by every lazy
6
+ * adapter so the rule lives in exactly one place.
7
+ */
8
+ export function resolveKankakuDir(dirOrRelative, cwd) {
9
+ return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
10
+ }
11
+ /** Matches this probe's own marker filename: `.kankaku-write-probe.<pid>.<timestamp>.tmp`. */
12
+ const PROBE_NAME_PATTERN = /^\.kankaku-write-probe\.\d+\.(\d+)\.tmp$/;
13
+ /** A marker older than this is assumed abandoned (its writer was SIGKILLed between the write and its own unlink) — R4. */
14
+ const PROBE_STALE_MS = 60_000;
15
+ /**
16
+ * Best-effort removal of a stale writability-probe marker (R4): a probe
17
+ * that gets SIGKILLed between its `writeFileSync` and its own `unlinkSync`
18
+ * leaves `.kankaku-write-probe.<pid>.<timestamp>.tmp` behind forever —
19
+ * nothing else in `dir` ever looks at it again otherwise. Runs
20
+ * opportunistically every time the probe itself runs (mirrors
21
+ * `machine-process-registry.ts`'s own-write-time sweep pattern), so no
22
+ * separate cleanup process is needed. Only removes a marker whose
23
+ * filename-embedded timestamp (not the file's mtime, so this stays
24
+ * deterministic and easy to test) is older than a minute — never a fresh
25
+ * one, including this call's own marker (written after this sweep runs) or
26
+ * a concurrent process's own in-flight probe.
27
+ */
28
+ function sweepStaleWriteProbes(dir, now) {
29
+ let names;
30
+ try {
31
+ names = readdirSync(dir);
32
+ }
33
+ catch {
34
+ return; // dir just created and already empty, or unreadable — nothing to sweep.
35
+ }
36
+ for (const name of names) {
37
+ const match = PROBE_NAME_PATTERN.exec(name);
38
+ if (!match)
39
+ continue;
40
+ const timestamp = Number(match[1]);
41
+ if (!Number.isFinite(timestamp) || now - timestamp <= PROBE_STALE_MS)
42
+ continue;
43
+ try {
44
+ unlinkSync(join(dir, name));
45
+ }
46
+ catch {
47
+ // Best effort: already gone, or a concurrent sweep got there first.
48
+ }
49
+ }
50
+ }
51
+ /** `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. */
52
+ function defaultWritabilityProbe(dir) {
53
+ mkdirSync(dir, { recursive: true });
54
+ sweepStaleWriteProbes(dir, Date.now());
55
+ const marker = join(dir, `.kankaku-write-probe.${process.pid}.${Date.now()}.tmp`);
56
+ writeFileSync(marker, "");
57
+ unlinkSync(marker);
58
+ }
59
+ /**
60
+ * Resolve the target directory for a write that would normally go to
61
+ * `candidateDir` (F1: routing a verified subagent's work log/inflight
62
+ * checkpoints to its orchestrator's kankaku directory instead of its own
63
+ * cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
64
+ * proven writable — created if it does not exist yet — `fallbackDir` (the
65
+ * process's own local directory) otherwise, so a parent directory that no
66
+ * longer exists, or that this process lacks permission to write to,
67
+ * degrades to a safe, local fallback instead of throwing and losing the
68
+ * record entirely. `usedFallback` lets the caller surface this (`/kankaku
69
+ * doctor`) so a human can notice and reunite the record manually — the
70
+ * append-only log can never be rewritten to fix it after the fact. Never
71
+ * throws: `fallbackDir` itself is never probed here — its own writability
72
+ * is handled the ordinary way, by whichever adapter eventually writes to
73
+ * it, exactly as it always has been for a process that never needed to
74
+ * route anywhere.
75
+ */
76
+ export function resolveWritableTarget(candidateDir, fallbackDir, deps = {}) {
77
+ const probe = deps.probe ?? defaultWritabilityProbe;
78
+ try {
79
+ probe(candidateDir);
80
+ return { dir: candidateDir, usedFallback: false };
81
+ }
82
+ catch {
83
+ return { dir: fallbackDir, usedFallback: true };
84
+ }
85
+ }
@@ -0,0 +1,17 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { WorkLog } from "../ports/work-log.ts";
3
+ /**
4
+ * {@link WorkLog} that resolves a relative log directory lazily: against the
5
+ * project of the first appended record, or against `fallbackCwd()` when a
6
+ * read happens before any record was written in this process.
7
+ */
8
+ export declare class LazyJsonlWorkLog implements WorkLog {
9
+ private readonly dirOrRelative;
10
+ private readonly fallbackCwd;
11
+ private resolved;
12
+ constructor(dirOrRelative: string, fallbackCwd?: () => string);
13
+ private resolveFor;
14
+ append(record: WorkRecord): void;
15
+ readAll(): WorkRecord[];
16
+ version(): string;
17
+ }
@@ -0,0 +1,31 @@
1
+ import { JsonlWorkLog } from "./jsonl-work-log.js";
2
+ import { resolveKankakuDir } from "./kankaku-dir.js";
3
+ /**
4
+ * {@link WorkLog} that resolves a relative log directory lazily: against the
5
+ * project of the first appended record, or against `fallbackCwd()` when a
6
+ * read happens before any record was written in this process.
7
+ */
8
+ export class LazyJsonlWorkLog {
9
+ dirOrRelative;
10
+ fallbackCwd;
11
+ resolved;
12
+ constructor(dirOrRelative, fallbackCwd = () => process.cwd()) {
13
+ this.dirOrRelative = dirOrRelative;
14
+ this.fallbackCwd = fallbackCwd;
15
+ }
16
+ resolveFor(cwd) {
17
+ if (!this.resolved) {
18
+ this.resolved = new JsonlWorkLog(resolveKankakuDir(this.dirOrRelative, cwd));
19
+ }
20
+ return this.resolved;
21
+ }
22
+ append(record) {
23
+ this.resolveFor(record.project).append(record);
24
+ }
25
+ readAll() {
26
+ return this.resolveFor(this.fallbackCwd()).readAll();
27
+ }
28
+ version() {
29
+ return this.resolveFor(this.fallbackCwd()).version();
30
+ }
31
+ }
@@ -0,0 +1,16 @@
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
+ import type { PocketBaseClient } from "./pocketbase-client.ts";
3
+ /**
4
+ * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
5
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
6
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
7
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
8
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
9
+ * title. `signal`, when given, bounds every fetch (see
10
+ * `pocketbase-client.ts#request`).
11
+ */
12
+ export declare function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{
13
+ clients: Client[];
14
+ projects: Project[];
15
+ tasks: HubTask[];
16
+ }>;
@@ -0,0 +1,56 @@
1
+ const TASK_STATUSES = new Set(["open", "doing", "done"]);
2
+ function mapClient(record) {
3
+ return {
4
+ id: record.id,
5
+ name: record.name,
6
+ code: record.code,
7
+ active: record.active === true,
8
+ ...(record.unassigned === true ? { unassigned: true } : {}),
9
+ };
10
+ }
11
+ function mapProject(record) {
12
+ return {
13
+ id: record.id,
14
+ name: record.name,
15
+ ...(record.code !== undefined ? { code: record.code } : {}),
16
+ clientId: record.client,
17
+ repoPaths: Array.isArray(record.repo_paths) ? record.repo_paths.filter((path) => typeof path === "string") : [],
18
+ active: record.active === true,
19
+ };
20
+ }
21
+ /** `open` on any unrecognised or missing status, rather than dropping the task. */
22
+ function mapTaskStatus(status) {
23
+ return status !== undefined && TASK_STATUSES.has(status) ? status : "open";
24
+ }
25
+ function mapTask(record) {
26
+ return {
27
+ id: record.id,
28
+ title: record.title ?? "",
29
+ projectId: record.project,
30
+ status: mapTaskStatus(record.status),
31
+ ...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
32
+ };
33
+ }
34
+ /**
35
+ * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
36
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
37
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
38
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
39
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
40
+ * title. `signal`, when given, bounds every fetch (see
41
+ * `pocketbase-client.ts#request`).
42
+ */
43
+ export function createPocketBaseCatalogFetcher(client) {
44
+ return async (signal) => {
45
+ const [clientRecords, projectRecords, taskRecords] = await Promise.all([
46
+ client.list("clients", { sort: "name" }, signal),
47
+ client.list("projects", { sort: "name" }, signal),
48
+ client.list("tasks", { sort: "title" }, signal),
49
+ ]);
50
+ return {
51
+ clients: clientRecords.map(mapClient),
52
+ projects: projectRecords.map(mapProject),
53
+ tasks: taskRecords.map(mapTask),
54
+ };
55
+ };
56
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Reusable, minimal PocketBase HTTP client: auth (with a single re-auth on
3
+ * 401), pagination, timeouts, and typed errors, built around an injected
4
+ * `fetch`. No pi imports, no domain knowledge — this is a general-purpose
5
+ * adapter meant to be reused by the sync push (a later phase) for
6
+ * create/update calls through the same `request()` primitive.
7
+ */
8
+ export type PocketBaseFetch = typeof fetch;
9
+ export interface PocketBaseRecord {
10
+ id: string;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface PocketBaseListResult<T> {
14
+ page: number;
15
+ perPage: number;
16
+ totalItems: number;
17
+ totalPages: number;
18
+ items: T[];
19
+ }
20
+ export interface PocketBaseClientOptions {
21
+ /** Base URL, e.g. `https://pb.example.com` (no trailing slash required). */
22
+ url: string;
23
+ email: string;
24
+ password: string;
25
+ /** Injectable for tests; defaults to `globalThis.fetch`. */
26
+ fetch?: PocketBaseFetch;
27
+ /** Per-request timeout in ms. Defaults to 3000. */
28
+ timeoutMs?: number;
29
+ }
30
+ export type PocketBaseErrorKind = "network" | "timeout" | "http" | "auth";
31
+ export declare class PocketBaseError extends Error {
32
+ readonly kind: PocketBaseErrorKind;
33
+ readonly status: number | undefined;
34
+ constructor(kind: PocketBaseErrorKind, message: string, status?: number);
35
+ }
36
+ /**
37
+ * Minimal PocketBase client: lazily authenticates on the first request,
38
+ * reuses the token, and re-authenticates exactly once on a 401 before
39
+ * failing cleanly. `request` is the generic primitive other adapters (and
40
+ * a future sync sink) build on; `list` is a pagination convenience for
41
+ * read-only catalog fetches.
42
+ */
43
+ export declare class PocketBaseClient {
44
+ private readonly baseUrl;
45
+ private readonly email;
46
+ private readonly password;
47
+ private readonly doFetch;
48
+ private readonly timeoutMs;
49
+ private token;
50
+ /** Single-flight guard: concurrent callers with no token share this in-flight authentication instead of each posting their own. Cleared on settle (success or failure) so a failed auth never poisons a later attempt. */
51
+ private authInFlight;
52
+ constructor(options: PocketBaseClientOptions);
53
+ /** Perform `request` with the current token (authenticating first if there is none). `signal` bounds the underlying fetch when starting a fresh authentication; ignored by a caller that joins one already in flight. */
54
+ private authenticate;
55
+ private doAuthenticate;
56
+ /** `signal`, when given, is composed with this call's own per-request timeout signal (`AbortSignal.any`) so a caller can additionally bound a whole sequence of calls with one overall deadline. */
57
+ private rawFetch;
58
+ /**
59
+ * Generic request primitive: `method`/`path` (e.g. `/api/collections/clients/records`)
60
+ * with an optional JSON `body`. Authenticates lazily, retries exactly
61
+ * once on a 401 after re-authenticating, and throws a typed
62
+ * {@link PocketBaseError} on network failure, timeout, auth failure, or
63
+ * any other non-2xx response. `signal`, when given, is composed with
64
+ * every underlying request's own per-request timeout, so a caller can
65
+ * bound this whole call (including a lazy auth and the 401 retry) with
66
+ * one overall deadline; an abort surfaces as a `"timeout"` error, same as
67
+ * a per-request timeout.
68
+ */
69
+ request<T = unknown>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
70
+ /**
71
+ * Fetch every record of `collection`, paginating until every page is
72
+ * read. `perPage` defaults to 200; `filter`/`sort` are passed through
73
+ * verbatim as PocketBase filter/sort expressions. `signal`, when given,
74
+ * bounds the whole pagination loop (see {@link request}).
75
+ */
76
+ list<T extends PocketBaseRecord = PocketBaseRecord>(collection: string, opts?: {
77
+ filter?: string;
78
+ sort?: string;
79
+ perPage?: number;
80
+ }, signal?: AbortSignal): Promise<T[]>;
81
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Reusable, minimal PocketBase HTTP client: auth (with a single re-auth on
3
+ * 401), pagination, timeouts, and typed errors, built around an injected
4
+ * `fetch`. No pi imports, no domain knowledge — this is a general-purpose
5
+ * adapter meant to be reused by the sync push (a later phase) for
6
+ * create/update calls through the same `request()` primitive.
7
+ */
8
+ export class PocketBaseError extends Error {
9
+ kind;
10
+ status;
11
+ constructor(kind, message, status) {
12
+ super(message);
13
+ this.name = "PocketBaseError";
14
+ this.kind = kind;
15
+ this.status = status;
16
+ }
17
+ }
18
+ const DEFAULT_TIMEOUT_MS = 3000;
19
+ const AUTH_PATH = "/api/collections/users/auth-with-password";
20
+ function isAbortError(error) {
21
+ return error instanceof Error && error.name === "AbortError";
22
+ }
23
+ /** Build a `records` list path with pagination/filter/sort query params. */
24
+ function listPath(collection, page, perPage, filter, sort) {
25
+ const params = new URLSearchParams({ page: String(page), perPage: String(perPage) });
26
+ if (filter)
27
+ params.set("filter", filter);
28
+ if (sort)
29
+ params.set("sort", sort);
30
+ return `/api/collections/${encodeURIComponent(collection)}/records?${params.toString()}`;
31
+ }
32
+ /**
33
+ * Minimal PocketBase client: lazily authenticates on the first request,
34
+ * reuses the token, and re-authenticates exactly once on a 401 before
35
+ * failing cleanly. `request` is the generic primitive other adapters (and
36
+ * a future sync sink) build on; `list` is a pagination convenience for
37
+ * read-only catalog fetches.
38
+ */
39
+ export class PocketBaseClient {
40
+ baseUrl;
41
+ email;
42
+ password;
43
+ doFetch;
44
+ timeoutMs;
45
+ token;
46
+ /** Single-flight guard: concurrent callers with no token share this in-flight authentication instead of each posting their own. Cleared on settle (success or failure) so a failed auth never poisons a later attempt. */
47
+ authInFlight;
48
+ constructor(options) {
49
+ this.baseUrl = options.url.replace(/\/+$/, "");
50
+ this.email = options.email;
51
+ this.password = options.password;
52
+ this.doFetch = options.fetch ?? fetch;
53
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ }
55
+ /** Perform `request` with the current token (authenticating first if there is none). `signal` bounds the underlying fetch when starting a fresh authentication; ignored by a caller that joins one already in flight. */
56
+ authenticate(signal) {
57
+ if (!this.authInFlight) {
58
+ this.authInFlight = this.doAuthenticate(signal).finally(() => {
59
+ this.authInFlight = undefined;
60
+ });
61
+ }
62
+ return this.authInFlight;
63
+ }
64
+ async doAuthenticate(signal) {
65
+ const response = await this.rawFetch("POST", AUTH_PATH, { identity: this.email, password: this.password }, undefined, signal);
66
+ if (!response.ok) {
67
+ throw new PocketBaseError("auth", `kankaku: hub authentication failed (${response.status})`, response.status);
68
+ }
69
+ const body = (await response.json());
70
+ this.token = body.token;
71
+ }
72
+ /** `signal`, when given, is composed with this call's own per-request timeout signal (`AbortSignal.any`) so a caller can additionally bound a whole sequence of calls with one overall deadline. */
73
+ async rawFetch(method, path, body, token, signal) {
74
+ const controller = new AbortController();
75
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
76
+ const requestSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
77
+ try {
78
+ return await this.doFetch(`${this.baseUrl}${path}`, {
79
+ method,
80
+ headers: {
81
+ "content-type": "application/json",
82
+ ...(token !== undefined ? { Authorization: token } : {}),
83
+ },
84
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
85
+ signal: requestSignal,
86
+ });
87
+ }
88
+ catch (error) {
89
+ if (isAbortError(error)) {
90
+ throw new PocketBaseError("timeout", `kankaku: hub request timed out after ${this.timeoutMs}ms: ${method} ${path}`);
91
+ }
92
+ throw new PocketBaseError("network", `kankaku: hub request failed: ${method} ${path}: ${error.message}`);
93
+ }
94
+ finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+ /**
99
+ * Generic request primitive: `method`/`path` (e.g. `/api/collections/clients/records`)
100
+ * with an optional JSON `body`. Authenticates lazily, retries exactly
101
+ * once on a 401 after re-authenticating, and throws a typed
102
+ * {@link PocketBaseError} on network failure, timeout, auth failure, or
103
+ * any other non-2xx response. `signal`, when given, is composed with
104
+ * every underlying request's own per-request timeout, so a caller can
105
+ * bound this whole call (including a lazy auth and the 401 retry) with
106
+ * one overall deadline; an abort surfaces as a `"timeout"` error, same as
107
+ * a per-request timeout.
108
+ */
109
+ async request(method, path, body, signal) {
110
+ if (this.token === undefined) {
111
+ await this.authenticate(signal);
112
+ }
113
+ let response = await this.rawFetch(method, path, body, this.token, signal);
114
+ if (response.status === 401) {
115
+ this.token = undefined;
116
+ await this.authenticate(signal);
117
+ response = await this.rawFetch(method, path, body, this.token, signal);
118
+ }
119
+ if (!response.ok) {
120
+ if (response.status === 401) {
121
+ throw new PocketBaseError("auth", `kankaku: hub rejected credentials for ${method} ${path}`, response.status);
122
+ }
123
+ throw new PocketBaseError("http", `kankaku: hub request failed: ${method} ${path} (${response.status})`, response.status);
124
+ }
125
+ if (response.status === 204)
126
+ return undefined;
127
+ return (await response.json());
128
+ }
129
+ /**
130
+ * Fetch every record of `collection`, paginating until every page is
131
+ * read. `perPage` defaults to 200; `filter`/`sort` are passed through
132
+ * verbatim as PocketBase filter/sort expressions. `signal`, when given,
133
+ * bounds the whole pagination loop (see {@link request}).
134
+ */
135
+ async list(collection, opts = {}, signal) {
136
+ const perPage = opts.perPage ?? 200;
137
+ const items = [];
138
+ let page = 1;
139
+ for (;;) {
140
+ const result = await this.request("GET", listPath(collection, page, perPage, opts.filter, opts.sort), undefined, signal);
141
+ items.push(...result.items);
142
+ if (page >= result.totalPages || result.items.length === 0)
143
+ break;
144
+ page += 1;
145
+ }
146
+ return items;
147
+ }
148
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * {@link WorkSink} backed by a real PocketBase instance: upserts one
3
+ * `task_entries` row per task (create-only assignment, see
4
+ * `domain/hub-entry.ts`) and, when enabled, its `work_records` children.
5
+ *
6
+ * PocketBase has no native upsert (contract.md "Upsert by task_id"): look
7
+ * up existing rows by their unique key in chunks, then create or patch.
8
+ * A unique-violation on create (approximated here as any 400 on create,
9
+ * since `PocketBaseError` does not carry the response body — see the
10
+ * `isLikelyUniqueViolation` note below) is treated as "already exists":
11
+ * look it up and patch instead.
12
+ */
13
+ import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
14
+ import type { TaskView } from "../domain/task-view.ts";
15
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
16
+ import type { PushTaskResult, WorkSink } from "../ports/work-sink.ts";
17
+ import type { PocketBaseClient } from "./pocketbase-client.ts";
18
+ export interface PocketBaseSinkDeps {
19
+ client: PocketBaseClient;
20
+ /** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
21
+ clients: Client[];
22
+ projects: Project[];
23
+ tasks: HubTask[];
24
+ machine: string;
25
+ promptMode: PromptPrivacyMode;
26
+ /** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
27
+ agent?: string;
28
+ agentVersion?: string;
29
+ /** The integration that wrote these rows; see `domain/hub-entry.ts#HubEntryContext.plugin`. Defaults to `"kankaku"`. */
30
+ plugin?: string;
31
+ pluginVersion?: string;
32
+ /** `KANKAKU_SYNC_RECORDS` (default enabled): also upsert each task's raw `work_records`. */
33
+ syncRecords: boolean;
34
+ /** ids per lookup request. Defaults to 30, per contract.md's guidance. */
35
+ chunkSize?: number;
36
+ }
37
+ /** Escape a value for PocketBase's filter string-literal syntax (`field="value"`). Exported for direct unit testing. */
38
+ export declare function escapeFilterValue(value: string): string;
39
+ export declare class PocketBaseSink implements WorkSink {
40
+ private readonly deps;
41
+ constructor(deps: PocketBaseSinkDeps);
42
+ private get chunkSize();
43
+ /** Look up existing records by a unique field, chunked to ~30 ids per request; returns a map of that field's value to the record's PocketBase id. */
44
+ private lookupExisting;
45
+ private findOne;
46
+ private get entryContext();
47
+ /** Create-or-patch one task_entries row. Returns its PocketBase record id and whether it was created or updated. */
48
+ private upsertTaskEntry;
49
+ /** Best-effort upsert of one task's work_records children. A per-record validation failure is swallowed (the task_entries row already succeeded); a fatal error propagates so the whole run stops. */
50
+ private upsertWorkRecords;
51
+ private pushOne;
52
+ push(tasks: TaskView[]): Promise<PushTaskResult[]>;
53
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * {@link WorkSink} backed by a real PocketBase instance: upserts one
3
+ * `task_entries` row per task (create-only assignment, see
4
+ * `domain/hub-entry.ts`) and, when enabled, its `work_records` children.
5
+ *
6
+ * PocketBase has no native upsert (contract.md "Upsert by task_id"): look
7
+ * up existing rows by their unique key in chunks, then create or patch.
8
+ * A unique-violation on create (approximated here as any 400 on create,
9
+ * since `PocketBaseError` does not carry the response body — see the
10
+ * `isLikelyUniqueViolation` note below) is treated as "already exists":
11
+ * look it up and patch instead.
12
+ */
13
+ import { buildTaskEntryCreatePayload, buildTaskEntryUpdatePayload, buildWorkRecordPayload, resolveTaskAssignment, taskWorkRecords } from "../domain/hub-entry.js";
14
+ import { PocketBaseError } from "./pocketbase-client.js";
15
+ const DEFAULT_CHUNK_SIZE = 30;
16
+ /** Escape a value for PocketBase's filter string-literal syntax (`field="value"`). Exported for direct unit testing. */
17
+ export function escapeFilterValue(value) {
18
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
19
+ }
20
+ /** Fatal (systemic) errors stop the whole sync run rather than failing one task. */
21
+ function isFatalError(error) {
22
+ if (!(error instanceof PocketBaseError))
23
+ return true;
24
+ if (error.kind === "network" || error.kind === "timeout" || error.kind === "auth")
25
+ return true;
26
+ if (error.kind === "http" && error.status !== undefined && error.status >= 500)
27
+ return true;
28
+ return false;
29
+ }
30
+ function describeError(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ /**
34
+ * `PocketBaseError` for a create only carries `status`/`kind`, not the
35
+ * parsed response body, so we cannot check `data.<field>.code ===
36
+ * "validation_not_unique"` directly (contract.md "What a unique-violation
37
+ * response actually looks like"). Instead, any 400 on a create is treated
38
+ * as a possible race and re-looked-up: if the row now exists, it was a
39
+ * duplicate (patch it); if not, it was a genuine validation error (the
40
+ * original message is kept).
41
+ */
42
+ function isLikelyUniqueViolation(error) {
43
+ return error instanceof PocketBaseError && error.kind === "http" && error.status === 400;
44
+ }
45
+ export class PocketBaseSink {
46
+ deps;
47
+ constructor(deps) {
48
+ this.deps = deps;
49
+ }
50
+ get chunkSize() {
51
+ return this.deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
52
+ }
53
+ /** Look up existing records by a unique field, chunked to ~30 ids per request; returns a map of that field's value to the record's PocketBase id. */
54
+ async lookupExisting(collection, field, values) {
55
+ const found = new Map();
56
+ const chunkSize = this.chunkSize;
57
+ for (let i = 0; i < values.length; i += chunkSize) {
58
+ const chunk = values.slice(i, i + chunkSize);
59
+ if (chunk.length === 0)
60
+ continue;
61
+ const filter = chunk.map((value) => `${field}="${escapeFilterValue(value)}"`).join("||");
62
+ const items = await this.deps.client.list(collection, { filter, perPage: chunkSize });
63
+ for (const item of items) {
64
+ const value = item[field];
65
+ if (typeof value === "string")
66
+ found.set(value, item.id);
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+ async findOne(collection, field, value) {
72
+ const found = await this.lookupExisting(collection, field, [value]);
73
+ return found.get(value);
74
+ }
75
+ get entryContext() {
76
+ return {
77
+ clients: this.deps.clients,
78
+ projects: this.deps.projects,
79
+ tasks: this.deps.tasks,
80
+ machine: this.deps.machine,
81
+ promptMode: this.deps.promptMode,
82
+ agent: this.deps.agent ?? "pi",
83
+ ...(this.deps.agentVersion !== undefined ? { agentVersion: this.deps.agentVersion } : {}),
84
+ plugin: this.deps.plugin ?? "kankaku",
85
+ ...(this.deps.pluginVersion !== undefined ? { pluginVersion: this.deps.pluginVersion } : {}),
86
+ };
87
+ }
88
+ /** Create-or-patch one task_entries row. Returns its PocketBase record id and whether it was created or updated. */
89
+ async upsertTaskEntry(task, existingId) {
90
+ const ctx = this.entryContext;
91
+ if (existingId) {
92
+ await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${existingId}`, buildTaskEntryUpdatePayload(task, ctx));
93
+ return { id: existingId, created: false };
94
+ }
95
+ try {
96
+ const created = await this.deps.client.request("POST", "/api/collections/task_entries/records", buildTaskEntryCreatePayload(task, ctx));
97
+ return { id: created.id, created: true };
98
+ }
99
+ catch (error) {
100
+ if (!isLikelyUniqueViolation(error))
101
+ throw error;
102
+ const found = await this.findOne("task_entries", "task_id", task.id);
103
+ if (!found)
104
+ throw error;
105
+ await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${found}`, buildTaskEntryUpdatePayload(task, ctx));
106
+ return { id: found, created: false };
107
+ }
108
+ }
109
+ /** Best-effort upsert of one task's work_records children. A per-record validation failure is swallowed (the task_entries row already succeeded); a fatal error propagates so the whole run stops. */
110
+ async upsertWorkRecords(task, taskEntryRecordId) {
111
+ const records = taskWorkRecords(task);
112
+ const existing = await this.lookupExisting("work_records", "kankaku_id", records.map((record) => record.id));
113
+ for (const record of records) {
114
+ const payload = buildWorkRecordPayload(record, taskEntryRecordId, { machine: this.deps.machine, promptMode: this.deps.promptMode });
115
+ const existingId = existing.get(record.id);
116
+ try {
117
+ if (existingId) {
118
+ await this.deps.client.request("PATCH", `/api/collections/work_records/records/${existingId}`, payload);
119
+ }
120
+ else {
121
+ try {
122
+ await this.deps.client.request("POST", "/api/collections/work_records/records", payload);
123
+ }
124
+ catch (error) {
125
+ if (!isLikelyUniqueViolation(error))
126
+ throw error;
127
+ const found = await this.findOne("work_records", "kankaku_id", record.id);
128
+ if (!found)
129
+ throw error;
130
+ await this.deps.client.request("PATCH", `/api/collections/work_records/records/${found}`, payload);
131
+ }
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (isFatalError(error))
136
+ throw error;
137
+ // Non-fatal: this one child row failed validation; the task_entries
138
+ // row it belongs to already succeeded, so skip it and keep going.
139
+ }
140
+ }
141
+ }
142
+ async pushOne(task, existingId) {
143
+ const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
144
+ const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
145
+ try {
146
+ const { id, created } = await this.upsertTaskEntry(task, existingId);
147
+ if (this.deps.syncRecords) {
148
+ await this.upsertWorkRecords(task, id);
149
+ }
150
+ return created ? { kind: "created", ...pushAssignment } : { kind: "updated", ...pushAssignment };
151
+ }
152
+ catch (error) {
153
+ if (isFatalError(error))
154
+ return { kind: "error", reason: describeError(error) };
155
+ return { kind: "failed", reason: describeError(error) };
156
+ }
157
+ }
158
+ async push(tasks) {
159
+ const results = [];
160
+ if (tasks.length === 0)
161
+ return results;
162
+ // One chunked lookup for every task up front (contract.md: "one list
163
+ // request per ~30 ids"), rather than one lookup per task.
164
+ let existingByTaskId;
165
+ try {
166
+ existingByTaskId = await this.lookupExisting("task_entries", "task_id", tasks.map((task) => task.id));
167
+ }
168
+ catch (error) {
169
+ const outcome = isFatalError(error) ? { kind: "error", reason: describeError(error) } : { kind: "failed", reason: describeError(error) };
170
+ results.push({ taskId: tasks[0].id, outcome });
171
+ return results;
172
+ }
173
+ for (const task of tasks) {
174
+ const outcome = await this.pushOne(task, existingByTaskId.get(task.id));
175
+ results.push({ taskId: task.id, outcome });
176
+ if (outcome.kind === "error")
177
+ break;
178
+ }
179
+ return results;
180
+ }
181
+ }