@indigoai-us/hq-cloud 6.14.23 → 6.14.25
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/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +11 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +11 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +37 -2
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +1 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +17 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/doctor.d.ts +119 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +485 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/doctor.test.d.ts +13 -0
- package/dist/cli/doctor.test.d.ts.map +1 -0
- package/dist/cli/doctor.test.js +485 -0
- package/dist/cli/doctor.test.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/share.js +6 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts +29 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +140 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +223 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.test.js +7 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/company-resolver.d.ts +31 -1
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +329 -21
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +371 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-file.d.ts.map +1 -1
- package/dist/lib/conflict-file.js +5 -1
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +7 -2
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/machine-id.test.js +14 -0
- package/dist/lib/machine-id.test.js.map +1 -1
- package/dist/local-path-codec.d.ts +18 -0
- package/dist/local-path-codec.d.ts.map +1 -1
- package/dist/local-path-codec.js +63 -0
- package/dist/local-path-codec.js.map +1 -1
- package/dist/local-path-codec.test.d.ts +14 -0
- package/dist/local-path-codec.test.d.ts.map +1 -0
- package/dist/local-path-codec.test.js +102 -0
- package/dist/local-path-codec.test.js.map +1 -0
- package/dist/machine-auth.test.js +7 -0
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/s3.d.ts +5 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +129 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.symlink-materialize.test.d.ts +18 -0
- package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
- package/dist/s3.symlink-materialize.test.js +394 -0
- package/dist/s3.symlink-materialize.test.js.map +1 -0
- package/dist/s3.test.js +19 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +24 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +17 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/telemetry.d.ts +10 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +95 -4
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +177 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +42 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +40 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +10 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +28 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +10 -0
- package/src/bin/sync-runner-planning.test.ts +1 -0
- package/src/bin/sync-runner-planning.ts +46 -3
- package/src/bin/sync-runner-watch-loop.ts +19 -1
- package/src/bin/sync-runner.test.ts +174 -0
- package/src/bin/sync-runner.ts +17 -1
- package/src/cli/doctor.test.ts +581 -0
- package/src/cli/doctor.ts +640 -0
- package/src/cli/index.ts +14 -0
- package/src/cli/share.ts +6 -1
- package/src/cli/sync.test.ts +281 -1
- package/src/cli/sync.ts +184 -2
- package/src/cognito-auth.test.ts +5 -0
- package/src/company-resolver.test.ts +432 -1
- package/src/company-resolver.ts +362 -23
- package/src/index.ts +10 -0
- package/src/lib/conflict-file.ts +6 -1
- package/src/lib/conflict-index.ts +7 -2
- package/src/lib/machine-id.test.ts +10 -0
- package/src/local-path-codec.test.ts +138 -0
- package/src/local-path-codec.ts +66 -0
- package/src/machine-auth.test.ts +5 -0
- package/src/s3.symlink-materialize.test.ts +492 -0
- package/src/s3.test.ts +24 -0
- package/src/s3.ts +148 -4
- package/src/skill-telemetry.test.ts +19 -0
- package/src/skill-telemetry.ts +32 -3
- package/src/telemetry.test.ts +241 -0
- package/src/telemetry.ts +124 -6
- package/src/vault-client.ts +68 -0
- package/src/watcher.test.ts +33 -0
- package/src/watcher.ts +10 -1
package/src/telemetry.ts
CHANGED
|
@@ -48,6 +48,14 @@ import type {
|
|
|
48
48
|
export interface TelemetryClientSurface {
|
|
49
49
|
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
50
50
|
postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
|
|
51
|
+
/**
|
|
52
|
+
* Optional so an older client (or a narrow test stub) still satisfies the
|
|
53
|
+
* surface — when it is absent the consent self-heal is simply skipped.
|
|
54
|
+
*/
|
|
55
|
+
setTelemetryOptIn?(
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
opts?: { onlyIfUnset?: boolean },
|
|
58
|
+
): Promise<{ applied: boolean } | void>;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export interface CollectTelemetryOptions {
|
|
@@ -79,7 +87,7 @@ export interface CollectTelemetryResult {
|
|
|
79
87
|
/** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
|
|
80
88
|
enabled: boolean;
|
|
81
89
|
/** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
|
|
82
|
-
optInSource: "server" | "menubar-fallback" | "skipped";
|
|
90
|
+
optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "skipped";
|
|
83
91
|
/** How many `.jsonl` files we considered (before the cursor diff). */
|
|
84
92
|
filesScanned: number;
|
|
85
93
|
/** Total events successfully POSTed across all batches. */
|
|
@@ -129,12 +137,58 @@ async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<
|
|
|
129
137
|
// ── Local opt-in fallback ─────────────────────────────────────────────────────
|
|
130
138
|
|
|
131
139
|
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
140
|
+
return (await readLocalTelemetryPreference(menubarPath)) === true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Tri-state read of the locally-stored consent.
|
|
145
|
+
*
|
|
146
|
+
* `~/.hq/menubar.json` → `telemetryEnabled` is written by the installer the
|
|
147
|
+
* moment the user answers the prompt, and that local write ALWAYS succeeds —
|
|
148
|
+
* unlike the paired server write, which fires before the person entity exists
|
|
149
|
+
* and 404s. So this file is frequently the ONLY durable record of the user's
|
|
150
|
+
* actual choice.
|
|
151
|
+
*
|
|
152
|
+
* `undefined` means absent / unreadable / not a boolean — "we hold no answer" —
|
|
153
|
+
* as distinct from `false`, which is a real opt-out. The self-heal path must
|
|
154
|
+
* never conflate the two: it replays an answer, it does not invent one.
|
|
155
|
+
*/
|
|
156
|
+
async function readLocalTelemetryPreference(
|
|
157
|
+
menubarPath: string,
|
|
158
|
+
): Promise<boolean | undefined> {
|
|
159
|
+
return (await readLocalConsentRecord(menubarPath)).enabled;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The locally-cached consent plus the account it belongs to.
|
|
164
|
+
*
|
|
165
|
+
* `telemetryOptInPersonUid` binds the answer to the `prs_*` that gave it.
|
|
166
|
+
* `menubar.json` is a per-MACHINE file, so when two people sign in under the
|
|
167
|
+
* same OS user it holds whoever answered LAST — replaying it unconditionally
|
|
168
|
+
* would opt in an account that never consented. The binding is what makes the
|
|
169
|
+
* replay safe; an unbound (legacy) record cannot be proven to belong to the
|
|
170
|
+
* current caller and is therefore never replayed.
|
|
171
|
+
*/
|
|
172
|
+
async function readLocalConsentRecord(
|
|
173
|
+
menubarPath: string,
|
|
174
|
+
): Promise<{ enabled?: boolean; personUid?: string }> {
|
|
132
175
|
try {
|
|
133
176
|
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
134
|
-
const parsed = JSON.parse(raw) as {
|
|
135
|
-
|
|
177
|
+
const parsed = JSON.parse(raw) as {
|
|
178
|
+
telemetryEnabled?: unknown;
|
|
179
|
+
telemetryOptInPersonUid?: unknown;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
enabled:
|
|
183
|
+
typeof parsed.telemetryEnabled === "boolean" ? parsed.telemetryEnabled : undefined,
|
|
184
|
+
personUid:
|
|
185
|
+
typeof parsed.telemetryOptInPersonUid === "string" &&
|
|
186
|
+
parsed.telemetryOptInPersonUid.length > 0
|
|
187
|
+
? parsed.telemetryOptInPersonUid
|
|
188
|
+
: undefined,
|
|
189
|
+
};
|
|
136
190
|
} catch {
|
|
137
|
-
return
|
|
191
|
+
return {};
|
|
138
192
|
}
|
|
139
193
|
}
|
|
140
194
|
|
|
@@ -422,15 +476,79 @@ export async function collectAndSendTelemetry(
|
|
|
422
476
|
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
423
477
|
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
424
478
|
? await buildRepoCompanyMap(opts.hqRoot)
|
|
425
|
-
: { entries: [], bySlug: new Map() };
|
|
479
|
+
: { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
|
|
426
480
|
|
|
427
|
-
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
481
|
+
// 1. Opt-in check (server-authoritative, with local fallback + self-heal).
|
|
428
482
|
let enabled: boolean;
|
|
429
483
|
let optInSource: CollectTelemetryResult["optInSource"];
|
|
430
484
|
try {
|
|
431
485
|
const resp = await opts.client.getTelemetryOptIn();
|
|
432
486
|
enabled = resp.enabled === true;
|
|
433
487
|
optInSource = "server";
|
|
488
|
+
|
|
489
|
+
// Self-heal a consent the user gave but that never reached the server.
|
|
490
|
+
//
|
|
491
|
+
// The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
|
|
492
|
+
// AND posts it to `/v1/usage/opt-in` — but that post fires before the
|
|
493
|
+
// person entity exists, so it 404s and the attribute is never written.
|
|
494
|
+
// Absence then reads as `false`, the emitter goes silent, and the person
|
|
495
|
+
// shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
|
|
496
|
+
// had no attribute at all, against only 2 genuine opt-outs.
|
|
497
|
+
//
|
|
498
|
+
// `unset` (never answered) is the ONLY state we heal. An explicit server
|
|
499
|
+
// `false` is a real opt-out and is left strictly alone. We also require a
|
|
500
|
+
// local answer to actually exist — we never invent consent, we only replay
|
|
501
|
+
// the answer the user already gave.
|
|
502
|
+
//
|
|
503
|
+
// And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
|
|
504
|
+
// when two people sign in under the same OS user it holds whoever answered
|
|
505
|
+
// last; replaying that for the second account would opt in someone who
|
|
506
|
+
// never consented. The replay therefore requires the cached record to name
|
|
507
|
+
// the same `prs_*` the server says we are. A legacy record with no binding
|
|
508
|
+
// cannot be proven to belong to this caller and is skipped — for those
|
|
509
|
+
// machines the installer's own post-sign-in upload is the recovery path,
|
|
510
|
+
// and it is correctly account-scoped because it runs right after that
|
|
511
|
+
// person authenticates.
|
|
512
|
+
if (resp.unset === true && opts.client.setTelemetryOptIn) {
|
|
513
|
+
const record = await readLocalConsentRecord(menubarPath);
|
|
514
|
+
const local = record.enabled;
|
|
515
|
+
const boundToCaller =
|
|
516
|
+
record.personUid !== undefined &&
|
|
517
|
+
resp.personUid !== undefined &&
|
|
518
|
+
record.personUid === resp.personUid;
|
|
519
|
+
if (local !== undefined && !boundToCaller) {
|
|
520
|
+
log(
|
|
521
|
+
"[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (local !== undefined && boundToCaller) {
|
|
525
|
+
try {
|
|
526
|
+
// `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
|
|
527
|
+
// are two requests, so without it another device could record a real
|
|
528
|
+
// opt-out in between and this stale local answer would overwrite it.
|
|
529
|
+
// The server tests "still unset?" as part of the write instead.
|
|
530
|
+
const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
|
|
531
|
+
if (ack && ack.applied === false) {
|
|
532
|
+
// Lost the race — a real answer was recorded first and it stands.
|
|
533
|
+
// Keep the server's answer for this run rather than the local one.
|
|
534
|
+
log(
|
|
535
|
+
"[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server",
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
enabled = local;
|
|
539
|
+
optInSource = "menubar-reasserted";
|
|
540
|
+
log(
|
|
541
|
+
`[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Non-fatal: fall through on the server's answer. Next run retries.
|
|
546
|
+
log(
|
|
547
|
+
`[telemetry] failed to re-assert local opt-in (${(err as Error).message ?? err})`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
434
552
|
} catch (err) {
|
|
435
553
|
log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
|
|
436
554
|
enabled = await readLocalTelemetryEnabled(menubarPath);
|
package/src/vault-client.ts
CHANGED
|
@@ -414,6 +414,25 @@ export interface VendChildResult {
|
|
|
414
414
|
export interface TelemetryOptInResponse {
|
|
415
415
|
enabled: boolean;
|
|
416
416
|
updatedAt: string | null;
|
|
417
|
+
/**
|
|
418
|
+
* `true` when the person row carries NO `telemetryOptIn` attribute — i.e. the
|
|
419
|
+
* consent question has never been answered server-side. Distinct from
|
|
420
|
+
* `enabled: false`, which is a deliberate opt-OUT.
|
|
421
|
+
*
|
|
422
|
+
* Optional because older servers omit it entirely. Absent is treated as
|
|
423
|
+
* `false`, so a client talking to one behaves exactly as before — no
|
|
424
|
+
* self-heal, no surprise writes.
|
|
425
|
+
*/
|
|
426
|
+
unset?: boolean;
|
|
427
|
+
/**
|
|
428
|
+
* The `prs_*` uid this answer belongs to — i.e. the authenticated caller.
|
|
429
|
+
*
|
|
430
|
+
* Needed because the local consent cache is a per-MACHINE file: if two people
|
|
431
|
+
* sign in under the same OS user it holds whoever answered last. A client
|
|
432
|
+
* must not replay it for a different account. Optional (older servers omit
|
|
433
|
+
* it), and absence means the replay cannot be proven safe, so it is skipped.
|
|
434
|
+
*/
|
|
435
|
+
personUid?: string;
|
|
417
436
|
}
|
|
418
437
|
|
|
419
438
|
export interface UsageBatch {
|
|
@@ -637,9 +656,26 @@ const telemetryOptInResponseSchema: VaultResponseSchema<TelemetryOptInResponse>
|
|
|
637
656
|
.object({
|
|
638
657
|
enabled: z.boolean().default(false),
|
|
639
658
|
updatedAt: z.string().nullable().default(null),
|
|
659
|
+
personUid: z.string().optional(),
|
|
660
|
+
// `.optional()`, NOT `.default(false)`: an older server omits this field,
|
|
661
|
+
// and materializing it would change the parsed response shape for every
|
|
662
|
+
// legacy caller. Callers test `unset === true`, so absent reads as
|
|
663
|
+
// "not unset" and the self-heal path is skipped, which is the pre-existing
|
|
664
|
+
// behaviour exactly.
|
|
665
|
+
unset: z.boolean().optional(),
|
|
640
666
|
})
|
|
641
667
|
.strip();
|
|
642
668
|
|
|
669
|
+
/**
|
|
670
|
+
* `POST /v1/usage/opt-in` acknowledgement.
|
|
671
|
+
*
|
|
672
|
+
* `applied` is absent on older servers (which always wrote unconditionally), so
|
|
673
|
+
* it stays optional and callers treat absence as "the write landed".
|
|
674
|
+
*/
|
|
675
|
+
const telemetryOptInAckSchema: VaultResponseSchema<{ ok: boolean; applied?: boolean }> = z
|
|
676
|
+
.object({ ok: z.boolean().default(true), applied: z.boolean().optional() })
|
|
677
|
+
.strip();
|
|
678
|
+
|
|
643
679
|
const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
|
|
644
680
|
.object({
|
|
645
681
|
ok: z.boolean(),
|
|
@@ -1353,6 +1389,38 @@ export class VaultClient {
|
|
|
1353
1389
|
return this.get("/v1/usage/opt-in", telemetryOptInResponseSchema);
|
|
1354
1390
|
}
|
|
1355
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* `POST /v1/usage/opt-in` — record the authenticated caller's consent.
|
|
1394
|
+
*
|
|
1395
|
+
* The installer is the primary writer (it owns the consent prompt). This
|
|
1396
|
+
* client-side setter exists so the sync runner can RE-ASSERT a consent the
|
|
1397
|
+
* user already gave locally but which never reached the server: the
|
|
1398
|
+
* installer's write fires before the person entity exists and 404s on
|
|
1399
|
+
* `no-person-entity`, so the answer survives only in `~/.hq/menubar.json`.
|
|
1400
|
+
* See `./telemetry.ts::collectAndSendTelemetry`, which calls this ONLY when
|
|
1401
|
+
* the server reports `unset` — never over an explicit opt-out.
|
|
1402
|
+
*
|
|
1403
|
+
* `onlyIfUnset` makes the server write conditional on the consent still never
|
|
1404
|
+
* having been recorded. The self-heal MUST pass it: reading `unset` and
|
|
1405
|
+
* replaying the answer are two separate requests, so without the condition
|
|
1406
|
+
* another device could record a real opt-out in between and this replay would
|
|
1407
|
+
* silently overwrite it. A deliberate user choice omits the flag so it always
|
|
1408
|
+
* wins. The response's `applied` reports whether the write landed.
|
|
1409
|
+
*/
|
|
1410
|
+
async setTelemetryOptIn(
|
|
1411
|
+
enabled: boolean,
|
|
1412
|
+
opts?: { onlyIfUnset?: boolean },
|
|
1413
|
+
): Promise<{ applied: boolean }> {
|
|
1414
|
+
const ack = await this.post(
|
|
1415
|
+
"/v1/usage/opt-in",
|
|
1416
|
+
opts?.onlyIfUnset ? { enabled, onlyIfUnset: true } : { enabled },
|
|
1417
|
+
telemetryOptInAckSchema,
|
|
1418
|
+
);
|
|
1419
|
+
// Older servers answer `{ ok: true }` with no `applied`; they also write
|
|
1420
|
+
// unconditionally, so a 2xx there means the write did land.
|
|
1421
|
+
return { applied: ack.applied ?? true };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1356
1424
|
/**
|
|
1357
1425
|
* `POST /v1/usage` — upload a batch of sanitized telemetry events.
|
|
1358
1426
|
*
|
package/src/watcher.test.ts
CHANGED
|
@@ -796,3 +796,36 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
796
796
|
]);
|
|
797
797
|
});
|
|
798
798
|
});
|
|
799
|
+
|
|
800
|
+
describe("TreeWatcher wire-key codec boundary (US-002)", () => {
|
|
801
|
+
it("emits the canonical vault key for an encoded win32 farm name (verbatim local name on POSIX)", () => {
|
|
802
|
+
const hqRoot = path.join(os.tmpdir(), "hq-watch-codec-test-root");
|
|
803
|
+
const clock = new FakeClock();
|
|
804
|
+
const watcher = new TreeWatcher({
|
|
805
|
+
hqRoot,
|
|
806
|
+
clock,
|
|
807
|
+
debounceMs: 100,
|
|
808
|
+
pathFilter: () => true,
|
|
809
|
+
});
|
|
810
|
+
const rels: string[] = [];
|
|
811
|
+
watcher.onChange((_first, batch) => {
|
|
812
|
+
if (batch) rels.push(...batch.paths.values());
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
watcher.handleEvent(
|
|
816
|
+
path.join(hqRoot, ".claude", "skills", "indigo%3Acall-prep-brief"),
|
|
817
|
+
);
|
|
818
|
+
clock.advance(200);
|
|
819
|
+
|
|
820
|
+
// The batch rel is a WIRE identifier (PushEvent.relativePath / targeted
|
|
821
|
+
// push route). On win32 the encoded local farm name must decode back to
|
|
822
|
+
// the canonical key or event-driven push re-mints the junk key family;
|
|
823
|
+
// on POSIX the local name IS the key verbatim (codec is identity there).
|
|
824
|
+
const expectedRel =
|
|
825
|
+
process.platform === "win32"
|
|
826
|
+
? ".claude/skills/indigo:call-prep-brief"
|
|
827
|
+
: ".claude/skills/indigo%3Acall-prep-brief";
|
|
828
|
+
expect(rels).toEqual([expectedRel]);
|
|
829
|
+
watcher.dispose();
|
|
830
|
+
});
|
|
831
|
+
});
|
package/src/watcher.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { watch } from "chokidar";
|
|
14
14
|
import { createIgnoreFilter } from "./ignore.js";
|
|
15
|
+
import { vaultKeyForLocalPath } from "./local-path-codec.js";
|
|
15
16
|
import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
|
|
16
17
|
import {
|
|
17
18
|
AGENCY_SYNC_REL,
|
|
@@ -734,7 +735,15 @@ export class TreeWatcher {
|
|
|
734
735
|
// authoritative emit gate; the chokidar backend's `ignored` pre-filters
|
|
735
736
|
// but a slipped-through (or synthetic test) path is re-checked here too.
|
|
736
737
|
if (!this.shouldEmit(abs, isDir)) return;
|
|
737
|
-
|
|
738
|
+
// Codec boundary: the batch's relative path is a WIRE identifier — it
|
|
739
|
+
// becomes `PushEvent.relativePath` and the targeted-push route key — so
|
|
740
|
+
// a win32 local farm name (`indigo%3Ax`) must decode back to its
|
|
741
|
+
// canonical vault-key spelling (`indigo:x`) here. Emitting the encoded
|
|
742
|
+
// local name verbatim would mint a fresh junk key family on the wire
|
|
743
|
+
// (the mixed-version amplifier). The emit filter above still sees the
|
|
744
|
+
// raw local path: its exclusion patterns live in local-name space and
|
|
745
|
+
// none of them contain codec-reserved characters.
|
|
746
|
+
const rel = vaultKeyForLocalPath(this.hqRoot, abs);
|
|
738
747
|
if (!this.pending.has(abs)) {
|
|
739
748
|
const entryBytes = estimatePendingEntryBytes(abs, rel);
|
|
740
749
|
if (
|