kankaku 0.7.1 → 0.8.2
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/README.md +60 -21
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -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 +21 -6
- package/dist/adapters/sync-runner.js +40 -9
- package/dist/domain/hub-entry.d.ts +26 -2
- package/dist/domain/hub-entry.js +50 -6
- package/dist/domain/sync-plan.d.ts +6 -5
- package/dist/domain/sync-plan.js +21 -7
- package/dist/domain/work-record.d.ts +17 -0
- package/dist/domain/work-record.js +5 -1
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +6 -0
- package/package.json +1 -1
- package/src/adapters/hub-actions.ts +2 -3
- package/src/adapters/kankaku-command.ts +5 -9
- package/src/adapters/pi-tracker.ts +8 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +1 -1
- package/src/adapters/sync-runner.ts +54 -15
- package/src/domain/hub-entry.ts +87 -8
- package/src/domain/sync-plan.ts +25 -10
- package/src/domain/work-record.ts +22 -1
- package/src/hub/index.ts +6 -0
|
@@ -14,7 +14,7 @@ import type { TaskView } from "./task-view.ts";
|
|
|
14
14
|
export interface SyncState {
|
|
15
15
|
/** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
|
|
16
16
|
syncedThrough?: string;
|
|
17
|
-
/** Content hash per task id (see {@link computeTaskContentHash}), pruned
|
|
17
|
+
/** 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}). */
|
|
18
18
|
hashes: Record<string, string>;
|
|
19
19
|
/** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
|
|
20
20
|
target: string;
|
|
@@ -36,7 +36,8 @@ export interface SyncState {
|
|
|
36
36
|
* `Clock`-based timestamp (ms) of the last real (non-short-circuited)
|
|
37
37
|
* automatic sync attempt, persisted so the automatic path's throttle
|
|
38
38
|
* (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
|
|
39
|
-
* within one.
|
|
39
|
+
* within one. Written by every run that reaches the sink, manual or
|
|
40
|
+
* automatic; only the automatic path READS it to throttle itself.
|
|
40
41
|
*/
|
|
41
42
|
lastRunAt?: number;
|
|
42
43
|
}
|
|
@@ -49,7 +50,7 @@ export interface SyncPlanOptions {
|
|
|
49
50
|
full?: boolean;
|
|
50
51
|
}
|
|
51
52
|
export interface SyncPlan {
|
|
52
|
-
/** Tasks whose content changed (or were never synced) and therefore need a request
|
|
53
|
+
/** 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}). */
|
|
53
54
|
toSync: TaskView[];
|
|
54
55
|
/** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
|
|
55
56
|
unchangedCount: number;
|
|
@@ -115,8 +116,8 @@ export declare function planSync(tasks: TaskView[], state: SyncState | undefined
|
|
|
115
116
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
116
117
|
* task older than the window look permanently changed, forever, the moment
|
|
117
118
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
118
|
-
* never-shrinking "changed outside the window" count that
|
|
119
|
-
*
|
|
119
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
120
|
+
* to ignore it (the bug this rewrite fixes).
|
|
120
121
|
*
|
|
121
122
|
* Never pruning by window instead means `hashes` grows with the total
|
|
122
123
|
* number of distinct tasks a directory has ever synced, not with time — an
|
package/dist/domain/sync-plan.js
CHANGED
|
@@ -71,6 +71,16 @@ export function computeTaskContentHash(task) {
|
|
|
71
71
|
// `undefined` is dropped by the serialiser, so a task that never knew
|
|
72
72
|
// its reasoning effort keeps the hash it had before this field existed.
|
|
73
73
|
thinkingLevel: task.orchestrator.thinkingLevel,
|
|
74
|
+
// A task gaining a who-measured identity
|
|
75
|
+
// (domain/hub-entry.ts#resolveTaskIdentity) must resync, since it
|
|
76
|
+
// changes which agent/plugin the row is attributed to; the version
|
|
77
|
+
// fields are left out on purpose so a version-only bump alone does not
|
|
78
|
+
// force a resync. The keys are added CONDITIONALLY: `stableStringify`
|
|
79
|
+
// renders an `undefined` value as `"agent":undefined`, so an
|
|
80
|
+
// unconditional key would change every legacy task's hash and force a
|
|
81
|
+
// full resync right after upgrading (see the golden-hash test).
|
|
82
|
+
...(task.orchestrator.agent !== undefined ? { agent: task.orchestrator.agent } : {}),
|
|
83
|
+
...(task.orchestrator.plugin !== undefined ? { plugin: task.orchestrator.plugin } : {}),
|
|
74
84
|
}));
|
|
75
85
|
}
|
|
76
86
|
/**
|
|
@@ -100,7 +110,11 @@ export function planSync(tasks, state, options) {
|
|
|
100
110
|
eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
|
|
101
111
|
outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
|
|
102
112
|
}
|
|
103
|
-
|
|
113
|
+
// A state written for another hub contributes nothing, not even its
|
|
114
|
+
// hashes: the new hub has none of these rows, so a task that matched
|
|
115
|
+
// the OLD hub's hash must still be pushed. `isFullSync` alone only
|
|
116
|
+
// widened the window; without this gate every task looked "unchanged".
|
|
117
|
+
const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
|
|
104
118
|
const changed = (task) => hashes[task.id] !== computeTaskContentHash(task);
|
|
105
119
|
// A row the hub ALREADY holds is corrected wherever it sits: the window
|
|
106
120
|
// bounds how far back NEW work is looked for, never whether a known row
|
|
@@ -118,10 +132,10 @@ export function planSync(tasks, state, options) {
|
|
|
118
132
|
const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
|
|
119
133
|
const correctionsDeferred = allCorrections.length - knownAndChanged.length;
|
|
120
134
|
const toSync = [...eligible.filter(changed), ...knownAndChanged];
|
|
121
|
-
// R3: cheap, pure visibility into a task
|
|
122
|
-
//
|
|
123
|
-
// comment.
|
|
124
|
-
//
|
|
135
|
+
// R3: cheap, pure visibility into a task this incremental run's window
|
|
136
|
+
// will not look at and that this hub has NEVER received (no stored hash)
|
|
137
|
+
// — see SyncPlan's doc comment. A known row that changed is a correction
|
|
138
|
+
// and is handled above, not reported here.
|
|
125
139
|
const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
|
|
126
140
|
return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
|
|
127
141
|
}
|
|
@@ -139,8 +153,8 @@ export function planSync(tasks, state, options) {
|
|
|
139
153
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
140
154
|
* task older than the window look permanently changed, forever, the moment
|
|
141
155
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
142
|
-
* never-shrinking "changed outside the window" count that
|
|
143
|
-
*
|
|
156
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
157
|
+
* to ignore it (the bug this rewrite fixes).
|
|
144
158
|
*
|
|
145
159
|
* Never pruning by window instead means `hashes` grows with the total
|
|
146
160
|
* number of distinct tasks a directory has ever synced, not with time — an
|
|
@@ -192,6 +192,23 @@ export interface WorkRecordMetadata {
|
|
|
192
192
|
* (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
|
|
193
193
|
*/
|
|
194
194
|
profile?: string;
|
|
195
|
+
/**
|
|
196
|
+
* Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
|
|
197
|
+
* Who measured, not who syncs — a different process (a standalone
|
|
198
|
+
* `kankaku` TUI, or a session for a different agent sharing the same
|
|
199
|
+
* `worklog.jsonl`) may later push this record to the hub, and must never
|
|
200
|
+
* overwrite this identity with its own. See kankaku-hub `docs/contract.md`
|
|
201
|
+
* "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
|
|
202
|
+
* additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
|
|
203
|
+
* it still validates.
|
|
204
|
+
*/
|
|
205
|
+
agent?: string;
|
|
206
|
+
/** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
|
|
207
|
+
agentVersion?: string;
|
|
208
|
+
/** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
|
|
209
|
+
plugin?: string;
|
|
210
|
+
/** This integration's own version, from its `package.json`, read once. */
|
|
211
|
+
pluginVersion?: string;
|
|
195
212
|
}
|
|
196
213
|
export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
197
214
|
export declare function emptyUsage(): UsageTotals;
|
|
@@ -83,5 +83,9 @@ export function isWorkRecord(value) {
|
|
|
83
83
|
(record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
|
|
84
84
|
(record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
|
|
85
85
|
(record["costObserved"] === undefined || record["costObserved"] === true) &&
|
|
86
|
-
(record["profile"] === undefined || typeof record["profile"] === "string")
|
|
86
|
+
(record["profile"] === undefined || typeof record["profile"] === "string") &&
|
|
87
|
+
(record["agent"] === undefined || typeof record["agent"] === "string") &&
|
|
88
|
+
(record["agentVersion"] === undefined || typeof record["agentVersion"] === "string") &&
|
|
89
|
+
(record["plugin"] === undefined || typeof record["plugin"] === "string") &&
|
|
90
|
+
(record["pluginVersion"] === undefined || typeof record["pluginVersion"] === "string"));
|
|
87
91
|
}
|
package/dist/hub/index.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* for which ones and why.
|
|
8
8
|
*/
|
|
9
9
|
export * from "../adapters/cached-catalog.ts";
|
|
10
|
+
export * from "../adapters/export-writer.ts";
|
|
10
11
|
export * from "../adapters/file-modes.ts";
|
|
12
|
+
export * from "../adapters/hub-actions.ts";
|
|
11
13
|
export * from "../adapters/hub-credentials.ts";
|
|
12
14
|
export * from "../adapters/jsonl-work-log.ts";
|
|
13
15
|
export * from "../adapters/kankaku-dir.ts";
|
|
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.ts";
|
|
|
15
17
|
export * from "../adapters/pocketbase-catalog.ts";
|
|
16
18
|
export * from "../adapters/pocketbase-client.ts";
|
|
17
19
|
export * from "../adapters/pocketbase-sink.ts";
|
|
20
|
+
export * from "../adapters/project-config.ts";
|
|
21
|
+
export * from "../adapters/report-data.ts";
|
|
22
|
+
export * from "../adapters/report.ts";
|
|
23
|
+
export * from "../adapters/report-views.ts";
|
|
18
24
|
export * from "../adapters/sync-runner.ts";
|
|
19
25
|
export * from "../adapters/sync-state-store.ts";
|
package/dist/hub/index.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* for which ones and why.
|
|
8
8
|
*/
|
|
9
9
|
export * from "../adapters/cached-catalog.js";
|
|
10
|
+
export * from "../adapters/export-writer.js";
|
|
10
11
|
export * from "../adapters/file-modes.js";
|
|
12
|
+
export * from "../adapters/hub-actions.js";
|
|
11
13
|
export * from "../adapters/hub-credentials.js";
|
|
12
14
|
export * from "../adapters/jsonl-work-log.js";
|
|
13
15
|
export * from "../adapters/kankaku-dir.js";
|
|
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.js";
|
|
|
15
17
|
export * from "../adapters/pocketbase-catalog.js";
|
|
16
18
|
export * from "../adapters/pocketbase-client.js";
|
|
17
19
|
export * from "../adapters/pocketbase-sink.js";
|
|
20
|
+
export * from "../adapters/project-config.js";
|
|
21
|
+
export * from "../adapters/report-data.js";
|
|
22
|
+
export * from "../adapters/report.js";
|
|
23
|
+
export * from "../adapters/report-views.js";
|
|
18
24
|
export * from "../adapters/sync-runner.js";
|
|
19
25
|
export * from "../adapters/sync-state-store.js";
|
package/package.json
CHANGED
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
* see odd/tasks/kankaku-panel.md P4.
|
|
7
7
|
*/
|
|
8
8
|
import type { CatalogSnapshot } from "../ports/catalog.ts";
|
|
9
|
-
import type {
|
|
10
|
-
import type { SyncSummary } from "./sync-runner.ts";
|
|
9
|
+
import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
14
13
|
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
15
14
|
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
16
15
|
*/
|
|
17
|
-
export function buildSyncStatusLines(status:
|
|
16
|
+
export function buildSyncStatusLines(status: SyncStatusSnapshot): string[] {
|
|
18
17
|
const { state, pending, staleOutsideWindow } = status;
|
|
19
18
|
const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
|
|
20
19
|
if (staleOutsideWindow > 0) {
|
|
@@ -7,24 +7,20 @@ import { formatWorkTargetLabel } from "../domain/work-target.ts";
|
|
|
7
7
|
import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
|
|
8
8
|
import type { SubagentProfile } from "../domain/subagent-profile.ts";
|
|
9
9
|
import type { RejectedChildEnvMarker } from "../config.ts";
|
|
10
|
-
import type { SyncState } from "../domain/sync-plan.ts";
|
|
11
10
|
import type { RegistryClassification } from "../domain/registry-health.ts";
|
|
12
11
|
import type { Catalog } from "../ports/catalog.ts";
|
|
13
12
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
14
13
|
import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
|
|
15
14
|
import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
|
|
16
|
-
import type { SyncSummary, SyncTrigger } from "./sync-runner.ts";
|
|
15
|
+
import type { SyncStatusSnapshot, SyncSummary, SyncTrigger } from "./sync-runner.ts";
|
|
17
16
|
import type { SessionClient } from "./session-client.ts";
|
|
18
17
|
import { readNonDefaultSessionDir } from "./session-dir.ts";
|
|
19
18
|
import type { SessionTarget } from "./session-target.ts";
|
|
19
|
+
import type { KankakuReportData } from "./report-data.ts";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
export type { KankakuReportData } from "./report-data.ts";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
export interface KankakuReportData {
|
|
25
|
-
title: string;
|
|
26
|
-
lines: string[];
|
|
27
|
-
}
|
|
23
|
+
const REPORT_ENTRY_TYPE = "kankaku-report";
|
|
28
24
|
|
|
29
25
|
/** Notify the user of an error through the UI, when one is available. */
|
|
30
26
|
export function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
@@ -217,7 +213,7 @@ export interface SyncCommandDeps {
|
|
|
217
213
|
* count, and (R3) how many tasks changed since their last sync but fall
|
|
218
214
|
* outside this run's revisit window — needs `sync all`. No network.
|
|
219
215
|
*/
|
|
220
|
-
status: () =>
|
|
216
|
+
status: () => SyncStatusSnapshot;
|
|
221
217
|
}
|
|
222
218
|
|
|
223
219
|
export interface KankakuCommandDeps {
|
|
@@ -496,6 +496,14 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
496
496
|
...(roleConfidence !== undefined ? { roleConfidence } : {}),
|
|
497
497
|
...(deps.orchestratorRef !== undefined ? { orchestratorRef: deps.orchestratorRef } : {}),
|
|
498
498
|
...(deps.profile !== undefined ? { profile: deps.profile } : {}),
|
|
499
|
+
// Who measured this record, not who later syncs it — see
|
|
500
|
+
// `domain/work-record.ts#WorkRecordMetadata.agent`. Always stamped for
|
|
501
|
+
// every record this process appends; the versions are the same ones
|
|
502
|
+
// `extension.ts` already resolves for the `about` screen.
|
|
503
|
+
agent: "pi",
|
|
504
|
+
...(deps.agentVersion !== undefined ? { agentVersion: deps.agentVersion } : {}),
|
|
505
|
+
plugin: "kankaku",
|
|
506
|
+
...(deps.pluginVersion !== undefined ? { pluginVersion: deps.pluginVersion } : {}),
|
|
499
507
|
};
|
|
500
508
|
}
|
|
501
509
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
|
|
3
|
+
* report view and the panel's report screen produce. Extracted out of
|
|
4
|
+
* `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
|
|
5
|
+
* be published through `kankaku/hub` without pulling in anything that
|
|
6
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
10
|
+
export interface KankakuReportData {
|
|
11
|
+
title: string;
|
|
12
|
+
lines: string[];
|
|
13
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
10
10
|
import { exportRows, toCsv, toJson } from "../domain/export.ts";
|
|
11
11
|
import type { WorkRecord } from "../domain/work-record.ts";
|
|
12
|
-
import type { KankakuReportData } from "./
|
|
12
|
+
import type { KankakuReportData } from "./report-data.ts";
|
|
13
13
|
import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
|
|
14
14
|
|
|
15
15
|
/** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
|
|
@@ -108,7 +108,11 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
108
108
|
const startedAt = deps.clock.now();
|
|
109
109
|
|
|
110
110
|
if (options.trigger !== undefined) {
|
|
111
|
-
|
|
111
|
+
// Gated like every other read below: a state written for another hub
|
|
112
|
+
// says nothing about THIS hub, so neither its `logVersion` (which
|
|
113
|
+
// would otherwise skip the new hub's very first run until the next
|
|
114
|
+
// record is appended) nor its `lastRunAt` throttle may apply.
|
|
115
|
+
const peek = stateForTarget(deps, deps.stateStore.read());
|
|
112
116
|
const currentVersion = deps.log.version?.();
|
|
113
117
|
const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
|
|
114
118
|
if (versionUnchanged && peek?.lastError === undefined) {
|
|
@@ -125,11 +129,18 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
125
129
|
try {
|
|
126
130
|
lockAcquired = deps.stateStore.tryLock();
|
|
127
131
|
if (!lockAcquired) {
|
|
128
|
-
const state = deps.stateStore.read();
|
|
132
|
+
const state = stateForTarget(deps, deps.stateStore.read());
|
|
129
133
|
return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
const state = deps.stateStore.read();
|
|
137
|
+
// A state from another hub is ignored wholesale, on EVERY path below
|
|
138
|
+
// (the summary, the error path, and the successful merge): its
|
|
139
|
+
// watermark and its hashes describe rows the configured hub does not
|
|
140
|
+
// have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
|
|
141
|
+
// still receives the raw `state`, since it applies that same gate
|
|
142
|
+
// itself and its `target` comparison is what makes the run a full one.
|
|
143
|
+
const priorState = stateForTarget(deps, state);
|
|
133
144
|
// Captured once, here, and persisted as-is below: this is the version
|
|
134
145
|
// the tasks below were actually built from, not whatever the log might
|
|
135
146
|
// become by the time an awaited push finishes.
|
|
@@ -144,10 +155,15 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
144
155
|
// WorkSink implementations are expected never to throw, but this
|
|
145
156
|
// runner must hold that guarantee even if one does.
|
|
146
157
|
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
-
const summary = emptySummary(deps.clock.now() - startedAt,
|
|
158
|
+
const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
|
|
148
159
|
summary.skipped = plan.unchangedCount;
|
|
149
160
|
summary.error = message;
|
|
150
|
-
|
|
161
|
+
// `priorState`, never `state`: a throwing sink on the first run
|
|
162
|
+
// against a NEW hub must not carry the old hub's hashes and
|
|
163
|
+
// watermark over under the new target, or the next run would
|
|
164
|
+
// skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
|
|
165
|
+
// successful path).
|
|
166
|
+
persistError(deps, priorState, message, logVersionAtRead);
|
|
151
167
|
return summary;
|
|
152
168
|
}
|
|
153
169
|
|
|
@@ -163,7 +179,7 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
163
179
|
const unassigned = new Map<string, number>();
|
|
164
180
|
let uploaded = 0;
|
|
165
181
|
let updated = 0;
|
|
166
|
-
let syncedThrough =
|
|
182
|
+
let syncedThrough = priorState?.syncedThrough;
|
|
167
183
|
let stopError: string | undefined;
|
|
168
184
|
// Whether at least one task was actually resolved (pushed or recorded
|
|
169
185
|
// as failed) this run — as opposed to the run stopping on its very
|
|
@@ -196,14 +212,17 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
196
212
|
progressed = true;
|
|
197
213
|
} else {
|
|
198
214
|
// "error": a network/timeout/5xx/auth failure. Stop here — nothing
|
|
199
|
-
// after this point in the
|
|
200
|
-
//
|
|
215
|
+
// after this point in the results is considered resolved. The
|
|
216
|
+
// results follow `plan.toSync`'s order (new work oldest-first,
|
|
217
|
+
// then corrections newest-first), and `syncedThrough` is a max
|
|
218
|
+
// over what WAS resolved, so stopping early can only leave it
|
|
219
|
+
// lower, never advance it past an unresolved task.
|
|
201
220
|
stopError = result.outcome.reason;
|
|
202
221
|
break;
|
|
203
222
|
}
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
const mergedHashes = { ...(
|
|
225
|
+
const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
|
|
207
226
|
// G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
|
|
208
227
|
// doc comment. `tasks` here is every task `buildTasks` currently knows
|
|
209
228
|
// about (the full `readAll()`, not just this run's eligible/window
|
|
@@ -239,6 +258,12 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
239
258
|
}
|
|
240
259
|
}
|
|
241
260
|
|
|
261
|
+
/** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
|
|
262
|
+
function stateForTarget(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>): ReturnType<SyncStateStore["read"]> {
|
|
263
|
+
return state !== undefined && state.target === deps.target ? state : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
|
|
242
267
|
function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>, message: string, logVersionAtRead: string | number | undefined): void {
|
|
243
268
|
deps.stateStore.write({
|
|
244
269
|
target: deps.target,
|
|
@@ -250,12 +275,31 @@ function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["re
|
|
|
250
275
|
});
|
|
251
276
|
}
|
|
252
277
|
|
|
253
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* Number of tasks pending a sync right now — computed locally, no network.
|
|
280
|
+
*
|
|
281
|
+
* @deprecated Superseded by {@link computeSyncStatus}, which returns the same
|
|
282
|
+
* `pending` count together with `state` and `staleOutsideWindow`. Nothing in
|
|
283
|
+
* kankaku calls this any more; it stays only because it is part of the
|
|
284
|
+
* published `kankaku/hub` surface. It will be removed in the next minor.
|
|
285
|
+
*/
|
|
254
286
|
export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number {
|
|
255
287
|
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
|
256
288
|
return plan.toSync.length + plan.correctionsDeferred;
|
|
257
289
|
}
|
|
258
290
|
|
|
291
|
+
/**
|
|
292
|
+
* {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
|
|
293
|
+
* `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
|
|
294
|
+
* `kankaku/hub`) can depend on this type without importing anything that
|
|
295
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
296
|
+
*/
|
|
297
|
+
export interface SyncStatusSnapshot {
|
|
298
|
+
state: SyncState | undefined;
|
|
299
|
+
pending: number;
|
|
300
|
+
staleOutsideWindow: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
259
303
|
/**
|
|
260
304
|
* `/kankaku sync status`: the persisted state, a locally-computed pending
|
|
261
305
|
* count, and (R3) how many tasks changed since their last sync but fall
|
|
@@ -263,12 +307,7 @@ export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore
|
|
|
263
307
|
* (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
|
|
264
308
|
* (PocketBase)" > "Sync" > "Limitations"). No network.
|
|
265
309
|
*/
|
|
266
|
-
export function computeSyncStatus(
|
|
267
|
-
log: WorkLog,
|
|
268
|
-
stateStore: SyncStateStore,
|
|
269
|
-
target: string,
|
|
270
|
-
windowHours?: number,
|
|
271
|
-
): { state: ReturnType<SyncStateStore["read"]>; pending: number; staleOutsideWindow: number } {
|
|
310
|
+
export function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot {
|
|
272
311
|
const state = stateStore.read();
|
|
273
312
|
const tasks = buildTasks(log.readAll());
|
|
274
313
|
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
package/src/domain/hub-entry.ts
CHANGED
|
@@ -207,10 +207,12 @@ export interface TaskEntryPayload {
|
|
|
207
207
|
legacy_client_label: string;
|
|
208
208
|
repo_project: string;
|
|
209
209
|
schema: number;
|
|
210
|
+
/** Coding agent that MEASURED this task, lowercase slug — the orchestrator record's own {@link WorkRecordMetadata.agent} when it carries one (who measured, not who syncs), else `ctx.agent` (the syncing process's own identity) as a legacy fallback. See {@link WorkRecordMetadata.agent}. */
|
|
210
211
|
agent: string;
|
|
211
212
|
agent_version?: string;
|
|
212
213
|
/** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
|
|
213
214
|
session_dir?: string;
|
|
215
|
+
/** The integration that wrote this task's records, lowercase slug — same record-wins-over-ctx rule as {@link agent}. */
|
|
214
216
|
plugin: string;
|
|
215
217
|
plugin_version?: string;
|
|
216
218
|
waiting_quality: WaitingQuality;
|
|
@@ -218,12 +220,59 @@ export interface TaskEntryPayload {
|
|
|
218
220
|
subagent_linkage: SubagentLinkage;
|
|
219
221
|
}
|
|
220
222
|
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
+
/**
|
|
224
|
+
* `TaskEntryPayload` minus the assignment fields — what an update sends. See
|
|
225
|
+
* the module docs' CRITICAL rule.
|
|
226
|
+
*
|
|
227
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are optional here (unlike
|
|
228
|
+
* on `TaskEntryPayload`, where they are always sent on create): an update for
|
|
229
|
+
* a task whose orchestrator record carries no who-measured identity (a
|
|
230
|
+
* legacy record, written before this feature existed) omits them entirely,
|
|
231
|
+
* so a re-sync by a DIFFERENT process never overwrites a row's original
|
|
232
|
+
* identity with its own. See {@link buildTaskEntryUpdatePayload}.
|
|
233
|
+
*/
|
|
234
|
+
export type TaskEntryUpdatePayload = Omit<
|
|
235
|
+
TaskEntryPayload,
|
|
236
|
+
"client" | "project" | "task" | "legacy_client_label" | "agent" | "agent_version" | "plugin" | "plugin_version"
|
|
237
|
+
> & {
|
|
238
|
+
agent?: string;
|
|
239
|
+
agent_version?: string;
|
|
240
|
+
plugin?: string;
|
|
241
|
+
plugin_version?: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
|
|
246
|
+
* for a task's payload: the orchestrator record's own fields, taken as a
|
|
247
|
+
* unit, when it carries an `agent` — a missing version on the record is
|
|
248
|
+
* omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
|
|
249
|
+
* syncing process's own), exactly as before this feature existed. See
|
|
250
|
+
* `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
|
|
251
|
+
*/
|
|
252
|
+
function resolveTaskIdentity(
|
|
253
|
+
task: TaskView,
|
|
254
|
+
ctx: HubEntryContext,
|
|
255
|
+
): { agent: string; agentVersion?: string; plugin: string; pluginVersion?: string } {
|
|
256
|
+
const orchestrator = task.orchestrator;
|
|
257
|
+
if (orchestrator.agent !== undefined) {
|
|
258
|
+
// The record's four fields are taken as a unit. A record written by an
|
|
259
|
+
// integration that stamped `agent` but not `plugin` gets the syncing
|
|
260
|
+
// context's `plugin` ONLY on create, so the row is never blank; the
|
|
261
|
+
// update payload below never resends a plugin the record does not own.
|
|
262
|
+
return {
|
|
263
|
+
agent: orchestrator.agent,
|
|
264
|
+
agentVersion: orchestrator.agentVersion,
|
|
265
|
+
plugin: orchestrator.plugin ?? ctx.plugin,
|
|
266
|
+
pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
|
|
270
|
+
}
|
|
223
271
|
|
|
224
272
|
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
225
273
|
export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload {
|
|
226
274
|
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
|
|
275
|
+
const identity = resolveTaskIdentity(task, ctx);
|
|
227
276
|
return {
|
|
228
277
|
task_id: task.id,
|
|
229
278
|
client: assignment.clientId,
|
|
@@ -253,11 +302,11 @@ export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext
|
|
|
253
302
|
legacy_client_label: assignment.legacyClientLabel,
|
|
254
303
|
repo_project: task.project,
|
|
255
304
|
schema: task.orchestrator.schema,
|
|
256
|
-
agent:
|
|
257
|
-
...(
|
|
305
|
+
agent: identity.agent,
|
|
306
|
+
...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
|
|
258
307
|
...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
|
|
259
|
-
plugin:
|
|
260
|
-
...(
|
|
308
|
+
plugin: identity.plugin,
|
|
309
|
+
...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
|
|
261
310
|
waiting_quality: computeWaitingQuality(),
|
|
262
311
|
cost_quality: computeCostQuality(task),
|
|
263
312
|
subagent_linkage: computeSubagentLinkage(task),
|
|
@@ -269,10 +318,40 @@ export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext
|
|
|
269
318
|
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
270
319
|
* so a re-sync can never undo a reassignment made in the web. See the
|
|
271
320
|
* module docs' CRITICAL rule.
|
|
321
|
+
*
|
|
322
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
|
|
323
|
+
* the orchestrator record itself carries a who-measured `agent` — otherwise
|
|
324
|
+
* they are OMITTED entirely (never sent as `ctx`'s own identity), so a
|
|
325
|
+
* re-sync of a legacy record by a different process never overwrites the
|
|
326
|
+
* row's original identity in the hub. See `domain/work-record.ts`'s
|
|
327
|
+
* "Measurement rules" and {@link resolveTaskIdentity}.
|
|
272
328
|
*/
|
|
273
329
|
export function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload {
|
|
274
|
-
const {
|
|
275
|
-
|
|
330
|
+
const {
|
|
331
|
+
client: _client,
|
|
332
|
+
project: _project,
|
|
333
|
+
task: _task,
|
|
334
|
+
legacy_client_label: _legacy,
|
|
335
|
+
agent,
|
|
336
|
+
agent_version,
|
|
337
|
+
plugin,
|
|
338
|
+
plugin_version,
|
|
339
|
+
...rest
|
|
340
|
+
} = buildTaskEntryCreatePayload(task, ctx);
|
|
341
|
+
|
|
342
|
+
if (task.orchestrator.agent === undefined) return rest;
|
|
343
|
+
|
|
344
|
+
// `plugin`/`plugin_version` are resent only when the record itself carries
|
|
345
|
+
// them; a record with `agent` but no `plugin` must never have the syncing
|
|
346
|
+
// process's plugin written over the row's original one.
|
|
347
|
+
const recordHasPlugin = task.orchestrator.plugin !== undefined;
|
|
348
|
+
return {
|
|
349
|
+
...rest,
|
|
350
|
+
agent,
|
|
351
|
+
...(agent_version !== undefined ? { agent_version } : {}),
|
|
352
|
+
...(recordHasPlugin ? { plugin } : {}),
|
|
353
|
+
...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
|
|
354
|
+
};
|
|
276
355
|
}
|
|
277
356
|
|
|
278
357
|
/** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
|
package/src/domain/sync-plan.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { TaskView } from "./task-view.ts";
|
|
|
17
17
|
export interface SyncState {
|
|
18
18
|
/** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
|
|
19
19
|
syncedThrough?: string;
|
|
20
|
-
/** Content hash per task id (see {@link computeTaskContentHash}), pruned
|
|
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
21
|
hashes: Record<string, string>;
|
|
22
22
|
/** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
|
|
23
23
|
target: string;
|
|
@@ -36,7 +36,8 @@ export interface SyncState {
|
|
|
36
36
|
* `Clock`-based timestamp (ms) of the last real (non-short-circuited)
|
|
37
37
|
* automatic sync attempt, persisted so the automatic path's throttle
|
|
38
38
|
* (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
|
|
39
|
-
* within one.
|
|
39
|
+
* within one. Written by every run that reaches the sink, manual or
|
|
40
|
+
* automatic; only the automatic path READS it to throttle itself.
|
|
40
41
|
*/
|
|
41
42
|
lastRunAt?: number;
|
|
42
43
|
}
|
|
@@ -51,7 +52,7 @@ export interface SyncPlanOptions {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface SyncPlan {
|
|
54
|
-
/** Tasks whose content changed (or were never synced) and therefore need a request
|
|
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}). */
|
|
55
56
|
toSync: TaskView[];
|
|
56
57
|
/** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
|
|
57
58
|
unchangedCount: number;
|
|
@@ -134,6 +135,16 @@ export function computeTaskContentHash(task: TaskView): string {
|
|
|
134
135
|
// `undefined` is dropped by the serialiser, so a task that never knew
|
|
135
136
|
// its reasoning effort keeps the hash it had before this field existed.
|
|
136
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 } : {}),
|
|
137
148
|
}),
|
|
138
149
|
);
|
|
139
150
|
}
|
|
@@ -167,7 +178,11 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
167
178
|
outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
|
-
|
|
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 : {};
|
|
171
186
|
const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
|
|
172
187
|
// A row the hub ALREADY holds is corrected wherever it sits: the window
|
|
173
188
|
// bounds how far back NEW work is looked for, never whether a known row
|
|
@@ -185,10 +200,10 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
185
200
|
const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
|
|
186
201
|
const correctionsDeferred = allCorrections.length - knownAndChanged.length;
|
|
187
202
|
const toSync = [...eligible.filter(changed), ...knownAndChanged];
|
|
188
|
-
// R3: cheap, pure visibility into a task
|
|
189
|
-
//
|
|
190
|
-
// comment.
|
|
191
|
-
//
|
|
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.
|
|
192
207
|
const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
|
|
193
208
|
|
|
194
209
|
return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
|
|
@@ -208,8 +223,8 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
208
223
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
209
224
|
* task older than the window look permanently changed, forever, the moment
|
|
210
225
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
211
|
-
* never-shrinking "changed outside the window" count that
|
|
212
|
-
*
|
|
226
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
227
|
+
* to ignore it (the bug this rewrite fixes).
|
|
213
228
|
*
|
|
214
229
|
* Never pruning by window instead means `hashes` grows with the total
|
|
215
230
|
* number of distinct tasks a directory has ever synced, not with time — an
|