@contraagent/api 0.0.0-stage → 0.1.0

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.
Files changed (72) hide show
  1. package/README.md +102 -2
  2. package/dist/client.d.ts +54 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +68 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/derive.d.ts +81 -0
  7. package/dist/derive.d.ts.map +1 -0
  8. package/dist/derive.js +89 -0
  9. package/dist/derive.js.map +1 -0
  10. package/dist/export.d.ts +36 -0
  11. package/dist/export.d.ts.map +1 -0
  12. package/dist/export.js +130 -0
  13. package/dist/export.js.map +1 -0
  14. package/dist/feed.d.ts +27 -0
  15. package/dist/feed.d.ts.map +1 -0
  16. package/dist/feed.js +39 -0
  17. package/dist/feed.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +13 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/outcomes.d.ts +117 -0
  23. package/dist/outcomes.d.ts.map +1 -0
  24. package/dist/outcomes.js +201 -0
  25. package/dist/outcomes.js.map +1 -0
  26. package/dist/panels.d.ts +31 -0
  27. package/dist/panels.d.ts.map +1 -0
  28. package/dist/panels.js +12 -0
  29. package/dist/panels.js.map +1 -0
  30. package/dist/quota.d.ts +32 -0
  31. package/dist/quota.d.ts.map +1 -0
  32. package/dist/quota.js +46 -0
  33. package/dist/quota.js.map +1 -0
  34. package/dist/reasons.d.ts +25 -0
  35. package/dist/reasons.d.ts.map +1 -0
  36. package/dist/reasons.js +46 -0
  37. package/dist/reasons.js.map +1 -0
  38. package/dist/run-state.d.ts +62 -0
  39. package/dist/run-state.d.ts.map +1 -0
  40. package/dist/run-state.js +109 -0
  41. package/dist/run-state.js.map +1 -0
  42. package/dist/schema.d.ts +3718 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/schema.js +3 -0
  45. package/dist/schema.js.map +1 -0
  46. package/dist/spend.d.ts +46 -0
  47. package/dist/spend.d.ts.map +1 -0
  48. package/dist/spend.js +68 -0
  49. package/dist/spend.js.map +1 -0
  50. package/dist/store.d.ts +121 -0
  51. package/dist/store.d.ts.map +1 -0
  52. package/dist/store.js +286 -0
  53. package/dist/store.js.map +1 -0
  54. package/dist/types.d.ts +112 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +19 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +43 -4
  59. package/src/client.ts +119 -0
  60. package/src/derive.ts +155 -0
  61. package/src/export.ts +158 -0
  62. package/src/feed.ts +81 -0
  63. package/src/index.ts +12 -0
  64. package/src/outcomes.ts +267 -0
  65. package/src/panels.ts +51 -0
  66. package/src/quota.ts +78 -0
  67. package/src/reasons.ts +57 -0
  68. package/src/run-state.ts +166 -0
  69. package/src/schema.ts +3719 -0
  70. package/src/spend.ts +108 -0
  71. package/src/store.ts +338 -0
  72. package/src/types.ts +165 -0
package/src/derive.ts ADDED
@@ -0,0 +1,155 @@
1
+ // Everything the views count and list about a run's tasks, derived once per
2
+ // state change: the outcomes, the rows, the counts and the problems. Every view
3
+ // reads `deriveRun(journal, plan)`, so "N of M tasks done" and every cause read
4
+ // the same on every screen. Pure; memoized on the identity of its inputs.
5
+ import { isLaunched, outcomeCounts, problems, taskOutcomes, taskRows } from './outcomes.js';
6
+ import type { Outcome, Problems, TaskOutcome, TaskRow } from './outcomes.js';
7
+ import type { HostSummary, JournalRow, Plan, PlanNode, RunSummary } from './types.js';
8
+
9
+ export interface RunCounts extends Record<Outcome, number> {
10
+ total: number;
11
+ /** done + recovered: "N of M tasks done" in every view. */
12
+ doneTotal: number;
13
+ /** done + recovered + partial + skipped: nothing is left to do. */
14
+ finished: number;
15
+ }
16
+
17
+ /** Tries (journal rows that ran) and rows of tasks that never started (a refusal, a skip). */
18
+ export interface TryCounts {
19
+ tries: number;
20
+ notStarted: number;
21
+ }
22
+
23
+ export interface RunDerived {
24
+ /** Every task of the run, by key: the plan joined with the journal. */
25
+ outcomes: ReadonlyMap<string, TaskOutcome>;
26
+ /** The same tasks as rows, in plan order. */
27
+ rows: readonly TaskRow[];
28
+ counts: RunCounts;
29
+ problems: Problems;
30
+ /** The tasks' kinds, sorted. */
31
+ kinds: readonly string[];
32
+ tries: TryCounts;
33
+ }
34
+
35
+ const NO_PLAN: Plan = { nodes: [], groups: [] };
36
+
37
+ /** Run state replaces the journal and the plan arrays on every change (`reduceRun`), so their identity is the key. */
38
+ const memo = new WeakMap<readonly JournalRow[], WeakMap<Plan, RunDerived>>();
39
+
40
+ /**
41
+ * What the views show about a run's tasks. Without a plan, the tasks are the
42
+ * journal's only. The same `journal` and `plan` objects give the same result object.
43
+ */
44
+ export function deriveRun(journal: readonly JournalRow[], plan: Plan | null | undefined = NO_PLAN): RunDerived {
45
+ const p = plan ?? NO_PLAN;
46
+ let byPlan = memo.get(journal);
47
+ if (!byPlan) memo.set(journal, (byPlan = new WeakMap()));
48
+ let d = byPlan.get(p);
49
+ if (!d) byPlan.set(p, (d = derive(journal, p)));
50
+ return d;
51
+ }
52
+
53
+ function derive(journal: readonly JournalRow[], plan: Plan): RunDerived {
54
+ const outcomes = taskOutcomes(journal, plan);
55
+ const c = outcomeCounts(outcomes);
56
+ return {
57
+ outcomes,
58
+ rows: taskRows(outcomes, plan),
59
+ counts: { ...c, doneTotal: c.done + c.recovered, finished: c.done + c.recovered + c.partial + c.skipped },
60
+ problems: problems(outcomes),
61
+ kinds: [...new Set([...outcomes.values()].map((t) => t.kind))].sort(),
62
+ tries: tryCounts(journal),
63
+ };
64
+ }
65
+
66
+ /** Tries (journal rows that ran) and rows of tasks that never started: the one way the UI counts them. */
67
+ export function tryCounts(journal: readonly JournalRow[]): TryCounts {
68
+ const tries = journal.filter(isLaunched).length;
69
+ return { tries, notStarted: journal.length - tries };
70
+ }
71
+
72
+ export interface Progress {
73
+ total: number;
74
+ /** Plan state `done`: done and done after retrying. */
75
+ done: number;
76
+ /** Plan state `partial`: ok, the output a placeholder. */
77
+ partial: number;
78
+ skipped: number;
79
+ /** Finished (done, partial and skipped) out of all, in percent. */
80
+ pct: number;
81
+ }
82
+
83
+ /** How far a run is, from the plan's groups. */
84
+ export function progress(plan: Plan): Progress {
85
+ const total = plan.groups.reduce((a, g) => a + g.total, 0);
86
+ const done = plan.groups.reduce((a, g) => a + g.done, 0);
87
+ const partial = plan.groups.reduce((a, g) => a + (g.partial ?? 0), 0);
88
+ const skipped = plan.groups.reduce((a, g) => a + g.skipped, 0);
89
+ return { total, done, partial, skipped, pct: Math.round((100 * (done + partial + skipped)) / Math.max(1, total)) };
90
+ }
91
+
92
+ /** How a run's tasks ended, from the plan: for a run that is over, in place of a progress bar. */
93
+ export function result(plan: Plan): { done: number; partial: number; skipped: number; failed: number; notStarted: number; left: number } {
94
+ const count = (...states: PlanNode['state'][]) => plan.nodes.filter((n) => states.includes(n.state)).length;
95
+ return { done: count('done'), partial: count('partial'), skipped: count('skipped'), failed: count('failed'), notStarted: count('blocked'), left: count('pending', 'running') };
96
+ }
97
+
98
+ /** How long a run took, wall clock: from its start to its last activity (null when unknown). */
99
+ export function elapsedS(r: Pick<RunSummary, 'started_at' | 'last_ts'> | undefined): number | null {
100
+ const a = Date.parse(r?.started_at ?? '');
101
+ const b = Date.parse(r?.last_ts ?? '');
102
+ return Number.isNaN(a) || Number.isNaN(b) ? null : Math.max(0, (b - a) / 1000);
103
+ }
104
+
105
+ // --- a run's process and its host ----------------------------------------------
106
+
107
+ /**
108
+ * A run's process, as every view names it: `running`; `unresponsive`, its host
109
+ * last reported it alive and has stopped reporting (a hub only), so whether it
110
+ * still runs is not known; or `stopped`.
111
+ */
112
+ export type RunActivity = 'running' | 'unresponsive' | 'stopped';
113
+
114
+ /** What a run's process is doing, from the run list and (when its feed is open) the feed's `procs.alive`. */
115
+ export function runActivity(summary: Pick<RunSummary, 'running' | 'unresponsive'> | undefined, alive = false): RunActivity {
116
+ if (alive || summary?.running) return 'running';
117
+ return summary?.unresponsive ? 'unresponsive' : 'stopped';
118
+ }
119
+
120
+ /** The host that drives a run, from the host list. */
121
+ export const hostOf = (hosts: readonly HostSummary[] | undefined, summary: Pick<RunSummary, 'host_id'> | undefined): HostSummary | undefined =>
122
+ summary?.host_id ? hosts?.find((h) => h.host_id === summary.host_id) : undefined;
123
+
124
+ /** A run's name without its host: a hub names a run `<run_id>@<host_id>`; any other name as it is. */
125
+ export function runIdOf(r: Pick<RunSummary, 'run' | 'host_id'>): string {
126
+ const n = r.run.length - (r.host_id?.length ?? 0) - 1;
127
+ return r.host_id && n > 0 && r.run.endsWith(`@${r.host_id}`) ? r.run.slice(0, n) : r.run;
128
+ }
129
+
130
+ /** One host's runs. */
131
+ export interface HostGroup {
132
+ hostId: string;
133
+ /** The host as the host list has it; undefined for a host the list does not have yet. */
134
+ host: HostSummary | undefined;
135
+ runs: RunSummary[];
136
+ }
137
+
138
+ const ACTIVITY_ORDER: RunActivity[] = ['running', 'unresponsive', 'stopped'];
139
+
140
+ /**
141
+ * Runs by host, in the host list's order (then the host ids it does not have,
142
+ * sorted): in each, the running runs, then those whose host stopped reporting,
143
+ * then the rest, each in the order given (the run list's: most recent first).
144
+ */
145
+ export function hostGroups(runs: readonly RunSummary[], hosts: readonly HostSummary[] = []): HostGroup[] {
146
+ const ids = [...new Set(runs.map((r) => r.host_id ?? ''))];
147
+ const known = hosts.map((h) => h.host_id);
148
+ const rank = (r: RunSummary) => ACTIVITY_ORDER.indexOf(runActivity(r));
149
+ return [...known.filter((id) => ids.includes(id)), ...ids.filter((id) => !known.includes(id)).sort()].map((hostId) => ({
150
+ hostId,
151
+ host: hosts.find((h) => h.host_id === hostId),
152
+ // A stable sort: equal runs keep the order given.
153
+ runs: runs.filter((r) => (r.host_id ?? '') === hostId).sort((a, b) => rank(a) - rank(b)),
154
+ }));
155
+ }
package/src/export.ts ADDED
@@ -0,0 +1,158 @@
1
+ // An export (`contraagent monitor export`): the monitor's views of some runs,
2
+ // written as files and read with no server. The client maps each endpoint to its
3
+ // file; the feed replays a run's files once and ends with `ready`.
4
+ import { ApiError, type MonitorClient } from './client.js';
5
+ import type { FeedHandlers, Unsubscribe } from './feed.js';
6
+ import type {
7
+ CursorView,
8
+ Info,
9
+ JournalPage,
10
+ JsonObject,
11
+ Outputs,
12
+ Plan,
13
+ RunFeedMessage,
14
+ RunSummary,
15
+ TaskDefinition,
16
+ TaskDetail,
17
+ Trends,
18
+ } from './types.js';
19
+
20
+ /** A client of an export: the monitor's client, and the files the server sends over its feed instead. */
21
+ export interface ExportClient extends MonitorClient {
22
+ /** A file of the export, `data/<path>` (cached): the endpoints' answers, and `plan`, `outputs`, `cursor`. */
23
+ file<T>(path: string): Promise<T>;
24
+ }
25
+
26
+ export interface ExportClientOptions {
27
+ fetch?: typeof globalThis.fetch;
28
+ }
29
+
30
+ const seg = encodeURIComponent;
31
+
32
+ /**
33
+ * The name task `key`'s files take in a run directory and in an export, without a
34
+ * suffix: `key` with every character but a letter, a digit, `-`, `_` and `.` turned
35
+ * into `_`, as are a leading `.` and each `..` (Python's `file_name`, `runs/layout.py`).
36
+ */
37
+ export function taskFileName(key: string): string {
38
+ const name = Array.from(key, (ch) => (/^[\p{L}\p{N}._-]$/u.test(ch) ? ch : '_'))
39
+ .join('')
40
+ .replace(/\.\.+/g, (m) => '_'.repeat(m.length));
41
+ return name.startsWith('.') ? `_${name.slice(1)}` : name || '_';
42
+ }
43
+
44
+ /**
45
+ * A `MonitorClient` over an export at `baseUrl` (its `data/`, relative to the page or
46
+ * absolute). An endpoint is a file: `info.json`, `runs.json`, `runs/<run>/journal.json`,
47
+ * `runs/<run>/tasks/<file>.json` (`taskFileName`) ... A task's file leaves out its rows and its
48
+ * definition, which the run's journal and definitions hold: the client puts them back.
49
+ * A view the export does not hold (a transcript, the hosts) answers `ApiError` 404.
50
+ * An endpoint of no run is a file under `data/` (`trends.json`, `trends/panels/<name>.json`):
51
+ * a new endpoint is one method here, `file<T>('<its file>')`, beside its entry in the
52
+ * Python `VIEWS` (`contraagent/monitor/views.py`) or `SITE_FILES` (`contraagent/monitor/export.py`).
53
+ */
54
+ export function createExportClient(baseUrl: string, options: ExportClientOptions = {}): ExportClient {
55
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
56
+ const fetch = options.fetch ?? globalThis.fetch;
57
+ const cache = new Map<string, Promise<unknown>>();
58
+
59
+ function file<T>(path: string): Promise<T> {
60
+ let p = cache.get(path);
61
+ if (!p) {
62
+ p = load(base + path);
63
+ // A failed read is tried again next time.
64
+ p.catch(() => cache.delete(path));
65
+ cache.set(path, p);
66
+ }
67
+ return p as Promise<T>;
68
+ }
69
+
70
+ async function load(url: string): Promise<unknown> {
71
+ let response: Response;
72
+ try {
73
+ response = await fetch(new Request(url));
74
+ } catch {
75
+ throw new ApiError(url, 'network');
76
+ }
77
+ const type = (response.headers.get('Content-Type') ?? '').split(';')[0]!.trim().toLowerCase();
78
+ if (!response.ok) throw new ApiError(url, 'status', response.status, type);
79
+ try {
80
+ return (await response.json()) as unknown;
81
+ } catch {
82
+ throw new ApiError(url, 'not-json', response.status, type);
83
+ }
84
+ }
85
+
86
+ const run = (r: string, path: string): string => `runs/${seg(r)}/${path}`;
87
+ const absent = (path: string): Promise<never> => Promise.reject(new ApiError(base + path, 'status', 404));
88
+
89
+ return {
90
+ baseUrl: base.replace(/\/$/, ''),
91
+ file,
92
+ info: () => file<Info>('info.json'),
93
+ runs: () => file<RunSummary[]>('runs.json'),
94
+ hosts: () => absent('hosts.json'),
95
+ async journal(r, since = 0): Promise<JournalPage> {
96
+ const page = await file<JournalPage>(run(r, 'journal.json'));
97
+ return since > 0 ? { entries: page.entries.slice(since), total: page.total } : page;
98
+ },
99
+ definitions: (r) => file<TaskDefinition[]>(run(r, 'definitions.json')),
100
+ async task(r, key): Promise<TaskDetail> {
101
+ const [detail, journal, definitions, plan] = await Promise.all([
102
+ file<TaskDetail>(run(r, `tasks/${seg(taskFileName(key))}.json`)),
103
+ file<JournalPage>(run(r, 'journal.json')),
104
+ file<TaskDefinition[]>(run(r, 'definitions.json')),
105
+ file<Plan>(run(r, 'plan.json')),
106
+ ]);
107
+ const rows = journal.entries.filter((e) => e.key === key);
108
+ const kind = rows.at(-1)?.kind ?? plan.nodes.find((n) => n.key === key)?.kind;
109
+ return { ...detail, rows, definition: definitions.find((d) => d.kind === kind) ?? null };
110
+ },
111
+ stream: (r, key) => absent(run(r, `streams/${seg(taskFileName(key))}.json`)),
112
+ panel: (r, name) => file<JsonObject>(run(r, `panels/${seg(name)}.json`)),
113
+ // An export holds one period's trends (`Info.export`): a query for another is answered with it.
114
+ trends: () => file<Trends>('trends.json'),
115
+ trendsPanel: (name) => file<JsonObject>(`trends/panels/${seg(name)}.json`),
116
+ };
117
+ }
118
+
119
+ /** A cursor file: `{}` when the run keeps no cursor. */
120
+ const isCursor = (c: CursorView | Record<string, never>): c is CursorView => Object.keys(c).length > 0;
121
+
122
+ /** A run feed opener, as `openRunFeed` is: what `MonitorStore` opens a run's feed with. */
123
+ export type RunFeedOpener = (run: string, panels: readonly string[], on: FeedHandlers<RunFeedMessage>) => Unsubscribe;
124
+
125
+ /**
126
+ * The run feed of an export: the run's journal rows, plan, outputs, cursor and panels,
127
+ * each once, then `ready`; nothing after. A file it cannot read stops it with `error`.
128
+ */
129
+ export function openExportFeed(client: ExportClient): RunFeedOpener {
130
+ return (r, panels, on) => {
131
+ let closed = false;
132
+ void (async () => {
133
+ try {
134
+ const [journal, plan, outputs, cursor, bodies] = await Promise.all([
135
+ client.journal(r),
136
+ client.file<Plan>(`runs/${seg(r)}/plan.json`),
137
+ client.file<Outputs>(`runs/${seg(r)}/outputs.json`),
138
+ client.file<CursorView | Record<string, never>>(`runs/${seg(r)}/cursor.json`),
139
+ Promise.all(panels.map((p) => client.panel(r, p).catch((e: unknown): JsonObject => ({ error: String(e) })))),
140
+ ]);
141
+ if (closed) return;
142
+ on.open?.();
143
+ const send = (m: RunFeedMessage) => !closed && on.message(m);
144
+ for (const row of journal.entries) send({ event: 'journal', data: row });
145
+ send({ event: 'plan', data: plan });
146
+ send({ event: 'outputs', data: outputs });
147
+ send({ event: 'cursor', data: isCursor(cursor) ? cursor : null });
148
+ panels.forEach((p, i) => send({ event: `panel:${p}`, data: bodies[i] ?? {} }));
149
+ send({ event: 'ready', data: { journal: journal.total } });
150
+ } catch {
151
+ if (!closed) on.error?.();
152
+ }
153
+ })();
154
+ return () => {
155
+ closed = true;
156
+ };
157
+ };
158
+ }
package/src/feed.ts ADDED
@@ -0,0 +1,81 @@
1
+ // The live half of the monitor API: Server-Sent Events, parsed into typed messages.
2
+ import type { RunEventName, RunFeedMessage, StreamPage } from './types.js';
3
+
4
+ /** Every SSE event name of `/events` with a fixed meaning; panels add `panel:<name>`. */
5
+ export const RUN_EVENT_NAMES = [
6
+ 'journal',
7
+ 'procs',
8
+ 'plan',
9
+ 'outputs',
10
+ 'cursor',
11
+ 'ready',
12
+ ] as const satisfies readonly RunEventName[];
13
+
14
+ // A name missing from the list above fails to compile here.
15
+ type Missing = Exclude<RunEventName, (typeof RUN_EVENT_NAMES)[number]>;
16
+ const _exhaustive: [Missing] extends [never] ? true : never = true;
17
+ void _exhaustive;
18
+
19
+ export interface FeedHandlers<M> {
20
+ message(msg: M): void;
21
+ /** The connection opened, or re-opened after a drop (the server then starts over). */
22
+ open?(): void;
23
+ /** The connection dropped; the browser retries on its own. */
24
+ error?(): void;
25
+ }
26
+
27
+ export interface FeedOptions {
28
+ /** Where the monitor is served, as for the client; '' (the default) is the page's own origin. */
29
+ baseUrl?: string;
30
+ /** For tests and non-browser hosts. */
31
+ EventSource?: typeof globalThis.EventSource;
32
+ }
33
+
34
+ /** A feed to stop. */
35
+ export type Unsubscribe = () => void;
36
+
37
+ function connect(url: string, names: readonly string[], on: FeedHandlers<{ event: string; data: unknown }>, opts: FeedOptions): Unsubscribe {
38
+ const ES = opts.EventSource ?? globalThis.EventSource;
39
+ const es = new ES(url);
40
+ es.onopen = () => on.open?.();
41
+ es.onerror = () => on.error?.();
42
+ for (const name of names) {
43
+ es.addEventListener(name, (ev) => {
44
+ on.message({ event: name, data: JSON.parse((ev as globalThis.MessageEvent<string>).data) as unknown });
45
+ });
46
+ }
47
+ return () => es.close();
48
+ }
49
+
50
+ const q = encodeURIComponent;
51
+ const base = (opts: FeedOptions): string => (opts.baseUrl ?? '').replace(/\/$/, '');
52
+
53
+ /**
54
+ * `/events?run=`: the run, live. The server replays the whole journal on every
55
+ * (re)connect and then sends `ready`; `panels` are the panel names from
56
+ * `/api/info` to listen for.
57
+ */
58
+ export function openRunFeed(
59
+ run: string,
60
+ panels: readonly string[],
61
+ on: FeedHandlers<RunFeedMessage>,
62
+ opts: FeedOptions = {},
63
+ ): Unsubscribe {
64
+ const names = [...RUN_EVENT_NAMES, ...panels.map((p) => `panel:${p}`)];
65
+ return connect(`${base(opts)}/events?run=${q(run)}`, names, on, opts);
66
+ }
67
+
68
+ /** `/events/stream`: one transcript, tailed. The first page is the tail; each later one only what is new. */
69
+ export function openStreamFeed(
70
+ run: string,
71
+ key: string,
72
+ on: FeedHandlers<StreamPage>,
73
+ opts: FeedOptions = {},
74
+ ): Unsubscribe {
75
+ return connect(
76
+ `${base(opts)}/events/stream?run=${q(run)}&key=${q(key)}`,
77
+ ['stream'],
78
+ { ...on, message: (m) => on.message(m.data as StreamPage) },
79
+ opts,
80
+ );
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from './types.js';
2
+ export * from './client.js';
3
+ export * from './feed.js';
4
+ export * from './run-state.js';
5
+ export * from './store.js';
6
+ export * from './export.js';
7
+ export * from './outcomes.js';
8
+ export * from './derive.js';
9
+ export * from './spend.js';
10
+ export * from './quota.js';
11
+ export * from './reasons.js';
12
+ export * from './panels.js';
@@ -0,0 +1,267 @@
1
+ // What happened to each task, in the terms a reader asks about: did it finish,
2
+ // fail, never start, or fail and then recover; and for a problem, which task
3
+ // caused it. Folded from the journal (finished attempts) and the plan (every
4
+ // queued task and what it waits for). Pure.
5
+ import type { DeferralCause, JournalRow, Plan, PlanNode, TaskState } from './types.js';
6
+
7
+ /**
8
+ * - `done`: its last attempt completed, first time.
9
+ * - `recovered`: a try that ran failed at least once, then its last attempt completed.
10
+ * - `partial`: its last attempt was partial (stop `partial`): ok, its output a placeholder; not done, not failed.
11
+ * - `failed`: it ran and its last attempt failed.
12
+ * - `not_started`: it never ran: an input was missing or off-contract, or it waits behind a task that failed.
13
+ * - `running`, `waiting` (queued, not started yet), `skipped`.
14
+ */
15
+ export type Outcome = 'done' | 'recovered' | 'partial' | 'failed' | 'not_started' | 'running' | 'waiting' | 'skipped';
16
+
17
+ export interface TaskOutcome {
18
+ key: string;
19
+ kind: string;
20
+ unit: string;
21
+ executor: 'agent' | 'python';
22
+ outcome: Outcome;
23
+ /** Its journal rows, oldest first (a refusal included). */
24
+ attempts: JournalRow[];
25
+ /** The rows of tries that actually ran (`isLaunched`), oldest first. */
26
+ tries: JournalRow[];
27
+ /** For a task that did not start: the failed task upstream that stopped it, if one is known. */
28
+ cause: string | null;
29
+ /** For a task the queue deferred (never launched): why, as the run recorded it; '' when it was not. */
30
+ deferred: string;
31
+ /** The same as data, for words of a reader's language; null when it was not deferred, or the run recorded no cause. */
32
+ deferredCause: DeferralCause | null;
33
+ /** For a task deferred by a flow call's own deadline (`time_box`): that flow call's function (`module.qualname`); '' otherwise. */
34
+ deferredIn: string;
35
+ }
36
+
37
+ /** The function of the flow call whose own deadline (`time_box`) deferred a task (`cause.flow_call`); '' for the run's deadline or another cause. */
38
+ export function deferredIn(plan: Pick<Plan, 'flow_calls'> | null | undefined, cause: DeferralCause | null | undefined): string {
39
+ const id = cause?.flow_call;
40
+ return id ? (plan?.flow_calls?.find((c) => c.id === id)?.function ?? '') : '';
41
+ }
42
+
43
+ const isOk = (r: JournalRow) => r.ok;
44
+
45
+ /**
46
+ * The row is a real try: the task ran. A task refused before launch
47
+ * (`invalid_input`) or skipped also gets a journal row, but never ran: it is
48
+ * not a try, not an agent session, and not a failed attempt.
49
+ */
50
+ export const isLaunched = (r: JournalRow): boolean => r.stop !== 'invalid_input' && r.stop !== 'skipped';
51
+
52
+ /**
53
+ * A task's outcome from its plan node (if any) and its journal rows, oldest
54
+ * first. `recovered` needs a try that ran and failed: a refusal before launch
55
+ * (`invalid_input`) is not a failed attempt, so "not started, then done" is `done`.
56
+ */
57
+ export function outcomeOf(node: Pick<PlanNode, 'state'> | undefined, attempts: readonly JournalRow[]): Outcome {
58
+ if (node?.state === 'running') return 'running';
59
+ const last = attempts[attempts.length - 1];
60
+ if (!last) return node?.state === 'blocked' ? 'not_started' : 'waiting';
61
+ if (last.stop === 'skipped') return 'skipped';
62
+ if (last.stop === 'invalid_input') return 'not_started';
63
+ if (last.stop === 'partial') return 'partial';
64
+ if (isOk(last)) return attempts.some((a) => isLaunched(a) && !isOk(a)) ? 'recovered' : 'done';
65
+ return 'failed';
66
+ }
67
+
68
+ /**
69
+ * The retries in `journal`, oldest first: the tries that ran (`isLaunched`)
70
+ * after an earlier try of the same task ran and failed, as in `recovered`. A
71
+ * try after a refusal (`invalid_input`) is not a retry.
72
+ */
73
+ export function retries(journal: readonly JournalRow[]): JournalRow[] {
74
+ const failed = new Set<string>();
75
+ const out: JournalRow[] = [];
76
+ for (const r of journal) {
77
+ if (!isLaunched(r)) continue;
78
+ if (failed.has(r.key)) out.push(r);
79
+ if (!isOk(r)) failed.add(r.key);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * A plan node's state as an outcome: `blocked` is `not_started` and `pending`
86
+ * is `waiting`. Plan state `done` covers both `done` and `recovered`.
87
+ */
88
+ export function planOutcome(state: TaskState): Outcome {
89
+ switch (state) {
90
+ case 'blocked':
91
+ return 'not_started';
92
+ case 'pending':
93
+ return 'waiting';
94
+ default:
95
+ return state;
96
+ }
97
+ }
98
+
99
+ /** Nothing is left to do for the task in this run: done, done after retrying, partial (its later tasks run on its output), or skipped. */
100
+ export const isFinished = (o: Outcome): boolean => o === 'done' || o === 'recovered' || o === 'partial' || o === 'skipped';
101
+
102
+ /** Every task of the run (from the plan, and from the journal for tasks the plan does not list), by key. */
103
+ export function taskOutcomes(journal: readonly JournalRow[], plan: Plan): Map<string, TaskOutcome> {
104
+ const rows = new Map<string, JournalRow[]>();
105
+ for (const r of journal) {
106
+ let list = rows.get(r.key);
107
+ if (!list) rows.set(r.key, (list = []));
108
+ list.push(r);
109
+ }
110
+ const nodes = new Map(plan.nodes.map((n) => [n.key, n]));
111
+ const keys = [...new Set([...plan.nodes.map((n) => n.key), ...rows.keys()])];
112
+ const out = new Map<string, TaskOutcome>();
113
+ for (const key of keys) {
114
+ const node = nodes.get(key);
115
+ const attempts = rows.get(key) ?? [];
116
+ const first = attempts[0];
117
+ out.set(key, {
118
+ key,
119
+ kind: node?.kind ?? first?.kind ?? '',
120
+ unit: node?.unit ?? first?.unit ?? '',
121
+ executor: node?.executor ?? first?.executor ?? 'agent',
122
+ outcome: outcomeOf(node, attempts),
123
+ attempts,
124
+ tries: attempts.filter(isLaunched),
125
+ cause: null,
126
+ deferred: node?.deferred ?? '',
127
+ deferredCause: node?.deferred_cause ?? null,
128
+ deferredIn: deferredIn(plan, node?.deferred_cause),
129
+ });
130
+ }
131
+ // A task that did not start: follow what it waits for to the task that actually failed.
132
+ const rootOf = (key: string, seen: Set<string>): string | null => {
133
+ for (const dep of nodes.get(key)?.after ?? []) {
134
+ if (seen.has(dep)) continue;
135
+ seen.add(dep);
136
+ const d = out.get(dep);
137
+ if (d?.outcome === 'failed') return dep;
138
+ if (d?.outcome === 'not_started') {
139
+ const r = rootOf(dep, seen);
140
+ if (r) return r;
141
+ }
142
+ }
143
+ return null;
144
+ };
145
+ for (const t of out.values()) if (t.outcome === 'not_started') t.cause = rootOf(t.key, new Set());
146
+ return out;
147
+ }
148
+
149
+ export interface Problem {
150
+ /** The task that failed. */
151
+ task: TaskOutcome;
152
+ /** The tasks that did not start because of it. */
153
+ stopped: TaskOutcome[];
154
+ }
155
+
156
+ export interface Problems {
157
+ /** Failed tasks, the latest first, each with the tasks it stopped. */
158
+ failed: Problem[];
159
+ /** Tasks that did not start for a reason of their own. */
160
+ notStarted: TaskOutcome[];
161
+ /** Partial tasks: ok, their output a placeholder. */
162
+ partial: TaskOutcome[];
163
+ /** Tasks done after retrying. */
164
+ recovered: TaskOutcome[];
165
+ }
166
+
167
+ /**
168
+ * What is wrong with the run, causes first: each failed task with the tasks
169
+ * it stopped, then tasks that did not start for a reason of their own (an
170
+ * input that was never produced by a task). Tasks that recovered are not
171
+ * problems: they are listed apart, after them.
172
+ */
173
+ export function problems(outcomes: ReadonlyMap<string, TaskOutcome>): Problems {
174
+ const failed = new Map<string, Problem>();
175
+ for (const t of outcomes.values()) if (t.outcome === 'failed') failed.set(t.key, { task: t, stopped: [] });
176
+ const notStarted: TaskOutcome[] = [];
177
+ for (const t of outcomes.values()) {
178
+ if (t.outcome !== 'not_started') continue;
179
+ const p = t.cause ? failed.get(t.cause) : undefined;
180
+ if (p) p.stopped.push(t);
181
+ else notStarted.push(t);
182
+ }
183
+ const lastTs = (t: TaskOutcome) => t.attempts[t.attempts.length - 1]?.ts ?? '';
184
+ return {
185
+ failed: [...failed.values()].sort((a, b) => lastTs(b.task).localeCompare(lastTs(a.task))),
186
+ notStarted,
187
+ partial: [...outcomes.values()].filter((t) => t.outcome === 'partial'),
188
+ recovered: [...outcomes.values()].filter((t) => t.outcome === 'recovered'),
189
+ };
190
+ }
191
+
192
+ /** How many tasks the run has, and how they ended. */
193
+ export function outcomeCounts(outcomes: ReadonlyMap<string, TaskOutcome>): Record<Outcome, number> & { total: number } {
194
+ const c = { done: 0, recovered: 0, partial: 0, failed: 0, not_started: 0, running: 0, waiting: 0, skipped: 0, total: 0 };
195
+ for (const t of outcomes.values()) {
196
+ c[t.outcome]++;
197
+ c.total++;
198
+ }
199
+ return c;
200
+ }
201
+
202
+ /**
203
+ * One task of the run as a list shows it: what happened to it (`TaskOutcome`)
204
+ * and what its tries add up to. A task that never ran has no tries: `wall_s` 0,
205
+ * `last_ts` and `last_stop` ''.
206
+ */
207
+ export interface TaskRow extends TaskOutcome {
208
+ /** Its loop pass (the innermost loop of its flow path), from 1; null outside a loop. */
209
+ pass: number | null;
210
+ /** Time of its tries, summed, in seconds. */
211
+ wall_s: number;
212
+ /** When its last journal row was written; '' for none. */
213
+ last_ts: string;
214
+ /** The stop of its last journal row; '' for none. */
215
+ last_stop: string;
216
+ /** A waiting task: the keys of the earlier tasks it waits for that have not finished, in plan order. */
217
+ waits_for: string[];
218
+ /** The names it holds while it runs (`claims`); empty for none. */
219
+ claims: string[];
220
+ /** Its task call's `priority`: a tie-breaker in the queue's order; 0 by default. */
221
+ priority: number;
222
+ }
223
+
224
+ /** The innermost loop pass of a plan node's flow path; null outside a loop (a pass not counted reads as the first). */
225
+ export function loopPass(node: Pick<PlanNode, 'flow'> | undefined): number | null {
226
+ const frames = Array.isArray(node?.flow) ? node.flow : [];
227
+ for (let i = frames.length - 1; i >= 0; i--) {
228
+ const loops = frames[i]?.loops ?? [];
229
+ const inner = loops[loops.length - 1];
230
+ if (inner) return typeof inner.pass === 'number' && inner.pass >= 1 ? inner.pass : 1;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Every task of the run, one row each, from its outcomes (`taskOutcomes`): the
237
+ * plan joined with the journal, so a task that never ran (waiting, not started)
238
+ * is a row too. In plan order. Views read them from `deriveRun(...).rows`.
239
+ */
240
+ export function taskRows(outcomes: ReadonlyMap<string, TaskOutcome>, plan: Plan): TaskRow[] {
241
+ const nodes = new Map(plan.nodes.map((n) => [n.key, n]));
242
+ return [...outcomes.values()].map((t) => {
243
+ const last = t.attempts[t.attempts.length - 1];
244
+ const node = nodes.get(t.key);
245
+ return {
246
+ ...t,
247
+ pass: loopPass(node),
248
+ wall_s: t.tries.reduce((s, r) => s + (r.wall_s || 0), 0),
249
+ last_ts: last?.ts ?? '',
250
+ last_stop: last?.stop ?? '',
251
+ waits_for:
252
+ t.outcome === 'waiting' ? (node?.after ?? []).filter((d) => !isFinished(outcomes.get(d)?.outcome ?? 'waiting')) : [],
253
+ claims: node?.claims ?? [],
254
+ priority: node?.priority ?? 0,
255
+ };
256
+ });
257
+ }
258
+
259
+ /** The task to open by default: a running agent task, else the agent task that ran last (it has a transcript); never a Python task. Its key, or null. */
260
+ export function latestTask(rows: readonly TaskRow[]): string | null {
261
+ const agent = rows.filter((t) => t.executor !== 'python');
262
+ const running = agent.find((t) => t.outcome === 'running');
263
+ if (running) return running.key;
264
+ const ran = (t: TaskRow) => t.tries[t.tries.length - 1]?.ts ?? '';
265
+ const last = agent.filter(ran).sort((a, b) => ran(b).localeCompare(ran(a)))[0];
266
+ return last?.key ?? null;
267
+ }