@indigoai-us/hq-cli 5.274.0 → 5.275.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 +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
- package/dist/command-catalog.generated.d.ts +34 -1
- package/dist/command-catalog.generated.js +43 -1
- package/dist/commands/cloud.js +22 -9
- package/dist/commands/company.js +66 -3
- package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
- package/dist/commands/core-checkpoint-gate-setting.js +163 -0
- package/dist/commands/core.js +2 -0
- package/dist/commands/daemon.js +7 -2
- package/dist/commands/lanes-message.js +40 -7
- package/dist/commands/lanes-shared.d.ts +4 -0
- package/dist/commands/lanes-shared.js +5 -0
- package/dist/commands/lanes.js +41 -17
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +6 -0
- package/dist/lib/daemon/flag.d.ts +23 -0
- package/dist/lib/daemon/flag.js +57 -0
- package/dist/lib/daemon/install.js +6 -1
- package/dist/lib/daemon/remote-control.d.ts +6 -0
- package/dist/lib/daemon/remote-control.js +12 -0
- package/dist/lib/daemon/run.js +115 -2
- package/dist/lib/daemon/succession.d.ts +116 -0
- package/dist/lib/daemon/succession.js +262 -0
- package/dist/lib/daemon/supervisor.d.ts +34 -1
- package/dist/lib/daemon/supervisor.js +190 -23
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -1
- package/dist/lib/lanes/hire-grant-dispatch.js +37 -2
- package/dist/lib/lanes/host-capacity.js +7 -1
- package/dist/lib/lanes/keep-alive.js +2 -2
- package/dist/lib/lanes/models.d.ts +2 -0
- package/dist/lib/lanes/models.js +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq core checkpoint-gate-setting` — keeps the local cache that lets a
|
|
3
|
+
* company owner turn the checkpoint Stop gate off for their company (CO-58).
|
|
4
|
+
*
|
|
5
|
+
* The owner sets `companySettings.checkpointGateEnabled` on hq-pro
|
|
6
|
+
* (`hq company settings set --checkpoint-gate-enabled false`, or the console
|
|
7
|
+
* Settings page). The Stop gate script never calls the network: it reads
|
|
8
|
+
* `~/.hq/cache/checkpoint-gate/<slug>.json` and, when that is missing or older
|
|
9
|
+
* than ten minutes, starts this command detached with `--refresh`.
|
|
10
|
+
*
|
|
11
|
+
* `--refresh` reads the setting through the member-readable
|
|
12
|
+
* `GET /company-settings/checkpoint-gate` route and writes the cache only while
|
|
13
|
+
* the hq-flag `checkpoint.gate.company-setting` is on for the company. With the
|
|
14
|
+
* flag off it removes the cache, so the gate keeps today's behaviour. Any
|
|
15
|
+
* failure (no login, API unreachable, timeout) leaves the cache as it was: the
|
|
16
|
+
* gate then uses the last known value, or today's behaviour when there is none.
|
|
17
|
+
* Only the named company's setting is ever read.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
23
|
+
import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
24
|
+
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
25
|
+
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
26
|
+
export const CHECKPOINT_GATE_SETTING_FLAG = "checkpoint.gate.company-setting";
|
|
27
|
+
/** The refresh gives up after this long; the gate keeps the cached value. */
|
|
28
|
+
export const CHECKPOINT_GATE_REFRESH_TIMEOUT_MS = 3_000;
|
|
29
|
+
/** Same character set the gate script accepts; anything else is refused. */
|
|
30
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
31
|
+
export function assertCompanySlug(slug) {
|
|
32
|
+
if (!SLUG_RE.test(slug) || slug === "personal") {
|
|
33
|
+
throw new Error(`checkpoint-gate-setting: '${slug}' is not a company slug`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function checkpointGateCacheDir(home = os.homedir()) {
|
|
37
|
+
return path.join(home, ".hq", "cache", "checkpoint-gate");
|
|
38
|
+
}
|
|
39
|
+
export function checkpointGateCachePath(slug, home = os.homedir()) {
|
|
40
|
+
return path.join(checkpointGateCacheDir(home), `${slug}.json`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `readLanesRegistryFlag` resolves a failed registry read to `false`. The
|
|
44
|
+
* refresh must not treat that as "flag off" and delete a valid cache, so a
|
|
45
|
+
* reported failure becomes null (unknown).
|
|
46
|
+
*/
|
|
47
|
+
export async function readCheckpointGateSettingFlag(slug, companyUid) {
|
|
48
|
+
let failed = false;
|
|
49
|
+
const enabled = await readLanesRegistryFlag(CHECKPOINT_GATE_SETTING_FLAG, {
|
|
50
|
+
company: { slug, uid: companyUid },
|
|
51
|
+
reportFailure: () => {
|
|
52
|
+
failed = true;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return failed ? null : enabled;
|
|
56
|
+
}
|
|
57
|
+
async function defaultFetchSetting(token, companyUid, signal) {
|
|
58
|
+
const res = await vaultApiFetch({
|
|
59
|
+
token,
|
|
60
|
+
path: "/company-settings/checkpoint-gate",
|
|
61
|
+
query: { companyUid },
|
|
62
|
+
signal,
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw new Error(`GET /company-settings/checkpoint-gate returned ${res.status}`);
|
|
66
|
+
const body = (await res.json());
|
|
67
|
+
if (typeof body.checkpointGateEnabled !== "boolean") {
|
|
68
|
+
throw new Error("GET /company-settings/checkpoint-gate returned no boolean checkpointGateEnabled");
|
|
69
|
+
}
|
|
70
|
+
return body.checkpointGateEnabled;
|
|
71
|
+
}
|
|
72
|
+
function writeCacheAtomic(file, entry) {
|
|
73
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
74
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
|
+
fs.writeFileSync(tmp, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
function withTimeout(work, ms) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
let timer;
|
|
81
|
+
const timeout = new Promise((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
controller.abort();
|
|
84
|
+
reject(new Error(`timed out after ${ms} ms`));
|
|
85
|
+
}, ms);
|
|
86
|
+
});
|
|
87
|
+
return Promise.race([work(controller.signal), timeout]).finally(() => clearTimeout(timer));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Refresh one company's cache entry. Never throws for a network or auth
|
|
91
|
+
* failure: those return `kept` and leave the cache untouched.
|
|
92
|
+
*/
|
|
93
|
+
export async function refreshCheckpointGateSetting(slug, deps = {}) {
|
|
94
|
+
assertCompanySlug(slug);
|
|
95
|
+
const file = checkpointGateCachePath(slug, deps.home);
|
|
96
|
+
// Never interactive: a detached refresh must not open a browser login.
|
|
97
|
+
const getToken = deps.getToken ?? (() => ensureCognitoToken({ interactive: false }));
|
|
98
|
+
const resolveCompanyUid = deps.resolveCompanyUid ?? ((token, companySlug) => getEntityUid(token, { companySlug }));
|
|
99
|
+
const fetchSetting = deps.fetchSetting ?? defaultFetchSetting;
|
|
100
|
+
const readFlag = deps.readFlag ?? readCheckpointGateSettingFlag;
|
|
101
|
+
// One timeout covers every network step, the flag read included.
|
|
102
|
+
let fetched;
|
|
103
|
+
try {
|
|
104
|
+
fetched = await withTimeout(async (signal) => {
|
|
105
|
+
const token = await getToken();
|
|
106
|
+
const uid = await resolveCompanyUid(token, slug);
|
|
107
|
+
const enabled = await fetchSetting(token, uid, signal);
|
|
108
|
+
return { enabled, flag: await readFlag(slug, uid) };
|
|
109
|
+
}, deps.timeoutMs ?? CHECKPOINT_GATE_REFRESH_TIMEOUT_MS);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { status: "kept", company: slug, reason: error instanceof Error ? error.message : String(error) };
|
|
113
|
+
}
|
|
114
|
+
if (fetched.flag === null) {
|
|
115
|
+
return { status: "kept", company: slug, reason: `could not read ${CHECKPOINT_GATE_SETTING_FLAG}` };
|
|
116
|
+
}
|
|
117
|
+
if (!fetched.flag) {
|
|
118
|
+
fs.rmSync(file, { force: true });
|
|
119
|
+
return { status: "removed", company: slug, reason: "flag_off" };
|
|
120
|
+
}
|
|
121
|
+
const now = (deps.now?.() ?? new Date()).getTime();
|
|
122
|
+
writeCacheAtomic(file, { company: slug, checkpointGateEnabled: fetched.enabled, fetched_at: Math.floor(now / 1000) });
|
|
123
|
+
return { status: "written", company: slug, checkpointGateEnabled: fetched.enabled };
|
|
124
|
+
}
|
|
125
|
+
export function describeRefreshOutcome(outcome) {
|
|
126
|
+
switch (outcome.status) {
|
|
127
|
+
case "written":
|
|
128
|
+
return `checkpoint gate for ${outcome.company}: ${outcome.checkpointGateEnabled ? "on" : "off"} (cached)`;
|
|
129
|
+
case "removed":
|
|
130
|
+
return `checkpoint gate for ${outcome.company}: company setting not in use (${CHECKPOINT_GATE_SETTING_FLAG} is off); cache cleared`;
|
|
131
|
+
case "kept":
|
|
132
|
+
return `checkpoint gate for ${outcome.company}: could not refresh (${outcome.reason}); cache unchanged`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function registerCoreCheckpointGateSettingCommand(core) {
|
|
136
|
+
const command = core
|
|
137
|
+
.command("checkpoint-gate-setting")
|
|
138
|
+
.description("Refresh the local cache of a company's checkpoint gate setting (used by the Stop gate)")
|
|
139
|
+
.requiredOption("--company <slug>", "company whose setting to read")
|
|
140
|
+
.option("--refresh", "read the setting from HQ and update the local cache")
|
|
141
|
+
.option("--json", "print the result as JSON")
|
|
142
|
+
.action(async (options) => {
|
|
143
|
+
try {
|
|
144
|
+
assertCompanySlug(options.company);
|
|
145
|
+
if (!options.refresh) {
|
|
146
|
+
const file = checkpointGateCachePath(options.company);
|
|
147
|
+
const cached = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
|
|
148
|
+
process.stdout.write(`${options.json ? JSON.stringify({ company: options.company, cached }) : cached ? `checkpoint gate for ${options.company}: ${cached.checkpointGateEnabled ? "on" : "off"} (cached at ${new Date(cached.fetched_at * 1000).toISOString()})` : `checkpoint gate for ${options.company}: no cached setting`}\n`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const outcome = await refreshCheckpointGateSetting(options.company);
|
|
152
|
+
process.stdout.write(`${options.json ? JSON.stringify(outcome) : describeRefreshOutcome(outcome)}\n`);
|
|
153
|
+
// A timed-out request can keep sockets open; the answer is already out.
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
describeCoreCommand(command, { execution: "native", root: "cwd" });
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=core-checkpoint-gate-setting.js.map
|
package/dist/commands/core.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { Option } from "commander";
|
|
32
32
|
import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
|
|
33
33
|
import { registerCoreOntologyCommand } from "./core-ontology.js";
|
|
34
|
+
import { registerCoreCheckpointGateSettingCommand } from "./core-checkpoint-gate-setting.js";
|
|
34
35
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
35
36
|
import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
|
|
36
37
|
import { renderIndexTarget } from "../lib/index-render/index.js";
|
|
@@ -286,6 +287,7 @@ export function registerCoreCommands(program) {
|
|
|
286
287
|
// hosts native TypeScript plumbing when a scaffold contract needs it.
|
|
287
288
|
registerCoreCheckpointCommand(core);
|
|
288
289
|
registerCoreOntologyCommand(core);
|
|
290
|
+
registerCoreCheckpointGateSettingCommand(core);
|
|
289
291
|
// These command names intentionally own native implementations. Their
|
|
290
292
|
// similarly named scaffold assets are forwarders, so sending them through
|
|
291
293
|
// runEntry would recurse back into this command forever.
|
package/dist/commands/daemon.js
CHANGED
|
@@ -489,8 +489,13 @@ function binaryFingerprint(bin) {
|
|
|
489
489
|
}
|
|
490
490
|
}
|
|
491
491
|
async function runTmuxService(d, opts) {
|
|
492
|
-
const { runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
|
|
492
|
+
const { installFingerprint, runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
|
|
493
493
|
const { findOnPath } = await import("../lib/daemon/run.js");
|
|
494
|
+
const { resolveRunningInstall } = await import("../utils/version-gate.js");
|
|
495
|
+
// The daemon keeps this host running across a CLI update, so a new hq-cli
|
|
496
|
+
// install also counts as an update and restarts the session once it is quiet.
|
|
497
|
+
const cliRoot = resolveRunningInstall().packageRoot;
|
|
498
|
+
const watched = cliRoot ? [opts.watch, path.join(cliRoot, "package.json")] : [opts.watch];
|
|
494
499
|
const hqRoot = d.env.HQ_ROOT?.trim();
|
|
495
500
|
if (!hqRoot)
|
|
496
501
|
throw new DaemonCommandError(`${opts.label} needs HQ_ROOT (the daemon sets it from the saved HQ folder)`);
|
|
@@ -508,7 +513,7 @@ async function runTmuxService(d, opts) {
|
|
|
508
513
|
cwd: hqRoot,
|
|
509
514
|
argv: opts.argv,
|
|
510
515
|
label: opts.label,
|
|
511
|
-
binaryFingerprint: () => binaryFingerprint
|
|
516
|
+
binaryFingerprint: () => installFingerprint(watched, binaryFingerprint),
|
|
512
517
|
now: () => Date.now(),
|
|
513
518
|
run: d.run,
|
|
514
519
|
pollMs: 5_000,
|
|
@@ -13,7 +13,7 @@ import { keepAliveExclusion, keepAliveSupersededBy, LANE_SUPERSESSION_LINEAGE_AW
|
|
|
13
13
|
import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
|
|
14
14
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
15
15
|
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
16
|
-
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
16
|
+
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
17
17
|
export function createLaneMessageHandlers(deps) {
|
|
18
18
|
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
19
19
|
function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
|
|
@@ -30,7 +30,19 @@ export function createLaneMessageHandlers(deps) {
|
|
|
30
30
|
}
|
|
31
31
|
return current.lane_id === lane.lane_id ? undefined : current;
|
|
32
32
|
}
|
|
33
|
-
function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride) {
|
|
33
|
+
function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride, callerSessionIdOverride) {
|
|
34
|
+
if (lane.senior.kind === "session") {
|
|
35
|
+
const sessionSeniorId = lane.senior.id;
|
|
36
|
+
if (!sessionSeniorId)
|
|
37
|
+
return false;
|
|
38
|
+
if (callerLaneIdOverride !== undefined || ctx.env.HQ_LANE_ID?.trim())
|
|
39
|
+
return false;
|
|
40
|
+
if (callerSessionIdOverride === null)
|
|
41
|
+
return false;
|
|
42
|
+
return callerSessionIdOverride === undefined
|
|
43
|
+
? callerMatchesSessionSenior(ctx, sessionSeniorId)
|
|
44
|
+
: callerSessionIdOverride === sessionSeniorId;
|
|
45
|
+
}
|
|
34
46
|
if (lane.senior.kind !== "lane")
|
|
35
47
|
return false;
|
|
36
48
|
const callerLaneId = callerLaneIdOverride ?? ctx.env.HQ_LANE_ID?.trim();
|
|
@@ -86,11 +98,15 @@ export function createLaneMessageHandlers(deps) {
|
|
|
86
98
|
let lanes = localLanes;
|
|
87
99
|
const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
|
|
88
100
|
const dispatchedCallerId = grantContext?.superseded_message_caller_id;
|
|
89
|
-
const
|
|
90
|
-
|
|
101
|
+
const dispatchedSessionId = grantContext?.superseded_message_session_id;
|
|
102
|
+
const dispatchedContextBound = grantContext !== undefined &&
|
|
91
103
|
ctx.isSsmOwnerDispatch() &&
|
|
92
104
|
sameHireGrantSenior(grantContext.expected_senior, currentLane.senior);
|
|
93
|
-
|
|
105
|
+
const dispatchedSessionContext = dispatchedContextBound && currentLane.senior.kind === "session";
|
|
106
|
+
const dispatchedContextAuthorized = dispatchedContextBound && (dispatchedCallerId !== undefined ||
|
|
107
|
+
(dispatchedSessionId !== undefined && dispatchedSessionId === currentLane.senior.id));
|
|
108
|
+
if (dispatchedContextBound && grantContext &&
|
|
109
|
+
(dispatchedCallerId !== undefined || dispatchedSessionContext)) {
|
|
94
110
|
const merged = new Map(localLanes.map((record) => [record.lane_id, record]));
|
|
95
111
|
for (const snapshot of grantContext.hierarchy) {
|
|
96
112
|
const contextLane = keepAliveLaneFromSnapshot(snapshot);
|
|
@@ -101,14 +117,23 @@ export function createLaneMessageHandlers(deps) {
|
|
|
101
117
|
lanes = [...merged.values()];
|
|
102
118
|
}
|
|
103
119
|
const exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
|
|
104
|
-
const callerLaneId =
|
|
105
|
-
|
|
120
|
+
const callerLaneId = dispatchedContextBound ? dispatchedCallerId : undefined;
|
|
121
|
+
const callerSessionId = dispatchedContextBound
|
|
122
|
+
? (dispatchedSessionId === undefined ? null : dispatchedSessionId)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (exclusion === "superseded" && callerMayMessageSupersededLane(ctx, currentLane, lanes, lineageAware, companyForLane, callerLaneId, callerSessionId)) {
|
|
106
125
|
return {
|
|
107
126
|
lane: currentLane,
|
|
108
127
|
exclusion: undefined,
|
|
109
128
|
...(!dispatchedContextAuthorized && ctx.env.HQ_LANE_ID?.trim()
|
|
110
129
|
? { supersededMessageCallerId: ctx.env.HQ_LANE_ID.trim() }
|
|
111
130
|
: {}),
|
|
131
|
+
...(!dispatchedContextBound && currentLane.senior.kind === "session" &&
|
|
132
|
+
typeof currentLane.senior.id === "string" &&
|
|
133
|
+
currentLane.senior.id.length > 0 &&
|
|
134
|
+
callerMatchesSessionSenior(ctx, currentLane.senior.id)
|
|
135
|
+
? { supersededMessageSessionId: currentLane.senior.id }
|
|
136
|
+
: {}),
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
return { lane: currentLane, exclusion };
|
|
@@ -149,9 +174,17 @@ export function createLaneMessageHandlers(deps) {
|
|
|
149
174
|
const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
|
|
150
175
|
? undefined
|
|
151
176
|
: (await messageLaneExclusion(ctx, lane)).supersededMessageCallerId;
|
|
177
|
+
const sessionSeniorId = lane.senior.kind === "session" ? lane.senior.id : undefined;
|
|
178
|
+
const supersededMessageSessionId = !resolution.remoteOnly &&
|
|
179
|
+
typeof sessionSeniorId === "string" &&
|
|
180
|
+
sessionSeniorId.length > 0 &&
|
|
181
|
+
callerMatchesSessionSenior(ctx, sessionSeniorId)
|
|
182
|
+
? sessionSeniorId
|
|
183
|
+
: undefined;
|
|
152
184
|
await dispatchOutpostLaneCommand(ctx, lane, "message", {
|
|
153
185
|
...opts,
|
|
154
186
|
...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
|
|
187
|
+
...(supersededMessageSessionId === undefined ? {} : { supersededMessageSessionId }),
|
|
155
188
|
}, resolution.remoteOnly
|
|
156
189
|
? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
|
|
157
190
|
: undefined);
|
|
@@ -43,6 +43,8 @@ export interface LaneMessageOptions {
|
|
|
43
43
|
grantContext?: string;
|
|
44
44
|
/** Primary-verified caller identity for a superseded Outpost message. */
|
|
45
45
|
supersededMessageCallerId?: string;
|
|
46
|
+
/** Primary-verified session senior for a superseded Outpost message. */
|
|
47
|
+
supersededMessageSessionId?: string;
|
|
46
48
|
monitorEvent?: {
|
|
47
49
|
monitor_id: string;
|
|
48
50
|
description: string;
|
|
@@ -248,6 +250,8 @@ export declare function readLanePlacementEnabled(ctx: Ctx, company: LaneCompanyR
|
|
|
248
250
|
export declare function processLanePlacementCompany(ctx: Ctx): LaneCompanyRef | undefined;
|
|
249
251
|
export declare function parsePlacementPolicy(raw: string): LanePlacementPolicy;
|
|
250
252
|
export declare function assertPlacementEnabled(ctx: Ctx, company: LaneCompanyRef | undefined, readPlacementEnabled?: () => Promise<boolean>): Promise<void>;
|
|
253
|
+
/** Match a direct session-senior caller while refusing lane-worker identities. */
|
|
254
|
+
export declare function callerMatchesSessionSenior(ctx: Pick<Ctx, "env">, seniorSessionId: string): boolean;
|
|
251
255
|
export interface LaneWaitCommandOptions {
|
|
252
256
|
any?: string[];
|
|
253
257
|
for?: string[];
|
|
@@ -4,6 +4,7 @@ import { resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
|
4
4
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
5
5
|
import { AccountResolutionError, accountStatus, loadLanesConfig } from "../lib/lanes/index.js";
|
|
6
6
|
import { resolveLaneModel } from "../lib/lanes/spawn.js";
|
|
7
|
+
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
7
8
|
export function accountRequestKeepsCurrentLane(lane, rawAccount) {
|
|
8
9
|
const raw = rawAccount.trim();
|
|
9
10
|
const colon = raw.indexOf(":");
|
|
@@ -151,4 +152,8 @@ export async function assertPlacementEnabled(ctx, company, readPlacementEnabled
|
|
|
151
152
|
return;
|
|
152
153
|
throw new LanesError("lane_placement_disabled", `Lane placement options require hq-flags gate ${LANE_OUTPOST_LOAD_BALANCING_FLAG}`, { flag: LANE_OUTPOST_LOAD_BALANCING_FLAG });
|
|
153
154
|
}
|
|
155
|
+
/** Match a direct session-senior caller while refusing lane-worker identities. */
|
|
156
|
+
export function callerMatchesSessionSenior(ctx, seniorSessionId) {
|
|
157
|
+
return !ctx.env.HQ_LANE_ID?.trim() && sessionIdFromEnv(ctx.env) === seniorSessionId;
|
|
158
|
+
}
|
|
154
159
|
//# sourceMappingURL=lanes-shared.js.map
|
package/dist/commands/lanes.js
CHANGED
|
@@ -17,7 +17,7 @@ import { createLaneMoveHandlers } from "./lanes-move.js";
|
|
|
17
17
|
import { createLaneQuestionsHandlers } from "./lanes-questions.js";
|
|
18
18
|
import { createLaneResumeHandlers } from "./lanes-resume.js";
|
|
19
19
|
import { createLaneWatchHandlers } from "./lanes-watch.js";
|
|
20
|
-
import { assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readLanePlacementEnabled, } from "./lanes-shared.js";
|
|
20
|
+
import { callerMatchesSessionSenior, assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readLanePlacementEnabled, } from "./lanes-shared.js";
|
|
21
21
|
export { LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
|
|
22
22
|
export { resolveAnswerChoice } from "./lanes-questions.js";
|
|
23
23
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
@@ -826,9 +826,9 @@ async function withResumeHireAdmission(ctx, lane, fn, grantContext) {
|
|
|
826
826
|
const hqRoot = ctx.hqRoot();
|
|
827
827
|
if (grantContext !== undefined && (!ctx.isSsmOwnerDispatch() ||
|
|
828
828
|
!laneGrantTargetHostedHere(ctx, lane) ||
|
|
829
|
-
lane.senior.kind !== "lane" ||
|
|
829
|
+
(lane.senior.kind !== "lane" && lane.senior.kind !== "session") ||
|
|
830
830
|
!sameHireGrantSenior(lane.senior, grantContext.expected_senior))) {
|
|
831
|
-
throw new LanesError("hire_grant_senior_changed", `Lane ${lane.lane_id}'s senior changed after the primary authorized its
|
|
831
|
+
throw new LanesError("hire_grant_senior_changed", `Lane ${lane.lane_id}'s senior changed after the primary authorized its Outpost dispatch`, { lane_id: lane.lane_id, expected_senior: grantContext.expected_senior, current_senior: lane.senior });
|
|
832
832
|
}
|
|
833
833
|
if (!isTerminalLaneState(lane.state) ||
|
|
834
834
|
lane.senior.kind !== "lane" ||
|
|
@@ -1158,7 +1158,7 @@ async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config) {
|
|
|
1158
1158
|
const actualLocalLanes = hostname
|
|
1159
1159
|
? localLanes.filter((lane) => lane.host.kind === "local" && lane.host.id === hostname)
|
|
1160
1160
|
: [];
|
|
1161
|
-
if (
|
|
1161
|
+
if (hostname) {
|
|
1162
1162
|
snapshots.push({
|
|
1163
1163
|
capacity: buildHostCapacitySnapshot({
|
|
1164
1164
|
host: actualLocalHost,
|
|
@@ -2402,6 +2402,11 @@ async function runCreate(ctx, opts) {
|
|
|
2402
2402
|
let placementHostOverride;
|
|
2403
2403
|
let autoPlacementChoice;
|
|
2404
2404
|
let autoPlacementSkippedHosts = [];
|
|
2405
|
+
let placementMachineIdentity;
|
|
2406
|
+
const resolvePlacementMachineIdentity = () => {
|
|
2407
|
+
placementMachineIdentity ??= ctx.resolveMachineIdentity();
|
|
2408
|
+
return placementMachineIdentity;
|
|
2409
|
+
};
|
|
2405
2410
|
let balanceHostLoad;
|
|
2406
2411
|
let balanceTargetHost;
|
|
2407
2412
|
let inheritedPlacement;
|
|
@@ -2485,23 +2490,39 @@ async function runCreate(ctx, opts) {
|
|
|
2485
2490
|
}
|
|
2486
2491
|
throw err;
|
|
2487
2492
|
}
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2493
|
+
const chosenHost = autoPlacementChoice.host;
|
|
2494
|
+
const localHostname = ctx.hostname().trim();
|
|
2495
|
+
if (!localHostname) {
|
|
2496
|
+
throw new LanesError("host_identity_unknown", "Cannot resolve this host for automatic lane placement", {});
|
|
2492
2497
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2498
|
+
const localHost = { kind: "local", id: localHostname };
|
|
2499
|
+
let chosenHostIsCaller = chosenHost.id === currentHost.id ||
|
|
2500
|
+
sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(localHostname);
|
|
2501
|
+
if (!chosenHostIsCaller && chosenHost.kind === "outpost") {
|
|
2502
|
+
const identity = resolvePlacementMachineIdentity();
|
|
2503
|
+
if (identity.status === "ok") {
|
|
2504
|
+
const machineIds = [identity.id, identity.imds, identity.hostname]
|
|
2505
|
+
.filter((id) => typeof id === "string" && id.trim() !== "");
|
|
2506
|
+
chosenHostIsCaller = machineIds.some((id) => sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(id.trim()));
|
|
2507
|
+
}
|
|
2495
2508
|
}
|
|
2496
|
-
|
|
2509
|
+
if (chosenHostIsCaller) {
|
|
2510
|
+
// A capacity snapshot can identify this process as an Outpost (or an
|
|
2511
|
+
// alias of its machine). Auto placement must spawn locally so the
|
|
2512
|
+
// Outpost self-target guard is never reached.
|
|
2513
|
+
placementHostOverride = localHost;
|
|
2497
2514
|
outpostId = undefined;
|
|
2498
2515
|
}
|
|
2516
|
+
else {
|
|
2517
|
+
placementHostOverride = chosenHost;
|
|
2518
|
+
outpostId = chosenHost.kind === "outpost" ? chosenHost.id : undefined;
|
|
2519
|
+
}
|
|
2499
2520
|
}
|
|
2500
2521
|
}
|
|
2501
2522
|
let originHost;
|
|
2502
2523
|
let originHostSource;
|
|
2503
2524
|
if (outpostId) {
|
|
2504
|
-
const identity =
|
|
2525
|
+
const identity = resolvePlacementMachineIdentity();
|
|
2505
2526
|
if (identity.status !== "ok") {
|
|
2506
2527
|
throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
|
|
2507
2528
|
}
|
|
@@ -3486,10 +3507,10 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
|
|
|
3486
3507
|
grantHierarchy = hierarchy;
|
|
3487
3508
|
grantContext = createOutpostHireGrantContext(lane, hierarchy);
|
|
3488
3509
|
}
|
|
3489
|
-
else if ((action === "resume"
|
|
3490
|
-
lane.senior.kind === "lane") {
|
|
3510
|
+
else if ((action === "resume" && lane.senior.kind === "lane") ||
|
|
3511
|
+
(action === "message" && (lane.senior.kind === "lane" || lane.senior.kind === "session"))) {
|
|
3491
3512
|
const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId));
|
|
3492
|
-
grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined);
|
|
3513
|
+
grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined, action === "message" ? options.supersededMessageSessionId : undefined);
|
|
3493
3514
|
}
|
|
3494
3515
|
let dispatched;
|
|
3495
3516
|
let grantContextRejected = false;
|
|
@@ -4468,9 +4489,12 @@ function callerSeniorAuthorization(ctx, seniorRef) {
|
|
|
4468
4489
|
if (!sessionId)
|
|
4469
4490
|
return { authorized: false, reason: "no session id" };
|
|
4470
4491
|
if (seniorRef.kind === "session") {
|
|
4471
|
-
|
|
4492
|
+
const seniorSessionId = seniorRef.id;
|
|
4493
|
+
if (!seniorSessionId)
|
|
4494
|
+
return { authorized: false, reason: "session senior has no id" };
|
|
4495
|
+
return callerMatchesSessionSenior(ctx, seniorSessionId)
|
|
4472
4496
|
? { authorized: true }
|
|
4473
|
-
: { authorized: false, reason: `session id ${sessionId} is not senior session ${
|
|
4497
|
+
: { authorized: false, reason: `session id ${sessionId} is not senior session ${seniorSessionId}` };
|
|
4474
4498
|
}
|
|
4475
4499
|
if (seniorRef.kind !== "user")
|
|
4476
4500
|
return { authorized: false, reason: "senior is not a user" };
|
|
@@ -54,6 +54,13 @@ export interface UnitPlan {
|
|
|
54
54
|
command: UnitCommand | null;
|
|
55
55
|
env: Record<string, string>;
|
|
56
56
|
blocked?: UnitBlock;
|
|
57
|
+
/**
|
|
58
|
+
* Signal that asks the unit to finish its current work and exit, used when a
|
|
59
|
+
* new daemon replaces it after a CLI update. Without one it gets SIGTERM.
|
|
60
|
+
*/
|
|
61
|
+
drainSignal?: "SIGUSR2";
|
|
62
|
+
/** A new CLI version alone does not restart this unit; it restarts itself when idle. */
|
|
63
|
+
keepAcrossCliUpdates?: true;
|
|
57
64
|
}
|
|
58
65
|
export interface PlanContext {
|
|
59
66
|
machineType: MachineType;
|
|
@@ -183,6 +183,8 @@ function planService(service, ctx) {
|
|
|
183
183
|
description: `runs the local bot ${name}`,
|
|
184
184
|
restart: "on-failure",
|
|
185
185
|
command: { type: "hq", args: ["bot", "run", name] },
|
|
186
|
+
// A bot drains on SIGUSR2: running turns finish, then it exits.
|
|
187
|
+
drainSignal: "SIGUSR2",
|
|
186
188
|
}));
|
|
187
189
|
}
|
|
188
190
|
case "session-host":
|
|
@@ -267,6 +269,8 @@ function planService(service, ctx) {
|
|
|
267
269
|
type: "hq",
|
|
268
270
|
args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
|
|
269
271
|
},
|
|
272
|
+
// Restarting the host ends the tmux session people are using.
|
|
273
|
+
keepAcrossCliUpdates: true,
|
|
270
274
|
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
271
275
|
blocked: firstBlock([
|
|
272
276
|
[!script, `this machine has no Codex remote-control runner (${CODEX_RUNNER_SCRIPT})`],
|
|
@@ -290,6 +294,8 @@ function planService(service, ctx) {
|
|
|
290
294
|
base(service, {
|
|
291
295
|
command: { type: "hq", args },
|
|
292
296
|
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
297
|
+
// Restarting the host ends the tmux session people are using.
|
|
298
|
+
keepAcrossCliUpdates: true,
|
|
293
299
|
blocked: firstBlock([
|
|
294
300
|
[!ctx.claudePath, "the claude CLI is not installed"],
|
|
295
301
|
[!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flags read by the daemon. The daemon runs for weeks, so each read
|
|
3
|
+
* asks the registry again instead of holding a per-process answer, and it
|
|
4
|
+
* reads as off unless the registry itself says on (or a local override does).
|
|
5
|
+
*/
|
|
6
|
+
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
7
|
+
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
8
|
+
export declare const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
9
|
+
export interface DaemonFlagClient {
|
|
10
|
+
ready(): Promise<void>;
|
|
11
|
+
explain(flagKey: string, options?: {
|
|
12
|
+
fallback?: boolean;
|
|
13
|
+
}): FlagResolution;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
export interface ReadDaemonFlagOptions {
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
createClient?: (options: FlagClientOptions) => DaemonFlagClient;
|
|
19
|
+
getToken?: () => Promise<string>;
|
|
20
|
+
onError?: (error: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
|
|
23
|
+
//# sourceMappingURL=flag.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flags read by the daemon. The daemon runs for weeks, so each read
|
|
3
|
+
* asks the registry again instead of holding a per-process answer, and it
|
|
4
|
+
* reads as off unless the registry itself says on (or a local override does).
|
|
5
|
+
*/
|
|
6
|
+
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
7
|
+
import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
8
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
9
|
+
import { resolveProcessFlagCompanyContext } from "../flag-registry.js";
|
|
10
|
+
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
11
|
+
export const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
12
|
+
const READ_TIMEOUT_MS = 5_000;
|
|
13
|
+
export async function readDaemonFlag(flagKey, options = {}) {
|
|
14
|
+
const env = options.env ?? process.env;
|
|
15
|
+
const override = readLocalOverride(flagKey, env);
|
|
16
|
+
if (override !== null)
|
|
17
|
+
return override;
|
|
18
|
+
let failed = false;
|
|
19
|
+
const onError = (error) => {
|
|
20
|
+
failed = true;
|
|
21
|
+
options.onError?.(error);
|
|
22
|
+
};
|
|
23
|
+
let client;
|
|
24
|
+
try {
|
|
25
|
+
const company = resolveProcessFlagCompanyContext(env);
|
|
26
|
+
// Signed out reads as off without reaching the registry.
|
|
27
|
+
const token = await (options.getToken ?? (() => ensureCognitoIdToken({ interactive: false })))();
|
|
28
|
+
if (typeof token !== "string" || token.trim() === "")
|
|
29
|
+
return false;
|
|
30
|
+
client = (options.createClient ?? createFlagClient)({
|
|
31
|
+
endpoint: resolveFlagRegistryEndpoint(undefined, env),
|
|
32
|
+
getToken: () => token,
|
|
33
|
+
env: {},
|
|
34
|
+
refreshIntervalMs: 0,
|
|
35
|
+
requestTimeoutMs: READ_TIMEOUT_MS,
|
|
36
|
+
...(company.companyUid ? { companyUid: company.companyUid } : {}),
|
|
37
|
+
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
38
|
+
onError,
|
|
39
|
+
});
|
|
40
|
+
await client.ready();
|
|
41
|
+
const resolution = client.explain(flagKey, { fallback: false });
|
|
42
|
+
return !failed && resolution.source === "snapshot" && resolution.value === true;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
onError(error);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
try {
|
|
50
|
+
client?.close();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// closing a one-shot client cannot change the answer
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=flag.js.map
|
|
@@ -61,6 +61,11 @@ function serviceHost(host) {
|
|
|
61
61
|
* Output goes to the journal: an `append:` log file would be created by
|
|
62
62
|
* systemd as root inside the user's home, and the daemon (running as the user)
|
|
63
63
|
* writes its own logs under ~/.hq/daemon/logs.
|
|
64
|
+
*
|
|
65
|
+
* `KillMode=process`: systemd signals only the daemon. The daemon stops its
|
|
66
|
+
* services itself on SIGTERM, and after a CLI update it can leave them running
|
|
67
|
+
* for the next daemon to adopt (see succession.ts). A daemon that starts and
|
|
68
|
+
* finds services it did not adopt stops them first.
|
|
64
69
|
*/
|
|
65
70
|
function renderSystemdSystemUnit(host, user, userHome) {
|
|
66
71
|
const exec = [host.nodeBinary, host.hqBinary, "daemon", "run", "--managed"].map(shellQuote).join(" ");
|
|
@@ -87,7 +92,7 @@ ExecStart=${exec}
|
|
|
87
92
|
WorkingDirectory=${userHome}
|
|
88
93
|
Restart=always
|
|
89
94
|
RestartSec=10
|
|
90
|
-
KillMode=
|
|
95
|
+
KillMode=process
|
|
91
96
|
TimeoutStopSec=20
|
|
92
97
|
${envLines}
|
|
93
98
|
|
|
@@ -31,6 +31,12 @@ export declare function remoteControlArgs(opts: {
|
|
|
31
31
|
* (`--name outpost-1-2`), so taking the runner over keeps the name people see.
|
|
32
32
|
*/
|
|
33
33
|
export declare function runnerSessionName(script: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Fingerprint of what a host runs: the tool first, then other installs it
|
|
36
|
+
* depends on (the hq-cli package). Null while the tool cannot be read; an
|
|
37
|
+
* unreadable extra install is left out rather than hiding a tool update.
|
|
38
|
+
*/
|
|
39
|
+
export declare function installFingerprint(paths: readonly string[], fingerprint: (file: string) => string | null): string | null;
|
|
34
40
|
/** A tool updated on disk restarts its session once quiet this long... */
|
|
35
41
|
export declare const IDLE_BEFORE_RESTART_MS: number;
|
|
36
42
|
/** ...or after this long, however busy the session is. */
|
|
@@ -35,6 +35,18 @@ export function runnerSessionName(script) {
|
|
|
35
35
|
const m = /remote-control\b[^\n]*?--name[= ]+"?([^\s"]+)"?/.exec(script);
|
|
36
36
|
return m ? m[1] : null;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Fingerprint of what a host runs: the tool first, then other installs it
|
|
40
|
+
* depends on (the hq-cli package). Null while the tool cannot be read; an
|
|
41
|
+
* unreadable extra install is left out rather than hiding a tool update.
|
|
42
|
+
*/
|
|
43
|
+
export function installFingerprint(paths, fingerprint) {
|
|
44
|
+
const [tool, ...rest] = paths;
|
|
45
|
+
const toolFp = tool === undefined ? null : fingerprint(tool);
|
|
46
|
+
if (toolFp === null)
|
|
47
|
+
return null;
|
|
48
|
+
return [toolFp, ...rest.map(fingerprint).filter((fp) => fp !== null)].join("|");
|
|
49
|
+
}
|
|
38
50
|
/** A tool updated on disk restarts its session once quiet this long... */
|
|
39
51
|
export const IDLE_BEFORE_RESTART_MS = 10 * 60_000;
|
|
40
52
|
/** ...or after this long, however busy the session is. */
|