@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
|
@@ -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
|
|
@@ -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;
|