@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,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three read-only views (OPS-280): `opsee foreman status`, `logs <task>` and `review
|
|
3
|
+
* [<initiativeId>]`. Read-only by design — every control the human has is in foreman-control.ts,
|
|
4
|
+
* and nothing here marks a row, stops a process or writes to the Run Record.
|
|
5
|
+
*
|
|
6
|
+
* Where each reads from follows ADR-0009's split. `status` and `logs` are about what is running on
|
|
7
|
+
* *this machine*, so they read the Process Table and the transcripts directory and never the
|
|
8
|
+
* backend: they answer while the network is down, and they answer for a daemon this process has no
|
|
9
|
+
* other channel to. `review` is about what *happened*, which Opsee owns, so it reads the Run
|
|
10
|
+
* Record and the Initiative's memory through the Tracker Adapter.
|
|
11
|
+
*
|
|
12
|
+
* "Within one tick of a change" is why nothing here caches: the Process Table is SQLite on disk
|
|
13
|
+
* that the Run writes as it goes, so each invocation opens it, reads it and is done. `--watch` is
|
|
14
|
+
* the same read on a timer, not a subscription.
|
|
15
|
+
*
|
|
16
|
+
* Everything these commands print goes through core/views.ts, which flattens and strips every
|
|
17
|
+
* string that came off a row, an Account, a Task or an event before it reaches the terminal; see
|
|
18
|
+
* that file's header for why a Task title and a Gate's name are as hostile as an assistant's
|
|
19
|
+
* output.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, openSync, readdirSync, readSync, closeSync, statSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { StringDecoder } from "node:string_decoder";
|
|
24
|
+
import type { AccountStore } from "../foreman/account-store.js";
|
|
25
|
+
import { ForemanError } from "../foreman/core/run.js";
|
|
26
|
+
import { formatReview, formatStatus, formatTranscriptLine, transcriptHeader, type NamedTask, type TranscriptTarget } from "../foreman/core/views.js";
|
|
27
|
+
import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
|
|
28
|
+
import type { ForemanLocal } from "./foreman.js";
|
|
29
|
+
|
|
30
|
+
/** How often `--watch` and a followed transcript re-read. Short enough to feel live, long enough
|
|
31
|
+
* that a terminal left open all night is not a busy loop on a SQLite file. */
|
|
32
|
+
export const DEFAULT_WATCH_MS = 2_000;
|
|
33
|
+
|
|
34
|
+
/** How many lines of a finished Worker's transcript are printed by default. */
|
|
35
|
+
export const DEFAULT_TAIL_LINES = 200;
|
|
36
|
+
|
|
37
|
+
/** How much of a transcript file is read to find its last `n` lines: enough for a long turn's tail,
|
|
38
|
+
* bounded so a multi-megabyte transcript is not pulled into memory to print a screenful. */
|
|
39
|
+
const TAIL_BYTES = 512 * 1024;
|
|
40
|
+
|
|
41
|
+
// --- status ---------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface ForemanStatusDeps {
|
|
44
|
+
store: AccountStore;
|
|
45
|
+
local: ForemanLocal;
|
|
46
|
+
out: (line: string) => void;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
49
|
+
/** Seam for the pid check behind `USED` and the `STATE` column; the real one by default. */
|
|
50
|
+
isAlive?: (pid: number | undefined) => boolean;
|
|
51
|
+
/** Called for a promise that resolves when a watch should stop (Ctrl-C, or a test's own signal).
|
|
52
|
+
* A function rather than a promise because the real one installs `SIGINT`/`SIGTERM` handlers,
|
|
53
|
+
* and installing those for a one-shot `status` that never awaits it turns Ctrl-C into a no-op on
|
|
54
|
+
* a command that should just die. It is called only on the path that actually waits. */
|
|
55
|
+
untilStop?: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ForemanStatusArgs {
|
|
59
|
+
/** Repaint every `watchMs` until stopped, rather than printing once. */
|
|
60
|
+
watch: boolean;
|
|
61
|
+
watchMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One reading of the Process Table and the Accounts file. Both are re-read per call, never held:
|
|
65
|
+
* that is the whole of "reflects the Process Table within one tick of a change". */
|
|
66
|
+
function statusOnce(deps: ForemanStatusDeps): string[] {
|
|
67
|
+
const { local } = deps;
|
|
68
|
+
return formatStatus({
|
|
69
|
+
accounts: deps.store.load(),
|
|
70
|
+
rows: local.table.liveWorkers(),
|
|
71
|
+
paused: local.table.pausedInitiatives(),
|
|
72
|
+
tablePath: local.table.path,
|
|
73
|
+
now: (deps.now ?? Date.now)(),
|
|
74
|
+
isAlive: deps.isAlive,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `opsee foreman status`: per-Account Slots, the live Workers and every Paused state. Exit 0
|
|
79
|
+
* whatever it finds — it reports, it does not judge. */
|
|
80
|
+
export async function runForemanStatus(deps: ForemanStatusDeps, args: ForemanStatusArgs = { watch: false }): Promise<number> {
|
|
81
|
+
if (!args.watch) {
|
|
82
|
+
for (const line of statusOnce(deps)) deps.out(line);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
// Deliberately dumb: re-read and print the whole view again, with a rule and a timestamp between
|
|
86
|
+
// readings. No cursor addressing and no diffing, so the output is the same whether it goes to a
|
|
87
|
+
// terminal, a pipe or a file, and one repaint can never be left half-drawn.
|
|
88
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
89
|
+
const every = args.watchMs ?? DEFAULT_WATCH_MS;
|
|
90
|
+
let stopping = false;
|
|
91
|
+
// Only here: the one-shot above returned before this line, so it never armed a handler.
|
|
92
|
+
const untilStop = deps.untilStop?.();
|
|
93
|
+
void untilStop?.then(() => {
|
|
94
|
+
stopping = true;
|
|
95
|
+
});
|
|
96
|
+
while (!stopping) {
|
|
97
|
+
deps.out(`--- ${new Date((deps.now ?? Date.now)()).toISOString()} · every ${Math.round(every / 1000)}s · Ctrl-C to stop ---`);
|
|
98
|
+
for (const line of statusOnce(deps)) deps.out(line);
|
|
99
|
+
if (stopping) break;
|
|
100
|
+
await (untilStop ? Promise.race([sleep(every), untilStop]) : sleep(every));
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- logs -----------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface ForemanLogsDeps {
|
|
108
|
+
local: ForemanLocal;
|
|
109
|
+
out: (line: string) => void;
|
|
110
|
+
/** Used only to turn a numeric Task id into its identifier when the Process Table has no row for
|
|
111
|
+
* it (a turn that is over). Absent, such a Task is refused with the identifier form to retry with. */
|
|
112
|
+
tracker?: TrackerAdapter;
|
|
113
|
+
sleep?: (ms: number) => Promise<void>;
|
|
114
|
+
/** Called only once the command has decided it is going to follow; see `ForemanStatusDeps`. */
|
|
115
|
+
untilStop?: () => Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ForemanLogsArgs {
|
|
119
|
+
/** `OPS-280` or `1447`: whichever the human has in front of them. */
|
|
120
|
+
task: string;
|
|
121
|
+
/** Lines of the stored tail; also how much of the file is printed before a live follow starts. */
|
|
122
|
+
lines?: number;
|
|
123
|
+
/** Do not follow a running Worker; print what is there and return. */
|
|
124
|
+
noFollow?: boolean;
|
|
125
|
+
pollMs?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `OPS-280-2.jsonl`, asked for as `ops-280` -> attempt 2, identifier `OPS-280`. The store names
|
|
129
|
+
* every file `<identifier>-<attempt>.jsonl` (core/transcripts.ts) and an identifier may itself
|
|
130
|
+
* contain a dash, so the attempt is the trailing run of digits and nothing else. Matched without
|
|
131
|
+
* regard to case, because a human types `ops-280` as readily as `OPS-280`, and the identifier as
|
|
132
|
+
* the file spells it is what comes back, so the header names the Task the way the Tracker does. */
|
|
133
|
+
function transcriptOf(file: string, identifier: string): { identifier: string; attempt: number } | undefined {
|
|
134
|
+
const match = new RegExp(`^(${identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})-(\\d+)\\.jsonl$`, "i").exec(file);
|
|
135
|
+
return match ? { identifier: match[1], attempt: Number(match[2]) } : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Every transcript the store has for an identifier, newest attempt first. Scans the Initiative
|
|
139
|
+
* directories rather than asking for one, so a Task whose Initiative the caller does not know
|
|
140
|
+
* (which is every finished Worker: its row is gone) is still found. */
|
|
141
|
+
function transcriptsFor(root: string, identifier: string): Array<{ path: string; identifier: string; initiativeId: number; attempt: number; mtimeMs: number }> {
|
|
142
|
+
if (!existsSync(root)) return [];
|
|
143
|
+
const found: Array<{ path: string; identifier: string; initiativeId: number; attempt: number; mtimeMs: number }> = [];
|
|
144
|
+
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
145
|
+
if (!dir.isDirectory()) continue;
|
|
146
|
+
const initiativeId = Number(dir.name);
|
|
147
|
+
if (!Number.isInteger(initiativeId)) continue;
|
|
148
|
+
const dirPath = join(root, dir.name);
|
|
149
|
+
for (const file of readdirSync(dirPath)) {
|
|
150
|
+
const match = transcriptOf(file, identifier);
|
|
151
|
+
if (!match) continue;
|
|
152
|
+
const path = join(dirPath, file);
|
|
153
|
+
found.push({ path, identifier: match.identifier, initiativeId, attempt: match.attempt, mtimeMs: statSync(path).mtimeMs });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Newest attempt first, and the newest file where two Initiatives somehow hold the same
|
|
157
|
+
// identifier: the turn the human just watched is the one they mean.
|
|
158
|
+
return found.sort((a, b) => b.attempt - a.attempt || b.mtimeMs - a.mtimeMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The Worker row for a Task reference, when one exists: by id for a number, by identifier
|
|
162
|
+
* otherwise (case-insensitively, since a human types `ops-280` as readily as `OPS-280`). */
|
|
163
|
+
function rowFor(deps: ForemanLogsDeps, task: string) {
|
|
164
|
+
const rows = deps.local.table.liveWorkers();
|
|
165
|
+
if (/^\d+$/.test(task)) return deps.local.table.worker(Number(task));
|
|
166
|
+
const wanted = task.toLowerCase();
|
|
167
|
+
return rows.find((r) => r.identifier.toLowerCase() === wanted);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which transcript `logs` is about, and whether its Worker is still writing to it.
|
|
171
|
+
*
|
|
172
|
+
* A running Worker has a row, and the row names its Initiative and attempt exactly. A finished one
|
|
173
|
+
* has none — the Run removes the row when the turn settles — so the file is found by identifier
|
|
174
|
+
* instead, which is why a bare numeric id for a finished Task has to be turned into an identifier
|
|
175
|
+
* through the Tracker first.
|
|
176
|
+
*/
|
|
177
|
+
export async function resolveTranscript(deps: ForemanLogsDeps, task: string): Promise<TranscriptTarget> {
|
|
178
|
+
const root = deps.local.transcripts.root;
|
|
179
|
+
const row = rowFor(deps, task);
|
|
180
|
+
if (row) {
|
|
181
|
+
const path = deps.local.transcripts.pathFor(row.initiativeId, row.identifier, row.attempt);
|
|
182
|
+
if (existsSync(path)) return { path, identifier: row.identifier, initiativeId: row.initiativeId, attempt: row.attempt, row };
|
|
183
|
+
// The row exists but nothing has been written yet: the dispatch is committed and the adapter
|
|
184
|
+
// has not produced its first event. Say that rather than "no such Task".
|
|
185
|
+
throw new ForemanError(
|
|
186
|
+
`Worker ${row.identifier} is on Task ${row.taskId} (Account ${row.account}, attempt ${row.attempt}) but has written nothing yet: ${path} does not exist. Its first output creates it; try again in a moment.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let identifier = task;
|
|
191
|
+
if (/^\d+$/.test(task)) {
|
|
192
|
+
if (!deps.tracker) {
|
|
193
|
+
throw new ForemanError(
|
|
194
|
+
`No Worker on Task ${task} in the Process Table ${deps.local.table.path}, and this command has no Tracker to look its identifier up with. Pass the Task's identifier instead: opsee foreman logs OPS-123`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
identifier = (await deps.tracker.getTask(Number(task))).task.identifier;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new ForemanError(
|
|
201
|
+
`No Worker on Task ${task} in the Process Table ${deps.local.table.path}, and the Tracker could not say what its identifier is (${error instanceof Error ? error.message : String(error)}). Pass the identifier instead: opsee foreman logs OPS-123`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const [newest] = transcriptsFor(root, identifier);
|
|
207
|
+
if (!newest) {
|
|
208
|
+
throw new ForemanError(
|
|
209
|
+
`Nothing to show for ${identifier}: the Process Table ${deps.local.table.path} has no Worker on it, and ${root} holds no transcript for it. A Task the Foreman has never dispatched on this machine has neither.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return { path: newest.path, identifier: newest.identifier, initiativeId: newest.initiativeId, attempt: newest.attempt };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The last `lines` lines of a file, and the byte offset the file was read to, so a follow can
|
|
216
|
+
* carry on from exactly there without reprinting what it just showed. */
|
|
217
|
+
export function tailOf(path: string, lines: number): { lines: string[]; offset: number } {
|
|
218
|
+
const size = statSync(path).size;
|
|
219
|
+
const from = Math.max(0, size - TAIL_BYTES);
|
|
220
|
+
const fd = openSync(path, "r");
|
|
221
|
+
let text: string;
|
|
222
|
+
try {
|
|
223
|
+
const buffer = Buffer.alloc(size - from);
|
|
224
|
+
readSync(fd, buffer, 0, buffer.length, from);
|
|
225
|
+
text = buffer.toString("utf8");
|
|
226
|
+
} finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
// A read that started mid-file almost certainly started mid-line; that partial first line is
|
|
230
|
+
// dropped rather than printed as a broken event.
|
|
231
|
+
const all = (from > 0 ? text.slice(text.indexOf("\n") + 1) : text).split("\n").filter((l) => l.trim() !== "");
|
|
232
|
+
return { lines: all.slice(-lines), offset: size };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `opsee foreman logs <task>`: one Worker's stream.
|
|
237
|
+
*
|
|
238
|
+
* Live while the Worker is running — the file is polled from where the tail left off and each new
|
|
239
|
+
* line printed as it lands — and the stored tail once the turn is over. The header says which of
|
|
240
|
+
* the two it is, because past the first screenful they read identically.
|
|
241
|
+
*/
|
|
242
|
+
export async function runForemanLogs(deps: ForemanLogsDeps, args: ForemanLogsArgs): Promise<number> {
|
|
243
|
+
const target = await resolveTranscript(deps, args.task);
|
|
244
|
+
const tail = args.lines ?? DEFAULT_TAIL_LINES;
|
|
245
|
+
const live = target.row !== undefined && !args.noFollow;
|
|
246
|
+
for (const line of transcriptHeader(target, { live, tail })) deps.out(line);
|
|
247
|
+
|
|
248
|
+
const start = tailOf(target.path, tail);
|
|
249
|
+
for (const raw of start.lines) for (const line of formatTranscriptLine(raw)) deps.out(line);
|
|
250
|
+
if (!live) return 0;
|
|
251
|
+
|
|
252
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
253
|
+
const every = args.pollMs ?? DEFAULT_WATCH_MS;
|
|
254
|
+
let stopping = false;
|
|
255
|
+
// Only on the following path: a `--no-follow` or a finished Worker returned above without ever
|
|
256
|
+
// asking for this, so neither arms a SIGINT handler it would not then wait on.
|
|
257
|
+
const untilStop = deps.untilStop?.();
|
|
258
|
+
void untilStop?.then(() => {
|
|
259
|
+
stopping = true;
|
|
260
|
+
});
|
|
261
|
+
let offset = start.offset;
|
|
262
|
+
let carry = "";
|
|
263
|
+
// Bytes, not characters: a read that ends mid-codepoint would become a replacement character if
|
|
264
|
+
// each chunk were decoded on its own, and `carry` only preserves a partial *line*. The decoder
|
|
265
|
+
// holds the partial bytes back until the rest of the sequence arrives on a later poll.
|
|
266
|
+
const decoder = new StringDecoder("utf8");
|
|
267
|
+
while (!stopping) {
|
|
268
|
+
await (untilStop ? Promise.race([sleep(every), untilStop]) : sleep(every));
|
|
269
|
+
if (stopping) break;
|
|
270
|
+
// The row going away is the turn settling: read whatever was appended in the meantime, then
|
|
271
|
+
// stop, so a follow never misses the Completion Report it was waiting for. A row that is still
|
|
272
|
+
// there but on a *later attempt* is not this turn: the Run removes the row when a turn settles
|
|
273
|
+
// and the next dispatch upserts a new one for the same Task, and at a 2s poll the gap between
|
|
274
|
+
// the two is usually missed entirely. Comparing the task id alone would have the follow decide
|
|
275
|
+
// attempt 2 was still running and go on tailing attempt 1's file, which nothing will ever
|
|
276
|
+
// append to again, under a header still saying it is streaming.
|
|
277
|
+
const current = deps.local.table.worker(target.row!.taskId);
|
|
278
|
+
const sameTurn = current !== undefined && current.attempt === target.attempt && current.identifier === target.identifier;
|
|
279
|
+
if (existsSync(target.path)) {
|
|
280
|
+
const size = statSync(target.path).size;
|
|
281
|
+
// Truncated or replaced under us (a rotation, a Worker rewriting its own file): the offset is
|
|
282
|
+
// past the end and every later poll would read nothing while the header still claims a live
|
|
283
|
+
// stream. Start again from the top rather than going quietly silent.
|
|
284
|
+
if (size < offset) {
|
|
285
|
+
deps.out("");
|
|
286
|
+
deps.out(`${target.path} is shorter than it was: it has been truncated or replaced, so the follow starts again from the beginning of the file.`);
|
|
287
|
+
offset = 0;
|
|
288
|
+
carry = "";
|
|
289
|
+
}
|
|
290
|
+
if (size > offset) {
|
|
291
|
+
// Bounded per poll. A Worker that dumps a large chunk between two polls would otherwise be
|
|
292
|
+
// allocated whole; the remainder is picked up by the next poll, which is 2s away.
|
|
293
|
+
const to = Math.min(size, offset + TAIL_BYTES);
|
|
294
|
+
const fd = openSync(target.path, "r");
|
|
295
|
+
try {
|
|
296
|
+
const buffer = Buffer.alloc(to - offset);
|
|
297
|
+
const read = readSync(fd, buffer, 0, buffer.length, offset);
|
|
298
|
+
carry += decoder.write(buffer.subarray(0, read));
|
|
299
|
+
offset += read;
|
|
300
|
+
} finally {
|
|
301
|
+
closeSync(fd);
|
|
302
|
+
}
|
|
303
|
+
const parts = carry.split("\n");
|
|
304
|
+
// The last piece has no newline yet: an append still in flight, kept for the next poll.
|
|
305
|
+
carry = parts.pop() ?? "";
|
|
306
|
+
for (const raw of parts) for (const line of formatTranscriptLine(raw)) deps.out(line);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!sameTurn) {
|
|
310
|
+
deps.out("");
|
|
311
|
+
if (current) {
|
|
312
|
+
deps.out(
|
|
313
|
+
`The Worker on ${target.identifier} is now on attempt ${current.attempt}: this turn settled and the Run dispatched again. This file is attempt ${target.attempt} and nothing more will be appended to it; follow the new one with: opsee foreman logs ${target.identifier}`,
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
deps.out(`The Worker on ${target.identifier} is gone from the Process Table: its turn settled. What the Run made of it is on the Task and the Run Record (opsee foreman review).`);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- review ---------------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
export interface ForemanReviewDeps {
|
|
327
|
+
tracker: TrackerAdapter;
|
|
328
|
+
local: ForemanLocal;
|
|
329
|
+
out: (line: string) => void;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface ForemanReviewArgs {
|
|
333
|
+
/** Omitted when this machine has exactly one Initiative to be about. */
|
|
334
|
+
initiativeId?: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The Initiative `review` is about: the one asked for, or the only one this machine has a Worker
|
|
338
|
+
* row or a Run request for. Several, and it asks rather than guessing which night is meant. */
|
|
339
|
+
export function chooseInitiative(local: ForemanLocal, asked: number | undefined): number {
|
|
340
|
+
if (asked !== undefined) return asked;
|
|
341
|
+
const known = local.table.knownInitiatives();
|
|
342
|
+
if (known.length === 1) return known[0];
|
|
343
|
+
if (known.length === 0) {
|
|
344
|
+
throw new ForemanError("Which Initiative? This machine's Process Table has no Worker and no Run request to name one: opsee foreman review <initiativeId>");
|
|
345
|
+
}
|
|
346
|
+
throw new ForemanError(`Which Initiative? This machine has Runs on ${known.join(", ")}: opsee foreman review <initiativeId>`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* `opsee foreman review [<initiativeId>]`: the morning summary of a Run.
|
|
351
|
+
*
|
|
352
|
+
* Three reads, all of them the backend's: the Run Record for what the night did, the Initiative
|
|
353
|
+
* context for the Tasks (so every id in the output is a title a human recognises), and the memory
|
|
354
|
+
* log for the Proposed Learnings, which live there rather than on the Run Record because they come
|
|
355
|
+
* out of Completion Reports (core/learnings.ts).
|
|
356
|
+
*
|
|
357
|
+
* The Initiative context is the one read that may fail on its own — an Initiative the Tracker does
|
|
358
|
+
* not know, a backend that refuses it — and it is not what the Run Record is about, so a failure
|
|
359
|
+
* there leaves the Run's own facts on screen with the Tasks named by id and says why.
|
|
360
|
+
*/
|
|
361
|
+
export async function runForemanReview(deps: ForemanReviewDeps, args: ForemanReviewArgs = {}): Promise<number> {
|
|
362
|
+
const initiativeId = chooseInitiative(deps.local, args.initiativeId);
|
|
363
|
+
const record = await deps.tracker.readRunRecord(initiativeId);
|
|
364
|
+
|
|
365
|
+
const tasks = new Map<number, NamedTask>();
|
|
366
|
+
let initiative: { title: string; status: string } | undefined;
|
|
367
|
+
let contextError: string | undefined;
|
|
368
|
+
let learnings: Array<{ body: string; sourceTaskId?: number; isAgent: boolean }> = [];
|
|
369
|
+
let learningsError: string | undefined;
|
|
370
|
+
try {
|
|
371
|
+
const context = await deps.tracker.getInitiativeContext(initiativeId);
|
|
372
|
+
initiative = { title: context.initiative.title, status: context.initiative.status };
|
|
373
|
+
for (const t of context.tasks) tasks.set(t.id, t);
|
|
374
|
+
// The context read has already walked the whole memory log — `getInitiativeContext` pages it
|
|
375
|
+
// through `listMemory` itself rather than taking the windowed copy off the response — and the
|
|
376
|
+
// Proposed Learnings are in what came back. Asking for them again would page a long-lived
|
|
377
|
+
// Initiative's log a second time for a list this already holds.
|
|
378
|
+
learnings = context.memory.filter((m) => m.kind === "learning" && !m.isSystem);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
contextError = error instanceof Error ? error.message : String(error);
|
|
381
|
+
// The context read is the only one that may fail on its own, and it is not what the Run Record
|
|
382
|
+
// is about. Its Learnings go with it, so they are fetched on their own here: a Tracker that
|
|
383
|
+
// will not describe the Initiative may still serve its memory log.
|
|
384
|
+
try {
|
|
385
|
+
learnings = (await deps.tracker.listMemory(initiativeId, { kinds: ["learning"] })).filter((l) => !l.isSystem);
|
|
386
|
+
} catch (memoryError) {
|
|
387
|
+
learningsError = memoryError instanceof Error ? memoryError.message : String(memoryError);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// `tasksUnavailable` rather than an empty map doing the talking: the Blocked section is read off
|
|
392
|
+
// the Status Label, which lives on the Task, so a task list that could not be read is "not known"
|
|
393
|
+
// and never "nothing is blocked".
|
|
394
|
+
for (const line of formatReview({ initiativeId, initiative, run: record.run, events: record.events, tasks, learnings, tasksUnavailable: contextError !== undefined })) deps.out(line);
|
|
395
|
+
if (contextError) deps.out(`\nTasks are named by id above: the Initiative's task list could not be read (${contextError}).`);
|
|
396
|
+
if (learningsError) deps.out(`\nProposed Learnings could not be read (${learningsError}); the Run Record's own sections above are unaffected.`);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|