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,89 @@
|
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
|
+
import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
|
|
3
|
+
|
|
4
|
+
interface ClientRecord extends PocketBaseRecord {
|
|
5
|
+
name: string;
|
|
6
|
+
code: string;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
unassigned?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ProjectRecord extends PocketBaseRecord {
|
|
12
|
+
name: string;
|
|
13
|
+
code?: string;
|
|
14
|
+
client: string;
|
|
15
|
+
repo_paths?: unknown;
|
|
16
|
+
active?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TaskRecord extends PocketBaseRecord {
|
|
20
|
+
title?: string;
|
|
21
|
+
project: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
external_ref?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TASK_STATUSES = new Set(["open", "doing", "done"]);
|
|
27
|
+
|
|
28
|
+
function mapClient(record: ClientRecord): Client {
|
|
29
|
+
return {
|
|
30
|
+
id: record.id,
|
|
31
|
+
name: record.name,
|
|
32
|
+
code: record.code,
|
|
33
|
+
active: record.active === true,
|
|
34
|
+
...(record.unassigned === true ? { unassigned: true } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mapProject(record: ProjectRecord): Project {
|
|
39
|
+
return {
|
|
40
|
+
id: record.id,
|
|
41
|
+
name: record.name,
|
|
42
|
+
...(record.code !== undefined ? { code: record.code } : {}),
|
|
43
|
+
clientId: record.client,
|
|
44
|
+
repoPaths: Array.isArray(record.repo_paths) ? record.repo_paths.filter((path): path is string => typeof path === "string") : [],
|
|
45
|
+
active: record.active === true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `open` on any unrecognised or missing status, rather than dropping the task. */
|
|
50
|
+
function mapTaskStatus(status: string | undefined): HubTask["status"] {
|
|
51
|
+
return status !== undefined && TASK_STATUSES.has(status) ? (status as HubTask["status"]) : "open";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mapTask(record: TaskRecord): HubTask {
|
|
55
|
+
return {
|
|
56
|
+
id: record.id,
|
|
57
|
+
title: record.title ?? "",
|
|
58
|
+
projectId: record.project,
|
|
59
|
+
status: mapTaskStatus(record.status),
|
|
60
|
+
...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
|
|
66
|
+
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
|
|
67
|
+
* `projects` and `tasks` record in parallel (no `active`/`status` filter,
|
|
68
|
+
* so the domain resolver can tell "deleted" from "inactive"/"done"; see
|
|
69
|
+
* `domain/work-target.ts`), clients/projects sorted by name and tasks by
|
|
70
|
+
* title. `signal`, when given, bounds every fetch (see
|
|
71
|
+
* `pocketbase-client.ts#request`).
|
|
72
|
+
*/
|
|
73
|
+
export function createPocketBaseCatalogFetcher(
|
|
74
|
+
client: PocketBaseClient,
|
|
75
|
+
): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks: HubTask[] }> {
|
|
76
|
+
return async (signal?: AbortSignal) => {
|
|
77
|
+
const [clientRecords, projectRecords, taskRecords] = await Promise.all([
|
|
78
|
+
client.list<ClientRecord>("clients", { sort: "name" }, signal),
|
|
79
|
+
client.list<ProjectRecord>("projects", { sort: "name" }, signal),
|
|
80
|
+
client.list<TaskRecord>("tasks", { sort: "title" }, signal),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
clients: clientRecords.map(mapClient),
|
|
85
|
+
projects: projectRecords.map(mapProject),
|
|
86
|
+
tasks: taskRecords.map(mapTask),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
|
|
9
|
+
export type PocketBaseFetch = typeof fetch;
|
|
10
|
+
|
|
11
|
+
export interface PocketBaseRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PocketBaseListResult<T> {
|
|
17
|
+
page: number;
|
|
18
|
+
perPage: number;
|
|
19
|
+
totalItems: number;
|
|
20
|
+
totalPages: number;
|
|
21
|
+
items: T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PocketBaseClientOptions {
|
|
25
|
+
/** Base URL, e.g. `https://pb.example.com` (no trailing slash required). */
|
|
26
|
+
url: string;
|
|
27
|
+
email: string;
|
|
28
|
+
password: string;
|
|
29
|
+
/** Injectable for tests; defaults to `globalThis.fetch`. */
|
|
30
|
+
fetch?: PocketBaseFetch;
|
|
31
|
+
/** Per-request timeout in ms. Defaults to 3000. */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PocketBaseErrorKind = "network" | "timeout" | "http" | "auth";
|
|
36
|
+
|
|
37
|
+
export class PocketBaseError extends Error {
|
|
38
|
+
readonly kind: PocketBaseErrorKind;
|
|
39
|
+
readonly status: number | undefined;
|
|
40
|
+
|
|
41
|
+
constructor(kind: PocketBaseErrorKind, message: string, status?: number) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "PocketBaseError";
|
|
44
|
+
this.kind = kind;
|
|
45
|
+
this.status = status;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AuthResponse {
|
|
50
|
+
token: string;
|
|
51
|
+
record: PocketBaseRecord;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
55
|
+
const AUTH_PATH = "/api/collections/users/auth-with-password";
|
|
56
|
+
|
|
57
|
+
function isAbortError(error: unknown): boolean {
|
|
58
|
+
return error instanceof Error && error.name === "AbortError";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a `records` list path with pagination/filter/sort query params. */
|
|
62
|
+
function listPath(collection: string, page: number, perPage: number, filter?: string, sort?: string): string {
|
|
63
|
+
const params = new URLSearchParams({ page: String(page), perPage: String(perPage) });
|
|
64
|
+
if (filter) params.set("filter", filter);
|
|
65
|
+
if (sort) params.set("sort", sort);
|
|
66
|
+
return `/api/collections/${encodeURIComponent(collection)}/records?${params.toString()}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Minimal PocketBase client: lazily authenticates on the first request,
|
|
71
|
+
* reuses the token, and re-authenticates exactly once on a 401 before
|
|
72
|
+
* failing cleanly. `request` is the generic primitive other adapters (and
|
|
73
|
+
* a future sync sink) build on; `list` is a pagination convenience for
|
|
74
|
+
* read-only catalog fetches.
|
|
75
|
+
*/
|
|
76
|
+
export class PocketBaseClient {
|
|
77
|
+
private readonly baseUrl: string;
|
|
78
|
+
private readonly email: string;
|
|
79
|
+
private readonly password: string;
|
|
80
|
+
private readonly doFetch: PocketBaseFetch;
|
|
81
|
+
private readonly timeoutMs: number;
|
|
82
|
+
private token: string | undefined;
|
|
83
|
+
/** 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. */
|
|
84
|
+
private authInFlight: Promise<void> | undefined;
|
|
85
|
+
|
|
86
|
+
constructor(options: PocketBaseClientOptions) {
|
|
87
|
+
this.baseUrl = options.url.replace(/\/+$/, "");
|
|
88
|
+
this.email = options.email;
|
|
89
|
+
this.password = options.password;
|
|
90
|
+
this.doFetch = options.fetch ?? fetch;
|
|
91
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 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. */
|
|
95
|
+
private authenticate(signal?: AbortSignal): Promise<void> {
|
|
96
|
+
if (!this.authInFlight) {
|
|
97
|
+
this.authInFlight = this.doAuthenticate(signal).finally(() => {
|
|
98
|
+
this.authInFlight = undefined;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return this.authInFlight;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async doAuthenticate(signal?: AbortSignal): Promise<void> {
|
|
105
|
+
const response = await this.rawFetch("POST", AUTH_PATH, { identity: this.email, password: this.password }, undefined, signal);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new PocketBaseError("auth", `kankaku: hub authentication failed (${response.status})`, response.status);
|
|
108
|
+
}
|
|
109
|
+
const body = (await response.json()) as AuthResponse;
|
|
110
|
+
this.token = body.token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `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. */
|
|
114
|
+
private async rawFetch(method: string, path: string, body: unknown, token: string | undefined, signal?: AbortSignal): Promise<Response> {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
117
|
+
const requestSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
118
|
+
try {
|
|
119
|
+
return await this.doFetch(`${this.baseUrl}${path}`, {
|
|
120
|
+
method,
|
|
121
|
+
headers: {
|
|
122
|
+
"content-type": "application/json",
|
|
123
|
+
...(token !== undefined ? { Authorization: token } : {}),
|
|
124
|
+
},
|
|
125
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
126
|
+
signal: requestSignal,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isAbortError(error)) {
|
|
130
|
+
throw new PocketBaseError("timeout", `kankaku: hub request timed out after ${this.timeoutMs}ms: ${method} ${path}`);
|
|
131
|
+
}
|
|
132
|
+
throw new PocketBaseError("network", `kankaku: hub request failed: ${method} ${path}: ${(error as Error).message}`);
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generic request primitive: `method`/`path` (e.g. `/api/collections/clients/records`)
|
|
140
|
+
* with an optional JSON `body`. Authenticates lazily, retries exactly
|
|
141
|
+
* once on a 401 after re-authenticating, and throws a typed
|
|
142
|
+
* {@link PocketBaseError} on network failure, timeout, auth failure, or
|
|
143
|
+
* any other non-2xx response. `signal`, when given, is composed with
|
|
144
|
+
* every underlying request's own per-request timeout, so a caller can
|
|
145
|
+
* bound this whole call (including a lazy auth and the 401 retry) with
|
|
146
|
+
* one overall deadline; an abort surfaces as a `"timeout"` error, same as
|
|
147
|
+
* a per-request timeout.
|
|
148
|
+
*/
|
|
149
|
+
async request<T = unknown>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
|
150
|
+
if (this.token === undefined) {
|
|
151
|
+
await this.authenticate(signal);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let response = await this.rawFetch(method, path, body, this.token, signal);
|
|
155
|
+
|
|
156
|
+
if (response.status === 401) {
|
|
157
|
+
this.token = undefined;
|
|
158
|
+
await this.authenticate(signal);
|
|
159
|
+
response = await this.rawFetch(method, path, body, this.token, signal);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
if (response.status === 401) {
|
|
164
|
+
throw new PocketBaseError("auth", `kankaku: hub rejected credentials for ${method} ${path}`, response.status);
|
|
165
|
+
}
|
|
166
|
+
throw new PocketBaseError("http", `kankaku: hub request failed: ${method} ${path} (${response.status})`, response.status);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (response.status === 204) return undefined as T;
|
|
170
|
+
return (await response.json()) as T;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetch every record of `collection`, paginating until every page is
|
|
175
|
+
* read. `perPage` defaults to 200; `filter`/`sort` are passed through
|
|
176
|
+
* verbatim as PocketBase filter/sort expressions. `signal`, when given,
|
|
177
|
+
* bounds the whole pagination loop (see {@link request}).
|
|
178
|
+
*/
|
|
179
|
+
async list<T extends PocketBaseRecord = PocketBaseRecord>(
|
|
180
|
+
collection: string,
|
|
181
|
+
opts: { filter?: string; sort?: string; perPage?: number } = {},
|
|
182
|
+
signal?: AbortSignal,
|
|
183
|
+
): Promise<T[]> {
|
|
184
|
+
const perPage = opts.perPage ?? 200;
|
|
185
|
+
const items: T[] = [];
|
|
186
|
+
let page = 1;
|
|
187
|
+
|
|
188
|
+
for (;;) {
|
|
189
|
+
const result = await this.request<PocketBaseListResult<T>>("GET", listPath(collection, page, perPage, opts.filter, opts.sort), undefined, signal);
|
|
190
|
+
items.push(...result.items);
|
|
191
|
+
if (page >= result.totalPages || result.items.length === 0) break;
|
|
192
|
+
page += 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return items;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
|
|
14
|
+
import { buildTaskEntryCreatePayload, buildTaskEntryUpdatePayload, buildWorkRecordPayload, resolveTaskAssignment, taskWorkRecords } from "../domain/hub-entry.ts";
|
|
15
|
+
import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
|
|
16
|
+
import type { TaskView } from "../domain/task-view.ts";
|
|
17
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
18
|
+
import type { PushOutcome, PushTaskResult, WorkSink } from "../ports/work-sink.ts";
|
|
19
|
+
import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
|
|
20
|
+
import { PocketBaseError } from "./pocketbase-client.ts";
|
|
21
|
+
|
|
22
|
+
export interface PocketBaseSinkDeps {
|
|
23
|
+
client: PocketBaseClient;
|
|
24
|
+
/** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
|
|
25
|
+
clients: Client[];
|
|
26
|
+
projects: Project[];
|
|
27
|
+
tasks: HubTask[];
|
|
28
|
+
machine: string;
|
|
29
|
+
promptMode: PromptPrivacyMode;
|
|
30
|
+
/** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
|
|
31
|
+
agent?: string;
|
|
32
|
+
agentVersion?: string;
|
|
33
|
+
/** The integration that wrote these rows; see `domain/hub-entry.ts#HubEntryContext.plugin`. Defaults to `"kankaku"`. */
|
|
34
|
+
plugin?: string;
|
|
35
|
+
pluginVersion?: string;
|
|
36
|
+
/** `KANKAKU_SYNC_RECORDS` (default enabled): also upsert each task's raw `work_records`. */
|
|
37
|
+
syncRecords: boolean;
|
|
38
|
+
/** ids per lookup request. Defaults to 30, per contract.md's guidance. */
|
|
39
|
+
chunkSize?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CHUNK_SIZE = 30;
|
|
43
|
+
|
|
44
|
+
/** Escape a value for PocketBase's filter string-literal syntax (`field="value"`). Exported for direct unit testing. */
|
|
45
|
+
export function escapeFilterValue(value: string): string {
|
|
46
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fatal (systemic) errors stop the whole sync run rather than failing one task. */
|
|
50
|
+
function isFatalError(error: unknown): boolean {
|
|
51
|
+
if (!(error instanceof PocketBaseError)) return true;
|
|
52
|
+
if (error.kind === "network" || error.kind === "timeout" || error.kind === "auth") return true;
|
|
53
|
+
if (error.kind === "http" && error.status !== undefined && error.status >= 500) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describeError(error: unknown): string {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `PocketBaseError` for a create only carries `status`/`kind`, not the
|
|
63
|
+
* parsed response body, so we cannot check `data.<field>.code ===
|
|
64
|
+
* "validation_not_unique"` directly (contract.md "What a unique-violation
|
|
65
|
+
* response actually looks like"). Instead, any 400 on a create is treated
|
|
66
|
+
* as a possible race and re-looked-up: if the row now exists, it was a
|
|
67
|
+
* duplicate (patch it); if not, it was a genuine validation error (the
|
|
68
|
+
* original message is kept).
|
|
69
|
+
*/
|
|
70
|
+
function isLikelyUniqueViolation(error: unknown): boolean {
|
|
71
|
+
return error instanceof PocketBaseError && error.kind === "http" && error.status === 400;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class PocketBaseSink implements WorkSink {
|
|
75
|
+
private readonly deps: PocketBaseSinkDeps;
|
|
76
|
+
|
|
77
|
+
constructor(deps: PocketBaseSinkDeps) {
|
|
78
|
+
this.deps = deps;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private get chunkSize(): number {
|
|
82
|
+
return this.deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 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. */
|
|
86
|
+
private async lookupExisting(collection: string, field: string, values: string[]): Promise<Map<string, string>> {
|
|
87
|
+
const found = new Map<string, string>();
|
|
88
|
+
const chunkSize = this.chunkSize;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < values.length; i += chunkSize) {
|
|
91
|
+
const chunk = values.slice(i, i + chunkSize);
|
|
92
|
+
if (chunk.length === 0) continue;
|
|
93
|
+
const filter = chunk.map((value) => `${field}="${escapeFilterValue(value)}"`).join("||");
|
|
94
|
+
const items = await this.deps.client.list<PocketBaseRecord & Record<string, unknown>>(collection, { filter, perPage: chunkSize });
|
|
95
|
+
for (const item of items) {
|
|
96
|
+
const value = item[field];
|
|
97
|
+
if (typeof value === "string") found.set(value, item.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async findOne(collection: string, field: string, value: string): Promise<string | undefined> {
|
|
105
|
+
const found = await this.lookupExisting(collection, field, [value]);
|
|
106
|
+
return found.get(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private get entryContext(): {
|
|
110
|
+
clients: Client[];
|
|
111
|
+
projects: Project[];
|
|
112
|
+
tasks: HubTask[];
|
|
113
|
+
machine: string;
|
|
114
|
+
promptMode: PromptPrivacyMode;
|
|
115
|
+
agent: string;
|
|
116
|
+
agentVersion?: string;
|
|
117
|
+
plugin: string;
|
|
118
|
+
pluginVersion?: string;
|
|
119
|
+
} {
|
|
120
|
+
return {
|
|
121
|
+
clients: this.deps.clients,
|
|
122
|
+
projects: this.deps.projects,
|
|
123
|
+
tasks: this.deps.tasks,
|
|
124
|
+
machine: this.deps.machine,
|
|
125
|
+
promptMode: this.deps.promptMode,
|
|
126
|
+
agent: this.deps.agent ?? "pi",
|
|
127
|
+
...(this.deps.agentVersion !== undefined ? { agentVersion: this.deps.agentVersion } : {}),
|
|
128
|
+
plugin: this.deps.plugin ?? "kankaku",
|
|
129
|
+
...(this.deps.pluginVersion !== undefined ? { pluginVersion: this.deps.pluginVersion } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Create-or-patch one task_entries row. Returns its PocketBase record id and whether it was created or updated. */
|
|
134
|
+
private async upsertTaskEntry(task: TaskView, existingId: string | undefined): Promise<{ id: string; created: boolean }> {
|
|
135
|
+
const ctx = this.entryContext;
|
|
136
|
+
|
|
137
|
+
if (existingId) {
|
|
138
|
+
await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${existingId}`, buildTaskEntryUpdatePayload(task, ctx));
|
|
139
|
+
return { id: existingId, created: false };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const created = await this.deps.client.request<PocketBaseRecord>(
|
|
144
|
+
"POST",
|
|
145
|
+
"/api/collections/task_entries/records",
|
|
146
|
+
buildTaskEntryCreatePayload(task, ctx),
|
|
147
|
+
);
|
|
148
|
+
return { id: created.id, created: true };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (!isLikelyUniqueViolation(error)) throw error;
|
|
151
|
+
const found = await this.findOne("task_entries", "task_id", task.id);
|
|
152
|
+
if (!found) throw error;
|
|
153
|
+
await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${found}`, buildTaskEntryUpdatePayload(task, ctx));
|
|
154
|
+
return { id: found, created: false };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 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. */
|
|
159
|
+
private async upsertWorkRecords(task: TaskView, taskEntryRecordId: string): Promise<void> {
|
|
160
|
+
const records = taskWorkRecords(task);
|
|
161
|
+
const existing = await this.lookupExisting(
|
|
162
|
+
"work_records",
|
|
163
|
+
"kankaku_id",
|
|
164
|
+
records.map((record) => record.id),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
for (const record of records) {
|
|
168
|
+
const payload = buildWorkRecordPayload(record, taskEntryRecordId, { machine: this.deps.machine, promptMode: this.deps.promptMode });
|
|
169
|
+
const existingId = existing.get(record.id);
|
|
170
|
+
try {
|
|
171
|
+
if (existingId) {
|
|
172
|
+
await this.deps.client.request("PATCH", `/api/collections/work_records/records/${existingId}`, payload);
|
|
173
|
+
} else {
|
|
174
|
+
try {
|
|
175
|
+
await this.deps.client.request("POST", "/api/collections/work_records/records", payload);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!isLikelyUniqueViolation(error)) throw error;
|
|
178
|
+
const found = await this.findOne("work_records", "kankaku_id", record.id);
|
|
179
|
+
if (!found) throw error;
|
|
180
|
+
await this.deps.client.request("PATCH", `/api/collections/work_records/records/${found}`, payload);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (isFatalError(error)) throw error;
|
|
185
|
+
// Non-fatal: this one child row failed validation; the task_entries
|
|
186
|
+
// row it belongs to already succeeded, so skip it and keep going.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async pushOne(task: TaskView, existingId: string | undefined): Promise<PushOutcome> {
|
|
192
|
+
const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
|
|
193
|
+
const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const { id, created } = await this.upsertTaskEntry(task, existingId);
|
|
197
|
+
|
|
198
|
+
if (this.deps.syncRecords) {
|
|
199
|
+
await this.upsertWorkRecords(task, id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return created ? { kind: "created", ...pushAssignment } : { kind: "updated", ...pushAssignment };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (isFatalError(error)) return { kind: "error", reason: describeError(error) };
|
|
205
|
+
return { kind: "failed", reason: describeError(error) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async push(tasks: TaskView[]): Promise<PushTaskResult[]> {
|
|
210
|
+
const results: PushTaskResult[] = [];
|
|
211
|
+
if (tasks.length === 0) return results;
|
|
212
|
+
|
|
213
|
+
// One chunked lookup for every task up front (contract.md: "one list
|
|
214
|
+
// request per ~30 ids"), rather than one lookup per task.
|
|
215
|
+
let existingByTaskId: Map<string, string>;
|
|
216
|
+
try {
|
|
217
|
+
existingByTaskId = await this.lookupExisting(
|
|
218
|
+
"task_entries",
|
|
219
|
+
"task_id",
|
|
220
|
+
tasks.map((task) => task.id),
|
|
221
|
+
);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const outcome: PushOutcome = isFatalError(error) ? { kind: "error", reason: describeError(error) } : { kind: "failed", reason: describeError(error) };
|
|
224
|
+
results.push({ taskId: tasks[0]!.id, outcome });
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const task of tasks) {
|
|
229
|
+
const outcome = await this.pushOne(task, existingByTaskId.get(task.id));
|
|
230
|
+
results.push({ taskId: task.id, outcome });
|
|
231
|
+
if (outcome.kind === "error") break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return results;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolveProcessIdentity } from "./process-identity.ts";
|
|
2
|
+
import type { ProcessIdentity, ResolveProcessIdentityDeps } from "./process-identity.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Freezes {@link resolveProcessIdentity}'s result for the lifetime of one
|
|
6
|
+
* OS process (G1, HIGH — verified): pi re-invokes an extension's factory
|
|
7
|
+
* function IN THE SAME OS PROCESS on `/new`, `/resume`, `/fork` and
|
|
8
|
+
* `/reload` ("reloads and rebinds extensions for the new session" — see
|
|
9
|
+
* `node_modules/@earendil-works/pi-coding-agent/docs/extensions.md`). Every
|
|
10
|
+
* fact `resolveProcessIdentity` computes is about the OS PROCESS, not the
|
|
11
|
+
* pi session running inside it, and several of them can only ever be read
|
|
12
|
+
* correctly once:
|
|
13
|
+
*
|
|
14
|
+
* - `roleOverride` (`KANKAKU_ROLE`, read before this call strips it from
|
|
15
|
+
* `process.env` so no spawned child ever inherits it — R1, layer 2): a
|
|
16
|
+
* second invocation would read `undefined` (already stripped by the
|
|
17
|
+
* first), silently losing an explicit `KANKAKU_ROLE=orchestrator` force —
|
|
18
|
+
* exactly the bug this module fixes. Without freezing, a forced
|
|
19
|
+
* orchestrator whose process happens to have a tracked ancestor (e.g. it
|
|
20
|
+
* was itself launched from inside another pi's shell tool) would fall
|
|
21
|
+
* back to ordinary detection on session 2 and could be demoted to
|
|
22
|
+
* `roleConfidence: "uncertain"`, silently dropping genuine billable work.
|
|
23
|
+
* - `hasTrackedAncestor`/`ancestorEntry` (the machine-wide registry +
|
|
24
|
+
* OS-ancestor-chain walk, F5): this process's ancestor chain is fixed at
|
|
25
|
+
* OS-process-creation time and does not change for the life of the
|
|
26
|
+
* process; re-walking it on every reload would also re-spawn a `ps`/
|
|
27
|
+
* `/proc` read each time for no benefit (F5's whole point is to avoid
|
|
28
|
+
* exactly that).
|
|
29
|
+
* - `ownProcessStartId` (F5's spawn-free own-identity estimate,
|
|
30
|
+
* `now - uptime`): a fresh reading on invocation 2 is a slightly
|
|
31
|
+
* different point-estimate of the same real start time as invocation 1's
|
|
32
|
+
* — usually well within `ancestry-match.ts#START_ID_TOLERANCE_MS`, but
|
|
33
|
+
* several `process.on("exit")` listeners (see below) each holding a
|
|
34
|
+
* *different* estimate risk `removeOwn`'s exact-match check silently
|
|
35
|
+
* failing to clean up the registry entry the LAST `record()` call
|
|
36
|
+
* actually wrote. Freezing it means every invocation's registry write —
|
|
37
|
+
* and any later cleanup — always agrees on the exact same value.
|
|
38
|
+
*
|
|
39
|
+
* Session-level facts are deliberately NOT frozen here and must be
|
|
40
|
+
* re-evaluated by the caller every invocation: interactive mode from
|
|
41
|
+
* `ctx.mode` (only known later, at `session_start`), and the session's
|
|
42
|
+
* project/client target (`process.cwd()` can legitimately differ between
|
|
43
|
+
* sessions in the same process — see `extensions.md`'s trust-resolution
|
|
44
|
+
* note on `/resume` entering a new cwd — so `extension.ts` still calls
|
|
45
|
+
* `resolveKankakuDir` fresh every invocation).
|
|
46
|
+
*
|
|
47
|
+
* Kept as a small, explicitly injectable holder (never module-level mutable
|
|
48
|
+
* state read directly by a domain type) so a test can construct its own
|
|
49
|
+
* memo, and so production code goes through exactly one shared, real
|
|
50
|
+
* process-wide instance (see {@link getProcessIdentityMemo}).
|
|
51
|
+
*/
|
|
52
|
+
export interface ProcessIdentityMemo {
|
|
53
|
+
/**
|
|
54
|
+
* Returns the frozen facts, computing them (and freezing whatever they
|
|
55
|
+
* are) on the first call this memo instance ever receives; every later
|
|
56
|
+
* call ignores `deps` entirely and returns the exact same object.
|
|
57
|
+
*/
|
|
58
|
+
resolve(deps: ResolveProcessIdentityDeps): ProcessIdentity;
|
|
59
|
+
/**
|
|
60
|
+
* Registers `cleanup` with `on` at most once across every call this memo
|
|
61
|
+
* instance ever receives — the fix for the sibling leak this finding
|
|
62
|
+
* flagged: `extension.ts` used to call `process.on("exit", ...)`
|
|
63
|
+
* unconditionally on every factory invocation, piling up one listener per
|
|
64
|
+
* `/new`/`/resume`/`/fork`/`/reload` (each running `removeOwn`
|
|
65
|
+
* needlessly, though harmlessly, since it is idempotent — the real risk
|
|
66
|
+
* is an unbounded listener count over a long-lived interactive session).
|
|
67
|
+
* `on` takes the listener directly (never the event name) so a test can
|
|
68
|
+
* inject a plain recording function instead of touching the real
|
|
69
|
+
* `process` object.
|
|
70
|
+
*/
|
|
71
|
+
registerExitCleanupOnce(on: (listener: () => void) => void, cleanup: () => void): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createProcessIdentityMemo(): ProcessIdentityMemo {
|
|
75
|
+
let facts: ProcessIdentity | undefined;
|
|
76
|
+
let exitCleanupRegistered = false;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
resolve(deps: ResolveProcessIdentityDeps): ProcessIdentity {
|
|
80
|
+
if (facts === undefined) facts = resolveProcessIdentity(deps);
|
|
81
|
+
return facts;
|
|
82
|
+
},
|
|
83
|
+
registerExitCleanupOnce(on: (listener: () => void) => void, cleanup: () => void): void {
|
|
84
|
+
if (exitCleanupRegistered) return;
|
|
85
|
+
exitCleanupRegistered = true;
|
|
86
|
+
on(cleanup);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The real, process-wide singleton `extension.ts` uses in production: one
|
|
92
|
+
// module-level instance, reused by every factory invocation in this OS
|
|
93
|
+
// process — which is the entire point (see the class doc above). A test
|
|
94
|
+
// must never rely on this singleton (module state would leak between test
|
|
95
|
+
// files run in the same worker); construct a fresh memo via
|
|
96
|
+
// `createProcessIdentityMemo()` instead.
|
|
97
|
+
let processWideMemo: ProcessIdentityMemo = createProcessIdentityMemo();
|
|
98
|
+
|
|
99
|
+
/** The real, process-wide {@link ProcessIdentityMemo} `extension.ts` reads and writes on every factory invocation. */
|
|
100
|
+
export function getProcessIdentityMemo(): ProcessIdentityMemo {
|
|
101
|
+
return processWideMemo;
|
|
102
|
+
}
|