@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
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bot's built-in hot-signal-sync job.
|
|
3
3
|
*
|
|
4
|
-
* A local bot
|
|
5
|
-
*
|
|
4
|
+
* A local bot registers one recurring job in its own `jobs.json` the first
|
|
5
|
+
* time it starts (`ensureHotSignalSyncJob`), for the bot's company, or, for a
|
|
6
|
+
* bot with no company (the personal bot first-run setup creates), for the
|
|
7
|
+
* HQ's active company (`.hq/config.json` `activeCompany`, the same company
|
|
8
|
+
* `hq sources sync` uses). Only one local bot registers it per company. Right
|
|
9
|
+
* after the job is created the bot runs the sync once, so a new install does
|
|
10
|
+
* not wait for the first scheduled time (`registerHotSignalSyncOnStart`). The job
|
|
6
11
|
* fires from the bot's inbox loop like every other local job
|
|
7
12
|
* (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
|
|
8
13
|
* The default schedule is 06:30 daily in the machine's time zone, ahead of a
|
|
@@ -34,14 +39,26 @@ export declare const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
|
|
|
34
39
|
export declare function hotSignalSyncJobContext(company: string): Record<string, unknown>;
|
|
35
40
|
/** The company a job item syncs, when its context marks it as this built-in; else null. */
|
|
36
41
|
export declare function hotSignalSyncJobCompany(context: unknown): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The HQ's active company from `{hqRoot}/.hq/config.json` (`activeCompany`),
|
|
44
|
+
* the same company `hq sources sync` falls back to. Undefined when the file is
|
|
45
|
+
* missing, unreadable, or the value is not a plain slug. Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
|
|
37
48
|
/**
|
|
38
49
|
* The company a bot's hot-signal-sync job syncs: the bot's own company, else
|
|
39
|
-
* the first company it serves
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
50
|
+
* the first company it serves, else (for a bot with no company at all, such as
|
|
51
|
+
* the personal bot first-run setup creates) `activeCompany`, the HQ's active
|
|
52
|
+
* company. Null = none, and no job is registered. Checked again each time the
|
|
53
|
+
* job fires, so a job for a company the bot no longer resolves to is removed.
|
|
43
54
|
*/
|
|
44
|
-
export declare function currentHotSignalSyncCompany(config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId"
|
|
55
|
+
export declare function currentHotSignalSyncCompany(config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">, activeCompany?: string | null): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The local bot folder, other than `dir`, that already has the hot-signal-sync
|
|
58
|
+
* job for `company` (or registered it once, even if its owner removed it
|
|
59
|
+
* since). Null = none. Unreadable folders are skipped.
|
|
60
|
+
*/
|
|
61
|
+
export declare function otherBotWithHotSignalSync(dir: string, company: string, botDirs: readonly string[]): string | null;
|
|
45
62
|
/**
|
|
46
63
|
* Remove every hot-signal-sync job whose company is not `current` (all of them
|
|
47
64
|
* when `current` is null), and forget their registration so the company can be
|
|
@@ -52,6 +69,8 @@ export interface EnsureHotSignalSyncJobResult {
|
|
|
52
69
|
/** The job, or null when the owner removed it after it was first registered. */
|
|
53
70
|
job: LocalJob | null;
|
|
54
71
|
created: boolean;
|
|
72
|
+
/** Set when another local bot already has this company's job; nothing was registered here. */
|
|
73
|
+
heldBy?: string;
|
|
55
74
|
}
|
|
56
75
|
/**
|
|
57
76
|
* Register the hot-signal-sync job for `company` once. Safe to call on every
|
|
@@ -61,7 +80,34 @@ export interface EnsureHotSignalSyncJobResult {
|
|
|
61
80
|
export declare function ensureHotSignalSyncJob(dir: string, input: {
|
|
62
81
|
company: string;
|
|
63
82
|
timezone: string;
|
|
83
|
+
otherBotDirs?: readonly string[];
|
|
64
84
|
}, now?: Date): EnsureHotSignalSyncJobResult;
|
|
85
|
+
export interface RegisterOnStartInput {
|
|
86
|
+
dir: string;
|
|
87
|
+
config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">;
|
|
88
|
+
/** The HQ's active company, used only when the bot has no company. */
|
|
89
|
+
activeCompany?: string | null;
|
|
90
|
+
/** Every local bot folder (this one may be included); used for the one-bot-per-company check. */
|
|
91
|
+
otherBotDirs?: readonly string[];
|
|
92
|
+
timezone: string;
|
|
93
|
+
/** The first sync, run once right after the job is created. Errors are logged, never thrown. */
|
|
94
|
+
runFirst: (company: string) => Promise<HotSignalSyncRunOutcome>;
|
|
95
|
+
log?: (level: "info" | "warn", message: string) => void;
|
|
96
|
+
now?: Date;
|
|
97
|
+
}
|
|
98
|
+
export interface RegisterOnStartResult {
|
|
99
|
+
company: string | null;
|
|
100
|
+
ensured: EnsureHotSignalSyncJobResult | null;
|
|
101
|
+
/** The first run, when the job was created on this start; resolves (never rejects) when it ends. */
|
|
102
|
+
firstRun: Promise<void> | null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* What a bot does at start for the built-in job: remove jobs for a company it
|
|
106
|
+
* no longer syncs, register the job for its current company once, and, when
|
|
107
|
+
* the job was created just now, start one sync in the background. Never
|
|
108
|
+
* throws and never waits for the sync, so bot start cannot fail or stall here.
|
|
109
|
+
*/
|
|
110
|
+
export declare function registerHotSignalSyncOnStart(input: RegisterOnStartInput): RegisterOnStartResult;
|
|
65
111
|
/**
|
|
66
112
|
* The signals pass over one adapter's landed sources: how many signals it
|
|
67
113
|
* generated. The bot wires the local `claude -p` pass
|
|
@@ -75,6 +121,8 @@ export type HotSignalSyncFn = (input: {
|
|
|
75
121
|
company: string;
|
|
76
122
|
trigger: RunTrigger;
|
|
77
123
|
catchUp: boolean;
|
|
124
|
+
/** The one run started right after the job was registered; marked in the ledger. */
|
|
125
|
+
firstRun?: boolean;
|
|
78
126
|
signalsGenerated: LocalSignalsPass;
|
|
79
127
|
now: () => Date;
|
|
80
128
|
/** The owner DM for the signals-failure alert (US-012); unset = no alert check. */
|
|
@@ -87,6 +135,8 @@ export interface HotSignalSyncRunInput {
|
|
|
87
135
|
company: string;
|
|
88
136
|
/** The run fired late, after a missed window (job-run.ts `isCatchUpRun`). */
|
|
89
137
|
catchUp: boolean;
|
|
138
|
+
/** The one run started right after the job was registered (`registerHotSignalSyncOnStart`). */
|
|
139
|
+
firstRun?: boolean;
|
|
90
140
|
now: () => Date;
|
|
91
141
|
sync: HotSignalSyncFn;
|
|
92
142
|
signalsPass?: LocalSignalsPass;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bot's built-in hot-signal-sync job.
|
|
3
3
|
*
|
|
4
|
-
* A local bot
|
|
5
|
-
*
|
|
4
|
+
* A local bot registers one recurring job in its own `jobs.json` the first
|
|
5
|
+
* time it starts (`ensureHotSignalSyncJob`), for the bot's company, or, for a
|
|
6
|
+
* bot with no company (the personal bot first-run setup creates), for the
|
|
7
|
+
* HQ's active company (`.hq/config.json` `activeCompany`, the same company
|
|
8
|
+
* `hq sources sync` uses). Only one local bot registers it per company. Right
|
|
9
|
+
* after the job is created the bot runs the sync once, so a new install does
|
|
10
|
+
* not wait for the first scheduled time (`registerHotSignalSyncOnStart`). The job
|
|
6
11
|
* fires from the bot's inbox loop like every other local job
|
|
7
12
|
* (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
|
|
8
13
|
* The default schedule is 06:30 daily in the machine's time zone, ahead of a
|
|
@@ -21,7 +26,7 @@
|
|
|
21
26
|
import * as fs from "node:fs";
|
|
22
27
|
import * as path from "node:path";
|
|
23
28
|
import { JOB_RUN_OPTIONS_CONTEXT_KEY } from "../job-run.js";
|
|
24
|
-
import { effectiveBotCompanies } from "../config.js";
|
|
29
|
+
import { effectiveBotCompanies, effectiveBotKind } from "../config.js";
|
|
25
30
|
import { ensureLocalJob, readLocalJobs, removeLocalJobsWhere, withJobsLock } from "../local-jobs.js";
|
|
26
31
|
export const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
|
|
27
32
|
/**
|
|
@@ -69,15 +74,55 @@ export function hotSignalSyncJobCompany(context) {
|
|
|
69
74
|
return null;
|
|
70
75
|
return typeof company === "string" && company.trim() ? company.trim() : null;
|
|
71
76
|
}
|
|
77
|
+
const ACTIVE_COMPANY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
78
|
+
/**
|
|
79
|
+
* The HQ's active company from `{hqRoot}/.hq/config.json` (`activeCompany`),
|
|
80
|
+
* the same company `hq sources sync` falls back to. Undefined when the file is
|
|
81
|
+
* missing, unreadable, or the value is not a plain slug. Never throws.
|
|
82
|
+
*/
|
|
83
|
+
export function readActiveCompanySlug(hqRoot) {
|
|
84
|
+
try {
|
|
85
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8"));
|
|
86
|
+
const slug = cfg?.activeCompany;
|
|
87
|
+
return typeof slug === "string" && slug.length > 0 ? slug : undefined;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
72
93
|
/**
|
|
73
94
|
* The company a bot's hot-signal-sync job syncs: the bot's own company, else
|
|
74
|
-
* the first company it serves
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
95
|
+
* the first company it serves, else (for a bot with no company at all, such as
|
|
96
|
+
* the personal bot first-run setup creates) `activeCompany`, the HQ's active
|
|
97
|
+
* company. Null = none, and no job is registered. Checked again each time the
|
|
98
|
+
* job fires, so a job for a company the bot no longer resolves to is removed.
|
|
78
99
|
*/
|
|
79
|
-
export function currentHotSignalSyncCompany(config) {
|
|
80
|
-
|
|
100
|
+
export function currentHotSignalSyncCompany(config, activeCompany) {
|
|
101
|
+
const own = config.companySlug ?? effectiveBotCompanies(config)[0];
|
|
102
|
+
if (own)
|
|
103
|
+
return own;
|
|
104
|
+
// A company bot syncs only the companies it was given.
|
|
105
|
+
if (effectiveBotKind(config) === "company")
|
|
106
|
+
return null;
|
|
107
|
+
const active = activeCompany?.trim();
|
|
108
|
+
return active && ACTIVE_COMPANY_RE.test(active) ? active : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The local bot folder, other than `dir`, that already has the hot-signal-sync
|
|
112
|
+
* job for `company` (or registered it once, even if its owner removed it
|
|
113
|
+
* since). Null = none. Unreadable folders are skipped.
|
|
114
|
+
*/
|
|
115
|
+
export function otherBotWithHotSignalSync(dir, company, botDirs) {
|
|
116
|
+
const self = path.resolve(dir);
|
|
117
|
+
for (const other of botDirs) {
|
|
118
|
+
if (path.resolve(other) === self)
|
|
119
|
+
continue;
|
|
120
|
+
if (readMarker(other).registered[markerKey(company)])
|
|
121
|
+
return other;
|
|
122
|
+
if (readLocalJobs(other).some((job) => hotSignalSyncJobCompany(job.context) === company))
|
|
123
|
+
return other;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
81
126
|
}
|
|
82
127
|
/**
|
|
83
128
|
* Remove every hot-signal-sync job whose company is not `current` (all of them
|
|
@@ -115,6 +160,10 @@ export function ensureHotSignalSyncJob(dir, input, now = new Date()) {
|
|
|
115
160
|
if (readMarker(dir).registered[key]) {
|
|
116
161
|
return { job: readLocalJobs(dir).find(matches) ?? null, created: false };
|
|
117
162
|
}
|
|
163
|
+
// One bot per company: a second local bot never adds its own copy.
|
|
164
|
+
const heldBy = otherBotWithHotSignalSync(dir, input.company, input.otherBotDirs ?? []);
|
|
165
|
+
if (heldBy)
|
|
166
|
+
return { job: null, created: false, heldBy };
|
|
118
167
|
const { job, created } = ensureLocalJob(dir, matches, {
|
|
119
168
|
prompt: `Sync hot channels for ${input.company} and run the signals pass (runs in the bot, no model turn).`,
|
|
120
169
|
cron: HOT_SIGNAL_SYNC_DEFAULT_CRON,
|
|
@@ -130,6 +179,53 @@ export function ensureHotSignalSyncJob(dir, input, now = new Date()) {
|
|
|
130
179
|
});
|
|
131
180
|
return { job, created };
|
|
132
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* What a bot does at start for the built-in job: remove jobs for a company it
|
|
184
|
+
* no longer syncs, register the job for its current company once, and, when
|
|
185
|
+
* the job was created just now, start one sync in the background. Never
|
|
186
|
+
* throws and never waits for the sync, so bot start cannot fail or stall here.
|
|
187
|
+
*/
|
|
188
|
+
export function registerHotSignalSyncOnStart(input) {
|
|
189
|
+
const log = input.log ?? (() => undefined);
|
|
190
|
+
const company = currentHotSignalSyncCompany(input.config, input.activeCompany);
|
|
191
|
+
try {
|
|
192
|
+
for (const stale of removeStaleHotSignalSyncJobs(input.dir, company)) {
|
|
193
|
+
log("info", `Removed the hot-channel sync job ${stale.jobId}: this bot no longer syncs that company.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
log("warn", `Could not check the hot-channel sync jobs: ${errorMessage(err)}`);
|
|
198
|
+
}
|
|
199
|
+
if (!company)
|
|
200
|
+
return { company: null, ensured: null, firstRun: null };
|
|
201
|
+
let ensured;
|
|
202
|
+
try {
|
|
203
|
+
ensured = ensureHotSignalSyncJob(input.dir, { company, timezone: input.timezone, otherBotDirs: input.otherBotDirs ?? [] }, input.now ?? new Date());
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
log("warn", `Could not register the hot-channel sync job for ${company}: ${errorMessage(err)}`);
|
|
207
|
+
return { company, ensured: null, firstRun: null };
|
|
208
|
+
}
|
|
209
|
+
if (ensured.heldBy) {
|
|
210
|
+
log("info", `Not adding a hot-channel sync job for ${company}: the bot in ${ensured.heldBy} already runs it.`);
|
|
211
|
+
}
|
|
212
|
+
if (!ensured.created)
|
|
213
|
+
return { company, ensured, firstRun: null };
|
|
214
|
+
log("info", `Registered the daily hot-channel sync for ${company}; running the first sync now.`);
|
|
215
|
+
const firstRun = (async () => {
|
|
216
|
+
try {
|
|
217
|
+
const outcome = await input.runFirst(company);
|
|
218
|
+
log(outcome.ok ? "info" : "warn", outcome.text);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
log("warn", `The first hot-channel sync for ${company} failed: ${errorMessage(err)}`);
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
return { company, ensured, firstRun };
|
|
225
|
+
}
|
|
226
|
+
function errorMessage(err) {
|
|
227
|
+
return err instanceof Error ? err.message : String(err);
|
|
228
|
+
}
|
|
133
229
|
export const noLocalSignalsPass = async () => 0;
|
|
134
230
|
/** Run the scheduled sync once and describe it in plain words. Never throws. */
|
|
135
231
|
export async function runHotSignalSyncJob(input) {
|
|
@@ -140,6 +236,7 @@ export async function runHotSignalSyncJob(input) {
|
|
|
140
236
|
company: input.company,
|
|
141
237
|
trigger: "scheduled",
|
|
142
238
|
catchUp: input.catchUp,
|
|
239
|
+
...(input.firstRun ? { firstRun: true } : {}),
|
|
143
240
|
signalsGenerated: signalsPass,
|
|
144
241
|
now: input.now,
|
|
145
242
|
...(input.sendAlert
|
|
@@ -95,9 +95,11 @@ export declare function ensureLocalJob(dir: string, matches: (job: LocalJob) =>
|
|
|
95
95
|
* Change a job's schedule in place, keeping its id, instruction, context and
|
|
96
96
|
* run history. Returns null when the bot has no job with that id.
|
|
97
97
|
*/
|
|
98
|
-
export declare function rescheduleLocalJob(dir: string, jobId: string,
|
|
98
|
+
export declare function rescheduleLocalJob(dir: string, jobId: string,
|
|
99
|
+
/** `timezone` omitted keeps the job's own, read under the same lock. */
|
|
100
|
+
schedule: {
|
|
99
101
|
cron: string;
|
|
100
|
-
timezone
|
|
102
|
+
timezone?: string;
|
|
101
103
|
}, now?: Date): LocalJob | null;
|
|
102
104
|
/** Remove every job `matches` picks, as one locked read-and-write. Returns the removed jobs. */
|
|
103
105
|
export declare function removeLocalJobsWhere(dir: string, matches: (job: LocalJob) => boolean): LocalJob[];
|
|
@@ -42,6 +42,33 @@ export function readLocalJobs(dir) {
|
|
|
42
42
|
return [];
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* `jobs.json` for a write that adds a job: a missing file is an empty list,
|
|
47
|
+
* but a file that exists and cannot be read or parsed throws, so adding a job
|
|
48
|
+
* never writes back a list that drops every other job.
|
|
49
|
+
*/
|
|
50
|
+
function readLocalJobsForAdd(dir) {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(localJobsPath(dir), "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err.code === "ENOENT")
|
|
57
|
+
return [];
|
|
58
|
+
throw new Error(`The bot's job list (${localJobsPath(dir)}) could not be read, so it was not changed: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw new Error(`The bot's job list (${localJobsPath(dir)}) is not valid JSON, so it was not changed: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (!parsed || !Array.isArray(parsed.jobs)) {
|
|
68
|
+
throw new Error(`The bot's job list (${localJobsPath(dir)}) has no jobs array, so it was not changed.`);
|
|
69
|
+
}
|
|
70
|
+
return parsed.jobs.filter((job) => !!job && typeof job.jobId === "string");
|
|
71
|
+
}
|
|
45
72
|
function writeLocalJobs(dir, jobs) {
|
|
46
73
|
const file = localJobsPath(dir);
|
|
47
74
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -311,7 +338,7 @@ export function addLocalJob(dir, input, now = new Date()) {
|
|
|
311
338
|
...(input.context ? { context: input.context } : {}),
|
|
312
339
|
createdAt: now.toISOString(),
|
|
313
340
|
};
|
|
314
|
-
withJobsLock(dir, () => writeLocalJobs(dir, [...
|
|
341
|
+
withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobsForAdd(dir), job]));
|
|
315
342
|
return job;
|
|
316
343
|
}
|
|
317
344
|
/**
|
|
@@ -322,7 +349,7 @@ export function ensureLocalJob(dir, matches, input, now = new Date()) {
|
|
|
322
349
|
if (!isRunnableSchedule(input.cron))
|
|
323
350
|
throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
|
|
324
351
|
return withJobsLock(dir, () => {
|
|
325
|
-
const jobs =
|
|
352
|
+
const jobs = readLocalJobsForAdd(dir);
|
|
326
353
|
const existing = jobs.find(matches);
|
|
327
354
|
if (existing)
|
|
328
355
|
return { job: existing, created: false };
|
|
@@ -342,7 +369,9 @@ export function ensureLocalJob(dir, matches, input, now = new Date()) {
|
|
|
342
369
|
* Change a job's schedule in place, keeping its id, instruction, context and
|
|
343
370
|
* run history. Returns null when the bot has no job with that id.
|
|
344
371
|
*/
|
|
345
|
-
export function rescheduleLocalJob(dir, jobId,
|
|
372
|
+
export function rescheduleLocalJob(dir, jobId,
|
|
373
|
+
/** `timezone` omitted keeps the job's own, read under the same lock. */
|
|
374
|
+
schedule, now = new Date()) {
|
|
346
375
|
if (!isRunnableSchedule(schedule.cron))
|
|
347
376
|
throw new Error(`"${schedule.cron}" is not a schedule this bot can run.`);
|
|
348
377
|
return withJobsLock(dir, () => {
|
|
@@ -353,7 +382,7 @@ export function rescheduleLocalJob(dir, jobId, schedule, now = new Date()) {
|
|
|
353
382
|
const updated = {
|
|
354
383
|
...jobs[index],
|
|
355
384
|
cron: schedule.cron,
|
|
356
|
-
timezone: schedule.timezone,
|
|
385
|
+
timezone: schedule.timezone ?? jobs[index].timezone,
|
|
357
386
|
scheduledFrom: now.toISOString(),
|
|
358
387
|
};
|
|
359
388
|
jobs[index] = updated;
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -83,7 +83,7 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
|
|
|
83
83
|
import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-mentions.js";
|
|
84
84
|
import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
|
|
85
85
|
import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
|
|
86
|
-
import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
|
|
86
|
+
import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, readActiveCompanySlug, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
|
|
87
87
|
import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
|
|
88
88
|
import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
|
|
89
89
|
import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
|
|
@@ -1978,7 +1978,7 @@ export async function runBot(deps) {
|
|
|
1978
1978
|
log("warn", `hot-signal-sync job ${jobId} run ${id} skipped: bot.json missing or unreadable`);
|
|
1979
1979
|
return;
|
|
1980
1980
|
}
|
|
1981
|
-
const currentCompany = currentHotSignalSyncCompany(freshConfig);
|
|
1981
|
+
const currentCompany = currentHotSignalSyncCompany(freshConfig, readActiveCompanySlug(freshConfig.hqRoot) ?? null);
|
|
1982
1982
|
if (currentCompany !== syncCompany) {
|
|
1983
1983
|
let removedNote = "";
|
|
1984
1984
|
try {
|
|
@@ -306,6 +306,9 @@ export function createAttioAdapter(options) {
|
|
|
306
306
|
reasons.add("owner");
|
|
307
307
|
if (reasons.size === 0)
|
|
308
308
|
continue;
|
|
309
|
+
// A saved record pick: only deliveries about a chosen record land.
|
|
310
|
+
if (ctx.selection && !(ref && ctx.selection.has(ref.recordId)))
|
|
311
|
+
continue;
|
|
309
312
|
events.push(toEvent(d, [...reasons], actor, ref, ctx.personUid, options.identities));
|
|
310
313
|
}
|
|
311
314
|
const last = page[page.length - 1];
|
|
@@ -290,6 +290,10 @@ export function createLinearAdapter(options = {}) {
|
|
|
290
290
|
if (!issue || Date.parse(issue.updatedAt) <= watermarkMs)
|
|
291
291
|
continue;
|
|
292
292
|
high = high === null ? issue.updatedAt : laterOf(high, issue.updatedAt);
|
|
293
|
+
// A saved issue pick (by issue id or identifier such as ENG-123): other
|
|
294
|
+
// issues still move the watermark but do not land.
|
|
295
|
+
if (ctx.selection && !ctx.selection.has(issue.uuid) && !ctx.selection.has(issue.identifier))
|
|
296
|
+
continue;
|
|
293
297
|
events.push(toEvent(issue, ctx.personUid));
|
|
294
298
|
}
|
|
295
299
|
if (page.hasNextPage) {
|
|
@@ -463,6 +463,10 @@ export function createMeetingsAdapter(options) {
|
|
|
463
463
|
return { events, watermark: mark ?? new Date(fromMs).toISOString(), hasMore, deduped };
|
|
464
464
|
}
|
|
465
465
|
async function fetch(cursor, ctx) {
|
|
466
|
+
// The person saved an empty meetings pick: meetings are excluded. Nothing
|
|
467
|
+
// is read or written and the cursor stays where it was.
|
|
468
|
+
if (ctx.selection && ctx.selection.size === 0)
|
|
469
|
+
return { events: [], cursor, hasMore: false };
|
|
466
470
|
const state = parseMeetingsCursor(cursor);
|
|
467
471
|
const vault = await fetchVault(state.vault, ctx);
|
|
468
472
|
const gateways = (ctx.gateways ?? []).filter((g) => MEETING_PROVIDERS.has(g.provider.toLowerCase()));
|
|
@@ -238,6 +238,9 @@ export function createSlackAdapter(options) {
|
|
|
238
238
|
const owners = [nonEmptyString(fm.person_uid), nonEmptyString(fm.account_id)];
|
|
239
239
|
if (!owners.some((id) => id !== undefined && me.has(id.toLowerCase())))
|
|
240
240
|
continue;
|
|
241
|
+
// A saved channel pick: only threads in a chosen channel land.
|
|
242
|
+
if (ctx.selection && !ctx.selection.has(t.channelId))
|
|
243
|
+
continue;
|
|
241
244
|
candidates.push(t);
|
|
242
245
|
}
|
|
243
246
|
if (!Number.isNaN(pausedMs) && pausedMs > floorMs) {
|
|
@@ -85,6 +85,13 @@ export interface PollContext {
|
|
|
85
85
|
* was lost.
|
|
86
86
|
*/
|
|
87
87
|
beforeWrite?: () => Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* The ids the person chose to watch on this connection (hq-pro saved
|
|
90
|
+
* selection). When set, an adapter lands only events about one of these
|
|
91
|
+
* ids (Slack channel, Linear issue, Attio record); an empty set lands
|
|
92
|
+
* nothing. Unset means no choice was saved: land everything.
|
|
93
|
+
*/
|
|
94
|
+
selection?: ReadonlySet<string>;
|
|
88
95
|
}
|
|
89
96
|
export interface PollPage {
|
|
90
97
|
events: HotEvent[];
|
|
@@ -41,6 +41,12 @@ export interface RunLedgerEntry {
|
|
|
41
41
|
* was asleep or the bot was stopped). Omitted for runs that were not marked.
|
|
42
42
|
*/
|
|
43
43
|
catchUp?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* True on the one run the bot starts right after it registers its daily
|
|
46
|
+
* hot-signal-sync job, so a new install syncs without waiting for the first
|
|
47
|
+
* scheduled time. Omitted on every other run.
|
|
48
|
+
*/
|
|
49
|
+
firstRun?: boolean;
|
|
44
50
|
}
|
|
45
51
|
/** The UTC calendar day of an instant, `YYYY-MM-DD`. Ledger files are per UTC day. */
|
|
46
52
|
export declare function ledgerDay(at: Date): string;
|
|
@@ -77,6 +83,8 @@ export interface RunHotChannelSyncInput extends RunHotChannelLoopInput {
|
|
|
77
83
|
runId?: string;
|
|
78
84
|
/** Mark every ledger line of this run as a catch-up of a missed scheduled window. */
|
|
79
85
|
catchUp?: boolean;
|
|
86
|
+
/** Mark every ledger line of this run as the job's first run (see RunLedgerEntry.firstRun). */
|
|
87
|
+
firstRun?: boolean;
|
|
80
88
|
/**
|
|
81
89
|
* The signals pass for this adapter's landed sources; returns the number of
|
|
82
90
|
* signals it wrote. Any throw makes this adapter's line `failed` with a count
|
|
@@ -12,6 +12,7 @@ import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
|
|
|
12
12
|
import { runHotChannelLoop } from "./loop.js";
|
|
13
13
|
import { SyncLockLostError } from "./sync-lock.js";
|
|
14
14
|
import { SignalsPassError } from "./signals-pass.js";
|
|
15
|
+
import { SELECTION_UNREADABLE_REASON } from "./selection.js";
|
|
15
16
|
export const RUN_TRIGGERS = ["scheduled", "on-demand"];
|
|
16
17
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
17
18
|
/** The UTC calendar day of an instant, `YYYY-MM-DD`. Ledger files are per UTC day. */
|
|
@@ -52,7 +53,8 @@ function isLedgerEntry(value) {
|
|
|
52
53
|
typeof v.finishedAt === "string" &&
|
|
53
54
|
(v.status === "ok" || v.status === "failed" || v.status === "skipped") &&
|
|
54
55
|
(v.error === null || typeof v.error === "string") &&
|
|
55
|
-
(v.catchUp === undefined || typeof v.catchUp === "boolean")
|
|
56
|
+
(v.catchUp === undefined || typeof v.catchUp === "boolean") &&
|
|
57
|
+
(v.firstRun === undefined || typeof v.firstRun === "boolean"));
|
|
56
58
|
}
|
|
57
59
|
/** Read one day's ledger. A missing file is an empty ledger. */
|
|
58
60
|
export async function readRunLedger(vaultRoot, personKey, day) {
|
|
@@ -123,7 +125,7 @@ export async function runHotChannelSync(input) {
|
|
|
123
125
|
}
|
|
124
126
|
const now = input.now ?? (() => new Date());
|
|
125
127
|
const runId = input.runId ?? `run_${randomUUID()}`;
|
|
126
|
-
const { trigger: _trigger, runId: _runId, catchUp, signalsGenerated, ...loopInput } = input;
|
|
128
|
+
const { trigger: _trigger, runId: _runId, catchUp, firstRun, signalsGenerated, ...loopInput } = input;
|
|
127
129
|
const results = [];
|
|
128
130
|
const entries = [];
|
|
129
131
|
for (const entry of input.catalog) {
|
|
@@ -175,6 +177,8 @@ export async function runHotChannelSync(input) {
|
|
|
175
177
|
error: failure,
|
|
176
178
|
personUid: input.personUid,
|
|
177
179
|
catchUp: catchUp ?? false,
|
|
180
|
+
...(result?.error?.code === SELECTION_UNREADABLE_REASON ? { reason: SELECTION_UNREADABLE_REASON } : {}),
|
|
181
|
+
...(firstRun ? { firstRun: true } : {}),
|
|
178
182
|
...(result && result.rejected > 0
|
|
179
183
|
? { rejected: result.rejected, rejectedReasons: [...(result.rejectedReasons ?? [])] }
|
|
180
184
|
: {}),
|
|
@@ -57,6 +57,12 @@ export interface RunHotChannelLoopInput {
|
|
|
57
57
|
* `PollContext.gateway`. Called only for entries with a `connectionId`.
|
|
58
58
|
*/
|
|
59
59
|
gatewayFor?: (entry: CatalogEntry) => HotChannelGateway | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The person's saved selection for a poll entry, handed to the adapter as
|
|
62
|
+
* `PollContext.selection`. Undefined means land everything; a throw skips
|
|
63
|
+
* this entry (fail closed) and records it as failed.
|
|
64
|
+
*/
|
|
65
|
+
selectionFor?: (entry: CatalogEntry) => Promise<ReadonlySet<string> | undefined>;
|
|
60
66
|
now?: () => Date;
|
|
61
67
|
maxPagesPerAdapter?: number;
|
|
62
68
|
maxPushEventsPerAdapter?: number;
|
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { createVaultCursorStore, cursorKey } from "./cursors.js";
|
|
5
5
|
import { assertHotEvent, assertSafeSegment, eventKey, HotEventValidationError, landHotEvent } from "./landing.js";
|
|
6
6
|
import { SyncLockLostError } from "./sync-lock.js";
|
|
7
|
+
import { SELECTION_UNREADABLE_REASON } from "./selection.js";
|
|
7
8
|
/** Default page cap per poll adapter per run (policy: bound every wait/loop). */
|
|
8
9
|
export const DEFAULT_MAX_PAGES_PER_ADAPTER = 20;
|
|
9
10
|
/** Default cap on buffered push events drained per adapter per run. */
|
|
@@ -118,7 +119,7 @@ async function drainPush(ctx, adapter, result) {
|
|
|
118
119
|
await fs.rm(file, { force: true });
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
|
-
async function runPoll(ctx, adapter, result, key, gateway, gateways = []) {
|
|
122
|
+
async function runPoll(ctx, adapter, result, key, gateway, gateways = [], selection) {
|
|
122
123
|
let cursor = result.cursorBefore;
|
|
123
124
|
for (let page = 0; page < ctx.maxPages; page += 1) {
|
|
124
125
|
const now = ctx.now();
|
|
@@ -129,6 +130,7 @@ async function runPoll(ctx, adapter, result, key, gateway, gateways = []) {
|
|
|
129
130
|
beforeWrite: ctx.beforeWrite,
|
|
130
131
|
...(gateway ? { gateway } : {}),
|
|
131
132
|
...(gateways.length > 0 ? { gateways } : {}),
|
|
133
|
+
...(selection ? { selection } : {}),
|
|
132
134
|
});
|
|
133
135
|
result.pages += 1;
|
|
134
136
|
let lastEventKey;
|
|
@@ -206,7 +208,19 @@ export async function runHotChannelLoop(input) {
|
|
|
206
208
|
});
|
|
207
209
|
result.cursorBefore = await ctx.cursorStore.get(ctx.personKey, key);
|
|
208
210
|
result.cursorAfter = result.cursorBefore;
|
|
209
|
-
|
|
211
|
+
let selection;
|
|
212
|
+
try {
|
|
213
|
+
selection = await input.selectionFor?.(entry);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// Fail closed: a saved selection that may exist but cannot be read
|
|
217
|
+
// skips this connection (nothing lands, the cursor stays) and the
|
|
218
|
+
// run goes on to the next one.
|
|
219
|
+
ctx.warn(`hot-channel ${adapter.id}: saved selection unreadable (${describeError(err)?.message}); skipping this connection`);
|
|
220
|
+
result.error = { name: "SelectionUnreadableError", code: SELECTION_UNREADABLE_REASON, message: describeError(err)?.message ?? "unreadable" };
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
await runPoll(ctx, adapter, result, key, gateway, gateways, selection);
|
|
210
224
|
}
|
|
211
225
|
}
|
|
212
226
|
catch (err) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads objects from the signed-in person's own vault bucket, the bucket
|
|
3
|
+
* hq-pro writes hot-channel selections to. Same path as
|
|
4
|
+
* `hq files cat --personal`: resolve the person entity, vend STS credentials
|
|
5
|
+
* through `/sts/vend-self`, then `GetObject`.
|
|
6
|
+
*
|
|
7
|
+
* The entity lookup and credential vend happen once per reader (one reader per
|
|
8
|
+
* run); a failed open is retried on the next read. A missing object resolves
|
|
9
|
+
* null; any other failure rejects, and the selection loader skips that
|
|
10
|
+
* connection (fail closed).
|
|
11
|
+
*/
|
|
12
|
+
import { GetObjectCommand, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
13
|
+
import type { PersonVaultObjectReader } from "./selection.js";
|
|
14
|
+
export interface PersonVaultReaderDeps {
|
|
15
|
+
/** The signed-in person's canonical person-entity uid. */
|
|
16
|
+
resolvePersonUid(): Promise<string>;
|
|
17
|
+
getEntity(uid: string): Promise<{
|
|
18
|
+
uid: string;
|
|
19
|
+
bucketName?: string;
|
|
20
|
+
}>;
|
|
21
|
+
vendSelf(personUid: string): Promise<{
|
|
22
|
+
credentials: {
|
|
23
|
+
accessKeyId: string;
|
|
24
|
+
secretAccessKey: string;
|
|
25
|
+
sessionToken: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
s3Factory(input: {
|
|
29
|
+
region: string;
|
|
30
|
+
credentials: {
|
|
31
|
+
accessKeyId: string;
|
|
32
|
+
secretAccessKey: string;
|
|
33
|
+
sessionToken: string;
|
|
34
|
+
};
|
|
35
|
+
}): {
|
|
36
|
+
send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
|
|
37
|
+
};
|
|
38
|
+
region: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function createPersonVaultReader(deps: PersonVaultReaderDeps): PersonVaultObjectReader;
|
|
41
|
+
//# sourceMappingURL=person-vault-reader.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads objects from the signed-in person's own vault bucket, the bucket
|
|
3
|
+
* hq-pro writes hot-channel selections to. Same path as
|
|
4
|
+
* `hq files cat --personal`: resolve the person entity, vend STS credentials
|
|
5
|
+
* through `/sts/vend-self`, then `GetObject`.
|
|
6
|
+
*
|
|
7
|
+
* The entity lookup and credential vend happen once per reader (one reader per
|
|
8
|
+
* run); a failed open is retried on the next read. A missing object resolves
|
|
9
|
+
* null; any other failure rejects, and the selection loader skips that
|
|
10
|
+
* connection (fail closed).
|
|
11
|
+
*/
|
|
12
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
13
|
+
function isMissingObject(err) {
|
|
14
|
+
const name = err?.name;
|
|
15
|
+
const status = err?.$metadata?.httpStatusCode;
|
|
16
|
+
return name === "NoSuchKey" || name === "NotFound" || status === 404;
|
|
17
|
+
}
|
|
18
|
+
export function createPersonVaultReader(deps) {
|
|
19
|
+
let session;
|
|
20
|
+
const open = async () => {
|
|
21
|
+
const uid = await deps.resolvePersonUid();
|
|
22
|
+
const entity = await deps.getEntity(uid);
|
|
23
|
+
// No personal bucket means hq-pro has nowhere to have saved a pick.
|
|
24
|
+
if (!entity.bucketName)
|
|
25
|
+
return null;
|
|
26
|
+
const vend = await deps.vendSelf(entity.uid);
|
|
27
|
+
return { bucket: entity.bucketName, s3: deps.s3Factory({ region: deps.region, credentials: vend.credentials }) };
|
|
28
|
+
};
|
|
29
|
+
return async (key) => {
|
|
30
|
+
// One open per reader (one reader per run). A failed open is not kept:
|
|
31
|
+
// the next read tries again, so one bad vend does not cover the run.
|
|
32
|
+
const opening = (session ??= open());
|
|
33
|
+
let opened;
|
|
34
|
+
try {
|
|
35
|
+
opened = await opening;
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (session === opening)
|
|
39
|
+
session = undefined;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
if (opened === null)
|
|
43
|
+
return null;
|
|
44
|
+
const { bucket, s3 } = opened;
|
|
45
|
+
let resp;
|
|
46
|
+
try {
|
|
47
|
+
resp = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (isMissingObject(err))
|
|
51
|
+
return null;
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
if (!resp.Body)
|
|
55
|
+
return null;
|
|
56
|
+
return await resp.Body.transformToString();
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=person-vault-reader.js.map
|