skillrepo 4.8.4 → 4.9.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
CHANGED
|
@@ -370,7 +370,7 @@ By default `skillrepo init` prompts you to install this hook. If you said no (or
|
|
|
370
370
|
|
|
371
371
|
#### Auto-refresh hooks for other agents
|
|
372
372
|
|
|
373
|
-
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library
|
|
373
|
+
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library stays current across your sessions without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
|
|
374
374
|
|
|
375
375
|
| Agent | Hook config path | Notes |
|
|
376
376
|
|-------|------------------|-------|
|
|
@@ -393,6 +393,8 @@ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents
|
|
|
393
393
|
|
|
394
394
|
**On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
|
|
395
395
|
|
|
396
|
+
**Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled (it always queries the server live), though it doesn't reset the window.
|
|
397
|
+
|
|
396
398
|
Flags:
|
|
397
399
|
|
|
398
400
|
- `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
|
package/package.json
CHANGED
package/src/commands/update.mjs
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* full contract. When this flag is absent, the
|
|
21
21
|
* command behaves as before (exit non-zero on
|
|
22
22
|
* network / auth / disk failures).
|
|
23
|
+
* Throttled (#2174): within MIN_SYNC_INTERVAL_MS of the
|
|
24
|
+
* last sync attempt this makes ZERO network calls (the
|
|
25
|
+
* hook fires every session; a bare `update` is never
|
|
26
|
+
* throttled).
|
|
23
27
|
*
|
|
24
28
|
* v4.1.0 silent mode (#1240):
|
|
25
29
|
* --silent Suppress stdout: write `{}` on success, propagate
|
|
@@ -144,6 +148,11 @@ export async function runUpdate(argv, io = {}) {
|
|
|
144
148
|
apiKey: flags.apiKey,
|
|
145
149
|
vendors,
|
|
146
150
|
global: flags.global,
|
|
151
|
+
// Throttle SessionStart syncs (#2174): this hook fires on every
|
|
152
|
+
// Claude Code session, so within MIN_SYNC_INTERVAL_MS of the last
|
|
153
|
+
// attempt runSync short-circuits with zero network calls. ONLY hook
|
|
154
|
+
// invocations pass this — a bare `skillrepo update` always syncs.
|
|
155
|
+
throttle: true,
|
|
147
156
|
io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
|
|
148
157
|
});
|
|
149
158
|
const total = summary.added + summary.updated + summary.removed;
|
|
@@ -218,6 +227,10 @@ export async function runUpdate(argv, io = {}) {
|
|
|
218
227
|
apiKey: flags.apiKey,
|
|
219
228
|
vendors,
|
|
220
229
|
global: flags.global,
|
|
230
|
+
// Throttle the cohort SessionStart hook too (#2174) — same reasoning
|
|
231
|
+
// as --session-hook: it fires every session, so skip the network
|
|
232
|
+
// within MIN_SYNC_INTERVAL_MS of the last attempt.
|
|
233
|
+
throttle: true,
|
|
221
234
|
// sync.mjs surfaces non-fatal warnings (e.g. failed to persist
|
|
222
235
|
// last-sync state) via stderr; preserve that channel so a real
|
|
223
236
|
// operator running `update --silent` from a terminal can still
|
package/src/lib/sync.mjs
CHANGED
|
@@ -36,20 +36,34 @@
|
|
|
36
36
|
* absence is meaningful (a state file with no `cliVersion` was
|
|
37
37
|
* written by a pre-#1911 CLI and triggers the one-time membership
|
|
38
38
|
* heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
|
|
39
|
+
* v3 — Adds `lastAttemptAt` (#2174): an ISO timestamp of the last
|
|
40
|
+
* network sync ATTEMPT, powering the SessionStart staleness
|
|
41
|
+
* throttle in `runSync`. Stamped on the 304 short-circuit and on
|
|
42
|
+
* every successful write; NOT stamped on a throttled skip (so a
|
|
43
|
+
* run of quick restarts can't push the clock forward and starve
|
|
44
|
+
* the sync). A v2 file (no `lastAttemptAt`) reads as "never
|
|
45
|
+
* attempted" and syncs — additive, mirroring how absent
|
|
46
|
+
* `cliVersion` was handled.
|
|
39
47
|
*
|
|
40
48
|
* Forward/backward compatibility
|
|
41
49
|
* ------------------------------
|
|
42
|
-
* • Old CLI reading a
|
|
43
|
-
*
|
|
44
|
-
* upgrade again.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
50
|
+
* • Old CLI reading a NEWER file (e.g. a v2 CLI reading v3) → unknown
|
|
51
|
+
* schemaVersion → null → full sync → writes its own older shape. The
|
|
52
|
+
* user loses the newer fields until they upgrade again. Harmless: a
|
|
53
|
+
* full re-fetch is idempotent.
|
|
54
|
+
* • New CLI reading a v1 file → in-memory migration to the current
|
|
55
|
+
* shape with the existing `etag` + `syncedAt` preserved and an EMPTY
|
|
56
|
+
* `skills` map. The next successful sync writes a current-schema file
|
|
57
|
+
* on disk. This is safe because an empty map means "we don't know
|
|
49
58
|
* per-skill state from this sync" — callers must treat absent
|
|
50
59
|
* entries as unknown, not as "missing from library." Preserving
|
|
51
60
|
* the etag also keeps the 304 short-circuit working, so an
|
|
52
61
|
* upgrade-then-no-changes path stays cheap.
|
|
62
|
+
* • New CLI reading a v2 file → accepted in place (v2 is v3 minus
|
|
63
|
+
* `lastAttemptAt`): etag/syncedAt/skills/cliVersion are preserved so
|
|
64
|
+
* the first post-upgrade sync stays a cheap 304, and the absent
|
|
65
|
+
* `lastAttemptAt` makes the throttle treat it as "never attempted"
|
|
66
|
+
* so that first sync proceeds and then stamps a v3 file.
|
|
53
67
|
* • Future unknown schemaVersion → null → full sync. Forward-compat
|
|
54
68
|
* without crashing.
|
|
55
69
|
*
|
|
@@ -58,6 +72,18 @@
|
|
|
58
72
|
* @property {number} updated - Skills overwritten on disk
|
|
59
73
|
* @property {number} removed - Tombstones applied
|
|
60
74
|
* @property {boolean} notModified - True if 304 short-circuit fired
|
|
75
|
+
* @property {boolean} [throttled] - True when the SessionStart staleness
|
|
76
|
+
* throttle skipped this run entirely — no
|
|
77
|
+
* library GET, no receipt POST (#2174).
|
|
78
|
+
* Only set on the throttled early-exit;
|
|
79
|
+
* absent (falsy) on every other path.
|
|
80
|
+
* NOTE: no production caller branches on
|
|
81
|
+
* this today (hook printers key on the
|
|
82
|
+
* zero counters) — it is the contract
|
|
83
|
+
* discriminator that lets tests tell a
|
|
84
|
+
* throttled skip from a 304, mirroring
|
|
85
|
+
* the `minSyncIntervalMs` test-seam
|
|
86
|
+
* pattern.
|
|
61
87
|
* @property {boolean | null} fullSync - True if this was a full (non-delta)
|
|
62
88
|
* sync — i.e. no prior `.last-sync` state
|
|
63
89
|
* existed so no `since` was sent. False if
|
|
@@ -73,7 +99,6 @@
|
|
|
73
99
|
* Consumers must distinguish these to
|
|
74
100
|
* render accurate user messages (see
|
|
75
101
|
* init.mjs step 7).
|
|
76
|
-
* @property {string} [syncedAt]
|
|
77
102
|
* @property {string} syncedAt - ISO timestamp of the sync: the server
|
|
78
103
|
* response `syncedAt` on a 200, or the
|
|
79
104
|
* previously-cached sync timestamp on a
|
|
@@ -97,6 +122,10 @@
|
|
|
97
122
|
* wrote this state. Absent on files written
|
|
98
123
|
* by a pre-#1911 CLI; absence triggers the
|
|
99
124
|
* one-time membership heal.
|
|
125
|
+
* @property {string} [lastAttemptAt] - v3+ (#2174). ISO timestamp of the last
|
|
126
|
+
* network sync attempt. Absent on a v2 file
|
|
127
|
+
* (or any pre-v3 writer) → the throttle
|
|
128
|
+
* treats it as "never attempted" and syncs.
|
|
100
129
|
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
101
130
|
*/
|
|
102
131
|
|
|
@@ -127,8 +156,49 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
127
156
|
*
|
|
128
157
|
* v2 (#1553): adds `skills` map keyed by `"<owner>/<name>"` →
|
|
129
158
|
* `{ version, skillMdSha256, filesSha256, syncedAt }`.
|
|
159
|
+
* v3 (#2174): adds `lastAttemptAt` — an ISO timestamp of the last
|
|
160
|
+
* network sync ATTEMPT (stamped on the 304 short-circuit and on every
|
|
161
|
+
* successful write, NOT on a throttled skip). Powers the SessionStart
|
|
162
|
+
* staleness throttle. Additive: a v2 file (no `lastAttemptAt`) reads as
|
|
163
|
+
* "never attempted" and syncs, so the bump is forward-compatible and a
|
|
164
|
+
* v2 file is accepted in place — its etag/skills are preserved (no
|
|
165
|
+
* forced re-fetch, unlike the v1→v2 migration).
|
|
130
166
|
*/
|
|
131
|
-
export const LAST_SYNC_SCHEMA_VERSION =
|
|
167
|
+
export const LAST_SYNC_SCHEMA_VERSION = 3;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Client-side SessionStart sync throttle window (#2174).
|
|
171
|
+
*
|
|
172
|
+
* A hook-triggered `skillrepo update` (the `--session-hook` and
|
|
173
|
+
* `--silent` cohort hooks) skips the network entirely — NO library GET,
|
|
174
|
+
* NO sync-receipt POST — when the last sync ATTEMPT was less than this
|
|
175
|
+
* long ago. This caps the highest-QPS uncacheable vector: the library
|
|
176
|
+
* sync fires on every editor session, and at 100k users the per-request
|
|
177
|
+
* cost (auth + rate-limit + per-account ETag query + heartbeat, then a
|
|
178
|
+
* second request for the receipt) dominates even on a 304. See #2170.
|
|
179
|
+
*
|
|
180
|
+
* SCOPE: hook invocations ONLY. A bare interactive `skillrepo update`
|
|
181
|
+
* always syncs (explicit intent), and it too stamps `lastAttemptAt`, so
|
|
182
|
+
* a manual sync resets the window for subsequent hook syncs.
|
|
183
|
+
*
|
|
184
|
+
* FRESHNESS: this bounds how stale a hook-skipped session can be to at
|
|
185
|
+
* most this window, measured from the last ACTUAL sync — NOT from a
|
|
186
|
+
* publish time, and NOT between raw session starts (a skipped session
|
|
187
|
+
* does not advance the clock, so continuous restarts can't starve the
|
|
188
|
+
* sync). Sessions spaced further apart than this window always sync.
|
|
189
|
+
* The SAME bound applies to LOCAL damage, not just server-side changes:
|
|
190
|
+
* a throttled skip returns before the placement-presence check below,
|
|
191
|
+
* so a skill directory the user deleted/corrupted on disk is repaired
|
|
192
|
+
* on the next non-throttled sync — up to this window later, or
|
|
193
|
+
* immediately via an interactive `update` (never throttled). Pre-#2174
|
|
194
|
+
* every session start repaired placements immediately; "my skill
|
|
195
|
+
* briefly disappeared after I deleted its folder" within this window
|
|
196
|
+
* is expected behavior, not a placement-check bug.
|
|
197
|
+
*
|
|
198
|
+
* 15 minutes (#2174, product-confirmed). Override per-call via
|
|
199
|
+
* `runSync({ minSyncIntervalMs })` — a test seam used to pin the boundary.
|
|
200
|
+
*/
|
|
201
|
+
export const MIN_SYNC_INTERVAL_MS = 15 * 60 * 1000;
|
|
132
202
|
|
|
133
203
|
/**
|
|
134
204
|
* One-time heal floor (#1911).
|
|
@@ -317,11 +387,12 @@ export function readLastSync() {
|
|
|
317
387
|
return null;
|
|
318
388
|
}
|
|
319
389
|
|
|
320
|
-
// v1 →
|
|
321
|
-
// delta sync still benefits from the prior state; start
|
|
322
|
-
// empty `skills` map
|
|
323
|
-
//
|
|
324
|
-
//
|
|
390
|
+
// v1 → current-schema in-memory migration. Preserve the etag and
|
|
391
|
+
// syncedAt so delta sync still benefits from the prior state; start
|
|
392
|
+
// with an empty `skills` map (and no `lastAttemptAt`, so the throttle
|
|
393
|
+
// treats this as "never attempted"). The next successful runSync
|
|
394
|
+
// writes the current schema to disk. We do NOT write here — readers
|
|
395
|
+
// should be pure with respect to the filesystem.
|
|
325
396
|
if (parsed.schemaVersion === 1) {
|
|
326
397
|
return {
|
|
327
398
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
@@ -331,7 +402,24 @@ export function readLastSync() {
|
|
|
331
402
|
};
|
|
332
403
|
}
|
|
333
404
|
|
|
334
|
-
|
|
405
|
+
// Accept the current schema AND the immediately-prior v2 (which is v3
|
|
406
|
+
// minus `lastAttemptAt`, #2174). A v2 file is read IN PLACE — its
|
|
407
|
+
// etag/syncedAt/skills/cliVersion are preserved, so the first
|
|
408
|
+
// post-upgrade sync stays a cheap 304 rather than a forced re-fetch;
|
|
409
|
+
// the absent `lastAttemptAt` makes the throttle treat it as "never
|
|
410
|
+
// attempted" so that first sync still proceeds, then writes a v3 file.
|
|
411
|
+
// Any OTHER version (v1 is migrated above; a future v4+ is unknown) →
|
|
412
|
+
// null → full sync. Forward-compat without crashing.
|
|
413
|
+
//
|
|
414
|
+
// MAINTAINER: the `!== 2` literal is the "accept the immediately-prior
|
|
415
|
+
// schema in place" branch — it is NOT auto-derived from
|
|
416
|
+
// LAST_SYNC_SCHEMA_VERSION. The next bump (v3 → v4) must add its own
|
|
417
|
+
// `!== 3` branch here (accept v3 in place; v2 then falls through to a
|
|
418
|
+
// full sync), the same way this branch was added for v2 → v3.
|
|
419
|
+
if (
|
|
420
|
+
parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION &&
|
|
421
|
+
parsed.schemaVersion !== 2
|
|
422
|
+
) {
|
|
335
423
|
return null;
|
|
336
424
|
}
|
|
337
425
|
|
|
@@ -351,6 +439,16 @@ export function readLastSync() {
|
|
|
351
439
|
parsed.skills = {};
|
|
352
440
|
}
|
|
353
441
|
|
|
442
|
+
// `lastAttemptAt` (v3+, #2174) must be an ISO string to be usable by
|
|
443
|
+
// the throttle. A v2 file omits it; a malformed value (number, object,
|
|
444
|
+
// truncated write) is coerced to absent so the throttle treats the
|
|
445
|
+
// state as "never attempted" and syncs rather than trusting a bogus
|
|
446
|
+
// clock. `Date.parse` in runSync is a second guard, but normalizing
|
|
447
|
+
// here keeps the returned shape honest for every caller.
|
|
448
|
+
if (typeof parsed.lastAttemptAt !== "string") {
|
|
449
|
+
delete parsed.lastAttemptAt;
|
|
450
|
+
}
|
|
451
|
+
|
|
354
452
|
return parsed;
|
|
355
453
|
}
|
|
356
454
|
|
|
@@ -375,19 +473,31 @@ export function readLastSync() {
|
|
|
375
473
|
* never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
|
|
376
474
|
* only by tests to simulate a pre-fix writer); production callers omit it.
|
|
377
475
|
*
|
|
476
|
+
* v3 (#2174): `lastAttemptAt` is the ISO timestamp of the last network
|
|
477
|
+
* sync attempt, used by the throttle. It is NOT auto-stamped: callers pass
|
|
478
|
+
* it explicitly so intent is unambiguous. `runSync` passes "now" on the 304
|
|
479
|
+
* and successful-write paths; the single-skill helpers (`upsertLastSyncEntry`
|
|
480
|
+
* / `deleteLastSyncEntry`) preserve the prior value verbatim because a
|
|
481
|
+
* single-skill add/remove is not a full-library sync and must not advance
|
|
482
|
+
* the throttle clock. Omitting it persists `null` — an explicit "never
|
|
483
|
+
* attempted" that never wrongly throttles the next sync.
|
|
484
|
+
*
|
|
378
485
|
* @param {object} state
|
|
379
486
|
* @param {string | null} [state.etag]
|
|
380
487
|
* @param {string} [state.syncedAt]
|
|
381
488
|
* @param {Record<string, SyncedSkillEntry>} [state.skills]
|
|
382
489
|
* @param {string | null} [state.cliVersion] - Override the writer-version
|
|
383
490
|
* stamp. Defaults to the running CLI's version.
|
|
491
|
+
* @param {string | null} [state.lastAttemptAt] - ISO timestamp of the last
|
|
492
|
+
* sync attempt (#2174). Omitting it persists `null`.
|
|
384
493
|
*/
|
|
385
|
-
export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
|
|
494
|
+
export function writeLastSync({ etag, syncedAt, skills, cliVersion, lastAttemptAt } = {}) {
|
|
386
495
|
const path = globalLastSyncPath();
|
|
387
496
|
const body = {
|
|
388
497
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
389
498
|
etag: etag ?? null,
|
|
390
499
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
500
|
+
lastAttemptAt: lastAttemptAt ?? null,
|
|
391
501
|
cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
|
|
392
502
|
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
393
503
|
};
|
|
@@ -480,6 +590,11 @@ export function upsertLastSyncEntry(skill) {
|
|
|
480
590
|
// carries a pre-fix file's absent version forward as an explicit null
|
|
481
591
|
// so the next `update` still heals. Same rationale as etag/syncedAt.
|
|
482
592
|
cliVersion: prior?.cliVersion ?? null,
|
|
593
|
+
// Preserve the throttle clock (#2174) — a single-skill `add`/`get` is
|
|
594
|
+
// not a full-library sync, so it must NOT advance `lastAttemptAt` (that
|
|
595
|
+
// would wrongly throttle the next SessionStart sync). Same rationale as
|
|
596
|
+
// cliVersion above.
|
|
597
|
+
lastAttemptAt: prior?.lastAttemptAt ?? null,
|
|
483
598
|
});
|
|
484
599
|
}
|
|
485
600
|
|
|
@@ -507,6 +622,9 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
507
622
|
// reconciliation, so it must not advance the one-time heal gate.
|
|
508
623
|
// See upsertLastSyncEntry for the full rationale.
|
|
509
624
|
cliVersion: prior.cliVersion ?? null,
|
|
625
|
+
// Preserve the throttle clock (#2174) — `remove` is not a full sync,
|
|
626
|
+
// so it must not advance `lastAttemptAt`. Same rationale as cliVersion.
|
|
627
|
+
lastAttemptAt: prior.lastAttemptAt ?? null,
|
|
510
628
|
});
|
|
511
629
|
}
|
|
512
630
|
|
|
@@ -514,12 +632,16 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
514
632
|
* Run a library sync against the server.
|
|
515
633
|
*
|
|
516
634
|
* Strategy:
|
|
517
|
-
* 1.
|
|
518
|
-
*
|
|
635
|
+
* 1. Read the last-sync state, then apply the SessionStart throttle
|
|
636
|
+
* (#2174): a hook-triggered sync (`throttle: true`) within
|
|
637
|
+
* MIN_SYNC_INTERVAL_MS of the last attempt short-circuits HERE with
|
|
638
|
+
* ZERO network calls. A bare interactive `update` never throttles.
|
|
639
|
+
* 2. Clean up any orphan .tmp/.old directories from a crashed prior run
|
|
519
640
|
* 3. Call GET /api/v1/library with `If-None-Match` (if we have a
|
|
520
641
|
* cached ETag) AND `since` (always, for delta semantics)
|
|
521
642
|
* 4. On 304 — re-assert the on-disk state via a receipt (liveness +
|
|
522
|
-
* heal) and short-circuit
|
|
643
|
+
* heal), stamp the throttle clock (`lastAttemptAt`), and short-circuit
|
|
644
|
+
* with `notModified: true`
|
|
523
645
|
* 5. For each skill in the response:
|
|
524
646
|
* a. Write it via `writeSkillDir` — overwrites if changed
|
|
525
647
|
* b. Skip writing skills with `filesIncomplete: true` (see comment)
|
|
@@ -546,10 +668,20 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
546
668
|
* when `writeLastSync` fails uses io.stderr so tests that
|
|
547
669
|
* inject a capture stream don't lose that output.
|
|
548
670
|
* @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
|
|
671
|
+
* @param {boolean} [options.throttle] - Hook-triggered syncs ONLY
|
|
672
|
+
* (update.mjs's --session-hook / --silent branches) pass
|
|
673
|
+
* true. When the last attempt was within the interval, the
|
|
674
|
+
* whole sync short-circuits with zero network calls and a
|
|
675
|
+
* `throttled: true` summary (#2174). A bare interactive
|
|
676
|
+
* `skillrepo update` omits this and always syncs.
|
|
677
|
+
* @param {number} [options.minSyncIntervalMs] - Override the throttle window
|
|
678
|
+
* (defaults to MIN_SYNC_INTERVAL_MS). A test seam to pin the
|
|
679
|
+
* boundary; production callers omit it.
|
|
549
680
|
* @returns {Promise<SyncSummary>}
|
|
550
681
|
*/
|
|
551
682
|
export async function runSync(options) {
|
|
552
|
-
const { serverUrl, apiKey, vendors, global, io } =
|
|
683
|
+
const { serverUrl, apiKey, vendors, global, io, throttle, minSyncIntervalMs } =
|
|
684
|
+
options;
|
|
553
685
|
// Coalesce both `undefined` AND `null` to {}. Destructuring with
|
|
554
686
|
// `io = {}` only handles `undefined`, so an explicit `io: null`
|
|
555
687
|
// would otherwise blow up at the .stderr access below. Both
|
|
@@ -564,12 +696,70 @@ export async function runSync(options) {
|
|
|
564
696
|
throw validationError("runSync: apiKey is required");
|
|
565
697
|
}
|
|
566
698
|
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
//
|
|
699
|
+
// Read prior state FIRST — the throttle decision below needs it, and
|
|
700
|
+
// reading one small JSON file is far cheaper than the orphan scan.
|
|
701
|
+
// Reordered ahead of cleanupOrphans (#2174) so a throttled hook sync
|
|
702
|
+
// does no filesystem walking either.
|
|
571
703
|
const lastSync = readLastSync();
|
|
572
704
|
|
|
705
|
+
// SessionStart staleness throttle (#2174). Hook-triggered syncs
|
|
706
|
+
// (`throttle: true`, set ONLY by update.mjs's --session-hook and
|
|
707
|
+
// --silent branches) skip ALL network work — no library GET, no receipt
|
|
708
|
+
// POST — when the last ATTEMPT was within the interval. A bare
|
|
709
|
+
// interactive `skillrepo update` passes no `throttle`, so it always
|
|
710
|
+
// syncs (explicit intent). `lastAttemptAt` is absent on a fresh install
|
|
711
|
+
// and on a v2 file (never attempted) → those never throttle. A skipped
|
|
712
|
+
// run does NOT re-stamp `lastAttemptAt` (only an actual sync does), so
|
|
713
|
+
// continuous restarts can't push the clock forward and starve the sync:
|
|
714
|
+
// the window is measured from the last REAL sync, not the last session.
|
|
715
|
+
const intervalMs =
|
|
716
|
+
typeof minSyncIntervalMs === "number" && minSyncIntervalMs >= 0
|
|
717
|
+
? minSyncIntervalMs
|
|
718
|
+
: MIN_SYNC_INTERVAL_MS;
|
|
719
|
+
if (throttle && lastSync && typeof lastSync.lastAttemptAt === "string") {
|
|
720
|
+
const lastAttemptMs = Date.parse(lastSync.lastAttemptAt);
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
const sinceLastAttemptMs = now - lastAttemptMs;
|
|
723
|
+
// Guard BOTH ends of the window. A FUTURE `lastAttemptAt` (negative
|
|
724
|
+
// delta — backward clock jump, VM snapshot restore, hand-edited or
|
|
725
|
+
// corrupt state file) must SYNC, not throttle: a plain `< intervalMs`
|
|
726
|
+
// check stays true until the wall clock catches up to the bogus
|
|
727
|
+
// stamp, silently disabling the hook sync for that whole span (hook
|
|
728
|
+
// modes are contract-silent, so the user would never see it).
|
|
729
|
+
// Syncing instead re-stamps a sane "now" — one pass self-heals.
|
|
730
|
+
if (
|
|
731
|
+
Number.isFinite(lastAttemptMs) &&
|
|
732
|
+
sinceLastAttemptMs >= 0 &&
|
|
733
|
+
sinceLastAttemptMs < intervalMs
|
|
734
|
+
) {
|
|
735
|
+
// SKILLREPO_VERBOSE gives the acceptance check a human-observable
|
|
736
|
+
// signal alongside the request-count spy — one dim stderr line only;
|
|
737
|
+
// stdout stays clean for the hook contracts.
|
|
738
|
+
if (process.env.SKILLREPO_VERBOSE) {
|
|
739
|
+
const agoS = Math.round(sinceLastAttemptMs / 1000);
|
|
740
|
+
const winS = Math.round(intervalMs / 1000);
|
|
741
|
+
stderr.write(
|
|
742
|
+
` [skillrepo] sync throttled: last attempt ${agoS}s ago < ${winS}s window; skipping network.\n`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
added: 0,
|
|
747
|
+
updated: 0,
|
|
748
|
+
removed: 0,
|
|
749
|
+
notModified: false,
|
|
750
|
+
throttled: true,
|
|
751
|
+
// Prior state existed (we had a lastAttemptAt) → never a first
|
|
752
|
+
// sync. Surface the cached syncedAt so consumers that read it
|
|
753
|
+
// (e.g. init.mjs) see the last known-good sync time.
|
|
754
|
+
fullSync: false,
|
|
755
|
+
syncedAt: lastSync.syncedAt ?? new Date(now).toISOString(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Step 2: clean orphans from prior crashes BEFORE doing any new writes
|
|
761
|
+
cleanupOrphans({ vendors, global });
|
|
762
|
+
|
|
573
763
|
// Step 3: fetch with conditional headers
|
|
574
764
|
const opts = {};
|
|
575
765
|
// The ETag short-circuit is only safe when EVERY skill in
|
|
@@ -638,6 +828,20 @@ export async function runSync(options) {
|
|
|
638
828
|
// 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
|
|
639
829
|
// the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
|
|
640
830
|
// is idempotent — and self-resolves once the older install is upgraded.
|
|
831
|
+
//
|
|
832
|
+
// Same-machine SPLIT-FLEET churn (#2174 rollout, tracked via #2181): the
|
|
833
|
+
// cohort hooks run `npx --yes skillrepo update --silent` (npm `latest` on
|
|
834
|
+
// every session), while the Claude Code hook pins the globally-installed
|
|
835
|
+
// binary. Until that global install is upgraded past the v3 schema bump,
|
|
836
|
+
// the two alternate writers ping-pong `.last-sync`: the ≥4.9.0 npx run
|
|
837
|
+
// writes schemaVersion 3; the ≤4.8.x pinned binary doesn't recognize v3 →
|
|
838
|
+
// null → FULL non-conditional fetch → writes v2 back (dropping
|
|
839
|
+
// `lastAttemptAt`), and the next npx run re-upgrades. Consequences while
|
|
840
|
+
// the split persists: the pinned binary never gets a 304 short-circuit
|
|
841
|
+
// and the throttle keeps getting disarmed — bounded by the once-daily
|
|
842
|
+
// upgrade nudge (bin/skillrepo.mjs), self-resolving on `npm install -g
|
|
843
|
+
// skillrepo@latest`. Harmless to data (full re-fetch is idempotent);
|
|
844
|
+
// do NOT add a compat shim for it here.
|
|
641
845
|
const needsFullResync =
|
|
642
846
|
!!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
|
|
643
847
|
|
|
@@ -679,6 +883,31 @@ export async function runSync(options) {
|
|
|
679
883
|
new Date().toISOString(),
|
|
680
884
|
stderr,
|
|
681
885
|
);
|
|
886
|
+
|
|
887
|
+
// Stamp the throttle clock (#2174). LOAD-BEARING: a deliberately-
|
|
888
|
+
// stable library is a 304 on every session, so if we didn't advance
|
|
889
|
+
// `lastAttemptAt` here the throttle would never re-arm and every future
|
|
890
|
+
// hook sync would hit the network again — defeating the whole feature.
|
|
891
|
+
// Preserve etag/syncedAt/skills verbatim (the library is unchanged; only
|
|
892
|
+
// the attempt clock moves) and preserve cliVersion — a 304 is NOT a
|
|
893
|
+
// membership heal, and reaching a 304 already implies we sent
|
|
894
|
+
// If-None-Match, i.e. cliVersion was >= the heal floor. Best-effort: a
|
|
895
|
+
// failed stamp just means the next hook sync isn't throttled, never a
|
|
896
|
+
// sync failure.
|
|
897
|
+
try {
|
|
898
|
+
writeLastSync({
|
|
899
|
+
etag: lastSync?.etag ?? null,
|
|
900
|
+
syncedAt: lastSync?.syncedAt,
|
|
901
|
+
skills: lastSync?.skills ?? {},
|
|
902
|
+
cliVersion: lastSync?.cliVersion ?? null,
|
|
903
|
+
lastAttemptAt: new Date().toISOString(),
|
|
904
|
+
});
|
|
905
|
+
} catch (err) {
|
|
906
|
+
stderr.write(
|
|
907
|
+
` warning: failed to persist last-sync state (${err.message}). ` +
|
|
908
|
+
`Next sync will not be throttled.\n`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
682
911
|
return {
|
|
683
912
|
added: 0,
|
|
684
913
|
updated: 0,
|
|
@@ -827,6 +1056,11 @@ export async function runSync(options) {
|
|
|
827
1056
|
etag: result.etag,
|
|
828
1057
|
syncedAt: result.syncedAt,
|
|
829
1058
|
skills: skillsMap,
|
|
1059
|
+
// Stamp the throttle clock on every successful sync (#2174). Gated
|
|
1060
|
+
// by the same `!anyIncomplete` guard as the ETag: a partial payload
|
|
1061
|
+
// does NOT advance the clock, so a broken sync is retried on the
|
|
1062
|
+
// next session rather than throttled away.
|
|
1063
|
+
lastAttemptAt: new Date().toISOString(),
|
|
830
1064
|
});
|
|
831
1065
|
} catch (err) {
|
|
832
1066
|
// Non-fatal — the skills are on disk, we just won't get the
|
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
writeLastSync,
|
|
19
19
|
FULL_RESYNC_FLOOR,
|
|
20
20
|
cliVersionBelowFloor,
|
|
21
|
+
LAST_SYNC_SCHEMA_VERSION,
|
|
21
22
|
} from "../../lib/sync.mjs";
|
|
23
|
+
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
22
24
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
23
25
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
24
26
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
@@ -689,3 +691,167 @@ describe("runUpdate — #1911 membership heal lands the skill on disk", () => {
|
|
|
689
691
|
);
|
|
690
692
|
});
|
|
691
693
|
});
|
|
694
|
+
|
|
695
|
+
// ── SessionStart throttle through the `update` command (#2174) ─────────
|
|
696
|
+
//
|
|
697
|
+
// The throttle lives in runSync, but what matters at the product boundary
|
|
698
|
+
// is that `skillrepo update --session-hook` (Claude Code) and the
|
|
699
|
+
// `--silent` cohort hook make ZERO HTTP calls when re-run within the
|
|
700
|
+
// window, while a bare interactive `skillrepo update` always syncs. These
|
|
701
|
+
// drive the real command entry point against the mock server's request
|
|
702
|
+
// spies — the acceptance criterion, end to end.
|
|
703
|
+
|
|
704
|
+
describe("runUpdate — SessionStart throttle (#2174)", () => {
|
|
705
|
+
beforeEach(setup);
|
|
706
|
+
afterEach(teardown);
|
|
707
|
+
|
|
708
|
+
it("ACCEPTANCE: two `update --session-hook` runs within the window → the 2nd makes zero HTTP calls", async () => {
|
|
709
|
+
server.setEtag('"v1"');
|
|
710
|
+
server.setLibraryResponse({
|
|
711
|
+
skills: [makeSkill("throttle-me")],
|
|
712
|
+
removals: [],
|
|
713
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Session 1: the hook runs a real sync (no prior attempt).
|
|
717
|
+
await runUpdate(
|
|
718
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
719
|
+
{ stdout },
|
|
720
|
+
);
|
|
721
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "session 1 hit the library GET");
|
|
722
|
+
assert.match(stdout.text(), /Library synced/, "session 1 reports the sync");
|
|
723
|
+
|
|
724
|
+
// Session 2 (same 15-min window): the hook must make NO HTTP calls.
|
|
725
|
+
server.resetLibraryInspection();
|
|
726
|
+
server.resetReceipts();
|
|
727
|
+
stdout.clear();
|
|
728
|
+
await runUpdate(
|
|
729
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
730
|
+
{ stdout },
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
assert.equal(
|
|
734
|
+
server.getLibraryRequestCount(),
|
|
735
|
+
0,
|
|
736
|
+
"throttled session makes NO library GET",
|
|
737
|
+
);
|
|
738
|
+
assert.equal(
|
|
739
|
+
server.getReceiptRequestCount(),
|
|
740
|
+
0,
|
|
741
|
+
"throttled session makes NO receipt POST",
|
|
742
|
+
);
|
|
743
|
+
assert.equal(
|
|
744
|
+
stdout.text(),
|
|
745
|
+
"",
|
|
746
|
+
"throttled session-hook stays silent (nothing changed to report)",
|
|
747
|
+
);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("a bare interactive `skillrepo update` always syncs, even seconds after a hook sync", async () => {
|
|
751
|
+
server.setEtag('"v1"');
|
|
752
|
+
server.setLibraryResponse({
|
|
753
|
+
skills: [makeSkill("fresh")],
|
|
754
|
+
removals: [],
|
|
755
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
756
|
+
});
|
|
757
|
+
// Hook sync arms the throttle clock.
|
|
758
|
+
await runUpdate(
|
|
759
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
760
|
+
{ stdout },
|
|
761
|
+
);
|
|
762
|
+
server.resetLibraryInspection();
|
|
763
|
+
server.resetReceipts();
|
|
764
|
+
stdout.clear();
|
|
765
|
+
|
|
766
|
+
// Interactive `update` (no hook flag) must contact the server despite
|
|
767
|
+
// the fresh clock — explicit intent always syncs.
|
|
768
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
769
|
+
assert.ok(
|
|
770
|
+
server.getLibraryRequestCount() >= 1,
|
|
771
|
+
"interactive update is never throttled",
|
|
772
|
+
);
|
|
773
|
+
assert.match(
|
|
774
|
+
stdout.text(),
|
|
775
|
+
/up to date/,
|
|
776
|
+
"interactive 304 prints the up-to-date line",
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("the --silent cohort hook throttles too (2nd run: zero HTTP, still emits `{}`)", async () => {
|
|
781
|
+
server.setEtag('"v1"');
|
|
782
|
+
server.setLibraryResponse({
|
|
783
|
+
skills: [makeSkill("cohort")],
|
|
784
|
+
removals: [],
|
|
785
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
786
|
+
});
|
|
787
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
|
|
788
|
+
server.resetLibraryInspection();
|
|
789
|
+
server.resetReceipts();
|
|
790
|
+
stdout.clear();
|
|
791
|
+
|
|
792
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
|
|
793
|
+
assert.equal(
|
|
794
|
+
server.getLibraryRequestCount(),
|
|
795
|
+
0,
|
|
796
|
+
"throttled --silent makes NO library GET",
|
|
797
|
+
);
|
|
798
|
+
assert.equal(
|
|
799
|
+
server.getReceiptRequestCount(),
|
|
800
|
+
0,
|
|
801
|
+
"throttled --silent makes NO receipt POST",
|
|
802
|
+
);
|
|
803
|
+
// The JSON hook contract still holds: stdout is exactly `{}` even when
|
|
804
|
+
// the sync was throttled away.
|
|
805
|
+
assert.equal(stdout.text(), "{}\n");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("upgrade path: a real v2 .last-sync (etag + skills, no lastAttemptAt) → first hook sync proceeds and upgrades to v3", async () => {
|
|
809
|
+
// Every existing install upgrading to 4.9.0 hits exactly this state:
|
|
810
|
+
// a v2 file with a genuine etag/skills baseline written by the old
|
|
811
|
+
// CLI. The first --session-hook run after upgrade must NOT throttle
|
|
812
|
+
// (no lastAttemptAt = never attempted), must keep the cheap 304 (etag
|
|
813
|
+
// preserved in place), and must leave a v3 file with the stamp.
|
|
814
|
+
server.setEtag('"v1"');
|
|
815
|
+
server.setLibraryResponse({
|
|
816
|
+
skills: [makeSkill("veteran")],
|
|
817
|
+
removals: [],
|
|
818
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
819
|
+
});
|
|
820
|
+
// Arm a fully-populated state via a real sync, then rewrite it as the
|
|
821
|
+
// v2 shape a pre-#2174 CLI would have left behind.
|
|
822
|
+
await runUpdate(
|
|
823
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
824
|
+
{ stdout },
|
|
825
|
+
);
|
|
826
|
+
const prior = readLastSync();
|
|
827
|
+
const v2File = {
|
|
828
|
+
schemaVersion: 2,
|
|
829
|
+
etag: prior.etag,
|
|
830
|
+
syncedAt: prior.syncedAt,
|
|
831
|
+
cliVersion: prior.cliVersion,
|
|
832
|
+
skills: prior.skills,
|
|
833
|
+
};
|
|
834
|
+
writeFileSync(globalLastSyncPath(), JSON.stringify(v2File, null, 2) + "\n");
|
|
835
|
+
server.resetLibraryInspection();
|
|
836
|
+
server.resetReceipts();
|
|
837
|
+
stdout.clear();
|
|
838
|
+
|
|
839
|
+
await runUpdate(
|
|
840
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
841
|
+
{ stdout },
|
|
842
|
+
);
|
|
843
|
+
assert.ok(
|
|
844
|
+
server.getLibraryRequestCount() >= 1,
|
|
845
|
+
"first post-upgrade hook sync is NOT throttled (v2 = never attempted)",
|
|
846
|
+
);
|
|
847
|
+
assert.equal(
|
|
848
|
+
server.getLastLibraryIfNoneMatch(),
|
|
849
|
+
prior.etag,
|
|
850
|
+
"the v2 etag was accepted in place — the post-upgrade sync stays a cheap 304",
|
|
851
|
+
);
|
|
852
|
+
const after = readLastSync();
|
|
853
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION, "file upgraded to v3");
|
|
854
|
+
assert.equal(typeof after.lastAttemptAt, "string", "throttle clock stamped");
|
|
855
|
+
assert.deepEqual(after.skills, prior.skills, "per-skill SHA baseline preserved");
|
|
856
|
+
});
|
|
857
|
+
});
|
|
@@ -57,7 +57,7 @@ import { runAdd } from "../../commands/add.mjs";
|
|
|
57
57
|
import { runRemove } from "../../commands/remove.mjs";
|
|
58
58
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
59
59
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
60
|
-
import { readLastSync } from "../../lib/sync.mjs";
|
|
60
|
+
import { readLastSync, LAST_SYNC_SCHEMA_VERSION } from "../../lib/sync.mjs";
|
|
61
61
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
62
62
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
63
63
|
import {
|
|
@@ -391,11 +391,11 @@ describe("update → list cross-command contract (#1574)", () => {
|
|
|
391
391
|
// is the only way to verify the v2 file that lands on disk has the
|
|
392
392
|
// expected shape AND the ETag carry-forward took effect.
|
|
393
393
|
|
|
394
|
-
describe("v1 →
|
|
394
|
+
describe("v1 → current-schema .last-sync migration round-trip", () => {
|
|
395
395
|
beforeEach(setup);
|
|
396
396
|
afterEach(teardown);
|
|
397
397
|
|
|
398
|
-
it("reads v1 .last-sync, performs sync, writes
|
|
398
|
+
it("reads v1 .last-sync, performs sync, writes the current schema with SHA map populated", async () => {
|
|
399
399
|
// Seed a v1 state file at the documented path. Both fields are
|
|
400
400
|
// strings — that's the v1 shape `readLastSync` migrates from.
|
|
401
401
|
const v1Path = globalLastSyncPath();
|
|
@@ -421,14 +421,18 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
421
421
|
// in readLastSync and the v2 write at the end of runSync ──
|
|
422
422
|
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
423
423
|
|
|
424
|
-
// The on-disk file must now
|
|
425
|
-
// map. The pre-existing v1 etag/syncedAt are NOT preserved
|
|
424
|
+
// The on-disk file must now carry the CURRENT schema and the per-skill
|
|
425
|
+
// SHA map. The pre-existing v1 etag/syncedAt are NOT preserved
|
|
426
426
|
// because the sync actually completed and got a fresh ETag from
|
|
427
427
|
// the server. Carry-forward applies to the per-skill SHA map
|
|
428
428
|
// (which was empty in v1), not to the library ETag itself.
|
|
429
429
|
const afterRaw = readFileSync(v1Path, "utf-8");
|
|
430
430
|
const after = JSON.parse(afterRaw);
|
|
431
|
-
assert.equal(
|
|
431
|
+
assert.equal(
|
|
432
|
+
after.schemaVersion,
|
|
433
|
+
LAST_SYNC_SCHEMA_VERSION,
|
|
434
|
+
"must persist as the current schema (v1 migrates forward on write)",
|
|
435
|
+
);
|
|
432
436
|
assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
|
|
433
437
|
assert.ok(
|
|
434
438
|
after.skills && typeof after.skills === "object",
|
|
@@ -502,11 +506,11 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
502
506
|
"v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
|
|
503
507
|
);
|
|
504
508
|
|
|
505
|
-
// After the recovery sync, the on-disk file
|
|
506
|
-
// skills map populated. The skill is on disk in the
|
|
507
|
-
// placement — the user-visible recovery.
|
|
509
|
+
// After the recovery sync, the on-disk file carries the current
|
|
510
|
+
// schema with the skills map populated. The skill is on disk in the
|
|
511
|
+
// claudeProject placement — the user-visible recovery.
|
|
508
512
|
const after = readLastSync();
|
|
509
|
-
assert.equal(after.schemaVersion,
|
|
513
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
510
514
|
assert.ok(after.skills["alice/unmigrated"]);
|
|
511
515
|
assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
|
|
512
516
|
|
|
@@ -1014,7 +1018,7 @@ describe("additional coverage from production-readiness audit", () => {
|
|
|
1014
1018
|
"runUpdate must survive a corrupt .last-sync and complete the sync",
|
|
1015
1019
|
);
|
|
1016
1020
|
const after = readLastSync();
|
|
1017
|
-
assert.equal(after.schemaVersion,
|
|
1021
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
1018
1022
|
assert.equal(after.etag, '"fresh"');
|
|
1019
1023
|
assert.ok(after.skills["alice/recovered"]);
|
|
1020
1024
|
});
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
cliVersionBelowFloor,
|
|
45
45
|
FULL_RESYNC_FLOOR,
|
|
46
46
|
LAST_SYNC_SCHEMA_VERSION,
|
|
47
|
+
MIN_SYNC_INTERVAL_MS,
|
|
47
48
|
} from "../../lib/sync.mjs";
|
|
48
49
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
49
50
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
@@ -124,11 +125,11 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
124
125
|
assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
it("writes
|
|
128
|
-
// Locks the constant so
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
assert.equal(LAST_SYNC_SCHEMA_VERSION,
|
|
128
|
+
it("writes v3 schema (LAST_SYNC_SCHEMA_VERSION === 3)", () => {
|
|
129
|
+
// Locks the constant so an accidental change fails loudly. #2174
|
|
130
|
+
// bumped it 2 → 3 (adds `lastAttemptAt`). v1 files migrate in-memory,
|
|
131
|
+
// v2 files are accepted in place, unknown future versions read null.
|
|
132
|
+
assert.equal(LAST_SYNC_SCHEMA_VERSION, 3);
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it("persists an empty skills map by default", () => {
|
|
@@ -164,7 +165,7 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
164
165
|
assert.deepEqual(result.skills, skills);
|
|
165
166
|
});
|
|
166
167
|
|
|
167
|
-
it("an old v1 file migrates in-memory to
|
|
168
|
+
it("an old v1 file migrates in-memory to the current-schema shape with empty skills", () => {
|
|
168
169
|
// Backward compat: a user upgrading from a v1-era CLI to v2 still
|
|
169
170
|
// has a v1 `.last-sync` on disk. Returning null would force a
|
|
170
171
|
// full sync on every command (the etag is lost), which is
|
|
@@ -235,9 +236,9 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
it("a file with an unknown schemaVersion still reads as null", () => {
|
|
238
|
-
// Forward compat: a
|
|
239
|
-
// understand) reads as null, triggering a full sync. We only
|
|
240
|
-
// migrate
|
|
239
|
+
// Forward compat: a v4 or v999 file (from a newer CLI we don't
|
|
240
|
+
// understand) reads as null, triggering a full sync. We only accept
|
|
241
|
+
// KNOWN shapes (v1 migrate, v2/v3 in place), not arbitrary unknown ones.
|
|
241
242
|
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
242
243
|
writeFileSync(
|
|
243
244
|
globalLastSyncPath(),
|
|
@@ -2046,3 +2047,490 @@ describe("placementsAreComplete — direct unit coverage", () => {
|
|
|
2046
2047
|
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
2047
2048
|
});
|
|
2048
2049
|
});
|
|
2050
|
+
|
|
2051
|
+
// ── .last-sync — lastAttemptAt (v3, #2174) ─────────────────────────────
|
|
2052
|
+
|
|
2053
|
+
describe("readLastSync / writeLastSync — lastAttemptAt (v3, #2174)", () => {
|
|
2054
|
+
beforeEach(setupServer);
|
|
2055
|
+
afterEach(teardownServer);
|
|
2056
|
+
|
|
2057
|
+
it("round-trips a lastAttemptAt ISO string", () => {
|
|
2058
|
+
writeLastSync({
|
|
2059
|
+
etag: '"e"',
|
|
2060
|
+
syncedAt: "s",
|
|
2061
|
+
lastAttemptAt: "2025-01-01T00:00:00.000Z",
|
|
2062
|
+
});
|
|
2063
|
+
assert.equal(readLastSync().lastAttemptAt, "2025-01-01T00:00:00.000Z");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("persists lastAttemptAt: null when omitted, and reads it back as ABSENT", () => {
|
|
2067
|
+
// Omitting the field writes explicit null on disk; readLastSync
|
|
2068
|
+
// normalizes a non-string to absent so the throttle sees "never
|
|
2069
|
+
// attempted" rather than a bogus clock value.
|
|
2070
|
+
writeLastSync({ etag: '"e"', syncedAt: "s" });
|
|
2071
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
2072
|
+
assert.equal(onDisk.lastAttemptAt, null);
|
|
2073
|
+
assert.equal("lastAttemptAt" in readLastSync(), false);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
it("coerces a non-string lastAttemptAt to absent (bogus clock ignored)", () => {
|
|
2077
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
2078
|
+
writeFileSync(
|
|
2079
|
+
globalLastSyncPath(),
|
|
2080
|
+
JSON.stringify({
|
|
2081
|
+
schemaVersion: 3,
|
|
2082
|
+
etag: "x",
|
|
2083
|
+
syncedAt: "x",
|
|
2084
|
+
skills: {},
|
|
2085
|
+
lastAttemptAt: 12345,
|
|
2086
|
+
}),
|
|
2087
|
+
);
|
|
2088
|
+
assert.equal("lastAttemptAt" in readLastSync(), false);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
it("accepts a v2 file in place (no lastAttemptAt) — etag + skills preserved", () => {
|
|
2092
|
+
// A v2 file written by a pre-#2174 CLI must be read in place: etag and
|
|
2093
|
+
// skills preserved (so the first post-upgrade sync stays a cheap 304),
|
|
2094
|
+
// lastAttemptAt absent (the throttle treats it as "never attempted").
|
|
2095
|
+
// This is the additive-forward-compat contract, distinct from the
|
|
2096
|
+
// v1→v2 migration which forces a re-fetch via an empty skills map.
|
|
2097
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
2098
|
+
const v2 = {
|
|
2099
|
+
schemaVersion: 2,
|
|
2100
|
+
etag: '"v2etag"',
|
|
2101
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2102
|
+
cliVersion: "4.8.1",
|
|
2103
|
+
skills: {
|
|
2104
|
+
"alice/x": {
|
|
2105
|
+
version: "1.0.0",
|
|
2106
|
+
skillMdSha256: "a".repeat(64),
|
|
2107
|
+
filesSha256: "b".repeat(64),
|
|
2108
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
};
|
|
2112
|
+
writeFileSync(globalLastSyncPath(), JSON.stringify(v2));
|
|
2113
|
+
const read = readLastSync();
|
|
2114
|
+
assert.ok(read, "a v2 file must be accepted, not null");
|
|
2115
|
+
assert.equal(read.etag, '"v2etag"');
|
|
2116
|
+
assert.deepEqual(Object.keys(read.skills), ["alice/x"]);
|
|
2117
|
+
assert.equal(
|
|
2118
|
+
"lastAttemptAt" in read,
|
|
2119
|
+
false,
|
|
2120
|
+
"v2 has no lastAttemptAt → the throttle treats it as never attempted",
|
|
2121
|
+
);
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
it("upsertLastSyncEntry preserves the prior lastAttemptAt (single-skill add is not a full sync)", () => {
|
|
2125
|
+
writeLastSync({
|
|
2126
|
+
etag: '"e"',
|
|
2127
|
+
syncedAt: "s",
|
|
2128
|
+
skills: {},
|
|
2129
|
+
lastAttemptAt: "2025-02-02T00:00:00.000Z",
|
|
2130
|
+
});
|
|
2131
|
+
upsertLastSyncEntry({
|
|
2132
|
+
owner: "alice",
|
|
2133
|
+
name: "added",
|
|
2134
|
+
version: "1.0.0",
|
|
2135
|
+
files: [
|
|
2136
|
+
{
|
|
2137
|
+
path: "SKILL.md",
|
|
2138
|
+
content: "---\nname: added\ndescription: d\n---\nbody\n",
|
|
2139
|
+
},
|
|
2140
|
+
],
|
|
2141
|
+
});
|
|
2142
|
+
assert.equal(
|
|
2143
|
+
readLastSync().lastAttemptAt,
|
|
2144
|
+
"2025-02-02T00:00:00.000Z",
|
|
2145
|
+
"a single-skill add must NOT advance the throttle clock",
|
|
2146
|
+
);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
it("deleteLastSyncEntry preserves the prior lastAttemptAt", () => {
|
|
2150
|
+
writeLastSync({
|
|
2151
|
+
etag: '"e"',
|
|
2152
|
+
syncedAt: "s",
|
|
2153
|
+
skills: {
|
|
2154
|
+
"alice/gone": {
|
|
2155
|
+
version: "1.0.0",
|
|
2156
|
+
skillMdSha256: "a".repeat(64),
|
|
2157
|
+
filesSha256: "b".repeat(64),
|
|
2158
|
+
syncedAt: "s",
|
|
2159
|
+
},
|
|
2160
|
+
},
|
|
2161
|
+
lastAttemptAt: "2025-03-03T00:00:00.000Z",
|
|
2162
|
+
});
|
|
2163
|
+
deleteLastSyncEntry("alice", "gone");
|
|
2164
|
+
assert.equal(readLastSync().lastAttemptAt, "2025-03-03T00:00:00.000Z");
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
// ── runSync — SessionStart staleness throttle (#2174) ──────────────────
|
|
2169
|
+
//
|
|
2170
|
+
// A hook-triggered sync (`throttle: true`, passed ONLY by update.mjs's
|
|
2171
|
+
// --session-hook / --silent branches) skips ALL network work — no library
|
|
2172
|
+
// GET, no receipt POST — when the last ATTEMPT was within the window. A
|
|
2173
|
+
// bare interactive sync (no `throttle`) always runs. `lastAttemptAt` is
|
|
2174
|
+
// stamped on the 304 path and every successful write, NOT on a throttled
|
|
2175
|
+
// skip, so the window is measured from the last REAL sync — a run of quick
|
|
2176
|
+
// restarts can't push the clock forward and starve the sync.
|
|
2177
|
+
|
|
2178
|
+
describe("runSync — SessionStart throttle (#2174)", () => {
|
|
2179
|
+
beforeEach(setupServer);
|
|
2180
|
+
afterEach(teardownServer);
|
|
2181
|
+
|
|
2182
|
+
it("locks the default window at 15 minutes (a change must be deliberate)", () => {
|
|
2183
|
+
assert.equal(MIN_SYNC_INTERVAL_MS, 15 * 60 * 1000);
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
it("ACCEPTANCE: two throttled syncs within the window → the 2nd makes ZERO HTTP calls", async () => {
|
|
2187
|
+
server.setEtag('"v1"');
|
|
2188
|
+
server.setLibraryResponse({
|
|
2189
|
+
skills: [makeSkill("pdf-helper")],
|
|
2190
|
+
removals: [],
|
|
2191
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
// Run 1 (hook-triggered): a real sync — no prior attempt, so it is NOT
|
|
2195
|
+
// throttled. It stamps lastAttemptAt = now.
|
|
2196
|
+
const first = await runSync({
|
|
2197
|
+
serverUrl,
|
|
2198
|
+
apiKey: VALID_KEY,
|
|
2199
|
+
vendors: ["claudeCode"],
|
|
2200
|
+
throttle: true,
|
|
2201
|
+
});
|
|
2202
|
+
assert.equal(first.throttled ?? false, false, "first run is a real sync");
|
|
2203
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "first run hit the library GET");
|
|
2204
|
+
|
|
2205
|
+
// Reset the request-count spies, then run again immediately. Within the
|
|
2206
|
+
// 15-min window the throttle MUST skip both the library GET and the
|
|
2207
|
+
// receipt POST — zero HTTP, the acceptance criterion.
|
|
2208
|
+
server.resetLibraryInspection();
|
|
2209
|
+
server.resetReceipts();
|
|
2210
|
+
const second = await runSync({
|
|
2211
|
+
serverUrl,
|
|
2212
|
+
apiKey: VALID_KEY,
|
|
2213
|
+
vendors: ["claudeCode"],
|
|
2214
|
+
throttle: true,
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
assert.equal(second.throttled, true, "second run within the window is throttled");
|
|
2218
|
+
assert.equal(server.getLibraryRequestCount(), 0, "throttled run makes NO library GET");
|
|
2219
|
+
assert.equal(server.getReceiptRequestCount(), 0, "throttled run makes NO receipt POST");
|
|
2220
|
+
assert.equal(
|
|
2221
|
+
second.notModified,
|
|
2222
|
+
false,
|
|
2223
|
+
"throttled is distinct from a 304 — we never contacted the server",
|
|
2224
|
+
);
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
it("a bare interactive sync (no throttle) ALWAYS syncs, even within the window", async () => {
|
|
2228
|
+
server.setEtag('"v1"');
|
|
2229
|
+
server.setLibraryResponse({
|
|
2230
|
+
skills: [makeSkill("x")],
|
|
2231
|
+
removals: [],
|
|
2232
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2233
|
+
});
|
|
2234
|
+
// Arm the clock with a hook sync.
|
|
2235
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2236
|
+
server.resetLibraryInspection();
|
|
2237
|
+
server.resetReceipts();
|
|
2238
|
+
// Interactive: `throttle` omitted → must hit the network despite a
|
|
2239
|
+
// fresh lastAttemptAt.
|
|
2240
|
+
const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2241
|
+
assert.equal(res.throttled ?? false, false);
|
|
2242
|
+
assert.ok(
|
|
2243
|
+
server.getLibraryRequestCount() >= 1,
|
|
2244
|
+
"an explicit interactive sync always contacts the server",
|
|
2245
|
+
);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
it("does NOT throttle a fresh install (no lastAttemptAt yet)", async () => {
|
|
2249
|
+
server.setEtag('"v1"');
|
|
2250
|
+
server.setLibraryResponse({
|
|
2251
|
+
skills: [makeSkill("x")],
|
|
2252
|
+
removals: [],
|
|
2253
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2254
|
+
});
|
|
2255
|
+
// No prior .last-sync → lastAttemptAt absent → never attempted → the
|
|
2256
|
+
// very first hook sync proceeds.
|
|
2257
|
+
const res = await runSync({
|
|
2258
|
+
serverUrl,
|
|
2259
|
+
apiKey: VALID_KEY,
|
|
2260
|
+
vendors: ["claudeCode"],
|
|
2261
|
+
throttle: true,
|
|
2262
|
+
});
|
|
2263
|
+
assert.equal(res.throttled ?? false, false);
|
|
2264
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
it("throttle expires: an attempt older than the window syncs again", async () => {
|
|
2268
|
+
server.setEtag('"v1"');
|
|
2269
|
+
server.setLibraryResponse({
|
|
2270
|
+
skills: [makeSkill("x")],
|
|
2271
|
+
removals: [],
|
|
2272
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2273
|
+
});
|
|
2274
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2275
|
+
|
|
2276
|
+
// Backdate lastAttemptAt to just past the window.
|
|
2277
|
+
const s = readLastSync();
|
|
2278
|
+
writeLastSync({
|
|
2279
|
+
etag: s.etag,
|
|
2280
|
+
syncedAt: s.syncedAt,
|
|
2281
|
+
skills: s.skills,
|
|
2282
|
+
cliVersion: s.cliVersion,
|
|
2283
|
+
lastAttemptAt: new Date(Date.now() - MIN_SYNC_INTERVAL_MS - 1000).toISOString(),
|
|
2284
|
+
});
|
|
2285
|
+
server.resetLibraryInspection();
|
|
2286
|
+
server.resetReceipts();
|
|
2287
|
+
|
|
2288
|
+
const res = await runSync({
|
|
2289
|
+
serverUrl,
|
|
2290
|
+
apiKey: VALID_KEY,
|
|
2291
|
+
vendors: ["claudeCode"],
|
|
2292
|
+
throttle: true,
|
|
2293
|
+
});
|
|
2294
|
+
assert.equal(res.throttled ?? false, false, "past the window → not throttled");
|
|
2295
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "expired window re-contacts the server");
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
it("minSyncIntervalMs override pins the boundary (0 disables the throttle)", async () => {
|
|
2299
|
+
server.setEtag('"v1"');
|
|
2300
|
+
server.setLibraryResponse({
|
|
2301
|
+
skills: [makeSkill("x")],
|
|
2302
|
+
removals: [],
|
|
2303
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2304
|
+
});
|
|
2305
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2306
|
+
server.resetLibraryInspection();
|
|
2307
|
+
// interval 0 → `now - last < 0` is never true → always syncs.
|
|
2308
|
+
const res = await runSync({
|
|
2309
|
+
serverUrl,
|
|
2310
|
+
apiKey: VALID_KEY,
|
|
2311
|
+
vendors: ["claudeCode"],
|
|
2312
|
+
throttle: true,
|
|
2313
|
+
minSyncIntervalMs: 0,
|
|
2314
|
+
});
|
|
2315
|
+
assert.equal(res.throttled ?? false, false);
|
|
2316
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
it("brackets the window: a 1h-old attempt throttles under a 2h interval, syncs under a 30-min one", async () => {
|
|
2320
|
+
server.setEtag('"v1"');
|
|
2321
|
+
server.setLibraryResponse({
|
|
2322
|
+
skills: [makeSkill("x")],
|
|
2323
|
+
removals: [],
|
|
2324
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2325
|
+
});
|
|
2326
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2327
|
+
const s = readLastSync();
|
|
2328
|
+
writeLastSync({
|
|
2329
|
+
etag: s.etag,
|
|
2330
|
+
syncedAt: s.syncedAt,
|
|
2331
|
+
skills: s.skills,
|
|
2332
|
+
cliVersion: s.cliVersion,
|
|
2333
|
+
lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
// Inside a 2h window → throttled (zero HTTP). A throttled skip does
|
|
2337
|
+
// not re-stamp, so the same 1h-old attempt then drives the second leg.
|
|
2338
|
+
server.resetLibraryInspection();
|
|
2339
|
+
server.resetReceipts();
|
|
2340
|
+
const inside = await runSync({
|
|
2341
|
+
serverUrl,
|
|
2342
|
+
apiKey: VALID_KEY,
|
|
2343
|
+
vendors: ["claudeCode"],
|
|
2344
|
+
throttle: true,
|
|
2345
|
+
minSyncIntervalMs: 2 * 60 * 60 * 1000,
|
|
2346
|
+
});
|
|
2347
|
+
assert.equal(inside.throttled, true, "1h elapsed < 2h window → throttled");
|
|
2348
|
+
assert.equal(server.getLibraryRequestCount(), 0);
|
|
2349
|
+
|
|
2350
|
+
// Outside a 30-min window → syncs.
|
|
2351
|
+
const outside = await runSync({
|
|
2352
|
+
serverUrl,
|
|
2353
|
+
apiKey: VALID_KEY,
|
|
2354
|
+
vendors: ["claudeCode"],
|
|
2355
|
+
throttle: true,
|
|
2356
|
+
minSyncIntervalMs: 30 * 60 * 1000,
|
|
2357
|
+
});
|
|
2358
|
+
assert.equal(outside.throttled ?? false, false, "1h elapsed >= 30-min window → syncs");
|
|
2359
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
it("a FUTURE lastAttemptAt (backward clock jump) syncs and self-heals instead of throttling until the clock catches up", async () => {
|
|
2363
|
+
server.setEtag('"v1"');
|
|
2364
|
+
server.setLibraryResponse({
|
|
2365
|
+
skills: [makeSkill("x")],
|
|
2366
|
+
removals: [],
|
|
2367
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2368
|
+
});
|
|
2369
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2370
|
+
|
|
2371
|
+
// A backward clock jump (NTP correction, VM snapshot restore) leaves a
|
|
2372
|
+
// stamp that is now in the FUTURE. `now - lastAttemptMs` goes negative —
|
|
2373
|
+
// an unguarded `< intervalMs` would throttle every hook sync until the
|
|
2374
|
+
// wall clock catches up, silently (hook modes are contract-silent).
|
|
2375
|
+
const s = readLastSync();
|
|
2376
|
+
writeLastSync({
|
|
2377
|
+
etag: s.etag,
|
|
2378
|
+
syncedAt: s.syncedAt,
|
|
2379
|
+
skills: s.skills,
|
|
2380
|
+
cliVersion: s.cliVersion,
|
|
2381
|
+
lastAttemptAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
2382
|
+
});
|
|
2383
|
+
server.resetLibraryInspection();
|
|
2384
|
+
server.resetReceipts();
|
|
2385
|
+
|
|
2386
|
+
const res = await runSync({
|
|
2387
|
+
serverUrl,
|
|
2388
|
+
apiKey: VALID_KEY,
|
|
2389
|
+
vendors: ["claudeCode"],
|
|
2390
|
+
throttle: true,
|
|
2391
|
+
});
|
|
2392
|
+
assert.equal(res.throttled ?? false, false, "a future stamp must NOT throttle");
|
|
2393
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
|
|
2394
|
+
// The sync (304 path here) re-stamps a sane "now" — the bogus future
|
|
2395
|
+
// value is gone after ONE pass; the next window measures from reality.
|
|
2396
|
+
const healed = readLastSync().lastAttemptAt;
|
|
2397
|
+
assert.ok(
|
|
2398
|
+
new Date(healed).getTime() <= Date.now(),
|
|
2399
|
+
"the sync self-healed the stamp back to a non-future time",
|
|
2400
|
+
);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
it("a malformed lastAttemptAt string ('not-a-date') is not trusted — the hook sync proceeds", async () => {
|
|
2404
|
+
server.setEtag('"v1"');
|
|
2405
|
+
server.setLibraryResponse({
|
|
2406
|
+
skills: [makeSkill("x")],
|
|
2407
|
+
removals: [],
|
|
2408
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2409
|
+
});
|
|
2410
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2411
|
+
|
|
2412
|
+
// readLastSync only normalizes NON-string values to absent; a string
|
|
2413
|
+
// that fails Date.parse reaches runSync's Number.isFinite guard. This
|
|
2414
|
+
// pins that guard: bogus clock → sync (safe direction), never throttle.
|
|
2415
|
+
const s = readLastSync();
|
|
2416
|
+
writeLastSync({
|
|
2417
|
+
etag: s.etag,
|
|
2418
|
+
syncedAt: s.syncedAt,
|
|
2419
|
+
skills: s.skills,
|
|
2420
|
+
cliVersion: s.cliVersion,
|
|
2421
|
+
lastAttemptAt: "not-a-date",
|
|
2422
|
+
});
|
|
2423
|
+
server.resetLibraryInspection();
|
|
2424
|
+
server.resetReceipts();
|
|
2425
|
+
|
|
2426
|
+
const res = await runSync({
|
|
2427
|
+
serverUrl,
|
|
2428
|
+
apiKey: VALID_KEY,
|
|
2429
|
+
vendors: ["claudeCode"],
|
|
2430
|
+
throttle: true,
|
|
2431
|
+
});
|
|
2432
|
+
assert.equal(res.throttled ?? false, false, "unparseable stamp must NOT throttle");
|
|
2433
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
|
|
2434
|
+
const healed = readLastSync().lastAttemptAt;
|
|
2435
|
+
assert.ok(
|
|
2436
|
+
typeof healed === "string" && Number.isFinite(Date.parse(healed)),
|
|
2437
|
+
"the sync replaced the malformed stamp with a valid timestamp",
|
|
2438
|
+
);
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
it("304 path stamps lastAttemptAt — LOAD-BEARING so the throttle re-arms every steady-state session", async () => {
|
|
2442
|
+
server.setEtag('"v1"');
|
|
2443
|
+
server.setLibraryResponse({
|
|
2444
|
+
skills: [makeSkill("x")],
|
|
2445
|
+
removals: [],
|
|
2446
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2447
|
+
});
|
|
2448
|
+
// First (interactive) sync stamps lastAttemptAt and the etag.
|
|
2449
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2450
|
+
assert.equal(
|
|
2451
|
+
typeof readLastSync().lastAttemptAt,
|
|
2452
|
+
"string",
|
|
2453
|
+
"first sync stamped lastAttemptAt",
|
|
2454
|
+
);
|
|
2455
|
+
|
|
2456
|
+
// Backdate so the next sync is NOT throttled but WILL 304 (etag matches,
|
|
2457
|
+
// placement present).
|
|
2458
|
+
const s = readLastSync();
|
|
2459
|
+
writeLastSync({
|
|
2460
|
+
etag: s.etag,
|
|
2461
|
+
syncedAt: s.syncedAt,
|
|
2462
|
+
skills: s.skills,
|
|
2463
|
+
cliVersion: s.cliVersion,
|
|
2464
|
+
lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
// Snapshot the full pre-304 state — the re-stamp must move ONLY the
|
|
2468
|
+
// attempt clock. etag/skills/cliVersion wiped here (e.g. a future
|
|
2469
|
+
// edit letting `skills` default to {}) would silently destroy every
|
|
2470
|
+
// user's per-skill SHA cache on their next up-to-date sync.
|
|
2471
|
+
const before = readLastSync();
|
|
2472
|
+
|
|
2473
|
+
const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2474
|
+
assert.equal(res.notModified, true, "etag match → 304");
|
|
2475
|
+
const after = readLastSync();
|
|
2476
|
+
assert.equal(typeof after.lastAttemptAt, "string");
|
|
2477
|
+
assert.ok(
|
|
2478
|
+
Date.now() - new Date(after.lastAttemptAt).getTime() < 60_000,
|
|
2479
|
+
"the 304 path re-stamped lastAttemptAt to a fresh time (throttle re-arms)",
|
|
2480
|
+
);
|
|
2481
|
+
assert.equal(after.etag, before.etag, "304 re-stamp preserves the etag verbatim");
|
|
2482
|
+
assert.deepEqual(
|
|
2483
|
+
after.skills,
|
|
2484
|
+
before.skills,
|
|
2485
|
+
"304 re-stamp preserves the per-skill SHA map verbatim",
|
|
2486
|
+
);
|
|
2487
|
+
assert.equal(
|
|
2488
|
+
after.cliVersion,
|
|
2489
|
+
before.cliVersion,
|
|
2490
|
+
"304 re-stamp preserves cliVersion (a 304 is not a membership heal)",
|
|
2491
|
+
);
|
|
2492
|
+
assert.equal(after.syncedAt, before.syncedAt, "304 re-stamp preserves syncedAt");
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
it("does NOT stamp lastAttemptAt on a filesIncomplete sync (a broken sync is retried, not throttled)", async () => {
|
|
2496
|
+
const inc = makeSkill("inc");
|
|
2497
|
+
inc.filesIncomplete = true;
|
|
2498
|
+
server.setEtag('"v1"');
|
|
2499
|
+
server.setLibraryResponse({ skills: [inc], removals: [], syncedAt: "x" });
|
|
2500
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2501
|
+
// filesIncomplete → the whole state-file write is skipped → no
|
|
2502
|
+
// lastAttemptAt persisted → the next hook sync is NOT throttled.
|
|
2503
|
+
assert.equal(readLastSync(), null, "no state persisted on filesIncomplete");
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
it("emits a SKILLREPO_VERBOSE stderr line when it throttles", async () => {
|
|
2507
|
+
const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
|
|
2508
|
+
const stderr = createCaptureStream();
|
|
2509
|
+
server.setEtag('"v1"');
|
|
2510
|
+
server.setLibraryResponse({
|
|
2511
|
+
skills: [makeSkill("x")],
|
|
2512
|
+
removals: [],
|
|
2513
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2514
|
+
});
|
|
2515
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2516
|
+
server.resetLibraryInspection();
|
|
2517
|
+
|
|
2518
|
+
const prevVerbose = process.env.SKILLREPO_VERBOSE;
|
|
2519
|
+
process.env.SKILLREPO_VERBOSE = "1";
|
|
2520
|
+
try {
|
|
2521
|
+
const res = await runSync({
|
|
2522
|
+
serverUrl,
|
|
2523
|
+
apiKey: VALID_KEY,
|
|
2524
|
+
vendors: ["claudeCode"],
|
|
2525
|
+
throttle: true,
|
|
2526
|
+
io: { stderr },
|
|
2527
|
+
});
|
|
2528
|
+
assert.equal(res.throttled, true);
|
|
2529
|
+
} finally {
|
|
2530
|
+
if (prevVerbose === undefined) delete process.env.SKILLREPO_VERBOSE;
|
|
2531
|
+
else process.env.SKILLREPO_VERBOSE = prevVerbose;
|
|
2532
|
+
}
|
|
2533
|
+
assert.match(stderr.text(), /throttled/);
|
|
2534
|
+
assert.equal(server.getLibraryRequestCount(), 0, "verbose throttle still makes no GET");
|
|
2535
|
+
});
|
|
2536
|
+
});
|