@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,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storyboarded bot replies for the signals job
|
|
3
|
+
* (companies/indigo/projects/hot-signal-sync/design/bot-replies.md).
|
|
4
|
+
*
|
|
5
|
+
* Every reply is plain prose followed by ONE `hq-block` fence whose JSON is on
|
|
6
|
+
* one line: `{"v":1,"blocks":[...]}`. Block kinds, tones and icons come from
|
|
7
|
+
* closed enums. Values come only from the run record; a value the record does
|
|
8
|
+
* not carry drops its row. Pure functions: no clock, no I/O.
|
|
9
|
+
*/
|
|
10
|
+
export const REPLY_BLOCK_KINDS = ["keyValue", "callout", "badge", "progress", "decision"];
|
|
11
|
+
export const REPLY_TONES = ["neutral", "info", "success", "warning", "danger"];
|
|
12
|
+
export const REPLY_ICONS = ["check", "clock", "folder", "calendar"];
|
|
13
|
+
/** One fence, JSON on one line, not indented. */
|
|
14
|
+
export function hqBlockFence(blocks) {
|
|
15
|
+
return "```hq-block\n" + JSON.stringify({ v: 1, blocks }) + "\n```";
|
|
16
|
+
}
|
|
17
|
+
function build(prose, blocks, variant) {
|
|
18
|
+
return { text: `${prose}\n\n${hqBlockFence(blocks)}`, prose, blocks, variant };
|
|
19
|
+
}
|
|
20
|
+
/** "6:02 AM" in the given zone. */
|
|
21
|
+
export function timeInWords(iso, timeZone) {
|
|
22
|
+
if (!iso)
|
|
23
|
+
return null;
|
|
24
|
+
const d = new Date(iso);
|
|
25
|
+
if (Number.isNaN(d.getTime()))
|
|
26
|
+
return null;
|
|
27
|
+
return new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit", hour12: true })
|
|
28
|
+
.format(d)
|
|
29
|
+
.replace(/\u202f/g, " ");
|
|
30
|
+
}
|
|
31
|
+
/** "Mountain Time" from America/Denver: the long generic zone name, without "Standard"/"Daylight". */
|
|
32
|
+
export function timezoneInWords(timeZone, at = new Date()) {
|
|
33
|
+
const name = (style) => {
|
|
34
|
+
try {
|
|
35
|
+
const part = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: style })
|
|
36
|
+
.formatToParts(at)
|
|
37
|
+
.find((p) => p.type === "timeZoneName");
|
|
38
|
+
return part?.value ?? null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const generic = name("longGeneric");
|
|
45
|
+
if (generic && !/^GMT/.test(generic))
|
|
46
|
+
return generic;
|
|
47
|
+
const long = name("long");
|
|
48
|
+
if (long && !/^GMT/.test(long))
|
|
49
|
+
return long.replace(/ (Standard|Daylight) /, " ");
|
|
50
|
+
return timeZone;
|
|
51
|
+
}
|
|
52
|
+
function titleCase(slug) {
|
|
53
|
+
return slug
|
|
54
|
+
.split(/[-_]/)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
57
|
+
.join(" ");
|
|
58
|
+
}
|
|
59
|
+
const ADAPTER_LABELS = {
|
|
60
|
+
meetings: "meetings",
|
|
61
|
+
granola: "meetings",
|
|
62
|
+
gmail: "email",
|
|
63
|
+
email: "email",
|
|
64
|
+
slack: "Slack",
|
|
65
|
+
linear: "Linear",
|
|
66
|
+
notion: "Notion",
|
|
67
|
+
};
|
|
68
|
+
const STEP_DOING = {
|
|
69
|
+
garden: "Tidying candidates",
|
|
70
|
+
adapters: "Reading sources",
|
|
71
|
+
extract: "Finding signals",
|
|
72
|
+
upload: "Uploading",
|
|
73
|
+
};
|
|
74
|
+
const STEP_NOUN = {
|
|
75
|
+
garden: "candidate tidying",
|
|
76
|
+
adapters: "source reading",
|
|
77
|
+
extract: "signals",
|
|
78
|
+
upload: "upload",
|
|
79
|
+
};
|
|
80
|
+
/** The engine's step order (engine.ts STEP_ORDER), for the "Not run" row. */
|
|
81
|
+
const STEP_ORDER_NAMES = ["garden", "adapters", "extract", "garden", "upload"];
|
|
82
|
+
function steps(record, name) {
|
|
83
|
+
return record.steps.filter((s) => s.name === name);
|
|
84
|
+
}
|
|
85
|
+
function companiesValue(summary, opts) {
|
|
86
|
+
const names = summary.companies
|
|
87
|
+
.filter((c) => c.status !== "skipped")
|
|
88
|
+
.map((c) => opts.companyNames?.[c.company] ?? titleCase(c.company));
|
|
89
|
+
return names.length ? names.join(", ") : null;
|
|
90
|
+
}
|
|
91
|
+
function plural(n, one, many = `${one}s`) {
|
|
92
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
93
|
+
}
|
|
94
|
+
/** The four keyValue rows, dropping any the record does not carry. */
|
|
95
|
+
export function runCountItems(record, summary, opts) {
|
|
96
|
+
const items = [];
|
|
97
|
+
const companies = companiesValue(summary, opts);
|
|
98
|
+
if (companies)
|
|
99
|
+
items.push({ key: "Companies", value: companies });
|
|
100
|
+
const extract = steps(record, "extract").find((s) => s.status !== "skipped");
|
|
101
|
+
const adapters = steps(record, "adapters").find((s) => s.status !== "skipped");
|
|
102
|
+
const read = extract ? extract.itemsIn : adapters ? adapters.itemsOut : null;
|
|
103
|
+
if (read !== null) {
|
|
104
|
+
const parts = Object.entries(adapters?.byAdapter ?? {})
|
|
105
|
+
.reduce((m, [k, v]) => m.set(ADAPTER_LABELS[k] ?? k, (m.get(ADAPTER_LABELS[k] ?? k) ?? 0) + v), new Map());
|
|
106
|
+
const breakdown = [...parts].map(([k, v]) => `${k} ${v}`).join(", ");
|
|
107
|
+
items.push({ key: "Sources read", value: `${plural(read, "item")}${breakdown ? ` (${breakdown})` : ""}` });
|
|
108
|
+
}
|
|
109
|
+
if (extract)
|
|
110
|
+
items.push({ key: "Signals found", value: String(extract.itemsOut) });
|
|
111
|
+
const garden = steps(record, "garden").filter((s) => s.status !== "skipped");
|
|
112
|
+
if (garden.length) {
|
|
113
|
+
const tidied = garden.reduce((n, s) => n + s.itemsIn, 0);
|
|
114
|
+
const waiting = garden[garden.length - 1].itemsOut;
|
|
115
|
+
items.push({ key: "Candidates tidied", value: `${tidied}, ${waiting === 0 ? "none left waiting" : `${waiting} left waiting`}` });
|
|
116
|
+
}
|
|
117
|
+
return items;
|
|
118
|
+
}
|
|
119
|
+
function spendUsd(record) {
|
|
120
|
+
return record.steps.reduce((n, s) => n + (s.estimatedSpendUsd ?? 0), 0);
|
|
121
|
+
}
|
|
122
|
+
function spendBlock(record) {
|
|
123
|
+
if (!(record.maxBudgetUsd > 0))
|
|
124
|
+
return null;
|
|
125
|
+
const spend = spendUsd(record);
|
|
126
|
+
const value = Math.max(0, Math.min(100, Math.round((spend / record.maxBudgetUsd) * 100)));
|
|
127
|
+
return {
|
|
128
|
+
kind: "progress",
|
|
129
|
+
value,
|
|
130
|
+
label: `Spend $${spend.toFixed(2)} of the $${record.maxBudgetUsd.toFixed(2)} cap`,
|
|
131
|
+
tone: "neutral",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function openBriefDecision(date) {
|
|
135
|
+
return {
|
|
136
|
+
kind: "decision",
|
|
137
|
+
questionId: `signals-run-${date}-open-brief`,
|
|
138
|
+
question: "Your brief builds next.",
|
|
139
|
+
options: [{ id: "open-brief", label: "Create today's brief", recommended: true }],
|
|
140
|
+
allowOther: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** "a, b and c". */
|
|
144
|
+
function listInWords(parts) {
|
|
145
|
+
if (parts.length <= 1)
|
|
146
|
+
return parts.join("");
|
|
147
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
148
|
+
}
|
|
149
|
+
function capitalize(text) {
|
|
150
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* What the member can do about a failed or partial run. The detail
|
|
154
|
+
* (reconnect, signed-out) is on the failing step; the record carries only the
|
|
155
|
+
* coarse v2 kind (auth, config, transient).
|
|
156
|
+
*/
|
|
157
|
+
function failureGuidance(record, failed, company) {
|
|
158
|
+
const stepKind = failed?.errorKind ?? record.steps.find((s) => s.status === "failed" || s.status === "partial")?.errorKind;
|
|
159
|
+
if (stepKind === "reconnect")
|
|
160
|
+
return `A connection for ${company} needs reconnecting.`;
|
|
161
|
+
if (stepKind === "signed-out")
|
|
162
|
+
return "You are signed out of HQ. Sign in again with hq login.";
|
|
163
|
+
if (record.errorKind === "auth")
|
|
164
|
+
return `A connection for ${company} needs signing in again.`;
|
|
165
|
+
if (record.errorKind === "config")
|
|
166
|
+
return `Something in the signals setup for ${company} needs fixing first.`;
|
|
167
|
+
if (record.errorKind === "transient")
|
|
168
|
+
return "This looks temporary.";
|
|
169
|
+
return failed ? `${STEP_DOING[failed.name]} for ${company} failed.` : `Signals for ${company} failed.`;
|
|
170
|
+
}
|
|
171
|
+
/** First sentence of an error, trimmed to one plain line. */
|
|
172
|
+
function oneSentence(text) {
|
|
173
|
+
const line = text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? text.trim();
|
|
174
|
+
const m = /^(.+?[.!?])(\s|$)/.exec(line);
|
|
175
|
+
return (m ? m[1] : line).slice(0, 160);
|
|
176
|
+
}
|
|
177
|
+
/** partialReason in words: "step-failed:extract" -> "Finding signals failed." */
|
|
178
|
+
function partialReasonInWords(reason) {
|
|
179
|
+
const m = /^step-(failed|partial):(\w+)$/.exec(reason);
|
|
180
|
+
if (m && m[2] in STEP_DOING) {
|
|
181
|
+
const doing = STEP_DOING[m[2]];
|
|
182
|
+
return m[1] === "failed" ? `${doing} failed.` : `${doing} finished with problems.`;
|
|
183
|
+
}
|
|
184
|
+
return reason;
|
|
185
|
+
}
|
|
186
|
+
const SKIPPED_BADGE = { kind: "badge", label: "Skipped", tone: "neutral" };
|
|
187
|
+
/**
|
|
188
|
+
* The reply for one finished run of the signals job: run complete, catch-up,
|
|
189
|
+
* late start, finished with problems, or run failed (bot-replies.md sections
|
|
190
|
+
* 2, 2b and 3; status mapping from run-record-contract-v2.md).
|
|
191
|
+
*
|
|
192
|
+
* A failure offers "Wait for <time>" only when `retryScheduled` says the bot
|
|
193
|
+
* really scheduled the retry at the record's `nextRetryAt`. Without it (config
|
|
194
|
+
* and auth failures have no nextRetryAt, and an automatic retry is never
|
|
195
|
+
* retried again) the only option is "Try now".
|
|
196
|
+
*/
|
|
197
|
+
export function formatRunReply(record, summary, opts) {
|
|
198
|
+
const job = opts.jobName ?? "Morning signals";
|
|
199
|
+
const tz = opts.timeZone;
|
|
200
|
+
const finished = timeInWords(record.finishedAt, tz) ?? timeInWords(summary.finishedAt, tz) ?? "an unknown time";
|
|
201
|
+
const company = opts.companyNames?.[record.company] ?? titleCase(record.company);
|
|
202
|
+
if (record.alreadyRanToday) {
|
|
203
|
+
const ranAt = timeInWords(record.jobRanAt, tz) ?? finished;
|
|
204
|
+
return build(`${job} already ran today, at ${ranAt}. Nothing new to do.`, [
|
|
205
|
+
{ kind: "badge", label: "Already ran today", tone: "neutral", icon: "check" },
|
|
206
|
+
], "already-ran");
|
|
207
|
+
}
|
|
208
|
+
if (record.status === "skipped") {
|
|
209
|
+
const reason = record.skipReason ?? record.skippedReason ?? null;
|
|
210
|
+
if (reason === "prefs-off") {
|
|
211
|
+
return build(`${job} did not run for ${company}: signals and ontology capture are both off.`, [
|
|
212
|
+
SKIPPED_BADGE,
|
|
213
|
+
{ kind: "callout", tone: "info", body: `Turn on signals_capture or ontology_capture for ${company} to include it.` },
|
|
214
|
+
], "skipped");
|
|
215
|
+
}
|
|
216
|
+
if (reason === "signals-off") {
|
|
217
|
+
return build(`${job} did not run for ${company}: signals are off.`, [SKIPPED_BADGE], "skipped");
|
|
218
|
+
}
|
|
219
|
+
if (reason === "late-start") {
|
|
220
|
+
return build(`${job} started too late in the day, so nothing ran for ${company}.`, [SKIPPED_BADGE], "skipped");
|
|
221
|
+
}
|
|
222
|
+
return build(`${job} had nothing to run for ${company}.`, [SKIPPED_BADGE], "skipped");
|
|
223
|
+
}
|
|
224
|
+
const failedStep = record.steps.find((s) => s.status === "failed");
|
|
225
|
+
if (record.status === "failed") {
|
|
226
|
+
const retry = opts.retryScheduled ? timeInWords(record.nextRetryAt, tz) : null;
|
|
227
|
+
const blocks = [];
|
|
228
|
+
const title = failedStep?.error
|
|
229
|
+
? oneSentence(failedStep.error)
|
|
230
|
+
: record.failureReason
|
|
231
|
+
? oneSentence(record.failureReason)
|
|
232
|
+
: `Signals for ${company} failed.`;
|
|
233
|
+
const brief = "Your brief is not built until signals finish, so you never read a brief without fresh signals.";
|
|
234
|
+
const retryLine = retry
|
|
235
|
+
? `I try again at ${retry}.`
|
|
236
|
+
: opts.wasRetry
|
|
237
|
+
? "This was the automatic retry, so I do not try again before the next scheduled run."
|
|
238
|
+
: "I do not retry this on my own.";
|
|
239
|
+
const guidance = failureGuidance(record, failedStep, company);
|
|
240
|
+
const body = [retry && record.errorKind === "transient" ? `${guidance} Nothing for you to do.` : guidance, brief, retryLine].join(" ");
|
|
241
|
+
blocks.push({ kind: "callout", tone: "danger", title, body });
|
|
242
|
+
const idx = failedStep ? record.steps.indexOf(failedStep) : record.steps.length;
|
|
243
|
+
const done = record.steps.slice(0, idx).filter((s) => s.status === "ok" || s.status === "partial");
|
|
244
|
+
const rows = [];
|
|
245
|
+
const gardenDone = done.filter((s) => s.name === "garden");
|
|
246
|
+
if (gardenDone.length) {
|
|
247
|
+
const n = gardenDone.reduce((a, s) => a + s.itemsIn, 0);
|
|
248
|
+
const count = summary.companies.filter((c) => c.status !== "skipped").length;
|
|
249
|
+
rows.push({ key: "Finished", value: `Candidates tidied: ${n}${count > 1 ? ` across ${count} companies` : ""}` });
|
|
250
|
+
}
|
|
251
|
+
if (failedStep)
|
|
252
|
+
rows.push({ key: "Stopped at", value: `${STEP_DOING[failedStep.name]} for ${company}` });
|
|
253
|
+
const notRun = [...new Set(STEP_ORDER_NAMES.slice(record.steps.length).filter((n) => n !== "garden").map((n) => STEP_NOUN[n]))];
|
|
254
|
+
if (notRun.length)
|
|
255
|
+
rows.push({ key: "Not run", value: capitalize(listInWords(notRun)) });
|
|
256
|
+
if (rows.length)
|
|
257
|
+
blocks.push({ kind: "keyValue", items: rows });
|
|
258
|
+
blocks.push(retry
|
|
259
|
+
? {
|
|
260
|
+
kind: "decision",
|
|
261
|
+
questionId: `signals-run-${summary.date}-retry`,
|
|
262
|
+
question: "Try again now, or wait for the retry?",
|
|
263
|
+
options: [
|
|
264
|
+
{ id: "try-now", label: "Try now", recommended: true },
|
|
265
|
+
{ id: "wait", label: `Wait for ${retry}` },
|
|
266
|
+
],
|
|
267
|
+
allowOther: false,
|
|
268
|
+
}
|
|
269
|
+
: {
|
|
270
|
+
kind: "decision",
|
|
271
|
+
questionId: `signals-run-${summary.date}-retry`,
|
|
272
|
+
question: "Try again now?",
|
|
273
|
+
options: [{ id: "try-now", label: "Try now", recommended: true }],
|
|
274
|
+
allowOther: false,
|
|
275
|
+
});
|
|
276
|
+
return build(`${job} stopped at ${finished}.`, blocks, "failed");
|
|
277
|
+
}
|
|
278
|
+
const counts = runCountItems(record, summary, opts);
|
|
279
|
+
const spend = spendBlock(record);
|
|
280
|
+
const tail = [
|
|
281
|
+
...(counts.length ? [{ kind: "keyValue", items: counts }] : []),
|
|
282
|
+
...(spend ? [spend] : []),
|
|
283
|
+
];
|
|
284
|
+
const due = timeInWords(opts.scheduledFor, tz);
|
|
285
|
+
const catchUpBadge = { kind: "badge", label: "Catch-up", tone: "warning", icon: "clock" };
|
|
286
|
+
const partialReason = record.partialReason ?? record.partial ?? null;
|
|
287
|
+
if (partialReason === "late-start") {
|
|
288
|
+
const catchUpLine = record.catchUp && due ? ` Your Mac was asleep at ${due}, so this was a catch-up run.` : "";
|
|
289
|
+
return build(`${job} started after 10 PM, so I only tidied candidates tonight. The full run happens at ${due ?? "the next scheduled time"}.${catchUpLine}`, [
|
|
290
|
+
{ kind: "badge", label: "Late start, garden only", tone: "warning", icon: "clock" },
|
|
291
|
+
...(record.catchUp ? [catchUpBadge] : []),
|
|
292
|
+
...tail,
|
|
293
|
+
], "late-start");
|
|
294
|
+
}
|
|
295
|
+
if (record.status === "partial") {
|
|
296
|
+
const title = record.failureReason
|
|
297
|
+
? oneSentence(record.failureReason)
|
|
298
|
+
: partialReason
|
|
299
|
+
? partialReasonInWords(partialReason)
|
|
300
|
+
: null;
|
|
301
|
+
const body = `${failureGuidance(record, failedStep, company)} Your brief builds with what came in. Say "Try now" to run signals again.`;
|
|
302
|
+
return build(`${job} finished with problems at ${finished}.`, [
|
|
303
|
+
{ kind: "badge", label: "Finished with problems", tone: "warning" },
|
|
304
|
+
...(record.catchUp ? [catchUpBadge] : []),
|
|
305
|
+
title ? { kind: "callout", tone: "warning", title, body } : { kind: "callout", tone: "warning", body },
|
|
306
|
+
...tail,
|
|
307
|
+
openBriefDecision(summary.date),
|
|
308
|
+
], "partial");
|
|
309
|
+
}
|
|
310
|
+
if (record.catchUp) {
|
|
311
|
+
const prose = due
|
|
312
|
+
? `${job} finished at ${finished} instead of ${due}. Your Mac was asleep at ${due}, so I ran it when it woke.`
|
|
313
|
+
: `${job} finished at ${finished}, later than scheduled. Your Mac was asleep, so I ran it when it woke.`;
|
|
314
|
+
return build(prose, [catchUpBadge, ...tail, openBriefDecision(summary.date)], "catch-up");
|
|
315
|
+
}
|
|
316
|
+
return build(`${job} finished at ${finished}.`, [{ kind: "badge", label: "Complete", tone: "success", icon: "check" }, ...tail, openBriefDecision(summary.date)], "complete");
|
|
317
|
+
}
|
|
318
|
+
/** Another run holds the company's lock (engine exit 3): nothing was started. */
|
|
319
|
+
export function formatLockedReply(locked, opts) {
|
|
320
|
+
const job = opts.jobName ?? "Morning signals";
|
|
321
|
+
const company = opts.companyNames?.[locked.company] ?? titleCase(locked.company);
|
|
322
|
+
const started = timeInWords(locked.startedAt, opts.timeZone);
|
|
323
|
+
return build(`${job} for ${company} is already running${started ? ` (a ${locked.trigger} run started at ${started})` : ""}. I did not start a second one.`, [{ kind: "badge", label: "Running", tone: "info", icon: "clock" }], "running");
|
|
324
|
+
}
|
|
325
|
+
/** A job that could not run the engine at all: the prose, then one danger callout with the reason. */
|
|
326
|
+
export function formatJobErrorReply(prose, reason) {
|
|
327
|
+
return build(prose, [{ kind: "callout", tone: "danger", body: reason }], "failed");
|
|
328
|
+
}
|
|
329
|
+
/** "Every day at 6:00 AM" from a daily cron; null when the cron is not a plain daily or weekday time. */
|
|
330
|
+
export function scheduleInWords(cron) {
|
|
331
|
+
const m = /^cron\((.+)\)$/.exec(cron.trim());
|
|
332
|
+
const fields = (m ? m[1] : cron).trim().split(/\s+/);
|
|
333
|
+
const [min, hour, dom, , dow] = fields;
|
|
334
|
+
if (!min || !hour || !/^\d+$/.test(min) || !/^\d+$/.test(hour))
|
|
335
|
+
return null;
|
|
336
|
+
if (dom !== "*" && dom !== "?")
|
|
337
|
+
return null;
|
|
338
|
+
const h = Number(hour);
|
|
339
|
+
const t = `${((h + 11) % 12) + 1}:${min.padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
|
340
|
+
if (dow === "*" || dow === "?" || dow === undefined)
|
|
341
|
+
return `Every day at ${t}`;
|
|
342
|
+
if (dow === "1-5" || dow === "MON-FRI")
|
|
343
|
+
return `Every weekday at ${t}`;
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
/** The job-created reply (bot-replies.md section 1). The zone comes from the job record, in words. */
|
|
347
|
+
export function formatJobCreatedReply(job, timezoneWords = timezoneInWords(job.timezone)) {
|
|
348
|
+
const name = job.name ?? "Morning signals";
|
|
349
|
+
const when = scheduleInWords(job.cron);
|
|
350
|
+
const items = [];
|
|
351
|
+
if (when)
|
|
352
|
+
items.push({ key: "Runs", value: `${when}, ${timezoneWords}`, icon: "clock" });
|
|
353
|
+
if (job.companies?.length)
|
|
354
|
+
items.push({ key: "Covers", value: job.companies.join(", "), icon: "folder" });
|
|
355
|
+
if (job.hasBriefJob)
|
|
356
|
+
items.push({ key: "Then", value: "Your daily brief, which now waits for it", icon: "calendar" });
|
|
357
|
+
const at = when ? when.replace(/^Every (day|weekday) at /, "") : "the scheduled time";
|
|
358
|
+
const blocks = [];
|
|
359
|
+
if (items.length)
|
|
360
|
+
blocks.push({ kind: "keyValue", items });
|
|
361
|
+
blocks.push({
|
|
362
|
+
kind: "callout",
|
|
363
|
+
tone: "info",
|
|
364
|
+
body: `If your Mac is asleep at ${at}, I run it as soon as it wakes and mark that run a catch-up. The job and its last run are under Bot jobs on my profile.`,
|
|
365
|
+
});
|
|
366
|
+
return build(`Done. I set up a job called ${name}.`, blocks, "complete");
|
|
367
|
+
}
|
|
368
|
+
//# sourceMappingURL=reply.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const LOCK_STALE_MS: number;
|
|
2
|
+
export interface LockHolder {
|
|
3
|
+
pid: number;
|
|
4
|
+
startedAt: string;
|
|
5
|
+
trigger: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LockTakeover extends LockHolder {
|
|
8
|
+
takenOverAt: string;
|
|
9
|
+
reason: "pid-dead" | "expired" | "unreadable";
|
|
10
|
+
}
|
|
11
|
+
export type AcquireResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
takeover: LockTakeover | null;
|
|
14
|
+
release: () => void;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
holder: LockHolder;
|
|
18
|
+
};
|
|
19
|
+
export declare function companyLockRel(date: string, company: string): string;
|
|
20
|
+
/** process.kill(pid, 0): ESRCH means gone; EPERM means alive under another user. */
|
|
21
|
+
export declare function defaultIsPidAlive(pid: number): boolean;
|
|
22
|
+
export declare function acquireCompanyLock(input: {
|
|
23
|
+
hqRoot: string;
|
|
24
|
+
date: string;
|
|
25
|
+
company: string;
|
|
26
|
+
trigger: string;
|
|
27
|
+
now: () => Date;
|
|
28
|
+
isPidAlive?: (pid: number) => boolean;
|
|
29
|
+
}): AcquireResult;
|
|
30
|
+
/**
|
|
31
|
+
* Short exclusive section around a read-modify-write of summary.json, so
|
|
32
|
+
* two runs for different companies cannot drop each other's entry. The
|
|
33
|
+
* lock is held for milliseconds; one older than 30 seconds is stale.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withSummaryLock<T>(hqRoot: string, date: string, fn: () => T): T;
|
|
36
|
+
//# sourceMappingURL=run-lock.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-company run lock and the summary lock for the signals engine
|
|
3
|
+
* (run record contract v2).
|
|
4
|
+
*
|
|
5
|
+
* Lock file: workspace/signals/runs/{date}/{company}.lock, created with
|
|
6
|
+
* O_EXCL ("wx") and holding JSON { pid, startedAt, trigger }. A lock is stale
|
|
7
|
+
* when its pid is not alive or its startedAt is older than 3 hours; a stale
|
|
8
|
+
* lock is removed and taken over, and the caller logs the takeover in the
|
|
9
|
+
* run record.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { assertAllowedPath } from "./write-guard.js";
|
|
14
|
+
export const LOCK_STALE_MS = 3 * 60 * 60 * 1000;
|
|
15
|
+
/** An unreadable lock younger than this is treated as being written right now. */
|
|
16
|
+
const UNREADABLE_GRACE_MS = 60 * 1000;
|
|
17
|
+
export function companyLockRel(date, company) {
|
|
18
|
+
return `workspace/signals/runs/${date}/${company}.lock`;
|
|
19
|
+
}
|
|
20
|
+
/** process.kill(pid, 0): ESRCH means gone; EPERM means alive under another user. */
|
|
21
|
+
export function defaultIsPidAlive(pid) {
|
|
22
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
23
|
+
return false;
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return err.code === "EPERM";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readHolder(abs) {
|
|
33
|
+
try {
|
|
34
|
+
const v = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
35
|
+
if (typeof v.pid === "number" && typeof v.startedAt === "string" && typeof v.trigger === "string") {
|
|
36
|
+
return { pid: v.pid, startedAt: v.startedAt, trigger: v.trigger };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// fall through
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function createExclusive(abs, body) {
|
|
45
|
+
let fd;
|
|
46
|
+
try {
|
|
47
|
+
fd = fs.openSync(abs, "wx");
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "EEXIST")
|
|
51
|
+
return false;
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
fs.writeSync(fd, body);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
export function acquireCompanyLock(input) {
|
|
63
|
+
const isAlive = input.isPidAlive ?? defaultIsPidAlive;
|
|
64
|
+
const abs = assertAllowedPath(input.hqRoot, companyLockRel(input.date, input.company), { company: input.company });
|
|
65
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
66
|
+
let takeover = null;
|
|
67
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
68
|
+
const mine = { pid: process.pid, startedAt: input.now().toISOString(), trigger: input.trigger };
|
|
69
|
+
if (createExclusive(abs, JSON.stringify(mine))) {
|
|
70
|
+
const release = () => {
|
|
71
|
+
const current = readHolder(abs);
|
|
72
|
+
if (current && current.pid === mine.pid && current.startedAt === mine.startedAt) {
|
|
73
|
+
fs.rmSync(abs, { force: true });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return { ok: true, takeover, release };
|
|
77
|
+
}
|
|
78
|
+
const holder = readHolder(abs);
|
|
79
|
+
const nowMs = input.now().getTime();
|
|
80
|
+
let reason = null;
|
|
81
|
+
if (!holder) {
|
|
82
|
+
let mtimeMs;
|
|
83
|
+
try {
|
|
84
|
+
mtimeMs = fs.statSync(abs).mtimeMs;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue; // removed between our open and stat: try again
|
|
88
|
+
}
|
|
89
|
+
if (Math.abs(Date.now() - mtimeMs) < UNREADABLE_GRACE_MS) {
|
|
90
|
+
return { ok: false, holder: { pid: 0, startedAt: new Date(mtimeMs).toISOString(), trigger: "unknown" } };
|
|
91
|
+
}
|
|
92
|
+
reason = "unreadable";
|
|
93
|
+
}
|
|
94
|
+
else if (!isAlive(holder.pid)) {
|
|
95
|
+
reason = "pid-dead";
|
|
96
|
+
}
|
|
97
|
+
else if (nowMs - Date.parse(holder.startedAt) > LOCK_STALE_MS) {
|
|
98
|
+
reason = "expired";
|
|
99
|
+
}
|
|
100
|
+
if (reason === null && holder)
|
|
101
|
+
return { ok: false, holder };
|
|
102
|
+
takeover = {
|
|
103
|
+
pid: holder?.pid ?? 0,
|
|
104
|
+
startedAt: holder?.startedAt ?? "",
|
|
105
|
+
trigger: holder?.trigger ?? "unknown",
|
|
106
|
+
takenOverAt: input.now().toISOString(),
|
|
107
|
+
reason: reason ?? "unreadable",
|
|
108
|
+
};
|
|
109
|
+
fs.rmSync(abs, { force: true });
|
|
110
|
+
}
|
|
111
|
+
const holder = readHolder(abs);
|
|
112
|
+
return { ok: false, holder: holder ?? { pid: 0, startedAt: input.now().toISOString(), trigger: "unknown" } };
|
|
113
|
+
}
|
|
114
|
+
function sleepSync(ms) {
|
|
115
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Short exclusive section around a read-modify-write of summary.json, so
|
|
119
|
+
* two runs for different companies cannot drop each other's entry. The
|
|
120
|
+
* lock is held for milliseconds; one older than 30 seconds is stale.
|
|
121
|
+
*/
|
|
122
|
+
export function withSummaryLock(hqRoot, date, fn) {
|
|
123
|
+
const abs = assertAllowedPath(hqRoot, `workspace/signals/runs/${date}/summary.lock`);
|
|
124
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
125
|
+
for (let i = 0;; i += 1) {
|
|
126
|
+
if (createExclusive(abs, String(process.pid)))
|
|
127
|
+
break;
|
|
128
|
+
try {
|
|
129
|
+
if (Date.now() - fs.statSync(abs).mtimeMs > 30_000) {
|
|
130
|
+
fs.rmSync(abs, { force: true });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (i >= 250)
|
|
138
|
+
throw new Error("hq signals: summary.json is locked by another run");
|
|
139
|
+
sleepSync(20);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return fn();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
fs.rmSync(abs, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=run-lock.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape check for workspace/signals/runs/{date}/{company}.json (run record
|
|
3
|
+
* contract v2). It accepts schemaVersion 2 records only. v1 records (no
|
|
4
|
+
* schemaVersion, record status "ok", detailed record errorKind) were written
|
|
5
|
+
* only by the unreleased US-002 drafts, and the engine treats a prior record
|
|
6
|
+
* without jobRanAt as "the job has not run today", so no hq-cli reader needs
|
|
7
|
+
* to accept them. Out-of-repo readers (the desktop app) handle v1 themselves,
|
|
8
|
+
* per the contract.
|
|
9
|
+
*
|
|
10
|
+
* The enum lists are typed against the engine's own types with `exhaustive`,
|
|
11
|
+
* so adding or removing a value in the engine without updating the list here
|
|
12
|
+
* is a compile error.
|
|
13
|
+
*/
|
|
14
|
+
import { type RunRecord } from "./engine.js";
|
|
15
|
+
/** Record statuses. "dry-run" only appears on in-memory records: a dry run writes nothing. */
|
|
16
|
+
export declare const RUN_STATUSES: readonly ["running", "complete", "partial", "failed", "skipped", "dry-run"];
|
|
17
|
+
/** Coarse record-level error kind, set on failed and partial records only. */
|
|
18
|
+
export declare const RUN_ERROR_KINDS: readonly ["transient", "config", "auth"];
|
|
19
|
+
export declare const STEP_NAMES: readonly ["garden", "adapters", "extract", "upload"];
|
|
20
|
+
export declare const STEP_STATUSES: readonly ["ok", "skipped", "failed", "partial"];
|
|
21
|
+
/** Detailed per-step error kind. */
|
|
22
|
+
export declare const STEP_ERROR_KINDS: readonly ["reconnect", "signed-out", "transient", "config", "other"];
|
|
23
|
+
export declare function isRunRecord(v: unknown): v is RunRecord;
|
|
24
|
+
//# sourceMappingURL=run-record.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape check for workspace/signals/runs/{date}/{company}.json (run record
|
|
3
|
+
* contract v2). It accepts schemaVersion 2 records only. v1 records (no
|
|
4
|
+
* schemaVersion, record status "ok", detailed record errorKind) were written
|
|
5
|
+
* only by the unreleased US-002 drafts, and the engine treats a prior record
|
|
6
|
+
* without jobRanAt as "the job has not run today", so no hq-cli reader needs
|
|
7
|
+
* to accept them. Out-of-repo readers (the desktop app) handle v1 themselves,
|
|
8
|
+
* per the contract.
|
|
9
|
+
*
|
|
10
|
+
* The enum lists are typed against the engine's own types with `exhaustive`,
|
|
11
|
+
* so adding or removing a value in the engine without updating the list here
|
|
12
|
+
* is a compile error.
|
|
13
|
+
*/
|
|
14
|
+
import { RUN_RECORD_SCHEMA_VERSION, SIGNALS_TRIGGERS } from "./engine.js";
|
|
15
|
+
/**
|
|
16
|
+
* Returns the list unchanged. Fails to compile when the list holds a value
|
|
17
|
+
* outside Union, or misses one of Union's members.
|
|
18
|
+
*/
|
|
19
|
+
const exhaustive = () => (list) => list;
|
|
20
|
+
/** Record statuses. "dry-run" only appears on in-memory records: a dry run writes nothing. */
|
|
21
|
+
export const RUN_STATUSES = exhaustive()(["running", "complete", "partial", "failed", "skipped", "dry-run"]);
|
|
22
|
+
/** Coarse record-level error kind, set on failed and partial records only. */
|
|
23
|
+
export const RUN_ERROR_KINDS = exhaustive()(["transient", "config", "auth"]);
|
|
24
|
+
export const STEP_NAMES = exhaustive()(["garden", "adapters", "extract", "upload"]);
|
|
25
|
+
export const STEP_STATUSES = exhaustive()(["ok", "skipped", "failed", "partial"]);
|
|
26
|
+
/** Detailed per-step error kind. */
|
|
27
|
+
export const STEP_ERROR_KINDS = exhaustive()(["reconnect", "signed-out", "transient", "config", "other"]);
|
|
28
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
30
|
+
const oneOf = (list, v) => typeof v === "string" && list.includes(v);
|
|
31
|
+
const strOrNull = (v) => v === null || typeof v === "string";
|
|
32
|
+
const optional = (v, check) => v === undefined || check(v);
|
|
33
|
+
function isStepRecord(v) {
|
|
34
|
+
return (isObj(v) &&
|
|
35
|
+
oneOf(STEP_NAMES, v.name) &&
|
|
36
|
+
oneOf(STEP_STATUSES, v.status) &&
|
|
37
|
+
isNum(v.itemsIn) &&
|
|
38
|
+
isNum(v.itemsOut) &&
|
|
39
|
+
isNum(v.durationMs) &&
|
|
40
|
+
optional(v.errorKind, (k) => oneOf(STEP_ERROR_KINDS, k)));
|
|
41
|
+
}
|
|
42
|
+
const isCounts = (v) => isObj(v) && isNum(v.sourcesIn) && isNum(v.signalsOut);
|
|
43
|
+
export function isRunRecord(v) {
|
|
44
|
+
if (!isObj(v))
|
|
45
|
+
return false;
|
|
46
|
+
const shape = v.schemaVersion === RUN_RECORD_SCHEMA_VERSION &&
|
|
47
|
+
typeof v.company === "string" &&
|
|
48
|
+
v.company.length > 0 &&
|
|
49
|
+
typeof v.startedAt === "string" &&
|
|
50
|
+
strOrNull(v.finishedAt) &&
|
|
51
|
+
oneOf(SIGNALS_TRIGGERS, v.trigger) &&
|
|
52
|
+
isNum(v.pid) &&
|
|
53
|
+
typeof v.catchUp === "boolean" &&
|
|
54
|
+
typeof v.dryRun === "boolean" &&
|
|
55
|
+
oneOf(RUN_STATUSES, v.status) &&
|
|
56
|
+
strOrNull(v.skipReason) &&
|
|
57
|
+
optional(v.skippedReason, (x) => typeof x === "string") &&
|
|
58
|
+
(v.currentStep === null || oneOf(STEP_NAMES, v.currentStep)) &&
|
|
59
|
+
Array.isArray(v.steps) &&
|
|
60
|
+
v.steps.every(isStepRecord) &&
|
|
61
|
+
isNum(v.maxBudgetUsd) &&
|
|
62
|
+
isNum(v.maxTurns) &&
|
|
63
|
+
strOrNull(v.jobRanAt) &&
|
|
64
|
+
strOrNull(v.nextRetryAt) &&
|
|
65
|
+
(v.errorKind === null || oneOf(RUN_ERROR_KINDS, v.errorKind)) &&
|
|
66
|
+
strOrNull(v.failureReason) &&
|
|
67
|
+
strOrNull(v.partialReason) &&
|
|
68
|
+
strOrNull(v.partial) &&
|
|
69
|
+
typeof v.alreadyRanToday === "boolean" &&
|
|
70
|
+
isCounts(v.counts) &&
|
|
71
|
+
optional(v.lockTakeover, isObj);
|
|
72
|
+
if (!shape)
|
|
73
|
+
return false;
|
|
74
|
+
// Contract invariants: finishedAt is null exactly while running, and the
|
|
75
|
+
// record-level errorKind is only set on failed and partial records.
|
|
76
|
+
if ((v.status === "running") !== (v.finishedAt === null))
|
|
77
|
+
return false;
|
|
78
|
+
if (v.errorKind !== null && v.status !== "failed" && v.status !== "partial")
|
|
79
|
+
return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=run-record.js.map
|