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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide which tasks a sync run should push, without touching the network
|
|
3
|
+
* or the filesystem. Pure, no I/O — see `sync-runner.ts` for the adapter
|
|
4
|
+
* that drives this with a real clock, `WorkLog` and `WorkSink`.
|
|
5
|
+
*
|
|
6
|
+
* A task is not final the moment it is first written: a background
|
|
7
|
+
* subagent can settle after its orchestrator and extend the task's union
|
|
8
|
+
* (see `task-view.ts`), which is exactly why every sync also revisits a
|
|
9
|
+
* trailing window behind the watermark instead of only pushing brand-new
|
|
10
|
+
* tasks (proposal §6.0).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { computeCostQuality, computeSubagentLinkage } from "./hub-entry.ts";
|
|
14
|
+
import type { TaskView } from "./task-view.ts";
|
|
15
|
+
|
|
16
|
+
/** Persisted sync state (`<KANKAKU_DIR>/sync-state.json`). */
|
|
17
|
+
export interface SyncState {
|
|
18
|
+
/** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
|
|
19
|
+
syncedThrough?: string;
|
|
20
|
+
/** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
|
|
21
|
+
hashes: Record<string, string>;
|
|
22
|
+
/** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
|
|
23
|
+
target: string;
|
|
24
|
+
/** Set after a network/5xx failure stopped a run short; cleared by the next fully-successful run. */
|
|
25
|
+
lastError?: { message: string; at: string };
|
|
26
|
+
/**
|
|
27
|
+
* The `WorkLog`'s cheap change signal (`version()`) as of the last real
|
|
28
|
+
* (non-short-circuited) automatic-or-manual sync attempt. The automatic
|
|
29
|
+
* path (`session_start`/`agent_settled`) compares this against the
|
|
30
|
+
* current value to skip entirely — no `readAll()`, no network — when
|
|
31
|
+
* nothing has changed and the last attempt did not error. See
|
|
32
|
+
* `adapters/sync-runner.ts#runSync`.
|
|
33
|
+
*/
|
|
34
|
+
logVersion?: string | number;
|
|
35
|
+
/**
|
|
36
|
+
* `Clock`-based timestamp (ms) of the last real (non-short-circuited)
|
|
37
|
+
* automatic sync attempt, persisted so the automatic path's throttle
|
|
38
|
+
* (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
|
|
39
|
+
* within one. Written by every run that reaches the sink, manual or
|
|
40
|
+
* automatic; only the automatic path READS it to throttle itself.
|
|
41
|
+
*/
|
|
42
|
+
lastRunAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SyncPlanOptions {
|
|
46
|
+
/** The configured hub URL. A state whose `target` differs triggers a full sync. */
|
|
47
|
+
target: string;
|
|
48
|
+
/** Revisit window in hours, applied behind `syncedThrough`. Defaults to 24. */
|
|
49
|
+
windowHours?: number;
|
|
50
|
+
/** Force every task to be (re-)evaluated, e.g. `/kankaku sync all`. */
|
|
51
|
+
full?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SyncPlan {
|
|
55
|
+
/** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
|
|
56
|
+
toSync: TaskView[];
|
|
57
|
+
/** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
|
|
58
|
+
unchangedCount: number;
|
|
59
|
+
/** `true` when this run evaluated every task rather than only the revisit window. */
|
|
60
|
+
isFullSync: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Tasks outside this run's revisit window that were NEVER synced to this
|
|
63
|
+
* hub: an ordinary incremental sync does not look that far back for new
|
|
64
|
+
* work, so only `sync all` (or `backfill`) uploads them. A task the hub
|
|
65
|
+
* already holds never appears here — if it changed it is in `toSync`.
|
|
66
|
+
* Always empty for a full sync. Surfaced by `/kankaku sync status`.
|
|
67
|
+
*/
|
|
68
|
+
staleOutsideWindow: TaskView[];
|
|
69
|
+
/** Already-synced rows that changed but were left for a later run by {@link MAX_CORRECTIONS_PER_RUN}. */
|
|
70
|
+
correctionsDeferred: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_WINDOW_HOURS = 24;
|
|
74
|
+
|
|
75
|
+
/** How many already-synced, out-of-window rows one incremental run corrects at most; the rest wait for the next run (or `sync all`). */
|
|
76
|
+
export const MAX_CORRECTIONS_PER_RUN = 50;
|
|
77
|
+
|
|
78
|
+
function windowMs(hours: number): number {
|
|
79
|
+
return hours * 60 * 60 * 1000;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stable (key-sorted) JSON serialization so field order never changes a hash. */
|
|
83
|
+
function stableStringify(value: unknown): string {
|
|
84
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
85
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
86
|
+
const record = value as Record<string, unknown>;
|
|
87
|
+
const keys = Object.keys(record).sort();
|
|
88
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** FNV-1a 32-bit over a UTF-16 code-unit stream — not a security hash, just a cheap deterministic change fingerprint. */
|
|
92
|
+
function fingerprint(input: string): string {
|
|
93
|
+
let hash = 0x811c9dc5;
|
|
94
|
+
for (let i = 0; i < input.length; i++) {
|
|
95
|
+
hash ^= input.charCodeAt(i);
|
|
96
|
+
hash = Math.imul(hash, 0x01000193);
|
|
97
|
+
}
|
|
98
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Content hash of everything a re-sync could change: the measurement
|
|
103
|
+
* fields (never the assignment — a reassignment made in the web is never
|
|
104
|
+
* visible locally, and must never trigger a resync on its own). A task
|
|
105
|
+
* whose hash matches the last stored one is unchanged and can be skipped
|
|
106
|
+
* without a request. Includes the derived measurement-quality fields
|
|
107
|
+
* (`domain/hub-entry.ts`) too, not just the raw numbers they are computed
|
|
108
|
+
* from: a background subagent that joins *after* this task was first
|
|
109
|
+
* synced can turn `cost_quality`/`subagent_linkage` from `"unknown"`/
|
|
110
|
+
* `"unlinked"` into a better answer without any of the other numeric
|
|
111
|
+
* fields necessarily changing (e.g. a joined child with no cost of its own
|
|
112
|
+
* still flips `subagent_linkage`) — that must still trigger a resync.
|
|
113
|
+
* `waiting_quality` is a true constant (`domain/hub-entry.ts`'s
|
|
114
|
+
* `computeWaitingQuality`) and is deliberately left out: it can never
|
|
115
|
+
* change between two evaluations of the same task. `sessionDir` is also a
|
|
116
|
+
* measurement-style field (`domain/hub-entry.ts`'s `session_dir`, sent on
|
|
117
|
+
* both create and update) — its own change, e.g. a resume that switches to
|
|
118
|
+
* a non-default session directory, must trigger a resync on its own even
|
|
119
|
+
* when nothing else changed.
|
|
120
|
+
*/
|
|
121
|
+
export function computeTaskContentHash(task: TaskView): string {
|
|
122
|
+
return fingerprint(
|
|
123
|
+
stableStringify({
|
|
124
|
+
endedAt: task.endedAt,
|
|
125
|
+
status: task.status,
|
|
126
|
+
wallMs: task.wallMs,
|
|
127
|
+
waitingMs: task.waitingMs,
|
|
128
|
+
workMs: task.workMs,
|
|
129
|
+
subagentCount: task.subagents.length,
|
|
130
|
+
usage: task.usage,
|
|
131
|
+
segments: task.segments,
|
|
132
|
+
costQuality: computeCostQuality(task),
|
|
133
|
+
subagentLinkage: computeSubagentLinkage(task),
|
|
134
|
+
sessionDir: task.sessionDir,
|
|
135
|
+
// `undefined` is dropped by the serialiser, so a task that never knew
|
|
136
|
+
// its reasoning effort keeps the hash it had before this field existed.
|
|
137
|
+
thinkingLevel: task.orchestrator.thinkingLevel,
|
|
138
|
+
// A task gaining a who-measured identity
|
|
139
|
+
// (domain/hub-entry.ts#resolveTaskIdentity) must resync, since it
|
|
140
|
+
// changes which agent/plugin the row is attributed to; the version
|
|
141
|
+
// fields are left out on purpose so a version-only bump alone does not
|
|
142
|
+
// force a resync. The keys are added CONDITIONALLY: `stableStringify`
|
|
143
|
+
// renders an `undefined` value as `"agent":undefined`, so an
|
|
144
|
+
// unconditional key would change every legacy task's hash and force a
|
|
145
|
+
// full resync right after upgrading (see the golden-hash test).
|
|
146
|
+
...(task.orchestrator.agent !== undefined ? { agent: task.orchestrator.agent } : {}),
|
|
147
|
+
...(task.orchestrator.plugin !== undefined ? { plugin: task.orchestrator.plugin } : {}),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decide which tasks need a request this run.
|
|
154
|
+
*
|
|
155
|
+
* Eligibility: every task, when there is no state yet, the state's
|
|
156
|
+
* `target` differs from the configured hub URL, or `options.full` is set
|
|
157
|
+
* (a full sync); otherwise only tasks with `endedAt` after
|
|
158
|
+
* `syncedThrough - window`. Within the eligible set, a task is only
|
|
159
|
+
* included in `toSync` when its current content hash differs from the one
|
|
160
|
+
* stored in `state.hashes` (absent, i.e. never synced, always counts as
|
|
161
|
+
* different).
|
|
162
|
+
*/
|
|
163
|
+
export function planSync(tasks: TaskView[], state: SyncState | undefined, options: SyncPlanOptions): SyncPlan {
|
|
164
|
+
const windowHours = options.windowHours ?? DEFAULT_WINDOW_HOURS;
|
|
165
|
+
const isFullSync = options.full === true || state === undefined || state.target !== options.target;
|
|
166
|
+
|
|
167
|
+
const sorted = [...tasks].sort((a, b) => Date.parse(a.endedAt) - Date.parse(b.endedAt));
|
|
168
|
+
|
|
169
|
+
let eligible: TaskView[];
|
|
170
|
+
let outsideWindow: TaskView[];
|
|
171
|
+
if (isFullSync) {
|
|
172
|
+
eligible = sorted;
|
|
173
|
+
outsideWindow = [];
|
|
174
|
+
} else {
|
|
175
|
+
const syncedThroughMs = state.syncedThrough ? Date.parse(state.syncedThrough) : Number.NEGATIVE_INFINITY;
|
|
176
|
+
const cutoff = syncedThroughMs - windowMs(windowHours);
|
|
177
|
+
eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
|
|
178
|
+
outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A state written for another hub contributes nothing, not even its
|
|
182
|
+
// hashes: the new hub has none of these rows, so a task that matched
|
|
183
|
+
// the OLD hub's hash must still be pushed. `isFullSync` alone only
|
|
184
|
+
// widened the window; without this gate every task looked "unchanged".
|
|
185
|
+
const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
|
|
186
|
+
const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
|
|
187
|
+
// A row the hub ALREADY holds is corrected wherever it sits: the window
|
|
188
|
+
// bounds how far back NEW work is looked for, never whether a known row
|
|
189
|
+
// may go stale. A child can move between tasks (task-view.ts's rescue
|
|
190
|
+
// join, or a crash-recovered parent appearing later), so a task can
|
|
191
|
+
// SHRINK — skipping it would leave its old, larger cost on the hub next
|
|
192
|
+
// to the row the money moved to.
|
|
193
|
+
// Newest first and capped: anything that invalidates many stored hashes at
|
|
194
|
+
// once (a new field in the content hash, a damaged state file) must drain
|
|
195
|
+
// over several runs, never as one burst from an automatic sync. New work
|
|
196
|
+
// goes FIRST and corrections last, so a correction the hub keeps failing
|
|
197
|
+
// (the runner stops at the first transport error) can never hold newer
|
|
198
|
+
// rows back; the runner's watermark is a max, so the order is safe for it.
|
|
199
|
+
const allCorrections = outsideWindow.filter((task) => hashes[task.id] !== undefined && changed(task)).reverse();
|
|
200
|
+
const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
|
|
201
|
+
const correctionsDeferred = allCorrections.length - knownAndChanged.length;
|
|
202
|
+
const toSync = [...eligible.filter(changed), ...knownAndChanged];
|
|
203
|
+
// R3: cheap, pure visibility into a task this incremental run's window
|
|
204
|
+
// will not look at and that this hub has NEVER received (no stored hash)
|
|
205
|
+
// — see SyncPlan's doc comment. A known row that changed is a correction
|
|
206
|
+
// and is handled above, not reported here.
|
|
207
|
+
const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
|
|
208
|
+
|
|
209
|
+
return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Drop hash entries for task ids that no longer exist in `tasks` (which
|
|
214
|
+
* should not normally happen since `worklog.jsonl` is append-only — this
|
|
215
|
+
* is a defensive backstop, not the normal path). `sync-state.json`'s
|
|
216
|
+
* `hashes` are otherwise kept **forever** for every task this process
|
|
217
|
+
* still knows about, regardless of how far outside the revisit window its
|
|
218
|
+
* `endedAt` has fallen (G2 fix).
|
|
219
|
+
*
|
|
220
|
+
* This used to also drop a hash once its task's `endedAt` fell behind
|
|
221
|
+
* `syncedThrough - window` — but `planSync`'s `staleOutsideWindow` treats a
|
|
222
|
+
* *missing* hash exactly like "content changed" (there is no third state
|
|
223
|
+
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
224
|
+
* task older than the window look permanently changed, forever, the moment
|
|
225
|
+
* its hash was first pruned: `/kankaku sync status` would report a
|
|
226
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
227
|
+
* to ignore it (the bug this rewrite fixes).
|
|
228
|
+
*
|
|
229
|
+
* Never pruning by window instead means `hashes` grows with the total
|
|
230
|
+
* number of distinct tasks a directory has ever synced, not with time — an
|
|
231
|
+
* FNV-1a hash is 8 hex chars and a task id (a `crypto.randomUUID()`) is 36,
|
|
232
|
+
* so each retained entry costs roughly 50 bytes of JSON. Measured: 10,000
|
|
233
|
+
* entries serialize to well under 1MB (see `tests/sync-plan.test.ts`'s
|
|
234
|
+
* state-size-bound test) — even a directory with a decade of daily,
|
|
235
|
+
* multi-task-per-day history stays a small, instantly-parseable file. A
|
|
236
|
+
* coarser design (e.g. one rolling digest per closed day) would bound the
|
|
237
|
+
* file even tighter, but cannot answer "which specific task changed" —
|
|
238
|
+
* `staleOutsideWindow` needs exactly that, per-task precision, to stay
|
|
239
|
+
* useful — so it was rejected in favour of this simpler, still-cheap
|
|
240
|
+
* per-task scheme.
|
|
241
|
+
*
|
|
242
|
+
* No migration is needed for an existing `sync-state.json`: it already has
|
|
243
|
+
* exactly this shape (`Record<taskId, hash>`), just with some outside-
|
|
244
|
+
* window entries already missing from a build that pruned them. The first
|
|
245
|
+
* sync after upgrading treats each of those exactly like "never synced" —
|
|
246
|
+
* a real fact this process cannot know is false, since the old hash is
|
|
247
|
+
* genuinely gone — and reports it once via `staleOutsideWindow`; once that
|
|
248
|
+
* task's hash is recorded again (an ordinary `sync all`, or simply being
|
|
249
|
+
* observed unchanged), this function never drops it again. See
|
|
250
|
+
* `tests/sync-plan.test.ts`'s "first sync after upgrading" test.
|
|
251
|
+
*
|
|
252
|
+
* Accumulated in a `Map` and emitted via `Object.fromEntries` (never
|
|
253
|
+
* `pruned[id] = ...` on a plain object), since a task id ultimately traces
|
|
254
|
+
* back to free-text worklog content: a value like `__proto__` written to a
|
|
255
|
+
* plain object would silently no-op (the inherited accessor ignores a
|
|
256
|
+
* non-object assignment) instead of being kept as an own property.
|
|
257
|
+
*/
|
|
258
|
+
export function pruneHashes(hashes: Record<string, string>, tasks: TaskView[]): Record<string, string> {
|
|
259
|
+
const knownIds = new Set(tasks.map((task) => task.id));
|
|
260
|
+
|
|
261
|
+
const pruned = new Map<string, string>();
|
|
262
|
+
for (const [id, hash] of Object.entries(hashes)) {
|
|
263
|
+
if (knownIds.has(id)) pruned.set(id, hash);
|
|
264
|
+
}
|
|
265
|
+
return Object.fromEntries(pruned);
|
|
266
|
+
}
|