@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Process Table (see ../../../CONTEXT.md; ADR-0009): the Foreman's local record of live
|
|
3
|
+
* Workers, OS process, vendor session id, Account, last output time, kept in SQLite on the machine
|
|
4
|
+
* that runs them, with an outbox for Run Record writes that could not reach Opsee. Nothing here is
|
|
5
|
+
* a fact about the work: those go to the Run Record first (ADR-0009), and deleting this file loses
|
|
6
|
+
* nothing about a Run except the ability to reattach to processes that are already gone (story 52).
|
|
7
|
+
*
|
|
8
|
+
* Three tables:
|
|
9
|
+
*
|
|
10
|
+
* - `workers`: one row per Worker the Foreman has running or believes it has (a crash leaves rows
|
|
11
|
+
* behind; Reconcile is what settles them). `session_id` is written the moment the adapter's
|
|
12
|
+
* `started` event arrives, before any further output, so a crash right after launch is resumable.
|
|
13
|
+
* - `outbox`: Run Record event batches that could not be appended, in id order, which is the order
|
|
14
|
+
* they must reach Opsee in. `drain` stops at the first failure so order is never broken.
|
|
15
|
+
* - `runs`: Run requests `foreman run` hands the daemon (story 13); the daemon serves the oldest
|
|
16
|
+
* pending one per tick, skipping those of a paused Initiative, and puts a request back to
|
|
17
|
+
* pending when its Initiative is paused mid-Run. One row per request, whatever the Slots the Run
|
|
18
|
+
* it starts then fills.
|
|
19
|
+
* - `pauses`: the Initiatives whose Run a human has paused (story 55): no new dispatch until
|
|
20
|
+
* resumed, in-flight Workers finish. One row per paused Initiative.
|
|
21
|
+
*
|
|
22
|
+
* A Worker row also carries a `control` mark, the machine-local side of the human's steering
|
|
23
|
+
* (stories 53-56; the Run Record gets a `control` event): `attached` while a human has the session
|
|
24
|
+
* as an attended turn (Reconcile leaves the row alone), `released` once handed back (the next tick
|
|
25
|
+
* resumes it unattended), `cancelled` once `foreman cancel` stopped it (whoever owns the turn, or
|
|
26
|
+
* the next tick once the pid is dead, closes the attempt). The command that sets the mark is the
|
|
27
|
+
* one that stops the process and clears the pid it stopped; the process that owns the turn reads
|
|
28
|
+
* the mark when the stream ends and settles accordingly, and Reconcile closes a cancelled row only
|
|
29
|
+
* when nothing is alive to own it, so the attempt is recorded once.
|
|
30
|
+
*
|
|
31
|
+
* The driver is `node:sqlite`, built into the Node the CLI runs on (see the decision on Initiative
|
|
32
|
+
* 17): no native build, no dependency. One connection per process; the daemon and a `foreman run`
|
|
33
|
+
* from another terminal share the file through SQLite's own locking, and every statement here is
|
|
34
|
+
* short.
|
|
35
|
+
*/
|
|
36
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
37
|
+
import { dirname } from "node:path";
|
|
38
|
+
import { DatabaseSync } from "node:sqlite";
|
|
39
|
+
import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf";
|
|
40
|
+
import { RunEventInputSchema, type RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
|
|
41
|
+
import type { Vendor } from "../vendor.js";
|
|
42
|
+
|
|
43
|
+
/** The human's steering mark on a Worker row (see the module comment). */
|
|
44
|
+
export type WorkerControl = "attached" | "released" | "cancelled";
|
|
45
|
+
|
|
46
|
+
export interface WorkerRow {
|
|
47
|
+
taskId: number;
|
|
48
|
+
initiativeId: number;
|
|
49
|
+
/** Human identifier (OPS-267), for the transcript file name and the log. */
|
|
50
|
+
identifier: string;
|
|
51
|
+
account: string;
|
|
52
|
+
vendor: Vendor;
|
|
53
|
+
/** Unset until the adapter's `started` event; a row without one cannot be resumed, only restarted. */
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
/** The Worker's OS pid, when the adapter exposes one; unset for adapters that do not (the fake).
|
|
56
|
+
* During a Verifier round it is the Verifier turn's pid (core/verifier.ts). */
|
|
57
|
+
pid?: number;
|
|
58
|
+
/** The pid (and, spawned detached, the process group) of the app a Verifier round has running
|
|
59
|
+
* for this Task; unset outside a round. `foreman cancel` stops it with the Worker, and
|
|
60
|
+
* Reconcile stops one a dead Foreman left running. */
|
|
61
|
+
appPid?: number;
|
|
62
|
+
workspace: string;
|
|
63
|
+
branch: string;
|
|
64
|
+
/** Epoch milliseconds. */
|
|
65
|
+
startedAt: number;
|
|
66
|
+
lastOutputAt: number;
|
|
67
|
+
attempt: number;
|
|
68
|
+
/** Set by `foreman attach`, `release` and `cancel`; unset for a Worker nobody has steered. */
|
|
69
|
+
control?: WorkerControl;
|
|
70
|
+
/** Epoch milliseconds of the last control change. */
|
|
71
|
+
controlAt?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface OutboxRow {
|
|
75
|
+
id: number;
|
|
76
|
+
initiativeId: number;
|
|
77
|
+
events: RunEventInput[];
|
|
78
|
+
createdAt: number;
|
|
79
|
+
/** How many drains have failed on this batch. */
|
|
80
|
+
attempts: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RunRequestStatus = "pending" | "running" | "done" | "failed";
|
|
84
|
+
|
|
85
|
+
export interface RunRequestRow {
|
|
86
|
+
id: number;
|
|
87
|
+
initiativeId: number;
|
|
88
|
+
account?: string;
|
|
89
|
+
once: boolean;
|
|
90
|
+
taskId?: number;
|
|
91
|
+
/** The checkout the request was made from; the daemon serves only its own. */
|
|
92
|
+
repoRoot?: string;
|
|
93
|
+
maxTurns?: number;
|
|
94
|
+
stallTimeoutMs?: number;
|
|
95
|
+
requestedAt: number;
|
|
96
|
+
startedAt?: number;
|
|
97
|
+
finishedAt?: number;
|
|
98
|
+
status: RunRequestStatus;
|
|
99
|
+
error?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NewRunRequest {
|
|
103
|
+
initiativeId: number;
|
|
104
|
+
account?: string;
|
|
105
|
+
once?: boolean;
|
|
106
|
+
taskId?: number;
|
|
107
|
+
repoRoot?: string;
|
|
108
|
+
maxTurns?: number;
|
|
109
|
+
stallTimeoutMs?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The seam the Run loop and Reconcile use; `ProcessTable` is the SQLite implementation. */
|
|
113
|
+
export interface ProcessTableApi {
|
|
114
|
+
upsertWorker(row: WorkerRow): void;
|
|
115
|
+
/** Records the vendor session id (and pid) the moment `started` arrives. */
|
|
116
|
+
setSession(taskId: number, sessionId: string, pid?: number): void;
|
|
117
|
+
/** Records the OS pid the moment the launch returns one, before the vendor has said anything:
|
|
118
|
+
* a row with a pid and no session is a Worker that may still be running. */
|
|
119
|
+
setPid(taskId: number, pid: number): void;
|
|
120
|
+
/** Forgets the pid once the process it named is known to be gone (`foreman attach` and `cancel`
|
|
121
|
+
* stopped it), so a later check never mistakes a recycled pid for the Worker. */
|
|
122
|
+
clearPid(taskId: number): void;
|
|
123
|
+
/** Records the pid of the app a Verifier round started for the Task, or forgets it (undefined)
|
|
124
|
+
* once the app is stopped. */
|
|
125
|
+
setAppPid(taskId: number, pid: number | undefined): void;
|
|
126
|
+
/** Bumps `lastOutputAt`; callers throttle. */
|
|
127
|
+
touch(taskId: number, at: number): void;
|
|
128
|
+
removeWorker(taskId: number): void;
|
|
129
|
+
liveWorkers(filter?: { initiativeId?: number; account?: string }): WorkerRow[];
|
|
130
|
+
worker(taskId: number): WorkerRow | undefined;
|
|
131
|
+
/** Sets or clears (undefined) the control mark on a row; false when there is no such row. */
|
|
132
|
+
setControl(taskId: number, control: WorkerControl | undefined, at: number): boolean;
|
|
133
|
+
/** Whether a human has paused new dispatch for the Initiative (story 55). */
|
|
134
|
+
isPaused(initiativeId: number): boolean;
|
|
135
|
+
enqueue(initiativeId: number, events: RunEventInput[]): number;
|
|
136
|
+
pending(filter?: { initiativeId?: number }): OutboxRow[];
|
|
137
|
+
/** Sends pending batches in id order through `sender`; stops at the first failure (the batch
|
|
138
|
+
* stays, its attempt count grows) and reports how many went and what stopped it. A failure
|
|
139
|
+
* `discard` accepts (the backend refused the batch and would refuse it forever) drops the batch
|
|
140
|
+
* instead, so one dead batch does not hold every later one; `discarded` counts those. */
|
|
141
|
+
drain(sender: (initiativeId: number, events: RunEventInput[]) => Promise<unknown>, options?: DrainOptions): Promise<DrainResult>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface DrainOptions {
|
|
145
|
+
initiativeId?: number;
|
|
146
|
+
discard?: (error: unknown) => boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DrainResult {
|
|
150
|
+
sent: number;
|
|
151
|
+
remaining: number;
|
|
152
|
+
/** Batches dropped because the backend refused them; each is logged by the caller. */
|
|
153
|
+
discarded: OutboxRow[];
|
|
154
|
+
/** The error that stopped the drain, when one did. */
|
|
155
|
+
error?: Error;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const SCHEMA = `
|
|
159
|
+
CREATE TABLE IF NOT EXISTS workers (
|
|
160
|
+
task_id INTEGER PRIMARY KEY,
|
|
161
|
+
initiative_id INTEGER NOT NULL,
|
|
162
|
+
identifier TEXT NOT NULL,
|
|
163
|
+
account TEXT NOT NULL,
|
|
164
|
+
vendor TEXT NOT NULL,
|
|
165
|
+
session_id TEXT,
|
|
166
|
+
pid INTEGER,
|
|
167
|
+
workspace TEXT NOT NULL,
|
|
168
|
+
branch TEXT NOT NULL,
|
|
169
|
+
started_at INTEGER NOT NULL,
|
|
170
|
+
last_output_at INTEGER NOT NULL,
|
|
171
|
+
attempt INTEGER NOT NULL,
|
|
172
|
+
settled_at INTEGER
|
|
173
|
+
);
|
|
174
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
initiative_id INTEGER NOT NULL,
|
|
177
|
+
events_json TEXT NOT NULL,
|
|
178
|
+
created_at INTEGER NOT NULL,
|
|
179
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
180
|
+
);
|
|
181
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
182
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
|
+
initiative_id INTEGER NOT NULL,
|
|
184
|
+
account TEXT,
|
|
185
|
+
once INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
task_id INTEGER,
|
|
187
|
+
repo_root TEXT,
|
|
188
|
+
max_turns INTEGER,
|
|
189
|
+
stall_timeout_ms INTEGER,
|
|
190
|
+
requested_at INTEGER NOT NULL,
|
|
191
|
+
started_at INTEGER,
|
|
192
|
+
finished_at INTEGER,
|
|
193
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
194
|
+
error TEXT
|
|
195
|
+
);
|
|
196
|
+
CREATE TABLE IF NOT EXISTS pauses (
|
|
197
|
+
initiative_id INTEGER PRIMARY KEY,
|
|
198
|
+
paused_at INTEGER NOT NULL
|
|
199
|
+
);
|
|
200
|
+
`;
|
|
201
|
+
|
|
202
|
+
/** Columns added after the first release; a table created before them gets them on open. */
|
|
203
|
+
const WORKER_COLUMNS_ADDED: ReadonlyArray<{ name: string; ddl: string }> = [
|
|
204
|
+
{ name: "control", ddl: "control TEXT" },
|
|
205
|
+
{ name: "control_at", ddl: "control_at INTEGER" },
|
|
206
|
+
{ name: "app_pid", ddl: "app_pid INTEGER" },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
type Row = Record<string, string | number | bigint | null | Uint8Array>;
|
|
210
|
+
|
|
211
|
+
const num = (v: Row[string]): number => Number(v);
|
|
212
|
+
const opt = <T>(v: Row[string], map: (x: Exclude<Row[string], null>) => T): T | undefined => (v === null || v === undefined ? undefined : map(v));
|
|
213
|
+
|
|
214
|
+
function workerOf(r: Row): WorkerRow {
|
|
215
|
+
return {
|
|
216
|
+
taskId: num(r.task_id),
|
|
217
|
+
initiativeId: num(r.initiative_id),
|
|
218
|
+
identifier: String(r.identifier),
|
|
219
|
+
account: String(r.account),
|
|
220
|
+
vendor: String(r.vendor) as Vendor,
|
|
221
|
+
sessionId: opt(r.session_id, String),
|
|
222
|
+
pid: opt(r.pid, num),
|
|
223
|
+
appPid: opt(r.app_pid, num),
|
|
224
|
+
workspace: String(r.workspace),
|
|
225
|
+
branch: String(r.branch),
|
|
226
|
+
startedAt: num(r.started_at),
|
|
227
|
+
lastOutputAt: num(r.last_output_at),
|
|
228
|
+
attempt: num(r.attempt),
|
|
229
|
+
control: opt(r.control, (v) => String(v) as WorkerControl),
|
|
230
|
+
controlAt: opt(r.control_at, num),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runOf(r: Row): RunRequestRow {
|
|
235
|
+
return {
|
|
236
|
+
id: num(r.id),
|
|
237
|
+
initiativeId: num(r.initiative_id),
|
|
238
|
+
account: opt(r.account, String),
|
|
239
|
+
once: num(r.once) === 1,
|
|
240
|
+
taskId: opt(r.task_id, num),
|
|
241
|
+
repoRoot: opt(r.repo_root, String),
|
|
242
|
+
maxTurns: opt(r.max_turns, num),
|
|
243
|
+
stallTimeoutMs: opt(r.stall_timeout_ms, num),
|
|
244
|
+
requestedAt: num(r.requested_at),
|
|
245
|
+
startedAt: opt(r.started_at, num),
|
|
246
|
+
finishedAt: opt(r.finished_at, num),
|
|
247
|
+
status: String(r.status) as RunRequestStatus,
|
|
248
|
+
error: opt(r.error, String),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function serializeEvents(events: RunEventInput[]): string {
|
|
253
|
+
return JSON.stringify(events.map((e) => toJson(RunEventInputSchema, e)));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function deserializeEvents(json: string): RunEventInput[] {
|
|
257
|
+
return (JSON.parse(json) as JsonValue[]).map((j) => fromJson(RunEventInputSchema, j));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class ProcessTable implements ProcessTableApi {
|
|
261
|
+
private readonly db: DatabaseSync;
|
|
262
|
+
|
|
263
|
+
/** The clock every row's timestamp is taken from. Injectable so a test can put worker rows and
|
|
264
|
+
* Run requests on one timeline: mixing a frozen clock for the first with the real one for the
|
|
265
|
+
* second makes an ordering assertion depend on the wall-clock date it is run on. */
|
|
266
|
+
private readonly now: () => number;
|
|
267
|
+
|
|
268
|
+
private constructor(readonly path: string, now: () => number = Date.now) {
|
|
269
|
+
this.now = now;
|
|
270
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
271
|
+
this.db = new DatabaseSync(path);
|
|
272
|
+
// Owner-only, like the credentials and Accounts files beside it; the driver takes no mode.
|
|
273
|
+
if (path !== ":memory:") chmodSync(path, 0o600);
|
|
274
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
|
|
275
|
+
this.db.exec(SCHEMA);
|
|
276
|
+
const have = new Set((this.db.prepare("PRAGMA table_info(workers)").all() as Row[]).map((c) => String(c.name)));
|
|
277
|
+
for (const column of WORKER_COLUMNS_ADDED) if (!have.has(column.name)) this.db.exec(`ALTER TABLE workers ADD COLUMN ${column.ddl}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Opens (creating when missing) the table at `path`; `:memory:` for tests that need no file. */
|
|
281
|
+
static open(path: string, options: { now?: () => number } = {}): ProcessTable {
|
|
282
|
+
return new ProcessTable(path, options.now);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
close(): void {
|
|
286
|
+
this.db.close();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- workers ---
|
|
290
|
+
|
|
291
|
+
upsertWorker(row: WorkerRow): void {
|
|
292
|
+
this.db
|
|
293
|
+
.prepare(
|
|
294
|
+
`INSERT INTO workers (task_id, initiative_id, identifier, account, vendor, session_id, pid, app_pid, workspace, branch, started_at, last_output_at, attempt, control, control_at)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
296
|
+
ON CONFLICT(task_id) DO UPDATE SET initiative_id = excluded.initiative_id, identifier = excluded.identifier, account = excluded.account,
|
|
297
|
+
vendor = excluded.vendor, session_id = excluded.session_id, pid = excluded.pid, app_pid = excluded.app_pid, workspace = excluded.workspace, branch = excluded.branch,
|
|
298
|
+
started_at = excluded.started_at, last_output_at = excluded.last_output_at, attempt = excluded.attempt, control = excluded.control, control_at = excluded.control_at`,
|
|
299
|
+
)
|
|
300
|
+
.run(
|
|
301
|
+
row.taskId,
|
|
302
|
+
row.initiativeId,
|
|
303
|
+
row.identifier,
|
|
304
|
+
row.account,
|
|
305
|
+
row.vendor,
|
|
306
|
+
row.sessionId ?? null,
|
|
307
|
+
row.pid ?? null,
|
|
308
|
+
row.appPid ?? null,
|
|
309
|
+
row.workspace,
|
|
310
|
+
row.branch,
|
|
311
|
+
row.startedAt,
|
|
312
|
+
row.lastOutputAt,
|
|
313
|
+
row.attempt,
|
|
314
|
+
row.control ?? null,
|
|
315
|
+
row.controlAt ?? null,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
setControl(taskId: number, control: WorkerControl | undefined, at: number): boolean {
|
|
320
|
+
return Number(this.db.prepare("UPDATE workers SET control = ?, control_at = ? WHERE task_id = ?").run(control ?? null, at, taskId).changes) > 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
setSession(taskId: number, sessionId: string, pid?: number): void {
|
|
324
|
+
this.db.prepare("UPDATE workers SET session_id = ?, pid = COALESCE(?, pid) WHERE task_id = ?").run(sessionId, pid ?? null, taskId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
setPid(taskId: number, pid: number): void {
|
|
328
|
+
this.db.prepare("UPDATE workers SET pid = ? WHERE task_id = ?").run(pid, taskId);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
clearPid(taskId: number): void {
|
|
332
|
+
this.db.prepare("UPDATE workers SET pid = NULL WHERE task_id = ?").run(taskId);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
setAppPid(taskId: number, pid: number | undefined): void {
|
|
336
|
+
this.db.prepare("UPDATE workers SET app_pid = ? WHERE task_id = ?").run(pid ?? null, taskId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
touch(taskId: number, at: number): void {
|
|
340
|
+
this.db.prepare("UPDATE workers SET last_output_at = ? WHERE task_id = ?").run(at, taskId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
removeWorker(taskId: number): void {
|
|
344
|
+
this.db.prepare("DELETE FROM workers WHERE task_id = ?").run(taskId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
worker(taskId: number): WorkerRow | undefined {
|
|
348
|
+
const r = this.db.prepare("SELECT * FROM workers WHERE task_id = ?").get(taskId) as Row | undefined;
|
|
349
|
+
return r ? workerOf(r) : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
liveWorkers(filter: { initiativeId?: number; account?: string } = {}): WorkerRow[] {
|
|
353
|
+
const where: string[] = [];
|
|
354
|
+
const args: (number | string)[] = [];
|
|
355
|
+
if (filter.initiativeId !== undefined) {
|
|
356
|
+
where.push("initiative_id = ?");
|
|
357
|
+
args.push(filter.initiativeId);
|
|
358
|
+
}
|
|
359
|
+
if (filter.account !== undefined) {
|
|
360
|
+
where.push("account = ?");
|
|
361
|
+
args.push(filter.account);
|
|
362
|
+
}
|
|
363
|
+
const sql = `SELECT * FROM workers${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY started_at, task_id`;
|
|
364
|
+
return (this.db.prepare(sql).all(...args) as Row[]).map(workerOf);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- outbox ---
|
|
368
|
+
|
|
369
|
+
enqueue(initiativeId: number, events: RunEventInput[]): number {
|
|
370
|
+
const result = this.db.prepare("INSERT INTO outbox (initiative_id, events_json, created_at) VALUES (?, ?, ?)").run(initiativeId, serializeEvents(events), this.now());
|
|
371
|
+
return Number(result.lastInsertRowid);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
pending(filter: { initiativeId?: number } = {}): OutboxRow[] {
|
|
375
|
+
const rows =
|
|
376
|
+
filter.initiativeId === undefined
|
|
377
|
+
? (this.db.prepare("SELECT * FROM outbox ORDER BY id").all() as Row[])
|
|
378
|
+
: (this.db.prepare("SELECT * FROM outbox WHERE initiative_id = ? ORDER BY id").all(filter.initiativeId) as Row[]);
|
|
379
|
+
return rows.map((r) => ({ id: num(r.id), initiativeId: num(r.initiative_id), events: deserializeEvents(String(r.events_json)), createdAt: num(r.created_at), attempts: num(r.attempts) }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async drain(sender: (initiativeId: number, events: RunEventInput[]) => Promise<unknown>, options: DrainOptions = {}): Promise<DrainResult> {
|
|
383
|
+
const filter = options.initiativeId === undefined ? {} : { initiativeId: options.initiativeId };
|
|
384
|
+
let sent = 0;
|
|
385
|
+
const discarded: OutboxRow[] = [];
|
|
386
|
+
for (const batch of this.pending(filter)) {
|
|
387
|
+
try {
|
|
388
|
+
await sender(batch.initiativeId, batch.events);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (options.discard?.(error)) {
|
|
391
|
+
this.db.prepare("DELETE FROM outbox WHERE id = ?").run(batch.id);
|
|
392
|
+
discarded.push(batch);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
this.db.prepare("UPDATE outbox SET attempts = attempts + 1 WHERE id = ?").run(batch.id);
|
|
396
|
+
return { sent, remaining: this.pending(filter).length, discarded, error: error instanceof Error ? error : new Error(String(error)) };
|
|
397
|
+
}
|
|
398
|
+
this.db.prepare("DELETE FROM outbox WHERE id = ?").run(batch.id);
|
|
399
|
+
sent++;
|
|
400
|
+
}
|
|
401
|
+
return { sent, remaining: 0, discarded };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- runs ---
|
|
405
|
+
|
|
406
|
+
requestRun(request: NewRunRequest): RunRequestRow {
|
|
407
|
+
const result = this.db
|
|
408
|
+
.prepare("INSERT INTO runs (initiative_id, account, once, task_id, repo_root, max_turns, stall_timeout_ms, requested_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
409
|
+
.run(request.initiativeId, request.account ?? null, request.once ? 1 : 0, request.taskId ?? null, request.repoRoot ?? null, request.maxTurns ?? null, request.stallTimeoutMs ?? null, this.now());
|
|
410
|
+
return this.run(Number(result.lastInsertRowid))!;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
run(id: number): RunRequestRow | undefined {
|
|
414
|
+
const r = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id) as Row | undefined;
|
|
415
|
+
return r ? runOf(r) : undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The oldest pending Run request, marked running, or undefined when none waits. A request for
|
|
419
|
+
* a paused Initiative (story 55) is left pending: serving it would only find the pause again, so
|
|
420
|
+
* it waits for `foreman resume` while the daemon serves the others. */
|
|
421
|
+
takeRun(): RunRequestRow | undefined {
|
|
422
|
+
const r = this.db
|
|
423
|
+
.prepare("SELECT * FROM runs WHERE status = 'pending' AND initiative_id NOT IN (SELECT initiative_id FROM pauses) ORDER BY id LIMIT 1")
|
|
424
|
+
.get() as Row | undefined;
|
|
425
|
+
if (!r) return undefined;
|
|
426
|
+
this.db.prepare("UPDATE runs SET status = 'running', started_at = ? WHERE id = ?").run(this.now(), num(r.id));
|
|
427
|
+
return this.run(num(r.id));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
finishRun(id: number, status: "done" | "failed", error?: string): void {
|
|
431
|
+
this.db.prepare("UPDATE runs SET status = ?, finished_at = ?, error = ? WHERE id = ?").run(status, this.now(), error ?? null, id);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Puts a request the daemon started back to pending, with a note saying why (its Initiative was
|
|
435
|
+
* paused mid-Run); the daemon takes it again once the pause is lifted. */
|
|
436
|
+
requeueRun(id: number, note: string): void {
|
|
437
|
+
this.db.prepare("UPDATE runs SET status = 'pending', started_at = NULL, finished_at = NULL, error = ? WHERE id = ?").run(note, id);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Requests left `running` by a daemon that died; the next daemon puts them back to pending. */
|
|
441
|
+
requeueRunning(): number {
|
|
442
|
+
return Number(this.db.prepare("UPDATE runs SET status = 'pending', started_at = NULL WHERE status = 'running'").run().changes);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- pauses ---
|
|
446
|
+
|
|
447
|
+
/** Pauses new dispatch for the Initiative; idempotent, keeps the first pause time. */
|
|
448
|
+
pause(initiativeId: number, at: number): void {
|
|
449
|
+
this.db.prepare("INSERT INTO pauses (initiative_id, paused_at) VALUES (?, ?) ON CONFLICT(initiative_id) DO NOTHING").run(initiativeId, at);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Lifts the pause; false when the Initiative was not paused. */
|
|
453
|
+
resume(initiativeId: number): boolean {
|
|
454
|
+
return Number(this.db.prepare("DELETE FROM pauses WHERE initiative_id = ?").run(initiativeId).changes) > 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
isPaused(initiativeId: number): boolean {
|
|
458
|
+
return this.pausedAt(initiativeId) !== undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Every Initiative a human has paused, oldest pause first: what `foreman status` shows so a
|
|
462
|
+
* fleet that has stopped dispatching says why. */
|
|
463
|
+
pausedInitiatives(): Array<{ initiativeId: number; pausedAt: number }> {
|
|
464
|
+
return (this.db.prepare("SELECT initiative_id, paused_at FROM pauses ORDER BY paused_at, initiative_id").all() as Row[]).map((r) => ({
|
|
465
|
+
initiativeId: num(r.initiative_id),
|
|
466
|
+
pausedAt: num(r.paused_at),
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The Initiatives this machine has anything of, most recently touched first: one with a live
|
|
471
|
+
* Worker row, or one a Run request named. What `foreman review` defaults to when exactly one
|
|
472
|
+
* comes back, so the usual "what happened last night" needs no id. */
|
|
473
|
+
knownInitiatives(): number[] {
|
|
474
|
+
const rows = this.db
|
|
475
|
+
.prepare(
|
|
476
|
+
`SELECT initiative_id, MAX(at) AS at FROM (
|
|
477
|
+
SELECT initiative_id, last_output_at AS at FROM workers
|
|
478
|
+
UNION ALL
|
|
479
|
+
SELECT initiative_id, COALESCE(finished_at, started_at, requested_at) AS at FROM runs
|
|
480
|
+
) GROUP BY initiative_id ORDER BY at DESC, initiative_id DESC`,
|
|
481
|
+
)
|
|
482
|
+
.all() as Row[];
|
|
483
|
+
return rows.map((r) => num(r.initiative_id));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** When the Initiative was paused, epoch milliseconds; undefined when it is not. */
|
|
487
|
+
pausedAt(initiativeId: number): number | undefined {
|
|
488
|
+
const r = this.db.prepare("SELECT paused_at FROM pauses WHERE initiative_id = ?").get(initiativeId) as Row | undefined;
|
|
489
|
+
return r ? num(r.paused_at) : undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** How long a row with no pid yet is read as a Foreman mid-dispatch rather than as leftover state.
|
|
494
|
+
* The row exists from the moment the dispatch is committed and gets its pid when the adapter
|
|
495
|
+
* launches (core/run.ts), so there is a window in which a live Foreman on an Account is invisible
|
|
496
|
+
* to a pid check. Short, because the other reading of a pid-less row is a Foreman that died in that
|
|
497
|
+
* same window, and one of those must not cost an Account its Failover for the rest of the day. */
|
|
498
|
+
export const DISPATCH_PID_GRACE_MS = 60_000;
|
|
499
|
+
|
|
500
|
+
/** The Workers another Foreman has on an Account right now (ADR-0013: the cap is one vendor
|
|
501
|
+
* identity's, and Slots to enforce it are a Run's, so a second Run opening its own Slots here would
|
|
502
|
+
* double it).
|
|
503
|
+
*
|
|
504
|
+
* A row counts when its pid is alive, and also when it has no pid but was written inside
|
|
505
|
+
* `DISPATCH_PID_GRACE_MS` — that is the dispatch-commit-to-launch window, where a Foreman really is
|
|
506
|
+
* starting a Worker on this Account and no pid check can see it. Rows the caller owns are its own
|
|
507
|
+
* turns, not another Foreman's, and rows whose Worker is provably gone are what Reconcile exists to
|
|
508
|
+
* settle: neither is held against the Account.
|
|
509
|
+
*/
|
|
510
|
+
export function otherForemanWorkersOn(
|
|
511
|
+
table: Pick<ProcessTableApi, "liveWorkers">,
|
|
512
|
+
account: string,
|
|
513
|
+
at: number,
|
|
514
|
+
options: { isAlive?: (pid: number | undefined) => boolean; owns?: (taskId: number) => boolean } = {},
|
|
515
|
+
): WorkerRow[] {
|
|
516
|
+
const isAlive = options.isAlive ?? pidIsAlive;
|
|
517
|
+
return table.liveWorkers({ account }).filter((row) => {
|
|
518
|
+
if (options.owns?.(row.taskId)) return false;
|
|
519
|
+
if (row.pid !== undefined) return isAlive(row.pid);
|
|
520
|
+
return at - row.startedAt < DISPATCH_PID_GRACE_MS;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** True when a process with this pid exists and this user may signal it. Unset means unknown,
|
|
525
|
+
* which Reconcile treats as gone: a row without a pid can only come from an adapter that never
|
|
526
|
+
* gave one, and then there is nothing to wait for. */
|
|
527
|
+
export function pidIsAlive(pid: number | undefined): boolean {
|
|
528
|
+
if (pid === undefined) return false;
|
|
529
|
+
try {
|
|
530
|
+
process.kill(pid, 0);
|
|
531
|
+
return true;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
534
|
+
}
|
|
535
|
+
}
|