@indigoai-us/hq-cloud 6.14.35 → 6.14.37
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 +20 -3
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +87 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +179 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +504 -26
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +639 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +24 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +45 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +34 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner-watch-loop.ts +13 -3
- package/src/bin/sync-runner.test.ts +105 -2
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/share.test.ts +794 -2
- package/src/cli/share.ts +624 -26
- package/src/cli/sync.test.ts +42 -0
- package/src/cli/sync.ts +78 -3
package/package.json
CHANGED
|
@@ -183,15 +183,20 @@ export async function executeCompanyFanout(options: {
|
|
|
183
183
|
...(event.errorCode ? { errorCode: event.errorCode } : {}),
|
|
184
184
|
});
|
|
185
185
|
} else if (event.type === "skip-junk-key-spelling") {
|
|
186
|
-
// Deliberate skip: forwarded for visibility, NEVER pushed into
|
|
187
|
-
// errors[] (policy hq-sync-deliberate-skip-not-fatal-error-exit2 —
|
|
188
|
-
// a benign recurring skip must not gate exit 2 / Sentry alerts).
|
|
189
186
|
options.emit({
|
|
190
187
|
type: "skip-junk-key-spelling",
|
|
191
188
|
company: companyLabel,
|
|
192
189
|
path: event.path,
|
|
193
190
|
journaledKey: event.journaledKey,
|
|
194
191
|
});
|
|
192
|
+
} else if (event.type === "not-shipped") {
|
|
193
|
+
options.emit({
|
|
194
|
+
type: "not-shipped",
|
|
195
|
+
company: companyLabel,
|
|
196
|
+
reason: event.reason,
|
|
197
|
+
count: event.count,
|
|
198
|
+
samplePaths: event.samplePaths,
|
|
199
|
+
});
|
|
195
200
|
}
|
|
196
201
|
};
|
|
197
202
|
|
|
@@ -208,6 +213,8 @@ export async function executeCompanyFanout(options: {
|
|
|
208
213
|
filesExcludedByPolicy: 0,
|
|
209
214
|
filesExcludedByScope: 0,
|
|
210
215
|
filesExcludedByIgnore: 0,
|
|
216
|
+
unreachablePaths: [],
|
|
217
|
+
linkedSubtreesNotShipped: [],
|
|
211
218
|
conflictPaths: [],
|
|
212
219
|
aborted: false,
|
|
213
220
|
};
|
|
@@ -288,6 +295,15 @@ export async function executeCompanyFanout(options: {
|
|
|
288
295
|
skipUnchanged: true,
|
|
289
296
|
propagateDeletes: true,
|
|
290
297
|
propagateDeletePolicy: options.resolveDeletePolicy(),
|
|
298
|
+
// `pushPaths` are INTERNAL walk roots (the company folder / the
|
|
299
|
+
// personal-vault dirs), never user input, so an unreachable entry
|
|
300
|
+
// here is a benign race — a directory removed between the scan and
|
|
301
|
+
// the push — and must not fail an unattended background sync. Record
|
|
302
|
+
// + report it (`not-shipped` event, `ShareResult.unreachablePaths`)
|
|
303
|
+
// and carry on. The interactive `hq sync push <path>` keeps the
|
|
304
|
+
// default "error" policy, which is where a user-typed path that
|
|
305
|
+
// silently ships nothing is a real bug (feedback_a51cb63d ask #2).
|
|
306
|
+
unreachablePathPolicy: "warn",
|
|
291
307
|
onEvent: tagAndEmit,
|
|
292
308
|
...(options.uploadAuthor ? { author: options.uploadAuthor } : {}),
|
|
293
309
|
...(target.personalMode !== undefined
|
|
@@ -1006,6 +1006,15 @@ export async function runWatchLoop(
|
|
|
1006
1006
|
while (!stopped) {
|
|
1007
1007
|
pollTick++;
|
|
1008
1008
|
const pending = takePendingWatcherChange();
|
|
1009
|
+
// The quarantine is scoped to the delete episode it refused, and only
|
|
1010
|
+
// the roots already refused when this batch was taken belong to it.
|
|
1011
|
+
// Snapshot them here rather than clearing the whole set after the pass:
|
|
1012
|
+
// the pass is awaited, so a bulk removal observed while it runs adds its
|
|
1013
|
+
// root afterwards and its snapshots ride the NEXT batch. Clearing
|
|
1014
|
+
// unconditionally would drop that root before `prepareWatcherChanges`
|
|
1015
|
+
// ever sees the batch, re-stamping the revoked descendant intents and
|
|
1016
|
+
// handing the engine exactly the bulk delete the quarantine refused.
|
|
1017
|
+
const drainedQuarantineRoots = new Set(quarantinedDeleteRoots);
|
|
1009
1018
|
const shouldRunFull =
|
|
1010
1019
|
pollTick === 1 ||
|
|
1011
1020
|
pending.bare ||
|
|
@@ -1014,11 +1023,12 @@ export async function runWatchLoop(
|
|
|
1014
1023
|
const result = shouldRunFull
|
|
1015
1024
|
? await runGuarded(passArgv)
|
|
1016
1025
|
: await runScopedDrain(pending.batch);
|
|
1017
|
-
//
|
|
1018
|
-
// carrying that episode has now been prepared and pushed, so a later
|
|
1026
|
+
// This batch's episode has now been prepared and pushed, so a later
|
|
1019
1027
|
// removal under the same path is judged fresh — a root that never
|
|
1020
1028
|
// expired would silently refuse ordinary deletes from here on.
|
|
1021
|
-
|
|
1029
|
+
for (const root of drainedQuarantineRoots) {
|
|
1030
|
+
quarantinedDeleteRoots.delete(root);
|
|
1031
|
+
}
|
|
1022
1032
|
const exitCode = passExitCode(result);
|
|
1023
1033
|
if (exitCode === runtime.authRequiredPassExit) {
|
|
1024
1034
|
// The pass already emitted auth-error. Stop the unattended loop but
|
|
@@ -157,6 +157,8 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
|
|
|
157
157
|
filesExcludedByPolicy: 0,
|
|
158
158
|
filesExcludedByScope: 0,
|
|
159
159
|
filesExcludedByIgnore: 0,
|
|
160
|
+
unreachablePaths: [],
|
|
161
|
+
linkedSubtreesNotShipped: [],
|
|
160
162
|
conflictPaths: [],
|
|
161
163
|
aborted: false,
|
|
162
164
|
...overrides,
|
|
@@ -7548,7 +7550,11 @@ describe("US-002 — manifest reconciliation wiring", () => {
|
|
|
7548
7550
|
describe("watcher delete intents without pre-seeded authorization", () => {
|
|
7549
7551
|
function startDeleteHarness(
|
|
7550
7552
|
keys: string[],
|
|
7551
|
-
options: {
|
|
7553
|
+
options: {
|
|
7554
|
+
gateInitialPull?: boolean;
|
|
7555
|
+
onPass?: (argv: string[]) => Promise<void> | void;
|
|
7556
|
+
direction?: "both" | "push";
|
|
7557
|
+
} = {},
|
|
7552
7558
|
) {
|
|
7553
7559
|
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
|
|
7554
7560
|
const companyRoot = path.join(hqRoot, "companies", "indigo");
|
|
@@ -7591,6 +7597,9 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7591
7597
|
});
|
|
7592
7598
|
let initialPullGated = options.gateInitialPull === true;
|
|
7593
7599
|
const runPass = vi.fn(async (argv: string[]) => {
|
|
7600
|
+
// Lets a test hold a pass mid-flight and act while the loop is awaiting
|
|
7601
|
+
// it, which is where producer-side state that outlives a pass is decided.
|
|
7602
|
+
await options.onPass?.(argv);
|
|
7594
7603
|
const direction = argv[argv.indexOf("--direction") + 1];
|
|
7595
7604
|
if (direction === "push") {
|
|
7596
7605
|
const journal = readJournal("indigo");
|
|
@@ -7665,7 +7674,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7665
7674
|
});
|
|
7666
7675
|
const loop = runRunnerWithLoop(
|
|
7667
7676
|
[
|
|
7668
|
-
"--companies", "--watch", "--event-push",
|
|
7677
|
+
"--companies", "--watch", "--event-push",
|
|
7678
|
+
"--direction", options.direction ?? "both",
|
|
7669
7679
|
"--hq-root", hqRoot, "--poll-remote-ms", "60000",
|
|
7670
7680
|
],
|
|
7671
7681
|
{
|
|
@@ -8169,6 +8179,99 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
8169
8179
|
await stopHarness(h);
|
|
8170
8180
|
});
|
|
8171
8181
|
|
|
8182
|
+
// A pass is awaited, so a bulk removal observed while one is in flight
|
|
8183
|
+
// registers its quarantine root only after that pass's batch was taken — its
|
|
8184
|
+
// own snapshots ride the NEXT batch. Expiring the whole set once the pass
|
|
8185
|
+
// returns would drop the root before `prepareWatcherChanges` ever sees that
|
|
8186
|
+
// batch, re-stamping the revoked descendant intents and handing the engine
|
|
8187
|
+
// the bulk delete the quarantine refused (remotely deleting the subtree under
|
|
8188
|
+
// HQ_SYNC_DELETE_POLICY=all).
|
|
8189
|
+
it("a bulk removal observed during an in-flight pass keeps its quarantine", async () => {
|
|
8190
|
+
const keys = Array.from(
|
|
8191
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8192
|
+
);
|
|
8193
|
+
let releaseGatedPush = () => {};
|
|
8194
|
+
const gatedPush = new Promise<void>((resolve) => {
|
|
8195
|
+
releaseGatedPush = resolve;
|
|
8196
|
+
});
|
|
8197
|
+
let gateArmed = false;
|
|
8198
|
+
let gateReached = false;
|
|
8199
|
+
// Push-only: a pull would restore the quarantined subtree (the documented
|
|
8200
|
+
// recovery for an intent-less disappearance) before the batch carrying the
|
|
8201
|
+
// episode is prepared, masking whether the refusal itself survived.
|
|
8202
|
+
const h = startDeleteHarness([...keys, "knowledge/other.md"], {
|
|
8203
|
+
direction: "push",
|
|
8204
|
+
onPass: async (_argv) => {
|
|
8205
|
+
if (!gateArmed) return;
|
|
8206
|
+
gateArmed = false;
|
|
8207
|
+
gateReached = true;
|
|
8208
|
+
await gatedPush;
|
|
8209
|
+
},
|
|
8210
|
+
});
|
|
8211
|
+
await settle();
|
|
8212
|
+
const intentCount = () =>
|
|
8213
|
+
Object.values(readJournal("indigo").files).filter(
|
|
8214
|
+
(entry) => entry.localDeleteIntent,
|
|
8215
|
+
).length;
|
|
8216
|
+
|
|
8217
|
+
// An unrelated edit drains as a scoped push, which then blocks on the gate
|
|
8218
|
+
// with its batch already taken from the pending buffer.
|
|
8219
|
+
gateArmed = true;
|
|
8220
|
+
const otherPath = path.join(h.companyRoot, "knowledge/other.md");
|
|
8221
|
+
fs.writeFileSync(otherPath, "edited");
|
|
8222
|
+
h.watcher.emit("companies/indigo/knowledge/other.md", {
|
|
8223
|
+
paths: new Map([[otherPath, "companies/indigo/knowledge/other.md"]]),
|
|
8224
|
+
} as TreeChangeBatch);
|
|
8225
|
+
h.tick();
|
|
8226
|
+
await settle();
|
|
8227
|
+
expect(gateReached).toBe(true);
|
|
8228
|
+
|
|
8229
|
+
// The whole subtree disappears while that pass is still in flight.
|
|
8230
|
+
const childChanges = new Map<string, TreeChange>();
|
|
8231
|
+
for (const key of keys) {
|
|
8232
|
+
const absolutePath = path.join(h.companyRoot, key);
|
|
8233
|
+
fs.unlinkSync(absolutePath);
|
|
8234
|
+
childChanges.set(absolutePath, {
|
|
8235
|
+
kind: "unlink",
|
|
8236
|
+
deleteSnapshots: h.capture()(`companies/indigo/${key}`, "unlink"),
|
|
8237
|
+
});
|
|
8238
|
+
}
|
|
8239
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8240
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8241
|
+
const dirSnapshots = h.capture()(
|
|
8242
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8243
|
+
);
|
|
8244
|
+
expect(dirSnapshots).toHaveLength(0);
|
|
8245
|
+
expect(intentCount()).toBe(0);
|
|
8246
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8247
|
+
paths: new Map<string, string>([
|
|
8248
|
+
...keys.map(
|
|
8249
|
+
(key) =>
|
|
8250
|
+
[
|
|
8251
|
+
path.join(h.companyRoot, key),
|
|
8252
|
+
`companies/indigo/${key}`,
|
|
8253
|
+
] as [string, string],
|
|
8254
|
+
),
|
|
8255
|
+
[subtree, "companies/indigo/knowledge/moved"],
|
|
8256
|
+
]),
|
|
8257
|
+
changes: new Map<string, TreeChange>([
|
|
8258
|
+
...childChanges,
|
|
8259
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8260
|
+
]),
|
|
8261
|
+
} as TreeChangeBatch);
|
|
8262
|
+
|
|
8263
|
+
// Completing the overlapped pass must not expire the refusal recorded
|
|
8264
|
+
// during it: the next drain prepares the batch that carries the episode.
|
|
8265
|
+
releaseGatedPush();
|
|
8266
|
+
await settle();
|
|
8267
|
+
h.tick();
|
|
8268
|
+
await settle();
|
|
8269
|
+
|
|
8270
|
+
expect(intentCount()).toBe(0);
|
|
8271
|
+
for (const key of keys) expect(h.remote.has(key)).toBe(true);
|
|
8272
|
+
await stopHarness(h);
|
|
8273
|
+
});
|
|
8274
|
+
|
|
8172
8275
|
it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
|
|
8173
8276
|
process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
|
|
8174
8277
|
try {
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -402,6 +402,10 @@ export type RunnerEvent =
|
|
|
402
402
|
Extract<SyncProgressEvent, { type: "skip-junk-key-spelling" }>,
|
|
403
403
|
"type"
|
|
404
404
|
>)
|
|
405
|
+
| ({ type: "not-shipped"; company: string } & Omit<
|
|
406
|
+
Extract<SyncProgressEvent, { type: "not-shipped" }>,
|
|
407
|
+
"type"
|
|
408
|
+
>)
|
|
405
409
|
| ({
|
|
406
410
|
type: "complete";
|
|
407
411
|
company: string;
|