@indigoai-us/hq-cli 5.124.0 → 5.126.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 +149 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/dist/command-catalog.generated.d.ts +414 -5
- package/dist/command-catalog.generated.js +531 -5
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +75 -0
- package/dist/commands/lanes.js +2032 -0
- package/dist/commands/login.js +49 -6
- package/dist/commands/mesh.js +133 -28
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/doctor/checks/work-context.js +4 -2
- package/dist/lib/lanes/accounts.d.ts +43 -0
- package/dist/lib/lanes/accounts.js +128 -0
- package/dist/lib/lanes/config.d.ts +56 -0
- package/dist/lib/lanes/config.js +351 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/index.d.ts +19 -0
- package/dist/lib/lanes/index.js +12 -0
- package/dist/lib/lanes/liveness.d.ts +89 -0
- package/dist/lib/lanes/liveness.js +273 -0
- package/dist/lib/lanes/registry.d.ts +103 -0
- package/dist/lib/lanes/registry.js +247 -0
- package/dist/lib/lanes/store.d.ts +64 -0
- package/dist/lib/lanes/store.js +627 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +193 -0
- package/dist/lib/lanes/types.js +217 -0
- 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/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -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/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- 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/scan-packages/index.js +17 -12
- 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 +55 -0
- package/dist/lib/work-context/config.js +333 -67
- 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 +86 -9
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +14 -0
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +1 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* - macOS: LaunchAgent ai.getindigo.hq-mesh-daemon
|
|
5
5
|
* - Linux: systemd user unit hq-mesh-daemon.service
|
|
6
6
|
* (documented fallback: root-owned unit running as ec2-user)
|
|
7
|
+
* - Windows: per-user Scheduled Task ai.getindigo.hq-mesh-daemon registered
|
|
8
|
+
* from a rendered task XML via schtasks.exe (no native dependencies)
|
|
7
9
|
*
|
|
8
10
|
* The unit embeds the hq binary resolved at install time. Logs go to
|
|
9
11
|
* ~/.hq/work-mesh/daemon/log (rotation at 5MB handled by the daemon).
|
|
@@ -14,8 +16,39 @@ import * as os from "node:os";
|
|
|
14
16
|
import * as path from "node:path";
|
|
15
17
|
import { spawnSync } from "node:child_process";
|
|
16
18
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
17
|
-
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, } from "./paths.js";
|
|
18
|
-
import { registerTrustedHqRoot } from "./config.js";
|
|
19
|
+
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
|
+
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
|
|
21
|
+
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
|
+
/**
|
|
23
|
+
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
24
|
+
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
25
|
+
* and leaves node running as an orphan that still holds the pid lock. Stop
|
|
26
|
+
* the recorded daemon pid explicitly. Returns the pid that was signalled, or
|
|
27
|
+
* null when no live daemon pid was recorded.
|
|
28
|
+
*/
|
|
29
|
+
export function stopWindowsDaemonProcess(dir, deps = {}) {
|
|
30
|
+
const record = readPidLock(daemonPidPath(dir), {
|
|
31
|
+
readFileSync: deps.readFileSync,
|
|
32
|
+
existsSync: deps.existsSync,
|
|
33
|
+
});
|
|
34
|
+
if (!record || record.pid === (deps.selfPid ?? process.pid))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
(deps.kill ?? ((pid) => process.kill(pid)))(record.pid);
|
|
38
|
+
return record.pid;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const defaultSchtasks = (args) => {
|
|
45
|
+
const r = spawnSync("schtasks.exe", args, { encoding: "utf8", windowsHide: true });
|
|
46
|
+
return {
|
|
47
|
+
status: r.status,
|
|
48
|
+
stdout: r.stdout ?? "",
|
|
49
|
+
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
19
52
|
/**
|
|
20
53
|
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
21
54
|
* best-effort: an update must remain successful if a user has no service,
|
|
@@ -53,6 +86,16 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
53
86
|
}
|
|
54
87
|
return { attempted: false, restarted: false };
|
|
55
88
|
}
|
|
89
|
+
if (platform === "win32") {
|
|
90
|
+
const dir = daemonDir(undefined, home);
|
|
91
|
+
const xml = path.join(dir, `${WINDOWS_TASK_NAME}.xml`);
|
|
92
|
+
if (!existsSync(xml))
|
|
93
|
+
return { attempted: false, restarted: false };
|
|
94
|
+
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
95
|
+
stopWindowsDaemonProcess(dir, { existsSync, kill: opts.kill });
|
|
96
|
+
const result = run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
|
|
97
|
+
return { attempted: true, restarted: result.status === 0 };
|
|
98
|
+
}
|
|
56
99
|
}
|
|
57
100
|
catch {
|
|
58
101
|
// The caller has already completed the update; service restart failure is
|
|
@@ -65,6 +108,8 @@ export function detectPlatform(platform = process.platform) {
|
|
|
65
108
|
return "darwin";
|
|
66
109
|
if (platform === "linux")
|
|
67
110
|
return "linux";
|
|
111
|
+
if (platform === "win32")
|
|
112
|
+
return "win32";
|
|
68
113
|
return "other";
|
|
69
114
|
}
|
|
70
115
|
/**
|
|
@@ -128,10 +173,71 @@ export function buildInstallPaths(opts) {
|
|
|
128
173
|
plistPath: path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
|
|
129
174
|
systemdUserDir: path.join(home, ".config", "systemd", "user"),
|
|
130
175
|
systemdUnitPath: path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME),
|
|
176
|
+
windowsTaskXmlPath: path.join(ddir, `${WINDOWS_TASK_NAME}.xml`),
|
|
177
|
+
windowsUser: opts.windowsUser ?? resolveWindowsUser(env),
|
|
178
|
+
windowsHeadlessLauncher: opts.windowsHeadlessLauncher === undefined
|
|
179
|
+
? resolveWindowsHeadlessLauncher(env)
|
|
180
|
+
: (opts.windowsHeadlessLauncher ?? undefined),
|
|
131
181
|
systemUser: opts.systemUser,
|
|
132
182
|
hqRoot: opts.hqRoot,
|
|
183
|
+
hqRootSource: opts.hqRootSource,
|
|
133
184
|
};
|
|
134
185
|
}
|
|
186
|
+
/** `DOMAIN\user` for the current account, as Task Scheduler expects. */
|
|
187
|
+
export function resolveWindowsUser(env = process.env) {
|
|
188
|
+
const user = env.USERNAME?.trim() || safeUsername();
|
|
189
|
+
const domain = env.USERDOMAIN?.trim();
|
|
190
|
+
return domain ? `${domain}\\${user}` : user;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* `conhost.exe --headless` is undocumented. It shipped with the ConPTY
|
|
194
|
+
* conhost in Windows 10 1809 (build 17763); older conhost builds reject the
|
|
195
|
+
* flag and the task would fail on every logon. Gate on the kernel build
|
|
196
|
+
* reported by os.release() ("10.0.<build>") plus a presence check.
|
|
197
|
+
*/
|
|
198
|
+
export const WINDOWS_CONHOST_HEADLESS_MIN_BUILD = 17763;
|
|
199
|
+
/** Build number from an os.release() string such as "10.0.26200"; null if unparsable. */
|
|
200
|
+
export function parseWindowsBuild(release) {
|
|
201
|
+
const m = /^\d+\.\d+\.(\d+)/.exec(release.trim());
|
|
202
|
+
if (!m)
|
|
203
|
+
return null;
|
|
204
|
+
const build = Number(m[1]);
|
|
205
|
+
return Number.isFinite(build) ? build : null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* `conhost.exe --headless` (Windows 10 1809+) runs a console app with no
|
|
209
|
+
* window. Returns the launcher path only when conhost.exe exists under
|
|
210
|
+
* SystemRoot and the host build is new enough to honour `--headless`.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveWindowsHeadlessLauncher(env = process.env, existsSync = fs.existsSync, release = os.release()) {
|
|
213
|
+
const build = parseWindowsBuild(release);
|
|
214
|
+
if (build === null || build < WINDOWS_CONHOST_HEADLESS_MIN_BUILD)
|
|
215
|
+
return undefined;
|
|
216
|
+
const systemRoot = env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
|
|
217
|
+
const candidate = path.join(systemRoot, "System32", "conhost.exe");
|
|
218
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
219
|
+
}
|
|
220
|
+
/** Which launch wrapper the rendered task uses (recorded in the install message). */
|
|
221
|
+
export function windowsLaunchMode(paths) {
|
|
222
|
+
return paths.windowsHeadlessLauncher ? "conhost-headless" : "direct";
|
|
223
|
+
}
|
|
224
|
+
/** The task uses the interactive token, so it exists only inside the user's logon session. */
|
|
225
|
+
export function windowsLogonNotice(paths) {
|
|
226
|
+
return `runs at logon of ${paths.windowsUser}; not running while logged out`;
|
|
227
|
+
}
|
|
228
|
+
function describeWindowsLaunchMode(paths) {
|
|
229
|
+
return windowsLaunchMode(paths) === "conhost-headless"
|
|
230
|
+
? `mode: conhost --headless via ${paths.windowsHeadlessLauncher}`
|
|
231
|
+
: "mode: direct node (conhost --headless unavailable on this host; a console window may appear at logon)";
|
|
232
|
+
}
|
|
233
|
+
function safeUsername() {
|
|
234
|
+
try {
|
|
235
|
+
return os.userInfo().username;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
135
241
|
export function renderLaunchdPlist(paths) {
|
|
136
242
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
137
243
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -224,6 +330,224 @@ StandardError=append:/home/${user}/.hq/work-mesh/daemon/log
|
|
|
224
330
|
WantedBy=multi-user.target
|
|
225
331
|
`;
|
|
226
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Windows Task Scheduler definition (schtasks /Create /XML). Runs at logon of
|
|
335
|
+
* the registering user, hidden, no execution time limit, one instance, and
|
|
336
|
+
* restarts every minute on failure. HOME/USERPROFILE come from the user's own
|
|
337
|
+
* session. Task Scheduler XML has no environment block, so the HQ root the
|
|
338
|
+
* install was run for is passed explicitly as `mesh daemon run --hq-root
|
|
339
|
+
* <path>` (the launchd/systemd equivalent of Environment=HQ_ROOT) and is also
|
|
340
|
+
* persisted in the daemon config as a trusted root.
|
|
341
|
+
*
|
|
342
|
+
* LogonType is InteractiveToken: S4U ("run whether user is logged on or not")
|
|
343
|
+
* needs elevation to register, and the interactive token is what an
|
|
344
|
+
* unprivileged `hq mesh daemon install` can create. As a consequence the task
|
|
345
|
+
* runs only while the registering user is logged on. The console window that
|
|
346
|
+
* an interactive-token task would otherwise open is avoided by launching
|
|
347
|
+
* through `conhost.exe --headless` when the host supports it (see
|
|
348
|
+
* resolveWindowsHeadlessLauncher); otherwise node is run directly.
|
|
349
|
+
*/
|
|
350
|
+
export function renderWindowsTaskXml(paths) {
|
|
351
|
+
const user = escapeXml(paths.windowsUser);
|
|
352
|
+
const nodeArg = windowsArgQuote(paths.nodeBinary);
|
|
353
|
+
const hqArg = windowsArgQuote(paths.hqBinary);
|
|
354
|
+
const command = paths.windowsHeadlessLauncher ?? paths.nodeBinary;
|
|
355
|
+
const rootArgs = paths.hqRoot ? ` --hq-root ${windowsArgQuote(paths.hqRoot)}` : "";
|
|
356
|
+
const args = paths.windowsHeadlessLauncher
|
|
357
|
+
? `--headless ${nodeArg} ${hqArg} mesh daemon run${rootArgs}`
|
|
358
|
+
: `${hqArg} mesh daemon run${rootArgs}`;
|
|
359
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
360
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
361
|
+
<RegistrationInfo>
|
|
362
|
+
<URI>\\${WINDOWS_TASK_NAME}</URI>
|
|
363
|
+
<Description>HQ Work Mesh Live daemon (spool drainer + MQTT presence)</Description>
|
|
364
|
+
</RegistrationInfo>
|
|
365
|
+
<Triggers>
|
|
366
|
+
<LogonTrigger>
|
|
367
|
+
<Enabled>true</Enabled>
|
|
368
|
+
<UserId>${user}</UserId>
|
|
369
|
+
</LogonTrigger>
|
|
370
|
+
</Triggers>
|
|
371
|
+
<Principals>
|
|
372
|
+
<Principal id="Author">
|
|
373
|
+
<UserId>${user}</UserId>
|
|
374
|
+
<LogonType>${WINDOWS_TASK_LOGON_TYPE}</LogonType>
|
|
375
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
376
|
+
</Principal>
|
|
377
|
+
</Principals>
|
|
378
|
+
<Settings>
|
|
379
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
380
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
381
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
382
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
383
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
384
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
385
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
386
|
+
<Enabled>true</Enabled>
|
|
387
|
+
<Hidden>true</Hidden>
|
|
388
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
389
|
+
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
|
390
|
+
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
|
391
|
+
<WakeToRun>false</WakeToRun>
|
|
392
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
393
|
+
<Priority>7</Priority>
|
|
394
|
+
<RestartOnFailure>
|
|
395
|
+
<Interval>PT1M</Interval>
|
|
396
|
+
<Count>999</Count>
|
|
397
|
+
</RestartOnFailure>
|
|
398
|
+
</Settings>
|
|
399
|
+
<Actions Context="Author">
|
|
400
|
+
<Exec>
|
|
401
|
+
<Command>${escapeXml(command)}</Command>
|
|
402
|
+
<Arguments>${escapeXml(args)}</Arguments>
|
|
403
|
+
<WorkingDirectory>${escapeXml(paths.daemonDir)}</WorkingDirectory>
|
|
404
|
+
</Exec>
|
|
405
|
+
</Actions>
|
|
406
|
+
</Task>
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
const WINDOWS_TASK_LOGON_TYPE = "InteractiveToken";
|
|
410
|
+
/** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
|
|
411
|
+
export function encodeWindowsTaskXml(rendered) {
|
|
412
|
+
return Buffer.from(`\uFEFF${rendered}`, "utf16le");
|
|
413
|
+
}
|
|
414
|
+
function windowsArgQuote(value) {
|
|
415
|
+
if (/^[A-Za-z0-9_.:\\/-]+$/.test(value))
|
|
416
|
+
return value;
|
|
417
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
418
|
+
}
|
|
419
|
+
/** Status column of `schtasks /Query /FO CSV /NH`: "TaskName","Next Run Time","Status". */
|
|
420
|
+
export function parseSchtasksCsvStatus(stdout) {
|
|
421
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
422
|
+
const cells = line.match(/"([^"]*)"/g);
|
|
423
|
+
if (!cells || cells.length < 3)
|
|
424
|
+
continue;
|
|
425
|
+
return cells[2].slice(1, -1).trim();
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/** Split one CSV line as schtasks emits it (all cells quoted, `""` escapes a quote). */
|
|
430
|
+
function splitCsvLine(line) {
|
|
431
|
+
const cells = [];
|
|
432
|
+
let cur = "";
|
|
433
|
+
let inQuotes = false;
|
|
434
|
+
for (let i = 0; i < line.length; i++) {
|
|
435
|
+
const ch = line[i];
|
|
436
|
+
if (inQuotes) {
|
|
437
|
+
if (ch === '"') {
|
|
438
|
+
if (line[i + 1] === '"') {
|
|
439
|
+
cur += '"';
|
|
440
|
+
i++;
|
|
441
|
+
}
|
|
442
|
+
else
|
|
443
|
+
inQuotes = false;
|
|
444
|
+
}
|
|
445
|
+
else
|
|
446
|
+
cur += ch;
|
|
447
|
+
}
|
|
448
|
+
else if (ch === '"')
|
|
449
|
+
inQuotes = true;
|
|
450
|
+
else if (ch === ",") {
|
|
451
|
+
cells.push(cur);
|
|
452
|
+
cur = "";
|
|
453
|
+
}
|
|
454
|
+
else
|
|
455
|
+
cur += ch;
|
|
456
|
+
}
|
|
457
|
+
cells.push(cur);
|
|
458
|
+
return cells;
|
|
459
|
+
}
|
|
460
|
+
/** Column index of "Last Result" in `schtasks /Query /V /FO CSV` (fixed order, header localized). */
|
|
461
|
+
const SCHTASKS_LAST_RESULT_COLUMN = 6;
|
|
462
|
+
/**
|
|
463
|
+
* "Last Result" of `schtasks /Query /TN <name> /V /FO CSV`, normalized to an
|
|
464
|
+
* unsigned 32-bit code (schtasks prints HRESULT/NTSTATUS values as signed
|
|
465
|
+
* decimals, e.g. -2147024894 for 0x80070002). Prefers the English header
|
|
466
|
+
* when present; otherwise uses the fixed column position. Null when the
|
|
467
|
+
* output carries no task row or the cell is not numeric.
|
|
468
|
+
*/
|
|
469
|
+
export function parseSchtasksLastResult(stdout) {
|
|
470
|
+
const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
471
|
+
if (lines.length === 0)
|
|
472
|
+
return null;
|
|
473
|
+
let column = SCHTASKS_LAST_RESULT_COLUMN;
|
|
474
|
+
let rows = lines;
|
|
475
|
+
const header = splitCsvLine(lines[0]);
|
|
476
|
+
const headerIdx = header.findIndex((h) => /^last result$/i.test(h.trim()));
|
|
477
|
+
if (headerIdx >= 0 || /^hostname$/i.test(header[0]?.trim() ?? "")) {
|
|
478
|
+
if (headerIdx >= 0)
|
|
479
|
+
column = headerIdx;
|
|
480
|
+
rows = lines.slice(1);
|
|
481
|
+
}
|
|
482
|
+
for (const line of rows) {
|
|
483
|
+
const cells = splitCsvLine(line);
|
|
484
|
+
if (cells.length <= column)
|
|
485
|
+
continue;
|
|
486
|
+
const raw = cells[column].trim();
|
|
487
|
+
if (!/^-?\d+$/.test(raw))
|
|
488
|
+
continue;
|
|
489
|
+
return Number(raw) >>> 0;
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Task Scheduler informational results mean "no failed start": 0 is success,
|
|
495
|
+
* and every SCHED_S_* code (ready, running, not yet run, terminated, queued,
|
|
496
|
+
* ...) is a success-severity HRESULT (bit 31 clear) in FACILITY_ITF (0x4)
|
|
497
|
+
* with code 0x1300-0x13FF, i.e. 0x00041300-0x000413FF. Everything else
|
|
498
|
+
* (0x8007xxxx, 0xC000xxxx, process exit codes such as 1) is a failed start.
|
|
499
|
+
*/
|
|
500
|
+
export function schtasksLastResultFailed(code) {
|
|
501
|
+
if (code === null || code === 0)
|
|
502
|
+
return false;
|
|
503
|
+
const severityFailed = (code & 0x80000000) !== 0;
|
|
504
|
+
const facility = (code >>> 16) & 0x7ff;
|
|
505
|
+
const lowCode = code & 0xffff;
|
|
506
|
+
if (!severityFailed && facility === 0x4 && lowCode >= 0x1300 && lowCode <= 0x13ff)
|
|
507
|
+
return false;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
/** `--hq-root <path>` from a rendered task's <Arguments> (XML-escaped, optionally quoted). */
|
|
511
|
+
export function parseWindowsTaskHqRoot(xml) {
|
|
512
|
+
const m = /<Arguments>([^<]*)<\/Arguments>/.exec(xml);
|
|
513
|
+
if (!m)
|
|
514
|
+
return null;
|
|
515
|
+
const args = unescapeXml(m[1]);
|
|
516
|
+
const quoted = /--hq-root "((?:[^"\\]|\\.)*)"/.exec(args);
|
|
517
|
+
if (quoted)
|
|
518
|
+
return quoted[1].replace(/\\"/g, '"');
|
|
519
|
+
const bare = /--hq-root (\S+)/.exec(args);
|
|
520
|
+
return bare ? bare[1] : null;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The HQ root the installed Windows task actually carries: the task XML on
|
|
524
|
+
* disk first, else the first persisted trusted root. Reads state only; never
|
|
525
|
+
* resolves from cwd, so status reports what install recorded.
|
|
526
|
+
*/
|
|
527
|
+
export function readRecordedWindowsHqRoot(paths, deps = {}) {
|
|
528
|
+
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
529
|
+
const readFileSync = deps.readFileSync ?? fs.readFileSync;
|
|
530
|
+
try {
|
|
531
|
+
if (existsSync(paths.windowsTaskXmlPath)) {
|
|
532
|
+
const raw = readFileSync(paths.windowsTaskXmlPath);
|
|
533
|
+
const text = Buffer.isBuffer(raw) ? raw.toString("utf16le").replace(/^\uFEFF/, "") : String(raw);
|
|
534
|
+
const fromXml = parseWindowsTaskHqRoot(text);
|
|
535
|
+
if (fromXml)
|
|
536
|
+
return fromXml;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// fall through to config
|
|
541
|
+
}
|
|
542
|
+
return readDaemonConfig(paths.daemonDir).trustedHqRoots[0] ?? null;
|
|
543
|
+
}
|
|
544
|
+
function unescapeXml(value) {
|
|
545
|
+
return value
|
|
546
|
+
.replace(/"/g, '"')
|
|
547
|
+
.replace(/</g, "<")
|
|
548
|
+
.replace(/>/g, ">")
|
|
549
|
+
.replace(/&/g, "&");
|
|
550
|
+
}
|
|
227
551
|
function escapeXml(value) {
|
|
228
552
|
return value
|
|
229
553
|
.replace(/&/g, "&")
|
|
@@ -292,6 +616,41 @@ export function installDaemonService(opts) {
|
|
|
292
616
|
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
|
|
293
617
|
};
|
|
294
618
|
}
|
|
619
|
+
if (platform === "win32") {
|
|
620
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
621
|
+
const rendered = renderWindowsTaskXml(paths);
|
|
622
|
+
writeFileSync(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), { mode: 0o600 });
|
|
623
|
+
const created = schtasks([
|
|
624
|
+
"/Create", "/TN", WINDOWS_TASK_NAME, "/XML", paths.windowsTaskXmlPath, "/F",
|
|
625
|
+
]);
|
|
626
|
+
if (created.status !== 0) {
|
|
627
|
+
throw new Error(`schtasks /Create failed for ${WINDOWS_TASK_NAME} (exit ${created.status ?? "signal"}): ` +
|
|
628
|
+
`${(created.stderr || created.stdout).trim()}`);
|
|
629
|
+
}
|
|
630
|
+
// /F replaced any previous registration without ending its instance; stop
|
|
631
|
+
// the recorded daemon so the fresh task instance can take the pid lock.
|
|
632
|
+
schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
633
|
+
stopWindowsDaemonProcess(paths.daemonDir, { kill: opts.kill });
|
|
634
|
+
const started = schtasks(["/Run", "/TN", WINDOWS_TASK_NAME]);
|
|
635
|
+
const running = started.status === 0;
|
|
636
|
+
const mode = describeWindowsLaunchMode(paths);
|
|
637
|
+
const rootLine = paths.hqRoot
|
|
638
|
+
? `HQ root: ${paths.hqRoot}${paths.hqRootSource ? ` (from ${paths.hqRootSource})` : ""}\n`
|
|
639
|
+
: "";
|
|
640
|
+
return {
|
|
641
|
+
platform,
|
|
642
|
+
action: "install",
|
|
643
|
+
paths,
|
|
644
|
+
rendered,
|
|
645
|
+
dest: paths.windowsTaskXmlPath,
|
|
646
|
+
running,
|
|
647
|
+
message: running
|
|
648
|
+
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
|
|
649
|
+
: `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
|
|
650
|
+
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.`,
|
|
651
|
+
notice: windowsLogonNotice(paths),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
295
654
|
return {
|
|
296
655
|
platform,
|
|
297
656
|
action: "install",
|
|
@@ -304,6 +663,30 @@ export function uninstallDaemonService(opts) {
|
|
|
304
663
|
const paths = opts.paths ?? buildInstallPaths({});
|
|
305
664
|
const unlinkSync = opts.unlinkSync ?? fs.unlinkSync;
|
|
306
665
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
666
|
+
if (platform === "win32") {
|
|
667
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
668
|
+
const registered = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status === 0;
|
|
669
|
+
if (registered) {
|
|
670
|
+
schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
671
|
+
stopWindowsDaemonProcess(paths.daemonDir, { existsSync, kill: opts.kill });
|
|
672
|
+
const deleted = schtasks(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]);
|
|
673
|
+
if (deleted.status !== 0) {
|
|
674
|
+
throw new Error(`schtasks /Delete failed for ${WINDOWS_TASK_NAME} (exit ${deleted.status ?? "signal"}): ` +
|
|
675
|
+
`${(deleted.stderr || deleted.stdout).trim()}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (existsSync(paths.windowsTaskXmlPath))
|
|
679
|
+
unlinkSync(paths.windowsTaskXmlPath);
|
|
680
|
+
return {
|
|
681
|
+
platform,
|
|
682
|
+
action: "uninstall",
|
|
683
|
+
paths,
|
|
684
|
+
dest: paths.windowsTaskXmlPath,
|
|
685
|
+
message: registered
|
|
686
|
+
? `Removed Scheduled Task ${WINDOWS_TASK_NAME}`
|
|
687
|
+
: `Scheduled Task ${WINDOWS_TASK_NAME} was not registered; removed local task XML if present`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
307
690
|
if (platform === "darwin") {
|
|
308
691
|
if (existsSync(paths.plistPath)) {
|
|
309
692
|
opts.deactivate?.(platform, paths.plistPath);
|
|
@@ -345,6 +728,65 @@ export function daemonServiceStatus(opts) {
|
|
|
345
728
|
const platform = opts.platform ?? detectPlatform();
|
|
346
729
|
const paths = opts.paths ?? buildInstallPaths({});
|
|
347
730
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
731
|
+
if (platform === "win32") {
|
|
732
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
733
|
+
// schtasks only tells us whether the task exists. Its Status column is
|
|
734
|
+
// localized ("Running" / "Wird ausgeführt" / ...), so the daemon pid lock
|
|
735
|
+
// is the source of truth for "running"; the column is shown when it
|
|
736
|
+
// happens to carry a value, as extra context only.
|
|
737
|
+
const query = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]);
|
|
738
|
+
const installed = query.status === 0;
|
|
739
|
+
const taskState = installed ? parseSchtasksCsvStatus(query.stdout) : null;
|
|
740
|
+
// The verbose query carries "Last Result": a non-zero, non-informational
|
|
741
|
+
// code means the last start failed (for example conhost rejecting
|
|
742
|
+
// --headless), which the pid lock alone would report as merely "not
|
|
743
|
+
// running". Surface it so a failed start never looks like a clean idle.
|
|
744
|
+
const verbose = installed
|
|
745
|
+
? schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/V", "/FO", "CSV"])
|
|
746
|
+
: null;
|
|
747
|
+
const lastResult = verbose && verbose.status === 0 ? parseSchtasksLastResult(verbose.stdout) : null;
|
|
748
|
+
const lastRunFailed = schtasksLastResultFailed(lastResult);
|
|
749
|
+
const lock = pidLockStatus(daemonPidPath(paths.daemonDir), {
|
|
750
|
+
isProcessAlive: opts.isProcessAlive ?? defaultPidLockDeps().isProcessAlive,
|
|
751
|
+
readFileSync: opts.readFileSync,
|
|
752
|
+
existsSync,
|
|
753
|
+
});
|
|
754
|
+
const running = lock === "held";
|
|
755
|
+
// Read the root the task actually carries and re-check it: a moved,
|
|
756
|
+
// renamed, or deleted HQ tree leaves the task pointing at nothing.
|
|
757
|
+
const recordedHqRoot = installed
|
|
758
|
+
? readRecordedWindowsHqRoot(paths, { existsSync, readFileSync: opts.readFileSync })
|
|
759
|
+
: null;
|
|
760
|
+
const rootValid = recordedHqRoot ? isInstallableHqRoot(recordedHqRoot) : undefined;
|
|
761
|
+
const detail = (taskState ? ` [task: ${taskState}]` : "") +
|
|
762
|
+
(lastRunFailed ? ` [last run failed: result ${lastResult}]` : "") +
|
|
763
|
+
(rootValid === false ? ` [recorded root no longer qualifies: ${recordedHqRoot}]` : "");
|
|
764
|
+
let message;
|
|
765
|
+
if (!installed) {
|
|
766
|
+
message = running
|
|
767
|
+
? `Scheduled Task ${WINDOWS_TASK_NAME}: not installed (daemon running outside the task)`
|
|
768
|
+
: `Scheduled Task ${WINDOWS_TASK_NAME}: not installed`;
|
|
769
|
+
}
|
|
770
|
+
else if (running) {
|
|
771
|
+
message = `Scheduled Task ${WINDOWS_TASK_NAME}: running${detail}`;
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
message = `Scheduled Task ${WINDOWS_TASK_NAME}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
platform,
|
|
778
|
+
action: "status",
|
|
779
|
+
paths,
|
|
780
|
+
dest: paths.windowsTaskXmlPath,
|
|
781
|
+
running,
|
|
782
|
+
message,
|
|
783
|
+
lastResult,
|
|
784
|
+
lastRunFailed,
|
|
785
|
+
notice: installed ? windowsLogonNotice(paths) : undefined,
|
|
786
|
+
recordedHqRoot,
|
|
787
|
+
rootValid,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
348
790
|
if (platform === "darwin") {
|
|
349
791
|
const installed = existsSync(paths.plistPath);
|
|
350
792
|
const running = installed
|
|
@@ -9,6 +9,8 @@ export declare const DAEMON_LOG_NAME = "log";
|
|
|
9
9
|
export declare const DAEMON_LOG_MAX_BYTES: number;
|
|
10
10
|
export declare const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
|
|
11
11
|
export declare const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
|
|
12
|
+
/** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
|
|
13
|
+
export declare const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
|
|
12
14
|
export declare function daemonDir(meshRoot?: string, home?: string, env?: NodeJS.ProcessEnv): string;
|
|
13
15
|
export declare function daemonPidPath(dir?: string): string;
|
|
14
16
|
export declare function daemonStatePath(dir?: string): string;
|
|
@@ -12,6 +12,8 @@ export const DAEMON_LOG_NAME = "log";
|
|
|
12
12
|
export const DAEMON_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
13
13
|
export const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
|
|
14
14
|
export const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
|
|
15
|
+
/** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
|
|
16
|
+
export const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
|
|
15
17
|
export function daemonDir(meshRoot, home = os.homedir(), env = process.env) {
|
|
16
18
|
return path.join(meshRoot ?? workMeshRoot(home, env), DAEMON_DIRNAME);
|
|
17
19
|
}
|
|
@@ -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;
|