@indigoai-us/hq-cli 5.124.0 → 5.125.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 +31 -0
- package/dist/command-catalog.generated.d.ts +7 -1
- package/dist/command-catalog.generated.js +9 -1
- package/dist/commands/login.js +49 -6
- package/dist/commands/mesh.js +96 -19
- package/dist/lib/doctor/checks/work-context.js +4 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
- package/dist/lib/mesh/live/backfill-held.js +8 -4
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +21 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
- package/dist/lib/mesh/live/daemon/run.js +129 -10
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/flush.d.ts +3 -0
- package/dist/lib/mesh/live/flush.js +11 -0
- package/dist/lib/work-context/company.d.ts +8 -0
- package/dist/lib/work-context/company.js +62 -18
- package/dist/lib/work-context/config.d.ts +35 -0
- package/dist/lib/work-context/config.js +307 -62
- package/dist/lib/work-context/contract.d.ts +2 -0
- package/dist/lib/work-context/default-company.d.ts +43 -0
- package/dist/lib/work-context/default-company.js +83 -0
- package/dist/lib/work-context/outbox.d.ts +5 -1
- package/dist/lib/work-context/outbox.js +5 -0
- package/dist/lib/work-context/reconcile.d.ts +4 -0
- package/dist/lib/work-context/reconcile.js +59 -3
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.125.0] — 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Work Mesh now preserves an acknowledged company binding through later
|
|
10
|
+
company-less hook writes, preventing repeated registrations and held events.
|
|
11
|
+
Held recovery accepts only durable registrations, canonical metadata under a
|
|
12
|
+
verified HQ root, or manifest-backed repository ownership; conflicting or
|
|
13
|
+
event-only hints stay unresolved. Its batch cursor rotates across restarts.
|
|
14
|
+
Both legacy and direct-mode daemons detect an upgraded CLI, and systemd units
|
|
15
|
+
use `Restart=always` so the installed service reloads the new code.
|
|
16
|
+
|
|
17
|
+
- Held-session recovery now accepts session metadata only from the daemon's
|
|
18
|
+
configured HQ root, requires current-session evidence before restoring an
|
|
19
|
+
acknowledged registration, and retains batch position when held records are
|
|
20
|
+
compacted. Repeated hook activity no longer re-registers an unchanged binding.
|
|
21
|
+
CLI updates report installed system units that need their host service manager
|
|
22
|
+
to restart them. Daemon installs persist verified HQ-root allowlists, inject
|
|
23
|
+
the primary root into service environments, and rewrite/reload existing user
|
|
24
|
+
units so upgraded daemons restart reliably.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Work Mesh now activates a device default company automatically when a signed-in
|
|
29
|
+
person has exactly one active membership. Devices with several memberships ask
|
|
30
|
+
for a choice in interactive CLI paths and show the stored state in `hq mesh
|
|
31
|
+
context default get --json`. Clearing the default is a durable opt-out until
|
|
32
|
+
the person explicitly sets one again. Historical held sessions remain unchanged
|
|
33
|
+
unless `hq mesh context backfill-held --use-device-default` is supplied; those
|
|
34
|
+
repaired records are labeled lower-confidence and counted by daemon doctor.
|
|
35
|
+
|
|
5
36
|
## [5.124.0] — 2026-09-20
|
|
6
37
|
|
|
7
38
|
### Added
|
|
@@ -5729,7 +5729,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5729
5729
|
readonly description: "Cap sessions processed (default 0 = no cap)";
|
|
5730
5730
|
}, {
|
|
5731
5731
|
readonly flags: "--company <slug>";
|
|
5732
|
-
readonly description: "Optional non-forcing company hint
|
|
5732
|
+
readonly description: "Optional non-forcing company hint";
|
|
5733
|
+
}, {
|
|
5734
|
+
readonly flags: "--use-device-default";
|
|
5735
|
+
readonly description: "Opt in to historical repair using the device default (lower-confidence attribution)";
|
|
5733
5736
|
}, {
|
|
5734
5737
|
readonly flags: "--json";
|
|
5735
5738
|
readonly description: "Print machine-readable JSON";
|
|
@@ -5869,6 +5872,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5869
5872
|
readonly options: readonly [{
|
|
5870
5873
|
readonly flags: "--allow-without-migration";
|
|
5871
5874
|
readonly description: "Deprecated compatibility no-op; selected-company membership is always verified";
|
|
5875
|
+
}, {
|
|
5876
|
+
readonly flags: "--repair-held-with-default";
|
|
5877
|
+
readonly description: "Persist opt-in for lower-confidence historical held-session repair";
|
|
5872
5878
|
}, {
|
|
5873
5879
|
readonly flags: "--company <slug|uid>";
|
|
5874
5880
|
readonly description: "Deprecated no-op; the selected company is verified directly";
|
|
@@ -7391,7 +7391,11 @@ export const COMMAND_CATALOG = [
|
|
|
7391
7391
|
},
|
|
7392
7392
|
{
|
|
7393
7393
|
"flags": "--company <slug>",
|
|
7394
|
-
"description": "Optional non-forcing company hint
|
|
7394
|
+
"description": "Optional non-forcing company hint"
|
|
7395
|
+
},
|
|
7396
|
+
{
|
|
7397
|
+
"flags": "--use-device-default",
|
|
7398
|
+
"description": "Opt in to historical repair using the device default (lower-confidence attribution)"
|
|
7395
7399
|
},
|
|
7396
7400
|
{
|
|
7397
7401
|
"flags": "--json",
|
|
@@ -7573,6 +7577,10 @@ export const COMMAND_CATALOG = [
|
|
|
7573
7577
|
"flags": "--allow-without-migration",
|
|
7574
7578
|
"description": "Deprecated compatibility no-op; selected-company membership is always verified"
|
|
7575
7579
|
},
|
|
7580
|
+
{
|
|
7581
|
+
"flags": "--repair-held-with-default",
|
|
7582
|
+
"description": "Persist opt-in for lower-confidence historical held-session repair"
|
|
7583
|
+
},
|
|
7576
7584
|
{
|
|
7577
7585
|
"flags": "--company <slug|uid>",
|
|
7578
7586
|
"description": "Deprecated no-op; the selected company is verified directly"
|
package/dist/commands/login.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
6
6
|
import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
|
|
7
|
+
import * as readline from "node:readline/promises";
|
|
8
|
+
import { listActiveMembershipCompanies } from "../lib/mesh/api.js";
|
|
9
|
+
import { setDefaultCompany } from "../lib/work-context/config.js";
|
|
10
|
+
import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
|
|
11
|
+
import { workContextRoot } from "../lib/work-context/paths.js";
|
|
7
12
|
function peekIdToken(idToken) {
|
|
8
13
|
try {
|
|
9
14
|
const payload = idToken.split('.')[1];
|
|
@@ -24,21 +29,59 @@ export function registerLoginCommand(program) {
|
|
|
24
29
|
.description('Authenticate with HQ via Cognito')
|
|
25
30
|
.option('--provider <provider>', 'OAuth provider to use: google, microsoft, or picker')
|
|
26
31
|
.action(async (options) => {
|
|
32
|
+
let accessToken;
|
|
27
33
|
try {
|
|
28
34
|
const existing = loadCachedTokens();
|
|
29
35
|
if (existing && !isExpiring(existing, 120)) {
|
|
30
36
|
const who = peekIdToken(existing.idToken).email ?? 'cached session';
|
|
31
37
|
console.log(chalk.green(`Already logged in as ${who}. Run \`hq logout\` to switch accounts.`));
|
|
32
|
-
|
|
38
|
+
accessToken = existing.accessToken;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.log('Opening browser for authentication...');
|
|
42
|
+
const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
|
|
43
|
+
const who = peekIdToken(tokens.idToken).email ?? 'HQ';
|
|
44
|
+
console.log(chalk.green(`Logged in as ${who}`));
|
|
45
|
+
accessToken = tokens.accessToken;
|
|
33
46
|
}
|
|
34
|
-
console.log('Opening browser for authentication...');
|
|
35
|
-
const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
|
|
36
|
-
const who = peekIdToken(tokens.idToken).email ?? 'HQ';
|
|
37
|
-
console.log(chalk.green(`Logged in as ${who}`));
|
|
38
47
|
}
|
|
39
48
|
catch (error) {
|
|
40
49
|
console.error(chalk.red('Login failed:'), error instanceof Error ? error.message : 'Unknown error');
|
|
41
|
-
process.
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const root = workContextRoot();
|
|
55
|
+
const activation = await ensureDefaultCompany({
|
|
56
|
+
root,
|
|
57
|
+
listMemberships: () => listActiveMembershipCompanies(accessToken),
|
|
58
|
+
log: (message) => console.error(chalk.dim(message)),
|
|
59
|
+
});
|
|
60
|
+
if (activation.status === "needs_choice" && process.stdin.isTTY && process.stdout.isTTY) {
|
|
61
|
+
const choices = activation.memberships
|
|
62
|
+
.map((membership, index) => `${index + 1}. ${membership.slug}`)
|
|
63
|
+
.join("\n");
|
|
64
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
65
|
+
try {
|
|
66
|
+
const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
|
|
67
|
+
const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
|
|
68
|
+
if (selected) {
|
|
69
|
+
await setDefaultCompany(selected.slug, {
|
|
70
|
+
root,
|
|
71
|
+
validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (activation.status === "needs_choice") {
|
|
80
|
+
console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error(chalk.yellow('Default-company setup failed:'), error instanceof Error ? error.message : 'Unknown error');
|
|
42
85
|
}
|
|
43
86
|
});
|
|
44
87
|
}
|
package/dist/commands/mesh.js
CHANGED
|
@@ -12,13 +12,15 @@ import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js
|
|
|
12
12
|
import * as readline from "node:readline/promises";
|
|
13
13
|
import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
14
14
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
15
|
-
import { clearDefaultCompany,
|
|
15
|
+
import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
16
|
+
import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
|
|
16
17
|
import { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
|
|
17
18
|
import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
|
|
18
19
|
import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
|
|
19
20
|
import { flushSessionEvents } from "../lib/mesh/live/flush.js";
|
|
20
21
|
import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
|
|
21
22
|
import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
|
|
23
|
+
const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
|
|
22
24
|
import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
|
|
23
25
|
import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
|
|
24
26
|
import { emitEvents } from "../lib/mesh/live/emit.js";
|
|
@@ -153,25 +155,73 @@ async function runContextReconcile(opts) {
|
|
|
153
155
|
let fetchCandidates;
|
|
154
156
|
let validateMembership;
|
|
155
157
|
let offline = Boolean(opts.offline);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
158
|
+
let token;
|
|
159
|
+
try {
|
|
160
|
+
// Offline is outbox-only delivery. It still refreshes a device default's
|
|
161
|
+
// token-backed membership so a revoked default cannot be preflighted.
|
|
162
|
+
token = await requireToken();
|
|
163
|
+
const sessionToken = token;
|
|
164
|
+
validateMembership = async (candidate) => {
|
|
165
|
+
const membership = await new Promise((resolve, reject) => {
|
|
166
|
+
const timer = setTimeout(() => reject(new Error("membership validation timed out")), MEMBERSHIP_VALIDATION_TIMEOUT_MS);
|
|
167
|
+
void resolveActiveMembershipCompany(sessionToken, candidate).then((value) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve(value);
|
|
170
|
+
}, (error) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
reject(error);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
if (!membership || typeof membership.companyUid !== "string" || !membership.companyUid) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return { uid: membership.companyUid };
|
|
179
|
+
};
|
|
180
|
+
if (!offline) {
|
|
181
|
+
deliver = createWorkSessionDeliverer({ token: sessionToken });
|
|
182
|
+
fetchCandidates = createCandidatesFetcher({ token: sessionToken });
|
|
167
183
|
}
|
|
168
|
-
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
if (!offline) {
|
|
169
187
|
offline = true;
|
|
170
188
|
if (!opts.machine) {
|
|
171
189
|
console.error(chalk.yellow("No Cognito session; reconciling offline (outbox only)."));
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
}
|
|
193
|
+
if (token) {
|
|
194
|
+
const activation = await ensureDefaultCompany({
|
|
195
|
+
root,
|
|
196
|
+
listMemberships: () => listActiveMembershipCompanies(token),
|
|
197
|
+
log: (message) => console.error(chalk.dim(message)),
|
|
198
|
+
});
|
|
199
|
+
if (activation.status === "needs_choice") {
|
|
200
|
+
const interactive = !opts.machine && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !isMachineIdentity();
|
|
201
|
+
if (interactive) {
|
|
202
|
+
const choices = activation.memberships
|
|
203
|
+
.map((membership, index) => `${index + 1}. ${membership.slug}`)
|
|
204
|
+
.join("\n");
|
|
205
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
206
|
+
try {
|
|
207
|
+
const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
|
|
208
|
+
const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
|
|
209
|
+
if (selected) {
|
|
210
|
+
await setDefaultCompany(selected.slug, {
|
|
211
|
+
root,
|
|
212
|
+
validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
rl.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (!opts.machine) {
|
|
221
|
+
console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
175
225
|
const outcome = await reconcileObservation(obs, {
|
|
176
226
|
root,
|
|
177
227
|
env: process.env,
|
|
@@ -295,6 +345,7 @@ async function runContextBackfillHeld(opts) {
|
|
|
295
345
|
const parsedLimit = opts.limit ? Number.parseInt(opts.limit, 10) : 0;
|
|
296
346
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
|
|
297
347
|
const companyHint = opts.company?.trim() || undefined;
|
|
348
|
+
const useDeviceDefault = opts.useDeviceDefault === true || readDeviceConfig({ root }).repairHeldWithDefault === true;
|
|
298
349
|
// Build the reconcile network seam exactly like `hq mesh context reconcile`.
|
|
299
350
|
// Not needed in --dry-run (reconcile is never invoked then). Explicit types
|
|
300
351
|
// because these are captured in the reconcile closure below (no control-flow
|
|
@@ -331,6 +382,7 @@ async function runContextBackfillHeld(opts) {
|
|
|
331
382
|
// Mirror the reconcile closure's non-forcing hint in the dry-run predictor
|
|
332
383
|
// so `--dry-run` and the real run report the same counts.
|
|
333
384
|
remoteOwnerHint: companyHint || undefined,
|
|
385
|
+
useDeviceDefault,
|
|
334
386
|
reconcile: (obs) => reconcileObservation(
|
|
335
387
|
// Pass --company as a non-forcing hint (remoteOwnerSlug feeds
|
|
336
388
|
// deterministic resolution BELOW the identity-file / device default,
|
|
@@ -342,6 +394,8 @@ async function runContextBackfillHeld(opts) {
|
|
|
342
394
|
fetchCandidates,
|
|
343
395
|
offline,
|
|
344
396
|
validateMembership,
|
|
397
|
+
allowDeviceDefault: useDeviceDefault,
|
|
398
|
+
deviceDefaultConfidence: useDeviceDefault,
|
|
345
399
|
}),
|
|
346
400
|
});
|
|
347
401
|
if (opts.json) {
|
|
@@ -364,16 +418,34 @@ async function runContextBackfillHeld(opts) {
|
|
|
364
418
|
async function runContextDefaultGet(opts) {
|
|
365
419
|
const root = workContextHomeRoot();
|
|
366
420
|
const cfg = readDeviceConfig({ root });
|
|
367
|
-
const current =
|
|
421
|
+
const current = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
|
|
422
|
+
? cfg.defaultCompany
|
|
423
|
+
: null;
|
|
424
|
+
const memberships = cfg.defaultCompanyMemberships?.memberships ?? [];
|
|
425
|
+
const disabled = isDefaultCompanyDisabled({ root });
|
|
426
|
+
const source = disabled ? "disabled" : current?.source ?? (current ? "chosen" : "unset");
|
|
368
427
|
if (opts.json) {
|
|
369
|
-
console.log(JSON.stringify({
|
|
428
|
+
console.log(JSON.stringify({
|
|
429
|
+
ok: true,
|
|
430
|
+
slug: current?.slug ?? null,
|
|
431
|
+
uid: current?.uid ?? null,
|
|
432
|
+
enabled: current?.enabled ?? false,
|
|
433
|
+
source,
|
|
434
|
+
needsChoice: !current && !disabled && memberships.length > 1,
|
|
435
|
+
memberships,
|
|
436
|
+
repairHeldWithDefault: cfg.repairHeldWithDefault === true,
|
|
437
|
+
}, null, 2));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (disabled) {
|
|
441
|
+
console.log("Default company disabled on this device.");
|
|
370
442
|
return;
|
|
371
443
|
}
|
|
372
444
|
if (!current) {
|
|
373
445
|
console.log("No device default company (default-company mode may be dark).");
|
|
374
446
|
return;
|
|
375
447
|
}
|
|
376
|
-
console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled}`);
|
|
448
|
+
console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
|
|
377
449
|
}
|
|
378
450
|
async function runContextDefaultSet(slug, opts) {
|
|
379
451
|
const root = workContextHomeRoot();
|
|
@@ -434,11 +506,14 @@ async function runContextDefaultSet(slug, opts) {
|
|
|
434
506
|
return { uid: membership.companyUid };
|
|
435
507
|
},
|
|
436
508
|
});
|
|
509
|
+
const configured = opts.repairHeldWithDefault
|
|
510
|
+
? setRepairHeldWithDefault(true, { root })
|
|
511
|
+
: cfg;
|
|
437
512
|
if (opts.json) {
|
|
438
|
-
console.log(JSON.stringify({ ok: true, config:
|
|
513
|
+
console.log(JSON.stringify({ ok: true, config: configured, migrationWarning }, null, 2));
|
|
439
514
|
return;
|
|
440
515
|
}
|
|
441
|
-
const stored =
|
|
516
|
+
const stored = configured.defaultCompany;
|
|
442
517
|
console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
|
|
443
518
|
if (migrationWarning)
|
|
444
519
|
console.error(chalk.yellow(migrationWarning));
|
|
@@ -1035,7 +1110,8 @@ export function registerMeshCommand(program) {
|
|
|
1035
1110
|
.option("--dry-run", "Report what would be reconciled; write nothing, no network")
|
|
1036
1111
|
.option("--offline", "Reconcile outbox-only (no candidates/register network)")
|
|
1037
1112
|
.option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
|
|
1038
|
-
.option("--company <slug>", "Optional non-forcing company hint
|
|
1113
|
+
.option("--company <slug>", "Optional non-forcing company hint")
|
|
1114
|
+
.option("--use-device-default", "Opt in to historical repair using the device default (lower-confidence attribution)")
|
|
1039
1115
|
.option("--json", "Print machine-readable JSON")
|
|
1040
1116
|
.action((opts) => wrap(() => runContextBackfillHeld(opts))());
|
|
1041
1117
|
context
|
|
@@ -1090,6 +1166,7 @@ export function registerMeshCommand(program) {
|
|
|
1090
1166
|
.description("Set the device default company slug after verifying membership")
|
|
1091
1167
|
.argument("<slug>", "Company slug")
|
|
1092
1168
|
.option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
|
|
1169
|
+
.option("--repair-held-with-default", "Persist opt-in for lower-confidence historical held-session repair")
|
|
1093
1170
|
.option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
|
|
1094
1171
|
.option("--json", "Print machine-readable JSON")
|
|
1095
1172
|
.action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { readDeviceConfig,
|
|
9
|
+
import { readDeviceConfig, } from "../../work-context/config.js";
|
|
10
10
|
import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
|
|
11
11
|
import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
|
|
12
12
|
import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
|
|
@@ -96,7 +96,9 @@ function runChecks(deps) {
|
|
|
96
96
|
// Default-company mode + migration capability (US-017B)
|
|
97
97
|
try {
|
|
98
98
|
const cfg = readDeviceConfig({ root });
|
|
99
|
-
const def =
|
|
99
|
+
const def = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
|
|
100
|
+
? cfg.defaultCompany
|
|
101
|
+
: null;
|
|
100
102
|
const mode = def?.enabled
|
|
101
103
|
? `enabled slug=${def.slug}`
|
|
102
104
|
: "disabled (dark / unset)";
|
|
@@ -111,6 +111,8 @@ export interface BackfillHeldDeps {
|
|
|
111
111
|
* meta) in BOTH the dry-run predictor and the write path, so the two agree.
|
|
112
112
|
*/
|
|
113
113
|
remoteOwnerHint?: string;
|
|
114
|
+
/** Historical device-default repair is opt-in; false keeps no-evidence sessions held. */
|
|
115
|
+
useDeviceDefault?: boolean;
|
|
114
116
|
/** Env passed to the dry-run resolver (tests). Default process.env. */
|
|
115
117
|
env?: NodeJS.ProcessEnv;
|
|
116
118
|
/**
|
|
@@ -231,7 +231,7 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
|
|
|
231
231
|
* the write run can still under-count; the gross all-or-nothing mismatch is
|
|
232
232
|
* gone because both paths key off this same resolver + evidence.
|
|
233
233
|
*/
|
|
234
|
-
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
234
|
+
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
|
|
235
235
|
// This predictor is called only with a ref already rebuilt from trusted local
|
|
236
236
|
// evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
|
|
237
237
|
const remoteOwnerSlug = remoteOwnerHint?.trim();
|
|
@@ -241,6 +241,7 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
|
241
241
|
env,
|
|
242
242
|
trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
|
|
243
243
|
remoteOwnerSlug,
|
|
244
|
+
allowDeviceDefault: useDeviceDefault,
|
|
244
245
|
});
|
|
245
246
|
return resolution.status === "resolved";
|
|
246
247
|
}
|
|
@@ -385,7 +386,7 @@ export async function backfillHeldSessions(deps) {
|
|
|
385
386
|
const env = deps.env ?? process.env;
|
|
386
387
|
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
387
388
|
const predictCompany = deps.predictCompany ??
|
|
388
|
-
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
|
|
389
|
+
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
|
|
389
390
|
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
390
391
|
// An acked register is authoritative local evidence. Older clients could
|
|
391
392
|
// lose its state projection on a later hook write; recover that projection
|
|
@@ -434,7 +435,10 @@ export async function backfillHeldSessions(deps) {
|
|
|
434
435
|
continue;
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
|
-
|
|
438
|
+
// Historical repair may use the explicitly opted-in device default only
|
|
439
|
+
// when there is no durable local company evidence. Never manufacture a
|
|
440
|
+
// slug from the held event itself.
|
|
441
|
+
if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
|
|
438
442
|
result.unresolved += 1;
|
|
439
443
|
deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
|
|
440
444
|
continue;
|
|
@@ -442,7 +446,7 @@ export async function backfillHeldSessions(deps) {
|
|
|
442
446
|
const evidenceRef = {
|
|
443
447
|
sessionId,
|
|
444
448
|
harness,
|
|
445
|
-
companySlug: evidence.candidate
|
|
449
|
+
companySlug: evidence.candidate?.slug,
|
|
446
450
|
// Held-event fields are transport input, not trusted context. Preserve
|
|
447
451
|
// project/task only when the durable session state already owns them.
|
|
448
452
|
project: state?.projectId,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* authoritative in ProjectView because `hq mesh story` writes there.
|
|
7
7
|
* Metadata-only snapshot — never prompts or tokens.
|
|
8
8
|
*/
|
|
9
|
+
import type { SessionStateFile } from "../../../work-context/state.js";
|
|
9
10
|
export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
|
|
10
11
|
export interface BoardStorySnapshot {
|
|
11
12
|
id: string;
|
|
@@ -26,6 +27,8 @@ export interface BoardRefreshDeps {
|
|
|
26
27
|
/** Per-session last refresh timestamps (ms). */
|
|
27
28
|
lastRefresh?: Map<string, number>;
|
|
28
29
|
intervalMs?: number;
|
|
30
|
+
/** Authorizes a device-default-derived session before its tenant read. */
|
|
31
|
+
authorizeDeviceDefaultTenant?: (sessionId: string, state: SessionStateFile) => Promise<boolean>;
|
|
29
32
|
}
|
|
30
33
|
export declare function formatBoardMarkdown(input: {
|
|
31
34
|
sessionId: string;
|
|
@@ -92,6 +92,12 @@ export async function refreshBoundSessionBoards(deps) {
|
|
|
92
92
|
skipped += 1;
|
|
93
93
|
continue;
|
|
94
94
|
}
|
|
95
|
+
if (state.companyConfidence === "device_default" &&
|
|
96
|
+
deps.authorizeDeviceDefaultTenant &&
|
|
97
|
+
!(await deps.authorizeDeviceDefaultTenant(sessionId, state))) {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
95
101
|
try {
|
|
96
102
|
const board = await deps.readBoard(state.companyUid, state.projectId);
|
|
97
103
|
if (!board) {
|
|
@@ -19,6 +19,7 @@ export interface DaemonDoctorReport {
|
|
|
19
19
|
heldCount: number;
|
|
20
20
|
/** Distinct held sessions with neither bound state nor local company evidence. */
|
|
21
21
|
heldWithNoLocalEvidence: number;
|
|
22
|
+
attributedByDeviceDefault: number;
|
|
22
23
|
deadLetterCount: number;
|
|
23
24
|
/** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
|
|
24
25
|
unattributedEvents: UnattributedEventCounts;
|
|
@@ -47,6 +48,8 @@ export interface DaemonDoctorReport {
|
|
|
47
48
|
rejected?: number;
|
|
48
49
|
error?: string;
|
|
49
50
|
};
|
|
51
|
+
/** Work held or quarantined after default-derived membership was denied. */
|
|
52
|
+
deviceDefaultAuthorizationDenials: number;
|
|
50
53
|
}
|
|
51
54
|
export interface DaemonDoctorDeps {
|
|
52
55
|
home?: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
|
|
8
9
|
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
9
10
|
import { readSessionState } from "../../../work-context/state.js";
|
|
@@ -52,6 +53,21 @@ function oldestJsonlAgeMs(filePath, nowMs) {
|
|
|
52
53
|
return undefined;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
function countDeviceDefaultAttribution(root) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.readdirSync(path.join(root, "sessions")).filter((entry) => {
|
|
59
|
+
if (!entry.endsWith(".json"))
|
|
60
|
+
return false;
|
|
61
|
+
const state = readSessionState(entry.slice(0, -5), root);
|
|
62
|
+
return state?.companyConfidence === "device_default" &&
|
|
63
|
+
Boolean(state.companyUid) &&
|
|
64
|
+
state.contextStatus !== "needs_company";
|
|
65
|
+
}).length;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
55
71
|
export function collectDaemonDoctor(deps = {}) {
|
|
56
72
|
const home = deps.home ?? os.homedir();
|
|
57
73
|
const env = deps.env ?? process.env;
|
|
@@ -88,6 +104,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
88
104
|
!hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
|
|
89
105
|
!refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
|
|
90
106
|
}).length;
|
|
107
|
+
const attributedByDeviceDefault = countDeviceDefaultAttribution(ctxRoot);
|
|
91
108
|
const deadLetterCount = countJsonlLines(deadPath);
|
|
92
109
|
const unattributedEvents = countUnattributedEvents(meshRoot);
|
|
93
110
|
const outbox = outboxStats(ctxRoot);
|
|
@@ -95,6 +112,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
95
112
|
const companiesOnline = state?.companiesOnline ?? [];
|
|
96
113
|
const emitMode = state?.emitMode ?? "legacy";
|
|
97
114
|
const subscribed = mqttState === "connected";
|
|
115
|
+
const deviceDefaultAuthorizationDenials = state?.deviceDefaultAuthorizationDenials ?? 0;
|
|
98
116
|
const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
|
|
99
117
|
const unhealthyReasons = [];
|
|
100
118
|
const online = mqttState === "connected" && companiesOnline.length > 0;
|
|
@@ -140,6 +158,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
140
158
|
spoolDepth,
|
|
141
159
|
heldCount,
|
|
142
160
|
heldWithNoLocalEvidence,
|
|
161
|
+
attributedByDeviceDefault,
|
|
143
162
|
deadLetterCount,
|
|
144
163
|
unattributedEvents,
|
|
145
164
|
outboxDepth: outbox.depth,
|
|
@@ -164,6 +183,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
164
183
|
error: emitState.lastError,
|
|
165
184
|
}
|
|
166
185
|
: undefined,
|
|
186
|
+
deviceDefaultAuthorizationDenials,
|
|
167
187
|
};
|
|
168
188
|
}
|
|
169
189
|
export function formatDaemonDoctor(report) {
|
|
@@ -179,7 +199,7 @@ export function formatDaemonDoctor(report) {
|
|
|
179
199
|
// receive-only (direct) mode.
|
|
180
200
|
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
181
201
|
}
|
|
182
|
-
lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
202
|
+
lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
183
203
|
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
184
204
|
if (report.emitMode === "legacy") {
|
|
185
205
|
lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* - MQTT presence (contract-3)
|
|
9
9
|
* - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
|
|
10
10
|
*/
|
|
11
|
+
import { type DeliverFn } from "../../../work-context/outbox.js";
|
|
12
|
+
import { type SessionEventsPoster } from "../session-events-client.js";
|
|
11
13
|
import { type FlushSummary } from "../flush.js";
|
|
12
14
|
import { type BackfillHeldResult } from "../backfill-held.js";
|
|
15
|
+
import { type BoardReader } from "./board-refresh.js";
|
|
13
16
|
import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
|
|
14
17
|
import { type PidLockDeps } from "./pid-lock.js";
|
|
15
18
|
import { PresenceClient, type MqttConnectFn } from "./presence.js";
|
|
@@ -19,6 +22,7 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
|
19
22
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
20
23
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
21
24
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
25
|
+
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
22
26
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
23
27
|
export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
24
28
|
/**
|
|
@@ -79,6 +83,13 @@ export interface DaemonRunDeps {
|
|
|
79
83
|
exit?: (code: number) => void;
|
|
80
84
|
/** Token provider (tests). */
|
|
81
85
|
getToken?: () => Promise<string>;
|
|
86
|
+
/** Low-level tenant I/O seams used by authorization regression tests. */
|
|
87
|
+
poster?: SessionEventsPoster;
|
|
88
|
+
deliver?: DeliverFn;
|
|
89
|
+
readBoard?: BoardReader;
|
|
90
|
+
resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
|
|
91
|
+
companyUid: string;
|
|
92
|
+
} | null>;
|
|
82
93
|
log?: (dir: string, line: string) => void;
|
|
83
94
|
/** Stop after this many ms (tests). */
|
|
84
95
|
runForMs?: number;
|