kankaku 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -19
- package/dist/adapters/cached-catalog.d.ts +36 -0
- package/dist/adapters/cached-catalog.js +111 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -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 +14 -0
- package/dist/adapters/pocketbase-catalog.js +39 -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 +52 -0
- package/dist/adapters/pocketbase-sink.js +180 -0
- package/dist/adapters/sync-runner.d.ts +99 -0
- package/dist/adapters/sync-runner.js +242 -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 +201 -0
- package/dist/domain/hub-entry.js +211 -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 +150 -0
- package/dist/domain/sync-plan.js +182 -0
- package/dist/domain/task-view.d.ts +113 -0
- package/dist/domain/task-view.js +426 -0
- package/dist/domain/work-record.d.ts +201 -0
- package/dist/domain/work-record.js +69 -0
- package/dist/domain/work-target.d.ts +72 -0
- package/dist/domain/work-target.js +127 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +19 -0
- package/dist/hub/index.js +19 -0
- package/dist/ports/catalog.d.ts +29 -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 +20 -2
- package/src/adapters/session-target.ts +86 -24
- package/src/domain/index.ts +19 -0
- package/src/hub/index.ts +19 -0
- package/src/ports/index.ts +11 -0
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
const peek = deps.stateStore.read();
|
|
64
|
+
const currentVersion = deps.log.version?.();
|
|
65
|
+
const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
|
|
66
|
+
if (versionUnchanged && peek?.lastError === undefined) {
|
|
67
|
+
return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
|
|
68
|
+
}
|
|
69
|
+
const minIntervalMs = deps.minAutoIntervalMs ?? DEFAULT_MIN_AUTO_INTERVAL_MS;
|
|
70
|
+
if (isThrottled(peek, options.trigger, deps.clock.now(), minIntervalMs)) {
|
|
71
|
+
return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let lockAcquired = false;
|
|
75
|
+
try {
|
|
76
|
+
lockAcquired = deps.stateStore.tryLock();
|
|
77
|
+
if (!lockAcquired) {
|
|
78
|
+
const state = deps.stateStore.read();
|
|
79
|
+
return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
|
|
80
|
+
}
|
|
81
|
+
const state = deps.stateStore.read();
|
|
82
|
+
// Captured once, here, and persisted as-is below: this is the version
|
|
83
|
+
// the tasks below were actually built from, not whatever the log might
|
|
84
|
+
// become by the time an awaited push finishes.
|
|
85
|
+
const logVersionAtRead = deps.log.version?.();
|
|
86
|
+
const tasks = buildTasks(deps.log.readAll());
|
|
87
|
+
const plan = planSync(tasks, state, { target: deps.target, ...(deps.windowHours !== undefined ? { windowHours: deps.windowHours } : {}), ...(options.full !== undefined ? { full: options.full } : {}) });
|
|
88
|
+
let results;
|
|
89
|
+
try {
|
|
90
|
+
results = await deps.sink.push(plan.toSync);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// WorkSink implementations are expected never to throw, but this
|
|
94
|
+
// runner must hold that guarantee even if one does.
|
|
95
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
96
|
+
const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
|
|
97
|
+
summary.skipped = plan.unchangedCount;
|
|
98
|
+
summary.error = message;
|
|
99
|
+
persistError(deps, state, message, logVersionAtRead);
|
|
100
|
+
return summary;
|
|
101
|
+
}
|
|
102
|
+
const byId = new Map(plan.toSync.map((task) => [task.id, task]));
|
|
103
|
+
// Both are keyed by content that ultimately traces back to free-text
|
|
104
|
+
// worklog/legacy-client data (task ids, legacy client labels): built in
|
|
105
|
+
// a `Map` and emitted via `Object.fromEntries` below (never
|
|
106
|
+
// `newHashes[task.id] = ...` on a plain object), so a value like
|
|
107
|
+
// `__proto__` or `constructor` becomes a normal own entry instead of
|
|
108
|
+
// silently colliding with an inherited `Object.prototype` property.
|
|
109
|
+
const newHashes = new Map();
|
|
110
|
+
const failed = [];
|
|
111
|
+
const unassigned = new Map();
|
|
112
|
+
let uploaded = 0;
|
|
113
|
+
let updated = 0;
|
|
114
|
+
let syncedThrough = state?.syncedThrough;
|
|
115
|
+
let stopError;
|
|
116
|
+
// Whether at least one task was actually resolved (pushed or recorded
|
|
117
|
+
// as failed) this run — as opposed to the run stopping on its very
|
|
118
|
+
// first attempt. Guards `target` below: a run against a new/unreachable
|
|
119
|
+
// target that resolves nothing must not overwrite the state's `target`,
|
|
120
|
+
// or a later sync against the *real* target would wrongly see it as
|
|
121
|
+
// unchanged and skip the full re-evaluation it needs.
|
|
122
|
+
let progressed = false;
|
|
123
|
+
for (const result of results) {
|
|
124
|
+
const task = byId.get(result.taskId);
|
|
125
|
+
if (!task)
|
|
126
|
+
continue; // defensive: a WorkSink implementation misbehaving should not crash the runner.
|
|
127
|
+
if (result.outcome.kind === "created" || result.outcome.kind === "updated") {
|
|
128
|
+
if (result.outcome.kind === "created")
|
|
129
|
+
uploaded += 1;
|
|
130
|
+
else
|
|
131
|
+
updated += 1;
|
|
132
|
+
newHashes.set(task.id, computeTaskContentHash(task));
|
|
133
|
+
syncedThrough = laterIso(syncedThrough, task.endedAt);
|
|
134
|
+
progressed = true;
|
|
135
|
+
if (result.outcome.unassigned) {
|
|
136
|
+
const label = result.outcome.legacyLabel || NO_LABEL;
|
|
137
|
+
unassigned.set(label, (unassigned.get(label) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (result.outcome.kind === "failed") {
|
|
141
|
+
// Recorded and skipped, not retried forever: stamp its hash too so
|
|
142
|
+
// an unchanged, permanently-invalid task is not resent every run.
|
|
143
|
+
failed.push({ id: task.id, reason: result.outcome.reason });
|
|
144
|
+
newHashes.set(task.id, computeTaskContentHash(task));
|
|
145
|
+
syncedThrough = laterIso(syncedThrough, task.endedAt);
|
|
146
|
+
progressed = true;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// "error": a network/timeout/5xx/auth failure. Stop here — nothing
|
|
150
|
+
// after this point in the (chronologically sorted) results is
|
|
151
|
+
// considered resolved, so syncedThrough does not advance past it.
|
|
152
|
+
stopError = result.outcome.reason;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
|
|
157
|
+
// G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
|
|
158
|
+
// doc comment. `tasks` here is every task `buildTasks` currently knows
|
|
159
|
+
// about (the full `readAll()`, not just this run's eligible/window
|
|
160
|
+
// subset), so a hash is only ever dropped for a task id that has
|
|
161
|
+
// genuinely vanished, never merely because it is old.
|
|
162
|
+
const prunedHashes = pruneHashes(mergedHashes, tasks);
|
|
163
|
+
// Only adopt deps.target as the persisted target once this run has
|
|
164
|
+
// actually resolved something against it; otherwise keep whatever
|
|
165
|
+
// target (if any) the previous state was synced against.
|
|
166
|
+
const persistedTarget = progressed || state === undefined ? deps.target : state.target;
|
|
167
|
+
deps.stateStore.write({
|
|
168
|
+
target: persistedTarget,
|
|
169
|
+
...(syncedThrough !== undefined ? { syncedThrough } : {}),
|
|
170
|
+
hashes: prunedHashes,
|
|
171
|
+
...(stopError !== undefined ? { lastError: { message: stopError, at: new Date(deps.clock.now()).toISOString() } } : {}),
|
|
172
|
+
...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
|
|
173
|
+
lastRunAt: deps.clock.now(),
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
uploaded,
|
|
177
|
+
updated,
|
|
178
|
+
skipped: plan.unchangedCount,
|
|
179
|
+
failed,
|
|
180
|
+
unassigned: Object.fromEntries(unassigned),
|
|
181
|
+
syncedThrough,
|
|
182
|
+
durationMs: deps.clock.now() - startedAt,
|
|
183
|
+
...(stopError !== undefined ? { error: stopError } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
if (lockAcquired)
|
|
188
|
+
deps.stateStore.unlock();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function persistError(deps, state, message, logVersionAtRead) {
|
|
192
|
+
deps.stateStore.write({
|
|
193
|
+
target: deps.target,
|
|
194
|
+
...(state?.syncedThrough !== undefined ? { syncedThrough: state.syncedThrough } : {}),
|
|
195
|
+
hashes: state?.hashes ?? {},
|
|
196
|
+
lastError: { message, at: new Date(deps.clock.now()).toISOString() },
|
|
197
|
+
...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
|
|
198
|
+
lastRunAt: deps.clock.now(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
|
|
202
|
+
export function pendingCount(tasks, state, target, windowHours) {
|
|
203
|
+
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
|
204
|
+
return plan.toSync.length + plan.correctionsDeferred;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `/kankaku sync status`: the persisted state, a locally-computed pending
|
|
208
|
+
* count, and (R3) how many tasks changed since their last sync but fall
|
|
209
|
+
* outside this run's revisit window — a `sync all` needed to pick them up
|
|
210
|
+
* (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
|
|
211
|
+
* (PocketBase)" > "Sync" > "Limitations"). No network.
|
|
212
|
+
*/
|
|
213
|
+
export function computeSyncStatus(log, stateStore, target, windowHours) {
|
|
214
|
+
const state = stateStore.read();
|
|
215
|
+
const tasks = buildTasks(log.readAll());
|
|
216
|
+
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
|
217
|
+
// Deferred corrections are pending too: the cap only spreads them over runs.
|
|
218
|
+
return { state, pending: plan.toSync.length + plan.correctionsDeferred, staleOutsideWindow: plan.staleOutsideWindow.length };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Wrap an async function so concurrent callers share one in-flight call
|
|
222
|
+
* instead of starting a new one each — kankaku's single-flight guard for
|
|
223
|
+
* sync: `/kankaku sync`, the `session_start` auto-sync and the
|
|
224
|
+
* `agent_settled` auto-sync all go through the same wrapped function, so
|
|
225
|
+
* only one sync is ever running at a time within this process. (The
|
|
226
|
+
* cross-process case is covered separately by `SyncStateStore`'s lock
|
|
227
|
+
* file.) A caller that arrives while one is in flight joins its result
|
|
228
|
+
* rather than queuing a fresh run — the next trigger (the next
|
|
229
|
+
* `session_start` or `agent_settled`) will pick up anything missed, since
|
|
230
|
+
* every sync also revisits the trailing window.
|
|
231
|
+
*/
|
|
232
|
+
export function singleFlight(fn) {
|
|
233
|
+
let inFlight;
|
|
234
|
+
return (...args) => {
|
|
235
|
+
if (!inFlight) {
|
|
236
|
+
inFlight = fn(...args).finally(() => {
|
|
237
|
+
inFlight = undefined;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return inFlight;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { SegmentRule } from "./domain/segment-rule.ts";
|
|
2
|
+
import type { PromptPrivacyMode } from "./domain/hub-entry.ts";
|
|
3
|
+
import type { ChildEnvMarker, SubagentProfile } from "./domain/subagent-profile.ts";
|
|
4
|
+
export interface KankakuConfig {
|
|
5
|
+
/** Directory for the work log, relative to the project cwd unless absolute. */
|
|
6
|
+
dir: string;
|
|
7
|
+
/** Tool names whose execution span counts as waiting time. */
|
|
8
|
+
interactiveTools: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Every active {@link SubagentProfile} (ADR 0020): the built-ins
|
|
11
|
+
* (gentle-pi, pi's bundled reference example, pi-subagents) plus, when
|
|
12
|
+
* `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV` are set, one
|
|
13
|
+
* additional `"configured"` profile — always additive, never replacing
|
|
14
|
+
* gentle-pi's own recognition. `SUBAGENT_TOOL` is no longer a hardcoded
|
|
15
|
+
* constant; `domain/work-tracker.ts` matches subagent tool calls against
|
|
16
|
+
* the union of every profile's `toolNames`.
|
|
17
|
+
*/
|
|
18
|
+
subagentProfiles: SubagentProfile[];
|
|
19
|
+
/** Rules that tag a tool execution's span under a named segment (e.g. `review`). */
|
|
20
|
+
segmentRules: SegmentRule[];
|
|
21
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT`. See `domain/client-label.ts`. */
|
|
22
|
+
client?: string;
|
|
23
|
+
/**
|
|
24
|
+
* C2 (CRITICAL fix): every `KANKAKU_SUBAGENT_CHILD_ENV` entry rejected by
|
|
25
|
+
* {@link validateSubagentChildEnvMarkers} — a marker name that looks like
|
|
26
|
+
* an ambient pi/shell/OS/npm environment variable, not a genuine
|
|
27
|
+
* child-only marker. Always present (empty when nothing was configured,
|
|
28
|
+
* or everything configured was accepted), so a caller never has to guard
|
|
29
|
+
* against it being `undefined`. `/kankaku doctor` and a one-time
|
|
30
|
+
* `ctx.ui.notify` are expected to surface this (see `adapters/pi-tracker.ts`).
|
|
31
|
+
*/
|
|
32
|
+
rejectedSubagentChildEnvMarkers: RejectedChildEnvMarker[];
|
|
33
|
+
}
|
|
34
|
+
/** One `KANKAKU_SUBAGENT_CHILD_ENV` marker {@link validateSubagentChildEnvMarkers} rejected, with why. */
|
|
35
|
+
export interface RejectedChildEnvMarker {
|
|
36
|
+
name: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* C2 (CRITICAL fix, item 1): validate every `KANKAKU_SUBAGENT_CHILD_ENV`
|
|
41
|
+
* marker against the denylist above, config-time. A rejected marker is
|
|
42
|
+
* never added to the `"configured"` profile — so it can never demote a
|
|
43
|
+
* user's own top-level session to `role: "subagent"` in the first place
|
|
44
|
+
* (layered with C2 items 2/3's runtime interactive guard in
|
|
45
|
+
* `config.ts#detectRole`, which still protects a marker this denylist does
|
|
46
|
+
* not happen to catch). `loadConfig` surfaces `rejected` via
|
|
47
|
+
* `KankakuConfig.rejectedSubagentChildEnvMarkers` for `/kankaku doctor` and
|
|
48
|
+
* a one-time `ctx.ui.notify`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function validateSubagentChildEnvMarkers(markers: readonly ChildEnvMarker[]): {
|
|
51
|
+
accepted: ChildEnvMarker[];
|
|
52
|
+
rejected: RejectedChildEnvMarker[];
|
|
53
|
+
};
|
|
54
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): KankakuConfig;
|
|
55
|
+
export interface RoleDetection {
|
|
56
|
+
role: "orchestrator" | "subagent";
|
|
57
|
+
/**
|
|
58
|
+
* Set only when this process could not be positively proven top-level
|
|
59
|
+
* (ADR 0022's four-state classification, applied on top of the still-
|
|
60
|
+
* binary `role`): no recognised child-env-marker matched, but a live
|
|
61
|
+
* tracked ancestor process was found via the machine-wide process
|
|
62
|
+
* registry, AND this process is not itself an interactive session (see
|
|
63
|
+
* `isInteractive` below — F3). See `domain/task-view.ts`'s
|
|
64
|
+
* `roleConfidence` handling.
|
|
65
|
+
*/
|
|
66
|
+
roleConfidence?: "uncertain";
|
|
67
|
+
/**
|
|
68
|
+
* Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
|
|
69
|
+
* marker, but was ignored because this process looked interactive (see
|
|
70
|
+
* this function's precedence doc — R1). The caller (`extension.ts`) is
|
|
71
|
+
* expected to surface this once via `ctx.ui.notify` at `session_start`
|
|
72
|
+
* and report it in `/kankaku doctor`, so the contradiction is never
|
|
73
|
+
* silent.
|
|
74
|
+
*/
|
|
75
|
+
overrideIgnoredInteractive?: true;
|
|
76
|
+
/**
|
|
77
|
+
* C2 item 2/3: set when a USER-CONFIGURED child-env marker
|
|
78
|
+
* (`KANKAKU_SUBAGENT_CHILD_ENV`, the `configuredMarkers` 5th param below)
|
|
79
|
+
* matched, but was ignored for this process's `role` because it looked
|
|
80
|
+
* interactive — a configured marker, unlike a BUILT-IN one, never demotes
|
|
81
|
+
* an interactive session (see this function's precedence doc). The
|
|
82
|
+
* caller is expected to surface this once (mirroring
|
|
83
|
+
* `overrideIgnoredInteractive`) via `ctx.ui.notify` and `/kankaku
|
|
84
|
+
* doctor`, and to escalate the wording when this process also has no
|
|
85
|
+
* tracked ancestor at all — the strongest signal the marker is genuinely
|
|
86
|
+
* ambient (C2 item 3's self-check), not a real subagent mechanism.
|
|
87
|
+
*/
|
|
88
|
+
configuredMarkerIgnoredInteractive?: true;
|
|
89
|
+
}
|
|
90
|
+
export type RoleOverride = "orchestrator" | "subagent";
|
|
91
|
+
/**
|
|
92
|
+
* `KANKAKU_ROLE`: an explicit escape hatch for a genuine session
|
|
93
|
+
* `detectRole` gets wrong (no reliable automatic signal exists for it),
|
|
94
|
+
* and for a legacy/JSONL record already written `uncertain`, which can
|
|
95
|
+
* never be rewritten after the fact (the log is append-only) but whose
|
|
96
|
+
* *next* run can be told the truth directly. It does **not** override
|
|
97
|
+
* every other signal unconditionally any more — see `detectRole`'s
|
|
98
|
+
* precedence doc (R1) for the confirmed-child-marker and interactive-
|
|
99
|
+
* session exceptions this now has. An unrecognised value (anything other
|
|
100
|
+
* than exactly `"orchestrator"` or `"subagent"`) is ignored, falling back
|
|
101
|
+
* to normal detection, rather than failing the process or guessing.
|
|
102
|
+
*/
|
|
103
|
+
export declare function readRoleOverride(env?: NodeJS.ProcessEnv): RoleOverride | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Remove `KANKAKU_ROLE` from `env` in place (R1, layer 2 — non-
|
|
106
|
+
* propagation). `KANKAKU_ROLE` decides only THIS process's role; a child
|
|
107
|
+
* this process spawns (a subagent runner, a tool shell) must never inherit
|
|
108
|
+
* it, since `process.env` is inherited by every OS child by default. Left
|
|
109
|
+
* unstripped, a user who once hit a false `uncertain` and exported
|
|
110
|
+
* `KANKAKU_ROLE=orchestrator` in a shell rc/tmux/CI environment would have
|
|
111
|
+
* every subsequent subagent see it too — layer 1's precedence fix
|
|
112
|
+
* (a confirmed child marker always wins) already neutralises that specific
|
|
113
|
+
* leak for a *recognised* subagent mechanism, but this strips it outright
|
|
114
|
+
* so it can never reach an unrecognised one, or reach an unrelated child
|
|
115
|
+
* process this one spawns for some other reason. Takes the env object as
|
|
116
|
+
* a parameter, rather than reaching for `process.env` itself, so this
|
|
117
|
+
* stays a pure function tests can exercise against a plain object without
|
|
118
|
+
* ever mutating the real environment — `extension.ts` is the one caller
|
|
119
|
+
* that passes the real `process.env`, right after reading the override.
|
|
120
|
+
*/
|
|
121
|
+
export declare function stripRoleOverride(env: NodeJS.ProcessEnv): void;
|
|
122
|
+
export declare function detectRole(env?: NodeJS.ProcessEnv, hasTrackedAncestor?: boolean, isInteractive?: boolean,
|
|
123
|
+
/** SUBAGENT-REQ-002/003: built-in profiles' own child-env markers ONLY (never the user-configured one — see `configuredMarkers` below) — generalises the single hardcoded `GENTLE_PI_AGENTS_CHILD` check without changing precedence. See `domain/subagent-profile.ts#builtinChildMarkers`. */
|
|
124
|
+
childMarkers?: readonly ChildEnvMarker[],
|
|
125
|
+
/** C2: the user-configured profile's child-env marker(s) only (`KANKAKU_SUBAGENT_CHILD_ENV`, already denylist-filtered by `loadConfig`) — a SEPARATE, weaker tier: it can confirm a subagent, but unlike `childMarkers` above, never demotes an interactive session. See `domain/subagent-profile.ts#configuredChildMarkers`. */
|
|
126
|
+
configuredMarkers?: readonly ChildEnvMarker[]): RoleDetection;
|
|
127
|
+
/** Hub (PocketBase) credentials read from the environment; any field can be absent. */
|
|
128
|
+
export interface HubEnvCredentials {
|
|
129
|
+
url?: string;
|
|
130
|
+
email?: string;
|
|
131
|
+
password?: string;
|
|
132
|
+
}
|
|
133
|
+
/** Read `KANKAKU_PB_URL`/`KANKAKU_PB_EMAIL`/`KANKAKU_PB_PASSWORD`. Empty/whitespace-only values are treated as absent. */
|
|
134
|
+
export declare function loadHubEnvCredentials(env?: NodeJS.ProcessEnv): HubEnvCredentials;
|
|
135
|
+
/** `KANKAKU_MACHINE`, or `hostname()` when unset/blank. Injected so this stays testable without touching `os.hostname`. */
|
|
136
|
+
export declare function loadMachine(env: NodeJS.ProcessEnv, hostname: () => string): string;
|
|
137
|
+
/** Hub sync (Phase 2) configuration, read from the environment. See README "Hub (PocketBase)" sync section. */
|
|
138
|
+
export interface SyncConfig {
|
|
139
|
+
/** `KANKAKU_SYNC_PROMPT`. Defaults to `"none"` — the conservative default (proposal §8). */
|
|
140
|
+
promptMode: PromptPrivacyMode;
|
|
141
|
+
/** `KANKAKU_SYNC_WINDOW_HOURS`. Defaults to 24; falls back to the default for a non-positive or non-numeric value. */
|
|
142
|
+
windowHours: number;
|
|
143
|
+
/** `KANKAKU_SYNC_RECORDS`. Defaults to enabled; `"0"` disables uploading `work_records` children. */
|
|
144
|
+
syncRecords: boolean;
|
|
145
|
+
/** `KANKAKU_SYNC_AUTO`. Defaults to enabled; `"0"` disables every automatic sync: the fire-and-forget session_start/agent_settled ones and the awaited session_shutdown one. */
|
|
146
|
+
auto: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`. How often the *automatic*
|
|
149
|
+
* (`session_start`/`agent_settled`) sync path is allowed to actually run
|
|
150
|
+
* a sync, at most. Defaults to 5; `0` disables throttling entirely. Never
|
|
151
|
+
* applies to a manual `/kankaku sync`, `sync all`, or `backfill`. See
|
|
152
|
+
* `adapters/sync-runner.ts#runSync`.
|
|
153
|
+
*/
|
|
154
|
+
minIntervalMinutes: number;
|
|
155
|
+
}
|
|
156
|
+
export declare function loadSyncConfig(env?: NodeJS.ProcessEnv): SyncConfig;
|
|
157
|
+
export type HubUrlValidation = {
|
|
158
|
+
ok: true;
|
|
159
|
+
} | {
|
|
160
|
+
ok: false;
|
|
161
|
+
reason: string;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* A hub URL must be HTTPS, unless it points at localhost/127.0.0.1/::1 (a
|
|
165
|
+
* local PocketBase instance for development). Also rejects a URL that does
|
|
166
|
+
* not parse at all.
|
|
167
|
+
*/
|
|
168
|
+
export declare function validateHubUrl(url: string): HubUrlValidation;
|