Harden worker federation and operator UI

This commit is contained in:
2026-07-29 13:30:55 +04:00
parent 95a96d87a5
commit 1ca9d64e89
35 changed files with 1195 additions and 581 deletions
+138 -201
View File
@@ -17,117 +17,80 @@ document.
---
## Current verdict (as of 2026-07-28)
## Current verdict (as of 2026-07-29)
The substrate (Layer 1) is solid. Layers 24 were originally shaped-but-not-
wired; most of the blocking defects below are now closed, verified by
reading the current code (not just by trusting this file) and by targeted
tests. Three things are not yet proven:
The core code and targeted tests are substantially ahead of the deployed
operator surface. The live API is reachable over HTTP at `orchestra.kvmx.ru`
(HTTPS currently returns nginx `502`), but its historical `test-e2e` backlog
is not an operationally useful control plane yet.
1. **A real end-to-end unattended run.** The live attempts of 2026-07-28
surfaced B12 through B17. All are now closed in code (B17 on the worker
path only), but **B13 through B17 were each found live and none has been
re-verified live** — occupancy/rotation/handoff/completion still haven't
been exercised end-to-end against a session guaranteed to exist. Treat
"closed" here as "the code no longer contains the defect," not as
operational proof.
2. **The operator surface was unauthenticated in the live deployment.**
Fixed in code (**B18**: mandatory Web token, cookie sessions for the
browser), but the fix is a breaking config change — the service will not
start until `.orchestra-config/orchestra.env` sets
`ORCHESTRA_WEB_TOKEN`. See B18 and the command-channel section.
3. **Cross-machine (federation) correctness.** Deliberately deferred — see
"The federation fork" below.
### Live evidence, checked 2026-07-29
`go build ./...`, `go vet ./...`, and `go test ./...` all pass.
- One `workpc-opencode` worker is heartbeating and reports capacity 1. Its
Unix-socket herdr path is the meaningful reachability signal; legacy
coordinator TCP probes are not.
- The API reports 28 tasks: **22 blocked, 5 completed, 1 failed, and no
queued, leased, approval-pending, or captured sessions**. The visible queue
is therefore historical E2E residue, not current work.
- A real browser loaded the deployed SPA with a valid Web session and crashed
before painting: `overview.sessions` is JSON `null`, while the bundle calls
`sessions.length`. Task detail has the same fault for `events: null`.
Source now emits empty arrays and the frontend is defensive; the current
local build renders the live board and task record. **The deployment still
needs a rebuild/redeploy.**
- The task record for a blocked task and the failed E2E task had no retained
session and no lifecycle events. Every lifecycle control was correctly
disabled, but neither record could explain its state.
- The live image predates the source session/logout work: `DELETE
/v1/ui/session` returns `405`. Do not treat token rotation as session
revocation until that endpoint is deployed.
### Follow-up live verification (2026-07-28)
Earlier successful OpenCode approval and worker-flow checks remain useful
historical evidence, but they do not prove the current deployment or a
successful worker-owned handoff/release/pickup. Cross-machine continuity is
still deliberately incomplete; Design A must not operate a non-local
checkout.
The OpenCode launch path has now been exercised against workpc herdr through
an isolated `test-e2e` worktree. The verified outcomes are deliberately
narrow:
### Operator UI: remaining work
- Initial prompts use a bounded retry only after an explicit JSON-RPC
rejection (herdr's short post-start readiness window). Transport timeouts
and disconnects are never replayed. A pane is inspected first; blocked or
permission-dialog panes are refused.
- Herdr accepts task-scoped unique names (`oc-<task-id>`); active sessions
are visible by that name in `agent.list`. Prompt routing uses that name,
while pane reads remain pane-scoped.
- The initial launch message carries the task title and description. This is
necessary under Design A because homesrv cannot write `TASK.md` inside a
workpc checkout. A live OpenCode run received the full instruction and,
after explicit operator approval of two file edits, created the requested
marker and report without touching other files.
- The initial launch does **not** wait for the agent to become idle. Waiting
converted an ordinary long-running first turn into a false `TaskBlocked`;
a fresh run now remains `TaskLeased` while OpenCode is paused at its normal
permission boundary.
The current UI should become a diagnosis surface, not a five-column task
catalogue.
### Deployment state observed live (2026-07-29)
Checked against the running system, not the docs:
- The deployment **moved from `orchestra.service` to Docker Compose**
(`/home/kami/docker-apps/orchestra-web-ui`). `orchestra-api` and
`orchestra-web-ui` are both up; `/healthz` and the UI answer 200. The
stopped systemd unit is the retired predecessor, not an outage — see the
deployment section of `CLAUDE.md`.
- The **running image predates the last two commits** (built 2026-07-28
21:28; `0b4b52a` "Require a token for the web UI" landed 23:15). So B18's
auth fix is *not* in the live container. Rebuild before drawing any
conclusion about the live surface's auth behaviour.
- **All six herdrs are unreachable** — workpc `192.168.1.105:9245-7` refuses,
homesrv `192.168.1.104:9245-7` times out (filtered). Probed directly, since
a herdr that connects logs nothing. Nothing can be leased; every task the
API serves is history. This is the sole remaining blocker on live proof for
B13B17.
- `GET /v1/tasks` has **no authz check at all** (`cmd/orchestra/main.go:286`
returns before any gate). B18 gated the mutation and approval controls, not
the read path. Accepted for now: the `0.0.0.0:9145` bind is deliberate and
ufw restricts the port to a single other LAN machine, so the read path's
trust boundary is the LAN, not the host. Revisit if that bind is ever
widened.
- ntfy delivery still fails `403 Forbidden` on every send (last observed
2026-07-28) — a wrong or expired credential, separate from S12's
`ORCHESTRA_NTFY_SURFACE_TOKEN`.
This does not complete the cross-machine continuity design. `clients/herdr-
bridge.go` is only a byte proxy; it is not a worker. The worker-side process
described below remains required before a remote checkout can author
`TASK.md`, derive a canonical handoff anchor, scratch-commit WIP, or safely
release it. Until then, homesrv must not rotate or clean up a non-local
worktree.
| Spec layer | State |
|---|---|
| L1 substrate (§3, §4) | Built and correct in the main path. |
| L2 harness (§5) | Occupancy, rotation, and completion all wired and reachable. B12 fixed and confirmed live; B13B16 fixed in code but not re-verified live. Unique per-task agent names (B16) and ambiguity-safe prompting (B14) are the substantive changes. |
| L3 continuity (§6) | Handoff schema, pickup validation, scratch branches, TASK.md are all wired into the live path (Phase 4 complete). |
| L4 surfaces (§7) | Brief/standup/delivery real; quota has a post-hoc producer only (no live push feed yet); B8's authz bypass closed, but the new web UI reintroduces an equivalent one (B18). |
1. **Persist and show a blocked diagnosis.** Project the structured blocking
reason/error into the task read model. Group the board by reason and age
(for example: lease failure, worker offline, expired lease, approval,
handoff validation, operator block) rather than rendering a giant generic
“Blocked” lane.
2. **Persist last-session and pane evidence.** Before a worker/coordinator
drops a terminal session mapping, retain harness, pane ID, last successful
capture/check, and a pane status of `open`, `closed`, `unreachable`, or
`unknown`. The UI must show the source and timestamp. For old records that
lack this evidence, say **unknown — legacy record has no retained
blocker/pane evidence**, never imply a live or closed pane.
3. **Make the task page lead with the diagnosis.** Put reason, last activity,
pane state, and next safe action first. Hide unavailable lifecycle forms
behind an “Unavailable actions” disclosure; an empty session must not
consume most of the page with disabled controls.
4. **Separate active work from history.** Default the board to active,
waiting, and needs-attention work; move completed/failed/test residue to
filters or a compact history view. Add search, project filtering, and an
explicit “no live work” state.
5. **Complete the worker truth model.** Show worker-owned heartbeat, local
herdr reachability, active task/pane, and last error separately from legacy
coordinator probes.
6. **Deploy and verify the browser path.** Ship the null-collection fixes and
session logout endpoint, then record real login, refresh, expiry, board,
task-detail, and approval browser flows. Keep `web/dist` and embedded
assets synchronized as part of that build.
---
## Blocking defects
B14, B15 and B16 are now closed by code and covered by tests; none has been
re-verified against a live cross-machine run. B17 is closed on the worker
path only and remains open for Design A. B18 (the unauthenticated web
surface) is closed in code but **requires an env change before the service
will start** — see B18 and "What's next".
Nothing is currently blocking in the sense B12/B13 were. B19B21 and S12S13
— the correctness and hygiene gaps found while implementing B18 — are now
fixed in code and covered by tests (2026-07-28); each is marked closed in
its own section below. The flaky router test is also fixed, and the flake
was in the test, not in assignment.
The real remaining risk is entirely evidential now: B13 through B17 were
each found live and **none has been re-verified live**, and none of the
2026-07-28 fixes has run on the deployed instance either — the service is
stopped, `/usr/local/bin/orchestra` predates all of them, and installing a
new binary or editing `/etc/orchestra/orchestra.env` needs privileges this
sandbox does not have.
The sections below are retained defect provenance and implementation detail.
Their dated deployment claims do not supersede the current verdict above.
Read them when changing the affected path; use the current verdict for the
live state and remaining operator work.
### B18 — the web UI surface is unauthenticated (found by audit 2026-07-28, uncommitted working tree) — closed (code fix; requires an env change before restart)
@@ -371,7 +334,7 @@ Delivery` asserting the prompt target is the unique name. Nothing yet starts
two same-harness sessions in distinct panes and verifies both attach, so the
`agent_name_taken` failure mode itself is untested end-to-end.
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed on the worker path; open for Design A
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed in code; live success still unproven
The real OpenCode healthcheck run showed that prompting an opaque agent to
write the full `continuity.Handoff` schema is the wrong ownership boundary.
@@ -427,7 +390,7 @@ attached Claude at 08:00:43Z. The renewed attempt then hit B14's prompt
timeout and became blocked. This is expected retry behavior, but illustrates
why lifecycle retries must be visible to operators.
**Status (2026-07-28): satisfied on the worker path, unchanged for Design A.**
**Status:** satisfied on the worker path.
`cmd/orchestra-worker` now implements the required ownership boundary. The
harness is asked once for a bounded semantic report; `releaseReady`
(`cmd/orchestra-worker/main.go:180`) then runs `CLIAdapter.Release` and
@@ -437,11 +400,23 @@ canonical artifact is validated before publication. The handoff-provenance
correction documented at the end of this file supplies the parsing and
validation half.
The homesrv coordinator's Design A path is unchanged and still cannot
satisfy this: it does not own the workpc checkout, so any Git-derived fact
it seals is derived from the wrong filesystem. B17 is therefore only closed
for tasks that actually run through a worker. The standing rule holds —
homesrv must not rotate or clean up a non-local worktree.
The coordinator no longer accepts a non-local herdr operation; the standing
rule is enforced rather than advisory. A successful live worker handoff,
release, and pickup remains the required operational proof.
**QA attempt (2026-07-29):** the bounded disposable task
`06FTSHBPYHQXN8MM849PFA1V6M` reached the worker and wrote its semantic
report, but publication correctly refused it. Its prescribed fields combined
to the canonical action `inspect the marker file — verify worker-owned
handoff construction`; the circular-action guard rejects that word even when
it occurs in the explanatory `WHY` text. This is an invalid QA fixture, not a
successful handoff or a release-path regression. The worker must be given a
fresh fixture whose combined `NEXT`/`WHY` action does not mention a handoff
(for example, `WHY: verify canonical artifact construction`), then the
release and successor pickup must be observed. The coordinator is token-gated
and this host cannot read the protected worker credential, so this session
could not enqueue that replacement task. The failed disposable pane was left
untouched; no destructive herdr calls were issued.
### B13 — `agent.start` silently no-ops under back-to-back leases (found live 2026-07-28) — closed (code fix; not yet re-verified live)
@@ -639,14 +614,7 @@ action without releasing the pane.
**Known weaknesses of this channel.** The defects found in it are filed
individually below — **B19** (federated approvals emit no event), **B20**
(the local capture revision is a timestamp, not a change counter) and
**B21** (`Registry.commands` never prunes). Two more, not separately filed:
- **Captures and commands are in-memory only.** `Registry.captures` and
`Registry.commands` have no persistence, so a coordinator restart drops
pending approvals silently and resets revision counters — which, with a
reset counter, could let a stale command match a new capture. This is the
other half of B21: the lifecycle forgets across restarts and never forgets
within one.
**B21** (`Registry.commands` never prunes). Two further gaps remain:
- **`RespondApproval` (coordinator path) has no test**, unlike its worker
counterpart — and per B20 it is the path whose revision is meaningless,
so its text-comparison guard is the only thing actually binding the
@@ -729,14 +697,16 @@ rescans the entire history on every worker poll. Combined with the
in-memory-only storage already noted, the lifecycle is wrong at both ends:
it forgets across restarts and never forgets within one.
**Fix (within-process half only).** `Registry.pruneCommands` drops resolved
**Fix.** `Registry.pruneCommands` drops resolved
commands older than `CommandRetention` (30 minutes), running on both `Queue`
and `Commands`, so the per-worker list is bounded and the poll path no
longer rescans unbounded history. Pending commands are never pruned, at any
age — dropping one would silently discard an operator decision. Covered by
`TestResolvedCommandsArePrunedButPendingOnesSurvive`. **The persistence half
of this defect is still open:** captures and commands remain in-memory only,
so a coordinator restart still drops pending approvals silently.
age — dropping one would silently discard an operator decision. Captures,
commands, and the worker identity/token binding now persist atomically in
`$ORCHESTRA_DATA/federation-state.json` (mode 0600), so the worker must
re-register with its original token after restart before it can consume a
recovered command. Covered by `TestResolvedCommandsArePrunedButPendingOnesSurvive`
and `TestPendingApprovalSurvivesRegistryRestart`.
### S12 — `ORCHESTRA_NTFY_TOKEN` serves two unrelated purposes — closed (2026-07-28)
@@ -775,41 +745,18 @@ bypass is closed by construction rather than by the header check alone.
In dependency order, not importance order:
1. **Finish applying the B18 env change on the deployed instance.** The
repo-side `.orchestra-config/orchestra.env` now sets a generated
`ORCHESTRA_WEB_TOKEN` and `ORCHESTRA_UI_INSECURE_COOKIE=1`, but the unit
reads `/etc/orchestra/orchestra.env`, which is `orchestra:orchestra 0600`
and unreadable from this sandbox — whether it carries the token could not
be confirmed. It was last modified 2026-07-28 14:30 and the service did
start at 16:17 with the B18 build absent, so this is unverified either
way. Also newly required: `ORCHESTRA_NTFY_SURFACE_TOKEN` (S12) if the
ntfy surface should stay gated, and the token for every existing
unauthenticated `/v1/` client, which now defaults to the Web surface.
2. **Install the new binary and restart.** `orchestra.service` has been
stopped since 2026-07-28 19:46 and `/usr/local/bin/orchestra` predates
every fix in this section. Needs privileges the sandbox lacks:
`go build -o /tmp/orchestra ./cmd/orchestra && sudo install /tmp/orchestra
/usr/local/bin/orchestra && sudo systemctl restart orchestra.service`.
3. **The live re-verification** that B13B17 all still lack, plus a first
live exercise of B18B21. Each of B13B17 was found live and closed on
paper; the code fixes are unproven against a real cross-machine run, and
that remains the single biggest gap between this document and reality.
Note that no herdr is currently reachable at all — the 16:17 startup logs
`connection refused` for **all six**, including workpc's, which was live
on 2026-07-27 — so a live run needs a herdr brought up first.
4. **Two operational faults visible in the journal**, unrelated to this
audit's defects but blocking a clean live run: every herdr is refusing
connections (above), and ntfy delivery is failing `403 Forbidden` on
every send (16:43 and 16:46), i.e. the ntfy server credential is wrong or
expired.
5. **The persistence half of B21** — captures and commands are in-memory
only, so a restart still silently drops pending approvals. The retention
fix bounds growth within a process; it does not make the lifecycle
durable.
6. **`RespondApproval` (coordinator path) still has no test**, unlike its
worker counterpart. With B20 fixed its revision is now meaningful, but
its text-comparison guard remains the thing actually binding a decision
to what the operator saw, and that guard is untested.
1. **Prove a successful B17 worker handoff.** Use a bounded disposable task
with a concrete next action and verify canonical report upload, release,
follow-up pickup, and cleanup. The rejection path is proven; success is
not.
2. **Fix health semantics and ntfy.** Report worker/local-herdr health
separately from legacy TCP registry probes, and correct the ntfy server
credential causing `403 Forbidden`.
3. **Address the Web UI backlog above**, starting with queue explanations,
actionable worker health, and a usable approval/recovery workflow.
4. **Record a browser smoke.** Confirm login, refresh, invalid token, and
expired-session behavior in an actual browser after the deployed assets
settle.
Deliberately *not* next: building further on Design A's cross-machine calls,
and closing B17 for the coordinator path. Both wait on the federation-fork
@@ -864,9 +811,9 @@ event appended with `Surface: system` set directly in Go, consistent with
B8. Extended to Codex/opencode via an optional `harness` field in the
request body (`"codex"` → `CodexUsage`, `"opencode"` → `OpenCodeUsage`).
**Still open:** no automated test for the HTTP handler (`cmd/orchestra/main.go`
has zero handler test coverage of any kind, pre-existing gap — this follows
the existing pattern rather than introducing a one-off harness).
Covered by `TestHarnessCompletionBuildsReceiptAndQuotaFromTranscript`, which
exercises authentication, transcript-derived receipts, `TaskCompleted`, and
the matching `QuotaReported` event through the actual HTTP handler.
### B4 — Router counted rotation as a retry (§5.3, §5.4) — closed
@@ -957,11 +904,10 @@ Covered by `TestGitWorktreesCommitsTaskFile`, `TestStartBlocksOnInvalidPickup`,
`TestReleaseRefusesOnStaleDirtyFile`, `TestConventionsDriftNotifiesActiveSession`,
`TestRotationRequestsHandoffBeforeReleasing` (internal/herdr, internal/orchestrator).
**Caveat still open:** TASK.md hashing is best-effort/untested for the
herdr-hosted (`WorktreeCreator`) worktree path specifically — no adapter or
test exercises that path with a real `handoff_ref`, so pickup validation
there runs with an empty `taskFileSHA` (anchor + dirty-file hashes still
checked). Same cross-host caveat as the federation fork, below.
The coordinator no longer supports a herdr-hosted `WorktreeCreator` path.
It always creates the local, committed `TASK.md` before leasing a local pane;
remote worktrees are exclusively worker-owned, so coordinator pickup
validation never runs against a remote filesystem path.
### B7 — Quota projection had no producer (§7.2) — closed (post-hoc only)
@@ -1221,42 +1167,25 @@ per-tool-call source at all.
Two incompatible federation designs coexist in the tree.
**Design A — "drive the remote socket" (currently deployed,
`clients/herdr-bridge.go`)**: homesrv calls `worktree.create`/`agent.start`/
etc. directly on workpc's herdr over TCP as if it were local — meaning
anchor validation (`git rev-parse HEAD`) executes on the *wrong machine*
relative to the actual checkout. Two outcomes if the coordinator's
`session.Worktree` path happens to also exist on homesrv (likely, since
every project shares the same directory layout): either `HeadSHA` errors and
rotation silently skips forever, or — the dangerous case — it returns
**homesrv's HEAD for an unrelated checkout**, passing validation while
certifying a commit the agent never touched. Same class of bug applies to
per-host quota accounting and to `cleanupCompleted`'s `git worktree remove`,
which runs on homesrv for a worktree that lives on workpc.
**Design A — "drive the remote socket" (`clients/herdr-bridge.go`)** remains
as a legacy byte proxy but is no longer a coordinator execution path. A
multi-machine registry now requires `ORCHESTRA_MACHINE_ID`; the coordinator
rejects every non-local herdr before leasing, capture, approval, rotation,
release, expiry cleanup, or worktree creation. It always creates the local
committed `TASK.md`; remote panes/checkouts are worker-owned. This prevents
the old wrong-host `git rev-parse`, anchor validation, and cleanup failures.
**Design B — "workers pull tasks" (`/v1/federation/*`)**: fully built
server-side (registration, heartbeat/TTL offline detection, event-cursor
polling/ack, lease claim), zero clients — no worker binary exists anywhere
in this repo. This is what the spec actually describes (§2.1: "everything
crossing a machine boundary is git + a validated artifact, never live state
over the wire"), but every endpoint is currently unreachable in the real
deployment. Latent, never-surfaced defects in this unused half: offline
detection only runs inside `Snapshot()`, called solely from a GET endpoint —
nothing ticks it on its own, so `OnOffline` (the hook that releases leases
held by a vanished worker) only fires if a human hits that endpoint; the
registry is in-memory with no persistence, so a restart forgets all workers
and cursors.
**Design B — "workers pull tasks" (`/v1/federation/*`)** is the sole remote
execution path. `cmd/orchestra-worker` registers, heartbeats, polls/acks
leases, and performs Git/herdr operations on the owning machine; coordinator
state for captures and pending approvals is durable across restart. Worker
offline detection is ticked by the API process rather than depending on a
human health request.
**Decision, unchanged:** keep Design A through Phase 5 (single-host concerns
— occupancy, Face B, rotation, continuity — are provable on homesrv alone
with workpc's herdr as just another pane host); commit to Design B in Phase
6. Two guardrails were meant to land immediately so Design A can't corrupt
state in the meantime: (1) refuse to rotate a lease held by a non-local
herdr rather than validate against the wrong checkout, since the protocol
schema can't run `rev-parse` where the checkout is; (2) same treatment for
`cleanupCompleted`'s worktree removal. **Status of these two guardrails is
unverified in this pass** — re-check `internal/orchestrator` before assuming
they landed; they are not confirmed closed above the way B1B11/S1S11 are.
**Decision, now enforced:** federation workers are the sole remote execution
path. The coordinator-side Design A operations are fail-closed rather than
guarded piecemeal; `clients/herdr-bridge.go` should be retired from deployment
once no other consumer needs it.
If the worker binary is ever abandoned, delete `/v1/federation/*` and record
the deviation — leaving both designs in place unmarked is explicitly not
@@ -1305,6 +1234,14 @@ Numbered defects are not repeated here — B19, B20, B21, S12 and S13 are in
in "What's next". This list is the unnumbered residue: conditions that are
known, accepted, or not actionable as a single fix.
- **Vikunja must become a first-class automatic task source.** The intended
path is `Vikunja task → Orchestra task → agent work → Vikunja update`, not
manual re-entry through the UI. Its provider needs stable external-ID
deduplication, explicit list/project and status/label eligibility mapping,
and guarded completion/blocker write-back that cannot create an ingestion
loop. Treat this alongside Gitea and JSONL ingestion when building the
automatic task-source surface.
- **B18's env change is applied repo-side only.** `.orchestra-config/
orchestra.env` now sets `ORCHESTRA_WEB_TOKEN`, but the unit loads
`/etc/orchestra/orchestra.env`, which is not readable or writable from
@@ -1323,11 +1260,11 @@ known, accepted, or not actionable as a single fix.
*which* of two equal-priority tasks wins is still nondeterministic, since
`sort.SliceStable` is applied to a randomly ordered slice; that is a real
property of the router, not a test artifact.
- **B14/B15/B16 are code-fixed but not re-verified live**, and B17 is closed
only for tasks that run through a worker. Each was found live, so a code
fix plus unit tests is weaker evidence than the failure that produced it.
In particular B16 still lacks the requested two-pane same-harness attach
contract test, so its actual `agent_name_taken` failure mode is untested.
- **B14/B15/B16 are code-fixed but not re-verified under their original live
races**, and B17 is closed only for tasks that run through a worker. Each
was found live, so deterministic coverage is weaker evidence than the
failure that produced it; B16's two-same-harness naming contract is now
covered by fake-herdr tests.
- **B15's reconciliation is operator-mediated only.** Blocked tasks keep
their session and are correctable through the UI, but nothing promotes
them automatically and the router still never retries a blocked task.
+57 -8
View File
@@ -34,7 +34,43 @@ type worker struct {
statePath string
hard float64
registration federation.Worker
lastError string
lastErrorAt time.Time
}
func (w *worker) recordError(err error) {
if err == nil {
return
}
w.lastError = err.Error()
w.lastErrorAt = time.Now().UTC()
}
func (w *worker) health(ctx context.Context) federation.WorkerHealth {
h := federation.WorkerHealth{HerdrStatus: "unknown"}
for taskID, session := range w.sessions {
// Workers currently advertise capacity one. Pick deterministically so a
// recovered legacy state with more sessions remains intelligible.
if h.ActiveTask == "" || taskID < h.ActiveTask {
h.ActiveTask, h.ActivePane = taskID, session.PaneID
}
}
if w.herdr != nil {
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err := w.herdr.CheckProtocol(checkCtx, "17")
cancel()
h.CheckedAt = time.Now().UTC()
if err == nil {
h.HerdrStatus = "reachable"
} else {
h.HerdrStatus = "unreachable"
w.recordError(fmt.Errorf("local herdr: %w", err))
}
}
h.LastError, h.ErrorAt = w.lastError, w.lastErrorAt
return h
}
type lease struct {
HandoffRef string `json:"handoff_ref,omitempty"`
}
@@ -248,22 +284,29 @@ func (w *worker) publishCaptures(ctx context.Context) {
}
}
func approvalResponse(text, kind string) (string, bool) {
type approvalInput struct {
Text string
Keys []string
}
func approvalResponse(text, kind string) (approvalInput, bool) {
low := strings.ToLower(text)
// Never invent a keystroke. y/n prompts label both decisions directly.
if strings.Contains(low, "[y/n]") || strings.Contains(low, "(y/n)") {
if kind == "grant_approval" {
return "y\n", true
return approvalInput{Text: "y\n"}, true
}
return "n\n", true
return approvalInput{Text: "n\n"}, true
}
// OpenCode's explicit selector states "Allow once Allow always Reject"
// and "enter confirm". Enter is consequently a bounded one-time grant;
// and "enter confirm". Send a real ENTER key, not a newline through
// pane.send_text: OpenCode's selector does not treat the latter as input.
// Enter is consequently a bounded one-time grant;
// rejection would require unobservable selector navigation, so refuse it.
if kind == "grant_approval" && strings.Contains(low, "allow once") && strings.Contains(low, "allow always") && strings.Contains(low, "reject") && strings.Contains(low, "enter confirm") {
return "\n", true
return approvalInput{Keys: []string{"ENTER"}}, true
}
return "", false
return approvalInput{}, false
}
func (w *worker) runCommands(ctx context.Context) {
commands, err := w.api.Commands(ctx)
@@ -296,7 +339,11 @@ func (w *worker) runCommands(ctx context.Context) {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "prompt does not expose an executable approval control")
continue
}
if err := w.herdr.Call(ctx, "pane.send_text", map[string]any{"pane_id": session.PaneID, "text": input}, nil); err != nil {
method, params := "pane.send_text", map[string]any{"pane_id": session.PaneID, "text": input.Text}
if len(input.Keys) > 0 {
method, params = "pane.send_keys", map[string]any{"pane_id": session.PaneID, "keys": input.Keys}
}
if err := w.herdr.Call(ctx, method, params, nil); err != nil {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "herdr did not acknowledge input: "+err.Error())
continue
}
@@ -450,13 +497,15 @@ func main() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
if err := w.api.Heartbeat(ctx); err != nil {
if err := w.api.Heartbeat(ctx, w.health(ctx)); err != nil {
w.recordError(fmt.Errorf("heartbeat: %w", err))
log.Printf("heartbeat: %v", err)
if w.reRegisterAfterCoordinatorRestart(ctx, err) {
continue
}
}
if err := w.once(ctx); err != nil {
w.recordError(fmt.Errorf("poll: %w", err))
log.Printf("poll: %v", err)
w.reRegisterAfterCoordinatorRestart(ctx, err)
}
+5 -4
View File
@@ -14,6 +14,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"time"
)
@@ -248,11 +249,11 @@ func TestWorkerApprovalCommandIsRevisionBoundAndAcknowledged(t *testing.T) {
func TestApprovalResponseOpenCodeAllowOnce(t *testing.T) {
text := "Permission required\nAllow once Allow always Reject\n⇆ select enter confirm"
if got, ok := approvalResponse(text, "grant_approval"); !ok || got != "\n" {
t.Fatalf("grant response = %q, %v", got, ok)
if got, ok := approvalResponse(text, "grant_approval"); !ok || !reflect.DeepEqual(got.Keys, []string{"ENTER"}) || got.Text != "" {
t.Fatalf("grant response = %+v, %v", got, ok)
}
if got, ok := approvalResponse(text, "deny_approval"); ok || got != "" {
t.Fatalf("deny response = %q, %v; reject must not guess selector navigation", got, ok)
if got, ok := approvalResponse(text, "deny_approval"); ok || got.Text != "" || len(got.Keys) != 0 {
t.Fatalf("deny response = %+v, %v; reject must not guess selector navigation", got, ok)
}
}
+145 -91
View File
@@ -5,6 +5,7 @@ import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
@@ -92,6 +93,95 @@ func (a federatedAvailability) Available(h registry.Herdr) bool {
return a.workers.Available(h.ID)
}
func validateLocalMachine(rr registry.Registry, localMachine string) error {
machines := rr.Machines()
if len(machines) <= 1 {
return nil
}
if localMachine == "" {
return fmt.Errorf("ORCHESTRA_MACHINE_ID is required for a multi-machine registry; refusing unsafe remote-herdr coordination")
}
if _, ok := rr.Machine(localMachine); !ok {
return fmt.Errorf("ORCHESTRA_MACHINE_ID %q is not in the registry", localMachine)
}
return nil
}
type harnessCompletion struct {
store *store.Store
route func(domain.Event) error
token string
}
func (h harnessCompletion) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.token != "" && r.Header.Get("Authorization") != "Bearer "+h.token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var p struct {
TaskID string `json:"task_id"`
Harness string `json:"harness"`
TranscriptPath string `json:"transcript_path"`
Report string `json:"report"`
}
if json.NewDecoder(r.Body).Decode(&p) != nil || p.TaskID == "" || p.Report == "" || p.TranscriptPath == "" {
http.Error(w, "task_id, transcript_path, and report are required", http.StatusBadRequest)
return
}
t, ok := h.store.Task(p.TaskID)
if !ok {
http.Error(w, "task not found", http.StatusNotFound)
return
}
var usage herdr.Usage
var err error
switch p.Harness {
case "codex":
usage, err = herdr.CodexUsage(p.TranscriptPath)
case "opencode":
usage, err = herdr.OpenCodeUsage(p.TranscriptPath)
case "", "claude":
usage, err = herdr.ClaudeUsage(p.TranscriptPath)
default:
http.Error(w, "unknown harness: "+p.Harness, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "reading transcript: "+err.Error(), http.StatusBadRequest)
return
}
ref, err := h.store.PutArtifact([]byte(p.Report))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload, _ := json.Marshal(map[string]any{"report_ref": ref, "receipt": map[string]any{
"input_tokens": usage.Input, "cache_read_tokens": usage.CacheRead,
"cache_write_tokens": usage.CacheWrite, "output_tokens": usage.Output, "numerator": usage.Numerator(),
}})
e := domain.Event{ID: id(), Type: "TaskCompleted", TaskID: p.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)}
if err := h.store.Append(e); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if h.route != nil {
if err := h.route(e); err != nil {
log.Printf("route task: %v", err)
}
}
if t.Lease != nil && t.Lease.HarnessID != "" {
qp, _ := json.Marshal(map[string]any{"harness_id": t.Lease.HarnessID, "consumed": float64(usage.Numerator())})
if err := h.store.Append(domain.Event{ID: id(), Type: "QuotaReported", TaskID: "system", Payload: qp, Surface: string(authz.System)}); err != nil {
log.Printf("quota report: %v", err)
}
}
json.NewEncoder(w).Encode(e)
}
func main() {
dir := os.Getenv("ORCHESTRA_DATA")
if dir == "" {
@@ -105,11 +195,17 @@ func main() {
var rt *router.Router
var coordinator *orchestrator.Coordinator
localMachine := os.Getenv("ORCHESTRA_MACHINE_ID")
workers := &federation.Registry{AdmitToken: os.Getenv("ORCHESTRA_FEDERATION_ADMIT_TOKEN")}
workers := &federation.Registry{AdmitToken: os.Getenv("ORCHESTRA_FEDERATION_ADMIT_TOKEN"), StatePath: filepath.Join(dir, "federation-state.json")}
if err := workers.Load(); err != nil {
log.Fatalf("load federation state: %v", err)
}
if config := os.Getenv("ORCHESTRA_CONFIG"); config != "" {
if rr, err = registry.Load(config); err != nil {
log.Fatalf("load orchestra config: %v", err)
}
if err := validateLocalMachine(rr, localMachine); err != nil {
log.Fatal(err)
}
reachability := registry.Reachability(registry.TCPReachability{})
if localMachine != "" {
reachability = federatedReachability{base: reachability, remote: remoteHerdrAddresses(rr, localMachine)}
@@ -169,7 +265,10 @@ func main() {
Projects: projectRepos,
Default: orchestrator.GitWorktrees{Root: root, Repo: repo},
}
coordinator = &orchestrator.Coordinator{Store: s, StatePath: filepath.Join(dir, "runtime-sessions.json"), Worktrees: worktrees, Adapters: orchestrator.AdapterFactory{Herdrs: adapters}}
coordinator = &orchestrator.Coordinator{Store: s, StatePath: filepath.Join(dir, "runtime-sessions.json"), Worktrees: worktrees, Adapters: orchestrator.AdapterFactory{Herdrs: adapters}, LocalHerdr: func(id string) bool {
h, ok := rr.Herdr(id)
return ok && (localMachine == "" || h.MachineID == localMachine)
}}
rt.OnLease = func(e domain.Event) error {
// In federated mode the coordinator must never inspect a remote
// checkout. Its worker consumes the router-issued lease event and
@@ -214,6 +313,17 @@ func main() {
// the token once for an HttpOnly cookie. Same credential, presentable
// form; no new authority is created here.
mux.HandleFunc("/v1/ui/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
// Expire the browser credential even if it is already absent or stale.
// The client never has access to the HttpOnly value, so this is the
// only reliable way for an operator to end a browser session.
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
sessions.Revoke(cookie.Value)
}
http.SetCookie(w, &http.Cookie{Name: authz.SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == ""})
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -284,6 +394,13 @@ func main() {
return v
}
mux.HandleFunc("/v1/tasks", func(w http.ResponseWriter, r *http.Request) {
if wid := r.Header.Get("X-Orchestra-Worker"); wid != "" {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if err := workers.Authenticate(wid, token); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
}
if r.Method == "GET" {
json.NewEncoder(w).Encode(s.Tasks())
return
@@ -353,6 +470,13 @@ func main() {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if wid := r.Header.Get("X-Orchestra-Worker"); wid != "" {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if err := workers.Authenticate(wid, token); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
}
// Reports and handoffs are content-addressed evidence. Keep uploads
// bounded because event payloads only carry their resulting hash.
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
@@ -407,94 +531,13 @@ func main() {
// same session-file assumption as CLIAdapter.Occupancy — rather than
// trusting a self-reported number.
harnessToken := os.Getenv("ORCHESTRA_HARNESS_TOKEN")
mux.HandleFunc("/v1/harness/complete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
mux.Handle("/v1/harness/complete", harnessCompletion{store: s, token: harnessToken, route: func(e domain.Event) error {
if rt == nil {
return nil
}
if harnessToken != "" && r.Header.Get("Authorization") != "Bearer "+harnessToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var p struct {
TaskID string `json:"task_id"`
Harness string `json:"harness"`
TranscriptPath string `json:"transcript_path"`
Report string `json:"report"`
}
if json.NewDecoder(r.Body).Decode(&p) != nil || p.TaskID == "" || p.Report == "" || p.TranscriptPath == "" {
http.Error(w, "task_id, transcript_path, and report are required", http.StatusBadRequest)
return
}
t, ok := s.Task(p.TaskID)
if !ok {
http.Error(w, "task not found", http.StatusNotFound)
return
}
// Harness-specific session-state readers (AUDIT.md "Codex/opencode
// completion producers"). Same local-filesystem assumption B3 already
// made for Claude: the caller supplies the path to its own session
// state (transcript / rollout / message file), never a herdr pane id.
var usage herdr.Usage
var err error
switch p.Harness {
case "codex":
usage, err = herdr.CodexUsage(p.TranscriptPath)
case "opencode":
usage, err = herdr.OpenCodeUsage(p.TranscriptPath)
case "", "claude":
usage, err = herdr.ClaudeUsage(p.TranscriptPath)
default:
http.Error(w, "unknown harness: "+p.Harness, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "reading transcript: "+err.Error(), http.StatusBadRequest)
return
}
ref, err := s.PutArtifact([]byte(p.Report))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload, _ := json.Marshal(map[string]any{
"report_ref": ref,
"receipt": map[string]any{
"input_tokens": usage.Input,
"cache_read_tokens": usage.CacheRead,
"cache_write_tokens": usage.CacheWrite,
"output_tokens": usage.Output,
"numerator": usage.Numerator(),
},
})
e := domain.Event{ID: id(), Type: "TaskCompleted", TaskID: p.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if rt != nil {
if _, routeErr := rt.HandleEvent(e); routeErr != nil {
log.Printf("route task: %v", routeErr)
}
}
// B7 (AUDIT.md): QuotaReported has no other producer, so router's
// 5h/weekly availability filter and the brief's quota_consumed are
// permanently zero without this. Fed by the same per-harness usage
// read as the receipt above (spec §7.2 — "same per-harness session
// state as §5.2.1"); harness_id comes from the lease this completion
// closes out, before it's released.
if t.Lease != nil && t.Lease.HarnessID != "" {
qp, _ := json.Marshal(map[string]any{
"harness_id": t.Lease.HarnessID,
"consumed": float64(usage.Numerator()),
})
qe := domain.Event{ID: id(), Type: "QuotaReported", TaskID: "system", Payload: qp, Surface: string(authz.System)}
if err := s.Append(qe); err != nil {
log.Printf("quota report: %v", err)
}
}
json.NewEncoder(w).Encode(e)
})
_, err := rt.HandleEvent(e)
return err
}})
// /v1/harness/turn is the unified turn-decision endpoint (AUDIT.md Phase
// 2 items 1-2): the Face-B stop hook posts here on every ordinary turn
// boundary (report marker absent — /v1/harness/complete covers task
@@ -1009,7 +1052,12 @@ func main() {
return
}
if strings.HasSuffix(r.URL.Path, "/heartbeat") {
if err := workers.Heartbeat(parts[3]); err != nil {
var health federation.WorkerHealth
if err := json.NewDecoder(r.Body).Decode(&health); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid worker health", 400)
return
}
if err := workers.Heartbeat(parts[3], health); err != nil {
http.Error(w, err.Error(), 404)
return
}
@@ -1049,7 +1097,13 @@ func main() {
http.Error(w, "task not found", 404)
return
}
if t.State != domain.StateLeased || t.Lease == nil || t.Lease.HarnessID != parts[3] {
ownedLease := t.State == domain.StateLeased && t.Lease != nil && t.Lease.HarnessID == parts[3]
// A prompt timeout can block the coordinator after herdr already
// accepted the request. If that same authenticated worker later reports
// a durable completion, reconcile it rather than preserving a known
// false blocked state. No other blocked task is admitted here.
recoverableBlocked := strings.HasSuffix(r.URL.Path, "/complete") && t.State == domain.StateBlocked && t.LastHarness == parts[3]
if !ownedLease && !recoverableBlocked {
http.Error(w, "lease not owned", 409)
return
}
+97
View File
@@ -1,12 +1,19 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
)
type unreachable struct{}
@@ -33,3 +40,93 @@ func TestFederatedReachabilityDefersRemoteHerdrToWorkerHeartbeat(t *testing.T) {
t.Fatal("local herdr should still require its TCP probe")
}
}
func TestHarnessCompletionBuildsReceiptAndQuotaFromTranscript(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "done", Surface: string(authz.System), Payload: []byte(`{"source":"qa","external_id":"done","project":"p"}`)}); err != nil {
t.Fatal(err)
}
if _, err := s.Lease("done", "local-claude", time.Minute); err != nil {
t.Fatal(err)
}
transcript := filepath.Join(t.TempDir(), "transcript.jsonl")
if err := os.WriteFile(transcript, []byte(`{"message":{"usage":{"input_tokens":100,"cache_read_input_tokens":20,"cache_creation_input_tokens":5,"output_tokens":7}}}`+"\n"), 0600); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"task_id": "done", "transcript_path": transcript, "report": "# done"})
req := httptest.NewRequest(http.MethodPost, "/v1/harness/complete", bytes.NewReader(body))
res := httptest.NewRecorder()
harnessCompletion{store: s, token: "secret"}.ServeHTTP(res, req)
if res.Code != http.StatusUnauthorized {
t.Fatalf("missing token status = %d, want 401", res.Code)
}
req.Header.Set("Authorization", "Bearer secret")
req = httptest.NewRequest(http.MethodPost, "/v1/harness/complete", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer secret")
res = httptest.NewRecorder()
harnessCompletion{store: s, token: "secret"}.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("completion status = %d: %s", res.Code, res.Body.String())
}
task, ok := s.Task("done")
if !ok || task.State != domain.StateCompleted {
t.Fatalf("task after completion = %#v, present=%v", task, ok)
}
var completed struct {
Receipt struct {
Input int `json:"input_tokens"`
CacheRead int `json:"cache_read_tokens"`
CacheWrite int `json:"cache_write_tokens"`
Output int `json:"output_tokens"`
} `json:"receipt"`
}
for _, e := range s.Events(0) {
if e.Type == "TaskCompleted" {
if err := json.Unmarshal(e.Payload, &completed); err != nil {
t.Fatal(err)
}
}
}
if completed.Receipt.Input != 100 || completed.Receipt.CacheRead != 20 || completed.Receipt.CacheWrite != 5 || completed.Receipt.Output != 7 {
t.Fatalf("receipt = %#v", completed.Receipt)
}
var quota struct {
Harness string `json:"harness_id"`
Consumed float64 `json:"consumed"`
}
found := false
for _, e := range s.Events(0) {
if e.Type == "QuotaReported" {
_ = json.Unmarshal(e.Payload, &quota)
found = true
}
}
if !found || quota.Harness != "local-claude" || quota.Consumed != 125 {
t.Fatalf("quota report = %#v, found=%v", quota, found)
}
}
func TestMultiMachineRegistryRequiresKnownLocalMachine(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
"machines":[{"id":"homesrv","address":"192.168.1.104:9145"},{"id":"workpc","address":"192.168.1.105:9145"}]
}`), 0o600); err != nil {
t.Fatal(err)
}
r, err := registry.Load(path)
if err != nil {
t.Fatal(err)
}
if err := validateLocalMachine(r, ""); err == nil {
t.Fatal("missing local machine accepted")
}
if err := validateLocalMachine(r, "missing"); err == nil {
t.Fatal("unknown local machine accepted")
}
if err := validateLocalMachine(r, "homesrv"); err != nil {
t.Fatalf("known local machine rejected: %v", err)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ ORCHESTRA_PORT=9145
# ORCHESTRA_WORKER_HERDR=/home/orchestra/.config/herdr/herdr.sock
# ORCHESTRA_WORKER_STATE=/var/lib/orchestra-worker/state.json
# ORCHESTRA_GIT_REMOTE=origin
# ORCHESTRA_MACHINE_ID=homesrv # set on the authoritative coordinator
ORCHESTRA_MACHINE_ID=homesrv # required when the registry has multiple machines
# Static project/machine/herdr topology (registry.Load). Required for
# routing across more than one machine; validated at startup.
+28
View File
@@ -141,6 +141,19 @@ func (s *Sessions) Valid(v string) bool {
return true
}
// Revoke removes one browser session. It is deliberately idempotent so a
// logout request remains safe after expiry or after a cookie was cleared by
// the browser.
func (s *Sessions) Revoke(v string) {
if v == "" {
return
}
sum := sha256.Sum256([]byte(v))
s.mu.Lock()
defer s.mu.Unlock()
delete(s.ids, hex.EncodeToString(sum[:]))
}
// HTTP enforces the same policy at the bus boundary. Authentication is
// optional for local development; when a token is supplied, control surfaces
// must present it as a Bearer token.
@@ -153,6 +166,21 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
// still has to present the token directly.
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Federation has per-worker credentials, not one shared surface token.
// Let only its registration request and requests that name a worker
// reach their handlers; those handlers authenticate the admission token
// or worker token respectively. Without this exception, an authenticated
// worker is incorrectly treated as the default Web surface.
worker := r.Header.Get("X-Orchestra-Worker") != ""
federationRegistration := r.Method == http.MethodPost && r.URL.Path == "/v1/federation/workers"
workerPath := strings.HasPrefix(r.URL.Path, "/v1/federation/") ||
(r.Method == http.MethodGet && r.URL.Path == "/v1/tasks") ||
(r.Method == http.MethodPost && r.URL.Path == "/v1/artifacts") ||
(r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/artifacts/"))
if federationRegistration || (worker && workerPath) {
next.ServeHTTP(w, r)
return
}
s := ParseSurface(r.Header.Get("X-Orchestra-Surface"))
if s == "" {
s = Web
+50
View File
@@ -96,6 +96,41 @@ func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
}
}
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
tokens := map[Surface]string{Web: "web-secret"}
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
for _, tc := range []struct {
name string
method string
path string
worker string
want int
}{
{name: "registration reaches admission handler", method: http.MethodPost, path: "/v1/federation/workers", want: http.StatusNoContent},
{name: "worker request reaches worker handler", method: http.MethodGet, path: "/v1/federation/events", worker: "workpc-opencode", want: http.StatusNoContent},
{name: "worker task reconciliation reaches worker handler", method: http.MethodGet, path: "/v1/tasks", worker: "workpc-opencode", want: http.StatusNoContent},
{name: "worker artifact read reaches worker handler", method: http.MethodGet, path: "/v1/artifacts/ref", worker: "workpc-opencode", want: http.StatusNoContent},
{name: "worker artifact upload reaches worker handler", method: http.MethodPost, path: "/v1/artifacts", worker: "workpc-opencode", want: http.StatusNoContent},
{name: "unnamed worker request remains web gated", method: http.MethodGet, path: "/v1/federation/events", want: http.StatusUnauthorized},
{name: "worker list remains web gated", method: http.MethodGet, path: "/v1/federation/workers", want: http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(tc.method, tc.path, nil)
if tc.worker != "" {
r.Header.Set("X-Orchestra-Worker", tc.worker)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
func TestSessionExpires(t *testing.T) {
s := &Sessions{TTL: time.Millisecond}
v, err := s.Issue()
@@ -110,3 +145,18 @@ func TestSessionExpires(t *testing.T) {
t.Fatal("empty session accepted")
}
}
func TestSessionRevoke(t *testing.T) {
s := &Sessions{}
v, err := s.Issue()
if err != nil {
t.Fatal(err)
}
if !s.Valid(v) {
t.Fatal("fresh session must be valid")
}
s.Revoke(v)
if s.Valid(v) {
t.Fatal("revoked session must not be valid")
}
}
+10
View File
@@ -66,6 +66,13 @@ type Task struct {
Version int `json:"version"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
// Block evidence is projected from TaskBlocked so terminal records remain
// diagnosable after the live coordinator mapping is gone.
Blocker string `json:"blocker,omitempty"`
BlockedAt time.Time `json:"blocked_at,omitempty"`
LastPaneID string `json:"last_pane_id,omitempty"`
LastHarness string `json:"last_harness_id,omitempty"`
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
}
type Event struct {
@@ -189,6 +196,9 @@ func ValidatePayload(typ string, p map[string]any) error {
return err
}
}
if v, ok := p["pane_state"]; ok && v != "open" && v != "closed" && v != "unreachable" && v != "unknown" {
return fmt.Errorf("%w: pane_state invalid", ErrInvalid)
}
case "TaskAmended":
if len(p) == 0 {
return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid)
+4 -2
View File
@@ -124,8 +124,8 @@ func (c Client) Ack(ctx context.Context, cursor uint64) error {
}
return err
}
func (c Client) Heartbeat(ctx context.Context) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", nil)
func (c Client) Heartbeat(ctx context.Context, health WorkerHealth) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", health)
if resp != nil {
resp.Body.Close()
}
@@ -146,6 +146,8 @@ func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
req.Header.Set("Authorization", "Bearer "+c.Token)
h := c.HTTP
if h == nil {
h = http.DefaultClient
+130 -10
View File
@@ -2,8 +2,11 @@ package federation
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
@@ -12,12 +15,26 @@ var ErrUnknownWorker = errors.New("unknown worker")
var ErrUnauthorized = errors.New("worker authentication failed")
type Worker struct {
ID string `json:"id"`
Address string `json:"address"`
Capacity int `json:"capacity"`
LastSeen time.Time `json:"last_seen"`
Online bool `json:"online"`
Token string `json:"-"`
ID string `json:"id"`
Address string `json:"address"`
Capacity int `json:"capacity"`
LastSeen time.Time `json:"last_seen"`
Online bool `json:"online"`
Health WorkerHealth `json:"health"`
Token string `json:"-"`
}
// WorkerHealth is reported by the worker that owns the local herdr socket.
// It intentionally does not reuse coordinator TCP-probe state: a remote
// socket is meaningful only from the machine where the worker and checkout
// live.
type WorkerHealth struct {
HerdrStatus string `json:"herdr_status"` // reachable, unreachable, or unknown
CheckedAt time.Time `json:"checked_at,omitempty"`
ActiveTask string `json:"active_task_id,omitempty"`
ActivePane string `json:"active_pane_id,omitempty"`
LastError string `json:"last_error,omitempty"`
ErrorAt time.Time `json:"error_at,omitempty"`
}
// Capture is published by a worker that owns the pane. The coordinator never
@@ -53,6 +70,88 @@ type Registry struct {
cursors map[string]uint64
captures map[string]Capture // worker/task
commands map[string][]Command
// StatePath preserves worker-owned pane captures and pending approval
// commands across coordinator restarts. A worker must still re-register to
// be online before it can read or act on recovered state.
StatePath string
}
type persistedState struct {
Captures map[string]Capture `json:"captures"`
Commands map[string][]Command `json:"commands"`
Workers map[string]persistedWorker `json:"workers"`
}
// persistedWorker deliberately includes the per-worker token. The state file
// is mode 0600, and retaining this binding prevents an arbitrary process from
// registering a recovered worker ID and executing its pending approval.
type persistedWorker struct {
ID string `json:"id"`
Address string `json:"address"`
Capacity int `json:"capacity"`
Token string `json:"token"`
}
// Load restores durable capture/command state. Call this before accepting
// federation requests; an unreadable state file is unsafe because it could
// otherwise make a pending approval silently disappear.
func (r *Registry) Load() error {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
if r.StatePath == "" {
return nil
}
b, err := os.ReadFile(r.StatePath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var state persistedState
if err := json.Unmarshal(b, &state); err != nil {
return fmt.Errorf("invalid federation state: %w", err)
}
if state.Captures != nil {
r.captures = state.Captures
}
if state.Commands != nil {
r.commands = state.Commands
}
for id, w := range state.Workers {
if id == "" || w.ID != id || w.Token == "" {
return fmt.Errorf("invalid federation worker %q", id)
}
r.workers[id] = Worker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
}
return nil
}
// persistLocked atomically replaces the state file. Callers hold r.mu.
func (r *Registry) persistLocked() error {
if r.StatePath == "" {
return nil
}
workers := make(map[string]persistedWorker, len(r.workers))
for id, w := range r.workers {
workers[id] = persistedWorker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
}
b, err := json.Marshal(persistedState{Captures: r.captures, Commands: r.commands, Workers: workers})
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(r.StatePath), 0755); err != nil {
return err
}
tmp := r.StatePath + ".tmp"
if err := os.WriteFile(tmp, b, 0600); err != nil {
return err
}
if err := os.Rename(tmp, r.StatePath); err != nil {
return err
}
return os.Chmod(r.StatePath, 0600)
}
func (r *Registry) init() {
@@ -94,6 +193,9 @@ func (r *Registry) PutCapture(worker string, c Capture) (Capture, error) {
}
c.At = time.Now().UTC()
r.captures[k] = c
if err := r.persistLocked(); err != nil {
return Capture{}, fmt.Errorf("persist capture: %w", err)
}
return c, nil
}
func (r *Registry) Capture(worker, task string) (Capture, bool) {
@@ -118,6 +220,9 @@ func (r *Registry) Queue(worker string, c Command) (Command, error) {
c.Status = "pending"
r.commands[worker] = append(r.commands[worker], c)
r.pruneCommands(worker)
if err := r.persistLocked(); err != nil {
return Command{}, fmt.Errorf("persist command: %w", err)
}
return c, nil
}
@@ -129,7 +234,7 @@ const CommandRetention = 30 * time.Minute
// pruneCommands drops resolved commands past CommandRetention. B21: this list
// was append-only, so resolved commands accumulated for the process lifetime
// and every worker poll rescanned the entire history. Callers hold r.mu.
func (r *Registry) pruneCommands(worker string) {
func (r *Registry) pruneCommands(worker string) bool {
cutoff := time.Now().UTC().Add(-CommandRetention)
in := r.commands[worker]
out := in[:0]
@@ -139,10 +244,12 @@ func (r *Registry) pruneCommands(worker string) {
}
}
if len(out) == 0 {
changed := len(in) != 0
delete(r.commands, worker)
return
return changed
}
r.commands[worker] = out
return len(out) != len(in)
}
func (r *Registry) Commands(worker string) ([]Command, error) {
r.mu.Lock()
@@ -151,7 +258,11 @@ func (r *Registry) Commands(worker string) ([]Command, error) {
if _, ok := r.workers[worker]; !ok {
return nil, ErrUnknownWorker
}
r.pruneCommands(worker)
if r.pruneCommands(worker) {
if err := r.persistLocked(); err != nil {
return nil, fmt.Errorf("persist pruned commands: %w", err)
}
}
var out []Command
for _, c := range r.commands[worker] {
if c.Status == "pending" {
@@ -182,6 +293,9 @@ func (r *Registry) CompleteCommand(worker, id, status, message string) error {
}
r.commands[worker][i].Status = status
r.commands[worker][i].Error = message
if err := r.persistLocked(); err != nil {
return fmt.Errorf("persist command resolution: %w", err)
}
return nil
}
}
@@ -214,6 +328,9 @@ func (r *Registry) Register(w Worker, admitToken string) error {
if _, ok := r.cursors[w.ID]; !ok {
r.cursors[w.ID] = 0
}
if err := r.persistLocked(); err != nil {
return fmt.Errorf("persist worker registration: %w", err)
}
return nil
}
func (r *Registry) Authenticate(id, token string) error {
@@ -251,7 +368,7 @@ func (r *Registry) Ack(id string, cursor uint64) error {
r.cursors[id] = cursor
return nil
}
func (r *Registry) Heartbeat(id string) error {
func (r *Registry) Heartbeat(id string, health ...WorkerHealth) error {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
@@ -261,6 +378,9 @@ func (r *Registry) Heartbeat(id string) error {
}
w.LastSeen = time.Now().UTC()
w.Online = true
if len(health) > 0 {
w.Health = health[0]
}
r.workers[id] = w
return nil
}
+87
View File
@@ -1,6 +1,8 @@
package federation
import (
"os"
"path/filepath"
"testing"
"time"
)
@@ -27,6 +29,68 @@ func TestCursorIsMonotonicAndAuthenticationIsRequired(t *testing.T) {
}
}
func TestPendingApprovalSurvivesRegistryRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "federation-state.json")
r := &Registry{StatePath: path}
if err := r.Load(); err != nil {
t.Fatal(err)
}
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
t.Fatal(err)
}
capture, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: "Allow command?"})
if err != nil {
t.Fatal(err)
}
queued, err := r.Queue("w", Command{TaskID: "task", Kind: "grant_approval", PaneID: "pane", CaptureRevision: capture.Revision})
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("federation state permissions = %o, want 0600", info.Mode().Perm())
}
restarted := &Registry{StatePath: path}
if err := restarted.Load(); err != nil {
t.Fatal(err)
}
if err := restarted.Register(Worker{ID: "w", Token: "intruder"}, ""); err != ErrUnauthorized {
t.Fatalf("recovered worker identity was hijackable: %v", err)
}
// A restart does not mark the worker online; it must prove its retained
// identity by registering again before recovered controls become available.
if err := restarted.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
t.Fatal(err)
}
gotCapture, ok := restarted.Capture("w", "task")
if !ok || gotCapture.Revision != capture.Revision || gotCapture.Text != capture.Text {
t.Fatalf("capture after restart = %#v, present=%v", gotCapture, ok)
}
commands, err := restarted.Commands("w")
if err != nil || len(commands) != 1 || commands[0].ID != queued.ID {
t.Fatalf("commands after restart = %#v, err=%v", commands, err)
}
if err := restarted.CompleteCommand("w", queued.ID, "acknowledged", ""); err != nil {
t.Fatal(err)
}
again := &Registry{StatePath: path}
if err := again.Load(); err != nil {
t.Fatal(err)
}
if err := again.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
t.Fatal(err)
}
commands, err = again.Commands("w")
if err != nil || len(commands) != 0 {
t.Fatalf("resolved command recovered as pending: %#v, err=%v", commands, err)
}
}
func TestRegisterRequiresAdmitTokenAndOwnToken(t *testing.T) {
r := &Registry{AdmitToken: "admit-secret"}
if err := r.Register(Worker{ID: "workpc", Token: "secret"}, "wrong"); err != ErrUnauthorized {
@@ -76,6 +140,29 @@ func TestOfflineHookRunsOnceOnTransition(t *testing.T) {
}
}
func TestHeartbeatProjectsWorkerOwnedHealth(t *testing.T) {
r := &Registry{}
if err := r.Register(Worker{ID: "workpc-opencode", Token: "secret"}, ""); err != nil {
t.Fatal(err)
}
checked := time.Now().UTC().Round(0)
errAt := checked.Add(-time.Minute)
if err := r.Heartbeat("workpc-opencode", WorkerHealth{
HerdrStatus: "unreachable", CheckedAt: checked, ActiveTask: "task-1", ActivePane: "pane-1",
LastError: "local herdr: connection refused", ErrorAt: errAt,
}); err != nil {
t.Fatal(err)
}
workers := r.Snapshot()
if len(workers) != 1 {
t.Fatalf("workers=%#v", workers)
}
h := workers[0].Health
if h.HerdrStatus != "unreachable" || h.ActiveTask != "task-1" || h.ActivePane != "pane-1" || h.LastError == "" || !h.CheckedAt.Equal(checked) || !h.ErrorAt.Equal(errAt) {
t.Fatalf("health=%#v", h)
}
}
func TestCaptureRevisionAndCommandQueue(t *testing.T) {
r := &Registry{}
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
+30 -18
View File
@@ -142,7 +142,7 @@ func ClaudeActivity(path string) ([]ToolCall, error) {
// 2. Shell commands: Codex's actual tool surface is a single freeform
// `custom_tool_call` named "exec" whose `input` is a JS snippet calling
// `tools.exec_command({cmd:"...", ...})` — not a flat arguments object.
// codexExecCommand best-effort-extracts the first embedded cmd string.
// codexExecCommands extracts every embedded cmd string in source order.
// Success is read from the paired `custom_tool_call_output`'s text
// blocks: a failed script's output observably starts with "Script
// error:" on this machine's real transcripts (both a JS syntax error and
@@ -173,7 +173,7 @@ func CodexActivity(path string) ([]ToolCall, error) {
Payload payload `json:"payload"`
}
pending := map[string]ToolCall{}
pending := map[string][]ToolCall{}
var calls []ToolCall
s := bufio.NewScanner(f)
s.Buffer(make([]byte, 1<<20), 10<<20)
@@ -188,13 +188,20 @@ func CodexActivity(path string) ([]ToolCall, error) {
calls = append(calls, ToolCall{Name: "apply_patch", Kind: "file", Key: path, Success: e.Payload.Success})
}
case e.Type == "response_item" && e.Payload.Type == "custom_tool_call":
kind, key := "", ""
if cmd := codexExecCommand(e.Payload.Input); cmd != "" {
kind, key = "command", cmd
var pendingCalls []ToolCall
if e.Payload.Name == "exec" {
for _, cmd := range codexExecCommands(e.Payload.Input) {
pendingCalls = append(pendingCalls, ToolCall{Name: e.Payload.Name, Kind: "command", Key: cmd, IsTest: isTestCommand("command", cmd)})
}
}
pending[e.Payload.CallID] = ToolCall{Name: e.Payload.Name, Kind: kind, Key: key, IsTest: isTestCommand(kind, key)}
// Retain a resolved call without an extractable command as activity:
// it is useful for ordering, but deliberately carries no key.
if len(pendingCalls) == 0 {
pendingCalls = []ToolCall{{Name: e.Payload.Name}}
}
pending[e.Payload.CallID] = pendingCalls
case e.Type == "response_item" && e.Payload.Type == "custom_tool_call_output":
if tc, ok := pending[e.Payload.CallID]; ok {
if pendingCalls, ok := pending[e.Payload.CallID]; ok {
failed := false
for _, o := range e.Payload.Output {
if strings.HasPrefix(strings.TrimSpace(o.Text), "Script error:") {
@@ -202,8 +209,10 @@ func CodexActivity(path string) ([]ToolCall, error) {
break
}
}
tc.Success = !failed
calls = append(calls, tc)
for _, tc := range pendingCalls {
tc.Success = !failed
calls = append(calls, tc)
}
delete(pending, e.Payload.CallID)
}
}
@@ -211,18 +220,21 @@ func CodexActivity(path string) ([]ToolCall, error) {
return calls, s.Err()
}
// codexExecCmdRe extracts the first `cmd:"..."` argument out of an "exec"
// custom-tool-call's JS-scripted input. Only the first embedded command in a
// multi-call script is captured — a documented limitation, not an oversight.
// codexExecCmdRe extracts `cmd:"..."` arguments out of an "exec"
// custom-tool-call's JS-scripted input. A single script can invoke several
// commands; their source order is the observable execution order.
var codexExecCmdRe = regexp.MustCompile(`cmd\s*:\s*"((?:[^"\\]|\\.)*)"`)
func codexExecCommand(input string) string {
m := codexExecCmdRe.FindStringSubmatch(input)
if m == nil {
return ""
func codexExecCommands(input string) []string {
matches := codexExecCmdRe.FindAllStringSubmatch(input, -1)
commands := make([]string, 0, len(matches))
for _, m := range matches {
cmd := strings.TrimSpace(strings.NewReplacer(`\"`, `"`, `\n`, "\n", `\t`, "\t", `\\`, `\`).Replace(m[1]))
if cmd != "" {
commands = append(commands, cmd)
}
}
cmd := strings.NewReplacer(`\"`, `"`, `\n`, "\n", `\t`, "\t", `\\`, `\`).Replace(m[1])
return strings.TrimSpace(cmd)
return commands
}
// OpenCodeActivity has no verified source. OpenCodeUsage already only reads
+20
View File
@@ -178,6 +178,26 @@ func TestCodexActivityMarksScriptErrorAsFailure(t *testing.T) {
}
}
func TestCodexActivityPreservesAllCommandsInAScript(t *testing.T) {
path := writeJSONL(t, []string{
codexExecCallLine(t, "c1", `const test = await tools.exec_command({cmd:"go test ./..."}); const commit = await tools.exec_command({cmd:"git commit -am done"}); text(test.output); text(commit.output)`),
codexExecOutputLine(t, "c1", "Script completed"),
})
calls, err := CodexActivity(path)
if err != nil {
t.Fatal(err)
}
if len(calls) != 2 {
t.Fatalf("calls=%+v, want both commands", calls)
}
if calls[0].Key != "go test ./..." || calls[1].Key != "git commit -am done" || !calls[0].Success || !calls[1].Success {
t.Fatalf("calls=%+v, want successful commands in source order", calls)
}
if !DetectMilestone(calls) {
t.Fatalf("want the later successful git commit to be a milestone")
}
}
func TestDetectThrashConsecutiveTestFailures(t *testing.T) {
calls := []ToolCall{
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: false, IsTest: true},
-15
View File
@@ -42,10 +42,6 @@ type PromptLeaser interface {
LeasePrompt(context.Context, string, string, string) (Session, error)
}
type WorktreeCreator interface {
CreateWorktree(context.Context, string, string, string) (string, error)
}
// TurnBoundary is optional so older herdr deployments remain usable. A true
// result means the current harness turn has ended and handoff is safe.
type TurnBoundary interface {
@@ -99,17 +95,6 @@ const HandoffFile = ".orchestra-handoff.json"
// seals the resulting canonical JSON.
const HandoffReportFile = ".orchestra-handoff-report.md"
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
path, err := a.Client.Worktree(ctx, repo, filepath.Join(root, taskID), "orchestra/"+taskID)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("adapter: herdr returned empty worktree path")
}
return path, nil
}
func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session, error) {
return a.LeasePrompt(ctx, task, worktree, defaultTaskPrompt(task))
}
+5 -3
View File
@@ -193,14 +193,16 @@ const (
// after 2+ minutes of polling, with no error surfaced anywhere. A
// legitimate attach has been observed taking "well over a minute", so
// this window is deliberately longer than bootRetryWindow.
agentAttachWindow = 90 * time.Second
agentAttachPoll = 2 * time.Second
claudeTrustObserveWindow = 15 * time.Second
claudeTrustClearWindow = 15 * time.Second
claudeTrustPoll = 500 * time.Millisecond
)
var (
agentAttachWindow = 90 * time.Second
agentAttachPoll = 2 * time.Second
)
type paneStatus struct {
Agent string `json:"agent"`
AgentStatus string `json:"agent_status"`
+92
View File
@@ -7,6 +7,7 @@ import (
"net"
"reflect"
"regexp"
"strings"
"testing"
"time"
)
@@ -82,6 +83,97 @@ func TestStartAgentPassesEmptyHarnessArgs(t *testing.T) {
}
}
func TestStartAgentAttachesTwoSameHarnessSessionsWithDistinctNames(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
starts := make(chan Request, 2)
go func() {
for i := 0; i < 4; i++ {
conn, err := ln.Accept()
if err != nil {
return
}
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
switch req.Method {
case "agent.start":
starts <- req
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
case "pane.get":
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"type":"pane_info","pane":{"agent":"opencode","agent_status":"idle"}}`)})
}
}
_ = conn.Close()
}
}()
c := &Client{Path: ln.Addr().String(), panes: map[string]string{"/one": "w1:p1", "/two": "w2:p1"}, dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
}}
first, err := c.StartAgent(context.Background(), "", "/one", "", "opencode", "first-task")
if err != nil {
t.Fatal(err)
}
second, err := c.StartAgent(context.Background(), "", "/two", "", "opencode", "second-task")
if err != nil {
t.Fatal(err)
}
if first.AgentName == second.AgentName || first.AgentName == "" || second.AgentName == "" {
t.Fatalf("agent names must be distinct and persisted: %+v / %+v", first, second)
}
for _, want := range []string{first.AgentName, second.AgentName} {
req := <-starts
params, _ := json.Marshal(req.Params)
var got struct {
Name string `json:"name"`
}
_ = json.Unmarshal(params, &got)
if got.Name != want {
t.Fatalf("agent.start name = %q, want %q", got.Name, want)
}
}
}
func TestStartAgentRejectsSuccessWithoutAttachment(t *testing.T) {
oldWindow, oldPoll := agentAttachWindow, agentAttachPoll
agentAttachWindow, agentAttachPoll = 25*time.Millisecond, time.Millisecond
t.Cleanup(func() { agentAttachWindow, agentAttachPoll = oldWindow, oldPoll })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) != nil {
return
}
result := json.RawMessage(`{"type":"pane_info","pane":{"agent_status":"unknown"}}`)
if req.Method == "agent.start" {
result = json.RawMessage(`{}`)
}
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: result})
}()
}
}()
c := &Client{Path: ln.Addr().String(), panes: map[string]string{"/worktree": "w1:p1"}, dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
}}
_, err = c.StartAgent(context.Background(), "", "/worktree", "", "opencode", "silent-noop")
if err == nil || !strings.Contains(err.Error(), "no agent attached") {
t.Fatalf("StartAgent error = %v, want explicit missing attachment", err)
}
}
func TestAgentNameIsBoundedAndValid(t *testing.T) {
got := agentName("OpenCode", "TASK With spaces / and symbols !!! 0123456789")
if len(got) > 32 || !regexp.MustCompile(`^[a-z0-9_-]+$`).MatchString(got) {
+26 -24
View File
@@ -170,11 +170,15 @@ type Coordinator struct {
Worktrees Worktrees
Adapters Adapters
StatePath string
mu sync.Mutex
sessions map[string]herdr.Session
loaded bool
healthMu sync.RWMutex
health MonitorHealth
// LocalHerdr, when set, is the coordinator's machine-ownership boundary.
// A coordinator must never operate a pane or checkout owned by another
// machine; federation workers own those operations locally.
LocalHerdr func(string) bool
mu sync.Mutex
sessions map[string]herdr.Session
loaded bool
healthMu sync.RWMutex
health MonitorHealth
// Hard is the occupancy threshold Monitor's periodic rotate() runs
// against, mirrored here so TurnDecision (the synchronous, per-turn
// counterpart driven by the Face-B stop hook) evaluates the same
@@ -300,6 +304,9 @@ func (c *Coordinator) adapterFor(taskID string, session herdr.Session) (herdr.Ad
if id == "" {
id = session.Harness
}
if c.LocalHerdr != nil && !c.LocalHerdr(id) {
return nil, fmt.Errorf("session %s is owned by non-local herdr %s", taskID, id)
}
return c.Adapters.Adapter(id)
}
@@ -875,31 +882,20 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
if err := json.Unmarshal(e.Payload, &p); err != nil || p.HarnessID == "" {
return fmt.Errorf("orchestrator: invalid lease")
}
if c.LocalHerdr != nil && !c.LocalHerdr(p.HarnessID) {
return c.block(t, "remote herdr must be operated by its federation worker")
}
a, err := c.Adapters.Adapter(p.HarnessID)
if err != nil {
return c.block(t, "adapter: "+err.Error())
}
var w string
if creator, ok := a.(herdr.WorktreeCreator); ok {
planner, planned := c.Worktrees.(WorktreeSpec)
if !planned {
return c.block(t, "worktree: repository specification unavailable")
}
repo, root, valid := planner.Spec(t)
if !valid {
return c.block(t, "worktree: repository and root required")
}
w, err = creator.CreateWorktree(ctx, repo, root, t.ID)
} else {
w, err = c.Worktrees.Create(ctx, t)
}
// Worktrees, including immutable TASK.md, are coordinator-local state.
// A remote herdr must be driven by its federation worker instead of being
// asked to create an opaque checkout that this coordinator cannot validate.
w, err := c.Worktrees.Create(ctx, t)
if err != nil {
return c.block(t, "worktree: "+err.Error())
}
// Best-effort: TASK.md only exists for worktrees this process can read
// locally (the GitWorktrees path). A herdr-hosted worktree on a remote
// machine (WorktreeCreator path) is the same cross-host gap named in
// AUDIT.md's federation-fork section — not solved here.
taskFileSHA, _ := continuity.TaskFileHash(w)
prompt := taskLaunchPrompt(t)
var s herdr.Session
@@ -983,7 +979,13 @@ func (c *Coordinator) rememberSession(taskID string, s herdr.Session) error {
}
func (c *Coordinator) block(t domain.Task, reason string) error {
b, _ := json.Marshal(map[string]string{"blocker": reason})
p := map[string]string{"blocker": reason, "pane_state": "unknown"}
if s, ok := c.Session(t.ID); ok {
p["pane_id"] = s.PaneID
p["harness_id"] = s.HerdrID
p["pane_state"] = "open"
}
b, _ := json.Marshal(p)
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
}
+120
View File
@@ -21,9 +21,17 @@ type fakeAdapter struct {
boundary bool
ref string
releases int
leases int
approval struct {
called bool
grant bool
session herdr.Session
capture string
}
}
func (a *fakeAdapter) Lease(_ context.Context, _ string, worktree string) (herdr.Session, error) {
a.leases++
return herdr.Session{Harness: "h1", PaneID: "pane-1", Worktree: worktree}, nil
}
func (a *fakeAdapter) Bootstrap(context.Context, herdr.Session, string) error { return nil }
@@ -36,6 +44,13 @@ func (a *fakeAdapter) Occupancy(herdr.Session) (float64, error) { return a.occu
func (a *fakeAdapter) AtTurnBoundary(context.Context, herdr.Session) (bool, error) {
return a.boundary, nil
}
func (a *fakeAdapter) RespondApproval(_ context.Context, s herdr.Session, grant bool, capture string) error {
a.approval.called = true
a.approval.grant = grant
a.approval.session = s
a.approval.capture = capture
return nil
}
type worktrees struct{ path string }
@@ -45,6 +60,12 @@ type adapters struct{ a herdr.Adapter }
func (a adapters) Adapter(string) (herdr.Adapter, error) { return a.a, nil }
type promptFailureAdapter struct{ fakeAdapter }
func (a *promptFailureAdapter) LeasePrompt(_ context.Context, _ string, worktree, _ string) (herdr.Session, error) {
return herdr.Session{Harness: "h1", PaneID: "pane-created-before-timeout", Worktree: worktree}, errors.New("prompt delivery uncertain")
}
func run(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
@@ -53,6 +74,105 @@ func run(t *testing.T, dir string, args ...string) {
}
}
func TestPromptFailureRetainsLivePaneForBlockedTaskAcrossRestart(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "blocked-live-pane", Surface: string(authz.System), Payload: mustJSON(map[string]any{
"source": "qa", "external_id": "prompt-timeout", "project": "p",
})}); err != nil {
t.Fatal(err)
}
task, ok := s.Task("blocked-live-pane")
if !ok {
t.Fatal("created task missing")
}
lease, err := s.Lease(task.ID, "h1", time.Minute)
if err != nil {
t.Fatal(err)
}
statePath := t.TempDir() + "/sessions.json"
a := &promptFailureAdapter{}
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: t.TempDir()}, Adapters: adapters{a}, StatePath: statePath}
if err := c.Start(context.Background(), lease); err != nil {
t.Fatal(err)
}
if got, ok := s.Task(task.ID); !ok || got.State != domain.StateBlocked {
t.Fatalf("task state = %+v, want blocked", got)
}
if session, ok := c.Session(task.ID); !ok || session.PaneID != "pane-created-before-timeout" || session.HerdrID != "h1" {
t.Fatalf("retained session = %+v, present=%v", session, ok)
}
// A fresh coordinator must retain the mapping for a blocked task rather
// than treating it as an orphan after restart.
restarted := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: t.TempDir()}, Adapters: adapters{a}, StatePath: statePath}
if err := restarted.Reconcile(context.Background()); err != nil {
t.Fatal(err)
}
if session, ok := restarted.Session(task.ID); !ok || session.PaneID != "pane-created-before-timeout" {
t.Fatalf("restarted session = %+v, present=%v", session, ok)
}
}
func TestRespondApprovalUsesOwningSessionAndPreservesCaptureBinding(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "approval-task", Surface: string(authz.System), Payload: mustJSON(map[string]any{
"source": "qa", "external_id": "approval", "project": "p",
})}); err != nil {
t.Fatal(err)
}
lease, err := s.Lease("approval-task", "herdr-1", time.Minute)
if err != nil {
t.Fatal(err)
}
a := &fakeAdapter{}
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: t.TempDir()}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"}
if err := c.Start(context.Background(), lease); err != nil {
t.Fatal(err)
}
const capture = "Approval required\n$ go test ./...\n[y/n]"
if err := c.RespondApproval(context.Background(), "approval-task", true, capture); err != nil {
t.Fatal(err)
}
if !a.approval.called || !a.approval.grant || a.approval.capture != capture {
t.Fatalf("approval invocation = %#v", a.approval)
}
if a.approval.session.HerdrID != "herdr-1" || a.approval.session.PaneID == "" {
t.Fatalf("approval used wrong session: %#v", a.approval.session)
}
}
func TestCoordinatorRefusesRemoteHerdrOperations(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "remote", Surface: string(authz.System), Payload: mustJSON(map[string]any{
"source": "qa", "external_id": "remote", "project": "p",
})}); err != nil {
t.Fatal(err)
}
lease, err := s.Lease("remote", "remote", time.Minute)
if err != nil {
t.Fatal(err)
}
a := &fakeAdapter{}
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: t.TempDir()}, Adapters: adapters{a}, LocalHerdr: func(id string) bool { return id == "local" }}
if err := c.Start(context.Background(), lease); err != nil {
t.Fatal(err)
}
if a.leases != 0 {
t.Fatal("remote adapter was started by coordinator")
}
if task, ok := s.Task("remote"); !ok || task.State != domain.StateBlocked {
t.Fatalf("remote task state = %#v, present=%v; want blocked", task, ok)
}
}
// TestRotationEmitsValidReleaseWithAnchorSHA guards the highest-priority spec
// defect noted in progress.md: automated rotation must emit a TaskReleased
// event that satisfies domain.ValidatePayload (handoff_ref + anchor_sha), not
+8
View File
@@ -174,6 +174,14 @@ func putID[T any](m map[string]T, id, kind string) error {
func (r Registry) Project(id string) (Project, bool) { p, ok := r.projects[id]; return p, ok }
func (r Registry) Machine(id string) (Machine, bool) { m, ok := r.machines[id]; return m, ok }
func (r Registry) Herdr(id string) (Herdr, bool) { h, ok := r.herdrs[id]; return h, ok }
func (r Registry) Machines() []Machine {
out := make([]Machine, 0, len(r.machines))
for _, m := range r.machines {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (r Registry) Herdrs() []Herdr {
out := make([]Herdr, 0, len(r.herdrs))
for _, h := range r.herdrs {
+8
View File
@@ -155,6 +155,14 @@ func (s *Store) apply(e domain.Event) error {
case "TaskBlocked":
t.State = domain.StateBlocked
t.Lease = nil
t.Blocker, _ = p["blocker"].(string)
t.BlockedAt = e.At
t.LastPaneID, _ = p["pane_id"].(string)
t.LastHarness, _ = p["harness_id"].(string)
t.PaneState, _ = p["pane_state"].(string)
if t.PaneState == "" {
t.PaneState = "unknown"
}
case "TaskAmended":
if v, ok := p["title"].(string); ok {
t.Title = v
+17 -2
View File
@@ -136,7 +136,9 @@ func (s Server) detail(ctx context.Context, id string) (TaskDetail, error) {
if !ok {
return TaskDetail{}, domain.ErrNotFound
}
d := TaskDetail{Task: t, Actions: actions(t)}
// Keep collection fields as JSON arrays for browser clients, including
// older task records that genuinely have no retained events.
d := TaskDetail{Task: t, Events: []domain.Event{}, Actions: actions(t)}
for _, e := range s.Store.Events(0) {
if e.TaskID != id {
continue
@@ -165,9 +167,14 @@ func (s Server) detail(ctx context.Context, id string) (TaskDetail, error) {
session.Blocker = "capture unavailable: " + err.Error()
}
d.Session = session
} else if t.State == domain.StateBlocked {
// A blocked task has no active lease by definition, but must retain its
// last observed pane evidence instead of rendering an unexplained void.
d.Session = &Session{PaneID: t.LastPaneID, HarnessID: t.LastHarness, AgentStatus: t.PaneState, Blocker: t.Blocker}
}
return d, nil
}
// captureRevision identifies *what the operator saw*, not when they saw it.
// B20: this was UnixNano, so it changed on every read and said nothing about
// whether the pane had changed. A content hash changes if and only if the
@@ -206,7 +213,15 @@ func actions(t domain.Task) []Action {
return []Action{{ID: "handoff", Enabled: active, Reason: "requires a live leased session"}, {ID: "release", Enabled: active, Needs: []string{"reason or handoff_ref"}}, {ID: "block", Enabled: active, Needs: []string{"blocker"}}, {ID: "complete", Enabled: active, Needs: []string{"report_ref", "receipt"}}}
}
func (s Server) Overview(ctx context.Context) Overview {
out := Overview{Tasks: s.Store.Tasks(), UpdatedAt: time.Now().UTC()}
// JSON null is not an empty collection to browser clients. In particular,
// the shell renders the number of active sessions before any page-level
// loading state, so a nil Sessions slice made an otherwise healthy empty
// worker pool crash the entire SPA on `sessions.length`.
tasks := s.Store.Tasks()
if tasks == nil {
tasks = []domain.Task{}
}
out := Overview{Tasks: tasks, Workers: []federation.Worker{}, Sessions: []Session{}, UpdatedAt: time.Now().UTC()}
if s.Workers != nil {
out.Workers = s.Workers.Snapshot()
}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}.actions{display:grid;gap:.75rem;max-width:42rem}.actions form{display:flex;flex-wrap:wrap;align-items:center}.actions textarea{flex:1;min-width:16rem}.approval-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#000a;display:grid;place-items:center;padding:1rem;z-index:10}.approval{max-width:50rem;max-height:90vh;overflow:auto}details textarea{width:100%}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<script type="module" crossorigin src="/assets/index-hnZ7xNV9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hcvlnkHy.css">
<script type="module" crossorigin src="/assets/index-Dqz7-YV3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzSA27i7.css">
<div id="root"></div>
+5 -2
View File
@@ -1,5 +1,8 @@
import type { Detail, Overview } from './types'
async function request<T>(path:string, init?:RequestInit):Promise<T>{const r=await fetch(path,{headers:{'Content-Type':'application/json',...init?.headers},...init});if(!r.ok)throw new Error(await r.text());return r.json() as Promise<T>}
function sessionExpired(r:Response){if(r.status===401)window.dispatchEvent(new Event('orchestra:unauthorized'))}
async function request<T>(path:string, init?:RequestInit):Promise<T>{const r=await fetch(path,{credentials:'same-origin',headers:{'Content-Type':'application/json',...init?.headers},...init});if(!r.ok){sessionExpired(r);throw new Error(await r.text())}return r.json() as Promise<T>}
async function text(path:string){const r=await fetch(path);if(!r.ok)throw new Error(await r.text());return r.text()}
async function upload(body:string){const r=await fetch('/v1/artifacts',{method:'POST',headers:{'Content-Type':'text/markdown'},body});if(!r.ok)throw new Error(await r.text());return (await r.json() as {ref:string}).ref}
export const api={overview:()=>request<Overview>('/v1/ui/overview'),detail:(id:string)=>request<Detail>('/v1/ui/tasks/'+id),artifact:(ref:string)=>text('/v1/ui/artifacts/'+ref),upload,create:(body:unknown)=>request('/v1/ui/tasks',{method:'POST',body:JSON.stringify(body)}),action:(id:string,action:string,body={})=>request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`,{method:'POST',body:JSON.stringify(body)})}
async function login(token:string){const r=await fetch('/v1/ui/session',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})});if(!r.ok)throw new Error(await r.text())}
async function logout(){const r=await fetch('/v1/ui/session',{method:'DELETE',credentials:'same-origin'});if(!r.ok)throw new Error(await r.text())}
export const api={login,logout,overview:()=>request<Overview>('/v1/ui/overview'),detail:(id:string)=>request<Detail>('/v1/ui/tasks/'+id),artifact:(ref:string)=>text('/v1/ui/artifacts/'+ref),upload,create:(body:unknown)=>request('/v1/ui/tasks',{method:'POST',body:JSON.stringify(body)}),action:(id:string,action:string,body={})=>request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`,{method:'POST',body:JSON.stringify(body)})}
+4 -2
View File
@@ -1,8 +1,10 @@
export type TaskState='queued'|'leased'|'blocked'|'completed'|'failed'
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string }
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string; blocker?:string; blocked_at?:string; last_pane_id?:string; last_harness_id?:string; pane_state?:string }
export interface PendingApproval { kind:'shell'|'opencode_once'|'edit'|'unknown'; summary:string; command?:string; diff?:string; pane_id:string; capture_revision:number; detected_at:string }
export interface Capture { task_id:string; source:string; text:string; revision:number; at:string; truncated:boolean }
export interface Session { pane_id?:string; harness_id?:string; agent_status?:string; blocker?:string; lease_until?:string; capture?:Capture; pending_approval?:PendingApproval }
export interface Action { id:string; enabled:boolean; reason?:string; needs?:string[] }
export interface Detail { task:Task; events:Array<{id:string;type:string;at:string;payload:unknown}>; session?:Session; handoff_ref?:string; report_ref?:string; actions:Action[] }
export interface Overview { tasks:Task[]; workers:Array<{id:string;capacity:number;last_seen:string;online:boolean}>; sessions:Session[]; updated_at:string }
export interface WorkerHealth { herdr_status:'reachable'|'unreachable'|'unknown'; checked_at?:string; active_task_id?:string; active_pane_id?:string; last_error?:string; error_at?:string }
export interface Worker { id:string; capacity:number; last_seen:string; online:boolean; health:WorkerHealth }
export interface Overview { tasks:Task[]; workers:Worker[]; sessions:Session[]; updated_at:string }
+31 -13
View File
@@ -3,21 +3,39 @@ import {createRoot} from 'react-dom/client'
import {BrowserRouter,Link,NavLink,Route,Routes,useLocation,useNavigate,useParams} from 'react-router-dom'
import {QueryClient,QueryClientProvider,useMutation,useQuery,useQueryClient} from '@tanstack/react-query'
import {api} from './api/client'
import type {Action,PendingApproval,Task,TaskState} from './api/types'
import type {Action,Capture,Detail,Overview,PendingApproval,Task,TaskState} from './api/types'
import './style.css'
const client=new QueryClient(),states:TaskState[]=['queued','leased','blocked','completed','failed']
const client=new QueryClient()
const states:TaskState[]=['queued','leased','blocked','completed','failed']
const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocked:'Blocked',completed:'Complete',failed:'Failed'}
const actionLabel:Record<string,string>={handoff:'Request handoff',complete:'Complete task',block:'Mark blocked'}
const actionLabel:Record<string,string>={handoff:'Request handoff',release:'Release task',complete:'Complete task',block:'Mark blocked'}
const date=(value?:string)=>value?new Date(value).toLocaleString():'—'
function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=useQueryClient(),input=useRef<HTMLInputElement>(null),[term,setTerm]=useState('');useEffect(()=>{input.current?.focus()},[]);const choose=(id:string)=>{if(id==='board')nav('/');if(id==='workers')nav('/workers');if(id==='new'){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(id==='refresh')qc.invalidateQueries();close()};const commands=[['board','Go to dispatch board','G then B'],['new','Create a new task','N'],['workers','View worker pool','G then W'],['refresh','Refresh live data','R']] as const;const matches=commands.filter(c=>c[1].toLowerCase().includes(term.toLowerCase()));return <div className="palette-backdrop" onMouseDown={close}><section className="palette" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e=>e.stopPropagation()}><input ref={input} value={term} onChange={e=>setTerm(e.target.value)} onKeyDown={e=>{if(e.key==='Escape')close();if(e.key==='Enter'&&matches[0])choose(matches[0][0])}} placeholder="Find a command…" aria-label="Find a command"/><div className="palette-list">{matches.map(([id,name,key])=><button key={id} className="palette-command" onClick={()=>choose(id)}><span>{name}</span><kbd>{key}</kbd></button>)}{!matches.length&&<p className="palette-empty">No matching command.</p>}</div><p className="palette-foot"><kbd></kbd> run <kbd>esc</kbd> close</p></section></div>}
function Shell({children}:{children:React.ReactNode}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!(e.target instanceof HTMLInputElement)&&!(e.target instanceof HTMLTextAreaElement)){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd> K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
function Board({tasks}:{tasks:Task[]}){return <div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><Link className="card" to={'/tasks/'+t.id} key={t.id}><b>{t.title||t.id}</b><small><span>{t.project}</span><span className="machine">{t.id.slice(-5)}</span></small></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
function Overview(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false);useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(d.sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id));return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Blocked or failed</span><b>{attention}</b></div><div><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><h2>Task flow</h2><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks}/>{createOpen&&<Create close={()=>setCreateOpen(false)}/>}</main>}
function Create({close}:{close:()=>void}){const qc=useQueryClient(),nav=useNavigate(),first=useRef<HTMLInputElement>(null),[title,setTitle]=useState(''),[description,setDescription]=useState(''),[project,setProject]=useState('default'),[capability,setCapability]=useState(''),[advanced,setAdvanced]=useState(''),[formError,setFormError]=useState('');useEffect(()=>{first.current?.focus()},[]);const m=useMutation({mutationFn:()=>{let extra:Record<string,unknown>={};if(advanced.trim()){try{extra=JSON.parse(advanced)}catch{return Promise.reject(new Error('Additional fields must be valid JSON.'))}}return api.create({...extra,source:'web',external_id:crypto.randomUUID(),project,title,description,capability:capability.split(',').map(x=>x.trim()).filter(Boolean)})},onSuccess:(e:any)=>{qc.invalidateQueries({queryKey:['overview']});nav('/tasks/'+e.task_id)}});const submit=(e:React.FormEvent)=>{e.preventDefault();setFormError('');if(title.trim().length<3){setFormError('Give the task a title of at least three characters.');return}if(description.trim().length<10){setFormError('Add enough immutable instructions for an agent to act safely.');return}m.mutate()};return <div className="modal-backdrop" onMouseDown={close}><section className="create modal" role="dialog" aria-modal="true" aria-labelledby="new-task-title" onMouseDown={e=>e.stopPropagation()}><div className="modal-heading"><div><p className="eyebrow">Dispatch new work</p><h2 id="new-task-title">New task</h2></div><button className="icon-button" onClick={close} aria-label="Close task creation">×</button></div><p>Give the harness a clear objective and immutable operating instructions.</p><form onSubmit={submit} noValidate><label>Task title<input ref={first} required value={title} onChange={e=>setTitle(e.target.value)} placeholder="e.g. Add the health endpoint" autoComplete="off"/></label><label>Project<input required value={project} onChange={e=>setProject(e.target.value)} placeholder="Project name" autoComplete="off"/></label><label>Capabilities <small>Optional, comma-separated</small><input value={capability} onChange={e=>setCapability(e.target.value)} placeholder="go, docker" autoComplete="off"/></label><label>Immutable instructions<textarea required value={description} onChange={e=>setDescription(e.target.value)} placeholder="Describe the outcome, constraints, and evidence required."/></label><details><summary>Additional accepted task fields</summary><textarea aria-label="Additional task fields JSON" placeholder={'{"parent":"…","inherent_priority":1,"due":"2026-07-30T12:00:00Z"}'} value={advanced} onChange={e=>setAdvanced(e.target.value)}/></details><div className="modal-actions"><button type="button" className="quiet-button" onClick={close}>Cancel</button><button disabled={m.isPending}>{m.isPending?'Creating task…':'Create task'}</button></div>{(formError||m.error)&&<span className="error" role="alert">{formError||String(m.error)}</span>}</form></section></div>}
function Approval({approval,onAction,pending}:{approval:PendingApproval;onAction:(a:string)=>void;pending:boolean}){const ref=useRef<HTMLDivElement>(null),grant=approval.kind!=='unknown',deny=approval.kind==='shell';useEffect(()=>{const node=ref.current;if(!node)return;node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();const trap=(e:KeyboardEvent)=>{if(e.key==='Escape')return; if(e.key!=='Tab')return;const focus=[...node.querySelectorAll<HTMLElement>('button:not(:disabled)')],i=focus.indexOf(document.activeElement as HTMLElement);if(e.shiftKey&&i<=0){e.preventDefault();focus.at(-1)?.focus()}else if(!e.shiftKey&&i===focus.length-1){e.preventDefault();focus[0].focus()}};node.addEventListener('keydown',trap);return()=>node.removeEventListener('keydown',trap)},[]);return <div className="approval-backdrop"><section ref={ref} className="approval" role="dialog" aria-modal="true" aria-labelledby="approval-title"><p className="eyebrow">Harness gate</p><h2 id="approval-title">Permission required</h2><p>{approval.summary}</p><pre>{approval.command||approval.diff||'The harness prompt could not be parsed safely.'}</pre><small>Pane {approval.pane_id} · capture revision {approval.capture_revision} · detected {new Date(approval.detected_at).toLocaleString()}</small>{approval.kind==='opencode_once'&&<p>Approving sends Enter to OpenCodes explicitly displayed <b>Allow once</b> selection. Reject is unavailable because the selected position cannot be verified from the capture.</p>}{!grant&&<p role="alert">This prompt cannot be safely executed because its exact confirmation is unknown.</p>}<div><button disabled={!grant||pending} onClick={()=>onAction('grant_approval')}>{approval.kind==='opencode_once'?'Allow once':'Approve'}</button><button disabled={!deny||pending} title={deny?'':'This selector does not expose a verifiable reject position.'} onClick={()=>onAction('deny_approval')}>Reject</button></div></section></div>}
function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,body?:object)=>void;pending:boolean}){const [value,setValue]=useState('');if(action.id==='handoff')return <button disabled={!action.enabled||pending} title={action.reason} onClick={()=>mutate('handoff')}>Request handoff</button>;if(action.id==='complete')return <form onSubmit={async e=>{e.preventDefault();try{const ref=await api.upload(value);mutate('complete',{report_ref:ref,receipt:{source:'web',completed_at:new Date().toISOString()}})}catch(err){alert(String(err))}}}><label className="sr-only" htmlFor="completion-report">Completion report</label><textarea id="completion-report" required placeholder="Completion report, stored as evidence" value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending}>Complete task</button></form>;const field=action.id==='block'?'blocker':'reason';return <form onSubmit={e=>{e.preventDefault();mutate(action.id,{[field]:value})}}><label className="sr-only" htmlFor={'action-'+action.id}>{field}</label><input id={'action-'+action.id} required placeholder={field==='blocker'?'What is blocking progress?':'Reason'} value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending} title={action.reason||action.needs?.join(',')}>{actionLabel[action.id]||action.id}</button></form>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient();const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:()=>qc.invalidateQueries({queryKey:['task',taskID]})});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!;return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><div className={'task-state state-'+d.task.state}>{label[d.task.state]}</div><h2>Live capture</h2><pre>{d.session?.capture?.text||d.session?.blocker||'No live capture is available for this task.'}</pre><h2 style={{marginTop:20}}>Actions</h2><div className="actions">{d.actions.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div>{m.error&&<p className="error" role="alert">{String(m.error)}</p>}</div><aside className="surface"><h2>Session</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>State</span><span className="machine">{d.task.state}</span></li><li><span>Harness</span><span className="machine">{d.session?.harness_id||''}</span></li><li><span>Pane</span><span className="machine">{d.session?.pane_id||''}</span></li><li><span>Lease ends</span><span className="machine">{d.session?.lease_until?new Date(d.session.lease_until).toLocaleString():'—'}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface" style={{marginTop:20}}><h2>Timeline</h2><ol className="timeline">{d.events.map(e=><li key={e.id}><span>{e.type}</span><time>{new Date(e.at).toLocaleString()}</time></li>)}</ol></div></main>}
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const workers=q.data!.workers;return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<table><thead><tr><th>Worker</th><th>State</th><th>Capacity</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}>{w.online?'online':'offline'}</td><td>{w.capacity}</td><td>{new Date(w.last_seen).toLocaleString()}</td></tr>)}</tbody></table>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}
function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=useQueryClient(),input=useRef<HTMLInputElement>(null),[term,setTerm]=useState('');useEffect(()=>{input.current?.focus()},[]);const choose=(id:string)=>{if(id==='board')nav('/');if(id==='workers')nav('/workers');if(id==='new'){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(id==='refresh')qc.invalidateQueries();close()};const commands=[['board','Go to dispatch board','G B'],['new','Create a new task','N'],['workers','View worker pool','G W'],['refresh','Refresh live data','R']] as const;const matches=commands.filter(c=>c[1].toLowerCase().includes(term.toLowerCase()));return <div className="palette-backdrop" onMouseDown={close}><section className="palette" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e=>e.stopPropagation()}><input ref={input} value={term} onChange={e=>setTerm(e.target.value)} onKeyDown={e=>{if(e.key==='Escape')close();if(e.key==='Enter'&&matches[0])choose(matches[0][0])}} placeholder="Find a command…" aria-label="Find a command"/><div className="palette-list">{matches.map(([id,name,key])=><button key={id} className="palette-command" onClick={()=>choose(id)}><span>{name}</span><kbd>{key}</kbd></button>)}{!matches.length&&<p className="palette-empty">No matching command.</p>}</div><p className="palette-foot"><kbd></kbd> run <kbd>esc</kbd> close</p></section></div>}
function Shell({children,onLogout}:{children:React.ReactNode;onLogout:()=>void}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false),[accountOpen,setAccountOpen]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions?.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{const editable=e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!editable){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(e.key==='r'&&!e.metaKey&&!e.ctrlKey&&!editable)overview.refetch()};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav,overview]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd> K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div><div className="account"><button className="account-button" onClick={()=>setAccountOpen(v=>!v)} aria-expanded={accountOpen}>Signed in <span className="status-dot"/></button>{accountOpen&&<div className="account-menu"><span>Browser session active</span><button onClick={onLogout}>Sign out</button></div>}</div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked')return task.blocker||'Unknown — legacy record has no retained blocker or pane evidence';if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
function Board({tasks,overview}:{tasks:Task[];overview:Overview}){return <div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><Link className="card" to={'/tasks/'+t.id} key={t.id}><b>{t.title||t.id}</b><small><span>{t.project}</span><span className="machine">{t.id.slice(-5)}</span></small><em>{taskExplanation(t,overview)}</em></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
function OverviewPage(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false);useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,sessions=d.sessions??[],active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id));return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Needs attention</span><b>{attention}</b></div><div className={approvals.size?'attention':''}><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><div><h2>Task flow</h2><p>Each card states the current reason it is waiting or running.</p></div><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks} overview={d}/>{createOpen&&<Create overview={d} close={()=>setCreateOpen(false)}/>}</main>}
function Create({overview,close}:{overview:Overview;close:()=>void}){const qc=useQueryClient(),nav=useNavigate(),first=useRef<HTMLInputElement>(null),[title,setTitle]=useState(''),[description,setDescription]=useState(''),[project,setProject]=useState(''),[capability,setCapability]=useState(''),[advanced,setAdvanced]=useState(''),[formError,setFormError]=useState('');const projects=[...new Set(overview.tasks.map(t=>t.project).filter(Boolean))];const online=overview.workers.filter(w=>w.online);useEffect(()=>{first.current?.focus()},[]);const m=useMutation({mutationFn:()=>{let extra:Record<string,unknown>={};if(advanced.trim()){try{extra=JSON.parse(advanced)}catch{return Promise.reject(new Error('Additional fields must be valid JSON.'))}}return api.create({...extra,source:'web',external_id:crypto.randomUUID(),project,title,description,capability:capability.split(',').map(x=>x.trim()).filter(Boolean)})},onSuccess:(e:any)=>{qc.invalidateQueries({queryKey:['overview']});nav('/tasks/'+e.task_id)}});const submit=(e:React.FormEvent)=>{e.preventDefault();setFormError('');if(title.trim().length<3){setFormError('Give the task a title of at least three characters.');return}if(!project.trim()){setFormError('Choose an existing project or enter its exact project ID.');return}if(description.trim().length<10){setFormError('Add enough immutable instructions for an agent to act safely.');return}m.mutate()};return <div className="modal-backdrop" onMouseDown={close}><section className="create modal" role="dialog" aria-modal="true" aria-labelledby="new-task-title" onMouseDown={e=>e.stopPropagation()}><div className="modal-heading"><div><p className="eyebrow">Dispatch new work</p><h2 id="new-task-title">New task</h2></div><button className="icon-button" onClick={close} aria-label="Close task creation">×</button></div><p>Give the harness a clear outcome and immutable operating instructions.</p><div className={'eligibility '+(!online.length?'warning':'')}><b>{online.length?`${online.length} worker${online.length===1?'':'s'} currently reachable`:'No workers are currently reachable'}</b><span>{online.length?'Orchestra will choose an eligible worker when the task is leased.':'This task will remain queued until a matching worker reconnects.'}</span></div><form onSubmit={submit} noValidate><label>Task title<input ref={first} required value={title} onChange={e=>setTitle(e.target.value)} placeholder="e.g. Add the health endpoint" autoComplete="off"/></label><label>Project <small>Existing projects are suggested; project IDs remain explicit.</small><input required list="projects" value={project} onChange={e=>setProject(e.target.value)} placeholder="Choose or enter a project" autoComplete="off"/><datalist id="projects">{projects.map(p=><option value={p} key={p}/>)}</datalist></label><label>Capabilities <small>Optional, comma-separated. Only use registered capability names.</small><input value={capability} onChange={e=>setCapability(e.target.value)} placeholder="go, docker" autoComplete="off"/></label><label>Immutable instructions<textarea required value={description} onChange={e=>setDescription(e.target.value)} placeholder="Describe the outcome, constraints, and evidence required."/></label><details><summary>Additional accepted task fields</summary><textarea aria-label="Additional task fields JSON" placeholder={'{"parent":"…","inherent_priority":1,"due":"2026-07-30T12:00:00Z"}'} value={advanced} onChange={e=>setAdvanced(e.target.value)}/></details><div className="modal-actions"><button type="button" className="quiet-button" onClick={close}>Cancel</button><button disabled={m.isPending}>{m.isPending?'Creating task…':'Create task'}</button></div>{(formError||m.error)&&<span className="error" role="alert">{formError||String(m.error)}</span>}</form></section></div>}
function Approval({approval,onAction,pending,notice}:{approval:PendingApproval;onAction:(a:string)=>void;pending:boolean;notice?:string}){const ref=useRef<HTMLDivElement>(null),grant=approval.kind!=='unknown',deny=approval.kind==='shell';useEffect(()=>{const node=ref.current;if(!node)return;node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();const trap=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const focus=[...node.querySelectorAll<HTMLElement>('button:not(:disabled)')],i=focus.indexOf(document.activeElement as HTMLElement);if(e.shiftKey&&i<=0){e.preventDefault();focus.at(-1)?.focus()}else if(!e.shiftKey&&i===focus.length-1){e.preventDefault();focus[0].focus()}};node.addEventListener('keydown',trap);return()=>node.removeEventListener('keydown',trap)},[]);const input=approval.command||approval.diff||'The harness prompt could not be parsed safely.';return <div className="approval-backdrop"><section ref={ref} className="approval" role="dialog" aria-modal="true" aria-labelledby="approval-title"><p className="eyebrow">Harness gate</p><h2 id="approval-title">{approval.summary}</h2><div className="approval-summary"><span>Pane <b>{approval.pane_id}</b></span><span>Seen <b>{date(approval.detected_at)}</b></span><span>Revision <b>{approval.capture_revision}</b></span></div><p>The exact input below is the permission request currently shown by the harness.</p><pre>{input}</pre>{notice&&<p className="notice" role="status">{notice}</p>}{approval.kind==='opencode_once'&&<p>Approving sends Enter to OpenCodes displayed <b>Allow once</b> selection. Reject is unavailable because the selected position cannot be verified from the capture.</p>}{!grant&&<p role="alert">This prompt cannot be safely executed because its exact confirmation is unknown.</p>}<div><button disabled={!grant||pending} onClick={()=>onAction('grant_approval')}>{pending?'Sending…':approval.kind==='opencode_once'?'Allow once':'Approve'}</button><button disabled={!deny||pending} title={deny?'':'This selector does not expose a verifiable reject position.'} onClick={()=>onAction('deny_approval')}>Reject</button></div></section></div>}
const actionHelp:Record<string,string>={handoff:'Ask the active harness to prepare a safe handoff. The task stays leased until the handoff is accepted.',release:'Release the current lease. The task may become eligible for another worker after routing.',block:'Stop normal routing and record a concrete blocker for the next operator.',complete:'Complete the task with a durable report. This is a terminal action.'}
function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,body?:object)=>void;pending:boolean}){const [value,setValue]=useState(''),[confirm,setConfirm]=useState(false);const submit=(actionID:string,body?:object)=>{if(['release','block','complete'].includes(actionID)&&!confirm){setConfirm(true);return}mutate(actionID,body)};if(action.id==='handoff')return <div className="action-row"><div><b>{actionLabel[action.id]}</b><span>{actionHelp[action.id]}</span></div><button disabled={!action.enabled||pending} title={action.reason} onClick={()=>mutate('handoff')}>Request handoff</button></div>;if(action.id==='complete')return <form className="action-form" onSubmit={async e=>{e.preventDefault();try{const ref=await api.upload(value);submit('complete',{report_ref:ref,receipt:{source:'web',completed_at:new Date().toISOString()}})}catch(err){alert(String(err))}}}><div><b>Complete task</b><span>{confirm?'Confirming will terminally complete this task. Submit again to continue.':actionHelp.complete}</span></div><textarea required placeholder="Completion report, stored as evidence" value={value} onChange={e=>{setValue(e.target.value);setConfirm(false)}}/><button disabled={!action.enabled||pending}>{confirm?'Confirm completion':'Complete task'}</button></form>;const field=action.id==='block'?'blocker':'reason';return <form className="action-form compact" onSubmit={e=>{e.preventDefault();submit(action.id,{[field]:value})}}><div><b>{actionLabel[action.id]||action.id}</b><span>{confirm?`Confirming will ${action.id==='release'?'release this lease':'block this task'}. Submit again to continue.`:actionHelp[action.id]||action.reason}</span></div><input required placeholder={field==='blocker'?'What is blocking progress?':'Reason for release'} value={value} onChange={e=>{setValue(e.target.value);setConfirm(false)}}/><button disabled={!action.enabled||pending} title={action.reason||action.needs?.join(',')}>{confirm?'Confirm':'Continue'}</button></form>}
function CaptureView({capture,blocker}:{capture?:Capture;blocker?:string}){const [expanded,setExpanded]=useState(false),[filter,setFilter]=useState('');if(!capture)return <div className="capture-empty"><b>Live capture unavailable</b><span>{blocker||'The session has not published a recent pane capture.'}</span></div>;const lines=capture.text.split('\n'),visible=filter?lines.filter(l=>l.toLowerCase().includes(filter.toLowerCase())):lines;const preview=expanded?visible:visible.slice(-18);return <section className="capture"><div className="capture-head"><div><h2>Live capture</h2><span>{capture.source} · updated {date(capture.at)} · {lines.length} lines{capture.truncated?' · truncated':''}</span></div><button className="quiet-button" onClick={()=>setExpanded(v=>!v)}>{expanded?'Show recent':'Expand'}</button></div><input className="capture-filter" value={filter} onChange={e=>setFilter(e.target.value)} placeholder="Filter visible capture" aria-label="Filter live capture"/><pre>{preview.join('\n')||'No lines match this filter.'}</pre>{!expanded&&visible.length>preview.length&&<p className="capture-more">Showing the most recent {preview.length} matching lines.</p>}</section>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient(),[notice,setNotice]=useState('');const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:(result:any,vars)=>{setNotice(vars.action.includes('approval')?'Approval request has been queued; waiting for the worker acknowledgement.':'Action recorded.');qc.invalidateQueries({queryKey:['task',taskID]});qc.invalidateQueries({queryKey:['overview']})}});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!,events=d.events??[];return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div><div className={'task-banner state-'+d.task.state}><div><span className="task-state">{label[d.task.state]}</span><p>{taskDetailExplanation(d)}</p></div>{d.session?.lease_until&&<span>Lease ends <b>{date(d.session.lease_until)}</b></span>}</div>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} notice={notice} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><CaptureView capture={d.session?.capture} blocker={d.session?.blocker}/><h2 className="actions-title">Recovery & lifecycle</h2><p className="section-copy">Actions are only enabled when their server-side requirements are met. Consequences are shown before the final submission.</p><div className="actions">{d.actions.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div>{m.error&&<p className="error" role="alert">{String(m.error)}</p>}{notice&&!d.session?.pending_approval&&<p className="notice" role="status">{notice}</p>}</div><aside className="surface"><h2>Session</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>State</span><span className="machine">{d.task.state}</span></li><li><span>Harness</span><span className="machine">{d.session?.harness_id||'—'}</span></li><li><span>Pane</span><span className="machine">{d.session?.pane_id||'—'}</span></li><li><span>Lease ends</span><span className="machine">{date(d.session?.lease_until)}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface timeline-surface"><div className="timeline-heading"><div><h2>Timeline</h2><p>Recorded operator and lifecycle decisions.</p></div><span>{events.length} events</span></div><ol className="timeline">{events.map(e=><li key={e.id}><span>{e.type}</span><time>{date(e.at)}</time></li>)}</ol></div></main>}
function taskDetailExplanation(d:Detail){if(d.session?.pending_approval)return 'This harness is paused at a permission boundary. Review the exact request before allowing it.';if(d.task.state==='leased')return d.session?.capture?'The harness is active and publishing a recent capture.':'The task is leased, but its live capture is unavailable.';if(d.task.state==='blocked')return d.task.blocker||'Unknown — legacy record has no retained blocker or pane evidence.';return label[d.task.state]+' task record.'}
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const d=q.data!,workers=d.workers;const herdr=(status?:string)=>status==='reachable'?'local herdr reachable':status==='unreachable'?'local herdr unreachable':'local herdr not yet checked';return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<><p className="worker-note">Heartbeat proves the worker can reach Orchestra. Local herdr status is separately reported by that worker; it is never inferred from legacy coordinator TCP probes.</p><table><thead><tr><th>Worker</th><th>Heartbeat</th><th>Local herdr</th><th>Active work</th><th>Last error</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}><span className="status-dot"/> {w.online?'reachable':'overdue'}</td><td className={w.health?.herdr_status==='reachable'?'online':w.health?.herdr_status==='unreachable'?'offline':''}>{herdr(w.health?.herdr_status)}<small>{date(w.health?.checked_at)}</small></td><td>{w.health?.active_task_id?<><span>{w.health.active_task_id}</span><small>{w.health.active_pane_id||'pane unknown'}</small></>:'idle'}</td><td>{w.health?.last_error?<><span>{w.health.last_error}</span><small>{date(w.health.error_at)}</small></>:'—'}</td><td>{date(w.last_seen)}</td></tr>)}</tbody></table></>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}
function Artifact(){const {ref=''}=useParams();const q=useQuery({queryKey:['artifact',ref],queryFn:()=>api.artifact(ref)});return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Evidence artifact</p><h1>Recorded output.</h1></div><p className="machine">{ref}</p></div>{q.isLoading?<p className="loading">Retrieving artifact</p>:q.error?<p className="error" role="alert">{String(q.error)}</p>:<pre>{q.data}</pre>}</main>}
function App(){return <Shell><Routes><Route path="/" element={<Overview/>}/><Route path="/tasks/:taskID" element={<TaskDetail/>}/><Route path="/workers" element={<Workers/>}/><Route path="/artifacts/:ref" element={<Artifact/>}/></Routes></Shell>};createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><BrowserRouter><App/></BrowserRouter></QueryClientProvider></React.StrictMode>)
function Login({onAuthenticated,message}:{onAuthenticated:()=>void;message?:string}){const [token,setToken]=useState(''),[error,setError]=useState(''),[pending,setPending]=useState(false);const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');setPending(true);try{await api.login(token);setToken('');onAuthenticated()}catch(err){setError(String(err))}finally{setPending(false)}};return <main className="login-page"><section className="login-card"><p className="eyebrow">Orchestra control plane</p><h1>Sign in</h1><p>Enter the Web token to open a secure browser session.</p>{message&&<p className="session-message" role="status">{message}</p>}<form onSubmit={submit}><label>Web token<input type="password" autoComplete="current-password" autoFocus value={token} onChange={e=>setToken(e.target.value)} required/></label><button disabled={pending}>{pending?'Signing in…':'Sign in'}</button>{error&&<span className="error" role="alert">{error}</span>}</form></section></main>}
function RoutesApp({onLogout}:{onLogout:()=>void}){return <Shell onLogout={onLogout}><Routes><Route path="/" element={<OverviewPage/>}/><Route path="/tasks/:taskID" element={<TaskDetail/>}/><Route path="/workers" element={<Workers/>}/><Route path="/artifacts/:ref" element={<Artifact/>}/></Routes></Shell>}
function App(){const [ready,setReady]=useState(false),[checking,setChecking]=useState(true),[message,setMessage]=useState('');useEffect(()=>{const unauth=()=>{client.clear();setMessage('Your browser session expired. Sign in again to continue.');setReady(false);setChecking(false)};window.addEventListener('orchestra:unauthorized',unauth);api.overview().then(()=>setReady(true)).catch(()=>setReady(false)).finally(()=>setChecking(false));return()=>window.removeEventListener('orchestra:unauthorized',unauth)},[]);const logout=async()=>{await api.logout();client.clear();setMessage('You have signed out.');setReady(false)};if(checking)return <main className="login-page">Checking session</main>;return ready?<RoutesApp onLogout={logout}/>:<Login message={message} onAuthenticated={()=>{client.clear();setMessage('');setReady(true)}}/>}
createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><BrowserRouter><App/></BrowserRouter></QueryClientProvider></React.StrictMode>)
+9
View File
@@ -7,4 +7,13 @@
@media(max-width:760px){.status-strip{grid-template-columns:1fr 1fr;margin-bottom:24px}.status-strip div:nth-child(2){border-right:0}.status-strip div:nth-child(-n+2){border-bottom:1px solid var(--line)}.palette-backdrop{padding-top:9vh}.palette{max-height:82vh}.palette-command{padding:13px 11px}}
.heading-actions{display:flex;align-items:end;gap:16px}.heading-actions button{white-space:nowrap}.modal-backdrop{position:fixed;inset:0;z-index:15;display:grid;place-items:center;padding:20px;background:rgba(8,7,5,.72)}.create.modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto}.modal-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.modal-heading h2{margin:0}.icon-button{display:grid;width:30px;height:30px;place-items:center;padding:0;border-color:var(--line-hi);color:var(--text-mid);background:transparent;font-size:21px;font-weight:400}.create form{display:grid;gap:9px}.create form label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}.create form input,.create form textarea{margin:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}.quiet-button{border-color:var(--line-hi);color:var(--text-mid);background:transparent}
.login-page{display:grid;min-height:100vh;place-items:center;padding:20px;color:var(--text-hi)}.login-card{width:min(420px,100%);padding:28px;background:var(--bg-1);border:1px solid var(--line-hi);border-radius:var(--r-lg);box-shadow:var(--shadow-soft)}.login-card h1{margin:0 0 8px;font-size:31px;letter-spacing:-.04em}.login-card>p:not(.eyebrow){margin:0 0 22px;color:var(--text-mid);line-height:1.5}.login-card form{display:grid;gap:12px}.login-card label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}
@media(max-width:760px){.heading-actions{width:100%;align-items:stretch;flex-direction:column}.heading-actions button{align-self:flex-start}.modal-backdrop{padding:12px}.create.modal{max-height:calc(100vh - 24px)}}
/* Operator comfort pass: make live state scannable before the raw data. */
.account{position:relative}.account-button{display:flex;align-items:center;gap:7px;padding:7px 9px;border-color:var(--line);color:var(--text-mid);background:transparent;font-size:11px;font-weight:500}.account-menu{position:absolute;top:calc(100% + 8px);right:0;z-index:10;display:grid;min-width:190px;gap:8px;padding:10px;border:1px solid var(--line-hi);border-radius:var(--r-sm);background:var(--bg-2);box-shadow:var(--shadow-soft);color:var(--text-lo);font-size:11px}.account-menu button{padding:7px 9px;text-align:left}.status-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent-hi);box-shadow:0 0 0 3px var(--accent-dim)}
.section-title>div{display:grid;gap:3px}.section-title p,.section-copy,.worker-note{margin:0;color:var(--text-lo);font-size:12px;line-height:1.5}.card em{display:block;overflow:hidden;margin-top:9px;color:var(--text-lo);font-size:11px;font-style:normal;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.card:hover em{color:var(--text-mid)}.eligibility{display:grid;gap:3px;padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);background:var(--accent-dim);color:var(--text-mid);font-size:12px}.eligibility b{color:var(--accent-hi);font-size:12px}.eligibility.warning{border-color:rgba(213,160,146,.45);background:rgba(213,160,146,.09)}.eligibility.warning b{color:#d5a092}
.task-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:-8px 0 20px;padding:13px 16px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.task-banner>div{display:flex;align-items:center;gap:12px}.task-banner .task-state{margin:0;color:var(--accent-hi);font-weight:600}.task-banner p{margin:0;color:var(--text-mid);font-size:13px;line-height:1.45}.task-banner>span{color:var(--text-lo);font:11px var(--mono);white-space:nowrap}.task-banner>span b{color:var(--text-machine);font-weight:500}.task-banner.state-blocked,.task-banner.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}
.capture{margin:24px 0}.capture-head{display:flex;align-items:start;justify-content:space-between;gap:12px;margin-bottom:9px}.capture-head h2{margin:0 0 4px}.capture-head span{color:var(--text-lo);font:11px var(--mono)}.capture-filter{margin-bottom:8px;font-size:12px}.capture-more{margin:8px 0 0;color:var(--text-lo);font-size:11px}.capture-empty{display:grid;gap:5px;margin:24px 0;padding:15px;border:1px dashed var(--line-hi);border-radius:var(--r-sm);color:var(--text-mid);font-size:12px}.capture-empty b{color:var(--text-hi)}.actions-title{margin:27px 0 5px!important}.actions{margin-top:13px}.action-row,.action-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:end;padding:13px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--bg-2)}.action-row>div,.action-form>div{display:grid;gap:4px}.action-row b,.action-form b{font-size:13px}.action-row span,.action-form span{color:var(--text-lo);font-size:11px;line-height:1.45}.action-form textarea,.action-form input{grid-column:1}.action-form button{grid-column:2;grid-row:1 / span 2}.action-form.compact input{margin:0}.notice,.session-message{padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);color:var(--accent-hi);background:var(--accent-dim);font-size:12px;line-height:1.45}.timeline-surface{margin-top:20px}.timeline-heading{display:flex;align-items:start;justify-content:space-between;gap:16px}.timeline-heading p{margin:-8px 0 12px;color:var(--text-lo);font-size:12px}.timeline-heading>span{color:var(--text-lo);font:11px var(--mono)}.worker-note{margin:-10px 0 15px;padding:10px 12px;border-left:2px solid var(--accent-line);background:var(--bg-1)}.online .status-dot{margin-right:4px}.offline .status-dot{background:#c09280;box-shadow:0 0 0 3px rgba(192,146,128,.13)}
.approval-summary{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.approval-summary span{padding:5px 7px;border:1px solid var(--line);border-radius:5px;color:var(--text-lo);font:10px var(--mono)}.approval-summary b{color:var(--text-machine);font-weight:500}.login-card .session-message{margin:-6px 0 4px}
@media(max-width:760px){.account{margin-left:auto}.account-button{font-size:0}.account-button .status-dot{width:8px;height:8px}.task-banner,.task-banner>div{align-items:start;flex-direction:column;gap:7px}.task-banner>span{white-space:normal}.action-row,.action-form{grid-template-columns:1fr}.action-row button,.action-form button{grid-column:auto;grid-row:auto;justify-self:start}.action-form textarea,.action-form input{grid-column:auto}.capture-head{align-items:stretch;flex-direction:column}.section-title{align-items:start;gap:12px;flex-direction:column}.section-title .machine{white-space:nowrap}.readout{display:none}}
+3 -1
View File
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins:[react()],
build:{outDir:'dist', emptyOutDir:true},
// The Go API embeds this directory. Building anywhere else makes it easy
// to test fresh browser sources while deploying a stale embedded bundle.
build:{outDir:'../internal/webui/assets', emptyOutDir:true},
test:{exclude:['**/node_modules/**','**/.node_modules/**','**/dist/**']},
})