@indigoai-us/hq-cli 5.130.0 → 5.132.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/CHANGELOG.md +155 -1
- package/contracts/preflight/v1/fixtures.json +87 -0
- package/contracts/preflight/v1/schema.json +58 -0
- package/dist/command-catalog.generated.d.ts +39 -0
- package/dist/command-catalog.generated.js +50 -0
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +1 -1
- package/dist/commands/files.js +137 -9
- package/dist/commands/lanes.d.ts +13 -2
- package/dist/commands/lanes.js +394 -42
- package/dist/commands/mesh.js +66 -13
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/index.d.ts +10 -3
- package/dist/lib/lanes/index.js +5 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +17 -0
- package/dist/lib/lanes/quota.js +24 -0
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.js +41 -11
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +49 -2
- package/dist/lib/lanes/types.js +4 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +15 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +15 -4
- package/dist/lib/mesh/live/daemon/install.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/install.js +84 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/run.js +17 -4
- package/dist/lib/mesh/live/emit-client.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.js +15 -8
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/mesh/live/emit.d.ts +9 -0
- package/dist/lib/mesh/live/emit.js +28 -2
- package/dist/lib/mesh/live/flush.js +9 -3
- package/dist/lib/search-index/index.js +22 -3
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/lib/work-context/preflight-contract.d.ts +28 -0
- package/dist/lib/work-context/preflight-contract.js +85 -0
- package/dist/main.js +11 -1
- package/dist/utils/cognito-session.d.ts +39 -0
- package/dist/utils/cognito-session.js +119 -3
- package/dist/utils/environmental-error.d.ts +18 -0
- package/dist/utils/environmental-error.js +240 -7
- package/dist/utils/expected-cli-error.d.ts +2 -0
- package/dist/utils/expected-cli-error.js +4 -0
- package/dist/utils/self-update.js +4 -1
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +11 -3
- package/package.json +3 -2
|
@@ -101,7 +101,12 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
|
|
|
101
101
|
platform?: DaemonInstallPlatform;
|
|
102
102
|
home?: string;
|
|
103
103
|
uid?: number;
|
|
104
|
+
/** HQ root the daemon was serving, or the root this update was run from. */
|
|
105
|
+
hqRoot?: string;
|
|
106
|
+
env?: NodeJS.ProcessEnv;
|
|
104
107
|
existsSync?: typeof fs.existsSync;
|
|
108
|
+
readFileSync?: typeof fs.readFileSync;
|
|
109
|
+
writeFileSync?: typeof fs.writeFileSync;
|
|
105
110
|
run?: (command: string, args: string[]) => {
|
|
106
111
|
status: number | null;
|
|
107
112
|
};
|
|
@@ -17,7 +17,7 @@ import * as path from "node:path";
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
19
19
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
|
-
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
|
|
20
|
+
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
21
21
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
22
|
/**
|
|
23
23
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
@@ -49,6 +49,86 @@ export const defaultSchtasks = (args) => {
|
|
|
49
49
|
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
50
50
|
};
|
|
51
51
|
};
|
|
52
|
+
function readServiceText(file, existsSync, readFileSync) {
|
|
53
|
+
if (!existsSync(file))
|
|
54
|
+
return "";
|
|
55
|
+
const raw = readFileSync(file);
|
|
56
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw));
|
|
57
|
+
if ((buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) || buf.includes(0)) {
|
|
58
|
+
return buf.toString("utf16le").replace(/^\uFEFF/, "");
|
|
59
|
+
}
|
|
60
|
+
return buf.toString("utf8");
|
|
61
|
+
}
|
|
62
|
+
function hqRootFromUnitText(platform, text) {
|
|
63
|
+
if (platform === "win32")
|
|
64
|
+
return parseWindowsTaskHqRoot(text);
|
|
65
|
+
if (platform === "darwin") {
|
|
66
|
+
const match = /<key>HQ_ROOT<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
|
|
67
|
+
return match?.[1] ?? null;
|
|
68
|
+
}
|
|
69
|
+
const match = /^Environment=HQ_ROOT=(.*)$/m.exec(text);
|
|
70
|
+
if (!match)
|
|
71
|
+
return null;
|
|
72
|
+
const value = match[1].trim();
|
|
73
|
+
return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Record the HQ root the daemon was serving and replace a pre-allowlist unit
|
|
77
|
+
* before the process is restarted. A unit with no HQ_ROOT (or Restart=on-failure)
|
|
78
|
+
* comes back with an empty root allowlist unless this runs first.
|
|
79
|
+
*/
|
|
80
|
+
function migrateLegacyDaemonUnit(opts) {
|
|
81
|
+
const { platform, home } = opts;
|
|
82
|
+
if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
|
|
83
|
+
return;
|
|
84
|
+
const unitPath = platform === "darwin"
|
|
85
|
+
? path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
|
|
86
|
+
: platform === "linux"
|
|
87
|
+
? path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME)
|
|
88
|
+
: path.join(daemonDir(undefined, home, opts.env), `${WINDOWS_TASK_NAME}.xml`);
|
|
89
|
+
if (!opts.existsSync(unitPath))
|
|
90
|
+
return;
|
|
91
|
+
const text = readServiceText(unitPath, opts.existsSync, opts.readFileSync);
|
|
92
|
+
const fromUnit = hqRootFromUnitText(platform, text);
|
|
93
|
+
const verified = verifyHqRoot(fromUnit ?? undefined)
|
|
94
|
+
?? verifyHqRoot(opts.hqRoot)
|
|
95
|
+
?? verifyHqRoot(opts.env?.HQ_ROOT);
|
|
96
|
+
if (!verified)
|
|
97
|
+
return;
|
|
98
|
+
registerTrustedHqRoot(daemonDir(undefined, home, opts.env), verified);
|
|
99
|
+
const legacy = platform === "linux"
|
|
100
|
+
? !text.includes("Environment=HQ_ROOT=") || /Restart=on-failure/.test(text)
|
|
101
|
+
: platform === "darwin"
|
|
102
|
+
? !/<key>HQ_ROOT<\/key>/.test(text)
|
|
103
|
+
: !fromUnit;
|
|
104
|
+
if (!legacy)
|
|
105
|
+
return;
|
|
106
|
+
const paths = buildInstallPaths({
|
|
107
|
+
home,
|
|
108
|
+
env: opts.env,
|
|
109
|
+
hqRoot: verified,
|
|
110
|
+
hqBinary: "/usr/bin/hq",
|
|
111
|
+
nodeBinary: process.execPath || "/usr/bin/node",
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// The rendered command only has to name a binary; install already resolved one.
|
|
118
|
+
}
|
|
119
|
+
if (platform === "darwin") {
|
|
120
|
+
opts.writeFileSync(unitPath, renderLaunchdPlist(paths), { mode: 0o600 });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (platform === "linux") {
|
|
124
|
+
opts.writeFileSync(unitPath, renderSystemdUserUnit(paths), { mode: 0o600 });
|
|
125
|
+
opts.run("systemctl", ["--user", "daemon-reload"]);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const rendered = renderWindowsTaskXml(paths);
|
|
129
|
+
opts.writeFileSync(unitPath, encodeWindowsTaskXml(rendered));
|
|
130
|
+
opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
|
|
131
|
+
}
|
|
52
132
|
/**
|
|
53
133
|
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
54
134
|
* best-effort: an update must remain successful if a user has no service,
|
|
@@ -58,8 +138,11 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
58
138
|
const platform = opts.platform ?? detectPlatform();
|
|
59
139
|
const home = opts.home ?? os.homedir();
|
|
60
140
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
141
|
+
const readFileSync = opts.readFileSync ?? fs.readFileSync;
|
|
142
|
+
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
|
|
61
143
|
const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
|
|
62
144
|
try {
|
|
145
|
+
migrateLegacyDaemonUnit({ platform, home, hqRoot: opts.hqRoot, env: opts.env, existsSync, readFileSync, writeFileSync, run });
|
|
63
146
|
if (platform === "darwin") {
|
|
64
147
|
const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
65
148
|
if (!existsSync(plist))
|
|
@@ -90,6 +90,11 @@ export interface DaemonRunDeps {
|
|
|
90
90
|
resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
|
|
91
91
|
companyUid: string;
|
|
92
92
|
} | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Machine credential (fleet box or local bot). A refused or empty membership
|
|
95
|
+
* probe defers; it does not clear the company. Defaults to isMachineIdentity().
|
|
96
|
+
*/
|
|
97
|
+
machineIdentity?: boolean;
|
|
93
98
|
/** Test seam immediately before a device-default revocation's guarded commit. */
|
|
94
99
|
beforeDeviceDefaultRevocationCommit?: () => void;
|
|
95
100
|
log?: (dir: string, line: string) => void;
|
|
@@ -14,7 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
15
15
|
import { createWorkSessionDeliverer } from "../../client.js";
|
|
16
16
|
import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
17
|
-
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
17
|
+
import { ensureCognitoToken, isMachineIdentity } from "../../../../utils/cognito-session.js";
|
|
18
18
|
import { replayOutbox } from "../../../work-context/outbox.js";
|
|
19
19
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
20
20
|
import { ensureDefaultCompany } from "../../../work-context/default-company.js";
|
|
@@ -137,9 +137,13 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
137
137
|
// expires after ~1h to every vend/flush forever, so the second credential
|
|
138
138
|
// renewal (~96 min in) and everything after it failed with HTTP 401 and the
|
|
139
139
|
// daemon went dark once its IoT credentials expired.
|
|
140
|
+
// ensureCognitoToken refuses a refresh whose subject differs from the
|
|
141
|
+
// active person cache and restores the file. The daemon must not replace
|
|
142
|
+
// the operator login with another cached session.
|
|
140
143
|
const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
|
|
141
144
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
142
145
|
const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
|
|
146
|
+
const machineIdentity = deps.machineIdentity ?? isMachineIdentity();
|
|
143
147
|
/** The sole authorization seam for daemon tenant I/O derived from a device default. */
|
|
144
148
|
const tenantGate = {
|
|
145
149
|
authorize: async (input) => {
|
|
@@ -158,9 +162,18 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
158
162
|
authorization = await Promise.race([
|
|
159
163
|
(async () => {
|
|
160
164
|
const membership = await resolveMembership(await getToken(), slug, controller.signal);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
if (membership)
|
|
166
|
+
return { status: "authorized", uid: membership.companyUid };
|
|
167
|
+
// A machine token often cannot read /membership/me (401/403, or an
|
|
168
|
+
// empty person-membership list). That is "cannot tell", not "not
|
|
169
|
+
// a member". Leave the company in place and retry.
|
|
170
|
+
if (machineIdentity) {
|
|
171
|
+
return {
|
|
172
|
+
status: "unavailable",
|
|
173
|
+
error: "membership probe on a machine token did not confirm a company; company left unchanged",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { status: "denied" };
|
|
164
177
|
})(),
|
|
165
178
|
new Promise((resolve) => {
|
|
166
179
|
controller.signal.addEventListener("abort", () => resolve({
|
|
@@ -23,6 +23,8 @@ export interface EmitEventResult {
|
|
|
23
23
|
status: EmitEventStatus;
|
|
24
24
|
/** Present when status != accepted (no_company_evidence | not_a_member | …). */
|
|
25
25
|
reason?: string;
|
|
26
|
+
/** Server rejection code when status is rejected (`code`, else `reason`). */
|
|
27
|
+
code?: string;
|
|
26
28
|
/** Resolved company (accepted, or unassigned-into-single-company). */
|
|
27
29
|
companyUid?: string;
|
|
28
30
|
}
|
|
@@ -58,10 +58,7 @@ export function parseEmitResults(body) {
|
|
|
58
58
|
if (!status)
|
|
59
59
|
continue;
|
|
60
60
|
const res = { eventId, status };
|
|
61
|
-
|
|
62
|
-
res.reason = r.reason.trim();
|
|
63
|
-
else if (typeof r.code === "string" && r.code.trim())
|
|
64
|
-
res.reason = r.code.trim();
|
|
61
|
+
applyEmitReasonAndCode(res, r);
|
|
65
62
|
if (typeof r.companyUid === "string" && r.companyUid.trim()) {
|
|
66
63
|
res.companyUid = r.companyUid.trim();
|
|
67
64
|
}
|
|
@@ -86,16 +83,26 @@ export function parseEmitResults(body) {
|
|
|
86
83
|
if (!eventId)
|
|
87
84
|
continue;
|
|
88
85
|
const res = { eventId, status: "rejected" };
|
|
89
|
-
|
|
90
|
-
res.reason = r.reason.trim();
|
|
91
|
-
else if (typeof r.code === "string" && r.code.trim())
|
|
92
|
-
res.reason = r.code.trim();
|
|
86
|
+
applyEmitReasonAndCode(res, r);
|
|
93
87
|
out.push(res);
|
|
94
88
|
}
|
|
95
89
|
}
|
|
96
90
|
}
|
|
97
91
|
return out.length > 0 ? out : null;
|
|
98
92
|
}
|
|
93
|
+
function applyEmitReasonAndCode(res, raw) {
|
|
94
|
+
const reason = typeof raw.reason === "string" ? raw.reason.trim() : "";
|
|
95
|
+
const code = typeof raw.code === "string" ? raw.code.trim() : "";
|
|
96
|
+
if (reason)
|
|
97
|
+
res.reason = reason;
|
|
98
|
+
else if (code)
|
|
99
|
+
res.reason = code;
|
|
100
|
+
if (res.status !== "rejected")
|
|
101
|
+
return;
|
|
102
|
+
const rejectionCode = code || reason;
|
|
103
|
+
if (rejectionCode)
|
|
104
|
+
res.code = rejectionCode;
|
|
105
|
+
}
|
|
99
106
|
function normalizeStatus(v) {
|
|
100
107
|
if (typeof v !== "string")
|
|
101
108
|
return null;
|
|
@@ -37,6 +37,17 @@ export interface BuildEmitEvidenceInput {
|
|
|
37
37
|
readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
|
|
38
38
|
/** Injectable work-tree finder (tests). */
|
|
39
39
|
findRepo?: (startDir: string) => string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Env for HQ_SPAWN_COMPANY and the identity file. Defaults to empty so a
|
|
42
|
+
* unit test does not inherit the developer shell. Callers that emit pass
|
|
43
|
+
* `process.env`.
|
|
44
|
+
*/
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
46
|
+
/** Injectable machine-company reader (tests). */
|
|
47
|
+
readMachineCompany?: (env: NodeJS.ProcessEnv) => {
|
|
48
|
+
slug?: string;
|
|
49
|
+
uid?: string;
|
|
50
|
+
} | undefined;
|
|
40
51
|
}
|
|
41
52
|
/** Dedupe absolute-ish paths, preserving first-seen order. */
|
|
42
53
|
export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
|
|
@@ -46,4 +57,12 @@ export declare function normalizeTouchedPaths(paths: Array<string | undefined |
|
|
|
46
57
|
* first touched file). All fields are omitted when empty.
|
|
47
58
|
*/
|
|
48
59
|
export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
|
|
60
|
+
/**
|
|
61
|
+
* A machine credential with no company must not be posted. The server would
|
|
62
|
+
* file it as company NONE. Hold it instead.
|
|
63
|
+
*/
|
|
64
|
+
export declare function machineEmitDisposition(input: {
|
|
65
|
+
machine: boolean;
|
|
66
|
+
companySlug?: string;
|
|
67
|
+
}): "post" | "hold";
|
|
49
68
|
//# sourceMappingURL=emit-evidence.d.ts.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* work-tree off disk. Never reads prompts/tokens; never hits the network.
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
-
import { readMetaCompanySlug } from "../../work-context/company.js";
|
|
12
|
+
import { readMachineCompanyBinding, readMetaCompanySlug } from "../../work-context/company.js";
|
|
13
13
|
import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
|
|
14
14
|
function clean(v) {
|
|
15
15
|
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
@@ -41,7 +41,16 @@ export function buildEmitEvidence(input) {
|
|
|
41
41
|
const cwd = clean(input.cwd);
|
|
42
42
|
const hqRoot = clean(input.hqRoot);
|
|
43
43
|
const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
|
|
44
|
-
const
|
|
44
|
+
const env = input.env ?? {};
|
|
45
|
+
const machine = (input.readMachineCompany ?? readMachineCompanyBinding)(env);
|
|
46
|
+
const spawned = clean(env.HQ_SPAWN_COMPANY);
|
|
47
|
+
// Explicit, spawn, session meta, then the box binding (slug, else uid).
|
|
48
|
+
// A uid with no slug still rides companySlug so the event is not company NONE.
|
|
49
|
+
const companySlug = clean(input.companySlug) ??
|
|
50
|
+
spawned ??
|
|
51
|
+
clean(readMeta(input.sessionId, hqRoot)) ??
|
|
52
|
+
(machine && machine.slug !== spawned ? clean(machine.slug) : undefined) ??
|
|
53
|
+
clean(machine?.uid);
|
|
45
54
|
let repoPath = clean(input.repoPath);
|
|
46
55
|
if (!repoPath) {
|
|
47
56
|
// Search from the cwd (a directory) or the directory of the first touched
|
|
@@ -69,6 +78,15 @@ export function buildEmitEvidence(input) {
|
|
|
69
78
|
evidence.task = task;
|
|
70
79
|
return evidence;
|
|
71
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A machine credential with no company must not be posted. The server would
|
|
83
|
+
* file it as company NONE. Hold it instead.
|
|
84
|
+
*/
|
|
85
|
+
export function machineEmitDisposition(input) {
|
|
86
|
+
if (input.machine && !clean(input.companySlug))
|
|
87
|
+
return "hold";
|
|
88
|
+
return "post";
|
|
89
|
+
}
|
|
72
90
|
/** Directory of the first absolute touched file, for work-tree discovery. */
|
|
73
91
|
function firstFileDir(touchedPaths) {
|
|
74
92
|
const abs = touchedPaths.find((p) => path.isAbsolute(p));
|
|
@@ -47,8 +47,17 @@ export interface EmitEventsSummary {
|
|
|
47
47
|
retryDepth: number;
|
|
48
48
|
batches: number;
|
|
49
49
|
statuses: Record<EmitEventStatus, number>;
|
|
50
|
+
/** Server code for each terminally rejected event. */
|
|
51
|
+
rejections: Array<{
|
|
52
|
+
eventId: string;
|
|
53
|
+
code: string;
|
|
54
|
+
}>;
|
|
50
55
|
lastPostAt?: string;
|
|
51
56
|
}
|
|
57
|
+
export declare function formatEmitRejectionLines(rejections: ReadonlyArray<{
|
|
58
|
+
eventId: string;
|
|
59
|
+
code: string;
|
|
60
|
+
}>): string[];
|
|
52
61
|
/** Drain the retry file, post pending + new events, persist failures. */
|
|
53
62
|
export declare function emitEvents(deps: EmitEventsDeps): Promise<EmitEventsSummary>;
|
|
54
63
|
//# sourceMappingURL=emit.d.ts.map
|
|
@@ -41,9 +41,23 @@ function writeEmitState(workMeshRoot, state) {
|
|
|
41
41
|
/* best-effort */
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
function rejectionCodeFromBody(body, status) {
|
|
45
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
46
|
+
const rec = body;
|
|
47
|
+
if (typeof rec.code === "string" && rec.code.trim())
|
|
48
|
+
return rec.code.trim();
|
|
49
|
+
if (typeof rec.error === "string" && /^[A-Z0-9_]+$/.test(rec.error.trim())) {
|
|
50
|
+
return rec.error.trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return `HTTP_${status}`;
|
|
54
|
+
}
|
|
44
55
|
function eventIdOf(e) {
|
|
45
56
|
return typeof e.eventId === "string" && e.eventId.trim() ? e.eventId.trim() : null;
|
|
46
57
|
}
|
|
58
|
+
export function formatEmitRejectionLines(rejections) {
|
|
59
|
+
return rejections.map((row) => `work-mesh: rejected eventId=${row.eventId} code=${row.code}`);
|
|
60
|
+
}
|
|
47
61
|
/** Drain the retry file, post pending + new events, persist failures. */
|
|
48
62
|
export async function emitEvents(deps) {
|
|
49
63
|
const now = deps.now ?? (() => new Date());
|
|
@@ -74,6 +88,7 @@ export async function emitEvents(deps) {
|
|
|
74
88
|
retryDepth: 0,
|
|
75
89
|
batches: 0,
|
|
76
90
|
statuses: { accepted: 0, unassigned: 0, rejected: 0 },
|
|
91
|
+
rejections: [],
|
|
77
92
|
};
|
|
78
93
|
const keep = [];
|
|
79
94
|
let anyPosted = false;
|
|
@@ -95,7 +110,13 @@ export async function emitEvents(deps) {
|
|
|
95
110
|
// Non-retryable 4xx (e.g. 401/400) → drop with a log (never user data).
|
|
96
111
|
summary.rejected += chunk.length;
|
|
97
112
|
summary.statuses.rejected += chunk.length;
|
|
98
|
-
|
|
113
|
+
const batchCode = rejectionCodeFromBody(result.body, result.status);
|
|
114
|
+
for (const event of chunk) {
|
|
115
|
+
const id = eventIdOf(event);
|
|
116
|
+
if (id)
|
|
117
|
+
summary.rejections.push({ eventId: id, code: batchCode });
|
|
118
|
+
}
|
|
119
|
+
deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status} code=${batchCode}`);
|
|
99
120
|
}
|
|
100
121
|
continue;
|
|
101
122
|
}
|
|
@@ -121,8 +142,13 @@ export async function emitEvents(deps) {
|
|
|
121
142
|
summary.accepted += 1;
|
|
122
143
|
else if (r.status === "unassigned")
|
|
123
144
|
summary.unassigned += 1;
|
|
124
|
-
else
|
|
145
|
+
else if (id) {
|
|
125
146
|
summary.rejected += 1;
|
|
147
|
+
summary.rejections.push({
|
|
148
|
+
eventId: id,
|
|
149
|
+
code: r.code || r.reason || "rejected",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
126
152
|
}
|
|
127
153
|
}
|
|
128
154
|
const written = writeEmitRetry(deps.workMeshRoot, keep, retryMax);
|
|
@@ -92,8 +92,14 @@ function isLocallyReconcilableHeldEvent(event, state, workContextRoot) {
|
|
|
92
92
|
repoIdentityKey,
|
|
93
93
|
}).status === "resolved");
|
|
94
94
|
}
|
|
95
|
-
function deadLetterEnvelope(event, reason, responseCode, at) {
|
|
96
|
-
return JSON.stringify({
|
|
95
|
+
function deadLetterEnvelope(event, reason, responseCode, at, code) {
|
|
96
|
+
return JSON.stringify({
|
|
97
|
+
event,
|
|
98
|
+
reason,
|
|
99
|
+
responseCode,
|
|
100
|
+
at,
|
|
101
|
+
...(code ? { code } : {}),
|
|
102
|
+
});
|
|
97
103
|
}
|
|
98
104
|
function groupBySession(events) {
|
|
99
105
|
const map = new Map();
|
|
@@ -431,7 +437,7 @@ function accountBatchSuccess(body, originalChunk, meshRoot, now, summary, markDi
|
|
|
431
437
|
const item = byId.get(row.eventId);
|
|
432
438
|
if (!item)
|
|
433
439
|
continue;
|
|
434
|
-
appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at), meshRoot);
|
|
440
|
+
appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at, row.code), meshRoot);
|
|
435
441
|
summary.deadLettered += 1;
|
|
436
442
|
rejectedIds.add(row.eventId);
|
|
437
443
|
markDisposed(item.event);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
3
|
import * as os from 'node:os';
|
|
5
4
|
import * as path from 'node:path';
|
|
6
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,9 +9,9 @@ import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js
|
|
|
10
9
|
import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
|
|
11
10
|
import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
|
|
12
11
|
import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
|
|
12
|
+
import { markExpectedUserError } from '../../utils/expected-cli-error.js';
|
|
13
13
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
14
14
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
15
|
/** The `path` implementation for a (possibly injected) platform. */
|
|
17
16
|
function pathFor(platform) {
|
|
18
17
|
return platform === 'win32' ? path.win32 : path.posix;
|
|
@@ -1005,6 +1004,22 @@ function describeQmdTermination(stdout, stderr) {
|
|
|
1005
1004
|
return `qmd reported: ${summary.reason}`;
|
|
1006
1005
|
return '';
|
|
1007
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* qmd caller-state failures that the top-level boundary may surface without a
|
|
1009
|
+
* crash report. The match is deliberately against qmd's raw stderr and a
|
|
1010
|
+
* closed subcommand set: a query containing these words must not change the
|
|
1011
|
+
* disposition of an unrelated qmd exit.
|
|
1012
|
+
*/
|
|
1013
|
+
function isExpectedQmdCallerFailure(args, stderr) {
|
|
1014
|
+
const subcommand = args[0];
|
|
1015
|
+
if (subcommand === 'get') {
|
|
1016
|
+
return /^Document not found:\s+[^\r\n]+$/m.test(stderr);
|
|
1017
|
+
}
|
|
1018
|
+
if (subcommand === 'collection') {
|
|
1019
|
+
return /(?:^|\b)Failed to parse\s+[^\r\n]+:\s*.+$/m.test(stderr);
|
|
1020
|
+
}
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1008
1023
|
/** Normalise a spawn result and raise the typed qmd failures. */
|
|
1009
1024
|
function finishRunQmd(result, bin, args, attribution) {
|
|
1010
1025
|
const normalized = {
|
|
@@ -1215,7 +1230,11 @@ function finishRunQmd(result, bin, args, attribution) {
|
|
|
1215
1230
|
if (alreadyExists) {
|
|
1216
1231
|
throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
|
|
1217
1232
|
}
|
|
1218
|
-
|
|
1233
|
+
const exitError = new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
1234
|
+
if (isExpectedQmdCallerFailure(args, normalized.stderr)) {
|
|
1235
|
+
throw markExpectedUserError(exitError);
|
|
1236
|
+
}
|
|
1237
|
+
throw exitError;
|
|
1219
1238
|
}
|
|
1220
1239
|
/** Run qmd with captured output and typed failures. */
|
|
1221
1240
|
export function runQmd(args, options = {}) {
|
|
@@ -77,7 +77,23 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
|
|
|
77
77
|
* Missing/unreadable/malformed → undefined
|
|
78
78
|
* (never throws; never logs file contents).
|
|
79
79
|
*/
|
|
80
|
+
export interface AgentIdentityCompany {
|
|
81
|
+
uid?: string;
|
|
82
|
+
slug?: string;
|
|
83
|
+
}
|
|
84
|
+
/** Company uid and slug from the fleet identity file. Missing file → undefined. */
|
|
85
|
+
export declare function readAgentIdentityCompany(env?: NodeJS.ProcessEnv): AgentIdentityCompany | undefined;
|
|
80
86
|
export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Company named for a machine session.
|
|
89
|
+
* Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
|
|
90
|
+
* Session meta is not included; callers place it between spawn and the file.
|
|
91
|
+
* No company → undefined. Never invents NONE.
|
|
92
|
+
*/
|
|
93
|
+
export declare function readMachineCompanyBinding(env?: NodeJS.ProcessEnv): {
|
|
94
|
+
slug?: string;
|
|
95
|
+
uid?: string;
|
|
96
|
+
} | undefined;
|
|
81
97
|
/**
|
|
82
98
|
* Agent boxes must never infer a human device default. The file itself is the
|
|
83
99
|
* machine marker; legacy identity files do not necessarily contain a company
|
|
@@ -38,13 +38,7 @@ function normalizeSlug(value) {
|
|
|
38
38
|
return undefined;
|
|
39
39
|
return s;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
* Read `companyUid` from the agent identity file when
|
|
43
|
-
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
44
|
-
* Missing/unreadable/malformed → undefined
|
|
45
|
-
* (never throws; never logs file contents).
|
|
46
|
-
*/
|
|
47
|
-
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
41
|
+
function readAgentIdentityRecord(env) {
|
|
48
42
|
// An explicit override (including empty/missing paths) never falls back to
|
|
49
43
|
// another identity. This prevents accidental attribution to a different box.
|
|
50
44
|
const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
|
|
@@ -54,15 +48,49 @@ export function readAgentIdentityCompanyUid(env = process.env) {
|
|
|
54
48
|
if (!fs.existsSync(file))
|
|
55
49
|
return undefined;
|
|
56
50
|
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
: undefined;
|
|
51
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
52
|
+
return undefined;
|
|
53
|
+
return raw;
|
|
61
54
|
}
|
|
62
55
|
catch {
|
|
63
56
|
return undefined;
|
|
64
57
|
}
|
|
65
58
|
}
|
|
59
|
+
/** Company uid and slug from the fleet identity file. Missing file → undefined. */
|
|
60
|
+
export function readAgentIdentityCompany(env = process.env) {
|
|
61
|
+
const raw = readAgentIdentityRecord(env);
|
|
62
|
+
if (!raw)
|
|
63
|
+
return undefined;
|
|
64
|
+
const uid = typeof raw.companyUid === "string" ? raw.companyUid.trim() : "";
|
|
65
|
+
const slug = normalizeSlug(typeof raw.companySlug === "string" ? raw.companySlug : undefined);
|
|
66
|
+
if (!uid && !slug)
|
|
67
|
+
return undefined;
|
|
68
|
+
return {
|
|
69
|
+
...(uid ? { uid } : {}),
|
|
70
|
+
...(slug ? { slug } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
74
|
+
return readAgentIdentityCompany(env)?.uid;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Company named for a machine session.
|
|
78
|
+
* Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
|
|
79
|
+
* Session meta is not included; callers place it between spawn and the file.
|
|
80
|
+
* No company → undefined. Never invents NONE.
|
|
81
|
+
*/
|
|
82
|
+
export function readMachineCompanyBinding(env = process.env) {
|
|
83
|
+
const spawn = normalizeSlug(env.HQ_SPAWN_COMPANY);
|
|
84
|
+
const identity = readAgentIdentityCompany(env);
|
|
85
|
+
if (spawn)
|
|
86
|
+
return { slug: spawn, ...(identity?.uid ? { uid: identity.uid } : {}) };
|
|
87
|
+
if (!identity?.slug && !identity?.uid)
|
|
88
|
+
return undefined;
|
|
89
|
+
return {
|
|
90
|
+
...(identity?.slug ? { slug: identity.slug } : {}),
|
|
91
|
+
...(identity?.uid ? { uid: identity.uid } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
66
94
|
/**
|
|
67
95
|
* Agent boxes must never infer a human device default. The file itself is the
|
|
68
96
|
* machine marker; legacy identity files do not necessarily contain a company
|
|
@@ -273,11 +301,15 @@ export function resolveCompany(input) {
|
|
|
273
301
|
// 4. Agent-box identity file (HQ_AGENT_IDENTITY_FILE). Prefer as the
|
|
274
302
|
// device-default role with source trusted_explicit — never device_default /
|
|
275
303
|
// "default company mode". Below HQ_SPAWN_COMPANY; above device default.
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
304
|
+
const identity = readAgentIdentityCompany(env);
|
|
305
|
+
if (identity?.uid || identity?.slug) {
|
|
278
306
|
return {
|
|
279
307
|
status: "resolved",
|
|
280
|
-
company: {
|
|
308
|
+
company: {
|
|
309
|
+
...(identity.slug ? { slug: identity.slug } : {}),
|
|
310
|
+
...(identity.uid ? { uid: identity.uid } : {}),
|
|
311
|
+
source: "trusted_explicit",
|
|
312
|
+
},
|
|
281
313
|
};
|
|
282
314
|
}
|
|
283
315
|
// 4b. Persisted per-repo company map (gap 4). A person answered once for this
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
|
|
3
|
+
* can emit. migration_pending is a migrate-path status, not a preflight result.
|
|
4
|
+
*
|
|
5
|
+
* contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
|
|
6
|
+
* contracts/preflight/v<N>/ and the vendored copy in hq-core
|
|
7
|
+
* (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
|
|
8
|
+
* in tests; a one-sided bump fails that side until the other is updated.
|
|
9
|
+
*/
|
|
10
|
+
import { type ContextResult } from "./contract.js";
|
|
11
|
+
export declare const PREFLIGHT_CONTRACT_VERSION: 1;
|
|
12
|
+
/** Classifications reconcile actually returns on the offline preflight path. */
|
|
13
|
+
export declare const OFFLINE_PREFLIGHT_CLASSIFICATIONS: readonly ["unresolved", "needs_company", "company_conflict", "needs_project", "needs_task", "bound", "untracked"];
|
|
14
|
+
export type OfflinePreflightClassification = (typeof OFFLINE_PREFLIGHT_CLASSIFICATIONS)[number];
|
|
15
|
+
/**
|
|
16
|
+
* One representative result per emitted classification, using the kind and
|
|
17
|
+
* delivery the offline path actually returns (company-resolved results are
|
|
18
|
+
* kind "queued" / delivery "queued"; local-only results are delivery "clean").
|
|
19
|
+
*/
|
|
20
|
+
export declare function preflightFixtureFor(classification: OfflinePreflightClassification): ContextResult;
|
|
21
|
+
export interface PreflightFixtureFile {
|
|
22
|
+
contractVersion: typeof PREFLIGHT_CONTRACT_VERSION;
|
|
23
|
+
source: "hq-cli contracts/preflight/v1/fixtures.json";
|
|
24
|
+
classifications: readonly OfflinePreflightClassification[];
|
|
25
|
+
fixtures: ContextResult[];
|
|
26
|
+
}
|
|
27
|
+
export declare function buildPreflightFixtures(): PreflightFixtureFile;
|
|
28
|
+
//# sourceMappingURL=preflight-contract.d.ts.map
|