@indigoai-us/hq-cli 5.345.198 → 5.345.200
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 +71 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
- package/dist/command-catalog.generated.d.ts +150 -6
- package/dist/command-catalog.generated.js +182 -6
- package/dist/commands/bot-events.d.ts +19 -0
- package/dist/commands/bot-events.js +46 -0
- package/dist/commands/bot-jobs.d.ts +2 -0
- package/dist/commands/bot-jobs.js +43 -2
- package/dist/commands/bot-runtime.d.ts +35 -0
- package/dist/commands/bot-runtime.js +115 -0
- package/dist/commands/bot.d.ts +11 -0
- package/dist/commands/bot.js +72 -11
- package/dist/commands/creators.js +2 -1
- package/dist/commands/lanes-report.d.ts +2 -1
- package/dist/commands/lanes-report.js +44 -1
- package/dist/commands/lanes.d.ts +9 -0
- package/dist/commands/lanes.js +95 -9
- package/dist/commands/library-remove.js +2 -1
- package/dist/commands/library.js +5 -4
- package/dist/commands/meetings.js +1 -21
- package/dist/commands/mesh.d.ts +0 -7
- package/dist/commands/mesh.js +5 -1
- package/dist/commands/signals-run.d.ts +2 -0
- package/dist/commands/signals-run.js +6 -1
- package/dist/commands/sync-held.js +2 -2
- package/dist/lanes-drain-hook.js +9 -0
- package/dist/lib/bot/api.d.ts +5 -0
- package/dist/lib/bot/claude-login.d.ts +31 -0
- package/dist/lib/bot/claude-login.js +160 -0
- package/dist/lib/bot/events.d.ts +18 -0
- package/dist/lib/bot/events.js +160 -0
- package/dist/lib/bot/executive-settings.d.ts +10 -0
- package/dist/lib/bot/executive-settings.js +43 -0
- package/dist/lib/bot/fleet-cron.js +4 -0
- package/dist/lib/bot/fleet-download.d.ts +27 -0
- package/dist/lib/bot/fleet-download.js +162 -0
- package/dist/lib/bot/fleet-local.d.ts +51 -2
- package/dist/lib/bot/fleet-local.js +179 -76
- package/dist/lib/bot/fleet-python.d.ts +2 -0
- package/dist/lib/bot/fleet-python.js +29 -0
- package/dist/lib/bot/job-run.js +1 -1
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
- package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
- package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
- package/dist/lib/bot/jobs/meeting-detect.js +176 -0
- package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
- package/dist/lib/bot/jobs/meeting-rider.js +284 -0
- package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
- package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
- package/dist/lib/bot/local-jobs.d.ts +15 -2
- package/dist/lib/bot/local-jobs.js +24 -7
- package/dist/lib/bot/run.d.ts +7 -49
- package/dist/lib/bot/run.js +100 -4
- package/dist/lib/bot/status.d.ts +21 -0
- package/dist/lib/doctor/checks/bots.d.ts +6 -0
- package/dist/lib/doctor/checks/bots.js +47 -1
- package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
- package/dist/lib/hot-channels/adapters/meetings.js +3 -1
- package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
- package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
- package/dist/lib/hot-channels/schedule.js +2 -2
- package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
- package/dist/lib/hot-channels/signals-pass.js +51 -17
- package/dist/lib/lanes/admission.d.ts +2 -0
- package/dist/lib/lanes/admission.js +4 -0
- package/dist/lib/lanes/approval-gate.js +4 -1
- package/dist/lib/lanes/links.d.ts +8 -1
- package/dist/lib/lanes/links.js +84 -3
- package/dist/lib/lanes/mesh-events.js +3 -1
- package/dist/lib/lanes/outcome.d.ts +154 -0
- package/dist/lib/lanes/outcome.js +151 -0
- package/dist/lib/lanes/reconcile.js +1 -0
- package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
- package/dist/lib/lanes/signal-brief-template.js +40 -0
- package/dist/lib/lanes/signal-lane.d.ts +24 -0
- package/dist/lib/lanes/signal-lane.js +99 -0
- package/dist/lib/lanes/source-report.d.ts +37 -0
- package/dist/lib/lanes/source-report.js +66 -0
- package/dist/lib/lanes/spawn.d.ts +6 -0
- package/dist/lib/lanes/spawn.js +21 -0
- package/dist/lib/lanes/store.d.ts +3 -0
- package/dist/lib/lanes/store.js +64 -0
- package/dist/lib/lanes/story-ledger.d.ts +3 -0
- package/dist/lib/lanes/story-ledger.js +1 -0
- package/dist/lib/lanes/types.d.ts +137 -0
- package/dist/lib/lanes/types.js +17 -2
- package/dist/lib/lanes/watcher.js +4 -0
- package/dist/lib/live-meetings.d.ts +19 -0
- package/dist/lib/live-meetings.js +23 -0
- package/dist/lib/mesh/api.js +2 -1
- package/dist/lib/mesh/live/daemon/doctor.js +6 -7
- package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
- package/dist/lib/mesh/live/daemon/run.js +3 -0
- package/dist/lib/mesh/live/emit.d.ts +2 -11
- package/dist/lib/mesh/live/emit.js +20 -2
- package/dist/lib/mesh/live/flush.d.ts +2 -0
- package/dist/lib/mesh/live/flush.js +34 -3
- package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
- package/dist/lib/mesh/live/reporting-privacy.js +47 -0
- package/dist/lib/mesh/live/spool.d.ts +8 -0
- package/dist/lib/mesh/live/spool.js +59 -40
- package/dist/lib/plan-limit-nag.d.ts +30 -4
- package/dist/lib/plan-limit-nag.js +68 -17
- package/dist/lib/signals/engine.d.ts +4 -0
- package/dist/lib/signals/engine.js +25 -5
- package/dist/lib/signals/incremental.d.ts +42 -0
- package/dist/lib/signals/incremental.js +191 -0
- package/dist/lib/signals/prior-signals.d.ts +11 -0
- package/dist/lib/signals/prior-signals.js +44 -0
- package/dist/lib/signals/reply.js +6 -1
- package/dist/lib/signals/settings.d.ts +5 -0
- package/dist/lib/signals/settings.js +17 -0
- package/dist/lib/signals/steps/extract.d.ts +23 -2
- package/dist/lib/signals/steps/extract.js +142 -9
- package/dist/lib/signals/steps/types.d.ts +1 -0
- package/dist/lib/signals/write-guard.js +7 -0
- package/dist/lib/work-context/company.d.ts +2 -3
- package/dist/lib/work-context/company.js +4 -22
- package/dist/secrets-env-action.js +5 -2
- package/dist/utils/cognito-callback-port.d.ts +17 -0
- package/dist/utils/cognito-callback-port.js +56 -0
- package/dist/utils/cognito-config.js +7 -3
- package/dist/utils/cognito-session.d.ts +7 -6
- package/dist/utils/cognito-session.js +19 -14
- package/dist/utils/interactive-caller.d.ts +23 -0
- package/dist/utils/interactive-caller.js +80 -0
- package/dist/utils/login-browser.js +5 -2
- package/dist/utils/resident-package-preload.js +4 -0
- package/package.json +7 -7
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** No-model producer of the same event the desktop emits. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import { fetchAttendedLiveBots } from "../../live-meetings.js";
|
|
7
|
+
import { ensureCognitoToken } from "../../../utils/cognito-session.js";
|
|
8
|
+
import { getCompanyUid } from "../../../utils/vault-api.js";
|
|
9
|
+
import { assertSafeSegment } from "../../hot-channels/landing.js";
|
|
10
|
+
import { readManifestCompanies } from "../company-bind.js";
|
|
11
|
+
import { emitBotEvent } from "../events.js";
|
|
12
|
+
import { executiveRiderEnabled, isExecutiveBot } from "../executive-settings.js";
|
|
13
|
+
const RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const ACTIVE = new Set(["joining", "recording", "in_call"]);
|
|
15
|
+
export function detectIntervalSeconds(hqRoot) {
|
|
16
|
+
try {
|
|
17
|
+
const settings = yaml.load(fs.readFileSync(path.join(hqRoot, "core/settings/signals.yaml"), "utf8"));
|
|
18
|
+
const value = settings?.rider?.detect_interval_seconds;
|
|
19
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(5, value) : 60;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code !== "ENOENT")
|
|
23
|
+
throw err;
|
|
24
|
+
return 60;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Desktop events carry company UIDs; local rider files use manifest slugs. */
|
|
28
|
+
export function normalizeDetectedCompany(hqRoot, payload) {
|
|
29
|
+
if (!payload || typeof payload !== "object" || !("company" in payload) || typeof payload.company !== "string")
|
|
30
|
+
return payload;
|
|
31
|
+
const company = payload.company;
|
|
32
|
+
if (!company.startsWith("cmp_"))
|
|
33
|
+
return payload;
|
|
34
|
+
const manifest = yaml.load(fs.readFileSync(path.join(hqRoot, "companies/manifest.yaml"), "utf8"));
|
|
35
|
+
const slug = Object.entries(manifest?.companies ?? {}).find(([, value]) => value?.cloud_uid === company)?.[0];
|
|
36
|
+
return slug ? { ...payload, company: slug } : payload;
|
|
37
|
+
}
|
|
38
|
+
/** Live reads are keyed by Recall ID; the desktop may emit a saved meeting ID or SDK window ID. */
|
|
39
|
+
export async function resolveDetectedMeeting(hqRoot, payload, list) {
|
|
40
|
+
const normalized = normalizeDetectedCompany(hqRoot, payload);
|
|
41
|
+
if (!normalized || typeof normalized !== "object")
|
|
42
|
+
return normalized;
|
|
43
|
+
const event = normalized;
|
|
44
|
+
if (event.source === "poll" || typeof event.company !== "string" || typeof event.meeting_url !== "string")
|
|
45
|
+
return normalized;
|
|
46
|
+
assertSafeSegment(event.company, "company");
|
|
47
|
+
const { companyId, bots } = await list(event.company, AbortSignal.timeout(15_000));
|
|
48
|
+
const row = bots.find((bot) => bot && (bot.companyId === undefined || bot.companyId === companyId) &&
|
|
49
|
+
typeof bot.status === "string" && ACTIVE.has(bot.status.toLowerCase().replaceAll("-", "_")) &&
|
|
50
|
+
([bot.recallBotId, bot.botId, bot.meetingId].includes(String(event.meeting_id)) || bot.meetingUrl === event.meeting_url));
|
|
51
|
+
const id = row?.recallBotId || row?.botId || row?.meetingId;
|
|
52
|
+
return id ? { ...event, meeting_id: id } : normalized;
|
|
53
|
+
}
|
|
54
|
+
export function createMeetingDetect(deps) {
|
|
55
|
+
const now = deps.now ?? (() => new Date());
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
let next = 0;
|
|
58
|
+
let disabledLogged = false;
|
|
59
|
+
let pending;
|
|
60
|
+
const poll = async () => {
|
|
61
|
+
// One slow or denied company must not delay detection in another company.
|
|
62
|
+
await Promise.all([...new Set(deps.companies())].map(async (company) => {
|
|
63
|
+
if (controller.signal.aborted || !deps.enabled())
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
assertSafeSegment(company, "company");
|
|
67
|
+
const root = path.join(deps.hqRoot, "workspace/executive/meetings", company);
|
|
68
|
+
const file = path.join(root, ".detected.json");
|
|
69
|
+
let stored = {};
|
|
70
|
+
try {
|
|
71
|
+
stored = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err.code !== "ENOENT")
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored))
|
|
78
|
+
throw new Error("Invalid detected ledger");
|
|
79
|
+
const time = now();
|
|
80
|
+
const seen = Object.fromEntries(Object.entries(stored).filter((entry) => typeof entry[1] === "string" && Date.parse(entry[1]) > time.getTime() - RETENTION_MS));
|
|
81
|
+
const { companyId, bots } = await deps.list(company, controller.signal);
|
|
82
|
+
if (controller.signal.aborted || !deps.enabled())
|
|
83
|
+
return;
|
|
84
|
+
fs.mkdirSync(root, { recursive: true });
|
|
85
|
+
for (const bot of bots) {
|
|
86
|
+
if (!bot || (bot.companyId !== undefined && bot.companyId !== companyId) || typeof bot.status !== "string" || !ACTIVE.has(bot.status.toLowerCase().replaceAll("-", "_")))
|
|
87
|
+
continue;
|
|
88
|
+
const id = bot.recallBotId || bot.botId || bot.meetingId;
|
|
89
|
+
if (typeof id !== "string" || !id)
|
|
90
|
+
continue;
|
|
91
|
+
assertSafeSegment(id, "meeting id");
|
|
92
|
+
if (Object.hasOwn(seen, id))
|
|
93
|
+
continue;
|
|
94
|
+
// Check aliases too: an older rider may have started with a Recall ID.
|
|
95
|
+
const ids = [id, bot.recallBotId, bot.botId, bot.meetingId].filter((value) => typeof value === "string" && value.length > 0);
|
|
96
|
+
for (const alias of ids)
|
|
97
|
+
assertSafeSegment(alias, "meeting id");
|
|
98
|
+
if (ids.some((alias) => [company, companyId].some((co) => {
|
|
99
|
+
assertSafeSegment(co, "company");
|
|
100
|
+
const base = path.join(deps.hqRoot, "workspace/executive/meetings", co, alias);
|
|
101
|
+
return fs.existsSync(`${base}.rider.lock`) || fs.existsSync(`${base}.rider.json`);
|
|
102
|
+
}))) {
|
|
103
|
+
seen[id] = time.toISOString();
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const event = emitBotEvent(deps.dir, "meeting.detected", { meeting_id: id, company, source: "poll" }, time, (prior) => prior.event === "meeting.detected" && [company, companyId].includes(String(prior.payload.company)) && ids.includes(String(prior.payload.meeting_id)));
|
|
107
|
+
seen[id] = event.at;
|
|
108
|
+
}
|
|
109
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
fs.writeFileSync(tmp, JSON.stringify(seen), { mode: 0o600 });
|
|
112
|
+
fs.renameSync(tmp, file);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
fs.rmSync(tmp, { force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
if (!controller.signal.aborted)
|
|
120
|
+
deps.log(`meeting detection failed for ${company}`);
|
|
121
|
+
}
|
|
122
|
+
}));
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
/** Called by the inbox loop; network work never holds up inbox delivery. */
|
|
126
|
+
tick() {
|
|
127
|
+
if (controller.signal.aborted || pending)
|
|
128
|
+
return;
|
|
129
|
+
if (!deps.enabled()) {
|
|
130
|
+
if (!disabledLogged) {
|
|
131
|
+
deps.log("rider disabled");
|
|
132
|
+
disabledLogged = true;
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (now().getTime() < next)
|
|
137
|
+
return;
|
|
138
|
+
const seconds = deps.intervalSeconds?.() ?? 60;
|
|
139
|
+
next = now().getTime() + (Number.isFinite(seconds) ? Math.max(5, seconds) : 60) * 1000;
|
|
140
|
+
pending = poll().catch(() => deps.log("meeting detection failed")).finally(() => { pending = undefined; });
|
|
141
|
+
},
|
|
142
|
+
async settled() { await pending; },
|
|
143
|
+
async stop() { controller.abort(); await pending; },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Bound waiting even for shared auth/scope helpers that cannot cancel every internal call. */
|
|
147
|
+
async function untilAborted(signal, work) {
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
let abort;
|
|
150
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
151
|
+
abort = () => reject(signal.reason);
|
|
152
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
return await Promise.race([work(), cancelled]);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
signal.removeEventListener("abort", abort);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function defaultMeetingDetectDeps(config, dir, log) {
|
|
162
|
+
return {
|
|
163
|
+
hqRoot: config.hqRoot, dir, log,
|
|
164
|
+
enabled: () => isExecutiveBot(config) && executiveRiderEnabled(),
|
|
165
|
+
companies: () => config.companySlug ? [config.companySlug] : readManifestCompanies(config.hqRoot),
|
|
166
|
+
intervalSeconds: () => detectIntervalSeconds(config.hqRoot),
|
|
167
|
+
list: async (company, signal) => {
|
|
168
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(15_000)]);
|
|
169
|
+
const token = await untilAborted(bounded, () => ensureCognitoToken({ interactive: false, signal: bounded }));
|
|
170
|
+
const companyId = company.startsWith("cmp_") ? company : await untilAborted(bounded, () => getCompanyUid(token, company));
|
|
171
|
+
const bots = await untilAborted(bounded, () => fetchAttendedLiveBots(token, companyId, true, bounded));
|
|
172
|
+
return { companyId, bots };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=meeting-detect.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type BotConfig } from "../config.js";
|
|
2
|
+
export { isExecutiveBot } from "../executive-settings.js";
|
|
3
|
+
import { type LocalJob } from "../local-jobs.js";
|
|
4
|
+
export declare const MEETING_RIDER_JOB_KIND = "meeting-rider";
|
|
5
|
+
export declare const END_POLLS = 3;
|
|
6
|
+
export interface MeetingIdentity {
|
|
7
|
+
meeting_id: string;
|
|
8
|
+
company: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RiderRecord {
|
|
11
|
+
started_at: string;
|
|
12
|
+
ended_at?: string;
|
|
13
|
+
passes: number;
|
|
14
|
+
signals_written: string[];
|
|
15
|
+
lanes: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface RiderDeps {
|
|
18
|
+
hqRoot: string;
|
|
19
|
+
dir: string;
|
|
20
|
+
bot: string;
|
|
21
|
+
/** Must check the authenticated owner's attended list in the requested company. */
|
|
22
|
+
live: (meeting: MeetingIdentity) => Promise<{
|
|
23
|
+
attended: boolean;
|
|
24
|
+
status: string;
|
|
25
|
+
}>;
|
|
26
|
+
runIncremental: (meeting: MeetingIdentity) => Promise<{
|
|
27
|
+
signal_ids: string[];
|
|
28
|
+
lanes?: string[];
|
|
29
|
+
skipped?: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
enabled: () => boolean;
|
|
32
|
+
intervalSeconds?: () => number;
|
|
33
|
+
now?: () => Date;
|
|
34
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
35
|
+
isAlive?: (pid: number) => boolean;
|
|
36
|
+
log: (message: string) => void;
|
|
37
|
+
emit?: (meeting: MeetingIdentity, record: RiderRecord) => void;
|
|
38
|
+
}
|
|
39
|
+
export declare function ensureMeetingRiderJob(dir: string): LocalJob | null;
|
|
40
|
+
export declare function meetingRiderSettings(hqRoot: string, home?: string): {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
interval: number;
|
|
43
|
+
};
|
|
44
|
+
export declare function createMeetingRider(deps: RiderDeps): {
|
|
45
|
+
/** Synchronously persists intent before the event handler may acknowledge delivery. */
|
|
46
|
+
start(payload: unknown): void;
|
|
47
|
+
resume(): void;
|
|
48
|
+
settled(): Promise<void>;
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
/** Production dependencies use the same attended endpoint as `meetings live`. */
|
|
52
|
+
export declare function defaultMeetingRiderDeps(config: BotConfig, dir: string, log: (message: string) => void): RiderDeps;
|
|
53
|
+
//# sourceMappingURL=meeting-rider.d.ts.map
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/** The meeting-specific producer; extraction and event consumers remain source-independent. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import yaml from "js-yaml";
|
|
8
|
+
import { ensureCognitoToken } from "../../../utils/cognito-session.js";
|
|
9
|
+
import { getCompanyUid, vaultApiFetch } from "../../../utils/vault-api.js";
|
|
10
|
+
import { assertSafeSegment } from "../../hot-channels/landing.js";
|
|
11
|
+
import { meetingSignalSourceRef, cursorPath } from "../../signals/incremental.js";
|
|
12
|
+
import { readAllLanes } from "../../lanes/store.js";
|
|
13
|
+
import { runSignals } from "../../signals/engine.js";
|
|
14
|
+
import { emitBotEvent, EVENT_PAYLOAD_BYTES } from "../events.js";
|
|
15
|
+
import { executiveRiderEnabled } from "../executive-settings.js";
|
|
16
|
+
export { isExecutiveBot } from "../executive-settings.js";
|
|
17
|
+
import { readLocalJobsStrict, withJobsLock, writeLocalJobs } from "../local-jobs.js";
|
|
18
|
+
import { readBuiltinJobsMarker, writeBuiltinJobsMarker } from "./hot-signal-sync.js";
|
|
19
|
+
export const MEETING_RIDER_JOB_KIND = "meeting-rider";
|
|
20
|
+
export const END_POLLS = 3;
|
|
21
|
+
const MAX_LOCK_AGE = 6 * 60 * 60 * 1000;
|
|
22
|
+
const TERMINAL = new Set(["completed", "done", "no_transcript", "failed", "fatal", "error", "cancelled", "canceled", "ended", "call_ended", "left", "processing"]);
|
|
23
|
+
const terminal = (status) => TERMINAL.has(status.toLowerCase().replaceAll("-", "_"));
|
|
24
|
+
function atomic(file, value) {
|
|
25
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
26
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
27
|
+
fs.renameSync(tmp, file);
|
|
28
|
+
}
|
|
29
|
+
function alive(pid) {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return err.code !== "ESRCH";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function ensureMeetingRiderJob(dir) {
|
|
39
|
+
return withJobsLock(dir, () => {
|
|
40
|
+
const jobs = readLocalJobsStrict(dir);
|
|
41
|
+
const existing = jobs.find((job) => job.context?.jobKind === MEETING_RIDER_JOB_KIND);
|
|
42
|
+
const marker = readBuiltinJobsMarker(dir);
|
|
43
|
+
if (marker.registered[MEETING_RIDER_JOB_KIND])
|
|
44
|
+
return existing ?? null;
|
|
45
|
+
const job = existing ?? {
|
|
46
|
+
jobId: `job_${randomUUID().replaceAll("-", "").slice(0, 20)}`,
|
|
47
|
+
prompt: "Ride an attended live meeting and extract incremental signals in-process.",
|
|
48
|
+
trigger: { kind: "event", event: "meeting.detected" }, timezone: "UTC",
|
|
49
|
+
context: { jobKind: MEETING_RIDER_JOB_KIND }, createdAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
if (!existing)
|
|
52
|
+
writeLocalJobs(dir, [...jobs, job]);
|
|
53
|
+
marker.registered[MEETING_RIDER_JOB_KIND] = new Date().toISOString();
|
|
54
|
+
writeBuiltinJobsMarker(dir, marker);
|
|
55
|
+
return job;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function meetingRiderSettings(hqRoot, home = os.homedir()) {
|
|
59
|
+
const enabled = executiveRiderEnabled(home);
|
|
60
|
+
let interval = 20;
|
|
61
|
+
try {
|
|
62
|
+
const settings = yaml.load(fs.readFileSync(path.join(hqRoot, "core/settings/signals.yaml"), "utf8"));
|
|
63
|
+
const value = settings?.rider?.interval_seconds;
|
|
64
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
65
|
+
interval = Math.max(5, value);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err.code !== "ENOENT")
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
return { enabled, interval };
|
|
72
|
+
}
|
|
73
|
+
/** A crash after append but before removing the lock must not append the end event twice. */
|
|
74
|
+
function emitEndedOnce(dir, meeting, record) {
|
|
75
|
+
let payload = { ...meeting, source_ref: meetingSignalSourceRef(meeting.meeting_id), signal_ids: record.signals_written };
|
|
76
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") >= EVENT_PAYLOAD_BYTES) {
|
|
77
|
+
// The summary is durable before emit. Keep the complete IDs there rather
|
|
78
|
+
// than silently truncating them or stranding completion on the event cap.
|
|
79
|
+
payload = { ...payload, signal_ids: [], signal_count: record.signals_written.length,
|
|
80
|
+
signal_ids_ref: { path: `workspace/executive/meetings/${meeting.company}/${meeting.meeting_id}.rider.json`, field: "signals_written" } };
|
|
81
|
+
}
|
|
82
|
+
emitBotEvent(dir, "meeting.ended", payload, new Date(), (event) => event.event === "meeting.ended" && event.payload.company === meeting.company && event.payload.meeting_id === meeting.meeting_id);
|
|
83
|
+
}
|
|
84
|
+
export function createMeetingRider(deps) {
|
|
85
|
+
const now = deps.now ?? (() => new Date());
|
|
86
|
+
const running = new Map();
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const root = path.join(deps.hqRoot, "workspace/executive/meetings");
|
|
89
|
+
const emit = deps.emit ?? ((meeting, record) => emitEndedOnce(deps.dir, meeting, record));
|
|
90
|
+
const paths = (meeting) => {
|
|
91
|
+
assertSafeSegment(meeting.company, "company");
|
|
92
|
+
assertSafeSegment(meeting.meeting_id, "meeting id");
|
|
93
|
+
const base = path.join(root, meeting.company, meeting.meeting_id);
|
|
94
|
+
return { lock: `${base}.rider.lock`, result: `${base}.rider.json` };
|
|
95
|
+
};
|
|
96
|
+
const persist = (state) => atomic(paths(state).lock, state);
|
|
97
|
+
const finish = (state) => {
|
|
98
|
+
const { lock, result } = paths(state);
|
|
99
|
+
const record = { started_at: state.started_at, ended_at: state.ended_at, passes: state.passes, signals_written: state.signals_written, lanes: state.lanes };
|
|
100
|
+
atomic(result, record);
|
|
101
|
+
emit({ company: state.company, meeting_id: state.meeting_id }, record);
|
|
102
|
+
fs.unlinkSync(lock);
|
|
103
|
+
};
|
|
104
|
+
const loop = async (state, resumed) => {
|
|
105
|
+
const file = paths(state).lock;
|
|
106
|
+
if (state.ended_at) {
|
|
107
|
+
finish(state);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let first = !resumed || state.passes === 0;
|
|
111
|
+
while (!controller.signal.aborted) {
|
|
112
|
+
if (!deps.enabled()) {
|
|
113
|
+
deps.log("rider disabled");
|
|
114
|
+
fs.unlinkSync(file);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const started = now().getTime();
|
|
118
|
+
const status = await deps.live({ meeting_id: state.meeting_id, company: state.company });
|
|
119
|
+
if (controller.signal.aborted)
|
|
120
|
+
return;
|
|
121
|
+
if (!status.attended) {
|
|
122
|
+
deps.log(`rider skipped ${state.meeting_id}: owner did not attend`);
|
|
123
|
+
fs.unlinkSync(file);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
state.end_polls = terminal(status.status) ? state.end_polls + 1 : 0;
|
|
127
|
+
// A detection delivered after the meeting ended needs just one final pass.
|
|
128
|
+
const ended = (first && terminal(status.status)) || state.end_polls >= END_POLLS;
|
|
129
|
+
first = false;
|
|
130
|
+
const pass = await deps.runIncremental({ meeting_id: state.meeting_id, company: state.company });
|
|
131
|
+
if (pass.skipped) {
|
|
132
|
+
deps.log(`rider ${state.meeting_id} waiting for the signals lock`);
|
|
133
|
+
// Keep a pending final pass pending even when the first read was terminal.
|
|
134
|
+
if (ended)
|
|
135
|
+
state.end_polls = END_POLLS;
|
|
136
|
+
persist(state);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
state.passes++;
|
|
140
|
+
state.signals_written = [...new Set([...state.signals_written, ...pass.signal_ids])];
|
|
141
|
+
state.lanes = [...new Set([...state.lanes, ...(pass.lanes ?? [])])];
|
|
142
|
+
deps.log(`rider ${state.meeting_id} pass ${state.passes}`);
|
|
143
|
+
// Persist progress even on a stop; the next process resumes this cursor.
|
|
144
|
+
persist(state);
|
|
145
|
+
if (controller.signal.aborted)
|
|
146
|
+
return;
|
|
147
|
+
if (ended) {
|
|
148
|
+
if (!deps.enabled()) {
|
|
149
|
+
deps.log("rider disabled");
|
|
150
|
+
fs.unlinkSync(file);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
state.ended_at = now().toISOString();
|
|
154
|
+
persist(state);
|
|
155
|
+
finish(state);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const seconds = deps.intervalSeconds?.() ?? 20;
|
|
160
|
+
const cadence = Number.isFinite(seconds) ? Math.max(5, seconds) * 1000 : 20_000;
|
|
161
|
+
await (deps.sleep ?? ((ms, signal) => delay(ms, undefined, { signal })))(Math.max(0, cadence - (now().getTime() - started)), controller.signal);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const launch = (state, resumed) => {
|
|
165
|
+
const key = paths(state).lock;
|
|
166
|
+
const task = loop(state, resumed).catch(() => {
|
|
167
|
+
if (!controller.signal.aborted)
|
|
168
|
+
deps.log(`rider ${state.meeting_id} interrupted; retained lock for restart`);
|
|
169
|
+
}).finally(() => running.delete(key));
|
|
170
|
+
running.set(key, task);
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
/** Synchronously persists intent before the event handler may acknowledge delivery. */
|
|
174
|
+
start(payload) {
|
|
175
|
+
if (!deps.enabled()) {
|
|
176
|
+
deps.log("rider disabled");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!payload || typeof payload !== "object")
|
|
180
|
+
throw new Error("Invalid meeting.detected payload");
|
|
181
|
+
const { company, meeting_id } = payload;
|
|
182
|
+
if (typeof company !== "string" || typeof meeting_id !== "string")
|
|
183
|
+
throw new Error("meeting.detected needs company and meeting_id");
|
|
184
|
+
const meeting = { company, meeting_id };
|
|
185
|
+
const { lock, result } = paths(meeting);
|
|
186
|
+
if (fs.existsSync(result) && !fs.existsSync(lock))
|
|
187
|
+
return;
|
|
188
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
189
|
+
const state = { ...meeting, bot: deps.bot, pid: process.pid, started_at: now().toISOString(), passes: 0, end_polls: 0, signals_written: [], lanes: [] };
|
|
190
|
+
// Publish a complete intent atomically without replacing an existing lock.
|
|
191
|
+
const intent = `${lock}.${randomUUID()}.tmp`;
|
|
192
|
+
fs.writeFileSync(intent, JSON.stringify(state), { mode: 0o600 });
|
|
193
|
+
try {
|
|
194
|
+
fs.linkSync(intent, lock);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err.code !== "EEXIST")
|
|
198
|
+
throw err;
|
|
199
|
+
deps.log(`rider already running: ${meeting_id}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
fs.unlinkSync(intent);
|
|
204
|
+
}
|
|
205
|
+
launch(state, false);
|
|
206
|
+
},
|
|
207
|
+
resume() {
|
|
208
|
+
if (!deps.enabled()) {
|
|
209
|
+
deps.log("rider disabled");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!fs.existsSync(root))
|
|
213
|
+
return;
|
|
214
|
+
for (const company of fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory())) {
|
|
215
|
+
for (const name of fs.readdirSync(path.join(root, company.name)).filter((name) => name.endsWith(".rider.lock"))) {
|
|
216
|
+
const file = path.join(root, company.name, name);
|
|
217
|
+
try {
|
|
218
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
219
|
+
if (state.bot !== deps.bot || paths(state).lock !== file || running.has(file))
|
|
220
|
+
continue;
|
|
221
|
+
if ((deps.isAlive ?? alive)(state.pid))
|
|
222
|
+
continue;
|
|
223
|
+
if (now().getTime() - fs.statSync(file).mtimeMs >= MAX_LOCK_AGE) {
|
|
224
|
+
fs.unlinkSync(file);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
state.pid = process.pid;
|
|
228
|
+
persist(state);
|
|
229
|
+
launch(state, true);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
deps.log(`rider lock could not be resumed: ${name}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
async settled() { await Promise.all(running.values()); },
|
|
238
|
+
async stop() { controller.abort(); await Promise.all(running.values()); },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Production dependencies use the same attended endpoint as `meetings live`. */
|
|
242
|
+
export function defaultMeetingRiderDeps(config, dir, log) {
|
|
243
|
+
return {
|
|
244
|
+
hqRoot: config.hqRoot, dir, bot: config.name, log,
|
|
245
|
+
enabled: () => meetingRiderSettings(config.hqRoot).enabled,
|
|
246
|
+
intervalSeconds: () => meetingRiderSettings(config.hqRoot).interval,
|
|
247
|
+
live: async ({ company, meeting_id }) => {
|
|
248
|
+
const token = await ensureCognitoToken();
|
|
249
|
+
const companyId = await getCompanyUid(token, company);
|
|
250
|
+
const response = await vaultApiFetch({ token, path: "/v1/meetings", query: { companyId, view: "live" } });
|
|
251
|
+
if (!response.ok)
|
|
252
|
+
throw new Error("Could not check attended meetings");
|
|
253
|
+
const data = await response.json();
|
|
254
|
+
if (!Array.isArray(data.bots))
|
|
255
|
+
throw new Error("Invalid attended meetings response");
|
|
256
|
+
const row = data.bots.find((bot) => bot && typeof bot === "object" &&
|
|
257
|
+
(bot.companyId === undefined || bot.companyId === companyId) &&
|
|
258
|
+
(bot.meetingId === meeting_id || bot.recallBotId === meeting_id || bot.botId === meeting_id));
|
|
259
|
+
if (row && typeof row.status !== "string")
|
|
260
|
+
throw new Error("Invalid attended meeting status");
|
|
261
|
+
return { attended: Boolean(row), status: typeof row?.status === "string" ? row.status : "unknown" };
|
|
262
|
+
},
|
|
263
|
+
runIncremental: async ({ company, meeting_id }) => {
|
|
264
|
+
const source = meetingSignalSourceRef(meeting_id);
|
|
265
|
+
const result = await runSignals({ hqRoot: config.hqRoot, company, source, incremental: true, trigger: "manual",
|
|
266
|
+
incrementalOptions: { onSignalWritten: async ({ event, ...payload }) => { emitBotEvent(dir, event, payload); } },
|
|
267
|
+
});
|
|
268
|
+
if (result.locked?.some((record) => record.company === company))
|
|
269
|
+
return { signal_ids: [], skipped: true };
|
|
270
|
+
if (!result.records.length || result.records.some((record) => record.status !== "complete"))
|
|
271
|
+
throw new Error("Incremental pass did not complete");
|
|
272
|
+
const file = path.join(config.hqRoot, cursorPath(company, source));
|
|
273
|
+
const cursor = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : { signal_ids: [] };
|
|
274
|
+
// US-003 adds signal metadata to lane records. Reading it is additive:
|
|
275
|
+
// older lanes have none and are not included in this source's summary.
|
|
276
|
+
const lanes = readAllLanes(config.hqRoot).filter((lane) => {
|
|
277
|
+
const signal = lane.signal;
|
|
278
|
+
return lane.company?.slug === company && signal?.source_ref === source;
|
|
279
|
+
}).map((lane) => lane.lane_id);
|
|
280
|
+
return { signal_ids: cursor.signal_ids, lanes };
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=meeting-rider.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type BotConfig } from "../config.js";
|
|
2
|
+
import { type LocalJob } from "../local-jobs.js";
|
|
3
|
+
import type { JobRunCompletion } from "../api.js";
|
|
4
|
+
export declare const SIGNAL_TO_LANE_JOB_KIND = "signal-to-lane";
|
|
5
|
+
export declare function signalLaneCompanies(config: BotConfig): string[];
|
|
6
|
+
export declare function ensureSignalToLaneJobs(dir: string, config: BotConfig, companies?: string[], home?: string): LocalJob[];
|
|
7
|
+
/** Only new locally extracted signals reach here; cloud upload/garden is separate. */
|
|
8
|
+
export declare function emitExecutiveSignalWritten(hqRoot: string, company: string, payload: Record<string, string>, root?: string, home?: string): boolean;
|
|
9
|
+
/** Persist intent before publishing a new signal file; ordinary redo creates none. */
|
|
10
|
+
export declare function prepareSignalPublication(hqRoot: string, company: string, payload: Record<string, string>): void;
|
|
11
|
+
/** A pre-append crash retries; a post-append crash uses the event journal's dedupe. */
|
|
12
|
+
export declare function flushSignalPublications(hqRoot: string, company: string, emit?: typeof emitExecutiveSignalWritten): void;
|
|
13
|
+
type Create = (input: {
|
|
14
|
+
hqRoot: string;
|
|
15
|
+
company: string;
|
|
16
|
+
signal_id: string;
|
|
17
|
+
}) => Promise<{
|
|
18
|
+
lane_id: string;
|
|
19
|
+
existing: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function runSignalToLane(input: {
|
|
22
|
+
dir: string;
|
|
23
|
+
config: BotConfig;
|
|
24
|
+
payload: unknown;
|
|
25
|
+
company: unknown;
|
|
26
|
+
log: (message: string) => void;
|
|
27
|
+
create?: Create;
|
|
28
|
+
companies?: string[];
|
|
29
|
+
home?: string;
|
|
30
|
+
}): Promise<JobRunCompletion>;
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=signal-to-lane.d.ts.map
|