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,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
|
+
}
|