@terreno/mcp 57.9.0 → 57.10.1

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.
Files changed (95) hide show
  1. package/dist/docs/upgrades/57.10.1.md +54 -0
  2. package/dist/docs/versioned/next/README.md +1 -0
  3. package/dist/docs/versioned/next/explanation/README.md +4 -0
  4. package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  5. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  6. package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  7. package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
  8. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  9. package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
  10. package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
  11. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  12. package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  13. package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
  14. package/dist/docs/versioned/next/how-to/README.md +6 -0
  15. package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  16. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  17. package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  18. package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  19. package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  20. package/dist/docs/versioned/next/how-to/circleci.md +5 -3
  21. package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  22. package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  23. package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  24. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  25. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  26. package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  27. package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  28. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  29. package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  30. package/dist/docs/versioned/next/reference/README.md +3 -0
  31. package/dist/docs/versioned/next/reference/admin-config.md +2 -0
  32. package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
  33. package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  34. package/dist/docs/versioned/next/reference/ai-harness.md +1881 -0
  35. package/dist/docs/versioned/next/reference/ai.md +251 -25
  36. package/dist/docs/versioned/next/reference/api.md +46 -0
  37. package/dist/docs/versioned/next/reference/blocks.md +178 -0
  38. package/dist/docs/versioned/next/reference/environment-variables.md +1 -0
  39. package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
  40. package/dist/docs/versioned/next/reference/test.md +11 -2
  41. package/dist/docs/versioned/next/reference/ui.md +325 -1
  42. package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  43. package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  44. package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
  45. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +183 -0
  46. package/dist/tools.d.ts +4 -0
  47. package/dist/tools.d.ts.map +1 -1
  48. package/dist/tools.js +37 -0
  49. package/dist/tools.js.map +1 -1
  50. package/package.json +3 -2
  51. package/src/docs/upgrades/57.10.1.md +54 -0
  52. package/src/docs/versioned/next/README.md +1 -0
  53. package/src/docs/versioned/next/explanation/README.md +4 -0
  54. package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  55. package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  56. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  57. package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
  58. package/src/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  59. package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
  60. package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
  61. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  62. package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  63. package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
  64. package/src/docs/versioned/next/how-to/README.md +6 -0
  65. package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  66. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  67. package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  68. package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  69. package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  70. package/src/docs/versioned/next/how-to/circleci.md +5 -3
  71. package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  72. package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  73. package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  74. package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  75. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  76. package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  77. package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  78. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  79. package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  80. package/src/docs/versioned/next/reference/README.md +3 -0
  81. package/src/docs/versioned/next/reference/admin-config.md +2 -0
  82. package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
  83. package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  84. package/src/docs/versioned/next/reference/ai-harness.md +1881 -0
  85. package/src/docs/versioned/next/reference/ai.md +251 -25
  86. package/src/docs/versioned/next/reference/api.md +46 -0
  87. package/src/docs/versioned/next/reference/blocks.md +178 -0
  88. package/src/docs/versioned/next/reference/environment-variables.md +1 -0
  89. package/src/docs/versioned/next/reference/mcp-server.md +17 -0
  90. package/src/docs/versioned/next/reference/test.md +11 -2
  91. package/src/docs/versioned/next/reference/ui.md +325 -1
  92. package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  93. package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  94. package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
  95. package/src/docs/versioned/next/tasks/durable-agent-harness.md +183 -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`. 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 that IP).
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.