@indigoai-us/hq-cloud 6.4.0 → 6.6.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/dist/bin/sync-runner.d.ts +4 -35
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -104
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +19 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.d.ts +2 -0
- package/dist/cli/rescue-classify-ordering.test.d.ts.map +1 -0
- package/dist/cli/rescue-classify-ordering.test.js +241 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -0
- package/dist/cli/rescue-core.js +68 -22
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/sync-scope.test.js +67 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +19 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +62 -19
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +15 -5
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -2
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +70 -7
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +102 -23
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +63 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +50 -0
- package/dist/sync/pull-scope.d.ts.map +1 -0
- package/dist/sync/pull-scope.js +129 -0
- package/dist/sync/pull-scope.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +23 -0
- package/src/bin/sync-runner.ts +19 -116
- package/src/cli/rescue-classify-ordering.test.ts +263 -0
- package/src/cli/rescue-core.ts +82 -26
- package/src/cli/sync-scope.test.ts +84 -0
- package/src/cli/sync.ts +90 -17
- package/src/index.ts +11 -0
- package/src/s3.test.ts +91 -1
- package/src/s3.ts +15 -5
- package/src/scope-shrink.test.ts +71 -0
- package/src/scope-shrink.ts +164 -20
- package/src/sync/pull-scope.ts +161 -0
package/src/cli/sync.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
applyScopeShrink,
|
|
41
41
|
ScopeShrinkBlockedError,
|
|
42
42
|
ScopeShrinkLargePruneError,
|
|
43
|
+
type ScopeShrinkAdviceContext,
|
|
43
44
|
} from "../scope-shrink.js";
|
|
44
45
|
import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
|
|
45
46
|
import { createIgnoreFilter } from "../ignore.js";
|
|
@@ -355,6 +356,25 @@ export interface SyncOptions {
|
|
|
355
356
|
* tombstoned. Mirrors `hq sync narrow --force`.
|
|
356
357
|
*/
|
|
357
358
|
forceScopeShrink?: boolean;
|
|
359
|
+
/**
|
|
360
|
+
* How `sync()` handles a scope shrink (US-005 / DEV-1768):
|
|
361
|
+
*
|
|
362
|
+
* - `"block"` (default) — a human is present (foreground `hq sync`). Dirty
|
|
363
|
+
* out-of-scope orphans, or a clean prune over the safety cap, raise a
|
|
364
|
+
* structured error whose advice is followable from a terminal. Clean
|
|
365
|
+
* orphans within the cap are QUARANTINED (moved, not deleted).
|
|
366
|
+
* - `"auto-recover"` — the background menubar runner, which can take no
|
|
367
|
+
* interactive flag. NEVER throws on a shrink: dirty orphans are kept on
|
|
368
|
+
* disk + un-tracked, clean orphans are quarantined, and the bulk-prune
|
|
369
|
+
* cap is bypassed (quarantine is non-destructive). This is what clears an
|
|
370
|
+
* already-wedged journal on the next sync, idempotently and without data
|
|
371
|
+
* loss — the recovery seam for the all→shared seed bug.
|
|
372
|
+
*
|
|
373
|
+
* Both policies are non-destructive for CLEAN files (quarantine, never
|
|
374
|
+
* silent delete) — the deliberate `hq sync narrow --apply` ritual is the only
|
|
375
|
+
* path that hard-deletes, and it confirms first.
|
|
376
|
+
*/
|
|
377
|
+
scopeShrinkPolicy?: "block" | "auto-recover";
|
|
358
378
|
/**
|
|
359
379
|
* The caller's own Cognito `sub`, used by the scope-shrink authorship guard
|
|
360
380
|
* so a scope shrink never prunes content the caller authored. Injected by the
|
|
@@ -655,23 +675,38 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
655
675
|
// explicit `hq sync narrow` ritual opts out of the unknown-author shield.
|
|
656
676
|
protectUnknownAuthors: true,
|
|
657
677
|
});
|
|
658
|
-
|
|
678
|
+
// Policy: the background menubar runner ("auto-recover") can take no
|
|
679
|
+
// interactive flag, so it must never throw on a shrink — it self-heals
|
|
680
|
+
// non-destructively (dirty kept on disk + un-tracked, clean quarantined).
|
|
681
|
+
// A foreground `hq sync` ("block", the default) keeps the protective gate
|
|
682
|
+
// but renders FOLLOWABLE advice. `autoRecover` implies force (proceed) and
|
|
683
|
+
// bypasses the bulk-prune cap (quarantine is non-destructive, so a large
|
|
684
|
+
// recovery move is safe). DEV-1768.
|
|
685
|
+
const scopeShrinkPolicy = options.scopeShrinkPolicy ?? "block";
|
|
686
|
+
const autoRecover = scopeShrinkPolicy === "auto-recover";
|
|
687
|
+
const adviceContext: ScopeShrinkAdviceContext = autoRecover ? "runner" : "cli";
|
|
688
|
+
const effectiveForce = options.forceScopeShrink === true || autoRecover;
|
|
689
|
+
|
|
690
|
+
if (shrinkPlan.dirty.length > 0 && !effectiveForce) {
|
|
659
691
|
throw new ScopeShrinkBlockedError(
|
|
660
692
|
ctx.uid,
|
|
661
693
|
lastRecord?.syncMode ?? "unknown",
|
|
662
694
|
syncMode,
|
|
663
695
|
shrinkPlan.dirty,
|
|
664
696
|
shrinkPlan.clean,
|
|
697
|
+
adviceContext,
|
|
665
698
|
);
|
|
666
699
|
}
|
|
667
|
-
// Bulk
|
|
668
|
-
//
|
|
669
|
-
// `hq sync narrow --apply` (its own confirmation)
|
|
670
|
-
//
|
|
671
|
-
//
|
|
700
|
+
// Bulk guard: refuse to auto-move more than the safety cap of CLEAN files in
|
|
701
|
+
// a single foreground sync. A deliberate large narrow goes through
|
|
702
|
+
// `hq sync narrow --apply` (its own confirmation); `--force-scope-shrink` (or
|
|
703
|
+
// raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited. Skipped
|
|
704
|
+
// under auto-recover — quarantine is non-destructive so a big recovery is
|
|
705
|
+
// safe, and the runner has no way to act on a thrown cap. The engine moves
|
|
706
|
+
// nothing when it throws here.
|
|
672
707
|
const autoPruneCap = resolveAutoPruneCap();
|
|
673
708
|
if (
|
|
674
|
-
|
|
709
|
+
!effectiveForce &&
|
|
675
710
|
autoPruneCap > 0 &&
|
|
676
711
|
shrinkPlan.clean.length > autoPruneCap
|
|
677
712
|
) {
|
|
@@ -680,27 +715,65 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
680
715
|
syncMode,
|
|
681
716
|
shrinkPlan.clean.length,
|
|
682
717
|
autoPruneCap,
|
|
718
|
+
adviceContext,
|
|
683
719
|
);
|
|
684
720
|
}
|
|
721
|
+
// Clean orphans are QUARANTINED (moved into `.hq/scope-quarantine/<slug>/`,
|
|
722
|
+
// recoverable), never silently deleted — a background sync purging local
|
|
723
|
+
// files unannounced was DEV-1768 fix #3. The quarantine root lives under the
|
|
724
|
+
// real HQ root's `.hq/` (outside `companyRoot` and never pushed), so moved
|
|
725
|
+
// files don't round-trip back through S3.
|
|
726
|
+
const scopeQuarantineRoot = path.join(
|
|
727
|
+
hqRoot,
|
|
728
|
+
".hq",
|
|
729
|
+
"scope-quarantine",
|
|
730
|
+
journalSlug,
|
|
731
|
+
);
|
|
685
732
|
const shrinkResult = applyScopeShrink({
|
|
686
733
|
journal,
|
|
687
734
|
plan: shrinkPlan,
|
|
688
735
|
hqRoot: companyRoot,
|
|
689
|
-
forceScopeShrink:
|
|
736
|
+
forceScopeShrink: effectiveForce,
|
|
690
737
|
reason: "scope_shrink",
|
|
738
|
+
cleanDisposition: "quarantine",
|
|
739
|
+
quarantineRoot: scopeQuarantineRoot,
|
|
691
740
|
});
|
|
692
|
-
// Surface each
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
741
|
+
// Surface each affected orphan explicitly (named path) so the prune is never
|
|
742
|
+
// silent. Quarantined clean files render as `deleted: true` (removed from the
|
|
743
|
+
// working tree, recoverable in quarantine); dirty files KEPT on disk render
|
|
744
|
+
// as a non-deletion notice so the operator knows they were un-tracked, not
|
|
745
|
+
// removed. The Rust menubar parser already handles `deleted: true`.
|
|
746
|
+
for (const relPath of shrinkResult.quarantinedPaths) {
|
|
747
|
+
emit({
|
|
748
|
+
type: "progress",
|
|
749
|
+
path: relPath,
|
|
750
|
+
bytes: 0,
|
|
751
|
+
deleted: true,
|
|
752
|
+
message: `scope-narrowed: moved out-of-scope copy to ${scopeQuarantineRoot}`,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
for (const relPath of shrinkResult.removedPaths) {
|
|
696
756
|
emit({
|
|
697
757
|
type: "progress",
|
|
698
|
-
path:
|
|
758
|
+
path: relPath,
|
|
699
759
|
bytes: 0,
|
|
700
760
|
deleted: true,
|
|
701
761
|
message: "scope-narrowed (removed local copy outside sync scope)",
|
|
702
762
|
});
|
|
703
763
|
}
|
|
764
|
+
for (const relPath of shrinkResult.dirtyKeptPaths) {
|
|
765
|
+
emit({
|
|
766
|
+
type: "progress",
|
|
767
|
+
path: relPath,
|
|
768
|
+
bytes: 0,
|
|
769
|
+
message:
|
|
770
|
+
"scope-narrowed: locally-modified file KEPT on disk, un-tracked from sync (outside scope)",
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
// "Removed from the working tree" = deleted OR quarantined; both vacate the
|
|
774
|
+
// file's original path. Reported as `scopeOrphansRemoved` for back-compat.
|
|
775
|
+
const scopeOrphansRemoved =
|
|
776
|
+
shrinkResult.cleanRemoved + shrinkResult.cleanQuarantined;
|
|
704
777
|
|
|
705
778
|
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|
|
706
779
|
// inline loop; the only structural change is that classification has
|
|
@@ -949,7 +1022,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
949
1022
|
// a conflict abort. `filesOutOfScope` reflects how far the serial
|
|
950
1023
|
// pass got before the abort; that's acceptable for an abort result.
|
|
951
1024
|
filesOutOfScope,
|
|
952
|
-
scopeOrphansRemoved
|
|
1025
|
+
scopeOrphansRemoved,
|
|
953
1026
|
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
954
1027
|
};
|
|
955
1028
|
break;
|
|
@@ -1328,7 +1401,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1328
1401
|
syncMode,
|
|
1329
1402
|
prefixSet: currentPrefixSet,
|
|
1330
1403
|
scopeChangeDetected: shrinkPlan.scopeChangeDetected,
|
|
1331
|
-
orphansRemoved:
|
|
1404
|
+
orphansRemoved: scopeOrphansRemoved,
|
|
1332
1405
|
orphansBlocked: shrinkResult.dirtyTombstoned,
|
|
1333
1406
|
});
|
|
1334
1407
|
|
|
@@ -1347,7 +1420,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1347
1420
|
const changedOnDisk =
|
|
1348
1421
|
filesDownloaded > 0 ||
|
|
1349
1422
|
filesTombstoned > 0 ||
|
|
1350
|
-
|
|
1423
|
+
scopeOrphansRemoved > 0;
|
|
1351
1424
|
if (!options.skipReindex && changedOnDisk) {
|
|
1352
1425
|
try {
|
|
1353
1426
|
// skipLock: the surrounding sync run already holds this root's operation
|
|
@@ -1370,7 +1443,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1370
1443
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1371
1444
|
filesTombstoned,
|
|
1372
1445
|
filesOutOfScope,
|
|
1373
|
-
scopeOrphansRemoved
|
|
1446
|
+
scopeOrphansRemoved,
|
|
1374
1447
|
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
1375
1448
|
};
|
|
1376
1449
|
}
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export {
|
|
|
69
69
|
buildScopeShrinkPlan,
|
|
70
70
|
applyScopeShrink,
|
|
71
71
|
ScopeShrinkBlockedError,
|
|
72
|
+
ScopeShrinkLargePruneError,
|
|
72
73
|
} from "./scope-shrink.js";
|
|
73
74
|
export type {
|
|
74
75
|
OrphanClassification,
|
|
@@ -76,6 +77,8 @@ export type {
|
|
|
76
77
|
BuildScopeShrinkPlanInput,
|
|
77
78
|
ApplyScopeShrinkInput,
|
|
78
79
|
ApplyScopeShrinkResult,
|
|
80
|
+
ScopeShrinkAdviceContext,
|
|
81
|
+
CleanOrphanDisposition,
|
|
79
82
|
} from "./scope-shrink.js";
|
|
80
83
|
|
|
81
84
|
// Engine-layer ACL-aware pull orchestration (US-005)
|
|
@@ -118,6 +121,14 @@ export {
|
|
|
118
121
|
} from "./cognito-auth.js";
|
|
119
122
|
export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
|
|
120
123
|
|
|
124
|
+
// Per-company PULL scope resolver (US-005) — shared between hq-sync-runner and
|
|
125
|
+
// `hq sync pull|now` (hq-cli). Exported so hq-cli's foreground pull paths resolve
|
|
126
|
+
// the SAME effective scope the menubar runner does, instead of defaulting every
|
|
127
|
+
// CLI pull to `syncMode: "all"` (the seed of the all→shared scope-shrink wedge,
|
|
128
|
+
// DEV-1768).
|
|
129
|
+
export { resolvePullScope, readPinnedPrefixes } from "./sync/pull-scope.js";
|
|
130
|
+
export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
|
|
131
|
+
|
|
121
132
|
// Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
|
|
122
133
|
export {
|
|
123
134
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
package/src/s3.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `Metadata`, so the listing's HEAD fan-out had nothing to attribute.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as os from "os";
|
|
12
12
|
import * as path from "path";
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
uploadFile,
|
|
84
84
|
uploadSymlink,
|
|
85
85
|
toPosixKey,
|
|
86
|
+
primeUploads,
|
|
86
87
|
downloadFile,
|
|
87
88
|
listRemoteFiles,
|
|
88
89
|
SYMLINK_BODY_PREFIX,
|
|
@@ -92,6 +93,12 @@ import {
|
|
|
92
93
|
FILE_MTIME_META_KEY,
|
|
93
94
|
FILE_BTIME_META_KEY,
|
|
94
95
|
} from "./s3.js";
|
|
96
|
+
import {
|
|
97
|
+
setObjectIOFactory,
|
|
98
|
+
presignObjectIOFactory,
|
|
99
|
+
type PresignTransportClient,
|
|
100
|
+
} from "./object-io.js";
|
|
101
|
+
import type { PresignResultRow } from "./vault-client.js";
|
|
95
102
|
import type { EntityContext } from "./types.js";
|
|
96
103
|
|
|
97
104
|
function makeCtx(): EntityContext {
|
|
@@ -158,6 +165,89 @@ describe("backslash key normalization (Windows client → POSIX S3 key)", () =>
|
|
|
158
165
|
});
|
|
159
166
|
});
|
|
160
167
|
|
|
168
|
+
describe("backslash key normalization on the PRESIGNED (GA) transport", () => {
|
|
169
|
+
// The tests above assert normalization on the S3-SDK transport (sentCommands
|
|
170
|
+
// capture SDK PutObjectCommands). As of 6.5.0 the presigned-URL transport is
|
|
171
|
+
// GA for every company vault (cmp_*) — that's the path a real Windows client
|
|
172
|
+
// like Ridge's now takes. These lock the SAME write-side invariant on that
|
|
173
|
+
// transport: a backslash key is canonicalized to POSIX before the server is
|
|
174
|
+
// ever asked to presign it, so a non-POSIX vault key can never be minted.
|
|
175
|
+
let tmpFile: string;
|
|
176
|
+
let presignCalls: Array<{ op?: string; keys: Array<{ key?: string }> }>;
|
|
177
|
+
|
|
178
|
+
function presignKeysFor(op: string): string[] {
|
|
179
|
+
return presignCalls
|
|
180
|
+
.filter((c) => c.op === op)
|
|
181
|
+
.flatMap((c) => c.keys.map((k) => k.key ?? ""));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
tmpFile = path.join(
|
|
186
|
+
os.tmpdir(),
|
|
187
|
+
`s3-presign-backslash-${Date.now()}-${Math.random()}.md`,
|
|
188
|
+
);
|
|
189
|
+
fs.writeFileSync(tmpFile, "hello");
|
|
190
|
+
|
|
191
|
+
presignCalls = [];
|
|
192
|
+
const vault: PresignTransportClient = {
|
|
193
|
+
// Echo each requested key back as a usable URL so putObject's PUT
|
|
194
|
+
// resolves; record the op + keys so we can assert what was signed.
|
|
195
|
+
presign: async (input) => {
|
|
196
|
+
presignCalls.push({ op: input.op, keys: input.keys as Array<{ key?: string }> });
|
|
197
|
+
const results: PresignResultRow[] = input.keys.map((k) => ({
|
|
198
|
+
key: (k as { key: string }).key,
|
|
199
|
+
op: input.op ?? "put",
|
|
200
|
+
url: `https://s3.test/${(k as { key: string }).key}`,
|
|
201
|
+
}));
|
|
202
|
+
return { results, expiresAt: "2099-01-01T00:00:00.000Z" };
|
|
203
|
+
},
|
|
204
|
+
listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
|
|
205
|
+
};
|
|
206
|
+
setObjectIOFactory(presignObjectIOFactory(vault));
|
|
207
|
+
// putObject moves bytes over the presigned URL via global fetch.
|
|
208
|
+
vi.stubGlobal(
|
|
209
|
+
"fetch",
|
|
210
|
+
vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"e"' } })),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
setObjectIOFactory(null);
|
|
216
|
+
vi.unstubAllGlobals();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("uploadFile presigns + PUTs a POSIX key for a Windows-style backslash path", async () => {
|
|
220
|
+
await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
|
|
221
|
+
expect(presignKeysFor("put")).toContain("knowledge/books-eoi.md");
|
|
222
|
+
// The malformed backslash key is never presigned (so never PUT).
|
|
223
|
+
expect(presignKeysFor("put")).not.toContain("knowledge\\books-eoi.md");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("uploadSymlink presigns a POSIX key for a Windows-style backslash path", async () => {
|
|
227
|
+
await uploadSymlink(makeCtx(), "../target.md", "data\\boots-accounts.json");
|
|
228
|
+
expect(presignKeysFor("put")).toContain("data/boots-accounts.json");
|
|
229
|
+
expect(presignKeysFor("put")).not.toContain("data\\boots-accounts.json");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("primeUploads canonicalizes keys so the primed PUT URL matches the upload lookup", async () => {
|
|
233
|
+
// Prime then upload the SAME Windows-origin key. Both must key the cache
|
|
234
|
+
// under the POSIX form: prime signs the POSIX PUT URL, and uploadFile's
|
|
235
|
+
// hasPrimedPut(toPosixKey(...)) reuses it instead of re-presigning — proving
|
|
236
|
+
// the primed fast-path can't strand a backslash key (and can't store one).
|
|
237
|
+
await primeUploads(makeCtx(), [
|
|
238
|
+
{ key: "knowledge\\books-eoi.md", localPath: tmpFile, isSymlink: false },
|
|
239
|
+
]);
|
|
240
|
+
// prime("get") + prime("put") both went out under the POSIX key only.
|
|
241
|
+
expect(presignKeysFor("get")).toEqual(["knowledge/books-eoi.md"]);
|
|
242
|
+
expect(presignKeysFor("put")).toEqual(["knowledge/books-eoi.md"]);
|
|
243
|
+
|
|
244
|
+
const putCountAfterPrime = presignKeysFor("put").length;
|
|
245
|
+
await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
|
|
246
|
+
// No NEW put presign: uploadFile reused the primed POSIX URL (cache hit).
|
|
247
|
+
expect(presignKeysFor("put").length).toBe(putCountAfterPrime);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
161
251
|
describe("uploadFile", () => {
|
|
162
252
|
let tmpFile: string;
|
|
163
253
|
|
package/src/s3.ts
CHANGED
|
@@ -339,9 +339,14 @@ export async function primeUploads(
|
|
|
339
339
|
if (!io.prime || items.length === 0) return;
|
|
340
340
|
|
|
341
341
|
// Prime GET first so each item's created-at HEAD reuses a cached URL.
|
|
342
|
+
// Canonicalize to POSIX here (one-canonical-form, matching the uploadFile /
|
|
343
|
+
// uploadSymlink boundary): a Windows-origin backslash key must cache under
|
|
344
|
+
// the SAME key uploadFile later looks up via hasPrimedPut(toPosixKey(...)),
|
|
345
|
+
// or the primed URL silently misses and the upload re-presigns. It also
|
|
346
|
+
// keeps the created-at HEAD pointed at the real (POSIX) object.
|
|
342
347
|
await io.prime(
|
|
343
348
|
"get",
|
|
344
|
-
items.map((i) => ({ key: i.key })),
|
|
349
|
+
items.map((i) => ({ key: toPosixKey(i.key) })),
|
|
345
350
|
);
|
|
346
351
|
|
|
347
352
|
// Build per-key PUT metadata with the SAME builders the upload path uses,
|
|
@@ -356,10 +361,15 @@ export async function primeUploads(
|
|
|
356
361
|
const worker = async (): Promise<void> => {
|
|
357
362
|
while (next < items.length) {
|
|
358
363
|
const it = items[next++];
|
|
359
|
-
|
|
364
|
+
// Same boundary guardrail as uploadFile/uploadSymlink: prime under the
|
|
365
|
+
// canonical POSIX key so the cached PUT URL is keyed identically to the
|
|
366
|
+
// hasPrimedPut/putObject lookup, and a backslash key can never be primed
|
|
367
|
+
// (let alone stored) as a non-POSIX vault key.
|
|
368
|
+
const key = toPosixKey(it.key);
|
|
369
|
+
const createdAt = await resolveCreatedAt(io, key, it.author);
|
|
360
370
|
if (it.isSymlink) {
|
|
361
371
|
putKeys.push({
|
|
362
|
-
key
|
|
372
|
+
key,
|
|
363
373
|
contentType: "application/octet-stream",
|
|
364
374
|
metadata: {
|
|
365
375
|
[SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
|
|
@@ -374,8 +384,8 @@ export async function primeUploads(
|
|
|
374
384
|
// raced rm / EPERM — leave stamps off (receiver umask default).
|
|
375
385
|
}
|
|
376
386
|
putKeys.push({
|
|
377
|
-
key
|
|
378
|
-
contentType: getMimeType(
|
|
387
|
+
key,
|
|
388
|
+
contentType: getMimeType(key),
|
|
379
389
|
metadata: {
|
|
380
390
|
...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
|
|
381
391
|
...modeTime,
|
package/src/scope-shrink.test.ts
CHANGED
|
@@ -498,6 +498,52 @@ describe("applyScopeShrink", () => {
|
|
|
498
498
|
journal.files["companies/indigo/scratch/clean.md"]?.removedReason,
|
|
499
499
|
).toBe("narrow_apply");
|
|
500
500
|
});
|
|
501
|
+
|
|
502
|
+
// DEV-1768 fix #3: clean orphans are MOVED to quarantine (recoverable),
|
|
503
|
+
// not unlinked, when `cleanDisposition: "quarantine"`.
|
|
504
|
+
it("quarantines clean orphans (moves, not deletes) and reports named paths", () => {
|
|
505
|
+
const rel = "companies/indigo/scratch/clean.md";
|
|
506
|
+
const abs = path.join(hqRoot, rel);
|
|
507
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
508
|
+
fs.writeFileSync(abs, "clean");
|
|
509
|
+
const past = Date.now() - 60_000;
|
|
510
|
+
fs.utimesSync(abs, past / 1000, past / 1000);
|
|
511
|
+
|
|
512
|
+
const quarantineRoot = path.join(hqRoot, ".hq", "scope-quarantine", "indigo");
|
|
513
|
+
const journal: SyncJournal = {
|
|
514
|
+
...emptyJournal(),
|
|
515
|
+
files: {
|
|
516
|
+
[rel]: { hash: sha256("clean"), size: 5, syncedAt: new Date().toISOString(), direction: "down" },
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
const plan = buildScopeShrinkPlan({
|
|
520
|
+
journal,
|
|
521
|
+
hqRoot,
|
|
522
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
523
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
524
|
+
});
|
|
525
|
+
const result = applyScopeShrink({
|
|
526
|
+
journal,
|
|
527
|
+
plan,
|
|
528
|
+
hqRoot,
|
|
529
|
+
forceScopeShrink: false,
|
|
530
|
+
cleanDisposition: "quarantine",
|
|
531
|
+
quarantineRoot,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(result.cleanQuarantined).toBe(1);
|
|
535
|
+
expect(result.cleanRemoved).toBe(0);
|
|
536
|
+
expect(result.quarantinedPaths).toEqual([rel]);
|
|
537
|
+
expect(result.removedPaths).toEqual([]);
|
|
538
|
+
expect(result.quarantineRoot).toBe(quarantineRoot);
|
|
539
|
+
// Moved, not deleted: gone from the working tree, present + intact in quarantine.
|
|
540
|
+
expect(fs.existsSync(abs)).toBe(false);
|
|
541
|
+
const quarantined = path.join(quarantineRoot, rel);
|
|
542
|
+
expect(fs.existsSync(quarantined)).toBe(true);
|
|
543
|
+
expect(fs.readFileSync(quarantined, "utf-8")).toBe("clean");
|
|
544
|
+
// Journal entry tombstoned so it stops being re-flagged (idempotent).
|
|
545
|
+
expect(journal.files[rel]?.removedAt).toBeTruthy();
|
|
546
|
+
});
|
|
501
547
|
});
|
|
502
548
|
|
|
503
549
|
describe("ScopeShrinkBlockedError", () => {
|
|
@@ -527,4 +573,29 @@ describe("ScopeShrinkBlockedError", () => {
|
|
|
527
573
|
expect(err.dirty).toHaveLength(1);
|
|
528
574
|
expect(err.name).toBe("ScopeShrinkBlockedError");
|
|
529
575
|
});
|
|
576
|
+
|
|
577
|
+
// DEV-1768 fix #2: advice must be FOLLOWABLE from the entry point that throws.
|
|
578
|
+
// The old message always said "pass --force-scope-shrink", which the menubar
|
|
579
|
+
// runner cannot accept.
|
|
580
|
+
const mk = (ctx?: "cli" | "runner" | "engine") =>
|
|
581
|
+
new ScopeShrinkBlockedError("cmp_x", "all", "shared", [], [], ctx);
|
|
582
|
+
|
|
583
|
+
it("runner-context advice points at a terminal command, NOT an impossible flag", () => {
|
|
584
|
+
const msg = mk("runner").message;
|
|
585
|
+
// The followable path from the menubar is a terminal command.
|
|
586
|
+
expect(msg).toContain("hq sync narrow --apply");
|
|
587
|
+
// It must NOT imply the flag is acceptable to the menubar itself.
|
|
588
|
+
expect(msg).toContain("menubar sync cannot take this flag");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("cli-context advice offers the flag the CLI actually accepts", () => {
|
|
592
|
+
const msg = mk("cli").message;
|
|
593
|
+
expect(msg).toContain("--force-scope-shrink");
|
|
594
|
+
expect(msg).toContain("hq sync narrow --apply");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("defaults to the engine/library advice when no context is given", () => {
|
|
598
|
+
expect(mk().adviceContext).toBe("engine");
|
|
599
|
+
expect(mk().message).toContain("hq sync narrow --apply");
|
|
600
|
+
});
|
|
530
601
|
});
|