@getpipher/armory-fleet 0.4.0 → 0.5.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 +1 -1
- package/src/engine/run-registry.ts +13 -1
- package/src/index.ts +67 -0
- package/src/lifecycle/run-lifecycle.ts +16 -0
- package/src/panel/bg-runs-store.ts +36 -0
- package/src/panel/fleet-items.ts +36 -0
- package/src/panel/fleet-panel.ts +164 -12
- package/src/panel/rows.ts +56 -0
- package/src/runtime/async-runner.ts +123 -0
- package/src/runtime/concurrency-pool.ts +27 -0
- package/src/runtime/results-inbox.ts +45 -0
- package/src/runtime/resume.ts +48 -0
- package/src/runtime/run-journal.ts +61 -0
- package/src/scheduling/expressions.ts +60 -0
- package/src/scheduling/pid-lock.ts +43 -0
- package/src/scheduling/scheduler.ts +150 -0
- package/src/tools/fleet-results.ts +35 -0
- package/src/tools/subagent.ts +26 -0
- package/src/vendor/cron-parser/NOTICE.md +23 -0
- package/src/vendor/cron-parser/lib/date.js +79 -0
- package/src/vendor/cron-parser/lib/expression.js +614 -0
- package/src/vendor/cron-parser/lib/number.js +8 -0
- package/src/vendor/cron-parser/lib/parser.js +103 -0
- package/src/vendor/cron-parser/package.json +3 -0
- package/src/vendor/cron-parser/types.d.ts +12 -0
- package/src/worktree/diff-service.ts +40 -0
- package/src/worktree/worktree-service.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,11 +23,15 @@ export function genRunId(): string {
|
|
|
23
23
|
return "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export type RunRegistryChangeListener = () => void;
|
|
27
|
+
|
|
26
28
|
export class RunRegistry {
|
|
27
29
|
private readonly runs = new Map<string, RunRecord>();
|
|
30
|
+
private readonly listeners = new Set<RunRegistryChangeListener>();
|
|
28
31
|
|
|
29
32
|
add(r: RunRecord): void {
|
|
30
33
|
this.runs.set(r.runId, r);
|
|
34
|
+
this.emit();
|
|
31
35
|
}
|
|
32
36
|
get(id: string): RunRecord | undefined {
|
|
33
37
|
return this.runs.get(id);
|
|
@@ -38,6 +42,14 @@ export class RunRegistry {
|
|
|
38
42
|
}
|
|
39
43
|
update(id: string, patch: Partial<Omit<RunRecord, "runId">>): void {
|
|
40
44
|
const r = this.runs.get(id);
|
|
41
|
-
if (r) this.runs.set(id, { ...r, ...patch });
|
|
45
|
+
if (r) { this.runs.set(id, { ...r, ...patch }); this.emit(); }
|
|
46
|
+
}
|
|
47
|
+
/** SPEC-5a proper-fix: subscribe to add/update mutations. Returns an unsubscribe fn. */
|
|
48
|
+
subscribe(fn: RunRegistryChangeListener): () => void {
|
|
49
|
+
this.listeners.add(fn);
|
|
50
|
+
return () => { this.listeners.delete(fn); };
|
|
51
|
+
}
|
|
52
|
+
private emit(): void {
|
|
53
|
+
for (const fn of this.listeners) fn();
|
|
42
54
|
}
|
|
43
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,16 @@ import { discoverLifecycles } from "./lifecycle/registry.ts";
|
|
|
30
30
|
import { DEFAULT_LIFECYCLE, builtinLifecyclesDir } from "./lifecycle/default.ts";
|
|
31
31
|
import type { LifecycleDef } from "./lifecycle/lifecycle-types.ts";
|
|
32
32
|
import type { LifecycleRunDeps } from "./lifecycle/run-lifecycle.ts";
|
|
33
|
+
import { WorktreeService } from "./worktree/worktree-service.ts";
|
|
34
|
+
import { DiffService } from "./worktree/diff-service.ts";
|
|
35
|
+
import { RunJournal } from "./runtime/run-journal.ts";
|
|
36
|
+
import { ConcurrencyPool } from "./runtime/concurrency-pool.ts";
|
|
37
|
+
import { ResultsInbox } from "./runtime/results-inbox.ts";
|
|
38
|
+
import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
|
|
39
|
+
import { scanResumeCandidates } from "./runtime/resume.ts";
|
|
40
|
+
import { Scheduler } from "./scheduling/scheduler.ts";
|
|
41
|
+
import { createFleetResultsTool } from "./tools/fleet-results.ts";
|
|
42
|
+
import { BgRunsStore } from "./panel/bg-runs-store.ts";
|
|
33
43
|
|
|
34
44
|
/** The package builtin agents/ dir, resolved relative to this module. */
|
|
35
45
|
function builtinAgentsDir(): string {
|
|
@@ -151,6 +161,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
151
161
|
deps.lifecycleDeps.registry = deps.lifecycleRegistry;
|
|
152
162
|
deps.lifecycleDeps.agentRegistry = deps.registry;
|
|
153
163
|
|
|
164
|
+
// ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
|
|
165
|
+
const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
|
|
166
|
+
const bgRuns = new BgRunsStore();
|
|
167
|
+
const resultsInbox = new ResultsInbox();
|
|
168
|
+
// The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
|
|
169
|
+
// spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
|
|
170
|
+
const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
|
|
171
|
+
const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
|
|
172
|
+
const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
|
|
173
|
+
const lifecycleFullDeps: LifecycleRunDeps = {
|
|
174
|
+
...deps.lifecycleDeps,
|
|
175
|
+
genRunId: () => opts.runId, // override: use the async runner's runId
|
|
176
|
+
// SPEC-5a (Q3=A): isolated run — worktree-diff artifact discovery instead of the prompt-baked block.
|
|
177
|
+
artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
|
|
178
|
+
spawn: async (o) => spawnSubagent({
|
|
179
|
+
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
180
|
+
skillsOverride: o.skills, backendOverride: o.backend,
|
|
181
|
+
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
182
|
+
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
|
|
186
|
+
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
187
|
+
};
|
|
188
|
+
// asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
|
|
189
|
+
// At init they're undefined — the subagent tool's `if (!deps.asyncRunner)` guard returns an
|
|
190
|
+
// actionable "not configured" error if called before session_start (can't happen in practice).
|
|
191
|
+
deps.bgRuns = bgRuns;
|
|
192
|
+
|
|
154
193
|
const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
|
|
155
194
|
const r = discoverAgents({
|
|
156
195
|
projectDir: join(ctx.cwd, ".pi", "agents"),
|
|
@@ -184,6 +223,32 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
184
223
|
const m = ctx.model;
|
|
185
224
|
deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
|
|
186
225
|
deps.parentCwd = ctx.cwd;
|
|
226
|
+
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
|
|
227
|
+
const dir = fleetDir(ctx.cwd);
|
|
228
|
+
deps.asyncRunner = {
|
|
229
|
+
worktree: new WorktreeService({ rootDir: ctx.cwd }),
|
|
230
|
+
diff: new DiffService(),
|
|
231
|
+
journal: new RunJournal(join(dir, "runs")),
|
|
232
|
+
pool: new ConcurrencyPool(3),
|
|
233
|
+
inbox: resultsInbox,
|
|
234
|
+
runLifecycle: asyncRunLifecycle,
|
|
235
|
+
notify: (m, lvl) => ctx.ui.notify(m, lvl),
|
|
236
|
+
genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
|
|
237
|
+
onProgress: (runId, status) => { bgRuns.set(runId, status); },
|
|
238
|
+
};
|
|
239
|
+
deps.scheduler = new Scheduler({
|
|
240
|
+
storePath: join(dir, "schedules.json"),
|
|
241
|
+
lockPath: join(dir, "schedules.lock"),
|
|
242
|
+
onFire: (spec) => {
|
|
243
|
+
if (!deps.asyncRunner) return;
|
|
244
|
+
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
deps.scheduler.start();
|
|
248
|
+
const cands = scanResumeCandidates(ctx.cwd, { runsDir: join(dir, "runs"), worktree: deps.asyncRunner.worktree });
|
|
249
|
+
if (cands.length > 0) {
|
|
250
|
+
ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
|
|
251
|
+
}
|
|
187
252
|
});
|
|
188
253
|
|
|
189
254
|
pi.on("resources_discover", (event, ctx) => {
|
|
@@ -192,6 +257,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
192
257
|
});
|
|
193
258
|
|
|
194
259
|
pi.registerTool(createSubagentTool(deps) as never);
|
|
260
|
+
// SPEC-5a: fleet.results — the agent pulls completed bg-run results from the inbox (Q6=C).
|
|
261
|
+
pi.registerTool(createFleetResultsTool({ inbox: resultsInbox }) as never);
|
|
195
262
|
|
|
196
263
|
pi.registerCommand("fleet", {
|
|
197
264
|
description: "Open the armory-fleet panel (running + recent subagents + agent registry).",
|
|
@@ -33,12 +33,20 @@ export interface LifecycleRunDeps {
|
|
|
33
33
|
/** Resolve the backend for a phase: phase.backend → lifecycle.backend → "pi" (+ availability check). */
|
|
34
34
|
resolveBackend: (phaseBackend: BackendId | undefined, lifecycleBackend: BackendId) => BackendId;
|
|
35
35
|
genRunId: () => string;
|
|
36
|
+
/** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery
|
|
37
|
+
* instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */
|
|
38
|
+
artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string };
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export interface LifecycleRunOpts {
|
|
39
42
|
deps: LifecycleRunDeps;
|
|
40
43
|
mode: LifecycleMode;
|
|
41
44
|
onCheckpoint: CheckpointFn;
|
|
45
|
+
/** SPEC-5a (Q3=A): the worktree path for isolated runs. When set + deps.artifactDiscovery is present,
|
|
46
|
+
* artifact discovery uses worktree-diff instead of parseArtifacts. Foreground runs leave this undefined. */
|
|
47
|
+
worktreePath?: string;
|
|
48
|
+
/** SPEC-5a: the base ref to diff against (default "HEAD"). */
|
|
49
|
+
baseRef?: string;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
export interface LifecycleRunResult {
|
|
@@ -143,6 +151,14 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
|
|
|
143
151
|
let phaseRec: PhaseRecord;
|
|
144
152
|
if (spawnRes.status === "failed") {
|
|
145
153
|
phaseRec = { name: phaseDef.name, summary: spawnRes.error ?? spawnRes.finalText.slice(0, 120), paths: [], status: "failed", reviseCount };
|
|
154
|
+
} else if (deps.artifactDiscovery && opts.worktreePath) {
|
|
155
|
+
// SPEC-5a (Q3=A): isolated run — structural worktree-diff (robust to models that omit the Artifacts block).
|
|
156
|
+
const art = deps.artifactDiscovery({ finalText: spawnRes.finalText, cwd: opts.worktreePath, baseRef: opts.baseRef ?? "HEAD", terminal: isTerminal });
|
|
157
|
+
if ("error" in art) {
|
|
158
|
+
phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount };
|
|
159
|
+
} else {
|
|
160
|
+
phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount };
|
|
161
|
+
}
|
|
146
162
|
} else {
|
|
147
163
|
const art = parseArtifacts(spawnRes.finalText, { terminal: isTerminal });
|
|
148
164
|
if ("error" in art) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/panel/bg-runs-store.ts
|
|
2
|
+
// SPEC-5a proper-fix: a change-emitting store for live bg-run status rows.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the bare `Map<string, BgRunStatus>` that `onProgress` used to mutate.
|
|
5
|
+
// The async runner's `onProgress` callback writes here on every phase transition
|
|
6
|
+
// and on completion; the /fleet panel subscribes so it re-renders the moment a
|
|
7
|
+
// bg run's status changes — even while the parent is idle (no `turn_end` fires
|
|
8
|
+
// for an async/fire-and-forget run). This is the SPEC-5b live-widget seam,
|
|
9
|
+
// delivered early as the proper fix for the stale "running" row bug.
|
|
10
|
+
import type { BgRunStatus } from "./rows.ts";
|
|
11
|
+
|
|
12
|
+
export type BgRunsChangeListener = (runId: string) => void;
|
|
13
|
+
|
|
14
|
+
export class BgRunsStore {
|
|
15
|
+
private readonly runs = new Map<string, BgRunStatus>();
|
|
16
|
+
private readonly listeners = new Set<BgRunsChangeListener>();
|
|
17
|
+
|
|
18
|
+
set(runId: string, status: BgRunStatus): void {
|
|
19
|
+
this.runs.set(runId, status);
|
|
20
|
+
for (const fn of this.listeners) fn(runId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(runId: string): BgRunStatus | undefined {
|
|
24
|
+
return this.runs.get(runId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
values(): IterableIterator<BgRunStatus> {
|
|
28
|
+
return this.runs.values();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Subscribe to status changes. Returns an unsubscribe function. */
|
|
32
|
+
subscribe(fn: BgRunsChangeListener): () => void {
|
|
33
|
+
this.listeners.add(fn);
|
|
34
|
+
return () => { this.listeners.delete(fn); };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/panel/fleet-items.ts
|
|
2
|
+
// SPEC-5a proper-fix: pure builder for the /fleet fleet-tab list items.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from FleetPanel.buildList so the merge + dedup of foreground
|
|
5
|
+
// (RunRegistry) rows and live bg (BgRunsStore) rows is unit-testable without
|
|
6
|
+
// a TUI harness. Foreground rows render via `fleetRow`; bg rows via
|
|
7
|
+
// `renderBgRow` (the Q8=A live status icons + phase progress). The two stores
|
|
8
|
+
// are disjoint by runId in practice (bg runs never enter RunRegistry under
|
|
9
|
+
// their own runId; the lifecycle's child spawns use their own runIds), but we
|
|
10
|
+
// dedup defensively in case a future change overlaps them.
|
|
11
|
+
import type { RunRecord } from "../engine/run-registry.ts";
|
|
12
|
+
import { fleetRow, renderBgRow, type BgRunStatus } from "./rows.ts";
|
|
13
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
export interface FleetItemSources {
|
|
16
|
+
runRegistry: { list(): RunRecord[] };
|
|
17
|
+
bgRuns?: { values(): IterableIterator<BgRunStatus> };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildFleetItems(src: FleetItemSources): SelectItem[] {
|
|
21
|
+
const items: SelectItem[] = [];
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
for (const r of src.runRegistry.list()) {
|
|
24
|
+
if (seen.has(r.runId)) continue;
|
|
25
|
+
seen.add(r.runId);
|
|
26
|
+
items.push({ value: r.runId, label: fleetRow(r) });
|
|
27
|
+
}
|
|
28
|
+
if (src.bgRuns) {
|
|
29
|
+
for (const b of src.bgRuns.values()) {
|
|
30
|
+
if (seen.has(b.runId)) continue;
|
|
31
|
+
seen.add(b.runId);
|
|
32
|
+
items.push({ value: b.runId, label: renderBgRow(b) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return items;
|
|
36
|
+
}
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
type SelectItem,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
13
|
+
import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
|
|
14
|
+
import { buildFleetItems } from "./fleet-items.ts";
|
|
15
|
+
import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
|
|
16
|
+
import type { BgRunsStore } from "./bg-runs-store.ts";
|
|
15
17
|
import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
|
|
16
18
|
import type { Backend, BackendRegistry } from "../backend/port.ts";
|
|
17
19
|
import type { RunRegistry } from "../engine/run-registry.ts";
|
|
@@ -21,7 +23,7 @@ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord
|
|
|
21
23
|
import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
|
|
22
24
|
import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
|
|
23
25
|
|
|
24
|
-
type View = "fleet" | "lifecycle" | "agents" | "backends";
|
|
26
|
+
type View = "fleet" | "lifecycle" | "agents" | "backends" | "scheduled";
|
|
25
27
|
|
|
26
28
|
export interface FleetPanelDeps {
|
|
27
29
|
registry: Map<string, AgentDef>;
|
|
@@ -35,6 +37,10 @@ export interface FleetPanelDeps {
|
|
|
35
37
|
lifecycleRegistry: Map<string, LifecycleDef>;
|
|
36
38
|
lifecycleRuns: Map<string, LifecycleRunRecord>;
|
|
37
39
|
lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
|
|
40
|
+
/** SPEC-5a: scheduler for the scheduled tab. Optional — panel degrades to an empty list when absent. */
|
|
41
|
+
scheduler?: Scheduler;
|
|
42
|
+
/** SPEC-5a: live bg run status rows for the fleet tab. Optional. */
|
|
43
|
+
bgRuns?: BgRunsStore;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export interface FleetPanelOpts {
|
|
@@ -67,6 +73,17 @@ export class FleetPanel extends Container {
|
|
|
67
73
|
private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
|
|
68
74
|
private lcReviseInput: Input | null = null;
|
|
69
75
|
private lcRevising = false;
|
|
76
|
+
// SPEC-5a: scheduled tab — add-schedule inline input state + selected schedule for i:Info
|
|
77
|
+
private schedRunMode = false;
|
|
78
|
+
private schedTaskInput: Input | null = null;
|
|
79
|
+
private schedExprInput: Input | null = null;
|
|
80
|
+
private schedNameInput: Input | null = null;
|
|
81
|
+
private schedPhase: "task" | "expr" | "name" = "task";
|
|
82
|
+
private selectedSchedule: Schedule | null = null;
|
|
83
|
+
/** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
|
|
84
|
+
* so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
|
|
85
|
+
private readonly unsubs: (() => void)[] = [];
|
|
86
|
+
private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
|
|
70
87
|
|
|
71
88
|
constructor(opts: FleetPanelOpts) {
|
|
72
89
|
super();
|
|
@@ -80,17 +97,25 @@ export class FleetPanel extends Container {
|
|
|
80
97
|
this.addChild(new Spacer(1));
|
|
81
98
|
this.list = this.buildList();
|
|
82
99
|
this.renderShell();
|
|
100
|
+
|
|
101
|
+
// SPEC-5a proper-fix: subscribe to run-registry + bg-runs mutations → live refresh.
|
|
102
|
+
// Covers both the model-invoked foreground case (spawnSubagent updates runRegistry)
|
|
103
|
+
// and the async/bg case (onProgress mutates BgRunsStore while the parent is idle).
|
|
104
|
+
this.unsubs.push(this.deps.runRegistry.subscribe(() => this.refresh()));
|
|
105
|
+
if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.refresh()));
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
private buildList(): SelectList {
|
|
86
109
|
const items: SelectItem[] =
|
|
87
110
|
this.view === "fleet"
|
|
88
|
-
?
|
|
111
|
+
? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns })
|
|
89
112
|
: this.view === "lifecycle"
|
|
90
113
|
? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
|
|
91
114
|
: this.view === "agents"
|
|
92
115
|
? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
|
|
93
|
-
: this.
|
|
116
|
+
: this.view === "scheduled"
|
|
117
|
+
? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
|
|
118
|
+
: this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
|
|
94
119
|
const fresh = new SelectList(items, 12, {
|
|
95
120
|
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
96
121
|
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
@@ -99,16 +124,36 @@ export class FleetPanel extends Container {
|
|
|
99
124
|
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
100
125
|
});
|
|
101
126
|
fresh.onSelect = (item: SelectItem) => this.onSelect(item.value);
|
|
102
|
-
fresh.onCancel = () => this.
|
|
127
|
+
fresh.onCancel = () => this.close();
|
|
103
128
|
return fresh;
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/** SPEC-5a proper-fix: re-pull from the run-registry + bg-runs stores and re-render.
|
|
132
|
+
* Called by the store-change subscriptions (constructor) on every mutation, so the
|
|
133
|
+
* fleet tab reflects completion/status changes without a keypress. Safe to call
|
|
134
|
+
* mid-overlay: renderShell preserves the active overlay (input/info/checkpoint). */
|
|
135
|
+
private refresh(): void {
|
|
136
|
+
this.list = this.buildList();
|
|
137
|
+
this.renderShell();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** SPEC-5a proper-fix: tear down store subscriptions then close the panel.
|
|
141
|
+
* Every exit path routes here so listeners never leak past the panel's lifetime.
|
|
142
|
+
* Idempotent — safe to call multiple times (esc + q + pi teardown). */
|
|
143
|
+
private close(): void {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
this.closed = true;
|
|
146
|
+
for (const u of this.unsubs) u();
|
|
147
|
+
this.unsubs.length = 0;
|
|
148
|
+
this.onDone();
|
|
149
|
+
}
|
|
150
|
+
|
|
106
151
|
private renderShell(): void {
|
|
107
152
|
const keep = this.children.slice(0, 2);
|
|
108
153
|
this.children.length = 0;
|
|
109
154
|
this.children.push(...keep);
|
|
110
155
|
const accent = (s: string): string => this.theme.fg("accent", s);
|
|
111
|
-
const tabs = (["fleet", "lifecycle", "agents", "backends"] as View[])
|
|
156
|
+
const tabs = (["fleet", "lifecycle", "agents", "backends", "scheduled"] as View[])
|
|
112
157
|
.map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
|
|
113
158
|
.join(" ");
|
|
114
159
|
this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
|
|
@@ -138,6 +183,24 @@ export class FleetPanel extends Container {
|
|
|
138
183
|
this.addChild(new Text(this.theme.fg("text", line), 0, 0));
|
|
139
184
|
}
|
|
140
185
|
this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
|
|
186
|
+
} else if (this.selectedSchedule) {
|
|
187
|
+
// SPEC-5a: i:Info detail pane (scheduled view)
|
|
188
|
+
this.addChild(new Text(this.theme.fg("dim", " ── schedule info ──"), 0, 0));
|
|
189
|
+
const s = this.selectedSchedule;
|
|
190
|
+
for (const line of [
|
|
191
|
+
`id: ${s.id}`,
|
|
192
|
+
`expression: ${s.expression}`,
|
|
193
|
+
`lifecycle: ${s.lifecycle}`,
|
|
194
|
+
`task: "${s.task}"`,
|
|
195
|
+
`paused: ${s.paused}`,
|
|
196
|
+
`nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
|
|
197
|
+
]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
|
|
198
|
+
this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
|
|
199
|
+
} else if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
|
|
200
|
+
const prompt = this.schedPhase === "task" ? " task> " : this.schedPhase === "expr" ? " schedule (cron | interval | one-shot ISO)> " : " lifecycle (blank=default)> ";
|
|
201
|
+
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
202
|
+
this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!);
|
|
203
|
+
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
141
204
|
} else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
|
|
142
205
|
const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
|
|
143
206
|
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
@@ -158,7 +221,7 @@ export class FleetPanel extends Container {
|
|
|
158
221
|
|
|
159
222
|
this.addChild(new Spacer(1));
|
|
160
223
|
const hint =
|
|
161
|
-
this.infoAgent || this.selectedBackend || this.selectedLifecycle
|
|
224
|
+
this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule
|
|
162
225
|
? " esc:Back"
|
|
163
226
|
: this.pendingCheckpoint
|
|
164
227
|
? " c:Continue v:Revise a:Abort"
|
|
@@ -170,7 +233,9 @@ export class FleetPanel extends Container {
|
|
|
170
233
|
? " r:Run-lifecycle i:Info tab:Agents q:Quit"
|
|
171
234
|
: this.view === "agents"
|
|
172
235
|
? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
|
|
173
|
-
: "
|
|
236
|
+
: this.view === "scheduled"
|
|
237
|
+
? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit"
|
|
238
|
+
: " r:Refresh i:Info tab:Fleet q:Quit";
|
|
174
239
|
this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
|
|
175
240
|
this.addChild(new Spacer(1));
|
|
176
241
|
this.addChild(new DynamicBorder(accent));
|
|
@@ -238,9 +303,11 @@ export class FleetPanel extends Container {
|
|
|
238
303
|
private switchView(): void {
|
|
239
304
|
this.view = this.view === "fleet" ? "lifecycle"
|
|
240
305
|
: this.view === "lifecycle" ? "agents"
|
|
241
|
-
: this.view === "agents" ? "backends"
|
|
306
|
+
: this.view === "agents" ? "backends"
|
|
307
|
+
: this.view === "backends" ? "scheduled" : "fleet";
|
|
242
308
|
this.selectedBackend = null;
|
|
243
309
|
this.selectedLifecycle = null;
|
|
310
|
+
this.selectedSchedule = null;
|
|
244
311
|
this.list = this.buildList();
|
|
245
312
|
this.renderShell();
|
|
246
313
|
}
|
|
@@ -258,6 +325,16 @@ export class FleetPanel extends Container {
|
|
|
258
325
|
if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); }
|
|
259
326
|
return;
|
|
260
327
|
}
|
|
328
|
+
if (this.selectedSchedule) {
|
|
329
|
+
if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
|
|
333
|
+
if (matchesKey(data, "escape")) { this.cancelScheduleAdd(); return; }
|
|
334
|
+
(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!).handleInput(data);
|
|
335
|
+
this.invalidate();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
261
338
|
if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
|
|
262
339
|
if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
|
|
263
340
|
(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
|
|
@@ -277,11 +354,11 @@ export class FleetPanel extends Container {
|
|
|
277
354
|
this.pendingCheckpoint.resolve({ action: "abort" });
|
|
278
355
|
this.pendingCheckpoint = null;
|
|
279
356
|
}
|
|
280
|
-
this.
|
|
357
|
+
this.close();
|
|
281
358
|
return;
|
|
282
359
|
}
|
|
283
360
|
if (matchesKey(data, "tab")) { this.switchView(); return; }
|
|
284
|
-
if (matchesKey(data, "q")) { this.
|
|
361
|
+
if (matchesKey(data, "q")) { this.close(); return; }
|
|
285
362
|
if (matchesKey(data, "r") && this.view === "agents") {
|
|
286
363
|
const sel = this.list.getSelectedItem();
|
|
287
364
|
if (sel) this.startRun(sel.value);
|
|
@@ -312,6 +389,28 @@ export class FleetPanel extends Container {
|
|
|
312
389
|
this.startLifecycleRun();
|
|
313
390
|
return;
|
|
314
391
|
}
|
|
392
|
+
// SPEC-5a: scheduled view — a:Add p:Pause/resume d:Delete i:Info
|
|
393
|
+
if (this.view === "scheduled" && this.deps.scheduler) {
|
|
394
|
+
if (matchesKey(data, "a")) { this.startScheduleAdd(); return; }
|
|
395
|
+
if (matchesKey(data, "i")) {
|
|
396
|
+
const sel = this.list.getSelectedItem();
|
|
397
|
+
if (sel) { this.selectedSchedule = this.deps.scheduler.list().find((x) => x.id === sel.value) ?? null; this.renderShell(); }
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (matchesKey(data, "p")) {
|
|
401
|
+
const sel = this.list.getSelectedItem();
|
|
402
|
+
if (sel) {
|
|
403
|
+
const s = this.deps.scheduler.list().find((x) => x.id === sel.value);
|
|
404
|
+
if (s) { s.paused ? this.deps.scheduler.resume(sel.value) : this.deps.scheduler.pause(sel.value); this.list = this.buildList(); this.renderShell(); }
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (matchesKey(data, "d")) {
|
|
409
|
+
const sel = this.list.getSelectedItem();
|
|
410
|
+
if (sel) { this.deps.scheduler.delete(sel.value); this.list = this.buildList(); this.renderShell(); }
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
315
414
|
// SPEC-4: pending checkpoint keys (c/v/a)
|
|
316
415
|
if (this.pendingCheckpoint && !this.lcRevising) {
|
|
317
416
|
if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
|
|
@@ -341,6 +440,59 @@ export class FleetPanel extends Container {
|
|
|
341
440
|
this.invalidate();
|
|
342
441
|
}
|
|
343
442
|
|
|
443
|
+
/** SPEC-5a: open the Add-schedule inline inputs (task → expression → lifecycle name → register). */
|
|
444
|
+
private startScheduleAdd(): void {
|
|
445
|
+
this.schedPhase = "task";
|
|
446
|
+
this.schedTaskInput = new Input();
|
|
447
|
+
this.schedTaskInput.onSubmit = (task: string) => {
|
|
448
|
+
if (!task.trim()) { this.cancelScheduleAdd(); return; }
|
|
449
|
+
this.schedPhase = "expr";
|
|
450
|
+
this.schedExprInput = new Input();
|
|
451
|
+
this.schedExprInput.onSubmit = (expr: string) => {
|
|
452
|
+
if (!expr.trim()) { this.cancelScheduleAdd(); return; }
|
|
453
|
+
this.schedPhase = "name";
|
|
454
|
+
this.schedNameInput = new Input();
|
|
455
|
+
this.schedNameInput.onSubmit = (name: string) => {
|
|
456
|
+
const lcName = name.trim() || "default";
|
|
457
|
+
this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
|
|
458
|
+
};
|
|
459
|
+
this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
|
|
460
|
+
this.renderShell();
|
|
461
|
+
};
|
|
462
|
+
this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
|
|
463
|
+
this.renderShell();
|
|
464
|
+
};
|
|
465
|
+
this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
|
|
466
|
+
this.schedRunMode = true;
|
|
467
|
+
this.renderShell();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private cancelScheduleAdd(): void {
|
|
471
|
+
this.schedRunMode = false;
|
|
472
|
+
this.schedTaskInput = null;
|
|
473
|
+
this.schedExprInput = null;
|
|
474
|
+
this.schedNameInput = null;
|
|
475
|
+
this.renderShell();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private executeScheduleAdd(task: string, expression: string, lifecycleName: string): void {
|
|
479
|
+
this.schedRunMode = false;
|
|
480
|
+
this.schedTaskInput = null;
|
|
481
|
+
this.schedExprInput = null;
|
|
482
|
+
this.schedNameInput = null;
|
|
483
|
+
if (!this.deps.scheduler) { this.onNotify("scheduling not configured", "error"); this.renderShell(); return; }
|
|
484
|
+
try {
|
|
485
|
+
const id = this.deps.scheduler.register({ task, expression, lifecycle: lifecycleName, auto: true });
|
|
486
|
+
this.list = this.buildList();
|
|
487
|
+
this.renderShell();
|
|
488
|
+
const entry = this.deps.scheduler.list().find((s) => s.id === id);
|
|
489
|
+
this.onNotify(`scheduled: ${id} · next fire: ${entry?.nextFire?.toLocaleString() ?? "(paused)"}`, "info");
|
|
490
|
+
} catch (e) {
|
|
491
|
+
this.onNotify(`schedule register failed: ${(e as Error).message}`, "error");
|
|
492
|
+
}
|
|
493
|
+
this.renderShell();
|
|
494
|
+
}
|
|
495
|
+
|
|
344
496
|
/** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */
|
|
345
497
|
private startLifecycleRun(): void {
|
|
346
498
|
this.lcPhase = "task";
|
package/src/panel/rows.ts
CHANGED
|
@@ -85,6 +85,62 @@ export function backendInfo(b: Backend): string {
|
|
|
85
85
|
|
|
86
86
|
import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts";
|
|
87
87
|
|
|
88
|
+
|
|
89
|
+
// SPEC-5a §11 — bg run row status (Q8=A). The fleet tab gains live status icons + phase progress
|
|
90
|
+
// for async/bg runs; foreground rows are unchanged.
|
|
91
|
+
export type BgStatus = "running" | "paused" | "completed" | "failed" | "queued";
|
|
92
|
+
|
|
93
|
+
export interface BgRunStatus {
|
|
94
|
+
runId: string;
|
|
95
|
+
lifecycle: string;
|
|
96
|
+
status: BgStatus;
|
|
97
|
+
phase: string;
|
|
98
|
+
phaseIndex: number;
|
|
99
|
+
phaseTotal: number;
|
|
100
|
+
mode: "auto" | "checkpointed";
|
|
101
|
+
backend: string;
|
|
102
|
+
task: string;
|
|
103
|
+
branch?: string;
|
|
104
|
+
elapsedMs?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function bgStatusIcon(s: BgStatus): string {
|
|
108
|
+
switch (s) {
|
|
109
|
+
case "running": return "▶";
|
|
110
|
+
case "paused": return "⏸";
|
|
111
|
+
case "completed": return "✓";
|
|
112
|
+
case "failed": return "✗";
|
|
113
|
+
case "queued": return "⏳";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function renderBgRow(r: BgRunStatus): string {
|
|
118
|
+
const icon = bgStatusIcon(r.status);
|
|
119
|
+
const phase = r.phase ? `●${r.phase} ${r.phaseIndex}/${r.phaseTotal}` : `${r.phaseIndex}/${r.phaseTotal}`;
|
|
120
|
+
const branch = r.branch ? ` ${r.branch}` : "";
|
|
121
|
+
const elapsed = r.elapsedMs ? ` ${fmtDuration(r.elapsedMs)}` : "";
|
|
122
|
+
const task = r.task.length > 30 ? r.task.slice(0, 29) + "…" : r.task;
|
|
123
|
+
return `${icon} ${r.runId} ${r.lifecycle} ${phase} ${r.mode}${elapsed} ${r.backend}${branch} "${task}"`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// SPEC-5a §11 — scheduled tab row rendering.
|
|
127
|
+
export interface ScheduleRow {
|
|
128
|
+
id: string;
|
|
129
|
+
expression: string;
|
|
130
|
+
lifecycle?: string;
|
|
131
|
+
task: string;
|
|
132
|
+
nextFire: Date | null;
|
|
133
|
+
paused: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function scheduleRow(s: ScheduleRow): string {
|
|
137
|
+
const icon = s.paused ? "⏸" : "▶";
|
|
138
|
+
const next = s.nextFire ? `next: ${s.nextFire.toLocaleString()}` : "paused";
|
|
139
|
+
const task = s.task.length > 24 ? s.task.slice(0, 23) + "…" : s.task;
|
|
140
|
+
const lc = s.lifecycle ?? "default";
|
|
141
|
+
return `${icon} ${s.expression} ${lc} "${task}" ${next} ${s.id}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
88
144
|
const LC_GLYPH: Record<LifecycleStatus, string> = {
|
|
89
145
|
running: "▶", checkpoint: "⏸", completed: "✓", failed: "✗", aborted: "✗",
|
|
90
146
|
};
|