@indigoai-us/hq-cli 5.145.0 → 5.146.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 +11 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/mesh.js +115 -1
- package/dist/commands/report.d.ts +6 -0
- package/dist/commands/report.js +23 -5
- package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/credentials.js +141 -10
- package/dist/lib/mesh/live/daemon/doctor.js +4 -3
- package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/presence.js +69 -1
- package/dist/lib/mesh/live/daemon/run.js +3 -1
- package/dist/lib/mesh/project-board.d.ts +14 -0
- package/dist/lib/mesh/project-board.js +24 -1
- package/dist/lib/work-context/bind-best.d.ts +2 -0
- package/dist/lib/work-context/bind-best.js +34 -1
- package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
- package/dist/lib/work-context/bind-calling-session.js +63 -0
- package/dist/lib/work-context/prd-sync.d.ts +34 -0
- package/dist/lib/work-context/prd-sync.js +120 -0
- package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
- package/dist/lib/work-context/prompt-rebind.js +76 -0
- package/dist/lib/work-context/reconcile.d.ts +2 -0
- package/dist/lib/work-context/reconcile.js +31 -0
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.146.0] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The mesh daemon asks for presence in the device-default company and the companies with a session updated in the last day (at most five), and asks again within a minute when that set changes. `hq mesh doctor` says when a company was left out because presence is off or because one credential cannot cover every company.
|
|
10
|
+
- `hq mesh project ensure` closes a genesis thread only when that same call created it and register returns a different server `proj-` thread. An already-open project thread stays open on a re-run. The register route does not accept a client thread id.
|
|
11
|
+
- `hq mesh project ensure` on a new project closes the client genesis thread when register returns the server `proj-` thread, so the project has one open thread. The register route does not accept a client thread id.
|
|
12
|
+
- A session that registers or creates a project is bound to it. `hq mesh project ensure` and `hq mesh project set --create` bind the calling session when it is the same company and still `needs_project` or unresolved, and they leave a session that is already on a different project alone. The next prompt re-checks `needs_project` against the current catalog. `hq mesh context prd-sync --session <sid> --file <prd.json>` emits `task_status` when a bound session's story `passes` or `status` changes (`passes: true` is done, `in_progress` is in progress) and does not call the network when nothing changed.
|
|
13
|
+
- `hq report publish` now fills a report's missing company from its channel and keeps it across updates. Before, a report saved without a company was published under a personal access policy and the deploy refused it.
|
|
14
|
+
|
|
5
15
|
## [5.145.0] — 2026-09-23
|
|
6
16
|
|
|
7
17
|
- `hq files` now preserves a trailing-slash private-folder pattern such as
|
|
@@ -58,6 +68,7 @@
|
|
|
58
68
|
### Fixed
|
|
59
69
|
|
|
60
70
|
- `hq lanes monitor-check` now recognizes a live Claude Monitor task launched through the CLI's shell/node process wrapper, so active lanes are not reported as uncovered.
|
|
71
|
+
|
|
61
72
|
- A session hint now binds to the best existing project. An exact Board is used as-is. An exact local PRD gets a Board through the same genesis-thread, view, then register path as `project ensure`, and is bound. A single clear match is bound with a recorded reason. When nothing matches, or two candidates are close, the session stays `needs_project` and the start hook lists the candidates. `hq mesh context bind-project` applies the answer. A flush HTTP 404 downgrades that session, holds its events, and logs one line instead of dead-lettering them. `hq mesh doctor` lists `needs_project` sessions.
|
|
62
73
|
|
|
63
74
|
## [5.139.0] — 2026-09-23
|
|
@@ -5882,6 +5882,24 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5882
5882
|
readonly description: "Print machine-readable JSON";
|
|
5883
5883
|
}];
|
|
5884
5884
|
readonly subcommands: readonly [];
|
|
5885
|
+
}, {
|
|
5886
|
+
readonly name: "prd-sync";
|
|
5887
|
+
readonly description: "Emit task_status for PRD story passes/status changes on a bound session";
|
|
5888
|
+
readonly aliases: readonly [];
|
|
5889
|
+
readonly hidden: false;
|
|
5890
|
+
readonly usage: "[options]";
|
|
5891
|
+
readonly arguments: readonly [];
|
|
5892
|
+
readonly options: readonly [{
|
|
5893
|
+
readonly flags: "--session <sid>";
|
|
5894
|
+
readonly description: "Canonical session id";
|
|
5895
|
+
}, {
|
|
5896
|
+
readonly flags: "--file <path>";
|
|
5897
|
+
readonly description: "prd.json written by the bound session";
|
|
5898
|
+
}, {
|
|
5899
|
+
readonly flags: "--json";
|
|
5900
|
+
readonly description: "Print machine-readable JSON";
|
|
5901
|
+
}];
|
|
5902
|
+
readonly subcommands: readonly [];
|
|
5885
5903
|
}, {
|
|
5886
5904
|
readonly name: "organize";
|
|
5887
5905
|
readonly description: "List or submit one-time project/task decisions (US-007B / US-005B)";
|
|
@@ -7593,6 +7593,29 @@ export const COMMAND_CATALOG = [
|
|
|
7593
7593
|
],
|
|
7594
7594
|
"subcommands": []
|
|
7595
7595
|
},
|
|
7596
|
+
{
|
|
7597
|
+
"name": "prd-sync",
|
|
7598
|
+
"description": "Emit task_status for PRD story passes/status changes on a bound session",
|
|
7599
|
+
"aliases": [],
|
|
7600
|
+
"hidden": false,
|
|
7601
|
+
"usage": "[options]",
|
|
7602
|
+
"arguments": [],
|
|
7603
|
+
"options": [
|
|
7604
|
+
{
|
|
7605
|
+
"flags": "--session <sid>",
|
|
7606
|
+
"description": "Canonical session id"
|
|
7607
|
+
},
|
|
7608
|
+
{
|
|
7609
|
+
"flags": "--file <path>",
|
|
7610
|
+
"description": "prd.json written by the bound session"
|
|
7611
|
+
},
|
|
7612
|
+
{
|
|
7613
|
+
"flags": "--json",
|
|
7614
|
+
"description": "Print machine-readable JSON"
|
|
7615
|
+
}
|
|
7616
|
+
],
|
|
7617
|
+
"subcommands": []
|
|
7618
|
+
},
|
|
7596
7619
|
{
|
|
7597
7620
|
"name": "organize",
|
|
7598
7621
|
"description": "List or submit one-time project/task decisions (US-007B / US-005B)",
|
package/dist/commands/mesh.js
CHANGED
|
@@ -12,7 +12,7 @@ import chalk from "chalk";
|
|
|
12
12
|
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
13
13
|
import * as readline from "node:readline/promises";
|
|
14
14
|
import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
15
|
-
import { ensureProjectBoard } from "../lib/mesh/project-board.js";
|
|
15
|
+
import { closeSupersededProjectThread, ensureProjectBoard } from "../lib/mesh/project-board.js";
|
|
16
16
|
import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
|
|
17
17
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
18
18
|
import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
@@ -39,6 +39,9 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
|
|
|
39
39
|
import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
|
|
40
40
|
import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
|
|
41
41
|
import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
|
|
42
|
+
import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
|
|
43
|
+
import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
|
|
44
|
+
import { planPrdSync } from "../lib/work-context/prd-sync.js";
|
|
42
45
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
|
|
43
46
|
import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
|
|
44
47
|
import { resolveCompany } from "../lib/work-context/company.js";
|
|
@@ -388,6 +391,9 @@ async function runProjectSet(projectId, opts) {
|
|
|
388
391
|
throw err;
|
|
389
392
|
}
|
|
390
393
|
}
|
|
394
|
+
if (!current && opts.create) {
|
|
395
|
+
noteCallingSessionBound(view.projectId, company);
|
|
396
|
+
}
|
|
391
397
|
if (opts.json) {
|
|
392
398
|
console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
|
|
393
399
|
return;
|
|
@@ -488,6 +494,13 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
488
494
|
}
|
|
489
495
|
}
|
|
490
496
|
registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
497
|
+
await closeSupersededProjectThread({
|
|
498
|
+
token,
|
|
499
|
+
companyUid: company.companyUid,
|
|
500
|
+
openedThreadId: genesis.threadId,
|
|
501
|
+
canonicalThreadId: registration.threadId,
|
|
502
|
+
created: genesis.created,
|
|
503
|
+
});
|
|
491
504
|
if (!registration.threadId || !registration.channelId) {
|
|
492
505
|
fail("Work mesh registration did not return a threadId and channelId.");
|
|
493
506
|
}
|
|
@@ -519,12 +532,91 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
519
532
|
version: verified.version,
|
|
520
533
|
stories: verified.stories,
|
|
521
534
|
};
|
|
535
|
+
noteCallingSessionBound(payload.projectId, company);
|
|
522
536
|
if (opts.json) {
|
|
523
537
|
console.log(JSON.stringify(payload));
|
|
524
538
|
return;
|
|
525
539
|
}
|
|
526
540
|
console.log(`Work mesh: project ${payload.projectId} ensured (thread ${payload.threadId}, channel ${payload.channelId}, ${payload.stories.length} stories)`);
|
|
527
541
|
}
|
|
542
|
+
async function runPrdSync(opts) {
|
|
543
|
+
const sessionId = opts.session.trim();
|
|
544
|
+
const file = opts.file.trim();
|
|
545
|
+
if (!sessionId || !file)
|
|
546
|
+
fail("Usage: hq mesh context prd-sync --session <sid> --file <prd.json>");
|
|
547
|
+
const root = workContextHomeRoot();
|
|
548
|
+
const plan = planPrdSync({ sessionId, file, root });
|
|
549
|
+
if (!plan.changed) {
|
|
550
|
+
const payload = { ok: true, action: "context.prd-sync", changed: 0, ...(plan.skip ? { skip: plan.skip } : {}) };
|
|
551
|
+
if (opts.json)
|
|
552
|
+
console.log(JSON.stringify(payload));
|
|
553
|
+
else
|
|
554
|
+
console.log(plan.skip ? `Work mesh: prd-sync skipped (${plan.skip})` : "Work mesh: prd-sync unchanged");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
let token;
|
|
558
|
+
try {
|
|
559
|
+
token = await requireToken();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
token = null;
|
|
563
|
+
}
|
|
564
|
+
if (!token) {
|
|
565
|
+
console.error("work-mesh: prd-sync has changes but no token; snapshot not advanced");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const events = plan.events.map((event, index) => buildMeshEmitEvent({
|
|
569
|
+
eventId: generateUlid(Date.now() + index),
|
|
570
|
+
kind: "task_status",
|
|
571
|
+
sessionId,
|
|
572
|
+
harness: "hq-sessions",
|
|
573
|
+
adapterVersion: "1.0.0",
|
|
574
|
+
at: new Date().toISOString(),
|
|
575
|
+
seq: plan.seqStart + index + 1,
|
|
576
|
+
source: "hooks",
|
|
577
|
+
taskId: event.taskId,
|
|
578
|
+
status: event.status,
|
|
579
|
+
summary: event.status === "done" ? `${event.taskId} passes` : `${event.taskId} in progress`,
|
|
580
|
+
}));
|
|
581
|
+
const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
|
|
582
|
+
await emitEvents({
|
|
583
|
+
workMeshRoot: workMeshHomeRoot(),
|
|
584
|
+
poster,
|
|
585
|
+
newEvents: events,
|
|
586
|
+
});
|
|
587
|
+
plan.commit();
|
|
588
|
+
if (plan.taskId) {
|
|
589
|
+
updateSessionState(sessionId, root, (current) => {
|
|
590
|
+
if (!current)
|
|
591
|
+
return null;
|
|
592
|
+
return {
|
|
593
|
+
...current,
|
|
594
|
+
taskId: plan.taskId,
|
|
595
|
+
contextStatus: current.projectId ? "bound" : current.contextStatus,
|
|
596
|
+
updatedAt: new Date().toISOString(),
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (opts.json) {
|
|
601
|
+
console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: plan.events.length, taskId: plan.taskId }));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
console.log(`Work mesh: prd-sync emitted ${plan.events.length} task_status`);
|
|
605
|
+
}
|
|
606
|
+
function noteCallingSessionBound(projectId, company) {
|
|
607
|
+
try {
|
|
608
|
+
const bound = bindCallingSession({
|
|
609
|
+
root: workContextHomeRoot(),
|
|
610
|
+
projectId,
|
|
611
|
+
company,
|
|
612
|
+
});
|
|
613
|
+
if (bound.bound)
|
|
614
|
+
console.error(formatSessionBoundLine(bound.sessionId, bound.projectId));
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
/* session bind is local and must not fail project ensure */
|
|
618
|
+
}
|
|
619
|
+
}
|
|
528
620
|
async function runDoctor(opts) {
|
|
529
621
|
if (opts.apply) {
|
|
530
622
|
fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
|
|
@@ -1327,6 +1419,21 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
1327
1419
|
const sessionId = resolveEnqueueSessionId(opts.sessionId, process.env);
|
|
1328
1420
|
if (!sessionId)
|
|
1329
1421
|
fail("sessionId required (--session-id or HQ_SESSION_ID)");
|
|
1422
|
+
if (kind === "turn_start") {
|
|
1423
|
+
try {
|
|
1424
|
+
rebindNeedsProjectOnPrompt({
|
|
1425
|
+
sessionId,
|
|
1426
|
+
root: workContextHomeRoot(),
|
|
1427
|
+
hqRoot: opts.hqRoot?.trim() || process.env.HQ_ROOT,
|
|
1428
|
+
cwd: opts.cwd,
|
|
1429
|
+
companySlug: opts.companySlug,
|
|
1430
|
+
env: process.env,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
catch {
|
|
1434
|
+
/* prompt rebind is fail-soft */
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1330
1437
|
const evidence = buildEmitEvidence({
|
|
1331
1438
|
sessionId: sessionId,
|
|
1332
1439
|
hqRoot: opts.hqRoot,
|
|
@@ -1674,6 +1781,13 @@ export function registerMeshCommand(program) {
|
|
|
1674
1781
|
.option("--company <slug|uid>", "Company override when creating a Board")
|
|
1675
1782
|
.option("--json", "Print machine-readable JSON")
|
|
1676
1783
|
.action((target, slug, opts) => wrap(() => runBindProject(target, { ...opts, slug }))());
|
|
1784
|
+
context
|
|
1785
|
+
.command("prd-sync")
|
|
1786
|
+
.description("Emit task_status for PRD story passes/status changes on a bound session")
|
|
1787
|
+
.requiredOption("--session <sid>", "Canonical session id")
|
|
1788
|
+
.requiredOption("--file <path>", "prd.json written by the bound session")
|
|
1789
|
+
.option("--json", "Print machine-readable JSON")
|
|
1790
|
+
.action((opts) => wrap(() => runPrdSync(opts))());
|
|
1677
1791
|
context
|
|
1678
1792
|
.command("organize")
|
|
1679
1793
|
.description("List or submit one-time project/task decisions (US-007B / US-005B)")
|
|
@@ -8,6 +8,12 @@ type ChannelRow = {
|
|
|
8
8
|
type ReportPatchInput = ReportPatch | ((current: ChannelReport) => ReportPatch);
|
|
9
9
|
/** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
|
|
10
10
|
export declare function isCompanyReportChannel(channel: ChannelRow): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Records created before hq-pro stamped companyUid carry null. The channel the
|
|
13
|
+
* caller can see knows its company, and publish needs it to pick the deploy
|
|
14
|
+
* org, so fill it from there rather than failing every publish.
|
|
15
|
+
*/
|
|
16
|
+
export declare function withChannelCompany(report: ChannelReport, channels: ChannelRow[]): ChannelReport;
|
|
11
17
|
/** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
|
|
12
18
|
export declare function resolveReport(token: string, reportId: string, requestedChannelId?: string): Promise<ChannelReport>;
|
|
13
19
|
export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatchInput): Promise<ChannelReport>;
|
package/dist/commands/report.js
CHANGED
|
@@ -117,6 +117,17 @@ async function readReportInChannel(token, channelId, reportId) {
|
|
|
117
117
|
throw new ReportPublishError("Report response was incomplete.");
|
|
118
118
|
return body.report;
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Records created before hq-pro stamped companyUid carry null. The channel the
|
|
122
|
+
* caller can see knows its company, and publish needs it to pick the deploy
|
|
123
|
+
* org, so fill it from there rather than failing every publish.
|
|
124
|
+
*/
|
|
125
|
+
export function withChannelCompany(report, channels) {
|
|
126
|
+
if (report.companyUid)
|
|
127
|
+
return report;
|
|
128
|
+
const companyUid = channels.find((row) => row.channelId === report.channelId)?.companyUid;
|
|
129
|
+
return companyUid ? { ...report, companyUid } : report;
|
|
130
|
+
}
|
|
120
131
|
/** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
|
|
121
132
|
export async function resolveReport(token, reportId, requestedChannelId) {
|
|
122
133
|
const explicit = requestedChannelId ?? process.env.HQ_REPORT_CHANNEL_ID;
|
|
@@ -129,14 +140,14 @@ export async function resolveReport(token, reportId, requestedChannelId) {
|
|
|
129
140
|
const body = await res.json();
|
|
130
141
|
if (!body.report)
|
|
131
142
|
throw new ReportPublishError("Report response was incomplete.");
|
|
132
|
-
return body.report;
|
|
143
|
+
return withChannelCompany(body.report, channels);
|
|
133
144
|
}
|
|
134
145
|
const recordedChannel = locallyRecordedChannel(reportId);
|
|
135
146
|
if (recordedChannel) {
|
|
136
147
|
requireCompanyReportChannel(channels, recordedChannel);
|
|
137
148
|
const recorded = await readReportInChannel(token, recordedChannel, reportId);
|
|
138
149
|
if (recorded)
|
|
139
|
-
return recorded;
|
|
150
|
+
return withChannelCompany(recorded, channels);
|
|
140
151
|
}
|
|
141
152
|
// The report API is intentionally channel-scoped. Discover only among the
|
|
142
153
|
// caller's existing channels, using list endpoints so missing reports never
|
|
@@ -157,13 +168,13 @@ export async function resolveReport(token, reportId, requestedChannelId) {
|
|
|
157
168
|
const reports = Array.isArray(body.reports) ? body.reports : [];
|
|
158
169
|
const match = reports.find((report) => report.reportId === reportId);
|
|
159
170
|
if (match)
|
|
160
|
-
return match;
|
|
171
|
+
return withChannelCompany(match, channels);
|
|
161
172
|
}
|
|
162
173
|
throw new ReportPublishError(notFoundMessage(scannedChannels));
|
|
163
174
|
}
|
|
164
175
|
export async function patchReport(token, report, patch) {
|
|
165
176
|
if (!report.companyUid)
|
|
166
|
-
throw new ReportPublishError("Channel reports require a company channel.");
|
|
177
|
+
throw new ReportPublishError("Channel reports require a company channel.", "source-invalid");
|
|
167
178
|
const patchOnce = (current) => vaultApiFetch({
|
|
168
179
|
token,
|
|
169
180
|
path: reportPath(current.channelId, current.reportId),
|
|
@@ -279,7 +290,14 @@ export async function deployReportStatic(token, input) {
|
|
|
279
290
|
}
|
|
280
291
|
async function publishWithOrg(token, report, directory, draft) {
|
|
281
292
|
const published = await publishReport(report, directory, draft, {
|
|
282
|
-
|
|
293
|
+
// The server echoes the stored record, whose companyUid may still be null
|
|
294
|
+
// on reports created before hq-pro stamped it. Keep the company resolved
|
|
295
|
+
// from the channel so the access policy and deploy org stay correct.
|
|
296
|
+
patch: async (current, patch) => {
|
|
297
|
+
const next = await patchReport(token, current, patch);
|
|
298
|
+
const companyUid = next.companyUid ?? current.companyUid ?? report.companyUid;
|
|
299
|
+
return companyUid ? { ...next, companyUid } : next;
|
|
300
|
+
},
|
|
283
301
|
deploy: async (input) => {
|
|
284
302
|
const org = await orgFor(token, report);
|
|
285
303
|
return deployReportStatic(token, { ...input, org });
|
|
@@ -18,6 +18,12 @@ export declare const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
|
|
|
18
18
|
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
19
19
|
/** Cap any single retry so a bogus Retry-After cannot park the daemon. */
|
|
20
20
|
export declare const MAX_RETRY_DELAY_MS: number;
|
|
21
|
+
/** Contract-3 vend accepts at most this many preferred company uids. */
|
|
22
|
+
export declare const PREFERRED_COMPANY_LIMIT = 5;
|
|
23
|
+
/** Session files older than this do not count as companies in use. */
|
|
24
|
+
export declare const PREFERRED_SESSION_WINDOW_MS: number;
|
|
25
|
+
/** Preferred-set changes re-vend at most this often. */
|
|
26
|
+
export declare const PREFERRED_REVEND_MIN_MS = 60000;
|
|
21
27
|
export type CredentialVendFailureKind = "refused" | "transient";
|
|
22
28
|
/**
|
|
23
29
|
* Typed vend failure. `refused` = server will not vend right now
|
|
@@ -51,6 +57,8 @@ export interface PresenceCompany {
|
|
|
51
57
|
}
|
|
52
58
|
export interface DroppedPresenceCompany {
|
|
53
59
|
companyUid: string;
|
|
60
|
+
/** Short name for doctor text when the server sent one. */
|
|
61
|
+
label?: string;
|
|
54
62
|
/** Server-supplied cause when available; otherwise a deliberately neutral label. */
|
|
55
63
|
reason: string;
|
|
56
64
|
}
|
|
@@ -98,6 +106,30 @@ export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
|
|
|
98
106
|
* No identity file → return the bundle unchanged (person laptop).
|
|
99
107
|
*/
|
|
100
108
|
export declare function scopeBundleToAgentIdentity(bundle: Contract3Bundle, env?: NodeJS.ProcessEnv): Contract3Bundle;
|
|
109
|
+
/**
|
|
110
|
+
* Device default first, then distinct company uids from session files updated
|
|
111
|
+
* in the last 24 hours, newest first, max 5. Any read error returns undefined
|
|
112
|
+
* so the vend body omits the field.
|
|
113
|
+
*/
|
|
114
|
+
export declare function readPreferredCompanyUids(input: {
|
|
115
|
+
workContextRoot: string;
|
|
116
|
+
nowMs?: number;
|
|
117
|
+
}): string[] | undefined;
|
|
118
|
+
export declare function preferredCompanyKey(uids: readonly string[] | undefined): string;
|
|
119
|
+
/** True when a new preferred set should force a vend, at most once per minute. */
|
|
120
|
+
export declare function shouldRevendPreferredCompanies(input: {
|
|
121
|
+
previous: readonly string[] | undefined;
|
|
122
|
+
next: readonly string[] | undefined;
|
|
123
|
+
lastVendMs: number;
|
|
124
|
+
nowMs: number;
|
|
125
|
+
minIntervalMs?: number;
|
|
126
|
+
}): boolean;
|
|
127
|
+
export declare function contract3VendBody(preferredCompanyUids: readonly string[] | undefined): {
|
|
128
|
+
contractVersion: 3;
|
|
129
|
+
preferredCompanyUids?: string[];
|
|
130
|
+
};
|
|
131
|
+
/** Plain doctor line for one company the vend left out. */
|
|
132
|
+
export declare function formatDroppedPresenceCompany(company: DroppedPresenceCompany): string;
|
|
101
133
|
export type CredentialVendPostResult = {
|
|
102
134
|
status: number;
|
|
103
135
|
body: unknown;
|
|
@@ -110,6 +142,8 @@ export declare function createContract3Fetcher(opts: {
|
|
|
110
142
|
post?: (path: string, body: unknown) => Promise<CredentialVendPostResult>;
|
|
111
143
|
/** Process env for identity-file scoping (tests). */
|
|
112
144
|
env?: NodeJS.ProcessEnv;
|
|
145
|
+
/** Companies to ask for. Undefined (read error) omits the field. */
|
|
146
|
+
readPreferredCompanyUids?: () => string[] | undefined;
|
|
113
147
|
}): CredentialsFetcher;
|
|
114
148
|
export interface CredentialRenewalErrorInfo {
|
|
115
149
|
/** Absolute ISO timestamp when the renewal retry is scheduled to fire. */
|
|
@@ -130,8 +164,11 @@ export declare class CredentialRenewalManager {
|
|
|
130
164
|
private readonly random;
|
|
131
165
|
private handle;
|
|
132
166
|
private stopped;
|
|
167
|
+
private inflight;
|
|
133
168
|
constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown, info?: CredentialRenewalErrorInfo) => void, timers?: TimerHost, retryDelayMs?: number, refusedRetryMs?: number, random?: () => number);
|
|
134
169
|
schedule(bundle: Contract3Bundle): void;
|
|
170
|
+
/** Vend now (preferred companies changed). No-op while a vend is in flight. */
|
|
171
|
+
renewNow(): void;
|
|
135
172
|
private renew;
|
|
136
173
|
stop(): void;
|
|
137
174
|
private clear;
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Contract-3 realtime credential vend + 80%-of-lifetime renewal.
|
|
3
3
|
* Never logs or persists credential material.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
5
7
|
import { DEFAULT_VAULT_API_URL } from "../../../../utils/cognito-session.js";
|
|
6
8
|
import { vaultApiFetch } from "../../../../utils/vault-api.js";
|
|
7
9
|
import { readAgentIdentityCompanyUid } from "../../../work-context/company.js";
|
|
10
|
+
import { readDeviceConfig } from "../../../work-context/config.js";
|
|
11
|
+
import { workContextSessionsDir } from "../../../work-context/paths.js";
|
|
8
12
|
export const REALTIME_CREDENTIALS_PATH = "/v1/realtime/credentials";
|
|
9
13
|
export const MQTT_KEEPALIVE_SECONDS = 15;
|
|
10
14
|
/** Renew at 80% of credential lifetime. */
|
|
@@ -20,6 +24,12 @@ export const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
|
|
|
20
24
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
21
25
|
/** Cap any single retry so a bogus Retry-After cannot park the daemon. */
|
|
22
26
|
export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/** Contract-3 vend accepts at most this many preferred company uids. */
|
|
28
|
+
export const PREFERRED_COMPANY_LIMIT = 5;
|
|
29
|
+
/** Session files older than this do not count as companies in use. */
|
|
30
|
+
export const PREFERRED_SESSION_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
/** Preferred-set changes re-vend at most this often. */
|
|
32
|
+
export const PREFERRED_REVEND_MIN_MS = 60_000;
|
|
23
33
|
/**
|
|
24
34
|
* Typed vend failure. `refused` = server will not vend right now
|
|
25
35
|
* (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
|
|
@@ -241,8 +251,14 @@ export function normalizeContract3Bundle(raw) {
|
|
|
241
251
|
const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
|
|
242
252
|
if (!companyUid)
|
|
243
253
|
continue;
|
|
254
|
+
const slug = typeof row.companySlug === "string"
|
|
255
|
+
? row.companySlug.trim()
|
|
256
|
+
: typeof row.slug === "string"
|
|
257
|
+
? row.slug.trim()
|
|
258
|
+
: "";
|
|
244
259
|
droppedCompanies.push({
|
|
245
260
|
companyUid,
|
|
261
|
+
...(slug ? { label: slug } : {}),
|
|
246
262
|
reason: typeof row.reason === "string" && row.reason.trim()
|
|
247
263
|
? row.reason.trim()
|
|
248
264
|
: "not included",
|
|
@@ -282,6 +298,101 @@ export function scopeBundleToAgentIdentity(bundle, env = process.env) {
|
|
|
282
298
|
}
|
|
283
299
|
return { ...bundle, companies: matched };
|
|
284
300
|
}
|
|
301
|
+
function pushDistinct(out, seen, uid) {
|
|
302
|
+
const trimmed = uid?.trim();
|
|
303
|
+
if (!trimmed || seen.has(trimmed) || out.length >= PREFERRED_COMPANY_LIMIT)
|
|
304
|
+
return;
|
|
305
|
+
seen.add(trimmed);
|
|
306
|
+
out.push(trimmed);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Device default first, then distinct company uids from session files updated
|
|
310
|
+
* in the last 24 hours, newest first, max 5. Any read error returns undefined
|
|
311
|
+
* so the vend body omits the field.
|
|
312
|
+
*/
|
|
313
|
+
export function readPreferredCompanyUids(input) {
|
|
314
|
+
try {
|
|
315
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
316
|
+
const config = readDeviceConfig({ root: input.workContextRoot });
|
|
317
|
+
const out = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
if (config.defaultCompany?.enabled && config.defaultCompany.uid) {
|
|
320
|
+
pushDistinct(out, seen, config.defaultCompany.uid);
|
|
321
|
+
}
|
|
322
|
+
const dir = workContextSessionsDir(input.workContextRoot);
|
|
323
|
+
let names = [];
|
|
324
|
+
try {
|
|
325
|
+
names = fs.readdirSync(dir);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
const code = err.code;
|
|
329
|
+
if (code !== "ENOENT")
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
const recent = [];
|
|
333
|
+
for (const name of names) {
|
|
334
|
+
if (!name.endsWith(".json"))
|
|
335
|
+
continue;
|
|
336
|
+
const full = path.join(dir, name);
|
|
337
|
+
const stat = fs.statSync(full);
|
|
338
|
+
if (!stat.isFile())
|
|
339
|
+
continue;
|
|
340
|
+
const raw = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
341
|
+
const parsedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : Number.NaN;
|
|
342
|
+
const at = Number.isFinite(parsedAt) ? parsedAt : stat.mtimeMs;
|
|
343
|
+
if (!Number.isFinite(at) || nowMs - at > PREFERRED_SESSION_WINDOW_MS)
|
|
344
|
+
continue;
|
|
345
|
+
if (nowMs < at)
|
|
346
|
+
continue;
|
|
347
|
+
if (typeof raw.companyUid !== "string")
|
|
348
|
+
continue;
|
|
349
|
+
recent.push({ uid: raw.companyUid, at });
|
|
350
|
+
}
|
|
351
|
+
recent.sort((a, b) => b.at - a.at || a.uid.localeCompare(b.uid));
|
|
352
|
+
for (const row of recent)
|
|
353
|
+
pushDistinct(out, seen, row.uid);
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export function preferredCompanyKey(uids) {
|
|
361
|
+
return (uids ?? []).join("\n");
|
|
362
|
+
}
|
|
363
|
+
/** True when a new preferred set should force a vend, at most once per minute. */
|
|
364
|
+
export function shouldRevendPreferredCompanies(input) {
|
|
365
|
+
if (!input.next)
|
|
366
|
+
return false;
|
|
367
|
+
if (preferredCompanyKey(input.previous) === preferredCompanyKey(input.next)) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const min = input.minIntervalMs ?? PREFERRED_REVEND_MIN_MS;
|
|
371
|
+
return input.nowMs - input.lastVendMs >= min;
|
|
372
|
+
}
|
|
373
|
+
export function contract3VendBody(preferredCompanyUids) {
|
|
374
|
+
const body = {
|
|
375
|
+
contractVersion: 3,
|
|
376
|
+
};
|
|
377
|
+
if (preferredCompanyUids && preferredCompanyUids.length > 0) {
|
|
378
|
+
body.preferredCompanyUids = preferredCompanyUids.slice(0, PREFERRED_COMPANY_LIMIT);
|
|
379
|
+
}
|
|
380
|
+
return body;
|
|
381
|
+
}
|
|
382
|
+
/** Plain doctor line for one company the vend left out. */
|
|
383
|
+
export function formatDroppedPresenceCompany(company) {
|
|
384
|
+
const name = company.label?.trim() || company.companyUid;
|
|
385
|
+
if (company.reason === "gate_disabled") {
|
|
386
|
+
return `${name}: not included (presence is turned off for this company)`;
|
|
387
|
+
}
|
|
388
|
+
if (company.reason === "policy_budget") {
|
|
389
|
+
return `${name}: not included (too many companies for one credential)`;
|
|
390
|
+
}
|
|
391
|
+
if (!company.reason || company.reason === "unknown" || company.reason === "not included") {
|
|
392
|
+
return `${name}: not included`;
|
|
393
|
+
}
|
|
394
|
+
return `${name}: not included (${company.reason})`;
|
|
395
|
+
}
|
|
285
396
|
function headerGet(headers, name) {
|
|
286
397
|
if (!headers)
|
|
287
398
|
return null;
|
|
@@ -294,11 +405,19 @@ function headerGet(headers, name) {
|
|
|
294
405
|
}
|
|
295
406
|
export function createContract3Fetcher(opts) {
|
|
296
407
|
return async () => {
|
|
408
|
+
let preferred;
|
|
409
|
+
if (opts.readPreferredCompanyUids) {
|
|
410
|
+
try {
|
|
411
|
+
preferred = opts.readPreferredCompanyUids();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
preferred = undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const body = contract3VendBody(preferred);
|
|
297
418
|
let bundle;
|
|
298
419
|
if (opts.post) {
|
|
299
|
-
const res = await opts.post(REALTIME_CREDENTIALS_PATH,
|
|
300
|
-
contractVersion: 3,
|
|
301
|
-
});
|
|
420
|
+
const res = await opts.post(REALTIME_CREDENTIALS_PATH, body);
|
|
302
421
|
if (res.status < 200 || res.status >= 300) {
|
|
303
422
|
throw classifyCredentialVendFailure(res.status, res.body, headerGet(res.headers, "Retry-After"));
|
|
304
423
|
}
|
|
@@ -309,23 +428,23 @@ export function createContract3Fetcher(opts) {
|
|
|
309
428
|
token: opts.token,
|
|
310
429
|
path: REALTIME_CREDENTIALS_PATH,
|
|
311
430
|
method: "POST",
|
|
312
|
-
body
|
|
431
|
+
body,
|
|
313
432
|
baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
|
|
314
433
|
});
|
|
315
434
|
const text = await res.text();
|
|
316
|
-
let
|
|
435
|
+
let parsed = {};
|
|
317
436
|
if (text) {
|
|
318
437
|
try {
|
|
319
|
-
|
|
438
|
+
parsed = JSON.parse(text);
|
|
320
439
|
}
|
|
321
440
|
catch {
|
|
322
|
-
|
|
441
|
+
parsed = { raw: text.slice(0, 200) };
|
|
323
442
|
}
|
|
324
443
|
}
|
|
325
444
|
if (!res.ok) {
|
|
326
|
-
throw classifyCredentialVendFailure(res.status,
|
|
445
|
+
throw classifyCredentialVendFailure(res.status, parsed, res.headers.get("Retry-After"));
|
|
327
446
|
}
|
|
328
|
-
bundle = normalizeContract3Bundle(
|
|
447
|
+
bundle = normalizeContract3Bundle(parsed);
|
|
329
448
|
}
|
|
330
449
|
return scopeBundleToAgentIdentity(bundle, opts.env ?? process.env);
|
|
331
450
|
};
|
|
@@ -344,6 +463,7 @@ export class CredentialRenewalManager {
|
|
|
344
463
|
random;
|
|
345
464
|
handle = null;
|
|
346
465
|
stopped = false;
|
|
466
|
+
inflight = false;
|
|
347
467
|
constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000, refusedRetryMs = DEFAULT_REFUSED_RETRY_MS, random = Math.random) {
|
|
348
468
|
this.fetchCredentials = fetchCredentials;
|
|
349
469
|
this.onRenewed = onRenewed;
|
|
@@ -360,9 +480,17 @@ export class CredentialRenewalManager {
|
|
|
360
480
|
const delay = renewalDelayMs(this.timers.now(), bundle.expiresAt);
|
|
361
481
|
this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
|
|
362
482
|
}
|
|
483
|
+
/** Vend now (preferred companies changed). No-op while a vend is in flight. */
|
|
484
|
+
renewNow() {
|
|
485
|
+
if (this.stopped || this.inflight)
|
|
486
|
+
return;
|
|
487
|
+
this.clear();
|
|
488
|
+
void this.renew();
|
|
489
|
+
}
|
|
363
490
|
async renew() {
|
|
364
|
-
if (this.stopped)
|
|
491
|
+
if (this.stopped || this.inflight)
|
|
365
492
|
return;
|
|
493
|
+
this.inflight = true;
|
|
366
494
|
try {
|
|
367
495
|
const bundle = await this.fetchCredentials();
|
|
368
496
|
if (this.stopped)
|
|
@@ -389,6 +517,9 @@ export class CredentialRenewalManager {
|
|
|
389
517
|
return;
|
|
390
518
|
this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
|
|
391
519
|
}
|
|
520
|
+
finally {
|
|
521
|
+
this.inflight = false;
|
|
522
|
+
}
|
|
392
523
|
}
|
|
393
524
|
stop() {
|
|
394
525
|
this.stopped = true;
|
|
@@ -9,6 +9,7 @@ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedToke
|
|
|
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";
|
|
12
|
+
import { formatDroppedPresenceCompany } from "./credentials.js";
|
|
12
13
|
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
13
14
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
14
15
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
@@ -239,9 +240,9 @@ export function formatDaemonDoctor(report) {
|
|
|
239
240
|
if (report.presenceDroppedCompanies.length > 0) {
|
|
240
241
|
lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
|
|
241
242
|
report.presenceDroppedCompanies
|
|
242
|
-
.map((company) =>
|
|
243
|
-
.join("
|
|
244
|
-
`; presence credential holds ${report.companiesOnline.length} companies
|
|
243
|
+
.map((company) => formatDroppedPresenceCompany(company))
|
|
244
|
+
.join("; ") +
|
|
245
|
+
`; presence credential holds ${report.companiesOnline.length} companies`);
|
|
245
246
|
}
|
|
246
247
|
if (report.unhealthy) {
|
|
247
248
|
lines.push(`health: UNHEALTHY`);
|