From 603253dc9abe79444dfc044c1e3593a11e301f19 Mon Sep 17 00:00:00 2001 From: Christoffer Hallas Date: Tue, 9 Jun 2026 08:44:46 -0400 Subject: [PATCH 1/2] feat: add manifest process state grammar --- agents/BADGER.md | 35 +++ docs/agents/issue-tracker.md | 21 +- docs/agents/triage-labels.md | 20 +- src/app.ts | 16 + ...cross-runtime-startup-dependencies.test.ts | 2 + src/docker.test.ts | 2 + src/docker.ts | 6 +- src/external-runtime-session.test.ts | 2 + src/external-runtime.test.ts | 2 + src/focus.ts | 1 + src/manifest-editing.test.ts | 2 + src/runtime-status.test.ts | 36 +++ src/runtime-status.ts | 73 +++++ src/service-manager.test.ts | 27 ++ src/service-manager.ts | 47 ++- src/shutdown.test.ts | 2 + src/types.ts | 12 + src/ui.ts | 278 +++++++++++------- src/workspace-startup.test.ts | 2 + 19 files changed, 443 insertions(+), 143 deletions(-) create mode 100644 agents/BADGER.md create mode 100644 src/runtime-status.test.ts create mode 100644 src/runtime-status.ts diff --git a/agents/BADGER.md b/agents/BADGER.md new file mode 100644 index 0000000..fd851c6 --- /dev/null +++ b/agents/BADGER.md @@ -0,0 +1,35 @@ +# Badger Book initialized + +Version: 0.0.1 +Date: 2026-06-07 +Initialized: 2026-06-09 +Updated: 2026-06-09 + +## Issue Tracker + +- Type: GitHub Issues + GitHub Project +- Repository: https://github.com/modoterra/stasium +- Project: https://github.com/orgs/modoterra/projects/1 + +## Labels And Status + +Labels are for triage/readiness: + +- `needs-triage` +- `needs-info` +- `ready-for-agent` +- `ready-for-human` +- `wontfix` + +GitHub Project `Status` is for delivery progress: + +- `Todo` +- `In Progress` +- `In Review` +- `Ready to Merge` +- `Done` +- `Blocked` + +Do not duplicate delivery progress as labels while the GitHub Project is configured. + +Rerun `/initialize` when workflow labels, Project fields, or `docs/agents` drift. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 67b9b2c..e09f75f 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -38,17 +38,16 @@ For multiple issues, repeat both steps for each issue. Do not leave newly create Use the `Status` field in the `Stasium` Project to show workflow state: -| Status | Use when | -| ----------------- | ---------------------------------------------------- | -| `Needs triage` | New or unclassified item | -| `Needs info` | Waiting on reporter, owner, or product clarification | -| `Ready for agent` | Fully specified and safe for an AFK coding agent | -| `Ready for human` | Requires human judgment or implementation | -| `In progress` | Actively owned work | -| `Done` | Completed or merged work | -| `Won't fix` | Closed as intentionally not actioned | - -When creating issues from a PRD or plan, set `Ready for agent` only if the issue is independently grabbable and has enough acceptance criteria. Otherwise set `Needs triage` or `Needs info`. +| Status | Use when | +| ---------------- | ------------------------------------------------------ | +| `Todo` | Queued for delivery | +| `In Progress` | Actively owned work | +| `In Review` | PR exists and is awaiting review or checks | +| `Ready to Merge` | PR is self-reviewed and all agent-runnable checks pass | +| `Done` | Completed or merged work | +| `Blocked` | Delivery cannot continue until a blocker is resolved | + +When creating issues from a PRD or plan, apply the `ready-for-agent` label only if the issue is independently grabbable and has enough acceptance criteria. Otherwise apply `needs-triage` or `needs-info`. Use Project `Status` for delivery progress only. To set a Project item status, fetch the Project item ID and option IDs, then update the `Status` field: diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index 9d66838..94687e9 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -1,15 +1,15 @@ # Triage Labels -The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. Delivery progress lives in the GitHub Project `Status` field, not in labels. -| Label in mattpocock/skills | Label in our tracker | Project `Status` | Meaning | -| -------------------------- | -------------------- | ----------------- | ---------------------------------------- | -| `needs-triage` | `needs-triage` | `Needs triage` | Maintainer needs to evaluate this issue | -| `needs-info` | `needs-info` | `Needs info` | Waiting on reporter for more information | -| `ready-for-agent` | `ready-for-agent` | `Ready for agent` | Fully specified, ready for an AFK agent | -| `ready-for-human` | `ready-for-human` | `Ready for human` | Requires human implementation | -| `wontfix` | `wontfix` | `Won't fix` | Will not be actioned | +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | -When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table and set the matching Project `Status` when the issue is in the `Stasium` Project. +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. -Issues with no triage label should usually be placed in `Needs triage`. Use `In progress` for actively owned work and `Done` for completed Project items. +Issues with no triage label should usually be placed in `needs-triage`. Use the GitHub Project `Status` field for active delivery states such as `In Progress`, `In Review`, `Ready to Merge`, `Done`, and `Blocked`. diff --git a/src/app.ts b/src/app.ts index 1a12cb6..3d75027 100644 --- a/src/app.ts +++ b/src/app.ts @@ -160,6 +160,13 @@ const setupKeybindings = ( case "i": await openDiscovery(); break; + case "space": + if (manager.getSelectedView()) { + manager.deselect(); + } else if (manager.getViews().length > 0) { + manager.setSelectedIndex(0); + } + break; case "d": { const view = manager.getSelectedView(); if (view) { @@ -419,6 +426,15 @@ const setupKeybindings = ( case "select": manager.moveSelection(1); return; + case "scope": + case "all logs": + case "service logs": + if (manager.getSelectedView()) { + manager.deselect(); + } else if (manager.getViews().length > 0) { + manager.setSelectedIndex(0); + } + return; default: return; } diff --git a/src/cross-runtime-startup-dependencies.test.ts b/src/cross-runtime-startup-dependencies.test.ts index 5cac5a4..87d4796 100644 --- a/src/cross-runtime-startup-dependencies.test.ts +++ b/src/cross-runtime-startup-dependencies.test.ts @@ -7,6 +7,7 @@ import { LaunchInstructionExecutionAdapter } from "./launch-execution"; import { addProcessDefinition } from "./manifest-editing"; import { loadManifest, saveManifest } from "./manifest"; import { ProcessClaimStore } from "./process-claim"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { ServiceManager } from "./service-manager"; import { normalizeProcessDefinition } from "./process-definition"; import type { ExternalManagedProcess, LogEntry, ServicePid } from "./types"; @@ -19,6 +20,7 @@ const externalProcess = ( runtimeName: "Docker Compose", name, state, + runtimeStatus: getExternalRuntimeStatus(state), status: state, ports: "", }); diff --git a/src/docker.test.ts b/src/docker.test.ts index 2932233..b4053f0 100644 --- a/src/docker.test.ts +++ b/src/docker.test.ts @@ -101,6 +101,7 @@ describe("createDockerComposeExternalRuntimeAdapter", () => { runtimeName: "Docker Compose", name: "api", state: "exited", + runtimeStatus: "off", status: "Exited", ports: "", }, @@ -109,6 +110,7 @@ describe("createDockerComposeExternalRuntimeAdapter", () => { runtimeName: "Docker Compose", name: "db", state: "running", + runtimeStatus: "running", status: "Up", ports: "5432", }, diff --git a/src/docker.ts b/src/docker.ts index 0196050..ffdc460 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -4,6 +4,7 @@ import type { ExternalRuntimeAdapter, ExternalRuntimeOutputStream, } from "./external-runtime"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { fileExists } from "./shared"; import type { ExternalManagedProcess, ExternalManagedProcessState, LogEntry } from "./types"; @@ -194,11 +195,13 @@ class DockerComposeExternalRuntime implements ExternalRuntime { return getStableDockerServiceNames(configServices, entryOrder).map((name) => { const list = entriesByService.get(name) ?? []; if (list.length === 0) { + const state = "created"; return { runtimeId: this.id, runtimeName: this.name, name, - state: "created", + state, + runtimeStatus: getExternalRuntimeStatus(state), status: "", ports: "", }; @@ -213,6 +216,7 @@ class DockerComposeExternalRuntime implements ExternalRuntime { runtimeName: this.name, name, state, + runtimeStatus: getExternalRuntimeStatus(state), status: representative?.Status ?? "", ports: representative?.Ports ?? "", }; diff --git a/src/external-runtime-session.test.ts b/src/external-runtime-session.test.ts index c068a1c..bd2c19d 100644 --- a/src/external-runtime-session.test.ts +++ b/src/external-runtime-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { ExternalRuntimeSession } from "./external-runtime"; +import { getExternalRuntimeStatus } from "./runtime-status"; import type { ExternalManagedProcess } from "./types"; describe("External Runtime Session", () => { @@ -28,6 +29,7 @@ const process = (runtimeId: string, name: string): ExternalManagedProcess => ({ runtimeName: runtimeId, name, state: "running", + runtimeStatus: getExternalRuntimeStatus("running"), status: "Up", ports: "", }); diff --git a/src/external-runtime.test.ts b/src/external-runtime.test.ts index 8279639..83cb596 100644 --- a/src/external-runtime.test.ts +++ b/src/external-runtime.test.ts @@ -5,6 +5,7 @@ import { type ExternalRuntime, type ExternalRuntimeAdapter, } from "./external-runtime"; +import { getExternalRuntimeStatus } from "./runtime-status"; import type { ExternalManagedProcess, LogEntry } from "./types"; describe("detectExternalRuntimes", () => { @@ -236,6 +237,7 @@ const process = ( runtimeName: runtimeId, name, state, + runtimeStatus: getExternalRuntimeStatus(state), status: "Up", ports: "", }); diff --git a/src/focus.ts b/src/focus.ts index 7c0fd5a..427e6d8 100644 --- a/src/focus.ts +++ b/src/focus.ts @@ -10,6 +10,7 @@ const MANIFEST_SHORTCUTS: Shortcut[] = [ { key: "i", label: "discover" }, { key: "d", label: "delete" }, { key: "e", label: "edit" }, + { key: "space", label: "scope" }, { key: "up/down", label: "select" }, ]; diff --git a/src/manifest-editing.test.ts b/src/manifest-editing.test.ts index e3ae4c5..aa1118e 100644 --- a/src/manifest-editing.test.ts +++ b/src/manifest-editing.test.ts @@ -12,6 +12,7 @@ import { import { loadManifest, renderServiceBlock, saveManifest } from "./manifest"; import { normalizeProcessDefinition } from "./process-definition"; import { ProcessClaimStore } from "./process-claim"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { ServiceManager } from "./service-manager"; import type { ExternalManagedProcess } from "./types"; @@ -53,6 +54,7 @@ const externalProcess = (name = "db"): ExternalManagedProcess => ({ runtimeName: "Docker Compose", name, state: "running", + runtimeStatus: getExternalRuntimeStatus("running"), status: "running", ports: "", }); diff --git a/src/runtime-status.test.ts b/src/runtime-status.test.ts new file mode 100644 index 0000000..0f90d82 --- /dev/null +++ b/src/runtime-status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { + getDirectManagedRuntimeStatus, + getExternalRuntimeStatus, + getRuntimeStatusView, +} from "./runtime-status"; + +describe("runtime status", () => { + test("uses user-facing codes for canonical statuses", () => { + expect(getRuntimeStatusView("running")).toMatchObject({ code: "Run", label: "running" }); + expect(getRuntimeStatusView("retrying")).toMatchObject({ code: "Rty", label: "retrying" }); + expect(getRuntimeStatusView("errored")).toMatchObject({ code: "Err", label: "errored" }); + expect(getRuntimeStatusView("blocked")).toMatchObject({ code: "Blk", label: "blocked" }); + expect(getRuntimeStatusView("paused")).toMatchObject({ code: "Pau", label: "paused" }); + expect(getRuntimeStatusView("unknown")).toMatchObject({ code: "Unk", label: "unknown" }); + }); + + test("normalizes direct-managed service states", () => { + expect(getDirectManagedRuntimeStatus("RUNNING", null)).toBe("running"); + expect(getDirectManagedRuntimeStatus("STARTING", null)).toBe("starting"); + expect(getDirectManagedRuntimeStatus("STOPPING", null)).toBe("stopping"); + expect(getDirectManagedRuntimeStatus("FAILED", null)).toBe("errored"); + expect(getDirectManagedRuntimeStatus("BLOCKED", null)).toBe("blocked"); + expect(getDirectManagedRuntimeStatus("STOPPED", null)).toBe("off"); + expect(getDirectManagedRuntimeStatus("FAILED", 250)).toBe("retrying"); + }); + + test("normalizes external runtime states", () => { + expect(getExternalRuntimeStatus("running")).toBe("running"); + expect(getExternalRuntimeStatus("restarting")).toBe("retrying"); + expect(getExternalRuntimeStatus("dead")).toBe("errored"); + expect(getExternalRuntimeStatus("paused")).toBe("paused"); + expect(getExternalRuntimeStatus("exited")).toBe("off"); + expect(getExternalRuntimeStatus("unknown")).toBe("unknown"); + }); +}); diff --git a/src/runtime-status.ts b/src/runtime-status.ts new file mode 100644 index 0000000..a24b485 --- /dev/null +++ b/src/runtime-status.ts @@ -0,0 +1,73 @@ +import type { ExternalManagedProcessState, RuntimeStatus, ServiceState } from "./types"; + +export interface RuntimeStatusView { + status: RuntimeStatus; + code: string; + label: string; + severity: "good" | "attention" | "bad" | "muted"; +} + +export const getRuntimeStatusView = (status: RuntimeStatus): RuntimeStatusView => { + switch (status) { + case "running": + return { status, code: "Run", label: "running", severity: "good" }; + case "retrying": + return { status, code: "Rty", label: "retrying", severity: "attention" }; + case "errored": + return { status, code: "Err", label: "errored", severity: "bad" }; + case "blocked": + return { status, code: "Blk", label: "blocked", severity: "attention" }; + case "starting": + return { status, code: "Str", label: "starting", severity: "attention" }; + case "stopping": + return { status, code: "Stp", label: "stopping", severity: "attention" }; + case "off": + return { status, code: "Off", label: "off", severity: "muted" }; + case "paused": + return { status, code: "Pau", label: "paused", severity: "attention" }; + case "unknown": + return { status, code: "Unk", label: "unknown", severity: "muted" }; + } +}; + +export const getDirectManagedRuntimeStatus = ( + state: ServiceState, + restartInMs: number | null, +): RuntimeStatus => { + if (restartInMs !== null) return "retrying"; + + switch (state) { + case "RUNNING": + return "running"; + case "STARTING": + return "starting"; + case "STOPPING": + return "stopping"; + case "FAILED": + return "errored"; + case "BLOCKED": + return "blocked"; + case "STOPPED": + return "off"; + } +}; + +export const getExternalRuntimeStatus = (state: ExternalManagedProcessState): RuntimeStatus => { + switch (state) { + case "running": + return "running"; + case "restarting": + return "retrying"; + case "paused": + return "paused"; + case "dead": + return "errored"; + case "created": + case "removing": + return "starting"; + case "exited": + return "off"; + case "unknown": + return "unknown"; + } +}; diff --git a/src/service-manager.test.ts b/src/service-manager.test.ts index 524cba6..d3961dd 100644 --- a/src/service-manager.test.ts +++ b/src/service-manager.test.ts @@ -3,6 +3,7 @@ import { ExternalRuntimeVisibilityManager, type ExternalRuntime } from "./extern import { LaunchInstructionExecutionAdapter } from "./launch-execution"; import { normalizeProcessDefinition, type ProcessDefinitionInput } from "./process-definition"; import { ProcessClaimStore } from "./process-claim"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { ServiceManager, ServiceManagerError } from "./service-manager"; import { planStartupDependencies } from "./startup-dependency-plan"; import type { ExternalManagedProcess, LogEntry, ProcessDefinition, ServicePid } from "./types"; @@ -64,6 +65,7 @@ const externalProcess = ( runtimeName: "Docker Compose", name, state, + runtimeStatus: getExternalRuntimeStatus(state), status: state, ports: "", }); @@ -114,6 +116,30 @@ describe("ServiceManager", () => { expect(manager.getSelectedConfig()?.name).toBe("worker"); }); + test("supports deselecting the Workspace service selection", () => { + const manager = new ServiceManager([makeConfig("api"), makeConfig("worker")]); + + expect(manager.getSelectedView()?.runtimeStatus).toBe("off"); + + manager.deselect(); + + expect(manager.getSelectedIndex()).toBe(-1); + expect(manager.getSelectedView()).toBeNull(); + expect(manager.getSelectedConfig()).toBeNull(); + + manager.moveSelection(1); + + expect(manager.getSelectedIndex()).toBe(0); + expect(manager.getSelectedConfig()?.name).toBe("api"); + }); + + test("reports no Workspace selection for an empty manifest", () => { + const manager = new ServiceManager([]); + + expect(manager.getSelectedIndex()).toBe(-1); + expect(manager.getSelectedView()).toBeNull(); + }); + test("starts dependencies before selected service", async () => { const manager = new ServiceManager([ service({ @@ -368,6 +394,7 @@ describe("ServiceManager", () => { }); expect(hasPendingRestart).toBe(true); + expect(manager.getSelectedView()?.runtimeStatus).toBe("retrying"); const restarted = await waitFor(() => { const view = manager.getSelectedView(); diff --git a/src/service-manager.ts b/src/service-manager.ts index baa46b8..7da0fb5 100644 --- a/src/service-manager.ts +++ b/src/service-manager.ts @@ -8,14 +8,16 @@ import { LogBuffer } from "./log-buffer"; import { LaunchInstructionExecutionAdapter } from "./launch-execution"; import { ProcessOutputStore } from "./process-output-store"; import { ProcessClaimStore } from "./process-claim"; +import { getDirectManagedRuntimeStatus } from "./runtime-status"; import { type ServiceEvent, ServiceProcess } from "./service"; import { ServiceGraphError } from "./service-graph"; import { StartupDependencyPlanError, planStartupDependencies } from "./startup-dependency-plan"; -import type { ProcessDefinition, ServicePid, ServiceState } from "./types"; +import type { ProcessDefinition, RuntimeStatus, ServicePid, ServiceState } from "./types"; export interface ServiceView { name: string; state: ServiceState; + runtimeStatus: RuntimeStatus; lastExitCode: number | null; restartCount: number; manualRestartCount: number; @@ -73,9 +75,11 @@ export class ServiceManager { this.services = configs.map( (config) => new ServiceProcess(config, this.launchAdapter, this.processClaimStore), ); + this.selectedIndex = configs.length === 0 ? -1 : 0; this.views = this.services.map((service) => ({ name: service.config.name, state: "STOPPED", + runtimeStatus: "off", lastExitCode: null, restartCount: 0, manualRestartCount: 0, @@ -104,13 +108,17 @@ export class ServiceManager { } setSelectedIndex(index: number): void { - const max = Math.max(0, this.views.length - 1); - const next = Math.min(Math.max(index, 0), max); + const max = this.views.length - 1; + const next = this.views.length === 0 ? -1 : Math.min(Math.max(index, -1), max); if (next === this.selectedIndex) return; this.selectedIndex = next; this.notify(); } + deselect(): void { + this.setSelectedIndex(-1); + } + moveSelection(delta: number): void { this.setSelectedIndex(this.selectedIndex + delta); } @@ -234,6 +242,7 @@ export class ServiceManager { throw new ServiceManagerError(`Service name already exists: ${config.name}`); } + const wasEmpty = this.views.length === 0; this.assertValidConfigGraph([...this.getConfigs(), config]); const process = new ServiceProcess(config, this.launchAdapter, this.processClaimStore); @@ -241,6 +250,7 @@ export class ServiceManager { this.views.push({ name: config.name, state: "STOPPED", + runtimeStatus: "off", lastExitCode: null, restartCount: 0, manualRestartCount: 0, @@ -250,6 +260,7 @@ export class ServiceManager { }); this.lifecycles.set(process, this.createLifecycle(process)); this.unsubscribers.push(this.subscribeService(process)); + if (wasEmpty) this.selectedIndex = 0; await this.forEachResolvedService(this.getStartOrderForService(config.name), async (next) => { await this.startServiceUnlessBlocked(next); @@ -259,7 +270,7 @@ export class ServiceManager { } async removeSelected(): Promise { - if (this.services.length === 0) return false; + if (this.services.length === 0 || this.selectedIndex === -1) return false; const index = this.selectedIndex; const service = this.services[index]; if (!service) return false; @@ -273,11 +284,10 @@ export class ServiceManager { this.services.splice(index, 1); this.views.splice(index, 1); - if (this.selectedIndex >= this.views.length && this.views.length > 0) { - this.selectedIndex = this.views.length - 1; - } if (this.views.length === 0) { - this.selectedIndex = 0; + this.selectedIndex = -1; + } else if (this.selectedIndex >= this.views.length) { + this.selectedIndex = this.views.length - 1; } this.notify(); @@ -307,6 +317,7 @@ export class ServiceManager { view.name = config.name; view.config = config; view.state = "STOPPED"; + view.runtimeStatus = "off"; view.lastExitCode = null; view.restartInMs = null; view.log.clear(); @@ -343,6 +354,7 @@ export class ServiceManager { view.restartInMs = null; this.getLifecycle(service)?.noteRunning(); } + this.updateRuntimeStatus(view); this.notifyProcessChange(); } else if (event.type === "log") { view.log.add(event.entry); @@ -352,6 +364,7 @@ export class ServiceManager { lifecycle?.noteExit(view, event.code); if (lifecycle?.hasPendingRestart()) this.startRestartTicker(); view.lastExitCode = event.code; + this.updateRuntimeStatus(view); this.notifyProcessChange(); } @@ -434,6 +447,10 @@ export class ServiceManager { return this.views[index] ?? null; } + private updateRuntimeStatus(view: ServiceView): void { + view.runtimeStatus = getDirectManagedRuntimeStatus(view.state, view.restartInMs); + } + private async startService( service: ServiceProcess, options: { resetAttempts: boolean } = { resetAttempts: true }, @@ -441,6 +458,7 @@ export class ServiceManager { const view = this.getViewByService(service); if (view) { view.restartInMs = null; + this.updateRuntimeStatus(view); } const lifecycle = this.getLifecycle(service); @@ -501,7 +519,10 @@ export class ServiceManager { private clearServiceRuntimeState(service: ServiceProcess): void { const view = this.getViewByService(service); - if (view) this.getLifecycle(service)?.clearRuntimeState(view); + if (view) { + this.getLifecycle(service)?.clearRuntimeState(view); + this.updateRuntimeStatus(view); + } } private startRestartTicker(): void { @@ -510,6 +531,9 @@ export class ServiceManager { this.restartTicker = setInterval(() => { const now = Date.now(); const changed = this.lifecycles.tick((service) => this.getViewByService(service), now); + if (changed) { + for (const view of this.views) this.updateRuntimeStatus(view); + } if (!this.lifecycles.hasPendingRestart()) { this.stopRestartTicker(); @@ -529,7 +553,10 @@ export class ServiceManager { private async stopService(service: ServiceProcess): Promise { const view = this.getViewByService(service); - if (view) this.getLifecycle(service)?.suppressRestart(view); + if (view) { + this.getLifecycle(service)?.suppressRestart(view); + this.updateRuntimeStatus(view); + } if (!service.isRunning()) return; await service.stop(); diff --git a/src/shutdown.test.ts b/src/shutdown.test.ts index 34c9144..d8ede2a 100644 --- a/src/shutdown.test.ts +++ b/src/shutdown.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { ExternalRuntimeVisibilityManager, type ExternalRuntime } from "./external-runtime"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { createShutdownHandler } from "./shutdown"; import type { ServiceManager } from "./service-manager"; import type { ExternalManagedProcess, LogEntry } from "./types"; @@ -189,6 +190,7 @@ const externalProcess = ( runtimeName: runtimeId, name, state, + runtimeStatus: getExternalRuntimeStatus(state), status: state, ports: "", }); diff --git a/src/types.ts b/src/types.ts index 6967280..8de61d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,17 @@ export type RestartPolicy = "never" | "on-failure" | "always"; export type ServiceState = "STOPPED" | "STARTING" | "RUNNING" | "FAILED" | "STOPPING" | "BLOCKED"; +export type RuntimeStatus = + | "running" + | "retrying" + | "errored" + | "blocked" + | "starting" + | "stopping" + | "off" + | "paused" + | "unknown"; + export type CommandSpec = string | string[]; export interface LaunchInstruction { @@ -76,6 +87,7 @@ export interface ExternalManagedProcess { runtimeName: string; name: string; state: ExternalManagedProcessState; + runtimeStatus: RuntimeStatus; status: string; ports: string; } diff --git a/src/ui.ts b/src/ui.ts index a3272eb..27b8e09 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -11,9 +11,17 @@ import { import type { DiscoverySelection, SelectionItem } from "./discovery"; import type { ExternalRuntimeVisibilityManager } from "./external-runtime"; import type { FocusManager } from "./focus"; +import { getRuntimeStatusView } from "./runtime-status"; import type { ServiceManager, ServiceView } from "./service-manager"; import { formatCommandSpec } from "./shared"; -import type { ExternalManagedProcess, LogEntry, Manifest, PanelId, Shortcut } from "./types"; +import type { + ExternalManagedProcess, + LogEntry, + Manifest, + PanelId, + RuntimeStatus, + Shortcut, +} from "./types"; import { STASIUM_VERSION } from "./version"; interface Palette { @@ -106,45 +114,20 @@ const MIN_APP_WIDTH = 80; const MIN_APP_HEIGHT_WITH_EXTERNAL_RUNTIME = 35; const MIN_APP_HEIGHT_NO_EXTERNAL_RUNTIME = 28; -const stateColor = (state: ServiceView["state"], palette: Palette): string => { - switch (state) { - case "RUNNING": +const runtimeStatusColor = (status: RuntimeStatus, palette: Palette): string => { + switch (getRuntimeStatusView(status).severity) { + case "good": return palette.green; - case "STARTING": + case "attention": return palette.amber; - case "STOPPING": - return palette.amber; - case "FAILED": - return palette.red; - default: - return palette.muted; - } -}; - -const externalProcessStateColor = ( - state: ExternalManagedProcess["state"], - palette: Palette, -): string => { - switch (state) { - case "running": - return palette.green; - case "restarting": - return palette.amber; - case "paused": - return palette.amber; - case "exited": - return palette.red; - case "dead": + case "bad": return palette.red; - default: + case "muted": return palette.muted; } }; -const formatState = (state: ServiceView["state"]) => state.padEnd(8, " "); - -const formatExternalProcessState = (state: ExternalManagedProcess["state"]) => - state.padEnd(10, " "); +const formatRuntimeStatus = (status: RuntimeStatus) => getRuntimeStatusView(status).code; const formatExit = (exit: number | null) => { if (exit === null) return "--"; @@ -168,24 +151,28 @@ const padRight = (value: string, width: number): string => { const formatManifestLine = (view: ServiceView, selected: boolean, rowWidth: number): string => { if (rowWidth <= 0) return ""; - const prefix = selected ? ">" : " "; - const status = formatState(view.state); + const prefix = selected ? "┃" : " "; + const status = formatRuntimeStatus(view.runtimeStatus); const meta = view.restartInMs !== null - ? `retry:${Math.ceil(view.restartInMs)}ms rst:${view.restartCount}` - : `exit:${formatExit(view.lastExitCode)} rst:${view.restartCount}`; - - const baseWidth = 2 + status.length + 1; - const metaWidth = rowWidth >= 56 ? 22 : rowWidth >= 46 ? 16 : 0; + ? `${Math.ceil(view.restartInMs)}ms` + : view.lastExitCode !== null && view.lastExitCode !== 0 + ? `Ext ${formatExit(view.lastExitCode)}` + : view.restartCount > 0 + ? `Rst ${view.restartCount}` + : ""; + + const baseWidth = 2 + status.length + 2; + const metaWidth = rowWidth >= 56 ? 12 : rowWidth >= 46 ? 8 : 0; const nameWidth = Math.max(4, rowWidth - baseWidth - (metaWidth > 0 ? metaWidth + 1 : 0)); const name = padRight(view.name, nameWidth); - if (metaWidth > 0) { - const right = padRight(meta, metaWidth); - return `${prefix} ${status} ${name} ${right}`.slice(0, rowWidth); + if (metaWidth > 0 && meta) { + const right = truncateText(meta, metaWidth).padStart(metaWidth, " "); + return `${prefix} ${status} ${name} ${right}`.slice(0, rowWidth); } - return `${prefix} ${status} ${name}`.slice(0, rowWidth); + return `${prefix} ${status} ${name}`.slice(0, rowWidth); }; const formatExternalProcessLine = ( @@ -195,20 +182,20 @@ const formatExternalProcessLine = ( ): string => { if (rowWidth <= 0) return ""; const prefix = selected ? ">" : " "; - const status = formatExternalProcessState(service.state); + const status = formatRuntimeStatus(service.runtimeStatus); const meta = service.ports ? `ports:${service.ports}` : service.status; - const baseWidth = 2 + status.length + 1; + const baseWidth = 2 + status.length + 2; const metaWidth = rowWidth >= 52 ? 18 : rowWidth >= 42 ? 12 : 0; const nameWidth = Math.max(4, rowWidth - baseWidth - (metaWidth > 0 ? metaWidth + 1 : 0)); const name = padRight(service.name, nameWidth); if (metaWidth > 0) { const right = padRight(meta, metaWidth); - return `${prefix} ${status} ${name} ${right}`.slice(0, rowWidth); + return `${prefix} ${status} ${name} ${right}`.slice(0, rowWidth); } - return `${prefix} ${status} ${name}`.slice(0, rowWidth); + return `${prefix} ${status} ${name}`.slice(0, rowWidth); }; const ensureIndexVisible = (box: ScrollBoxRenderable, index: number): void => { @@ -607,6 +594,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo "switch panel": "switch", "next field": "next", follow: "tail", + scope: "scope", + "all logs": "all logs", + "service logs": "svc logs", discover: "scan", "manifest panel": "manifest", "external panel": "external", @@ -618,6 +608,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo start: 90, stop: 90, restart: 85, + scope: 82, + "all logs": 82, + "service logs": 82, select: 80, scroll: 80, page: 75, @@ -678,11 +671,16 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }; const getFooterLayout = () => { - const shortcuts = logsPanelVisible + const baseShortcuts = logsPanelVisible ? focusManager.getShortcuts() : focusManager .getShortcuts() .filter((shortcut) => shortcut.label !== "log page" && shortcut.label !== "log jump"); + const shortcuts = baseShortcuts.map((shortcut) => + shortcut.label === "scope" + ? { ...shortcut, label: manager.getSelectedView() ? "all logs" : "service logs" } + : shortcut, + ); const available = Math.max(0, renderer.width - 30); if (measureFooterWidth(shortcuts, "full") <= available) { @@ -746,6 +744,28 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo return palette.muted; }; + const statusSummaryOrder: RuntimeStatus[] = [ + "errored", + "retrying", + "blocked", + "starting", + "stopping", + "off", + "paused", + "unknown", + "running", + ]; + + const formatStatusSummary = (statuses: RuntimeStatus[], emptyLabel: string): string => { + if (statuses.length === 0) return emptyLabel; + const counts = new Map(); + for (const status of statuses) counts.set(status, (counts.get(status) ?? 0) + 1); + return statusSummaryOrder + .filter((status) => counts.has(status)) + .map((status) => `${counts.get(status)} ${getRuntimeStatusView(status).code}`) + .join(" · "); + }; + const footerShortcutBackground = (hovered: boolean): string => hovered ? palette.hover : "transparent"; @@ -759,12 +779,12 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const buildHeaderStatus = (): Array<{ content: string; fg: string; panel?: PanelId }> => { const views = manager.getViews(); - const running = views.filter((view) => view.state === "RUNNING").length; - const failed = views.filter((view) => view.state === "FAILED").length; - const stopped = views.filter((view) => view.state === "STOPPED").length; + const manifestStatuses = views.map((view) => view.runtimeStatus); + const running = manifestStatuses.filter((status) => status === "running").length; + const failed = manifestStatuses.filter((status) => status === "errored").length; const segments: Array<{ content: string; fg: string; panel?: PanelId }> = [ { - content: `${running}/${views.length} svc`, + content: formatStatusSummary(manifestStatuses, "Add a service"), fg: summaryColor(running, views.length, failed), panel: "manifest", }, @@ -772,42 +792,19 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo if (hasExternalRuntime && externalRuntimeManager) { const externalProcesses = externalRuntimeManager.getProcesses(); - const externalRunning = externalProcesses.filter( - (process) => process.state === "running", - ).length; - const externalStopped = externalProcesses.filter( - (service) => service.state === "dead" || service.state === "exited", - ).length; - - segments.push({ - content: `${externalRunning}/${externalProcesses.length} external`, - fg: summaryColor(externalRunning, externalProcesses.length, externalStopped), - panel: "external", - }); + const externalStatuses = externalProcesses.map((process) => process.runtimeStatus); + const externalRunning = externalStatuses.filter((status) => status === "running").length; + const externalErrored = externalStatuses.filter((status) => status === "errored").length; segments.push({ - content: `${externalStopped} stopped`, - fg: externalStopped > 0 ? palette.red : palette.muted, + content: `external ${formatStatusSummary(externalStatuses, "0 services")}`, + fg: summaryColor(externalRunning, externalProcesses.length, externalErrored), panel: "external", }); return segments; } - segments.push({ - content: `${stopped} stopped`, - fg: stopped > 0 ? palette.amber : palette.muted, - panel: "logs", - }); - - if (failed > 0) { - segments.push({ - content: `${failed} failed`, - fg: palette.red, - panel: "logs", - }); - } - return segments; }; @@ -880,22 +877,26 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const activeLogName = logSource === "external" ? (selectedExternalProcess?.name ?? "external") - : (selectedManifest?.name ?? "service"); + : (selectedManifest?.name ?? "all"); const tailState = logsFollowTail ? "tail:on" : "tail:paused"; - const manifestState = selectedManifest?.state.toLowerCase() ?? "none"; - const externalProcessState = selectedExternalProcess?.state ?? "none"; + const manifestStatus = selectedManifest + ? getRuntimeStatusView(selectedManifest.runtimeStatus) + : null; + const externalProcessStatus = selectedExternalProcess + ? getRuntimeStatusView(selectedExternalProcess.runtimeStatus) + : null; const segments = [ { content: `layout:${formatVisiblePanels(visiblePanels)}`, fg: palette.secondary }, { content: `panel:${panelName(activePanel)}`, fg: panelTitleColor(activePanel) }, { - content: `svc:${selectedManifest?.name ?? "-"} (${manifestState})`, - fg: selectedManifest ? stateColor(selectedManifest.state, palette) : palette.muted, + content: `svc:${selectedManifest?.name ?? "-"} (${manifestStatus?.label ?? "none"})`, + fg: manifestStatus ? runtimeStatusColor(manifestStatus.status, palette) : palette.muted, }, { - content: `external:${selectedExternalProcess?.name ?? "-"} (${externalProcessState})`, - fg: selectedExternalProcess - ? externalProcessStateColor(selectedExternalProcess.state, palette) + content: `external:${selectedExternalProcess?.name ?? "-"} (${externalProcessStatus?.label ?? "none"})`, + fg: externalProcessStatus + ? runtimeStatusColor(externalProcessStatus.status, palette) : palette.muted, }, { @@ -1358,6 +1359,37 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const getLogEntryKey = (entry: LogEntry, index: number): string => `${entry.timestamp}:${entry.stream}:${index}:${entry.line}`; + const getManifestLogTarget = (): { + entries: LogEntry[]; + version: number; + name: string | null; + } => { + const selected = manager.getSelectedView(); + if (selected) { + return { + entries: selected.log.all(), + version: selected.log.getVersion(), + name: selected.name, + }; + } + + const entries = manager + .getViews() + .flatMap((view) => + view.log.all().map((entry) => ({ + ...entry, + line: `[${view.name}] ${entry.line}`, + })), + ) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + return { + entries, + version: manager.getViews().reduce((sum, view) => sum + view.log.getVersion(), 0), + name: null, + }; + }; + const listRowBackground = (panel: PanelId, selected: boolean, hovered: boolean): string => { if (selected) return listSelectionBackground(); if (hovered) return listHoverBackground(); @@ -1424,11 +1456,8 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const getActiveLogEntries = (): LogEntry[] => { const source = logSource === "external" && externalRuntimeManager ? "external" : "manifest"; - const buffer = - source === "external" - ? (externalRuntimeManager?.getActiveLogBuffer() ?? null) - : (manager.getSelectedView()?.log ?? null); - return buffer?.all() ?? []; + if (source === "external") return externalRuntimeManager?.getActiveLogBuffer()?.all() ?? []; + return getManifestLogTarget().entries; }; const moveLogSelection = (delta: number): void => { @@ -1713,6 +1742,21 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }; const rebuildList = (views: ServiceView[], selectedIndex: number) => { + if (views.length === 0) { + listLines = syncRows(manifestList, listLines, 1, "service"); + const line = listLines[0]; + if (line) { + line.content = "No Process Definitions yet. Press a to add or i to discover."; + line.fg = palette.muted; + line.bg = panelBackgroundColor("manifest"); + line.onMouseDown = undefined; + line.onMouseOver = undefined; + line.onMouseOut = undefined; + } + manifestPanelMeta.content = "Add a service"; + return; + } + listLines = syncRows(manifestList, listLines, views.length, "service"); const viewportWidth = Math.floor(manifestList.viewport.width); const rowWidth = Math.max(20, viewportWidth > 0 ? viewportWidth - 1 : 48); @@ -1721,8 +1765,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const selected = index === selectedIndex; const line = listLines[index]; if (!line) return; + const status = view.runtimeStatus; line.content = formatManifestLine(view, selected, rowWidth); - line.fg = selected ? palette.active : stateColor(view.state, palette); + line.fg = runtimeStatusColor(status, palette); line.bg = listRowBackground("manifest", selected, index === hoveredManifestIndex); line.onMouseDown = (event) => { event.stopPropagation(); @@ -1734,7 +1779,10 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }; }); - manifestPanelMeta.content = `${views.filter((view) => view.state === "RUNNING").length}/${views.length} running`; + manifestPanelMeta.content = formatStatusSummary( + views.map((view) => view.runtimeStatus), + "Add a service", + ); ensureIndexVisible(manifestList, selectedIndex); }; @@ -1752,8 +1800,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const selected = index === selectedIdx; const line = externalLines[index]; if (!line) return; + const status = service.runtimeStatus; line.content = formatExternalProcessLine(service, selected, rowWidth); - line.fg = selected ? palette.active : externalProcessStateColor(service.state, palette); + line.fg = runtimeStatusColor(status, palette); line.bg = listRowBackground("external", selected, index === hoveredExternalIndex); line.onMouseDown = (event) => { event.stopPropagation(); @@ -1765,9 +1814,10 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }; }); - externalPanelMeta.content = `${services.filter((service) => service.state === "running").length}/${ - services.length - } running`; + externalPanelMeta.content = formatStatusSummary( + services.map((service) => service.runtimeStatus), + "0 services", + ); ensureIndexVisible(externalList, selectedIdx); }; @@ -1777,11 +1827,10 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo source === "external" ? (externalRuntimeManager?.getSelectedIndex() ?? 0) : manager.getSelectedIndex(); - const buffer = - source === "external" - ? (externalRuntimeManager?.getActiveLogBuffer() ?? null) - : (manager.getSelectedView()?.log ?? null); - const version = buffer ? buffer.getVersion() : 0; + const manifestLogTarget = source === "manifest" ? getManifestLogTarget() : null; + const externalLogBuffer = + source === "external" ? (externalRuntimeManager?.getActiveLogBuffer() ?? null) : null; + const version = manifestLogTarget?.version ?? externalLogBuffer?.getVersion() ?? 0; if ( version === lastLogVersion && @@ -1801,7 +1850,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo lastSelectedIndex = selectedIndex; lastLogSource = source; - const entries = buffer?.all() ?? []; + const entries = manifestLogTarget?.entries ?? externalLogBuffer?.all() ?? []; const entryKeys = new Set(entries.map((entry, index) => getLogEntryKey(entry, index))); if (hoveredLogEntryKey && !entryKeys.has(hoveredLogEntryKey)) hoveredLogEntryKey = null; if (selectedLogEntryKey && !entryKeys.has(selectedLogEntryKey)) selectedLogEntryKey = null; @@ -1893,7 +1942,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo } const selected = manager.getSelectedView(); - logPanelMeta.content = `${selected?.name ?? "service"} lines:${entries.length} show:${visibleStart}-${visibleEnd} ${logsFollowTail ? "tail:on" : "tail:off"} scroll:${scroll}%`; + logPanelMeta.content = `${selected?.name ?? "all services"} lines:${entries.length} show:${visibleStart}-${visibleEnd} ${logsFollowTail ? "tail:on" : "tail:off"} scroll:${scroll}%`; }; const updatePanelStyles = () => { @@ -1901,7 +1950,11 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo manifestPanelTitle.fg = panelTitleColor("manifest"); manifestPanel.backgroundColor = panelBackgroundColor("manifest"); - logPanelTitle.content = "Logs"; + const selectedLogName = + logSource === "external" && externalRuntimeManager + ? externalRuntimeManager.getSelectedService()?.name + : manager.getSelectedView()?.name; + logPanelTitle.content = selectedLogName ? `Logs (${selectedLogName})` : "Logs"; logPanelTitle.fg = panelTitleColor("logs"); const logsBackground = panelBackgroundColor("logs"); logPanel.backgroundColor = logsBackground; @@ -2361,9 +2414,12 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo return; } - const view = manager.getSelectedView(); - if (view) { - view.log.clear(); + const selectedView = manager.getSelectedView(); + const views = selectedView ? [selectedView] : manager.getViews(); + if (views.length > 0) { + for (const view of views) { + view.log.clear(); + } resetLogInteraction(); lastLogVersion = -1; lastSelectedIndex = -1; diff --git a/src/workspace-startup.test.ts b/src/workspace-startup.test.ts index aa34633..86120d7 100644 --- a/src/workspace-startup.test.ts +++ b/src/workspace-startup.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveManifest } from "./manifest"; import { normalizeProcessDefinition } from "./process-definition"; +import { getExternalRuntimeStatus } from "./runtime-status"; import { startWorkspace } from "./workspace-startup"; import type { ExternalRuntime, ExternalRuntimeAdapter } from "./external-runtime"; import type { ExternalManagedProcess, LogEntry } from "./types"; @@ -107,6 +108,7 @@ const externalProcess = (): ExternalManagedProcess => ({ runtimeName: "Docker Compose", name: "db", state: "running", + runtimeStatus: getExternalRuntimeStatus("running"), status: "Up", ports: "", }); From ab567389e3aa6a6f41e6b851f1ea5418aeee48a8 Mon Sep 17 00:00:00 2001 From: Christoffer Hallas Date: Tue, 9 Jun 2026 09:07:42 -0400 Subject: [PATCH 2/2] fix: treat created external processes as off --- src/runtime-status.test.ts | 1 + src/runtime-status.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/runtime-status.test.ts b/src/runtime-status.test.ts index 0f90d82..75f5d83 100644 --- a/src/runtime-status.test.ts +++ b/src/runtime-status.test.ts @@ -30,6 +30,7 @@ describe("runtime status", () => { expect(getExternalRuntimeStatus("restarting")).toBe("retrying"); expect(getExternalRuntimeStatus("dead")).toBe("errored"); expect(getExternalRuntimeStatus("paused")).toBe("paused"); + expect(getExternalRuntimeStatus("created")).toBe("off"); expect(getExternalRuntimeStatus("exited")).toBe("off"); expect(getExternalRuntimeStatus("unknown")).toBe("unknown"); }); diff --git a/src/runtime-status.ts b/src/runtime-status.ts index a24b485..c6ab089 100644 --- a/src/runtime-status.ts +++ b/src/runtime-status.ts @@ -63,6 +63,7 @@ export const getExternalRuntimeStatus = (state: ExternalManagedProcessState): Ru case "dead": return "errored"; case "created": + return "off"; case "removing": return "starting"; case "exited":