@indigoai-us/hq-cli 5.345.169 → 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 +14 -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/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 +184 -4
- 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 +32 -0
- package/dist/lib/doctor/checks/sync-health.js +54 -1
- package/dist/lib/doctor/fix/apply.d.ts +26 -4
- package/dist/lib/doctor/fix/apply.js +95 -11
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -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 +1 -0
- package/package.json +7 -7
package/dist/lib/daemon/run.js
CHANGED
|
@@ -22,6 +22,7 @@ import { readBotConfig } from "../bot/config.js";
|
|
|
22
22
|
import { botsRoot } from "../bot/paths.js";
|
|
23
23
|
import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
|
|
24
24
|
import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
|
|
25
|
+
import { inspectSyncCoreCanary } from "./sync-core-canary.js";
|
|
25
26
|
import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
|
|
26
27
|
import { loadLanesConfig } from "../lanes/config.js";
|
|
27
28
|
import { daemonLanesReconcileHandler } from "../lanes/daemon-reconcile.js";
|
|
@@ -54,7 +55,7 @@ import { stageHqChildBootstrap } from "./hq-child-bootstrap-stage.js";
|
|
|
54
55
|
import { runnerSessionName } from "./remote-control.js";
|
|
55
56
|
import { adoptChild, createChildTracker, finishSuccession, handoffEligible, readProcStart, stopOrphans, successionPaths, systemdKillMode, takeSuccession, writeHandoff, } from "./succession.js";
|
|
56
57
|
import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
|
|
57
|
-
import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, } from "./supervisor.js";
|
|
58
|
+
import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, planKey, } from "./supervisor.js";
|
|
58
59
|
import { userToolDirs } from "./tools.js";
|
|
59
60
|
import { logOffset, readLogSince, readUnitHolds, writeUnitHolds } from "./unit-holds.js";
|
|
60
61
|
import { daemonRunGuard } from "./lanes-reconcile-guard.js";
|
|
@@ -63,6 +64,19 @@ export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
|
63
64
|
export const RESTART_EXIT_CODE = 75;
|
|
64
65
|
const NON_SYNC_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + 5_000;
|
|
65
66
|
const SYNC_RUNNER_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + SYNC_RUNNER_PERSIST_GRACE_MS + 5_000;
|
|
67
|
+
export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
|
|
68
|
+
const syncCoreCanaryRunning = supervisor?.runningPlanKey("sync-core-canary") !== undefined;
|
|
69
|
+
const transitionContext = {
|
|
70
|
+
...context,
|
|
71
|
+
syncCoreCanaryRunning,
|
|
72
|
+
legacySyncReadyForCore: false,
|
|
73
|
+
};
|
|
74
|
+
const waiting = planUnits(services, transitionContext);
|
|
75
|
+
const legacy = waiting.find((unit) => unit.id === "sync");
|
|
76
|
+
const legacySyncReadyForCore = legacy !== undefined
|
|
77
|
+
&& supervisor?.runningPlanKey("sync") === planKey(legacy, CLI_VERSION);
|
|
78
|
+
return planUnits(services, { ...transitionContext, legacySyncReadyForCore });
|
|
79
|
+
}
|
|
66
80
|
/** A bounded shutdown deadline that can account for sync push persistence. */
|
|
67
81
|
export function createShutdownCap() {
|
|
68
82
|
const startedAt = Date.now();
|
|
@@ -939,7 +953,7 @@ export async function runDaemon(opts) {
|
|
|
939
953
|
legacy,
|
|
940
954
|
appRunning: () => desktopAppRunning(platform),
|
|
941
955
|
});
|
|
942
|
-
const
|
|
956
|
+
const context = {
|
|
943
957
|
machineType,
|
|
944
958
|
platform,
|
|
945
959
|
home,
|
|
@@ -964,6 +978,11 @@ export async function runDaemon(opts) {
|
|
|
964
978
|
return switches;
|
|
965
979
|
})(),
|
|
966
980
|
syncOwner,
|
|
981
|
+
syncCoreCanary: config.syncCoreCanaryError
|
|
982
|
+
? { blockedReason: config.syncCoreCanaryError }
|
|
983
|
+
: config.syncCoreCanary
|
|
984
|
+
? inspectSyncCoreCanary(home, hqRoot, config.syncCoreCanary, platform)
|
|
985
|
+
: undefined,
|
|
967
986
|
desktopAppManagesSync: appManagesSync,
|
|
968
987
|
syncRunnerProcesses,
|
|
969
988
|
syncRunnerOwnerPid,
|
|
@@ -991,7 +1010,8 @@ export async function runDaemon(opts) {
|
|
|
991
1010
|
invalidPoolServiceSessions,
|
|
992
1011
|
availablePoolFrontends,
|
|
993
1012
|
unverifiablePoolFrontends,
|
|
994
|
-
}
|
|
1013
|
+
};
|
|
1014
|
+
const plans = planUnitsWithSyncCoreInterlock(services, context, supervisor);
|
|
995
1015
|
if (startupPlanOpen) {
|
|
996
1016
|
const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
|
|
997
1017
|
if (hqCloud?.source === "bundled")
|
|
@@ -179,6 +179,7 @@ export interface Supervisor {
|
|
|
179
179
|
stop(): Promise<void>;
|
|
180
180
|
reconcile(): void;
|
|
181
181
|
state(): DaemonState;
|
|
182
|
+
runningPlanKey(unitId: string): string | undefined;
|
|
182
183
|
/** True while a live sync runner has reported that it is persisting pushes. */
|
|
183
184
|
hasStoppingSyncRunner(): boolean;
|
|
184
185
|
/** Stop supervising without signalling any child; returns the long-running units left running. */
|
|
@@ -1399,6 +1399,10 @@ export function createSupervisor(deps) {
|
|
|
1399
1399
|
},
|
|
1400
1400
|
reconcile,
|
|
1401
1401
|
state: snapshot,
|
|
1402
|
+
runningPlanKey(unitId) {
|
|
1403
|
+
const slot = slots.get(unitId);
|
|
1404
|
+
return slot?.child ? slot.childKey : undefined;
|
|
1405
|
+
},
|
|
1402
1406
|
hasStoppingSyncRunner: () => [...liveOwners.values()].some(syncRunnerIsStopping),
|
|
1403
1407
|
release() {
|
|
1404
1408
|
const released = [];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SyncCoreCanaryConfig } from "./config.js";
|
|
2
|
+
export interface SyncCoreCanaryPlan {
|
|
3
|
+
binaryPath?: string;
|
|
4
|
+
vaultUid?: string;
|
|
5
|
+
canaryPath?: string;
|
|
6
|
+
blockedReason?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function inspectSyncCoreCanary(home: string, hqRoot: string | undefined, config: SyncCoreCanaryConfig, platform: NodeJS.Platform): SyncCoreCanaryPlan;
|
|
9
|
+
//# sourceMappingURL=sync-core-canary.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
function blocked(config, blockedReason) {
|
|
5
|
+
return { ...config, blockedReason };
|
|
6
|
+
}
|
|
7
|
+
function record(value) {
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
? value
|
|
10
|
+
: undefined;
|
|
11
|
+
}
|
|
12
|
+
export function inspectSyncCoreCanary(home, hqRoot, config, platform) {
|
|
13
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
14
|
+
return blocked(config, "the sync-core canary requires macOS or Linux");
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.statSync(config.binaryPath).isFile())
|
|
18
|
+
return blocked(config, "the sync-core binary is missing");
|
|
19
|
+
fs.accessSync(config.binaryPath, fs.constants.X_OK);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return blocked(config, "the sync-core binary is missing or not executable");
|
|
23
|
+
}
|
|
24
|
+
const configPath = path.join(home, ".hq", "sync-core", "config.toml");
|
|
25
|
+
let sync;
|
|
26
|
+
try {
|
|
27
|
+
sync = record(record(parseToml(fs.readFileSync(configPath, "utf8")))?.sync);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return blocked(config, `the sync-core config at ${configPath} is missing or invalid`);
|
|
31
|
+
}
|
|
32
|
+
if (sync?.engine !== "rust-test" || sync.mode !== "full") {
|
|
33
|
+
return blocked(config, "the sync-core config must select engine rust-test in full mode");
|
|
34
|
+
}
|
|
35
|
+
const vaults = sync.vaults;
|
|
36
|
+
if (!Array.isArray(vaults) || vaults.length !== 1 || vaults[0] !== config.vaultUid) {
|
|
37
|
+
return blocked(config, `the sync-core config must name exactly ${config.vaultUid}`);
|
|
38
|
+
}
|
|
39
|
+
if (!hqRoot || typeof sync.hq_root !== "string" || path.resolve(sync.hq_root) !== path.resolve(hqRoot)) {
|
|
40
|
+
return blocked(config, "the sync-core config must use the daemon's HQ folder");
|
|
41
|
+
}
|
|
42
|
+
const canaryPath = typeof sync.canary_path === "string" ? sync.canary_path.trim() : "";
|
|
43
|
+
if (!canaryPath)
|
|
44
|
+
return blocked(config, "the sync-core config must name one canary path");
|
|
45
|
+
const ownedPath = path.join(home, ".hq", "sync-core", "owned", `${config.vaultUid}.json`);
|
|
46
|
+
try {
|
|
47
|
+
const owned = record(JSON.parse(fs.readFileSync(ownedPath, "utf8")));
|
|
48
|
+
if (owned?.vault !== config.vaultUid)
|
|
49
|
+
throw new Error("mismatch");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return blocked(config, `the sync-core ownership record for ${config.vaultUid} is missing or invalid`);
|
|
53
|
+
}
|
|
54
|
+
return { ...config, canaryPath };
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=sync-core-canary.js.map
|
|
@@ -61,7 +61,39 @@ export interface SyncManifestUploadStatus {
|
|
|
61
61
|
baseUsable: boolean;
|
|
62
62
|
/** Local company slug for company-scoped remediation; absent for personal. */
|
|
63
63
|
companySlug?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Last failed pass, from hq-cloud >= 6.18.102 (`readManifestUploadStatus`,
|
|
66
|
+
* hq-cloud #1050). Fixed tokens only. Absent on older hq-cloud and after a
|
|
67
|
+
* later pass succeeded.
|
|
68
|
+
*/
|
|
69
|
+
lastAttemptAt?: string;
|
|
70
|
+
consecutiveFailures?: number;
|
|
71
|
+
lastFailureReason?: string;
|
|
72
|
+
lastFailureKind?: string;
|
|
73
|
+
lastFailureStatus?: number;
|
|
64
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The last failed pass for a stale scope, when hq-cloud recorded one after the
|
|
77
|
+
* last successful upload. Undefined means "no evidence a pass ran", which is
|
|
78
|
+
* the only case where "the daily audit pass is not running" is accurate.
|
|
79
|
+
*/
|
|
80
|
+
export declare function manifestLastFailure(entry: SyncManifestUploadStatus): {
|
|
81
|
+
reason: string;
|
|
82
|
+
status?: number;
|
|
83
|
+
attempts: number;
|
|
84
|
+
at: string;
|
|
85
|
+
} | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Failures a fresh `hq sync manifest` from this machine cannot clear: the
|
|
88
|
+
* upload route is not deployed (404 soft skip), the server rejected this
|
|
89
|
+
* identity, or it refused access (403). A 401 is not listed: it can be an
|
|
90
|
+
* expired token that a fresh run refreshes. Offering `--fix` for these only makes the
|
|
91
|
+
* health hook report the same row as "repair did not clear" every day.
|
|
92
|
+
*/
|
|
93
|
+
export declare function manifestFailureNotFixable(failure: {
|
|
94
|
+
reason: string;
|
|
95
|
+
status?: number;
|
|
96
|
+
}): boolean;
|
|
65
97
|
/** Injectable dependencies — defaults are the real collectors. */
|
|
66
98
|
export interface SyncHealthDeps {
|
|
67
99
|
/** Runner log and conflict signals; omitted in unit tests of other checks. */
|
|
@@ -52,6 +52,45 @@ const SYNC_FAMILY_TITLE = "Versions & sync";
|
|
|
52
52
|
export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
53
53
|
/** The offline update cache written by the check-hq-update SessionStart hook. */
|
|
54
54
|
export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
|
|
55
|
+
/** Failure tokens hq-cloud stores, restricted the same way hq-cloud reads them. */
|
|
56
|
+
const MANIFEST_FAILURE_TOKEN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
57
|
+
/**
|
|
58
|
+
* The last failed pass for a stale scope, when hq-cloud recorded one after the
|
|
59
|
+
* last successful upload. Undefined means "no evidence a pass ran", which is
|
|
60
|
+
* the only case where "the daily audit pass is not running" is accurate.
|
|
61
|
+
*/
|
|
62
|
+
export function manifestLastFailure(entry) {
|
|
63
|
+
const reason = entry.lastFailureReason;
|
|
64
|
+
const at = entry.lastAttemptAt;
|
|
65
|
+
if (!reason || !MANIFEST_FAILURE_TOKEN.test(reason) || !at)
|
|
66
|
+
return undefined;
|
|
67
|
+
const attempts = entry.consecutiveFailures ?? 0;
|
|
68
|
+
if (!Number.isFinite(attempts) || attempts < 1)
|
|
69
|
+
return undefined;
|
|
70
|
+
const atMs = Date.parse(at);
|
|
71
|
+
if (!Number.isFinite(atMs))
|
|
72
|
+
return undefined;
|
|
73
|
+
const uploadedMs = entry.lastUploadAt ? Date.parse(entry.lastUploadAt) : Number.NaN;
|
|
74
|
+
if (Number.isFinite(uploadedMs) && atMs <= uploadedMs)
|
|
75
|
+
return undefined;
|
|
76
|
+
const status = typeof entry.lastFailureStatus === "number" && Number.isInteger(entry.lastFailureStatus)
|
|
77
|
+
? entry.lastFailureStatus
|
|
78
|
+
: undefined;
|
|
79
|
+
return { reason, attempts: Math.floor(attempts), at, ...(status !== undefined ? { status } : {}) };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Failures a fresh `hq sync manifest` from this machine cannot clear: the
|
|
83
|
+
* upload route is not deployed (404 soft skip), the server rejected this
|
|
84
|
+
* identity, or it refused access (403). A 401 is not listed: it can be an
|
|
85
|
+
* expired token that a fresh run refreshes. Offering `--fix` for these only makes the
|
|
86
|
+
* health hook report the same row as "repair did not clear" every day.
|
|
87
|
+
*/
|
|
88
|
+
export function manifestFailureNotFixable(failure) {
|
|
89
|
+
if (failure.reason === "soft_skip" || failure.reason === "rejected_identity")
|
|
90
|
+
return true;
|
|
91
|
+
// 401 is left fixable: an expired token is refreshed by the next run.
|
|
92
|
+
return failure.status === 403 || failure.status === 404;
|
|
93
|
+
}
|
|
55
94
|
/** Fill in the optional dependencies so call sites never branch on undefined. */
|
|
56
95
|
function withDefaults(deps, hqRoot) {
|
|
57
96
|
return {
|
|
@@ -782,7 +821,21 @@ function manifestResults(deps, journals) {
|
|
|
782
821
|
}
|
|
783
822
|
}
|
|
784
823
|
const command = `hq sync manifest${scope ? ` --scope ${scope}` : ""}`;
|
|
785
|
-
const
|
|
824
|
+
const failure = manifestLastFailure(entry);
|
|
825
|
+
const age = `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt})`;
|
|
826
|
+
// A pass that ran and failed is not "not running": name the stored reason.
|
|
827
|
+
const message = failure
|
|
828
|
+
? `${age}. The last pass (${failure.at}) failed with ${failure.reason}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ""}, ${failure.attempts} failed pass${failure.attempts === 1 ? "" : "es"} in a row.`
|
|
829
|
+
: `${age} — the daily audit pass is not running.`;
|
|
830
|
+
if (failure && manifestFailureNotFixable(failure)) {
|
|
831
|
+
return {
|
|
832
|
+
status: "WARN",
|
|
833
|
+
checkId,
|
|
834
|
+
reasonCode: "manifest-upload-rejected",
|
|
835
|
+
message,
|
|
836
|
+
remediation: `--fix cannot clear this from this machine. Check you are signed in to the right HQ account (/hq-whoami), then run \`${command}\` and read its error.`,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
786
839
|
// With the kill switch set the upload exits 0 and sends nothing, so the
|
|
787
840
|
// row carries no `--fix` target and names the switch instead.
|
|
788
841
|
if (deps.manifestUploadsDisabled()) {
|
|
@@ -157,8 +157,13 @@ export interface RefreshManifestTarget {
|
|
|
157
157
|
}
|
|
158
158
|
/** Outcome of one {@link ApplyFixesOptions.refreshManifest} call. */
|
|
159
159
|
export interface RefreshManifestResult {
|
|
160
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Whether the upload command exited 0 and, when it printed a `--json`
|
|
162
|
+
* outcome, that outcome was `uploaded`. The re-check is the real verdict.
|
|
163
|
+
*/
|
|
161
164
|
ok: boolean;
|
|
165
|
+
/** The `hq sync manifest --json` status token, when the pass did not upload. */
|
|
166
|
+
passStatus?: string;
|
|
162
167
|
/** One-line summary. */
|
|
163
168
|
message: string;
|
|
164
169
|
/**
|
|
@@ -333,11 +338,28 @@ export interface RefreshSyncSpawnDeps {
|
|
|
333
338
|
export declare const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
|
|
334
339
|
/**
|
|
335
340
|
* Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
|
|
336
|
-
* against the tree `--fix` is repairing
|
|
337
|
-
*
|
|
338
|
-
*
|
|
341
|
+
* against the tree `--fix` is repairing, with `--json` so a pass that exits 0
|
|
342
|
+
* without uploading (soft_skipped, throttled, disabled, locked,
|
|
343
|
+
* resend_full_scheduled) is reported as not uploaded. A `resend_full` answer
|
|
344
|
+
* is retried once with `--full`. The post-fix re-check still decides whether
|
|
345
|
+
* the row cleared. A timeout or non-zero exit is reported, not thrown.
|
|
339
346
|
*/
|
|
340
347
|
export declare function defaultRefreshManifest(target: RefreshManifestTarget): Promise<RefreshManifestResult>;
|
|
348
|
+
type ManifestSpawnResult = Pick<SpawnSyncReturns<string>, "error" | "status" | "signal"> & Partial<Pick<SpawnSyncReturns<string>, "stdout" | "stderr">>;
|
|
349
|
+
/**
|
|
350
|
+
* Run one upload and, when the server answered `resend_full` (it dropped or
|
|
351
|
+
* refused the delta base), run it once more with `--full`. Without the retry
|
|
352
|
+
* the scope stays stale until the next day's --fix run, and the health hook
|
|
353
|
+
* reports it as unrepaired in between (feedback_84d7a862).
|
|
354
|
+
*/
|
|
355
|
+
export declare function refreshManifestWithRetry(scope: string, run: (full: boolean, json: boolean) => ManifestSpawnResult): RefreshManifestResult;
|
|
356
|
+
/** Commander's rejection of `--json` by a binary that predates it. */
|
|
357
|
+
export declare function manifestJsonUnsupported(result: ManifestSpawnResult): boolean;
|
|
358
|
+
/**
|
|
359
|
+
* The status token from the `hq sync manifest --json` line, or undefined when
|
|
360
|
+
* the output has none (an older binary, or a pass that printed only a summary).
|
|
361
|
+
*/
|
|
362
|
+
export declare function manifestPassStatus(stdout: string | undefined): string | undefined;
|
|
341
363
|
/**
|
|
342
364
|
* Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
|
|
343
365
|
* A spawn killed by the timeout has a null status, so it is reported as a
|
|
@@ -735,20 +735,95 @@ function defaultLocksDir() {
|
|
|
735
735
|
export const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
|
|
736
736
|
/**
|
|
737
737
|
* Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
|
|
738
|
-
* against the tree `--fix` is repairing
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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.
|
|
741
743
|
*/
|
|
742
744
|
export function defaultRefreshManifest(target) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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 ?? ""}`);
|
|
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;
|
|
751
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
|
+
};
|
|
752
827
|
/**
|
|
753
828
|
* Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
|
|
754
829
|
* A spawn killed by the timeout has a null status, so it is reported as a
|
|
@@ -778,6 +853,15 @@ export function manifestUploadResult(scope, result) {
|
|
|
778
853
|
: {}),
|
|
779
854
|
};
|
|
780
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
|
+
}
|
|
781
865
|
return { ok: true, message: `uploaded the manifest for '${scope}'` };
|
|
782
866
|
}
|
|
783
867
|
/** `hq sync manifest` output for a company this identity is not a member of. */
|
|
@@ -19,7 +19,7 @@ export declare const DOCTOR_STATUSES: readonly ["PASS", "FAIL", "WARN", "UNTESTE
|
|
|
19
19
|
*/
|
|
20
20
|
export type DoctorStatus = (typeof DOCTOR_STATUSES)[number];
|
|
21
21
|
/** Stable, privacy-safe diagnostics emitted by the sync health family. */
|
|
22
|
-
export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "company-not-resolved", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "refresh-in-progress", "refresh-lock-held", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "pull-access-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
|
|
22
|
+
export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "company-not-resolved", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "refresh-in-progress", "refresh-lock-held", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "manifest-upload-rejected", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "pull-access-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
|
|
23
23
|
export type SyncReasonCode = (typeof SYNC_REASON_CODES)[number];
|
|
24
24
|
/** Runtime type guard: true only for a member of the fixed vocabulary. */
|
|
25
25
|
export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
|
package/dist/lib/doctor/types.js
CHANGED
|
@@ -12,6 +12,33 @@ import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
|
|
|
12
12
|
import { AGENT_INBOX_PUSH_STATE_BASENAME, SYNC_WAKE_STATE_BASENAME } from "../paths.js";
|
|
13
13
|
export { AGENT_INBOX_PUSH_STATE_BASENAME, SYNC_WAKE_STATE_BASENAME } from "../paths.js";
|
|
14
14
|
const INBOX_PUSH_HEARTBEAT_MS = 5_000;
|
|
15
|
+
const SYNC_WAKE_KEYS = new Set(["v", "type", "vault", "head", "rootId"]);
|
|
16
|
+
const SYNC_WAKE_VAULT = /^(?:prs|cmp|agt)_[0-9A-Za-z]+$/;
|
|
17
|
+
const SYNC_WAKE_ROOT = /^[0-9a-f]{64}$/i;
|
|
18
|
+
function isSyncWakeEnvelope(payload) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(Buffer.isBuffer(payload) ? payload.toString("utf8") : payload);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
27
|
+
return false;
|
|
28
|
+
const message = parsed;
|
|
29
|
+
const keys = Object.keys(message);
|
|
30
|
+
if (keys.length !== SYNC_WAKE_KEYS.size || keys.some((key) => !SYNC_WAKE_KEYS.has(key))) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return message.v === 1 &&
|
|
34
|
+
message.type === "vault.head" &&
|
|
35
|
+
typeof message.vault === "string" &&
|
|
36
|
+
SYNC_WAKE_VAULT.test(message.vault) &&
|
|
37
|
+
Number.isSafeInteger(message.head) &&
|
|
38
|
+
Number(message.head) > 0 &&
|
|
39
|
+
(message.rootId === null ||
|
|
40
|
+
(typeof message.rootId === "string" && SYNC_WAKE_ROOT.test(message.rootId)));
|
|
41
|
+
}
|
|
15
42
|
function readWakeSequence(meshRoot, basename, label, onInfo) {
|
|
16
43
|
const target = path.join(meshRoot, basename);
|
|
17
44
|
try {
|
|
@@ -260,6 +287,10 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
260
287
|
return;
|
|
261
288
|
}
|
|
262
289
|
if (syncTopic && _topic === syncTopic && syncSubscribed) {
|
|
290
|
+
if (!isSyncWakeEnvelope(payload)) {
|
|
291
|
+
opts.onInfo?.("sync wake ignored (invalid envelope)");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
263
294
|
syncWakeSequence += 1;
|
|
264
295
|
persistSyncState(true);
|
|
265
296
|
return;
|
|
@@ -91,6 +91,13 @@ export declare function prepareOrganizeDecision(input: {
|
|
|
91
91
|
fetchCandidates?: FetchCandidatesFn;
|
|
92
92
|
/** Skip network; leave unresolved without candidates. */
|
|
93
93
|
offline?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Membership-validated slug -> companyUid. Reconcile can persist a session
|
|
96
|
+
* with only `companySlug` (for example a local-only project bound while the
|
|
97
|
+
* membership lookup was offline or failed). Online organize uses this to
|
|
98
|
+
* recover the uid instead of reporting `candidates_unavailable`.
|
|
99
|
+
*/
|
|
100
|
+
resolveCompanyUid?: (companySlug: string) => Promise<string | null | undefined>;
|
|
94
101
|
}): Promise<OrganizeListResult>;
|
|
95
102
|
/** True when a local decision's server window has passed (or is unparsable). */
|
|
96
103
|
export declare function isLocalDecisionExpired(expiresAt: string | undefined, now?: Date): boolean;
|
|
@@ -76,7 +76,7 @@ export function decisionFromCandidates(sessionId, response) {
|
|
|
76
76
|
*/
|
|
77
77
|
export async function prepareOrganizeDecision(input) {
|
|
78
78
|
const nowFn = input.now ?? (() => new Date());
|
|
79
|
-
|
|
79
|
+
let state = readSessionState(input.sessionId, input.root);
|
|
80
80
|
if (!state) {
|
|
81
81
|
return {
|
|
82
82
|
sessionId: input.sessionId,
|
|
@@ -114,6 +114,26 @@ export async function prepareOrganizeDecision(input) {
|
|
|
114
114
|
reason: state.contextStatus,
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
+
// Recover the uid after the status gates, before refreshExpired and the
|
|
118
|
+
// candidates_unavailable gate both read it.
|
|
119
|
+
if (!state.companyUid &&
|
|
120
|
+
state.companySlug &&
|
|
121
|
+
!input.offline &&
|
|
122
|
+
input.fetchCandidates &&
|
|
123
|
+
input.resolveCompanyUid) {
|
|
124
|
+
let recovered;
|
|
125
|
+
try {
|
|
126
|
+
recovered = await input.resolveCompanyUid(state.companySlug);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
recovered = undefined;
|
|
130
|
+
}
|
|
131
|
+
if (typeof recovered === "string" && recovered.startsWith("cmp_")) {
|
|
132
|
+
const withUid = mergeLocalSessionFields({ ...state, companyUid: recovered, updatedAt: nowFn().toISOString() }, state);
|
|
133
|
+
writeSessionState(withUid, input.root);
|
|
134
|
+
state = withUid;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
117
137
|
// Ask-once: an existing decision is never replaced or re-armed — unless its
|
|
118
138
|
// server decision window has expired. The server stamps a fresh window only
|
|
119
139
|
// when candidates are listed, so returning the stale decision would make
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.170",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.170",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.170",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.170",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.170",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.170",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.170"
|
|
123
123
|
}
|
|
124
124
|
}
|