@terreno/mcp 57.9.0 → 57.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs/upgrades/57.10.1.md +54 -0
- package/dist/docs/upgrades/57.11.0.md +80 -0
- package/dist/docs/versioned/next/README.md +1 -0
- package/dist/docs/versioned/next/explanation/README.md +4 -0
- package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/dist/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/dist/docs/versioned/next/how-to/README.md +7 -0
- package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/dist/docs/versioned/next/how-to/circleci.md +5 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/dist/docs/versioned/next/reference/README.md +3 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -0
- package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/dist/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/dist/docs/versioned/next/reference/ai.md +251 -25
- package/dist/docs/versioned/next/reference/api.md +110 -2
- package/dist/docs/versioned/next/reference/blocks.md +178 -0
- package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
- package/dist/docs/versioned/next/reference/syncdb.md +156 -6
- package/dist/docs/versioned/next/reference/test.md +11 -2
- package/dist/docs/versioned/next/reference/ui.md +331 -6
- package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
- package/dist/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -1
- package/package.json +3 -2
- package/src/docs/upgrades/57.10.1.md +54 -0
- package/src/docs/upgrades/57.11.0.md +80 -0
- package/src/docs/versioned/next/README.md +1 -0
- package/src/docs/versioned/next/explanation/README.md +4 -0
- package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/src/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/src/docs/versioned/next/how-to/README.md +7 -0
- package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/src/docs/versioned/next/how-to/circleci.md +5 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/src/docs/versioned/next/reference/README.md +3 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -0
- package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/src/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/src/docs/versioned/next/reference/ai.md +251 -25
- package/src/docs/versioned/next/reference/api.md +110 -2
- package/src/docs/versioned/next/reference/blocks.md +178 -0
- package/src/docs/versioned/next/reference/environment-variables.md +2 -1
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/src/docs/versioned/next/reference/mcp-server.md +17 -0
- package/src/docs/versioned/next/reference/syncdb.md +156 -6
- package/src/docs/versioned/next/reference/test.md +11 -2
- package/src/docs/versioned/next/reference/ui.md +331 -6
- package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
- package/src/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Implementation Plan: Durable agent harness (`@terreno/ai/harness`)
|
|
2
|
+
|
|
3
|
+
**Status:** Approved
|
|
4
|
+
**Roadmap:** Area=`ai`, Target=`Next`, Impact=`Feature`
|
|
5
|
+
**Branch:** `durable-agent-harness` (stacked on `cursor/ai-observability-ip-64ca`)
|
|
6
|
+
**Owner:** unassigned
|
|
7
|
+
**Created:** 2026-10-02
|
|
8
|
+
**Task list:** [docs/tasks/durable-agent-harness.md](../tasks/durable-agent-harness.md)
|
|
9
|
+
**Inspiration:** [Pi Durable](https://earendil.com/posts/pi-durable/) (concepts only; no dependency)
|
|
10
|
+
**Depends on:** [ai-observability.md](./ai-observability.md) (`ObsTrace` / `ObsSpan`, `ObservabilityApp`), [job-queues.md](./job-queues.md) (`@terreno/jobs`, optional)
|
|
11
|
+
**Supersedes in part:** [ai-agents-and-failover.md](./ai-agents-and-failover.md) — its `Agent` and failover ideas become harness primitives; its "no human-in-the-loop / no resume / no sub-agents" non-goals are reversed here.
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
One easy-to-use harness inside `@terreno/ai` that runs long-lived agentic work durably:
|
|
16
|
+
deterministic multi-phase workflows, LLM agent loops, tools, subagents, human approvals,
|
|
17
|
+
retries, and crash recovery — all audited. It must be strong enough to build an
|
|
18
|
+
OpenClaw/Hermes-class personal agent, a coding agent, and a regulated clinical automation
|
|
19
|
+
on the same primitives.
|
|
20
|
+
|
|
21
|
+
Today a chat turn lives inside one HTTP request (`ai/src/routes/gpt.ts`), history is saved
|
|
22
|
+
only after the stream ends, and a crash loses the turn. `@terreno/jobs` is durable but a
|
|
23
|
+
job is one opaque handler with no checkpoints. The harness fills that gap.
|
|
24
|
+
|
|
25
|
+
## Non-Goals
|
|
26
|
+
|
|
27
|
+
- Depending on or wrapping `@earendil-works/pi-durable`.
|
|
28
|
+
- Journaled/replayed `step.run` workflows (Temporal/Inngest style). Phases only.
|
|
29
|
+
- Socket.io streaming for agent output. Streaming is SSE only.
|
|
30
|
+
- Auto-migrating in-flight runs across task versions.
|
|
31
|
+
- Pluggable non-Mongo storage (SQLite/JSONL/memory). Mongo for every runner.
|
|
32
|
+
- Field-level encryption, retention/redaction policies, e-signatures (future work).
|
|
33
|
+
- Changing `/gpt/prompt` behavior before Phase 4.
|
|
34
|
+
|
|
35
|
+
## Decisions
|
|
36
|
+
|
|
37
|
+
| Question | Decision |
|
|
38
|
+
|----------|----------|
|
|
39
|
+
| Build our own harness or adopt pi-durable? | Own harness on Mongo + Vercel `ai` SDK, copying Pi's concepts (tasks, checkpoints, replay-safe tools, hooks, memos, documents, compaction, forks). |
|
|
40
|
+
| Where should the harness live? | Inside `@terreno/ai`, as subpath `@terreno/ai/harness`. |
|
|
41
|
+
| How should the `@terreno/jobs` dependency be wired? | Optional peer. Jobs integration is optional; a single process can own execution. |
|
|
42
|
+
| Who executes a run? | Pluggable `Runner`: `InProcessRunner` (single owner, default) and `JobsRunner` (leased, multi-instance). |
|
|
43
|
+
| How do durable code steps resume after a crash? | Explicit phases (Pi-style `defineTask` state machine); each phase commits a checkpoint. |
|
|
44
|
+
| Where does state live for the single-process owner? | Mongo for both runners; switching runners needs no migration. |
|
|
45
|
+
| What compliance guarantees ship in v1? | Immutable step/event audit log, built on the AI observability branch's `ObsTrace` / `ObsSpan`. |
|
|
46
|
+
| How does the audit log relate to `ObsTrace` / `ObsSpan`? | `ObsSpan` **is** the audit log for harness work. |
|
|
47
|
+
| How do we guarantee a span exists for every checkpoint? | Checkpoint commit and its `ObsSpan` write happen in one Mongo transaction. Langfuse/OTel stay best-effort after commit. |
|
|
48
|
+
| How does this sequence against the observability branch? | Branch off `cursor/ai-observability-ip-64ca` and rebase as it lands. |
|
|
49
|
+
| What happens on resume when replay was not declared? | Never auto-replay. Interrupted tools are reported to the model; interrupted phases go to `interrupted`. Opt in with `replay: "safe"`. |
|
|
50
|
+
| Which features land in Phase 1 with the clinical tracer? | Tasks, phases, agent loop, tools + replay, retries, audit, **hooks + memos + approvals**, **subagents + ownership tree**. |
|
|
51
|
+
| How do clients watch runs live? | SSE only, resumable via `Last-Event-ID`; a separate submit endpoint sends input/steer. |
|
|
52
|
+
| How does a clinician sign off on an approval? | An approval inbox in `@terreno/admin-frontend`. |
|
|
53
|
+
| Who can see and act on an approval? | Per-approval `approvers` (modelRouter permission shape); inbox lists only what the user may approve; default `IsAdmin`; mountable outside admin chrome. |
|
|
54
|
+
| What happens to `/gpt/prompt`? | Untouched in Phase 1; rebuilt on the harness in Phase 4. |
|
|
55
|
+
| Where do coding-agent execution environments land? | Phase 1 defines the `ExecutionEnv` interface only; Phase 5 ships Node env, coding tools, sandbox adapter. |
|
|
56
|
+
| Task definition changes while runs are in flight? | Runs pin `name@version`; old versions must stay registered; startup fails loudly otherwise. |
|
|
57
|
+
|
|
58
|
+
### Recorded assumptions (low-risk, chosen by convention)
|
|
59
|
+
|
|
60
|
+
- **Interrupted model requests are re-requested.** A model call has no side effect, so a
|
|
61
|
+
crash mid-stream marks the partial response aborted and the turn retries the request.
|
|
62
|
+
- **Model-call retries:** 429/5xx/network errors retry with exponential backoff + jitter
|
|
63
|
+
(default 3 attempts), then an optional `fallbackModels` list. A thrown phase retries
|
|
64
|
+
under the task's `retry` policy (default `maxAttempts: 3`); exhausted → `failed`.
|
|
65
|
+
- **Token deltas are persisted, coalesced** (~250 ms) as `HarnessEvent` rows with a TTL so
|
|
66
|
+
any instance can serve an SSE resume. Committed messages are permanent.
|
|
67
|
+
- **Cross-instance SSE** tails `HarnessEvent` with a Mongo change stream (replica set is
|
|
68
|
+
already required).
|
|
69
|
+
- **Non-local trace sinks** (Langfuse, OTel) receive the full trace once a root task is
|
|
70
|
+
terminal, best-effort, via `ObservabilityApp.exportTrace`.
|
|
71
|
+
- **Approvals inbox filtering** evaluates `approvers` per pending approval in memory
|
|
72
|
+
(pending sets are small); pagination applies after filtering.
|
|
73
|
+
|
|
74
|
+
## Architecture
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
app code: defineTask / defineAgent / defineTool / defineExtension
|
|
78
|
+
|
|
|
79
|
+
Harness (registry + storage)
|
|
80
|
+
+-------------+-------------+--------------+----------------+
|
|
81
|
+
| | | | |
|
|
82
|
+
Task engine Agent loop Hooks/memos Approvals Event log
|
|
83
|
+
(phases, (built-in (extensions) (wait + inbox) (SSE source)
|
|
84
|
+
checkpoints, task)
|
|
85
|
+
ownership)
|
|
86
|
+
|
|
|
87
|
+
runtime.commit() ── one Mongo transaction ──> HarnessTask/Message/Memo/Event
|
|
88
|
+
+ ObsSpan / ObsTrace (audit)
|
|
89
|
+
|
|
|
90
|
+
Runner: InProcessRunner (owner lease) | JobsRunner (@terreno/jobs, optional)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Core concepts
|
|
94
|
+
|
|
95
|
+
| Concept | What it is |
|
|
96
|
+
|---------|-----------|
|
|
97
|
+
| **Task** | Durable unit of work: `name@version`, `input`, `state`, `phase`, status, ownership. Everything (model request, tool call, workflow, subagent) is a task. |
|
|
98
|
+
| **Phase** | A named async function of a task. It does work and calls `rt.commit()` with the next phase or a terminal outcome. A phase is the replay boundary. |
|
|
99
|
+
| **Checkpoint** | The committed `{phase, state}`. Resume always starts the current phase from its checkpoint. |
|
|
100
|
+
| **Conversation** | A transcript plus agent config (model, instructions, tools, extensions). Root, or owned by a task (subagent). |
|
|
101
|
+
| **Agent** | `defineAgent` config. Running an agent = the built-in `terreno.agent.turn` task on a conversation. |
|
|
102
|
+
| **Tool** | `defineTool` with zod params and `replay: "safe" | "never"` (default `never`). Runs as a child task of the turn. |
|
|
103
|
+
| **Extension** | Named bundle of prompt sections, tools, hooks, and tool wraps. |
|
|
104
|
+
| **Hook** | `beforeModelRequest`, `beforeTool`, `afterTool`. Can rewrite, block, or annotate. |
|
|
105
|
+
| **Memo** | `rt.memo(key, value?)` — durable decision storage scoped to a task, survives restarts. |
|
|
106
|
+
| **Approval** | `rt.approval(key, {...})` — creates a `HarnessApproval`, waits for decision, audits the decider. |
|
|
107
|
+
| **Event** | Append-only `HarnessEvent` rows with per-conversation/task `seq`; the SSE source. |
|
|
108
|
+
| **Runner** | Decides who executes runnable tasks and when. |
|
|
109
|
+
|
|
110
|
+
### Commit and audit
|
|
111
|
+
|
|
112
|
+
`rt.commit(fn)` opens a Mongo transaction that, fenced by the task's lease token:
|
|
113
|
+
|
|
114
|
+
1. Updates `HarnessTask` (`phase`, `state`, status, `attempt`, outcome).
|
|
115
|
+
2. Inserts any `HarnessMessage`, `HarnessMemo`, `HarnessApproval`, `HarnessEvent` rows.
|
|
116
|
+
3. Inserts the `ObsSpan` for the finished work and updates the root `ObsTrace`.
|
|
117
|
+
|
|
118
|
+
If the lease token no longer matches (another owner took over) the transaction aborts and
|
|
119
|
+
the phase result is discarded. `Harness` construction throws when the local observability
|
|
120
|
+
plugin (`createLocalObservabilityPlugin`) is not registered or the connection is not a
|
|
121
|
+
replica set.
|
|
122
|
+
|
|
123
|
+
Span mapping: root task → `ObsTrace` + `CHAIN` root span; phase → `CHAIN`; model request →
|
|
124
|
+
`LLM` (usage, cost via `priceMap`); tool → `TOOL`; subagent → `AGENT`; approval decision →
|
|
125
|
+
`CHAIN` named `approval:<key>` with `decidedBy`, `decision`, `reason` in `output`. Spans
|
|
126
|
+
are never updated after insert except `endedAt`/`status` on the same commit.
|
|
127
|
+
|
|
128
|
+
### Replay and interruption
|
|
129
|
+
|
|
130
|
+
On resume, a task whose status is `running` with an expired lease was interrupted:
|
|
131
|
+
|
|
132
|
+
| Interrupted thing | `replay: "safe"` | default (`never`) |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| Phase | Re-run the phase from its checkpoint | Status `interrupted`; await `resolveInterrupted` (`retry` / `abort` / `complete`) |
|
|
135
|
+
| Tool call | Re-run the tool | Tool result = "interrupted, not retried"; agent loop continues |
|
|
136
|
+
| Model request | Always re-requested (assumption above) | — |
|
|
137
|
+
|
|
138
|
+
### Runners
|
|
139
|
+
|
|
140
|
+
| Runner | Ownership | Use |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `InProcessRunner` (default) | One process holds the `HarnessOwner` lease (heartbeat). A second process gets `standby` and takes over on lease expiry. Polls runnable tasks; wakes on `runAt` and events. | Local agents, single-instance apps, tests |
|
|
143
|
+
| `JobsRunner` (`@terreno/ai/harness/jobsRunner`, optional peer `@terreno/jobs`) | Each runnable phase → job `terreno.harness.phase`, idempotency key `taskId:phase:attempt:claims` (`claims` counts claims, so a phase revisited by an event wake or crash replay gets a new job; jobs idempotency keys are permanent). Handler acquires the task lease (fenced, heartbeat) and runs one phase. The task lease, not the job lock, is the authority. | Multi-instance Cloud Run |
|
|
144
|
+
|
|
145
|
+
### Ownership tree
|
|
146
|
+
|
|
147
|
+
Tasks are owned by a conversation or a task. `abort(taskId)` aborts owned tasks
|
|
148
|
+
bottom-up, running each task's `abort` handler (compensations) before marking it
|
|
149
|
+
`aborted`. `background: true` tasks belong to a conversation but are not part of its
|
|
150
|
+
current turn; a turn abort leaves them running.
|
|
151
|
+
|
|
152
|
+
### Versioning
|
|
153
|
+
|
|
154
|
+
Each task records `name` + `version`. The runner only resumes a task on a registered
|
|
155
|
+
handler for exactly that version. `Harness.start()` scans non-terminal tasks and throws
|
|
156
|
+
listing every unregistered `name@version`.
|
|
157
|
+
|
|
158
|
+
## Models
|
|
159
|
+
|
|
160
|
+
All in `ai/src/harness/models/`, types in `ai/src/types/harness.ts`. Every field has
|
|
161
|
+
`description`; `strict: "throw"`.
|
|
162
|
+
|
|
163
|
+
| Model | Key fields |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `HarnessTask` | `name`, `version`, `input`, `state`, `phase`, `status` (`pending` \| `running` \| `waiting` \| `interrupted` \| `completed` \| `failed` \| `aborted`), `ownership {kind: conversation\|task\|root, id}`, `background`, `attempt`, `retry {maxAttempts, backoffMs, maxBackoffMs}`, `runAt`, `lease {owner, token, expiresAt}`, `waiting {kind: event\|tasks\|sleep, key, taskIds, policy, timeoutAt}`, `outcome {status, result, error}`, `traceId`, `rootTaskId`, `requestId` (unique sparse), `userId` |
|
|
166
|
+
| `HarnessConversation` | `ownership`, `agent {name, model, instructions, tools[], extensions[]}`, `status` (`idle` \| `busy`), `activeTurnTaskId`, `queued[]`, `userId`, `seq` |
|
|
167
|
+
| `HarnessMessage` | `conversationId`, `seq`, `role`, `parts[]`, `toolCallId`, `toolName`, `turnTaskId`, `aborted` |
|
|
168
|
+
| `HarnessMemo` | `taskId`, `key`, `value` — unique `(taskId, key)` |
|
|
169
|
+
| `HarnessApproval` | `taskId`, `rootTaskId`, `definitionKey` (`name@version:key`), `title`, `summary`, `payload`, `status` (`pending` \| `approved` \| `rejected` \| `expired`), `decidedBy`, `decidedAt`, `reason`, `expiresAt` |
|
|
170
|
+
| `HarnessEvent` | `streamId` (conversation or task), `seq`, `type`, `payload`, TTL for `delta` events |
|
|
171
|
+
| `HarnessOwner` | singleton lease for `InProcessRunner`: `owner`, `expiresAt` |
|
|
172
|
+
|
|
173
|
+
## APIs
|
|
174
|
+
|
|
175
|
+
### Library (`@terreno/ai/harness`)
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import {
|
|
179
|
+
Harness, defineTask, defineAgent, defineTool, defineExtension, hook, section,
|
|
180
|
+
approvalGate, InProcessRunner,
|
|
181
|
+
} from "@terreno/ai/harness";
|
|
182
|
+
|
|
183
|
+
const summarizer = defineAgent({
|
|
184
|
+
name: "clinic.summarizer",
|
|
185
|
+
model: {provider: "anthropic", modelId: "claude-sonnet-5-5"},
|
|
186
|
+
instructions: "Summarize the chart for a clinician. Cite sources.",
|
|
187
|
+
tools: [],
|
|
188
|
+
fallbackModels: [{provider: "anthropic", modelId: "claude-haiku-4-5-20251001"}],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const intakeSummary = defineTask<{patientId: string}, IntakeState, {status: string}>({
|
|
192
|
+
name: "clinic.intakeSummary",
|
|
193
|
+
version: 1,
|
|
194
|
+
initial: () => ({phase: "fetch"}),
|
|
195
|
+
retry: {maxAttempts: 3},
|
|
196
|
+
phases: {
|
|
197
|
+
fetch: {
|
|
198
|
+
replay: "safe",
|
|
199
|
+
run: async (task, rt) => {
|
|
200
|
+
const chart = await ehr.getChart(task.input.patientId);
|
|
201
|
+
await rt.commit({phase: "summarize", state: {chart}});
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
summarize: {
|
|
205
|
+
replay: "safe",
|
|
206
|
+
run: async (task, rt) => {
|
|
207
|
+
const summary = await rt.runAgent(summarizer, {input: task.state.chart, output: SummarySchema});
|
|
208
|
+
await rt.commit({phase: "review", state: {...task.state, summary}});
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
review: {
|
|
212
|
+
replay: "safe",
|
|
213
|
+
run: async (task, rt) => {
|
|
214
|
+
// Approvers are declared on the definition: approvals: {"clinician-signoff": {approvers: [Permissions.IsAdmin, isClinician]}}
|
|
215
|
+
const decision = await rt.approval("clinician-signoff", {
|
|
216
|
+
title: "Sign off intake summary",
|
|
217
|
+
payload: task.state.summary,
|
|
218
|
+
timeout: {hours: 24},
|
|
219
|
+
});
|
|
220
|
+
await rt.commit(decision.approved ? {phase: "write"} : {terminal: {status: "completed", result: {status: "rejected"}}});
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
write: {
|
|
224
|
+
// default replay: "never" — an interrupted EHR write goes to `interrupted`
|
|
225
|
+
run: async (task, rt) => {
|
|
226
|
+
await ehr.writeNote(task.input.patientId, task.state.summary, {idempotencyKey: `note-${task.id}`});
|
|
227
|
+
await rt.commit({terminal: {status: "completed", result: {status: "filed"}}});
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
abort: async (task, rt) => { /* compensations */ },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const harness = await Harness.open({
|
|
235
|
+
models: createModelFn,
|
|
236
|
+
registry: [intakeSummary, summarizer, ClinicExtension],
|
|
237
|
+
runner: new InProcessRunner(),
|
|
238
|
+
});
|
|
239
|
+
await harness.start();
|
|
240
|
+
const run = await harness.createTask(intakeSummary, {patientId}, {requestId: `intake-${patientId}`});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Runtime (`rt`) surface in Phase 1: `commit`, `memo`, `runAgent`, `createTask`,
|
|
244
|
+
`waitForTasks(ids, {policy: "all" | "failFast"})`, `waitFor(event, {timeout})`,
|
|
245
|
+
`sleep(duration)`, `approval`, `output` (stream text to the event log), `signal`, `env`
|
|
246
|
+
(`ExecutionEnv`, interface only in Phase 1).
|
|
247
|
+
|
|
248
|
+
Harness surface: `open`, `start`, `stop`, `createTask`, `conversation(id)`,
|
|
249
|
+
`createConversation`, `sendEvent(taskId, event, payload)`, `abort(taskId)`,
|
|
250
|
+
`resolveInterrupted(taskId, action)`.
|
|
251
|
+
|
|
252
|
+
### HTTP (`HarnessApp` plugin)
|
|
253
|
+
|
|
254
|
+
| Method | Path | Behavior |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| POST | `/harness/conversations/:id/submit` | instanceAction. `{content, requestId, whenBusy: "queue" \| "steer"}`; idempotent on `requestId` |
|
|
257
|
+
| GET | `/harness/conversations/:id/events` | SSE; honors `Last-Event-ID`; replays then tails |
|
|
258
|
+
| GET | `/harness/tasks/:id/events` | SSE for a task subtree |
|
|
259
|
+
| POST | `/harness/tasks/:id/abort` | instanceAction; owner or admin |
|
|
260
|
+
| POST | `/harness/tasks/:id/resolveInterrupted` | instanceAction; admin; `{action: "retry" \| "abort" \| "complete", reason}` |
|
|
261
|
+
| GET | `/harness/approvals` | list pending approvals the caller may approve |
|
|
262
|
+
| POST | `/harness/approvals/:id/approve` \| `/reject` | instanceActions; `{reason}` (required on reject); audited span |
|
|
263
|
+
|
|
264
|
+
SSE is a listed exception to the modelRouter-actions rule; everything else is
|
|
265
|
+
`modelRouter` collection/instance actions.
|
|
266
|
+
|
|
267
|
+
## Notifications
|
|
268
|
+
|
|
269
|
+
None in Phase 1. `rt.approval` accepts an optional `notify` callback so apps can send via
|
|
270
|
+
`@terreno/comms`; the harness does not depend on comms.
|
|
271
|
+
|
|
272
|
+
## UI
|
|
273
|
+
|
|
274
|
+
- **Approvals inbox** (`@terreno/admin-frontend`, `widgets/harness/`): list of pending
|
|
275
|
+
approvals (title, task, age, expires), detail with `payload` rendered as JSON/markdown,
|
|
276
|
+
Approve / Reject with reason. Registered as admin custom screen `harness-approvals`
|
|
277
|
+
(group "AI Harness"), and exported as `HarnessApprovalInbox` for mounting outside admin
|
|
278
|
+
chrome. Uses `@terreno/ui` only.
|
|
279
|
+
- **Runs screen** (Phase 2): task list/tree, status filters, abort, resolve interrupted.
|
|
280
|
+
- Spans render in the existing observability Traces screens.
|
|
281
|
+
|
|
282
|
+
## Phases
|
|
283
|
+
|
|
284
|
+
| Phase | Ships | Proof |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| **1 — Durable clinical tracer** | Engine, phases, checkpoints, `InProcessRunner`, transactional `ObsSpan` audit, replay semantics, retries, agent loop, tools, subagents + ownership tree, extensions/hooks/memos, waits/events, approvals + inbox, SSE + submit, version pinning, example-backend `clinic.intakeSummary` | Crash-and-resume e2e; inbox Playwright screenshots; span tree assertions |
|
|
287
|
+
| **2 — Multi-instance** | `JobsRunner`, two-worker fencing, Runs admin screen | Two-process race tests; screenshots |
|
|
288
|
+
| **3 — Long context** | Background compaction, `reset`/handoff, conversation forks, `defineDoc` documents (rewindable, fork `asOf`) | Bun tests on 10k-message conversations; fork isolation tests |
|
|
289
|
+
| **4 — Durable chat + multiplayer** | `/gpt` rebuilt on harness, `GptHistory` mapping, steering, foreground/background abort, `viewState` over SSE, rtk `useHarnessConversation`, `GPTChat` adapter | Kill backend mid-chat, reconnect resumes; two clients see same view |
|
|
290
|
+
| **5 — Coding agents + malleability** | `NodeExecutionEnv`, `CodingTools` (read/write/edit/bash), remote sandbox adapter, registry hot-replace | Coding agent fixes a failing test in a fixture repo after a crash |
|
|
291
|
+
|
|
292
|
+
## Feature Flags & Migrations
|
|
293
|
+
|
|
294
|
+
- No feature flag; the harness is opt-in by registering `HarnessApp`.
|
|
295
|
+
- New collections only. Phase 4 adds a migration mapping `GptHistory` → `HarnessConversation`/`HarnessMessage` (human gate at Phase 4 start: keep dual-read or one-shot migrate).
|
|
296
|
+
|
|
297
|
+
## Activity Log & User Updates
|
|
298
|
+
|
|
299
|
+
Audit lives in `ObsSpan`. Changelog entry per phase in `changelog/`.
|
|
300
|
+
|
|
301
|
+
## Not Included / Future Work
|
|
302
|
+
|
|
303
|
+
Field encryption, retention/redaction, e-signature approvals, Socket.io streaming,
|
|
304
|
+
non-Mongo storage, journaled steps, in-flight version migration.
|
|
305
|
+
|
|
306
|
+
## Files to Create / Modify
|
|
307
|
+
|
|
308
|
+
| Path | Change |
|
|
309
|
+
|---|---|
|
|
310
|
+
| `ai/src/harness/` | new: `harness.ts`, `defineTask.ts`, `runtime.ts`, `commit.ts`, `agentLoop.ts`, `defineAgent.ts`, `defineTool.ts`, `extensions.ts`, `approvals.ts`, `events.ts`, `modelCall.ts`, `runners/inProcessRunner.ts`, `runners/jobsRunner.ts`, `harnessApp.ts`, `routes/*.ts`, `models/*.ts` |
|
|
311
|
+
| `ai/src/types/harness.ts` | new model + API types |
|
|
312
|
+
| `ai/package.json` | `./harness`, `./harness/jobsRunner` exports; optional peer `@terreno/jobs` |
|
|
313
|
+
| `admin-frontend/src/widgets/harness/` | new approvals inbox (Phase 1), runs screen (Phase 2) |
|
|
314
|
+
| `example-backend/src/harness/` | `clinic.intakeSummary`, fake EHR, registration in `server.ts` |
|
|
315
|
+
| `example-frontend` | admin route for inbox; `bun run sdk` |
|
|
316
|
+
| `docs/explanation/durable-agent-harness.md` | new |
|
|
317
|
+
| `docs/reference/ai-harness.md` | new |
|
|
318
|
+
| `docs/how-to/build-a-durable-workflow.md` | new |
|
|
319
|
+
| `docs/reference/ai.md`, `docs/reference/admin-frontend.md` | link + inbox section |
|
|
320
|
+
|
|
321
|
+
## Task List
|
|
322
|
+
|
|
323
|
+
[docs/tasks/durable-agent-harness.md](../tasks/durable-agent-harness.md)
|
|
324
|
+
|
|
325
|
+
## Acceptance Criteria
|
|
326
|
+
|
|
327
|
+
| # | Criterion | Verification |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| AC1 | A two-phase task runs to completion and every checkpoint has exactly one `ObsSpan`, written in the same transaction | bun test: abort the transaction mid-commit → neither task update nor span exists |
|
|
330
|
+
| AC2 | Killing the owner mid-run resumes from the last checkpoint on a fresh `Harness` | bun test: throw-on-signal simulation + new harness instance; e2e: kill example-backend during `summarize`, restart, run completes |
|
|
331
|
+
| AC3 | Default-replay phases and tools never re-run after interruption; `replay: "safe"` ones do | bun test matrix over phase/tool × replay |
|
|
332
|
+
| AC4 | Thrown phases retry with backoff and land `failed` after `maxAttempts`; model 429/5xx retries then falls back | bun test with fake model provider |
|
|
333
|
+
| AC5 | Aborting a root task aborts owned subtasks bottom-up and runs `abort` handlers | bun test ordering assertion |
|
|
334
|
+
| AC6 | `beforeTool` hook can block a tool; memo decision survives restart | bun test |
|
|
335
|
+
| AC7 | `rt.approval` blocks until a permitted user approves; unpermitted users get 403 and do not see it in the inbox; decision span records `decidedBy` | supertest |
|
|
336
|
+
| AC8 | Approvals inbox lists, approves, and rejects in admin-frontend | Playwright run with screenshots in `/opt/cursor/artifacts/` |
|
|
337
|
+
| AC9 | SSE reconnect with `Last-Event-ID` replays missed events exactly once, from any instance | bun test with two `HarnessApp` instances on one replica set |
|
|
338
|
+
| AC10 | Starting with an in-flight unregistered `name@version` throws listing it | bun test |
|
|
339
|
+
| AC11 | `JobsRunner`: two workers never run the same phase concurrently; stale lease owner's commit is rejected | bun test (Phase 2) |
|
|
340
|
+
| AC12 | Docs explain, reference, and how-to every public API in the slice that ships it | `docs-audit` skill run shows no undocumented harness exports; Roast reads each page against the shipped API |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Roadmap issue:** https://github.com/TerrenoLabs/terreno/issues/1093
|
|
4
4
|
**Status:** Complete
|
|
5
5
|
|
|
6
|
-
> **See also:** Native in-app observability (prompts, traces, evals, experiments, review queue) is specified in [ai-observability.md](./ai-observability.md). This IP remains the **Langfuse Cloud/self-host vendor plugin**. Both plugins may be registered; traces/scores fan out (see
|
|
6
|
+
> **See also:** Native in-app observability (prompts, traces, evals, experiments, review queue) is specified in [ai-observability.md](./ai-observability.md). Operator loop: [Develop an AI feature](../how-to/ai-feature-development.md). This IP remains the **Langfuse Cloud/self-host vendor plugin**. Both plugins may be registered; traces/scores fan out (see the observability IP).
|
|
7
7
|
|
|
8
8
|
A TerrenoApp plugin that integrates [Langfuse](https://langfuse.com) into Terreno backends and frontends. Provides prompt management with MongoDB caching, OpenTelemetry tracing, Vercel AI SDK helpers, React hooks, and an admin UI — all wired up via `.install()`.
|
|
9
9
|
|
|
@@ -8,6 +8,9 @@ Technical reference for Terreno packages and APIs. Information-oriented, precise
|
|
|
8
8
|
- [@terreno/ui](ui.md) — Components, theming, layout
|
|
9
9
|
- [@terreno/syncdb](syncdb.md) — Local-first data layer (reads, writes, offline sync)
|
|
10
10
|
- [@terreno/ai](ai.md) — AI service, GPT routes, Langfuse integration
|
|
11
|
+
- [@terreno/ai/harness](ai-harness.md) — Durable multi-phase tasks with transactional checkpoints and audit spans
|
|
12
|
+
- [Agent UI Asks](agent-ui-asks.md) — `@terreno/blocks` ask schemas, simple cards, the compact surface, limits, error codes, SSE events, the headless `pendingAsks` and `turn` endpoints, and JSON Schemas for native clients
|
|
13
|
+
- [UI blocks](blocks.md) — whole-reply YAML for leaf and layout blocks: grammar, limits, and error codes
|
|
11
14
|
- [@terreno/admin-backend](admin-backend.md) — Auto-generated admin CRUD endpoints
|
|
12
15
|
- [@terreno/admin-frontend](admin-frontend.md) — Admin panel UI components
|
|
13
16
|
- [@terreno/admin-spa](admin-spa.md) — Standalone admin SPA + Express serve plugin
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
`AdminAction` has `id`, `label`, optional `confirm`, `background`, and `patchKeys`.
|
|
41
41
|
|
|
42
42
|
`AdminContribution` may provide `models`, `customScreens`, `homeWidgets`, and `scripts`.
|
|
43
|
+
`customScreens[].group` becomes a named sidebar heading (`AI Observability`). Screens without
|
|
44
|
+
`group` stay under **Screens** — that is where **AI Requests** remains.
|
|
43
45
|
Model contributions contain `model`, `routePath`, and `admin`. Duplicate `routePath`s
|
|
44
46
|
throw for registered routers; the same Mongoose model at two paths gets unique config
|
|
45
47
|
`name`s so the admin UI can route to each.
|
|
@@ -434,3 +434,219 @@ import {CommsDashboardScreenWidget, CommsMessageDetail} from "@terreno/admin-fro
|
|
|
434
434
|
The list screen persists filters in the URL (`channel`, `provider`, `status`, `errorClass`, `q`,
|
|
435
435
|
`startDate`, `endDate`, `page`) and calls `/comms/messages`, `/comms/stats`, and
|
|
436
436
|
`/comms/messages/retryMany`.
|
|
437
|
+
|
|
438
|
+
### AI Observability chrome
|
|
439
|
+
|
|
440
|
+
`ObservabilityApp` contributes grouped custom screens (`group: "AI Observability"`): `ai-prompts`,
|
|
441
|
+
`ai-traces`, `ai-evaluators`, `ai-datasets`, `ai-experiments`, and `ai-review` when the local
|
|
442
|
+
plugin is on. `AI Requests` (`ai-requests`) stays ungrouped. Widgets also register detail routes:
|
|
443
|
+
`ai-prompt-editor`, `ai-trace-detail`, `ai-review-item`, `ai-evaluator-detail`, `ai-evaluator-new`,
|
|
444
|
+
`ai-dataset-detail`, `ai-experiment-new`, and `ai-experiment-results`.
|
|
445
|
+
|
|
446
|
+
Every observability screen wraps `AiObservabilityChrome`: breadcrumbs
|
|
447
|
+
`Admin / AI Observability / <Section> / <leaf>` and a status chip from
|
|
448
|
+
`GET /ai/observability/status` (`Local on|off` plus active primaries). Review queue nav and the
|
|
449
|
+
review screen body hide when `localOn` is false.
|
|
450
|
+
|
|
451
|
+
Each observability list screen is contributed with `adminAccess: {resource, action: "list"}` so
|
|
452
|
+
`/admin/config` omits screens the caller cannot list (`aiPrompt`, `aiTrace`, `aiEvaluator`,
|
|
453
|
+
`aiDataset`, `aiExperiment`, `aiReview`). Detail routes stay registered on the host; route
|
|
454
|
+
handlers still enforce Task 4.1 RBAC.
|
|
455
|
+
|
|
456
|
+
`GET /ai/observability/status` includes a `permissions` map (per observability resource and
|
|
457
|
+
action). Legacy admin users and apps without `accessControl` receive all actions `true`. RBAC
|
|
458
|
+
callers receive effective flags for the signed-in user. Pass the same `accessControl` to
|
|
459
|
+
`ObservabilityApp` and `AdminApp` so status permissions match sidebar filtering. Admin widgets
|
|
460
|
+
read the payload through `resolvePromptActionPermissions` and `observabilityActionAllowed` (fail
|
|
461
|
+
closed while status is loading, errored, or missing `permissions`). List screens gate **Create
|
|
462
|
+
prompt** on `aiPrompt:create`, **Create evaluator** on `aiEvaluator:create`, and **New dataset** on
|
|
463
|
+
`aiDataset:create`; experiment lists hide **New experiment** without `aiExperiment:create`.
|
|
464
|
+
Dataset detail hides **Add item** without `aiDataset:update` and **Run experiment** without
|
|
465
|
+
`aiExperiment:create`; dataset lists hide **Import** without
|
|
466
|
+
`aiDataset:update`. Judge schema checks use the production prompt version, or the latest version
|
|
467
|
+
when production is unset. Widgets do not couple to host Redux.
|
|
468
|
+
|
|
469
|
+
`ai-prompts` lists prompts with a folder rail, search, type badge, latest vs production columns
|
|
470
|
+
(tooltips), 7-day usage, and **Create prompt** when `aiPrompt:create` is allowed. The create modal
|
|
471
|
+
includes an optional **Description** (saved on the prompt record and shown on the hub Overview tab).
|
|
472
|
+
`ai-prompt-editor?name=` is a prompt hub with **Overview**, **Versions**, **Traces**, and
|
|
473
|
+
**Experiments** tabs. Overview shows optional description, folder, tags, and latest vs production
|
|
474
|
+
badges. Traces and Experiments render bounded relationship rows from
|
|
475
|
+
`GET /ai/observability/prompts/:name?promptVersion=` (the hub pins the selected version, defaulting
|
|
476
|
+
to latest after the first load, so related traces and experiments are filtered server-side while the
|
|
477
|
+
full immutable version list stays on the detail payload). The **Versions** tab
|
|
478
|
+
hosts the versioned editor: full-width version history rows show `vN`, every label attached to
|
|
479
|
+
that version, and its creation time on one line. The selected row is highlighted. Editor /
|
|
480
|
+
Playground tabs appear on Versions when allowed; **Save as vN+1**, **Set vN as production…**, and
|
|
481
|
+
playground **Run once** hide when `aiPrompt:update`, `aiPrompt:promote`, or `aiPrompt:playground`
|
|
482
|
+
is denied (fields stay read-only). Playground **Run once** does not create a version; hosts may pass `apiKey` to
|
|
483
|
+
`AiPromptEditorScreenWidget`, which forwards it as `x-ai-api-key` without putting the key in the
|
|
484
|
+
request body. Pass `apiKeyLoading` while the host reads a saved key (for example from
|
|
485
|
+
`useStoredState`) so the playground waits instead of showing a missing-key message. Pass
|
|
486
|
+
`playgroundApiKeyHint` when `GET /ai/observability/status` reports
|
|
487
|
+
`playgroundAi.source: "request-key"`; the editor blocks **Run once** and shows that hint until a
|
|
488
|
+
trimmed key is available. When `playgroundAi.source` is `server`, no key is required. When it is
|
|
489
|
+
`unavailable`, the editor explains that the backend must configure `aiService` or
|
|
490
|
+
`requestAiServiceFactory`. A stale playground **503** with the missing-key title is remapped to the
|
|
491
|
+
same hint for `request-key` hosts so operators are not told to fix server configuration. The
|
|
492
|
+
example admin supplies the Gemini key saved from Profile. **Save this run to dataset** stays
|
|
493
|
+
disabled until phase 2.
|
|
494
|
+
|
|
495
|
+
`ai-traces` lists traces with a filter bar: from/to date fields; dropdowns for prompt, status,
|
|
496
|
+
score presence (**All traces / Has a score / No scores**), and data sensitivity
|
|
497
|
+
(**All traces / Sensitive only / Not sensitive**); plus user and session text fields.
|
|
498
|
+
Checkbox selection opens a bulk bar with **Send to human review**, a sensitive-count
|
|
499
|
+
warning, **Clear**, and **Add to dataset** (opens a dataset picker modal; sensitive traces show a
|
|
500
|
+
warning before bulk add). **Send to human review** opens a modal that explains how human
|
|
501
|
+
evaluators define the score fields and reviewer instructions, then requires a human evaluator
|
|
502
|
+
before creating one review-queue item per trace. When local trace storage is on,
|
|
503
|
+
**Run multi-stage trace test** calls the admin-only smoke endpoint and
|
|
504
|
+
opens the resulting detail: two schema-validated LLM stages, one deterministic tool span, and a
|
|
505
|
+
final schema-validated combining LLM stage under one CHAIN root. LLM span input includes
|
|
506
|
+
`outputSchema`. That run needs AI the same way the playground does: hosts may pass `apiKey`,
|
|
507
|
+
`apiKeyLoading`, and `apiKeyHint` to `AiTracesScreenWidget`, which forwards the key as
|
|
508
|
+
`x-ai-api-key`. When `GET /ai/observability/status` reports `playgroundAi.source: "request-key"`
|
|
509
|
+
and no trimmed key is available, the button is disabled and the hint explains where to save one;
|
|
510
|
+
`unavailable` instead reports that the backend must configure `aiService` or
|
|
511
|
+
`requestAiServiceFactory`. The example admin supplies the Gemini key saved from Profile. Rows show a status dot (primary for successful runs, accent for failed runs),
|
|
512
|
+
`sensitive` badge, error line, numeric prompt count, span count, tokens, cost, latency, score count, and
|
|
513
|
+
**Open**. Pagination uses `page` / `limit` / `more` / `total`.
|
|
514
|
+
`ai-trace-detail?id=` shows the header, left span list (kind badge, indent, duration bar),
|
|
515
|
+
right span detail with **collapsed** sensitive I/O, and scores (value + source). The two columns
|
|
516
|
+
size from the row width rather than their content, so a wide span value (multi-stage LLM spans embed
|
|
517
|
+
`outputSchema` JSON) wraps inside the detail column instead of pushing it below the span list.
|
|
518
|
+
|
|
519
|
+
`ai-review` shows Pending / In progress / Done / Skipped tabs with counts. Each tab is
|
|
520
|
+
oldest-first and lists the trace action, prompt, assignee, waiting time, and status.
|
|
521
|
+
**Start reviewing — oldest first** opens the first pending item; the empty state names both
|
|
522
|
+
Traces intake and manual **Assign to me** assignment.
|
|
523
|
+
|
|
524
|
+
`ai-review-item?id=` shows "Item N of M pending", previous/next navigation, read-only
|
|
525
|
+
**What the AI was given** / **What the AI wrote** panels, collapsed long fields with word
|
|
526
|
+
counts, reviewer notes, and a collapsed Raw JSON disclosure. Score controls come from evaluator
|
|
527
|
+
dimensions (numeric slider, boolean Pass / Fail, categorical pills). Actions are
|
|
528
|
+
**Submit & next**, **Skip**, and **Assign to me**; completion toasts report the remaining count
|
|
529
|
+
or **Queue clear**.
|
|
530
|
+
|
|
531
|
+
`ai-evaluators` lists evaluators with type badge, dimension summary, target, and run-mode chips.
|
|
532
|
+
**Create evaluator** opens `ai-evaluator-new` with the name and optional purpose first, then how
|
|
533
|
+
scoring happens (human / JSON assert / LLM judge). New evaluators score a **full trace** only;
|
|
534
|
+
generation span and dataset item targets are not offered yet. Score fields depend on data type:
|
|
535
|
+
boolean has no extra control, numeric asks for min and max, and categorical is a list you add to.
|
|
536
|
+
LLM judge picks the judge prompt from the existing prompt list. Human setup points operators to
|
|
537
|
+
**Send to human review** and omits live sampling because it never applies; automatic evaluators
|
|
538
|
+
explain experiment/manual availability and production sampling, while LLM judges call out the billed
|
|
539
|
+
model-call impact. The optional purpose is saved as the evaluator description and appears on its
|
|
540
|
+
detail screen. LLM judge schema feedback stays idle until a prompt is selected, then shows
|
|
541
|
+
loading/error states and only checks dimensions after its production schema loads.
|
|
542
|
+
`ai-evaluator-detail?id=` leads with the evaluator name, description, and type/target/run-mode
|
|
543
|
+
badges, then explains the saved scores, how scoring works, where it runs, and a **Used by** list
|
|
544
|
+
derived from recent experiments. A failed experiments request shows **Could not load experiments.**
|
|
545
|
+
instead of an empty 30-day list. Its dimension and usage rows use the shared `ObservabilityTable` instead of
|
|
546
|
+
`DataTable`, which sizes to a height-constrained parent and collapses inside a scrolling page. LLM
|
|
547
|
+
judge details show loading or load-failed feedback while resolving the judge prompt; schema
|
|
548
|
+
mismatches appear only after its production schema loads.
|
|
549
|
+
|
|
550
|
+
`ai-datasets` lists datasets with item counts, provenance bar, input-schema binding, and updated
|
|
551
|
+
time. **New dataset** creates a dataset; **Import** on each row accepts `.json` or `.csv` via
|
|
552
|
+
`FilePickerButton` (local URI read) or paste, posting `{rows}` for JSON or `{format:'csv',content}`
|
|
553
|
+
for CSV. `ai-dataset-detail?id=` shows counts, schema binding, tabs **All / Human / Auto / Needs
|
|
554
|
+
review**. Human is proofread items. Auto is unreviewed trace or synthetic items, so a proofread
|
|
555
|
+
trace is not listed in both tabs. The screen includes an items table (input, expected, provenance,
|
|
556
|
+
trace link), **Add item**, and **Run experiment**. The items table uses `ObservabilityTable`: rows grow with their content
|
|
557
|
+
and cells wrap to three lines before truncating, so long inputs and expected outputs no longer
|
|
558
|
+
overlap adjacent rows. Input and Expected receive 2.5× the flexible width of metadata columns.
|
|
559
|
+
Selecting a row opens a scrollable modal with the complete input, expected output, provenance,
|
|
560
|
+
annotation ids, tags, timestamps, metadata, and an **Open source trace** action when linked.
|
|
561
|
+
Opening the source trace dismisses the item modal before navigation.
|
|
562
|
+
Item-query loading and failures render dedicated states with Retry; they never appear as an empty
|
|
563
|
+
dataset.
|
|
564
|
+
|
|
565
|
+
`ObservabilityTable` (`widgets/aiObservability/shell/ObservabilityTable.tsx`) is the shared
|
|
566
|
+
flow-height table for these screens. Columns take a `title`, optional `minWidth`, and optional
|
|
567
|
+
relative `grow`; rows take a `key`, `cells`, and optional click/accessibility properties. A string
|
|
568
|
+
cell renders truncated text and a node cell renders as-is. Its
|
|
569
|
+
bordered shell shrink-wraps the rows inside flex/scroll parents so its bottom border cannot stretch
|
|
570
|
+
into the following section.
|
|
571
|
+
|
|
572
|
+
`ai-experiments` lists experiments with status, running progress, and cost. **New experiment**
|
|
573
|
+
opens a four-step wizard (dataset with counts, prompt versions tagged latest/production/superseded,
|
|
574
|
+
llm-judge and json-assert evaluators, review & run with estimate). A slower in-flight estimate is ignored after the selection changes, so it cannot replace a newer estimate or its error. Human evaluators are omitted. On the prompt-versions step, **Next** and the later
|
|
575
|
+
wizard rail buttons stay disabled until 2–3 versions are selected. `includeUnproofread` and optional model override are on
|
|
576
|
+
the wizard. `ai-experiment-results?id=` polls while pending/running, shows gate tiles per version,
|
|
577
|
+
failing gate count, outliers, a side-by-side per-item output table (failed rows first from the
|
|
578
|
+
API), and **Promote to production** with a confirm modal; promote is blocked when gates fail (409).
|
|
579
|
+
|
|
580
|
+
### AI Harness approvals inbox
|
|
581
|
+
|
|
582
|
+
`HarnessApp` (`@terreno/ai/harness`) contributes custom screen `harness-approvals`
|
|
583
|
+
(`group: "AI Harness"`, label **Approvals**) through `adminContribution()`, so registering
|
|
584
|
+
`HarnessApp` before `AdminApp` is enough on the backend. `HARNESS_ADMIN_WIDGETS["harness-approvals"]`
|
|
585
|
+
(`HarnessApprovalsScreenWidget`) is in the built-in screen registry, so `/admin/harness-approvals`
|
|
586
|
+
renders through `AdminScreenRouter` with no host route file. See
|
|
587
|
+
[AI harness approvals](ai-harness.md#approvals).
|
|
588
|
+
|
|
589
|
+
The inbox lists the pending approvals the signed-in user may decide
|
|
590
|
+
(`GET /harness/approvals`, oldest first), each with title, task (`name@version` from
|
|
591
|
+
`definitionKey`), age, and expiry. Selecting one shows its `summary` (rendered with `MarkdownView`
|
|
592
|
+
when it looks like markdown) and `payload` (pretty JSON; a string payload that looks like
|
|
593
|
+
markdown renders as markdown), plus a **Reason** field and
|
|
594
|
+
**Approve** / **Reject**.
|
|
595
|
+
|
|
596
|
+
| Behavior | Detail |
|
|
597
|
+
| --- | --- |
|
|
598
|
+
| Approve | `POST /harness/approvals/:id/approve` with `{reason}` when one was typed, else `{}`. |
|
|
599
|
+
| Reject | Requires a non-blank reason (checked before the request); `POST .../reject` with `{reason}` (trimmed). |
|
|
600
|
+
| Success | Back to the list with a confirmation; the list refetches. |
|
|
601
|
+
| 409 | "Already decided, expired, or its task ended" notice; back to the list and refetch. |
|
|
602
|
+
| Other errors | The API error `title` under the buttons; the detail stays open. |
|
|
603
|
+
| States | Loading spinner, error with **Try again**, empty (**Nothing to approve.**), **Refresh** button. |
|
|
604
|
+
| Page size | The oldest 100 (`?limit=100`). When the response has `more`, a note says so; deciding items brings the rest into view. |
|
|
605
|
+
| In flight | Both buttons and the reason field are disabled until the decision returns, so it is sent once. |
|
|
606
|
+
|
|
607
|
+
Requests use the host-bound admin request client (`AdminProvider` `getAuthHeaders` / `apiOrigin`),
|
|
608
|
+
never RTK `injectEndpoints`. Test ids: `harness-approvals` (root), `harness-approvals-list`,
|
|
609
|
+
`harness-approvals-empty`, `harness-approvals-error`, `harness-approvals-loading`,
|
|
610
|
+
`harness-approvals-refresh`, `harness-approvals-notice`, `harness-approval-row-<id>-clickable`,
|
|
611
|
+
`harness-approval-detail`, `harness-approval-payload`, `harness-approval-reason`,
|
|
612
|
+
`harness-approval-approve`, `harness-approval-reject`, `harness-approval-error`,
|
|
613
|
+
`harness-approval-back` (standalone only; in admin the page back chevron closes the detail).
|
|
614
|
+
|
|
615
|
+
#### Mount outside admin chrome
|
|
616
|
+
|
|
617
|
+
`HarnessApprovalInbox` is exported for app screens (for example a clinician view). Inside an
|
|
618
|
+
`AdminProvider` it needs no props. Elsewhere, pass a request client from `bindAdminRequest`:
|
|
619
|
+
|
|
620
|
+
```typescript
|
|
621
|
+
import {bindAdminRequest, HarnessApprovalInbox} from "@terreno/admin-frontend";
|
|
622
|
+
import {baseUrl} from "@terreno/rtk";
|
|
623
|
+
import {Page} from "@terreno/ui";
|
|
624
|
+
|
|
625
|
+
// getAuthHeaders: the host's `() => ({Authorization: "Bearer …"})`, as passed to AdminProvider.
|
|
626
|
+
const request = bindAdminRequest({getAuthHeaders, origin: baseUrl});
|
|
627
|
+
|
|
628
|
+
export const SignOffScreen: React.FC = () => (
|
|
629
|
+
<Page title="Sign-offs" scroll>
|
|
630
|
+
<HarnessApprovalInbox request={request} />
|
|
631
|
+
</Page>
|
|
632
|
+
);
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
| Prop | Type | Default | Description |
|
|
636
|
+
| --- | --- | --- | --- |
|
|
637
|
+
| `request` | `(args: AdminRequestArgs) => Promise<unknown>` | `AdminProvider` client | Request client, usually `bindAdminRequest({getAuthHeaders, origin})`. |
|
|
638
|
+
| `baseUrl` | `string` | `/harness` | Where `HarnessApp` is mounted (its `basePath`). Relative paths resolve against the client's `origin`. |
|
|
639
|
+
| `selected` | `HarnessApprovalRow \| undefined` | internal | Controlled selection: the approval whose detail is open. |
|
|
640
|
+
| `onSelectedChange` | `(approval \| undefined) => void` | — | With `selected`, lets the host's back control close the detail; the inbox then hides its own **All approvals** button. The admin screen uses this so the page chevron returns to the list. |
|
|
641
|
+
| `showHeading` | `boolean` | `true` | Show the **Approvals** heading; the admin screen passes `false` because `AdminScreenPage` already titles it. |
|
|
642
|
+
| `testID` | `string` | `harness-approvals` | Root test id. |
|
|
643
|
+
|
|
644
|
+
The built-in admin screen always uses `/harness`. A host that mounts `HarnessApp` at another
|
|
645
|
+
`basePath` registers its own `widgets.screens["harness-approvals"]` that renders
|
|
646
|
+
`<HarnessApprovalInbox baseUrl="/ops/harness" />`.
|
|
647
|
+
|
|
648
|
+
The example backend registers `HarnessApp` with a demo task, `demo.approvalDemo@1`. Start
|
|
649
|
+
approvals with the admin script `startHarnessApprovalDemo` (args `count`, default 2, and `prefix`)
|
|
650
|
+
from the admin Scripts screen or `bun run script startHarnessApprovalDemo --wet`. It needs Mongo
|
|
651
|
+
running as a replica set; without one the backend logs `Harness disabled` and skips the routes.
|
|
652
|
+
`example-frontend/e2e/harness-approvals.spec.ts` approves one and rejects one.
|