kankaku 0.5.0 → 0.6.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 (76) hide show
  1. package/README.md +67 -19
  2. package/dist/adapters/cached-catalog.d.ts +36 -0
  3. package/dist/adapters/cached-catalog.js +111 -0
  4. package/dist/adapters/file-modes.d.ts +20 -0
  5. package/dist/adapters/file-modes.js +34 -0
  6. package/dist/adapters/hub-credentials.d.ts +35 -0
  7. package/dist/adapters/hub-credentials.js +58 -0
  8. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  9. package/dist/adapters/jsonl-work-log.js +62 -0
  10. package/dist/adapters/kankaku-dir.d.ts +38 -0
  11. package/dist/adapters/kankaku-dir.js +85 -0
  12. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  13. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  14. package/dist/adapters/pocketbase-catalog.d.ts +14 -0
  15. package/dist/adapters/pocketbase-catalog.js +39 -0
  16. package/dist/adapters/pocketbase-client.d.ts +81 -0
  17. package/dist/adapters/pocketbase-client.js +148 -0
  18. package/dist/adapters/pocketbase-sink.d.ts +52 -0
  19. package/dist/adapters/pocketbase-sink.js +180 -0
  20. package/dist/adapters/sync-runner.d.ts +99 -0
  21. package/dist/adapters/sync-runner.js +242 -0
  22. package/dist/adapters/sync-state-store.d.ts +62 -0
  23. package/dist/adapters/sync-state-store.js +188 -0
  24. package/dist/config.d.ts +168 -0
  25. package/dist/config.js +392 -0
  26. package/dist/domain/ancestry-match.d.ts +49 -0
  27. package/dist/domain/ancestry-match.js +82 -0
  28. package/dist/domain/client-label.d.ts +28 -0
  29. package/dist/domain/client-label.js +44 -0
  30. package/dist/domain/day.d.ts +2 -0
  31. package/dist/domain/day.js +8 -0
  32. package/dist/domain/export.d.ts +38 -0
  33. package/dist/domain/export.js +68 -0
  34. package/dist/domain/hub-entry.d.ts +201 -0
  35. package/dist/domain/hub-entry.js +211 -0
  36. package/dist/domain/index.d.ts +19 -0
  37. package/dist/domain/index.js +19 -0
  38. package/dist/domain/intervals.d.ts +17 -0
  39. package/dist/domain/intervals.js +43 -0
  40. package/dist/domain/registry-health.d.ts +49 -0
  41. package/dist/domain/registry-health.js +58 -0
  42. package/dist/domain/segment-rule.d.ts +10 -0
  43. package/dist/domain/segment-rule.js +1 -0
  44. package/dist/domain/subagent-profile.d.ts +278 -0
  45. package/dist/domain/subagent-profile.js +418 -0
  46. package/dist/domain/sync-plan.d.ts +150 -0
  47. package/dist/domain/sync-plan.js +182 -0
  48. package/dist/domain/task-view.d.ts +113 -0
  49. package/dist/domain/task-view.js +426 -0
  50. package/dist/domain/work-record.d.ts +201 -0
  51. package/dist/domain/work-record.js +69 -0
  52. package/dist/domain/work-target.d.ts +72 -0
  53. package/dist/domain/work-target.js +127 -0
  54. package/dist/domain/work-tracker.d.ts +90 -0
  55. package/dist/domain/work-tracker.js +405 -0
  56. package/dist/hub/index.d.ts +19 -0
  57. package/dist/hub/index.js +19 -0
  58. package/dist/ports/catalog.d.ts +29 -0
  59. package/dist/ports/catalog.js +1 -0
  60. package/dist/ports/clock.d.ts +3 -0
  61. package/dist/ports/clock.js +1 -0
  62. package/dist/ports/index.d.ts +11 -0
  63. package/dist/ports/index.js +1 -0
  64. package/dist/ports/inflight-store.d.ts +15 -0
  65. package/dist/ports/inflight-store.js +1 -0
  66. package/dist/ports/process-registry.d.ts +72 -0
  67. package/dist/ports/process-registry.js +1 -0
  68. package/dist/ports/work-log.d.ts +14 -0
  69. package/dist/ports/work-log.js +1 -0
  70. package/dist/ports/work-sink.d.ts +39 -0
  71. package/dist/ports/work-sink.js +1 -0
  72. package/package.json +20 -2
  73. package/src/adapters/session-target.ts +86 -24
  74. package/src/domain/index.ts +19 -0
  75. package/src/hub/index.ts +19 -0
  76. package/src/ports/index.ts +11 -0
@@ -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,14 @@
1
+ import type { Client, 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` and
6
+ * `projects` record (no `active` filter, so the domain resolver can tell
7
+ * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
8
+ * `signal`, when given, bounds both fetches (see
9
+ * `pocketbase-client.ts#request`).
10
+ */
11
+ export declare function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{
12
+ clients: Client[];
13
+ projects: Project[];
14
+ }>;
@@ -0,0 +1,39 @@
1
+ function mapClient(record) {
2
+ return {
3
+ id: record.id,
4
+ name: record.name,
5
+ code: record.code,
6
+ active: record.active === true,
7
+ ...(record.unassigned === true ? { unassigned: true } : {}),
8
+ };
9
+ }
10
+ function mapProject(record) {
11
+ return {
12
+ id: record.id,
13
+ name: record.name,
14
+ ...(record.code !== undefined ? { code: record.code } : {}),
15
+ clientId: record.client,
16
+ repoPaths: Array.isArray(record.repo_paths) ? record.repo_paths.filter((path) => typeof path === "string") : [],
17
+ active: record.active === true,
18
+ };
19
+ }
20
+ /**
21
+ * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
22
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
23
+ * `projects` record (no `active` filter, so the domain resolver can tell
24
+ * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
25
+ * `signal`, when given, bounds both fetches (see
26
+ * `pocketbase-client.ts#request`).
27
+ */
28
+ export function createPocketBaseCatalogFetcher(client) {
29
+ return async (signal) => {
30
+ const [clientRecords, projectRecords] = await Promise.all([
31
+ client.list("clients", { sort: "name" }, signal),
32
+ client.list("projects", { sort: "name" }, signal),
33
+ ]);
34
+ return {
35
+ clients: clientRecords.map(mapClient),
36
+ projects: projectRecords.map(mapProject),
37
+ };
38
+ };
39
+ }
@@ -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,52 @@
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, 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
+ machine: string;
24
+ promptMode: PromptPrivacyMode;
25
+ /** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
26
+ agent?: string;
27
+ agentVersion?: string;
28
+ /** The integration that wrote these rows; see `domain/hub-entry.ts#HubEntryContext.plugin`. Defaults to `"kankaku"`. */
29
+ plugin?: string;
30
+ pluginVersion?: string;
31
+ /** `KANKAKU_SYNC_RECORDS` (default enabled): also upsert each task's raw `work_records`. */
32
+ syncRecords: boolean;
33
+ /** ids per lookup request. Defaults to 30, per contract.md's guidance. */
34
+ chunkSize?: number;
35
+ }
36
+ /** Escape a value for PocketBase's filter string-literal syntax (`field="value"`). Exported for direct unit testing. */
37
+ export declare function escapeFilterValue(value: string): string;
38
+ export declare class PocketBaseSink implements WorkSink {
39
+ private readonly deps;
40
+ constructor(deps: PocketBaseSinkDeps);
41
+ private get chunkSize();
42
+ /** 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. */
43
+ private lookupExisting;
44
+ private findOne;
45
+ private get entryContext();
46
+ /** Create-or-patch one task_entries row. Returns its PocketBase record id and whether it was created or updated. */
47
+ private upsertTaskEntry;
48
+ /** 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. */
49
+ private upsertWorkRecords;
50
+ private pushOne;
51
+ push(tasks: TaskView[]): Promise<PushTaskResult[]>;
52
+ }
@@ -0,0 +1,180 @@
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
+ machine: this.deps.machine,
80
+ promptMode: this.deps.promptMode,
81
+ agent: this.deps.agent ?? "pi",
82
+ ...(this.deps.agentVersion !== undefined ? { agentVersion: this.deps.agentVersion } : {}),
83
+ plugin: this.deps.plugin ?? "kankaku",
84
+ ...(this.deps.pluginVersion !== undefined ? { pluginVersion: this.deps.pluginVersion } : {}),
85
+ };
86
+ }
87
+ /** Create-or-patch one task_entries row. Returns its PocketBase record id and whether it was created or updated. */
88
+ async upsertTaskEntry(task, existingId) {
89
+ const ctx = this.entryContext;
90
+ if (existingId) {
91
+ await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${existingId}`, buildTaskEntryUpdatePayload(task, ctx));
92
+ return { id: existingId, created: false };
93
+ }
94
+ try {
95
+ const created = await this.deps.client.request("POST", "/api/collections/task_entries/records", buildTaskEntryCreatePayload(task, ctx));
96
+ return { id: created.id, created: true };
97
+ }
98
+ catch (error) {
99
+ if (!isLikelyUniqueViolation(error))
100
+ throw error;
101
+ const found = await this.findOne("task_entries", "task_id", task.id);
102
+ if (!found)
103
+ throw error;
104
+ await this.deps.client.request("PATCH", `/api/collections/task_entries/records/${found}`, buildTaskEntryUpdatePayload(task, ctx));
105
+ return { id: found, created: false };
106
+ }
107
+ }
108
+ /** 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. */
109
+ async upsertWorkRecords(task, taskEntryRecordId) {
110
+ const records = taskWorkRecords(task);
111
+ const existing = await this.lookupExisting("work_records", "kankaku_id", records.map((record) => record.id));
112
+ for (const record of records) {
113
+ const payload = buildWorkRecordPayload(record, taskEntryRecordId, { machine: this.deps.machine, promptMode: this.deps.promptMode });
114
+ const existingId = existing.get(record.id);
115
+ try {
116
+ if (existingId) {
117
+ await this.deps.client.request("PATCH", `/api/collections/work_records/records/${existingId}`, payload);
118
+ }
119
+ else {
120
+ try {
121
+ await this.deps.client.request("POST", "/api/collections/work_records/records", payload);
122
+ }
123
+ catch (error) {
124
+ if (!isLikelyUniqueViolation(error))
125
+ throw error;
126
+ const found = await this.findOne("work_records", "kankaku_id", record.id);
127
+ if (!found)
128
+ throw error;
129
+ await this.deps.client.request("PATCH", `/api/collections/work_records/records/${found}`, payload);
130
+ }
131
+ }
132
+ }
133
+ catch (error) {
134
+ if (isFatalError(error))
135
+ throw error;
136
+ // Non-fatal: this one child row failed validation; the task_entries
137
+ // row it belongs to already succeeded, so skip it and keep going.
138
+ }
139
+ }
140
+ }
141
+ async pushOne(task, existingId) {
142
+ const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
143
+ const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
144
+ try {
145
+ const { id, created } = await this.upsertTaskEntry(task, existingId);
146
+ if (this.deps.syncRecords) {
147
+ await this.upsertWorkRecords(task, id);
148
+ }
149
+ return created ? { kind: "created", ...pushAssignment } : { kind: "updated", ...pushAssignment };
150
+ }
151
+ catch (error) {
152
+ if (isFatalError(error))
153
+ return { kind: "error", reason: describeError(error) };
154
+ return { kind: "failed", reason: describeError(error) };
155
+ }
156
+ }
157
+ async push(tasks) {
158
+ const results = [];
159
+ if (tasks.length === 0)
160
+ return results;
161
+ // One chunked lookup for every task up front (contract.md: "one list
162
+ // request per ~30 ids"), rather than one lookup per task.
163
+ let existingByTaskId;
164
+ try {
165
+ existingByTaskId = await this.lookupExisting("task_entries", "task_id", tasks.map((task) => task.id));
166
+ }
167
+ catch (error) {
168
+ const outcome = isFatalError(error) ? { kind: "error", reason: describeError(error) } : { kind: "failed", reason: describeError(error) };
169
+ results.push({ taskId: tasks[0].id, outcome });
170
+ return results;
171
+ }
172
+ for (const task of tasks) {
173
+ const outcome = await this.pushOne(task, existingByTaskId.get(task.id));
174
+ results.push({ taskId: task.id, outcome });
175
+ if (outcome.kind === "error")
176
+ break;
177
+ }
178
+ return results;
179
+ }
180
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Orchestrates one sync run: read every record, build tasks (never
3
+ * re-implementing the aggregation — `buildTasks` is the only place it
4
+ * lives), plan what needs pushing, push it, and persist the new state.
5
+ * Never throws to its caller: every failure mode is folded into the
6
+ * returned {@link SyncSummary}.
7
+ */
8
+ import type { TaskView } from "../domain/task-view.ts";
9
+ import type { Clock } from "../ports/clock.ts";
10
+ import type { WorkLog } from "../ports/work-log.ts";
11
+ import type { WorkSink } from "../ports/work-sink.ts";
12
+ import type { SyncStateStore } from "./sync-state-store.ts";
13
+ export interface SyncSummary {
14
+ uploaded: number;
15
+ updated: number;
16
+ skipped: number;
17
+ failed: Array<{
18
+ id: string;
19
+ reason: string;
20
+ }>;
21
+ /** Count of tasks routed to the unassigned client this run, grouped by their historical free-text label. */
22
+ unassigned: Record<string, number>;
23
+ syncedThrough: string | undefined;
24
+ durationMs: number;
25
+ /** Set when a network/timeout/5xx/auth failure stopped the run before every candidate task was attempted. */
26
+ error?: string;
27
+ /** `true` when another sync already holds the lock; nothing was attempted this run. */
28
+ locked?: boolean;
29
+ }
30
+ /**
31
+ * Which automatic trigger asked for this run, or `undefined` for a manual
32
+ * one (`/kankaku sync`, `sync all`, `backfill`) — see `runSync`'s
33
+ * short-circuit and throttle, which apply only to the automatic path.
34
+ * `session_shutdown` (pi awaits this handler — see `adapters/pi-tracker.ts`)
35
+ * is, like `session_start`, never throttled: only `agent_settled` is.
36
+ */
37
+ export type SyncTrigger = "session_start" | "agent_settled" | "session_shutdown";
38
+ export interface SyncRunnerDeps {
39
+ log: WorkLog;
40
+ sink: WorkSink;
41
+ stateStore: SyncStateStore;
42
+ clock: Clock;
43
+ /** The configured hub URL — a state file synced against a different one triggers a full sync. */
44
+ target: string;
45
+ windowHours?: number;
46
+ /**
47
+ * `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`, already converted to ms. Only
48
+ * applies to the automatic path (`options.trigger` set). Defaults to 5
49
+ * minutes; `0` disables throttling.
50
+ */
51
+ minAutoIntervalMs?: number;
52
+ }
53
+ /**
54
+ * Run one sync pass. Acquires the cross-process lock for the whole run
55
+ * (never held across `await` boundaries outside this function) so two pi
56
+ * processes never race on the same `sync-state.json`.
57
+ *
58
+ * When `options.trigger` is set (the automatic `session_start`/
59
+ * `agent_settled`/`session_shutdown` path, as opposed to a manual
60
+ * `/kankaku sync`), two cheap gates run before any `WorkLog.readAll()` or
61
+ * network call: (a) if the log's `version()` is unchanged since the last
62
+ * successful sync and that sync did not error, skip entirely, for every
63
+ * trigger; otherwise (b) throttle to at most one real attempt per
64
+ * `minAutoIntervalMs`, but only for `agent_settled` — fired once per
65
+ * prompt, so `version()` almost always differs right after it appended a
66
+ * record. `session_start` and `session_shutdown` never throttle (see
67
+ * `isThrottled`). Neither gate ever applies to a manual sync.
68
+ */
69
+ export declare function runSync(deps: SyncRunnerDeps, options?: {
70
+ full?: boolean;
71
+ trigger?: SyncTrigger;
72
+ }): Promise<SyncSummary>;
73
+ /** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
74
+ export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number;
75
+ /**
76
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
77
+ * count, and (R3) how many tasks changed since their last sync but fall
78
+ * outside this run's revisit window — a `sync all` needed to pick them up
79
+ * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
80
+ * (PocketBase)" > "Sync" > "Limitations"). No network.
81
+ */
82
+ export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): {
83
+ state: ReturnType<SyncStateStore["read"]>;
84
+ pending: number;
85
+ staleOutsideWindow: number;
86
+ };
87
+ /**
88
+ * Wrap an async function so concurrent callers share one in-flight call
89
+ * instead of starting a new one each — kankaku's single-flight guard for
90
+ * sync: `/kankaku sync`, the `session_start` auto-sync and the
91
+ * `agent_settled` auto-sync all go through the same wrapped function, so
92
+ * only one sync is ever running at a time within this process. (The
93
+ * cross-process case is covered separately by `SyncStateStore`'s lock
94
+ * file.) A caller that arrives while one is in flight joins its result
95
+ * rather than queuing a fresh run — the next trigger (the next
96
+ * `session_start` or `agent_settled`) will pick up anything missed, since
97
+ * every sync also revisits the trailing window.
98
+ */
99
+ export declare function singleFlight<Args extends unknown[], T>(fn: (...args: Args) => Promise<T>): (...args: Args) => Promise<T>;