kankaku 0.5.0 → 0.5.1
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 +33 -14
- package/package.json +1 -1
- package/src/adapters/session-target.ts +86 -24
package/README.md
CHANGED
|
@@ -804,7 +804,9 @@ On `session_start`, for the orchestrator role with a UI available:
|
|
|
804
804
|
sorted by name, plus "— skip —"), then for the project (active projects
|
|
805
805
|
of that client, plus "(no project)" and "— skip —"). Declining at either
|
|
806
806
|
step — "— skip —" or dismissing the dialog — cancels the whole pick and
|
|
807
|
-
is remembered for the session.
|
|
807
|
+
is remembered for the session. The picker shows the freshly refreshed
|
|
808
|
+
catalog when the hub answered within the deadline described in "Caching
|
|
809
|
+
and offline behaviour" below; otherwise it falls back to the cache.
|
|
808
810
|
|
|
809
811
|
After a pick, kankaku asks whether to remember it for this repository; a
|
|
810
812
|
"yes" merges `clientId`/`projectId` into `<KANKAKU_DIR>/config.json`.
|
|
@@ -826,19 +828,36 @@ a project) in place of the legacy client label, both idle and during a run.
|
|
|
826
828
|
### Caching and offline behaviour
|
|
827
829
|
|
|
828
830
|
The catalog (clients/projects) is cached machine-wide at
|
|
829
|
-
`~/.kankaku/catalog.json
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
831
|
+
`~/.kankaku/catalog.json`. On `session_start`, for the orchestrator role
|
|
832
|
+
with a UI available, kankaku always starts a background refresh when a
|
|
833
|
+
cache already exists — regardless of the cache's age — so a client or
|
|
834
|
+
project created in the hub minutes ago shows up without waiting for a TTL
|
|
835
|
+
to expire (the 6-hour TTL and `isStale()` still exist and still gate other
|
|
836
|
+
callers, but session start no longer depends on them). If the target
|
|
837
|
+
resolves silently from the project config file or `repo_paths` against
|
|
838
|
+
the cached snapshot, `ensurePicked` returns immediately without waiting
|
|
839
|
+
for that refresh at all; it keeps running in the background and
|
|
840
|
+
`catalog.read()` reflects it once it lands, exactly as before. Only when
|
|
841
|
+
the picker is actually about to be shown does kankaku wait for the
|
|
842
|
+
in-flight refresh, bounded by a short deadline (1.5s by default,
|
|
843
|
+
`pickerRefreshDeadlineMs`): if the hub answers in time, the picker offers
|
|
844
|
+
the fresh clients/projects; otherwise (or if the refresh fails) it falls
|
|
845
|
+
back to the cached snapshot silently, and the refresh keeps running
|
|
846
|
+
in the background rather than being aborted. `/kankaku target pick` (the
|
|
847
|
+
explicit re-pick command) follows the same wait-then-fall-back rule. When
|
|
848
|
+
there is no cache at all, one refresh is still awaited (bounded by the hub
|
|
849
|
+
client's own request timeout, 3s by default) before falling back — this
|
|
850
|
+
path is unchanged. If the hub is unreachable and there is no cache,
|
|
851
|
+
kankaku notifies once (`kankaku: hub unreachable, using local labels`) and
|
|
852
|
+
continues exactly as it would without a hub configured; a background
|
|
853
|
+
refresh that merely fails once a cache already exists is silent, with no
|
|
854
|
+
notification. `/kankaku catalog refresh` still forces a refresh on demand
|
|
855
|
+
independently of any of this. The cache file is always written owner-only
|
|
856
|
+
(`0600`); if kankaku is the first thing to ever create `~/.kankaku` itself
|
|
857
|
+
(no project has put its own `.kankaku` there), the directory is created
|
|
858
|
+
owner-only (`0700`) too — but an already-existing `~/.kankaku` is never
|
|
859
|
+
chmod'd, since it may be a project's own kankaku directory (see "The
|
|
860
|
+
registry" below for the same rule applied to `run/`).
|
|
842
861
|
|
|
843
862
|
### Privacy (catalog)
|
|
844
863
|
|
package/package.json
CHANGED
|
@@ -32,12 +32,21 @@ export interface SessionTargetDeps {
|
|
|
32
32
|
* per-request. Defaults to 5000.
|
|
33
33
|
*/
|
|
34
34
|
firstFetchDeadlineMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Deadline, in ms, for awaiting an already-in-flight background refresh
|
|
37
|
+
* before showing the picker with the cached snapshot instead — see
|
|
38
|
+
* {@link getSnapshotForPicker}. The refresh itself is never aborted at
|
|
39
|
+
* this deadline; it keeps running, and a later `catalog.read()` call
|
|
40
|
+
* (e.g. the next run) sees its result once it lands. Defaults to 1500.
|
|
41
|
+
*/
|
|
42
|
+
pickerRefreshDeadlineMs?: number;
|
|
35
43
|
/** Injectable for tests; defaults to the global timer functions. */
|
|
36
44
|
setTimeout?: (handler: () => void, ms: number) => NodeJS.Timeout;
|
|
37
45
|
clearTimeout?: (timer: NodeJS.Timeout) => void;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const DEFAULT_FIRST_FETCH_DEADLINE_MS = 5000;
|
|
49
|
+
const DEFAULT_PICKER_REFRESH_DEADLINE_MS = 1500;
|
|
41
50
|
|
|
42
51
|
export interface SessionTarget {
|
|
43
52
|
/** Restore the session-level target (or its remembered "skipped" state) from the last `kankaku-target` entry. */
|
|
@@ -86,6 +95,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
86
95
|
const scheduleTimeout = deps.setTimeout ?? setTimeout;
|
|
87
96
|
const cancelTimeout = deps.clearTimeout ?? clearTimeout;
|
|
88
97
|
const firstFetchDeadlineMs = deps.firstFetchDeadlineMs ?? DEFAULT_FIRST_FETCH_DEADLINE_MS;
|
|
98
|
+
const pickerRefreshDeadlineMs = deps.pickerRefreshDeadlineMs ?? DEFAULT_PICKER_REFRESH_DEADLINE_MS;
|
|
89
99
|
|
|
90
100
|
/** Session-level override, restored on `session_start` or set by an explicit pick/skip/legacy command. */
|
|
91
101
|
let sessionOverride: WorkTargetSessionOverride;
|
|
@@ -166,30 +176,18 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* *overall* deadline ({@link SessionTargetDeps.firstFetchDeadlineMs},
|
|
175
|
-
* default 5000ms) — not merely the hub client's own per-request timeout,
|
|
176
|
-
* which alone does not bound the whole sequence of a lazy auth,
|
|
177
|
-
* pagination, and a possible 401 retry. The deadline is enforced with an
|
|
179
|
+
* The no-cache-at-all path: one awaited refresh bounded by an *overall*
|
|
180
|
+
* deadline ({@link SessionTargetDeps.firstFetchDeadlineMs}, default
|
|
181
|
+
* 5000ms) — not merely the hub client's own per-request timeout, which
|
|
182
|
+
* alone does not bound the whole sequence of a lazy auth, pagination,
|
|
183
|
+
* and a possible 401 retry. The deadline is enforced with an
|
|
178
184
|
* `AbortSignal` composed, per request, with that request's own
|
|
179
185
|
* per-request timeout (see `pocketbase-client.ts#rawFetch`). Notifies
|
|
180
186
|
* "hub unreachable" at most once when no snapshot is available at all,
|
|
181
187
|
* whether because the hub failed outright or because the deadline fired
|
|
182
188
|
* first — both are treated identically.
|
|
183
189
|
*/
|
|
184
|
-
async function
|
|
185
|
-
const cached = deps.catalog.read();
|
|
186
|
-
if (cached) {
|
|
187
|
-
if (deps.catalog.isStale()) {
|
|
188
|
-
void deps.catalog.refresh();
|
|
189
|
-
}
|
|
190
|
-
return cached;
|
|
191
|
-
}
|
|
192
|
-
|
|
190
|
+
async function awaitFirstFetch(ctx: ExtensionContext): Promise<CatalogSnapshot | undefined> {
|
|
193
191
|
const controller = new AbortController();
|
|
194
192
|
const timer = scheduleTimeout(() => controller.abort(), firstFetchDeadlineMs);
|
|
195
193
|
let fresh: CatalogSnapshot | undefined;
|
|
@@ -204,6 +202,50 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
204
202
|
return fresh;
|
|
205
203
|
}
|
|
206
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Races an already-started background refresh against
|
|
207
|
+
* {@link SessionTargetDeps.pickerRefreshDeadlineMs} (default 1500ms): if
|
|
208
|
+
* the refresh lands in time (and did not fail — `refresh()` resolving
|
|
209
|
+
* `undefined` falls back exactly like a deadline miss, with no
|
|
210
|
+
* notification), the picker shows the fresh snapshot; otherwise it
|
|
211
|
+
* shows `cached`. The refresh is never aborted here — it keeps running
|
|
212
|
+
* in the background, and `catalog.read()` reflects it once it resolves,
|
|
213
|
+
* exactly as it would without a picker in the way.
|
|
214
|
+
*/
|
|
215
|
+
function awaitPickerRefresh(
|
|
216
|
+
refreshPromise: Promise<CatalogSnapshot | undefined>,
|
|
217
|
+
cached: CatalogSnapshot,
|
|
218
|
+
): Promise<CatalogSnapshot> {
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
let settled = false;
|
|
221
|
+
const timer = scheduleTimeout(() => {
|
|
222
|
+
if (settled) return;
|
|
223
|
+
settled = true;
|
|
224
|
+
resolve(cached);
|
|
225
|
+
}, pickerRefreshDeadlineMs);
|
|
226
|
+
void refreshPromise.then((fresh) => {
|
|
227
|
+
if (settled) return;
|
|
228
|
+
settled = true;
|
|
229
|
+
cancelTimeout(timer);
|
|
230
|
+
resolve(fresh ?? cached);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Resolves silently (project config / `repoPaths`) against `snapshot`, or shows the picker with it. */
|
|
236
|
+
async function resolveOrShowPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
|
|
237
|
+
const projectIds = deps.resolveProjectConfigIds();
|
|
238
|
+
const resolved = resolveWorkTarget({
|
|
239
|
+
project: projectIds,
|
|
240
|
+
cwd: cwd(),
|
|
241
|
+
clients: snapshot.clients,
|
|
242
|
+
projects: snapshot.projects,
|
|
243
|
+
});
|
|
244
|
+
if (resolved) return;
|
|
245
|
+
|
|
246
|
+
await runPicker(pi, ctx, snapshot);
|
|
247
|
+
}
|
|
248
|
+
|
|
207
249
|
async function runPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
|
|
208
250
|
const result = await pickTarget(ctx, snapshot);
|
|
209
251
|
|
|
@@ -227,24 +269,44 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
227
269
|
if (deps.role !== "orchestrator" || !ctx.hasUI) return;
|
|
228
270
|
if (sessionOverride !== undefined) return;
|
|
229
271
|
|
|
230
|
-
const
|
|
231
|
-
if (!
|
|
272
|
+
const cached = deps.catalog.read();
|
|
273
|
+
if (!cached) {
|
|
274
|
+
const fresh = await awaitFirstFetch(ctx);
|
|
275
|
+
if (!fresh) return;
|
|
276
|
+
await resolveOrShowPicker(pi, ctx, fresh);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Always start a refresh, regardless of staleness (the owner hit a
|
|
281
|
+
// clients/projects change that a merely-stale-TTL check missed). Silent
|
|
282
|
+
// resolution below returns without ever awaiting it; the refresh keeps
|
|
283
|
+
// running and `catalog.read()` reflects it once it lands.
|
|
284
|
+
const refreshPromise = deps.catalog.refresh();
|
|
232
285
|
|
|
233
286
|
const projectIds = deps.resolveProjectConfigIds();
|
|
234
287
|
const resolved = resolveWorkTarget({
|
|
235
288
|
project: projectIds,
|
|
236
289
|
cwd: cwd(),
|
|
237
|
-
clients:
|
|
238
|
-
projects:
|
|
290
|
+
clients: cached.clients,
|
|
291
|
+
projects: cached.projects,
|
|
239
292
|
});
|
|
240
293
|
if (resolved) return;
|
|
241
294
|
|
|
295
|
+
const snapshot = await awaitPickerRefresh(refreshPromise, cached);
|
|
242
296
|
await runPicker(pi, ctx, snapshot);
|
|
243
297
|
}
|
|
244
298
|
|
|
245
299
|
async function pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
300
|
+
const cached = deps.catalog.read();
|
|
301
|
+
if (!cached) {
|
|
302
|
+
const fresh = await awaitFirstFetch(ctx);
|
|
303
|
+
if (!fresh) return;
|
|
304
|
+
await runPicker(pi, ctx, fresh);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const refreshPromise = deps.catalog.refresh();
|
|
309
|
+
const snapshot = await awaitPickerRefresh(refreshPromise, cached);
|
|
248
310
|
await runPicker(pi, ctx, snapshot);
|
|
249
311
|
}
|
|
250
312
|
|