Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f3c2888f | |||
| 118ac9fbcb | |||
| 2d28f7b462 | |||
| a757cffc78 | |||
| 2f61a99986 | |||
| 595a1d3533 | |||
| 0797432d6f | |||
| 3c7cf95d8c | |||
| 0f83559ecc |
@@ -16,3 +16,4 @@ node_modules/
|
||||
.node_modules/
|
||||
web/dist/
|
||||
web/tsconfig.tsbuildinfo
|
||||
build/
|
||||
|
||||
@@ -76,6 +76,11 @@ the live herdr instance and check.
|
||||
There is no container entrypoint script — `Dockerfile.api` execs
|
||||
`/app/orchestra` directly. Neither deployed file is the repo's
|
||||
`deploy/config.example.jsonc`.
|
||||
- Browser operator accounts live in `$ORCHESTRA_DATA/auth.db`. Create or reset
|
||||
one with `orchestra-user set -data /data -username NAME` while the API is
|
||||
stopped, or use the authenticated Settings screen. The old
|
||||
`ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is accepted only
|
||||
for a one-time import into an empty database and should then be removed.
|
||||
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
|
||||
(192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode).
|
||||
In practice **homesrv has no local herdr running** (connection refused on
|
||||
|
||||
@@ -391,3 +391,37 @@ work.
|
||||
|
||||
Sudo is not available in this sandbox, so the worker install, the restart, and
|
||||
the `worker.env` scrub remain operator steps.
|
||||
|
||||
## Browser operator database and UI refresh (2026-08-26)
|
||||
|
||||
The browser login no longer depends on an operator copying a bcrypt hash into
|
||||
deployment configuration. The live startup path in `cmd/orchestra/main.go`
|
||||
opens `$ORCHESTRA_DATA/auth.db` through `internal/authn`, refuses to serve with
|
||||
an empty operator database, and registers the database-backed session and
|
||||
account handlers before wrapping the mux with `authz.HTTPWithSessions`.
|
||||
|
||||
- `auth.db` is an embedded bbolt database created mode 0600. Passwords are
|
||||
bcrypt-hashed before the record is written; login also performs bcrypt for an
|
||||
unknown username to avoid an account-existence timing shortcut.
|
||||
- `orchestra-user set -data DIR -username NAME` reads and confirms a password
|
||||
from the terminal, creates the first operator, and resets an existing one.
|
||||
The Docker API image includes this helper. The authenticated Settings screen
|
||||
changes the current username/password and revokes every session for that
|
||||
identity.
|
||||
- An existing `ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is
|
||||
imported once if and only if the database has no users. Once a user exists,
|
||||
those variables are ignored with an explicit startup log, so an old `.env`
|
||||
cannot overwrite a database credential.
|
||||
- The browser now gets its actual username from `GET /v1/ui/session`, renders
|
||||
it in the shell, and has a dedicated account page. The login view was rebuilt
|
||||
as a responsive desktop/mobile entry experience.
|
||||
- Frontend state drift was fixed at the same time: `needs_attention` and
|
||||
`in_review`, plus the three newer block reasons, are in the TypeScript model,
|
||||
board lanes, status colors, diagnosis copy, and filtering. The seven-state
|
||||
"All" board now has an explicit layout instead of falling back to one column.
|
||||
|
||||
Verified from the working tree after rebuilding the embedded assets:
|
||||
`go build ./...`, `go vet ./...`, and `go test ./...` all pass (21 test
|
||||
packages). The frontend TypeScript build passes, all five API-client tests
|
||||
pass, and Vite's production build emits the assets embedded by
|
||||
`internal/webui`.
|
||||
|
||||
@@ -2274,3 +2274,256 @@ tmux -L orchestra kill-session -t <session>
|
||||
|
||||
Task `06G4JX6MSQEP7N0D5JWW9EP5X4` is the other run 12 task and is `in_review` on
|
||||
a pull request. It is real work and should be reviewed or failed, not cleaned.
|
||||
|
||||
### F18 closed: a bounded observation ring
|
||||
|
||||
`d6ee10f`. `WorkerHealth` now carries up to sixteen distinct observations, each
|
||||
with a repeat count and first/last times. Collapsing is by message, not by
|
||||
position, because a loop interleaved with other failures would otherwise still
|
||||
flush the ring. Eviction drops the least recently seen, so a loop keeps its
|
||||
slot but carries the count that says what it is. `last_error` and `error_at`
|
||||
keep their wire names and still report only the newest failure.
|
||||
|
||||
The ring lives in memory beside `last_error` and is not persisted, which is
|
||||
what `last_error` already did across a restart.
|
||||
|
||||
Deployed and verified on both halves. It reports nothing yet, because
|
||||
`omitempty` hides an empty ring and nothing has failed since the deploy. No UI
|
||||
renders it: `internal/ui` never showed `last_error` either, so
|
||||
`GET /v1/federation/workers` is the only reader today.
|
||||
|
||||
### Run 12 closed out
|
||||
|
||||
PR 17 merged, task `06G4JX6MSQEP7N0D5JWW9EP5X4` completed at v32, pane closed
|
||||
by the normal completion path. The quality gate was re-run independently
|
||||
against the submitted commit `419e17fc` before the merge, rather than trusted
|
||||
from the worker's own report:
|
||||
|
||||
```text
|
||||
bash -n scripts/*.sh syntax ok
|
||||
bash scripts/orchestra_e2e_healthcheck.sh OK - all healthchecks passed, exit 0
|
||||
bash scripts/test_healthcheck.sh all checks passed, exit 0
|
||||
```
|
||||
|
||||
Worker state is empty: no sessions, no leases, no release transactions, and
|
||||
`tmux -L orchestra ls` lists nothing. The two panes the previous section left
|
||||
for an operator were already gone by then.
|
||||
|
||||
| Half | Revision |
|
||||
|---|---|
|
||||
| Coordinator, homesrv container | `d6ee10f` |
|
||||
| Worker, workpc systemd | `d6ee10f` |
|
||||
|
||||
```text
|
||||
commit d6ee10f02843e2267c859b03d17db00995941e64
|
||||
coordinator sha256 f8a847477ae0ac03fe78338775f52ed1f167f0e67eab557feffe4d67da3ad99e
|
||||
worker sha256 fe7d2188b12369fb7682d3424fa11e2db47abed9c55313291a4b467b5972d02d
|
||||
```
|
||||
|
||||
### Still open after run 12
|
||||
|
||||
- **29 blocked `test-e2e` tasks**, mostly burn-in debris. Operator hygiene, not
|
||||
lifecycle code.
|
||||
- **Three queued `correx` tasks** that cannot be scheduled, because `correx` has
|
||||
no entry in the coordinator's `config.jsonc`. Either give it one or block them
|
||||
explicitly.
|
||||
- **Gitea returns 422 for a review on your own pull request**, so
|
||||
`REQUEST_CHANGES` needs a separate bot account for `ORCHESTRA_GITEA_TOKEN`.
|
||||
- **A blocked task that is never reopened can still loop** on a dead release
|
||||
transaction. F59 covers failed, not blocked.
|
||||
|
||||
## Run 13, 2026-08-28: F60, and the ring proven under a real failure
|
||||
|
||||
### F60: a transaction must settle deterministically, in every case
|
||||
|
||||
`3c7cf95`. F58 and F59 each fixed one case of the same rule. F60 states the
|
||||
rule and covers the rest:
|
||||
|
||||
> a transaction that can no longer be legitimately committed must be abandoned
|
||||
> deterministically, and must never spin on an answer that cannot change.
|
||||
|
||||
- **Terminal is failed or completed.** Both drop the transaction and free the
|
||||
session. Completed was the gap F59 left.
|
||||
- **Blocked keeps the transaction**, because a reopen returns the task to the
|
||||
queue and that exact owner can still commit.
|
||||
- **`TaskBlocked` now retains the ending epoch**, as `TaskReleased` already did.
|
||||
Without it a reopened task has no epoch for the late-handoff path to fence
|
||||
against, so keeping the transaction would be a lie.
|
||||
- **A refusal parks the commit**, 30s backing off to a 5 minute cap. Any event
|
||||
about the task un-parks it. A reopen arrives as `TaskCorrected`, so the rule
|
||||
cannot be a list of event types.
|
||||
- **A transport failure is not an answer** and retries at once. The park keys on
|
||||
a 4xx `StatusError`, never on any error.
|
||||
|
||||
### Proven live, and the contrast with run 10
|
||||
|
||||
Task `06G4KVSHAK9B8M9K8HENAF23CG`, same race-guard rig: force the expiry inside
|
||||
the push, then hand the task to `race-guard-probe`.
|
||||
|
||||
```text
|
||||
19:54:10.888 transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8 opens at prepared
|
||||
19:54:10.907 forced expiry accepted
|
||||
19:54:10.916 successor lease to race-guard-probe accepted
|
||||
19:54:11.776 commit refused once, 409 lease not owned, parked
|
||||
19:54:15.741 superseded by lease 06G4KVYSXJH053K3DAG6FKHD20, transaction abandoned
|
||||
```
|
||||
|
||||
Run 10's equivalent ran roughly 5,000 retries over seven hours and needed an
|
||||
operator to clear the state file by hand. This one asked once and settled
|
||||
itself in four seconds. Worker state afterwards: no sessions, no leases, no
|
||||
transactions, and `tmux -L orchestra ls` empty.
|
||||
|
||||
### F18 populated under a real failure
|
||||
|
||||
The gap the previous section recorded is closed. The same run produced this
|
||||
health payload, two distinct observations rather than one overwritten slot:
|
||||
|
||||
```json
|
||||
"observations": [
|
||||
{"message": "release 06G4KVSHAK9B8M9K8HENAF23CG commit: federation: 409 Conflict: lease not owned",
|
||||
"count": 1, "first": "2026-08-28T19:54:11.776815524Z", "last": "2026-08-28T19:54:11.776815524Z"},
|
||||
{"message": "release 06G4KVSHAK9B8M9K8HENAF23CG superseded by lease 06G4KVYSXJH053K3DAG6FKHD20: abandoning transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8",
|
||||
"count": 1, "first": "2026-08-28T19:54:15.74116014Z", "last": "2026-08-28T19:54:15.74116014Z"}
|
||||
]
|
||||
```
|
||||
|
||||
Under the old single slot the abandonment message would have erased the 409
|
||||
that caused it, and the causal chain would have been unreadable. Both counts
|
||||
are 1, which is itself the F60 evidence: nothing looped.
|
||||
|
||||
### Deployed state
|
||||
|
||||
| Half | Revision |
|
||||
|---|---|
|
||||
| Coordinator, homesrv container | `3c7cf95` |
|
||||
| Worker, workpc systemd | `3c7cf95` |
|
||||
|
||||
```text
|
||||
commit 3c7cf95d8cdaaff5dcec460c5d7874f7f4f0dbea
|
||||
coordinator sha256 3fec1e99a638da851f5c970abaf80a006dc87425936f71fe33f10830f4bac573
|
||||
worker sha256 a373445f167f0a321dfc3866a1d2ed703b670b0fb3c188a8d00ad8099586dfdf
|
||||
```
|
||||
|
||||
`orchestra-f18-baseline` tags `d6ee10f`, the post-F18 deployed baseline, and is
|
||||
pushed. The branch is pushed too: 91 commits, because nothing had gone up since
|
||||
`97a9c65` on 2026-07-31.
|
||||
|
||||
### Still open
|
||||
|
||||
Unchanged and all operational rather than runtime: 29 blocked `test-e2e` tasks,
|
||||
three unschedulable `correx` tasks, and the Gitea 422 on reviewing your own
|
||||
pull request. The rig task above will requeue when the probe lease expires and
|
||||
start fresh, which is correct: its anchor was abandoned, not committed.
|
||||
|
||||
## Run 14, 2026-08-28: plan-phase execution proven on `3c7cf95`
|
||||
|
||||
Four tasks. Two carried a full lifecycle to a pull request, one failed for a
|
||||
cause outside Orchestra, and the F60 rig task failed on its retry budget.
|
||||
|
||||
### The rung: the worker runs the sealed plan's commands
|
||||
|
||||
Task `06G4M8WHGQ4P3GQMPEEH0RJRHM`, plan `789ed6a8477b`, both phases verified
|
||||
against the plan document rather than the request.
|
||||
|
||||
```text
|
||||
plan phase-1 - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
- run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
event v19 commands identical, exit_codes [0,0], at_sha 3b66b2b6a669,
|
||||
evidence_ref a5dbc4ecc9ce, status awaiting_manual_verification
|
||||
|
||||
plan phase-2 - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
- run: ["bash", "-n", "scripts/test_healthcheck.sh"]
|
||||
- run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
event v20 commands identical, exit_codes [0,0,0], same at_sha and plan_ref
|
||||
```
|
||||
|
||||
Every record binds `plan_ref`, `phase_id`, `at_sha`, `evidence_ref`,
|
||||
`lease_epoch` and `harness_id`. Rotation ran between every work phase with
|
||||
`TaskPickupValidated` each time.
|
||||
|
||||
**Not yet proven:** a successor picking up mid-implement. Both phases verified
|
||||
under one lease epoch, so no rotation separated phase 1 from phase 2.
|
||||
|
||||
**The planner found the legal route to a forbidden command.**
|
||||
`scripts/test_healthcheck.sh` may not be executed, but `["bash", "-n", ...]`
|
||||
covers any path, so it syntax-checked the file it could not run.
|
||||
|
||||
### The refusal loop is actionable, not merely observable
|
||||
|
||||
Task `06G4M6HF1Z3EREX1X3NEKSHP24` sealed a plan naming
|
||||
`bash scripts/test_healthcheck.sh`, which the policy forbids.
|
||||
|
||||
```text
|
||||
20:45:02 phase request refused: "bash scripts/test_healthcheck.sh" is not in the policy
|
||||
20:45:33 implement <- the planner rewrote and resealed, 31 seconds later
|
||||
20:48:03 in_review
|
||||
```
|
||||
|
||||
F52 refused on the planner, F55's answer path delivered the reason, and the
|
||||
agent corrected. Count stayed at 1 for both tasks that hit it. A brief that
|
||||
states the policy avoids the round trip entirely: the `--list-checks` task was
|
||||
told the allowed argv and sealed on the first attempt.
|
||||
|
||||
### F61, recorded and deliberately not built
|
||||
|
||||
The verification allow-list is enforced and never stated to the planner. Every
|
||||
plan that guesses pays one refused round trip. It is not a one-liner:
|
||||
`agentctx.Input.Policy` is filled from the worker's `SafeOperations`, while the
|
||||
verification policy is coordinator-side by design, so this needs a new channel.
|
||||
|
||||
The reason to promote it later is local models. A strong planner consumed the
|
||||
refusal and repaired the plan in 31 seconds. A weaker one may propose forbidden
|
||||
commands repeatedly, because it cannot infer the allowed substitute. Then F61
|
||||
stops being latency and becomes model compatibility.
|
||||
|
||||
### opencode is not running, and the reason was only in the pane
|
||||
|
||||
Task `06G4M6HH5BAM235AC4PWYXF1HM` failed at attempt 3 having never left
|
||||
`frame`. Its worktree held only `launch.md`. The pane said why. The blocked
|
||||
thing is the selected model, not the OpenCode Zen free tier, which works:
|
||||
|
||||
```text
|
||||
This model is not available in your country.
|
||||
▣ Build · Muse Spark 1.2 Free OpenCode Zen
|
||||
```
|
||||
|
||||
Orchestra behaved correctly throughout: lease, no progress, expire, requeue,
|
||||
fail. Two things are worth keeping.
|
||||
|
||||
- **The adapter cannot resolve a session file for opencode**, so activity is
|
||||
`unknown` and the worker falls back to agent status. The failure therefore
|
||||
reads as "done and pane unchanged" rather than "dead". Orchestra cannot tell
|
||||
finished from never-started on this harness.
|
||||
- **No observation carried the cause.** Four of them named the lease, the
|
||||
rotation and the activity. The fatal line existed only in the pane capture,
|
||||
which Orchestra publishes but nothing summarises. Parsing harness chrome is
|
||||
not Orchestra's job; surfacing the capture is.
|
||||
|
||||
**Do not route to `workpc-opencode` until its model is reachable.** Every task
|
||||
sent there burns three attempts and then fails. `~/.config/opencode/opencode.jsonc`
|
||||
sets no top-level `model`, so OpenCode picks its own default. The file already
|
||||
declares reachable `nvidia-nim` and `llama-cpp` providers.
|
||||
|
||||
### The retry budget counts idleness, not just failure
|
||||
|
||||
The F60 rig task `06G4KVSHAK9B8M9K8HENAF23CG` failed at attempt 3 mid-review.
|
||||
Two attempts were rig-induced expiries and the third was a natural idle
|
||||
expiry. Every lease expiry increments `attempt`, and an agent going quiet at a
|
||||
phase boundary is a routine expiry here. A task making real progress can spend
|
||||
its retry budget on idleness. The two clean tasks never expired at all, so this
|
||||
is a dynamic to watch rather than a defect to fix.
|
||||
|
||||
F59 was confirmed live twice more: both failed tasks left no release
|
||||
transaction and no session behind, on either worker.
|
||||
|
||||
### Result
|
||||
|
||||
| Task | Harness | Outcome |
|
||||
|---|---|---|
|
||||
| `06G4M6HF1Z3EREX1X3NEKSHP24` | workpc-claude | in_review, recovered from a policy refusal |
|
||||
| `06G4M8WHGQ4P3GQMPEEH0RJRHM` | workpc-claude | in_review, plan-phase execution proven |
|
||||
| `06G4M6HH5BAM235AC4PWYXF1HM` | workpc-opencode | failed, model unavailable |
|
||||
| `06G4KVSHAK9B8M9K8HENAF23CG` | workpc-claude | failed, retry budget |
|
||||
|
||||
Both workers hold no sessions and no release transactions. Deployed pair is
|
||||
still `3c7cf95` on both halves.
|
||||
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
# Debt ledger: design from what the tree already supports
|
||||
|
||||
Written 2026-08-29 against `3c7cf95`. Read with `BURNIN.md` (the run ledger),
|
||||
`PLAN-SPEC-DESIGN.md` and `AUDIT.md`.
|
||||
|
||||
This is a design, not an implementation. Nothing here has been built.
|
||||
|
||||
## The problem being solved
|
||||
|
||||
AI raises implementation throughput faster than it raises integration, cleanup,
|
||||
observability, consolidation and architectural understanding. The residue is
|
||||
fixes, compatibility paths, operational workarounds, duplicate config, adapter
|
||||
gaps and one-off patches. Some are justified. Some become permanent because
|
||||
nobody remembers why they were added or what they have cost since.
|
||||
|
||||
The target is a first-class debt ledger driven by evidence from real tasks, not
|
||||
a second TODO list.
|
||||
|
||||
## Classes
|
||||
|
||||
Four, and no more. A fifth class invented at runtime makes the priority model
|
||||
meaningless.
|
||||
|
||||
| Class | Meaning |
|
||||
|---|---|
|
||||
| correctness | Known behaviour is wrong or violates an invariant. |
|
||||
| operational | The system works, but diagnosis, recovery, deployment, observability or operation repeatedly costs time. |
|
||||
| structural | Duplication or architecture is demonstrably raising the cost of future changes. |
|
||||
| polish | Cleanup or consistency work with no demonstrated cost yet. |
|
||||
|
||||
Correctness and operational debt gain priority quickly. Structural debt needs
|
||||
evidence that it is causing repeated work. Polish never becomes work on its own.
|
||||
|
||||
## 1. What Orchestra already has
|
||||
|
||||
The event log is the durable spine, and it already carries most of what a debt
|
||||
ledger needs. Every row below is mechanically countable today, with no new
|
||||
instrumentation.
|
||||
|
||||
| Evidence | Source | Carries |
|
||||
|---|---|---|
|
||||
| Failure shape | `TaskFailed`, `TaskBlocked` | 12 typed `block_reason` values, 6 `failure_class` values |
|
||||
| Retry cost | `Task.Attempt`, `NextRetryAt`, `FailureClass` | how many leases a task burned, durable in the projection |
|
||||
| Review findings | `ReviewRecorded` | `{ID, Severity, File, Line, Claim, Evidence}`, bound to a `ResultSHA` |
|
||||
| Out-of-scope discoveries | `DeferredFindingRecorded` | `{Summary, Why}`, already recorded outside agent context |
|
||||
| Plan contradicted by code | `PlanMismatchRecorded` | `{PlanRef, PhaseID, AtSHA, Observed, Contradicts, Evidence[]}` |
|
||||
| Verification history | `PlanPhaseVerified` | `{Commands, ExitCodes, AtSHA, EvidenceRef}` per phase |
|
||||
| Rework rounds | `TaskChangesRequested`, `TaskSubmitted` | how many times a change went back |
|
||||
| Provenance | `TaskCreated` | `Source`, `ExternalID`, `Parent` |
|
||||
|
||||
Three existing patterns matter more than the data.
|
||||
|
||||
**Global-subject events already work.** `QuotaReported` and `StandupAdvisory`
|
||||
use `TaskID: "system"` and are whitelisted in `internal/domain/domain.go:279`.
|
||||
A debt item needs no new subject mechanism.
|
||||
|
||||
**`GenerateStandupAdvisory` is the precedent for the shape.** It is a
|
||||
persisted, read-only recommendation, and applying it is a separate
|
||||
approval-gated operation. A debt ledger is that pattern with better inputs.
|
||||
|
||||
**`CheckSubmission` is the precedent for eligibility.** It is a pure function
|
||||
of task, commit and gate run, returning `{Eligible bool, Reasons []string}`.
|
||||
Debt eligibility should have the same shape.
|
||||
|
||||
## 2. What evidence is missing
|
||||
|
||||
**Worker observations are not durable.** This is the largest gap. The F18 ring
|
||||
lives in worker memory and reaches the coordinator inside `WorkerHealth` on
|
||||
heartbeat. `Registry.persistedState` holds captures, commands and workers only,
|
||||
with no health. A coordinator restart erases every observation.
|
||||
|
||||
Operational debt is exactly what that ring holds. Repeated 409s, unrenewed
|
||||
leases, adapter gaps.
|
||||
|
||||
**No operator-intervention record.** Every manual repair in run 12 and run 13
|
||||
left no trace in Orchestra. State-file edits, worker restarts, two manual
|
||||
transaction cleanups. "Required manual recovery" is the strongest priority
|
||||
signal available, and it is currently unrecorded.
|
||||
|
||||
**No diagnosis-cost signal.** Time spent diagnosing is not measured. The
|
||||
closest proxy is wall time in `blocked` or `needs_attention`.
|
||||
|
||||
**No component dimension.** Findings carry file paths. Tasks carry none.
|
||||
Breadth across components has to be derived from paths.
|
||||
|
||||
**No link from a repair commit back to what it repaired.** The Fxx-to-commit
|
||||
mapping exists only in `BURNIN.md` prose.
|
||||
|
||||
**Deferred findings are too thin.** `{Summary, Why}` has no class, no severity,
|
||||
no paths and no evidence refs.
|
||||
|
||||
## 3. Minimal durable data model
|
||||
|
||||
No new subsystem. Two event types on the existing spine, one projection beside
|
||||
`Task`.
|
||||
|
||||
```go
|
||||
// DebtObservation is one piece of counted evidence, always pointing at the
|
||||
// event that produced it. Provenance is an event id, never prose.
|
||||
type DebtObservation struct {
|
||||
// Exactly one of EventID and LegacyRef. See section 8.
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind string `json:"kind"` // block_reason, failure_class, review_finding,
|
||||
// plan_mismatch, manual_intervention, deferred_finding
|
||||
Signature string `json:"signature"` // the mechanical dedup key, see section 4
|
||||
Detail string `json:"detail"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type DebtItem struct {
|
||||
ID string `json:"id"` // continues the Fxx namespace
|
||||
Class DebtClass `json:"class"` // correctness, operational, structural, polish
|
||||
Status DebtStatus `json:"status"` // observed, eligible, scheduled, repaired, withdrawn
|
||||
Symptom string `json:"symptom"`
|
||||
Consequence string `json:"consequence"`
|
||||
Severity Severity `json:"severity"` // reuse review.Severity
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Signatures []string `json:"signatures"` // every key that attaches here
|
||||
Observations []DebtObservation `json:"observations"`
|
||||
IntroducedIn string `json:"introduced_in,omitempty"` // task id or commit
|
||||
RepairBoundary string `json:"repair_boundary,omitempty"`
|
||||
RepairTask string `json:"repair_task,omitempty"`
|
||||
RepairCommit string `json:"repair_commit,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Every counted field the priority model needs is derived, never stored:
|
||||
|
||||
```text
|
||||
recurrence len(Observations)
|
||||
blocked_tasks distinct TaskID where Kind is block_reason
|
||||
manual_interventions count of Kind == manual_intervention
|
||||
breadth distinct components derived from Paths
|
||||
```
|
||||
|
||||
A stored count invites drift from the log. A derived one cannot drift.
|
||||
|
||||
Two events carry it. `DebtObserved` appends one observation. `DebtItemUpdated`
|
||||
records class, status, severity or a merge. Both need adding to the `allowed`
|
||||
map and a validator, which is the work every existing event type already did.
|
||||
|
||||
## 4. Deduplication without corrupting provenance
|
||||
|
||||
**A signature is computed from typed facts, never from prose.**
|
||||
|
||||
```text
|
||||
operational class + block_reason + harness + normalized component
|
||||
correctness class + failure_class + normalized component
|
||||
structural class + finding severity + normalized component
|
||||
```
|
||||
|
||||
Exact signature match attaches automatically. That is the only automatic path.
|
||||
|
||||
**Signatures are versioned, in the string itself.**
|
||||
|
||||
```text
|
||||
v1:operational:lease_not_renewed:workpc-opencode:federation
|
||||
```
|
||||
|
||||
Normalization rules will change. Without a version, changing them silently
|
||||
regroups every historical observation, and the recurrence counts that drive
|
||||
eligibility move underneath the items that already used them. A `v2` signature
|
||||
never matches a `v1` one, so old evidence keeps the grouping it was counted
|
||||
under.
|
||||
|
||||
**A model may propose, never merge.** Clustering emits `DebtMergeSuggested`
|
||||
carrying both item ids and its reasoning. The merge is an operator action or a
|
||||
policy threshold, recorded as `DebtItemUpdated`.
|
||||
|
||||
**Merges are additive.** The surviving item gains the other's signatures and
|
||||
observations. The absorbed item becomes `withdrawn` with a pointer, and its
|
||||
observations keep their original event ids. Nothing is rewritten, so a bad
|
||||
merge is reversible by reading the log.
|
||||
|
||||
**Every observation names its event.** A debt item's evidence is always
|
||||
checkable against the log that produced it. That rule is what stops a fuzzy
|
||||
proposal from becoming an unverifiable claim.
|
||||
|
||||
## 5. Eligibility policy
|
||||
|
||||
A pure function, mirroring `CheckSubmission`.
|
||||
|
||||
```go
|
||||
func CheckDebtEligibility(item DebtItem) DebtCheck // {Eligible bool, Reasons []string}
|
||||
```
|
||||
|
||||
| Class | Becomes eligible when |
|
||||
|---|---|
|
||||
| correctness | first confirmed observation, always |
|
||||
| operational | recurrence >= 3 across >= 2 distinct tasks, or >= 1 manual intervention |
|
||||
| structural | >= 3 review findings, or >= 2 tasks blocked or reworked in the same component |
|
||||
| polish | never automatically, operator promotion only |
|
||||
|
||||
`Reasons` lists what fired and what did not. "Not eligible" alone sends an
|
||||
operator reading code, which is the mistake `SubmissionCheck` already documents.
|
||||
|
||||
Eligible means a task may be created. It does not create one.
|
||||
|
||||
## 6. How maintenance and consolidation fit the existing lifecycle
|
||||
|
||||
A maintenance task is an ordinary task. Source `debt`, external id the debt item
|
||||
id, phases as usual.
|
||||
|
||||
```text
|
||||
research → plan → implement → verification → review → submission
|
||||
```
|
||||
|
||||
Nothing in the lifecycle changes. One extension is needed: the task's brief must
|
||||
carry the debt item's evidence. That is the mechanism research and plan
|
||||
artifacts already use, a CAS ref on the task.
|
||||
|
||||
A consolidation task is the same thing with a research brief that asks:
|
||||
|
||||
- which temporary paths are still necessary?
|
||||
- which fixes now duplicate each other?
|
||||
- which compatibility branches are obsolete?
|
||||
- which config or state is represented twice?
|
||||
- which abstractions exist only because of defects that have since disappeared?
|
||||
- what can now be deleted safely?
|
||||
|
||||
Its first output is a deletion plan, never an automatic refactor. Verification
|
||||
uses the project's existing policy. Review is the normal independent review.
|
||||
|
||||
**The repair gets no discount.** Classification as debt changes what gets
|
||||
scheduled, never what gets checked.
|
||||
|
||||
### Success metrics for maintenance work
|
||||
|
||||
Lines of code are not a metric. Prefer evidence that can be checked:
|
||||
|
||||
- behaviour preserved
|
||||
- tests preserved or strengthened
|
||||
- branches removed
|
||||
- obsolete types removed
|
||||
- compatibility code removed
|
||||
- duplicate config removed
|
||||
- manual recovery paths eliminated
|
||||
|
||||
## 7. What must not be automated
|
||||
|
||||
- No autonomous cleanup agent, and no repo-wide sweep.
|
||||
- No LLM score treated as authoritative. Counted facts decide, models propose.
|
||||
- No automatic merge of debt items.
|
||||
- No maintenance task from polish without an operator.
|
||||
- No deletion without an independent review.
|
||||
- No auto-closing an item because a commit touched the file. Closure needs a
|
||||
repair task or a stated run of clean evidence.
|
||||
- No new classes invented at runtime.
|
||||
|
||||
## 8. Migration, so there is one track and not two
|
||||
|
||||
`BURNIN.md` holds 58 Fxx entries in the form `| F52 | commit | prose |`.
|
||||
`AUDIT.md` holds the older narrative.
|
||||
|
||||
One-time import, by hand, with a class assigned per row. Each becomes a
|
||||
`DebtItem` with `Status: repaired`, its commit as `RepairCommit`, and one
|
||||
observation citing the ledger.
|
||||
|
||||
**Imported observations carry legacy provenance, never a fabricated event id.**
|
||||
The Fxx history predates the capability that would have produced an event, and
|
||||
inventing one would break the provenance rule the ledger exists to enforce.
|
||||
|
||||
```go
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"` // "BURNIN.md:F18"
|
||||
```
|
||||
|
||||
Exactly one is required. A reader can then tell a counted fact from an imported
|
||||
claim at a glance, and the counts that drive eligibility can exclude imported
|
||||
evidence if that turns out to matter.
|
||||
|
||||
**Keep the namespace.** New items continue at F62. Two numbering schemes would
|
||||
be the first structural debt the ledger itself creates.
|
||||
|
||||
After import, `BURNIN.md` stops being the item ledger and remains the run
|
||||
narrative. The defect table becomes generated output from the projection.
|
||||
|
||||
### The history that tests the model
|
||||
|
||||
These are not special cases. They are the check on whether the model can
|
||||
represent the history this project already has.
|
||||
|
||||
| History | Expected class | Why |
|
||||
|---|---|---|
|
||||
| F18, the single `last_error` slot | operational | Repeatedly destroyed causal evidence before it was fixed. |
|
||||
| F43 to F46, the submission path | correctness | Apparently implemented, actually unreachable. |
|
||||
| F61, planner learns policy by refusal | operational | Costs a round trip per plan, and becomes model compatibility on weaker planners. |
|
||||
| Duplicated `quality_gate` config | structural | Only if it keeps causing drift or operator mistakes. |
|
||||
| Old blocked burn-in tasks | neither | Hygiene, unless one exposes a runtime defect. |
|
||||
| The blocked release loop, F57 to F60 | correctness and operational | Wrong behaviour, and it required manual cleanup twice. |
|
||||
|
||||
## 9. Smallest slice to live-prove first
|
||||
|
||||
**A read-only projection over the existing log. No new events, no writes, no
|
||||
schema commitment.**
|
||||
|
||||
```text
|
||||
internal/domain/debt.go DebtItem, DebtObservation, signature, classification
|
||||
internal/store/debt_projection.go the fold over s.Events(0), and the gap report
|
||||
internal/operations/debt.go CheckDebtEligibility, and later the actions
|
||||
GET /v1/debt read-only, tui surface
|
||||
```
|
||||
|
||||
The projected type stays out of `operations`. Baking a read model into the
|
||||
command layer in the first slice is the mistake that would be hardest to undo
|
||||
later. `operations` owns actions: `CheckDebtEligibility` now, and
|
||||
`SuggestDebtMerge`, `PromoteDebtItem` and `ScheduleDebtRepair` when they exist.
|
||||
|
||||
The proof is what the projection can and cannot recover from canonical history.
|
||||
Section 2 already says worker observations are not durable and operator
|
||||
interventions are unrecorded, so demanding their reconstruction would be asking
|
||||
the projection to invent evidence.
|
||||
|
||||
The first burn-in is therefore three requirements, not one:
|
||||
|
||||
1. Recover every debt signal that canonical history actually encodes.
|
||||
2. Report, explicitly and per kind, where known debt cannot be reconstructed.
|
||||
3. Never infer a missing observation from `BURNIN.md`.
|
||||
|
||||
Incompleteness is part of the result, not a failure. The 409 loop lived in the
|
||||
F18 worker ring rather than in an event, so the ledger should say it has no
|
||||
durable evidence for that shape. That statement is what makes slice two
|
||||
necessary, measurably rather than by assertion.
|
||||
|
||||
What the projection should recover from the log alone:
|
||||
|
||||
- repeated `lease_expired` and `retry_limit` on one harness, which is the
|
||||
opencode failure shape across four tasks
|
||||
- the retry-idleness dynamic, attached to the tasks that expired
|
||||
- review findings grouped by component, if any repeat
|
||||
|
||||
A model that cannot represent debt already known is wrong, and no schema has
|
||||
been committed to yet. That is the cheapest place to find out.
|
||||
|
||||
**Slice two** makes worker observations durable, because that is the input the
|
||||
projection will most visibly lack.
|
||||
|
||||
**Slice three** adds `DebtObserved` and manual-intervention recording.
|
||||
|
||||
## Scope boundary
|
||||
|
||||
This design changes no part of the task lifecycle, phases, leases, review,
|
||||
submission, federation or the plan machinery. The only extensions it needs are
|
||||
two event types, one projection, one pure eligibility function and one
|
||||
read-only endpoint.
|
||||
|
||||
---
|
||||
|
||||
# Slice one, run against real history
|
||||
|
||||
Built and deployed as `a757cff`. `GET /v1/debt` folded 881 events from the
|
||||
live log, wrote nothing, and produced 15 candidate items and 3 gaps.
|
||||
|
||||
## What it recovered
|
||||
|
||||
```text
|
||||
v1:operational:lease_expired:workpc-opencode:lease r=41 tasks=4
|
||||
v1:operational:lease_expired:workpc-claude:lease r=29 tasks=13
|
||||
v1:operational:lease_failure:-:lease r=20 tasks=16
|
||||
v1:operational:system_error:-:lease r=11 tasks=7
|
||||
v1:correctness:handoff_validation:-:lease r=5 tasks=5
|
||||
v1:correctness:plan_mismatch:-:... r=1 tasks=1
|
||||
v1:structural:minor:-:scripts/orchestra_e2e_healthcheck.sh r=1 tasks=1
|
||||
```
|
||||
|
||||
The opencode failure shape is the top item, found mechanically. Run 14
|
||||
diagnosed the same thing by hand from a pane capture. The retry-idleness
|
||||
dynamic is the second item, and it is now a number: 29 expiries across 13
|
||||
tasks on one harness.
|
||||
|
||||
Seven items are eligible under the stated policy. Polish and the single
|
||||
structural finding correctly are not.
|
||||
|
||||
## What it reported that it could not see
|
||||
|
||||
```text
|
||||
durable=false manual_intervention no event type records an operator repair
|
||||
durable=false worker_observation worker health is not persisted
|
||||
durable=true deferred_finding carried by the log, this history has none
|
||||
```
|
||||
|
||||
The 409 release loop does not appear, and it should not. That evidence lived in
|
||||
the F18 worker ring, which no event carries. The ledger says so rather than
|
||||
inferring it, which is the result slice two exists to change.
|
||||
|
||||
## Four defects the first run exposed
|
||||
|
||||
**The component part is too coarse for lease evidence.** Everything
|
||||
lease-related normalizes to `lease`, so `lease_expired:workpc-claude` is one
|
||||
bucket holding idle agents, rig interference and real failures. Recurrence 29
|
||||
is true and the item is not a defect.
|
||||
|
||||
**`harness` is often empty on block reasons.** `TaskBlocked` payloads do not
|
||||
always carry `harness_id`, so `lease_failure:-` mixes harnesses that should be
|
||||
separate items.
|
||||
|
||||
**Path normalization mangled a mismatch reference.**
|
||||
`scripts/orchestra_e2e_healthcheck.sh:12` became
|
||||
`scripts/orchestra_e2e_healthcheck.sh_12`, because the signature sanitizer
|
||||
replaces `:` and the mismatch evidence field holds prose, not clean paths.
|
||||
|
||||
**Recurrence alone is the wrong sort.** 41 occurrences on 4 tasks currently
|
||||
outranks 29 on 13 tasks. Breadth is in the design and not yet in the ordering,
|
||||
which is the priority function slice one deliberately omitted.
|
||||
|
||||
None of these required a schema commitment to discover. That was the point of
|
||||
making the first slice read-only.
|
||||
@@ -157,6 +157,22 @@ type releaseTransaction struct {
|
||||
AgentReleased bool `json:"agent_released,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// NextAttemptAt parks a commit the coordinator has refused. A refusal is
|
||||
// an answer about the task, not a transport failure, so it stays true
|
||||
// until something about the task changes.
|
||||
NextAttemptAt time.Time `json:"next_attempt_at,omitempty"`
|
||||
Attempts int `json:"attempts,omitempty"`
|
||||
}
|
||||
|
||||
// releaseBackoff spaces out refused commits. The first wait is long enough
|
||||
// that a parked transaction stops filling the observation ring, and the cap
|
||||
// keeps a reopen from waiting more than five minutes to be noticed.
|
||||
func releaseBackoff(attempts int) time.Duration {
|
||||
d := 30 * time.Second << (attempts - 1)
|
||||
if attempts < 1 || d > 5*time.Minute {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
type projectConfig struct {
|
||||
Repo string `json:"repo"`
|
||||
@@ -894,18 +910,31 @@ func (w *worker) advanceRelease(ctx context.Context, id string, s herdr.Session)
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
}
|
||||
if tx.Phase == "anchor_pushed" && time.Now().Before(tx.NextAttemptAt) {
|
||||
return
|
||||
}
|
||||
if tx.Phase == "anchor_pushed" {
|
||||
// The epoch comes from the transaction, not from w.leases: an expiry
|
||||
// replay deletes the lease, and the coordinator needs the epoch of the
|
||||
// lease this anchor was pushed under to accept the late commit.
|
||||
if err := w.api.Release(ctx, id, tx.Ref, tx.AnchorSHA, tx.ID, tx.LeaseEpoch, tx.LeaseVersion, w.sessionEvidence(ctx, id, s)); err != nil {
|
||||
tx.LastError, tx.UpdatedAt = err.Error(), time.Now().UTC()
|
||||
// A refusal is the coordinator's answer about who owns the task.
|
||||
// It cannot change until an event about that task does, so asking
|
||||
// again every five seconds only burns the observation ring. A
|
||||
// transport failure is the opposite and must retry at once.
|
||||
var refused *federation.StatusError
|
||||
if errors.As(err, &refused) && refused.Code >= 400 && refused.Code < 500 {
|
||||
tx.Attempts++
|
||||
tx.NextAttemptAt = time.Now().UTC().Add(releaseBackoff(tx.Attempts))
|
||||
}
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
w.recordError(fmt.Errorf("release %s commit: %w", id, err))
|
||||
return
|
||||
}
|
||||
tx.Phase, tx.LastError, tx.UpdatedAt = "event_committed", "", time.Now().UTC()
|
||||
tx.NextAttemptAt, tx.Attempts = time.Time{}, 0
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
}
|
||||
@@ -1313,6 +1342,13 @@ func (w *worker) once(ctx context.Context) error {
|
||||
if t, ok := created(e); ok {
|
||||
w.tasks[t.ID] = t
|
||||
}
|
||||
// Any event about this task is the change a parked commit was waiting
|
||||
// for. A reopen arrives as TaskCorrected, so this cannot be a list of
|
||||
// specific types without going stale.
|
||||
if tx, parked := w.releases[e.TaskID]; parked && !tx.NextAttemptAt.IsZero() {
|
||||
tx.NextAttemptAt, tx.Attempts = time.Time{}, 0
|
||||
w.releases[e.TaskID] = tx
|
||||
}
|
||||
if e.Type == "TaskLeased" {
|
||||
var p struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
@@ -1403,7 +1439,7 @@ func (w *worker) once(ctx context.Context) error {
|
||||
w.leases[e.TaskID] = l
|
||||
}
|
||||
}
|
||||
if e.Type == "TaskReleased" || e.Type == "TaskFailed" || e.Type == "TaskBlocked" {
|
||||
if e.Type == "TaskReleased" || e.Type == "TaskFailed" || e.Type == "TaskBlocked" || e.Type == "TaskCompleted" {
|
||||
if e.Type == "TaskReleased" {
|
||||
var p struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
@@ -1425,12 +1461,13 @@ func (w *worker) once(ctx context.Context) error {
|
||||
// mapping protects nothing. F30: a transaction stuck at "prepared"
|
||||
// held the session forever once its pane was gone, health() kept
|
||||
// reporting ActiveTask, and the harness never leased again.
|
||||
// A failed task is terminal: no successor will ever lease it, so
|
||||
// its anchor protects nothing and its transaction can only retry
|
||||
// a refusal forever. Blocked is different, because a reopen still
|
||||
// produces a successor.
|
||||
// Failed and completed are terminal: no successor will ever lease
|
||||
// the task, so the anchor protects nothing and the transaction can
|
||||
// only retry a refusal forever. Blocked is different, because a
|
||||
// reopen returns the task to the queue and the epoch that ended is
|
||||
// still on record, so that exact commit can still be accepted.
|
||||
tx, releasing := w.releases[e.TaskID]
|
||||
if !releasing || tx.Ref == "" || e.Type == "TaskFailed" {
|
||||
if !releasing || tx.Ref == "" || e.Type == "TaskFailed" || e.Type == "TaskCompleted" {
|
||||
if releasing {
|
||||
delete(w.releases, e.TaskID)
|
||||
}
|
||||
|
||||
@@ -1327,3 +1327,120 @@ func TestObservationRingEvictsLeastRecentlySeen(t *testing.T) {
|
||||
t.Fatal("least recently seen entry survived")
|
||||
}
|
||||
}
|
||||
|
||||
// F60. A refusal is an answer about the task, not a transport failure, and it
|
||||
// stays true until something about that task changes. Run 10's blocked task
|
||||
// asked 5,000 times over seven hours and got the same 409 every time.
|
||||
func TestRefusedCommitParksUntilSomethingChanges(t *testing.T) {
|
||||
var commits int
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/handoff"):
|
||||
commits++
|
||||
http.Error(w, "lease not owned", http.StatusConflict)
|
||||
case strings.HasSuffix(r.URL.Path, "/events"):
|
||||
_, _ = w.Write([]byte(`{"cursor":0,"events":[{"seq":1,"id":"c","type":"TaskCorrected","task_id":"t","version":9,"payload":{"state":"queued"},"surface":"web"}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
tx := releaseTransaction{ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": tx},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
if commits != 1 {
|
||||
t.Fatalf("refused commit retried %d times without waiting", commits)
|
||||
}
|
||||
if w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("refused commit was not parked")
|
||||
}
|
||||
// A reopen arrives as TaskCorrected. Any event about the task is the
|
||||
// change the parked commit was waiting for, so the same tick retries it
|
||||
// and, still refused, parks it again.
|
||||
if err := w.once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if commits != 2 {
|
||||
t.Fatalf("an event about the task did not un-park its commit, commits=%d", commits)
|
||||
}
|
||||
if w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("the second refusal did not park it again")
|
||||
}
|
||||
}
|
||||
|
||||
// The opposite case, and the one a backoff must not break: the coordinator is
|
||||
// unreachable or broken rather than answering. That says nothing about who
|
||||
// owns the task, so it has to retry at once.
|
||||
func TestTransientCommitFailureKeepsRetryingAtOnce(t *testing.T) {
|
||||
var commits int
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/handoff") {
|
||||
commits++
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer s.Close()
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": {ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
if commits != 2 {
|
||||
t.Fatalf("transport failure was parked like a refusal, commits=%d", commits)
|
||||
}
|
||||
if !w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("transport failure must not park the transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// Completion is terminal for a release transaction just as failure is. The
|
||||
// task is done; nothing will ever lease it again to pick the anchor up.
|
||||
func TestCompletedTaskDropsItsReleaseTransaction(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/events") {
|
||||
_, _ = w.Write([]byte(`{"cursor":0,"events":[{"seq":1,"id":"c","type":"TaskCompleted","task_id":"t","version":9,"payload":{"report_ref":"sha256:r"},"surface":"system"}]}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer s.Close()
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
backend: deadTmuxBackend(t),
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": {ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
if err := w.once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.releases) != 0 || len(w.sessions) != 0 {
|
||||
t.Fatalf("completed task kept its release: releases=%v sessions=%v", w.releases, w.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +445,17 @@ func main() {
|
||||
}
|
||||
json.NewEncoder(w).Encode(s.Events(n))
|
||||
})
|
||||
mux.HandleFunc("/v1/debt", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Read-only, and deliberately so. This projection is evidence about
|
||||
// history, not a new kind of truth: nothing here writes an event,
|
||||
// schedules work, or decides that something is worth repairing.
|
||||
ledger := s.DebtLedger()
|
||||
out := struct {
|
||||
domain.DebtLedger
|
||||
Eligible []operations.DebtCandidate `json:"eligible"`
|
||||
}{ledger, operations.EligibleDebt(ledger)}
|
||||
json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
mux.HandleFunc("/v1/handoffs", func(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]domain.Event, 0)
|
||||
for _, e := range s.Events(0) {
|
||||
|
||||
+31
-8
@@ -8,17 +8,40 @@ coordinator deployment" below for the build that carries provenance. (The old
|
||||
|
||||
## Browser operator login
|
||||
|
||||
The browser UI requires `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH`. Generate a bcrypt hash without putting the
|
||||
password in shell history:
|
||||
Browser operators now live in the embedded `${ORCHESTRA_DATA}/auth.db`
|
||||
database. Passwords are bcrypt-hashed inside that database; no password hash
|
||||
belongs in `.env`.
|
||||
|
||||
For a new local data directory, create the first account while Orchestra is
|
||||
stopped. The command reads and confirms the password from the terminal:
|
||||
|
||||
```sh
|
||||
go run ./cmd/orchestra-password
|
||||
go run ./cmd/orchestra-user set -data ./data -username kami
|
||||
```
|
||||
|
||||
Set the emitted hash in the service environment along with the chosen
|
||||
username, then restart the coordinator. `ORCHESTRA_WEB_TOKEN` is not used by
|
||||
the browser UI anymore.
|
||||
For the Docker Compose deployment, the API image includes the same helper.
|
||||
Keep the API stopped while it opens the database, then use the existing data
|
||||
volume through Compose:
|
||||
|
||||
```sh
|
||||
docker compose stop orchestra-api
|
||||
docker compose run --rm --entrypoint /app/orchestra-user \
|
||||
orchestra-api set -data /data -username kami
|
||||
docker compose up -d orchestra-api
|
||||
```
|
||||
|
||||
After signing in, the Settings screen can change the username or password.
|
||||
Every browser session for that account is revoked after a credential change.
|
||||
To recover a forgotten password, stop the API and run `orchestra-user set`
|
||||
again for the same username. `orchestra-user list -data /data` lists usernames
|
||||
without exposing password hashes.
|
||||
|
||||
On the first start after upgrading, an empty auth database automatically
|
||||
imports the existing `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH` pair. Once the startup log confirms the import,
|
||||
remove both legacy values from `.env`; they are ignored whenever the database
|
||||
already contains an account. `ORCHESTRA_WEB_TOKEN` remains unused by the
|
||||
browser UI.
|
||||
|
||||
Build a worker for staging on workpc with:
|
||||
|
||||
@@ -44,7 +67,7 @@ credential: its `build` object is the coordinator provenance. `GET
|
||||
/v1/federation/workers` shows every worker's `build`, supported projects, and
|
||||
worker-local health without SSH.
|
||||
|
||||
Build both binaries with `deploy/build.sh`, which stamps them from one commit
|
||||
Build the coordinator and worker with `deploy/build.sh`, which stamps them from one commit
|
||||
and refuses a dirty tree. A burn-in run must never pair a new coordinator with
|
||||
an old worker, and matching revisions are how that is checked rather than
|
||||
assumed.
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Build the coordinator and the worker from one commit, with one stamp, so a
|
||||
# Build the coordinator, worker, and operator-account helper from one commit,
|
||||
# with one stamp, so a
|
||||
# burn-in run can never pair a new coordinator with an old worker. Both
|
||||
# binaries then report the same revision at /v1/admin/diagnostics and in the
|
||||
# worker's registration, which is what makes deployed identity evidence rather
|
||||
@@ -23,4 +24,5 @@ flags="-s -w -X orchestra/internal/buildinfo.Revision=$rev -X orchestra/internal
|
||||
mkdir -p "$out"
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra" ./cmd/orchestra)
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra-worker" ./cmd/orchestra-worker)
|
||||
(cd "$tree" && go build -trimpath -ldflags="-s -w" -o "$out/orchestra-user" ./cmd/orchestra-user)
|
||||
echo "$rev"
|
||||
|
||||
@@ -139,12 +139,11 @@ ORCHESTRA_CONTEXT_WINDOW=200000
|
||||
# --- Bus authorization tokens (bearer auth per surface; a surface with no
|
||||
# token set has no auth requirement — set these once you have real clients) ---
|
||||
#ORCHESTRA_TUI_TOKEN=
|
||||
# Required: the service refuses to start without both. The browser UI's
|
||||
# task, lifecycle and approval controls are session-gated; it no longer
|
||||
# accepts a shared Web bearer token. Generate the bcrypt hash with:
|
||||
# go run ./cmd/orchestra-password
|
||||
ORCHESTRA_WEB_USERNAME=operator
|
||||
ORCHESTRA_WEB_PASSWORD_HASH=
|
||||
# Browser operators are stored in $ORCHESTRA_DATA/auth.db, not in this file.
|
||||
# With Orchestra stopped, create or reset one interactively with:
|
||||
# orchestra-user set -data /data -username kami
|
||||
# Existing ORCHESTRA_WEB_USERNAME + ORCHESTRA_WEB_PASSWORD_HASH values are
|
||||
# imported once only when auth.db contains no users, then should be removed.
|
||||
# Set when the UI is served over plain HTTP, so the session cookie can be
|
||||
# sent without Secure. Leave unset behind TLS.
|
||||
#ORCHESTRA_UI_INSECURE_COOKIE=1
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/review"
|
||||
)
|
||||
|
||||
// Debt is the projected read model for accumulated cost: the defects,
|
||||
// workarounds and operational burdens that real tasks keep paying for. It is
|
||||
// derived from the event log rather than written by hand, so a claim in the
|
||||
// ledger can always be checked against the events that produced it.
|
||||
//
|
||||
// Four classes, and no more. A fifth invented at runtime makes the priority
|
||||
// model meaningless, because the thresholds in operations.CheckDebtEligibility
|
||||
// are stated per class.
|
||||
type DebtClass string
|
||||
|
||||
const (
|
||||
// DebtCorrectness is behaviour that is wrong or violates an invariant.
|
||||
DebtCorrectness DebtClass = "correctness"
|
||||
// DebtOperational works, but repeatedly costs time to diagnose, recover,
|
||||
// deploy, observe or operate.
|
||||
DebtOperational DebtClass = "operational"
|
||||
// DebtStructural is duplication or architecture demonstrably raising the
|
||||
// cost of future changes.
|
||||
DebtStructural DebtClass = "structural"
|
||||
// DebtPolish has no demonstrated cost yet, and never promotes itself.
|
||||
DebtPolish DebtClass = "polish"
|
||||
)
|
||||
|
||||
func (c DebtClass) Valid() bool {
|
||||
switch c {
|
||||
case DebtCorrectness, DebtOperational, DebtStructural, DebtPolish:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DebtStatus string
|
||||
|
||||
const (
|
||||
DebtObserved DebtStatus = "observed"
|
||||
DebtEligible DebtStatus = "eligible"
|
||||
DebtScheduled DebtStatus = "scheduled"
|
||||
DebtRepaired DebtStatus = "repaired"
|
||||
DebtWithdrawn DebtStatus = "withdrawn"
|
||||
)
|
||||
|
||||
// ObservationKind names where one piece of evidence came from. It is the kind
|
||||
// of the source fact, never an interpretation of it.
|
||||
type ObservationKind string
|
||||
|
||||
const (
|
||||
ObservationBlockReason ObservationKind = "block_reason"
|
||||
ObservationFailureClass ObservationKind = "failure_class"
|
||||
ObservationReviewFinding ObservationKind = "review_finding"
|
||||
ObservationPlanMismatch ObservationKind = "plan_mismatch"
|
||||
ObservationDeferredFinding ObservationKind = "deferred_finding"
|
||||
ObservationManualIntervention ObservationKind = "manual_intervention"
|
||||
ObservationWorkerFailure ObservationKind = "worker_observation"
|
||||
)
|
||||
|
||||
// SignatureVersion prefixes every signature this build produces. Normalization
|
||||
// rules will change, and without a version a change silently regroups every
|
||||
// historical observation, moving the recurrence counts that eligibility was
|
||||
// already decided on. A v2 signature never matches a v1 one, so old evidence
|
||||
// keeps the grouping it was counted under.
|
||||
const SignatureVersion = "v1"
|
||||
|
||||
// DebtSignature is the mechanical dedup key. Every part is a typed fact, never
|
||||
// prose, because exact signature match is the only path that attaches evidence
|
||||
// without a human. An empty part becomes "-" so the arity never varies.
|
||||
func DebtSignature(class DebtClass, reason, scope, component string) string {
|
||||
part := func(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = strings.ReplaceAll(s, ":", "_")
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return strings.Join([]string{SignatureVersion, part(string(class)), part(reason), part(scope), part(component)}, ":")
|
||||
}
|
||||
|
||||
// DebtComponent reduces a repository path to the unit that owns it. Two
|
||||
// segments is the whole rule: it keeps internal/store distinct from
|
||||
// internal/herdr without splitting one package across files.
|
||||
func DebtComponent(path string) string {
|
||||
path = strings.TrimSpace(strings.Trim(path, "/"))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return parts[0] + "/" + parts[1]
|
||||
}
|
||||
|
||||
// DebtObservation is one piece of counted evidence. Provenance is required and
|
||||
// is either an event id or, for history imported from a markdown ledger that
|
||||
// predates this projection, an explicit legacy reference. Exactly one, because
|
||||
// a fabricated event id would break the rule the ledger exists to enforce.
|
||||
type DebtObservation struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind ObservationKind `json:"kind"`
|
||||
Signature string `json:"signature"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
func (o DebtObservation) Validate() error {
|
||||
if (o.EventID == "") == (o.LegacyRef == "") {
|
||||
return fmt.Errorf("observation needs exactly one of event_id and legacy_ref")
|
||||
}
|
||||
if o.Kind == "" {
|
||||
return fmt.Errorf("observation kind required")
|
||||
}
|
||||
if !strings.HasPrefix(o.Signature, SignatureVersion+":") {
|
||||
return fmt.Errorf("observation signature %q is not %s", o.Signature, SignatureVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebtItem groups observations that share a signature. Every count the
|
||||
// priority model needs is derived from Observations rather than stored, so a
|
||||
// count can never drift from the log that justifies it.
|
||||
type DebtItem struct {
|
||||
ID string `json:"id"`
|
||||
Class DebtClass `json:"class"`
|
||||
Status DebtStatus `json:"status"`
|
||||
Symptom string `json:"symptom"`
|
||||
Consequence string `json:"consequence,omitempty"`
|
||||
Severity review.Severity `json:"severity,omitempty"`
|
||||
Signatures []string `json:"signatures"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Observations []DebtObservation `json:"observations"`
|
||||
IntroducedIn string `json:"introduced_in,omitempty"`
|
||||
RepairBoundary string `json:"repair_boundary,omitempty"`
|
||||
RepairTask string `json:"repair_task,omitempty"`
|
||||
RepairCommit string `json:"repair_commit,omitempty"`
|
||||
}
|
||||
|
||||
// Recurrence is how many times this shape has been seen.
|
||||
func (d DebtItem) Recurrence() int { return len(d.Observations) }
|
||||
|
||||
// AffectedTasks is how broadly the shape has spread. Distinct tasks, because
|
||||
// one task failing ten times is weaker evidence than ten tasks failing once.
|
||||
func (d DebtItem) AffectedTasks() int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range d.Observations {
|
||||
if o.TaskID != "" {
|
||||
seen[o.TaskID] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// BlockedTasks counts distinct tasks this shape actually stopped.
|
||||
func (d DebtItem) BlockedTasks() int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range d.Observations {
|
||||
if o.Kind == ObservationBlockReason && o.TaskID != "" {
|
||||
seen[o.TaskID] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// ManualInterventions counts recorded operator repairs. It reads zero on any
|
||||
// log written before that recording exists, which is why the ledger reports it
|
||||
// as an evidence gap rather than as an absence of operator cost.
|
||||
func (d DebtItem) ManualInterventions() int {
|
||||
n := 0
|
||||
for _, o := range d.Observations {
|
||||
if o.Kind == ObservationManualIntervention {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Components is the breadth of the shape across the tree.
|
||||
func (d DebtItem) Components() []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, o := range d.Observations {
|
||||
for _, p := range o.Paths {
|
||||
if c := DebtComponent(p); c != "" && !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Imported reports whether any evidence here came from a markdown ledger
|
||||
// rather than from an event. A reader can then tell a counted fact from an
|
||||
// imported claim without opening the observations.
|
||||
func (d DebtItem) Imported() bool {
|
||||
for _, o := range d.Observations {
|
||||
if o.LegacyRef != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EvidenceGap is a debt signal the ledger knows it cannot see. Reporting the
|
||||
// gap is the point: a ledger that silently omits what it cannot record reads
|
||||
// as "no operator cost" when it means "operator cost is not recorded".
|
||||
type EvidenceGap struct {
|
||||
Kind ObservationKind `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
// Durable is false when no event type carries this evidence at all. It is
|
||||
// true when the log could carry it and this particular log does not.
|
||||
Durable bool `json:"durable"`
|
||||
}
|
||||
|
||||
// DebtLedger is the whole read model: what history establishes, and what it
|
||||
// cannot.
|
||||
type DebtLedger struct {
|
||||
Items []DebtItem `json:"items"`
|
||||
Gaps []EvidenceGap `json:"gaps"`
|
||||
// Events is how many log entries the projection folded, so a caller can
|
||||
// tell an empty ledger from an unread log.
|
||||
Events int `json:"events"`
|
||||
}
|
||||
|
||||
// DebtClassForBlockReason maps a typed block reason to a class. A reason that
|
||||
// is a normal lifecycle stop rather than a cost returns false: waiting for a
|
||||
// human decision is the system working, not debt.
|
||||
func DebtClassForBlockReason(r BlockReason) (DebtClass, bool) {
|
||||
switch r {
|
||||
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired, BlockReasonSystem:
|
||||
return DebtOperational, true
|
||||
case BlockReasonHandoffValidation, BlockReasonPlanMismatch:
|
||||
return DebtCorrectness, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DebtClassForFailureClass maps a worker failure class to a class. Only the
|
||||
// classes a worker actually emits are listed; an unknown one is not guessed at.
|
||||
func DebtClassForFailureClass(f string) (DebtClass, bool) {
|
||||
switch f {
|
||||
case "retry_limit", "launch_failed", "launch_transient", "launch_uncertain", "prompt_not_submitted", "lease_expired":
|
||||
return DebtOperational, true
|
||||
case "invalid_handoff":
|
||||
return DebtCorrectness, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// Normalization rules will change. A v2 signature must never match a v1 one,
|
||||
// or changing them silently regroups history and moves the recurrence counts
|
||||
// eligibility was already decided on.
|
||||
func TestSignatureCarriesItsVersion(t *testing.T) {
|
||||
sig := DebtSignature(DebtOperational, "lease_expired", "workpc-opencode", "internal/herdr")
|
||||
if sig != "v1:operational:lease_expired:workpc-opencode:internal/herdr" {
|
||||
t.Fatalf("signature %q", sig)
|
||||
}
|
||||
// Arity never varies, so a missing part cannot shift the fields left.
|
||||
if got := DebtSignature(DebtPolish, "deferred_finding", "", ""); got != "v1:polish:deferred_finding:-:-" {
|
||||
t.Fatalf("empty parts not padded: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentIsTwoSegments(t *testing.T) {
|
||||
for path, want := range map[string]string{
|
||||
"internal/store/store.go": "internal/store",
|
||||
"internal/herdr/adapter.go": "internal/herdr",
|
||||
"cmd/orchestra-worker/main.go": "cmd/orchestra-worker",
|
||||
"BURNIN.md": "BURNIN.md",
|
||||
"": "",
|
||||
} {
|
||||
if got := DebtComponent(path); got != want {
|
||||
t.Fatalf("component(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Imported history predates the events that would have justified it. A
|
||||
// fabricated event id would break the provenance rule the ledger enforces.
|
||||
func TestObservationNeedsExactlyOneProvenance(t *testing.T) {
|
||||
sig := DebtSignature(DebtOperational, "x", "", "")
|
||||
both := DebtObservation{EventID: "e1", LegacyRef: "BURNIN.md:F18", Kind: ObservationBlockReason, Signature: sig}
|
||||
neither := DebtObservation{Kind: ObservationBlockReason, Signature: sig}
|
||||
if both.Validate() == nil || neither.Validate() == nil {
|
||||
t.Fatal("exactly one of event_id and legacy_ref must be required")
|
||||
}
|
||||
for _, ok := range []DebtObservation{
|
||||
{EventID: "e1", Kind: ObservationBlockReason, Signature: sig},
|
||||
{LegacyRef: "BURNIN.md:F18", Kind: ObservationBlockReason, Signature: sig},
|
||||
} {
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid observation refused: %v", err)
|
||||
}
|
||||
}
|
||||
stale := DebtObservation{EventID: "e1", Kind: ObservationBlockReason, Signature: "v0:operational:x:-:-"}
|
||||
if stale.Validate() == nil {
|
||||
t.Fatal("a signature from another version must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
// DebtCheck is why a debt item may or may not become work. Reasons are listed
|
||||
// rather than summarised, for the same reason SubmissionCheck lists them: "not
|
||||
// eligible" alone sends an operator reading code.
|
||||
type DebtCheck struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDebtEligibility is the whole promotion rule, as one pure function of a
|
||||
// projected item. It decides only whether a maintenance task may be created.
|
||||
// It never creates one, and it never changes what that task must then pass:
|
||||
// classification as debt changes what gets scheduled, never what gets checked.
|
||||
//
|
||||
// The thresholds differ by class on purpose. Correctness and operational debt
|
||||
// have already cost something measurable. Structural debt needs evidence that
|
||||
// it is causing repeated work rather than merely offending taste. Polish never
|
||||
// promotes itself, or the ledger becomes a permanent cleanup generator.
|
||||
func CheckDebtEligibility(item domain.DebtItem) DebtCheck {
|
||||
recurrence, tasks := item.Recurrence(), item.AffectedTasks()
|
||||
blocked, manual := item.BlockedTasks(), item.ManualInterventions()
|
||||
switch item.Class {
|
||||
case domain.DebtCorrectness:
|
||||
if recurrence >= 1 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("correctness debt is eligible on first confirmed observation, and has %d", recurrence)}}
|
||||
}
|
||||
return DebtCheck{false, []string{"no confirmed observation"}}
|
||||
case domain.DebtOperational:
|
||||
var why []string
|
||||
if manual >= 1 {
|
||||
why = append(why, fmt.Sprintf("%d manual intervention(s) recorded", manual))
|
||||
}
|
||||
if recurrence >= 3 && tasks >= 2 {
|
||||
why = append(why, fmt.Sprintf("recurred %d times across %d tasks", recurrence, tasks))
|
||||
}
|
||||
if len(why) > 0 {
|
||||
return DebtCheck{true, why}
|
||||
}
|
||||
return DebtCheck{false, []string{
|
||||
fmt.Sprintf("needs 3 occurrences across 2 tasks, or 1 manual intervention; has %d across %d tasks with %d interventions", recurrence, tasks, manual),
|
||||
"manual interventions are not recorded by any event type, so that count reads 0 on every current log",
|
||||
}}
|
||||
case domain.DebtStructural:
|
||||
if recurrence >= 3 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("%d review findings in this component", recurrence)}}
|
||||
}
|
||||
if blocked >= 2 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("blocked %d distinct tasks in this component", blocked)}}
|
||||
}
|
||||
return DebtCheck{false, []string{
|
||||
fmt.Sprintf("needs 3 review findings or 2 blocked tasks in one component; has %d findings and %d blocked", recurrence, blocked),
|
||||
}}
|
||||
case domain.DebtPolish:
|
||||
return DebtCheck{false, []string{"polish never promotes itself, an operator promotes it explicitly"}}
|
||||
}
|
||||
return DebtCheck{false, []string{"unknown debt class " + string(item.Class)}}
|
||||
}
|
||||
|
||||
// EligibleDebt filters a projected ledger to what policy would allow to become
|
||||
// work. It returns the check alongside each item so the reasons stay visible.
|
||||
func EligibleDebt(ledger domain.DebtLedger) []DebtCandidate {
|
||||
out := []DebtCandidate{}
|
||||
for _, item := range ledger.Items {
|
||||
if check := CheckDebtEligibility(item); check.Eligible {
|
||||
out = append(out, DebtCandidate{Item: item, Check: check})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type DebtCandidate struct {
|
||||
Item domain.DebtItem `json:"item"`
|
||||
Check DebtCheck `json:"check"`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
func debtItem(class domain.DebtClass, kinds ...struct {
|
||||
kind domain.ObservationKind
|
||||
task string
|
||||
}) domain.DebtItem {
|
||||
item := domain.DebtItem{Class: class}
|
||||
for _, k := range kinds {
|
||||
item.Observations = append(item.Observations, domain.DebtObservation{Kind: k.kind, TaskID: k.task})
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
type obs = struct {
|
||||
kind domain.ObservationKind
|
||||
task string
|
||||
}
|
||||
|
||||
// The thresholds are the whole policy, so each class gets its own case. Polish
|
||||
// is the one that must never pass, or the ledger becomes a cleanup generator.
|
||||
func TestDebtEligibilityPerClass(t *testing.T) {
|
||||
fail := obs{domain.ObservationFailureClass, "t1"}
|
||||
if !CheckDebtEligibility(debtItem(domain.DebtCorrectness, fail)).Eligible {
|
||||
t.Fatal("correctness debt is eligible on first observation")
|
||||
}
|
||||
twice := debtItem(domain.DebtOperational, fail, obs{domain.ObservationFailureClass, "t1"})
|
||||
if CheckDebtEligibility(twice).Eligible {
|
||||
t.Fatal("two occurrences on one task must not qualify as operational debt")
|
||||
}
|
||||
spread := debtItem(domain.DebtOperational, fail,
|
||||
obs{domain.ObservationFailureClass, "t2"}, obs{domain.ObservationFailureClass, "t3"})
|
||||
if !CheckDebtEligibility(spread).Eligible {
|
||||
t.Fatal("three occurrences across three tasks must qualify")
|
||||
}
|
||||
manual := debtItem(domain.DebtOperational, obs{domain.ObservationManualIntervention, "t1"})
|
||||
if !CheckDebtEligibility(manual).Eligible {
|
||||
t.Fatal("one manual intervention must qualify on its own")
|
||||
}
|
||||
blocked := debtItem(domain.DebtStructural,
|
||||
obs{domain.ObservationBlockReason, "t1"}, obs{domain.ObservationBlockReason, "t2"})
|
||||
if !CheckDebtEligibility(blocked).Eligible {
|
||||
t.Fatal("two blocked tasks in one component must qualify as structural")
|
||||
}
|
||||
polish := debtItem(domain.DebtPolish, fail, fail, fail, fail, fail)
|
||||
if CheckDebtEligibility(polish).Eligible {
|
||||
t.Fatal("polish must never promote itself, at any recurrence")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal has to say what is missing. "Not eligible" alone sends an operator
|
||||
// reading code, which is the mistake SubmissionCheck already documents.
|
||||
func TestDebtRefusalNamesTheMissingEvidence(t *testing.T) {
|
||||
check := CheckDebtEligibility(debtItem(domain.DebtOperational, obs{domain.ObservationFailureClass, "t1"}))
|
||||
if check.Eligible || len(check.Reasons) == 0 {
|
||||
t.Fatalf("want a refusal with reasons, got %+v", check)
|
||||
}
|
||||
joined := strings.Join(check.Reasons, " ")
|
||||
if !strings.Contains(joined, "manual interventions are not recorded") {
|
||||
t.Fatalf("the refusal must say the intervention count is structurally zero: %v", check.Reasons)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/review"
|
||||
)
|
||||
|
||||
// ProjectDebt folds canonical history into a debt ledger. It is a pure
|
||||
// function of the events so it can be tested against a real log, and it writes
|
||||
// nothing: this projection is a read model, not a new kind of truth.
|
||||
//
|
||||
// It deliberately reports what it cannot see. Worker observations and operator
|
||||
// interventions are not carried by any event type today, so a ledger that
|
||||
// stayed silent about them would read as "no operational cost" when it means
|
||||
// "operational cost is unrecorded".
|
||||
func ProjectDebt(events []domain.Event) domain.DebtLedger {
|
||||
return projectDebt(events, nil)
|
||||
}
|
||||
|
||||
// ProjectDebtWithArtifacts is ProjectDebt plus the CAS reads that review
|
||||
// findings need. Findings are sealed as an artifact rather than inlined in the
|
||||
// event, and they are the only evidence structural debt has, so a projection
|
||||
// without them can never produce a structural item at all.
|
||||
func ProjectDebtWithArtifacts(events []domain.Event, read func(ref string) ([]byte, error)) domain.DebtLedger {
|
||||
return projectDebt(events, read)
|
||||
}
|
||||
|
||||
func projectDebt(events []domain.Event, readArtifact func(string) ([]byte, error)) domain.DebtLedger {
|
||||
bySignature := map[string]*domain.DebtItem{}
|
||||
order := []string{}
|
||||
seenKinds := map[domain.ObservationKind]bool{}
|
||||
|
||||
add := func(class domain.DebtClass, sig string, o domain.DebtObservation, symptom string, sev review.Severity) {
|
||||
o.Signature = sig
|
||||
if o.Validate() != nil {
|
||||
return
|
||||
}
|
||||
seenKinds[o.Kind] = true
|
||||
item, ok := bySignature[sig]
|
||||
if !ok {
|
||||
item = &domain.DebtItem{
|
||||
// The id is the signature, not an Fxx number. This projection
|
||||
// produces candidates from history; adopting a curated id is a
|
||||
// later, deliberate step, and minting one here would collide
|
||||
// with the hand-written ledger.
|
||||
ID: sig, Class: class, Status: domain.DebtObserved,
|
||||
Symptom: symptom, Signatures: []string{sig}, Severity: sev,
|
||||
}
|
||||
bySignature[sig] = item
|
||||
order = append(order, sig)
|
||||
}
|
||||
item.Observations = append(item.Observations, o)
|
||||
for _, p := range o.Paths {
|
||||
if !contains(item.Paths, p) {
|
||||
item.Paths = append(item.Paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
var p map[string]any
|
||||
if len(e.Payload) > 0 && json.Unmarshal(e.Payload, &p) != nil {
|
||||
continue
|
||||
}
|
||||
base := domain.DebtObservation{EventID: e.ID, TaskID: e.TaskID, At: e.At}
|
||||
harness, _ := p["harness_id"].(string)
|
||||
switch e.Type {
|
||||
case "TaskBlocked":
|
||||
reason, _ := p["block_reason"].(string)
|
||||
if reason == "" {
|
||||
reason = string(domain.InferBlockReason(str(p["blocker"])))
|
||||
}
|
||||
class, ok := domain.DebtClassForBlockReason(domain.BlockReason(reason))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationBlockReason
|
||||
o.Detail = str(p["blocker"])
|
||||
add(class, domain.DebtSignature(class, reason, harness, "lease"), o,
|
||||
"tasks are blocked with "+reason, review.Important)
|
||||
case "TaskFailed", "TaskReleased":
|
||||
// A release carries a failure class only when it is a reclaim. A
|
||||
// handoff release is ordinary progress and never debt.
|
||||
failure, _ := p["failure_class"].(string)
|
||||
if failure == "" {
|
||||
failure, _ = p["reason"].(string)
|
||||
}
|
||||
if e.Type == "TaskReleased" && str(p["handoff_ref"]) != "" {
|
||||
continue
|
||||
}
|
||||
class, ok := domain.DebtClassForFailureClass(failure)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationFailureClass
|
||||
o.Detail = str(p["last_error"])
|
||||
add(class, domain.DebtSignature(class, failure, harness, "lease"), o,
|
||||
"tasks end in "+failure, review.Important)
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
o := base
|
||||
o.Kind = domain.ObservationPlanMismatch
|
||||
o.Detail = str(p["observed"])
|
||||
o.Paths = strList(p["evidence"])
|
||||
component := firstComponent(o.Paths)
|
||||
add(domain.DebtCorrectness,
|
||||
domain.DebtSignature(domain.DebtCorrectness, "plan_mismatch", "", component), o,
|
||||
"the repository contradicts sealed plans", review.Important)
|
||||
case domain.EventReviewRecorded:
|
||||
// Repeated findings in one component are the only mechanical
|
||||
// evidence that structure is costing future changes. One finding
|
||||
// is a review doing its job.
|
||||
if readArtifact == nil {
|
||||
break
|
||||
}
|
||||
raw, err := readArtifact(str(p["artifact_ref"]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
result, err := review.Decode(raw)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
for _, f := range result.Findings {
|
||||
component := domain.DebtComponent(f.File)
|
||||
o := base
|
||||
o.Kind = domain.ObservationReviewFinding
|
||||
o.Detail = f.Claim
|
||||
if f.File != "" {
|
||||
o.Paths = []string{f.File}
|
||||
}
|
||||
add(domain.DebtStructural,
|
||||
domain.DebtSignature(domain.DebtStructural, string(f.Severity), "", component), o,
|
||||
"review keeps finding "+string(f.Severity)+" issues in "+component, f.Severity)
|
||||
}
|
||||
case domain.EventDeferredFindingRecorded:
|
||||
o := base
|
||||
o.Kind = domain.ObservationDeferredFinding
|
||||
o.Detail = str(p["summary"])
|
||||
// Polish until something demonstrates a cost. A discovery nobody
|
||||
// has paid for yet is not debt with a priority.
|
||||
add(domain.DebtPolish,
|
||||
domain.DebtSignature(domain.DebtPolish, "deferred_finding", "", ""), o,
|
||||
"out-of-scope discoveries recorded and unaddressed", review.Minor)
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]domain.DebtItem, 0, len(order))
|
||||
for _, sig := range order {
|
||||
items = append(items, *bySignature[sig])
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Recurrence() != items[j].Recurrence() {
|
||||
return items[i].Recurrence() > items[j].Recurrence()
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
return domain.DebtLedger{Items: items, Gaps: debtGaps(seenKinds), Events: len(events)}
|
||||
}
|
||||
|
||||
// debtGaps separates two different silences. A kind no event type carries is a
|
||||
// hole in the system. A kind the log could carry and does not is a fact about
|
||||
// this history.
|
||||
func debtGaps(seen map[domain.ObservationKind]bool) []domain.EvidenceGap {
|
||||
gaps := []domain.EvidenceGap{
|
||||
{Kind: domain.ObservationManualIntervention, Durable: false,
|
||||
Reason: "no event type records an operator repair, so every manual recovery is invisible to this ledger"},
|
||||
{Kind: domain.ObservationWorkerFailure, Durable: false,
|
||||
Reason: "worker observations live in worker memory and reach the coordinator only inside WorkerHealth, which is not persisted"},
|
||||
}
|
||||
for _, k := range []domain.ObservationKind{
|
||||
domain.ObservationBlockReason, domain.ObservationFailureClass,
|
||||
domain.ObservationReviewFinding, domain.ObservationPlanMismatch,
|
||||
domain.ObservationDeferredFinding,
|
||||
} {
|
||||
if !seen[k] {
|
||||
gaps = append(gaps, domain.EvidenceGap{Kind: k, Durable: true,
|
||||
Reason: "carried by the log, but this history contains none"})
|
||||
}
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
// DebtLedger projects the whole log. Read-only, like every other projection
|
||||
// the coordinator serves.
|
||||
func (s *Store) DebtLedger() domain.DebtLedger {
|
||||
return ProjectDebtWithArtifacts(s.Events(0), s.Artifact)
|
||||
}
|
||||
|
||||
func str(v any) string { s, _ := v.(string); return s }
|
||||
|
||||
func strList(v any) []string {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
if s := strings.TrimSpace(str(item)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstComponent(paths []string) string {
|
||||
for _, p := range paths {
|
||||
if c := domain.DebtComponent(p); c != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, item := range list {
|
||||
if item == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
func debtEvent(id, typ, task string, payload map[string]any) domain.Event {
|
||||
b, _ := json.Marshal(payload)
|
||||
return domain.Event{ID: id, Type: typ, TaskID: task, Payload: b, At: time.Now().UTC()}
|
||||
}
|
||||
|
||||
// The opencode shape from run 14: one harness failing the same way across
|
||||
// several tasks. That is the recurrence the ledger exists to notice.
|
||||
func TestProjectDebtGroupsOneFailureShapeAcrossTasks(t *testing.T) {
|
||||
events := []domain.Event{
|
||||
debtEvent("e1", "TaskFailed", "t1", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e2", "TaskFailed", "t2", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e3", "TaskFailed", "t3", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e4", "TaskFailed", "t4", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-claude"}),
|
||||
// A handoff release is ordinary progress and must not become debt.
|
||||
debtEvent("e5", "TaskReleased", "t5", map[string]any{"handoff_ref": "sha256:abc", "harness_id": "workpc-claude"}),
|
||||
}
|
||||
ledger := ProjectDebt(events)
|
||||
if len(ledger.Items) != 2 {
|
||||
t.Fatalf("want one item per harness, got %d: %+v", len(ledger.Items), ledger.Items)
|
||||
}
|
||||
top := ledger.Items[0]
|
||||
if top.Recurrence() != 3 || top.AffectedTasks() != 3 {
|
||||
t.Fatalf("recurrence %d across %d tasks, want 3 and 3", top.Recurrence(), top.AffectedTasks())
|
||||
}
|
||||
if top.Class != domain.DebtOperational {
|
||||
t.Fatalf("class %q, want operational", top.Class)
|
||||
}
|
||||
if got := top.ID; got != "v1:operational:retry_limit:workpc-opencode:lease" {
|
||||
t.Fatalf("signature %q", got)
|
||||
}
|
||||
for _, o := range top.Observations {
|
||||
if o.EventID == "" || o.LegacyRef != "" {
|
||||
t.Fatalf("observation lost its event provenance: %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A lifecycle stop is the system working. Waiting for a human is not debt, and
|
||||
// counting it would drown the real signal.
|
||||
func TestProjectDebtIgnoresOrdinaryLifecycleStops(t *testing.T) {
|
||||
events := []domain.Event{
|
||||
debtEvent("e1", "TaskBlocked", "t1", map[string]any{"block_reason": "human_decision", "blocker": "which route"}),
|
||||
debtEvent("e2", "TaskBlocked", "t2", map[string]any{"block_reason": "trajectory_gate", "blocker": "confirm plan"}),
|
||||
debtEvent("e3", "TaskBlocked", "t3", map[string]any{"block_reason": "lease_expired", "blocker": "lease expired"}),
|
||||
}
|
||||
ledger := ProjectDebt(events)
|
||||
if len(ledger.Items) != 1 || ledger.Items[0].Class != domain.DebtOperational {
|
||||
t.Fatalf("want only the lease_expired item, got %+v", ledger.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// Incompleteness is part of the result. A ledger that stays silent about what
|
||||
// it cannot record reads as "no operator cost" when it means "operator cost is
|
||||
// not recorded anywhere".
|
||||
func TestProjectDebtReportsWhatItCannotSee(t *testing.T) {
|
||||
ledger := ProjectDebt(nil)
|
||||
var manual, worker bool
|
||||
for _, g := range ledger.Gaps {
|
||||
if g.Durable {
|
||||
continue
|
||||
}
|
||||
switch g.Kind {
|
||||
case domain.ObservationManualIntervention:
|
||||
manual = true
|
||||
case domain.ObservationWorkerFailure:
|
||||
worker = true
|
||||
}
|
||||
}
|
||||
if !manual || !worker {
|
||||
t.Fatalf("the two known holes must always be reported: %+v", ledger.Gaps)
|
||||
}
|
||||
for _, g := range ledger.Gaps {
|
||||
if g.Reason == "" {
|
||||
t.Fatalf("gap %q has no reason", g.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,13 @@ func (s *Store) apply(e domain.Event) error {
|
||||
t.Lease = nil
|
||||
case "TaskBlocked", "TaskNeedsAttention":
|
||||
if e.Type == "TaskBlocked" {
|
||||
if t.Lease != nil {
|
||||
// Same reason as TaskReleased: a worker may hold a pushed
|
||||
// anchor whose commit was refused. A reopen returns the task
|
||||
// to the queue, and the late-handoff path can only accept it
|
||||
// if the epoch that ended is still on record.
|
||||
t.LastLeaseEpoch = t.Lease.Epoch
|
||||
}
|
||||
t.State = domain.StateBlocked
|
||||
t.Lease = nil
|
||||
} else {
|
||||
|
||||
@@ -693,3 +693,33 @@ func TestExpiryRetainsLeaseEpoch(t *testing.T) {
|
||||
t.Fatalf("last lease epoch %q, want %q", after.LastLeaseEpoch, epoch)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker can hold a pushed anchor whose commit was refused when an operator
|
||||
// blocks the task. A reopen returns it to the queue, and the late-handoff path
|
||||
// can only accept that exact owner if the epoch that ended is still recorded.
|
||||
func TestBlockRetainsLeaseEpochForALaterReopen(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(created("e1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := s.Tasks()[0].ID
|
||||
if _, err := s.Lease(id, "h1", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leased, _ := s.Task(id)
|
||||
epoch := leased.Lease.Epoch
|
||||
p, _ := json.Marshal(map[string]any{"blocker": "parked by the operator", "harness_id": "h1", "lease_epoch": epoch})
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: id, Version: leased.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := s.Task(id)
|
||||
if after.State != domain.StateBlocked || after.Lease != nil {
|
||||
t.Fatalf("expected a blocked unleased task, got %s lease=%v", after.State, after.Lease)
|
||||
}
|
||||
if after.LastLeaseEpoch != epoch || epoch == "" {
|
||||
t.Fatalf("last lease epoch %q, want %q", after.LastLeaseEpoch, epoch)
|
||||
}
|
||||
}
|
||||
|
||||
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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ETHOS — icon set
|
||||
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
|
||||
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
|
||||
pause, prev, next, more) are the only filled exceptions.
|
||||
|
||||
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
color + width/height come from the consumer; the icon inherits them.
|
||||
|
||||
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
|
||||
same corner feel. Match the existing hand — do not import lucide or
|
||||
any other pack. Keep ids prefixed i- and kebab-cased.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
|
||||
<!-- ── app mark / motif seeds ── -->
|
||||
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── navigation ── -->
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
|
||||
</symbol>
|
||||
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── transport (filled) ── -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
|
||||
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
|
||||
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── actions / status ── -->
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
@@ -1,3 +1,13 @@
|
||||
<script type="module" crossorigin src="/assets/index-C0H-zYgt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B0BkAbwp.css">
|
||||
<div id="root"></div>
|
||||
<!doctype html>
|
||||
<html lang="en" data-app="orchestra">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>orchestra</title>
|
||||
<script type="module" crossorigin src="/assets/index-BAiY1a7C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BUuDvtvX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-1
@@ -1 +1,12 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
<!doctype html>
|
||||
<html lang="en" data-app="orchestra">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>orchestra</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ETHOS — icon set
|
||||
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
|
||||
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
|
||||
pause, prev, next, more) are the only filled exceptions.
|
||||
|
||||
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
color + width/height come from the consumer; the icon inherits them.
|
||||
|
||||
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
|
||||
same corner feel. Match the existing hand — do not import lucide or
|
||||
any other pack. Keep ids prefixed i- and kebab-cased.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
|
||||
<!-- ── app mark / motif seeds ── -->
|
||||
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── navigation ── -->
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
|
||||
</symbol>
|
||||
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── transport (filled) ── -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
|
||||
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
|
||||
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── actions / status ── -->
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Shell } from './shell/Shell'
|
||||
import { Dashboard } from './screens/Dashboard'
|
||||
import { Tasks } from './screens/Tasks'
|
||||
import { TaskDetail } from './screens/TaskDetail'
|
||||
import { Terminal } from './screens/Terminal'
|
||||
import { Decisions } from './screens/Decisions'
|
||||
import { Workers } from './screens/Workers'
|
||||
import { Projects } from './screens/Projects'
|
||||
import { Review } from './screens/Review'
|
||||
import { Settings } from './screens/Settings'
|
||||
import { api } from './api/client'
|
||||
import type { Account } from './api/types'
|
||||
|
||||
/** The operator console. Auth stays in main.tsx; this is only what an
|
||||
* authenticated operator sees. */
|
||||
export function Console({
|
||||
account,
|
||||
onLogout,
|
||||
onCredentialsChanged,
|
||||
}: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const overview = useQuery({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* No revision in the top bar yet. The overview carries worker builds,
|
||||
not the coordinator's, and labelling a worker build as the
|
||||
coordinator's would be a lie in the one place an operator trusts. */}
|
||||
<Route element={<Shell live={!overview.isError} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="tasks/:id" element={<TaskDetail />} />
|
||||
<Route path="tasks/:id/terminal" element={<Terminal />} />
|
||||
<Route path="decisions" element={<Decisions />} />
|
||||
<Route path="workers" element={<Workers />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="review" element={<Review />} />
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<Settings
|
||||
account={account}
|
||||
onLogout={onLogout}
|
||||
onCredentialsChanged={onCredentialsChanged}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
+33
-13
@@ -17,20 +17,40 @@ describe('UI API client',()=>{
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
|
||||
it('a zero Go timestamp is stripped rather than rendered as a date', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
tasks: [{ id: 't', blocked_at: '0001-01-01T00:00:00Z', at: '2026-08-29T00:00:00Z' }],
|
||||
workers: [],
|
||||
sessions: [],
|
||||
updated_at: '2026-08-29T00:00:00Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const overview = await api.overview()
|
||||
const task = overview.tasks[0] as unknown as Record<string, unknown>
|
||||
expect(task.blocked_at).toBeUndefined()
|
||||
expect(task.at).toBe('2026-08-29T00:00:00Z')
|
||||
expect(overview.updated_at).toBe('2026-08-29T00:00:00Z')
|
||||
})
|
||||
})
|
||||
|
||||
+39
-14
@@ -1,4 +1,4 @@
|
||||
import type { Account, CreatedEvent, Detail, Overview } from './types'
|
||||
import type { Account, CreatedEvent, DebtLedger, Detail, Event, Overview, Worker } from './types'
|
||||
|
||||
function sessionExpired(response: Response) {
|
||||
if (response.status === 401) {
|
||||
@@ -11,6 +11,27 @@ async function responseError(response: Response) {
|
||||
return new Error(message || `${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
|
||||
* not omit a struct, so an absent timestamp arrives populated-looking. Every
|
||||
* screen that formatted one rendered "739855d ago", which reads as data.
|
||||
* Stripping it here is one guard on the boundary every screen reads through,
|
||||
* rather than one guard per screen per field. */
|
||||
function stripZeroTimes(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('0001-01-01') ? undefined : value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(stripZeroTimes)
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const cleaned = stripZeroTimes(v)
|
||||
if (cleaned !== undefined) out[k] = cleaned
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
@@ -23,7 +44,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
sessionExpired(response)
|
||||
throw await responseError(response)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
return stripZeroTimes(await response.json()) as T
|
||||
}
|
||||
|
||||
async function text(path: string) {
|
||||
@@ -50,10 +71,10 @@ async function upload(body: string) {
|
||||
}
|
||||
|
||||
async function session(): Promise<Account | undefined> {
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
@@ -64,7 +85,7 @@ async function login(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -76,15 +97,14 @@ async function logout() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session,
|
||||
session,
|
||||
login,
|
||||
logout,
|
||||
account: () => request<Account>('/v1/ui/account'),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
overview: () => request<Overview>('/v1/ui/overview'),
|
||||
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
|
||||
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
|
||||
@@ -94,6 +114,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// Endpoints outside /v1/ui that the console reads directly. Adding a UI
|
||||
// wrapper for each would be a second copy of the same projection.
|
||||
workers: () => request<Worker[]>('/v1/federation/workers'),
|
||||
events: (since = 0) => request<Event[]>(`/v1/events?since=${since}`),
|
||||
debt: () => request<DebtLedger>('/v1/debt'),
|
||||
action: (id: string, action: string, body: object = {}) =>
|
||||
request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -35,7 +35,27 @@ export interface SessionEvidence {
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
/** Mirrors domain.DecisionRequest. Fields are optional here because a
|
||||
* projection may omit them, and a screen must never assume a question. */
|
||||
export interface DecisionOption {
|
||||
id?: string
|
||||
description?: string
|
||||
tradeoff?: string
|
||||
}
|
||||
|
||||
export interface DecisionRequest {
|
||||
question?: string
|
||||
why?: string
|
||||
options?: DecisionOption[]
|
||||
evidence?: string[]
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
/** On the wire from domain.Task. Three screens widened these locally
|
||||
* before they lived here. */
|
||||
work_phase?: string
|
||||
last_lease_epoch?: string
|
||||
decision_request?: DecisionRequest
|
||||
id: string
|
||||
source: string
|
||||
external_id: string
|
||||
@@ -122,7 +142,17 @@ export interface Detail {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
/** One distinct worker failure with its repeat count. The count is the
|
||||
* signal: a repeat means something looped rather than merely broke. */
|
||||
export interface Observation {
|
||||
message: string
|
||||
count: number
|
||||
first: string
|
||||
last: string
|
||||
}
|
||||
|
||||
export interface WorkerHealth {
|
||||
observations?: Observation[]
|
||||
backend?: 'herdr' | 'tmux'
|
||||
herdr_status: 'reachable' | 'unreachable' | 'unknown'
|
||||
checked_at?: string
|
||||
@@ -154,3 +184,41 @@ export interface CreatedEvent {
|
||||
task_id: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/** The read-only debt projection. Items are candidates derived from history,
|
||||
* and gaps name the evidence the log cannot carry at all. */
|
||||
export interface DebtObservation {
|
||||
event_id?: string
|
||||
legacy_ref?: string
|
||||
task_id?: string
|
||||
kind: string
|
||||
signature: string
|
||||
detail?: string
|
||||
paths?: string[]
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface DebtItem {
|
||||
id: string
|
||||
class: 'correctness' | 'operational' | 'structural' | 'polish'
|
||||
status: string
|
||||
symptom: string
|
||||
consequence?: string
|
||||
severity?: string
|
||||
signatures: string[]
|
||||
paths?: string[]
|
||||
observations: DebtObservation[]
|
||||
}
|
||||
|
||||
export interface EvidenceGap {
|
||||
kind: string
|
||||
reason: string
|
||||
durable: boolean
|
||||
}
|
||||
|
||||
export interface DebtLedger {
|
||||
items: DebtItem[]
|
||||
gaps: EvidenceGap[]
|
||||
events: number
|
||||
eligible?: { item: DebtItem; check: { eligible: boolean; reasons?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** One sprite, one hand. Never import a generic icon pack: that breaks the
|
||||
* single-hand rule the ethos set exists to hold. */
|
||||
export function Icon({ name, size = 20 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<use href={`/ethos-icons.svg#i-${name}`} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
/** Machine values are mono, always. A label is human, its value is not. */
|
||||
export function M({ children }: { children: ReactNode }) {
|
||||
return <span className="mono">{children}</span>
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
count,
|
||||
trailing,
|
||||
children,
|
||||
bodied = false,
|
||||
}: {
|
||||
title?: string
|
||||
count?: number
|
||||
trailing?: ReactNode
|
||||
children: ReactNode
|
||||
bodied?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="panel">
|
||||
{title && (
|
||||
<header className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
{count !== undefined && <span className="count">{count}</span>}
|
||||
{trailing && <span className="trailing">{trailing}</span>}
|
||||
</header>
|
||||
)}
|
||||
{bodied ? <div className="panel-body">{children}</div> : children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type Health = 'healthy' | 'degraded' | 'error' | 'unknown'
|
||||
|
||||
export function Dot({ health }: { health: Health }) {
|
||||
return <span className="dot" data-health={health} />
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone?: 'accent' | 'warn' | 'fault' | 'done'
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<span className="chip" data-tone={tone}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value)))
|
||||
return (
|
||||
<span className="meter" role="img" aria-label={`${pct} percent`}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ace-fca path, drawn as the routing fork. Completed phases read quiet
|
||||
* green, the current phase carries the accent, the future stays neutral. */
|
||||
export const PHASES = ['frame', 'research', 'plan', 'implement', 'review', 'pr', 'merged'] as const
|
||||
export type Phase = (typeof PHASES)[number]
|
||||
|
||||
export function PhasePath({ current, labels = true }: { current?: string; labels?: boolean }) {
|
||||
const at = PHASES.indexOf((current || 'frame') as Phase)
|
||||
return (
|
||||
<div className="phases">
|
||||
{PHASES.map((phase, i) => {
|
||||
const state = i < at ? 'done' : i === at ? 'current' : 'todo'
|
||||
return (
|
||||
<span key={phase} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{i > 0 && <span className="phase-link" data-state={i <= at ? 'done' : 'todo'} />}
|
||||
<span className="phase-step" data-state={state}>
|
||||
<span className="phase-node" />
|
||||
{labels && <span className="phase-name">{phase}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Empty({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<span className="motif">
|
||||
<Icon name="fork" size={28} />
|
||||
</span>
|
||||
<h3>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A screen with no endpoint says which one it needs. The interface never
|
||||
* implies data it does not have. */
|
||||
export function EndpointGap({ path, what }: { path: string; what: string }) {
|
||||
return (
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">
|
||||
no endpoint yet: {path}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-mid)', marginBottom: 0 }}>{what}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
|
||||
* not omit a struct. Every timestamp off the wire can therefore arrive
|
||||
* populated-looking and mean absent. Without this guard a screen prints
|
||||
* "739000d ago" and reads as data. */
|
||||
export function absent(at?: string) {
|
||||
return !at || at.startsWith('0001-01-01')
|
||||
}
|
||||
|
||||
/** A relative age, or null when the timestamp is absent. Never a fabricated
|
||||
* duration. */
|
||||
export function ago(at?: string): string | null {
|
||||
if (absent(at)) return null
|
||||
const seconds = Math.round((Date.now() - new Date(at as string).getTime()) / 1000)
|
||||
if (!Number.isFinite(seconds)) return null
|
||||
if (seconds < 60) return `${Math.max(0, seconds)}s ago`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
}
|
||||
+193
-165
@@ -19,7 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { api } from './api/client'
|
||||
import type {
|
||||
Account,
|
||||
Account,
|
||||
Action,
|
||||
BlockReason,
|
||||
Capture,
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
Worker,
|
||||
} from './api/types'
|
||||
import './style.css'
|
||||
import './styles/orchestra.css'
|
||||
import { Console } from './Console'
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -46,9 +48,9 @@ const historyStates: TaskState[] = ['completed', 'failed']
|
||||
const stateLabel: Record<TaskState, string> = {
|
||||
queued: 'Queued',
|
||||
leased: 'In session',
|
||||
needs_attention: 'Needs attention',
|
||||
needs_attention: 'Needs attention',
|
||||
blocked: 'Blocked',
|
||||
in_review: 'In review',
|
||||
in_review: 'In review',
|
||||
completed: 'Complete',
|
||||
failed: 'Failed',
|
||||
}
|
||||
@@ -61,9 +63,9 @@ const blockLabel: Record<BlockReason, string> = {
|
||||
handoff_validation: 'Handoff validation',
|
||||
operator_block: 'Operator block',
|
||||
system_error: 'System error',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
@@ -100,8 +102,8 @@ type IconName =
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'terminal'
|
||||
| 'users'
|
||||
| 'x'
|
||||
@@ -169,13 +171,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||
<path d="M7 7h.01M7 17h.01" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
terminal: (
|
||||
<>
|
||||
<path d="m4 6 5 5-5 5M11 18h9" />
|
||||
@@ -256,8 +258,8 @@ function humanize(value: string) {
|
||||
}
|
||||
|
||||
function initials(username: string) {
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function sessionFor(task: Task, overview: Overview) {
|
||||
@@ -284,13 +286,13 @@ function taskExplanation(task: Task, overview: Overview) {
|
||||
if (session?.blocker) return session.blocker
|
||||
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'blocked') {
|
||||
return task.blocker || 'No blocker detail was retained for this task'
|
||||
}
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
|
||||
return 'Work and completion evidence retained'
|
||||
}
|
||||
@@ -344,7 +346,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
['board', 'Open dispatch board', '', 'grid'],
|
||||
['new', 'Create a new task', 'N', 'plus'],
|
||||
['workers', 'Open worker pool', '', 'server'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['refresh', 'Refresh live data', 'R', 'refresh'],
|
||||
] as const,
|
||||
[],
|
||||
@@ -364,7 +366,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
const choose = (id: string) => {
|
||||
if (id === 'board') navigate('/')
|
||||
if (id === 'workers') navigate('/workers')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'new') {
|
||||
navigate('/')
|
||||
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
|
||||
@@ -451,8 +453,8 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
? 'Dispatch board'
|
||||
: location.pathname === '/workers'
|
||||
? 'Worker pool'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname.startsWith('/artifacts/')
|
||||
? 'Evidence artifact'
|
||||
: 'Task record'
|
||||
@@ -501,10 +503,10 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
<span>Workers</span>
|
||||
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className={onlineWorkers ? 'signal online' : 'signal'} />
|
||||
@@ -538,17 +540,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="account-menu">
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -941,9 +943,9 @@ function OverviewPage() {
|
||||
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
|
||||
const pendingApprovals = sessions.filter((session) => session.pending_approval)
|
||||
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const queued = data.tasks.filter((task) => task.state === 'queued').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
|
||||
const onlineWorkers = data.workers.filter((worker) => worker.online).length
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -1267,14 +1269,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
|
||||
const title = approval
|
||||
? 'Waiting for operator approval'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? blockLabel[detail.task.block_reason || 'unknown']
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
|
||||
: stateLabel[detail.task.state]
|
||||
const explanation = approval
|
||||
? 'The harness is paused at a permission boundary. Review the exact request below.'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? detail.task.blocker || 'No blocker detail was retained.'
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture
|
||||
@@ -1282,8 +1284,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
|
||||
: detail.task.state === 'queued'
|
||||
? 'This task is eligible for routing when a compatible worker has capacity.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: 'This is a terminal task record with retained evidence.'
|
||||
|
||||
return (
|
||||
@@ -1581,114 +1583,116 @@ function Workers() {
|
||||
}
|
||||
|
||||
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = newPassword.length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = Array.from(newPassword).length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-new-password">New password
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="account-confirm-password">Confirm new password
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</label>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-new-password">New password</label>
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-confirm-password">Confirm new password</label>
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</div>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Artifact() {
|
||||
@@ -1721,7 +1725,7 @@ function Artifact() {
|
||||
)
|
||||
}
|
||||
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: () => void; message?: string }) {
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: (account: Account) => void; message?: string }) {
|
||||
const usernameInput = useRef<HTMLInputElement>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -1742,9 +1746,9 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
setError('')
|
||||
setPending(true)
|
||||
try {
|
||||
await api.login(username, password)
|
||||
const account = await api.login(username, password)
|
||||
setPassword('')
|
||||
onAuthenticated()
|
||||
onAuthenticated(account)
|
||||
} catch {
|
||||
setError('That username or password was not accepted. Check both and try again.')
|
||||
} finally {
|
||||
@@ -1753,9 +1757,26 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<main className="legacy login-page">
|
||||
<div className="login-grid" aria-hidden="true" />
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-layout">
|
||||
<section className="login-showcase" aria-label="Orchestra overview">
|
||||
<div className="showcase-brand"><Logo /><span><b>Orchestra</b><small>Unattended work, under control</small></span></div>
|
||||
<div className="showcase-copy">
|
||||
<span className="eyebrow">Operator console</span>
|
||||
<h2>Keep every agent<br />on the same score.</h2>
|
||||
<p>Dispatch work, inspect live sessions, resolve decisions, and retain the evidence that brought each task home.</p>
|
||||
</div>
|
||||
<div className="showcase-flow" aria-hidden="true">
|
||||
<span><i className="flow-dot queued" /> Queue</span><b />
|
||||
<span><i className="flow-dot active" /> Agent</span><b />
|
||||
<span><i className="flow-dot review" /> Review</span><b />
|
||||
<span><i className="flow-dot done" /> Done</span>
|
||||
</div>
|
||||
<footer><span className="signal online" /> Control plane ready</footer>
|
||||
</section>
|
||||
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<header className="login-brand"><Logo /><span><b>Orchestra</b><small>Control plane</small></span></header>
|
||||
<div className="login-heading">
|
||||
<span className="eyebrow">Operator access</span>
|
||||
@@ -1799,10 +1820,11 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
</button>
|
||||
</form>
|
||||
<footer className="login-security">
|
||||
<span><Icon name="check" size={15} /></span>
|
||||
<p>Credentials are verified server-side. This browser receives an HttpOnly session cookie that expires after 12 hours.</p>
|
||||
<span><Icon name="shield" size={15} /></span>
|
||||
<p>Your password is verified against Orchestra’s local operator database. The browser receives only a 12-hour HttpOnly session cookie.</p>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1820,13 +1842,14 @@ function NotFound() {
|
||||
)
|
||||
}
|
||||
|
||||
function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
function RoutesApp({ account, onLogout, onCredentialsChanged }: { account: Account; onLogout: () => void; onCredentialsChanged: (username: string) => void }) {
|
||||
return (
|
||||
<Shell onLogout={onLogout}>
|
||||
<Shell account={account} onLogout={onLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/tasks/:taskID" element={<TaskDetail />} />
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
<Route path="/settings" element={<Settings account={account} onCredentialsChanged={onCredentialsChanged} />} />
|
||||
<Route path="/artifacts/:ref" element={<Artifact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
@@ -1835,7 +1858,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const [account, setAccount] = useState<Account>()
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -1843,13 +1866,13 @@ function App() {
|
||||
const unauthorized = () => {
|
||||
client.clear()
|
||||
setMessage('Your browser session expired. Sign in again to continue.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
setChecking(false)
|
||||
}
|
||||
window.addEventListener('orchestra:unauthorized', unauthorized)
|
||||
api.overview()
|
||||
.then(() => setReady(true))
|
||||
.catch(() => setReady(false))
|
||||
api.session()
|
||||
.then((session) => setAccount(session))
|
||||
.catch(() => setAccount(undefined))
|
||||
.finally(() => setChecking(false))
|
||||
return () => window.removeEventListener('orchestra:unauthorized', unauthorized)
|
||||
}, [])
|
||||
@@ -1858,12 +1881,17 @@ function App() {
|
||||
await api.logout()
|
||||
client.clear()
|
||||
setMessage('You have signed out.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
}
|
||||
const credentialsChanged = (username: string) => {
|
||||
client.clear()
|
||||
setMessage(`Credentials for ${username} were saved. Sign in again to continue.`)
|
||||
setAccount(undefined)
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<main className="legacy login-page">
|
||||
<section className="session-check" aria-live="polite">
|
||||
<Logo /><span className="spinner" /><span>Checking your operator session…</span>
|
||||
</section>
|
||||
@@ -1871,9 +1899,9 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
return ready
|
||||
? <RoutesApp onLogout={logout} />
|
||||
: <Login message={message} onAuthenticated={() => { client.clear(); setMessage(''); setReady(true) }} />
|
||||
return account
|
||||
? <Console account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
|
||||
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Dashboard-only geometry. Everything structural (panel, row, table, stat,
|
||||
chip, meter) comes from orchestra.css and is not restated here. */
|
||||
|
||||
.head-stamp {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.stamp-fault {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
/* The attention mark is a square of the row's own tone — a signal, never a
|
||||
wash across the row. */
|
||||
.att-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.att-mark[data-kind='fault'] {
|
||||
border-color: rgba(216, 166, 87, 0.3);
|
||||
background: rgba(216, 166, 87, 0.1);
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.att-mark[data-kind='review'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.att-task {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.att-when {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-main,
|
||||
.att-task,
|
||||
.cell-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-title,
|
||||
.row-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.phase-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table td .mono[data-expired] {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.worker-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.worker-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.worker-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.worker-line:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.worker-seen {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.worker-errors {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.worker-errors p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.att-task,
|
||||
.att-when {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.worker-line {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { BlockReason, Overview, Session, Task, Worker } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Dashboard.css'
|
||||
|
||||
/* The ace-fca phase lives on the task as work_phase; the shared Task type has
|
||||
not caught up with the Go domain yet, so it is read through a narrow cast
|
||||
rather than invented or renamed. */
|
||||
const phaseOf = (t: Task) => (t as Task & { work_phase?: string }).work_phase
|
||||
|
||||
const now = () => Date.now()
|
||||
|
||||
/** "18s ago" / "2m ago" / "1h ago" — a machine value, so it renders mono. */
|
||||
function ago(at?: string) {
|
||||
// Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty does
|
||||
// not omit a struct, so an absent timestamp arrives populated-looking. Left
|
||||
// unguarded this rendered "739855d ago", which reads as data.
|
||||
if (!at || at.startsWith('0001-01-01')) return undefined
|
||||
const s = Math.max(0, Math.round((now() - Date.parse(at)) / 1000))
|
||||
if (!Number.isFinite(s)) return undefined
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
/** Time remaining on a lease as mm:ss, or "expired" when it already ran out. */
|
||||
function until(at?: string) {
|
||||
if (!at) return undefined
|
||||
const s = Math.round((Date.parse(at) - now()) / 1000)
|
||||
if (!Number.isFinite(s)) return undefined
|
||||
if (s <= 0) return 'expired'
|
||||
const m = Math.floor(s / 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id)
|
||||
|
||||
/* Why an item is on the operator's desk. Every line is derived from a real
|
||||
block_reason or state — none of it is decorative copy. */
|
||||
const attentionReason: Record<BlockReason, string> = {
|
||||
trajectory_gate: 'A trajectory gate is waiting for your steering.',
|
||||
human_decision: 'The agent asked for a decision it may not make itself.',
|
||||
operator_required: 'Orchestra cannot proceed without an operator action.',
|
||||
approval: 'A tool or edit approval is pending.',
|
||||
handoff_validation: 'The handoff failed validation and was not accepted.',
|
||||
lease_failure: 'The lease could not be established.',
|
||||
lease_expired: 'The lease expired before the agent finished.',
|
||||
worker_offline: 'The worker holding this task went offline.',
|
||||
system_error: 'Orchestra hit an error it could not retry past.',
|
||||
operator_block: 'You blocked this task.',
|
||||
unknown: 'Blocked for a reason orchestra could not classify.',
|
||||
}
|
||||
|
||||
const stateReason: Partial<Record<Task['state'], string>> = {
|
||||
in_review: 'A pull request is waiting on review.',
|
||||
failed: 'The task failed and will not retry on its own.',
|
||||
needs_attention: 'Orchestra flagged this task for you.',
|
||||
blocked: 'The task is blocked.',
|
||||
}
|
||||
|
||||
function reasonFor(t: Task) {
|
||||
if (t.block_reason && attentionReason[t.block_reason]) return attentionReason[t.block_reason]
|
||||
return stateReason[t.state] ?? 'Waiting on you.'
|
||||
}
|
||||
|
||||
const needsOperator = (t: Task) =>
|
||||
t.state === 'needs_attention' || t.state === 'blocked' || t.state === 'failed' || t.state === 'in_review'
|
||||
|
||||
/** Sessions carry no task id of their own unless a capture is attached, so
|
||||
* they are matched on the pane the task last held. */
|
||||
function sessionFor(t: Task, sessions: Session[]) {
|
||||
return sessions.find(
|
||||
(s) =>
|
||||
s.capture?.task_id === t.id ||
|
||||
(t.last_pane_id !== undefined && s.pane_id === t.last_pane_id) ||
|
||||
(t.lease !== undefined && s.harness_id === t.lease.harness_id),
|
||||
)
|
||||
}
|
||||
|
||||
function attentionIcon(t: Task) {
|
||||
if (t.state === 'in_review') return 'review'
|
||||
if (t.block_reason === 'trajectory_gate') return 'fork'
|
||||
if (t.block_reason === 'human_decision' || t.block_reason === 'approval') return 'decision'
|
||||
return 'alert'
|
||||
}
|
||||
|
||||
function Attention({ tasks }: { tasks: Task[] }) {
|
||||
return (
|
||||
<Panel
|
||||
title="Needs your attention"
|
||||
count={tasks.length}
|
||||
trailing={<Link to="/tasks">View all</Link>}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<Empty title="Nothing is waiting on you">
|
||||
<p>Blocked tasks, trajectory gates and open reviews land here.</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="rows">
|
||||
{tasks.map((t) => (
|
||||
<Link className="row" key={t.id} to={`/tasks/${t.id}`}>
|
||||
<span className="att-mark" data-kind={t.state === 'in_review' ? 'review' : 'fault'}>
|
||||
<Icon name={attentionIcon(t)} size={18} />
|
||||
</span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{t.title || 'Untitled task'}</span>
|
||||
<span className="row-sub">{reasonFor(t)}</span>
|
||||
</span>
|
||||
<span className="att-task">
|
||||
<span className="row-title">{t.title ? t.project : t.id}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
{phaseOf(t) && (
|
||||
<>
|
||||
{' · '}
|
||||
<M>{phaseOf(t)}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{t.blocked_at && (
|
||||
<span className="mono att-when">{ago(t.blocked_at)}</span>
|
||||
)}
|
||||
<Chip tone={t.state === 'in_review' ? 'accent' : 'warn'}>
|
||||
{t.block_reason ?? t.state}
|
||||
</Chip>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Running({ tasks, sessions }: { tasks: Task[]; sessions: Session[] }) {
|
||||
return (
|
||||
<Panel
|
||||
title="Running tasks"
|
||||
count={tasks.length}
|
||||
trailing={<Link to="/tasks">View all tasks</Link>}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<Empty title="No task holds a lease">
|
||||
<p>Leased sessions appear here with their phase, worker and lease clock.</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Phase</th>
|
||||
<th>Worker / harness</th>
|
||||
<th>Last progress</th>
|
||||
<th>Lease ends in</th>
|
||||
<th>Attempt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => {
|
||||
const s = sessionFor(t, sessions)
|
||||
const left = until(t.lease?.until)
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<Link className="cell-link" to={`/tasks/${t.id}`}>
|
||||
<span className="row-title">{t.title || 'Untitled task'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<div className="phase-cell">
|
||||
<Chip tone="accent">{phaseOf(t) ?? 'unknown'}</Chip>
|
||||
<PhasePath current={phaseOf(t)} />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="row-title mono">{t.lease?.harness_id ?? '—'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{s?.pane_id ?? t.last_pane_id ?? 'pane unknown'}</M>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{s?.capture?.at ? (
|
||||
<>
|
||||
<span className="mono">{ago(s.capture.at)}</span>
|
||||
<span className="row-sub">
|
||||
capture <M>r{s.capture.revision}</M>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="row-sub">no capture yet</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="mono" data-expired={left === 'expired' ? '' : undefined}>
|
||||
{left ?? '—'}
|
||||
</span>
|
||||
{s?.agent_status && <span className="row-sub">{s.agent_status}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<M>{t.attempt ?? 1}</M>
|
||||
</td>
|
||||
<td>
|
||||
<Link className="cell-link" to={`/tasks/${t.id}`} aria-label="Open task">
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<EndpointGap
|
||||
path="GET /v1/ui/overview → session.context"
|
||||
what="Context occupancy per session is measured by the herdr adapter but is not carried on the overview projection, so this table shows lease time instead of a context meter."
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Capacity({ workers, tasks }: { workers: Worker[]; tasks: Task[] }) {
|
||||
const online = workers.filter((w) => w.online).length
|
||||
const reachable = workers.filter((w) => w.health.herdr_status === 'reachable').length
|
||||
const unreachable = workers.filter((w) => w.health.herdr_status === 'unreachable').length
|
||||
const leased = tasks.filter((t) => t.lease)
|
||||
const expired = leased.filter((t) => until(t.lease?.until) === 'expired').length
|
||||
const erroring = workers.filter((w) => w.health.last_error)
|
||||
|
||||
return (
|
||||
<Panel title="System state and capacity" trailing={<Link to="/workers">View workers</Link>}>
|
||||
<div className="panel-body">
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Workers online</span>
|
||||
<span className="stat-value">
|
||||
{online} / {workers.length}
|
||||
</span>
|
||||
<span className="row-sub">
|
||||
<M>{workers.reduce((n, w) => n + w.capacity, 0)}</M> total capacity
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Active leases</span>
|
||||
<span className="stat-value">{leased.length}</span>
|
||||
<span className="row-sub">
|
||||
<M>{expired}</M> past their lease end
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Herdr backends</span>
|
||||
<span className="stat-value">
|
||||
{reachable} / {workers.length}
|
||||
</span>
|
||||
<span className="row-sub">
|
||||
<M>{unreachable}</M> unreachable, from worker health
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="worker-lines">
|
||||
{workers.length === 0 ? (
|
||||
<p className="row-sub">No worker has ever registered.</p>
|
||||
) : (
|
||||
workers.map((w) => (
|
||||
<Link className="worker-line" key={w.id} to="/workers">
|
||||
<Dot
|
||||
health={
|
||||
!w.online
|
||||
? 'error'
|
||||
: w.health.herdr_status === 'reachable'
|
||||
? 'healthy'
|
||||
: w.health.herdr_status === 'unreachable'
|
||||
? 'degraded'
|
||||
: 'unknown'
|
||||
}
|
||||
/>
|
||||
<span className="mono">{w.id}</span>
|
||||
<span className="row-sub">{w.health.backend ?? 'backend unknown'}</span>
|
||||
<span className="mono worker-seen">seen {ago(w.last_seen) ?? '—'}</span>
|
||||
{w.health.active_task_id && (
|
||||
<span className="mono">{shortId(w.health.active_task_id)}</span>
|
||||
)}
|
||||
{w.build?.revision && <span className="mono">{w.build.revision.slice(0, 7)}</span>}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{erroring.length > 0 && (
|
||||
<div className="worker-errors">
|
||||
{erroring.map((w) => (
|
||||
<p key={w.id}>
|
||||
<span className="mono">{w.id}</span>{' '}
|
||||
<span className="row-sub">{w.health.last_error}</span>{' '}
|
||||
<span className="mono">{ago(w.health.error_at) ?? ''}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EndpointGap
|
||||
path="GET /v1/quota, GET /v1/router, GET /v1/sources"
|
||||
what="Quota headroom, router queue depth and rejections, and source reconciliation state have no endpoint. Nothing here stands in for them."
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const overview = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const workers = useQuery<Worker[]>({
|
||||
queryKey: ['workers'],
|
||||
queryFn: api.workers,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const tasks = overview.data?.tasks ?? []
|
||||
const sessions = overview.data?.sessions ?? []
|
||||
// Workers come from the federation registry; the overview snapshot is the
|
||||
// fallback when that call has not landed or is failing.
|
||||
const workerList = workers.data ?? overview.data?.workers ?? []
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Dashboard</h1>
|
||||
<p>What needs you, what is running, and whether the system is healthy.</p>
|
||||
<span className="head-stamp">
|
||||
{overview.data ? (
|
||||
<>
|
||||
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
|
||||
</>
|
||||
) : overview.isError ? (
|
||||
<span className="stamp-fault">overview unreachable — {String(overview.error)}</span>
|
||||
) : (
|
||||
'reading the first snapshot'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Attention tasks={tasks.filter(needsOperator)} />
|
||||
<Running tasks={tasks.filter((t) => t.state === 'leased')} sessions={sessions} />
|
||||
<Capacity workers={workerList} tasks={tasks} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* Decisions — durable human authority.
|
||||
Only what the shared sheet does not already carry: the filter bar, the
|
||||
standing/superseded split, and the expanded detail row. */
|
||||
|
||||
.dec-filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dec-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.dec-search:hover,
|
||||
.dec-search:focus {
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dec-filter select {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Operator authority is the prominent class; history is muted and never
|
||||
dominant. The accent marks the standing decision, it does not wash the row. */
|
||||
.dec-table tbody tr[data-status] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='active'] td:first-child {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='waiting'] td:first-child {
|
||||
box-shadow: inset 2px 0 0 #d8a657;
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='superseded'] .row-title,
|
||||
.dec-table tbody tr[data-status='superseded'] .row-sub,
|
||||
.dec-table tbody tr[data-status='superseded'] .mono {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='superseded'] .row-title {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-open='true'] {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.dec-table .row-title {
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.dec-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dec-task:hover .mono {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.dec-task .row-sub {
|
||||
max-width: 22ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── detail ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.dec-detail-row td {
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
gap: 24px;
|
||||
padding: 6px 4px 10px;
|
||||
}
|
||||
|
||||
.dec-detail .label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dec-detail p {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
/* The human decision text stays sans, and reads as the loudest thing here. */
|
||||
.dec-value {
|
||||
font-size: 15px;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.dec-subject {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.dec-impact {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.dec-impact[data-retired='true'] {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.dec-facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 4px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dec-facts .label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dec-facts > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dec-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--accent-hi);
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dec-trail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dec-event {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dec-event:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dec-event-type {
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dec-event .mono:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-value[data-accent='true'] {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dec-detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Event, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import './Decisions.css'
|
||||
|
||||
/** The payload of a HumanDecisionRecorded event. There is no decisions
|
||||
* endpoint, so this screen reduces the log the way domain.ReduceIntent does:
|
||||
* a decision stands until something names it. */
|
||||
interface DecisionPayload {
|
||||
decision_id?: string
|
||||
kind?: string
|
||||
subject?: string
|
||||
value?: string
|
||||
supersedes?: string[]
|
||||
source?: { provider?: string; external_id?: string }
|
||||
}
|
||||
|
||||
/** The coordinator serves these on the task; api/types.ts does not model them
|
||||
* yet, so the shapes this screen needs are narrowed here rather than assumed. */
|
||||
interface BlockedTask extends Task {
|
||||
work_phase?: string
|
||||
decision_request?: { question?: string; why?: string }
|
||||
}
|
||||
|
||||
interface Decision {
|
||||
id: string
|
||||
taskId: string
|
||||
kind: string
|
||||
subject: string
|
||||
value: string
|
||||
supersedes: string[]
|
||||
provider: string
|
||||
externalId: string
|
||||
at: string
|
||||
eventId: string
|
||||
seq?: number
|
||||
}
|
||||
|
||||
/** A question the operator has not answered yet. It is not a decision — it
|
||||
* has no id and no value — but it is the same authority, pending. */
|
||||
interface Waiting {
|
||||
taskId: string
|
||||
question: string
|
||||
why: string
|
||||
at: string
|
||||
}
|
||||
|
||||
function payload(event: Event): DecisionPayload {
|
||||
return (event.payload ?? {}) as DecisionPayload
|
||||
}
|
||||
|
||||
function stamp(at: string) {
|
||||
return at.replace('T', ' ').slice(0, 19) + ' UTC'
|
||||
}
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.length > 8 ? id.slice(-6) : id
|
||||
}
|
||||
|
||||
interface Reduced {
|
||||
decisions: Decision[]
|
||||
/** decision id -> the decision that retired it, or '' for a standalone
|
||||
* HumanDecisionSuperseded event with no successor. */
|
||||
retiredBy: Map<string, string>
|
||||
events: Map<string, Event[]>
|
||||
}
|
||||
|
||||
function reduce(events: Event[]): Reduced {
|
||||
const decisions: Decision[] = []
|
||||
const retiredBy = new Map<string, string>()
|
||||
const byDecision = new Map<string, Event[]>()
|
||||
|
||||
const touch = (id: string, event: Event) => {
|
||||
const list = byDecision.get(id)
|
||||
if (list) list.push(event)
|
||||
else byDecision.set(id, [event])
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const p = payload(event)
|
||||
if (!p.decision_id) continue
|
||||
if (event.type === 'HumanDecisionRecorded') {
|
||||
const supersedes = p.supersedes ?? []
|
||||
decisions.push({
|
||||
id: p.decision_id,
|
||||
taskId: event.task_id ?? '',
|
||||
kind: p.kind ?? '',
|
||||
subject: p.subject ?? '',
|
||||
value: p.value ?? '',
|
||||
supersedes,
|
||||
provider: p.source?.provider ?? '',
|
||||
externalId: p.source?.external_id ?? '',
|
||||
at: event.at,
|
||||
eventId: event.id,
|
||||
seq: event.seq,
|
||||
})
|
||||
touch(p.decision_id, event)
|
||||
for (const target of supersedes) {
|
||||
retiredBy.set(target, p.decision_id)
|
||||
touch(target, event)
|
||||
}
|
||||
} else if (event.type === 'HumanDecisionSuperseded') {
|
||||
if (!retiredBy.has(p.decision_id)) retiredBy.set(p.decision_id, '')
|
||||
touch(p.decision_id, event)
|
||||
}
|
||||
}
|
||||
|
||||
decisions.sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))
|
||||
return { decisions, retiredBy, events: byDecision }
|
||||
}
|
||||
|
||||
function Select({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="dec-filter">
|
||||
<span className="label">{label}</span>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">all</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Decisions() {
|
||||
const events = useQuery({ queryKey: ['events'], queryFn: () => api.events(), refetchInterval: 5000 })
|
||||
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
|
||||
|
||||
const [status, setStatus] = useState('')
|
||||
const [kind, setKind] = useState('')
|
||||
const [project, setProject] = useState('')
|
||||
const [source, setSource] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState<string>()
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
const map = new Map<string, BlockedTask>()
|
||||
for (const task of (overview.data?.tasks ?? []) as BlockedTask[]) map.set(task.id, task)
|
||||
return map
|
||||
}, [overview.data])
|
||||
|
||||
const { decisions, retiredBy, events: trail } = useMemo(
|
||||
() => reduce(events.data ?? []),
|
||||
[events.data],
|
||||
)
|
||||
|
||||
const waiting = useMemo<Waiting[]>(
|
||||
() =>
|
||||
[...tasks.values()]
|
||||
.filter((t) => t.block_reason === 'human_decision' && t.decision_request)
|
||||
.map((t) => ({
|
||||
taskId: t.id,
|
||||
question: t.decision_request?.question ?? '',
|
||||
why: t.decision_request?.why ?? '',
|
||||
at: t.blocked_at ?? '',
|
||||
})),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
const kinds = useMemo(
|
||||
() => [...new Set(decisions.map((d) => d.kind).filter(Boolean))].sort(),
|
||||
[decisions],
|
||||
)
|
||||
const sources = useMemo(
|
||||
() => [...new Set(decisions.map((d) => d.provider).filter(Boolean))].sort(),
|
||||
[decisions],
|
||||
)
|
||||
const projects = useMemo(
|
||||
() => [...new Set([...tasks.values()].map((t) => t.project).filter(Boolean))].sort(),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (d: Decision) => {
|
||||
const task = tasks.get(d.taskId)
|
||||
const retired = retiredBy.has(d.id)
|
||||
if (status === 'active' && retired) return false
|
||||
if (status === 'superseded' && !retired) return false
|
||||
if (status === 'waiting') return false
|
||||
if (kind && d.kind !== kind) return false
|
||||
if (source && d.provider !== source) return false
|
||||
if (project && task?.project !== project) return false
|
||||
if (needle) {
|
||||
const hay = [d.id, d.taskId, d.subject, d.value, task?.title ?? ''].join(' ').toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const visible = decisions.filter(matches)
|
||||
const visibleWaiting = waiting.filter((w) => {
|
||||
if (status && status !== 'waiting') return false
|
||||
if (kind || source) return false
|
||||
if (project && tasks.get(w.taskId)?.project !== project) return false
|
||||
if (needle) return [w.taskId, w.question, w.why].join(' ').toLowerCase().includes(needle)
|
||||
return true
|
||||
})
|
||||
|
||||
const active = decisions.length - retiredBy.size
|
||||
const loading = events.isLoading || overview.isLoading
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Decisions</h1>
|
||||
<p>Durable human authority: what the operator decided, and what still stands.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Recorded</span>
|
||||
<span className="stat-value">{decisions.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Standing</span>
|
||||
<span className="stat-value" data-accent="true">
|
||||
{active}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Superseded</span>
|
||||
<span className="stat-value">{retiredBy.size}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Waiting on you</span>
|
||||
<span className="stat-value">{waiting.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Decision log"
|
||||
count={visible.length + visibleWaiting.length}
|
||||
trailing={
|
||||
events.isError || overview.isError ? 'read failed' : `reduced from ${(events.data ?? []).length} events`
|
||||
}
|
||||
>
|
||||
<div className="dec-filters">
|
||||
<input
|
||||
className="dec-search"
|
||||
placeholder="Search decisions…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
options={['active', 'superseded', 'waiting']}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Select label="Kind" value={kind} options={kinds} onChange={setKind} />
|
||||
<Select label="Project" value={project} options={projects} onChange={setProject} />
|
||||
<Select label="Source" value={source} options={sources} onChange={setSource} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">reading /v1/events…</p>
|
||||
</div>
|
||||
) : visible.length + visibleWaiting.length === 0 ? (
|
||||
<Empty title="No decisions match">
|
||||
<p>
|
||||
The log holds <M>{decisions.length}</M> recorded decisions. Clear the filters to see
|
||||
them.
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="table dec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Decision</th>
|
||||
<th>Task</th>
|
||||
<th>Phase</th>
|
||||
<th>Provenance</th>
|
||||
<th>Recorded</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleWaiting.map((w) => (
|
||||
<tr key={`w-${w.taskId}`} data-status="waiting">
|
||||
<td className="mono">—</td>
|
||||
<td>
|
||||
<div className="row-title">{w.question}</div>
|
||||
<div className="row-sub">{w.why}</div>
|
||||
</td>
|
||||
<td>
|
||||
<Link className="dec-task" to={`/tasks/${w.taskId}`}>
|
||||
<M>{shortId(w.taskId)}</M>
|
||||
<span className="row-sub">{tasks.get(w.taskId)?.title ?? ''}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{tasks.get(w.taskId)?.work_phase ?? '—'}</td>
|
||||
<td className="mono">—</td>
|
||||
<td className="mono">{w.at ? stamp(w.at) : '—'}</td>
|
||||
<td>
|
||||
<Chip tone="warn">waiting</Chip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.map((d) => {
|
||||
const retired = retiredBy.has(d.id)
|
||||
const successor = retiredBy.get(d.id)
|
||||
const task = tasks.get(d.taskId)
|
||||
const isOpen = open === d.id
|
||||
return (
|
||||
<Fragment key={d.id}>
|
||||
<tr
|
||||
data-status={retired ? 'superseded' : 'active'}
|
||||
data-open={isOpen ? 'true' : undefined}
|
||||
onClick={() => setOpen(isOpen ? undefined : d.id)}
|
||||
>
|
||||
<td className="mono" title={d.id}>
|
||||
{shortId(d.id)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-title">{d.value}</div>
|
||||
<div className="row-sub">{d.subject}</div>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
className="dec-task"
|
||||
to={`/tasks/${d.taskId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<M>{shortId(d.taskId)}</M>
|
||||
<span className="row-sub">{task?.title ?? ''}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{task?.work_phase ?? '—'}</td>
|
||||
<td className="mono" title={d.externalId}>
|
||||
{d.provider || '—'}
|
||||
</td>
|
||||
<td className="mono">{stamp(d.at)}</td>
|
||||
<td>
|
||||
{retired ? (
|
||||
<Chip>superseded</Chip>
|
||||
) : (
|
||||
<Chip tone="accent">{d.kind || 'active'}</Chip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="dec-detail-row">
|
||||
<td colSpan={7}>
|
||||
<div className="dec-detail">
|
||||
<div>
|
||||
<span className="label">Value</span>
|
||||
<p className="dec-value">{d.value}</p>
|
||||
<span className="label">Subject</span>
|
||||
<p className="dec-subject">{d.subject || '—'}</p>
|
||||
<span className="label">Effective authority</span>
|
||||
<p className="dec-impact" data-retired={retired ? 'true' : undefined}>
|
||||
{retired
|
||||
? successor
|
||||
? 'Retired. It is out of the effective intent for this task.'
|
||||
: 'Retired by an explicit supersede. It no longer binds the agent.'
|
||||
: 'Standing. It is in the effective intent handed to every session of this task.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dec-facts">
|
||||
<span className="label">Decision id</span>
|
||||
<div>
|
||||
<M>{d.id}</M>
|
||||
</div>
|
||||
<span className="label">Kind</span>
|
||||
<div>
|
||||
<M>{d.kind}</M>
|
||||
</div>
|
||||
<span className="label">Task</span>
|
||||
<div>
|
||||
<Link to={`/tasks/${d.taskId}`}>
|
||||
<M>{d.taskId}</M>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="label">Provenance</span>
|
||||
<div>
|
||||
<M>{d.provider || 'unknown'}</M>{' '}
|
||||
{d.externalId && <M>· {d.externalId}</M>}
|
||||
</div>
|
||||
<span className="label">Supersedes</span>
|
||||
<div>
|
||||
{d.supersedes.length === 0 ? (
|
||||
<M>none</M>
|
||||
) : (
|
||||
d.supersedes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="dec-link mono"
|
||||
onClick={() => setOpen(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<span className="label">Superseded by</span>
|
||||
<div>
|
||||
{!retired ? (
|
||||
<M>none</M>
|
||||
) : successor ? (
|
||||
<button className="dec-link mono" onClick={() => setOpen(successor)}>
|
||||
{successor}
|
||||
</button>
|
||||
) : (
|
||||
<M>explicit supersede event</M>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dec-trail">
|
||||
<span className="label">Event sequence</span>
|
||||
{(trail.get(d.id) ?? []).map((e) => (
|
||||
<div key={e.id} className="dec-event">
|
||||
<M>{e.seq !== undefined ? `#${e.seq}` : '—'}</M>
|
||||
<span className="dec-event-type">{e.type}</span>
|
||||
<M>{stamp(e.at)}</M>
|
||||
<M>{e.id}</M>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Law 5: the mockup's approval columns have no source in the log. */}
|
||||
<EndpointGap
|
||||
path="/v1/ui/decisions"
|
||||
what="Recorded, superseded and waiting are reduced from the event log. Approved, rejected and auto are not: the domain has no approval state on a decision, so those statuses are omitted rather than guessed. The acting human is not recorded either — source.provider is the channel the decision arrived on, not the person. Phase is the task's current work phase, not the phase the decision was made in."
|
||||
/>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Projects: two columns on desktop, stacked on mobile. Everything else comes
|
||||
from orchestra.css — this file exists only for the reflow. */
|
||||
.projects-split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
|
||||
}
|
||||
|
||||
/* .row is a flex container; its title/subtitle stack. */
|
||||
.projects-split .row-main > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.projects-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Task, TaskState, Worker } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import './Projects.css'
|
||||
|
||||
/** Orchestra has no projects endpoint. The registry that defines a project —
|
||||
* repo, remote, quality gate, verification policy, machine affinity — lives in
|
||||
* the coordinator's config.jsonc and is never served over HTTP. What this
|
||||
* screen shows is derived from work that actually exists: the tasks in
|
||||
* /v1/ui/overview carry a project, and the workers in the federation registry
|
||||
* declare which projects they will accept. Everything else is a stated gap,
|
||||
* not a plausible-looking guess. */
|
||||
|
||||
const ATTENTION: TaskState[] = ['needs_attention', 'blocked', 'failed']
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
tasks: Task[]
|
||||
counts: Record<TaskState, number>
|
||||
attention: number
|
||||
active: number
|
||||
/** Newest capture/check the coordinator has for this project's sessions.
|
||||
* A task carries no updated_at, so this is the only honest recency signal
|
||||
* the overview projection actually contains. */
|
||||
lastSeen?: string
|
||||
workers: Worker[]
|
||||
}
|
||||
|
||||
function fmt(at?: string) {
|
||||
if (!at) return undefined
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) ? at : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
}
|
||||
|
||||
function newest(a?: string, b?: string) {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a > b ? a : b
|
||||
}
|
||||
|
||||
function derive(tasks: Task[], workers: Worker[]): ProjectRow[] {
|
||||
const rows = new Map<string, ProjectRow>()
|
||||
for (const task of tasks) {
|
||||
const id = task.project || '(unset)'
|
||||
let row = rows.get(id)
|
||||
if (!row) {
|
||||
row = {
|
||||
id,
|
||||
tasks: [],
|
||||
counts: {} as Record<TaskState, number>,
|
||||
attention: 0,
|
||||
active: 0,
|
||||
workers: workers.filter((w) => (w.supported_projects || []).includes(id)),
|
||||
}
|
||||
rows.set(id, row)
|
||||
}
|
||||
row.tasks.push(task)
|
||||
row.counts[task.state] = (row.counts[task.state] || 0) + 1
|
||||
if (ATTENTION.includes(task.state)) row.attention++
|
||||
if (task.state === 'leased') row.active++
|
||||
row.lastSeen = newest(
|
||||
row.lastSeen,
|
||||
newest(task.last_session?.captured_at, task.last_session?.checked_at),
|
||||
)
|
||||
}
|
||||
return [...rows.values()].sort(
|
||||
(a, b) => b.attention - a.attention || b.active - a.active || a.id.localeCompare(b.id),
|
||||
)
|
||||
}
|
||||
|
||||
function stateChips(counts: Record<TaskState, number>) {
|
||||
const order: TaskState[] = [
|
||||
'leased',
|
||||
'queued',
|
||||
'in_review',
|
||||
'needs_attention',
|
||||
'blocked',
|
||||
'failed',
|
||||
'completed',
|
||||
]
|
||||
return order
|
||||
.filter((state) => counts[state])
|
||||
.map((state) => (
|
||||
<Chip
|
||||
key={state}
|
||||
tone={
|
||||
state === 'leased'
|
||||
? 'accent'
|
||||
: state === 'completed'
|
||||
? 'done'
|
||||
: state === 'needs_attention'
|
||||
? 'warn'
|
||||
: ATTENTION.includes(state)
|
||||
? 'fault'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.replace('_', ' ')} <M>{counts[state]}</M>
|
||||
</Chip>
|
||||
))
|
||||
}
|
||||
|
||||
export function Projects() {
|
||||
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
|
||||
const [selected, setSelected] = useState<string>()
|
||||
|
||||
const rows = useMemo(
|
||||
() => derive(overview.data?.tasks || [], overview.data?.workers || []),
|
||||
[overview.data],
|
||||
)
|
||||
const current = rows.find((r) => r.id === selected) || rows[0]
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Projects</h1>
|
||||
<p>
|
||||
Projects orchestra is holding work for, derived from the tasks it knows about. The
|
||||
coordinator does not serve its project registry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{overview.isError && (
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">/v1/ui/overview failed: {String(overview.error)}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!overview.data && !overview.isError && (
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">reading /v1/ui/overview</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{overview.data && rows.length === 0 && (
|
||||
<Panel>
|
||||
<Empty title="No project has work">
|
||||
<p>
|
||||
Every project here comes from a task. Orchestra holds{' '}
|
||||
<M>{overview.data.tasks.length}</M> tasks, so nothing groups yet.
|
||||
</p>
|
||||
</Empty>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{current && (
|
||||
<div className="grid projects-split">
|
||||
<Panel title="With work" count={rows.length}>
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Tasks</th>
|
||||
<th>Leased</th>
|
||||
<th>Attention</th>
|
||||
<th>Last session evidence</th>
|
||||
<th>Workers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => setSelected(row.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: row.id === current.id ? 'var(--bg-2)' : undefined,
|
||||
boxShadow:
|
||||
row.id === current.id ? 'inset 3px 0 0 0 var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<M>{row.id}</M>
|
||||
</td>
|
||||
<td>
|
||||
<M>{row.tasks.length}</M>
|
||||
</td>
|
||||
<td>
|
||||
<M>{row.active}</M>
|
||||
</td>
|
||||
<td>
|
||||
{row.attention ? (
|
||||
<Chip tone="fault">
|
||||
<M>{row.attention}</M>
|
||||
</Chip>
|
||||
) : (
|
||||
<M>0</M>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<M>{fmt(row.lastSeen) || '—'}</M>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{row.workers.length === 0 ? (
|
||||
<M>none</M>
|
||||
) : (
|
||||
row.workers.map((w) => (
|
||||
<Chip key={w.id}>
|
||||
<Dot health={w.online ? 'healthy' : 'error'} />
|
||||
<M>{w.id}</M>
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid" style={{ alignContent: 'start' }}>
|
||||
<Panel
|
||||
title={current.id}
|
||||
trailing={<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>Tasks →</Link>}
|
||||
>
|
||||
<div className="panel-body" style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{stateChips(current.counts)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Workers declaring this project</div>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-mid)' }}>
|
||||
{current.workers.length === 0
|
||||
? 'No registered worker accepts it. Orchestra does not treat an omitted declaration as a wildcard, so nothing can lease this work.'
|
||||
: 'Worker-declared, from the federation registry — not the coordinator’s project affinity.'}
|
||||
</p>
|
||||
{current.workers.map((w) => (
|
||||
<div key={w.id} style={{ marginTop: 6 }}>
|
||||
<Dot health={w.online ? 'healthy' : 'error'} /> <M>{w.id}</M>{' '}
|
||||
<M>{w.address || ''}</M>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Tasks" count={current.tasks.length}>
|
||||
<div className="rows">
|
||||
{current.tasks.slice(0, 8).map((task) => (
|
||||
<Link key={task.id} className="row" to={`/tasks/${task.id}`}>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{task.title || task.external_id || 'untitled'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{task.id}</M>
|
||||
</span>
|
||||
</span>
|
||||
<Chip
|
||||
tone={
|
||||
task.state === 'leased'
|
||||
? 'accent'
|
||||
: task.state === 'completed'
|
||||
? 'done'
|
||||
: task.state === 'needs_attention'
|
||||
? 'warn'
|
||||
: ATTENTION.includes(task.state)
|
||||
? 'fault'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{task.state.replace('_', ' ')}
|
||||
</Chip>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{current.tasks.length > 8 && (
|
||||
<div className="panel-body">
|
||||
<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>
|
||||
All <M>{current.tasks.length}</M> tasks →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Project configuration">
|
||||
<EndpointGap
|
||||
path="/v1/ui/projects"
|
||||
what="repo, remote, quality gate and verification policy live in the coordinator's config.jsonc and are not served over HTTP"
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Review-only geometry. Panel, row, table, stat, chip and btn come from
|
||||
orchestra.css and are not restated here. */
|
||||
|
||||
.rev-stamp {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.rev-fault {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.rev-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rev-tabs .btn[data-active='true'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
/* One desk item: the row itself, plus whatever evidence it carries under it. */
|
||||
.rev-item {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rev-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rev-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.rev-line .row.rev-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rev-out {
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The kind mark is a square of the item's own tone — a signal, never a wash
|
||||
across the row. */
|
||||
.rev-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='trajectory'],
|
||||
.rev-mark[data-kind='pr'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='decision'],
|
||||
.rev-mark[data-kind='operator'] {
|
||||
border-color: rgba(216, 166, 87, 0.3);
|
||||
background: rgba(216, 166, 87, 0.1);
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='findings'] {
|
||||
border-color: rgba(224, 108, 96, 0.3);
|
||||
background: rgba(224, 108, 96, 0.1);
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.rev-kind {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
font-size: 13px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.rev-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 0 0 168px;
|
||||
}
|
||||
|
||||
.rev-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
min-width: 72px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-main,
|
||||
.rev-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rev-link .row-title,
|
||||
.rev-link .row-sub,
|
||||
.rev-task .row-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Refs sit under the row as verified orchestra state: neutral, mono values. */
|
||||
.rev-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
padding: 0 16px 12px 60px;
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
}
|
||||
|
||||
/* An agent claim is labelled as one. It is never given orchestra's voice. */
|
||||
.claim-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.rev-findings,
|
||||
.rev-options {
|
||||
margin: 0 16px 14px 60px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-hi);
|
||||
border-left: 2px solid rgba(216, 166, 87, 0.45);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.rev-options p {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.rev-findings pre {
|
||||
margin: 0;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-mid);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rev-line {
|
||||
flex-wrap: wrap;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.rev-kind,
|
||||
.rev-task {
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rev-refs,
|
||||
.rev-findings,
|
||||
.rev-options {
|
||||
margin-left: 16px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Overview, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Review.css'
|
||||
|
||||
/* The Go domain carries review, submission, decision_request and work_phase on
|
||||
the task; the shared TS Task type has not caught up. They are read through a
|
||||
narrow cast rather than invented, renamed, or added to a file this screen
|
||||
does not own. Shapes are internal/domain/domain.go and submission.go. */
|
||||
type ReviewRef = { artifact_ref: string; result_sha: string; blocking: number }
|
||||
type ExternalRef = { provider: string; id: string; url?: string }
|
||||
type SubmissionRef = {
|
||||
result_sha: string
|
||||
remote_ref?: string
|
||||
pr: ExternalRef
|
||||
gate_ref?: string
|
||||
review_ref?: string
|
||||
}
|
||||
type DecisionOption = { id: string; description: string; tradeoff?: string }
|
||||
type DecisionRequest = {
|
||||
question: string
|
||||
why: string
|
||||
options?: DecisionOption[]
|
||||
evidence?: string[]
|
||||
}
|
||||
type FullTask = Task & {
|
||||
review?: ReviewRef
|
||||
submission?: SubmissionRef
|
||||
decision_request?: DecisionRequest
|
||||
review_target_sha?: string
|
||||
work_phase?: string
|
||||
}
|
||||
|
||||
const full = (t: Task) => t as FullTask
|
||||
|
||||
/** "2m ago" — a machine value, so it renders mono at the call site. */
|
||||
function ago(at?: string) {
|
||||
if (!at) return undefined
|
||||
const s = Math.round((Date.now() - Date.parse(at)) / 1000)
|
||||
if (!Number.isFinite(s) || s < 0) return undefined
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id)
|
||||
const shortSha = (sha?: string) => (sha ? sha.slice(0, 7) : undefined)
|
||||
|
||||
type Kind = 'trajectory' | 'decision' | 'operator' | 'findings' | 'pr'
|
||||
|
||||
const KINDS: { kind: Kind; label: string; icon: string }[] = [
|
||||
{ kind: 'trajectory', label: 'Trajectory', icon: 'fork' },
|
||||
{ kind: 'decision', label: 'Blocker', icon: 'decision' },
|
||||
{ kind: 'operator', label: 'Operator', icon: 'alert' },
|
||||
{ kind: 'findings', label: 'AI review', icon: 'review' },
|
||||
{ kind: 'pr', label: 'Code review', icon: 'task' },
|
||||
]
|
||||
const meta = (k: Kind) => KINDS.find((x) => x.kind === k)!
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
kind: Kind
|
||||
task: FullTask
|
||||
/** Why this is on the desk, in the operator's language. Always derived. */
|
||||
why: string
|
||||
headline: string
|
||||
at?: string
|
||||
}
|
||||
|
||||
/* Reasons an operator-required block can carry. Each line names the real
|
||||
block_reason it came from — none of it is decorative copy. */
|
||||
const operatorReason: Record<string, string> = {
|
||||
operator_required: 'Orchestra cannot proceed without an operator action.',
|
||||
approval: 'A tool or edit approval is pending in the pane.',
|
||||
handoff_validation: 'The handoff failed validation and was not accepted.',
|
||||
lease_failure: 'The lease could not be established.',
|
||||
lease_expired: 'The lease expired before the agent finished.',
|
||||
worker_offline: 'The worker holding this task went offline.',
|
||||
system_error: 'Orchestra hit an error it could not retry past.',
|
||||
operator_block: 'You blocked this task.',
|
||||
unknown: 'Blocked for a reason orchestra could not classify.',
|
||||
}
|
||||
|
||||
/** One task can be on the desk for more than one reason, and each reason is
|
||||
* its own item: answering the blocker does not clear the review findings. */
|
||||
function itemsFor(task: Task): Item[] {
|
||||
const t = full(task)
|
||||
const out: Item[] = []
|
||||
const at = t.blocked_at
|
||||
|
||||
if (t.block_reason === 'trajectory_gate') {
|
||||
out.push({
|
||||
key: `${t.id}:trajectory`,
|
||||
kind: 'trajectory',
|
||||
task: t,
|
||||
headline: t.decision_request?.question ?? 'Trajectory gate is held',
|
||||
why:
|
||||
t.decision_request?.why ??
|
||||
t.blocker ??
|
||||
'A trajectory gate is waiting for your steering before work continues.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.block_reason === 'human_decision' || (t.decision_request && !out.length)) {
|
||||
out.push({
|
||||
key: `${t.id}:decision`,
|
||||
kind: 'decision',
|
||||
task: t,
|
||||
headline: t.decision_request?.question ?? t.blocker ?? 'A decision is required',
|
||||
why:
|
||||
t.decision_request?.why ??
|
||||
t.blocker ??
|
||||
'The agent asked for a decision it may not make on its own.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.state === 'blocked' && !out.length) {
|
||||
const reason = t.block_reason ?? 'unknown'
|
||||
out.push({
|
||||
key: `${t.id}:operator`,
|
||||
kind: 'operator',
|
||||
task: t,
|
||||
headline: t.blocker || `Blocked: ${reason}`,
|
||||
why: operatorReason[reason] ?? operatorReason.unknown,
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.state === 'needs_attention' && !out.length) {
|
||||
out.push({
|
||||
key: `${t.id}:operator`,
|
||||
kind: 'operator',
|
||||
task: t,
|
||||
headline: t.blocker || t.last_error || 'Flagged for you',
|
||||
why: 'Orchestra flagged this task and will not move it without you.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.review && t.review.blocking > 0) {
|
||||
const stale = t.review.result_sha !== (t.submission?.result_sha ?? t.review.result_sha)
|
||||
out.push({
|
||||
key: `${t.id}:findings`,
|
||||
kind: 'findings',
|
||||
task: t,
|
||||
headline: `${t.review.blocking} blocking finding${t.review.blocking === 1 ? '' : 's'}`,
|
||||
why: stale
|
||||
? 'A sealed review claims blocking findings against a commit the submission has moved past. Accept or override them.'
|
||||
: 'A sealed review claims blocking findings. They stand until you accept or override them.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.submission?.pr) {
|
||||
out.push({
|
||||
key: `${t.id}:pr`,
|
||||
kind: 'pr',
|
||||
task: t,
|
||||
headline: t.title || 'Change submitted for review',
|
||||
why: 'A pull request is open and waiting on human review.',
|
||||
at,
|
||||
})
|
||||
} else if (t.state === 'in_review' && !out.some((i) => i.kind === 'findings')) {
|
||||
out.push({
|
||||
key: `${t.id}:pr`,
|
||||
kind: 'pr',
|
||||
task: t,
|
||||
headline: t.title || 'Awaiting review',
|
||||
why: 'The task is in review with no submission recorded yet.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** The sealed review artifact. It is an agent claim until a human accepts it,
|
||||
* so it is labelled as one and never rendered as orchestra's own verdict. */
|
||||
function Findings({ artifactRef }: { artifactRef: string }) {
|
||||
const q = useQuery({
|
||||
queryKey: ['artifact', artifactRef],
|
||||
queryFn: () => api.artifact(artifactRef),
|
||||
staleTime: Infinity, // a sealed artifact never changes
|
||||
})
|
||||
return (
|
||||
<div className="rev-findings">
|
||||
<p className="claim-label">
|
||||
<Icon name="alert" size={14} />
|
||||
agent-supplied · sealed review artifact <M>{artifactRef}</M>
|
||||
</p>
|
||||
{q.isError ? (
|
||||
<p className="rev-fault">could not read artifact: {String(q.error)}</p>
|
||||
) : q.data === undefined ? (
|
||||
<p className="row-sub">
|
||||
reading <M>{artifactRef}</M>
|
||||
</p>
|
||||
) : (
|
||||
<pre>{q.data}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemRow({ item }: { item: Item }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const t = item.task
|
||||
const m = meta(item.kind)
|
||||
const age = ago(item.at)
|
||||
|
||||
return (
|
||||
<div className="rev-item">
|
||||
<div className="rev-line">
|
||||
<Link className="row rev-link" to={`/tasks/${t.id}`}>
|
||||
<span className="rev-mark" data-kind={item.kind}>
|
||||
<Icon name={m.icon} size={18} />
|
||||
</span>
|
||||
<span className="rev-kind">{m.label}</span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{item.headline}</span>
|
||||
<span className="row-sub">{item.why}</span>
|
||||
</span>
|
||||
<span className="rev-task">
|
||||
<span className="row-title">{t.project}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
{t.work_phase && (
|
||||
<>
|
||||
{' · '}
|
||||
<M>{t.work_phase}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rev-meta">
|
||||
<span className="label">priority</span>
|
||||
<M>{t.inherent_priority ?? 0}</M>
|
||||
</span>
|
||||
<span className="rev-meta">
|
||||
<span className="label">age</span>
|
||||
{age ? <M>{age}</M> : <span className="row-sub">not timestamped</span>}
|
||||
</span>
|
||||
{item.kind === 'findings' && t.review && (
|
||||
<Chip tone="fault">
|
||||
<M>{t.review.blocking}</M> blocking
|
||||
</Chip>
|
||||
)}
|
||||
{item.kind === 'pr' && t.submission?.pr?.id && (
|
||||
<Chip tone="accent">
|
||||
<M>{t.submission.pr.id}</M>
|
||||
</Chip>
|
||||
)}
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
|
||||
{item.kind === 'pr' && t.submission?.pr?.url && (
|
||||
<a
|
||||
className="btn rev-out"
|
||||
href={t.submission.pr.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon name="upload" size={14} />
|
||||
{t.submission.pr.provider}
|
||||
</a>
|
||||
)}
|
||||
{item.kind === 'findings' && t.review && (
|
||||
<button className="btn rev-out" onClick={() => setOpen(!open)}>
|
||||
{open ? 'Hide findings' : 'Read findings'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(item.kind === 'pr' || item.kind === 'findings') && (
|
||||
<div className="rev-refs">
|
||||
{t.review && (
|
||||
<span>
|
||||
<span className="label">reviewed sha</span> <M>{shortSha(t.review.result_sha)}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission && (
|
||||
<span>
|
||||
<span className="label">submitted sha</span>{' '}
|
||||
<M>{shortSha(t.submission.result_sha)}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission?.gate_ref && (
|
||||
<span>
|
||||
<span className="label">gate</span> <M>{t.submission.gate_ref}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission?.review_ref && (
|
||||
<span>
|
||||
<span className="label">review</span> <M>{t.submission.review_ref}</M>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.kind === 'trajectory' && t.decision_request?.options?.length ? (
|
||||
<div className="rev-options">
|
||||
<p className="claim-label">
|
||||
<Icon name="alert" size={14} />
|
||||
agent-supplied · proposed options
|
||||
</p>
|
||||
{t.decision_request.options.map((o) => (
|
||||
<p key={o.id}>
|
||||
<M>{o.id}</M> {o.description}
|
||||
{o.tradeoff && <span className="row-sub"> — {o.tradeoff}</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open && item.kind === 'findings' && t.review && (
|
||||
<Findings artifactRef={t.review.artifact_ref} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Review() {
|
||||
const overview = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const [kind, setKind] = useState<Kind | 'all'>('all')
|
||||
|
||||
const items = (overview.data?.tasks ?? []).flatMap(itemsFor)
|
||||
// Most urgent first: high inherent priority, then oldest wait.
|
||||
const order = KINDS.map((k) => k.kind)
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(b.task.inherent_priority ?? 0) - (a.task.inherent_priority ?? 0) ||
|
||||
Date.parse(a.at ?? '') - Date.parse(b.at ?? '') ||
|
||||
order.indexOf(a.kind) - order.indexOf(b.kind),
|
||||
)
|
||||
const shown = kind === 'all' ? items : items.filter((i) => i.kind === kind)
|
||||
const count = (k: Kind) => items.filter((i) => i.kind === k).length
|
||||
const blocking = items.reduce((n, i) => n + (i.kind === 'findings' ? (i.task.review?.blocking ?? 0) : 0), 0)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Review</h1>
|
||||
<p>Items that need your attention and judgement.</p>
|
||||
<span className="rev-stamp">
|
||||
{overview.data ? (
|
||||
<>
|
||||
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
|
||||
</>
|
||||
) : overview.isError ? (
|
||||
<span className="rev-fault">overview unreachable — {String(overview.error)}</span>
|
||||
) : (
|
||||
'reading the first snapshot'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Needs you</span>
|
||||
<span className="stat-value">{items.length}</span>
|
||||
<span className="row-sub">
|
||||
across <M>{new Set(items.map((i) => i.task.id)).size}</M> tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Blocking findings</span>
|
||||
<span className="stat-value">{blocking}</span>
|
||||
<span className="row-sub">claimed by sealed reviews, not yet accepted</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Pull requests open</span>
|
||||
<span className="stat-value">{count('pr')}</span>
|
||||
<span className="row-sub">recorded on a submission</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel title="Attention queue" count={shown.length}>
|
||||
<div className="rev-tabs">
|
||||
<button className="btn" data-active={kind === 'all'} onClick={() => setKind('all')}>
|
||||
All <M>{items.length}</M>
|
||||
</button>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
key={k.kind}
|
||||
className="btn"
|
||||
data-active={kind === k.kind}
|
||||
onClick={() => setKind(k.kind)}
|
||||
>
|
||||
{k.label} <M>{count(k.kind)}</M>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<Empty title={items.length === 0 ? 'Nothing is waiting on you' : 'Nothing of this type'}>
|
||||
<p>
|
||||
Trajectory gates, human decisions, operator-required blocks, blocking review findings
|
||||
and open pull requests land here.
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="rows">
|
||||
{shown.map((i) => (
|
||||
<ItemRow key={i.key} item={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EndpointGap
|
||||
path="GET /v1/ui/submissions → pr.state, GET /v1/ui/review/snoozes"
|
||||
what="Two item types the spec lists have no source. A closed-but-unmerged pull request cannot be derived: submission.pr carries provider, id and url but no forge state, and orchestra never polls the forge. Informational/FYI items and snooze or defer state have no endpoint and no event either, so no tab stands in for them."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/* Settings. Only what the shared shell does not carry: the tab strip, the
|
||||
credential form geometry, and the fact list. */
|
||||
|
||||
.set-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.set-tab {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-mid);
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.set-tab:hover {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
/* Accent marks the section you are in, and the save button. Nothing else. */
|
||||
.set-tab[aria-selected='true'] {
|
||||
color: var(--accent-hi);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.set-account {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.set-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.set-note {
|
||||
margin: 0;
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.set-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.set-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.set-input {
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.set-input::placeholder {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.set-input:focus {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.set-reveal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.set-reveal-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-mid);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.set-rules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.set-rules span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.set-rules span[data-met='true'] {
|
||||
color: #5fbf82;
|
||||
}
|
||||
|
||||
.set-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.set-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.set-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.set-facts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.set-signout {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.set-account {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Account } from '../api/types'
|
||||
import { Chip, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Settings.css'
|
||||
|
||||
/** The spec asks for eight tabs. The coordinator has exactly one settings
|
||||
* mutation — PUT /v1/ui/account — so exactly one tab holds controls. Every
|
||||
* other tab names the endpoint that would supply it rather than showing a
|
||||
* form that cannot save. Law 5. */
|
||||
type TabKey =
|
||||
| 'account'
|
||||
| 'general'
|
||||
| 'appearance'
|
||||
| 'notifications'
|
||||
| 'access'
|
||||
| 'integrations'
|
||||
| 'agents'
|
||||
| 'policies'
|
||||
| 'advanced'
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'account', label: 'Account' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'appearance', label: 'Appearance' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'access', label: 'Access & security' },
|
||||
{ key: 'integrations', label: 'Integrations' },
|
||||
{ key: 'agents', label: 'Agents & tools' },
|
||||
{ key: 'policies', label: 'Policies' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
]
|
||||
|
||||
const GAPS: Record<Exclude<TabKey, 'account'>, { path: string; what: string }> = {
|
||||
general: {
|
||||
path: 'GET/PUT /v1/ui/settings/general',
|
||||
what: 'Instance name and environment, time display, default task preferences, default workflow policy and event/artifact retention. The coordinator keeps these in config.jsonc and the container environment; nothing reads or writes them over the API.',
|
||||
},
|
||||
appearance: {
|
||||
path: 'GET/PUT /v1/ui/settings/appearance',
|
||||
what: 'Theme and reduced motion are the only appearance choices ethos permits, and neither is stored server-side today. The console follows the operating system.',
|
||||
},
|
||||
notifications: {
|
||||
path: 'GET/PUT /v1/ui/settings/notifications',
|
||||
what: 'Notification sinks and per-event routing. ntfy credentials live in the compose .env file and are never returned by the API.',
|
||||
},
|
||||
access: {
|
||||
path: 'GET /v1/ui/settings/access',
|
||||
what: 'Web tokens and their status, trusted and ignored review actors, live operator sessions, agent-surface auth and the source each secret is loaded from. Only the operator credential below is exposed today.',
|
||||
},
|
||||
integrations: {
|
||||
path: 'GET /v1/ui/settings/integrations',
|
||||
what: 'Gitea and Vikunja endpoints, notification sinks and source reconciliation state, each with its identity and last successful interaction. No endpoint reports integration health.',
|
||||
},
|
||||
agents: {
|
||||
path: 'GET /v1/ui/settings/harnesses',
|
||||
what: 'Harness definitions, adapter runtime configuration, herdr and tmux ownership, default launch behaviour and context occupancy thresholds. The registry is read from config.jsonc at start-up and is not served.',
|
||||
},
|
||||
policies: {
|
||||
path: 'GET/PUT /v1/ui/settings/policies',
|
||||
what: 'Work phase defaults, the trajectory gate, the bounded decision request budget, retry policy, reconcile failure handoff threshold and review/submission policy. These are compiled-in defaults, not stored settings.',
|
||||
},
|
||||
advanced: {
|
||||
path: 'GET /v1/ui/settings/diagnostics',
|
||||
what: 'Event-store diagnostics, snapshots and replay, raw configuration, migration status and destructive operator actions. Destructive actions must not be built before the coordinator can report what they would touch.',
|
||||
},
|
||||
}
|
||||
|
||||
function stamp(at?: string) {
|
||||
if (!at) return '—'
|
||||
const ms = Date.parse(at)
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : at
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export function Settings({ account, onLogout, onCredentialsChanged }: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>('account')
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Settings</h1>
|
||||
<p>One operator credential is stored here. Everything else Orchestra runs on comes from config and environment.</p>
|
||||
</div>
|
||||
|
||||
<div className="set-tabs" role="tablist" aria-label="Settings sections">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
type="button"
|
||||
className="set-tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'account' ? (
|
||||
<AccountTab account={account} onLogout={onLogout} onCredentialsChanged={onCredentialsChanged} />
|
||||
) : (
|
||||
<Panel title={TABS.find((t) => t.key === tab)?.label}>
|
||||
<EndpointGap path={GAPS[tab].path} what={GAPS[tab].what} />
|
||||
</Panel>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTab({ account, onLogout, onCredentialsChanged }: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
|
||||
const changed = username.trim() !== account.username || !!newPassword
|
||||
const longEnough = newPassword.length >= 10
|
||||
const withinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const matches = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) return setFormError('Username cannot be empty.')
|
||||
if (!changed) return setFormError('Change the username or enter a new password first.')
|
||||
if (!currentPassword) return setFormError('Enter your current password to authorize this change.')
|
||||
if (newPassword && (!longEnough || !withinLimit || !matches)) {
|
||||
return setFormError(matches ? 'Use a password between 10 and 72 bytes.' : 'The new passwords do not match.')
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="set-account">
|
||||
<Panel title="Sign-in credentials">
|
||||
<form className="panel-body set-form" onSubmit={submit} noValidate>
|
||||
<p className="set-note">
|
||||
Changing either field signs out every browser using this account. Your current password authorizes the change and is never stored by the console.
|
||||
</p>
|
||||
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-username">Username</label>
|
||||
<input
|
||||
id="set-username"
|
||||
className="set-input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="set-split">
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-new">New password</label>
|
||||
<div className="set-reveal">
|
||||
<input
|
||||
id="set-new"
|
||||
className="set-input"
|
||||
type={visible ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep it"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setFormError('') }}
|
||||
/>
|
||||
<button type="button" className="set-reveal-btn" onClick={() => setVisible((v) => !v)}>
|
||||
{visible ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-confirm">Confirm new password</label>
|
||||
<input
|
||||
id="set-confirm"
|
||||
className="set-input"
|
||||
type={visible ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Repeat new password"
|
||||
value={confirmation}
|
||||
onChange={(e) => { setConfirmation(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newPassword && (
|
||||
<div className="set-rules" aria-live="polite">
|
||||
<span data-met={longEnough}><Icon name="check" size={13} /> 10 characters or more</span>
|
||||
<span data-met={withinLimit}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span data-met={matches && !!confirmation}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-current">Current password</label>
|
||||
<input
|
||||
id="set-current"
|
||||
className="set-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(formError || mutation.error) && (
|
||||
<p className="set-error" role="alert">
|
||||
<Icon name="alert" size={15} /> {formError || message(mutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="set-actions">
|
||||
<span className="set-note">You sign in again after saving.</span>
|
||||
<button className="btn" data-variant="primary" type="submit" disabled={mutation.isPending || !changed}>
|
||||
{mutation.isPending ? 'Saving' : 'Save credentials'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="This account" trailing={<Chip tone="accent">operator</Chip>}>
|
||||
<div className="panel-body set-facts">
|
||||
<div><span className="label">Username</span><span>{account.username}</span></div>
|
||||
<div><span className="label">Created</span><M>{stamp(account.created_at)}</M></div>
|
||||
<div><span className="label">Credentials updated</span><M>{stamp(account.updated_at)}</M></div>
|
||||
<div><span className="label">Credential store</span><M>bcrypt · orchestra data volume</M></div>
|
||||
<button className="btn set-signout" type="button" onClick={onLogout}>Sign out</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* Task detail. Only what orchestra.css does not already carry: the two-column
|
||||
split, the agent-claim treatment, and the muted superseded register. */
|
||||
|
||||
.td-top {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.td-top .page-head {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.td-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.td-ids {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.td-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.td-doing > .label {
|
||||
display: block;
|
||||
margin: 18px 0 6px;
|
||||
}
|
||||
|
||||
.td-doing > .label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.td-prose {
|
||||
margin: 0;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.td-muted {
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.td-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.td-more > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.td-more > summary::before {
|
||||
content: '▸ ';
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-more[open] > summary::before {
|
||||
content: '▾ ';
|
||||
}
|
||||
|
||||
.td-more[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Operator authority: the one class of content that carries the accent. */
|
||||
.td-decisions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.td-decisions li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-dim);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.td-kind {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.td-subject {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.td-value {
|
||||
color: var(--text-hi);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Superseded: still readable, never dominant, and stripped of the accent. */
|
||||
.td-superseded .td-decisions li {
|
||||
background: var(--bg-2);
|
||||
border-left-color: var(--line-hi);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.td-superseded .td-kind {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-superseded .td-value {
|
||||
color: var(--text-mid);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.td-exec {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.td-exec .stat {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.td-exec .stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Agent claims read as quotation, not as state: dashed rule, machine text,
|
||||
and a tag that names the source. */
|
||||
.td-claim-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 18px 0 6px;
|
||||
}
|
||||
|
||||
.td-claim-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-claim {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px dashed var(--line-hi);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.td-claim > .mono:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.td-claim-at {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.td-approval {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.td-approval .td-prose {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.td-approval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.td-approval-row .mono {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.td-links {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
|
||||
.td-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.td-flow .phases {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.td-entries {
|
||||
display: flex;
|
||||
gap: 8px 20px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.td-entry-phase {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
/* Actions menu: a native disclosure, not a popover library. */
|
||||
.td-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.td-menu > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.td-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.td-menu-body {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
min-width: 250px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-hi);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td-menu-body .row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.td-menu-body .row:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.td-menu-body .row:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.td-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Action, Detail, Event, Task } from '../api/types'
|
||||
import { Chip, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './TaskDetail.css'
|
||||
|
||||
/** work_phase is on the wire (domain.Task) but not yet in api/types.ts, which
|
||||
* another screen owns. Read it through a local widening rather than editing a
|
||||
* shared file out from under someone. */
|
||||
type Phased = Task & { work_phase?: string; last_lease_epoch?: string }
|
||||
|
||||
const time = (iso?: string) => (iso ? new Date(iso).toISOString().slice(11, 19) : '—')
|
||||
|
||||
function ago(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
return `${Math.floor(s / 3600)}h ago`
|
||||
}
|
||||
|
||||
/** ponytail: recomputed on each 5s poll rather than ticking per second. The
|
||||
* operator reads a lease in minutes; a second-accurate countdown would need a
|
||||
* timer for no decision it changes. */
|
||||
function remaining(iso?: string) {
|
||||
if (!iso) return undefined
|
||||
const s = Math.round((Date.parse(iso) - Date.now()) / 1000)
|
||||
if (s <= 0) return 'expired'
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
type Decision = {
|
||||
id: string
|
||||
kind: string
|
||||
subject: string
|
||||
value: string
|
||||
at: string
|
||||
standing: boolean
|
||||
}
|
||||
|
||||
/** The standing authority for this task, reduced from its own log. Mirrors
|
||||
* domain.ReduceIntent: a decision is retired only by being named, never by a
|
||||
* newer decision on the same subject. Superseded records are kept so the
|
||||
* screen can show them muted instead of pretending they never existed. */
|
||||
function decisionsOf(events: Event[]): Decision[] {
|
||||
const out: Decision[] = []
|
||||
const retired = new Set<string>()
|
||||
for (const e of events) {
|
||||
const p = (e.payload ?? {}) as {
|
||||
decision_id?: string
|
||||
kind?: string
|
||||
subject?: string
|
||||
value?: string
|
||||
supersedes?: string[]
|
||||
}
|
||||
if (!p.decision_id) continue
|
||||
if (e.type === 'HumanDecisionRecorded') {
|
||||
out.push({
|
||||
id: p.decision_id,
|
||||
kind: p.kind ?? 'decision',
|
||||
subject: p.subject ?? '',
|
||||
value: p.value ?? '',
|
||||
at: e.at,
|
||||
standing: true,
|
||||
})
|
||||
p.supersedes?.forEach((id) => retired.add(id))
|
||||
} else if (e.type === 'HumanDecisionSuperseded') {
|
||||
retired.add(p.decision_id)
|
||||
}
|
||||
}
|
||||
return out.map((d) => ({ ...d, standing: !retired.has(d.id) }))
|
||||
}
|
||||
|
||||
/** When each phase was entered, from the events Orchestra itself appended.
|
||||
* Verified state, not a claim. */
|
||||
function phaseEntries(events: Event[]) {
|
||||
return events
|
||||
.filter((e) => e.type === 'WorkPhaseChanged')
|
||||
.map((e) => ({ phase: (e.payload as { phase?: string })?.phase ?? '', at: e.at }))
|
||||
.filter((p) => p.phase)
|
||||
}
|
||||
|
||||
/** The last thing the pane actually showed. Agent-authored text: it is
|
||||
* rendered as a claim, never as orchestra state. */
|
||||
function lastLine(text?: string) {
|
||||
const lines = (text ?? '').split('\n').map((l) => l.trimEnd()).filter((l) => l.trim())
|
||||
return lines[lines.length - 1]
|
||||
}
|
||||
|
||||
function stateTone(task: Task) {
|
||||
if (task.state === 'blocked' || task.state === 'failed' || task.state === 'needs_attention') {
|
||||
return 'fault' as const
|
||||
}
|
||||
if (task.state === 'completed') return 'done' as const
|
||||
if (task.state === 'leased') return 'accent' as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function TaskDetail() {
|
||||
const { id = '' } = useParams()
|
||||
const client = useQueryClient()
|
||||
const query = useQuery({
|
||||
queryKey: ['task', id],
|
||||
queryFn: () => api.detail(id),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const act = useMutation({
|
||||
mutationFn: (action: string) => api.action(id, action),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['task', id] }),
|
||||
})
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Task</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">{String((query.error as Error).message)}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (!query.data) {
|
||||
return (
|
||||
<main className="page">
|
||||
<p className="mono">reading /v1/ui/tasks/{id}…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const detail: Detail = query.data
|
||||
const task = detail.task as Phased
|
||||
const session = detail.session
|
||||
const capture = session?.capture
|
||||
const approval = session?.pending_approval
|
||||
const decisions = decisionsOf(detail.events)
|
||||
const standing = decisions.filter((d) => d.standing)
|
||||
const constraints = standing.filter((d) => d.kind === 'constraint')
|
||||
const effective = standing.filter((d) => d.kind !== 'constraint')
|
||||
const superseded = decisions.filter((d) => !d.standing)
|
||||
const acceptance = task.acceptance ?? []
|
||||
const shown = acceptance.slice(0, 4)
|
||||
const lease = session?.lease_until ?? task.lease?.until
|
||||
const activity = lastLine(capture?.text)
|
||||
const entries = phaseEntries(detail.events)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
{/* ── top area ──────────────────────────────────────────────────── */}
|
||||
<header className="td-top">
|
||||
<div className="page-head">
|
||||
<h1>{task.title || task.external_id || 'untitled task'}</h1>
|
||||
<Chip tone="accent">{task.work_phase || 'frame'} phase</Chip>
|
||||
<Chip tone={stateTone(task)}>{task.state}</Chip>
|
||||
</div>
|
||||
<div className="td-actions">
|
||||
<ActionsMenu actions={detail.actions} pending={act.isPending} run={act.mutate} />
|
||||
<button className="btn" data-variant="primary" disabled title="no endpoint yet">
|
||||
<Icon name="decision" size={16} />
|
||||
Steer / Correct
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="td-ids">
|
||||
<span className="label">task id</span>
|
||||
<M>{task.id}</M>
|
||||
<span className="label">epoch</span>
|
||||
<M>{task.lease?.epoch || task.last_lease_epoch || '—'}</M>
|
||||
<span className="label">project</span>
|
||||
<M>{task.project}</M>
|
||||
<span className="label">version</span>
|
||||
<M>{task.version}</M>
|
||||
</div>
|
||||
|
||||
{act.isError && <p className="gap-note">{String((act.error as Error).message)}</p>}
|
||||
|
||||
<div className="td-columns">
|
||||
{/* ── what we're doing ────────────────────────────────────────── */}
|
||||
<Panel title="What we're doing">
|
||||
<div className="panel-body td-doing">
|
||||
<h3 className="label">goal</h3>
|
||||
<p className="td-prose">{task.description || task.title || 'No goal recorded.'}</p>
|
||||
|
||||
<h3 className="label">
|
||||
acceptance <M>{acceptance.length}</M>
|
||||
</h3>
|
||||
{acceptance.length === 0 ? (
|
||||
<p className="td-muted">No acceptance criteria on the contract.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="td-list">
|
||||
{shown.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
{acceptance.length > shown.length && (
|
||||
<details className="td-more">
|
||||
<summary>
|
||||
<M>+{acceptance.length - shown.length}</M> more criteria
|
||||
</summary>
|
||||
<ul className="td-list">
|
||||
{acceptance.slice(shown.length).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{task.quality_gate && (
|
||||
<p className="td-muted">
|
||||
<span className="label">quality gate</span> <M>{task.quality_gate}</M>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{effective.length > 0 && (
|
||||
<>
|
||||
<h3 className="label">effective human decisions</h3>
|
||||
<ul className="td-decisions">
|
||||
{effective.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">{d.kind}</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{constraints.length > 0 && (
|
||||
<>
|
||||
<h3 className="label">active constraints</h3>
|
||||
<ul className="td-decisions">
|
||||
{constraints.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">constraint</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{superseded.length > 0 && (
|
||||
<details className="td-more td-superseded">
|
||||
<summary>
|
||||
<M>{superseded.length}</M> superseded {superseded.length === 1 ? 'decision' : 'decisions'}
|
||||
</summary>
|
||||
<ul className="td-decisions">
|
||||
{superseded.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">{d.kind}</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* ── live execution ──────────────────────────────────────────── */}
|
||||
<Panel
|
||||
title="Live execution"
|
||||
trailing={
|
||||
session?.agent_status ? (
|
||||
<Chip tone={session.agent_status === 'blocked' ? 'fault' : 'accent'}>
|
||||
{session.agent_status}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="mono">no session</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="panel-body">
|
||||
<div className="td-exec">
|
||||
<div className="stat">
|
||||
<span className="label">worker / harness</span>
|
||||
<M>{session?.harness_id || task.lease?.harness_id || task.last_harness_id || '—'}</M>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">pane</span>
|
||||
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
||||
<span className="td-muted">
|
||||
<M>{task.pane_state || 'unknown'}</M>
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">lease remaining</span>
|
||||
<span className="stat-value">{remaining(lease) ?? '—'}</span>
|
||||
<span className="td-muted">
|
||||
until <M>{time(lease)}</M>
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">last progress</span>
|
||||
<span className="stat-value">{ago(capture?.at)}</span>
|
||||
<span className="td-muted">
|
||||
capture rev <M>{capture?.revision ?? '—'}</M>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context occupancy is the one number this panel is supposed to
|
||||
carry and the read model does not produce it. Say so rather
|
||||
than compute a plausible-looking figure. */}
|
||||
<EndpointGap
|
||||
path="/v1/ui/tasks/:id (no context accounting on session)"
|
||||
what="Context occupancy — tokens used against the harness window — has no field in the read model, so no meter is drawn."
|
||||
/>
|
||||
|
||||
<h3 className="label td-claim-head">
|
||||
current activity
|
||||
<span className="td-claim-tag">
|
||||
agent-supplied · pane capture{capture?.source ? ` · ${capture.source}` : ''}
|
||||
</span>
|
||||
</h3>
|
||||
{activity ? (
|
||||
<p className="td-claim">
|
||||
<M>{activity}</M>
|
||||
<span className="td-claim-at">
|
||||
<M>{time(capture?.at)}</M>
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="td-muted">No capture from the owning worker.</p>
|
||||
)}
|
||||
|
||||
{approval && (
|
||||
<div className="td-approval">
|
||||
<span className="label">pending approval</span>
|
||||
<p className="td-prose">{approval.summary}</p>
|
||||
{approval.command && <M>{approval.command}</M>}
|
||||
<div className="td-approval-row">
|
||||
<span className="mono">
|
||||
{approval.pane_id} · rev {approval.capture_revision}
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
data-variant="primary"
|
||||
disabled={act.isPending}
|
||||
onClick={() => act.mutate('grant_approval')}
|
||||
>
|
||||
Grant
|
||||
</button>
|
||||
<button className="btn" disabled={act.isPending} onClick={() => act.mutate('deny_approval')}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="td-links">
|
||||
<Link className="btn" to={`/tasks/${task.id}/terminal`}>
|
||||
<Icon name="terminal" size={16} />
|
||||
Open live pane
|
||||
</Link>
|
||||
{task.handoff_ref && (
|
||||
<a className="btn" href={`/v1/ui/artifacts/${task.handoff_ref}`}>
|
||||
<Icon name="file" size={16} />
|
||||
Handoff
|
||||
</a>
|
||||
)}
|
||||
{detail.report_ref && (
|
||||
<a className="btn" href={`/v1/ui/artifacts/${detail.report_ref}`}>
|
||||
<Icon name="file" size={16} />
|
||||
Report
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<EndpointGap
|
||||
path="/v1/ui/tasks/:id/{logs,launch_context,diff}"
|
||||
what="Worker logs, launch context and git diff have no endpoint, so those links are not offered."
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── workflow ──────────────────────────────────────────────────── */}
|
||||
<Panel title="Workflow" trailing={<span className="mono">ace-fca</span>}>
|
||||
<div className="panel-body td-flow">
|
||||
<PhasePath current={task.work_phase} />
|
||||
{entries.length > 0 ? (
|
||||
<div className="td-entries">
|
||||
{entries.map((e) => (
|
||||
<span key={`${e.phase}-${e.at}`}>
|
||||
<span className="td-entry-phase">{e.phase}</span> entered <M>{time(e.at)}</M>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="td-muted">
|
||||
No WorkPhaseChanged event in this task's log — the path shows the current phase only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** Operator actions, exactly as the server declares them. An action that
|
||||
* needs input the console cannot collect stays disabled and says what it
|
||||
* wants — offering a button that will 409 is the dishonest option. */
|
||||
function ActionsMenu({
|
||||
actions,
|
||||
pending,
|
||||
run,
|
||||
}: {
|
||||
actions: Action[]
|
||||
pending: boolean
|
||||
run: (action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<details className="td-menu">
|
||||
<summary className="btn">
|
||||
Actions <Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
<div className="td-menu-body">
|
||||
{actions.map((a) => {
|
||||
const blocked = !a.enabled || (a.needs?.length ?? 0) > 0
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
className="row"
|
||||
disabled={blocked || pending}
|
||||
onClick={() => run(a.id)}
|
||||
title={a.needs?.length ? `needs ${a.needs.join(', ')}` : a.reason}
|
||||
>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{a.id}</span>
|
||||
<span className="row-sub">
|
||||
{a.needs?.length ? `needs ${a.needs.join(', ')}` : a.enabled ? a.reason || '' : a.reason || 'unavailable'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/* Tasks list. Only what the shared shell does not already carry: the row
|
||||
column geometry, the group disclosure, and the filter bar. */
|
||||
|
||||
.tasks-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-tab {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 7px 11px;
|
||||
color: var(--text-mid);
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.tasks-tab span {
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.tasks-tab:hover {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.tasks-tab[aria-selected='true'] {
|
||||
color: var(--text-hi);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tasks-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tasks-filter select {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasks-filter select:hover {
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.page-head .count {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── group disclosure ────────────────────────────────────────────────── */
|
||||
|
||||
.tasks-group > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tasks-group > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tasks-group:not([open]) > summary {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.tasks-group > summary > svg:first-child {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.tasks-group > summary > svg:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--text-lo);
|
||||
transform: rotate(90deg);
|
||||
transition: transform var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.tasks-group[open] > summary > svg:last-child {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ── row geometry ────────────────────────────────────────────────────── */
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.task-row > svg:last-child {
|
||||
color: var(--text-lo);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.task-row-main {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-row-main .row-title,
|
||||
.task-row-main .row-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-row-phase {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-row-phase-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* The compact path carries no labels, so the connector no longer needs to
|
||||
sit above a name. */
|
||||
.tasks-phases .phase-step {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks-phases .phase-link {
|
||||
width: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-row-worker {
|
||||
flex: none;
|
||||
width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-row-when {
|
||||
flex: none;
|
||||
width: 118px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-row-status {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.task-row-phase,
|
||||
.task-row-when {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-row-worker {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-row-worker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tasks-filters {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Overview, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, PHASES, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Tasks.css'
|
||||
|
||||
type GroupKey = 'attention' | 'running' | 'waiting' | 'completed' | 'failed'
|
||||
|
||||
const GROUPS: { key: GroupKey; title: string; icon: string; open: boolean }[] = [
|
||||
{ key: 'attention', title: 'Needs attention', icon: 'alert', open: true },
|
||||
{ key: 'running', title: 'Running', icon: 'play', open: true },
|
||||
{ key: 'waiting', title: 'Waiting / queued', icon: 'queue', open: true },
|
||||
{ key: 'completed', title: 'Completed', icon: 'check', open: false },
|
||||
{ key: 'failed', title: 'Failed', icon: 'x', open: false },
|
||||
]
|
||||
|
||||
function groupOf(task: Task): GroupKey {
|
||||
switch (task.state) {
|
||||
case 'needs_attention':
|
||||
case 'blocked':
|
||||
return 'attention'
|
||||
case 'leased':
|
||||
return 'running'
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
case 'failed':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
}
|
||||
|
||||
/** The harness holding the lease, or the last one that did. Nothing is
|
||||
* invented: a task that never ran reports no worker. */
|
||||
function harnessOf(task: Task) {
|
||||
return task.lease?.harness_id || task.last_harness_id || ''
|
||||
}
|
||||
|
||||
function stateTone(task: Task) {
|
||||
if (task.state === 'failed' || task.state === 'blocked') return 'fault' as const
|
||||
if (task.state === 'needs_attention') return 'warn' as const
|
||||
if (task.state === 'completed') return 'done' as const
|
||||
if (task.state === 'leased') return 'accent' as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ago(at?: string) {
|
||||
if (!at) return ''
|
||||
const ms = Date.now() - Date.parse(at)
|
||||
if (!Number.isFinite(ms)) return ''
|
||||
return `${span(ms)} ago`
|
||||
}
|
||||
|
||||
function span(ms: number) {
|
||||
const s = Math.max(0, Math.round(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h`
|
||||
return `${Math.round(s / 86400)}d`
|
||||
}
|
||||
|
||||
/** The one time value a task actually carries per state. There is no
|
||||
* per-task updated_at on the overview, so this never claims one. */
|
||||
function timing(task: Task): { label: string; value: string } | undefined {
|
||||
if (task.lease?.until) {
|
||||
const left = Date.parse(task.lease.until) - Date.now()
|
||||
return left > 0
|
||||
? { label: 'lease', value: `${span(left)} left` }
|
||||
: { label: 'lease', value: `expired ${span(-left)} ago` }
|
||||
}
|
||||
if (task.blocked_at) return { label: 'blocked', value: ago(task.blocked_at) }
|
||||
if (task.next_retry_at) {
|
||||
const left = Date.parse(task.next_retry_at) - Date.now()
|
||||
return { label: 'retry', value: left > 0 ? `in ${span(left)}` : 'due' }
|
||||
}
|
||||
if (task.due) return { label: 'due', value: task.due.slice(0, 10) }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function unique(values: (string | undefined)[]) {
|
||||
return [...new Set(values.filter((value): value is string => !!value))].sort()
|
||||
}
|
||||
|
||||
function Select({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="tasks-filter">
|
||||
<span className="label">{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ task, agentStatus }: { task: Task; agentStatus?: string }) {
|
||||
const harness = harnessOf(task)
|
||||
const when = timing(task)
|
||||
return (
|
||||
<Link className="row task-row" to={`/tasks/${task.id}`}>
|
||||
<span className="task-row-main">
|
||||
<span className="row-title">{task.title || task.external_id || 'untitled task'}</span>
|
||||
<span className="row-sub">
|
||||
{task.project} · <M>{task.external_id || task.id}</M>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="task-row-phase tasks-phases">
|
||||
<PhasePath current={task.lifecycle_phase} labels={false} />
|
||||
<span className="task-row-phase-name mono">{task.lifecycle_phase || '—'}</span>
|
||||
</span>
|
||||
|
||||
<span className="task-row-worker">
|
||||
{harness ? (
|
||||
<>
|
||||
<M>{harness}</M>
|
||||
{task.last_pane_id && <span className="row-sub mono">{task.last_pane_id}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="row-sub">unassigned</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="task-row-when">
|
||||
{when && (
|
||||
<>
|
||||
<span className="label">{when.label}</span>
|
||||
<M>{when.value}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="task-row-status">
|
||||
{agentStatus && <Chip>{agentStatus}</Chip>}
|
||||
<Chip tone={stateTone(task)}>{task.block_reason || task.state}</Chip>
|
||||
</span>
|
||||
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
title,
|
||||
icon,
|
||||
open,
|
||||
tasks,
|
||||
statusFor,
|
||||
}: {
|
||||
title: string
|
||||
icon: string
|
||||
open: boolean
|
||||
tasks: Task[]
|
||||
statusFor: (task: Task) => string | undefined
|
||||
}) {
|
||||
return (
|
||||
<details className="panel tasks-group" open={open && tasks.length > 0}>
|
||||
<summary className="panel-head">
|
||||
<Icon name={icon} size={17} />
|
||||
<h2>{title}</h2>
|
||||
<span className="count">{tasks.length}</span>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
{tasks.length ? (
|
||||
<div className="rows">
|
||||
{tasks.map((task) => (
|
||||
<Row key={task.id} task={task} agentStatus={statusFor(task)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty title="Nothing here" />
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function Tasks() {
|
||||
const { data, isLoading, error } = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const [project, setProject] = useState('')
|
||||
const [phase, setPhase] = useState('')
|
||||
const [worker, setWorker] = useState('')
|
||||
const [attention, setAttention] = useState('')
|
||||
const [group, setGroup] = useState<GroupKey | ''>('')
|
||||
|
||||
const tasks = useMemo(() => data?.tasks ?? [], [data])
|
||||
|
||||
/** Agent status comes from the live session, matched by pane. It is an
|
||||
* agent claim, so it stays a separate chip from orchestra's own state. */
|
||||
const statusFor = useMemo(() => {
|
||||
const byPane = new Map((data?.sessions ?? []).map((s) => [s.pane_id, s]))
|
||||
return (task: Task) =>
|
||||
task.state === 'leased' ? byPane.get(task.last_pane_id)?.agent_status : undefined
|
||||
}, [data])
|
||||
|
||||
const filtered = tasks.filter((task) => {
|
||||
if (project && task.project !== project) return false
|
||||
if (phase && task.lifecycle_phase !== phase) return false
|
||||
if (worker && harnessOf(task) !== worker) return false
|
||||
if (attention === 'needs' && groupOf(task) !== 'attention') return false
|
||||
if (attention === 'clear' && groupOf(task) === 'attention') return false
|
||||
if (attention && attention !== 'needs' && attention !== 'clear' && task.block_reason !== attention)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
const counts = (key: GroupKey) => filtered.filter((task) => groupOf(task) === key).length
|
||||
const shown = GROUPS.filter((g) => !group || g.key === group)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Tasks</h1>
|
||||
<p>All work under orchestration.</p>
|
||||
<span className="count mono">{tasks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="tasks-controls">
|
||||
<div className="tasks-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={group === ''}
|
||||
className="tasks-tab"
|
||||
onClick={() => setGroup('')}
|
||||
>
|
||||
All <span className="mono">{filtered.length}</span>
|
||||
</button>
|
||||
{GROUPS.map((g) => (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={group === g.key}
|
||||
className="tasks-tab"
|
||||
onClick={() => setGroup(group === g.key ? '' : g.key)}
|
||||
>
|
||||
{g.title} <span className="mono">{counts(g.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tasks-filters">
|
||||
<Select
|
||||
label="Project"
|
||||
value={project}
|
||||
options={unique(tasks.map((task) => task.project))}
|
||||
onChange={setProject}
|
||||
/>
|
||||
<Select label="Phase" value={phase} options={[...PHASES]} onChange={setPhase} />
|
||||
<Select
|
||||
label="Worker"
|
||||
value={worker}
|
||||
options={unique(tasks.map(harnessOf))}
|
||||
onChange={setWorker}
|
||||
/>
|
||||
<Select
|
||||
label="Attention"
|
||||
value={attention}
|
||||
options={['needs', 'clear', ...unique(tasks.map((task) => task.block_reason))]}
|
||||
onChange={setAttention}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="panel panel-body">
|
||||
<p className="gap-note">/v1/ui/overview failed: {(error as Error).message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !data && (
|
||||
<div className="panel panel-body">
|
||||
<p className="gap-note">loading /v1/ui/overview…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !tasks.length && (
|
||||
<div className="panel">
|
||||
<Empty title="No tasks yet">
|
||||
<p>Orchestra has no work on record. Ingest a task to start.</p>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data &&
|
||||
tasks.length > 0 &&
|
||||
shown.map((g) => (
|
||||
<Group
|
||||
key={g.key}
|
||||
title={g.title}
|
||||
icon={g.icon}
|
||||
open={g.open || !!group}
|
||||
tasks={filtered.filter((task) => groupOf(task) === g.key)}
|
||||
statusFor={statusFor}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data && tasks.length > 0 && (
|
||||
<div className="panel">
|
||||
<EndpointGap
|
||||
path="/v1/ui/overview carries no per-task progress, ETA or context occupancy"
|
||||
what="Rows show lease, block and retry times because those are the only per-task clocks orchestra records. Progress bars and context meters are left out rather than estimated."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/* The live pane. Tokens only — nothing here redefines the ethos ramp. */
|
||||
|
||||
.term {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.term-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.term-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-sep {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.term-controls {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-mode {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.term-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* The frame. Depth from a hairline and a surface step, never from glass. */
|
||||
.term-frame {
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line-hi);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.term-frame[data-lost='true'] {
|
||||
border-color: rgba(224, 108, 96, 0.42);
|
||||
}
|
||||
|
||||
.term-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.term-bar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Exact pane characters: no wrapping, no reflow, no prettifying. */
|
||||
.term-text {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
height: min(62vh, 720px);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
tab-size: 8;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: #cfc6b4;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.term-text:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.term-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.term-foot:not(:empty) {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.term-jump {
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-warn {
|
||||
font-size: 12px;
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.term-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.term-fields,
|
||||
.term-lost {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.term-lost p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.term-field-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.term-health {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.term-link:hover {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.term-resubmit p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.term-event {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-muted {
|
||||
margin: 0;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.term-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.term-controls {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term-text {
|
||||
height: 54vh;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Capture, Detail } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Terminal.css'
|
||||
|
||||
/** This is the screen that must not lie. Everything inside the frame is the
|
||||
* pane's own bytes; everything outside it is state Orchestra can prove. */
|
||||
|
||||
function ago(at?: string) {
|
||||
if (!at) return '—'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - new Date(at).getTime()) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
return `${Math.floor(seconds / 3600)}h ago`
|
||||
}
|
||||
|
||||
function clock(at?: string) {
|
||||
return at ? new Date(at).toISOString().slice(11, 19) : '—'
|
||||
}
|
||||
|
||||
function until(at?: string) {
|
||||
if (!at) return '—'
|
||||
const seconds = Math.round((new Date(at).getTime() - Date.now()) / 1000)
|
||||
if (seconds <= 0) return 'expired'
|
||||
const m = Math.floor(seconds / 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="term-field">
|
||||
<span className="label">{label}</span>
|
||||
<span className="term-field-value">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Terminal() {
|
||||
const { id = '' } = useParams()
|
||||
const queries = useQueryClient()
|
||||
const detail = useQuery<Detail>({
|
||||
queryKey: ['detail', id],
|
||||
queryFn: () => api.detail(id),
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
|
||||
// The last frame we actually received, kept across refreshes so a lost pane
|
||||
// still shows what was true when it was lost instead of an empty box.
|
||||
const [frame, setFrame] = useState<Capture | undefined>()
|
||||
const [lostAt, setLostAt] = useState<string | undefined>()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pinned, setPinned] = useState(true)
|
||||
const view = useRef<HTMLPreElement>(null)
|
||||
|
||||
const task = detail.data?.task
|
||||
const session = detail.data?.session
|
||||
const capture = session?.capture
|
||||
|
||||
useEffect(() => {
|
||||
if (capture) {
|
||||
setFrame(capture)
|
||||
setLostAt(undefined)
|
||||
return
|
||||
}
|
||||
// A leased task with no capture is a lost pane, not an empty one.
|
||||
if (detail.isSuccess && task?.state === 'leased') {
|
||||
setLostAt((was) => was ?? new Date().toISOString())
|
||||
}
|
||||
}, [capture, detail.isSuccess, task?.state])
|
||||
|
||||
// Scrollback inspection must not disturb the live view: we only follow the
|
||||
// tail while the operator is already sitting at the tail.
|
||||
useEffect(() => {
|
||||
const el = view.current
|
||||
if (el && pinned) el.scrollTop = el.scrollHeight
|
||||
}, [frame?.revision, pinned])
|
||||
|
||||
const resubmit = useMutation({
|
||||
mutationFn: () => api.action(id, 'resubmit'),
|
||||
onSuccess: (next) => queries.setQueryData(['detail', id], next),
|
||||
})
|
||||
|
||||
const canResubmit = detail.data?.actions.find((a) => a.id === 'resubmit')?.enabled ?? false
|
||||
const lost = Boolean(lostAt) && Boolean(frame)
|
||||
const status = session?.agent_status || task?.pane_state || 'unknown'
|
||||
const health = lost ? 'error' : status === 'blocked' ? 'degraded' : status ? 'healthy' : 'unknown'
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<div className="panel-body">Reading the pane…</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.isError || !task) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<Empty title="No such task">
|
||||
<p>
|
||||
<M>{id}</M> is not in the store, so there is no pane to show.
|
||||
</p>
|
||||
</Empty>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page term">
|
||||
<div className="page-head term-head">
|
||||
<h1>Terminal — live pane</h1>
|
||||
{lost ? <Chip tone="fault">Pane lost</Chip> : capture ? <Chip tone="accent">Live</Chip> : <Chip>No session</Chip>}
|
||||
<span className="term-identity">
|
||||
<M>{session?.harness_id || task.last_harness_id || 'no worker'}</M>
|
||||
<span className="term-sep">/</span>
|
||||
<M>{session?.pane_id || task.last_pane_id || 'no pane'}</M>
|
||||
</span>
|
||||
<div className="term-controls">
|
||||
<span className="chip term-mode" data-mode="read-only">
|
||||
<Icon name="terminal" size={14} /> Read-only
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled
|
||||
title="Orchestra has no keystroke-forwarding endpoint"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!frame}
|
||||
onClick={() => {
|
||||
if (!frame) return
|
||||
void navigator.clipboard.writeText(frame.text).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy frame'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="term-layout">
|
||||
<div className="term-frame" data-lost={lost}>
|
||||
<div className="term-bar">
|
||||
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
||||
<span className="term-bar-right">
|
||||
<M>rev {frame ? frame.revision : '—'}</M>
|
||||
<M>{frame?.source || '—'}</M>
|
||||
<M>{clock(frame?.at)}</M>
|
||||
</span>
|
||||
</div>
|
||||
{frame ? (
|
||||
<pre
|
||||
ref={view}
|
||||
className="term-text"
|
||||
tabIndex={0}
|
||||
aria-label="Pane capture, read-only"
|
||||
onScroll={(event) => {
|
||||
const el = event.currentTarget
|
||||
setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 24)
|
||||
}}
|
||||
>
|
||||
{frame.text}
|
||||
</pre>
|
||||
) : (
|
||||
<Empty title="No capture">
|
||||
<p>
|
||||
This task holds no leased session, so no worker is publishing pane text for{' '}
|
||||
<M>{task.id}</M>.
|
||||
</p>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="term-foot">
|
||||
{frame?.truncated && <span className="term-warn">frame truncated by the worker</span>}
|
||||
{!pinned && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn term-jump"
|
||||
onClick={() => {
|
||||
setPinned(true)
|
||||
const el = view.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} /> Scrollback held — jump to live
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="term-side">
|
||||
{lost && (
|
||||
<Panel title="Pane lost">
|
||||
<div className="panel-body term-lost">
|
||||
<p>
|
||||
The frame above is the last one Orchestra received. It is history, not the
|
||||
present.
|
||||
</p>
|
||||
<Field label="Frame captured" value={<M>{clock(frame?.at)} UTC</M>} />
|
||||
<Field label="Capture stopped" value={<M>{clock(lostAt)} UTC</M>} />
|
||||
<Field
|
||||
label="Lease consequence"
|
||||
value={
|
||||
task.lease?.until ? (
|
||||
<span>
|
||||
the lease still runs until <M>{clock(task.lease.until)} UTC</M>; nothing is
|
||||
rerouted before it expires
|
||||
</span>
|
||||
) : (
|
||||
<span>no lease is held, so this task can be routed again immediately</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Session">
|
||||
<div className="panel-body term-fields">
|
||||
<Field label="Task" value={<Link to={`/tasks/${task.id}`} className="term-link"><M>{task.id}</M></Link>} />
|
||||
<Field label="Worker" value={<M>{session?.harness_id || task.last_harness_id || '—'}</M>} />
|
||||
<Field label="Pane" value={<M>{session?.pane_id || task.last_pane_id || '—'}</M>} />
|
||||
<Field label="Lease epoch" value={<M>{task.lease?.epoch || '—'}</M>} />
|
||||
<Field
|
||||
label="Lease"
|
||||
value={
|
||||
task.lease?.until ? (
|
||||
<span>
|
||||
<M>{until(task.lease.until)}</M> left · until <M>{clock(task.lease.until)} UTC</M>
|
||||
</span>
|
||||
) : (
|
||||
<M>none</M>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field label="Capture source" value={<M>{frame?.source || '—'}</M>} />
|
||||
<Field label="Capture revision" value={<M>{frame ? frame.revision : '—'}</M>} />
|
||||
<Field label="Last verified progress" value={<M>{ago(frame?.at)}</M>} />
|
||||
<Field label="Mode" value={<M>read-only</M>} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Health">
|
||||
<div className="panel-body term-fields">
|
||||
<Field
|
||||
label="Agent"
|
||||
value={
|
||||
<span className="term-health">
|
||||
<Dot health={health} />
|
||||
<M>{status}</M>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Field label="Checked" value={<M>{ago(task.last_session?.checked_at || frame?.at)}</M>} />
|
||||
{(session?.blocker || task.blocker) && (
|
||||
<Field label="Blocker" value={<span>{session?.blocker || task.blocker}</span>} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Keyboard forwarding">
|
||||
<EndpointGap
|
||||
path="POST /v1/ui/tasks/:id/actions/send_keys"
|
||||
what="Orchestra exposes no endpoint that forwards arbitrary keystrokes to a pane. The only operator input that reaches a live pane today is resubmit (a single Enter on text Orchestra itself submitted) and approval grant/deny. Take control stays disabled rather than opening a text box that goes nowhere."
|
||||
/>
|
||||
<div className="panel-body term-resubmit">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-variant="primary"
|
||||
disabled={!canResubmit || resubmit.isPending}
|
||||
onClick={() => resubmit.mutate()}
|
||||
>
|
||||
{resubmit.isPending ? 'Sending…' : 'Resubmit (send Enter)'}
|
||||
</button>
|
||||
<p>
|
||||
Presses Enter once on input Orchestra placed in this pane. It sends no other key and
|
||||
changes no lifecycle state.
|
||||
</p>
|
||||
{resubmit.isError && (
|
||||
<p className="term-warn">{(resubmit.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Recent events" count={detail.data?.events.length}>
|
||||
<div className="panel-body term-events">
|
||||
{(detail.data?.events ?? []).slice(-5).reverse().map((event) => (
|
||||
<div key={event.id} className="term-event">
|
||||
<M>{clock(event.at)}</M>
|
||||
<span>{event.type}</span>
|
||||
</div>
|
||||
))}
|
||||
{!detail.data?.events.length && <p className="term-muted">No events recorded yet.</p>}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* Workers screen. Only what the shared sheet does not already carry:
|
||||
the two-column split, the label/value field row, and the observations ring. */
|
||||
|
||||
.workers-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.workers-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workers .worker-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workers .worker-row[aria-selected='true'] {
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.workers .status-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workers .worker-id {
|
||||
font-size: 15px;
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.workers .dim {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.workers .task-link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workers .task-link .mono {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.workers .task-link:hover .mono {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workers .task-title {
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* A human label, a machine value, on one line. Law 1 drawn as geometry. */
|
||||
.workers .field {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.workers .field-name {
|
||||
flex: none;
|
||||
width: 150px;
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workers .field-value {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workers .chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workers .steps {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
color: var(--text-mid);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workers .reg-lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.workers .reg-lead svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── the observations ring ───────────────────────────────────────────
|
||||
One distinct failure per row, with its repeat count kept whole. A count
|
||||
of 41 means something looped 41 times; that number is the finding, so it
|
||||
gets weight instead of a parenthesis. */
|
||||
|
||||
.workers .obs-latest {
|
||||
margin: 0 0 12px;
|
||||
border-style: solid;
|
||||
border-color: var(--line-hi);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workers .obs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workers .obs-item {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.workers .obs-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.workers .obs-count {
|
||||
flex: none;
|
||||
font-size: 15px;
|
||||
color: var(--text-hi);
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Repeats are the signal that something is stuck rather than merely broken. */
|
||||
.workers .obs-count[data-repeat='true'] {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.workers .obs-msg {
|
||||
min-width: 0;
|
||||
color: var(--text-machine);
|
||||
font-size: 12.5px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workers .obs-when {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-left: 52px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workers .obs-when .mono {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.workers-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workers .obs-when {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.workers .field {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workers .field-name {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Task, Worker, WorkerHealth } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import type { Health } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Workers.css'
|
||||
|
||||
/** The coordinator's Go structs carry `observations` on WorkerHealth; the
|
||||
* shared type file has not caught up and is owned elsewhere, so the shape is
|
||||
* narrowed here rather than guessed at the call site. */
|
||||
interface Observation {
|
||||
message: string
|
||||
count: number
|
||||
first: string
|
||||
last: string
|
||||
}
|
||||
type Health2 = WorkerHealth & { observations?: Observation[] }
|
||||
|
||||
/** Go marshals a zero time.Time as year 1 — `omitempty` does not omit a
|
||||
* struct. Treating that as a real timestamp would print "2025y ago". */
|
||||
function absent(iso?: string) {
|
||||
return !iso || iso.startsWith('0001-')
|
||||
}
|
||||
|
||||
function ago(iso?: string) {
|
||||
if (absent(iso)) return '—'
|
||||
const s = Math.max(0, (Date.now() - new Date(iso as string).getTime()) / 1000)
|
||||
if (s < 60) return `${Math.floor(s)}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
function clock(iso?: string) {
|
||||
if (absent(iso)) return '—'
|
||||
return new Date(iso as string).toISOString().slice(11, 19) + ' UTC'
|
||||
}
|
||||
|
||||
function health(w: Worker): Health {
|
||||
if (!w.online) return 'unknown'
|
||||
if (w.health.herdr_status === 'unreachable') return 'error'
|
||||
if (w.health.herdr_status === 'unknown') return 'unknown'
|
||||
return w.health.last_error ? 'degraded' : 'healthy'
|
||||
}
|
||||
|
||||
function statusLabel(w: Worker) {
|
||||
if (!w.online) return 'Offline'
|
||||
if (w.health.herdr_status === 'unreachable') return 'Backend unreachable'
|
||||
if (w.health.active_task_id) return 'Busy'
|
||||
return w.health.last_error ? 'Degraded' : 'Idle'
|
||||
}
|
||||
|
||||
/** The ring is the evidence. A message with a count of 41 means a loop ran 41
|
||||
* times, and that number is the whole point — it is never collapsed away. */
|
||||
function Observations({ items }: { items: Observation[] }) {
|
||||
return (
|
||||
<ul className="obs">
|
||||
{items.map((o) => (
|
||||
<li className="obs-item" key={o.message + o.first}>
|
||||
<div className="obs-head">
|
||||
<span className="obs-count mono" data-repeat={o.count > 1} title={`${o.count} occurrences`}>
|
||||
×{o.count}
|
||||
</span>
|
||||
<span className="obs-msg mono">{o.message}</span>
|
||||
</div>
|
||||
<div className="obs-when">
|
||||
<span className="label">first</span>
|
||||
<M>{clock(o.first)}</M>
|
||||
<span className="label">last</span>
|
||||
<M>{clock(o.last)}</M>
|
||||
<M>({ago(o.last)})</M>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ name, children }: { name: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<span className="field-name">{name}</span>
|
||||
<span className="field-value">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
|
||||
const h = worker.health as Health2
|
||||
const obs = h.observations ?? []
|
||||
const task = tasks.find((t) => t.id === h.active_task_id)
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<Panel>
|
||||
<header className="panel-head">
|
||||
<h2 className="mono worker-id">{worker.id}</h2>
|
||||
<span className="trailing">
|
||||
<Dot health={health(worker)} /> {statusLabel(worker)}
|
||||
</span>
|
||||
</header>
|
||||
<div className="panel-body">
|
||||
<Field name="Backend">
|
||||
<M>{h.backend || 'unknown'}</M>
|
||||
</Field>
|
||||
<Field name="Pane backend status">
|
||||
<Dot health={health(worker)} /> <M>{h.herdr_status}</M>
|
||||
</Field>
|
||||
<Field name="Checked">
|
||||
<M>{clock(h.checked_at)}</M> <span className="dim mono">({ago(h.checked_at)})</span>
|
||||
</Field>
|
||||
<Field name="Last seen">
|
||||
<M>{clock(worker.last_seen)}</M>{' '}
|
||||
<span className="dim mono">({ago(worker.last_seen)})</span>
|
||||
</Field>
|
||||
<Field name="Capacity">
|
||||
<M>{worker.capacity}</M>
|
||||
</Field>
|
||||
{worker.address && (
|
||||
<Field name="Address">
|
||||
<M>{worker.address}</M>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Current session">
|
||||
<div className="panel-body">
|
||||
{h.active_task_id ? (
|
||||
<>
|
||||
<Field name="Task">
|
||||
<Link className="task-link" to={`/tasks/${h.active_task_id}`}>
|
||||
<M>{h.active_task_id}</M>
|
||||
{task?.title && <span className="task-title">{task.title}</span>}
|
||||
</Link>
|
||||
</Field>
|
||||
{task?.lifecycle_phase && (
|
||||
<Field name="Phase">
|
||||
<Chip tone="accent">{task.lifecycle_phase}</Chip>
|
||||
</Field>
|
||||
)}
|
||||
<Field name="Pane">
|
||||
<M>{h.active_pane_id || '—'}</M>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<p className="dim">No task leased to this worker.</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Build">
|
||||
<div className="panel-body">
|
||||
<Field name="Revision">
|
||||
<M>{worker.build?.revision || 'unknown'}</M>
|
||||
</Field>
|
||||
<Field name="Built">
|
||||
<M>{worker.build?.time || 'unknown'}</M>
|
||||
</Field>
|
||||
{worker.build?.dirty === 'true' && (
|
||||
<Field name="Tree">
|
||||
<Chip tone="warn">dirty — built from uncommitted changes</Chip>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Declared projects" trailing={<span className="dim">reported by worker</span>}>
|
||||
<div className="panel-body">
|
||||
{worker.supported_projects?.length ? (
|
||||
<div className="chips">
|
||||
{worker.supported_projects.map((p) => (
|
||||
<Chip key={p}>
|
||||
<span className="mono">{p}</span>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim">The worker declared no projects, so nothing routes to it.</p>
|
||||
)}
|
||||
<EndpointGap
|
||||
path="PATCH /v1/federation/workers/:id"
|
||||
what="Projects and capabilities are set by the worker at registration. There is no endpoint to edit them from here, and a form that only looked like it worked would lie about who owns the value."
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Observations"
|
||||
count={obs.length}
|
||||
trailing={<span className="dim">bounded ring of distinct failures</span>}
|
||||
>
|
||||
{obs.length ? (
|
||||
<div className="panel-body">
|
||||
{h.last_error && (
|
||||
<p className="gap-note obs-latest">
|
||||
latest: {h.last_error}
|
||||
{!absent(h.error_at) && ` — ${clock(h.error_at)}`}
|
||||
</p>
|
||||
)}
|
||||
<Observations items={obs} />
|
||||
</div>
|
||||
) : (
|
||||
<Empty title="No failures recorded">
|
||||
<p>The worker has reported nothing to explain. Repeats would collapse here with a count.</p>
|
||||
</Empty>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Workers() {
|
||||
const workers = useQuery({ queryKey: ['workers'], queryFn: api.workers, refetchInterval: 5000 })
|
||||
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
|
||||
const [picked, setPicked] = useState<string>()
|
||||
|
||||
const list = workers.data ?? []
|
||||
const selected = list.find((w) => w.id === picked) ?? list[0]
|
||||
const online = list.filter((w) => w.online)
|
||||
const busy = online.filter((w) => w.health.active_task_id)
|
||||
const faulted = list.filter((w) => health(w) === 'error' || health(w) === 'degraded')
|
||||
const capacity = online.reduce((n, w) => n + (w.capacity || 0), 0)
|
||||
|
||||
return (
|
||||
<main className="page workers">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Workers</h1>
|
||||
<p>Where work can run, and whether that machinery is usable.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workers.isError ? (
|
||||
<Panel>
|
||||
<Empty title="Worker list unavailable">
|
||||
<p className="mono">GET /v1/federation/workers failed. Nothing can be leased until it answers.</p>
|
||||
</Empty>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Online</span>
|
||||
<span className="stat-value">{online.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Busy</span>
|
||||
<span className="stat-value">{busy.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Offline</span>
|
||||
<span className="stat-value">{list.length - online.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Faulted</span>
|
||||
<span className="stat-value">{faulted.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Free capacity</span>
|
||||
<span className="stat-value">{capacity - busy.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workers-split">
|
||||
<Panel title="Fleet" count={list.length}>
|
||||
{list.length === 0 ? (
|
||||
<Empty title="No workers registered">
|
||||
<p>
|
||||
A worker registers itself: it starts with coordinator credentials, announces its
|
||||
id, revision, harnesses, projects and capacity, then keeps liveness with a
|
||||
heartbeat. Nothing here can create one.
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Status</th>
|
||||
<th>Backend</th>
|
||||
<th>Current task</th>
|
||||
<th>Pane</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((w) => {
|
||||
const h = w.health as Health2
|
||||
return (
|
||||
<tr
|
||||
key={w.id}
|
||||
className="worker-row"
|
||||
aria-selected={w.id === selected?.id}
|
||||
onClick={() => setPicked(w.id)}
|
||||
>
|
||||
<td className="mono">{w.id}</td>
|
||||
<td>
|
||||
<span className="status-cell">
|
||||
<Dot health={health(w)} /> {statusLabel(w)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Chip>
|
||||
<span className="mono">{h.backend || 'unknown'}</span>
|
||||
</Chip>
|
||||
</td>
|
||||
<td>
|
||||
{h.active_task_id ? (
|
||||
<Link
|
||||
className="task-link"
|
||||
to={`/tasks/${h.active_task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<M>{h.active_task_id}</M>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="dim mono">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{h.active_pane_id || '—'}</td>
|
||||
<td className="mono">{ago(w.last_seen)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="workers-detail">
|
||||
{selected && <Detail worker={selected} tasks={overview.data?.tasks ?? []} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel title="Registration">
|
||||
<div className="panel-body registration">
|
||||
<p className="reg-lead">
|
||||
<Icon name="worker" size={16} /> Workers register themselves; the console only reads
|
||||
what they announce.
|
||||
</p>
|
||||
<ol className="steps">
|
||||
<li>The worker starts with coordinator credentials.</li>
|
||||
<li>It announces id, revision, harnesses, projects and capacity.</li>
|
||||
<li>The coordinator records it; heartbeats keep liveness.</li>
|
||||
</ol>
|
||||
<EndpointGap
|
||||
path="POST /v1/federation/workers"
|
||||
what="There is no browser-side worker creation and no drain or unregister endpoint. Buttons for them would be decoration over machinery that does not exist."
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { NAV } from './Shell'
|
||||
|
||||
/** The shared command surface. It navigates and nothing else: an operator
|
||||
* console should not hide state changes behind a search box. */
|
||||
export function CommandPalette({
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onNavigate: (to: string) => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => input.current?.focus(), [])
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
return NAV.filter((item) => !needle || item.label.toLowerCase().includes(needle))
|
||||
}, [query])
|
||||
|
||||
useEffect(() => setActive(0), [query])
|
||||
|
||||
return (
|
||||
<div className="cmdk-scrim" onClick={onClose} role="presentation">
|
||||
<div className="cmdk-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
ref={input}
|
||||
className="cmdk-input"
|
||||
placeholder="Search tasks, decisions, workers…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') setActive((i) => Math.min(i + 1, matches.length - 1))
|
||||
if (event.key === 'ArrowUp') setActive((i) => Math.max(i - 1, 0))
|
||||
if (event.key === 'Enter' && matches[active]) onNavigate(matches[active].to)
|
||||
}}
|
||||
/>
|
||||
<div className="cmdk-list">
|
||||
{matches.map((item, i) => (
|
||||
<button
|
||||
key={item.to}
|
||||
className="cmdk-row"
|
||||
data-active={i === active}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 && (
|
||||
<p style={{ padding: '14px 10px', color: 'var(--text-mid)', margin: 0 }}>
|
||||
Nothing matches that.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
|
||||
export const NAV = [
|
||||
{ to: '/', icon: 'home', label: 'Dashboard', end: true },
|
||||
{ to: '/tasks', icon: 'task', label: 'Tasks' },
|
||||
{ to: '/decisions', icon: 'decision', label: 'Decisions' },
|
||||
{ to: '/workers', icon: 'worker', label: 'Workers' },
|
||||
{ to: '/projects', icon: 'project', label: 'Projects' },
|
||||
{ to: '/review', icon: 'review', label: 'Review' },
|
||||
{ to: '/settings', icon: 'settings', label: 'Settings', secondary: true },
|
||||
]
|
||||
|
||||
/** Machine readouts belong in the top bar on desktop. On mobile they relocate
|
||||
* into the detail screens rather than disappearing: honesty moves, it is
|
||||
* never deleted. */
|
||||
function Clock() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
return <span>{now.toISOString().slice(11, 19)} UTC</span>
|
||||
}
|
||||
|
||||
export function Shell({ revision, live }: { revision?: string; live?: boolean }) {
|
||||
const [palette, setPalette] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onShortcut = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
setPalette((open) => !open)
|
||||
}
|
||||
if (event.key === 'Escape') setPalette(false)
|
||||
}
|
||||
window.addEventListener('keydown', onShortcut)
|
||||
return () => window.removeEventListener('keydown', onShortcut)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="console shell">
|
||||
<nav className="rail" aria-label="Primary">
|
||||
<span className="rail-mark">
|
||||
<Icon name="fork" size={26} />
|
||||
</span>
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className="rail-item"
|
||||
data-secondary={item.secondary ? 'true' : undefined}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
</NavLink>
|
||||
))}
|
||||
<span className="rail-spacer" />
|
||||
</nav>
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<span className="topbar-title">orchestra</span>
|
||||
<button className="cmdk-trigger" onClick={() => setPalette(true)}>
|
||||
<kbd>⌘K</kbd>
|
||||
<span>Search tasks, decisions, workers…</span>
|
||||
</button>
|
||||
<div className="topbar-readouts">
|
||||
{revision && <span title={revision}>{revision.slice(0, 7)}</span>}
|
||||
<span>
|
||||
<span className="dot" data-health={live ? 'healthy' : 'unknown'} />{' '}
|
||||
{live ? 'Live' : 'Unknown'}
|
||||
</span>
|
||||
<Clock />
|
||||
</div>
|
||||
</header>
|
||||
<Outlet />
|
||||
</div>
|
||||
{palette && (
|
||||
<CommandPalette
|
||||
onClose={() => setPalette(false)}
|
||||
onNavigate={(to) => {
|
||||
setPalette(false)
|
||||
navigate(to)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+977
-427
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ETHOS — tokens
|
||||
Shared design language for the kvmx.ru apps.
|
||||
|
||||
The neutral system (ramp, type, motion, radii, shadow) is shared by
|
||||
every app and does not change. The only per-app difference is the
|
||||
accent (one hue) and the motif (one geometric signature).
|
||||
|
||||
NEW APP: do not invent the accent silently. Ask the operator for the
|
||||
accent name + hex and the motif, then add one [data-app] block at the
|
||||
bottom using the TEMPLATE. Fonts are self-hosted (subset + woff2,
|
||||
immutable caching) — no font CDN.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('/fonts/Geist-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900; font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('/fonts/GeistMono-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900; font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ── neutral ramp — warm, brown-tinted (dark, default) ── */
|
||||
--bg-0: #14110D; /* deepest room */
|
||||
--bg-1: #1B1712; /* surface */
|
||||
--bg-2: #221D17; /* raised */
|
||||
--bg-3: #2C261D; /* hover / raised */
|
||||
--bg-4: #372F24; /* pressed / high */
|
||||
|
||||
--line: rgba(244, 234, 220, 0.09); /* hairline */
|
||||
--line-hi: rgba(244, 234, 220, 0.16); /* hairline emphasized */
|
||||
|
||||
--text-hi: #F4EEE4; /* human primary (sans) */
|
||||
--text-mid: #B4AA98; /* human secondary (sans) */
|
||||
--text-lo: #756C5C; /* human tertiary / idle */
|
||||
--text-machine: #9C917D; /* machine default (mono) */
|
||||
|
||||
/* ── type ── */
|
||||
--sans: 'Geist', -apple-system, system-ui, sans-serif;
|
||||
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* mono numerics: apply on any element using --mono */
|
||||
/* font-feature-settings: "tnum" 1, "zero" 1; letter-spacing: -0.01em; */
|
||||
|
||||
/* ── motion — mechanical, no bounce ── */
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
--fast: 130ms;
|
||||
--med: 170ms;
|
||||
|
||||
/* ── radii ── */
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 18px;
|
||||
--r-xl: 24px;
|
||||
|
||||
/* ── elevation: depth from light, never blur ── */
|
||||
--shadow-soft: 0 2px 8px rgba(0,0,0,0.35), 0 12px 32px rgba(0,0,0,0.28);
|
||||
--shadow-lift: 0 4px 14px rgba(0,0,0,0.40), 0 20px 48px rgba(0,0,0,0.34);
|
||||
|
||||
/* ── accent slot ──
|
||||
Neutral fallback so an app with no [data-app] is never unstyled.
|
||||
Real values come from the per-app block below. Accent is a SIGNAL
|
||||
(active state, focus ring, primary action, one lit detail) — never a
|
||||
fill-everything wash. */
|
||||
--accent: var(--text-mid);
|
||||
--accent-hi: var(--text-hi);
|
||||
--accent-dim: rgba(244, 234, 220, 0.10);
|
||||
--accent-line: rgba(244, 234, 220, 0.24);
|
||||
--accent-glow: rgba(244, 234, 220, 0.14);
|
||||
}
|
||||
|
||||
/* ── light theme — warm, off-white (kept off cream to dodge the AI-cream tell) ── */
|
||||
[data-theme="light"] {
|
||||
--bg-0: #F1ECE3;
|
||||
--bg-1: #EAE4D8;
|
||||
--bg-2: #E2DACB;
|
||||
--bg-3: #D7CDBB;
|
||||
--bg-4: #C9BDA7;
|
||||
|
||||
--line: rgba(28, 22, 14, 0.10);
|
||||
--line-hi: rgba(28, 22, 14, 0.18);
|
||||
|
||||
--text-hi: #1B1712;
|
||||
--text-mid: #544C3E;
|
||||
--text-lo: #877E6C;
|
||||
--text-machine: #6B6252;
|
||||
|
||||
--shadow-soft: 0 2px 8px rgba(60,45,25,0.10), 0 12px 32px rgba(60,45,25,0.08);
|
||||
--shadow-lift: 0 4px 14px rgba(60,45,25,0.12), 0 20px 48px rgba(60,45,25,0.10);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PER-APP OVERRIDES — one small block each. Accent + optional motif
|
||||
hooks only; never touch the neutral ramp.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* muzick — reference app · honey amber · waveform */
|
||||
[data-app="muzick"] {
|
||||
--accent: #EDA24E;
|
||||
--accent-hi: #F5B667;
|
||||
--accent-dim: rgba(237, 162, 78, 0.14);
|
||||
--accent-line: rgba(237, 162, 78, 0.32);
|
||||
--accent-glow: rgba(237, 162, 78, 0.22);
|
||||
|
||||
/* motif hook: cool content-art gradient so art carries color, amber stays signal */
|
||||
--np-art: radial-gradient(120% 120% at 22% 14%, #7FB3BC 0%, #2F6E7A 42%, #1C4552 78%, #14262E 100%);
|
||||
}
|
||||
|
||||
/* ── TEMPLATE — copy for a new app AFTER intake with the operator ──
|
||||
[data-app="APPNAME"] {
|
||||
--accent: #RRGGBB; // the operator's chosen hue
|
||||
--accent-hi: #RRGGBB; // ~ +10–14% lightness
|
||||
--accent-dim: rgba(R, G, B, 0.14); // active-state backgrounds
|
||||
--accent-line: rgba(R, G, B, 0.32); // accent hairlines
|
||||
--accent-glow: rgba(R, G, B, 0.22); // restrained ambient pool
|
||||
// optional motif hooks (gradients / seeds) go here, app-specific
|
||||
}
|
||||
*/
|
||||
|
||||
/* orchestra — signal violet · routing fork
|
||||
Accent and motif come from orchestra-ui-spec.md, chosen by the operator. */
|
||||
[data-app="orchestra"] {
|
||||
--accent: #8F7AE5;
|
||||
--accent-hi: #A291EC;
|
||||
--accent-dim: rgba(143, 122, 229, 0.14);
|
||||
--accent-line: rgba(143, 122, 229, 0.32);
|
||||
--accent-glow: rgba(143, 122, 229, 0.22);
|
||||
|
||||
/* motif hook: the routing fork, one path splitting and rejoining. Used by
|
||||
the app mark, phase transitions and empty states. */
|
||||
--fork-line: rgba(143, 122, 229, 0.55);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
/* Orchestra operator console — the ethos shell, applied.
|
||||
Tokens come from ethos.tokens.css and are never redefined here. This file
|
||||
holds the shared shell geometry and the primitives every screen reuses, so
|
||||
nine screens cannot drift into nine dialects. */
|
||||
|
||||
@import './ethos.tokens.css';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-hi);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Law 1. Anything the machine owns is mono, with tabular figures so columns
|
||||
of ids, durations and percentages line up instead of shimmering. */
|
||||
.mono,
|
||||
code,
|
||||
kbd,
|
||||
pre {
|
||||
font-family: var(--mono);
|
||||
font-feature-settings: 'tnum' 1, 'zero' 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-machine);
|
||||
}
|
||||
|
||||
/* Machine ids are long and unbreakable by default, so a pane id ran out of
|
||||
its card and under the next one. Ids wrap; tables keep their own scroll. */
|
||||
.console .mono {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.console .table .mono,
|
||||
.console .topbar-readouts .mono {
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.console .label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-lo);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.console h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.console h2 {
|
||||
font-size: 17px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.console a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.console button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── shell ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.console.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.console .rail {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 14px 0 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console .rail-mark {
|
||||
color: var(--accent);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.console .rail-item {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-lo);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--ease), background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.console .rail-item:hover {
|
||||
color: var(--text-mid);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
/* Accent marks the one active thing. It never fills the rail. */
|
||||
.console .rail-item[aria-current='page'] {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.console .rail-item[aria-current='page']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 3px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.console .rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.console .topbar {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.console .topbar-title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.console .topbar-readouts {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
}
|
||||
|
||||
.console .main {
|
||||
display: grid;
|
||||
grid-template-rows: 56px 1fr;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.console .page {
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.console .page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console .page-head p {
|
||||
margin: 0;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
/* ── command surface ─────────────────────────────────────────────────── */
|
||||
|
||||
.console .cmdk-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
min-width: 260px;
|
||||
max-width: 520px;
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-lo);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.console .cmdk-trigger:hover {
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.console .cmdk-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 8, 6, 0.62);
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding-top: 12vh;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.console .cmdk-panel {
|
||||
width: min(620px, 92vw);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-hi);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console .cmdk-input {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 16px;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.console .cmdk-list {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.console .cmdk-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.console .cmdk-row[data-active='true'] {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
/* ── panels and tables ───────────────────────────────────────────────── */
|
||||
|
||||
.console .panel {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.console .panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.console .panel-head .count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-machine);
|
||||
}
|
||||
|
||||
.console .panel-head .trailing {
|
||||
margin-left: auto;
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console .panel-body {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.console .rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.console .row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.console .row:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.console .row-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.console .row-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.console .row-sub {
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console .table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.console .table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-lo);
|
||||
font-weight: 500;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console .table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.console .table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.console .table tbody tr:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.console .table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ── status, phases, values ──────────────────────────────────────────── */
|
||||
|
||||
.console .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.console .dot[data-health='healthy'] {
|
||||
background: #5fbf82;
|
||||
}
|
||||
|
||||
.console .dot[data-health='degraded'] {
|
||||
background: #d8a657;
|
||||
}
|
||||
|
||||
.console .dot[data-health='error'] {
|
||||
background: #e06c60;
|
||||
}
|
||||
|
||||
.console .dot[data-health='unknown'] {
|
||||
background: var(--text-lo);
|
||||
}
|
||||
|
||||
.console .chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console .chip[data-tone='accent'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.console .chip[data-tone='warn'] {
|
||||
border-color: rgba(216, 166, 87, 0.32);
|
||||
background: rgba(216, 166, 87, 0.12);
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.console .chip[data-tone='fault'] {
|
||||
border-color: rgba(224, 108, 96, 0.32);
|
||||
background: rgba(224, 108, 96, 0.12);
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.console .chip[data-tone='done'] {
|
||||
border-color: rgba(95, 191, 130, 0.28);
|
||||
background: rgba(95, 191, 130, 0.1);
|
||||
color: #5fbf82;
|
||||
}
|
||||
|
||||
/* The routing fork, rendered as the phase path. Completed is quiet green,
|
||||
the current phase carries the accent, the future stays neutral. */
|
||||
.console .phases {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.console .phase-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 46px;
|
||||
}
|
||||
|
||||
.console .phase-node {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 1.6px solid var(--text-lo);
|
||||
}
|
||||
|
||||
.console .phase-step[data-state='done'] .phase-node {
|
||||
border-color: #5fbf82;
|
||||
background: rgba(95, 191, 130, 0.35);
|
||||
}
|
||||
|
||||
.console .phase-step[data-state='current'] .phase-node {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.console .phase-step .phase-name {
|
||||
font-size: 9px;
|
||||
color: var(--text-lo);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.console .phase-step[data-state='current'] .phase-name {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.console .phase-link {
|
||||
height: 1.6px;
|
||||
width: 18px;
|
||||
background: var(--line-hi);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.console .phase-link[data-state='done'] {
|
||||
background: rgba(95, 191, 130, 0.4);
|
||||
}
|
||||
|
||||
.console .meter {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-3);
|
||||
overflow: hidden;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.console .meter > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── actions ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.console .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-hi);
|
||||
cursor: pointer;
|
||||
transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.console .btn:hover {
|
||||
background: var(--bg-3);
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.console .btn[data-variant='primary'] {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.console .btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── empty, error, gap ───────────────────────────────────────────────── */
|
||||
|
||||
.console .empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 44px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.console .empty .motif {
|
||||
color: var(--accent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.console .empty h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-hi);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* A screen with no endpoint says so, with the path it would call. Law 5: an
|
||||
interface never implies data it does not have. */
|
||||
.console .gap-note {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
background: var(--bg-2);
|
||||
border: 1px dashed var(--line-hi);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.console .grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.console .stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(184px, 1fr));
|
||||
}
|
||||
|
||||
.console .stat {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console .stat-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
color: var(--text-hi);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── mobile: rail becomes a bottom tab bar, readouts relocate ────────── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.console.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
.console .rail {
|
||||
order: 2;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
border-right: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rail-mark,
|
||||
.rail-spacer,
|
||||
.console .rail-item[data-secondary='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console .rail-item[aria-current='page']::before {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: -4px;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 3px;
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.console .topbar-readouts {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console .page {
|
||||
padding: 16px 14px 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user