@indigoai-us/hq-cli 5.345.183 → 5.345.187
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 +13 -0
- package/dist/command-catalog.generated.d.ts +29 -0
- package/dist/command-catalog.generated.js +35 -0
- 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/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/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 +7 -7
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { CatchUpWindow, HotTransport, SuggestionRule, TokenScope } from "./contract.js";
|
|
16
16
|
import { type RunLedgerEntry, type RunTrigger, type SignalsGeneratedFn } from "./ledger.js";
|
|
17
17
|
import type { HotChannelRegistry } from "./registry.js";
|
|
18
|
+
import { type SelectionLoader } from "./selection.js";
|
|
18
19
|
/** One item of the catalog API's top-level `hotChannels` array (hq-pro US-002). */
|
|
19
20
|
export interface HotChannelCatalogItem {
|
|
20
21
|
entryId?: string;
|
|
@@ -96,10 +97,17 @@ export interface OnDemandSyncInput {
|
|
|
96
97
|
trigger?: RunTrigger;
|
|
97
98
|
/** A scheduled run that fired late, after a missed window. */
|
|
98
99
|
catchUp?: boolean;
|
|
100
|
+
/** The first run after the bot registered its hot-signal-sync job. */
|
|
101
|
+
firstRun?: boolean;
|
|
99
102
|
/** The signals pass over each adapter's landed sources (US-011); 0 when absent. */
|
|
100
103
|
signalsGenerated?: SignalsGeneratedFn;
|
|
101
104
|
/** Checked before each document write (sync-lock ownership); a throw stops the run. */
|
|
102
105
|
beforeWrite?: () => Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* The person's saved hot-channel selections for this run (one loader per
|
|
108
|
+
* run, so nothing is cached across runs). Absent = land everything.
|
|
109
|
+
*/
|
|
110
|
+
selections?: SelectionLoader;
|
|
103
111
|
}
|
|
104
112
|
export type SyncOutcomeStatus = "ok" | "failed" | "unavailable" | "no-connection" | "gated";
|
|
105
113
|
export interface SyncOutcome {
|
|
@@ -18,6 +18,7 @@ import { bareProvider } from "../integrations/provider-slug.js";
|
|
|
18
18
|
import { createVaultCursorStore } from "./cursors.js";
|
|
19
19
|
import { cursorFilePath } from "./cursors.js";
|
|
20
20
|
import { runHotChannelSync } from "./ledger.js";
|
|
21
|
+
import { connectionSelection, meetingsSelection } from "./selection.js";
|
|
21
22
|
/** Token-scope providers served from the HQ vault: no external connection. */
|
|
22
23
|
/**
|
|
23
24
|
* The bare, lowercased provider id used to match a connection against a
|
|
@@ -174,6 +175,22 @@ function sourcesOnlyCursorStore(vaultRoot, now) {
|
|
|
174
175
|
},
|
|
175
176
|
};
|
|
176
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The saved selection for one planned entry: the entry's own connection (an
|
|
180
|
+
* empty list counts as no choice), or for a connectionless channel with companions (meetings) the include/exclude
|
|
181
|
+
* choice saved on its recorder connections.
|
|
182
|
+
*/
|
|
183
|
+
function selectionFor(load) {
|
|
184
|
+
return async (entry) => {
|
|
185
|
+
// An empty saved list on a connection means no choice yet: land everything.
|
|
186
|
+
if (entry.connectionId)
|
|
187
|
+
return connectionSelection(await load(entry.connectionId));
|
|
188
|
+
const companions = entry.companionConnectionIds ?? [];
|
|
189
|
+
if (companions.length === 0)
|
|
190
|
+
return undefined;
|
|
191
|
+
return meetingsSelection(await Promise.all(companions.map((id) => load(id))));
|
|
192
|
+
};
|
|
193
|
+
}
|
|
177
194
|
export async function runOnDemandSync(input) {
|
|
178
195
|
const report = {
|
|
179
196
|
runId: null,
|
|
@@ -276,6 +293,7 @@ export async function runOnDemandSync(input) {
|
|
|
276
293
|
const sync = await runHotChannelSync({
|
|
277
294
|
trigger: input.trigger ?? "on-demand",
|
|
278
295
|
...(input.catchUp === undefined ? {} : { catchUp: input.catchUp }),
|
|
296
|
+
...(input.firstRun ? { firstRun: true } : {}),
|
|
279
297
|
...(input.signalsGenerated ? { signalsGenerated: input.signalsGenerated } : {}),
|
|
280
298
|
...(input.beforeWrite ? { beforeWrite: input.beforeWrite } : {}),
|
|
281
299
|
registry: input.registry,
|
|
@@ -284,6 +302,7 @@ export async function runOnDemandSync(input) {
|
|
|
284
302
|
personUid: input.personUid,
|
|
285
303
|
personKey: input.personKey,
|
|
286
304
|
cursorStore: sourcesOnlyCursorStore(input.vaultRoot, input.now),
|
|
305
|
+
...(input.selections ? { selectionFor: selectionFor(input.selections) } : {}),
|
|
287
306
|
gatewayFor: (entry) => {
|
|
288
307
|
const app = entry.connectionId ? appsByConnection.get(entry.connectionId) : undefined;
|
|
289
308
|
return app ? gatewayFor(app, input.deps, reconnects) : undefined;
|
|
@@ -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
|
|
@@ -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.187",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -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.187",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.187",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.187",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.187",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.187",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.187"
|
|
123
123
|
}
|
|
124
124
|
}
|