@yaag/extension 0.1.4 → 0.2.1
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/package.json +5 -4
- package/src/background-status.ts +94 -0
- package/src/cli-child.ts +3 -0
- package/src/fake-extension-ui.ts +6 -3
- package/src/fake-tui.ts +1 -1
- package/src/index.ts +10 -7
- package/src/process-liveness.ts +51 -0
- package/src/run-details.ts +4 -2
- package/src/run-foreground.ts +107 -60
- package/src/run-id.ts +24 -0
- package/src/run-record.ts +91 -0
- package/src/run-registry.ts +153 -16
- package/src/run-restore.ts +43 -0
- package/src/run-settlement.ts +1 -1
- package/src/run-store.ts +103 -0
- package/src/run-tool-test-support.ts +4 -4
- package/src/run-tool.ts +38 -30
- package/src/run-tree-component.ts +2 -1
- package/src/run-tree-host.ts +4 -5
- package/src/run-trees.ts +4 -4
- package/src/runs-dir.ts +18 -0
- package/src/spawn-run.ts +3 -1
- package/src/status-tool.ts +39 -4
- package/src/stop-tool.ts +41 -3
- package/src/test-tui-context.ts +7 -1
- package/src/yaag-command.ts +12 -19
- package/src/background-widget.ts +0 -107
- package/src/run-foreground-view.ts +0 -95
package/src/run-registry.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
|
|
2
|
+
import type { ProcessIdentity } from "./process-liveness.ts";
|
|
3
|
+
import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
|
|
4
|
+
import { restoreRecords } from "./run-restore.ts";
|
|
5
|
+
import type { RunStore } from "./run-store.ts";
|
|
2
6
|
import type { RunOutcome } from "./spawn-run.ts";
|
|
3
7
|
|
|
8
|
+
export type { RunLaunch } from "./run-record.ts";
|
|
9
|
+
|
|
4
10
|
/** A Run in flight, including its stop capability and latest Summary fold. */
|
|
5
11
|
export interface LiveRun {
|
|
6
12
|
readonly id: string;
|
|
@@ -21,30 +27,51 @@ export interface FinishedRun {
|
|
|
21
27
|
readonly outcome: RunSettlement;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/** A Run read back from disk, started by an earlier Host Session. */
|
|
31
|
+
export interface RestoredRun {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly summary: RunSummary;
|
|
34
|
+
readonly record: RunRecord;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/** The retained state of a registered Run. */
|
|
25
38
|
export type RegisteredRun =
|
|
26
39
|
| { readonly state: "live"; readonly run: LiveRun }
|
|
27
|
-
| { readonly state: "finished"; readonly run: FinishedRun }
|
|
40
|
+
| { readonly state: "finished"; readonly run: FinishedRun }
|
|
41
|
+
| { readonly state: "restored"; readonly run: RestoredRun };
|
|
28
42
|
|
|
29
43
|
/** What the registry knows about an id. */
|
|
30
44
|
export type RunStatus = RegisteredRun | { readonly state: "unknown" };
|
|
31
45
|
|
|
46
|
+
/** How often a Run's latest Summary is persisted while it runs. */
|
|
47
|
+
export const SUMMARY_WRITE_INTERVAL_MS = 2_000;
|
|
48
|
+
|
|
49
|
+
export interface RunRegistryOptions {
|
|
50
|
+
/** Durable mirror; omit it and the registry stays memory-only. */
|
|
51
|
+
readonly store?: RunStore;
|
|
52
|
+
readonly now?: () => Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
/**
|
|
33
|
-
*
|
|
56
|
+
* Source of truth for every Run the Host Session can address.
|
|
34
57
|
*
|
|
35
|
-
* It
|
|
36
|
-
*
|
|
37
|
-
*
|
|
58
|
+
* It retains live stop capabilities and final outcomes in memory, and mirrors
|
|
59
|
+
* each Run to a {@link RunStore} so a restart of the Host Session does not lose
|
|
60
|
+
* the id (ticket 02). Concurrent Runs remain independently controllable
|
|
61
|
+
* (ADR-0008).
|
|
38
62
|
*/
|
|
39
63
|
export class RunRegistry {
|
|
40
|
-
#counter = 0;
|
|
41
64
|
readonly #live = new Map<string, LiveRun>();
|
|
42
65
|
readonly #runs = new Map<string, RegisteredRun>();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
readonly #records = new Map<string, RunRecord>();
|
|
67
|
+
readonly #lastWrite = new Map<string, number>();
|
|
68
|
+
readonly #trailing = new Map<string, ReturnType<typeof setTimeout>>();
|
|
69
|
+
readonly #store: RunStore | undefined;
|
|
70
|
+
readonly #now: () => Date;
|
|
71
|
+
|
|
72
|
+
constructor(options: RunRegistryOptions = {}) {
|
|
73
|
+
this.#store = options.store;
|
|
74
|
+
this.#now = options.now ?? (() => new Date());
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
/** Every currently-live Run, in registration order. */
|
|
@@ -52,31 +79,72 @@ export class RunRegistry {
|
|
|
52
79
|
return [...this.#live.values()];
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
/** Every
|
|
82
|
+
/** Every addressable Run: this session's Runs, plus unaccounted earlier ones. */
|
|
56
83
|
get runs(): readonly RegisteredRun[] {
|
|
57
84
|
return [...this.#runs.values()];
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
/** Every
|
|
87
|
+
/** Every addressable Run id, in registration order. */
|
|
61
88
|
get knownIds(): readonly string[] {
|
|
62
89
|
return [...this.#runs.keys()];
|
|
63
90
|
}
|
|
64
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Loads persisted Runs, settles the ones an earlier session left `live`, and
|
|
94
|
+
* seeds the still-unaccounted ones. Finished Runs of earlier sessions stay on
|
|
95
|
+
* disk and are reachable through {@link recall} by id.
|
|
96
|
+
*/
|
|
97
|
+
async restore(): Promise<void> {
|
|
98
|
+
if (this.#store === undefined) return;
|
|
99
|
+
const records = await restoreRecords({ store: this.#store, now: this.#now });
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
if (record.state !== "orphaned" && record.state !== "interrupted") continue;
|
|
102
|
+
this.#runs.set(record.id, { state: "restored", run: restored(record) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
/** Registers a newly-started Run and synchronously observes its settlement. */
|
|
66
|
-
add(run: LiveRun): void {
|
|
107
|
+
add(run: LiveRun, launch?: RunLaunch, identity?: ProcessIdentity | null): void {
|
|
67
108
|
this.#live.set(run.id, run);
|
|
68
109
|
this.#runs.set(run.id, { state: "live", run });
|
|
110
|
+
if (launch !== undefined) this.#persistStart(run.id, launch, identity ?? null);
|
|
69
111
|
void run.outcome.then(
|
|
70
112
|
(outcome) => this.finish(run.id, outcome),
|
|
71
113
|
(reason: unknown) => this.reject(run.id, reason),
|
|
72
114
|
);
|
|
73
115
|
}
|
|
74
116
|
|
|
117
|
+
/** Records the latest fold for a live Run and persists it at a bounded rate. */
|
|
118
|
+
progress(id: string, summary: RunSummary): void {
|
|
119
|
+
const run = this.#live.get(id);
|
|
120
|
+
if (run === undefined) return;
|
|
121
|
+
run.summary = summary;
|
|
122
|
+
const record = this.#records.get(id);
|
|
123
|
+
if (record === undefined) return;
|
|
124
|
+
this.#records.set(id, { ...record, summary });
|
|
125
|
+
const waited = this.#now().getTime() - (this.#lastWrite.get(id) ?? 0);
|
|
126
|
+
if (waited < SUMMARY_WRITE_INTERVAL_MS) {
|
|
127
|
+
this.#scheduleTrailing(id, waited);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#persist(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
75
133
|
/** Looks up a live capability or the retained completed Run. */
|
|
76
134
|
lookup(id: string): RunStatus {
|
|
77
135
|
return this.#runs.get(id) ?? { state: "unknown" };
|
|
78
136
|
}
|
|
79
137
|
|
|
138
|
+
/** Reads a Run this session never started back from the store, by id. */
|
|
139
|
+
async recall(id: string): Promise<RunStatus> {
|
|
140
|
+
const known = this.lookup(id);
|
|
141
|
+
if (known.state !== "unknown" || this.#store === undefined) return known;
|
|
142
|
+
const record = (await this.#store.load()).find((entry) => entry.id === id);
|
|
143
|
+
return record === undefined
|
|
144
|
+
? { state: "unknown" }
|
|
145
|
+
: { state: "restored", run: restored(record) };
|
|
146
|
+
}
|
|
147
|
+
|
|
80
148
|
/**
|
|
81
149
|
* Retains a Run's first observed final outcome and removes only its live
|
|
82
150
|
* capability. Repeated observers are harmless and cannot overwrite history.
|
|
@@ -88,16 +156,85 @@ export class RunRegistry {
|
|
|
88
156
|
/** Retains a rejected child outcome with its latest terminal accounting projection. */
|
|
89
157
|
reject(id: string, reason: unknown): void {
|
|
90
158
|
const record = this.#runs.get(id);
|
|
91
|
-
if (record === undefined || record.state
|
|
159
|
+
if (record === undefined || record.state !== "live") return;
|
|
92
160
|
this.#settle(id, failedSummary(record.run.summary), { kind: "rejected", reason });
|
|
93
161
|
}
|
|
94
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Persists the latest Summary when the interval ends. Without it, everything
|
|
165
|
+
* folded inside one interval would be lost if the Host Session died before
|
|
166
|
+
* the next write (ticket 02).
|
|
167
|
+
*/
|
|
168
|
+
#scheduleTrailing(id: string, waited: number): void {
|
|
169
|
+
if (this.#trailing.has(id)) return;
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
this.#trailing.delete(id);
|
|
172
|
+
if (this.#live.has(id)) this.#persist(id);
|
|
173
|
+
}, SUMMARY_WRITE_INTERVAL_MS - waited);
|
|
174
|
+
// Housekeeping must never keep the Host Session alive.
|
|
175
|
+
timer.unref();
|
|
176
|
+
this.#trailing.set(id, timer);
|
|
177
|
+
}
|
|
178
|
+
|
|
95
179
|
#settle(id: string, summary: RunSummary, outcome: RunSettlement): void {
|
|
96
180
|
const record = this.#runs.get(id);
|
|
97
|
-
if (record === undefined || record.state
|
|
181
|
+
if (record === undefined || record.state !== "live") return;
|
|
98
182
|
this.#live.delete(id);
|
|
183
|
+
const trailing = this.#trailing.get(id);
|
|
184
|
+
if (trailing !== undefined) clearTimeout(trailing);
|
|
185
|
+
this.#trailing.delete(id);
|
|
99
186
|
this.#runs.set(id, { state: "finished", run: { id, summary, outcome } });
|
|
187
|
+
this.#persistEnd(id, summary, outcome);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#persistStart(id: string, launch: RunLaunch, identity: ProcessIdentity | null): void {
|
|
191
|
+
if (this.#store === undefined) return;
|
|
192
|
+
const store = this.#store;
|
|
193
|
+
this.#records.set(
|
|
194
|
+
id,
|
|
195
|
+
startedRecord({ id, launch, process: identity, now: this.#now().toISOString() }),
|
|
196
|
+
);
|
|
197
|
+
this.#persist(id);
|
|
198
|
+
void store.prune();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#persistEnd(id: string, summary: RunSummary, outcome: RunSettlement): void {
|
|
202
|
+
const record = this.#records.get(id);
|
|
203
|
+
if (record === undefined) return;
|
|
204
|
+
this.#records.set(id, {
|
|
205
|
+
...record,
|
|
206
|
+
state: "finished",
|
|
207
|
+
endedAt: this.#now().toISOString(),
|
|
208
|
+
summary,
|
|
209
|
+
outcome: persistedOutcome(outcome),
|
|
210
|
+
});
|
|
211
|
+
this.#persist(id);
|
|
100
212
|
}
|
|
213
|
+
|
|
214
|
+
#persist(id: string): void {
|
|
215
|
+
const record = this.#records.get(id);
|
|
216
|
+
if (record === undefined || this.#store === undefined) return;
|
|
217
|
+
this.#lastWrite.set(id, this.#now().getTime());
|
|
218
|
+
void this.#store.save(record);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function restored(record: RunRecord): RestoredRun {
|
|
223
|
+
return { id: record.id, summary: record.summary, record };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function persistedOutcome(outcome: RunSettlement): RunRecord["outcome"] {
|
|
227
|
+
if (outcome.kind === "rejected")
|
|
228
|
+
return { kind: "rejected", reason: errorMessage(outcome.reason) };
|
|
229
|
+
return {
|
|
230
|
+
kind: "fulfilled",
|
|
231
|
+
code: outcome.outcome.code,
|
|
232
|
+
result: outcome.outcome.stdout.trimEnd(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function errorMessage(reason: unknown): string {
|
|
237
|
+
return reason instanceof Error ? reason.message : String(reason);
|
|
101
238
|
}
|
|
102
239
|
|
|
103
240
|
function failedSummary(summary: RunSummary): EndedRunSummary {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isProcessAlive, type ProcessIdentity } from "./process-liveness.ts";
|
|
2
|
+
import type { RunRecord } from "./run-record.ts";
|
|
3
|
+
import type { RunStore } from "./run-store.ts";
|
|
4
|
+
|
|
5
|
+
/** Decides whether the process a record names is still running. */
|
|
6
|
+
export type LivenessProbe = (identity: ProcessIdentity) => boolean;
|
|
7
|
+
|
|
8
|
+
export interface RestoreOptions {
|
|
9
|
+
readonly store: RunStore;
|
|
10
|
+
readonly isAlive?: LivenessProbe;
|
|
11
|
+
readonly now?: () => Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads every persisted Run and settles the ones a previous Host Session left
|
|
16
|
+
* behind.
|
|
17
|
+
*
|
|
18
|
+
* A `live` record can only be stale here, because the session that owned it is
|
|
19
|
+
* gone: its child is dead and the Run is `interrupted`, or its child still runs
|
|
20
|
+
* without an owner and the Run is `orphaned`. Both re-writes are persisted, so
|
|
21
|
+
* the verdict survives this session too (ticket 02).
|
|
22
|
+
*/
|
|
23
|
+
export async function restoreRecords(options: RestoreOptions): Promise<readonly RunRecord[]> {
|
|
24
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
25
|
+
const now = options.now ?? (() => new Date());
|
|
26
|
+
const records = await options.store.load();
|
|
27
|
+
return Promise.all(records.map((record) => settleStale(record, isAlive, now, options.store)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function settleStale(
|
|
31
|
+
record: RunRecord,
|
|
32
|
+
isAlive: LivenessProbe,
|
|
33
|
+
now: () => Date,
|
|
34
|
+
store: RunStore,
|
|
35
|
+
): Promise<RunRecord> {
|
|
36
|
+
if (record.state !== "live") return record;
|
|
37
|
+
const alive = record.process !== null && isAlive(record.process);
|
|
38
|
+
const settled: RunRecord = alive
|
|
39
|
+
? { ...record, state: "orphaned" }
|
|
40
|
+
: { ...record, state: "interrupted", endedAt: now().toISOString() };
|
|
41
|
+
await store.save(settled);
|
|
42
|
+
return settled;
|
|
43
|
+
}
|
package/src/run-settlement.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function observedSettlement(
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* The Result region's content for a settled Run, shared by the
|
|
29
|
+
* The Result region's content for a settled Run, shared by the `/yaag` view
|
|
30
30
|
* and `/yaag`'s settled background view.
|
|
31
31
|
*/
|
|
32
32
|
export function viewResult(settlement: RunSettlement): RunViewResult {
|
package/src/run-store.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isRunRecord, type RunRecord } from "./run-record.ts";
|
|
5
|
+
import { resolveRunsDirectory } from "./runs-dir.ts";
|
|
6
|
+
|
|
7
|
+
/** How many finished records are kept before the oldest ones are pruned. */
|
|
8
|
+
export const FINISHED_RECORD_CAP = 200;
|
|
9
|
+
|
|
10
|
+
export interface RunStoreOptions {
|
|
11
|
+
readonly directory?: string;
|
|
12
|
+
readonly cap?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The durable side of the Run registry: one JSON file per Run.
|
|
17
|
+
*
|
|
18
|
+
* Every method is best-effort and never throws — a Host Session must keep
|
|
19
|
+
* working when the state directory is read-only or full. Writes go to a temp
|
|
20
|
+
* file and are renamed into place, so a reader never sees a half-written record
|
|
21
|
+
* (ADR-0021).
|
|
22
|
+
*/
|
|
23
|
+
export class RunStore {
|
|
24
|
+
readonly #directory: string;
|
|
25
|
+
readonly #cap: number;
|
|
26
|
+
#chain: Promise<void> = Promise.resolve();
|
|
27
|
+
|
|
28
|
+
constructor(options: RunStoreOptions = {}) {
|
|
29
|
+
this.#directory = options.directory ?? resolveRunsDirectory();
|
|
30
|
+
this.#cap = options.cap ?? FINISHED_RECORD_CAP;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get directory(): string {
|
|
34
|
+
return this.#directory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Queues one record write; later writes for any Run never overtake earlier ones. */
|
|
38
|
+
save(record: RunRecord): Promise<void> {
|
|
39
|
+
this.#chain = this.#chain.then(() => this.#write(record));
|
|
40
|
+
return this.#chain;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolves once every queued write has been attempted. */
|
|
44
|
+
async settled(): Promise<void> {
|
|
45
|
+
await this.#chain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every readable record, oldest start first. Unreadable records are skipped. */
|
|
49
|
+
async load(): Promise<readonly RunRecord[]> {
|
|
50
|
+
let entries: readonly string[];
|
|
51
|
+
try {
|
|
52
|
+
entries = await readdir(this.#directory);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const records = await Promise.all(
|
|
57
|
+
entries.filter((entry) => entry.endsWith(".json")).map((entry) => this.#read(entry)),
|
|
58
|
+
);
|
|
59
|
+
return records
|
|
60
|
+
.filter((record): record is RunRecord => record !== null)
|
|
61
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Removes the oldest finished records above the cap. `live`, `orphaned`, and
|
|
66
|
+
* `interrupted` records are never pruned: they are the only trace of a Run
|
|
67
|
+
* nobody accounted for.
|
|
68
|
+
*/
|
|
69
|
+
async prune(): Promise<void> {
|
|
70
|
+
const finished = (await this.load()).filter((record) => record.state === "finished");
|
|
71
|
+
const excess = finished.slice(0, Math.max(0, finished.length - this.#cap));
|
|
72
|
+
await Promise.all(excess.map((record) => this.remove(record.id)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async remove(id: string): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await unlink(join(this.#directory, `${id}.json`));
|
|
78
|
+
} catch {
|
|
79
|
+
// Already gone, or never written; nothing to report.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #write(record: RunRecord): Promise<void> {
|
|
84
|
+
const target = join(this.#directory, `${record.id}.json`);
|
|
85
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(this.#directory, { recursive: true });
|
|
88
|
+
await writeFile(temp, JSON.stringify(record), "utf8");
|
|
89
|
+
await rename(temp, target);
|
|
90
|
+
} catch {
|
|
91
|
+
await unlink(temp).catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async #read(entry: string): Promise<RunRecord | null> {
|
|
96
|
+
try {
|
|
97
|
+
const value: unknown = JSON.parse(await readFile(join(this.#directory, entry), "utf8"));
|
|
98
|
+
return isRunRecord(value) ? value : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type {
|
|
3
|
+
import type { BackgroundStatus } from "./background-status.ts";
|
|
4
4
|
import { resolveBun } from "./resolve-bun.ts";
|
|
5
5
|
import { resolveCliEntry } from "./resolve-cli.ts";
|
|
6
6
|
import type { RunDetails } from "./run-details.ts";
|
|
@@ -40,10 +40,10 @@ export async function run(
|
|
|
40
40
|
readonly registry?: RunRegistry;
|
|
41
41
|
readonly sent?: Sent[];
|
|
42
42
|
readonly start?: (options: import("./spawn-run.ts").StartRunOptions) => RunHandle;
|
|
43
|
-
/** A tui-mode context, for the
|
|
43
|
+
/** A tui-mode context, for the inline foreground path. */
|
|
44
44
|
readonly ctx?: ExtensionContext;
|
|
45
45
|
readonly store?: RunTreeStore;
|
|
46
|
-
readonly
|
|
46
|
+
readonly status?: BackgroundStatus;
|
|
47
47
|
} = {},
|
|
48
48
|
): Promise<{
|
|
49
49
|
readonly text: string;
|
|
@@ -56,7 +56,7 @@ export async function run(
|
|
|
56
56
|
registry: opts.registry ?? new RunRegistry(),
|
|
57
57
|
...(opts.start === undefined ? {} : { start: opts.start }),
|
|
58
58
|
...(opts.store === undefined ? {} : { store: opts.store }),
|
|
59
|
-
...(opts.
|
|
59
|
+
...(opts.status === undefined ? {} : { status: opts.status }),
|
|
60
60
|
sendMessage: (message, options) =>
|
|
61
61
|
void opts.sent?.push({
|
|
62
62
|
content: message.content,
|
package/src/run-tool.ts
CHANGED
|
@@ -4,14 +4,16 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import { initialSummary } from "@yaag/runtime";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
-
import { type
|
|
7
|
+
import { type BackgroundStatus, createBackgroundStatus } from "./background-status.ts";
|
|
8
8
|
import type { RunDetails } from "./run-details.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { foregroundInline, foregroundResult, inlineAvailable } from "./run-foreground.ts";
|
|
10
10
|
import { RunTreeComponent } from "./run-tree-component.ts";
|
|
11
11
|
import { RunTreeStore } from "./run-trees.ts";
|
|
12
12
|
|
|
13
13
|
export type { RunDetails } from "./run-details.ts";
|
|
14
14
|
|
|
15
|
+
import { type ProcessIdentity, readProcessStart } from "./process-liveness.ts";
|
|
16
|
+
import { mintRunId } from "./run-id.ts";
|
|
15
17
|
import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
|
|
16
18
|
import { errorText, failure, observedSettlement } from "./run-settlement.ts";
|
|
17
19
|
import { type RunHandle, type RunOutcome, type StartRunOptions, startRun } from "./spawn-run.ts";
|
|
@@ -48,8 +50,8 @@ export interface RunToolOptions {
|
|
|
48
50
|
readonly start?: (options: StartRunOptions) => RunHandle;
|
|
49
51
|
/** Per-session renderer projections; defaulted so a test may omit it. */
|
|
50
52
|
readonly store?: RunTreeStore;
|
|
51
|
-
/** The
|
|
52
|
-
readonly
|
|
53
|
+
/** The background footer segment; defaulted so a test may omit it. */
|
|
54
|
+
readonly status?: BackgroundStatus;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const DESCRIPTION = [
|
|
@@ -88,7 +90,7 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
|
|
|
88
90
|
const { bun, cli, registry, sendMessage } = deps;
|
|
89
91
|
const start = deps.start ?? startRun;
|
|
90
92
|
const store = deps.store ?? new RunTreeStore();
|
|
91
|
-
const
|
|
93
|
+
const status = deps.status ?? createBackgroundStatus({ registry, store });
|
|
92
94
|
return {
|
|
93
95
|
name: "yaag_run",
|
|
94
96
|
label: "Run",
|
|
@@ -122,13 +124,12 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
|
|
|
122
124
|
throw new Error(`yaag_run: no such Orchestration Program: ${file}`);
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
const id =
|
|
127
|
+
const id = mintRunId();
|
|
126
128
|
const background = params.background === true;
|
|
127
|
-
const
|
|
129
|
+
const inline = inlineAvailable(ctx, background);
|
|
128
130
|
// Attached before start(): the Ask ledger folds from events, so a state
|
|
129
|
-
// created when
|
|
131
|
+
// created when the frame is first drawn would miss every early Ask.
|
|
130
132
|
const state = store.attach(id, background ? "background" : "foreground");
|
|
131
|
-
const tree = interactive ? state : undefined;
|
|
132
133
|
const run = registeredRun({
|
|
133
134
|
bun,
|
|
134
135
|
cli,
|
|
@@ -140,33 +141,28 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
|
|
|
140
141
|
start,
|
|
141
142
|
onUpdate,
|
|
142
143
|
store,
|
|
143
|
-
onIngest: () => view?.touch(),
|
|
144
144
|
});
|
|
145
|
-
let view: { touch(): void } | undefined;
|
|
146
145
|
|
|
147
146
|
if (background) {
|
|
148
147
|
// Attached before returning, so a Run that ends immediately still
|
|
149
148
|
// announces itself (ADR-0005).
|
|
150
|
-
void announce(id, run.outcome, registry, sendMessage,
|
|
151
|
-
|
|
149
|
+
void announce(id, run.outcome, registry, sendMessage, status);
|
|
150
|
+
status.refresh();
|
|
152
151
|
return {
|
|
153
152
|
content: [{ type: "text", text: `Run ${id} started in the background.` }],
|
|
154
153
|
details: { summary: run.summary, id },
|
|
155
154
|
};
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
if (
|
|
159
|
-
return await
|
|
157
|
+
if (!inline) return await foregroundResult({ run, registry, signal });
|
|
158
|
+
return await foregroundInline({
|
|
160
159
|
run,
|
|
161
160
|
registry,
|
|
162
161
|
signal,
|
|
163
162
|
ctx,
|
|
164
|
-
state
|
|
163
|
+
state,
|
|
165
164
|
store,
|
|
166
|
-
announce: () => void announce(id, run.outcome, registry, sendMessage,
|
|
167
|
-
onOpen: (opened) => {
|
|
168
|
-
view = opened;
|
|
169
|
-
},
|
|
165
|
+
announce: () => void announce(id, run.outcome, registry, sendMessage, status),
|
|
170
166
|
});
|
|
171
167
|
},
|
|
172
168
|
};
|
|
@@ -195,11 +191,11 @@ async function announce(
|
|
|
195
191
|
outcome: Promise<RunOutcome>,
|
|
196
192
|
registry: RunRegistry,
|
|
197
193
|
sendMessage: SendMessage,
|
|
198
|
-
|
|
194
|
+
status: BackgroundStatus,
|
|
199
195
|
): Promise<void> {
|
|
200
196
|
const settlement = await observedSettlement(id, outcome, registry);
|
|
201
|
-
// The Run left `registry.live`, so
|
|
202
|
-
|
|
197
|
+
// The Run left `registry.live`, so the footer segment must drop it.
|
|
198
|
+
status.refresh();
|
|
203
199
|
const content = completionContent(id, settlement);
|
|
204
200
|
const details = completionDetails(id, settlement, registry);
|
|
205
201
|
await sendMessage<RunDetails>(
|
|
@@ -221,10 +217,7 @@ function registeredRun(options: {
|
|
|
221
217
|
readonly onUpdate?: (partial: { readonly content: []; readonly details: RunDetails }) => void;
|
|
222
218
|
/** The projection store; it ingests each occurrence exactly once. */
|
|
223
219
|
readonly store: RunTreeStore;
|
|
224
|
-
/** Redraw request for the pushed path: fd 3 fold → ingest → requestRender. */
|
|
225
|
-
readonly onIngest?: () => void;
|
|
226
220
|
}): LiveRun {
|
|
227
|
-
let run: LiveRun;
|
|
228
221
|
const handle = options.start({
|
|
229
222
|
bun: options.bun,
|
|
230
223
|
cli: options.cli,
|
|
@@ -233,17 +226,32 @@ function registeredRun(options: {
|
|
|
233
226
|
record: options.record,
|
|
234
227
|
resume: options.resume,
|
|
235
228
|
onProgress: (summary, event, sequence) => {
|
|
236
|
-
|
|
229
|
+
options.registry.progress(options.id, summary);
|
|
237
230
|
options.store.ingest(options.id, { summary, event, sequence, id: options.id });
|
|
238
231
|
options.onUpdate?.({ content: [], details: { summary, event, sequence, id: options.id } });
|
|
239
|
-
options.onIngest?.();
|
|
240
232
|
},
|
|
241
233
|
});
|
|
242
|
-
run = {
|
|
243
|
-
|
|
234
|
+
const run: LiveRun = {
|
|
235
|
+
id: options.id,
|
|
236
|
+
stop: handle.stop,
|
|
237
|
+
outcome: handle.outcome,
|
|
238
|
+
summary: initialSummary(),
|
|
239
|
+
};
|
|
240
|
+
const launch = {
|
|
241
|
+
file: options.file,
|
|
242
|
+
...(options.args === undefined ? {} : { args: options.args }),
|
|
243
|
+
...(options.record === undefined ? {} : { record: options.record }),
|
|
244
|
+
...(options.resume === undefined ? {} : { resume: options.resume }),
|
|
245
|
+
};
|
|
246
|
+
options.registry.add(run, launch, processIdentity(handle.pid));
|
|
244
247
|
return run;
|
|
245
248
|
}
|
|
246
249
|
|
|
250
|
+
/** A pid alone cannot be trusted after a restart, so its start time goes with it. */
|
|
251
|
+
function processIdentity(pid: number | undefined): ProcessIdentity | null {
|
|
252
|
+
return pid === undefined ? null : { pid, startedAt: readProcessStart(pid) };
|
|
253
|
+
}
|
|
254
|
+
|
|
247
255
|
function completionContent(id: string, settlement: RunSettlement): string {
|
|
248
256
|
if (settlement.kind === "rejected") return `Run ${id} failed: ${errorText(settlement.reason)}`;
|
|
249
257
|
const { outcome } = settlement;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* `yaag_run` tool result or its details-only progress frames.
|
|
4
4
|
*
|
|
5
5
|
* It is not interactive — the rpc-mode and details-only path has no input focus
|
|
6
|
-
* (
|
|
6
|
+
* (architecture §9). The inline frame a blocking Run draws is `run-foreground.ts`, and
|
|
7
|
+
* the interactive view is `/yaag` (`yaag-command.ts`).
|
|
7
8
|
*/
|
|
8
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
10
|
import { renderTree, TreeState } from "@yaag/tui";
|
package/src/run-tree-host.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The read-only half of `RunTreeViewHost`, adapted from a pi context.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* and `done`.
|
|
4
|
+
* `/yaag` composes it, so the property that a Peek observes and never sends is
|
|
5
|
+
* asserted in one place: nothing here can prompt an Agent. The caller adds
|
|
6
|
+
* `stop` and `done`.
|
|
8
7
|
*/
|
|
9
8
|
import { readFile } from "node:fs/promises";
|
|
10
9
|
import { copyToClipboard, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -22,7 +21,7 @@ export interface RunTreeHostOptions {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/** The read-only capabilities every Run view shares. */
|
|
25
|
-
export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "
|
|
24
|
+
export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "done">;
|
|
26
25
|
|
|
27
26
|
/** Builds the read-only host; it never writes to an Agent or to the Run. */
|
|
28
27
|
export function createRunTreeHost(options: RunTreeHostOptions): ReadOnlyRunTreeHost {
|
package/src/run-trees.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `RunRegistry` stays the domain record — ids, stop capabilities, outcomes —
|
|
5
5
|
* and this store holds the bounded renderer-only fold beside it, so the inline
|
|
6
|
-
*
|
|
6
|
+
* frame of a blocking Run and `/yaag` read the same state. A
|
|
7
7
|
* `TreeState` is bounded on every axis (node cap, Ask-ledger pruning, 2-line
|
|
8
8
|
* output tails), so keeping one per Run for the session is bounded memory.
|
|
9
9
|
*/
|
|
10
10
|
import { TreeState, type TreeUpdate } from "@yaag/tui";
|
|
11
11
|
|
|
12
|
-
/** Which surface owns a Run: the blocking tool call, or the
|
|
12
|
+
/** Which surface owns a Run: the blocking tool call, or the background. */
|
|
13
13
|
export type RunKind = "foreground" | "background";
|
|
14
14
|
|
|
15
15
|
interface Entry {
|
|
@@ -49,8 +49,8 @@ export class RunTreeStore {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Converts a foreground Run to a background Run after a detach, then
|
|
52
|
-
* notifies subscribers so the
|
|
53
|
-
*
|
|
52
|
+
* notifies subscribers so the footer segment counts the Run at once, without
|
|
53
|
+
* waiting for a later Lifecycle Event.
|
|
54
54
|
*/
|
|
55
55
|
adopt(id: string): void {
|
|
56
56
|
const found = this.#entries.get(id);
|
package/src/runs-dir.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const RUNS_SUFFIX = join("yaag", "runs");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves where Run records are stored: `YAAG_RUNS_DIR`, then an absolute
|
|
8
|
+
* `XDG_STATE_HOME`, then `$HOME/.local/state`. A relative `XDG_STATE_HOME` is
|
|
9
|
+
* ignored (XDG spec). Same conventions as the checkpoint directory (ADR-0021).
|
|
10
|
+
*/
|
|
11
|
+
export function resolveRunsDirectory(env: NodeJS.ProcessEnv = process.env): string {
|
|
12
|
+
const override = env["YAAG_RUNS_DIR"];
|
|
13
|
+
if (override !== undefined && override !== "") return override;
|
|
14
|
+
const xdg = env["XDG_STATE_HOME"];
|
|
15
|
+
if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, RUNS_SUFFIX);
|
|
16
|
+
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
|
|
17
|
+
return join(home, ".local", "state", RUNS_SUFFIX);
|
|
18
|
+
}
|