@indigoai-us/hq-cli 5.121.1 → 5.122.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 +34 -0
- package/dist/command-catalog.generated.d.ts +38 -4
- package/dist/command-catalog.generated.js +48 -4
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +26 -0
- package/dist/commands/agent-enroll.js +63 -6
- package/dist/commands/agent.js +15 -1
- package/dist/commands/cloud-demote.js +3 -2
- package/dist/commands/cloud-provision.d.ts +11 -0
- package/dist/commands/cloud-provision.js +25 -0
- package/dist/commands/cloud-retire.d.ts +51 -0
- package/dist/commands/cloud-retire.js +154 -0
- package/dist/commands/mesh.js +26 -26
- package/dist/lib/agent-kit/adopt-identity.d.ts +27 -0
- package/dist/lib/agent-kit/adopt-identity.js +51 -0
- package/dist/lib/agent-kit/paths.d.ts +20 -0
- package/dist/lib/agent-kit/paths.js +61 -1
- package/dist/lib/doctor/checks/work-context.js +1 -1
- package/dist/lib/mesh/client.d.ts +3 -2
- package/dist/lib/mesh/client.js +3 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +3 -2
- package/dist/lib/mesh/live/backfill-held.js +3 -2
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +12 -2
- package/dist/lib/mesh/live/daemon/run.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/run.js +41 -0
- package/dist/lib/mesh/live/flush.js +44 -2
- package/dist/lib/mesh/live/spool.d.ts +13 -0
- package/dist/lib/mesh/live/spool.js +67 -0
- package/dist/lib/work-context/config.d.ts +1 -4
- package/dist/lib/work-context/config.js +1 -5
- package/dist/lib/work-context/outbox.d.ts +8 -0
- package/dist/lib/work-context/outbox.js +47 -0
- package/dist/lib/work-context/reconcile.js +12 -2
- package/dist/lib/work-context/state.d.ts +23 -0
- package/dist/lib/work-context/state.js +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq cloud retire company <slug>` — soft-tombstone a cloud company so
|
|
3
|
+
* `hq cloud demote company` can run without `--propagate-deletes`.
|
|
4
|
+
*
|
|
5
|
+
* Console equivalent: Settings → Delete company (`DELETE /entity/{uid}`).
|
|
6
|
+
* Owner-only. The entity row, S3 bucket, KMS key, and memberships stay;
|
|
7
|
+
* `/membership/me` stops listing the company. Local files are not touched —
|
|
8
|
+
* demote is the follow-up that flips the local tree back to local-only.
|
|
9
|
+
*
|
|
10
|
+
* Confirm step is on by default (`[y/N]`). `--yes` skips it for scripts.
|
|
11
|
+
* A non-TTY stdin without `--yes` refuses rather than hanging.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes (mirrors cloud-provision / cloud-demote):
|
|
14
|
+
* 0 — success, already tombstoned, or the operator cancelled.
|
|
15
|
+
* 1 — vault HTTP failure (auth/network/5xx).
|
|
16
|
+
* 2 — validation (bad slug, no entity, not owner, missing confirm).
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as readline from "node:readline";
|
|
20
|
+
import chalk from "chalk";
|
|
21
|
+
import { ProvisionError, companyConfigPath, createDefaultVaultClient, validateSlug, } from "./cloud-provision.js";
|
|
22
|
+
import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
|
|
23
|
+
export function defaultConfirm(question) {
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
});
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
30
|
+
rl.close();
|
|
31
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read `companyUid` from `companies/<slug>/.hq/config.json` when the by-slug
|
|
37
|
+
* lookup misses (a tombstoned company is filtered from that route).
|
|
38
|
+
*/
|
|
39
|
+
export function readLocalCompanyUid(hqRoot, slug) {
|
|
40
|
+
const cPath = companyConfigPath(hqRoot, slug);
|
|
41
|
+
if (!fs.existsSync(cPath))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(fs.readFileSync(cPath, "utf-8"));
|
|
45
|
+
return typeof parsed.companyUid === "string" && parsed.companyUid.length > 0
|
|
46
|
+
? parsed.companyUid
|
|
47
|
+
: null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function retireCompany(options) {
|
|
54
|
+
validateSlug(options.slug);
|
|
55
|
+
const accessToken = options.resolveAccessToken
|
|
56
|
+
? await options.resolveAccessToken()
|
|
57
|
+
: await ensureCognitoToken();
|
|
58
|
+
const client = options.vaultClient ??
|
|
59
|
+
createDefaultVaultClient(options.vaultApiUrl, accessToken);
|
|
60
|
+
let entity;
|
|
61
|
+
try {
|
|
62
|
+
entity = await client.findCompanyBySlug(options.slug);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof ProvisionError)
|
|
66
|
+
throw err;
|
|
67
|
+
throw new ProvisionError(1, `Vault GET by-slug failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
68
|
+
}
|
|
69
|
+
if (entity?.deleted === true) {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
company_slug: options.slug,
|
|
73
|
+
cloud_uid: entity.uid,
|
|
74
|
+
already_deleted: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const uid = entity?.uid ?? readLocalCompanyUid(options.hqRoot, options.slug);
|
|
78
|
+
if (!uid) {
|
|
79
|
+
throw new ProvisionError(2, `Refusing to retire '${options.slug}': no cloud entity found.`);
|
|
80
|
+
}
|
|
81
|
+
if (options.yes !== true) {
|
|
82
|
+
const tty = options.stdinIsTTY ?? process.stdin.isTTY === true;
|
|
83
|
+
if (!tty) {
|
|
84
|
+
throw new ProvisionError(2, `Refusing to retire '${options.slug}' without confirmation. Re-run with --yes.`);
|
|
85
|
+
}
|
|
86
|
+
const confirm = options.confirm ?? defaultConfirm;
|
|
87
|
+
const ok = await confirm(`Soft-tombstone cloud company '${options.slug}' (${uid})? ` +
|
|
88
|
+
`It will disappear from every member's picker. Vault files stay. ` +
|
|
89
|
+
`Then run: hq cloud demote company ${options.slug}`);
|
|
90
|
+
if (!ok) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
company_slug: options.slug,
|
|
94
|
+
cloud_uid: uid,
|
|
95
|
+
already_deleted: false,
|
|
96
|
+
cancelled: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await client.softDeleteCompany(uid);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err instanceof ProvisionError)
|
|
105
|
+
throw err;
|
|
106
|
+
throw new ProvisionError(1, `Vault DELETE /entity/${uid} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
company_slug: options.slug,
|
|
111
|
+
cloud_uid: uid,
|
|
112
|
+
already_deleted: false,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Register `retire company <slug>` under the `cloud` command group.
|
|
117
|
+
*/
|
|
118
|
+
export function registerCloudRetireCommands(program) {
|
|
119
|
+
const retireCmd = program
|
|
120
|
+
.command("retire")
|
|
121
|
+
.description("Soft-tombstone a cloud-backed company (owner only)");
|
|
122
|
+
retireCmd
|
|
123
|
+
.command("company")
|
|
124
|
+
.description("Soft-tombstone a cloud company so `hq cloud demote company` can run. " +
|
|
125
|
+
"Does not delete vault files or local folders. Prompts unless --yes.")
|
|
126
|
+
.argument("<slug>", "Company slug")
|
|
127
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
128
|
+
.option("--vault-api-url <url>", `Vault API URL (default: ${DEFAULT_VAULT_API_URL})`, DEFAULT_VAULT_API_URL)
|
|
129
|
+
.option("-y, --yes", "Skip the confirmation prompt (for scripts)")
|
|
130
|
+
.action(async (slug, options) => {
|
|
131
|
+
try {
|
|
132
|
+
const result = await retireCompany({
|
|
133
|
+
slug,
|
|
134
|
+
hqRoot: options.hqRoot,
|
|
135
|
+
vaultApiUrl: options.vaultApiUrl,
|
|
136
|
+
yes: options.yes,
|
|
137
|
+
});
|
|
138
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
139
|
+
if (result.cancelled) {
|
|
140
|
+
process.stderr.write(chalk.dim("[hq cloud retire] Cancelled — nothing was tombstoned.\n"));
|
|
141
|
+
}
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof ProvisionError) {
|
|
146
|
+
process.stderr.write(chalk.red(`[hq cloud retire] ${err.message}\n`));
|
|
147
|
+
process.exit(err.code);
|
|
148
|
+
}
|
|
149
|
+
process.stderr.write(chalk.red(`[hq cloud retire] Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=cloud-retire.js.map
|
package/dist/commands/mesh.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as readline from "node:readline/promises";
|
|
|
12
12
|
import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
13
13
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
14
14
|
import { clearDefaultCompany, getDefaultCompany, readDeviceConfig, recordMigrationCapabilitySnapshot, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
15
|
-
import {
|
|
15
|
+
import { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
|
|
16
16
|
import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
|
|
17
17
|
import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
|
|
18
18
|
import { flushSessionEvents } from "../lib/mesh/live/flush.js";
|
|
@@ -375,7 +375,7 @@ async function runContextDefaultGet(opts) {
|
|
|
375
375
|
}
|
|
376
376
|
async function runContextDefaultSet(slug, opts) {
|
|
377
377
|
const root = workContextHomeRoot();
|
|
378
|
-
void opts.company; // retained for CLI compatibility
|
|
378
|
+
void opts.company; // retained for CLI compatibility
|
|
379
379
|
let token;
|
|
380
380
|
try {
|
|
381
381
|
token = await requireToken();
|
|
@@ -383,42 +383,38 @@ async function runContextDefaultSet(slug, opts) {
|
|
|
383
383
|
catch {
|
|
384
384
|
token = undefined;
|
|
385
385
|
}
|
|
386
|
-
//
|
|
386
|
+
// Migration remains useful diagnostic context, but it must never prevent a
|
|
387
|
+
// verified member from choosing their own device-local preference.
|
|
387
388
|
let migrationCapability = false;
|
|
389
|
+
let migrationWarning;
|
|
388
390
|
if (token) {
|
|
389
391
|
const probe = await probeMigrationCapabilityForMemberships(token);
|
|
390
392
|
recordMigrationCapabilitySnapshot(probe, { root });
|
|
391
393
|
migrationCapability = probe.unlocked;
|
|
392
|
-
if (probe.offline
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return;
|
|
394
|
+
if (probe.offline) {
|
|
395
|
+
migrationWarning =
|
|
396
|
+
"Warning: migration capability could not be checked; membership for the selected company will still be verified.";
|
|
396
397
|
}
|
|
397
|
-
if (!probe.unlocked
|
|
398
|
+
else if (!probe.unlocked) {
|
|
398
399
|
const detail = probe.companies.length === 0
|
|
399
400
|
? "no active memberships"
|
|
400
401
|
: probe.companies
|
|
401
402
|
.filter((c) => !c.migration)
|
|
402
403
|
.map((c) => c.companySlug || c.companyUid)
|
|
403
404
|
.join(", ");
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
405
|
+
migrationWarning =
|
|
406
|
+
`Warning: migration capability is mixed or unavailable for ${detail || "some memberships"}; ` +
|
|
407
|
+
"the selected company can still be set because membership is verified directly.";
|
|
407
408
|
}
|
|
408
409
|
}
|
|
409
|
-
else
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
if (!migrationCapability && opts.allowWithoutMigration) {
|
|
415
|
-
console.error(chalk.yellow("Warning: --allow-without-migration bypasses DEFAULT_COMPANY_LOCKED; a wrong default is hard to correct until migration is available for every company you belong to."));
|
|
410
|
+
else {
|
|
411
|
+
migrationWarning =
|
|
412
|
+
"Warning: migration capability could not be checked without a Cognito session; membership for the selected company must still be verified.";
|
|
416
413
|
}
|
|
417
414
|
try {
|
|
418
415
|
const cfg = await setDefaultCompany(slug, {
|
|
419
416
|
root,
|
|
420
417
|
migrationCapability,
|
|
421
|
-
allowWithoutMigration: opts.allowWithoutMigration,
|
|
422
418
|
validateMembership: async (candidate) => {
|
|
423
419
|
if (!token) {
|
|
424
420
|
throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${candidate}" (no Cognito session; offline or not logged in)`);
|
|
@@ -437,15 +433,16 @@ async function runContextDefaultSet(slug, opts) {
|
|
|
437
433
|
},
|
|
438
434
|
});
|
|
439
435
|
if (opts.json) {
|
|
440
|
-
console.log(JSON.stringify({ ok: true, config: cfg }, null, 2));
|
|
436
|
+
console.log(JSON.stringify({ ok: true, config: cfg, migrationWarning }, null, 2));
|
|
441
437
|
return;
|
|
442
438
|
}
|
|
443
439
|
const stored = cfg.defaultCompany;
|
|
444
440
|
console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
|
|
441
|
+
if (migrationWarning)
|
|
442
|
+
console.error(chalk.yellow(migrationWarning));
|
|
445
443
|
}
|
|
446
444
|
catch (err) {
|
|
447
|
-
if (err instanceof
|
|
448
|
-
err instanceof DefaultCompanyUnavailableError) {
|
|
445
|
+
if (err instanceof DefaultCompanyUnavailableError) {
|
|
449
446
|
console.error(chalk.red(`${err.code}: ${err.message}`));
|
|
450
447
|
process.exitCode = 1;
|
|
451
448
|
return;
|
|
@@ -715,13 +712,16 @@ async function runSessionStatus(opts) {
|
|
|
715
712
|
}
|
|
716
713
|
const { token, company } = await withCompany({ company: opts.company });
|
|
717
714
|
const live = await fetchCompanyLive(token, company.companyUid);
|
|
715
|
+
const unattributedEvents = collectDaemonDoctor().unattributedEvents;
|
|
718
716
|
if (opts.json) {
|
|
719
|
-
console.log(JSON.stringify({ ok: true, action: "session-status", company, live }, null, 2));
|
|
717
|
+
console.log(JSON.stringify({ ok: true, action: "session-status", company, live, unattributedEvents }, null, 2));
|
|
720
718
|
return;
|
|
721
719
|
}
|
|
722
720
|
for (const line of formatCompanyLiveTable(live, company.companySlug || company.companyUid)) {
|
|
723
721
|
console.log(line);
|
|
724
722
|
}
|
|
723
|
+
console.log(`unattributed events: ${unattributedEvents.total} ` +
|
|
724
|
+
`(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
|
|
725
725
|
}
|
|
726
726
|
const HARNESSES = new Set([
|
|
727
727
|
"claude-code",
|
|
@@ -1085,10 +1085,10 @@ export function registerMeshCommand(program) {
|
|
|
1085
1085
|
.action((opts) => wrap(() => runContextDefaultGet(opts))());
|
|
1086
1086
|
def
|
|
1087
1087
|
.command("set")
|
|
1088
|
-
.description("Set the device default company slug
|
|
1088
|
+
.description("Set the device default company slug after verifying membership")
|
|
1089
1089
|
.argument("<slug>", "Company slug")
|
|
1090
|
-
.option("--allow-without-migration", "
|
|
1091
|
-
.option("--company <slug|uid>", "Deprecated no-op;
|
|
1090
|
+
.option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
|
|
1091
|
+
.option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
|
|
1092
1092
|
.option("--json", "Print machine-readable JSON")
|
|
1093
1093
|
.action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());
|
|
1094
1094
|
def
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make `hq agent …` act as THIS host's agent, even on a computer whose owner
|
|
3
|
+
* is signed in to HQ.
|
|
4
|
+
*
|
|
5
|
+
* The token layer resolves machine credentials from `HQ_MACHINE_CREDS_FILE`,
|
|
6
|
+
* falling back to the one fixed path `~/.hq-agent/machine-creds.json` — and
|
|
7
|
+
* hq-cloud's own `loadMachineCreds` reads the same variable. A bot that shares
|
|
8
|
+
* a computer with its owner lives in a sibling directory instead, so without
|
|
9
|
+
* this the CLI would find the layout of an agent and the credentials of a
|
|
10
|
+
* person: `hq agent probe` would report its owner's identity and the bot would
|
|
11
|
+
* act as them.
|
|
12
|
+
*
|
|
13
|
+
* So before any `hq agent` subcommand runs, the resolved tree is published to
|
|
14
|
+
* the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
|
|
15
|
+
* a daemon unit that pins one stays pinned.
|
|
16
|
+
*
|
|
17
|
+
* The person's own session file is not read, moved, or changed by any of this.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentKitPaths } from "./paths.js";
|
|
20
|
+
export declare const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
|
|
21
|
+
export declare const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
|
|
22
|
+
/**
|
|
23
|
+
* Point the token layer at `paths` unless the caller already pinned it.
|
|
24
|
+
* Returns the variables it set, for tests and for `--verbose` output.
|
|
25
|
+
*/
|
|
26
|
+
export declare function adoptAgentIdentityEnv(paths: Pick<AgentKitPaths, "agentDir" | "machineCredsPath">, env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
27
|
+
//# sourceMappingURL=adopt-identity.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make `hq agent …` act as THIS host's agent, even on a computer whose owner
|
|
3
|
+
* is signed in to HQ.
|
|
4
|
+
*
|
|
5
|
+
* The token layer resolves machine credentials from `HQ_MACHINE_CREDS_FILE`,
|
|
6
|
+
* falling back to the one fixed path `~/.hq-agent/machine-creds.json` — and
|
|
7
|
+
* hq-cloud's own `loadMachineCreds` reads the same variable. A bot that shares
|
|
8
|
+
* a computer with its owner lives in a sibling directory instead, so without
|
|
9
|
+
* this the CLI would find the layout of an agent and the credentials of a
|
|
10
|
+
* person: `hq agent probe` would report its owner's identity and the bot would
|
|
11
|
+
* act as them.
|
|
12
|
+
*
|
|
13
|
+
* So before any `hq agent` subcommand runs, the resolved tree is published to
|
|
14
|
+
* the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
|
|
15
|
+
* a daemon unit that pins one stays pinned.
|
|
16
|
+
*
|
|
17
|
+
* The person's own session file is not read, moved, or changed by any of this.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
export const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
|
|
22
|
+
export const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
|
|
23
|
+
/**
|
|
24
|
+
* Point the token layer at `paths` unless the caller already pinned it.
|
|
25
|
+
* Returns the variables it set, for tests and for `--verbose` output.
|
|
26
|
+
*/
|
|
27
|
+
export function adoptAgentIdentityEnv(paths, env = process.env) {
|
|
28
|
+
const applied = {};
|
|
29
|
+
if (env[MACHINE_CREDS_FILE_ENV]?.trim())
|
|
30
|
+
return applied;
|
|
31
|
+
// Only adopt a tree that actually holds an identity: on a host that has not
|
|
32
|
+
// enrolled yet, leaving the environment alone keeps today's error messages.
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.statSync(paths.machineCredsPath).isFile())
|
|
35
|
+
return applied;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return applied;
|
|
39
|
+
}
|
|
40
|
+
applied[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
|
|
41
|
+
env[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
|
|
42
|
+
// Per-agent token cache. Two identities on one computer must never share a
|
|
43
|
+
// minted-token file; the agent's belongs inside the agent's own tree.
|
|
44
|
+
if (!env[MACHINE_TOKEN_STATE_DIR_ENV]?.trim()) {
|
|
45
|
+
const dir = path.join(paths.agentDir, "token-state");
|
|
46
|
+
applied[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
|
|
47
|
+
env[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
|
|
48
|
+
}
|
|
49
|
+
return applied;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=adopt-identity.js.map
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* `HQ_AGENT_DIR` relocates the whole tree (tests, containers). The creds file
|
|
14
14
|
* additionally honours `HQ_MACHINE_CREDS_FILE`, the override hq-cloud reads,
|
|
15
15
|
* so a kit pointed at a custom creds path and hq-cloud's mint agree.
|
|
16
|
+
*
|
|
17
|
+
* LOCAL BOTS. A bot that runs on its owner's own computer cannot use the
|
|
18
|
+
* default tree: that machine already has the owner's HQ session, and an agent
|
|
19
|
+
* identity must not share a host account with a person's login. Such a bot
|
|
20
|
+
* enrolls into a SIBLING directory instead — `~/.hq-agent/<name>/` with the
|
|
21
|
+
* same layout — and every `hq agent …` command finds it here, so the bot does
|
|
22
|
+
* not have to carry environment variables around to be itself. Two or more
|
|
23
|
+
* local agents are ambiguous on purpose: pick one with `HQ_AGENT_DIR`.
|
|
16
24
|
*/
|
|
17
25
|
export declare const AGENT_DIR_ENV = "HQ_AGENT_DIR";
|
|
18
26
|
export declare const HOST_KEY_NAME = "host-key";
|
|
@@ -35,6 +43,18 @@ export interface AgentKitPaths {
|
|
|
35
43
|
skillsDir: string;
|
|
36
44
|
lastHeartbeatPath: string;
|
|
37
45
|
}
|
|
46
|
+
/** Default name for a bot enrolled alongside its owner's own session. */
|
|
47
|
+
export declare const DEFAULT_LOCAL_AGENT_NAME = "local";
|
|
48
|
+
/** `~/.hq-agent` — the root, whether or not it holds an identity itself. */
|
|
49
|
+
export declare function agentRootDir(home?: string): string;
|
|
50
|
+
/** `~/.hq-agent/<name>` — an isolated home for one local bot. */
|
|
51
|
+
export declare function localAgentDir(name: string, home?: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Local agent homes under `~/.hq-agent`, by name, oldest name order. A
|
|
54
|
+
* directory counts only once it holds a creds file, so a half-written tree is
|
|
55
|
+
* never mistaken for an identity.
|
|
56
|
+
*/
|
|
57
|
+
export declare function listLocalAgentDirs(home?: string): string[];
|
|
38
58
|
export declare function agentDir(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
39
59
|
export declare function agentKitPaths(home?: string, env?: NodeJS.ProcessEnv): AgentKitPaths;
|
|
40
60
|
export declare function componentStatePath(paths: Pick<AgentKitPaths, "stateDir">, component: KitComponent): string;
|
|
@@ -13,7 +13,16 @@
|
|
|
13
13
|
* `HQ_AGENT_DIR` relocates the whole tree (tests, containers). The creds file
|
|
14
14
|
* additionally honours `HQ_MACHINE_CREDS_FILE`, the override hq-cloud reads,
|
|
15
15
|
* so a kit pointed at a custom creds path and hq-cloud's mint agree.
|
|
16
|
+
*
|
|
17
|
+
* LOCAL BOTS. A bot that runs on its owner's own computer cannot use the
|
|
18
|
+
* default tree: that machine already has the owner's HQ session, and an agent
|
|
19
|
+
* identity must not share a host account with a person's login. Such a bot
|
|
20
|
+
* enrolls into a SIBLING directory instead — `~/.hq-agent/<name>/` with the
|
|
21
|
+
* same layout — and every `hq agent …` command finds it here, so the bot does
|
|
22
|
+
* not have to carry environment variables around to be itself. Two or more
|
|
23
|
+
* local agents are ambiguous on purpose: pick one with `HQ_AGENT_DIR`.
|
|
16
24
|
*/
|
|
25
|
+
import * as fs from "node:fs";
|
|
17
26
|
import * as os from "node:os";
|
|
18
27
|
import * as path from "node:path";
|
|
19
28
|
export const AGENT_DIR_ENV = "HQ_AGENT_DIR";
|
|
@@ -23,11 +32,62 @@ export const MACHINE_CREDS_NAME = "machine-creds.json";
|
|
|
23
32
|
export const KIT_CONFIG_NAME = "kit.json";
|
|
24
33
|
export const LAST_HEARTBEAT_NAME = "last-heartbeat.json";
|
|
25
34
|
export const KIT_COMPONENTS = ["sync", "inbox"];
|
|
35
|
+
/** Default name for a bot enrolled alongside its owner's own session. */
|
|
36
|
+
export const DEFAULT_LOCAL_AGENT_NAME = "local";
|
|
37
|
+
/** `~/.hq-agent` — the root, whether or not it holds an identity itself. */
|
|
38
|
+
export function agentRootDir(home = os.homedir()) {
|
|
39
|
+
return path.join(home, ".hq-agent");
|
|
40
|
+
}
|
|
41
|
+
/** `~/.hq-agent/<name>` — an isolated home for one local bot. */
|
|
42
|
+
export function localAgentDir(name, home = os.homedir()) {
|
|
43
|
+
return path.join(agentRootDir(home), name);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Local agent homes under `~/.hq-agent`, by name, oldest name order. A
|
|
47
|
+
* directory counts only once it holds a creds file, so a half-written tree is
|
|
48
|
+
* never mistaken for an identity.
|
|
49
|
+
*/
|
|
50
|
+
export function listLocalAgentDirs(home = os.homedir()) {
|
|
51
|
+
const root = agentRootDir(home);
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return entries
|
|
60
|
+
.filter((e) => e.isDirectory())
|
|
61
|
+
.map((e) => path.join(root, e.name))
|
|
62
|
+
.filter((dir) => {
|
|
63
|
+
try {
|
|
64
|
+
return fs.statSync(path.join(dir, MACHINE_CREDS_NAME)).isFile();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
26
72
|
export function agentDir(home = os.homedir(), env = process.env) {
|
|
27
73
|
const override = env[AGENT_DIR_ENV]?.trim();
|
|
28
74
|
if (override)
|
|
29
75
|
return override;
|
|
30
|
-
|
|
76
|
+
const root = agentRootDir(home);
|
|
77
|
+
// An identity in the root wins: that is the dedicated-host layout, and a
|
|
78
|
+
// host that has one is not running a local bot beside a person.
|
|
79
|
+
try {
|
|
80
|
+
if (fs.statSync(path.join(root, MACHINE_CREDS_NAME)).isFile())
|
|
81
|
+
return root;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* no identity in the root — fall through to the local homes */
|
|
85
|
+
}
|
|
86
|
+
const locals = listLocalAgentDirs(home);
|
|
87
|
+
// Exactly one is unambiguous. Two or more cannot be guessed, so keep the
|
|
88
|
+
// root and let the caller fail with a message naming HQ_AGENT_DIR rather
|
|
89
|
+
// than silently acting as whichever agent sorted first.
|
|
90
|
+
return locals.length === 1 ? locals[0] : root;
|
|
31
91
|
}
|
|
32
92
|
export function agentKitPaths(home = os.homedir(), env = process.env) {
|
|
33
93
|
const dir = agentDir(home, env);
|
|
@@ -178,7 +178,7 @@ function formatMigrationCapabilityCheck(cap, root) {
|
|
|
178
178
|
status: "WARN",
|
|
179
179
|
checkId: "work-context.migration-capability",
|
|
180
180
|
target,
|
|
181
|
-
message: `Migration capability: offline at ${cap.checkedAt};
|
|
181
|
+
message: `Migration capability: offline at ${cap.checkedAt}; selected-company membership is still verified when setting a default.`,
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
const falseCount = cap.companies.filter((c) => !c.migration).length;
|
|
@@ -73,8 +73,9 @@ export interface MigrationCapabilityProbe {
|
|
|
73
73
|
companies: MigrationCapabilityCompanyResult[];
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* Diagnostic probe for migration capability across active memberships. The
|
|
77
|
+
* result is recorded and warned on, but does not gate a verified member from
|
|
78
|
+
* choosing a device-local default company.
|
|
78
79
|
*/
|
|
79
80
|
export declare function probeMigrationCapabilityForMemberships(token: string, opts?: {
|
|
80
81
|
listCompanies?: (token: string) => Promise<MeshCompany[]>;
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -119,8 +119,9 @@ export async function fetchMigrationCapability(token, companyUid) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
122
|
+
* Diagnostic probe for migration capability across active memberships. The
|
|
123
|
+
* result is recorded and warned on, but does not gate a verified member from
|
|
124
|
+
* choosing a device-local default company.
|
|
124
125
|
*/
|
|
125
126
|
export async function probeMigrationCapabilityForMemberships(token, opts) {
|
|
126
127
|
const now = (opts?.now ?? (() => new Date()))().toISOString();
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* company (via the identity-file default resolver shipped in 5.108.22). The
|
|
14
14
|
* daemon's next held retry then re-attributes and posts those events naturally.
|
|
15
15
|
*
|
|
16
|
-
* Constraints
|
|
17
|
-
* -
|
|
16
|
+
* Constraints:
|
|
17
|
+
* - The daemon runs this in bounded startup batches; the CLI command remains
|
|
18
|
+
* available for an explicit full/manual recovery.
|
|
18
19
|
* - Idempotent: sessions that already carry a companyUid are skipped.
|
|
19
20
|
* - Never deletes held events; the daemon posts them on the next retry.
|
|
20
21
|
* - Purely local to the box it runs on; no fleet fan-out.
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* company (via the identity-file default resolver shipped in 5.108.22). The
|
|
14
14
|
* daemon's next held retry then re-attributes and posts those events naturally.
|
|
15
15
|
*
|
|
16
|
-
* Constraints
|
|
17
|
-
* -
|
|
16
|
+
* Constraints:
|
|
17
|
+
* - The daemon runs this in bounded startup batches; the CLI command remains
|
|
18
|
+
* available for an explicit full/manual recovery.
|
|
18
19
|
* - Idempotent: sessions that already carry a companyUid are skipped.
|
|
19
20
|
* - Never deletes held events; the daemon posts them on the next retry.
|
|
20
21
|
* - Purely local to the box it runs on; no fleet fan-out.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* outbox/last-flush, and unhealthy when unacked spool age > 60s while online.
|
|
4
4
|
*/
|
|
5
5
|
import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
|
|
6
|
+
import { type UnattributedEventCounts } from "../spool.js";
|
|
6
7
|
import { type DaemonStateFile } from "./state.js";
|
|
7
8
|
export declare const UNHEALTHY_SPOOL_AGE_MS = 60000;
|
|
8
9
|
export interface DaemonDoctorReport {
|
|
@@ -17,6 +18,8 @@ export interface DaemonDoctorReport {
|
|
|
17
18
|
spoolDepth: number;
|
|
18
19
|
heldCount: number;
|
|
19
20
|
deadLetterCount: number;
|
|
21
|
+
/** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
|
|
22
|
+
unattributedEvents: UnattributedEventCounts;
|
|
20
23
|
outboxDepth: number;
|
|
21
24
|
lastFlushAt?: string;
|
|
22
25
|
lastFlushResult?: DaemonStateFile["lastFlushResult"];
|
|
@@ -7,7 +7,7 @@ import * as os from "node:os";
|
|
|
7
7
|
import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
|
|
8
8
|
import { outboxStats, } from "../../../work-context/outbox.js";
|
|
9
9
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
10
|
-
import { countJsonlLines, } from "../spool.js";
|
|
10
|
+
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
11
11
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
12
12
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
13
13
|
import { readEmitState } from "../emit.js";
|
|
@@ -78,6 +78,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
78
78
|
const spoolDepth = countJsonlLines(spoolPath);
|
|
79
79
|
const heldCount = countJsonlLines(heldPath);
|
|
80
80
|
const deadLetterCount = countJsonlLines(deadPath);
|
|
81
|
+
const unattributedEvents = countUnattributedEvents(meshRoot);
|
|
81
82
|
const outbox = outboxStats(ctxRoot);
|
|
82
83
|
const mqttState = state?.mqttState ?? (running ? "unknown" : "closed");
|
|
83
84
|
const companiesOnline = state?.companiesOnline ?? [];
|
|
@@ -96,6 +97,13 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
96
97
|
if (deadLetterCount > 0) {
|
|
97
98
|
unhealthyReasons.push(`dead-letter count=${deadLetterCount}`);
|
|
98
99
|
}
|
|
100
|
+
if (emitMode === "legacy" && heldCount > 0) {
|
|
101
|
+
unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
|
|
102
|
+
}
|
|
103
|
+
if (unattributedEvents.total > 0) {
|
|
104
|
+
unhealthyReasons.push(`unattributed events=${unattributedEvents.total} ` +
|
|
105
|
+
`(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
|
|
106
|
+
}
|
|
99
107
|
const presenceRefusal = state?.presenceRefusal ?? null;
|
|
100
108
|
if (presenceRefusal) {
|
|
101
109
|
unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
|
|
@@ -114,6 +122,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
114
122
|
spoolDepth,
|
|
115
123
|
heldCount,
|
|
116
124
|
deadLetterCount,
|
|
125
|
+
unattributedEvents,
|
|
117
126
|
outboxDepth: outbox.depth,
|
|
118
127
|
lastFlushAt: state?.lastFlushAt,
|
|
119
128
|
lastFlushResult: state?.lastFlushResult,
|
|
@@ -150,7 +159,8 @@ export function formatDaemonDoctor(report) {
|
|
|
150
159
|
// receive-only (direct) mode.
|
|
151
160
|
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
|
|
152
161
|
}
|
|
153
|
-
lines.push(`dead-letter: ${report.deadLetterCount}`, `
|
|
162
|
+
lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
163
|
+
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
154
164
|
if (report.emitMode === "legacy") {
|
|
155
165
|
lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
156
166
|
? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
|
|
10
10
|
*/
|
|
11
11
|
import { type FlushSummary } from "../flush.js";
|
|
12
|
+
import { type BackfillHeldResult } from "../backfill-held.js";
|
|
12
13
|
import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
|
|
13
14
|
import { type PidLockDeps } from "./pid-lock.js";
|
|
14
15
|
import { PresenceClient, type MqttConnectFn } from "./presence.js";
|
|
@@ -16,6 +17,8 @@ import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
|
|
|
16
17
|
import { type MeshEmitMode } from "./mode.js";
|
|
17
18
|
export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
18
19
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
20
|
+
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
21
|
+
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
19
22
|
/**
|
|
20
23
|
* Only producer appends to spool.jsonl should wake the watcher. Every other
|
|
21
24
|
* file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
|
|
@@ -44,6 +47,10 @@ export interface DaemonRunDeps {
|
|
|
44
47
|
quarantined: number;
|
|
45
48
|
skipped?: number;
|
|
46
49
|
}>;
|
|
50
|
+
/** Injected held-session recovery (tests). Runs once before the startup flush. */
|
|
51
|
+
backfillHeld?: () => Promise<BackfillHeldResult>;
|
|
52
|
+
/** Maximum distinct held sessions reconciled during one daemon startup. */
|
|
53
|
+
heldBackfillBatchSize?: number;
|
|
47
54
|
/** Injected board refresh (tests). */
|
|
48
55
|
refreshBoards?: () => Promise<{
|
|
49
56
|
refreshed: number;
|