@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.
- package/CHANGELOG.md +7 -0
- package/dist/command-catalog.generated.d.ts +56 -11
- package/dist/command-catalog.generated.js +67 -11
- package/dist/commands/db-destroy.js +13 -11
- package/dist/commands/db-dump.js +3 -3
- package/dist/commands/db-remote-deps.d.ts +3 -0
- package/dist/commands/db-remote-deps.js +13 -0
- package/dist/commands/db-sql.js +4 -3
- package/dist/commands/db-usage.js +4 -3
- package/dist/commands/dm-forward-flag.d.ts +4 -0
- package/dist/commands/dm-forward-flag.js +7 -0
- package/dist/commands/dm.d.ts +8 -0
- package/dist/commands/dm.js +219 -27
- package/dist/lib/daemon/hq-cloud.d.ts +2 -2
- package/dist/lib/daemon/hq-cloud.js +38 -9
- package/dist/lib/daemon/run.js +45 -19
- package/dist/lib/db/app-ref.d.ts +21 -0
- package/dist/lib/db/app-ref.js +45 -0
- package/dist/lib/lanes/keep-alive-audit.d.ts +5 -1
- package/dist/lib/lanes/keep-alive-audit.js +85 -3
- package/package.json +1 -1
package/dist/commands/dm.js
CHANGED
|
@@ -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
|
-
*
|
|
551
|
-
*
|
|
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
|
|
561
|
-
opts.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1334
|
-
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs
|
|
1335
|
-
.option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs
|
|
1336
|
-
.option("--details-file <path>", "Read the details from a file (1:1 DMs
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
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 =
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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.
|
|
109
|
-
//
|
|
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
|
-
|
|
115
|
-
(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 },
|
|
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
|
-
|
|
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): {
|