Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/a-change-shows-the-logs-of-its-own-worktree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@openspec-ui/core": patch
"@openspec-ui/server": patch
"openspec-ui-vscode": patch
"@openspec-ui/webui": patch
---

Show Logs on a change's card reads the logs of the change's own worktree, where its runs write them, and the card of a change worked in its own worktree offers it. Before, the button read only the host's root and said there were no logs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-10-12
follows:
- a-change-shows-its-run-logs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Design

## Context

`createFileRunLogs(workspaceRoot)` writes under the root of the directory a run
runs in. A change worked in its own worktree therefore logs there, not in the
checkout the host was opened on. The read side, `listRunLogs` and `readRunLog`,
takes one root, and every host passes its own. ADR 0026 already forbids acting
on a path a request names; `resolveOwnWorktree` finds a change's worktree from
its name and `git worktree list`.

## Goals / Non-Goals

**Goals:**

- A card's Show Logs lists, and opens, the runs of its change wherever they
were logged.
- Every card of a change in its own worktree offers Show Logs.

**Non-Goals:**

- No change to where logs are written, to the log format, or to retention.
- No log reading for a worktree that is not a change's own. A directory that
is not the worktree of the change the card names is not read.
- No archived card gains Show Logs: its worktree is gone and so are its logs.

## Decisions

1. **The host resolves the place; the request names the change.** A new
function in core, beside `own-worktree-tasks.ts`, returns the change's own
worktree path where `git worktree list` has one, else the host's root.
Rejected: sending the worktree's path from the page - a request is data from
a page, and ADR 0026 forbids writing to, and by the same reasoning reading
from, a path it names.
2. **Reading one run names its change.** `pipeline/run-log` and
`/api/run-logs/read` take an optional `changeName` and read where the list
found the run. Without it they read the host root, as before, so an older
caller is unchanged. Rejected: searching every worktree for a run id - it
reads directories the card did not name and costs a listing per worktree.
3. **Fall back to the host root, never fail.** A change with no worktree of its
own keeps its logs at the host root (a run started there), which is what the
button read before. The resolver therefore never refuses.
4. **The button follows `own`.** `DirectoryCard` already knows `own` (the
directory is the worktree of exactly this change) and already offers Run
Change on that condition. Show Logs is offered on the same condition, and
the handler is passed down the same way `onStart` is.

## Risks / Trade-offs

- A worktree removed after a run takes its logs with it (ADR 0027: a directory
is disposable). The card then says no log was kept, which is true; the
harvest on removal does not copy `.openspec-ui/runs`. Not changed here.
- Two worktrees on one change name cannot exist (the branch is the change
name), so the resolver has one answer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Proposal

## Why

A change is worked in the worktree made for it (ADR 0027 keeps one root for
them), and a run there writes its log under that worktree's
`.openspec-ui/runs`. A card's Show Logs reads only the host's own root, so for
a change worked in its own worktree it answers "nothing" while the logs sit
one directory away. Measured on 2026-10-12 with the very call the button
makes: `listRunLogs` over the host root returned 0 logs for
`remaining-groups-move-into-the-register`; over its own worktree, 2.

Two more defects share the cause. Opening one run sends only its run id, so
a list that found the logs could not open one. And the card of a change
worked in its own worktree, drawn from another directory's picture, offers no
Show Logs at all, although `a-change-shows-its-run-logs` says every card has
one. ADR 0026 decides that a change is the pair of a directory and a name and
that a request is data, never a path to read: the host finds the worktree
from the name.

## What Changes

- Core resolves where a change's logs are: the change's own worktree where
`git worktree list` has one, else the host's root. A request names the
change, never a path.
- `pipeline/run-logs` and `pipeline/run-log` (extension) and
`/api/run-logs/list` and `/api/run-logs/read` (server) read from that
place. Reading one run's log names its change, so it is read where the
list found it.
- The card of a change in its own worktree, drawn from another directory's
picture, offers Show Logs.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `execution-core`: where a change's run logs are read from.
- `standalone-app`: the log endpoints read a change's own worktree; Logs on
every card.
- `vscode-extension`: the bridge operations read a change's own worktree.

## Impact

`packages/core` (`run-log.ts`, a resolver beside `own-worktree-tasks.ts`),
`packages/server` (`rest.ts`), `packages/extension`
(`webview/pipeline-panel.ts`), `packages/webui` (`run-logs-client.ts`,
`pipeline-entry.tsx`, `standalone-entry.tsx`, `PipelineView.tsx`). No
command or event of the protocol changes; the two read operations gain an
optional `changeName`, so an old caller that sends none reads the host root as
before. A patch-level behaviour fix for each affected package.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
## MODIFIED Requirements

### Requirement: Every run keeps a log

When a host hands its runners the workspace's run logs, `packages/core`
SHALL write each run's log to `.openspec-ui/runs/<runId>.jsonl`. The log
SHALL hold, for every stage the run id is given:

- a start record naming the agent, the kind, the working directory, the
change, the stage and the task where there is one;
- a line for each event that says something, read the way the hosts read
it;
- an end record with the outcome, reason and summary.

A run the sandbox or the allowlist refused SHALL get a log whose end says
it was refused and why.

A log SHALL stop at a size cap and say where it stopped, and its end SHALL
still be written. The directory SHALL keep only the newest logs. Writing a
log SHALL NOT delay or fail a run.

Core SHALL list a workspace's logged runs, newest first, and one change's
when asked, without reading each log whole. It SHALL read one run's log by
its id, and SHALL refuse an id that could name a file outside the
directory.

Core SHALL resolve where one change's logs are read from: the change's own
worktree, found from the change's name and the repository's worktree list,
where it has one, and the host's own root where it has none. A request SHALL
name the change and SHALL NOT name a path.

#### Scenario: A run completes

- **WHEN** a run writes to stdout and completes
- **THEN** its log holds its start, what it wrote, and an end that says it
completed

#### Scenario: A chain runs two stages

- **WHEN** a chain's `propose` and `apply` stages run under one run id
- **THEN** one log holds both, each with its own start and end
- **AND** the list names both stages for that run

#### Scenario: The allowlist refuses a run

- **WHEN** the allowlist refuses a run's invocation
- **THEN** the run's log ends as refused, with the reason

#### Scenario: A run says too much

- **WHEN** a run's output passes the cap
- **THEN** the log says it stopped there, and still ends with the outcome

#### Scenario: A request names a path

- **WHEN** a log is asked for under the id `../audit`
- **THEN** nothing is read

#### Scenario: A change worked in its own worktree

- **WHEN** a change has a worktree of its own, a run there kept two logs,
and the host's own root kept none
- **THEN** listing that change's logs answers both runs

#### Scenario: A change with no worktree of its own

- **WHEN** a change has no worktree of its own and the host's own root kept
a log of its run
- **THEN** listing that change's logs answers that run from the host's root
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
## MODIFIED Requirements

### Requirement: A change's card opens its run logs

The standalone server SHALL answer `POST /api/run-logs/list` with an
authorized workspace's logged runs, one change's when the body names it,
and `POST /api/run-logs/read` with one run's log. Where the body names a
change, both SHALL read from that change's own worktree, found from its
name, and from the workspace root where it has none. It SHALL answer 400
for a run id that could name a file outside the log directory, and 404
where no log is kept.

The Pipeline SHALL offer Logs on every change's card, including the card of
a change worked in its own worktree drawn from another directory's
picture. It SHALL open, over the board and in view whatever the board's
height and scroll:

- the change's runs, newest first, each with when it started, what it
was, the agent and how it ended;
- the chosen run's log, with each stage as a heading and the output of
one stream joined.

It SHALL say so where no run of the change left a log.

The view SHALL take the focus when it opens. Escape SHALL close it as its
Close button does, and closing it SHALL give the focus back to the Logs
button that opened it. A view opened beneath a picture taller than the
window is a press that seems to do nothing: reported on 2026-09-23, it
showed 82 pixels of itself at the window's bottom edge.

#### Scenario: A person opens a change's logs

- **WHEN** a person presses Logs on a change's card
- **THEN** the change's runs are listed and the newest run's log is shown

#### Scenario: A change never ran

- **WHEN** no run of the change left a log
- **THEN** the view says that no run of this change has left a log

#### Scenario: The board is taller than the window

- **WHEN** a person presses Logs on a card of a board taller than the window
- **THEN** the view is drawn over the board, whole within the window, with the focus in it

#### Scenario: A person closes the logs with Escape

- **WHEN** a person presses Escape in the logs view
- **THEN** the view closes and the focus is back on the Logs button that opened it

#### Scenario: A change worked in its own worktree

- **WHEN** a person presses Logs on the card of a change whose runs were
logged in its own worktree
- **THEN** the view lists those runs and opens the one chosen

#### Scenario: A request names a path

- **WHEN** the list request names a path in place of a change
- **THEN** the server reads nothing outside the workspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## MODIFIED Requirements

### Requirement: The Pipeline panel opens a change's run logs

The extension SHALL hand its runners the workspace's run logs, so every
run it starts keeps one. Its Pipeline panel SHALL offer Logs on every
change's card and show the same view the standalone shows. It reads
through the bridge operations `pipeline/run-logs` and `pipeline/run-log`,
which answer from the change's own worktree, found from the change's name,
and from the panel's own workspace root where the change has none. A
request that names no change, or no valid run id, SHALL be refused by
name.

#### Scenario: A person opens a change's logs in the editor

- **WHEN** a person presses Logs on a card in the Pipeline panel
- **THEN** the panel lists the change's runs and shows the newest run's log

#### Scenario: A change worked in its own worktree

- **WHEN** a person presses Logs on the card of a change whose runs were
logged in its own worktree
- **THEN** the panel lists those runs, and opening one reads it from there

#### Scenario: A request names a path

- **WHEN** the panel is asked for the log of run `../audit`
- **THEN** it answers that a run id is required, and reads nothing
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
From a bug found on 2026-10-12: Show Logs on a card said there were no logs
for a change whose runs had logged in its own worktree.

## 1. Core

- [x] 1.1 `packages/core/src/change-run-logs.ts`: `changeLogsRoot`,
`listChangeRunLogs` and `readChangeRunLog` resolve a change's own worktree
with `resolveOwnWorktree` and fall back to the host's root; none takes a
path. Exported from `packages/core/src/index.ts`, and not from `browser.ts`.
- [x] 1.2 `packages/core/src/change-run-logs.test.ts`: the own worktree's
logs are listed and read where the host's root kept none; the host's root is
the fallback with no worktree and where the list cannot be read; a run id
naming a path reads nothing. Verified to fail with the resolver returning
the host's root.
- [x] 1.3 `packages/core/src/change-run-logs.git.test.ts` and
`packages/core/vitest.workspace.ts`: the same over a real `git worktree
add`, in the `core-git-subprocess` project.

## 2. Hosts

- [x] 2.1 `packages/server/src/rest.ts`: `handleRunLogsListRequest` lists
through `listChangeRunLogs` where the body names a change, and
`handleRunLogReadRequest` reads through `readChangeRunLog` where it names
one, answering 400 for a change name that is not a non-empty string.
`authorizeCwd` still checks the workspace root, never a path computed here.
- [x] 2.2 `packages/extension/src/webview/pipeline-panel.ts`:
`pipeline/run-logs` lists through `listChangeRunLogs`, and `pipeline/run-log`
reads through `readChangeRunLog` where `changeName` is given, refusing one
that is not a non-empty string.
- [x] 2.3 Tests in `packages/server/src/server.test.ts` and
`packages/extension/src/webview/pipeline-panel.test.ts` for a named change
and for one that is not a name.

## 3. Web UI

- [x] 3.1 `packages/webui/src/run-logs-client.ts`,
`packages/webui/src/standalone-entry.tsx` and
`packages/webui/src/pipeline-entry.tsx`: reading one run sends the change
name the view was opened for.
- [x] 3.2 `packages/webui/src/components/PipelineView.tsx`: `DirectoryCard`
offers Show Logs on a card of a change's own worktree, with `onViewLogs`
threaded from `controls` along the route `onStart` takes. Not on a card of a
directory that is not the change's own.
- [x] 3.3 Test in `packages/webui/src/components/PipelineView.test.tsx`: the
button is on `pipeline-directory-0-node-fresh` and calls `onViewLogs` with
`fresh`, and is absent from another directory's card. Verified to fail with
the `own` condition removed.

## 4. Documents

- [x] 4.1 A changeset: core, server, extension, webui - patch.

## 5. Checks

- [x] 5.1 `npm run typecheck && npm run lint`, and the affected tests of core,
server, extension and webui.
2026-10-12/13: typecheck clean; lint 0 errors; core 2303 passed, cli 285,
extension 539, server 136, webui 769.
- [x] 5.2 `openspec validate a-change-shows-the-logs-of-its-own-worktree
--strict`, and the merge gate with `--change` and the worktree's absolute
path as `--cwd`.
2026-10-13: "Change 'a-change-shows-the-logs-of-its-own-worktree' is
valid".
- [x] 5.3 On the real data that showed the bug: `listChangeRunLogs` over the
main checkout for `remaining-groups-move-into-the-register` lists its two
runs and reads the newest.
2026-10-12: listed 2 runs (3257e8cf, 4143b08e, both apply, cancelled) and
read the newest's 494 records from its own worktree, where the old read
over the host root found 0.
42 changes: 42 additions & 0 deletions packages/core/src/change-run-logs.git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { listChangeRunLogs } from "./change-run-logs.js";
import { createFileRunLogs } from "./run-log.js";

// a-change-shows-the-logs-of-its-own-worktree: the real `git worktree list`,
// which the other file replaces with a list.

let base: string;

const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, stdio: "pipe" });

beforeEach(async () => {
base = await mkdtemp(path.join(os.tmpdir(), "change-run-logs-git-"));
});

afterEach(async () => {
await rm(base, { recursive: true, force: true });
});

describe("a change's run logs, over a real worktree", () => {
it("finds the logs through a real git worktree list", async () => {
const repo = path.join(base, "real");
const own = path.join(base, "real-worktrees", "demo");
await mkdir(repo, { recursive: true });
git(repo, "init", "-b", "main");
git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "init");
git(repo, "worktree", "add", own, "-b", "demo");
await mkdir(path.join(own, "openspec", "changes", "demo"), { recursive: true });
await writeFile(path.join(own, "openspec", "changes", "demo", "tasks.md"), "- [ ] 1.1 x\n");
await createFileRunLogs(own)
.open({ runId: "run-real", agent: "claude-cli", kind: "implement", cwd: own, changeName: "demo" })
.end({ outcome: "completed" });

const found = await listChangeRunLogs({ repositoryRoot: repo, changeName: "demo" });

expect(found.map((one) => one.runId)).toEqual(["run-real"]);
}, 30_000);
});
Loading
Loading