@indigoai-us/hq-cloud 5.32.0 → 5.34.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 +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +53 -27
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +69 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +60 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +129 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +104 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +260 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +469 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +7 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +19 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/lib/conflict-file.d.ts +7 -6
- package/dist/lib/conflict-file.d.ts.map +1 -1
- package/dist/lib/conflict-file.js +7 -27
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/lib/conflict.test.d.ts +4 -3
- package/dist/lib/conflict.test.d.ts.map +1 -1
- package/dist/lib/conflict.test.js +5 -33
- package/dist/lib/conflict.test.js.map +1 -1
- package/dist/lib/machine-id.d.ts +108 -0
- package/dist/lib/machine-id.d.ts.map +1 -0
- package/dist/lib/machine-id.js +170 -0
- package/dist/lib/machine-id.js.map +1 -0
- package/dist/lib/machine-id.test.d.ts +8 -0
- package/dist/lib/machine-id.test.d.ts.map +1 -0
- package/dist/lib/machine-id.test.js +195 -0
- package/dist/lib/machine-id.test.js.map +1 -0
- package/dist/s3.d.ts +21 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +69 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +129 -2
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +85 -0
- package/src/bin/sync-runner.ts +62 -25
- package/src/cli/share.test.ts +115 -6
- package/src/cli/share.ts +149 -9
- package/src/cli/sync.test.ts +529 -0
- package/src/cli/sync.ts +295 -8
- package/src/ignore.test.ts +20 -3
- package/src/ignore.ts +7 -1
- package/src/lib/conflict-file.ts +7 -27
- package/src/lib/conflict.test.ts +4 -40
- package/src/lib/machine-id.test.ts +221 -0
- package/src/lib/machine-id.ts +175 -0
- package/src/s3.test.ts +142 -2
- package/src/s3.ts +71 -2
package/src/bin/sync-runner.ts
CHANGED
|
@@ -89,6 +89,7 @@ import type { ConflictStrategy } from "../cli/conflict.js";
|
|
|
89
89
|
import type { UploadAuthor } from "../s3.js";
|
|
90
90
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
91
91
|
import { describeError } from "../lib/describe-error.js";
|
|
92
|
+
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
92
93
|
import {
|
|
93
94
|
TreeWatcher,
|
|
94
95
|
WatchPushDriver,
|
|
@@ -258,6 +259,15 @@ export type RunnerEvent =
|
|
|
258
259
|
*/
|
|
259
260
|
filesTombstoned: number;
|
|
260
261
|
filesRefusedStale: number;
|
|
262
|
+
/**
|
|
263
|
+
* Paths corresponding to `filesRefusedStale`, capped at 50 (mirrors
|
|
264
|
+
* `newFiles` cap). Surfaced on the `complete` event so operators
|
|
265
|
+
* can triage the recurring `filesRefusedStale: 205` signal that
|
|
266
|
+
* the 5.33.0 deep-test flagged as untriageable — the count alone
|
|
267
|
+
* is impossible to investigate after the per-file
|
|
268
|
+
* `delete-refused-stale-etag` events scroll off.
|
|
269
|
+
*/
|
|
270
|
+
filesRefusedStalePaths: string[];
|
|
261
271
|
filesExcludedByPolicy: number;
|
|
262
272
|
} & SyncResult)
|
|
263
273
|
| {
|
|
@@ -593,24 +603,17 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
593
603
|
async function defaultCollectTelemetry(
|
|
594
604
|
client: VaultClientSurface,
|
|
595
605
|
clientIsStub: boolean,
|
|
606
|
+
hqRoot: string,
|
|
596
607
|
): Promise<void> {
|
|
597
608
|
if (clientIsStub) return;
|
|
598
609
|
try {
|
|
599
|
-
// machineId:
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const raw = await fs.promises.readFile(menubarPath, "utf-8");
|
|
607
|
-
const parsed = JSON.parse(raw) as { machineId?: unknown };
|
|
608
|
-
if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
|
|
609
|
-
machineId = parsed.machineId;
|
|
610
|
-
}
|
|
611
|
-
} catch {
|
|
612
|
-
// No menubar.json — proceed with "unknown".
|
|
613
|
-
}
|
|
610
|
+
// machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
|
|
611
|
+
// (see `src/lib/machine-id.ts`). The resolver migrates forward from
|
|
612
|
+
// any legacy `~/.hq/menubar.json` value on first call, then becomes
|
|
613
|
+
// self-sufficient. On a clean Linux outpost (no menubar app), a fresh
|
|
614
|
+
// UUID is generated + persisted, so this row is attributable rather
|
|
615
|
+
// than collapsing onto the legacy `"unknown"` sentinel.
|
|
616
|
+
const machineId = getOrCreateMachineId(hqRoot);
|
|
614
617
|
|
|
615
618
|
// installerVersion: callers (the Tauri menubar) set this when spawning
|
|
616
619
|
// the runner so the historical `installerVersion` dimension on
|
|
@@ -954,6 +957,7 @@ export async function runRunner(
|
|
|
954
957
|
filesDeleted: 0,
|
|
955
958
|
filesTombstoned: 0,
|
|
956
959
|
filesRefusedStale: 0,
|
|
960
|
+
filesRefusedStalePaths: [],
|
|
957
961
|
filesExcludedByPolicy: 0,
|
|
958
962
|
conflictPaths: [],
|
|
959
963
|
aborted: false,
|
|
@@ -967,6 +971,8 @@ export async function runRunner(
|
|
|
967
971
|
aborted: false,
|
|
968
972
|
newFiles: [],
|
|
969
973
|
newFilesCount: 0,
|
|
974
|
+
filesExcludedByPolicy: 0,
|
|
975
|
+
filesTombstoned: 0,
|
|
970
976
|
};
|
|
971
977
|
|
|
972
978
|
// Push first so a subsequent pull doesn't overwrite files we were about
|
|
@@ -1094,13 +1100,24 @@ export async function runRunner(
|
|
|
1094
1100
|
});
|
|
1095
1101
|
}
|
|
1096
1102
|
|
|
1097
|
-
// Concat push + pull conflict paths into a single per-company list
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1103
|
+
// Concat push + pull conflict paths into a single per-company list,
|
|
1104
|
+
// then dedupe — a key that legitimately conflicts on both halves of
|
|
1105
|
+
// a bidirectional run (e.g. `.hq/install-manifest.json` round-trip
|
|
1106
|
+
// before Fix #1) appears twice in the concat but represents a single
|
|
1107
|
+
// logical conflict for the operator. Bug #3 in the 5.33.0 deep-test:
|
|
1108
|
+
// every round produced `conflictPaths: [X, X]` and `conflicts: 2`,
|
|
1109
|
+
// double-counting the conflict in every metric downstream. The push
|
|
1110
|
+
// and pull halves each emit a `conflict` event in their own direction
|
|
1111
|
+
// (preserved on the event stream for tracing); only the merged
|
|
1112
|
+
// result list is collapsed. Stable first-seen order is preserved so
|
|
1113
|
+
// consumers can rely on the pull entry coming before its push twin.
|
|
1114
|
+
const seenConflictPaths = new Set<string>();
|
|
1115
|
+
const mergedConflictPaths: string[] = [];
|
|
1116
|
+
for (const p of [...pullResult.conflictPaths, ...pushResult.conflictPaths]) {
|
|
1117
|
+
if (seenConflictPaths.has(p)) continue;
|
|
1118
|
+
seenConflictPaths.add(p);
|
|
1119
|
+
mergedConflictPaths.push(p);
|
|
1120
|
+
}
|
|
1104
1121
|
const aborted = pullResult.aborted || pushResult.aborted;
|
|
1105
1122
|
|
|
1106
1123
|
// Overwrite the progress-derived counts with the authoritative numbers
|
|
@@ -1131,9 +1148,28 @@ export async function runRunner(
|
|
|
1131
1148
|
// from the legacy-engine `None` and useful when the UI wants to
|
|
1132
1149
|
// distinguish "engine ran, nothing tombstoned" from "engine
|
|
1133
1150
|
// didn't report".
|
|
1134
|
-
|
|
1151
|
+
// Tombstones now flow on both legs:
|
|
1152
|
+
// - push side: `ShareResult.filesTombstoned` (remote was already
|
|
1153
|
+
// 404 at HEAD time, journal entry dropped).
|
|
1154
|
+
// - pull side: `SyncResult.filesTombstoned` (Bug #9 — journal-
|
|
1155
|
+
// known key missing from remote LIST, applied as local delete).
|
|
1156
|
+
// Sum them so the menubar's `SyncCompleteEvent` reflects the total
|
|
1157
|
+
// delete-propagation activity for that company across the run.
|
|
1158
|
+
filesTombstoned: pushResult.filesTombstoned + pullResult.filesTombstoned,
|
|
1135
1159
|
filesRefusedStale: pushResult.filesRefusedStale,
|
|
1136
|
-
|
|
1160
|
+
// Bonus diagnostic: surface the paths so operators can triage the
|
|
1161
|
+
// recurring `filesRefusedStale: N` signal — the count alone was
|
|
1162
|
+
// untriageable per the 5.33.0 deep-test report's "205 issue".
|
|
1163
|
+
// Pre-capped at 50 by share() itself.
|
|
1164
|
+
filesRefusedStalePaths: pushResult.filesRefusedStalePaths,
|
|
1165
|
+
// Pull side now reports an `filesExcludedByPolicy` too (Bug #2 —
|
|
1166
|
+
// ephemeral conflict-mirror refusals in the pull walker). Sum
|
|
1167
|
+
// both legs so the `complete` event reports total excluded across
|
|
1168
|
+
// the full bidirectional pass; pre-fix the pull half silently
|
|
1169
|
+
// pushed legacy `.conflict-*` litter into clean trees with the
|
|
1170
|
+
// same counter showing 0.
|
|
1171
|
+
filesExcludedByPolicy:
|
|
1172
|
+
pushResult.filesExcludedByPolicy + pullResult.filesExcludedByPolicy,
|
|
1137
1173
|
// Sourced from the merged path list so push-side conflicts are
|
|
1138
1174
|
// counted too — `ShareResult` doesn't expose a numeric counter,
|
|
1139
1175
|
// and using `pullResult.conflicts` alone silently dropped any
|
|
@@ -1180,6 +1216,7 @@ export async function runRunner(
|
|
|
1180
1216
|
// throw.
|
|
1181
1217
|
filesTombstoned: 0,
|
|
1182
1218
|
filesRefusedStale: 0,
|
|
1219
|
+
filesRefusedStalePaths: [],
|
|
1183
1220
|
filesExcludedByPolicy: 0,
|
|
1184
1221
|
conflicts: 0,
|
|
1185
1222
|
conflictPaths: [],
|
|
@@ -1245,7 +1282,7 @@ export async function runRunner(
|
|
|
1245
1282
|
// which naturally bounds the outer wait.
|
|
1246
1283
|
const telemetryFn =
|
|
1247
1284
|
deps.collectTelemetry ??
|
|
1248
|
-
(() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined));
|
|
1285
|
+
(() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined, parsed.hqRoot));
|
|
1249
1286
|
await telemetryFn().catch(() => undefined);
|
|
1250
1287
|
|
|
1251
1288
|
emit({
|
package/src/cli/share.test.ts
CHANGED
|
@@ -378,6 +378,95 @@ describe("share", () => {
|
|
|
378
378
|
});
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
|
|
382
|
+
// Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
|
|
383
|
+
// NO prior journal entry (fresh upload from this machine) but the
|
|
384
|
+
// remote already has a different object at that key (a peer pushed
|
|
385
|
+
// first), the previous detector required \`!!journalEntry\` for BOTH
|
|
386
|
+
// localChanged and remoteChanged, so the whole \`localChanged &&
|
|
387
|
+
// remoteChanged\` predicate evaluated false. Push fell through to
|
|
388
|
+
// an unconditional PUT, silently overwriting the peer's content
|
|
389
|
+
// without recording a conflict event or writing a mirror.
|
|
390
|
+
//
|
|
391
|
+
// The verification report's V7 isolated the mechanism: the bug is
|
|
392
|
+
// independent of \`--on-conflict\` mode — the conflict-detection
|
|
393
|
+
// branch is keyed on "do I have a prior journal entry?", not on the
|
|
394
|
+
// policy flag. \`s13-conflict.txt\` (had a journal entry) correctly
|
|
395
|
+
// aborted under --on-conflict abort; \`v7b-fresh-collision.txt\`
|
|
396
|
+
// (no journal entry) was never even reported.
|
|
397
|
+
//
|
|
398
|
+
// Fix: even on first-time upload, if HEAD shows remote exists and
|
|
399
|
+
// its content (etag) differs from what we'd put, emit a conflict.
|
|
400
|
+
// Under keep, the existing pull-side mirror routine downloads the
|
|
401
|
+
// remote bytes to \`<orig>.conflict-<ts>-<short>\` so both versions
|
|
402
|
+
// survive on disk; under abort, the company aborts before PUT.
|
|
403
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
404
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
405
|
+
const testFile = path.join(companyRoot, "v7b-fresh.txt");
|
|
406
|
+
fs.writeFileSync(testFile, "macos-fresh-collision-content");
|
|
407
|
+
|
|
408
|
+
// Remote exists with a different etag — and there is NO journal
|
|
409
|
+
// entry for this path (this is the first push from this machine).
|
|
410
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
411
|
+
lastModified: new Date(),
|
|
412
|
+
etag: '"remote-version-etag"',
|
|
413
|
+
size: 40,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// No journal file written — first-time upload contract.
|
|
417
|
+
const events: unknown[] = [];
|
|
418
|
+
const result = await share({
|
|
419
|
+
paths: [testFile],
|
|
420
|
+
company: "acme",
|
|
421
|
+
vaultConfig: mockConfig,
|
|
422
|
+
hqRoot: tmpDir,
|
|
423
|
+
onConflict: "keep",
|
|
424
|
+
onEvent: (e) => events.push(e),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(result.conflictPaths).toEqual(["v7b-fresh.txt"]);
|
|
428
|
+
// Under keep, local stays as-is (no upload) and the mirror records
|
|
429
|
+
// the remote bytes so neither version is lost.
|
|
430
|
+
expect(result.filesUploaded).toBe(0);
|
|
431
|
+
const conflicts = events.filter(
|
|
432
|
+
(e): e is { type: "conflict"; path: string } =>
|
|
433
|
+
typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
434
|
+
);
|
|
435
|
+
expect(conflicts).toHaveLength(1);
|
|
436
|
+
expect(conflicts[0].path).toBe("v7b-fresh.txt");
|
|
437
|
+
// Local file unmodified — it must still contain the local content.
|
|
438
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("macos-fresh-collision-content");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("first-time-upload-with-cloud-collision: aborts company under --on-conflict abort (Bug #7)", async () => {
|
|
442
|
+
// Symmetric to the keep case: when --on-conflict abort is passed, a
|
|
443
|
+
// first-time-upload-with-cloud-collision must abort the share leg
|
|
444
|
+
// before the PUT (preserving remote intact) and surface
|
|
445
|
+
// \`aborted: true\` + \`conflicts: 1\` in the result.
|
|
446
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
447
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
448
|
+
const testFile = path.join(companyRoot, "v7b-abort.txt");
|
|
449
|
+
fs.writeFileSync(testFile, "local-version");
|
|
450
|
+
|
|
451
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
452
|
+
lastModified: new Date(),
|
|
453
|
+
etag: '"different-remote"',
|
|
454
|
+
size: 99,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const result = await share({
|
|
458
|
+
paths: [testFile],
|
|
459
|
+
company: "acme",
|
|
460
|
+
vaultConfig: mockConfig,
|
|
461
|
+
hqRoot: tmpDir,
|
|
462
|
+
onConflict: "abort",
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(result.aborted).toBe(true);
|
|
466
|
+
expect(result.conflictPaths).toEqual(["v7b-abort.txt"]);
|
|
467
|
+
expect(result.filesUploaded).toBe(0);
|
|
468
|
+
});
|
|
469
|
+
|
|
381
470
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
382
471
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
383
472
|
// that exists on S3 used to trigger a push conflict because the
|
|
@@ -2454,6 +2543,23 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
|
|
|
2454
2543
|
["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
|
|
2455
2544
|
// Non-markdown extensions also valid (sh scripts, ts files, etc.).
|
|
2456
2545
|
["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
|
|
2546
|
+
// ── extensionless originals (regression: `path.extname('.gitignore')`
|
|
2547
|
+
// returns '' in Node, so `buildConflictPath` produces no trailing
|
|
2548
|
+
// `.<ext>` segment for hidden-but-extensionless files).
|
|
2549
|
+
[".gitignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
|
|
2550
|
+
[".hqignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
|
|
2551
|
+
[".agents/skills.conflict-2026-05-19T17-07-01Z-0a513b", true],
|
|
2552
|
+
// ── legacy "unknown" machine token (regression: hosts without
|
|
2553
|
+
// `~/.hq/menubar.json` pre-Fix-3 fell through to the literal string
|
|
2554
|
+
// `"unknown"`, which `[a-f0-9]+` refused. Producer side is closed in
|
|
2555
|
+
// `../lib/machine-id.ts`, but the regex still must filter the
|
|
2556
|
+
// already-on-disk legacy files so the next push removes them).
|
|
2557
|
+
[".gitignore.conflict-2026-05-15T15-10-35Z-unknown", true],
|
|
2558
|
+
[".agents/skills.conflict-2026-05-15T15-10-35Z-unknown", true],
|
|
2559
|
+
["notes.md.conflict-2026-05-15T15-10-35Z-unknown.md", true],
|
|
2560
|
+
[".hq/install-manifest.json.conflict-2026-05-15T15-11-58Z-unknown.json", true],
|
|
2561
|
+
// Multi-dot extension (e.g., archive tarballs that conflicted).
|
|
2562
|
+
["dump.conflict-2026-05-13T19-40-40Z-abc.tar.gz", true],
|
|
2457
2563
|
])("matches conflict mirror: %s", (p, expected) => {
|
|
2458
2564
|
expect(isEphemeralPath(p)).toBe(expected);
|
|
2459
2565
|
});
|
|
@@ -2467,17 +2573,20 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
|
|
|
2467
2573
|
["conflict-resolution.md", false],
|
|
2468
2574
|
["my-conflict.md", false],
|
|
2469
2575
|
["foo.conflict-handler.md", false],
|
|
2470
|
-
//
|
|
2471
|
-
// always carry a file extension; the trailing `\.` in the pattern is the
|
|
2472
|
-
// safety against bare-substring false positives).
|
|
2473
|
-
["foo.conflict-2026-05-13T19-40-40Z-abc", false],
|
|
2474
|
-
// Wrong-case or non-hex machine hash.
|
|
2576
|
+
// Wrong-case or non-hex/non-"unknown" machine hash.
|
|
2475
2577
|
["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
|
|
2476
2578
|
// Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
|
|
2477
2579
|
["foo.conflict-2026-05-13-abc123.md", false],
|
|
2478
|
-
// Missing leading dot before "conflict" (
|
|
2580
|
+
// Missing leading dot before "conflict" (protects against legitimate
|
|
2479
2581
|
// files that happen to contain the word "conflict" mid-name).
|
|
2480
2582
|
["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
|
|
2583
|
+
// Extra trailing segments after the machine hash — the `$` anchor +
|
|
2584
|
+
// `[^/]*` ext class ensure a conflict marker can't appear mid-path.
|
|
2585
|
+
["foo.conflict-2026-05-13T19-40-40Z-abc/extra/path", false],
|
|
2586
|
+
["foo.conflict-2026-05-13T19-40-40Z-abc-then-more-text", false],
|
|
2587
|
+
// Bare "unknown"-like tokens that aren't the literal sentinel.
|
|
2588
|
+
["foo.conflict-2026-05-13T19-40-40Z-unknowing.md", false],
|
|
2589
|
+
["foo.conflict-2026-05-13T19-40-40Z-UNKNOWN.md", false],
|
|
2481
2590
|
])("rejects non-mirror: %s", (p, expected) => {
|
|
2482
2591
|
expect(isEphemeralPath(p)).toBe(expected);
|
|
2483
2592
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -9,7 +9,8 @@ import * as fs from "fs";
|
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
|
-
import { uploadFile, uploadSymlink, headRemoteFile, deleteRemoteFile } from "../s3.js";
|
|
12
|
+
import { uploadFile, uploadSymlink, headRemoteFile, deleteRemoteFile, downloadFile } from "../s3.js";
|
|
13
|
+
import * as crypto from "crypto";
|
|
13
14
|
import type { UploadAuthor } from "../s3.js";
|
|
14
15
|
import {
|
|
15
16
|
readJournal,
|
|
@@ -28,12 +29,21 @@ import {
|
|
|
28
29
|
import { resolveConflict } from "./conflict.js";
|
|
29
30
|
import type { ConflictStrategy } from "./conflict.js";
|
|
30
31
|
import type { SyncProgressEvent } from "./sync.js";
|
|
32
|
+
import {
|
|
33
|
+
buildConflictId,
|
|
34
|
+
buildConflictPath,
|
|
35
|
+
readShortMachineId,
|
|
36
|
+
} from "../lib/conflict-file.js";
|
|
37
|
+
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
40
|
* Local-only ephemeral artifacts: conflict-mirror files written by the pull
|
|
34
41
|
* leg whenever a 3-way merge keeps local AND wants to preserve the remote
|
|
35
|
-
* version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash
|
|
36
|
-
* (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md
|
|
42
|
+
* version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
|
|
43
|
+
* (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
|
|
44
|
+
* or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
|
|
45
|
+
* originals produce no trailing dot, see `buildConflictPath` in
|
|
46
|
+
* `../lib/conflict-file.ts`).
|
|
37
47
|
*
|
|
38
48
|
* These files MUST never round-trip to S3 — they're local-only safety backups
|
|
39
49
|
* the user reviews and deletes once the merge is resolved. Pre-fix, the push
|
|
@@ -42,21 +52,46 @@ import type { SyncProgressEvent } from "./sync.js";
|
|
|
42
52
|
* deleted them locally (because pull-confirmation had stamped them as
|
|
43
53
|
* `direction: "down"`). Net effect: a permanent litter ratchet on remote.
|
|
44
54
|
*
|
|
55
|
+
* Two known producer-shapes the regex must accommodate (both observed on
|
|
56
|
+
* affected user trees prior to this fix):
|
|
57
|
+
*
|
|
58
|
+
* 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
|
|
59
|
+
* provisioning (see `../lib/machine-id.ts`), hosts without
|
|
60
|
+
* `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
|
|
61
|
+
* install — fell through to the literal string `"unknown"` from the
|
|
62
|
+
* old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
|
|
63
|
+
* live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
|
|
64
|
+
* filenames. They round-tripped to S3 as ordinary files (which IS the
|
|
65
|
+
* "permanent litter ratchet" this module's contract was supposed to
|
|
66
|
+
* prevent). The new machine-id provisioning closes the producer side,
|
|
67
|
+
* but we still accept `unknown` here so legacy files already on disk
|
|
68
|
+
* are filtered out by the next push.
|
|
69
|
+
*
|
|
70
|
+
* 2. **Extensionless originals.** `path.extname('.gitignore')` returns
|
|
71
|
+
* `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
|
|
72
|
+
* segment for hidden-but-extensionless files like `.gitignore`,
|
|
73
|
+
* `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
|
|
74
|
+
* tail was mandatory, so those names slipped through.
|
|
75
|
+
*
|
|
45
76
|
* Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
|
|
46
77
|
* they never upload; (2) `computeDeletePlan` — skip these so an already-
|
|
47
78
|
* journaled mirror that's been deleted locally doesn't get included in the
|
|
48
79
|
* regular delete plan (the dedicated reconcile path handles existing litter).
|
|
49
80
|
*/
|
|
50
|
-
const EPHEMERAL_PATH_PATTERN =
|
|
51
|
-
/\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]
|
|
81
|
+
export const EPHEMERAL_PATH_PATTERN =
|
|
82
|
+
/\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
|
|
52
83
|
|
|
53
84
|
/**
|
|
54
85
|
* Cheap pure check — pass the relative key OR a basename; either works. Used
|
|
55
86
|
* in both the file walker (basename matching) and the delete-plan walker
|
|
56
|
-
* (relative-key matching)
|
|
57
|
-
*
|
|
87
|
+
* (relative-key matching), and also by the pull walker in sync.ts to refuse
|
|
88
|
+
* downloading legacy conflict-mirror files that still live in cloud staging
|
|
89
|
+
* (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since
|
|
90
|
+
* 5.33.0 but pull-side downloaded them freely until this export). The regex
|
|
91
|
+
* matches anywhere in the string, which is fine: the
|
|
92
|
+
* `.conflict-<ISO>-<hash>.` token is unambiguous.
|
|
58
93
|
*/
|
|
59
|
-
function isEphemeralPath(p: string): boolean {
|
|
94
|
+
export function isEphemeralPath(p: string): boolean {
|
|
60
95
|
return EPHEMERAL_PATH_PATTERN.test(p);
|
|
61
96
|
}
|
|
62
97
|
|
|
@@ -416,6 +451,16 @@ export interface ShareResult {
|
|
|
416
451
|
* `currency-gated`.
|
|
417
452
|
*/
|
|
418
453
|
filesRefusedStale: number;
|
|
454
|
+
/**
|
|
455
|
+
* Paths corresponding to `filesRefusedStale`, capped at 50 to keep the
|
|
456
|
+
* event payload bounded (mirrors `newFiles` capping). Surfaces *which*
|
|
457
|
+
* paths were refused so operators can triage the recurring
|
|
458
|
+
* \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test —
|
|
459
|
+
* the count alone is impossible to investigate because the per-file
|
|
460
|
+
* \`delete-refused-stale-etag\` events vanish from the event stream
|
|
461
|
+
* once the runner has folded them into the totals.
|
|
462
|
+
*/
|
|
463
|
+
filesRefusedStalePaths: string[];
|
|
419
464
|
/**
|
|
420
465
|
* Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
|
|
421
466
|
* run (push leg, personalMode=true). Includes both files that would have
|
|
@@ -544,6 +589,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
544
589
|
// propagateDeletes=false path.
|
|
545
590
|
let filesTombstoned = 0;
|
|
546
591
|
let filesRefusedStale = 0;
|
|
592
|
+
// Capped at 50 to bound event payload size — `newFiles` uses the same cap.
|
|
593
|
+
const REFUSED_STALE_PATH_CAP = 50;
|
|
594
|
+
const filesRefusedStalePaths: string[] = [];
|
|
547
595
|
const conflictPaths: string[] = [];
|
|
548
596
|
|
|
549
597
|
// Collect all files to share
|
|
@@ -668,13 +716,58 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
668
716
|
// the current remote ETag against the one captured at last sync; when
|
|
669
717
|
// missing (legacy entries), we fall back to the same `lastModified >
|
|
670
718
|
// syncedAt` heuristic the pull side uses.
|
|
719
|
+
//
|
|
720
|
+
// Bug #7 (data-loss class — see workspace/reports/hq-cloud-5.33.0-
|
|
721
|
+
// deep-test.md): for a path with NO prior journal entry (first push
|
|
722
|
+
// from this machine), the localChanged/remoteChanged predicates above
|
|
723
|
+
// both evaluate FALSE (their guards require `!!journalEntry`). Push
|
|
724
|
+
// fell through to an unconditional PUT, silently clobbering any
|
|
725
|
+
// peer's content already at that key. The verification report's V7
|
|
726
|
+
// isolated this — the bug is independent of \`--on-conflict\` mode;
|
|
727
|
+
// it's keyed on "do I have a prior journal entry?" not on the flag.
|
|
728
|
+
//
|
|
729
|
+
// Fresh-collision branch: when remoteMeta exists and there's no
|
|
730
|
+
// journal entry, hash the local body (MD5 for parity with S3's
|
|
731
|
+
// single-part etag) and compare. Match → no conflict, silently skip
|
|
732
|
+
// the PUT (the bytes are already there). Mismatch → treat as a
|
|
733
|
+
// conflict in the same shared branch below.
|
|
671
734
|
const remoteMeta = await headRemoteFile(ctx, relativePath);
|
|
672
735
|
if (remoteMeta) {
|
|
673
736
|
const journalEntry = journal.files[relativePath];
|
|
674
737
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
675
738
|
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
676
739
|
|
|
677
|
-
|
|
740
|
+
let isFreshCollision = false;
|
|
741
|
+
if (!journalEntry && item.kind === "file") {
|
|
742
|
+
// Single-part S3 PUT etag is MD5 of the body. Multipart uploads
|
|
743
|
+
// produce \`<md5>-<partCount>\`; we treat any non-single-part etag
|
|
744
|
+
// as ambiguous and DO classify as a conflict (safer for the
|
|
745
|
+
// first-time path — false positives prompt the operator, false
|
|
746
|
+
// negatives lose data). Symlink records (\`kind: "symlink"\`)
|
|
747
|
+
// skip the check entirely — the wire body shape (\`hq-symlink:\`
|
|
748
|
+
// prefix + target) isn't a pure byte mirror and would mis-
|
|
749
|
+
// classify; symlink overwrites are rare and an audit pass after
|
|
750
|
+
// the broader bug-cleanup wave can extend coverage if needed.
|
|
751
|
+
const remoteEtagNormalized = normalizeEtag(remoteMeta.etag);
|
|
752
|
+
const isMultipart = /-\d+$/.test(remoteEtagNormalized);
|
|
753
|
+
if (!isMultipart) {
|
|
754
|
+
const localBody = fs.readFileSync(absolutePath);
|
|
755
|
+
const localMd5 = crypto.createHash("md5").update(localBody).digest("hex");
|
|
756
|
+
if (localMd5 !== remoteEtagNormalized) {
|
|
757
|
+
isFreshCollision = true;
|
|
758
|
+
}
|
|
759
|
+
// Match → bytes are already there; fall through to upload
|
|
760
|
+
// path which is idempotent (S3 will overwrite with identical
|
|
761
|
+
// content + carry our metadata). Cheap, no behavior change.
|
|
762
|
+
} else {
|
|
763
|
+
// Multipart object pre-exists with unknown body shape — assume
|
|
764
|
+
// collision rather than risk a silent overwrite. The operator
|
|
765
|
+
// can resolve via the standard conflict prompt.
|
|
766
|
+
isFreshCollision = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
678
771
|
conflictPaths.push(relativePath);
|
|
679
772
|
|
|
680
773
|
const resolution = await resolveConflict(
|
|
@@ -705,6 +798,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
705
798
|
// ShareResult shape stable for consumers that destructure.
|
|
706
799
|
filesTombstoned,
|
|
707
800
|
filesRefusedStale,
|
|
801
|
+
// Always present so consumers can destructure without a
|
|
802
|
+
// defaulting fallback. Empty on the abort path because the
|
|
803
|
+
// delete-plan execution loop is short-circuited.
|
|
804
|
+
filesRefusedStalePaths,
|
|
708
805
|
// Exclusions are computed during the upload walk which has
|
|
709
806
|
// already completed by the time we hit a per-file conflict-
|
|
710
807
|
// abort, so the count is meaningful here. No event emit on
|
|
@@ -716,6 +813,45 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
716
813
|
};
|
|
717
814
|
}
|
|
718
815
|
if (resolution === "keep" || resolution === "skip") {
|
|
816
|
+
// Bug #7 mirror branch: when the resolution is keep/skip on a
|
|
817
|
+
// FRESH collision (no prior journal entry), download the
|
|
818
|
+
// remote bytes to \`<orig>.conflict-<ts>-<short>\` so both
|
|
819
|
+
// versions survive on disk. Mirrors the pull-side mirror-write
|
|
820
|
+
// routine in sync.ts exactly. Skipped for stale-journal
|
|
821
|
+
// conflicts (the pre-Bug-#7 codepath) — those already produce
|
|
822
|
+
// a pull-side mirror on the next sync cycle.
|
|
823
|
+
if (isFreshCollision) {
|
|
824
|
+
try {
|
|
825
|
+
const detectedAt = new Date().toISOString();
|
|
826
|
+
const machineId = readShortMachineId(hqRoot);
|
|
827
|
+
const originalRelative = path.relative(hqRoot, absolutePath);
|
|
828
|
+
const conflictRelative = buildConflictPath(
|
|
829
|
+
originalRelative,
|
|
830
|
+
detectedAt,
|
|
831
|
+
machineId,
|
|
832
|
+
);
|
|
833
|
+
const conflictAbs = path.join(hqRoot, conflictRelative);
|
|
834
|
+
await downloadFile(ctx, relativePath, conflictAbs);
|
|
835
|
+
appendConflictEntry(hqRoot, {
|
|
836
|
+
id: buildConflictId(originalRelative, detectedAt),
|
|
837
|
+
originalPath: originalRelative,
|
|
838
|
+
conflictPath: conflictRelative,
|
|
839
|
+
detectedAt,
|
|
840
|
+
side: "push",
|
|
841
|
+
machineId,
|
|
842
|
+
localHash,
|
|
843
|
+
remoteHash: normalizeEtag(remoteMeta.etag),
|
|
844
|
+
});
|
|
845
|
+
} catch (mirrorErr) {
|
|
846
|
+
emit({
|
|
847
|
+
type: "error",
|
|
848
|
+
path: relativePath,
|
|
849
|
+
message: `conflict mirror write failed: ${
|
|
850
|
+
mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)
|
|
851
|
+
}`,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
719
855
|
filesSkipped++;
|
|
720
856
|
continue;
|
|
721
857
|
}
|
|
@@ -838,6 +974,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
838
974
|
for (const refused of deletePlan.refusedStale) {
|
|
839
975
|
if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
|
|
840
976
|
filesRefusedStale++;
|
|
977
|
+
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
978
|
+
filesRefusedStalePaths.push(refused.key);
|
|
979
|
+
}
|
|
841
980
|
emit({
|
|
842
981
|
type: "delete-refused-stale-etag",
|
|
843
982
|
path: refused.key,
|
|
@@ -877,6 +1016,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
877
1016
|
filesDeleted,
|
|
878
1017
|
filesTombstoned,
|
|
879
1018
|
filesRefusedStale,
|
|
1019
|
+
filesRefusedStalePaths,
|
|
880
1020
|
filesExcludedByPolicy: excludedSet.size,
|
|
881
1021
|
conflictPaths,
|
|
882
1022
|
aborted: false,
|