@indigoai-us/hq-cloud 6.14.0 → 6.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +39 -10
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +40 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +53 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +44 -13
- package/src/cli/reindex.ts +74 -21
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +22 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +82 -0
- package/src/watcher.ts +69 -0
package/src/cli/accept.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VaultPermissionDeniedError,
|
|
15
15
|
} from "../vault-client.js";
|
|
16
16
|
import type { Membership } from "../vault-client.js";
|
|
17
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
17
18
|
|
|
18
19
|
export interface AcceptOptions {
|
|
19
20
|
/** Raw token or magic link (hq://accept/<token>) */
|
|
@@ -78,8 +79,26 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
void emitCloudTelemetry(client, {
|
|
83
|
+
eventName: "membership_accept",
|
|
84
|
+
source: "hq-cli",
|
|
85
|
+
companyUid: membership.companyUid,
|
|
86
|
+
properties: {
|
|
87
|
+
role: membership.role,
|
|
88
|
+
result: "success",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
81
92
|
return { membership, companySlug };
|
|
82
93
|
} catch (err) {
|
|
94
|
+
void emitCloudTelemetry(client, {
|
|
95
|
+
eventName: "membership_accept",
|
|
96
|
+
source: "hq-cli",
|
|
97
|
+
properties: {
|
|
98
|
+
result: "failure",
|
|
99
|
+
errorCode: classifyAcceptError(err),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
83
102
|
if (err instanceof VaultAuthError) {
|
|
84
103
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
85
104
|
}
|
|
@@ -95,3 +114,11 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
|
|
|
95
114
|
throw err;
|
|
96
115
|
}
|
|
97
116
|
}
|
|
117
|
+
|
|
118
|
+
function classifyAcceptError(err: unknown): string {
|
|
119
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
120
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
121
|
+
if (err instanceof VaultNotFoundError) return "not_found";
|
|
122
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
123
|
+
return "error";
|
|
124
|
+
}
|
package/src/cli/invite.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VaultConflictError,
|
|
15
15
|
} from "../vault-client.js";
|
|
16
16
|
import type { MembershipRole, Membership } from "../vault-client.js";
|
|
17
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
17
18
|
|
|
18
19
|
export interface InviteOptions {
|
|
19
20
|
/** Target — email address or person slug/uid */
|
|
@@ -83,6 +84,17 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
const magicLink = `hq://accept/${result.inviteToken}`;
|
|
87
|
+
void emitCloudTelemetry(client, {
|
|
88
|
+
eventName: "membership_invite",
|
|
89
|
+
source: "hq-cli",
|
|
90
|
+
companyUid,
|
|
91
|
+
properties: {
|
|
92
|
+
role,
|
|
93
|
+
result: "success",
|
|
94
|
+
targetType: isEmail ? "email" : "person",
|
|
95
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
86
98
|
|
|
87
99
|
return {
|
|
88
100
|
inviteToken: result.inviteToken,
|
|
@@ -90,6 +102,18 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
90
102
|
membership: result.membership,
|
|
91
103
|
};
|
|
92
104
|
} catch (err) {
|
|
105
|
+
void emitCloudTelemetry(client, {
|
|
106
|
+
eventName: "membership_invite",
|
|
107
|
+
source: "hq-cli",
|
|
108
|
+
companyUid,
|
|
109
|
+
properties: {
|
|
110
|
+
role,
|
|
111
|
+
result: "failure",
|
|
112
|
+
errorCode: classifyInviteError(err),
|
|
113
|
+
targetType: isEmail ? "email" : "person",
|
|
114
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
93
117
|
if (err instanceof VaultAuthError) {
|
|
94
118
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
95
119
|
}
|
|
@@ -103,6 +127,13 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
function classifyInviteError(err: unknown): string {
|
|
131
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
132
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
133
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
134
|
+
return "error";
|
|
135
|
+
}
|
|
136
|
+
|
|
106
137
|
/**
|
|
107
138
|
* List pending invites for a company.
|
|
108
139
|
*/
|
package/src/cli/promote.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
VaultConflictError,
|
|
14
14
|
} from "../vault-client.js";
|
|
15
15
|
import type { MembershipRole, Membership } from "../vault-client.js";
|
|
16
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
16
17
|
|
|
17
18
|
export interface PromoteOptions {
|
|
18
19
|
/** Person slug or UID of the member to promote */
|
|
@@ -66,8 +67,30 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
|
|
|
66
67
|
companyUid,
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
void emitCloudTelemetry(client, {
|
|
71
|
+
eventName: "membership_promote",
|
|
72
|
+
source: "hq-cli",
|
|
73
|
+
companyUid,
|
|
74
|
+
properties: {
|
|
75
|
+
role: newRole,
|
|
76
|
+
result: "success",
|
|
77
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
69
81
|
return { membership };
|
|
70
82
|
} catch (err) {
|
|
83
|
+
void emitCloudTelemetry(client, {
|
|
84
|
+
eventName: "membership_promote",
|
|
85
|
+
source: "hq-cli",
|
|
86
|
+
companyUid,
|
|
87
|
+
properties: {
|
|
88
|
+
role: newRole,
|
|
89
|
+
result: "failure",
|
|
90
|
+
errorCode: classifyPromoteError(err),
|
|
91
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
71
94
|
if (err instanceof VaultAuthError) {
|
|
72
95
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
73
96
|
}
|
|
@@ -84,6 +107,14 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
|
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
function classifyPromoteError(err: unknown): string {
|
|
111
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
112
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
113
|
+
if (err instanceof VaultNotFoundError) return "not_found";
|
|
114
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
115
|
+
return "error";
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
// ---------------------------------------------------------------------------
|
|
88
119
|
// Helpers
|
|
89
120
|
// ---------------------------------------------------------------------------
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -389,17 +389,46 @@ describe("reindex", () => {
|
|
|
389
389
|
expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
-
// ── operation lock
|
|
392
|
+
// ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
|
|
393
|
+
|
|
394
|
+
// Regression for feedback_ed98d810 (the "hq reindex indefinite hang"): when
|
|
395
|
+
// a watch-mode hq-sync-runner holds the per-root "operation" lock for its
|
|
396
|
+
// whole lifetime, a shared lock starved `hq reindex` forever. reindex now
|
|
397
|
+
// takes its own "reindex" scope, so it must run to completion even while the
|
|
398
|
+
// sync/watch "operation" lock is held by a LIVE holder.
|
|
399
|
+
it("completes while the sync/watch operation lock is held (no starvation)", () => {
|
|
400
|
+
// Simulate the long-lived watch runner holding the "operation"-scope lock.
|
|
401
|
+
// pid = this process → the lock probes it as ALIVE (a live same-PID holder
|
|
402
|
+
// is treated as busy, never reclaimed), so it is a genuine live holder.
|
|
403
|
+
const syncLock = lockPathFor(root); // default "operation" scope
|
|
404
|
+
fs.mkdirSync(path.dirname(syncLock), { recursive: true });
|
|
405
|
+
fs.writeFileSync(
|
|
406
|
+
syncLock,
|
|
407
|
+
JSON.stringify({
|
|
408
|
+
pid: process.pid,
|
|
409
|
+
command: "sync",
|
|
410
|
+
startedAt: new Date(0).toISOString(),
|
|
411
|
+
hqRoot: root,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
// Even with a zero-wait bound (never block), reindex acquires its OWN scope
|
|
415
|
+
// and finishes — proving the operation lock no longer gates it.
|
|
416
|
+
const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
|
|
417
|
+
expect(status).toBe(0);
|
|
418
|
+
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
|
|
419
|
+
// The sync/watch holder is untouched.
|
|
420
|
+
expect(fs.existsSync(syncLock)).toBe(true);
|
|
421
|
+
});
|
|
393
422
|
|
|
394
|
-
it("refuses (OPERATION_LOCKED_EXIT) when another
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// wait-then-acquire path is
|
|
398
|
-
const lp = lockPathFor(root);
|
|
423
|
+
it("refuses (OPERATION_LOCKED_EXIT) when another reindex holds the reindex-scope lock (lockTimeoutSec:0)", () => {
|
|
424
|
+
// reindex-vs-reindex is still mutually exclusive. A live holder on the
|
|
425
|
+
// "reindex" scope → with lockTimeoutSec:0 reindex refuses immediately and
|
|
426
|
+
// does no work. (The wait-then-acquire path is in operation-lock.test.ts.)
|
|
427
|
+
const lp = lockPathFor(root, "reindex");
|
|
399
428
|
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
400
429
|
fs.writeFileSync(
|
|
401
430
|
lp,
|
|
402
|
-
JSON.stringify({ pid: 1, command: "
|
|
431
|
+
JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
403
432
|
);
|
|
404
433
|
const before = fs.existsSync(path.join(root, ".claude/skills"));
|
|
405
434
|
const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
|
|
@@ -408,22 +437,24 @@ describe("reindex", () => {
|
|
|
408
437
|
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
|
|
409
438
|
});
|
|
410
439
|
|
|
411
|
-
it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
|
|
412
|
-
const lp = lockPathFor(root);
|
|
440
|
+
it("skipLock bypasses the reindex lock (internal sync/rescue caller path)", () => {
|
|
441
|
+
const lp = lockPathFor(root, "reindex");
|
|
413
442
|
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
414
443
|
fs.writeFileSync(
|
|
415
444
|
lp,
|
|
416
|
-
JSON.stringify({ pid: 1, command: "
|
|
445
|
+
JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
417
446
|
);
|
|
418
|
-
// Even with a live holder on record, the internal caller (which
|
|
419
|
-
//
|
|
447
|
+
// Even with a live reindex holder on record, the internal caller (which
|
|
448
|
+
// serializes reindex itself at the tail of a pass) runs to completion.
|
|
420
449
|
const { status } = reindex({ repoRoot: root, skipLock: true });
|
|
421
450
|
expect(status).toBe(0);
|
|
422
451
|
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
|
|
423
452
|
});
|
|
424
453
|
|
|
425
|
-
it("releases the lock after a normal run (no leftover lock file)", () => {
|
|
454
|
+
it("releases the reindex-scope lock after a normal run (no leftover lock file)", () => {
|
|
426
455
|
reindex({ repoRoot: root });
|
|
456
|
+
expect(fs.existsSync(lockPathFor(root, "reindex"))).toBe(false);
|
|
457
|
+
// ...and never created the sync/rescue "operation" lock.
|
|
427
458
|
expect(fs.existsSync(lockPathFor(root))).toBe(false);
|
|
428
459
|
});
|
|
429
460
|
|
package/src/cli/reindex.ts
CHANGED
|
@@ -25,29 +25,45 @@ import {
|
|
|
25
25
|
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
26
26
|
type LockHandle,
|
|
27
27
|
} from "../operation-lock.js";
|
|
28
|
+
import {
|
|
29
|
+
emitCloudTelemetry,
|
|
30
|
+
type CloudTelemetryClient,
|
|
31
|
+
type TelemetryClaims,
|
|
32
|
+
} from "../telemetry-events.js";
|
|
28
33
|
|
|
29
34
|
export interface ReindexOptions {
|
|
30
35
|
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
31
36
|
repoRoot?: string;
|
|
32
37
|
/**
|
|
33
|
-
* Skip
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* Skip reindex's own per-root "reindex"-scope lock. Internal callers
|
|
39
|
+
* (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
|
|
40
|
+
* already serialize, and pass `true` so a standalone `hq reindex` holding the
|
|
41
|
+
* reindex lock never blocks the sync/rescue tail (reindex is idempotent and
|
|
42
|
+
* re-run every pass, so a benign overlap self-heals). Standalone callers
|
|
43
|
+
* (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
|
|
44
|
+
* mutually exclusive.
|
|
38
45
|
*/
|
|
39
46
|
skipLock?: boolean;
|
|
40
47
|
/**
|
|
41
|
-
* Bounded wait (seconds) for the per-root
|
|
42
|
-
* is already running. `0` → refuse immediately; omitted → inherit
|
|
48
|
+
* Bounded wait (seconds) for the per-root "reindex"-scope lock when another
|
|
49
|
+
* reindex is already running. `0` → refuse immediately; omitted → inherit
|
|
43
50
|
* `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
|
|
44
51
|
*
|
|
45
|
-
* NB:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
52
|
+
* NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
|
|
53
|
+
* so a long-lived watch-mode sync-runner (which holds the operation lock for
|
|
54
|
+
* its whole lifetime) can never starve a standalone `hq reindex`
|
|
55
|
+
* (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
|
|
56
|
+
* a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
|
|
57
|
+
* should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
|
|
58
|
+
* another reindex runs never blocks the interactive agent indefinitely.
|
|
49
59
|
*/
|
|
50
60
|
lockTimeoutSec?: number;
|
|
61
|
+
/** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
|
|
62
|
+
telemetryClient?: CloudTelemetryClient | null;
|
|
63
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
64
|
+
telemetrySessionId?: string;
|
|
65
|
+
telemetryCompanyUid?: string;
|
|
66
|
+
changedCount?: number;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
export interface ReindexResult {
|
|
@@ -202,23 +218,32 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
202
218
|
const rawRoot = opts.repoRoot ?? process.cwd();
|
|
203
219
|
if (!isDir(rawRoot)) {
|
|
204
220
|
warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
|
|
205
|
-
|
|
221
|
+
const result = { status: 1 };
|
|
222
|
+
emitReindexTelemetry(opts, result, "not_attempted");
|
|
223
|
+
return result;
|
|
206
224
|
}
|
|
207
225
|
const root = path.resolve(rawRoot);
|
|
208
226
|
|
|
209
|
-
// Acquire the per-root
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
227
|
+
// Acquire the per-root "reindex"-scope lock unless an internal caller
|
|
228
|
+
// (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
|
|
229
|
+
// "operation" lock, so a long-lived watch-mode sync-runner holding the
|
|
230
|
+
// operation lock never starves us (feedback_ed98d810) — we only contend with
|
|
231
|
+
// other reindexes. A live holder → refuse fast with the holder's command +
|
|
232
|
+
// PID. The whole body runs inside the try so the lock is released on every
|
|
233
|
+
// exit path (the process-level signal/exit hooks are the crash backstop).
|
|
214
234
|
let opLock: LockHandle | null = null;
|
|
215
235
|
if (!opts.skipLock) {
|
|
216
236
|
try {
|
|
217
|
-
opLock = acquireOperationLock(root, "reindex", {
|
|
237
|
+
opLock = acquireOperationLock(root, "reindex", {
|
|
238
|
+
scope: "reindex",
|
|
239
|
+
timeoutSec: opts.lockTimeoutSec,
|
|
240
|
+
});
|
|
218
241
|
} catch (err) {
|
|
219
242
|
if (err instanceof OperationLockedError) {
|
|
220
243
|
warn(err.message);
|
|
221
|
-
|
|
244
|
+
const result = { status: OPERATION_LOCKED_EXIT };
|
|
245
|
+
emitReindexTelemetry(opts, result, "locked");
|
|
246
|
+
return result;
|
|
222
247
|
}
|
|
223
248
|
if (err instanceof OperationLockUnwritableError) {
|
|
224
249
|
// The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
@@ -227,7 +252,9 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
227
252
|
// and exit cleanly instead of crashing with a raw `EPERM ... open`
|
|
228
253
|
// (HQ-CLI-2).
|
|
229
254
|
warn(err.message);
|
|
230
|
-
|
|
255
|
+
const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
|
|
256
|
+
emitReindexTelemetry(opts, result, "unwritable");
|
|
257
|
+
return result;
|
|
231
258
|
}
|
|
232
259
|
throw err;
|
|
233
260
|
}
|
|
@@ -548,8 +575,34 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
548
575
|
);
|
|
549
576
|
}
|
|
550
577
|
|
|
551
|
-
|
|
578
|
+
const result = { status: 0 };
|
|
579
|
+
emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
|
|
580
|
+
return result;
|
|
552
581
|
} finally {
|
|
553
582
|
opLock?.release();
|
|
554
583
|
}
|
|
555
584
|
}
|
|
585
|
+
|
|
586
|
+
function emitReindexTelemetry(
|
|
587
|
+
opts: ReindexOptions,
|
|
588
|
+
result: ReindexResult,
|
|
589
|
+
lockResult: string,
|
|
590
|
+
): void {
|
|
591
|
+
void emitCloudTelemetry(
|
|
592
|
+
opts.telemetryClient,
|
|
593
|
+
{
|
|
594
|
+
eventName: "reindex_run",
|
|
595
|
+
source: "hq-reindex",
|
|
596
|
+
...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
|
|
597
|
+
...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
|
|
598
|
+
properties: {
|
|
599
|
+
result: result.status === 0 ? "completed" : "failed",
|
|
600
|
+
statusCode: result.status,
|
|
601
|
+
lockResult,
|
|
602
|
+
changedCount: opts.changedCount ?? 0,
|
|
603
|
+
skipLockState: opts.skipLock === true ? "skipped" : "acquired",
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
{ claims: opts.telemetryClaims },
|
|
607
|
+
);
|
|
608
|
+
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -1265,8 +1265,8 @@ describe("share", () => {
|
|
|
1265
1265
|
});
|
|
1266
1266
|
|
|
1267
1267
|
it("a file over the size limit is a benign skip-size-limit event, NEVER an error (HQ-SYNC-4 exit-2 flood)", async () => {
|
|
1268
|
-
// Regression for the
|
|
1269
|
-
// (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ machines). An
|
|
1268
|
+
// Regression for the "auto-sync watcher exited unexpectedly
|
|
1269
|
+
// (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ user machines). An
|
|
1270
1270
|
// oversized file is a permanent, benign skip — it exceeds the 50 MB cap on
|
|
1271
1271
|
// EVERY pass, forever. Emitting it as `type: "error"` pushed it into the
|
|
1272
1272
|
// runner's `errors[]`, so the pass returned exit 2, which the menubar
|
|
@@ -4992,3 +4992,140 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4992
4992
|
});
|
|
4993
4993
|
});
|
|
4994
4994
|
});
|
|
4995
|
+
|
|
4996
|
+
describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
|
|
4997
|
+
let tmpDir: string;
|
|
4998
|
+
let stateDir: string;
|
|
4999
|
+
|
|
5000
|
+
beforeEach(() => {
|
|
5001
|
+
clearContextCache();
|
|
5002
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-poison-test-"));
|
|
5003
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
|
|
5004
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
5005
|
+
setupFetchMock();
|
|
5006
|
+
// Earlier describes in this file don't all clear call history; reset it
|
|
5007
|
+
// here so uploaded-key assertions below are order-independent.
|
|
5008
|
+
vi.mocked(uploadFile).mockClear();
|
|
5009
|
+
vi.mocked(uploadSymlink).mockClear();
|
|
5010
|
+
vi.mocked(headRemoteFile).mockClear();
|
|
5011
|
+
vi.mocked(deleteRemoteFile).mockClear();
|
|
5012
|
+
vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
|
|
5013
|
+
vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
|
|
5014
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
5015
|
+
});
|
|
5016
|
+
|
|
5017
|
+
afterEach(() => {
|
|
5018
|
+
vi.unstubAllGlobals();
|
|
5019
|
+
vi.clearAllMocks();
|
|
5020
|
+
vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
|
|
5021
|
+
vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
|
|
5022
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
5023
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
5024
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
5025
|
+
delete process.env.HQ_STATE_DIR;
|
|
5026
|
+
});
|
|
5027
|
+
|
|
5028
|
+
it("refuses to upload doubled-tree companies/ keys in company mode — clear per-key warning, never an error", async () => {
|
|
5029
|
+
// The incident writer: a stale doubled local tree
|
|
5030
|
+
// companies/acme/companies/acme/… would push literal `companies/…` keys
|
|
5031
|
+
// via the direct-S3 STS transport, poisoning the bucket with keys the
|
|
5032
|
+
// presign download API rejects (INVALID_KEY_COMPANIES_SCOPED) for every
|
|
5033
|
+
// other machine. The push planner must refuse them up front.
|
|
5034
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5035
|
+
const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
|
|
5036
|
+
fs.mkdirSync(doubled, { recursive: true });
|
|
5037
|
+
fs.writeFileSync(path.join(doubled, "poison.md"), "poison");
|
|
5038
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
5039
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
5040
|
+
|
|
5041
|
+
const events: Array<{ type?: string; path?: string }> = [];
|
|
5042
|
+
const result = await share({
|
|
5043
|
+
paths: [companyRoot],
|
|
5044
|
+
company: "acme",
|
|
5045
|
+
vaultConfig: mockConfig,
|
|
5046
|
+
hqRoot: tmpDir,
|
|
5047
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
5048
|
+
});
|
|
5049
|
+
|
|
5050
|
+
expect(result.aborted).toBe(false);
|
|
5051
|
+
expect(result.filesUploaded).toBe(1);
|
|
5052
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
5053
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
5054
|
+
|
|
5055
|
+
// Per-key warning on the existing event channel, never type:"error"
|
|
5056
|
+
// (an error event flips the runner pass to exit 2 — the flood class).
|
|
5057
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
5058
|
+
expect(skipEvents.map((e) => e.path)).toEqual([
|
|
5059
|
+
"companies/acme/knowledge/poison.md",
|
|
5060
|
+
]);
|
|
5061
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
5062
|
+
});
|
|
5063
|
+
|
|
5064
|
+
it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
|
|
5065
|
+
// Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
|
|
5066
|
+
// companies/-prefixed key would be rejected by the presign transport's
|
|
5067
|
+
// server validator, so the delete plan must route it straight to
|
|
5068
|
+
// toTombstone — journal entry drained, no remote call, no re-upload.
|
|
5069
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5070
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5071
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5072
|
+
fs.writeFileSync(
|
|
5073
|
+
journalPath,
|
|
5074
|
+
JSON.stringify({
|
|
5075
|
+
version: "1",
|
|
5076
|
+
lastSync: new Date().toISOString(),
|
|
5077
|
+
files: {
|
|
5078
|
+
"companies/acme/knowledge/poison.md": {
|
|
5079
|
+
hash: "poison-hash",
|
|
5080
|
+
size: 6,
|
|
5081
|
+
syncedAt: new Date().toISOString(),
|
|
5082
|
+
direction: "up",
|
|
5083
|
+
remoteEtag: "poison-etag",
|
|
5084
|
+
},
|
|
5085
|
+
},
|
|
5086
|
+
}),
|
|
5087
|
+
);
|
|
5088
|
+
|
|
5089
|
+
const result = await share({
|
|
5090
|
+
paths: [companyRoot],
|
|
5091
|
+
company: "acme",
|
|
5092
|
+
vaultConfig: mockConfig,
|
|
5093
|
+
hqRoot: tmpDir,
|
|
5094
|
+
skipUnchanged: true,
|
|
5095
|
+
propagateDeletes: true,
|
|
5096
|
+
propagateDeletePolicy: "currency-gated",
|
|
5097
|
+
});
|
|
5098
|
+
|
|
5099
|
+
expect(result.aborted).toBe(false);
|
|
5100
|
+
// Tombstoned: journal entry dropped without any remote HEAD or DELETE.
|
|
5101
|
+
expect(result.filesTombstoned).toBe(1);
|
|
5102
|
+
expect(result.filesDeleted).toBe(0);
|
|
5103
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
5104
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5105
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
5106
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5107
|
+
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
5108
|
+
});
|
|
5109
|
+
|
|
5110
|
+
it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
|
|
5111
|
+
const localCo = path.join(tmpDir, "companies", "localco");
|
|
5112
|
+
fs.mkdirSync(localCo, { recursive: true });
|
|
5113
|
+
fs.writeFileSync(path.join(localCo, "notes.md"), "n");
|
|
5114
|
+
|
|
5115
|
+
const events: Array<{ type?: string }> = [];
|
|
5116
|
+
const result = await share({
|
|
5117
|
+
paths: [localCo],
|
|
5118
|
+
company: "acme",
|
|
5119
|
+
vaultConfig: mockConfig,
|
|
5120
|
+
hqRoot: tmpDir,
|
|
5121
|
+
personalMode: true,
|
|
5122
|
+
journalSlug: "personal-vault",
|
|
5123
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
5124
|
+
});
|
|
5125
|
+
|
|
5126
|
+
expect(result.aborted).toBe(false);
|
|
5127
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
5128
|
+
expect(uploadedKeys).toContain("companies/localco/notes.md");
|
|
5129
|
+
expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
|
|
5130
|
+
});
|
|
5131
|
+
});
|
package/src/cli/share.ts
CHANGED
|
@@ -1047,12 +1047,34 @@ function createShareCounters(): ShareCounters {
|
|
|
1047
1047
|
}
|
|
1048
1048
|
|
|
1049
1049
|
async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
1050
|
-
const
|
|
1050
|
+
const collected = collectFiles(
|
|
1051
1051
|
run.paths,
|
|
1052
1052
|
run.hqRoot,
|
|
1053
1053
|
run.syncRoot,
|
|
1054
1054
|
run.shouldSync,
|
|
1055
1055
|
);
|
|
1056
|
+
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
1057
|
+
// root IS the company folder, so a local entry whose vault key starts with
|
|
1058
|
+
// `companies/` can only come from a stale doubled local tree
|
|
1059
|
+
// (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
|
|
1060
|
+
// with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
|
|
1061
|
+
// the direct-S3 STS transport bypasses server validation. Refuse them here
|
|
1062
|
+
// with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
|
|
1063
|
+
// is the belt-and-suspenders backstop. The pull leg's journal-tombstone
|
|
1064
|
+
// path then CLEANS the doubled tree (local delete + journal drop) instead
|
|
1065
|
+
// of this leg ever re-uploading it. Personal vaults legitimately carry
|
|
1066
|
+
// `companies/{slug}/…` keys, so this only applies in company mode.
|
|
1067
|
+
const filesToShare =
|
|
1068
|
+
run.options.personalMode === true
|
|
1069
|
+
? collected
|
|
1070
|
+
: collected.filter((entry) => {
|
|
1071
|
+
if (!entry.relativePath.startsWith("companies/")) return true;
|
|
1072
|
+
run.emit({
|
|
1073
|
+
type: "skip-invalid-scoped-key",
|
|
1074
|
+
path: entry.relativePath,
|
|
1075
|
+
});
|
|
1076
|
+
return false;
|
|
1077
|
+
});
|
|
1056
1078
|
const pushPlan = computePushPlan(
|
|
1057
1079
|
filesToShare,
|
|
1058
1080
|
run.journal,
|
|
@@ -1069,6 +1091,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1069
1091
|
run.shouldSync,
|
|
1070
1092
|
run.propagateDeletePolicy,
|
|
1071
1093
|
run.ctx,
|
|
1094
|
+
run.options.personalMode !== true,
|
|
1072
1095
|
)
|
|
1073
1096
|
: { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
1074
1097
|
const decommissionPlan =
|
|
@@ -1139,7 +1162,7 @@ async function executeUploads(
|
|
|
1139
1162
|
// Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
|
|
1140
1163
|
// so every watch pass returned exit 2 and the menubar reported an
|
|
1141
1164
|
// "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
|
|
1142
|
-
// tick (the
|
|
1165
|
+
// tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
|
|
1143
1166
|
// do not let it flip the pass to a non-zero exit.
|
|
1144
1167
|
let bytes = 0;
|
|
1145
1168
|
try {
|
|
@@ -1676,6 +1699,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1676
1699
|
` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
|
|
1677
1700
|
` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
|
|
1678
1701
|
);
|
|
1702
|
+
} else if (event.type === "skip-invalid-scoped-key") {
|
|
1703
|
+
console.warn(
|
|
1704
|
+
` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
|
|
1705
|
+
);
|
|
1679
1706
|
} else if (event.type === "ignore-excluded") {
|
|
1680
1707
|
// The "not silent" surface: an ignore rule dropped content that doesn't
|
|
1681
1708
|
// look like expected build/VCS/cache noise, so it never reached the vault.
|
|
@@ -2178,6 +2205,11 @@ async function computeDeletePlan(
|
|
|
2178
2205
|
shouldSync: (filePath: string, isDir?: boolean) => boolean,
|
|
2179
2206
|
policy: "currency-gated" | "owned-only" | "all",
|
|
2180
2207
|
ctx: EntityContext,
|
|
2208
|
+
// True for a COMPANY-scoped push (personalMode !== true): the sync root is
|
|
2209
|
+
// the company folder and journal keys are bucket-relative, so a literal
|
|
2210
|
+
// `companies/…` key is scope-invalid (see the poisoned-key branch below).
|
|
2211
|
+
// Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
|
|
2212
|
+
companyScoped: boolean = true,
|
|
2181
2213
|
): Promise<DeletePlan> {
|
|
2182
2214
|
const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
2183
2215
|
if (scopeRoots.length === 0) return plan;
|
|
@@ -2249,6 +2281,20 @@ async function computeDeletePlan(
|
|
|
2249
2281
|
continue;
|
|
2250
2282
|
}
|
|
2251
2283
|
|
|
2284
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
2285
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
2286
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
2287
|
+
// presign transport is rejected by the server validator
|
|
2288
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
2289
|
+
// straight to `toTombstone` (journal drop, no remote call) — the local
|
|
2290
|
+
// journal entry drains; server-side cleanup of any poisoned object is an
|
|
2291
|
+
// operator action. Personal-vault pushes (personalMode) carry legitimate
|
|
2292
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
2293
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
2294
|
+
plan.toTombstone.push(relativeKey);
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2252
2298
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
2253
2299
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
2254
2300
|
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|