@indigoai-us/hq-cli 5.345.198 → 5.345.200
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 +71 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
- package/dist/command-catalog.generated.d.ts +150 -6
- package/dist/command-catalog.generated.js +182 -6
- package/dist/commands/bot-events.d.ts +19 -0
- package/dist/commands/bot-events.js +46 -0
- package/dist/commands/bot-jobs.d.ts +2 -0
- package/dist/commands/bot-jobs.js +43 -2
- package/dist/commands/bot-runtime.d.ts +35 -0
- package/dist/commands/bot-runtime.js +115 -0
- package/dist/commands/bot.d.ts +11 -0
- package/dist/commands/bot.js +72 -11
- package/dist/commands/creators.js +2 -1
- package/dist/commands/lanes-report.d.ts +2 -1
- package/dist/commands/lanes-report.js +44 -1
- package/dist/commands/lanes.d.ts +9 -0
- package/dist/commands/lanes.js +95 -9
- package/dist/commands/library-remove.js +2 -1
- package/dist/commands/library.js +5 -4
- package/dist/commands/meetings.js +1 -21
- package/dist/commands/mesh.d.ts +0 -7
- package/dist/commands/mesh.js +5 -1
- package/dist/commands/signals-run.d.ts +2 -0
- package/dist/commands/signals-run.js +6 -1
- package/dist/commands/sync-held.js +2 -2
- package/dist/lanes-drain-hook.js +9 -0
- package/dist/lib/bot/api.d.ts +5 -0
- package/dist/lib/bot/claude-login.d.ts +31 -0
- package/dist/lib/bot/claude-login.js +160 -0
- package/dist/lib/bot/events.d.ts +18 -0
- package/dist/lib/bot/events.js +160 -0
- package/dist/lib/bot/executive-settings.d.ts +10 -0
- package/dist/lib/bot/executive-settings.js +43 -0
- package/dist/lib/bot/fleet-cron.js +4 -0
- package/dist/lib/bot/fleet-download.d.ts +27 -0
- package/dist/lib/bot/fleet-download.js +162 -0
- package/dist/lib/bot/fleet-local.d.ts +51 -2
- package/dist/lib/bot/fleet-local.js +179 -76
- package/dist/lib/bot/fleet-python.d.ts +2 -0
- package/dist/lib/bot/fleet-python.js +29 -0
- package/dist/lib/bot/job-run.js +1 -1
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
- package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
- package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
- package/dist/lib/bot/jobs/meeting-detect.js +176 -0
- package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
- package/dist/lib/bot/jobs/meeting-rider.js +284 -0
- package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
- package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
- package/dist/lib/bot/local-jobs.d.ts +15 -2
- package/dist/lib/bot/local-jobs.js +24 -7
- package/dist/lib/bot/run.d.ts +7 -49
- package/dist/lib/bot/run.js +100 -4
- package/dist/lib/bot/status.d.ts +21 -0
- package/dist/lib/doctor/checks/bots.d.ts +6 -0
- package/dist/lib/doctor/checks/bots.js +47 -1
- package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
- package/dist/lib/hot-channels/adapters/meetings.js +3 -1
- package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
- package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
- package/dist/lib/hot-channels/schedule.js +2 -2
- package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
- package/dist/lib/hot-channels/signals-pass.js +51 -17
- package/dist/lib/lanes/admission.d.ts +2 -0
- package/dist/lib/lanes/admission.js +4 -0
- package/dist/lib/lanes/approval-gate.js +4 -1
- package/dist/lib/lanes/links.d.ts +8 -1
- package/dist/lib/lanes/links.js +84 -3
- package/dist/lib/lanes/mesh-events.js +3 -1
- package/dist/lib/lanes/outcome.d.ts +154 -0
- package/dist/lib/lanes/outcome.js +151 -0
- package/dist/lib/lanes/reconcile.js +1 -0
- package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
- package/dist/lib/lanes/signal-brief-template.js +40 -0
- package/dist/lib/lanes/signal-lane.d.ts +24 -0
- package/dist/lib/lanes/signal-lane.js +99 -0
- package/dist/lib/lanes/source-report.d.ts +37 -0
- package/dist/lib/lanes/source-report.js +66 -0
- package/dist/lib/lanes/spawn.d.ts +6 -0
- package/dist/lib/lanes/spawn.js +21 -0
- package/dist/lib/lanes/store.d.ts +3 -0
- package/dist/lib/lanes/store.js +64 -0
- package/dist/lib/lanes/story-ledger.d.ts +3 -0
- package/dist/lib/lanes/story-ledger.js +1 -0
- package/dist/lib/lanes/types.d.ts +137 -0
- package/dist/lib/lanes/types.js +17 -2
- package/dist/lib/lanes/watcher.js +4 -0
- package/dist/lib/live-meetings.d.ts +19 -0
- package/dist/lib/live-meetings.js +23 -0
- package/dist/lib/mesh/api.js +2 -1
- package/dist/lib/mesh/live/daemon/doctor.js +6 -7
- package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
- package/dist/lib/mesh/live/daemon/run.js +3 -0
- package/dist/lib/mesh/live/emit.d.ts +2 -11
- package/dist/lib/mesh/live/emit.js +20 -2
- package/dist/lib/mesh/live/flush.d.ts +2 -0
- package/dist/lib/mesh/live/flush.js +34 -3
- package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
- package/dist/lib/mesh/live/reporting-privacy.js +47 -0
- package/dist/lib/mesh/live/spool.d.ts +8 -0
- package/dist/lib/mesh/live/spool.js +59 -40
- package/dist/lib/plan-limit-nag.d.ts +30 -4
- package/dist/lib/plan-limit-nag.js +68 -17
- package/dist/lib/signals/engine.d.ts +4 -0
- package/dist/lib/signals/engine.js +25 -5
- package/dist/lib/signals/incremental.d.ts +42 -0
- package/dist/lib/signals/incremental.js +191 -0
- package/dist/lib/signals/prior-signals.d.ts +11 -0
- package/dist/lib/signals/prior-signals.js +44 -0
- package/dist/lib/signals/reply.js +6 -1
- package/dist/lib/signals/settings.d.ts +5 -0
- package/dist/lib/signals/settings.js +17 -0
- package/dist/lib/signals/steps/extract.d.ts +23 -2
- package/dist/lib/signals/steps/extract.js +142 -9
- package/dist/lib/signals/steps/types.d.ts +1 -0
- package/dist/lib/signals/write-guard.js +7 -0
- package/dist/lib/work-context/company.d.ts +2 -3
- package/dist/lib/work-context/company.js +4 -22
- package/dist/secrets-env-action.js +5 -2
- package/dist/utils/cognito-callback-port.d.ts +17 -0
- package/dist/utils/cognito-callback-port.js +56 -0
- package/dist/utils/cognito-config.js +7 -3
- package/dist/utils/cognito-session.d.ts +7 -6
- package/dist/utils/cognito-session.js +19 -14
- package/dist/utils/interactive-caller.d.ts +23 -0
- package/dist/utils/interactive-caller.js +80 -0
- package/dist/utils/login-browser.js +5 -2
- package/dist/utils/resident-package-preload.js +4 -0
- package/package.json +7 -7
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Local event journal and durable inbox. Cursor and queued deliveries commit together. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readLocalJobsStrict, withJobsLock } from "./local-jobs.js";
|
|
6
|
+
/** Rotate journals at five MiB without discarding unread records. */
|
|
7
|
+
export const EVENT_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
export const EVENT_PAYLOAD_BYTES = 16 * 1024;
|
|
9
|
+
const journal = "events.jsonl";
|
|
10
|
+
const stateFile = "events-state.json";
|
|
11
|
+
export function validateEventPayload(payload) {
|
|
12
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
13
|
+
throw new Error("Event payload must be a JSON object.");
|
|
14
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") >= EVENT_PAYLOAD_BYTES)
|
|
15
|
+
throw new Error("Event payload must be under 16 KB.");
|
|
16
|
+
}
|
|
17
|
+
function readState(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const state = JSON.parse(fs.readFileSync(path.join(dir, stateFile), "utf8"));
|
|
20
|
+
if (state.v !== 1 || !state.cursors || !Array.isArray(state.pending))
|
|
21
|
+
throw new Error("Invalid event state");
|
|
22
|
+
return state;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err.code === "ENOENT")
|
|
26
|
+
return { v: 1, cursors: {}, pending: [] };
|
|
27
|
+
throw err; // Never reset a corrupt cursor and replay the journal.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeState(dir, state) {
|
|
31
|
+
const file = path.join(dir, stateFile);
|
|
32
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmp, file);
|
|
35
|
+
}
|
|
36
|
+
function files(dir) {
|
|
37
|
+
return fs.readdirSync(dir).filter((f) => /^events\.\d+-[a-f0-9-]+\.jsonl$/.test(f)).sort().concat(journal);
|
|
38
|
+
}
|
|
39
|
+
/** A persisted intent makes both sides of the journal rename recoverable. */
|
|
40
|
+
function recoverRotation(dir, state) {
|
|
41
|
+
if (!state.rotating)
|
|
42
|
+
return;
|
|
43
|
+
const archived = state.rotating;
|
|
44
|
+
if (!fs.existsSync(path.join(dir, archived))) {
|
|
45
|
+
fs.renameSync(path.join(dir, journal), path.join(dir, archived));
|
|
46
|
+
}
|
|
47
|
+
state.cursors[archived] = state.cursors[journal] ?? 0;
|
|
48
|
+
delete state.cursors[journal];
|
|
49
|
+
delete state.rotating;
|
|
50
|
+
writeState(dir, state);
|
|
51
|
+
}
|
|
52
|
+
export function emitBotEvent(dir, event, payload, now = new Date(), alreadyEmitted) {
|
|
53
|
+
if (!event.trim() || event.length > 256 || /[\r\n]/.test(event))
|
|
54
|
+
throw new Error("Event name must be 1–256 characters without newlines.");
|
|
55
|
+
validateEventPayload(payload);
|
|
56
|
+
const record = { event, at: now.toISOString(), payload };
|
|
57
|
+
return withJobsLock(dir, () => {
|
|
58
|
+
const state = readState(dir);
|
|
59
|
+
recoverRotation(dir, state);
|
|
60
|
+
// Optional producer idempotency: scan and append under the same lock as
|
|
61
|
+
// rotation, including archived journals and only fully written lines.
|
|
62
|
+
if (alreadyEmitted) {
|
|
63
|
+
for (const name of files(dir)) {
|
|
64
|
+
const candidate = path.join(dir, name);
|
|
65
|
+
if (!fs.existsSync(candidate))
|
|
66
|
+
continue;
|
|
67
|
+
const lines = fs.readFileSync(candidate, "utf8").split("\n").slice(0, -1);
|
|
68
|
+
for (const line of lines.filter(Boolean)) {
|
|
69
|
+
const prior = JSON.parse(line);
|
|
70
|
+
if (alreadyEmitted(prior))
|
|
71
|
+
return prior;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const file = path.join(dir, journal);
|
|
76
|
+
const bytes = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
|
|
77
|
+
// A process killed during append may leave an incomplete final line.
|
|
78
|
+
// It was never accepted as an event: discard it before the next append,
|
|
79
|
+
// otherwise that next valid record would join it and poison the drain.
|
|
80
|
+
const size = bytes.length && bytes.at(-1) !== 10 ? bytes.lastIndexOf(10) + 1 : bytes.length;
|
|
81
|
+
if (size !== bytes.length)
|
|
82
|
+
fs.truncateSync(file, size);
|
|
83
|
+
if (size >= EVENT_ROTATE_BYTES) {
|
|
84
|
+
state.rotating = `events.${Date.now()}-${randomUUID()}.jsonl`;
|
|
85
|
+
writeState(dir, state);
|
|
86
|
+
recoverRotation(dir, state);
|
|
87
|
+
}
|
|
88
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
89
|
+
return record;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export function eventMatches(job, record) {
|
|
93
|
+
return job.trigger?.kind === "event" && job.trigger.event === record.event &&
|
|
94
|
+
Object.entries(job.trigger.filter ?? {}).every(([key, value]) => {
|
|
95
|
+
if (!Object.hasOwn(record.payload, key))
|
|
96
|
+
return false;
|
|
97
|
+
// JSON objects can shadow toString/valueOf with non-callable fields.
|
|
98
|
+
// Such a value cannot match a string filter and must not poison the drain.
|
|
99
|
+
try {
|
|
100
|
+
return String(record.payload[key]) === value;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Pending items survive a crash between queueing and the next poll. */
|
|
108
|
+
export function drainBotEvents(dir) {
|
|
109
|
+
return withJobsLock(dir, () => {
|
|
110
|
+
const state = readState(dir);
|
|
111
|
+
recoverRotation(dir, state);
|
|
112
|
+
const jobs = readLocalJobsStrict(dir);
|
|
113
|
+
for (const name of files(dir)) {
|
|
114
|
+
const file = path.join(dir, name);
|
|
115
|
+
if (!fs.existsSync(file))
|
|
116
|
+
continue;
|
|
117
|
+
let offset = state.cursors[name] ?? 0;
|
|
118
|
+
// Consumed archives need only a stat, not a full read on every poll.
|
|
119
|
+
if (offset === fs.statSync(file).size)
|
|
120
|
+
continue;
|
|
121
|
+
const bytes = fs.readFileSync(file);
|
|
122
|
+
if (offset > bytes.length)
|
|
123
|
+
throw new Error(`Event journal was truncated: ${name}`);
|
|
124
|
+
while (offset < bytes.length) {
|
|
125
|
+
const end = bytes.indexOf(10, offset);
|
|
126
|
+
if (end < 0)
|
|
127
|
+
break; // An interrupted append is never treated as a full event.
|
|
128
|
+
const record = JSON.parse(bytes.subarray(offset, end).toString("utf8"));
|
|
129
|
+
validateEventPayload(record.payload);
|
|
130
|
+
for (const job of jobs.filter((j) => eventMatches(j, record))) {
|
|
131
|
+
state.pending.push({
|
|
132
|
+
messageId: `localjob-event-${randomUUID()}`, channel: "job", jobId: job.jobId,
|
|
133
|
+
prompt: job.prompt, firedAt: record.at, receivedAt: record.at,
|
|
134
|
+
context: { ...job.context, payload: record.payload, event: record },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
offset = end + 1;
|
|
138
|
+
}
|
|
139
|
+
state.cursors[name] = offset;
|
|
140
|
+
}
|
|
141
|
+
writeState(dir, state);
|
|
142
|
+
return state.pending;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function ackBotEvent(dir, messageId) {
|
|
146
|
+
withJobsLock(dir, () => {
|
|
147
|
+
const state = readState(dir);
|
|
148
|
+
state.pending = state.pending.filter((item) => item.messageId !== messageId);
|
|
149
|
+
writeState(dir, state);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function tailBotEvents(dir, predicate = () => true) {
|
|
153
|
+
return withJobsLock(dir, () => files(dir).flatMap((name) => {
|
|
154
|
+
const file = path.join(dir, name);
|
|
155
|
+
if (!fs.existsSync(file))
|
|
156
|
+
return [];
|
|
157
|
+
return fs.readFileSync(file, "utf8").split("\n").slice(0, -1).filter(Boolean).map((line) => JSON.parse(line));
|
|
158
|
+
}).filter(predicate).slice(-50));
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type BotConfig } from "./config.js";
|
|
2
|
+
export declare function executiveRiderEnabled(home?: string): boolean;
|
|
3
|
+
export declare function isExecutiveBot(config: Pick<BotConfig, "name" | "kind" | "workerId">, home?: string): boolean;
|
|
4
|
+
export declare function executiveStatus(config: Pick<BotConfig, "name" | "kind" | "workerId">, dir: string, home?: string): {
|
|
5
|
+
is_executive: boolean;
|
|
6
|
+
rider_enabled: boolean;
|
|
7
|
+
detect_source_last: string | null;
|
|
8
|
+
last_detected_at: string | null;
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=executive-settings.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Shared Executive identity and the desktop's default-on rider preference. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { effectiveBotKind } from "./config.js";
|
|
6
|
+
import { tailBotEvents } from "./events.js";
|
|
7
|
+
export function executiveRiderEnabled(home = os.homedir()) {
|
|
8
|
+
try {
|
|
9
|
+
const settings = JSON.parse(fs.readFileSync(path.join(home, ".hq/menubar.json"), "utf8"));
|
|
10
|
+
return settings?.executiveMeetingRider?.enabled !== false;
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return err.code === "ENOENT";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function isExecutiveBot(config, home = os.homedir()) {
|
|
17
|
+
if (effectiveBotKind(config) !== "personal")
|
|
18
|
+
return false;
|
|
19
|
+
let name = "setup";
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(fs.readFileSync(path.join(home, ".hq/executive.json"), "utf8"));
|
|
22
|
+
if (value?.bot != null) {
|
|
23
|
+
if (typeof value.bot !== "string" || !value.bot.trim())
|
|
24
|
+
return false;
|
|
25
|
+
name = value.bot;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code !== "ENOENT")
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return config.name === name;
|
|
33
|
+
}
|
|
34
|
+
export function executiveStatus(config, dir, home = os.homedir()) {
|
|
35
|
+
const last = tailBotEvents(dir, (event) => event.event === "meeting.detected").at(-1);
|
|
36
|
+
return {
|
|
37
|
+
is_executive: isExecutiveBot(config, home), rider_enabled: executiveRiderEnabled(home),
|
|
38
|
+
// The first desktop producer did not set source.
|
|
39
|
+
detect_source_last: last ? (last.payload.source === "poll" ? "poll" : "desktop") : null,
|
|
40
|
+
last_detected_at: last?.at ?? null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=executive-settings.js.map
|
|
@@ -169,6 +169,10 @@ export function migrateLegacyJobsToFleet(dir, config, ops, now = new Date()) {
|
|
|
169
169
|
const skipped = [];
|
|
170
170
|
const moved = new Set();
|
|
171
171
|
for (const job of legacy) {
|
|
172
|
+
if (job.trigger || !job.cron) {
|
|
173
|
+
skipped.push(job.jobId);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
172
176
|
let schedule;
|
|
173
177
|
try {
|
|
174
178
|
schedule = /^(cron|rate)\(/i.test(job.cron.trim()) ? fleetScheduleFromLegacy(job.cron) : job.cron.trim();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A download that receives no bytes for this long is treated as stalled and retried. */
|
|
2
|
+
export declare const FLEET_DOWNLOAD_STALL_MS = 90000;
|
|
3
|
+
/** How many times a stalled or broken download is resumed before giving up. */
|
|
4
|
+
export declare const FLEET_DOWNLOAD_MAX_RESUMES = 5;
|
|
5
|
+
export interface FleetDownloadProgress {
|
|
6
|
+
name: string;
|
|
7
|
+
received: number;
|
|
8
|
+
total: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface FleetDownloadOptions {
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
stallMs?: number;
|
|
14
|
+
maxResumes?: number;
|
|
15
|
+
onProgress?: (progress: FleetDownloadProgress) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Download `url` into `destination`, writing through `<destination>.part` and
|
|
19
|
+
* resuming from the bytes already there. Every attempt that receives nothing
|
|
20
|
+
* for `stallMs` is retried from where it stopped, up to `maxResumes` times.
|
|
21
|
+
* The file is renamed into place only when the body ended cleanly and, when
|
|
22
|
+
* the server told us its size, the sizes match.
|
|
23
|
+
*/
|
|
24
|
+
export declare function downloadFleetFile(url: string, destination: string, options?: FleetDownloadOptions): Promise<string>;
|
|
25
|
+
/** sha256 of a file on disk, streamed. */
|
|
26
|
+
export declare function fileSha256(file: string): string;
|
|
27
|
+
//# sourceMappingURL=fleet-download.d.ts.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
/** A download that receives no bytes for this long is treated as stalled and retried. */
|
|
5
|
+
export const FLEET_DOWNLOAD_STALL_MS = 90_000;
|
|
6
|
+
/** How many times a stalled or broken download is resumed before giving up. */
|
|
7
|
+
export const FLEET_DOWNLOAD_MAX_RESUMES = 5;
|
|
8
|
+
function expectedError(message) {
|
|
9
|
+
return Object.assign(new Error(message), { expected: true });
|
|
10
|
+
}
|
|
11
|
+
function partFileSize(file) {
|
|
12
|
+
try {
|
|
13
|
+
return fs.statSync(file).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function contentLength(response) {
|
|
20
|
+
const raw = response.headers.get("content-length");
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
const value = Number.parseInt(raw, 10);
|
|
24
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
function contentRangeTotal(response) {
|
|
27
|
+
const raw = response.headers.get("content-range");
|
|
28
|
+
const match = raw ? /\/(\d+)\s*$/.exec(raw) : null;
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
const value = Number.parseInt(match[1], 10);
|
|
32
|
+
return Number.isFinite(value) ? value : null;
|
|
33
|
+
}
|
|
34
|
+
async function pumpBody(response, fd, offset, name, total, stallMs, onProgress) {
|
|
35
|
+
if (!response.body) {
|
|
36
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
37
|
+
fs.writeSync(fd, bytes, 0, bytes.length, offset);
|
|
38
|
+
const received = offset + bytes.length;
|
|
39
|
+
onProgress?.({ name, received, total });
|
|
40
|
+
return received;
|
|
41
|
+
}
|
|
42
|
+
const reader = response.body.getReader();
|
|
43
|
+
let received = offset;
|
|
44
|
+
let lastReport = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
let timer;
|
|
47
|
+
const stalled = new Promise((_resolve, reject) => {
|
|
48
|
+
timer = setTimeout(() => reject(expectedError(`stalled: no data for ${Math.round(stallMs / 1000)}s`)), stallMs);
|
|
49
|
+
});
|
|
50
|
+
let chunk;
|
|
51
|
+
try {
|
|
52
|
+
chunk = await Promise.race([reader.read(), stalled]);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
void reader.cancel().catch(() => undefined);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (timer)
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
if (chunk.done)
|
|
63
|
+
break;
|
|
64
|
+
fs.writeSync(fd, chunk.value, 0, chunk.value.length, received);
|
|
65
|
+
received += chunk.value.length;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - lastReport >= 250 || (total !== null && received >= total)) {
|
|
68
|
+
lastReport = now;
|
|
69
|
+
onProgress?.({ name, received, total });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
onProgress?.({ name, received, total });
|
|
73
|
+
return received;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Download `url` into `destination`, writing through `<destination>.part` and
|
|
77
|
+
* resuming from the bytes already there. Every attempt that receives nothing
|
|
78
|
+
* for `stallMs` is retried from where it stopped, up to `maxResumes` times.
|
|
79
|
+
* The file is renamed into place only when the body ended cleanly and, when
|
|
80
|
+
* the server told us its size, the sizes match.
|
|
81
|
+
*/
|
|
82
|
+
export async function downloadFleetFile(url, destination, options = {}) {
|
|
83
|
+
const fetcher = options.fetch ?? fetch;
|
|
84
|
+
const stallMs = options.stallMs ?? FLEET_DOWNLOAD_STALL_MS;
|
|
85
|
+
const maxResumes = options.maxResumes ?? FLEET_DOWNLOAD_MAX_RESUMES;
|
|
86
|
+
const name = path.basename(destination);
|
|
87
|
+
const part = `${destination}.part`;
|
|
88
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
89
|
+
let lastFailure = null;
|
|
90
|
+
for (let attempt = 0; attempt <= maxResumes; attempt += 1) {
|
|
91
|
+
const have = partFileSize(part);
|
|
92
|
+
const headers = { ...options.headers };
|
|
93
|
+
if (have > 0)
|
|
94
|
+
headers.Range = `bytes=${have}-`;
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await (Object.keys(headers).length > 0 ? fetcher(url, { headers }) : fetcher(url));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
lastFailure = error;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let offset = have;
|
|
104
|
+
let total;
|
|
105
|
+
if (response.status === 206 && have > 0) {
|
|
106
|
+
total = contentRangeTotal(response) ?? (contentLength(response) !== null ? have + contentLength(response) : null);
|
|
107
|
+
}
|
|
108
|
+
else if (response.status === 416 && have > 0) {
|
|
109
|
+
void response.body?.cancel().catch(() => undefined);
|
|
110
|
+
fs.renameSync(part, destination);
|
|
111
|
+
return destination;
|
|
112
|
+
}
|
|
113
|
+
else if (response.ok) {
|
|
114
|
+
offset = 0;
|
|
115
|
+
total = contentLength(response);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
void response.body?.cancel().catch(() => undefined);
|
|
119
|
+
throw expectedError(`Could not download ${name}: HQ storage returned HTTP ${response.status}.`);
|
|
120
|
+
}
|
|
121
|
+
const fd = fs.openSync(part, offset === 0 ? "w" : "r+", 0o600);
|
|
122
|
+
let received;
|
|
123
|
+
try {
|
|
124
|
+
received = await pumpBody(response, fd, offset, name, total, stallMs, options.onProgress);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
fs.closeSync(fd);
|
|
128
|
+
lastFailure = error;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
fs.closeSync(fd);
|
|
132
|
+
if (total !== null && received !== total) {
|
|
133
|
+
if (received > total)
|
|
134
|
+
fs.rmSync(part, { force: true });
|
|
135
|
+
lastFailure = expectedError(`ended early at ${received} of ${total} bytes`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
fs.renameSync(part, destination);
|
|
139
|
+
return destination;
|
|
140
|
+
}
|
|
141
|
+
const cause = lastFailure instanceof Error ? lastFailure.message : String(lastFailure ?? "unknown error");
|
|
142
|
+
throw expectedError(`Could not download ${name} after ${maxResumes + 1} attempts (${cause}). Check your internet connection and retry; the download continues where it stopped.`);
|
|
143
|
+
}
|
|
144
|
+
/** sha256 of a file on disk, streamed. */
|
|
145
|
+
export function fileSha256(file) {
|
|
146
|
+
const hash = crypto.createHash("sha256");
|
|
147
|
+
const fd = fs.openSync(file, "r");
|
|
148
|
+
try {
|
|
149
|
+
const chunk = Buffer.allocUnsafe(1 << 20);
|
|
150
|
+
for (;;) {
|
|
151
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
152
|
+
if (read === 0)
|
|
153
|
+
break;
|
|
154
|
+
hash.update(chunk.subarray(0, read));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
fs.closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
return hash.digest("hex");
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=fleet-download.js.map
|
|
@@ -65,6 +65,9 @@ export interface FleetLocalInstallDeps {
|
|
|
65
65
|
now?: () => Date;
|
|
66
66
|
release?: FleetLocalReleasePin;
|
|
67
67
|
publicKey?: string | readonly string[];
|
|
68
|
+
onProgress?: FleetInstallProgressListener;
|
|
69
|
+
/** How long a download may go without bytes before it is resumed (tests). */
|
|
70
|
+
downloadStallMs?: number;
|
|
68
71
|
}
|
|
69
72
|
export interface FleetLocalProfileInput {
|
|
70
73
|
agentUid: string;
|
|
@@ -113,6 +116,7 @@ export interface FleetLocalBootstrapDeps {
|
|
|
113
116
|
spawn?: FleetLocalSpawn;
|
|
114
117
|
start?: boolean;
|
|
115
118
|
python?: FleetPython;
|
|
119
|
+
onProgress?: FleetInstallProgressListener;
|
|
116
120
|
}
|
|
117
121
|
interface SharedFleetRuntime {
|
|
118
122
|
id: string;
|
|
@@ -257,9 +261,35 @@ export declare function installTargetFleetLocalDistribution(deps?: Omit<FleetLoc
|
|
|
257
261
|
target?: FleetLocalTargetRelease;
|
|
258
262
|
now?: () => number;
|
|
259
263
|
}): Promise<FleetLocalInstallation>;
|
|
264
|
+
/** What an install is doing right now, for a progress bar or a log line. */
|
|
265
|
+
export type FleetInstallProgress = {
|
|
266
|
+
phase: "download";
|
|
267
|
+
file: string;
|
|
268
|
+
received: number;
|
|
269
|
+
total: number | null;
|
|
270
|
+
} | {
|
|
271
|
+
phase: "verify";
|
|
272
|
+
} | {
|
|
273
|
+
phase: "python";
|
|
274
|
+
} | {
|
|
275
|
+
phase: "extract";
|
|
276
|
+
} | {
|
|
277
|
+
phase: "environment";
|
|
278
|
+
} | {
|
|
279
|
+
phase: "packages";
|
|
280
|
+
} | {
|
|
281
|
+
phase: "ready";
|
|
282
|
+
step: "download" | "runtime";
|
|
283
|
+
cached: boolean;
|
|
284
|
+
};
|
|
285
|
+
export type FleetInstallProgressListener = (progress: FleetInstallProgress) => void;
|
|
260
286
|
export declare const FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
261
|
-
|
|
262
|
-
export declare const
|
|
287
|
+
/** One machine-wide lock: concurrent creates and adopts share one download instead of repeating it. */
|
|
288
|
+
export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_NAME = "hq-fleet-local-distribution";
|
|
289
|
+
export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_WAIT_MS: number;
|
|
290
|
+
export declare const FLEET_LOCAL_DISTRIBUTION_LOCK_STALE_MS: number;
|
|
291
|
+
/** Whether the pinned release is installed on this machine (no download needed). */
|
|
292
|
+
export declare function fleetLocalDistributionInstalled(release?: FleetLocalReleasePin, root?: string): boolean;
|
|
263
293
|
export declare function installPinnedFleetLocalDistribution(deps?: FleetLocalInstallDeps): Promise<FleetLocalInstallation>;
|
|
264
294
|
/** The process id the bot's gateway recorded, when its file reads; the caller checks whether it is alive. */
|
|
265
295
|
export declare function fleetLocalGatewayPid(agentUid: string, deps?: Pick<FleetLocalControlDeps, "fleetHome">): number | null;
|
|
@@ -339,6 +369,25 @@ export declare function windowsExtractionPathProblem(destination: string, longes
|
|
|
339
369
|
export declare function importFleetLocalMachineCredential(agentUid: string, machineCredentials: string, deps?: FleetLocalControlDeps, opts?: {
|
|
340
370
|
keepSource?: boolean;
|
|
341
371
|
}): void;
|
|
372
|
+
export interface FleetRuntimeInstallResult {
|
|
373
|
+
release: FleetLocalReleasePin;
|
|
374
|
+
distributionDir: string;
|
|
375
|
+
runtimeDir: string;
|
|
376
|
+
runtimeId: string;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Everything a bot needs that is shared by every bot on this machine: the
|
|
380
|
+
* pinned distribution and runtime archive, the private Python, and the built
|
|
381
|
+
* runtime environment. Safe to run any number of times; a complete install
|
|
382
|
+
* returns at once.
|
|
383
|
+
*/
|
|
384
|
+
export declare function ensureFleetRuntimeInstalled(installation: FleetLocalInstallation, deps?: FleetLocalBootstrapDeps): Promise<FleetRuntimeInstallResult>;
|
|
385
|
+
/** Whether the shared runtime for this release is already built on this machine. */
|
|
386
|
+
export declare function fleetRuntimeReady(installation: Pick<FleetLocalInstallation, "release">, deps?: {
|
|
387
|
+
fleetHome?: string;
|
|
388
|
+
platform?: NodeJS.Platform;
|
|
389
|
+
python?: FleetPython;
|
|
390
|
+
}): boolean;
|
|
342
391
|
export declare function bootstrapFleetLocalBot(input: FleetLocalProfileInput & {
|
|
343
392
|
machineCredentials?: string;
|
|
344
393
|
}, deps?: FleetLocalBootstrapDeps): Promise<FleetLocalBootstrapResult>;
|