@indigoai-us/hq-cli 5.108.20 → 5.108.22
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 +33 -0
- package/dist/commands/files-browse.d.ts +22 -0
- package/dist/commands/files-browse.js +62 -8
- package/dist/lib/doctor/checks/runtime-probe.js +1 -1
- package/dist/lib/mesh/client.js +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +2 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.js +18 -26
- package/dist/lib/work-context/company.d.ts +3 -1
- package/dist/lib/work-context/company.js +6 -2
- package/dist/lib/work-context/outbox.d.ts +4 -1
- package/dist/lib/work-context/outbox.js +14 -1
- package/dist/lib/work-context/reconcile.js +11 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.22] — 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Work Mesh registrations now retain the runtime harness in the durable outbox
|
|
10
|
+
and send it to the server, including a compatible fallback for older queued
|
|
11
|
+
operations. Fleet sessions discover their company from the standard agent
|
|
12
|
+
identity file without requiring a launcher environment variable, and transcript
|
|
13
|
+
registration uses the shared company precedence and conflict rules.
|
|
14
|
+
|
|
15
|
+
## [5.108.21] — 2026-09-07
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A large `hq files cat`/`hq files get`/`hq files browse`/`hq files search`
|
|
20
|
+
download no longer dies part-way through with an unexplained crash (Sentry
|
|
21
|
+
HQ-CLI-5 — 7596554539). Every company-mode vault read goes through a presigned
|
|
22
|
+
GET, and the CLI handed the caller the download's response body *before anyone
|
|
23
|
+
started reading it*. Node's bundled `undici` builds that body stream with a
|
|
24
|
+
zero high-water mark, so the first chunk pauses its HTTP parser until a
|
|
25
|
+
consumer pulls — and the download loop ran a synchronous `mkdirSync` before it
|
|
26
|
+
began pulling. When S3 closed the connection inside that gap, undici's
|
|
27
|
+
socket-end handler tripped an internal `assert(!this.paused)` and threw an
|
|
28
|
+
`AssertionError` from a background tick that no `try/catch` around the download
|
|
29
|
+
could see, so `@sentry/node` filed it as a fatal and the process exited
|
|
30
|
+
mid-download. The presigned GET is now drained into memory before its body is
|
|
31
|
+
handed on whenever the response declares a length at or below 32 MiB (which
|
|
32
|
+
covers every object the CLI reads today), removing the paused-parser window
|
|
33
|
+
entirely; larger or unmeasured responses keep streaming, and the destination
|
|
34
|
+
directory is now created *before* the download is issued so no synchronous
|
|
35
|
+
filesystem call sits inside the read window on that path either. The observable
|
|
36
|
+
output of every `hq files` subcommand is unchanged.
|
|
37
|
+
|
|
5
38
|
## [5.108.20] — 2026-09-07
|
|
6
39
|
|
|
7
40
|
### Fixed
|
|
@@ -403,6 +403,28 @@ export interface RunGetResult {
|
|
|
403
403
|
* HQ root itself, which is too broad to do implicitly.
|
|
404
404
|
*/
|
|
405
405
|
export declare function runGet(input: RunGetInput): Promise<RunGetResult>;
|
|
406
|
+
/**
|
|
407
|
+
* Upper bound (bytes) on a presigned download we drain into memory before
|
|
408
|
+
* handing the caller a Body (HQ-CLI-5).
|
|
409
|
+
*
|
|
410
|
+
* fetch()'s response body is an undici ReadableStream with a zero high-water
|
|
411
|
+
* mark: the first chunk drives desiredSize to 0 and undici PAUSES its llhttp
|
|
412
|
+
* parser until a consumer pulls. `getObject` used to hand that still-paused
|
|
413
|
+
* stream straight to the orchestrators, which each run a synchronous fs call
|
|
414
|
+
* before they start pulling. If S3 sends its connection FIN inside that gap,
|
|
415
|
+
* undici's socket-end handler trips `assert(!this.paused)` in Parser.finish and
|
|
416
|
+
* throws an AssertionError from a process tick — outside any try/catch — that
|
|
417
|
+
* @sentry/node files as an uncaught fatal and that kills the CLI mid-download.
|
|
418
|
+
*
|
|
419
|
+
* Draining the body up front removes the pause entirely (the same
|
|
420
|
+
* buffer-then-rewrap posture peekPlanLimitStatus uses in vault-api.ts). We cap
|
|
421
|
+
* it so a very large object still streams and never buffers into a small agent
|
|
422
|
+
* box's memory; a response above the ceiling (or with no declared length) keeps
|
|
423
|
+
* the streaming path, whose residual window is narrowed by the mkdirSync hoist
|
|
424
|
+
* in runGet/runCat. 32 MiB comfortably covers every object hq-cli reads today
|
|
425
|
+
* (vault JSON + images) while staying well under a 4 GB host's headroom.
|
|
426
|
+
*/
|
|
427
|
+
export declare const PRESIGN_BUFFER_MAX_BYTES: number;
|
|
406
428
|
/**
|
|
407
429
|
* Build a COMPANY-mode browse client backed by the list + presign API. The
|
|
408
430
|
* access token + companyUid are captured here; the orchestrator just calls
|
|
@@ -318,6 +318,15 @@ export async function runCat(input) {
|
|
|
318
318
|
// COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
|
|
319
319
|
s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
|
|
320
320
|
}
|
|
321
|
+
// HQ-CLI-5: when writing to --out, create the parent directory BEFORE issuing
|
|
322
|
+
// the presigned GET, so no synchronous filesystem call sits between receiving
|
|
323
|
+
// the body and the nextTick resume that starts pulling it — that gap is what
|
|
324
|
+
// left undici's HTTP parser paused into the socket FIN. The guard above
|
|
325
|
+
// already validated absOut is outside the protected companies/ tree, so its
|
|
326
|
+
// parent is outside too.
|
|
327
|
+
if (absOut !== undefined) {
|
|
328
|
+
fs.mkdirSync(path.dirname(absOut), { recursive: true });
|
|
329
|
+
}
|
|
321
330
|
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })));
|
|
322
331
|
if (!resp.Body) {
|
|
323
332
|
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
@@ -331,10 +340,6 @@ export async function runCat(input) {
|
|
|
331
340
|
bytesWritten += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
|
|
332
341
|
});
|
|
333
342
|
if (absOut !== undefined) {
|
|
334
|
-
// Ensure the parent directory exists — but ONLY if it's also outside
|
|
335
|
-
// the protected tree (the guard already validated absOut itself; the
|
|
336
|
-
// parent of an outside-tree path is by definition outside too).
|
|
337
|
-
fs.mkdirSync(path.dirname(absOut), { recursive: true });
|
|
338
343
|
await pipeline(body, fs.createWriteStream(absOut));
|
|
339
344
|
return {
|
|
340
345
|
bytesWritten,
|
|
@@ -536,12 +541,17 @@ export async function runGet(input) {
|
|
|
536
541
|
else {
|
|
537
542
|
destAbs = path.join(hqRoot, "companies", slug, key);
|
|
538
543
|
}
|
|
544
|
+
// HQ-CLI-5: create the destination directory BEFORE issuing the presigned
|
|
545
|
+
// GET, so no synchronous filesystem call sits between receiving the body
|
|
546
|
+
// and starting to pull it — that gap is what left undici's HTTP parser
|
|
547
|
+
// paused into the socket FIN. Buffered downloads no longer keep a live
|
|
548
|
+
// socket at all; this also closes the window on the streaming branch.
|
|
549
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
539
550
|
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
|
|
540
551
|
if (!resp.Body) {
|
|
541
552
|
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
542
553
|
}
|
|
543
554
|
const body = resp.Body;
|
|
544
|
-
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
545
555
|
await pipeline(body, fs.createWriteStream(destAbs));
|
|
546
556
|
bytesWritten += fs.statSync(destAbs).size;
|
|
547
557
|
destinations.push(destAbs);
|
|
@@ -557,6 +567,28 @@ export async function runGet(input) {
|
|
|
557
567
|
}
|
|
558
568
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
559
569
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
570
|
+
/**
|
|
571
|
+
* Upper bound (bytes) on a presigned download we drain into memory before
|
|
572
|
+
* handing the caller a Body (HQ-CLI-5).
|
|
573
|
+
*
|
|
574
|
+
* fetch()'s response body is an undici ReadableStream with a zero high-water
|
|
575
|
+
* mark: the first chunk drives desiredSize to 0 and undici PAUSES its llhttp
|
|
576
|
+
* parser until a consumer pulls. `getObject` used to hand that still-paused
|
|
577
|
+
* stream straight to the orchestrators, which each run a synchronous fs call
|
|
578
|
+
* before they start pulling. If S3 sends its connection FIN inside that gap,
|
|
579
|
+
* undici's socket-end handler trips `assert(!this.paused)` in Parser.finish and
|
|
580
|
+
* throws an AssertionError from a process tick — outside any try/catch — that
|
|
581
|
+
* @sentry/node files as an uncaught fatal and that kills the CLI mid-download.
|
|
582
|
+
*
|
|
583
|
+
* Draining the body up front removes the pause entirely (the same
|
|
584
|
+
* buffer-then-rewrap posture peekPlanLimitStatus uses in vault-api.ts). We cap
|
|
585
|
+
* it so a very large object still streams and never buffers into a small agent
|
|
586
|
+
* box's memory; a response above the ceiling (or with no declared length) keeps
|
|
587
|
+
* the streaming path, whose residual window is narrowed by the mkdirSync hoist
|
|
588
|
+
* in runGet/runCat. 32 MiB comfortably covers every object hq-cli reads today
|
|
589
|
+
* (vault JSON + images) while staying well under a 4 GB host's headroom.
|
|
590
|
+
*/
|
|
591
|
+
export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
560
592
|
/**
|
|
561
593
|
* Build a COMPANY-mode browse client backed by the list + presign API. The
|
|
562
594
|
* access token + companyUid are captured here; the orchestrator just calls
|
|
@@ -616,9 +648,31 @@ export function createCompanyPresignClient(input) {
|
|
|
616
648
|
if (!dl.ok) {
|
|
617
649
|
throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
|
|
618
650
|
}
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
651
|
+
// HQ-CLI-5: when the response declares a length at or below the ceiling,
|
|
652
|
+
// DRAIN it here — read the whole body before send() resolves and replay it
|
|
653
|
+
// from memory. That removes undici's paused-parser window entirely: by the
|
|
654
|
+
// time the orchestrators pull, there is no live socket left to hit
|
|
655
|
+
// `assert(!this.paused)` on its FIN. `Number(null)` is 0, so guard the
|
|
656
|
+
// absent header explicitly (NaN) — an unmeasured body must keep streaming,
|
|
657
|
+
// never look like a zero-length buffer.
|
|
658
|
+
const declaredLengthHeader = dl.headers.get("content-length");
|
|
659
|
+
const declaredLength = declaredLengthHeader === null ? NaN : Number(declaredLengthHeader);
|
|
660
|
+
const canBuffer = Number.isFinite(declaredLength) &&
|
|
661
|
+
declaredLength >= 0 &&
|
|
662
|
+
declaredLength <= PRESIGN_BUFFER_MAX_BYTES;
|
|
663
|
+
if (canBuffer) {
|
|
664
|
+
const buf = Buffer.from(await dl.arrayBuffer());
|
|
665
|
+
return {
|
|
666
|
+
Body: Readable.from(buf),
|
|
667
|
+
$metadata: {},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// Above the ceiling, or no declared length: keep streaming so a very large
|
|
671
|
+
// object is never buffered whole. fetch() yields a web ReadableStream; the
|
|
672
|
+
// orchestrators consume Body as a Node Readable (body.on('data') + stream
|
|
673
|
+
// pipeline), so adapt it. An empty body (no stream) becomes an empty
|
|
674
|
+
// Readable. The mkdirSync hoist in runGet/runCat narrows the residual
|
|
675
|
+
// paused-parser window on this branch.
|
|
622
676
|
const nodeBody = dl.body
|
|
623
677
|
? Readable.fromWeb(dl.body)
|
|
624
678
|
: Readable.from([]);
|
|
@@ -300,7 +300,7 @@ export function checkRuntimeProbe(context) {
|
|
|
300
300
|
status: "PASS",
|
|
301
301
|
checkId,
|
|
302
302
|
target,
|
|
303
|
-
message: `Host platform is
|
|
303
|
+
message: `Host platform is the Agents v2 runtime: the on-box adapter wrote the ` +
|
|
304
304
|
`policy-trigger ledger through the same .claude hooks, so hook dispatch was ` +
|
|
305
305
|
`observed this session — the ledger has an entry${scope}.`,
|
|
306
306
|
},
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -24,6 +24,8 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
24
24
|
contractVersion: 1,
|
|
25
25
|
companyUid,
|
|
26
26
|
sessionId: op.sessionId,
|
|
27
|
+
// Pre-upgrade outbox records have no harness; do not invent a provider.
|
|
28
|
+
harness: op.harness?.trim() || "unknown",
|
|
27
29
|
clientOperationId: op.clientOperationId,
|
|
28
30
|
operationId: op.operationId,
|
|
29
31
|
digest: op.digest,
|
|
@@ -219,10 +219,11 @@ export declare function isTranscriptHookCovered(sessionId: string, opts: {
|
|
|
219
219
|
}): boolean;
|
|
220
220
|
/**
|
|
221
221
|
* Resolve local registration for a transcript session. Never asks, never
|
|
222
|
-
* creates a project.
|
|
222
|
+
* creates a project. Shares reconcile's company precedence and conflict rules.
|
|
223
223
|
*/
|
|
224
224
|
export declare function resolveTranscriptRegistration(input: {
|
|
225
225
|
sessionId: string;
|
|
226
|
+
env?: NodeJS.ProcessEnv;
|
|
226
227
|
cwd?: string;
|
|
227
228
|
hqRoot?: string;
|
|
228
229
|
workContextRoot: string;
|
|
@@ -29,8 +29,7 @@ import * as fs from "node:fs";
|
|
|
29
29
|
import * as os from "node:os";
|
|
30
30
|
import * as path from "node:path";
|
|
31
31
|
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
32
|
-
import { companySlugFromCwd, projectIdFromCwd,
|
|
33
|
-
import { getDefaultCompany } from "../../../work-context/config.js";
|
|
32
|
+
import { companySlugFromCwd, projectIdFromCwd, resolveCompany, } from "../../../work-context/company.js";
|
|
34
33
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../../work-context/contract.js";
|
|
35
34
|
import { reconcileObservation, } from "../../../work-context/reconcile.js";
|
|
36
35
|
import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
@@ -441,7 +440,7 @@ export function isTranscriptHookCovered(sessionId, opts) {
|
|
|
441
440
|
}
|
|
442
441
|
/**
|
|
443
442
|
* Resolve local registration for a transcript session. Never asks, never
|
|
444
|
-
* creates a project.
|
|
443
|
+
* creates a project. Shares reconcile's company precedence and conflict rules.
|
|
445
444
|
*/
|
|
446
445
|
export function resolveTranscriptRegistration(input) {
|
|
447
446
|
const now = input.now ?? (() => new Date());
|
|
@@ -449,33 +448,26 @@ export function resolveTranscriptRegistration(input) {
|
|
|
449
448
|
const remoteOwnerSlug = input.cwd
|
|
450
449
|
? deriveRemoteOwnerSlug({ cwd: input.cwd, hqRoot: input.hqRoot })
|
|
451
450
|
: null;
|
|
452
|
-
const
|
|
451
|
+
const resolution = resolveCompany({
|
|
452
|
+
root: input.workContextRoot,
|
|
453
|
+
sessionId: input.sessionId,
|
|
454
|
+
env: input.env,
|
|
453
455
|
cwd: input.cwd,
|
|
454
456
|
hqRoot: input.hqRoot,
|
|
455
457
|
remoteOwnerSlug,
|
|
456
458
|
});
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
let contextStatus;
|
|
470
|
-
if (!companySlug) {
|
|
471
|
-
contextStatus = "needs_company";
|
|
472
|
-
}
|
|
473
|
-
else if (projectId) {
|
|
474
|
-
contextStatus = "needs_task";
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
contextStatus = "unresolved";
|
|
478
|
-
}
|
|
459
|
+
const company = resolution.status === "resolved" ? resolution.company : undefined;
|
|
460
|
+
const companySlug = company?.slug;
|
|
461
|
+
const companyUid = company?.uid;
|
|
462
|
+
// A cwd project belongs to its cwd company, never to a higher-precedence
|
|
463
|
+
// identity or explicit scope naming another (or not-yet-mapped) company.
|
|
464
|
+
const cwdCompany = companySlugFromCwd(input.cwd, input.hqRoot);
|
|
465
|
+
const projectId = companySlug && cwdCompany?.toLowerCase() === companySlug.toLowerCase()
|
|
466
|
+
? projectIdFromCwd(input.cwd, input.hqRoot)
|
|
467
|
+
: undefined;
|
|
468
|
+
const contextStatus = resolution.status === "company_conflict"
|
|
469
|
+
? "company_conflict"
|
|
470
|
+
: !company ? "needs_company" : projectId ? "needs_task" : "unresolved";
|
|
479
471
|
const state = {
|
|
480
472
|
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
481
473
|
sessionId: input.sessionId,
|
|
@@ -58,9 +58,11 @@ export interface CompanyResolveInput {
|
|
|
58
58
|
}
|
|
59
59
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
60
60
|
export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
61
|
+
export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.json";
|
|
61
62
|
/**
|
|
62
63
|
* Read `companyUid` from the agent identity file when
|
|
63
|
-
* `HQ_AGENT_IDENTITY_FILE` is set
|
|
64
|
+
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
65
|
+
* Missing/unreadable/malformed → undefined
|
|
64
66
|
* (never throws; never logs file contents).
|
|
65
67
|
*/
|
|
66
68
|
export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
|
|
@@ -26,6 +26,7 @@ export function companyCorrectionPath(sessionId) {
|
|
|
26
26
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
27
27
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
28
28
|
export const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
29
|
+
export const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.json";
|
|
29
30
|
function normalizeSlug(value) {
|
|
30
31
|
if (!value)
|
|
31
32
|
return undefined;
|
|
@@ -36,11 +37,14 @@ function normalizeSlug(value) {
|
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
39
|
* Read `companyUid` from the agent identity file when
|
|
39
|
-
* `HQ_AGENT_IDENTITY_FILE` is set
|
|
40
|
+
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
41
|
+
* Missing/unreadable/malformed → undefined
|
|
40
42
|
* (never throws; never logs file contents).
|
|
41
43
|
*/
|
|
42
44
|
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
43
|
-
|
|
45
|
+
// An explicit override (including empty/missing paths) never falls back to
|
|
46
|
+
// another identity. This prevents accidental attribution to a different box.
|
|
47
|
+
const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
|
|
44
48
|
if (!file)
|
|
45
49
|
return undefined;
|
|
46
50
|
try {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { DeliveryState } from "./contract.js";
|
|
6
6
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
7
|
/** Fields allowed on a durable outbox operation (privacy allowlist). */
|
|
8
|
-
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
8
|
+
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "harness", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
9
9
|
/** Base delay for outbox retry backoff (attempt 1 → 30s). */
|
|
10
10
|
export declare const OUTBOX_RETRY_BASE_MS = 30000;
|
|
11
11
|
/** Cap for outbox retry backoff (6 hours). */
|
|
@@ -20,6 +20,7 @@ export interface OutboxOperation {
|
|
|
20
20
|
operationId: string;
|
|
21
21
|
clientOperationId: string;
|
|
22
22
|
sessionId: string;
|
|
23
|
+
harness?: string;
|
|
23
24
|
companyUid?: string;
|
|
24
25
|
companySlug?: string;
|
|
25
26
|
projectId?: string;
|
|
@@ -41,6 +42,7 @@ export interface OutboxOperation {
|
|
|
41
42
|
export interface OutboxEnqueueInput {
|
|
42
43
|
clientOperationId: string;
|
|
43
44
|
sessionId: string;
|
|
45
|
+
harness?: string;
|
|
44
46
|
companyUid?: string;
|
|
45
47
|
companySlug?: string;
|
|
46
48
|
projectId?: string;
|
|
@@ -54,6 +56,7 @@ export declare function clearOutboxListCache(): void;
|
|
|
54
56
|
export declare function stableOperationId(clientOperationId: string, sessionId: string): string;
|
|
55
57
|
export declare function digestOperation(parts: {
|
|
56
58
|
sessionId: string;
|
|
59
|
+
harness?: string;
|
|
57
60
|
clientOperationId: string;
|
|
58
61
|
companyUid?: string;
|
|
59
62
|
companySlug?: string;
|
|
@@ -15,6 +15,7 @@ export const OUTBOX_ALLOWLIST = [
|
|
|
15
15
|
"operationId",
|
|
16
16
|
"clientOperationId",
|
|
17
17
|
"sessionId",
|
|
18
|
+
"harness",
|
|
18
19
|
"companyUid",
|
|
19
20
|
"companySlug",
|
|
20
21
|
"projectId",
|
|
@@ -59,6 +60,7 @@ export function digestOperation(parts) {
|
|
|
59
60
|
const canonical = JSON.stringify({
|
|
60
61
|
v: 1,
|
|
61
62
|
sessionId: parts.sessionId,
|
|
63
|
+
...(parts.harness ? { harness: parts.harness } : {}),
|
|
62
64
|
clientOperationId: parts.clientOperationId,
|
|
63
65
|
companyUid: parts.companyUid ?? null,
|
|
64
66
|
companySlug: parts.companySlug ?? null,
|
|
@@ -83,6 +85,8 @@ function projectOutbox(op) {
|
|
|
83
85
|
updatedAt: op.updatedAt,
|
|
84
86
|
attemptCount: op.attemptCount,
|
|
85
87
|
};
|
|
88
|
+
if (op.harness)
|
|
89
|
+
out.harness = op.harness;
|
|
86
90
|
if (op.companyUid)
|
|
87
91
|
out.companyUid = op.companyUid;
|
|
88
92
|
if (op.companySlug)
|
|
@@ -132,6 +136,7 @@ export function enqueueOutbox(input, root) {
|
|
|
132
136
|
}
|
|
133
137
|
const digest = digestOperation({
|
|
134
138
|
sessionId: input.sessionId,
|
|
139
|
+
harness: input.harness,
|
|
135
140
|
clientOperationId: input.clientOperationId,
|
|
136
141
|
companyUid: input.companyUid,
|
|
137
142
|
companySlug: input.companySlug,
|
|
@@ -144,7 +149,14 @@ export function enqueueOutbox(input, root) {
|
|
|
144
149
|
const now = (input.now ?? (() => new Date()))().toISOString();
|
|
145
150
|
const existing = readOutboxOperation(operationId, root);
|
|
146
151
|
if (existing) {
|
|
147
|
-
|
|
152
|
+
// A pre-upgrade operation has no harness in its digest. Preserve its
|
|
153
|
+
// original receipt identity when the same observation is enqueued again.
|
|
154
|
+
const legacyReplay = !existing.harness && input.harness && existing.digest === digestOperation({
|
|
155
|
+
...input,
|
|
156
|
+
harness: undefined,
|
|
157
|
+
kind,
|
|
158
|
+
});
|
|
159
|
+
if (existing.digest !== digest && !legacyReplay) {
|
|
148
160
|
throw new NotTrackingError(`Idempotency conflict for ${operationId}`, "IdempotencyConflictError");
|
|
149
161
|
}
|
|
150
162
|
return existing;
|
|
@@ -154,6 +166,7 @@ export function enqueueOutbox(input, root) {
|
|
|
154
166
|
operationId,
|
|
155
167
|
clientOperationId: input.clientOperationId,
|
|
156
168
|
sessionId: input.sessionId,
|
|
169
|
+
harness: input.harness,
|
|
157
170
|
companyUid: input.companyUid,
|
|
158
171
|
companySlug: input.companySlug,
|
|
159
172
|
projectId: input.projectId,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import { InvalidSessionIdentityError, normalizeSessionIdentity, } from "../mesh/live/session-identity.js";
|
|
7
|
-
import { companyCorrectionPath, resolveCompany, } from "./company.js";
|
|
7
|
+
import { companyCorrectionPath, companySlugFromCwd, resolveCompany, } from "./company.js";
|
|
8
8
|
import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
9
9
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
@@ -385,7 +385,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
// Company resolved — resolve project/task (US-007B order).
|
|
388
|
-
|
|
388
|
+
let projectResolved = resolveProjectTask({
|
|
389
389
|
sessionId,
|
|
390
390
|
env,
|
|
391
391
|
trusted,
|
|
@@ -393,6 +393,14 @@ export async function reconcileObservation(obs, deps) {
|
|
|
393
393
|
hqRoot,
|
|
394
394
|
existingState: prior,
|
|
395
395
|
});
|
|
396
|
+
// Deterministic cwd projects belong to the cwd company. A higher-precedence
|
|
397
|
+
// identity UID must not adopt a project from an unrelated/unverified slug.
|
|
398
|
+
if (projectResolved?.source === "deterministic_cwd") {
|
|
399
|
+
const cwdCompany = companySlugFromCwd(cwd, hqRoot);
|
|
400
|
+
if (!resolvedCompany.slug || cwdCompany?.toLowerCase() !== resolvedCompany.slug.toLowerCase()) {
|
|
401
|
+
projectResolved = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
396
404
|
const projectId = projectResolved?.projectId;
|
|
397
405
|
const taskId = projectResolved?.taskId;
|
|
398
406
|
// Trusted / deterministic auto-bind never asks.
|
|
@@ -475,6 +483,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
475
483
|
outboxOp = enqueueOutbox({
|
|
476
484
|
clientOperationId: obs.clientOperationId,
|
|
477
485
|
sessionId,
|
|
486
|
+
harness: obs.identity.harness,
|
|
478
487
|
companyUid: resolvedCompany.uid,
|
|
479
488
|
companySlug: resolvedCompany.slug,
|
|
480
489
|
projectId,
|