@indigoai-us/hq-cli 5.113.0 → 5.114.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 +27 -0
- package/dist/commands/bot.js +9 -6
- package/dist/lib/bot/inflight.d.ts +18 -7
- package/dist/lib/bot/inflight.js +57 -18
- package/dist/lib/bot/prompt.js +1 -0
- package/dist/lib/bot/run.d.ts +18 -2
- package/dist/lib/bot/run.js +94 -33
- package/dist/utils/feedback-log-bundle.d.ts +61 -11
- package/dist/utils/feedback-log-bundle.js +209 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.114.0] — 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Local bots now work on two conversations at once. A question in one
|
|
10
|
+
channel no longer waits for the bot to finish answering in another; messages
|
|
11
|
+
in the same conversation (your DMs with the bot, or one channel) are still
|
|
12
|
+
answered in order. A restart or stop during two turns reports each one that
|
|
13
|
+
did not finish.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- The log bundle attached to `hq feedback` reports is now a zip instead of a
|
|
18
|
+
gzip. Slack refuses a gzip file from the HQ app at any size, so for several
|
|
19
|
+
weeks every report's logs were uploaded and then silently dropped before
|
|
20
|
+
anyone could read them. Nothing about what the bundle contains or how it is
|
|
21
|
+
redacted has changed.
|
|
22
|
+
|
|
23
|
+
## [5.113.1] — 2026-09-15
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A bot can create a bot for its owner. `hq bot` commands (create, list, rm,
|
|
28
|
+
promote) always act as the signed-in person, even when a bot runs them; they
|
|
29
|
+
used the bot's own identity, which can own no bot, so the setup bot told
|
|
30
|
+
people to open a terminal to create their first bot.
|
|
31
|
+
|
|
5
32
|
## [5.113.0] — 2026-09-15
|
|
6
33
|
|
|
7
34
|
### Added
|
package/dist/commands/bot.js
CHANGED
|
@@ -27,7 +27,7 @@ import * as fs from "node:fs";
|
|
|
27
27
|
import * as path from "node:path";
|
|
28
28
|
import * as readline from "node:readline";
|
|
29
29
|
import { DEFAULT_COGNITO, ensureCognitoToken, resolveDefaultHqRoot, } from "../utils/cognito-session.js";
|
|
30
|
-
import {
|
|
30
|
+
import { peekHqApiKey } from "../utils/resolve-vault-credential.js";
|
|
31
31
|
import { resolveCallerPersonUid } from "../utils/vault-api.js";
|
|
32
32
|
import { resolveHqBinary } from "../lib/mesh/live/daemon/install.js";
|
|
33
33
|
import { BotApi, BotApiError, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, isBotRuntimeId, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, } from "../lib/bot/index.js";
|
|
@@ -88,15 +88,18 @@ export function botTokenSupplier(dir) {
|
|
|
88
88
|
return () => ensureCognitoToken({ tokenSource: "machine", interactive: false });
|
|
89
89
|
}
|
|
90
90
|
async function ownerToken(interactive = process.stdin.isTTY === true) {
|
|
91
|
-
|
|
92
|
-
// sign-in and shares the same cached session file.
|
|
93
|
-
const cred = await resolveVaultCredential({ interactive });
|
|
94
|
-
if (cred.kind !== "cognito") {
|
|
91
|
+
if (peekHqApiKey()) {
|
|
95
92
|
throw Object.assign(new Error("hq bot needs your HQ login session (HQ_API_KEY is not supported here). Run `hq login`."), {
|
|
96
93
|
expected: true,
|
|
97
94
|
});
|
|
98
95
|
}
|
|
99
|
-
|
|
96
|
+
// Bots belong to a person, so `hq bot` always acts as the signed-in person,
|
|
97
|
+
// even when a bot runs it: a bot's own process exports its machine
|
|
98
|
+
// credentials, and a machine identity can own no bot ("No person entity
|
|
99
|
+
// found"), which left a setup bot unable to create its owner's first bot.
|
|
100
|
+
// Under the desktop app (no TTY) never open a browser login; the app owns
|
|
101
|
+
// sign-in and shares the same cached session file.
|
|
102
|
+
return ensureCognitoToken({ tokenSource: "person", interactive });
|
|
100
103
|
}
|
|
101
104
|
function rowFor(name, opts = {}) {
|
|
102
105
|
const dir = botDir(name);
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* inflight.json — the
|
|
2
|
+
* inflight.json — the messages model turns are working on right now.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* answered (or answered with "did not finish"). If the bot restarts
|
|
6
|
-
*
|
|
7
|
-
* running the message again, because the first run may already
|
|
8
|
-
* things outside this Mac (sent something, published something).
|
|
4
|
+
* A marker is written before the model process starts and removed once the
|
|
5
|
+
* message is answered (or answered with "did not finish"). If the bot restarts
|
|
6
|
+
* while a marker exists, that turn was cut off: the bot tells the person
|
|
7
|
+
* instead of running the message again, because the first run may already
|
|
8
|
+
* have done things outside this Mac (sent something, published something).
|
|
9
|
+
*
|
|
10
|
+
* A bot works on up to two conversations at once, so the file holds one marker
|
|
11
|
+
* per running turn (`v: 2`). A `v: 1` file (a single marker, written before
|
|
12
|
+
* turns ran in parallel) is still read. Every write is a synchronous
|
|
13
|
+
* read-modify-rename, so turns in the same process cannot lose each other's
|
|
14
|
+
* markers.
|
|
9
15
|
*/
|
|
10
16
|
export declare const BOT_INFLIGHT_NAME = "inflight.json";
|
|
11
17
|
export interface InflightTurn {
|
|
@@ -33,8 +39,13 @@ export interface InflightTurn {
|
|
|
33
39
|
reply?: string;
|
|
34
40
|
}
|
|
35
41
|
export declare function botInflightPath(dir: string): string;
|
|
36
|
-
|
|
42
|
+
/** Every turn that was in progress, oldest first. */
|
|
43
|
+
export declare function readInflightTurns(dir: string): InflightTurn[];
|
|
44
|
+
/** The marker for one message, or null. */
|
|
45
|
+
export declare function readInflight(dir: string, messageId?: string): InflightTurn | null;
|
|
46
|
+
/** Add or replace the marker for `turn.messageId`; other turns' markers stay. */
|
|
37
47
|
export declare function writeInflight(dir: string, turn: InflightTurn): void;
|
|
38
48
|
export declare function patchInflight(dir: string, messageId: string, patch: Partial<InflightTurn>): void;
|
|
49
|
+
/** Remove one message's marker, or every marker when no id is given. */
|
|
39
50
|
export declare function clearInflight(dir: string, messageId?: string): void;
|
|
40
51
|
//# sourceMappingURL=inflight.d.ts.map
|
package/dist/lib/bot/inflight.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* inflight.json — the
|
|
2
|
+
* inflight.json — the messages model turns are working on right now.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* answered (or answered with "did not finish"). If the bot restarts
|
|
6
|
-
*
|
|
7
|
-
* running the message again, because the first run may already
|
|
8
|
-
* things outside this Mac (sent something, published something).
|
|
4
|
+
* A marker is written before the model process starts and removed once the
|
|
5
|
+
* message is answered (or answered with "did not finish"). If the bot restarts
|
|
6
|
+
* while a marker exists, that turn was cut off: the bot tells the person
|
|
7
|
+
* instead of running the message again, because the first run may already
|
|
8
|
+
* have done things outside this Mac (sent something, published something).
|
|
9
|
+
*
|
|
10
|
+
* A bot works on up to two conversations at once, so the file holds one marker
|
|
11
|
+
* per running turn (`v: 2`). A `v: 1` file (a single marker, written before
|
|
12
|
+
* turns ran in parallel) is still read. Every write is a synchronous
|
|
13
|
+
* read-modify-rename, so turns in the same process cannot lose each other's
|
|
14
|
+
* markers.
|
|
9
15
|
*/
|
|
10
16
|
import * as fs from "node:fs";
|
|
11
17
|
import * as path from "node:path";
|
|
@@ -13,32 +19,65 @@ export const BOT_INFLIGHT_NAME = "inflight.json";
|
|
|
13
19
|
export function botInflightPath(dir) {
|
|
14
20
|
return path.join(dir, BOT_INFLIGHT_NAME);
|
|
15
21
|
}
|
|
16
|
-
|
|
22
|
+
function isTurn(raw) {
|
|
23
|
+
const t = raw;
|
|
24
|
+
return t?.v === 1 && typeof t.messageId === "string" && Boolean(t.target);
|
|
25
|
+
}
|
|
26
|
+
/** Every turn that was in progress, oldest first. */
|
|
27
|
+
export function readInflightTurns(dir) {
|
|
17
28
|
try {
|
|
18
29
|
const raw = JSON.parse(fs.readFileSync(botInflightPath(dir), "utf8"));
|
|
19
|
-
if (raw
|
|
20
|
-
return
|
|
21
|
-
|
|
30
|
+
if (isTurn(raw))
|
|
31
|
+
return [raw];
|
|
32
|
+
const file = raw;
|
|
33
|
+
if (file?.v !== 2 || !Array.isArray(file.turns))
|
|
34
|
+
return [];
|
|
35
|
+
return file.turns.filter(isTurn);
|
|
22
36
|
}
|
|
23
37
|
catch {
|
|
24
|
-
return
|
|
38
|
+
return [];
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
|
-
|
|
41
|
+
/** The marker for one message, or null. */
|
|
42
|
+
export function readInflight(dir, messageId) {
|
|
43
|
+
const turns = readInflightTurns(dir);
|
|
44
|
+
if (messageId === undefined)
|
|
45
|
+
return turns[0] ?? null;
|
|
46
|
+
return turns.find((t) => t.messageId === messageId) ?? null;
|
|
47
|
+
}
|
|
48
|
+
function writeTurns(dir, turns) {
|
|
49
|
+
if (turns.length === 0) {
|
|
50
|
+
fs.rmSync(botInflightPath(dir), { force: true });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
28
53
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
54
|
const tmp = path.join(dir, `.inflight.tmp.${process.pid}.${Date.now()}`);
|
|
30
|
-
|
|
55
|
+
const file = { v: 2, turns };
|
|
56
|
+
fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
31
57
|
fs.renameSync(tmp, botInflightPath(dir));
|
|
32
58
|
}
|
|
59
|
+
/** Add or replace the marker for `turn.messageId`; other turns' markers stay. */
|
|
60
|
+
export function writeInflight(dir, turn) {
|
|
61
|
+
const others = readInflightTurns(dir).filter((t) => t.messageId !== turn.messageId);
|
|
62
|
+
writeTurns(dir, [...others, turn]);
|
|
63
|
+
}
|
|
33
64
|
export function patchInflight(dir, messageId, patch) {
|
|
34
|
-
const
|
|
35
|
-
|
|
65
|
+
const turns = readInflightTurns(dir);
|
|
66
|
+
const index = turns.findIndex((t) => t.messageId === messageId);
|
|
67
|
+
if (index < 0)
|
|
36
68
|
return;
|
|
37
|
-
|
|
69
|
+
turns[index] = { ...turns[index], ...patch };
|
|
70
|
+
writeTurns(dir, turns);
|
|
38
71
|
}
|
|
72
|
+
/** Remove one message's marker, or every marker when no id is given. */
|
|
39
73
|
export function clearInflight(dir, messageId) {
|
|
40
|
-
if (messageId
|
|
74
|
+
if (messageId === undefined) {
|
|
75
|
+
writeTurns(dir, []);
|
|
41
76
|
return;
|
|
42
|
-
|
|
77
|
+
}
|
|
78
|
+
const turns = readInflightTurns(dir);
|
|
79
|
+
const rest = turns.filter((t) => t.messageId !== messageId);
|
|
80
|
+
if (rest.length !== turns.length)
|
|
81
|
+
writeTurns(dir, rest);
|
|
43
82
|
}
|
|
44
83
|
//# sourceMappingURL=inflight.js.map
|
package/dist/lib/bot/prompt.js
CHANGED
|
@@ -253,6 +253,7 @@ export function buildSystemPrompt(input) {
|
|
|
253
253
|
`Their HQ folder is ${input.hqRoot}; it is your working directory, so its files, skills, and \`hq\` commands are available directly.\n` +
|
|
254
254
|
`\`hq\` commands you run authenticate as you, the bot (${input.agentUid}), not as your owner: \`hq whoami\`, membership and company lookups describe the bot, ` +
|
|
255
255
|
`and a bot usually belongs to no company. Never use them to say who your owner is or which companies they belong to. ` +
|
|
256
|
+
`The exception is \`hq bot\` (create, list, rm, promote): it always acts as your owner, so you can create a bot for them when they ask; its owner is your owner. ` +
|
|
256
257
|
`Direct messages from your owner start with an "Owner context" block checked with your owner's own sign-in; that is the only source for your owner's companies. ` +
|
|
257
258
|
`If it says the check failed, say you could not check; never say your owner has no company unless that block says so. ` +
|
|
258
259
|
`Room messages do not carry it: in a room, never state which companies your owner belongs to.\n` +
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - refuses to start unless the creds file names this bot's agt_ AND the
|
|
6
6
|
* server-side record is owned by the configured owner
|
|
7
7
|
* - heartbeat every 30s (POST /v1/agents/{uid}/heartbeat, agent JWT)
|
|
8
|
-
* - inbox poll every
|
|
8
|
+
* - inbox poll every 2s: owner DMs go to ONE headless model turn (resumed by
|
|
9
9
|
* session id); the reply is sent as the bot; the message is acked only after
|
|
10
10
|
* the reply is sent; processed ids persist so a crash between reply and ack
|
|
11
11
|
* never answers twice
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - room items (channels / group chats): answered when the bot is @mentioned,
|
|
17
17
|
* or when the owner posts in a small group (see room-policy.ts); claimed
|
|
18
18
|
* first (first-claim-wins), one session per room, reply posted in the room
|
|
19
|
+
* - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
|
|
20
|
+
* one lane per model session (the owner DM session, or one room). Messages
|
|
21
|
+
* in the same lane run in order, so a session is never resumed by two turns
|
|
22
|
+
* at once and a thread's answers never arrive out of sequence.
|
|
19
23
|
* - while a turn runs, each finished assistant message is posted as its own
|
|
20
24
|
* message (progress.ts); turns have no time limit
|
|
21
25
|
* - every received message gets a reply: a turn that fails after the model
|
|
@@ -29,7 +33,7 @@
|
|
|
29
33
|
* Everything with a side effect is injectable so the loop is unit-testable.
|
|
30
34
|
*/
|
|
31
35
|
import { type OwnerContext } from "./owner-context.js";
|
|
32
|
-
import type { BotApi } from "./api.js";
|
|
36
|
+
import type { BotApi, InboxItem } from "./api.js";
|
|
33
37
|
import type { BotConfig } from "./config.js";
|
|
34
38
|
import { type ProgressPosterOptions } from "./progress.js";
|
|
35
39
|
import { type BotLogger } from "./log.js";
|
|
@@ -44,6 +48,8 @@ export declare const THINKING_EMOJI = "\uD83D\uDC40";
|
|
|
44
48
|
export declare const ROOM_THINKING_STATUS = "is thinking\u2026";
|
|
45
49
|
export declare const RECENT_MESSAGES_LIMIT = 15;
|
|
46
50
|
export declare const RECENT_MESSAGE_MAX_CHARS = 400;
|
|
51
|
+
/** Model turns a bot runs at once (each on a different conversation). */
|
|
52
|
+
export declare const MAX_PARALLEL_TURNS = 2;
|
|
47
53
|
export type SleepFn = (ms: number) => Promise<void>;
|
|
48
54
|
export interface BotRunDeps {
|
|
49
55
|
dir: string;
|
|
@@ -78,6 +84,8 @@ export interface BotRunDeps {
|
|
|
78
84
|
* Omitted in tests that do not exercise it.
|
|
79
85
|
*/
|
|
80
86
|
ownerContext?: () => Promise<OwnerContext>;
|
|
87
|
+
/** Turns run at once across conversations (default MAX_PARALLEL_TURNS). */
|
|
88
|
+
maxParallelTurns?: number;
|
|
81
89
|
}
|
|
82
90
|
export declare const KICKOFF_MESSAGE_ID = "kickoff";
|
|
83
91
|
/** The core worker HQ's setup bot runs. */
|
|
@@ -92,7 +100,15 @@ export interface BotHandle {
|
|
|
92
100
|
/** Resolves when both loops have ended (state stopped or failed). */
|
|
93
101
|
done: Promise<BotState>;
|
|
94
102
|
status(): BotStatusFile | null;
|
|
103
|
+
/** True when no inbox message is queued or being worked on. */
|
|
104
|
+
idle(): boolean;
|
|
95
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The lane an inbox item runs in: the model session its turn resumes. Owner
|
|
108
|
+
* DMs share the DM session; each room has its own. An item that never reaches
|
|
109
|
+
* a model turn (refusal, not addressed to the bot) gets a lane of its own.
|
|
110
|
+
*/
|
|
111
|
+
export declare function inboxLaneKey(item: InboxItem, config: Pick<BotConfig, "ownerUid" | "agentUid" | "name">): string;
|
|
96
112
|
export declare function preflightCreds(dir: string, config: BotConfig): string | null;
|
|
97
113
|
export declare function runBot(deps: BotRunDeps): Promise<BotHandle>;
|
|
98
114
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - refuses to start unless the creds file names this bot's agt_ AND the
|
|
6
6
|
* server-side record is owned by the configured owner
|
|
7
7
|
* - heartbeat every 30s (POST /v1/agents/{uid}/heartbeat, agent JWT)
|
|
8
|
-
* - inbox poll every
|
|
8
|
+
* - inbox poll every 2s: owner DMs go to ONE headless model turn (resumed by
|
|
9
9
|
* session id); the reply is sent as the bot; the message is acked only after
|
|
10
10
|
* the reply is sent; processed ids persist so a crash between reply and ack
|
|
11
11
|
* never answers twice
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - room items (channels / group chats): answered when the bot is @mentioned,
|
|
17
17
|
* or when the owner posts in a small group (see room-policy.ts); claimed
|
|
18
18
|
* first (first-claim-wins), one session per room, reply posted in the room
|
|
19
|
+
* - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
|
|
20
|
+
* one lane per model session (the owner DM session, or one room). Messages
|
|
21
|
+
* in the same lane run in order, so a session is never resumed by two turns
|
|
22
|
+
* at once and a thread's answers never arrive out of sequence.
|
|
19
23
|
* - while a turn runs, each finished assistant message is posted as its own
|
|
20
24
|
* message (progress.ts); turns have no time limit
|
|
21
25
|
* - every received message gets a reply: a turn that fails after the model
|
|
@@ -39,7 +43,7 @@ import { patchBotConfig, readBotConfig } from "./config.js";
|
|
|
39
43
|
import { ensureBotSessionMeta } from "./company-bind.js";
|
|
40
44
|
import { readBotCredsIdentity } from "./creds.js";
|
|
41
45
|
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
42
|
-
import { clearInflight, patchInflight, readInflight, writeInflight } from "./inflight.js";
|
|
46
|
+
import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
|
|
43
47
|
import { ProgressPoster } from "./progress.js";
|
|
44
48
|
import { createBotLogger } from "./log.js";
|
|
45
49
|
import { buildSystemPrompt, introDmText, nonOwnerRefusalText, readPromptSources } from "./prompt.js";
|
|
@@ -57,6 +61,8 @@ export const THINKING_EMOJI = "👀";
|
|
|
57
61
|
export const ROOM_THINKING_STATUS = "is thinking…";
|
|
58
62
|
export const RECENT_MESSAGES_LIMIT = 15;
|
|
59
63
|
export const RECENT_MESSAGE_MAX_CHARS = 400;
|
|
64
|
+
/** Model turns a bot runs at once (each on a different conversation). */
|
|
65
|
+
export const MAX_PARALLEL_TURNS = 2;
|
|
60
66
|
export const KICKOFF_MESSAGE_ID = "kickoff";
|
|
61
67
|
/** The core worker HQ's setup bot runs. */
|
|
62
68
|
export const SETUP_WORKER_ID = "setup";
|
|
@@ -68,6 +74,17 @@ export function didNotFinishText(reason, progressPosted) {
|
|
|
68
74
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
69
75
|
export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
70
76
|
export const RESTARTED_REASON = "I was restarted while I was working on it";
|
|
77
|
+
/**
|
|
78
|
+
* The lane an inbox item runs in: the model session its turn resumes. Owner
|
|
79
|
+
* DMs share the DM session; each room has its own. An item that never reaches
|
|
80
|
+
* a model turn (refusal, not addressed to the bot) gets a lane of its own.
|
|
81
|
+
*/
|
|
82
|
+
export function inboxLaneKey(item, config) {
|
|
83
|
+
if (isDmItem(item))
|
|
84
|
+
return "dm";
|
|
85
|
+
const target = resolveReplyTarget(item, config);
|
|
86
|
+
return target?.kind === "room" ? target.sessionScope : `item:${item.messageId}`;
|
|
87
|
+
}
|
|
71
88
|
class FailureWindow {
|
|
72
89
|
windowMs;
|
|
73
90
|
limit;
|
|
@@ -117,8 +134,9 @@ export async function runBot(deps) {
|
|
|
117
134
|
const setStatus = (patch) => patchBotStatus(dir, patch, fallback, now);
|
|
118
135
|
let state = "starting";
|
|
119
136
|
let stopping = false;
|
|
120
|
-
/** Aborts the model
|
|
121
|
-
|
|
137
|
+
/** Aborts the model turns in progress (owner stop). */
|
|
138
|
+
const currentTurns = new Set();
|
|
139
|
+
const maxParallelTurns = Math.max(1, deps.maxParallelTurns ?? MAX_PARALLEL_TURNS);
|
|
122
140
|
let resolveDone;
|
|
123
141
|
const done = new Promise((r) => (resolveDone = r));
|
|
124
142
|
// Only override isProcessAlive when a caller injected one: spreading an
|
|
@@ -152,14 +170,14 @@ export async function runBot(deps) {
|
|
|
152
170
|
log("error", credsProblem);
|
|
153
171
|
resolveDone("failed");
|
|
154
172
|
exit(0);
|
|
155
|
-
return { stop: async () => { }, done, status: () => null };
|
|
173
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
156
174
|
}
|
|
157
175
|
if (config.enabled === false) {
|
|
158
176
|
setStatus({ state: "stopped", pid });
|
|
159
177
|
log("info", "bot is disabled (hq bot start to enable)");
|
|
160
178
|
resolveDone("stopped");
|
|
161
179
|
exit(0);
|
|
162
|
-
return { stop: async () => { }, done, status: () => null };
|
|
180
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
163
181
|
}
|
|
164
182
|
if (!deps.skipPidLock) {
|
|
165
183
|
const lock = acquirePidLock(dir, lockDeps);
|
|
@@ -168,12 +186,12 @@ export async function runBot(deps) {
|
|
|
168
186
|
log("warn", msg);
|
|
169
187
|
resolveDone("stopped");
|
|
170
188
|
exit(0);
|
|
171
|
-
return { stop: async () => { }, done, status: () => null };
|
|
189
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
if (hasPromotionHold(dir)) {
|
|
175
193
|
await finish("stopped", 0, "local bot held for cloud promotion");
|
|
176
|
-
return { stop: async () => { }, done, status: () => null };
|
|
194
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
177
195
|
}
|
|
178
196
|
setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false });
|
|
179
197
|
log("info", `starting ${config.name} (${config.runtime}) as ${config.agentUid} for ${config.ownerUid}`);
|
|
@@ -183,17 +201,17 @@ export async function runBot(deps) {
|
|
|
183
201
|
// an old local installation start consuming the cloud bot's inbox.
|
|
184
202
|
if (record.uid !== config.agentUid || record.computeMode !== "local" || record.botKind !== "personal") {
|
|
185
203
|
await finish("failed", 0, `identity ${config.agentUid} is not a personal local bot; this installation cannot run it`);
|
|
186
|
-
return { stop: async () => { }, done, status: () => null };
|
|
204
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
187
205
|
}
|
|
188
206
|
const owner = record.ownerUid ?? null;
|
|
189
207
|
if (owner !== config.ownerUid) {
|
|
190
208
|
await finish("failed", 0, `identity ${config.agentUid} is owned by ${owner ?? "nobody"}, not ${config.ownerUid}`);
|
|
191
|
-
return { stop: async () => { }, done, status: () => null };
|
|
209
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
192
210
|
}
|
|
193
211
|
}
|
|
194
212
|
catch (err) {
|
|
195
213
|
await finish("failed", 0, `could not verify bot identity: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
-
return { stop: async () => { }, done, status: () => null };
|
|
214
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
197
215
|
}
|
|
198
216
|
// ── System prompt ──────────────────────────────────────────────────────────
|
|
199
217
|
const systemPrompt = deps.systemPromptOverride ??
|
|
@@ -218,14 +236,14 @@ export async function runBot(deps) {
|
|
|
218
236
|
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
|
|
219
237
|
if (hasPromotionHold(dir)) {
|
|
220
238
|
await finish("stopped", 0, "local bot held for cloud promotion");
|
|
221
|
-
return { stop: async () => { }, done, status: () => null };
|
|
239
|
+
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
222
240
|
}
|
|
223
241
|
state = "running";
|
|
224
242
|
setStatus({ state: "running", lastActivityAt: now().toISOString() });
|
|
225
243
|
log("info", "running");
|
|
226
244
|
// Read before the kickoff can write its own marker: whatever is here now was
|
|
227
245
|
// left by a previous run that was cut off mid-turn.
|
|
228
|
-
const
|
|
246
|
+
const leftovers = readInflightTurns(dir);
|
|
229
247
|
// ── Intro DM (once) ────────────────────────────────────────────────────────
|
|
230
248
|
// The kickoff turn is tied to the intro: it runs only in the start that sent
|
|
231
249
|
// the intro, so introSentAt (persisted before the turn) guarantees it never
|
|
@@ -419,7 +437,7 @@ export async function runBot(deps) {
|
|
|
419
437
|
},
|
|
420
438
|
});
|
|
421
439
|
const controller = new AbortController();
|
|
422
|
-
|
|
440
|
+
currentTurns.add(controller);
|
|
423
441
|
poster.start();
|
|
424
442
|
try {
|
|
425
443
|
const turn = await executeTurn(args.prompt, args.sessionScope, args.tReceived, {
|
|
@@ -442,8 +460,7 @@ export async function runBot(deps) {
|
|
|
442
460
|
return { outcome: { ok: false, started, aborted, excerpt, error: err }, poster };
|
|
443
461
|
}
|
|
444
462
|
finally {
|
|
445
|
-
|
|
446
|
-
currentTurn = null;
|
|
463
|
+
currentTurns.delete(controller);
|
|
447
464
|
}
|
|
448
465
|
};
|
|
449
466
|
const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : `${runtime.id} stopped with an error: ${outcome.excerpt.slice(0, 160)}`;
|
|
@@ -561,8 +578,8 @@ export async function runBot(deps) {
|
|
|
561
578
|
}
|
|
562
579
|
// A turn on this message already started once and was cut off: say so,
|
|
563
580
|
// never run it again.
|
|
564
|
-
const inflight = readInflight(dir);
|
|
565
|
-
if (inflight
|
|
581
|
+
const inflight = readInflight(dir, id);
|
|
582
|
+
if (inflight) {
|
|
566
583
|
await recoverInflight(inflight);
|
|
567
584
|
return;
|
|
568
585
|
}
|
|
@@ -721,9 +738,54 @@ export async function runBot(deps) {
|
|
|
721
738
|
log("error", `kickoff turn crashed: ${err instanceof Error ? err.message : String(err)}`);
|
|
722
739
|
})
|
|
723
740
|
: Promise.resolve();
|
|
741
|
+
// ── Lanes: conversations side by side, each conversation in order ─────────
|
|
742
|
+
/** Messages waiting per lane, in arrival order. */
|
|
743
|
+
const lanes = new Map();
|
|
744
|
+
/** Lanes with a message being handled right now. */
|
|
745
|
+
const busyLanes = new Set();
|
|
746
|
+
/** Every message queued or being handled (the inbox re-delivers until acked). */
|
|
747
|
+
const pending = new Set();
|
|
748
|
+
const running = new Set();
|
|
749
|
+
const canStart = () => !stopping && state === "running" && !hasPromotionHold(dir);
|
|
750
|
+
const pump = () => {
|
|
751
|
+
for (const [lane, queue] of lanes) {
|
|
752
|
+
if (busyLanes.size >= maxParallelTurns || !canStart())
|
|
753
|
+
return;
|
|
754
|
+
if (busyLanes.has(lane))
|
|
755
|
+
continue;
|
|
756
|
+
const item = queue.shift();
|
|
757
|
+
if (queue.length === 0)
|
|
758
|
+
lanes.delete(lane);
|
|
759
|
+
if (!item)
|
|
760
|
+
continue;
|
|
761
|
+
busyLanes.add(lane);
|
|
762
|
+
const task = handleItem(item)
|
|
763
|
+
.catch((err) => {
|
|
764
|
+
log("warn", `inbox item ${item.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
|
+
})
|
|
766
|
+
.finally(() => {
|
|
767
|
+
busyLanes.delete(lane);
|
|
768
|
+
pending.delete(item.messageId);
|
|
769
|
+
running.delete(task);
|
|
770
|
+
pump();
|
|
771
|
+
});
|
|
772
|
+
running.add(task);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const enqueue = (item) => {
|
|
776
|
+
if (pending.has(item.messageId))
|
|
777
|
+
return;
|
|
778
|
+
pending.add(item.messageId);
|
|
779
|
+
const lane = inboxLaneKey(item, config);
|
|
780
|
+
const queue = lanes.get(lane);
|
|
781
|
+
if (queue)
|
|
782
|
+
queue.push(item);
|
|
783
|
+
else
|
|
784
|
+
lanes.set(lane, [item]);
|
|
785
|
+
};
|
|
724
786
|
const inboxLoop = (async () => {
|
|
725
|
-
//
|
|
726
|
-
|
|
787
|
+
// Turns cut off by the last restart are reported before anything new runs.
|
|
788
|
+
for (const leftover of leftovers) {
|
|
727
789
|
try {
|
|
728
790
|
await recoverInflight(leftover);
|
|
729
791
|
}
|
|
@@ -738,16 +800,9 @@ export async function runBot(deps) {
|
|
|
738
800
|
try {
|
|
739
801
|
const items = await api.pullInbox(config.agentUid);
|
|
740
802
|
setStatus({ lastInboxPollAt: now().toISOString() });
|
|
741
|
-
for (const item of items)
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
try {
|
|
745
|
-
await handleItem(item);
|
|
746
|
-
}
|
|
747
|
-
catch (err) {
|
|
748
|
-
log("warn", `inbox item ${item.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
803
|
+
for (const item of items)
|
|
804
|
+
enqueue(item);
|
|
805
|
+
pump();
|
|
751
806
|
}
|
|
752
807
|
catch (err) {
|
|
753
808
|
log("warn", `inbox poll failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -756,6 +811,10 @@ export async function runBot(deps) {
|
|
|
756
811
|
break;
|
|
757
812
|
await sleep(inboxIntervalMs);
|
|
758
813
|
}
|
|
814
|
+
// Stop, drain and promotion wait for the turns already running. Queued
|
|
815
|
+
// messages were never started or acked, so the inbox delivers them again.
|
|
816
|
+
while (running.size > 0)
|
|
817
|
+
await Promise.allSettled([...running]);
|
|
759
818
|
})();
|
|
760
819
|
function readStatus() {
|
|
761
820
|
return readBotStatus(dir);
|
|
@@ -763,7 +822,8 @@ export async function runBot(deps) {
|
|
|
763
822
|
const stop = async (mode = "abort") => {
|
|
764
823
|
if (stopping) {
|
|
765
824
|
if (mode === "abort")
|
|
766
|
-
|
|
825
|
+
for (const turn of currentTurns)
|
|
826
|
+
turn.abort();
|
|
767
827
|
return;
|
|
768
828
|
}
|
|
769
829
|
stopping = true;
|
|
@@ -771,7 +831,8 @@ export async function runBot(deps) {
|
|
|
771
831
|
// Promotion instead drains that turn, including its reply and ACK, before
|
|
772
832
|
// the coordinator snapshots memory and starts the cloud consumer.
|
|
773
833
|
if (mode === "abort")
|
|
774
|
-
|
|
834
|
+
for (const turn of currentTurns)
|
|
835
|
+
turn.abort();
|
|
775
836
|
await Promise.allSettled([heartbeatLoop, inboxLoop]);
|
|
776
837
|
await finish("stopped", 0, mode === "drain" ? "promotion drain completed" : "stop requested");
|
|
777
838
|
};
|
|
@@ -789,6 +850,6 @@ export async function runBot(deps) {
|
|
|
789
850
|
if (state === "running")
|
|
790
851
|
void finish("stopped", 0, "loops ended");
|
|
791
852
|
});
|
|
792
|
-
return { stop, done, status: readStatus };
|
|
853
|
+
return { stop, done, status: readStatus, idle: () => pending.size === 0 };
|
|
793
854
|
}
|
|
794
855
|
//# sourceMappingURL=run.js.map
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
|
|
7
7
|
* caps at 400 KB. Neither can be tuned into carrying a real log history, so the
|
|
8
8
|
* inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
|
|
9
|
-
* produces the other half: a
|
|
9
|
+
* produces the other half: a zipped bundle uploaded direct to S3, carrying the
|
|
10
10
|
* files whole rather than in tail-sized slivers.
|
|
11
11
|
*
|
|
12
12
|
* Three properties are load-bearing and none may be traded away:
|
|
@@ -25,17 +25,33 @@
|
|
|
25
25
|
* 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
|
|
26
26
|
* at the ratio these files compress at. Nothing is ever fully materialised:
|
|
27
27
|
* files are read in slices, redacted a line at a time, and streamed into
|
|
28
|
-
*
|
|
28
|
+
* the deflate stream, with only the compressed output retained.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
30
|
+
* WHY ZIP AND NOT GZIP. This bundle is attached to a Slack thread by the
|
|
31
|
+
* hq-pro feedback worker, and Slack's `files.completeUploadExternal` refuses a
|
|
32
|
+
* gzip member from this app at ANY size — a 52-byte gzip was rejected with
|
|
33
|
+
* `internal_error`, while the same bytes as zip were accepted. That was the
|
|
34
|
+
* root cause of roughly 110 consecutive silent attachment failures. The worker
|
|
35
|
+
* carries a gz-to-zip repack so older CLIs still get an attachable bundle;
|
|
36
|
+
* emitting zip here means the common path never pays for that unpack, and the
|
|
37
|
+
* archive is the format the destination actually accepts.
|
|
38
|
+
*
|
|
39
|
+
* A single deflate member wrapped in a ZIP32 container, one entry. The stream
|
|
40
|
+
* is raw deflate so the container's own CRC and sizes are the only integrity
|
|
41
|
+
* record; crc32 is folded in incrementally as lines are written, and the local
|
|
42
|
+
* header, central directory, and EOCD are assembled once at finish. The size
|
|
43
|
+
* budget is charged on the container, not the member, so the ~98 bytes of
|
|
44
|
+
* framing can never be what pushes an upload past the server's ceiling.
|
|
45
|
+
*
|
|
46
|
+
* Output format inside the entry — NDJSON, one JSON record per line:
|
|
31
47
|
* {"kind":"manifest","version":1,...} exactly one, first
|
|
32
48
|
* {"kind":"file","name":"logs/hq-sync.log",...} one per file
|
|
33
49
|
* {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
|
|
34
50
|
* {"kind":"summary","fileCount":12,...} exactly one, last
|
|
35
51
|
*
|
|
36
|
-
* NDJSON rather than tar
|
|
37
|
-
* bundle is still parseable line-by-line up to the cut, and so the
|
|
38
|
-
* carry the same redaction metadata the inline blob already reports.
|
|
52
|
+
* NDJSON inside the entry rather than a tar of separate members, so a
|
|
53
|
+
* truncated bundle is still parseable line-by-line up to the cut, and so the
|
|
54
|
+
* records carry the same redaction metadata the inline blob already reports.
|
|
39
55
|
*/
|
|
40
56
|
import { type LogCandidate } from "./feedback-logs.js";
|
|
41
57
|
import { vaultApiFetch } from "./vault-api.js";
|
|
@@ -45,10 +61,17 @@ import { vaultApiFetch } from "./vault-api.js";
|
|
|
45
61
|
* stay in step or the CLI will build bundles the server will not accept.
|
|
46
62
|
*/
|
|
47
63
|
export declare const LOG_BUNDLE_MAX_BYTES: number;
|
|
64
|
+
/**
|
|
65
|
+
* What the presign request declares, and what the server must answer with.
|
|
66
|
+
* Mirrors `LOG_BUNDLE_FORMAT_ZIP` / `LOG_BUNDLE_ZIP_CONTENT_TYPE` in the hq-pro
|
|
67
|
+
* handler `feedback-log-bundles.ts`.
|
|
68
|
+
*/
|
|
69
|
+
export declare const LOG_BUNDLE_FORMAT = "zip";
|
|
70
|
+
export declare const LOG_BUNDLE_CONTENT_TYPE = "application/zip";
|
|
48
71
|
/**
|
|
49
72
|
* Headroom between the size we stop feeding at and the hard cap.
|
|
50
73
|
*
|
|
51
|
-
*
|
|
74
|
+
* deflate reports compressed bytes only as its internal buffer flushes, so the
|
|
52
75
|
* running total lags the bytes actually consumed. The lag is bounded by that
|
|
53
76
|
* buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
|
|
54
77
|
* to spare, and the final size is asserted against the real cap regardless.
|
|
@@ -62,9 +85,9 @@ export declare const LOG_BUNDLE_SAFETY_MARGIN_BYTES: number;
|
|
|
62
85
|
*/
|
|
63
86
|
export declare const LOG_BUNDLE_MAX_FILE_RAW_BYTES: number;
|
|
64
87
|
export interface LogBundleResult {
|
|
65
|
-
/** The
|
|
66
|
-
|
|
67
|
-
/** `
|
|
88
|
+
/** The zipped NDJSON bytes, ready to PUT. */
|
|
89
|
+
bytes: Buffer;
|
|
90
|
+
/** `bytes.byteLength` — what the presign request must declare. */
|
|
68
91
|
sizeBytes: number;
|
|
69
92
|
/** How many files contributed at least one chunk. */
|
|
70
93
|
fileCount: number;
|
|
@@ -95,6 +118,12 @@ export interface BuildLogBundleOptions {
|
|
|
95
118
|
homeDir?: string;
|
|
96
119
|
/** Override the per-file raw ceiling (tests). */
|
|
97
120
|
maxFileRawBytes?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Uncompressed ceiling for the zip entry. Clamped to what ZIP32 can describe,
|
|
123
|
+
* so a caller can lower this but never raise it past the format's limit.
|
|
124
|
+
* Exists so the ceiling is exercisable without a test that allocates 4 GiB.
|
|
125
|
+
*/
|
|
126
|
+
maxEntryBytes?: number;
|
|
98
127
|
}
|
|
99
128
|
/**
|
|
100
129
|
* Order candidates so that, when the cap truncates collection, what survives is
|
|
@@ -107,6 +136,27 @@ export interface BuildLogBundleOptions {
|
|
|
107
136
|
* crowd out a stale claim in the inline collector.
|
|
108
137
|
*/
|
|
109
138
|
export declare function orderBundleCandidates(hqDir: string): LogCandidate[];
|
|
139
|
+
/**
|
|
140
|
+
* Name of the single entry inside the zip. The `.ndjson` extension is what
|
|
141
|
+
* tells a triager (and `jq`) what is inside once it is unzipped.
|
|
142
|
+
*/
|
|
143
|
+
export declare const LOG_BUNDLE_ENTRY_NAME = "hq-logs.ndjson";
|
|
144
|
+
/** The line collection actually stops at. Never above what ZIP32 can describe. */
|
|
145
|
+
export declare const ZIP32_UNCOMPRESSED_STOP_BYTES: number;
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the entry ceiling a caller asked for. A caller may lower it; nothing
|
|
148
|
+
* may raise it past what the container can describe, because a ceiling above
|
|
149
|
+
* the format's limit is not a ceiling at all.
|
|
150
|
+
*/
|
|
151
|
+
export declare function entryCeiling(requested?: number): number;
|
|
152
|
+
export declare function crc32Fallback(buf: Buffer, seed?: number): number;
|
|
153
|
+
/** Exported for the test that proves the fallback agrees with the native one. */
|
|
154
|
+
export declare function crc32(buf: Buffer, seed?: number): number;
|
|
155
|
+
/**
|
|
156
|
+
* Bytes of ZIP framing around one entry: local header, central directory
|
|
157
|
+
* header, end-of-central-directory. The name is stored twice.
|
|
158
|
+
*/
|
|
159
|
+
export declare function zipOverheadBytes(entryName: string): number;
|
|
110
160
|
/**
|
|
111
161
|
* Read `absPath` in slices, redacting whole lines, invoking `onChunk` with
|
|
112
162
|
* roughly {@link CHUNK_TEXT_BYTES} of redacted text at a time.
|
|
@@ -120,7 +170,7 @@ export declare function streamRedactedFile(absPath: string, sizeBytes: number, m
|
|
|
120
170
|
stopped: boolean;
|
|
121
171
|
}>;
|
|
122
172
|
/**
|
|
123
|
-
* Build a
|
|
173
|
+
* Build a zipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
|
|
124
174
|
*
|
|
125
175
|
* Returns `undefined` when nothing eligible exists, so the caller can skip the
|
|
126
176
|
* upload entirely. Never throws: a bug report must not fail over its own
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
|
|
7
7
|
* caps at 400 KB. Neither can be tuned into carrying a real log history, so the
|
|
8
8
|
* inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
|
|
9
|
-
* produces the other half: a
|
|
9
|
+
* produces the other half: a zipped bundle uploaded direct to S3, carrying the
|
|
10
10
|
* files whole rather than in tail-sized slivers.
|
|
11
11
|
*
|
|
12
12
|
* Three properties are load-bearing and none may be traded away:
|
|
@@ -25,17 +25,33 @@
|
|
|
25
25
|
* 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
|
|
26
26
|
* at the ratio these files compress at. Nothing is ever fully materialised:
|
|
27
27
|
* files are read in slices, redacted a line at a time, and streamed into
|
|
28
|
-
*
|
|
28
|
+
* the deflate stream, with only the compressed output retained.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
30
|
+
* WHY ZIP AND NOT GZIP. This bundle is attached to a Slack thread by the
|
|
31
|
+
* hq-pro feedback worker, and Slack's `files.completeUploadExternal` refuses a
|
|
32
|
+
* gzip member from this app at ANY size — a 52-byte gzip was rejected with
|
|
33
|
+
* `internal_error`, while the same bytes as zip were accepted. That was the
|
|
34
|
+
* root cause of roughly 110 consecutive silent attachment failures. The worker
|
|
35
|
+
* carries a gz-to-zip repack so older CLIs still get an attachable bundle;
|
|
36
|
+
* emitting zip here means the common path never pays for that unpack, and the
|
|
37
|
+
* archive is the format the destination actually accepts.
|
|
38
|
+
*
|
|
39
|
+
* A single deflate member wrapped in a ZIP32 container, one entry. The stream
|
|
40
|
+
* is raw deflate so the container's own CRC and sizes are the only integrity
|
|
41
|
+
* record; crc32 is folded in incrementally as lines are written, and the local
|
|
42
|
+
* header, central directory, and EOCD are assembled once at finish. The size
|
|
43
|
+
* budget is charged on the container, not the member, so the ~98 bytes of
|
|
44
|
+
* framing can never be what pushes an upload past the server's ceiling.
|
|
45
|
+
*
|
|
46
|
+
* Output format inside the entry — NDJSON, one JSON record per line:
|
|
31
47
|
* {"kind":"manifest","version":1,...} exactly one, first
|
|
32
48
|
* {"kind":"file","name":"logs/hq-sync.log",...} one per file
|
|
33
49
|
* {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
|
|
34
50
|
* {"kind":"summary","fileCount":12,...} exactly one, last
|
|
35
51
|
*
|
|
36
|
-
* NDJSON rather than tar
|
|
37
|
-
* bundle is still parseable line-by-line up to the cut, and so the
|
|
38
|
-
* carry the same redaction metadata the inline blob already reports.
|
|
52
|
+
* NDJSON inside the entry rather than a tar of separate members, so a
|
|
53
|
+
* truncated bundle is still parseable line-by-line up to the cut, and so the
|
|
54
|
+
* records carry the same redaction metadata the inline blob already reports.
|
|
39
55
|
*/
|
|
40
56
|
import * as fs from "node:fs";
|
|
41
57
|
import * as os from "node:os";
|
|
@@ -50,10 +66,17 @@ import { vaultApiFetch } from "./vault-api.js";
|
|
|
50
66
|
* stay in step or the CLI will build bundles the server will not accept.
|
|
51
67
|
*/
|
|
52
68
|
export const LOG_BUNDLE_MAX_BYTES = 50 * 1024 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* What the presign request declares, and what the server must answer with.
|
|
71
|
+
* Mirrors `LOG_BUNDLE_FORMAT_ZIP` / `LOG_BUNDLE_ZIP_CONTENT_TYPE` in the hq-pro
|
|
72
|
+
* handler `feedback-log-bundles.ts`.
|
|
73
|
+
*/
|
|
74
|
+
export const LOG_BUNDLE_FORMAT = "zip";
|
|
75
|
+
export const LOG_BUNDLE_CONTENT_TYPE = "application/zip";
|
|
53
76
|
/**
|
|
54
77
|
* Headroom between the size we stop feeding at and the hard cap.
|
|
55
78
|
*
|
|
56
|
-
*
|
|
79
|
+
* deflate reports compressed bytes only as its internal buffer flushes, so the
|
|
57
80
|
* running total lags the bytes actually consumed. The lag is bounded by that
|
|
58
81
|
* buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
|
|
59
82
|
* to spare, and the final size is asserted against the real cap regardless.
|
|
@@ -132,24 +155,171 @@ export function orderBundleCandidates(hqDir) {
|
|
|
132
155
|
const logs = discoverLogFiles(hqDir).sort((a, b) => b.modifiedMs - a.modifiedMs || a.name.localeCompare(b.name));
|
|
133
156
|
return [...state, ...logs];
|
|
134
157
|
}
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Name of the single entry inside the zip. The `.ndjson` extension is what
|
|
160
|
+
* tells a triager (and `jq`) what is inside once it is unzipped.
|
|
161
|
+
*/
|
|
162
|
+
export const LOG_BUNDLE_ENTRY_NAME = "hq-logs.ndjson";
|
|
163
|
+
/**
|
|
164
|
+
* ZIP32 stores the uncompressed size in 32 bits, so a bundle whose contents
|
|
165
|
+
* exceed 4 GiB cannot be described by the container at all.
|
|
166
|
+
*
|
|
167
|
+
* The compressed cap does not prevent this. Log text compresses at roughly 24x
|
|
168
|
+
* in practice but there is no upper bound on the ratio — repetitive logs go
|
|
169
|
+
* far higher — and discovery admits an unbounded number of files at up to
|
|
170
|
+
* {@link LOG_BUNDLE_MAX_FILE_RAW_BYTES} each. Collection stops at the line
|
|
171
|
+
* below and reports `truncated`, which is what the caller already handles;
|
|
172
|
+
* without it `writeUInt32LE` throws at finish and the outer catch discards the
|
|
173
|
+
* whole bundle, so the installation with the most logs would send none.
|
|
174
|
+
*/
|
|
175
|
+
const ZIP32_MAX_UNCOMPRESSED_BYTES = 0xffffffff;
|
|
176
|
+
/**
|
|
177
|
+
* Room left below the ZIP32 ceiling for the chunk in flight plus the closing
|
|
178
|
+
* summary record. A chunk is written whole before the budget is re-checked.
|
|
179
|
+
*/
|
|
180
|
+
const ZIP32_UNCOMPRESSED_MARGIN_BYTES = 1024 * 1024;
|
|
181
|
+
/** The line collection actually stops at. Never above what ZIP32 can describe. */
|
|
182
|
+
export const ZIP32_UNCOMPRESSED_STOP_BYTES = ZIP32_MAX_UNCOMPRESSED_BYTES - ZIP32_UNCOMPRESSED_MARGIN_BYTES;
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the entry ceiling a caller asked for. A caller may lower it; nothing
|
|
185
|
+
* may raise it past what the container can describe, because a ceiling above
|
|
186
|
+
* the format's limit is not a ceiling at all.
|
|
187
|
+
*/
|
|
188
|
+
export function entryCeiling(requested) {
|
|
189
|
+
if (requested === undefined || !Number.isFinite(requested)) {
|
|
190
|
+
return ZIP32_UNCOMPRESSED_STOP_BYTES;
|
|
191
|
+
}
|
|
192
|
+
return Math.max(0, Math.min(requested, ZIP32_UNCOMPRESSED_STOP_BYTES));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* crc32 with a fallback, because `zlib.crc32` is newer than this package's
|
|
196
|
+
* floor: it landed in Node 22.2.0 and was backported only to 20.15.0, while
|
|
197
|
+
* `engines.node` is `>=20.0.0`. On Node 20.0-20.14 and 21.x the native call is
|
|
198
|
+
* undefined, the first sink write throws, and `buildLogBundle` catches it and
|
|
199
|
+
* returns undefined — every report on those runtimes silently loses its log
|
|
200
|
+
* bundle. The table is built once, on first use, and only when needed.
|
|
201
|
+
*/
|
|
202
|
+
let crcTable = null;
|
|
203
|
+
export function crc32Fallback(buf, seed = 0) {
|
|
204
|
+
if (!crcTable) {
|
|
205
|
+
crcTable = new Uint32Array(256);
|
|
206
|
+
for (let i = 0; i < 256; i++) {
|
|
207
|
+
let c = i;
|
|
208
|
+
for (let k = 0; k < 8; k++)
|
|
209
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
210
|
+
crcTable[i] = c >>> 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let crc = ~seed >>> 0;
|
|
214
|
+
for (let i = 0; i < buf.length; i++) {
|
|
215
|
+
crc = (crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0;
|
|
216
|
+
}
|
|
217
|
+
return (~crc >>> 0) >>> 0;
|
|
218
|
+
}
|
|
219
|
+
/** Exported for the test that proves the fallback agrees with the native one. */
|
|
220
|
+
export function crc32(buf, seed = 0) {
|
|
221
|
+
const native = zlib.crc32;
|
|
222
|
+
return typeof native === "function" ? native(buf, seed) : crc32Fallback(buf, seed);
|
|
223
|
+
}
|
|
224
|
+
/** Local file header, central directory header, and EOCD signatures. */
|
|
225
|
+
const ZIP_LOCAL_SIG = 0x04034b50;
|
|
226
|
+
const ZIP_CENTRAL_SIG = 0x02014b50;
|
|
227
|
+
const ZIP_EOCD_SIG = 0x06054b50;
|
|
228
|
+
/** Deflate, no data descriptor: sizes and CRC are known before a header is written. */
|
|
229
|
+
const ZIP_METHOD_DEFLATE = 8;
|
|
230
|
+
const ZIP_VERSION = 20;
|
|
231
|
+
/**
|
|
232
|
+
* A fixed MS-DOS timestamp, so the same logs produce the same bytes.
|
|
233
|
+
*
|
|
234
|
+
* Deliberately not the wall clock: a bundle's timing lives in the manifest
|
|
235
|
+
* record, which is redacted content the recipient can read, rather than in
|
|
236
|
+
* archive metadata that no tool here surfaces. 0x0021 is 1980-01-01, the
|
|
237
|
+
* earliest the format can represent.
|
|
238
|
+
*/
|
|
239
|
+
const ZIP_DOS_TIME = 0;
|
|
240
|
+
const ZIP_DOS_DATE = 0x0021;
|
|
241
|
+
/**
|
|
242
|
+
* Bytes of ZIP framing around one entry: local header, central directory
|
|
243
|
+
* header, end-of-central-directory. The name is stored twice.
|
|
244
|
+
*/
|
|
245
|
+
export function zipOverheadBytes(entryName) {
|
|
246
|
+
return 30 + 46 + 22 + Buffer.byteLength(entryName, "utf8") * 2;
|
|
247
|
+
}
|
|
248
|
+
function createZipSink(entryName = LOG_BUNDLE_ENTRY_NAME, maxUncompressed = ZIP32_UNCOMPRESSED_STOP_BYTES) {
|
|
249
|
+
const name = Buffer.from(entryName, "utf8");
|
|
250
|
+
const deflate = zlib.createDeflateRaw({ level: 9 });
|
|
137
251
|
const parts = [];
|
|
252
|
+
const overhead = zipOverheadBytes(entryName);
|
|
138
253
|
let compressed = 0;
|
|
254
|
+
let uncompressed = 0;
|
|
255
|
+
let crc = 0;
|
|
139
256
|
let failure = null;
|
|
140
|
-
|
|
257
|
+
deflate.on("data", (chunk) => {
|
|
141
258
|
parts.push(chunk);
|
|
142
259
|
compressed += chunk.byteLength;
|
|
143
260
|
});
|
|
144
|
-
|
|
261
|
+
deflate.on("error", (err) => {
|
|
145
262
|
failure = err;
|
|
146
263
|
});
|
|
264
|
+
function container(body) {
|
|
265
|
+
const local = Buffer.alloc(30);
|
|
266
|
+
local.writeUInt32LE(ZIP_LOCAL_SIG, 0);
|
|
267
|
+
local.writeUInt16LE(ZIP_VERSION, 4);
|
|
268
|
+
local.writeUInt16LE(0, 6); // flags
|
|
269
|
+
local.writeUInt16LE(ZIP_METHOD_DEFLATE, 8);
|
|
270
|
+
local.writeUInt16LE(ZIP_DOS_TIME, 10);
|
|
271
|
+
local.writeUInt16LE(ZIP_DOS_DATE, 12);
|
|
272
|
+
local.writeUInt32LE(crc >>> 0, 14);
|
|
273
|
+
local.writeUInt32LE(body.byteLength, 18);
|
|
274
|
+
local.writeUInt32LE(uncompressed, 22);
|
|
275
|
+
local.writeUInt16LE(name.byteLength, 26);
|
|
276
|
+
local.writeUInt16LE(0, 28); // extra length
|
|
277
|
+
const central = Buffer.alloc(46);
|
|
278
|
+
central.writeUInt32LE(ZIP_CENTRAL_SIG, 0);
|
|
279
|
+
central.writeUInt16LE(ZIP_VERSION, 4); // version made by
|
|
280
|
+
central.writeUInt16LE(ZIP_VERSION, 6); // version needed
|
|
281
|
+
central.writeUInt16LE(0, 8); // flags
|
|
282
|
+
central.writeUInt16LE(ZIP_METHOD_DEFLATE, 10);
|
|
283
|
+
central.writeUInt16LE(ZIP_DOS_TIME, 12);
|
|
284
|
+
central.writeUInt16LE(ZIP_DOS_DATE, 14);
|
|
285
|
+
central.writeUInt32LE(crc >>> 0, 16);
|
|
286
|
+
central.writeUInt32LE(body.byteLength, 20);
|
|
287
|
+
central.writeUInt32LE(uncompressed, 24);
|
|
288
|
+
central.writeUInt16LE(name.byteLength, 28);
|
|
289
|
+
central.writeUInt16LE(0, 30); // extra
|
|
290
|
+
central.writeUInt16LE(0, 32); // comment
|
|
291
|
+
central.writeUInt16LE(0, 34); // disk number start
|
|
292
|
+
central.writeUInt16LE(0, 36); // internal attrs
|
|
293
|
+
central.writeUInt32LE(0, 38); // external attrs
|
|
294
|
+
central.writeUInt32LE(0, 42); // local header offset
|
|
295
|
+
const centralSize = central.byteLength + name.byteLength;
|
|
296
|
+
const centralOffset = local.byteLength + name.byteLength + body.byteLength;
|
|
297
|
+
const eocd = Buffer.alloc(22);
|
|
298
|
+
eocd.writeUInt32LE(ZIP_EOCD_SIG, 0);
|
|
299
|
+
eocd.writeUInt16LE(0, 4); // this disk
|
|
300
|
+
eocd.writeUInt16LE(0, 6); // disk with central dir
|
|
301
|
+
eocd.writeUInt16LE(1, 8); // entries on this disk
|
|
302
|
+
eocd.writeUInt16LE(1, 10); // entries total
|
|
303
|
+
eocd.writeUInt32LE(centralSize, 12);
|
|
304
|
+
eocd.writeUInt32LE(centralOffset, 16);
|
|
305
|
+
eocd.writeUInt16LE(0, 20); // comment length
|
|
306
|
+
return Buffer.concat([local, name, body, central, name, eocd]);
|
|
307
|
+
}
|
|
147
308
|
return {
|
|
148
|
-
|
|
309
|
+
// The budget is charged on what will be PUT, framing included, so the
|
|
310
|
+
// container can never be what carries the upload over the server's cap.
|
|
311
|
+
compressedBytes: () => overhead + compressed,
|
|
312
|
+
atUncompressedLimit: () => uncompressed > maxUncompressed,
|
|
149
313
|
write: (line) => new Promise((resolve, reject) => {
|
|
150
314
|
if (failure)
|
|
151
315
|
return reject(failure);
|
|
152
|
-
|
|
316
|
+
const buf = Buffer.from(line, "utf8");
|
|
317
|
+
// Folded in as we go: the whole point of this module is that the
|
|
318
|
+
// uncompressed text is never materialised, so there is nothing to
|
|
319
|
+
// checksum at the end.
|
|
320
|
+
crc = crc32(buf, crc);
|
|
321
|
+
uncompressed += buf.byteLength;
|
|
322
|
+
deflate.write(buf, (err) => (err ? reject(err) : resolve()));
|
|
153
323
|
}),
|
|
154
324
|
// Z_SYNC_FLUSH costs a handful of bytes per boundary and a little ratio.
|
|
155
325
|
// That is the price of a budget that is enforced rather than estimated:
|
|
@@ -159,13 +329,13 @@ function createGzipSink() {
|
|
|
159
329
|
sync: () => new Promise((resolve, reject) => {
|
|
160
330
|
if (failure)
|
|
161
331
|
return reject(failure);
|
|
162
|
-
|
|
332
|
+
deflate.flush(zlib.constants.Z_SYNC_FLUSH, () => resolve());
|
|
163
333
|
}),
|
|
164
334
|
finish: () => new Promise((resolve, reject) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
335
|
+
deflate.on("end", () => failure ? reject(failure) : resolve(container(Buffer.concat(parts))));
|
|
336
|
+
deflate.on("error", reject);
|
|
337
|
+
deflate.end();
|
|
338
|
+
deflate.resume();
|
|
169
339
|
}),
|
|
170
340
|
};
|
|
171
341
|
}
|
|
@@ -268,7 +438,7 @@ export async function streamRedactedFile(absPath, sizeBytes, maxRawBytes, chunkB
|
|
|
268
438
|
}
|
|
269
439
|
}
|
|
270
440
|
/**
|
|
271
|
-
* Build a
|
|
441
|
+
* Build a zipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
|
|
272
442
|
*
|
|
273
443
|
* Returns `undefined` when nothing eligible exists, so the caller can skip the
|
|
274
444
|
* upload entirely. Never throws: a bug report must not fail over its own
|
|
@@ -294,7 +464,7 @@ export async function buildLogBundle(opts = {}) {
|
|
|
294
464
|
if (candidates.length === 0)
|
|
295
465
|
return undefined;
|
|
296
466
|
try {
|
|
297
|
-
const sink =
|
|
467
|
+
const sink = createZipSink(LOG_BUNDLE_ENTRY_NAME, entryCeiling(opts.maxEntryBytes));
|
|
298
468
|
let fileCount = 0;
|
|
299
469
|
let rawBytes = 0;
|
|
300
470
|
let redactions = 0;
|
|
@@ -309,7 +479,7 @@ export async function buildLogBundle(opts = {}) {
|
|
|
309
479
|
}));
|
|
310
480
|
for (const candidate of candidates) {
|
|
311
481
|
await sink.sync();
|
|
312
|
-
if (sink.compressedBytes() > stopAt) {
|
|
482
|
+
if (sink.compressedBytes() > stopAt || sink.atUncompressedLimit()) {
|
|
313
483
|
truncated = true;
|
|
314
484
|
break;
|
|
315
485
|
}
|
|
@@ -334,7 +504,7 @@ export async function buildLogBundle(opts = {}) {
|
|
|
334
504
|
// Stop feeding once the compressed total reaches the stop line.
|
|
335
505
|
// The sync is what makes that total trustworthy.
|
|
336
506
|
await sink.sync();
|
|
337
|
-
return sink.compressedBytes() <= stopAt;
|
|
507
|
+
return sink.compressedBytes() <= stopAt && !sink.atUncompressedLimit();
|
|
338
508
|
});
|
|
339
509
|
stopped = result.stopped;
|
|
340
510
|
}
|
|
@@ -349,18 +519,18 @@ export async function buildLogBundle(opts = {}) {
|
|
|
349
519
|
}
|
|
350
520
|
}
|
|
351
521
|
await sink.write(record({ kind: "summary", fileCount, rawBytes, redactions, truncated }));
|
|
352
|
-
const
|
|
522
|
+
const bytes = await sink.finish();
|
|
353
523
|
// Nothing but a manifest and a summary is not worth uploading.
|
|
354
524
|
if (fileCount === 0)
|
|
355
525
|
return undefined;
|
|
356
526
|
// Final authority. The stop line plus margin should make this unreachable,
|
|
357
527
|
// but the server refuses to presign above the cap, so a bundle that
|
|
358
528
|
// overshot is useless and must not be offered.
|
|
359
|
-
if (
|
|
529
|
+
if (bytes.byteLength > maxBytes)
|
|
360
530
|
return undefined;
|
|
361
531
|
return {
|
|
362
|
-
|
|
363
|
-
sizeBytes:
|
|
532
|
+
bytes,
|
|
533
|
+
sizeBytes: bytes.byteLength,
|
|
364
534
|
fileCount,
|
|
365
535
|
truncated,
|
|
366
536
|
rawBytes,
|
|
@@ -395,7 +565,10 @@ export async function uploadLogBundle(opts) {
|
|
|
395
565
|
token: opts.token,
|
|
396
566
|
path: "/v1/feedback/logs/presign",
|
|
397
567
|
method: "POST",
|
|
398
|
-
|
|
568
|
+
// The server owns the key and the signed content type, so it has to be
|
|
569
|
+
// told which format is coming; without this it mints a `.ndjson.gz` slot
|
|
570
|
+
// signed `application/gzip` and the PUT would be a lie on both counts.
|
|
571
|
+
body: { sizeBytes: bundle.sizeBytes, format: LOG_BUNDLE_FORMAT },
|
|
399
572
|
});
|
|
400
573
|
if (!res.ok)
|
|
401
574
|
return undefined;
|
|
@@ -404,15 +577,22 @@ export async function uploadLogBundle(opts) {
|
|
|
404
577
|
if (!slot || typeof slot.key !== "string" || typeof slot.url !== "string") {
|
|
405
578
|
return undefined;
|
|
406
579
|
}
|
|
580
|
+
// A server too old to know about `format` answers with a gzip slot. Its
|
|
581
|
+
// signature binds `application/gzip` and its key claims `.gz`, and these
|
|
582
|
+
// bytes are neither. Skipping is the honest outcome and costs only the
|
|
583
|
+
// bundle — the submission still carries the inline logs, which is exactly
|
|
584
|
+
// what happens when bundles are disabled entirely.
|
|
585
|
+
if (slot.contentType !== LOG_BUNDLE_CONTENT_TYPE)
|
|
586
|
+
return undefined;
|
|
407
587
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
408
588
|
const put = await doFetch(slot.url, {
|
|
409
589
|
method: "PUT",
|
|
410
590
|
headers: {
|
|
411
|
-
"Content-Type":
|
|
591
|
+
"Content-Type": slot.contentType,
|
|
412
592
|
// Must match the length bound into the signature, or S3 rejects it.
|
|
413
593
|
"Content-Length": String(bundle.sizeBytes),
|
|
414
594
|
},
|
|
415
|
-
body: new Uint8Array(bundle.
|
|
595
|
+
body: new Uint8Array(bundle.bytes),
|
|
416
596
|
});
|
|
417
597
|
if (!put.ok)
|
|
418
598
|
return undefined;
|