@indigoai-us/hq-cli 5.130.0 → 5.131.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 +44 -1
- package/contracts/preflight/v1/fixtures.json +87 -0
- package/contracts/preflight/v1/schema.json +58 -0
- package/dist/command-catalog.generated.d.ts +27 -0
- package/dist/command-catalog.generated.js +34 -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/mesh.js +46 -12
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +15 -2
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +10 -2
- package/dist/lib/mesh/live/daemon/run.js +3 -0
- 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.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/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/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +11 -3
- package/package.json +3 -2
package/dist/lib/mesh/api.js
CHANGED
|
@@ -30,6 +30,7 @@ export async function meshJson(token, path, init = {}) {
|
|
|
30
30
|
method: init.method,
|
|
31
31
|
body: init.body,
|
|
32
32
|
query: init.query,
|
|
33
|
+
headers: init.headers,
|
|
33
34
|
signal: init.signal,
|
|
34
35
|
});
|
|
35
36
|
const text = await res.text();
|
|
@@ -53,7 +54,11 @@ export async function meshJson(token, path, init = {}) {
|
|
|
53
54
|
typeof err.stage === "string" && err.stage ? `stage=${err.stage}` : "",
|
|
54
55
|
typeof err.recovery === "string" && err.recovery ? `recovery=${err.recovery}` : "",
|
|
55
56
|
].filter(Boolean);
|
|
56
|
-
|
|
57
|
+
const failure = new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
|
|
58
|
+
failure.status = res.status;
|
|
59
|
+
if (typeof err.code === "string" && err.code)
|
|
60
|
+
failure.code = err.code;
|
|
61
|
+
throw failure;
|
|
57
62
|
}
|
|
58
63
|
return data;
|
|
59
64
|
}
|
|
@@ -415,9 +420,17 @@ export async function getProjectView(token, companyUid, projectId) {
|
|
|
415
420
|
}
|
|
416
421
|
/** Replace a project's durable Board view and keep the local project cache current. */
|
|
417
422
|
export async function putProjectView(token, companyUid, projectId, input) {
|
|
423
|
+
const { expectedVersion, ...document } = input;
|
|
418
424
|
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
|
|
419
425
|
method: "PUT",
|
|
420
|
-
body: {
|
|
426
|
+
body: {
|
|
427
|
+
companyUid,
|
|
428
|
+
...document,
|
|
429
|
+
...(expectedVersion !== undefined ? { expectedVersion } : {}),
|
|
430
|
+
},
|
|
431
|
+
...(expectedVersion !== undefined
|
|
432
|
+
? { headers: { "If-Match": String(expectedVersion) } }
|
|
433
|
+
: {}),
|
|
421
434
|
}));
|
|
422
435
|
if (!view)
|
|
423
436
|
throw new Error("Work mesh returned an invalid project view");
|
|
@@ -33,6 +33,10 @@ export interface DaemonDoctorReport {
|
|
|
33
33
|
tokenSource: CognitoTokenSource;
|
|
34
34
|
/** human | agent | unknown — from token claims / machine signal. */
|
|
35
35
|
actorKind: CognitoActorKind;
|
|
36
|
+
/** Subject in the person login file (`hq whoami`), or empty claims. */
|
|
37
|
+
whoamiSubject: string;
|
|
38
|
+
/** Subject the daemon will authenticate as (machine cache, else person file). */
|
|
39
|
+
daemonSubject: string;
|
|
36
40
|
/** Credential vend refused (disabled / unsupported / 403). */
|
|
37
41
|
presenceRefusal?: DaemonStateFile["presenceRefusal"];
|
|
38
42
|
/** Companies the current presence credential did not include. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
|
|
8
|
+
import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedTokens, readPersonTokenFile, } from "../../../../utils/cognito-session.js";
|
|
9
9
|
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
10
10
|
import { readSessionState } from "../../../work-context/state.js";
|
|
11
11
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
@@ -145,6 +145,12 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
145
145
|
unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
|
|
146
146
|
}
|
|
147
147
|
const auth = describeCognitoTokenSource({ home, env });
|
|
148
|
+
const whoamiTokens = readPersonTokenFile(home, env);
|
|
149
|
+
const daemonTokens = auth.tokenSource === "machine"
|
|
150
|
+
? loadMachineCachedTokens(home, env)
|
|
151
|
+
: whoamiTokens;
|
|
152
|
+
const whoamiSubject = formatCognitoSubject(whoamiTokens);
|
|
153
|
+
const daemonSubject = formatCognitoSubject(daemonTokens);
|
|
148
154
|
const emitState = readEmitState(meshRoot);
|
|
149
155
|
const emitRetry = emitRetryDepth(meshRoot);
|
|
150
156
|
if (emitRetry > 0) {
|
|
@@ -169,6 +175,8 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
169
175
|
oldestSpoolAgeMs,
|
|
170
176
|
tokenSource: auth.tokenSource,
|
|
171
177
|
actorKind: auth.actorKind,
|
|
178
|
+
whoamiSubject,
|
|
179
|
+
daemonSubject,
|
|
172
180
|
presenceRefusal,
|
|
173
181
|
presenceDroppedCompanies,
|
|
174
182
|
emitMode,
|
|
@@ -200,7 +208,7 @@ export function formatDaemonDoctor(report) {
|
|
|
200
208
|
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
201
209
|
}
|
|
202
210
|
lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
203
|
-
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
211
|
+
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`, `identity whoami: ${report.whoamiSubject}`, `identity daemon: ${report.daemonSubject}`);
|
|
204
212
|
if (report.emitMode === "legacy") {
|
|
205
213
|
lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
206
214
|
? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
|
|
@@ -137,6 +137,9 @@ 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;
|
|
@@ -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;
|
|
@@ -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 = {}) {
|
|
@@ -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
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { WORK_CONTEXT_CONTRACT_VERSION, } from "./contract.js";
|
|
11
|
+
export const PREFLIGHT_CONTRACT_VERSION = WORK_CONTEXT_CONTRACT_VERSION;
|
|
12
|
+
/** Classifications reconcile actually returns on the offline preflight path. */
|
|
13
|
+
export const OFFLINE_PREFLIGHT_CLASSIFICATIONS = [
|
|
14
|
+
"unresolved",
|
|
15
|
+
"needs_company",
|
|
16
|
+
"company_conflict",
|
|
17
|
+
"needs_project",
|
|
18
|
+
"needs_task",
|
|
19
|
+
"bound",
|
|
20
|
+
"untracked",
|
|
21
|
+
];
|
|
22
|
+
const FIXTURE_SESSION = "sid-preflight-fixture";
|
|
23
|
+
const FIXTURE_OP = "op-preflight-fixture";
|
|
24
|
+
function base(classification, kind, delivery) {
|
|
25
|
+
return {
|
|
26
|
+
contractVersion: PREFLIGHT_CONTRACT_VERSION,
|
|
27
|
+
kind,
|
|
28
|
+
classification,
|
|
29
|
+
delivery,
|
|
30
|
+
lifecycle: "open",
|
|
31
|
+
sessionId: FIXTURE_SESSION,
|
|
32
|
+
clientOperationId: FIXTURE_OP,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One representative result per emitted classification, using the kind and
|
|
37
|
+
* delivery the offline path actually returns (company-resolved results are
|
|
38
|
+
* kind "queued" / delivery "queued"; local-only results are delivery "clean").
|
|
39
|
+
*/
|
|
40
|
+
export function preflightFixtureFor(classification) {
|
|
41
|
+
switch (classification) {
|
|
42
|
+
case "unresolved":
|
|
43
|
+
return base("unresolved", "queued", "queued");
|
|
44
|
+
case "needs_company":
|
|
45
|
+
return base("needs_company", "needs_company", "clean");
|
|
46
|
+
case "company_conflict":
|
|
47
|
+
return base("company_conflict", "company_conflict", "clean");
|
|
48
|
+
case "needs_project":
|
|
49
|
+
return {
|
|
50
|
+
...base("needs_project", "queued", "queued"),
|
|
51
|
+
companySlug: "acme",
|
|
52
|
+
companyUid: "cmp_acme",
|
|
53
|
+
};
|
|
54
|
+
case "needs_task":
|
|
55
|
+
return {
|
|
56
|
+
...base("needs_task", "queued", "queued"),
|
|
57
|
+
companySlug: "acme",
|
|
58
|
+
companyUid: "cmp_acme",
|
|
59
|
+
projectId: "dispatch-proj",
|
|
60
|
+
};
|
|
61
|
+
case "bound":
|
|
62
|
+
return {
|
|
63
|
+
...base("bound", "queued", "queued"),
|
|
64
|
+
companySlug: "acme",
|
|
65
|
+
companyUid: "cmp_acme",
|
|
66
|
+
projectId: "dispatch-proj",
|
|
67
|
+
taskId: "US-9",
|
|
68
|
+
};
|
|
69
|
+
case "untracked":
|
|
70
|
+
return base("untracked", "untracked", "clean");
|
|
71
|
+
default: {
|
|
72
|
+
const never = classification;
|
|
73
|
+
return never;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function buildPreflightFixtures() {
|
|
78
|
+
return {
|
|
79
|
+
contractVersion: PREFLIGHT_CONTRACT_VERSION,
|
|
80
|
+
source: "hq-cli contracts/preflight/v1/fixtures.json",
|
|
81
|
+
classifications: [...OFFLINE_PREFLIGHT_CLASSIFICATIONS],
|
|
82
|
+
fixtures: OFFLINE_PREFLIGHT_CLASSIFICATIONS.map(preflightFixtureFor),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=preflight-contract.js.map
|
package/dist/main.js
CHANGED
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import { finishSentrySession, initSentry, Sentry } from "./sentry.js";
|
|
13
13
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
14
|
-
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
14
|
+
import { environmentalFsErrorMessage, filesystemErrorCaptureContext, filesystemErrorMessage, } from "./utils/environmental-error.js";
|
|
15
15
|
import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
|
|
16
16
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
17
17
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
@@ -690,6 +690,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
690
690
|
lockMsg
|
|
691
691
|
? null
|
|
692
692
|
: networkTransportErrorMessage(err);
|
|
693
|
+
// Windows EPERM/EACCES/EBUSY/UNKNOWN filesystem failures are not proven
|
|
694
|
+
// caller-owned conditions. Keep them at error level and attach bounded,
|
|
695
|
+
// installation-independent context so OneDrive/antivirus/path-length
|
|
696
|
+
// hypotheses can be separated without creating one issue per host path.
|
|
697
|
+
const fsContext = filesystemErrorCaptureContext(err);
|
|
698
|
+
const fsMsg = fsContext ? filesystemErrorMessage(err) : null;
|
|
693
699
|
if (qmdMsg) {
|
|
694
700
|
deps.stderr.write(`hq: ${qmdMsg}\n`);
|
|
695
701
|
}
|
|
@@ -735,6 +741,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
735
741
|
else if (transportMsg) {
|
|
736
742
|
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
737
743
|
}
|
|
744
|
+
else if (fsMsg && fsContext) {
|
|
745
|
+
deps.sentry.captureException(err, fsContext);
|
|
746
|
+
deps.stderr.write(`hq: ${fsMsg}\n`);
|
|
747
|
+
}
|
|
738
748
|
else {
|
|
739
749
|
// A genuinely unclassified fault is still captured exactly once. Two
|
|
740
750
|
// shapes attach bounded, hq-derived context so the next occurrence
|
|
@@ -153,6 +153,45 @@ export declare function wantsMachineCognitoTokens(options?: Pick<EnsureCognitoTo
|
|
|
153
153
|
* not flip this to machine.
|
|
154
154
|
*/
|
|
155
155
|
export declare function resolveCognitoTokenSource(env?: NodeJS.ProcessEnv): CognitoTokenSource;
|
|
156
|
+
/**
|
|
157
|
+
* Refresh wrote a different Cognito subject than the active cache.
|
|
158
|
+
* Callers must not fall through to browser login or machine mint — that
|
|
159
|
+
* would replace the operator session the refusal is protecting.
|
|
160
|
+
*/
|
|
161
|
+
export declare class IdentitySwapRefusal extends Error {
|
|
162
|
+
constructor(message: string);
|
|
163
|
+
}
|
|
164
|
+
export declare function isIdentitySwapRefusal(err: unknown): err is IdentitySwapRefusal;
|
|
165
|
+
/** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
|
|
166
|
+
export declare function cognitoSubjectFromToken(token: string | undefined): {
|
|
167
|
+
sub: string | null;
|
|
168
|
+
entityUid: string | null;
|
|
169
|
+
};
|
|
170
|
+
/** Stable subject key. Null when neither sub nor entity uid is present. */
|
|
171
|
+
export declare function cognitoSubjectKey(tokens: {
|
|
172
|
+
idToken?: string;
|
|
173
|
+
accessToken?: string;
|
|
174
|
+
} | null | undefined): string | null;
|
|
175
|
+
/** Doctor/status line. Claims only — never token material. */
|
|
176
|
+
export declare function formatCognitoSubject(tokens: {
|
|
177
|
+
idToken?: string;
|
|
178
|
+
accessToken?: string;
|
|
179
|
+
} | null | undefined): string;
|
|
180
|
+
/**
|
|
181
|
+
* Person cache as stored on disk for `env`. Does not consult hq-cloud's
|
|
182
|
+
* in-memory fallback, which can surface a different session when the file
|
|
183
|
+
* is missing or unreadable.
|
|
184
|
+
*/
|
|
185
|
+
export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv): CognitoTokens | null;
|
|
186
|
+
/**
|
|
187
|
+
* Refresh the active person session and refuse a subject change.
|
|
188
|
+
*
|
|
189
|
+
* hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
|
|
190
|
+
* cache miss or unreadable file it can refresh an in-memory session for a
|
|
191
|
+
* different subject (same app client). Snapshot the active file first; if
|
|
192
|
+
* the response subject differs, put the bytes back and leave them there.
|
|
193
|
+
*/
|
|
194
|
+
export declare function refreshActivePersonTokens(cached: CognitoTokens, env?: NodeJS.ProcessEnv): Promise<CognitoTokens>;
|
|
156
195
|
/** Derive actor kind from an ID token's custom:entity* claims (no verify). */
|
|
157
196
|
export declare function actorKindFromIdToken(idToken: string | undefined): CognitoActorKind;
|
|
158
197
|
/**
|