@indigoai-us/hq-cli 5.345.168 → 5.345.170
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/CHANGELOG.md +23 -0
- package/dist/command-catalog.generated.d.ts +49 -4
- package/dist/command-catalog.generated.js +60 -4
- package/dist/commands/access.js +7 -2
- package/dist/commands/bot.js +4 -0
- package/dist/commands/cloud.d.ts +20 -0
- package/dist/commands/cloud.js +55 -4
- package/dist/commands/core-checkpoint-native.js +30 -10
- package/dist/commands/feedback-triage.d.ts +43 -1
- package/dist/commands/feedback-triage.js +123 -23
- package/dist/commands/feedback.d.ts +40 -1
- package/dist/commands/feedback.js +195 -7
- package/dist/commands/files.d.ts +42 -0
- package/dist/commands/files.js +223 -30
- package/dist/commands/mesh.js +6 -0
- package/dist/commands/sync-manifest.d.ts +16 -0
- package/dist/commands/sync-manifest.js +17 -1
- package/dist/lib/bot/decisions.d.ts +62 -0
- package/dist/lib/bot/decisions.js +228 -0
- package/dist/lib/bot/index.d.ts +1 -0
- package/dist/lib/bot/index.js +1 -0
- package/dist/lib/bot/run.d.ts +7 -0
- package/dist/lib/bot/run.js +104 -1
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +60 -29
- package/dist/lib/daemon/config.d.ts +7 -0
- package/dist/lib/daemon/config.js +32 -0
- package/dist/lib/daemon/run.d.ts +3 -2
- package/dist/lib/daemon/run.js +23 -3
- package/dist/lib/daemon/supervisor.d.ts +1 -0
- package/dist/lib/daemon/supervisor.js +4 -0
- package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
- package/dist/lib/daemon/sync-core-canary.js +56 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +39 -1
- package/dist/lib/doctor/checks/sync-health.js +121 -3
- package/dist/lib/doctor/fix/apply.d.ts +66 -8
- package/dist/lib/doctor/fix/apply.js +278 -80
- package/dist/lib/doctor/fix/process-identity.d.ts +13 -0
- package/dist/lib/doctor/fix/process-identity.js +34 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.d.ts +201 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.js +752 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +1 -1
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +13 -0
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +3 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
- package/dist/lib/work-context/organize.d.ts +7 -0
- package/dist/lib/work-context/organize.js +21 -1
- package/dist/sentry-before-send.js +14 -0
- package/dist/utils/breadcrumb-buffer.js +15 -4
- package/dist/utils/feedback-diagnostics.js +5 -2
- package/dist/utils/feedback-logs.d.ts +7 -0
- package/dist/utils/feedback-logs.js +37 -3
- package/dist/utils/scrub-diagnostics.d.ts +4 -0
- package/dist/utils/scrub-diagnostics.js +95 -0
- package/package.json +7 -7
|
@@ -37,9 +37,10 @@
|
|
|
37
37
|
* (the command resolves it once), keeping this module decoupled from the CLI and
|
|
38
38
|
* trivially testable against a fake tree.
|
|
39
39
|
*/
|
|
40
|
-
import { spawnSync } from "node:child_process";
|
|
40
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
41
41
|
import * as fs from "node:fs";
|
|
42
42
|
import * as path from "node:path";
|
|
43
|
+
import { getStateDir } from "@indigoai-us/hq-cloud";
|
|
43
44
|
import { isPersonalJournalSlug } from "../checks/sync-health.js";
|
|
44
45
|
import { createDefaultRegistry } from "../registry.js";
|
|
45
46
|
import { flattenFamilies } from "../report.js";
|
|
@@ -51,6 +52,8 @@ import { HQ_CLI_BINS, removeHqCliPs1Shims } from "../../windows/ps1-shims.js";
|
|
|
51
52
|
import { botDir } from "../../bot/paths.js";
|
|
52
53
|
import { readBotConfig } from "../../bot/config.js";
|
|
53
54
|
import { buildBotDaemonPaths, installedDaemonHqBinary, repairBotDaemonIfStale, rewriteBotDaemonNodePath, } from "../../bot/daemon.js";
|
|
55
|
+
import { DEFAULT_OWNER_IDENTITY, processStartTime } from "./process-identity.js";
|
|
56
|
+
import { REFRESH_BACKGROUND_MAX_MS, REFRESH_SUPERVISOR_SOURCE, anyRefreshHandoffAlive, harvestRefreshHandoffs, releaseDeadRefreshSpawnLock, newRefreshRunPaths, readRefreshRunExit, readRefreshRunLog, claimRefreshRun, clearRefreshRecord, removeRefreshRun, } from "./refresh-sync-handoff.js";
|
|
54
57
|
import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
|
|
55
58
|
import { pullOutputShowsCompanyNotResolved, recordCompanyResolution, } from "../company-resolution-state.js";
|
|
56
59
|
import { readRefreshManifestCursor, readRefreshSyncProgress, writeRefreshManifestCursor, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
|
|
@@ -184,6 +187,10 @@ export async function applyFixes(options) {
|
|
|
184
187
|
};
|
|
185
188
|
const refreshSync = async (target) => {
|
|
186
189
|
const result = await performRefreshSync(target);
|
|
190
|
+
// Still running in the background: no evidence about the company yet.
|
|
191
|
+
// The next --fix harvests its real exit before it plans anything.
|
|
192
|
+
if (result.outcome === "pull-handed-off")
|
|
193
|
+
return result;
|
|
187
194
|
if (target.personal)
|
|
188
195
|
return result;
|
|
189
196
|
noteCompanyOutcome(target.slug, result.ok, result.companyNotResolved === true);
|
|
@@ -205,6 +212,17 @@ export async function applyFixes(options) {
|
|
|
205
212
|
// 2. Collect findings, then plan each newly unlocked allowlisted repair.
|
|
206
213
|
// Identity is deliberately the check id + target pair: an unsuccessful
|
|
207
214
|
// repair must not be applied repeatedly in one invocation.
|
|
215
|
+
// Background pulls from an earlier --fix: record how each really ended
|
|
216
|
+
// before planning, then drop the finished records (feedback cd56a501).
|
|
217
|
+
// A spawn lock left by a crashed claimer is released here when its owner is
|
|
218
|
+
// proven gone, even if no journal is stale this run.
|
|
219
|
+
releaseDeadRefreshSpawnLock(hqRoot, Date.now(), DEFAULT_OWNER_IDENTITY);
|
|
220
|
+
for (const harvested of harvestRefreshHandoffs(hqRoot, Date.now(), killOrphanPullIfSame)) {
|
|
221
|
+
const outcome = harvestedRefreshSyncResult(harvested);
|
|
222
|
+
if (harvested.slug === "personal" || isPersonalJournalSlug(harvested.slug))
|
|
223
|
+
continue;
|
|
224
|
+
noteCompanyOutcome(harvested.slug, outcome.ok, outcome.companyNotResolved === true);
|
|
225
|
+
}
|
|
208
226
|
let findings = await collect(hqRoot);
|
|
209
227
|
let lastAttemptedRefreshSyncSlug = readRefreshSyncProgress(hqRoot)?.lastAttemptedSlug ?? null;
|
|
210
228
|
let refreshSyncAppliedThisRun = 0;
|
|
@@ -333,6 +351,11 @@ export async function applyFixes(options) {
|
|
|
333
351
|
// left over and reported "not attempted" rather than as a failed repair.
|
|
334
352
|
if (network && !networkBudgetLeft())
|
|
335
353
|
continue;
|
|
354
|
+
// One background pull per HQ root. While it runs it holds the operation
|
|
355
|
+
// lock, so another pull would only wait on it; leave the rest for the
|
|
356
|
+
// next run.
|
|
357
|
+
if (plan.fixClass === "refresh-sync" && anyRefreshHandoffAlive(hqRoot, Date.now()))
|
|
358
|
+
continue;
|
|
336
359
|
if (plan.fixClass === "refresh-sync") {
|
|
337
360
|
// Persist before starting the bounded pull. If this process is killed,
|
|
338
361
|
// the next invocation resumes after this company instead of starving
|
|
@@ -540,101 +563,267 @@ async function defaultRunChecks(hqRoot) {
|
|
|
540
563
|
/**
|
|
541
564
|
* Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
|
|
542
565
|
* `hq sync pull --company <slug>` (or `--personal`) against the tree
|
|
543
|
-
* `--fix` is repairing. Never a push, never interactive. A
|
|
544
|
-
*
|
|
566
|
+
* `--fix` is repairing. Never a push, never interactive. A non-zero exit is
|
|
567
|
+
* reported rather than thrown so later plans still run.
|
|
568
|
+
*
|
|
569
|
+
* A pull still running after {@link REFRESH_SYNC_TIMEOUT_MS} is NOT killed
|
|
570
|
+
* (feedback cd56a501). A pull writes `lastSync` only when its pass finishes,
|
|
571
|
+
* so killing it at the deadline left a large vault, or a pull queued behind
|
|
572
|
+
* the sync runner's lock, stale on every run. It is left running detached and
|
|
573
|
+
* recorded in refresh-sync-handoff.ts; the journal check reports it as
|
|
574
|
+
* "refresh in progress" until it finishes.
|
|
545
575
|
*/
|
|
546
|
-
export function defaultRefreshSync(target) {
|
|
576
|
+
export async function defaultRefreshSync(target, deps = {}) {
|
|
547
577
|
const hqBin = process.argv[1];
|
|
548
578
|
const selector = target.personal || isPersonalJournalSlug(target.slug)
|
|
549
579
|
? ["--personal"]
|
|
550
580
|
: ["--company", target.slug];
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
...selector,
|
|
568
|
-
"--hq-root",
|
|
569
|
-
target.hqRoot,
|
|
570
|
-
"--on-conflict",
|
|
571
|
-
"keep",
|
|
572
|
-
"--lock-timeout",
|
|
573
|
-
"30",
|
|
574
|
-
];
|
|
575
|
-
const result = spawnSync(hqBin ? process.execPath : "hq", args, {
|
|
576
|
-
encoding: "utf8",
|
|
577
|
-
timeout: REFRESH_SYNC_TIMEOUT_MS,
|
|
578
|
-
cwd: target.hqRoot,
|
|
579
|
-
});
|
|
580
|
-
if (result.error) {
|
|
581
|
-
const spawnError = result.error;
|
|
582
|
-
const outcome = classifyRefreshSyncOutcome({
|
|
583
|
-
errorCode: spawnError.code,
|
|
584
|
-
...(result.error.message.includes("ETIMEDOUT")
|
|
585
|
-
? { errorCode: "ETIMEDOUT" }
|
|
586
|
-
: {}),
|
|
587
|
-
signal: result.signal,
|
|
588
|
-
status: result.status,
|
|
589
|
-
stdout: result.stdout,
|
|
590
|
-
stderr: result.stderr,
|
|
591
|
-
});
|
|
592
|
-
return Promise.resolve({
|
|
581
|
+
const pullArgs = [
|
|
582
|
+
"sync",
|
|
583
|
+
"pull",
|
|
584
|
+
...selector,
|
|
585
|
+
"--hq-root",
|
|
586
|
+
target.hqRoot,
|
|
587
|
+
"--on-conflict",
|
|
588
|
+
"keep",
|
|
589
|
+
"--lock-timeout",
|
|
590
|
+
String(REFRESH_SYNC_LOCK_TIMEOUT_SEC),
|
|
591
|
+
];
|
|
592
|
+
// Run the same CLI script under this node. Without a script path there is no
|
|
593
|
+
// safe way to start `hq` on Windows (it is a .cmd shim that needs a shell),
|
|
594
|
+
// so only POSIX falls back to `hq` on PATH.
|
|
595
|
+
if (!deps.command && !hqBin && process.platform === "win32") {
|
|
596
|
+
return {
|
|
593
597
|
ok: false,
|
|
594
|
-
outcome,
|
|
595
|
-
message:
|
|
596
|
-
|
|
597
|
-
: `could not start pull for '${target.slug}': ${result.error.message}`,
|
|
598
|
-
});
|
|
598
|
+
outcome: "pull-failed:other",
|
|
599
|
+
message: `could not start pull for '${target.slug}': the hq CLI script path is unknown`,
|
|
600
|
+
};
|
|
599
601
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
602
|
+
const command = deps.command ?? (hqBin ? process.execPath : "hq");
|
|
603
|
+
const args = deps.args ?? (hqBin ? [hqBin, ...pullArgs] : pullArgs);
|
|
604
|
+
const timeoutMs = deps.timeoutMs ?? REFRESH_SYNC_TIMEOUT_MS;
|
|
605
|
+
const maxBackgroundMs = deps.maxBackgroundMs ?? REFRESH_BACKGROUND_MAX_MS;
|
|
606
|
+
const nowMs = deps.nowMs ?? (() => Date.now());
|
|
607
|
+
// The pull runs under a supervisor that records its real exit status, so a
|
|
608
|
+
// pull left running past the deadline is still classified by the next run.
|
|
609
|
+
const run = newRefreshRunPaths(target.hqRoot, target.slug);
|
|
610
|
+
// One pull per HQ root: claim it (record written under the spawn lock)
|
|
611
|
+
// before spawning, so a concurrent --fix sees this pull and backs off.
|
|
612
|
+
if (!claimRefreshRun(target.hqRoot, target.slug, run, nowMs(), deps.identity ?? DEFAULT_OWNER_IDENTITY)) {
|
|
613
|
+
return {
|
|
608
614
|
ok: false,
|
|
609
|
-
outcome,
|
|
610
|
-
message: `pull
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
+
outcome: "pull-failed:lock-busy",
|
|
616
|
+
message: `another hq doctor --fix pull is already running; '${target.slug}' is left for the next run`,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
let supervisor;
|
|
620
|
+
try {
|
|
621
|
+
fs.mkdirSync(path.dirname(run.log), { recursive: true });
|
|
622
|
+
supervisor = spawn(process.execPath, [
|
|
623
|
+
"-e",
|
|
624
|
+
REFRESH_SUPERVISOR_SOURCE,
|
|
625
|
+
run.log,
|
|
626
|
+
run.exit,
|
|
627
|
+
run.heartbeat,
|
|
628
|
+
run.pid,
|
|
629
|
+
run.cancel,
|
|
630
|
+
deps.locksDir ?? defaultLocksDir(),
|
|
631
|
+
String(maxBackgroundMs),
|
|
632
|
+
command,
|
|
633
|
+
...args,
|
|
634
|
+
], { cwd: target.hqRoot, detached: true, stdio: "ignore", windowsHide: true });
|
|
615
635
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
636
|
+
catch (error) {
|
|
637
|
+
clearRefreshRecord(target.hqRoot, target.slug, run);
|
|
638
|
+
removeRefreshRun(run);
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
outcome: classifyRefreshSyncOutcome({ errorCode: error.code }),
|
|
642
|
+
message: `could not start pull for '${target.slug}': ${error.message}`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const finished = await new Promise((resolve) => {
|
|
646
|
+
let settled = false;
|
|
647
|
+
const settle = (value) => {
|
|
648
|
+
if (settled)
|
|
649
|
+
return;
|
|
650
|
+
settled = true;
|
|
651
|
+
clearTimeout(timer);
|
|
652
|
+
resolve(value);
|
|
653
|
+
};
|
|
654
|
+
supervisor.once("error", () => settle(true));
|
|
655
|
+
supervisor.once("exit", () => settle(true));
|
|
656
|
+
const timer = setTimeout(() => settle(false), timeoutMs);
|
|
620
657
|
});
|
|
658
|
+
if (!finished) {
|
|
659
|
+
// Leave the pull running on its own. It records lastSync when it
|
|
660
|
+
// finishes; until then the journal check reports "refresh in progress".
|
|
661
|
+
supervisor.removeAllListeners();
|
|
662
|
+
supervisor.unref();
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
outcome: "pull-handed-off",
|
|
666
|
+
message: `still pulling '${target.slug}' after ${timeoutMs / 1000}s; it keeps running in the background`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const result = harvestedRefreshSyncResult({
|
|
670
|
+
slug: target.slug,
|
|
671
|
+
exit: readRefreshRunExit(run),
|
|
672
|
+
output: readRefreshRunLog(run),
|
|
673
|
+
});
|
|
674
|
+
clearRefreshRecord(target.hqRoot, target.slug, run);
|
|
675
|
+
removeRefreshRun(run);
|
|
676
|
+
return result;
|
|
677
|
+
}
|
|
678
|
+
/** Map a finished supervised pull to a {@link RefreshSyncResult}. */
|
|
679
|
+
export function harvestedRefreshSyncResult(harvested) {
|
|
680
|
+
const { slug, exit, output } = harvested;
|
|
681
|
+
if (!exit) {
|
|
682
|
+
return { ok: false, outcome: "pull-failed:other", message: `pull of '${slug}' ended without a result` };
|
|
683
|
+
}
|
|
684
|
+
if (exit.capped) {
|
|
685
|
+
// Stopped at the background time cap. Completed downloads are kept.
|
|
686
|
+
return { ok: false, outcome: "pull-timeout-capped", message: `stopped pulling '${slug}' at the background time limit` };
|
|
687
|
+
}
|
|
688
|
+
if (exit.preempted) {
|
|
689
|
+
// Stopped so a sync the user or the runner started could take the lock.
|
|
690
|
+
// Completed downloads are kept; the next --fix resumes the journal.
|
|
691
|
+
return { ok: false, outcome: "pull-preempted", message: `paused pulling '${slug}' for another sync` };
|
|
692
|
+
}
|
|
693
|
+
if (exit.status === 0 && !exit.signal) {
|
|
694
|
+
return { ok: true, outcome: "pull-succeeded", message: `pulled '${slug}'` };
|
|
695
|
+
}
|
|
696
|
+
const outcome = classifyRefreshSyncOutcome({ status: exit.status, signal: exit.signal, stdout: output });
|
|
697
|
+
const detail = output.trim().split("\n").at(-1) ?? "";
|
|
698
|
+
const personal = slug === "personal" || isPersonalJournalSlug(slug);
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
outcome,
|
|
702
|
+
message: `pull of '${slug}' exited ${exit.status ?? exit.signal}${detail ? `: ${detail}` : ""}`,
|
|
703
|
+
companyNotResolved: !personal && pullOutputShowsCompanyNotResolved(output),
|
|
704
|
+
};
|
|
621
705
|
}
|
|
706
|
+
export { processStartTime };
|
|
707
|
+
/**
|
|
708
|
+
* Kill an orphaned --fix pull past its cap, but only when the pid still names
|
|
709
|
+
* that pull: its process start time must match the one recorded when it
|
|
710
|
+
* started. A reused pid has a different start time and is left alone. On
|
|
711
|
+
* Windows no start time is recorded, so nothing is killed there.
|
|
712
|
+
*/
|
|
713
|
+
export function killOrphanPullIfSame(orphan, _nowMs, probe = processStartTime) {
|
|
714
|
+
if (process.platform === "win32" || !orphan.recordedStart)
|
|
715
|
+
return;
|
|
716
|
+
const current = probe(orphan.pid);
|
|
717
|
+
if (!current || current !== orphan.recordedStart)
|
|
718
|
+
return;
|
|
719
|
+
try {
|
|
720
|
+
process.kill(-orphan.pid, "SIGKILL");
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
// gone
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** hq-cloud keeps operation locks in `<stateDir>/locks`. */
|
|
727
|
+
function defaultLocksDir() {
|
|
728
|
+
return path.join(getStateDir(), "locks");
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* How long a `--fix` pull waits for the operation lock. Unchanged from before
|
|
732
|
+
* the hand-off: a background pull must never queue on the lock longer than a
|
|
733
|
+
* foreground one did.
|
|
734
|
+
*/
|
|
735
|
+
export const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
|
|
622
736
|
/**
|
|
623
737
|
* Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
|
|
624
|
-
* against the tree `--fix` is repairing
|
|
625
|
-
*
|
|
626
|
-
*
|
|
738
|
+
* against the tree `--fix` is repairing, with `--json` so a pass that exits 0
|
|
739
|
+
* without uploading (soft_skipped, throttled, disabled, locked,
|
|
740
|
+
* resend_full_scheduled) is reported as not uploaded. A `resend_full` answer
|
|
741
|
+
* is retried once with `--full`. The post-fix re-check still decides whether
|
|
742
|
+
* the row cleared. A timeout or non-zero exit is reported, not thrown.
|
|
627
743
|
*/
|
|
628
744
|
export function defaultRefreshManifest(target) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
745
|
+
return Promise.resolve(refreshManifestWithRetry(target.scope, (full, json) => {
|
|
746
|
+
const hqBin = process.argv[1];
|
|
747
|
+
const args = [
|
|
748
|
+
"sync",
|
|
749
|
+
"manifest",
|
|
750
|
+
"--scope",
|
|
751
|
+
target.scope,
|
|
752
|
+
"--hq-root",
|
|
753
|
+
target.hqRoot,
|
|
754
|
+
...(json ? ["--json"] : []),
|
|
755
|
+
...(full ? ["--full"] : []),
|
|
756
|
+
];
|
|
757
|
+
return spawnSync(hqBin ? process.execPath : "hq", hqBin ? [hqBin, ...args] : args, {
|
|
758
|
+
encoding: "utf8",
|
|
759
|
+
timeout: REFRESH_SYNC_TIMEOUT_MS,
|
|
760
|
+
cwd: target.hqRoot,
|
|
761
|
+
});
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Run one upload and, when the server answered `resend_full` (it dropped or
|
|
766
|
+
* refused the delta base), run it once more with `--full`. Without the retry
|
|
767
|
+
* the scope stays stale until the next day's --fix run, and the health hook
|
|
768
|
+
* reports it as unrepaired in between (feedback_84d7a862).
|
|
769
|
+
*/
|
|
770
|
+
export function refreshManifestWithRetry(scope, run) {
|
|
771
|
+
const firstRaw = run(false, true);
|
|
772
|
+
// `hq` from PATH can be an older binary without `--json`. Fall back to the
|
|
773
|
+
// exit-code handling it always had instead of reporting a failure.
|
|
774
|
+
if (manifestJsonUnsupported(firstRaw))
|
|
775
|
+
return manifestUploadResult(scope, run(false, false));
|
|
776
|
+
const first = manifestUploadResult(scope, firstRaw);
|
|
777
|
+
if (first.passStatus !== "resend_full_scheduled")
|
|
778
|
+
return first;
|
|
779
|
+
const retry = manifestUploadResult(scope, run(true, true));
|
|
780
|
+
if (retry.passStatus !== "resend_full_scheduled")
|
|
781
|
+
return retry;
|
|
782
|
+
return {
|
|
783
|
+
...retry,
|
|
784
|
+
reason: "the server asked for a full manifest again after a --full retry; a full resend is queued, so run `hq sync` again later",
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** Commander's rejection of `--json` by a binary that predates it. */
|
|
788
|
+
export function manifestJsonUnsupported(result) {
|
|
789
|
+
if (result.status === 0 || result.status === null)
|
|
790
|
+
return false;
|
|
791
|
+
return /unknown option ['"]?--json/i.test(`${result.stderr ?? ""}\n${result.stdout ?? ""}`);
|
|
637
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* The status token from the `hq sync manifest --json` line, or undefined when
|
|
795
|
+
* the output has none (an older binary, or a pass that printed only a summary).
|
|
796
|
+
*/
|
|
797
|
+
export function manifestPassStatus(stdout) {
|
|
798
|
+
const lines = (stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
799
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
800
|
+
const line = lines[index];
|
|
801
|
+
if (!line.startsWith("{"))
|
|
802
|
+
continue;
|
|
803
|
+
try {
|
|
804
|
+
const parsed = JSON.parse(line);
|
|
805
|
+
if (typeof parsed.status === "string" && /^[a-z_]{1,40}$/.test(parsed.status)) {
|
|
806
|
+
return parsed.status;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// Not the outcome line.
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Plain-words reason for a pass that exited 0 without uploading. Each one
|
|
817
|
+
* leaves `lastUploadAt` unchanged, so the stale row cannot clear.
|
|
818
|
+
*/
|
|
819
|
+
const MANIFEST_NOT_UPLOADED_REASONS = {
|
|
820
|
+
soft_skipped: "the sync service did not accept manifest uploads (the pass was skipped)",
|
|
821
|
+
throttled: "the pass was throttled and sent nothing",
|
|
822
|
+
disabled: "manifest uploads are turned off on this machine",
|
|
823
|
+
locked: "another manifest pass for this scope was already running",
|
|
824
|
+
resend_full_scheduled: "the server asked for a fresh full manifest and the upload did not finish",
|
|
825
|
+
printed: "the pass only printed the manifest",
|
|
826
|
+
};
|
|
638
827
|
/**
|
|
639
828
|
* Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
|
|
640
829
|
* A spawn killed by the timeout has a null status, so it is reported as a
|
|
@@ -664,6 +853,15 @@ export function manifestUploadResult(scope, result) {
|
|
|
664
853
|
: {}),
|
|
665
854
|
};
|
|
666
855
|
}
|
|
856
|
+
const passStatus = manifestPassStatus(result.stdout);
|
|
857
|
+
if (passStatus && passStatus !== "uploaded") {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
passStatus,
|
|
861
|
+
reason: MANIFEST_NOT_UPLOADED_REASONS[passStatus] ?? `the pass ended '${passStatus}' without uploading`,
|
|
862
|
+
message: `manifest upload for '${scope}' did not upload (${passStatus})`,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
667
865
|
return { ok: true, message: `uploaded the manifest for '${scope}'` };
|
|
668
866
|
}
|
|
669
867
|
/** `hq sync manifest` output for a company this identity is not a member of. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process start times, used to tell the owner of a pid from a later process
|
|
3
|
+
* that reused it (feedback cd56a501).
|
|
4
|
+
*/
|
|
5
|
+
import type { RefreshOwnerIdentity } from "./refresh-sync-handoff.js";
|
|
6
|
+
/**
|
|
7
|
+
* A process's start time, or null if it cannot be read: `ps -o lstart=` on
|
|
8
|
+
* POSIX, the process StartTime from PowerShell on Windows.
|
|
9
|
+
*/
|
|
10
|
+
export declare function processStartTime(pid: number): string | null;
|
|
11
|
+
/** Real process-table identity for the refresh spawn lock. */
|
|
12
|
+
export declare const DEFAULT_OWNER_IDENTITY: RefreshOwnerIdentity;
|
|
13
|
+
//# sourceMappingURL=process-identity.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process start times, used to tell the owner of a pid from a later process
|
|
3
|
+
* that reused it (feedback cd56a501).
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
/**
|
|
7
|
+
* A process's start time, or null if it cannot be read: `ps -o lstart=` on
|
|
8
|
+
* POSIX, the process StartTime from PowerShell on Windows.
|
|
9
|
+
*/
|
|
10
|
+
export function processStartTime(pid) {
|
|
11
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
const result = process.platform === "win32"
|
|
15
|
+
? spawnSync("powershell.exe", [
|
|
16
|
+
"-NoProfile",
|
|
17
|
+
"-NonInteractive",
|
|
18
|
+
"-Command",
|
|
19
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`,
|
|
20
|
+
], { encoding: "utf8", timeout: 10_000, windowsHide: true })
|
|
21
|
+
: spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
|
|
22
|
+
const text = (result.stdout ?? "").trim();
|
|
23
|
+
return result.status === 0 && text ? text : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Real process-table identity for the refresh spawn lock. */
|
|
30
|
+
export const DEFAULT_OWNER_IDENTITY = {
|
|
31
|
+
selfStart: () => processStartTime(process.pid),
|
|
32
|
+
startOf: (pid) => processStartTime(pid),
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=process-identity.js.map
|