@yaag/extension 0.3.0 → 0.5.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.
- package/README.md +13 -3
- package/package.json +4 -4
- package/src/cli-child.ts +34 -4
- package/src/eval-pipe.ts +25 -0
- package/src/event-reader.ts +8 -0
- package/src/fake-extension-ui.ts +6 -12
- package/src/fake-run-start.ts +25 -0
- package/src/resume-source.ts +77 -0
- package/src/run-argv.ts +16 -11
- package/src/run-call-render.ts +58 -0
- package/src/run-program-param.ts +137 -23
- package/src/run-record.ts +109 -30
- package/src/run-registry.ts +16 -0
- package/src/run-store.ts +22 -5
- package/src/run-summary-parse.ts +265 -0
- package/src/run-tool-test-support.ts +2 -61
- package/src/run-tool.ts +42 -19
- package/src/run-tree-host.ts +8 -5
- package/src/spawn-run.ts +10 -3
- package/src/status-tool.ts +32 -9
- package/src/test-tui-context.ts +4 -14
- package/src/yaag-command.ts +1 -1
package/src/run-record.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initialSummary, type RunSummary } from "@yaag/runtime";
|
|
2
2
|
import type { ProcessIdentity } from "./process-liveness.ts";
|
|
3
|
+
import { parseRunSummary } from "./run-summary-parse.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The durable state of a Run.
|
|
@@ -16,21 +17,26 @@ export type PersistedOutcome =
|
|
|
16
17
|
| { readonly kind: "fulfilled"; readonly code: number | null; readonly result: string }
|
|
17
18
|
| { readonly kind: "rejected"; readonly reason: string };
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
export interface
|
|
21
|
-
/**
|
|
22
|
-
* Exactly one of `file` and `script` is present: `file` for a program file,
|
|
23
|
-
* `script` for an Inline Program (ADR-0033). The pair stays a tolerant
|
|
24
|
-
* optional pair rather than a union, because a record written by an older
|
|
25
|
-
* yaag must still narrow.
|
|
26
|
-
*/
|
|
27
|
-
readonly file?: string;
|
|
28
|
-
readonly script?: string;
|
|
20
|
+
/** Everything a launch carries besides the program itself. */
|
|
21
|
+
export interface RunLaunchContext {
|
|
29
22
|
readonly args?: string;
|
|
30
23
|
readonly record?: string;
|
|
31
24
|
readonly resume?: string;
|
|
32
25
|
}
|
|
33
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What starting a Run said about it; enough to describe it in a later session.
|
|
29
|
+
*
|
|
30
|
+
* The program is one of exactly two kinds (ADR-0033): a program file, or an
|
|
31
|
+
* Inline Program source. The tolerance for a record an older yaag wrote without
|
|
32
|
+
* a `kind` lives in {@link parseRunRecord}, not in this type. A new record does
|
|
33
|
+
* carry `kind` on disk, which an older yaag ignores, so the format drifts in
|
|
34
|
+
* both directions without loss.
|
|
35
|
+
*/
|
|
36
|
+
export type RunLaunch =
|
|
37
|
+
| (RunLaunchContext & { readonly kind: "file"; readonly file: string })
|
|
38
|
+
| (RunLaunchContext & { readonly kind: "inline"; readonly script: string });
|
|
39
|
+
|
|
34
40
|
/** One Run as stored on disk, at `<runs dir>/<id>.json`. */
|
|
35
41
|
export interface RunRecord {
|
|
36
42
|
readonly id: string;
|
|
@@ -44,22 +50,45 @@ export interface RunRecord {
|
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
47
|
-
*
|
|
53
|
+
* Parses an untrusted parsed JSON value into a {@link RunRecord}, or `null`.
|
|
48
54
|
*
|
|
49
55
|
* A record may have been written by an older yaag, or truncated by a crash, so
|
|
50
56
|
* every field the extension reads is checked here and a record that fails is
|
|
51
|
-
* dropped rather than repaired.
|
|
57
|
+
* dropped rather than repaired. The record is rebuilt from checked fields, and
|
|
58
|
+
* the launch is normalized to its tagged kind, which is why this is a parser
|
|
59
|
+
* and not a type guard. The Summary shape is checked by {@link parseRunSummary}.
|
|
52
60
|
*/
|
|
53
|
-
export function
|
|
54
|
-
if (typeof value !== "object" || value === null) return
|
|
61
|
+
export function parseRunRecord(value: unknown): RunRecord | null {
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
55
63
|
const candidate: Record<string, unknown> = { ...value };
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
const launch = parseLaunch(candidate["launch"]);
|
|
65
|
+
const identity = parseProcess(candidate["process"]);
|
|
66
|
+
const summary = parseRunSummary(candidate["summary"]);
|
|
67
|
+
const outcome = parseOutcome(candidate["outcome"]);
|
|
68
|
+
if (
|
|
69
|
+
launch === null ||
|
|
70
|
+
identity === undefined ||
|
|
71
|
+
summary === null ||
|
|
72
|
+
outcome === undefined ||
|
|
73
|
+
typeof candidate["id"] !== "string" ||
|
|
74
|
+
typeof candidate["startedAt"] !== "string" ||
|
|
75
|
+
!isNullableString(candidate["endedAt"]) ||
|
|
76
|
+
!isState(candidate["state"])
|
|
77
|
+
) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// The record is rebuilt field by field, so nothing untrusted is cast: every
|
|
81
|
+
// field a consumer reads has the type this parser proved (rule 0001 §3).
|
|
82
|
+
return {
|
|
83
|
+
id: candidate["id"],
|
|
84
|
+
launch,
|
|
85
|
+
process: identity,
|
|
86
|
+
startedAt: candidate["startedAt"],
|
|
87
|
+
endedAt: candidate["endedAt"],
|
|
88
|
+
state: candidate["state"],
|
|
89
|
+
summary,
|
|
90
|
+
outcome,
|
|
91
|
+
};
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
/** A Run record for a Run that has just started. */
|
|
@@ -87,15 +116,65 @@ function isState(value: unknown): value is PersistedRunState {
|
|
|
87
116
|
);
|
|
88
117
|
}
|
|
89
118
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
119
|
+
/** Normalizes the tolerant on-disk launch shape; a record with no `kind` still parses. */
|
|
120
|
+
function parseLaunch(value: unknown): RunLaunch | null {
|
|
121
|
+
if (typeof value !== "object" || value === null) return null;
|
|
122
|
+
const stored: Record<string, unknown> = { ...value };
|
|
123
|
+
const { file, script } = stored;
|
|
124
|
+
const context = launchContext(stored);
|
|
125
|
+
// Both present is the state `programTarget` refuses, so a record holding it
|
|
126
|
+
// is dropped rather than repaired, like a record holding neither.
|
|
127
|
+
if (typeof file === "string" && typeof script === "string") return null;
|
|
128
|
+
if (typeof file === "string") return { kind: "file", file, ...context };
|
|
129
|
+
if (typeof script === "string") return { kind: "inline", script, ...context };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function launchContext(stored: Record<string, unknown>): RunLaunchContext {
|
|
134
|
+
return {
|
|
135
|
+
...(typeof stored.args === "string" ? { args: stored.args } : {}),
|
|
136
|
+
...(typeof stored.record === "string" ? { record: stored.record } : {}),
|
|
137
|
+
...(typeof stored.resume === "string" ? { resume: stored.resume } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `undefined` means the stored value is malformed or missing; a stored `null`
|
|
143
|
+
* is a Run whose process was never known.
|
|
144
|
+
*/
|
|
145
|
+
function parseProcess(value: unknown): ProcessIdentity | null | undefined {
|
|
146
|
+
if (value === null) return null;
|
|
147
|
+
if (typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
148
|
+
const stored: Record<string, unknown> = { ...value };
|
|
149
|
+
if (typeof stored.pid !== "number" || !isNullableString(stored.startedAt)) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
return { pid: stored.pid, startedAt: stored.startedAt };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `undefined` means the stored value is malformed or missing; a stored `null`
|
|
157
|
+
* is a Run that has not ended.
|
|
158
|
+
*/
|
|
159
|
+
function parseOutcome(value: unknown): PersistedOutcome | null | undefined {
|
|
160
|
+
if (value === null) return null;
|
|
161
|
+
if (typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
162
|
+
const stored: Record<string, unknown> = { ...value };
|
|
163
|
+
if (stored.kind === "fulfilled") {
|
|
164
|
+
if (typeof stored.result !== "string" || !isNullableNumber(stored.code)) return undefined;
|
|
165
|
+
return { kind: "fulfilled", code: stored.code, result: stored.result };
|
|
166
|
+
}
|
|
167
|
+
if (stored.kind === "rejected") {
|
|
168
|
+
if (typeof stored.reason !== "string") return undefined;
|
|
169
|
+
return { kind: "rejected", reason: stored.reason };
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isNullableString(value: unknown): value is string | null {
|
|
175
|
+
return value === null || typeof value === "string";
|
|
97
176
|
}
|
|
98
177
|
|
|
99
|
-
function
|
|
100
|
-
return
|
|
178
|
+
function isNullableNumber(value: unknown): value is number | null {
|
|
179
|
+
return value === null || typeof value === "number";
|
|
101
180
|
}
|
package/src/run-registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
|
|
2
2
|
import type { ProcessIdentity } from "./process-liveness.ts";
|
|
3
|
+
import { pickInlineResumeSource } from "./resume-source.ts";
|
|
3
4
|
import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
|
|
4
5
|
import { restoreRecords } from "./run-restore.ts";
|
|
5
6
|
import type { RunStore } from "./run-store.ts";
|
|
@@ -135,6 +136,21 @@ export class RunRegistry {
|
|
|
135
136
|
return this.#runs.get(id) ?? { state: "unknown" };
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The Inline Program source a Checkpoint resumes, read back from the durable
|
|
141
|
+
* records (ADR-0033). Null when no record holds one, and for a memory-only
|
|
142
|
+
* registry.
|
|
143
|
+
*
|
|
144
|
+
* It waits for every queued write first: a record write is asynchronous and
|
|
145
|
+
* queued, so a resume of a Run that just settled would otherwise read the
|
|
146
|
+
* directory before its own record is there and refuse a resumable Run.
|
|
147
|
+
*/
|
|
148
|
+
async inlineSourceFor(artifact: string): Promise<string | null> {
|
|
149
|
+
if (this.#store === undefined) return null;
|
|
150
|
+
await this.#store.settled();
|
|
151
|
+
return pickInlineResumeSource(await this.#store.load(), artifact);
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
/** Reads a Run this session never started back from the store, by id. */
|
|
139
155
|
async recall(id: string): Promise<RunStatus> {
|
|
140
156
|
const known = this.lookup(id);
|
package/src/run-store.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { parseRunRecord, type RunRecord } from "./run-record.ts";
|
|
5
5
|
import { resolveRunsDirectory } from "./runs-dir.ts";
|
|
6
6
|
|
|
7
7
|
/** How many finished records are kept before the oldest ones are pruned. */
|
|
8
8
|
export const FINISHED_RECORD_CAP = 200;
|
|
9
9
|
|
|
10
|
+
/** Owner-only directory: a Run record can hold an Inline Program source (ADR-0033). */
|
|
11
|
+
export const RECORD_DIRECTORY_MODE = 0o700;
|
|
12
|
+
|
|
13
|
+
/** Owner-only record file, for the same reason as {@link RECORD_DIRECTORY_MODE}. */
|
|
14
|
+
export const RECORD_FILE_MODE = 0o600;
|
|
15
|
+
|
|
10
16
|
export interface RunStoreOptions {
|
|
11
17
|
readonly directory?: string;
|
|
12
18
|
readonly cap?: number;
|
|
@@ -19,6 +25,11 @@ export interface RunStoreOptions {
|
|
|
19
25
|
* working when the state directory is read-only or full. Writes go to a temp
|
|
20
26
|
* file and are renamed into place, so a reader never sees a half-written record
|
|
21
27
|
* (ADR-0021).
|
|
28
|
+
*
|
|
29
|
+
* A record holds the whole Inline Program source (an inline `RunLaunch`), so the
|
|
30
|
+
* directory and each record file are private to the user who owns them
|
|
31
|
+
* ({@link RECORD_DIRECTORY_MODE}, {@link RECORD_FILE_MODE}); the source stays
|
|
32
|
+
* off argv for the same reason (ADR-0033).
|
|
22
33
|
*/
|
|
23
34
|
export class RunStore {
|
|
24
35
|
readonly #directory: string;
|
|
@@ -84,9 +95,15 @@ export class RunStore {
|
|
|
84
95
|
const target = join(this.#directory, `${record.id}.json`);
|
|
85
96
|
const temp = `${target}.${randomUUID()}.tmp`;
|
|
86
97
|
try {
|
|
87
|
-
await mkdir(this.#directory, { recursive: true });
|
|
88
|
-
await writeFile(temp, JSON.stringify(record), "utf8");
|
|
98
|
+
await mkdir(this.#directory, { recursive: true, mode: RECORD_DIRECTORY_MODE });
|
|
99
|
+
await writeFile(temp, JSON.stringify(record), { encoding: "utf8", mode: RECORD_FILE_MODE });
|
|
89
100
|
await rename(temp, target);
|
|
101
|
+
// A directory or a record file that an older yaag left readable is
|
|
102
|
+
// tightened here: the record can hold an Inline Program source. The two
|
|
103
|
+
// modes above already hold for what this write creates, because an
|
|
104
|
+
// explicit mode is not broadened by the umask.
|
|
105
|
+
await chmod(target, RECORD_FILE_MODE);
|
|
106
|
+
await chmod(this.#directory, RECORD_DIRECTORY_MODE);
|
|
90
107
|
} catch {
|
|
91
108
|
await unlink(temp).catch(() => {});
|
|
92
109
|
}
|
|
@@ -95,7 +112,7 @@ export class RunStore {
|
|
|
95
112
|
async #read(entry: string): Promise<RunRecord | null> {
|
|
96
113
|
try {
|
|
97
114
|
const value: unknown = JSON.parse(await readFile(join(this.#directory, entry), "utf8"));
|
|
98
|
-
return
|
|
115
|
+
return parseRunRecord(value);
|
|
99
116
|
} catch {
|
|
100
117
|
return null;
|
|
101
118
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentActivity,
|
|
3
|
+
AgentInfo,
|
|
4
|
+
AgentState,
|
|
5
|
+
NodeInfo,
|
|
6
|
+
NodeState,
|
|
7
|
+
RunOutcome,
|
|
8
|
+
RunSummary,
|
|
9
|
+
TokenBreakdown,
|
|
10
|
+
} from "@yaag/runtime";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses the persisted Summary of a Run record, or returns `null`.
|
|
14
|
+
*
|
|
15
|
+
* The Summary is the one field of a Run record whose whole shape is read again
|
|
16
|
+
* by the renderers, so it is reconstructed field by field instead of cast: a
|
|
17
|
+
* record truncated by a crash or written by an older yaag is dropped rather
|
|
18
|
+
* than repaired (ADR-0021). Every optional field of the fold is rebuilt only
|
|
19
|
+
* when the stored value has the type the fold gives it.
|
|
20
|
+
*/
|
|
21
|
+
export function parseRunSummary(value: unknown): RunSummary | null {
|
|
22
|
+
const stored = asRecord(value);
|
|
23
|
+
if (stored === null) return null;
|
|
24
|
+
const base = parseBase(stored);
|
|
25
|
+
if (base === null) return null;
|
|
26
|
+
if (stored.runState === "running") {
|
|
27
|
+
if (stored.outcome !== null || stored.ok !== null) return null;
|
|
28
|
+
return { ...base, runState: "running", outcome: null, ok: null };
|
|
29
|
+
}
|
|
30
|
+
if (stored.runState !== "ended") return null;
|
|
31
|
+
const outcome = stored.outcome;
|
|
32
|
+
const lost = stored.checkpointLost;
|
|
33
|
+
if (!isOutcome(outcome) || typeof stored.ok !== "boolean") return null;
|
|
34
|
+
if (!isNullableNumber(stored.endedAt)) return null;
|
|
35
|
+
if (lost !== undefined && typeof lost !== "string") return null;
|
|
36
|
+
return {
|
|
37
|
+
...base,
|
|
38
|
+
runState: "ended",
|
|
39
|
+
outcome,
|
|
40
|
+
ok: stored.ok,
|
|
41
|
+
endedAt: stored.endedAt,
|
|
42
|
+
...(lost === undefined ? {} : { checkpointLost: lost }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The accounting and identity facts every Summary state carries. */
|
|
47
|
+
interface SummaryBase {
|
|
48
|
+
readonly program: string;
|
|
49
|
+
readonly startedAt: number | null;
|
|
50
|
+
readonly artifact: string | null;
|
|
51
|
+
readonly agents: Readonly<Record<string, AgentInfo>>;
|
|
52
|
+
readonly asksStarted: number;
|
|
53
|
+
readonly asksSettled: number;
|
|
54
|
+
readonly cost: number;
|
|
55
|
+
readonly tokens: TokenBreakdown | null;
|
|
56
|
+
readonly incomplete: boolean;
|
|
57
|
+
readonly durationMs: number;
|
|
58
|
+
readonly worstFrameGapMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseBase(stored: Record<string, unknown>): SummaryBase | null {
|
|
62
|
+
const agents = parseAgents(stored.agents);
|
|
63
|
+
const tokens = parseTokens(stored.tokens);
|
|
64
|
+
if (
|
|
65
|
+
agents === null ||
|
|
66
|
+
tokens === undefined ||
|
|
67
|
+
typeof stored.program !== "string" ||
|
|
68
|
+
!isNullableNumber(stored.startedAt) ||
|
|
69
|
+
!isNullableString(stored.artifact) ||
|
|
70
|
+
typeof stored.asksStarted !== "number" ||
|
|
71
|
+
typeof stored.asksSettled !== "number" ||
|
|
72
|
+
typeof stored.cost !== "number" ||
|
|
73
|
+
typeof stored.incomplete !== "boolean" ||
|
|
74
|
+
typeof stored.durationMs !== "number" ||
|
|
75
|
+
typeof stored.worstFrameGapMs !== "number"
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
program: stored.program,
|
|
81
|
+
startedAt: stored.startedAt,
|
|
82
|
+
artifact: stored.artifact,
|
|
83
|
+
agents,
|
|
84
|
+
asksStarted: stored.asksStarted,
|
|
85
|
+
asksSettled: stored.asksSettled,
|
|
86
|
+
cost: stored.cost,
|
|
87
|
+
tokens,
|
|
88
|
+
incomplete: stored.incomplete,
|
|
89
|
+
durationMs: stored.durationMs,
|
|
90
|
+
worstFrameGapMs: stored.worstFrameGapMs,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseAgents(value: unknown): Record<string, AgentInfo> | null {
|
|
95
|
+
const stored = asRecord(value);
|
|
96
|
+
if (stored === null) return null;
|
|
97
|
+
const agents: Record<string, AgentInfo> = {};
|
|
98
|
+
for (const [name, entry] of Object.entries(stored)) {
|
|
99
|
+
const agent = parseAgent(entry);
|
|
100
|
+
if (agent === null) return null;
|
|
101
|
+
agents[name] = agent;
|
|
102
|
+
}
|
|
103
|
+
return agents;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseAgent(value: unknown): AgentInfo | null {
|
|
107
|
+
const stored = asRecord(value);
|
|
108
|
+
if (stored === null) return null;
|
|
109
|
+
const state = stored.state;
|
|
110
|
+
const nodes = parseNodes(stored.nodes);
|
|
111
|
+
const tokens = parseTokens(stored.tokens);
|
|
112
|
+
const activity = parseActivity(stored.activity);
|
|
113
|
+
if (
|
|
114
|
+
nodes === null ||
|
|
115
|
+
tokens === undefined ||
|
|
116
|
+
activity === undefined ||
|
|
117
|
+
!isAgentState(state) ||
|
|
118
|
+
!isNullableString(stored.model) ||
|
|
119
|
+
!isNullableString(stored.cwd) ||
|
|
120
|
+
!isNullableString(stored.branch) ||
|
|
121
|
+
!isNullableString(stored.sessionFile) ||
|
|
122
|
+
!isNullableNumber(stored.cost) ||
|
|
123
|
+
typeof stored.incomplete !== "boolean" ||
|
|
124
|
+
!isNullableNumber(stored.stateChangedAt) ||
|
|
125
|
+
!isNullableNumber(stored.usageUpdatedAt) ||
|
|
126
|
+
!isNullableNumber(stored.askStartedAt) ||
|
|
127
|
+
typeof stored.finishedNodesPruned !== "number"
|
|
128
|
+
) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const base = {
|
|
132
|
+
model: stored.model,
|
|
133
|
+
cwd: stored.cwd,
|
|
134
|
+
branch: stored.branch,
|
|
135
|
+
sessionFile: stored.sessionFile,
|
|
136
|
+
activity,
|
|
137
|
+
tokens,
|
|
138
|
+
cost: stored.cost,
|
|
139
|
+
incomplete: stored.incomplete,
|
|
140
|
+
stateChangedAt: stored.stateChangedAt,
|
|
141
|
+
usageUpdatedAt: stored.usageUpdatedAt,
|
|
142
|
+
askStartedAt: stored.askStartedAt,
|
|
143
|
+
nodes,
|
|
144
|
+
finishedNodesPruned: stored.finishedNodesPruned,
|
|
145
|
+
};
|
|
146
|
+
if (state === "asking") {
|
|
147
|
+
if (typeof stored.askIndex !== "number") return null;
|
|
148
|
+
if (typeof stored.promptGist !== "string") return null;
|
|
149
|
+
if (typeof stored.replayed !== "boolean") return null;
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
state,
|
|
153
|
+
askIndex: stored.askIndex,
|
|
154
|
+
promptGist: stored.promptGist,
|
|
155
|
+
replayed: stored.replayed,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!isNullableNumber(stored.askIndex)) return null;
|
|
159
|
+
if (!isNullableString(stored.promptGist)) return null;
|
|
160
|
+
return { ...base, state, askIndex: stored.askIndex, promptGist: stored.promptGist };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseNodes(value: unknown): NodeInfo[] | null {
|
|
164
|
+
if (!Array.isArray(value)) return null;
|
|
165
|
+
const nodes: NodeInfo[] = [];
|
|
166
|
+
for (const entry of value) {
|
|
167
|
+
const stored = asRecord(entry);
|
|
168
|
+
if (stored === null) return null;
|
|
169
|
+
const tokens = parseTokens(stored.tokens);
|
|
170
|
+
if (
|
|
171
|
+
tokens === undefined ||
|
|
172
|
+
typeof stored.path !== "string" ||
|
|
173
|
+
!isNodeState(stored.state) ||
|
|
174
|
+
!isNullableString(stored.activityGist) ||
|
|
175
|
+
!isNullableNumber(stored.cost) ||
|
|
176
|
+
!isNullableNumber(stored.updatedAt)
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
nodes.push({
|
|
181
|
+
path: stored.path,
|
|
182
|
+
state: stored.state,
|
|
183
|
+
activityGist: stored.activityGist,
|
|
184
|
+
tokens,
|
|
185
|
+
cost: stored.cost,
|
|
186
|
+
updatedAt: stored.updatedAt,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return nodes;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `undefined` means the stored value is malformed or missing; a stored `null`
|
|
194
|
+
* is an Agent or Node whose usage was never reported.
|
|
195
|
+
*/
|
|
196
|
+
function parseTokens(value: unknown): TokenBreakdown | null | undefined {
|
|
197
|
+
if (value === null) return null;
|
|
198
|
+
const stored = asRecord(value);
|
|
199
|
+
if (stored === null) return undefined;
|
|
200
|
+
const fields = ["input", "output", "cacheRead", "cacheWrite", "total"] as const;
|
|
201
|
+
if (fields.some((field) => typeof stored[field] !== "number")) return undefined;
|
|
202
|
+
return {
|
|
203
|
+
input: stored.input as number,
|
|
204
|
+
output: stored.output as number,
|
|
205
|
+
cacheRead: stored.cacheRead as number,
|
|
206
|
+
cacheWrite: stored.cacheWrite as number,
|
|
207
|
+
total: stored.total as number,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `undefined` means the stored value is malformed or missing; a stored `null`
|
|
213
|
+
* is an Agent with no current activity.
|
|
214
|
+
*/
|
|
215
|
+
function parseActivity(value: unknown): AgentActivity | null | undefined {
|
|
216
|
+
if (value === null) return null;
|
|
217
|
+
const stored = asRecord(value);
|
|
218
|
+
if (stored === null) return undefined;
|
|
219
|
+
const type = stored.type;
|
|
220
|
+
if (type === "thinking" || type === "writing" || type === "compacting") return { type };
|
|
221
|
+
if (type === "tool") {
|
|
222
|
+
if (typeof stored.name !== "string" || typeof stored.argsGist !== "string") {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
return { type, name: stored.name, argsGist: stored.argsGist };
|
|
226
|
+
}
|
|
227
|
+
if (type === "retrying") {
|
|
228
|
+
if (typeof stored.attempt !== "number" || typeof stored.max !== "number") {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return { type, attempt: stored.attempt, max: stored.max };
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
237
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
238
|
+
return { ...value };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isNullableString(value: unknown): value is string | null {
|
|
242
|
+
return value === null || typeof value === "string";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isNullableNumber(value: unknown): value is number | null {
|
|
246
|
+
return value === null || typeof value === "number";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isAgentState(value: unknown): value is AgentState {
|
|
250
|
+
return value === "idle" || value === "asking" || value === "exited";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isNodeState(value: unknown): value is NodeState {
|
|
254
|
+
return value === "running" || value === "exited" || value === "failed";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isOutcome(value: unknown): value is RunOutcome {
|
|
258
|
+
return (
|
|
259
|
+
value === "completed" ||
|
|
260
|
+
value === "failed" ||
|
|
261
|
+
value === "stopped" ||
|
|
262
|
+
value === "paused" ||
|
|
263
|
+
value === "interrupted"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
4
|
-
import { initialSummary } from "@yaag/runtime";
|
|
5
3
|
import type { BackgroundStatus } from "./background-status.ts";
|
|
6
|
-
import { fakeTheme } from "./fake-theme.ts";
|
|
7
4
|
import { resolveBun } from "./resolve-bun.ts";
|
|
8
5
|
import { resolveCliEntry } from "./resolve-cli.ts";
|
|
6
|
+
import type { RunParams } from "./run-call-render.ts";
|
|
9
7
|
import type { RunDetails } from "./run-details.ts";
|
|
10
8
|
import { RunRegistry } from "./run-registry.ts";
|
|
11
9
|
import { createRunTool, type SendMessage } from "./run-tool.ts";
|
|
12
10
|
import type { RunTreeStore } from "./run-trees.ts";
|
|
13
|
-
import type { RunHandle
|
|
11
|
+
import type { RunHandle } from "./spawn-run.ts";
|
|
14
12
|
import { createStopTool, type StopDetails } from "./stop-tool.ts";
|
|
15
13
|
import { TestExtensionContext } from "./test-extension-context.ts";
|
|
16
14
|
|
|
@@ -21,63 +19,6 @@ const ctx = new TestExtensionContext(dirname(cli));
|
|
|
21
19
|
|
|
22
20
|
export const fixture = (name: string): string => join(dirname(cli), "fixtures", `${name}.ts`);
|
|
23
21
|
|
|
24
|
-
export interface RunParams {
|
|
25
|
-
readonly file?: string;
|
|
26
|
-
readonly script?: string;
|
|
27
|
-
readonly args?: string;
|
|
28
|
-
readonly background?: boolean;
|
|
29
|
-
readonly record?: string;
|
|
30
|
-
readonly resume?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Renders the tool's call line, stripped of styling, as the transcript shows it. */
|
|
34
|
-
export function renderCallLabel(params: RunParams): string {
|
|
35
|
-
const tool = createRunTool({
|
|
36
|
-
bun,
|
|
37
|
-
cli,
|
|
38
|
-
registry: new RunRegistry(),
|
|
39
|
-
sendMessage: () => undefined,
|
|
40
|
-
});
|
|
41
|
-
const component = tool.renderCall?.(params, fakeTheme(), {
|
|
42
|
-
args: params,
|
|
43
|
-
toolCallId: "call-1",
|
|
44
|
-
invalidate: () => {},
|
|
45
|
-
lastComponent: undefined,
|
|
46
|
-
state: undefined,
|
|
47
|
-
cwd: process.cwd(),
|
|
48
|
-
executionStarted: false,
|
|
49
|
-
argsComplete: true,
|
|
50
|
-
isPartial: false,
|
|
51
|
-
expanded: false,
|
|
52
|
-
showImages: false,
|
|
53
|
-
isError: false,
|
|
54
|
-
});
|
|
55
|
-
return stripTerminalSequences(component?.render(80)[0] ?? "");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** A start seam that never spawns: it captures its options and ends the Run at once. */
|
|
59
|
-
export function fakeStart(stdout = "{}"): {
|
|
60
|
-
readonly start: (options: StartRunOptions) => RunHandle;
|
|
61
|
-
readonly seen: StartRunOptions[];
|
|
62
|
-
} {
|
|
63
|
-
const seen: StartRunOptions[] = [];
|
|
64
|
-
return {
|
|
65
|
-
seen,
|
|
66
|
-
start: (options) => {
|
|
67
|
-
seen.push(options);
|
|
68
|
-
return {
|
|
69
|
-
stop: () => {},
|
|
70
|
-
outcome: Promise.resolve({
|
|
71
|
-
code: 0,
|
|
72
|
-
stdout,
|
|
73
|
-
stderr: "",
|
|
74
|
-
summary: initialSummary(),
|
|
75
|
-
}),
|
|
76
|
-
};
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
22
|
export interface Sent {
|
|
82
23
|
readonly content: unknown;
|
|
83
24
|
readonly details: unknown;
|