kankaku-pi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,114 @@
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 { SyncState } from "../domain/sync-plan.ts";
9
+ import type { TaskView } from "../domain/task-view.ts";
10
+ import type { Clock } from "../ports/clock.ts";
11
+ import type { WorkLog } from "../ports/work-log.ts";
12
+ import type { WorkSink } from "../ports/work-sink.ts";
13
+ import type { SyncStateStore } from "./sync-state-store.ts";
14
+ export interface SyncSummary {
15
+ uploaded: number;
16
+ updated: number;
17
+ skipped: number;
18
+ failed: Array<{
19
+ id: string;
20
+ reason: string;
21
+ }>;
22
+ /** Count of tasks routed to the unassigned client this run, grouped by their historical free-text label. */
23
+ unassigned: Record<string, number>;
24
+ syncedThrough: string | undefined;
25
+ durationMs: number;
26
+ /** Set when a network/timeout/5xx/auth failure stopped the run before every candidate task was attempted. */
27
+ error?: string;
28
+ /** `true` when another sync already holds the lock; nothing was attempted this run. */
29
+ locked?: boolean;
30
+ }
31
+ /**
32
+ * Which automatic trigger asked for this run, or `undefined` for a manual
33
+ * one (`/kankaku sync`, `sync all`, `backfill`) — see `runSync`'s
34
+ * short-circuit and throttle, which apply only to the automatic path.
35
+ * `session_shutdown` (pi awaits this handler — see `adapters/pi-tracker.ts`)
36
+ * is, like `session_start`, never throttled: only `agent_settled` is.
37
+ */
38
+ export type SyncTrigger = "session_start" | "agent_settled" | "session_shutdown";
39
+ export interface SyncRunnerDeps {
40
+ log: WorkLog;
41
+ sink: WorkSink;
42
+ stateStore: SyncStateStore;
43
+ clock: Clock;
44
+ /** The configured hub URL — a state file synced against a different one triggers a full sync. */
45
+ target: string;
46
+ windowHours?: number;
47
+ /**
48
+ * `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`, already converted to ms. Only
49
+ * applies to the automatic path (`options.trigger` set). Defaults to 5
50
+ * minutes; `0` disables throttling.
51
+ */
52
+ minAutoIntervalMs?: number;
53
+ }
54
+ /**
55
+ * Run one sync pass. Acquires the cross-process lock for the whole run
56
+ * (never held across `await` boundaries outside this function) so two pi
57
+ * processes never race on the same `sync-state.json`.
58
+ *
59
+ * When `options.trigger` is set (the automatic `session_start`/
60
+ * `agent_settled`/`session_shutdown` path, as opposed to a manual
61
+ * `/kankaku sync`), two cheap gates run before any `WorkLog.readAll()` or
62
+ * network call: (a) if the log's `version()` is unchanged since the last
63
+ * successful sync and that sync did not error, skip entirely, for every
64
+ * trigger; otherwise (b) throttle to at most one real attempt per
65
+ * `minAutoIntervalMs`, but only for `agent_settled` — fired once per
66
+ * prompt, so `version()` almost always differs right after it appended a
67
+ * record. `session_start` and `session_shutdown` never throttle (see
68
+ * `isThrottled`). Neither gate ever applies to a manual sync.
69
+ */
70
+ export declare function runSync(deps: SyncRunnerDeps, options?: {
71
+ full?: boolean;
72
+ trigger?: SyncTrigger;
73
+ }): Promise<SyncSummary>;
74
+ /**
75
+ * Number of tasks pending a sync right now — computed locally, no network.
76
+ *
77
+ * @deprecated Superseded by {@link computeSyncStatus}, which returns the same
78
+ * `pending` count together with `state` and `staleOutsideWindow`. Nothing in
79
+ * kankaku calls this any more; it stays only because it is part of the
80
+ * published `kankaku-pi/hub` surface. It will be removed in the next minor.
81
+ */
82
+ export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number;
83
+ /**
84
+ * {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
85
+ * `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
86
+ * `kankaku-pi/hub`) can depend on this type without importing anything that
87
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
88
+ */
89
+ export interface SyncStatusSnapshot {
90
+ state: SyncState | undefined;
91
+ pending: number;
92
+ staleOutsideWindow: number;
93
+ }
94
+ /**
95
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
96
+ * count, and (R3) how many tasks changed since their last sync but fall
97
+ * outside this run's revisit window — a `sync all` needed to pick them up
98
+ * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
99
+ * (PocketBase)" > "Sync" > "Limitations"). No network.
100
+ */
101
+ export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot;
102
+ /**
103
+ * Wrap an async function so concurrent callers share one in-flight call
104
+ * instead of starting a new one each — kankaku's single-flight guard for
105
+ * sync: `/kankaku sync`, the `session_start` auto-sync and the
106
+ * `agent_settled` auto-sync all go through the same wrapped function, so
107
+ * only one sync is ever running at a time within this process. (The
108
+ * cross-process case is covered separately by `SyncStateStore`'s lock
109
+ * file.) A caller that arrives while one is in flight joins its result
110
+ * rather than queuing a fresh run — the next trigger (the next
111
+ * `session_start` or `agent_settled`) will pick up anything missed, since
112
+ * every sync also revisits the trailing window.
113
+ */
114
+ export declare function singleFlight<Args extends unknown[], T>(fn: (...args: Args) => Promise<T>): (...args: Args) => Promise<T>;
@@ -0,0 +1,273 @@
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 { buildTasks } from "../domain/task-view.js";
9
+ import { computeTaskContentHash, planSync, pruneHashes } from "../domain/sync-plan.js";
10
+ const NO_LABEL = "(no label)";
11
+ const DEFAULT_MIN_AUTO_INTERVAL_MS = 5 * 60 * 1000;
12
+ /** The later of two ISO timestamps, treating `undefined` as earlier than anything. */
13
+ function laterIso(a, b) {
14
+ if (a === undefined)
15
+ return b;
16
+ return Date.parse(b) > Date.parse(a) ? b : a;
17
+ }
18
+ function emptySummary(durationMs, syncedThrough) {
19
+ return { uploaded: 0, updated: 0, skipped: 0, failed: [], unassigned: {}, syncedThrough, durationMs };
20
+ }
21
+ /**
22
+ * Whether the automatic path's throttle should block this run right now.
23
+ * Only `agent_settled` — fired once per prompt, far more often than a
24
+ * session starts or ends — is ever throttled; `session_start` and
25
+ * `session_shutdown` always bypass it (a session boundary is a good time
26
+ * to catch up regardless of how recently the last automatic run happened,
27
+ * and the shutdown one is awaited and time-bounded on its own — see
28
+ * `adapters/pi-tracker.ts`). `undefined`/non-finite `lastRunAt` (never
29
+ * run, or a malformed on-disk value) never throttles either — there is
30
+ * nothing to measure the interval against.
31
+ */
32
+ function isThrottled(state, trigger, now, minIntervalMs) {
33
+ if (trigger !== "agent_settled")
34
+ return false;
35
+ if (minIntervalMs <= 0)
36
+ return false;
37
+ const lastRunAt = state?.lastRunAt;
38
+ if (!Number.isFinite(lastRunAt))
39
+ return false;
40
+ if (now - lastRunAt >= minIntervalMs)
41
+ return false;
42
+ return true;
43
+ }
44
+ /**
45
+ * Run one sync pass. Acquires the cross-process lock for the whole run
46
+ * (never held across `await` boundaries outside this function) so two pi
47
+ * processes never race on the same `sync-state.json`.
48
+ *
49
+ * When `options.trigger` is set (the automatic `session_start`/
50
+ * `agent_settled`/`session_shutdown` path, as opposed to a manual
51
+ * `/kankaku sync`), two cheap gates run before any `WorkLog.readAll()` or
52
+ * network call: (a) if the log's `version()` is unchanged since the last
53
+ * successful sync and that sync did not error, skip entirely, for every
54
+ * trigger; otherwise (b) throttle to at most one real attempt per
55
+ * `minAutoIntervalMs`, but only for `agent_settled` — fired once per
56
+ * prompt, so `version()` almost always differs right after it appended a
57
+ * record. `session_start` and `session_shutdown` never throttle (see
58
+ * `isThrottled`). Neither gate ever applies to a manual sync.
59
+ */
60
+ export async function runSync(deps, options = {}) {
61
+ const startedAt = deps.clock.now();
62
+ if (options.trigger !== undefined) {
63
+ // Gated like every other read below: a state written for another hub
64
+ // says nothing about THIS hub, so neither its `logVersion` (which
65
+ // would otherwise skip the new hub's very first run until the next
66
+ // record is appended) nor its `lastRunAt` throttle may apply.
67
+ const peek = stateForTarget(deps, deps.stateStore.read());
68
+ const currentVersion = deps.log.version?.();
69
+ const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
70
+ if (versionUnchanged && peek?.lastError === undefined) {
71
+ return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
72
+ }
73
+ const minIntervalMs = deps.minAutoIntervalMs ?? DEFAULT_MIN_AUTO_INTERVAL_MS;
74
+ if (isThrottled(peek, options.trigger, deps.clock.now(), minIntervalMs)) {
75
+ return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
76
+ }
77
+ }
78
+ let lockAcquired = false;
79
+ try {
80
+ lockAcquired = deps.stateStore.tryLock();
81
+ if (!lockAcquired) {
82
+ const state = stateForTarget(deps, deps.stateStore.read());
83
+ return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
84
+ }
85
+ const state = deps.stateStore.read();
86
+ // A state from another hub is ignored wholesale, on EVERY path below
87
+ // (the summary, the error path, and the successful merge): its
88
+ // watermark and its hashes describe rows the configured hub does not
89
+ // have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
90
+ // still receives the raw `state`, since it applies that same gate
91
+ // itself and its `target` comparison is what makes the run a full one.
92
+ const priorState = stateForTarget(deps, state);
93
+ // Captured once, here, and persisted as-is below: this is the version
94
+ // the tasks below were actually built from, not whatever the log might
95
+ // become by the time an awaited push finishes.
96
+ const logVersionAtRead = deps.log.version?.();
97
+ const tasks = buildTasks(deps.log.readAll());
98
+ const plan = planSync(tasks, state, { target: deps.target, ...(deps.windowHours !== undefined ? { windowHours: deps.windowHours } : {}), ...(options.full !== undefined ? { full: options.full } : {}) });
99
+ let results;
100
+ try {
101
+ results = await deps.sink.push(plan.toSync);
102
+ }
103
+ catch (error) {
104
+ // WorkSink implementations are expected never to throw, but this
105
+ // runner must hold that guarantee even if one does.
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
108
+ summary.skipped = plan.unchangedCount;
109
+ summary.error = message;
110
+ // `priorState`, never `state`: a throwing sink on the first run
111
+ // against a NEW hub must not carry the old hub's hashes and
112
+ // watermark over under the new target, or the next run would
113
+ // skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
114
+ // successful path).
115
+ persistError(deps, priorState, message, logVersionAtRead);
116
+ return summary;
117
+ }
118
+ const byId = new Map(plan.toSync.map((task) => [task.id, task]));
119
+ // Both are keyed by content that ultimately traces back to free-text
120
+ // worklog/legacy-client data (task ids, legacy client labels): built in
121
+ // a `Map` and emitted via `Object.fromEntries` below (never
122
+ // `newHashes[task.id] = ...` on a plain object), so a value like
123
+ // `__proto__` or `constructor` becomes a normal own entry instead of
124
+ // silently colliding with an inherited `Object.prototype` property.
125
+ const newHashes = new Map();
126
+ const failed = [];
127
+ const unassigned = new Map();
128
+ let uploaded = 0;
129
+ let updated = 0;
130
+ let syncedThrough = priorState?.syncedThrough;
131
+ let stopError;
132
+ // Whether at least one task was actually resolved (pushed or recorded
133
+ // as failed) this run — as opposed to the run stopping on its very
134
+ // first attempt. Guards `target` below: a run against a new/unreachable
135
+ // target that resolves nothing must not overwrite the state's `target`,
136
+ // or a later sync against the *real* target would wrongly see it as
137
+ // unchanged and skip the full re-evaluation it needs.
138
+ let progressed = false;
139
+ for (const result of results) {
140
+ const task = byId.get(result.taskId);
141
+ if (!task)
142
+ continue; // defensive: a WorkSink implementation misbehaving should not crash the runner.
143
+ if (result.outcome.kind === "created" || result.outcome.kind === "updated") {
144
+ if (result.outcome.kind === "created")
145
+ uploaded += 1;
146
+ else
147
+ updated += 1;
148
+ newHashes.set(task.id, computeTaskContentHash(task));
149
+ syncedThrough = laterIso(syncedThrough, task.endedAt);
150
+ progressed = true;
151
+ if (result.outcome.unassigned) {
152
+ const label = result.outcome.legacyLabel || NO_LABEL;
153
+ unassigned.set(label, (unassigned.get(label) ?? 0) + 1);
154
+ }
155
+ }
156
+ else if (result.outcome.kind === "failed") {
157
+ // Recorded and skipped, not retried forever: stamp its hash too so
158
+ // an unchanged, permanently-invalid task is not resent every run.
159
+ failed.push({ id: task.id, reason: result.outcome.reason });
160
+ newHashes.set(task.id, computeTaskContentHash(task));
161
+ syncedThrough = laterIso(syncedThrough, task.endedAt);
162
+ progressed = true;
163
+ }
164
+ else {
165
+ // "error": a network/timeout/5xx/auth failure. Stop here — nothing
166
+ // after this point in the results is considered resolved. The
167
+ // results follow `plan.toSync`'s order (new work oldest-first,
168
+ // then corrections newest-first), and `syncedThrough` is a max
169
+ // over what WAS resolved, so stopping early can only leave it
170
+ // lower, never advance it past an unresolved task.
171
+ stopError = result.outcome.reason;
172
+ break;
173
+ }
174
+ }
175
+ const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
176
+ // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
177
+ // doc comment. `tasks` here is every task `buildTasks` currently knows
178
+ // about (the full `readAll()`, not just this run's eligible/window
179
+ // subset), so a hash is only ever dropped for a task id that has
180
+ // genuinely vanished, never merely because it is old.
181
+ const prunedHashes = pruneHashes(mergedHashes, tasks);
182
+ // Only adopt deps.target as the persisted target once this run has
183
+ // actually resolved something against it; otherwise keep whatever
184
+ // target (if any) the previous state was synced against.
185
+ const persistedTarget = progressed || state === undefined ? deps.target : state.target;
186
+ deps.stateStore.write({
187
+ target: persistedTarget,
188
+ ...(syncedThrough !== undefined ? { syncedThrough } : {}),
189
+ hashes: prunedHashes,
190
+ ...(stopError !== undefined ? { lastError: { message: stopError, at: new Date(deps.clock.now()).toISOString() } } : {}),
191
+ ...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
192
+ lastRunAt: deps.clock.now(),
193
+ });
194
+ return {
195
+ uploaded,
196
+ updated,
197
+ skipped: plan.unchangedCount,
198
+ failed,
199
+ unassigned: Object.fromEntries(unassigned),
200
+ syncedThrough,
201
+ durationMs: deps.clock.now() - startedAt,
202
+ ...(stopError !== undefined ? { error: stopError } : {}),
203
+ };
204
+ }
205
+ finally {
206
+ if (lockAcquired)
207
+ deps.stateStore.unlock();
208
+ }
209
+ }
210
+ /** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
211
+ function stateForTarget(deps, state) {
212
+ return state !== undefined && state.target === deps.target ? state : undefined;
213
+ }
214
+ /** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
215
+ function persistError(deps, state, message, logVersionAtRead) {
216
+ deps.stateStore.write({
217
+ target: deps.target,
218
+ ...(state?.syncedThrough !== undefined ? { syncedThrough: state.syncedThrough } : {}),
219
+ hashes: state?.hashes ?? {},
220
+ lastError: { message, at: new Date(deps.clock.now()).toISOString() },
221
+ ...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
222
+ lastRunAt: deps.clock.now(),
223
+ });
224
+ }
225
+ /**
226
+ * Number of tasks pending a sync right now — computed locally, no network.
227
+ *
228
+ * @deprecated Superseded by {@link computeSyncStatus}, which returns the same
229
+ * `pending` count together with `state` and `staleOutsideWindow`. Nothing in
230
+ * kankaku calls this any more; it stays only because it is part of the
231
+ * published `kankaku-pi/hub` surface. It will be removed in the next minor.
232
+ */
233
+ export function pendingCount(tasks, state, target, windowHours) {
234
+ const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
235
+ return plan.toSync.length + plan.correctionsDeferred;
236
+ }
237
+ /**
238
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
239
+ * count, and (R3) how many tasks changed since their last sync but fall
240
+ * outside this run's revisit window — a `sync all` needed to pick them up
241
+ * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
242
+ * (PocketBase)" > "Sync" > "Limitations"). No network.
243
+ */
244
+ export function computeSyncStatus(log, stateStore, target, windowHours) {
245
+ const state = stateStore.read();
246
+ const tasks = buildTasks(log.readAll());
247
+ const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
248
+ // Deferred corrections are pending too: the cap only spreads them over runs.
249
+ return { state, pending: plan.toSync.length + plan.correctionsDeferred, staleOutsideWindow: plan.staleOutsideWindow.length };
250
+ }
251
+ /**
252
+ * Wrap an async function so concurrent callers share one in-flight call
253
+ * instead of starting a new one each — kankaku's single-flight guard for
254
+ * sync: `/kankaku sync`, the `session_start` auto-sync and the
255
+ * `agent_settled` auto-sync all go through the same wrapped function, so
256
+ * only one sync is ever running at a time within this process. (The
257
+ * cross-process case is covered separately by `SyncStateStore`'s lock
258
+ * file.) A caller that arrives while one is in flight joins its result
259
+ * rather than queuing a fresh run — the next trigger (the next
260
+ * `session_start` or `agent_settled`) will pick up anything missed, since
261
+ * every sync also revisits the trailing window.
262
+ */
263
+ export function singleFlight(fn) {
264
+ let inFlight;
265
+ return (...args) => {
266
+ if (!inFlight) {
267
+ inFlight = fn(...args).finally(() => {
268
+ inFlight = undefined;
269
+ });
270
+ }
271
+ return inFlight;
272
+ };
273
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Disk-backed store for `<KANKAKU_DIR>/sync-state.json`, plus a simple
3
+ * cross-process lock so two pi processes (e.g. an orchestrator's
4
+ * auto-sync and a manual `/kankaku sync` in another terminal) never sync
5
+ * the same directory concurrently. Mirrors `file-inflight-store.ts`'s
6
+ * atomic-write and liveness-probe conventions.
7
+ */
8
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import type { SyncState } from "../domain/sync-plan.ts";
10
+ /** The subset of `node:fs` the lock's acquire/recover path needs, injectable so tests can simulate cross-process interleaving deterministically. */
11
+ export interface SyncStateStoreFsOps {
12
+ existsSync: typeof existsSync;
13
+ readFileSync: typeof readFileSync;
14
+ writeFileSync: typeof writeFileSync;
15
+ renameSync: typeof renameSync;
16
+ unlinkSync: typeof unlinkSync;
17
+ openSync: typeof openSync;
18
+ closeSync: typeof closeSync;
19
+ }
20
+ export interface SyncStateStoreDeps {
21
+ dir: string;
22
+ pid: number;
23
+ /** Whether a pid is still alive. Defaults to the same signal-probe used elsewhere. */
24
+ isAlive?: (pid: number) => boolean;
25
+ /** Injectable for tests. Defaults to `Date.now`. */
26
+ now?: () => number;
27
+ /** Injectable `node:fs` primitives for the lock's acquire/recover path. Defaults to the real ones. */
28
+ fs?: SyncStateStoreFsOps;
29
+ }
30
+ /**
31
+ * `read()`/`write()` tolerate a missing or malformed file (return
32
+ * `undefined` / overwrite, respectively) since this state is disposable —
33
+ * losing it only costs a full re-evaluation on the next sync, never data.
34
+ * `tryLock()`/`unlock()` implement a simple pid+timestamp lock file, stale
35
+ * after {@link STALE_LOCK_MS}.
36
+ */
37
+ export declare class SyncStateStore {
38
+ private readonly deps;
39
+ constructor(deps: SyncStateStoreDeps);
40
+ private get statePath();
41
+ private get lockPath();
42
+ read(): SyncState | undefined;
43
+ write(state: SyncState): void;
44
+ /**
45
+ * Try to acquire the cross-process sync lock. Acquisition itself is
46
+ * atomic: it always goes through an exclusive create ({@link acquireFresh},
47
+ * `open` with the `wx` flag), never a read-then-write, so two processes
48
+ * racing to create the lock file can never both succeed. Returns `true`
49
+ * (and takes ownership) when there is no lock file, this process already
50
+ * owns it (re-entrant), or the existing one is stale (its pid is no
51
+ * longer alive, or it is older than {@link STALE_LOCK_MS}) and this
52
+ * process wins the race to recover it; `false` when a live, fresh lock is
53
+ * held by another process, or this process loses a stale-lock recovery
54
+ * race to another one.
55
+ */
56
+ tryLock(): boolean;
57
+ /** Create the lock file exclusively (`wx`): fails with EEXIST when another lock already exists, never silently overwrites one. Assumes `this.deps.dir` already exists (`tryLock` ensures it once up front). */
58
+ private acquireFresh;
59
+ /** Release the lock, but only if this process still owns it (never clobber someone else's fresher lock). */
60
+ unlock(): void;
61
+ private readLock;
62
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Disk-backed store for `<KANKAKU_DIR>/sync-state.json`, plus a simple
3
+ * cross-process lock so two pi processes (e.g. an orchestrator's
4
+ * auto-sync and a manual `/kankaku sync` in another terminal) never sync
5
+ * the same directory concurrently. Mirrors `file-inflight-store.ts`'s
6
+ * atomic-write and liveness-probe conventions.
7
+ */
8
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ const STATE_FILE_NAME = "sync-state.json";
11
+ const LOCK_FILE_NAME = "sync.lock";
12
+ /** A lock older than this is considered abandoned even if its owning pid still (coincidentally) exists. */
13
+ const STALE_LOCK_MS = 5 * 60 * 1000;
14
+ const defaultFsOps = { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, closeSync };
15
+ function isEnoent(error) {
16
+ return error?.code === "ENOENT";
17
+ }
18
+ function isEexist(error) {
19
+ return error?.code === "EEXIST";
20
+ }
21
+ function isSyncState(value) {
22
+ if (!value || typeof value !== "object")
23
+ return false;
24
+ const record = value;
25
+ return (typeof record["target"] === "string" &&
26
+ typeof record["hashes"] === "object" &&
27
+ record["hashes"] !== null &&
28
+ (record["syncedThrough"] === undefined || typeof record["syncedThrough"] === "string"));
29
+ }
30
+ function isLockFile(value) {
31
+ if (!value || typeof value !== "object")
32
+ return false;
33
+ const record = value;
34
+ return typeof record["pid"] === "number" && typeof record["at"] === "number";
35
+ }
36
+ function atomicWrite(filePath, content) {
37
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
38
+ writeFileSync(tmp, content);
39
+ renameSync(tmp, filePath);
40
+ }
41
+ function safeUnlink(fs, filePath) {
42
+ try {
43
+ fs.unlinkSync(filePath);
44
+ }
45
+ catch {
46
+ // Best effort: already removed, or never existed.
47
+ }
48
+ }
49
+ /**
50
+ * `read()`/`write()` tolerate a missing or malformed file (return
51
+ * `undefined` / overwrite, respectively) since this state is disposable —
52
+ * losing it only costs a full re-evaluation on the next sync, never data.
53
+ * `tryLock()`/`unlock()` implement a simple pid+timestamp lock file, stale
54
+ * after {@link STALE_LOCK_MS}.
55
+ */
56
+ export class SyncStateStore {
57
+ deps;
58
+ constructor(deps) {
59
+ this.deps = {
60
+ dir: deps.dir,
61
+ pid: deps.pid,
62
+ isAlive: deps.isAlive ?? defaultIsAlive,
63
+ now: deps.now ?? (() => Date.now()),
64
+ fs: deps.fs ?? defaultFsOps,
65
+ };
66
+ }
67
+ get statePath() {
68
+ return join(this.deps.dir, STATE_FILE_NAME);
69
+ }
70
+ get lockPath() {
71
+ return join(this.deps.dir, LOCK_FILE_NAME);
72
+ }
73
+ read() {
74
+ try {
75
+ if (!existsSync(this.statePath))
76
+ return undefined;
77
+ const parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
78
+ return isSyncState(parsed) ? parsed : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ write(state) {
85
+ mkdirSync(this.deps.dir, { recursive: true });
86
+ atomicWrite(this.statePath, JSON.stringify(state));
87
+ }
88
+ /**
89
+ * Try to acquire the cross-process sync lock. Acquisition itself is
90
+ * atomic: it always goes through an exclusive create ({@link acquireFresh},
91
+ * `open` with the `wx` flag), never a read-then-write, so two processes
92
+ * racing to create the lock file can never both succeed. Returns `true`
93
+ * (and takes ownership) when there is no lock file, this process already
94
+ * owns it (re-entrant), or the existing one is stale (its pid is no
95
+ * longer alive, or it is older than {@link STALE_LOCK_MS}) and this
96
+ * process wins the race to recover it; `false` when a live, fresh lock is
97
+ * held by another process, or this process loses a stale-lock recovery
98
+ * race to another one.
99
+ */
100
+ tryLock() {
101
+ mkdirSync(this.deps.dir, { recursive: true });
102
+ if (this.acquireFresh())
103
+ return true;
104
+ const existing = this.readLock();
105
+ if (!existing) {
106
+ // Raced with a release between our failed create and this read; the
107
+ // slot may be free again now. One more attempt, then give up rather
108
+ // than looping forever.
109
+ return this.acquireFresh();
110
+ }
111
+ if (existing.pid === this.deps.pid)
112
+ return true; // re-entrant: we already own it.
113
+ const age = this.deps.now() - existing.at;
114
+ const stale = age > STALE_LOCK_MS || !this.deps.isAlive(existing.pid);
115
+ if (!stale)
116
+ return false; // live, fresh lock held by someone else.
117
+ // Stale-lock recovery, made race-safe: rename the stale file to a
118
+ // unique tombstone name first. `rename` is atomic, so only one racer's
119
+ // call can succeed; every loser gets ENOENT and backs off instead of
120
+ // deleting (or overwriting) a lock it never proved was still stale.
121
+ const tombstone = `${this.lockPath}.stale.${this.deps.pid}.${this.deps.now()}.tmp`;
122
+ try {
123
+ this.deps.fs.renameSync(this.lockPath, tombstone);
124
+ }
125
+ catch (error) {
126
+ if (isEnoent(error))
127
+ return false; // lost the recovery race; back off.
128
+ throw error;
129
+ }
130
+ safeUnlink(this.deps.fs, tombstone);
131
+ return this.acquireFresh(); // false here means a third racer won it first.
132
+ }
133
+ /** Create the lock file exclusively (`wx`): fails with EEXIST when another lock already exists, never silently overwrites one. Assumes `this.deps.dir` already exists (`tryLock` ensures it once up front). */
134
+ acquireFresh() {
135
+ let fd;
136
+ try {
137
+ fd = this.deps.fs.openSync(this.lockPath, "wx");
138
+ }
139
+ catch (error) {
140
+ if (isEexist(error))
141
+ return false;
142
+ throw error;
143
+ }
144
+ try {
145
+ this.deps.fs.writeFileSync(fd, JSON.stringify({ pid: this.deps.pid, at: this.deps.now() }));
146
+ }
147
+ catch (error) {
148
+ try {
149
+ this.deps.fs.closeSync(fd);
150
+ }
151
+ catch {
152
+ // Best effort: still try to clean up the partially written file below.
153
+ }
154
+ safeUnlink(this.deps.fs, this.lockPath);
155
+ throw error;
156
+ }
157
+ this.deps.fs.closeSync(fd);
158
+ return true;
159
+ }
160
+ /** Release the lock, but only if this process still owns it (never clobber someone else's fresher lock). */
161
+ unlock() {
162
+ const existing = this.readLock();
163
+ if (existing && existing.pid === this.deps.pid) {
164
+ safeUnlink(this.deps.fs, this.lockPath);
165
+ }
166
+ }
167
+ readLock() {
168
+ try {
169
+ if (!this.deps.fs.existsSync(this.lockPath))
170
+ return undefined;
171
+ const parsed = JSON.parse(this.deps.fs.readFileSync(this.lockPath, "utf8"));
172
+ return isLockFile(parsed) ? parsed : undefined;
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ }
179
+ /** Default `isAlive`: probe with signal 0 — mirrors `pi-tracker.ts`'s default. */
180
+ function defaultIsAlive(pid) {
181
+ try {
182
+ process.kill(pid, 0);
183
+ return true;
184
+ }
185
+ catch (error) {
186
+ return error.code === "EPERM";
187
+ }
188
+ }