@indigoai-us/hq-cli 5.345.183 → 5.345.188
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/command-catalog.generated.d.ts +29 -0
- package/dist/command-catalog.generated.js +35 -0
- package/dist/commands/agent-inbox.js +43 -11
- package/dist/commands/agent-mcp.js +5 -2
- package/dist/commands/bot.d.ts +5 -5
- package/dist/commands/bot.js +30 -31
- package/dist/commands/sources.d.ts +37 -0
- package/dist/commands/sources.js +104 -1
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +34414 -49801
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-fetch.d.ts +92 -0
- package/dist/lib/agent-kit/inbox-fetch.js +135 -0
- package/dist/lib/agent-kit/mcp/tools.d.ts +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +17 -3
- package/dist/lib/anywhere-person-setting.js +24 -10
- package/dist/lib/bot/fleet-local.d.ts +4 -2
- package/dist/lib/bot/fleet-local.js +23 -7
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -7
- package/dist/lib/bot/jobs/hot-signal-sync.js +106 -9
- package/dist/lib/bot/local-jobs.d.ts +4 -2
- package/dist/lib/bot/local-jobs.js +33 -4
- package/dist/lib/bot/run.js +2 -2
- package/dist/lib/hot-channels/adapters/attio.js +3 -0
- package/dist/lib/hot-channels/adapters/linear.js +4 -0
- package/dist/lib/hot-channels/adapters/meetings.js +4 -0
- package/dist/lib/hot-channels/adapters/slack.js +3 -0
- package/dist/lib/hot-channels/contract.d.ts +7 -0
- package/dist/lib/hot-channels/ledger.d.ts +8 -0
- package/dist/lib/hot-channels/ledger.js +6 -2
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +16 -2
- package/dist/lib/hot-channels/person-vault-reader.d.ts +41 -0
- package/dist/lib/hot-channels/person-vault-reader.js +59 -0
- package/dist/lib/hot-channels/run.d.ts +8 -0
- package/dist/lib/hot-channels/run.js +19 -0
- package/dist/lib/hot-channels/schedule.d.ts +64 -0
- package/dist/lib/hot-channels/schedule.js +162 -0
- package/dist/lib/hot-channels/selection.d.ts +87 -0
- package/dist/lib/hot-channels/selection.js +175 -0
- package/dist/utils/cognito-session.d.ts +1 -1
- package/dist/utils/cognito-session.js +16 -4
- package/dist/utils/shipped-codex-hooks.js +1 -1
- package/dist/utils/version-report.d.ts +1 -1
- package/dist/utils/version-report.js +20 -7
- package/package.json +8 -8
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq sources schedule`: find a company's hot-signal-sync job across the
|
|
3
|
+
* local bots, show when it runs and how each adapter's last run went, and
|
|
4
|
+
* reschedule it to a daily time.
|
|
5
|
+
*
|
|
6
|
+
* Jobs live in each bot's `jobs.json` (lib/bot/local-jobs.ts). Reads go
|
|
7
|
+
* straight to the file; the reschedule goes through `rescheduleLocalJob`,
|
|
8
|
+
* which takes the job list's lock.
|
|
9
|
+
*/
|
|
10
|
+
import { type LocalJob } from "../bot/local-jobs.js";
|
|
11
|
+
/** How many UTC days of the run ledger `lastRuns` looks back over, today included. */
|
|
12
|
+
export declare const SCHEDULE_LOOKBACK_DAYS = 7;
|
|
13
|
+
export interface ScheduleJobView {
|
|
14
|
+
botName: string;
|
|
15
|
+
jobId: string;
|
|
16
|
+
cron: string;
|
|
17
|
+
timezone: string;
|
|
18
|
+
dailyAt: string | null;
|
|
19
|
+
nextRunAt: string | null;
|
|
20
|
+
lastRunAt: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface ScheduleLastRun {
|
|
23
|
+
adapter: string;
|
|
24
|
+
finishedAt: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
sourcesLanded: number;
|
|
27
|
+
signalsGenerated: number | null;
|
|
28
|
+
}
|
|
29
|
+
export interface ScheduleReport {
|
|
30
|
+
company: string;
|
|
31
|
+
job: ScheduleJobView | null;
|
|
32
|
+
lastRuns: ScheduleLastRun[];
|
|
33
|
+
}
|
|
34
|
+
export interface FoundHotSignalSyncJob {
|
|
35
|
+
botName: string;
|
|
36
|
+
dir: string;
|
|
37
|
+
job: LocalJob;
|
|
38
|
+
}
|
|
39
|
+
/** Parse `HH:MM` (24-hour). Throws a plain error on anything else. */
|
|
40
|
+
export declare function parseDailyAt(at: string): {
|
|
41
|
+
hour: number;
|
|
42
|
+
minute: number;
|
|
43
|
+
};
|
|
44
|
+
/** The stored EventBridge form of "every day at H:M". */
|
|
45
|
+
export declare function dailyCron(hour: number, minute: number): string;
|
|
46
|
+
/** `HH:MM` when the cron fires once a day at a fixed time; null for any other schedule. */
|
|
47
|
+
export declare function dailyAtOf(cron: string): string | null;
|
|
48
|
+
/** Every local bot's hot-signal-sync job for `company`, ordered by bot name. */
|
|
49
|
+
export declare function findHotSignalSyncJobs(botsRoot: string, company: string): FoundHotSignalSyncJob[];
|
|
50
|
+
export declare function toJobView(found: FoundHotSignalSyncJob, now: Date): ScheduleJobView;
|
|
51
|
+
/**
|
|
52
|
+
* Reschedule each found job to daily at `at`, keeping its time zone. Returns
|
|
53
|
+
* the jobs as stored after the change.
|
|
54
|
+
*/
|
|
55
|
+
export declare function rescheduleDaily(found: readonly FoundHotSignalSyncJob[], at: string, now: Date): FoundHotSignalSyncJob[];
|
|
56
|
+
/**
|
|
57
|
+
* The newest ledger entry per adapter over the last `SCHEDULE_LOOKBACK_DAYS`
|
|
58
|
+
* UTC days, today included. Skipped runs (another sync held the lock) are not
|
|
59
|
+
* adapter runs and are left out. Sorted by adapter id.
|
|
60
|
+
*/
|
|
61
|
+
export declare function lastRunsPerAdapter(vaultRoot: string, personKey: string, now: Date): Promise<ScheduleLastRun[]>;
|
|
62
|
+
/** Plain lines for the terminal. */
|
|
63
|
+
export declare function renderScheduleReport(report: ScheduleReport, extraBots?: readonly string[]): string;
|
|
64
|
+
//# sourceMappingURL=schedule.d.ts.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq sources schedule`: find a company's hot-signal-sync job across the
|
|
3
|
+
* local bots, show when it runs and how each adapter's last run went, and
|
|
4
|
+
* reschedule it to a daily time.
|
|
5
|
+
*
|
|
6
|
+
* Jobs live in each bot's `jobs.json` (lib/bot/local-jobs.ts). Reads go
|
|
7
|
+
* straight to the file; the reschedule goes through `rescheduleLocalJob`,
|
|
8
|
+
* which takes the job list's lock.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { hotSignalSyncJobCompany } from "../bot/jobs/hot-signal-sync.js";
|
|
13
|
+
import { nextDueFire, readLocalJobs, rescheduleLocalJob } from "../bot/local-jobs.js";
|
|
14
|
+
import { ledgerDay, readRunLedger } from "./ledger.js";
|
|
15
|
+
/** How many UTC days of the run ledger `lastRuns` looks back over, today included. */
|
|
16
|
+
export const SCHEDULE_LOOKBACK_DAYS = 7;
|
|
17
|
+
/** Parse `HH:MM` (24-hour). Throws a plain error on anything else. */
|
|
18
|
+
export function parseDailyAt(at) {
|
|
19
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(at.trim());
|
|
20
|
+
const hour = match ? Number(match[1]) : NaN;
|
|
21
|
+
const minute = match ? Number(match[2]) : NaN;
|
|
22
|
+
if (!match || hour > 23 || minute > 59) {
|
|
23
|
+
throw new Error(`--at must be a 24-hour time HH:MM, such as 06:30 or 18:05 (got '${at}').`);
|
|
24
|
+
}
|
|
25
|
+
return { hour, minute };
|
|
26
|
+
}
|
|
27
|
+
/** The stored EventBridge form of "every day at H:M". */
|
|
28
|
+
export function dailyCron(hour, minute) {
|
|
29
|
+
return `cron(${minute} ${hour} * * ? *)`;
|
|
30
|
+
}
|
|
31
|
+
function hhmm(hour, minute) {
|
|
32
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
/** `HH:MM` when the cron fires once a day at a fixed time; null for any other schedule. */
|
|
35
|
+
export function dailyAtOf(cron) {
|
|
36
|
+
const match = /^cron\((\d{1,2}) (\d{1,2}) \* \* \? \*\)$/.exec(cron.trim()) ?? /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(cron.trim());
|
|
37
|
+
if (!match)
|
|
38
|
+
return null;
|
|
39
|
+
const minute = Number(match[1]);
|
|
40
|
+
const hour = Number(match[2]);
|
|
41
|
+
return hour <= 23 && minute <= 59 ? hhmm(hour, minute) : null;
|
|
42
|
+
}
|
|
43
|
+
/** Every local bot's hot-signal-sync job for `company`, ordered by bot name. */
|
|
44
|
+
export function findHotSignalSyncJobs(botsRoot, company) {
|
|
45
|
+
let names;
|
|
46
|
+
try {
|
|
47
|
+
names = fs.readdirSync(botsRoot);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT")
|
|
51
|
+
return [];
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
const found = [];
|
|
55
|
+
for (const botName of names.sort()) {
|
|
56
|
+
const dir = path.join(botsRoot, botName);
|
|
57
|
+
let isDir;
|
|
58
|
+
try {
|
|
59
|
+
isDir = fs.statSync(dir).isDirectory();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Gone between readdir and stat: not a bot.
|
|
63
|
+
isDir = false;
|
|
64
|
+
}
|
|
65
|
+
if (!isDir)
|
|
66
|
+
continue;
|
|
67
|
+
for (const job of readLocalJobs(dir)) {
|
|
68
|
+
if (hotSignalSyncJobCompany(job.context) === company)
|
|
69
|
+
found.push({ botName, dir, job });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
export function toJobView(found, now) {
|
|
75
|
+
const next = nextDueFire(found.job, now);
|
|
76
|
+
return {
|
|
77
|
+
botName: found.botName,
|
|
78
|
+
jobId: found.job.jobId,
|
|
79
|
+
cron: found.job.cron,
|
|
80
|
+
timezone: found.job.timezone,
|
|
81
|
+
dailyAt: dailyAtOf(found.job.cron),
|
|
82
|
+
nextRunAt: next ? next.toISOString() : null,
|
|
83
|
+
lastRunAt: found.job.lastRunAt ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reschedule each found job to daily at `at`, keeping its time zone. Returns
|
|
88
|
+
* the jobs as stored after the change.
|
|
89
|
+
*/
|
|
90
|
+
export function rescheduleDaily(found, at, now) {
|
|
91
|
+
const { hour, minute } = parseDailyAt(at);
|
|
92
|
+
const cron = dailyCron(hour, minute);
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const f of found) {
|
|
95
|
+
// The time zone is read under the job-list lock, not from the earlier
|
|
96
|
+
// read, so a concurrent change to it is never overwritten.
|
|
97
|
+
const updated = rescheduleLocalJob(f.dir, f.job.jobId, { cron }, now);
|
|
98
|
+
if (!updated)
|
|
99
|
+
throw new Error(`The hot-signal-sync job ${f.job.jobId} on bot ${f.botName} was removed while rescheduling.`);
|
|
100
|
+
out.push({ ...f, job: updated });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The newest ledger entry per adapter over the last `SCHEDULE_LOOKBACK_DAYS`
|
|
106
|
+
* UTC days, today included. Skipped runs (another sync held the lock) are not
|
|
107
|
+
* adapter runs and are left out. Sorted by adapter id.
|
|
108
|
+
*/
|
|
109
|
+
export async function lastRunsPerAdapter(vaultRoot, personKey, now) {
|
|
110
|
+
const latest = new Map();
|
|
111
|
+
for (let i = 0; i < SCHEDULE_LOOKBACK_DAYS; i += 1) {
|
|
112
|
+
const day = ledgerDay(new Date(now.getTime() - i * 24 * 60 * 60 * 1000));
|
|
113
|
+
const { entries } = await readRunLedger(vaultRoot, personKey, day);
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.status === "skipped")
|
|
116
|
+
continue;
|
|
117
|
+
const seen = latest.get(entry.adapter);
|
|
118
|
+
if (!seen || entry.finishedAt > seen.finishedAt)
|
|
119
|
+
latest.set(entry.adapter, entry);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...latest.values()]
|
|
123
|
+
.sort((a, b) => (a.adapter < b.adapter ? -1 : a.adapter > b.adapter ? 1 : 0))
|
|
124
|
+
.map((e) => ({
|
|
125
|
+
adapter: e.adapter,
|
|
126
|
+
finishedAt: e.finishedAt,
|
|
127
|
+
ok: e.status === "ok",
|
|
128
|
+
sourcesLanded: e.sourcesLanded,
|
|
129
|
+
signalsGenerated: typeof e.signalsGenerated === "number" ? e.signalsGenerated : null,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
/** Plain lines for the terminal. */
|
|
133
|
+
export function renderScheduleReport(report, extraBots = []) {
|
|
134
|
+
const lines = [];
|
|
135
|
+
const job = report.job;
|
|
136
|
+
if (!job) {
|
|
137
|
+
lines.push(`No hot-channel sync job for ${report.company} on any local bot.`);
|
|
138
|
+
}
|
|
139
|
+
else if (job.dailyAt) {
|
|
140
|
+
lines.push(`Syncs daily at ${job.dailyAt} (${job.timezone}) on bot ${job.botName}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
lines.push(`Syncs on schedule ${job.cron} (${job.timezone}) on bot ${job.botName}`);
|
|
144
|
+
}
|
|
145
|
+
if (job) {
|
|
146
|
+
if (extraBots.length > 0)
|
|
147
|
+
lines.push(`Also scheduled on bot ${extraBots.join(", ")}`);
|
|
148
|
+
lines.push(`Next run: ${job.nextRunAt ?? "not within the next week"}`);
|
|
149
|
+
lines.push(`Last run: ${job.lastRunAt ?? "never"}`);
|
|
150
|
+
}
|
|
151
|
+
if (report.lastRuns.length === 0) {
|
|
152
|
+
lines.push(`No runs logged in the last ${SCHEDULE_LOOKBACK_DAYS} days.`);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
for (const run of report.lastRuns) {
|
|
156
|
+
const signals = run.signalsGenerated === null ? "unknown" : String(run.signalsGenerated);
|
|
157
|
+
lines.push(`${run.adapter}: ${run.ok ? "ok" : "failed"} at ${run.finishedAt}, ${run.sourcesLanded} source(s), ${signals} signal(s)`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved hot-channel selections (hq-pro US-014).
|
|
3
|
+
*
|
|
4
|
+
* A person can pick which channels, issues or records to watch on each
|
|
5
|
+
* connection. hq-pro stores the pick as
|
|
6
|
+
* `integrations/hot-channels/<connectionId>.json` in that person's own vault
|
|
7
|
+
* bucket (hq-pro `hotChannelSelectionKey`), shaped
|
|
8
|
+
* `{ schemaVersion: 1, connectionId, provider, hotChannelId, selected: [{ id, label, source }], updatedAt }`.
|
|
9
|
+
*
|
|
10
|
+
* The sync reads it once per connection per run. A missing file (404) means
|
|
11
|
+
* "no choice made": the adapter lands everything. A file that may exist but
|
|
12
|
+
* cannot be read (an error, a read slower than 5 seconds, malformed content,
|
|
13
|
+
* or a file naming another connection) fails closed: the loader throws
|
|
14
|
+
* `SelectionUnreadableError`, the loop skips that connection's adapter run
|
|
15
|
+
* and the ledger records it as failed with reason `selection-unreadable`.
|
|
16
|
+
* Nothing lands that the person may have excluded.
|
|
17
|
+
*
|
|
18
|
+
* An empty `selected` list on a connection's own channel (Slack, Linear,
|
|
19
|
+
* Attio) also means "no choice made" and lands everything: hq-pro saves `[]`
|
|
20
|
+
* when the person accepts defaults before any suggestions are listed, and has
|
|
21
|
+
* no delete, so `[]` must not silently stop the channel. Meetings keep their
|
|
22
|
+
* own rule (`meetingsSelection`): an empty pick on every recorder excludes them.
|
|
23
|
+
*/
|
|
24
|
+
export declare const HOT_CHANNEL_SELECTION_SCHEMA_VERSION = 1;
|
|
25
|
+
export declare const HOT_CHANNEL_SELECTION_PREFIX = "integrations/hot-channels/";
|
|
26
|
+
/** Upper bound on one selection read. */
|
|
27
|
+
export declare const DEFAULT_SELECTION_READ_TIMEOUT_MS = 5000;
|
|
28
|
+
export interface HotChannelSelectionEntry {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
source: "suggested" | "added";
|
|
32
|
+
}
|
|
33
|
+
export interface HotChannelSelectionFile {
|
|
34
|
+
schemaVersion: typeof HOT_CHANNEL_SELECTION_SCHEMA_VERSION;
|
|
35
|
+
connectionId: string;
|
|
36
|
+
provider: string;
|
|
37
|
+
hotChannelId: string;
|
|
38
|
+
selected: HotChannelSelectionEntry[];
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
/** Bucket-relative key of a connection's selection; null for an id hq-pro would never write. */
|
|
42
|
+
export declare function hotChannelSelectionKey(connectionId: string): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Parse a stored selection the way hq-pro's `parseSelectionFile` does.
|
|
45
|
+
* Anything unexpected (bad JSON, other schema version, a bad entry) is null.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parseHotChannelSelection(text: string | null | undefined): HotChannelSelectionFile | null;
|
|
48
|
+
/**
|
|
49
|
+
* Reads one object from the signed-in person's vault bucket by
|
|
50
|
+
* bucket-relative key. Resolves null when the object does not exist.
|
|
51
|
+
*/
|
|
52
|
+
export type PersonVaultObjectReader = (key: string) => Promise<string | null>;
|
|
53
|
+
export interface SelectionLoaderOptions {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
log?: (message: string) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare const SELECTION_UNREADABLE_REASON = "selection-unreadable";
|
|
58
|
+
/** A saved selection may exist but could not be read; the connection is skipped. */
|
|
59
|
+
export declare class SelectionUnreadableError extends Error {
|
|
60
|
+
readonly code = "selection-unreadable";
|
|
61
|
+
constructor(connectionId: string, detail: string);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Selected ids for one connection; undefined means no saved choice (land
|
|
65
|
+
* everything). Rejects with `SelectionUnreadableError` when a saved choice may
|
|
66
|
+
* exist but could not be read.
|
|
67
|
+
*/
|
|
68
|
+
export type SelectionLoader = (connectionId: string) => Promise<ReadonlySet<string> | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* A loader for one run. Each connection's file is read at most once, and the
|
|
71
|
+
* cache dies with the loader, so the next run reads the file again. Rejects
|
|
72
|
+
* only with `SelectionUnreadableError`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createSelectionLoader(read: PersonVaultObjectReader, options?: SelectionLoaderOptions): SelectionLoader;
|
|
75
|
+
/**
|
|
76
|
+
* The selection a connection's own channel lands by: the saved ids, or
|
|
77
|
+
* undefined (land everything) when nothing was saved or the saved list is empty.
|
|
78
|
+
*/
|
|
79
|
+
export declare function connectionSelection(saved: ReadonlySet<string> | undefined): ReadonlySet<string> | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* The meetings channel has no connection of its own; its checklist is an
|
|
82
|
+
* include/exclude choice saved on each meeting-recorder connection. Meetings
|
|
83
|
+
* are excluded (an empty set) when at least one recorder connection saved an
|
|
84
|
+
* empty selection and none saved a non-empty one. Otherwise undefined: include.
|
|
85
|
+
*/
|
|
86
|
+
export declare function meetingsSelection(selections: ReadonlyArray<ReadonlySet<string> | undefined>): ReadonlySet<string> | undefined;
|
|
87
|
+
//# sourceMappingURL=selection.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved hot-channel selections (hq-pro US-014).
|
|
3
|
+
*
|
|
4
|
+
* A person can pick which channels, issues or records to watch on each
|
|
5
|
+
* connection. hq-pro stores the pick as
|
|
6
|
+
* `integrations/hot-channels/<connectionId>.json` in that person's own vault
|
|
7
|
+
* bucket (hq-pro `hotChannelSelectionKey`), shaped
|
|
8
|
+
* `{ schemaVersion: 1, connectionId, provider, hotChannelId, selected: [{ id, label, source }], updatedAt }`.
|
|
9
|
+
*
|
|
10
|
+
* The sync reads it once per connection per run. A missing file (404) means
|
|
11
|
+
* "no choice made": the adapter lands everything. A file that may exist but
|
|
12
|
+
* cannot be read (an error, a read slower than 5 seconds, malformed content,
|
|
13
|
+
* or a file naming another connection) fails closed: the loader throws
|
|
14
|
+
* `SelectionUnreadableError`, the loop skips that connection's adapter run
|
|
15
|
+
* and the ledger records it as failed with reason `selection-unreadable`.
|
|
16
|
+
* Nothing lands that the person may have excluded.
|
|
17
|
+
*
|
|
18
|
+
* An empty `selected` list on a connection's own channel (Slack, Linear,
|
|
19
|
+
* Attio) also means "no choice made" and lands everything: hq-pro saves `[]`
|
|
20
|
+
* when the person accepts defaults before any suggestions are listed, and has
|
|
21
|
+
* no delete, so `[]` must not silently stop the channel. Meetings keep their
|
|
22
|
+
* own rule (`meetingsSelection`): an empty pick on every recorder excludes them.
|
|
23
|
+
*/
|
|
24
|
+
export const HOT_CHANNEL_SELECTION_SCHEMA_VERSION = 1;
|
|
25
|
+
export const HOT_CHANNEL_SELECTION_PREFIX = "integrations/hot-channels/";
|
|
26
|
+
/** Upper bound on one selection read. */
|
|
27
|
+
export const DEFAULT_SELECTION_READ_TIMEOUT_MS = 5_000;
|
|
28
|
+
const CONNECTION_ID_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
|
|
29
|
+
const ENTRY_ID_PATTERN = /^[^\s/\\]{1,256}$/;
|
|
30
|
+
/** Bucket-relative key of a connection's selection; null for an id hq-pro would never write. */
|
|
31
|
+
export function hotChannelSelectionKey(connectionId) {
|
|
32
|
+
return CONNECTION_ID_PATTERN.test(connectionId) ? `${HOT_CHANNEL_SELECTION_PREFIX}${connectionId}.json` : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a stored selection the way hq-pro's `parseSelectionFile` does.
|
|
36
|
+
* Anything unexpected (bad JSON, other schema version, a bad entry) is null.
|
|
37
|
+
*/
|
|
38
|
+
export function parseHotChannelSelection(text) {
|
|
39
|
+
if (!text)
|
|
40
|
+
return null;
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
49
|
+
return null;
|
|
50
|
+
const file = parsed;
|
|
51
|
+
if (file.schemaVersion !== HOT_CHANNEL_SELECTION_SCHEMA_VERSION)
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof file.connectionId !== "string" || typeof file.provider !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof file.hotChannelId !== "string" || typeof file.updatedAt !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
if (!Array.isArray(file.selected))
|
|
58
|
+
return null;
|
|
59
|
+
const selected = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const item of file.selected) {
|
|
62
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
63
|
+
return null;
|
|
64
|
+
const { id, label, source } = item;
|
|
65
|
+
if (typeof id !== "string" || !ENTRY_ID_PATTERN.test(id.trim()))
|
|
66
|
+
return null;
|
|
67
|
+
if (source !== "suggested" && source !== "added")
|
|
68
|
+
return null;
|
|
69
|
+
const cleanId = id.trim();
|
|
70
|
+
if (seen.has(cleanId))
|
|
71
|
+
continue;
|
|
72
|
+
seen.add(cleanId);
|
|
73
|
+
selected.push({ id: cleanId, label: typeof label === "string" && label.trim() ? label.trim() : cleanId, source });
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: HOT_CHANNEL_SELECTION_SCHEMA_VERSION,
|
|
77
|
+
connectionId: file.connectionId,
|
|
78
|
+
provider: file.provider,
|
|
79
|
+
hotChannelId: file.hotChannelId,
|
|
80
|
+
selected,
|
|
81
|
+
updatedAt: file.updatedAt,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export const SELECTION_UNREADABLE_REASON = "selection-unreadable";
|
|
85
|
+
/** A saved selection may exist but could not be read; the connection is skipped. */
|
|
86
|
+
export class SelectionUnreadableError extends Error {
|
|
87
|
+
code = SELECTION_UNREADABLE_REASON;
|
|
88
|
+
constructor(connectionId, detail) {
|
|
89
|
+
super(`saved selection for ${connectionId} could not be read (${detail}); skipped this connection`);
|
|
90
|
+
this.name = "SelectionUnreadableError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
class SelectionReadTimeoutError extends Error {
|
|
94
|
+
constructor(ms) {
|
|
95
|
+
super(`timed out after ${ms}ms`);
|
|
96
|
+
this.name = "SelectionReadTimeoutError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function withTimeout(work, ms) {
|
|
100
|
+
let timer;
|
|
101
|
+
const timeout = new Promise((_, reject) => {
|
|
102
|
+
timer = setTimeout(() => reject(new SelectionReadTimeoutError(ms)), ms);
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
return await Promise.race([work, timeout]);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
if (timer !== undefined)
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A loader for one run. Each connection's file is read at most once, and the
|
|
114
|
+
* cache dies with the loader, so the next run reads the file again. Rejects
|
|
115
|
+
* only with `SelectionUnreadableError`.
|
|
116
|
+
*/
|
|
117
|
+
export function createSelectionLoader(read, options = {}) {
|
|
118
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SELECTION_READ_TIMEOUT_MS;
|
|
119
|
+
const log = options.log ?? ((message) => void process.stderr.write(`${message}\n`));
|
|
120
|
+
const cache = new Map();
|
|
121
|
+
async function load(connectionId) {
|
|
122
|
+
const key = hotChannelSelectionKey(connectionId);
|
|
123
|
+
if (key === null)
|
|
124
|
+
return undefined;
|
|
125
|
+
let text;
|
|
126
|
+
try {
|
|
127
|
+
text = await withTimeout(Promise.resolve().then(() => read(key)), timeoutMs);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
131
|
+
log(`hot-channel selection for ${connectionId} could not be read (${reason}); skipping this connection`);
|
|
132
|
+
throw new SelectionUnreadableError(connectionId, reason);
|
|
133
|
+
}
|
|
134
|
+
if (text === null)
|
|
135
|
+
return undefined;
|
|
136
|
+
const file = parseHotChannelSelection(text);
|
|
137
|
+
if (!file) {
|
|
138
|
+
log(`hot-channel selection for ${connectionId} is malformed; skipping this connection`);
|
|
139
|
+
throw new SelectionUnreadableError(connectionId, "malformed");
|
|
140
|
+
}
|
|
141
|
+
if (file.connectionId !== connectionId) {
|
|
142
|
+
log(`hot-channel selection for ${connectionId} names connection ${file.connectionId}; skipping this connection`);
|
|
143
|
+
throw new SelectionUnreadableError(connectionId, `names connection ${file.connectionId}`);
|
|
144
|
+
}
|
|
145
|
+
return new Set(file.selected.map((entry) => entry.id));
|
|
146
|
+
}
|
|
147
|
+
return (connectionId) => {
|
|
148
|
+
let hit = cache.get(connectionId);
|
|
149
|
+
if (!hit) {
|
|
150
|
+
hit = load(connectionId);
|
|
151
|
+
cache.set(connectionId, hit);
|
|
152
|
+
}
|
|
153
|
+
return hit;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The selection a connection's own channel lands by: the saved ids, or
|
|
158
|
+
* undefined (land everything) when nothing was saved or the saved list is empty.
|
|
159
|
+
*/
|
|
160
|
+
export function connectionSelection(saved) {
|
|
161
|
+
return saved && saved.size > 0 ? saved : undefined;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The meetings channel has no connection of its own; its checklist is an
|
|
165
|
+
* include/exclude choice saved on each meeting-recorder connection. Meetings
|
|
166
|
+
* are excluded (an empty set) when at least one recorder connection saved an
|
|
167
|
+
* empty selection and none saved a non-empty one. Otherwise undefined: include.
|
|
168
|
+
*/
|
|
169
|
+
export function meetingsSelection(selections) {
|
|
170
|
+
const saved = selections.filter((s) => s !== undefined);
|
|
171
|
+
if (saved.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
return saved.every((s) => s.size === 0) ? new Set() : undefined;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=selection.js.map
|
|
@@ -267,7 +267,7 @@ export declare function buildVaultConfig(authToken: string): VaultServiceConfig;
|
|
|
267
267
|
* means no valid session could be ensured non-interactively; the caller gets a
|
|
268
268
|
* reason string and can decide what to do next. Never opens a browser.
|
|
269
269
|
*/
|
|
270
|
-
type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource"> & MachineTokenValidityOptions & {
|
|
270
|
+
type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource" | "signal"> & MachineTokenValidityOptions & {
|
|
271
271
|
/** Refresh the person session even when the cached token is still valid. */
|
|
272
272
|
force?: boolean;
|
|
273
273
|
};
|
|
@@ -780,11 +780,21 @@ function withTokenStateDirSection(fn) {
|
|
|
780
780
|
return run;
|
|
781
781
|
}
|
|
782
782
|
const MAX_MACHINE_TOKEN_MIN_VALIDITY_SECONDS = 1800;
|
|
783
|
-
|
|
783
|
+
/**
|
|
784
|
+
* `signal` cancels only this caller's wait inside hq-cloud (a mint shared with
|
|
785
|
+
* other callers keeps running). Without it a bounded caller such as `hq agent
|
|
786
|
+
* inbox` timed out, retried, and its retry queued behind the orphaned first
|
|
787
|
+
* mint, which still held the lock.
|
|
788
|
+
*/
|
|
789
|
+
async function ensureMachineTokens(minValiditySeconds, signal) {
|
|
784
790
|
const resolverMinimum = minValiditySeconds === undefined
|
|
785
791
|
? undefined
|
|
786
792
|
: Math.min(minValiditySeconds, MAX_MACHINE_TOKEN_MIN_VALIDITY_SECONDS);
|
|
787
|
-
|
|
793
|
+
const resolverOptions = {
|
|
794
|
+
...(resolverMinimum === undefined ? {} : { minValiditySeconds: resolverMinimum }),
|
|
795
|
+
...(signal ? { signal } : {}),
|
|
796
|
+
};
|
|
797
|
+
return withMachineTokenStateDir(() => getValidMachineTokens(DEFAULT_COGNITO, Object.keys(resolverOptions).length === 0 ? undefined : resolverOptions));
|
|
788
798
|
}
|
|
789
799
|
/**
|
|
790
800
|
* Ensure the current machine identity has a valid session and return only the
|
|
@@ -803,7 +813,7 @@ async function ensureMachineTokensMaybeFallback(options = {}) {
|
|
|
803
813
|
if (isDefaultMachineCredsFallback(options)) {
|
|
804
814
|
logDefaultMachineCredsFallback();
|
|
805
815
|
}
|
|
806
|
-
return ensureMachineTokens(options.minValiditySeconds);
|
|
816
|
+
return ensureMachineTokens(options.minValiditySeconds, options.signal);
|
|
807
817
|
}
|
|
808
818
|
/**
|
|
809
819
|
* After a person-cache miss or refresh failure: if the default machine-creds
|
|
@@ -818,7 +828,7 @@ async function tryDefaultMachineCredsFallback(options = {}) {
|
|
|
818
828
|
if (!isMachineIdentity())
|
|
819
829
|
return null;
|
|
820
830
|
logDefaultMachineCredsFallback();
|
|
821
|
-
return ensureMachineTokens(options.minValiditySeconds);
|
|
831
|
+
return ensureMachineTokens(options.minValiditySeconds, options.signal);
|
|
822
832
|
}
|
|
823
833
|
/**
|
|
824
834
|
* True when a person-token refresh failed with a definitive Cognito auth
|
|
@@ -880,6 +890,8 @@ function resolvePersonSessionAfterRefreshRejection(cached) {
|
|
|
880
890
|
* a browser.
|
|
881
891
|
*/
|
|
882
892
|
export async function ensureCognitoToken(options = {}) {
|
|
893
|
+
if (options.signal?.aborted)
|
|
894
|
+
throw options.signal.reason ?? new Error("Token acquisition was aborted");
|
|
883
895
|
if (wantsMachineCognitoTokens(options))
|
|
884
896
|
return ensureCognitoTokenUnserialized(options);
|
|
885
897
|
return withTokenStateDirSection(() => ensureCognitoTokenUnserialized(options));
|
|
@@ -18,7 +18,7 @@ const CORE_SOURCES = [PROD_SOURCE, STAGING_SOURCE];
|
|
|
18
18
|
/** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
|
|
19
19
|
export function resolveGhToken() {
|
|
20
20
|
try {
|
|
21
|
-
const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
|
|
21
|
+
const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 5_000 });
|
|
22
22
|
if (res.status === 0) {
|
|
23
23
|
const tok = res.stdout.trim();
|
|
24
24
|
if (tok)
|
|
@@ -16,7 +16,7 @@ export interface VersionReportDeps {
|
|
|
16
16
|
printUpdateNudge?: (stderr: Pick<NodeJS.WriteStream, "write">) => Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
export declare function normalizeHqCoreTag(tag: string): string | null;
|
|
19
|
-
export declare function fetchLatestHqCoreRelease(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
|
|
19
|
+
export declare function fetchLatestHqCoreRelease(fetchImpl?: typeof fetch, timeoutMs?: number, token?: string | undefined): Promise<string | null>;
|
|
20
20
|
export declare function collectVersionReport(deps?: VersionReportDeps): Promise<VersionReport>;
|
|
21
21
|
export declare function formatVersionReport(report: VersionReport): string;
|
|
22
22
|
export declare function versionReportJson(report: VersionReport): string;
|
|
@@ -13,22 +13,35 @@ import { CLI_VERSION } from "../cli-version.js";
|
|
|
13
13
|
import { wantsJson } from "../version-request.js";
|
|
14
14
|
import { findHqRoot } from "./manifest.js";
|
|
15
15
|
import { readHqVersion } from "./hq-core-yaml.js";
|
|
16
|
+
import { resolveGhToken } from "./shipped-codex-hooks.js";
|
|
16
17
|
export const HQ_CORE_LATEST_URL = "https://api.github.com/repos/indigoai-us/hq-core/releases/latest";
|
|
17
18
|
export const LATEST_FETCH_TIMEOUT_MS = 3_000;
|
|
18
19
|
export function normalizeHqCoreTag(tag) {
|
|
19
20
|
const version = tag.trim().replace(/^v/i, "");
|
|
20
21
|
return semver.valid(version) ? version : null;
|
|
21
22
|
}
|
|
22
|
-
export async function fetchLatestHqCoreRelease(fetchImpl = fetch, timeoutMs = LATEST_FETCH_TIMEOUT_MS) {
|
|
23
|
+
export async function fetchLatestHqCoreRelease(fetchImpl = fetch, timeoutMs = LATEST_FETCH_TIMEOUT_MS, token = resolveGhToken()) {
|
|
23
24
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
const headers = {
|
|
26
|
+
Accept: "application/vnd.github+json",
|
|
27
|
+
"User-Agent": "hq-cli-version",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
};
|
|
30
|
+
if (token)
|
|
31
|
+
headers.Authorization = `Bearer ${token}`;
|
|
32
|
+
let res = await fetchImpl(HQ_CORE_LATEST_URL, {
|
|
33
|
+
headers,
|
|
30
34
|
signal: AbortSignal.timeout(timeoutMs),
|
|
31
35
|
});
|
|
36
|
+
// hq-core is public. A stale GH_TOKEN 401/403 should not hide the anonymous
|
|
37
|
+
// lookup that used to work.
|
|
38
|
+
if (token && (res.status === 401 || res.status === 403)) {
|
|
39
|
+
const { Authorization: _drop, ...anonHeaders } = headers;
|
|
40
|
+
res = await fetchImpl(HQ_CORE_LATEST_URL, {
|
|
41
|
+
headers: anonHeaders,
|
|
42
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
32
45
|
if (!res.ok)
|
|
33
46
|
return null;
|
|
34
47
|
const body = (await res.json());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.188",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
53
53
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
54
54
|
"@aws-sdk/dsql-signer": "^3.1146.0",
|
|
55
|
-
"@indigoai-us/hq-cloud": "6.18.
|
|
55
|
+
"@indigoai-us/hq-cloud": "6.18.107",
|
|
56
56
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
57
57
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
58
58
|
"@sentry/node": "^10.49.0",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.188",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.188",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.188",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.188",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.188",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.188"
|
|
123
123
|
}
|
|
124
124
|
}
|