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
40 changes: 40 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Setup
description: pnpm, Node, git identity and dependencies; optionally the living-architecture tools.

inputs:
arch-tools:
description: Install la-arch-check and its TypeScript twin, which the architecture tests run.
required: false
default: 'false'

runs:
using: composite
steps:
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- if: inputs.arch-tools == 'true'
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
# la-arch-check and its TypeScript twin, which is not on npm.
- if: inputs.arch-tools == 'true'
shell: bash
run: uv tool install --no-build living-architecture==0.3.1
- if: inputs.arch-tools == 'true'
shell: bash
run: |
twin="$RUNNER_TEMP/living-architecture"
git init -q "$twin"
git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1
git -C "$twin" checkout -q FETCH_HEAD
cd "$twin/node"
npm ci --ignore-scripts
npm run build
mkdir "$twin/bin"
for file in dist/bin/*.js; do ln -s "$PWD/$file" "$twin/bin/$(basename "$file" .js)"; done
echo "$twin/bin" >> "$GITHUB_PATH"
- shell: bash
run: git config --global user.name ci && git config --global user.email ci@example.com
- shell: bash
run: pnpm install --frozen-lockfile
56 changes: 33 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,44 @@ permissions:
contents: read

jobs:
test:
# tsc, ESLint, la-arch-check, LikeC4 and the structure and dependency rules.
architecture:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@v4
- uses: ./.github/actions/setup
with:
node-version: 22
cache: pnpm
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
# la-arch-check (run by the architecture tests) and its TypeScript twin, which is not on npm.
- run: uv tool install --no-build living-architecture==0.3.1
- run: |
twin="$RUNNER_TEMP/living-architecture"
git init -q "$twin"
git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1
git -C "$twin" checkout -q FETCH_HEAD
cd "$twin/node"
npm ci --ignore-scripts
npm run build
mkdir "$twin/bin"
for file in dist/bin/*.js; do ln -s "$PWD/$file" "$twin/bin/$(basename "$file" .js)"; done
echo "$twin/bin" >> "$GITHUB_PATH"
- run: git config --global user.name ci && git config --global user.email ci@example.com
- run: pnpm install --frozen-lockfile
- run: pnpm lint
arch-tools: 'true'
- run: pnpm exec vitest run --project architecture

vitest:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ./.github/actions/setup
- run: pnpm exec vitest run --project unit --project process --shard=${{ matrix.shard }}/2

e2e:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ./.github/actions/setup
# The perf budgets need hardware WebGL, which hosted runners lack; `pnpm test` runs them locally.
- run: pnpm test:ci
- run: pnpm exec playwright test --project e2e --shard=${{ matrix.shard }}/2
8 changes: 4 additions & 4 deletions architecture/daemon.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ flowchart TD
## Principles

1. `worktrees`, `terminals` and `state` never import each other; only `server` and `main` compose them. [enforced: arch_check:model-truth]
2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server.test.ts]
3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts]
4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals.test.ts]
5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals.test.ts]
2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server-startup.test.ts]
3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals-lifecycle.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts]
4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals-output.test.ts]
5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals-output.test.ts]
6. Worktree changes are observed by events, never by polling. [review]
7. Persisted state references no worktree that is gone and has no terminals. [enforced: test:src/daemon/state/store.test.ts]
6 changes: 3 additions & 3 deletions architecture/hub.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ flowchart TD

1. Reaches every daemon, local or remote, only through a link; never imports daemon code. [enforced: arch_check:model-truth]
2. Only `server` faces the browser; only `links` opens daemon connections or spawns processes. [enforced: arch_check:model-truth] [enforced: test:test/architecture/dependencies.test.ts]
3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server.test.ts]
4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router.test.ts]
5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router.test.ts]
3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts]
4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router-routing.test.ts]
5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router-routing.test.ts]

## Rationale

Expand Down
2 changes: 1 addition & 1 deletion architecture/system.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ flowchart TD
4. Code passes the strict compiler config and the strict lint config with zero findings; suppressions state a reason. [enforced: test:test/architecture/static.test.ts]
5. Input from outside the process is schema-parsed at the boundary; unparsed data never travels inward. [review]
6. Errors are never swallowed: a catch rethrows, reports, or returns a typed failure. [review]
7. Nothing listens beyond loopback or an owner-only unix socket. [review] [enforced: test:test/process/hub-server.test.ts]
7. Nothing listens beyond loopback or an owner-only unix socket. [review] [enforced: test:test/process/hub-server-http.test.ts]
8. Every production change ships with tests in its tier: unit beside the code, `test/process`, `test/e2e`; `test/integration` holds only opt-in real-SSH tests. Mocks are typed against the real module. [review]

## Rationale
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-10-18
skip_specs: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Context

Baseline (green run 39123723382): setup ~15–25 s, `pnpm lint` 36 s, vitest 148 s, Playwright 3.5 min at 2 workers (156 tests). Vitest file wall times: `hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s (31 s of it one test sleeping past the 30 s code TTL), `static` 40 s (eslint), `daemon-server` 28 s; everything else ≤ 15 s. Process tests mostly wait on real timers. `main` has no branch protection, so job names are free to change. Architecture: system principle 4 names `test/architecture/static.test.ts` as its enforcement; principle 8 keeps split tests in `test/process` and shared helpers in `test/support`. No principle's wording changes and no `index.yaml` change is needed; the arc42 `[enforced: test:…]` tags naming the four split files are repointed to the files that now hold those tests (`la-arch-check` does not check that tagged paths exist).

## Goals / Non-Goals

**Goals:** total CI wall ≤ ~3 min (expected ~2); each e2e shard no slower than the slower vitest shard; zero failures over 3 consecutive full runs; local scripts unchanged.

**Non-Goals:** cancelling superseded runs (concurrency groups); sharing `dist` across jobs; speeding up individual timer-bound tests (the 31 s code-expiry test stays — `src/hub/server/codes.test.ts` covers the logic with an injected clock, the process test covers the wiring).

## Decisions

**D1 — `architecture` job replaces lint.** CI drops the `pnpm lint` step; the `architecture` job runs `pnpm exec vitest run --project architecture`, which runs tsc, eslint, `la-arch-check`, likec4, structure and dependency rules. One definition of lint, the one principle 4 cites. Alternative rejected: a `lint` job plus excluding `static.test.ts` in CI — that leaves principle 4's enforcement unrun in CI and two lint definitions to keep in sync.

**D2 — Composite setup action.** `.github/actions/setup/action.yml`: pnpm, Node 22 with pnpm cache, git identity (`ci` / `ci@example.com`), `pnpm install --frozen-lockfile`. Boolean input `arch-tools` (default `false`) adds uv, `living-architecture==0.3.1` and the TypeScript twin at the pinned commit `15ed4263…` (existing script, moved verbatim), on `PATH`. Checkout stays in each job (`persist-credentials: false`) since a local action needs the repo checked out first.

**D3 — Job matrix.** `architecture`; `vitest` matrix `shard: [1, 2]` running `pnpm exec vitest run --project unit --project process --shard=${shard}/2`; `e2e` matrix `shard: [1, 2]` running `pnpm exec playwright test --project e2e --shard=${shard}/2`. `fail-fast: false`, `timeout-minutes: 10`, `ubuntu-latest`. Each vitest and Playwright invocation runs its project's `globalSetup` (`pnpm build`) once. Playwright with `fullyParallel: true` shards at test level, so e2e shards balance; perf stays local-only.

**D4 — Split process files; balance shards.** Split the four files along `describe` blocks, splitting an oversized block across files where needed (carrying its hooks and timeouts), into `test/process/<old>-<area>.test.ts` of ≤ ~35 s each on the CI runner. Helpers shared by several new files go to `test/support/<name>.ts`. Pure moves: total test count is unchanged (check vitest's `Tests N passed` before and after). Vitest assigns files to shards before ordering by duration, so small files alone do not guarantee balance: record each shard's files and wall time; if imbalance misses the target, add a custom `sequence.sequencer` whose `shard` step balances by recorded durations.

**D5 — Playwright workers.** With `CI` set: `min(8, availableParallelism())`; otherwise unchanged (`min(8, max(1, floor(cpus / 2)))`). The comment on the 8 cap (inotify instances) stays and says CI uses every CPU. If e2e flakes at 4 workers in the measurement runs, drop to 3 and record why here.

**D6 — Reinstall flake is a test bug.** `DaemonClient.waitOutput()` searches only output received since the latest snapshot (`applySnapshot()` clears it) — by design. The reinstall test creates its terminal with `echo after-reinstall; exec sleep 60` and attaches afterwards, so the marker can land only in the snapshot (failure trace: `working` → `done(attach)` → `idle`, no output after). Fix: gate the command until attach completes, following the existing `started()` helper pattern. `waitOutput` semantics stay; production code changes only if a deterministic regression shows snapshot content missing.

## Risks / Trade-offs

- [Four CPUs × four Playwright workers (each Chrome + hub + daemon) contend for CPU] → measure 3 runs; fall back to 3 workers (D5).
- [Split files reshuffle timing, surfacing latent races] → 3 consecutive full CI runs plus a local unsharded `pnpm test:ci`; any flake found is fixed at its cause, not retried.
- [Each job repeats setup and `pnpm build`] → ~25 s per job in parallel; accepted (dist sharing is a non-goal).
- [Lint failures appear as a failed `static.test.ts` assertion] → the assertion message carries eslint/tsc stdout + stderr.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Why

A CI run takes 6.5–9 min: one job on a 4-CPU runner runs lint, vitest and Playwright in sequence. Profiling (runs 39123723382, 38746061575) shows three causes beyond the serial layout: `pnpm lint` repeats exactly what `test/architecture/static.test.ts` runs (`tsc -b` + eslint, ~40 s); vitest's 148 s wall is bounded by four huge, timer-bound process-test files (`hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s, `daemon-server` 28 s) whose tests run sequentially; and Playwright runs only 2 workers on CI. Main is also intermittently red from an `install-local` test that misses output printed before it attaches.

## What Changes

- Split `.github/workflows/ci.yml` into parallel jobs: `architecture` (the architecture vitest project, which includes tsc + eslint and replaces the `pnpm lint` step), `vitest` × 2 shards (unit + process), `e2e` × 2 shards (Playwright `--shard`).
- Move the shared setup into a composite action `.github/actions/setup`, with the living-architecture tools behind an input so only the `architecture` job installs them.
- Run Playwright with every CPU (capped at 8) on CI; local runs keep half the CPUs.
- Split the four big process-test files into smaller files (pure moves, no test renamed, rewritten or dropped) so vitest shards balance and no file bounds the critical path.
- Fix the `install-local` reinstall flake in the test: gate its command until attach completes.
- `pnpm test`, `test:ci`, `test:vitest` and `lint` stay unchanged and keep working locally.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

None — CI layout and test organisation only; no specified behaviour changes (`skip_specs: true`).

## Impact

- `.github/workflows/ci.yml`, new `.github/actions/setup/action.yml`.
- `playwright.config.ts` (CI worker count).
- `test/process/{hub-router,daemon-terminals,hub-server,daemon-server}.test.ts` split into `test/process/<old>-<area>.test.ts`; shared helpers into `test/support/`.
- `test/process/install-local.test.ts` (reinstall test).
- Possibly a vitest sequencer file if shard imbalance requires it (design D4).
- README, if it describes the single CI job.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## 1. Tests

- [x] 1.1 Record the baseline: run `pnpm test:vitest` and note the `Tests N passed` total; verify by writing N into this task line when done — N = 2051
- [x] 1.2 `test/process/install-local.test.ts` reinstall test: gate `echo after-reinstall` until attach completes, following the `started()` helper pattern (design D6); verify by running that test 20× in a loop (`vitest run --project process -t 'two reinstalls'`) with zero failures, and by showing it fails reliably when the gate is released before attach

## 2. Split the process-test files

- [x] 2.1 Split `test/process/hub-router.test.ts` into `hub-router-<area>.test.ts` files (design D4); shared helpers into `test/support/`; verify every moved test passes and the total from 1.1 is unchanged
- [x] 2.2 Split `test/process/daemon-terminals.test.ts` the same way (its `activity` block likely needs splitting across files); verify as 2.1
- [x] 2.3 Split `test/process/hub-server.test.ts` the same way (the 31 s code-expiry test moves unchanged); verify as 2.1
- [x] 2.4 Split `test/process/daemon-server.test.ts` the same way; verify as 2.1
- [x] 2.5 Verify no old file name remains referenced (README, comments, specs) and `pnpm lint` passes; repoint the arc42 `[enforced: test:…]` tags to the new files (design Context)

## 3. CI

- [x] 3.1 Add `.github/actions/setup/action.yml` with input `arch-tools` (design D2); verify by the workflow run in 3.3
- [x] 3.2 Rewrite `.github/workflows/ci.yml` as jobs `architecture`, `vitest` (shard 1/2, 2/2), `e2e` (shard 1/2, 2/2) per design D1/D3, keeping the hardware-WebGL comment; verify with `actionlint` if available, else by the run in 3.3
- [x] 3.3 `playwright.config.ts`: CI workers = `min(8, availableParallelism())` (design D5); verify locally that `CI=1 pnpm exec playwright test --list` and an unset `CI` both load, and on CI that each e2e shard logs 4 workers
- [x] 3.4 Update README if it describes the single CI job; verify by grep for `pnpm lint` / CI wording

## 4. Measure and final gates

- [x] 4.1 Local: unsharded `pnpm test:ci` passes, and `pnpm test` (incl. perf) passes; `openspec validate dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel --strict` passes
- [x] 4.2 After the PR is open: 3 consecutive full CI runs (`gh run rerun`) all green; record per-job wall times, each vitest shard's file list, and the total in the PR description
- [x] 4.3 Check targets: total ≤ ~3 min, each e2e shard ≤ the slower vitest shard, no failures; if vitest shards are imbalanced enough to miss the target, add the duration-balancing sequencer (design D4) and re-measure; if e2e flakes at 4 workers, drop to 3 (design D5)
3 changes: 2 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export default defineConfig({
testDir: 'test/e2e',
globalSetup: './test/e2e/global-setup.ts',
// Each worker runs a Chrome, a hub and a daemon; more than 8 exhausts the default 128 inotify instances per user.
workers: Math.min(8, Math.max(1, Math.floor(availableParallelism() / 2))),
// CI uses every CPU; local runs leave half for the rest of the machine.
workers: Math.min(8, process.env['CI'] ? availableParallelism() : Math.max(1, Math.floor(availableParallelism() / 2))),
timeout: 60_000,
use: {
channel: 'chrome',
Expand Down
4 changes: 3 additions & 1 deletion src/protocol/frames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ describe('stream framing', () => {
const got: Frame[] = [];
for (let at = 0; at < bytes.length; at += 1000) got.push(...decoder.push(bytes.subarray(at, at + 1000)));
decoder.end();
expect(got).toEqual([{ kind: 2, payload }]);
// toEqual walks a typed array element by element, which takes seconds on a loaded machine.
expect(got.map((frame) => [frame.kind, frame.payload.length])).toEqual([[2, payload.length]]);
expect(got[0]?.payload.findIndex((byte, i) => byte !== payload[i])).toBe(-1);
});

it('fails on an unknown kind', () => {
Expand Down
Loading
Loading