@indigoai-us/hq-cli 5.345.191 → 5.345.194
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 +431 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +145 -19
- package/dist/command-catalog.generated.js +184 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +4 -3
- package/dist/commands/bot.js +265 -140
- package/dist/commands/core-jobs-schedule.js +10 -0
- package/dist/commands/daemon.js +37 -0
- package/dist/commands/files.d.ts +41 -6
- package/dist/commands/files.js +108 -27
- package/dist/commands/pack-start-bot.d.ts +4 -6
- package/dist/commands/pack-start-bot.js +4 -8
- package/dist/commands/signals-run.d.ts +46 -0
- package/dist/commands/signals-run.js +180 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +58 -0
- package/dist/commands/sources.js +294 -26
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +35 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/api.d.ts +3 -2
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-cron.d.ts +121 -0
- package/dist/lib/bot/fleet-cron.js +319 -0
- package/dist/lib/bot/fleet-local.d.ts +93 -1
- package/dist/lib/bot/fleet-local.js +546 -34
- package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
- package/dist/lib/bot/fleet-owner-companies.js +30 -0
- package/dist/lib/bot/fleet-watch.d.ts +25 -0
- package/dist/lib/bot/fleet-watch.js +51 -0
- package/dist/lib/bot/index.d.ts +1 -1
- package/dist/lib/bot/index.js +1 -1
- package/dist/lib/bot/job-run.d.ts +5 -4
- package/dist/lib/bot/job-run.js +5 -4
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
- package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
- package/dist/lib/bot/local-jobs.d.ts +13 -2
- package/dist/lib/bot/local-jobs.js +17 -4
- package/dist/lib/bot/progress.d.ts +14 -0
- package/dist/lib/bot/progress.js +47 -1
- package/dist/lib/bot/run.d.ts +55 -2
- package/dist/lib/bot/run.js +192 -10
- package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
- package/dist/lib/bot/setup-tool-offer.js +16 -0
- package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
- package/dist/lib/bot/signals-engine-flag.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +29 -9
- package/dist/lib/daemon/legacy.d.ts +6 -0
- package/dist/lib/daemon/legacy.js +16 -10
- package/dist/lib/daemon/run.d.ts +1 -0
- package/dist/lib/daemon/run.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.d.ts +13 -1
- package/dist/lib/flag-registry.js +46 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
- package/dist/lib/hot-channels/adapters/attio.js +367 -29
- package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
- package/dist/lib/hot-channels/adapters/gmail.js +59 -2
- package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
- package/dist/lib/hot-channels/adapters/linear.js +71 -1
- package/dist/lib/hot-channels/adapters/meetings.js +4 -1
- package/dist/lib/hot-channels/adapters/slack.js +7 -3
- package/dist/lib/hot-channels/alert.js +1 -1
- package/dist/lib/hot-channels/contract.d.ts +11 -0
- package/dist/lib/hot-channels/contract.js +1 -0
- package/dist/lib/hot-channels/cursors.js +1 -1
- package/dist/lib/hot-channels/landing.d.ts +6 -2
- package/dist/lib/hot-channels/landing.js +13 -6
- package/dist/lib/hot-channels/ledger.js +2 -0
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +19 -4
- package/dist/lib/hot-channels/registry.js +9 -1
- package/dist/lib/hot-channels/run.d.ts +18 -0
- package/dist/lib/hot-channels/run.js +15 -4
- package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
- package/dist/lib/hot-channels/signals-pass.js +149 -38
- package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
- package/dist/lib/hot-channels/signals-redo.js +207 -0
- package/dist/lib/hot-channels/sync-lock.js +22 -6
- package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
- package/dist/lib/mesh/live/daemon/install.js +117 -30
- package/dist/lib/signals/adapters-catalog.d.ts +77 -0
- package/dist/lib/signals/adapters-catalog.js +194 -0
- package/dist/lib/signals/engine.d.ts +202 -0
- package/dist/lib/signals/engine.js +504 -0
- package/dist/lib/signals/failure-alert.d.ts +89 -0
- package/dist/lib/signals/failure-alert.js +175 -0
- package/dist/lib/signals/local-date.d.ts +3 -0
- package/dist/lib/signals/local-date.js +6 -0
- package/dist/lib/signals/projection.d.ts +42 -0
- package/dist/lib/signals/projection.js +67 -0
- package/dist/lib/signals/reply.d.ts +112 -0
- package/dist/lib/signals/reply.js +368 -0
- package/dist/lib/signals/run-lock.d.ts +36 -0
- package/dist/lib/signals/run-lock.js +148 -0
- package/dist/lib/signals/run-record.d.ts +24 -0
- package/dist/lib/signals/run-record.js +82 -0
- package/dist/lib/signals/sanitize.d.ts +105 -0
- package/dist/lib/signals/sanitize.js +439 -0
- package/dist/lib/signals/settings.d.ts +10 -0
- package/dist/lib/signals/settings.js +48 -0
- package/dist/lib/signals/steps/adapters.d.ts +14 -0
- package/dist/lib/signals/steps/adapters.js +156 -0
- package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
- package/dist/lib/signals/steps/entity-similarity.js +47 -0
- package/dist/lib/signals/steps/extract.d.ts +118 -0
- package/dist/lib/signals/steps/extract.js +793 -0
- package/dist/lib/signals/steps/garden.d.ts +24 -0
- package/dist/lib/signals/steps/garden.js +250 -0
- package/dist/lib/signals/steps/types.d.ts +117 -0
- package/dist/lib/signals/steps/types.js +4 -0
- package/dist/lib/signals/steps/upload.d.ts +43 -0
- package/dist/lib/signals/steps/upload.js +324 -0
- package/dist/lib/signals/write-guard.d.ts +63 -0
- package/dist/lib/signals/write-guard.js +287 -0
- package/dist/utils/resident-package-preload.js +16 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +9 -9
- package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
- package/dist/lib/bot/fleet-default-flag.js +0 -127
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator for `hq signals run`. Callable programmatically via
|
|
3
|
+
* runSignals(); clock, spawn and HQ root are injected for tests.
|
|
4
|
+
*
|
|
5
|
+
* Step order: garden -> adapters -> extract -> garden -> upload.
|
|
6
|
+
* Records (run record contract v2): workspace/signals/runs/{date}/{company}.json
|
|
7
|
+
* is the source of truth for one company's day; summary.json in the same
|
|
8
|
+
* folder is read-modify-written per company and never drops other companies.
|
|
9
|
+
* Each company runs under workspace/signals/runs/{date}/{company}.lock.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import yaml from "js-yaml";
|
|
15
|
+
import { loadSignalsSettings } from "./settings.js";
|
|
16
|
+
import { localDate } from "./local-date.js";
|
|
17
|
+
import { guardedWrite, WriteGuardError } from "./write-guard.js";
|
|
18
|
+
import { acquireCompanyLock, withSummaryLock } from "./run-lock.js";
|
|
19
|
+
import { gardenStep } from "./steps/garden.js";
|
|
20
|
+
import { adaptersStep } from "./steps/adapters.js";
|
|
21
|
+
import { extractStep, redoItemChannel } from "./steps/extract.js";
|
|
22
|
+
import { uploadStep } from "./steps/upload.js";
|
|
23
|
+
import { raiseSignalsRunAlerts } from "./failure-alert.js";
|
|
24
|
+
export const SIGNALS_TRIGGERS = ["job", "chat", "manual", "brief"];
|
|
25
|
+
export const SIGNALS_STEP_CHOICES = ["garden", "extract", "adapters", "upload", "all"];
|
|
26
|
+
export const STEP_ORDER = ["garden", "adapters", "extract", "garden", "upload"];
|
|
27
|
+
export const RUN_RECORD_SCHEMA_VERSION = 2;
|
|
28
|
+
/** A failed run with a transient error is due for a retry this long after it finished. */
|
|
29
|
+
export const RETRY_DELAY_MS = 15 * 60 * 1000;
|
|
30
|
+
/** Bad input from the caller (company slug, missing company): exit 2. */
|
|
31
|
+
export class SignalsUsageError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "SignalsUsageError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Broken local setup (missing or failing knowledge-prefs.sh): errorKind config. */
|
|
38
|
+
export class SignalsConfigError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "SignalsConfigError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const defaultSpawn = (cmd, args, opts) => {
|
|
45
|
+
const r = spawnSync(cmd, args, {
|
|
46
|
+
cwd: opts.cwd,
|
|
47
|
+
env: opts.env ?? process.env,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
50
|
+
...(opts.timeoutMs !== undefined ? { timeout: opts.timeoutMs, killSignal: "SIGKILL" } : {}),
|
|
51
|
+
});
|
|
52
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
|
|
53
|
+
};
|
|
54
|
+
const DEFAULT_STEPS = {
|
|
55
|
+
garden: gardenStep,
|
|
56
|
+
adapters: adaptersStep,
|
|
57
|
+
extract: extractStep,
|
|
58
|
+
upload: uploadStep,
|
|
59
|
+
};
|
|
60
|
+
// Lives in its own module so steps can use it without importing the engine.
|
|
61
|
+
export { localDate };
|
|
62
|
+
export function runRecordRel(date, company) {
|
|
63
|
+
return `workspace/signals/runs/${date}/${company}.json`;
|
|
64
|
+
}
|
|
65
|
+
export function summaryRel(date) {
|
|
66
|
+
return `workspace/signals/runs/${date}/summary.json`;
|
|
67
|
+
}
|
|
68
|
+
const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
69
|
+
/** Companies under companies/ that are also listed in companies/manifest.yaml. */
|
|
70
|
+
export function listCompanies(hqRoot) {
|
|
71
|
+
let manifest;
|
|
72
|
+
try {
|
|
73
|
+
manifest = yaml.load(fs.readFileSync(path.join(hqRoot, "companies", "manifest.yaml"), "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
process.stderr.write(`hq signals: cannot read companies/manifest.yaml: ${msg}\n`);
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const listed = manifest?.companies ?? {};
|
|
81
|
+
return Object.keys(listed)
|
|
82
|
+
.filter((co) => SLUG_RE.test(co) && fs.existsSync(path.join(hqRoot, "companies", co)))
|
|
83
|
+
.sort();
|
|
84
|
+
}
|
|
85
|
+
const TRUE_VALUES = new Set(["true", "yes", "on", "1"]);
|
|
86
|
+
/** Reads one knowledge preference. Throws SignalsConfigError when the script is missing or fails. */
|
|
87
|
+
export function readPref(hqRoot, company, field, spawn) {
|
|
88
|
+
const script = path.join(hqRoot, "core", "scripts", "knowledge-prefs.sh");
|
|
89
|
+
if (!fs.existsSync(script)) {
|
|
90
|
+
throw new SignalsConfigError("core/scripts/knowledge-prefs.sh is missing, so the capture settings could not be read");
|
|
91
|
+
}
|
|
92
|
+
const r = spawn("bash", [script, "get", company, field], {
|
|
93
|
+
cwd: hqRoot,
|
|
94
|
+
env: { ...process.env, HQ_ROOT: hqRoot },
|
|
95
|
+
});
|
|
96
|
+
if (r.error || r.status !== 0) {
|
|
97
|
+
const detail = (r.error?.message ?? r.stderr.trim()) || `exit ${String(r.status)}`;
|
|
98
|
+
throw new SignalsConfigError(`knowledge-prefs.sh could not read ${field}: ${detail.slice(0, 200)}`);
|
|
99
|
+
}
|
|
100
|
+
return TRUE_VALUES.has(r.stdout.trim().toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
function readJson(hqRoot, rel) {
|
|
103
|
+
const abs = path.join(hqRoot, rel);
|
|
104
|
+
if (!fs.existsSync(abs))
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
process.stderr.write(`hq signals: ignoring unreadable ${rel}: ${msg}\n`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function baseStatus(steps) {
|
|
116
|
+
if (steps.some((s) => s.status === "failed")) {
|
|
117
|
+
return steps.some((s) => s.status === "ok" || s.status === "partial") ? "partial" : "failed";
|
|
118
|
+
}
|
|
119
|
+
if (steps.some((s) => s.status === "partial"))
|
|
120
|
+
return "partial";
|
|
121
|
+
if (steps.length > 0 && steps.every((s) => s.status === "skipped"))
|
|
122
|
+
return "skipped";
|
|
123
|
+
return "complete";
|
|
124
|
+
}
|
|
125
|
+
const KIND_RANK = { auth: 3, config: 2, transient: 1 };
|
|
126
|
+
/** Coarse v2 kind for a step's detailed kind. */
|
|
127
|
+
export function toRunErrorKind(kind) {
|
|
128
|
+
if (kind === "reconnect" || kind === "signed-out")
|
|
129
|
+
return "auth";
|
|
130
|
+
if (kind === "config")
|
|
131
|
+
return "config";
|
|
132
|
+
return "transient";
|
|
133
|
+
}
|
|
134
|
+
function stepErrorKindFor(err) {
|
|
135
|
+
return err instanceof SignalsConfigError || err instanceof WriteGuardError ? "config" : "other";
|
|
136
|
+
}
|
|
137
|
+
function sentence(text) {
|
|
138
|
+
const t = text.trim().replace(/[.\s]+$/, "");
|
|
139
|
+
return `${t}.`;
|
|
140
|
+
}
|
|
141
|
+
/** Fills the v2 outcome fields of a finished record from its steps. */
|
|
142
|
+
export function finalizeRecord(record, input) {
|
|
143
|
+
record.currentStep = null;
|
|
144
|
+
record.finishedAt = input.finishedAt.toISOString();
|
|
145
|
+
const problems = record.steps.filter((s) => s.status === "failed" || s.status === "partial");
|
|
146
|
+
// A skip decided before any step (prefs-off, late-start with nothing to run) stays a skip.
|
|
147
|
+
let status = record.skipReason ? "skipped" : baseStatus(record.steps);
|
|
148
|
+
let partialReason = null;
|
|
149
|
+
let failureReason = null;
|
|
150
|
+
if (input.lateStart && status !== "failed" && status !== "skipped") {
|
|
151
|
+
status = "partial";
|
|
152
|
+
partialReason = "late-start";
|
|
153
|
+
failureReason = `The run started after ${String(input.lateStartHour).padStart(2, "0")}:00, so only the garden step ran.`;
|
|
154
|
+
}
|
|
155
|
+
else if (status === "partial" || status === "failed") {
|
|
156
|
+
const first = problems.find((s) => s.status === "failed") ?? problems[0];
|
|
157
|
+
if (first) {
|
|
158
|
+
if (status === "partial")
|
|
159
|
+
partialReason = `step-${first.status === "failed" ? "failed" : "partial"}:${first.name}`;
|
|
160
|
+
failureReason =
|
|
161
|
+
first.status === "failed"
|
|
162
|
+
? sentence(`The ${first.name} step failed${first.error ? `: ${first.error}` : ""}`)
|
|
163
|
+
: sentence(`The ${first.name} step finished with problems${first.error ? `: ${first.error}` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let errorKind = null;
|
|
167
|
+
if (status === "partial" || status === "failed") {
|
|
168
|
+
errorKind = "transient";
|
|
169
|
+
for (const s of problems) {
|
|
170
|
+
const k = toRunErrorKind(s.errorKind);
|
|
171
|
+
if (KIND_RANK[k] > KIND_RANK[errorKind])
|
|
172
|
+
errorKind = k;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
record.status = status;
|
|
176
|
+
record.partialReason = partialReason;
|
|
177
|
+
record.partial = partialReason;
|
|
178
|
+
record.failureReason = failureReason;
|
|
179
|
+
record.errorKind = errorKind;
|
|
180
|
+
record.nextRetryAt =
|
|
181
|
+
status === "failed" && errorKind === "transient"
|
|
182
|
+
? new Date(input.finishedAt.getTime() + RETRY_DELAY_MS).toISOString()
|
|
183
|
+
: null;
|
|
184
|
+
if (status === "skipped") {
|
|
185
|
+
record.skipReason = record.skipReason ?? record.steps.find((s) => s.skippedReason)?.skippedReason ?? "nothing-to-run";
|
|
186
|
+
record.skippedReason = record.skipReason;
|
|
187
|
+
}
|
|
188
|
+
if (record.trigger === "job" && (status === "complete" || status === "partial")) {
|
|
189
|
+
record.jobRanAt = record.finishedAt;
|
|
190
|
+
}
|
|
191
|
+
record.counts = {
|
|
192
|
+
sourcesIn: record.steps.filter((s) => s.name === "adapters").reduce((n, s) => n + s.itemsOut, 0),
|
|
193
|
+
signalsOut: record.steps.filter((s) => s.name === "extract").reduce((n, s) => n + s.itemsOut, 0),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function summaryEntry(r) {
|
|
197
|
+
const sum = (f) => r.steps.reduce((n, s) => n + f(s), 0);
|
|
198
|
+
return {
|
|
199
|
+
company: r.company,
|
|
200
|
+
status: r.status,
|
|
201
|
+
trigger: r.trigger,
|
|
202
|
+
catchUp: r.catchUp === true,
|
|
203
|
+
finishedAt: r.finishedAt,
|
|
204
|
+
...(r.skipReason ? { skipReason: r.skipReason, skippedReason: r.skipReason } : {}),
|
|
205
|
+
itemsIn: sum((s) => s.itemsIn),
|
|
206
|
+
itemsOut: sum((s) => s.itemsOut),
|
|
207
|
+
estimatedSpendUsd: sum((s) => s.estimatedSpendUsd ?? 0),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function buildSummary(date, trigger, entries, finishedAt, dryRun) {
|
|
211
|
+
const totals = { itemsIn: 0, itemsOut: 0, estimatedSpendUsd: 0 };
|
|
212
|
+
for (const e of entries) {
|
|
213
|
+
totals.itemsIn += e.itemsIn;
|
|
214
|
+
totals.itemsOut += e.itemsOut;
|
|
215
|
+
totals.estimatedSpendUsd += e.estimatedSpendUsd;
|
|
216
|
+
}
|
|
217
|
+
const statuses = entries.map((e) => e.status);
|
|
218
|
+
const status = dryRun
|
|
219
|
+
? "dry-run"
|
|
220
|
+
: statuses.includes("failed")
|
|
221
|
+
? "failed"
|
|
222
|
+
: statuses.includes("partial")
|
|
223
|
+
? "partial"
|
|
224
|
+
: statuses.includes("running")
|
|
225
|
+
? "running"
|
|
226
|
+
: statuses.length > 0 && statuses.every((s) => s === "skipped")
|
|
227
|
+
? "skipped"
|
|
228
|
+
: "complete";
|
|
229
|
+
return { schemaVersion: RUN_RECORD_SCHEMA_VERSION, date, trigger, companies: entries, totals, status, finishedAt };
|
|
230
|
+
}
|
|
231
|
+
/** Replaces this company's entry in summary.json, keeping every other company's. */
|
|
232
|
+
function updateSummaryFile(hqRoot, date, record, finishedAt) {
|
|
233
|
+
withSummaryLock(hqRoot, date, () => {
|
|
234
|
+
const existing = readJson(hqRoot, summaryRel(date));
|
|
235
|
+
const others = (Array.isArray(existing?.companies) ? existing.companies : []).filter((e) => typeof e === "object" && e !== null && typeof e.company === "string" && e.company !== record.company);
|
|
236
|
+
const entries = [...others, summaryEntry(record)].sort((a, b) => a.company.localeCompare(b.company));
|
|
237
|
+
const summary = buildSummary(date, record.trigger, entries, finishedAt, false);
|
|
238
|
+
guardedWrite(hqRoot, summaryRel(date), `${JSON.stringify(summary, null, 2)}\n`);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
export async function runSignals(opts) {
|
|
242
|
+
const hqRoot = path.resolve(opts.hqRoot);
|
|
243
|
+
const now = opts.now ?? (() => new Date());
|
|
244
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
245
|
+
const trigger = opts.trigger ?? "manual";
|
|
246
|
+
const stepChoice = opts.step ?? "all";
|
|
247
|
+
const dryRun = opts.dryRun === true;
|
|
248
|
+
const settings = loadSignalsSettings(hqRoot);
|
|
249
|
+
const maxTurns = opts.maxTurns ?? settings.maxTurns;
|
|
250
|
+
const maxBudgetUsd = opts.maxBudgetUsd ?? settings.maxBudgetUsd;
|
|
251
|
+
const stepImpls = { ...DEFAULT_STEPS, ...(opts.steps ?? {}) };
|
|
252
|
+
if (opts.company !== undefined && !SLUG_RE.test(opts.company)) {
|
|
253
|
+
throw new SignalsUsageError(`invalid company slug: ${opts.company}`);
|
|
254
|
+
}
|
|
255
|
+
if (opts.company !== undefined && !fs.existsSync(path.join(hqRoot, "companies", opts.company))) {
|
|
256
|
+
throw new SignalsUsageError(`no company directory: companies/${opts.company}`);
|
|
257
|
+
}
|
|
258
|
+
const redo = opts.redo ?? [];
|
|
259
|
+
if (redo.length > 0) {
|
|
260
|
+
if (opts.company === undefined)
|
|
261
|
+
throw new SignalsUsageError("--redo needs --company");
|
|
262
|
+
const bad = redo.find((f) => redoItemChannel(f) === null);
|
|
263
|
+
if (bad !== undefined)
|
|
264
|
+
throw new SignalsUsageError(`--redo item must be sources/<channel>/<file>.md: ${bad}`);
|
|
265
|
+
}
|
|
266
|
+
const companies = opts.company !== undefined ? [opts.company] : listCompanies(hqRoot);
|
|
267
|
+
const start = now();
|
|
268
|
+
const date = localDate(start);
|
|
269
|
+
const lateStart = trigger === "job" && start.getHours() >= settings.lateStartHour;
|
|
270
|
+
let plan = stepChoice === "all" ? STEP_ORDER : STEP_ORDER.filter((s) => s === stepChoice);
|
|
271
|
+
if (stepChoice !== "all")
|
|
272
|
+
plan = plan.slice(0, 1);
|
|
273
|
+
if (lateStart)
|
|
274
|
+
plan = plan.filter((s) => s === "garden").slice(0, 1);
|
|
275
|
+
const records = [];
|
|
276
|
+
const locked = [];
|
|
277
|
+
for (const company of companies) {
|
|
278
|
+
const rel = runRecordRel(date, company);
|
|
279
|
+
// One company scope per run: every write for this company is held to
|
|
280
|
+
// companies/{company}/ (plus the workspace and personal signals roots), and
|
|
281
|
+
// a link that resolves into another company's folder is refused.
|
|
282
|
+
const write = (r, data) => guardedWrite(hqRoot, r, data, { company });
|
|
283
|
+
// 1. Take the company lock (a dry run writes nothing, so it takes none).
|
|
284
|
+
let release = null;
|
|
285
|
+
let takeover = null;
|
|
286
|
+
if (!dryRun) {
|
|
287
|
+
const got = acquireCompanyLock({ hqRoot, date, company, trigger, now, ...(opts.isPidAlive ? { isPidAlive: opts.isPidAlive } : {}) });
|
|
288
|
+
if (!got.ok) {
|
|
289
|
+
process.stderr.write(`hq signals: ${company}: another run (${got.holder.trigger}, started ${got.holder.startedAt}) holds the lock; nothing started\n`);
|
|
290
|
+
locked.push({ company, status: "running", startedAt: got.holder.startedAt, trigger: got.holder.trigger });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
release = got.release;
|
|
294
|
+
takeover = got.takeover;
|
|
295
|
+
}
|
|
296
|
+
const record = {
|
|
297
|
+
schemaVersion: RUN_RECORD_SCHEMA_VERSION,
|
|
298
|
+
company,
|
|
299
|
+
startedAt: now().toISOString(),
|
|
300
|
+
finishedAt: null,
|
|
301
|
+
trigger,
|
|
302
|
+
pid: process.pid,
|
|
303
|
+
catchUp: opts.catchUp === true,
|
|
304
|
+
dryRun,
|
|
305
|
+
status: "running",
|
|
306
|
+
skipReason: null,
|
|
307
|
+
currentStep: null,
|
|
308
|
+
steps: [],
|
|
309
|
+
maxBudgetUsd,
|
|
310
|
+
maxTurns,
|
|
311
|
+
jobRanAt: null,
|
|
312
|
+
nextRetryAt: null,
|
|
313
|
+
errorKind: null,
|
|
314
|
+
failureReason: null,
|
|
315
|
+
partialReason: null,
|
|
316
|
+
partial: null,
|
|
317
|
+
alreadyRanToday: false,
|
|
318
|
+
counts: { sourcesIn: 0, signalsOut: 0 },
|
|
319
|
+
...(takeover ? { lockTakeover: takeover } : {}),
|
|
320
|
+
};
|
|
321
|
+
const persist = () => {
|
|
322
|
+
if (!dryRun)
|
|
323
|
+
write(rel, `${JSON.stringify(record, null, 2)}\n`);
|
|
324
|
+
};
|
|
325
|
+
const finish = () => {
|
|
326
|
+
const decidedSkip = record.skipReason;
|
|
327
|
+
finalizeRecord(record, { lateStart, lateStartHour: settings.lateStartHour, finishedAt: now() });
|
|
328
|
+
if (dryRun) {
|
|
329
|
+
if (!decidedSkip) {
|
|
330
|
+
record.status = "dry-run";
|
|
331
|
+
record.skipReason = null;
|
|
332
|
+
delete record.skippedReason;
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
persist();
|
|
337
|
+
updateSummaryFile(hqRoot, date, record, record.finishedAt ?? now().toISOString());
|
|
338
|
+
};
|
|
339
|
+
try {
|
|
340
|
+
if (takeover) {
|
|
341
|
+
process.stderr.write(`hq signals: ${company}: took over a stale lock (${takeover.reason}) from pid ${takeover.pid}, started ${takeover.startedAt}\n`);
|
|
342
|
+
}
|
|
343
|
+
// 2. The day's job already ran: do nothing and leave the record as it is.
|
|
344
|
+
const prior = readJson(hqRoot, rel);
|
|
345
|
+
const priorJobRanAt = typeof prior?.jobRanAt === "string" ? prior.jobRanAt : null;
|
|
346
|
+
if (trigger === "job" && !opts.force && prior && priorJobRanAt) {
|
|
347
|
+
process.stderr.write(`hq signals: ${company}: already-ran (the job ran at ${priorJobRanAt})\n`);
|
|
348
|
+
records.push({ ...prior, alreadyRanToday: true });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
record.jobRanAt = priorJobRanAt;
|
|
352
|
+
// 3. Mark the run as running before any step.
|
|
353
|
+
persist();
|
|
354
|
+
let signalsOn;
|
|
355
|
+
let ontologyOn;
|
|
356
|
+
try {
|
|
357
|
+
signalsOn = readPref(hqRoot, company, "signals_capture", spawn);
|
|
358
|
+
ontologyOn = readPref(hqRoot, company, "ontology_capture", spawn);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
if (!(err instanceof SignalsConfigError))
|
|
362
|
+
throw err;
|
|
363
|
+
record.currentStep = null;
|
|
364
|
+
record.finishedAt = now().toISOString();
|
|
365
|
+
record.status = dryRun ? "dry-run" : "failed";
|
|
366
|
+
record.errorKind = "config";
|
|
367
|
+
record.failureReason = sentence(err.message.charAt(0).toUpperCase() + err.message.slice(1));
|
|
368
|
+
record.nextRetryAt = null;
|
|
369
|
+
if (!dryRun) {
|
|
370
|
+
persist();
|
|
371
|
+
updateSummaryFile(hqRoot, date, record, record.finishedAt ?? now().toISOString());
|
|
372
|
+
}
|
|
373
|
+
records.push(record);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!signalsOn && !ontologyOn) {
|
|
377
|
+
record.skipReason = "prefs-off";
|
|
378
|
+
finish();
|
|
379
|
+
records.push(record);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (plan.length === 0) {
|
|
383
|
+
record.skipReason = lateStart ? "late-start" : "nothing-to-run";
|
|
384
|
+
finish();
|
|
385
|
+
records.push(record);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// 4. Run the steps.
|
|
389
|
+
for (const name of plan) {
|
|
390
|
+
record.currentStep = name;
|
|
391
|
+
persist();
|
|
392
|
+
const t0 = now().getTime();
|
|
393
|
+
const ctx = {
|
|
394
|
+
hqRoot,
|
|
395
|
+
company,
|
|
396
|
+
dryRun,
|
|
397
|
+
trigger,
|
|
398
|
+
catchUp: record.catchUp,
|
|
399
|
+
maxTurns,
|
|
400
|
+
maxBudgetUsd,
|
|
401
|
+
now,
|
|
402
|
+
spawn,
|
|
403
|
+
write,
|
|
404
|
+
...(opts.hotChannelSync ? { hotChannelSync: opts.hotChannelSync } : {}),
|
|
405
|
+
...(opts.uploadDeps ? { uploadDeps: opts.uploadDeps } : {}),
|
|
406
|
+
...(opts.isFlagOn ? { isFlagOn: opts.isFlagOn } : {}),
|
|
407
|
+
...(opts.registeredAdapterIds ? { registeredAdapterIds: opts.registeredAdapterIds } : {}),
|
|
408
|
+
...(redo.length > 0 ? { redo } : {}),
|
|
409
|
+
};
|
|
410
|
+
let stepRec;
|
|
411
|
+
const planner = stepImpls[name].dryRun;
|
|
412
|
+
if (dryRun && planner) {
|
|
413
|
+
// A step with a read-only planner reports what it would do.
|
|
414
|
+
try {
|
|
415
|
+
const res = await planner.call(stepImpls[name], ctx);
|
|
416
|
+
const { errorKind: _ignored, ...rest } = res;
|
|
417
|
+
stepRec = { name, ...rest, status: "skipped", skippedReason: "dry-run", durationMs: 0 };
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
421
|
+
stepRec = {
|
|
422
|
+
name,
|
|
423
|
+
itemsIn: 0,
|
|
424
|
+
itemsOut: 0,
|
|
425
|
+
durationMs: 0,
|
|
426
|
+
status: "skipped",
|
|
427
|
+
skippedReason: "dry-run",
|
|
428
|
+
plan: [`plan failed: ${msg.slice(0, 200)}`],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (dryRun) {
|
|
433
|
+
stepRec = { name, itemsIn: 0, itemsOut: 0, durationMs: 0, status: "skipped", skippedReason: "dry-run" };
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
try {
|
|
437
|
+
const res = await stepImpls[name].run(ctx);
|
|
438
|
+
stepRec = { name, ...res, durationMs: Math.max(0, now().getTime() - t0) };
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
442
|
+
stepRec = {
|
|
443
|
+
name,
|
|
444
|
+
itemsIn: 0,
|
|
445
|
+
itemsOut: 0,
|
|
446
|
+
durationMs: Math.max(0, now().getTime() - t0),
|
|
447
|
+
status: "failed",
|
|
448
|
+
error: msg.slice(0, 500),
|
|
449
|
+
errorKind: stepErrorKindFor(err),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
record.steps.push(stepRec);
|
|
454
|
+
persist();
|
|
455
|
+
}
|
|
456
|
+
// 5. Final record and summary entry.
|
|
457
|
+
finish();
|
|
458
|
+
records.push(record);
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
// Anything outside a step (a refused write, say): record the failure
|
|
462
|
+
// instead of leaving a "running" record behind.
|
|
463
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
464
|
+
record.currentStep = null;
|
|
465
|
+
record.finishedAt = now().toISOString();
|
|
466
|
+
record.status = dryRun ? "dry-run" : "failed";
|
|
467
|
+
record.errorKind = err instanceof WriteGuardError ? "config" : "transient";
|
|
468
|
+
record.failureReason = sentence(`The run stopped: ${msg.slice(0, 300)}`);
|
|
469
|
+
record.nextRetryAt =
|
|
470
|
+
record.errorKind === "transient" ? new Date(new Date(record.finishedAt).getTime() + RETRY_DELAY_MS).toISOString() : null;
|
|
471
|
+
try {
|
|
472
|
+
persist();
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// the failure is already reported on stderr below
|
|
476
|
+
}
|
|
477
|
+
process.stderr.write(`hq signals: ${company}: ${msg}\n`);
|
|
478
|
+
records.push(record);
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
release?.();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const summary = buildSummary(date, trigger, records.map(summaryEntry), now().toISOString(), dryRun);
|
|
485
|
+
if (!dryRun && opts.signalsAlert) {
|
|
486
|
+
// The signals-failure alert (US-018). Already-ran and dry-run records are never judged.
|
|
487
|
+
const alerts = await raiseSignalsRunAlerts({ hqRoot, records, senders: opts.signalsAlert, now });
|
|
488
|
+
return { date, records, locked, summary, alerts };
|
|
489
|
+
}
|
|
490
|
+
return { date, records, locked, summary };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Exit code for `hq signals run`: 1 when a company failed (a job that already
|
|
494
|
+
* ran today does not count), else 3 when another run held a company's lock,
|
|
495
|
+
* else 0 (complete, partial, skipped, already ran).
|
|
496
|
+
*/
|
|
497
|
+
export function signalsExitCode(result) {
|
|
498
|
+
if (result.records.some((r) => !r.alreadyRanToday && r.status === "failed"))
|
|
499
|
+
return 1;
|
|
500
|
+
if (result.locked.length > 0)
|
|
501
|
+
return 3;
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signals-failure alert for the `hq signals run` engine (Hot-Signal Sync
|
|
3
|
+
* US-018).
|
|
4
|
+
*
|
|
5
|
+
* The engine's run record already counts what each step took in and put out.
|
|
6
|
+
* A run whose extract step read sources and produced no candidates is a
|
|
7
|
+
* silent stall, the shape of the 2026-10-14 stall. Only sources extract reads
|
|
8
|
+
* count: hot-channel landings (Slack, Linear, Gmail) that no declared source
|
|
9
|
+
* channel covers never reach extract, so they cannot show a stall. This module
|
|
10
|
+
* checks the run record for that shape and hands the failure to the existing
|
|
11
|
+
* ledger alert path (`raiseSignalsFailureAlerts` in
|
|
12
|
+
* `src/lib/hot-channels/alert.ts`), so the alert text, the owner DM and the
|
|
13
|
+
* hourly dedupe in `sources/_runs/@{personKey}/_alerts.json` are the same ones
|
|
14
|
+
* the hot-channel sync uses. No new posting path is added here.
|
|
15
|
+
*/
|
|
16
|
+
import { type RaiseSignalsFailureAlertsResult } from "../hot-channels/alert.js";
|
|
17
|
+
import type { RunLedgerEntry } from "../hot-channels/ledger.js";
|
|
18
|
+
import type { RunRecord, StepRecord } from "./engine.js";
|
|
19
|
+
/**
|
|
20
|
+
* Extract skips that are not a stall: no source channel to read, or the
|
|
21
|
+
* ontology worker is not installed. Both are reported in the run record
|
|
22
|
+
* already, and an hourly alert would only repeat them.
|
|
23
|
+
*/
|
|
24
|
+
export declare const EXTRACT_SKIPS_WITHOUT_ALERT: ReadonlySet<string>;
|
|
25
|
+
/** The adapter name the engine's alert uses in the shared dedupe file: one key per company vault. */
|
|
26
|
+
export declare const SIGNALS_RUN_ALERT_ADAPTER = "signals-run";
|
|
27
|
+
/** Who the alert is for, and the key of their dedupe file. */
|
|
28
|
+
export interface SignalsAlertPerson {
|
|
29
|
+
/** The audience key of the person's `sources/_runs/@{personKey}/` folder. */
|
|
30
|
+
personKey: string;
|
|
31
|
+
/** Shown in the alert text; usually the person's email. */
|
|
32
|
+
personName?: string;
|
|
33
|
+
/** The Cognito sub, recorded on the ledger line the alert is built from. */
|
|
34
|
+
personUid?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Where the engine sends a signals-failure alert. */
|
|
37
|
+
export interface SignalsRunAlertSenders {
|
|
38
|
+
/** The primary alert to the owner; throws when it was not delivered. */
|
|
39
|
+
sendOwnerDm: (text: string) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* The person whose dedupe file is used. Defaults to the signed-in person
|
|
42
|
+
* (the same key `hq sources sync` uses). Null skips the alert.
|
|
43
|
+
*/
|
|
44
|
+
resolvePerson?: (company: string) => SignalsAlertPerson | null | Promise<SignalsAlertPerson | null>;
|
|
45
|
+
log?: (message: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* False for a sender no one reads later, such as the terminal of a manual
|
|
48
|
+
* `hq signals run`. Its alert neither reads nor writes the hourly dedupe, so
|
|
49
|
+
* it never suppresses the owner DM the bot sends for the same company.
|
|
50
|
+
* Default true.
|
|
51
|
+
*/
|
|
52
|
+
recordsDelivery?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** The signed-in person, as `runCompanyHotChannelSync` resolves them; null when no one is signed in. */
|
|
55
|
+
export declare function resolveSignedInAlertPerson(): SignalsAlertPerson | null;
|
|
56
|
+
/** Source items the extract step read: its worklist items minus the ones the worklist skipped. */
|
|
57
|
+
export declare function extractSourcesRead(extract: StepRecord): number;
|
|
58
|
+
/**
|
|
59
|
+
* True when a run's sources produced no signals. The run must be real (not a
|
|
60
|
+
* dry run, not a repeat of an earlier job run) and have both an adapters and an
|
|
61
|
+
* extract step (one-step runs and late starts are not judged), and extract
|
|
62
|
+
* must have produced no candidates. Then either:
|
|
63
|
+
* - extract ran and read at least one source item, or
|
|
64
|
+
* - extract did not finish (failed, or skipped for a reason other than
|
|
65
|
+
* `no-sources` / `ontology-worker-missing`) after the adapters step landed
|
|
66
|
+
* new sources.
|
|
67
|
+
* Hot-channel landings extract never reads do not count on their own, so a
|
|
68
|
+
* day with Slack activity and no declared-source items is not a stall.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isSignalsRunFailure(record: RunRecord): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The run record as one ledger line, so the existing alert path formats,
|
|
73
|
+
* sends and dedupes it. `sourcesLanded` is the source items extract read (or,
|
|
74
|
+
* when extract read none, what the adapters step landed) and
|
|
75
|
+
* `signalsGenerated` is what extract produced.
|
|
76
|
+
*/
|
|
77
|
+
export declare function signalsRunLedgerLine(record: RunRecord, person: SignalsAlertPerson): RunLedgerEntry;
|
|
78
|
+
/**
|
|
79
|
+
* Alert the owner for each run record that landed sources and produced no
|
|
80
|
+
* signals. Never throws; a failed send is logged and left unrecorded, so the
|
|
81
|
+
* next run tries again. Returns the alert result per company that alerted.
|
|
82
|
+
*/
|
|
83
|
+
export declare function raiseSignalsRunAlerts(input: {
|
|
84
|
+
hqRoot: string;
|
|
85
|
+
records: readonly RunRecord[];
|
|
86
|
+
senders: SignalsRunAlertSenders;
|
|
87
|
+
now: () => Date;
|
|
88
|
+
}): Promise<Record<string, RaiseSignalsFailureAlertsResult>>;
|
|
89
|
+
//# sourceMappingURL=failure-alert.d.ts.map
|