@indigoai-us/hq-cli 5.345.49 → 5.345.50

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.
@@ -5,6 +5,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
8
+ import { dmForwardCliEnabled } from "./dm-forward-flag.js";
8
9
  /**
9
10
  * Classify a recipient arg as an email or a person/agent uid. Mirrors the
10
11
  * email/uid heuristic used by `hq members`. Returns null for neither.
@@ -346,6 +347,32 @@ function friendlyDmError(status, code, fallback) {
346
347
  return `Server error: ${fallback}`;
347
348
  return fallback;
348
349
  }
350
+ function friendlyForwardError(code) {
351
+ switch (code) {
352
+ case "FORWARD_SOURCE_NOT_FOUND":
353
+ return "Source message not found or you cannot read it.";
354
+ case "INVALID_FORWARD_OF":
355
+ return "Invalid source message reference; provide a valid eventId and --from value.";
356
+ case "INVALID_FILE_ACCESS":
357
+ return "Invalid file access choice; use --grant-files or --omit-files.";
358
+ case "FORWARD_FILE_ACCESS_REQUIRED":
359
+ return "Some forwarded files are not readable by the recipient.";
360
+ case "FORWARD_FILES_NOT_ALLOWED":
361
+ return "Files cannot be granted for this channel or cross-company forward.";
362
+ case "CROSS_COMPANY_ACK_REQUIRED":
363
+ return "Warning: this forward crosses company boundaries; review the destination and retry with --cross-company.";
364
+ case "CROSS_COMPANY_FORBIDDEN":
365
+ return "Cross-company forwarding requires owner or admin access in both companies.";
366
+ case "FORWARD_FILE_SHARE_FORBIDDEN":
367
+ return "You do not have permission to share one or more forwarded files.";
368
+ case "FORWARD_FILE_GRANT_FAILED":
369
+ return "The recipient could not be granted access to a forwarded file; no message was sent.";
370
+ case "RECIPIENT_NOT_FOUND":
371
+ return "Recipient not found or not reachable.";
372
+ default:
373
+ return "Message forwarding failed; check the source and destination, then try again.";
374
+ }
375
+ }
349
376
  /**
350
377
  * Resolve a connection request from a pending-requests list by a caller-supplied
351
378
  * identifier (email, personUid, or pairKey). Pure → unit-testable.
@@ -529,6 +556,21 @@ export async function fetchChannelMembers(token, channelId) {
529
556
  const data = (await res.json());
530
557
  return data.members ?? [];
531
558
  }
559
+ /** Resolve structured mentions in a channel message body, failing before post on invalid names. */
560
+ async function resolveChannelBodyMentions(token, channelId, body) {
561
+ const tokens = parseMentionTokens(body);
562
+ if (tokens.length === 0)
563
+ return { ok: true, body, mentions: [] };
564
+ const members = await fetchChannelMembers(token, channelId);
565
+ const resolved = resolveMentions(tokens, members);
566
+ if (!resolved.ok)
567
+ return resolved;
568
+ return {
569
+ ok: true,
570
+ body: stripMentionQuotes(body),
571
+ mentions: resolved.mentions,
572
+ };
573
+ }
532
574
  /** Fetch a channel history page (GET /v1/notify/channels/{id}/messages). Newest-first. */
533
575
  async function fetchChannelMessages(token, channelId, query = {}) {
534
576
  const res = await vaultApiFetch({
@@ -546,9 +588,9 @@ async function fetchChannelMessages(token, channelId, query = {}) {
546
588
  /**
547
589
  * Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
548
590
  * `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
549
- * GET /v1/notify/channels, then posts the message into it. Scheduling /
550
- * prompt / details are 1:1-DM features and are rejected here rather than
551
- * silently dropped.
591
+ * GET /v1/notify/channels, then posts the message into it. Scheduling remains
592
+ * a 1:1-DM feature; prompt and details are supported for channel sends while
593
+ * the dm.forward-cli kill switch is enabled.
552
594
  */
553
595
  async function runChannelSend(channelName, message, opts) {
554
596
  try {
@@ -557,12 +599,17 @@ async function runChannelSend(channelName, message, opts) {
557
599
  console.error(chalk.red(`A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`));
558
600
  process.exit(1);
559
601
  }
560
- const unsupported = [
561
- opts.prompt || opts.promptFile ? "--prompt/--prompt-file" : null,
562
- opts.details || opts.detailsFile ? "--details/--details-file" : null,
563
- opts.at ? "--at" : null,
564
- opts.in ? "--in" : null,
565
- ].filter(Boolean);
602
+ const hasChannelContext = opts.prompt !== undefined || opts.promptFile !== undefined ||
603
+ opts.details !== undefined || opts.detailsFile !== undefined;
604
+ if (hasChannelContext && !dmForwardCliEnabled()) {
605
+ console.error(chalk.red("Channel --details/--prompt support is disabled by the dm.forward-cli kill switch."));
606
+ process.exit(1);
607
+ }
608
+ if (opts.promptFile === "" || opts.detailsFile === "") {
609
+ console.error(chalk.red("--prompt-file and --details-file require a non-empty path."));
610
+ process.exit(1);
611
+ }
612
+ const unsupported = [opts.at ? "--at" : null, opts.in ? "--in" : null].filter(Boolean);
566
613
  if (unsupported.length > 0) {
567
614
  console.error(chalk.red(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} only supported for 1:1 DMs, not channel messages.`));
568
615
  process.exit(1);
@@ -599,21 +646,16 @@ async function runChannelSend(channelName, message, opts) {
599
646
  // and posts the text literally.
600
647
  let mentions = [];
601
648
  if (opts.mentions !== false) {
602
- const tokens = parseMentionTokens(body);
603
- if (tokens.length > 0) {
604
- const members = await fetchChannelMembers(token, channel.channelId);
605
- const resolved = resolveMentions(tokens, members);
606
- if (!resolved.ok) {
607
- console.error(chalk.red(resolved.error));
608
- process.exit(1);
609
- return;
610
- }
611
- mentions = resolved.mentions;
649
+ const resolved = await resolveChannelBodyMentions(token, channel.channelId, body);
650
+ if (!resolved.ok) {
651
+ console.error(chalk.red(resolved.error));
652
+ process.exit(1);
653
+ return;
612
654
  }
655
+ body = resolved.body;
656
+ mentions = resolved.mentions;
613
657
  }
614
658
  // Send the human-readable form: the quotes only existed for parsing.
615
- if (mentions.length > 0)
616
- body = stripMentionQuotes(body);
617
659
  let audience;
618
660
  if (opts.audience !== undefined && opts.audience !== "") {
619
661
  if (!DM_AUDIENCE_VALUES.includes(opts.audience)) {
@@ -622,6 +664,12 @@ async function runChannelSend(channelName, message, opts) {
622
664
  audience = opts.audience;
623
665
  }
624
666
  const payload = { body };
667
+ const prompt = opts.promptFile ? readFileSync(opts.promptFile, "utf8") : opts.prompt;
668
+ const details = opts.detailsFile ? readFileSync(opts.detailsFile, "utf8") : opts.details;
669
+ if (prompt !== undefined)
670
+ payload.prompt = prompt;
671
+ if (details !== undefined)
672
+ payload.details = details;
625
673
  if (mentions.length > 0)
626
674
  payload.mentions = mentions;
627
675
  if (rootEventId)
@@ -736,6 +784,130 @@ async function runDmSend(recipient, message, opts) {
736
784
  process.exit(1);
737
785
  }
738
786
  }
787
+ async function resolveForwardSourceConversation(token, source) {
788
+ if (source.includes(",")) {
789
+ throw new Error("--from accepts one person or channel, not a group recipient list.");
790
+ }
791
+ const channelName = parseChannelName(source);
792
+ if (channelName !== null) {
793
+ const channel = await resolveChannel(token, channelName);
794
+ return { conversationId: channel.channelId };
795
+ }
796
+ const recipient = detectRecipient(source);
797
+ if (!recipient) {
798
+ throw new Error("--from must be a person email, person/agent UID, or channel name/ID.");
799
+ }
800
+ if (recipient.toPersonUid)
801
+ return { conversationId: recipient.toPersonUid };
802
+ const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
803
+ if (!res.ok)
804
+ throw new Error("Could not resolve the source person; use their person UID.");
805
+ const data = (await res.json());
806
+ const peer = (data.contacts ?? []).find((contact) => contact.email?.trim().toLowerCase() === recipient.toEmail);
807
+ if (!peer?.personUid)
808
+ throw new Error("Source person not found in your contacts; use their person UID.");
809
+ return { conversationId: peer.personUid };
810
+ }
811
+ async function runDmForward(eventId, opts) {
812
+ try {
813
+ if (!dmForwardCliEnabled()) {
814
+ console.error(chalk.red("Message forwarding is disabled by the dm.forward-cli kill switch."));
815
+ process.exit(1);
816
+ }
817
+ if (opts.grantFiles && opts.omitFiles) {
818
+ console.error(chalk.red("Choose only one of --grant-files or --omit-files."));
819
+ process.exit(1);
820
+ }
821
+ if (opts.note !== undefined && opts.noteFile !== undefined) {
822
+ console.error(chalk.red("Choose only one of --note or --note-file."));
823
+ process.exit(1);
824
+ }
825
+ if (!eventId.trim()) {
826
+ console.error(chalk.red("A source eventId is required."));
827
+ process.exit(1);
828
+ }
829
+ if (opts.to.includes(",")) {
830
+ console.error(chalk.red("--to accepts one recipient or channel, not a group recipient list."));
831
+ process.exit(1);
832
+ }
833
+ const note = opts.noteFile !== undefined
834
+ ? readFileSync(opts.noteFile, "utf8")
835
+ : opts.note;
836
+ const token = await ensureCognitoToken();
837
+ const source = await resolveForwardSourceConversation(token, opts.from);
838
+ const destinationChannelName = parseChannelName(opts.to);
839
+ const fileAccess = opts.grantFiles ? "grant" : opts.omitFiles ? "omit" : undefined;
840
+ const shared = {
841
+ ...(note !== undefined ? { body: note } : {}),
842
+ forwardOf: { conversationId: source.conversationId, eventId: eventId.trim() },
843
+ ...(fileAccess ? { fileAccess } : {}),
844
+ ...(opts.crossCompany ? { acknowledgeCrossCompany: true } : {}),
845
+ };
846
+ let path;
847
+ let payload;
848
+ let destinationLabel;
849
+ if (destinationChannelName !== null) {
850
+ const channel = await resolveChannel(token, destinationChannelName);
851
+ path = `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`;
852
+ payload = { ...shared };
853
+ if (note !== undefined) {
854
+ const resolved = await resolveChannelBodyMentions(token, channel.channelId, note);
855
+ if (!resolved.ok) {
856
+ console.error(chalk.red(resolved.error));
857
+ process.exit(1);
858
+ return;
859
+ }
860
+ payload.body = resolved.body;
861
+ if (resolved.mentions.length > 0)
862
+ payload.mentions = resolved.mentions;
863
+ }
864
+ destinationLabel = `#${channel.name ?? destinationChannelName}`;
865
+ }
866
+ else {
867
+ const recipient = detectRecipient(opts.to);
868
+ if (!recipient) {
869
+ console.error(chalk.red("--to must be a person email, person/agent UID, or channel name/ID."));
870
+ process.exit(1);
871
+ }
872
+ path = "/v1/notify/dm";
873
+ payload = { ...recipient, ...shared };
874
+ destinationLabel = opts.to;
875
+ }
876
+ const res = await vaultApiFetch({ token, path, method: "POST", body: payload });
877
+ if (!res.ok) {
878
+ const err = (await res.json().catch(() => ({})));
879
+ const code = typeof err.code === "string" ? err.code : undefined;
880
+ if (code === "FORWARD_FILE_ACCESS_REQUIRED") {
881
+ console.error(chalk.yellow(friendlyForwardError(code)));
882
+ const files = Array.isArray(err.files) ? err.files : [];
883
+ for (const item of files) {
884
+ if (!item || typeof item !== "object")
885
+ continue;
886
+ const file = item;
887
+ const label = typeof file.name === "string" ? file.name : "forwarded file";
888
+ const location = typeof file.vaultPath === "string" ? file.vaultPath :
889
+ typeof file.path === "string" ? file.path : undefined;
890
+ console.error(chalk.dim(` - ${label}${location ? ` (${location})` : ""}`));
891
+ }
892
+ console.error(chalk.dim("Retry with --grant-files or --omit-files."));
893
+ }
894
+ else {
895
+ console.error(chalk.red(friendlyForwardError(code)));
896
+ }
897
+ process.exit(1);
898
+ }
899
+ const data = (await res.json().catch(() => ({})));
900
+ if (typeof data.eventId !== "string" || !data.eventId.trim()) {
901
+ console.error(chalk.red("Forward succeeded without returning an eventId."));
902
+ process.exit(1);
903
+ }
904
+ console.log(chalk.green(`Message forwarded to ${destinationLabel}. New eventId: ${data.eventId}`));
905
+ }
906
+ catch (err) {
907
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
908
+ process.exit(1);
909
+ }
910
+ }
739
911
  async function runDmRequests() {
740
912
  try {
741
913
  const token = await ensureCognitoToken();
@@ -871,7 +1043,11 @@ export function formatThreadMessage(m, nowMs) {
871
1043
  const who = m.direction === "out" ? "you" : senderLabel(m);
872
1044
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
873
1045
  const audienceTag = m.audience === "agent" ? chalk.dim(" [agent]") : "";
874
- return `${arrow} ${chalk.bold(who)} ${when}${audienceTag}\n ${firstLine(m.body)}${formatAttachments(m)}`;
1046
+ const id = m.eventId ? ` ${chalk.dim(m.eventId)}` : "";
1047
+ const forwarded = m.forwardedFrom?.senderName
1048
+ ? `\n ${chalk.dim(`Forwarded from ${m.forwardedFrom.senderName}`)}`
1049
+ : "";
1050
+ return `${arrow} ${chalk.bold(who)} ${when}${audienceTag}${id}${forwarded}\n ${firstLine(m.body)}${formatAttachments(m)}`;
875
1051
  }
876
1052
  /** Render one channel/group message line. */
877
1053
  export function formatChannelMessage(m, nowMs, opts = {}) {
@@ -886,7 +1062,10 @@ export function formatChannelMessage(m, nowMs, opts = {}) {
886
1062
  (senderLabel(m).toLowerCase() === "hq"
887
1063
  ? "System notification (no text provided)."
888
1064
  : "(no message text)");
889
- return `${indent}${who} ${when}${idPart}\n${bodyIndent}${readableBody}${formatAttachments(m, bodyIndent)}`;
1065
+ const forwarded = m.forwardedFrom?.senderName
1066
+ ? `${bodyIndent}${chalk.dim(`Forwarded from ${m.forwardedFrom.senderName}`)}\n`
1067
+ : "";
1068
+ return `${indent}${who} ${when}${idPart}\n${forwarded}${bodyIndent}${readableBody}${formatAttachments(m, bodyIndent)}`;
890
1069
  }
891
1070
  /**
892
1071
  * Read the caller's person/agent uid and email from a JWT we already hold.
@@ -1330,16 +1509,29 @@ export function registerDmCommand(program) {
1330
1509
  .option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
1331
1510
  .option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
1332
1511
  .option("--thread <eventId>", 'Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel')
1333
- .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
1334
- .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
1335
- .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
1336
- .option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
1512
+ .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs; channel sends also supported)")
1513
+ .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs; channel sends also supported)")
1514
+ .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs; channel sends also supported)")
1515
+ .option("--details-file <path>", "Read the details from a file (1:1 DMs; channel sends also supported)")
1337
1516
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1338
1517
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1339
1518
  .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both")
1340
1519
  .action(async (recipient, message, opts) => {
1341
1520
  await runDmSend(recipient, message, opts);
1342
1521
  });
1522
+ dm
1523
+ .command("forward <eventId>")
1524
+ .description("Forward a DM or channel message through the existing send routes.")
1525
+ .requiredOption("--from <person|channel>", "Source person email/UID or channel name/ID")
1526
+ .requiredOption("--to <person|agent|channel>", "Destination person email/UID, agent UID, or channel name/ID")
1527
+ .option("--note <text>", "Optional note to add above the forwarded message")
1528
+ .option("--note-file <path>", "Read the optional forward note from a file")
1529
+ .option("--grant-files", "Grant the recipient access to files they cannot currently open")
1530
+ .option("--omit-files", "Omit files the recipient cannot currently open")
1531
+ .option("--cross-company", "Confirm forwarding across company boundaries")
1532
+ .action(async (eventId, opts) => {
1533
+ await runDmForward(eventId, opts);
1534
+ });
1343
1535
  dm
1344
1536
  .command("inbox")
1345
1537
  .description("List your recent incoming direct messages.")
@@ -14,8 +14,6 @@ export interface HqCloudCopy {
14
14
  }
15
15
  export interface PickedHqCloud extends HqCloudCopy {
16
16
  source: "bundled" | "managed";
17
- /** Set when a version is pinned but that version is not the installed managed copy. */
18
- pinnedVersionMissing?: string;
19
17
  }
20
18
  export type ManagedHqCloud = PickedHqCloud & {
21
19
  source: "managed";
@@ -85,6 +83,8 @@ export interface UpdateHqCloudDeps {
85
83
  run?: (file: string, args: string[], shell: boolean, env: NodeJS.ProcessEnv) => NpmRunResult;
86
84
  bundled?: HqCloudCopy | null;
87
85
  platform?: NodeJS.Platform;
86
+ /** Test seam for bounded install retry delays. */
87
+ sleep?: (ms: number) => void;
88
88
  }
89
89
  /** Install or update the updater's copy, as this (the login) user. */
90
90
  export declare function updateManagedHqCloud(daemonRoot: string, env: NodeJS.ProcessEnv, deps?: UpdateHqCloudDeps): HqCloudUpdateOutcome;
@@ -54,10 +54,11 @@ export function pickHqCloud(bundled, managed, usable = () => true, pinnedVersion
54
54
  if (managed && managed.version === pinnedVersion && b && m && m[0] === b[0] && usable(managed)) {
55
55
  return { ...managed, source: "managed" };
56
56
  }
57
- // The pinned version is not installed yet (the updater installs it). Keep
58
- // running the newest usable copy meanwhile instead of dropping to an older
59
- // bundled one, and report the missing pin.
60
- return { ...pickNewest(bundled, managed, b, m, usable), pinnedVersionMissing: pinnedVersion };
57
+ if (bundled.version === pinnedVersion && b && usable(bundled))
58
+ return { ...bundled, source: "bundled" };
59
+ // A pin is an exact version contract. Do not silently run another installed
60
+ // package while npm has not yet delivered the pinned version.
61
+ return null;
61
62
  }
62
63
  return pickNewest(bundled, managed, b, m, usable);
63
64
  }
@@ -160,6 +161,17 @@ export function currentHqCloudAfterLeaseRelease(daemonRoot, expectedVersion) {
160
161
  return null;
161
162
  return { ...managed, source: "managed" };
162
163
  }
164
+ function isTransientNpmFailure(result) {
165
+ // spawnSync's ETIMEDOUT means this attempt hit our full 10-minute process
166
+ // timeout. Retrying that result would multiply one stall across every attempt.
167
+ if (result.error?.code === "ETIMEDOUT")
168
+ return false;
169
+ const text = `${result.stderr ?? ""}\n${result.stdout ?? ""}\n${result.error?.message ?? ""}`;
170
+ return /\b(?:ETARGET|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETWORK|ECONNABORTED)\b|\bnotarget\b|network timeout|fetch failed|socket hang up/i.test(text);
171
+ }
172
+ function sleepSync(ms) {
173
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
174
+ }
163
175
  function runNpm(file, args, shell, env) {
164
176
  return spawnSync(file, args, {
165
177
  encoding: "utf8",
@@ -180,7 +192,7 @@ export function updateManagedHqCloud(daemonRoot, env, deps = {}) {
180
192
  const platform = deps.platform ?? process.platform;
181
193
  const pinnedVersion = readDaemonConfig(path.join(daemonRoot, "config.json")).hqCloudVersion;
182
194
  const bundled = deps.bundled !== undefined ? deps.bundled : bundledHqCloud();
183
- const from = currentHqCloud(daemonRoot)?.version ?? null;
195
+ const from = managedHqCloud(daemonRoot)?.version ?? bundled?.version ?? null;
184
196
  if (!bundled)
185
197
  return { ok: false, from, to: null, detail: "this CLI install has no bundled hq-cloud to match" };
186
198
  const prefix = managedHqCloudDir(daemonRoot);
@@ -203,9 +215,26 @@ export function updateManagedHqCloud(daemonRoot, env, deps = {}) {
203
215
  version = latest;
204
216
  }
205
217
  const cmd = hqCloudInstallCommand(prefix, version, platform);
206
- const r = run(cmd.file, cmd.args, cmd.shell, env);
207
- if (r.status !== 0)
208
- return { ok: false, from, to: null, detail: npmFailure(r) };
209
- return { ok: true, from, to: currentHqCloud(daemonRoot)?.version ?? null };
218
+ const retryDelays = [500, 1_000, 2_000];
219
+ const sleep = deps.sleep ?? sleepSync;
220
+ let r;
221
+ for (let attempt = 0;; attempt++) {
222
+ r = run(cmd.file, cmd.args, cmd.shell, env);
223
+ if (r.status === 0)
224
+ break;
225
+ const retryable = pinnedVersion !== undefined && pinnedVersion !== null && isTransientNpmFailure(r);
226
+ const delay = retryDelays[attempt];
227
+ if (!retryable || delay === undefined) {
228
+ const retries = retryable ? attempt : 0;
229
+ return {
230
+ ok: false,
231
+ from,
232
+ to: null,
233
+ detail: retries > 0 ? `pinned hq-cloud install failed after ${retries + 1} attempts: ${npmFailure(r)}` : npmFailure(r),
234
+ };
235
+ }
236
+ sleep(delay);
237
+ }
238
+ return { ok: true, from, to: managedHqCloud(daemonRoot)?.version ?? null };
210
239
  }
211
240
  //# sourceMappingURL=hq-cloud.js.map
@@ -105,17 +105,26 @@ const HANDOFF_FLAG_TIMEOUT_MS = 10_000;
105
105
  const HQD_PERSON_SETTING_GRACE_MS = 30 * 60_000;
106
106
  export function syncRunnerSourceForPlan(stagedRunnerPath, hqCloud, legacyBundledRunnerPath = null, staged = { version: null }, currentSyncRunnerSource = null) {
107
107
  // A managed copy lives in the daemon directory, so it survives a CLI
108
- // replacement. Before lease release pickHqCloud returns it only when it is
109
- // pinned or newer than the bundled copy; after release it is the only
110
- // eligible package, so it must not displace a newer staged bundle unless it
111
- // is the pinned version. The staged bundle is built from the bundled copy and
112
- // otherwise only stands in for that copy (hq-cli#1547).
108
+ // replacement. A configured pin is an exact version contract: only that
109
+ // installed package or an exactly matching staged bundle may start.
113
110
  const pin = staged.pinnedVersion;
114
- const hasRunnerAtOrAbovePin = pin != null &&
115
- ((staged.version !== null && !hqCloudVersionOlder(staged.version, pin)) ||
116
- (hqCloud !== null && !hqCloudVersionOlder(hqCloud.version, pin)));
117
- if (pin && !hasRunnerAtOrAbovePin && currentSyncRunnerSource?.runnerPath) {
118
- return currentSyncRunnerSource;
111
+ if (pin) {
112
+ if (hqCloud?.version === pin) {
113
+ if (hqCloud.source === "managed") {
114
+ return { kind: "managed-full-package", runnerPath: path.join(hqCloud.root, "dist", "bin", "sync-runner.js") };
115
+ }
116
+ if (stagedRunnerPath && staged.version === pin)
117
+ return { kind: "staged-sync", runnerPath: stagedRunnerPath };
118
+ return { kind: "bundled-full-package", runnerPath: path.join(hqCloud.root, "dist", "bin", "sync-runner.js") };
119
+ }
120
+ if (stagedRunnerPath && staged.version === pin)
121
+ return { kind: "staged-sync", runnerPath: stagedRunnerPath };
122
+ // A transient install gap must not stop a runner that is already serving
123
+ // sync. On cold start there is no current source, so mismatched bundles
124
+ // remain unavailable and cannot be started.
125
+ if (currentSyncRunnerSource?.runnerPath)
126
+ return currentSyncRunnerSource;
127
+ return { kind: "unavailable", runnerPath: null };
119
128
  }
120
129
  const managedYieldsToStaged = stagedRunnerPath !== null &&
121
130
  hqCloud?.source === "managed" &&
@@ -408,6 +417,7 @@ export async function runDaemon(opts) {
408
417
  let legacySyncRunnerPathChecked = false;
409
418
  let legacySyncRunnerPath = null;
410
419
  let lastPlannedSyncRunnerSource = null;
420
+ let lastPlannedSyncRunnerVersion = null;
411
421
  let packageUseLeaseReleased = false;
412
422
  let hqCloudVersionBeforeRelease = null;
413
423
  let startupPlanOpen = true;
@@ -580,6 +590,7 @@ export async function runDaemon(opts) {
580
590
  let warnedMissingPin = null;
581
591
  let warnedMissingSyncRunner = false;
582
592
  const daemonSyncRunnerPids = new Set();
593
+ let supervisor = null;
583
594
  const plan = () => {
584
595
  const config = readDaemonConfig(paths.configPath);
585
596
  const legacy = legacyUnits();
@@ -594,19 +605,34 @@ export async function runDaemon(opts) {
594
605
  : currentHqCloud(paths.root);
595
606
  if (!packageUseLeaseReleased)
596
607
  hqCloudVersionBeforeRelease = hqCloud?.version ?? staged?.metadata.version ?? null;
597
- const missingPin = hqCloud?.pinnedVersionMissing ?? null;
598
- if (missingPin !== warnedMissingPin) {
599
- warnedMissingPin = missingPin;
600
- if (missingPin && hqCloud) {
601
- log("warn", `hq-cloud is pinned to ${missingPin}, which is not installed; running ${hqCloud.version} (${hqCloud.source}) until the updater installs it`);
602
- }
603
- }
604
608
  if (!packageUseLeaseReleased && !legacySyncRunnerPathChecked) {
605
609
  legacySyncRunnerPath = resolveSyncRunnerPath();
606
610
  legacySyncRunnerPathChecked = true;
607
611
  }
608
- const syncSource = syncRunnerSourceForPlan(staged?.runnerPath ?? null, hqCloud, packageUseLeaseReleased ? null : legacySyncRunnerPath, { version: staged?.metadata.version ?? null, pinnedVersion: config.hqCloudVersion ?? null }, lastPlannedSyncRunnerSource);
612
+ const syncSource = syncRunnerSourceForPlan(staged?.runnerPath ?? null, hqCloud, packageUseLeaseReleased ? null : legacySyncRunnerPath, { version: staged?.metadata.version ?? null, pinnedVersion: config.hqCloudVersion ?? null }, supervisor?.state().units.some((unit) => unit.id === "sync" && unit.status === "running" && typeof unit.pid === "number")
613
+ ? lastPlannedSyncRunnerSource
614
+ : null);
615
+ const retainedLiveSource = syncSource === lastPlannedSyncRunnerSource;
616
+ const syncRunnerVersion = retainedLiveSource
617
+ ? lastPlannedSyncRunnerVersion
618
+ : syncSource.kind === "staged-sync"
619
+ ? staged?.metadata.version ?? null
620
+ : hqCloud?.version ?? null;
621
+ const missingPin = config.hqCloudVersion && syncRunnerVersion !== config.hqCloudVersion ? config.hqCloudVersion : null;
622
+ const pinWarningKey = missingPin
623
+ ? `${missingPin}:${retainedLiveSource ? syncRunnerVersion ?? "unknown" : "unavailable"}`
624
+ : null;
625
+ if (pinWarningKey !== warnedMissingPin) {
626
+ warnedMissingPin = pinWarningKey;
627
+ if (missingPin && retainedLiveSource) {
628
+ log("warn", `hq-cloud is pinned to ${missingPin}, but the running sync runner is ${syncRunnerVersion ?? "unknown"}; keeping it running while the updater retries`);
629
+ }
630
+ else if (missingPin && syncSource.kind === "unavailable") {
631
+ log("warn", `hq-cloud is pinned to ${missingPin}, which is not installed; sync is unavailable until the updater installs it`);
632
+ }
633
+ }
609
634
  lastPlannedSyncRunnerSource = syncSource.runnerPath ? syncSource : null;
635
+ lastPlannedSyncRunnerVersion = syncSource.runnerPath ? syncRunnerVersion : null;
610
636
  if (!syncSource.runnerPath && !warnedMissingSyncRunner) {
611
637
  warnedMissingSyncRunner = true;
612
638
  log("warn", "the hq-cloud sync runner was not found in this CLI install; sync is unavailable");
@@ -797,7 +823,7 @@ export async function runDaemon(opts) {
797
823
  // Set once the shutdown path exists below; a restart request before then is dropped.
798
824
  let restartDaemon = () => false;
799
825
  let extendSyncShutdownCap;
800
- const supervisor = createSupervisor({
826
+ supervisor = createSupervisor({
801
827
  machineType,
802
828
  host: opts.host,
803
829
  pid: process.pid,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolve the `--app` value of the `hq db` commands to an app id. The value can
3
+ * be the app id, its name or its subdomain. Only apps in the given company are
4
+ * searched, so a name never resolves to another company's app.
5
+ */
6
+ import { deployApiRequest } from "../deploy-api.js";
7
+ export interface AppRefRow {
8
+ id?: unknown;
9
+ name?: unknown;
10
+ subdomain?: unknown;
11
+ }
12
+ export declare class AppRefError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ export declare function matchAppRef(apps: AppRefRow[], ref: string): string;
16
+ export declare function resolveAppRef(input: {
17
+ token: string;
18
+ companySlug: string;
19
+ ref: string;
20
+ }, request?: typeof deployApiRequest): Promise<string>;
21
+ //# sourceMappingURL=app-ref.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Resolve the `--app` value of the `hq db` commands to an app id. The value can
3
+ * be the app id, its name or its subdomain. Only apps in the given company are
4
+ * searched, so a name never resolves to another company's app.
5
+ */
6
+ import { deployApiRequest } from "../deploy-api.js";
7
+ import { listDeployApps } from "../deploy-apps.js";
8
+ export class AppRefError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "AppRefError";
12
+ }
13
+ }
14
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ export function matchAppRef(apps, ref) {
16
+ const ids = (rows) => [
17
+ ...new Set(rows.map((row) => row.id).filter((id) => typeof id === "string")),
18
+ ];
19
+ const byId = ids(apps.filter((row) => row.id === ref));
20
+ if (byId.length === 1)
21
+ return byId[0];
22
+ const byName = ids(apps.filter((row) => row.name === ref || row.subdomain === ref));
23
+ if (byName.length === 1)
24
+ return byName[0];
25
+ if (byName.length > 1) {
26
+ throw new AppRefError(`More than one app in this company is called "${ref}" (${byName.join(", ")}). Use the app id.`);
27
+ }
28
+ throw new AppRefError(`No app called "${ref}" was found in this company.`);
29
+ }
30
+ export async function resolveAppRef(input, request = deployApiRequest) {
31
+ const ref = input.ref.trim();
32
+ if (UUID_RE.test(ref))
33
+ return ref;
34
+ const headers = { "X-Org-Slug": input.companySlug };
35
+ const apps = await listDeployApps(async (cursor) => {
36
+ const route = cursor ? `/api/apps?cursor=${encodeURIComponent(cursor)}` : "/api/apps";
37
+ const res = await request(input.token, route, "GET", undefined, headers);
38
+ if (!res.ok) {
39
+ throw new AppRefError(`Could not look up the app "${ref}" (hq-deploy returned ${res.status}). Use the app id.`);
40
+ }
41
+ return (await res.json());
42
+ });
43
+ return matchAppRef(apps, ref);
44
+ }
45
+ //# sourceMappingURL=app-ref.js.map
@@ -1,4 +1,4 @@
1
- type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "work_triggered" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "stranded" | "keep_alive_off" | "waiting";
1
+ type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "work_triggered" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "stranded" | "keep_alive_off" | "finished_story" | "idle_no_queued_work" | "waiting";
2
2
  interface KeepAliveAuditRestart {
3
3
  at: string | null;
4
4
  trigger: string;
@@ -17,6 +17,7 @@ interface KeepAliveAuditRow {
17
17
  blocked_outcome?: "woken" | "skipped";
18
18
  blocked_outcome_reason?: string;
19
19
  verdict: KeepAliveAuditVerdict;
20
+ queued_work_at_audit_time?: boolean;
20
21
  remediation?: string;
21
22
  terminal_reason?: string;
22
23
  }
@@ -25,6 +26,8 @@ interface KeepAliveAuditSummary {
25
26
  not_restarted: number;
26
27
  stranded: number;
27
28
  restarted_untimed: number;
29
+ finished_story: number;
30
+ idle_no_queued_work: number;
28
31
  late_restarts_after_window: number;
29
32
  late_over_10m: number;
30
33
  early_wake_by_others: number;
@@ -53,6 +56,7 @@ export interface StopEnvelope {
53
56
  name: string;
54
57
  mtimeMs: number;
55
58
  decision: "done" | "blocked";
59
+ storyComplete: boolean;
56
60
  nextCheckAt?: string;
57
61
  }
58
62
  export declare function readStopEnvelopes(hqRoot: string, laneId: string): {