@exaudeus/workrail 3.40.0 → 3.42.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/cli/commands/init.js +0 -3
- package/dist/cli-worktrain.js +48 -11
- package/dist/cli.js +0 -18
- package/dist/config/app-config.d.ts +0 -16
- package/dist/config/app-config.js +0 -14
- package/dist/config/config-file.js +0 -3
- package/dist/console-ui/assets/index-DGj8EsFR.css +1 -0
- package/dist/console-ui/assets/index-DwfWMKvv.js +28 -0
- package/dist/console-ui/index.html +2 -2
- package/dist/context-assembly/deps.d.ts +8 -0
- package/dist/context-assembly/deps.js +2 -0
- package/dist/context-assembly/index.d.ts +6 -0
- package/dist/context-assembly/index.js +50 -0
- package/dist/context-assembly/infra.d.ts +3 -0
- package/dist/context-assembly/infra.js +154 -0
- package/dist/context-assembly/types.d.ts +30 -0
- package/dist/context-assembly/types.js +2 -0
- package/dist/coordinators/pr-review.d.ts +20 -1
- package/dist/coordinators/pr-review.js +189 -4
- package/dist/daemon/daemon-events.d.ts +9 -1
- package/dist/daemon/soul-template.d.ts +2 -2
- package/dist/daemon/soul-template.js +11 -1
- package/dist/daemon/workflow-runner.d.ts +14 -1
- package/dist/daemon/workflow-runner.js +406 -25
- package/dist/di/container.js +1 -25
- package/dist/di/tokens.d.ts +0 -3
- package/dist/di/tokens.js +0 -3
- package/dist/domain/execution/state.d.ts +6 -6
- package/dist/engine/engine-factory.js +0 -1
- package/dist/infrastructure/console-defaults.d.ts +1 -0
- package/dist/infrastructure/console-defaults.js +4 -0
- package/dist/infrastructure/session/index.d.ts +0 -1
- package/dist/infrastructure/session/index.js +1 -3
- package/dist/manifest.json +138 -122
- package/dist/mcp/handlers/session.d.ts +1 -0
- package/dist/mcp/handlers/session.js +61 -13
- package/dist/mcp/handlers/v2-workflow.d.ts +2 -2
- package/dist/mcp/output-schemas.d.ts +234 -234
- package/dist/mcp/server.js +1 -18
- package/dist/mcp/tools.d.ts +2 -2
- package/dist/mcp/transports/http-entry.js +0 -2
- package/dist/mcp/transports/stdio-entry.js +1 -2
- package/dist/mcp/types.d.ts +0 -2
- package/dist/mcp/v2/tools.d.ts +24 -24
- package/dist/trigger/daemon-console.d.ts +2 -0
- package/dist/trigger/daemon-console.js +1 -1
- package/dist/trigger/trigger-listener.d.ts +2 -0
- package/dist/trigger/trigger-listener.js +3 -1
- package/dist/trigger/trigger-router.d.ts +4 -3
- package/dist/trigger/trigger-router.js +4 -3
- package/dist/trigger/trigger-store.js +17 -4
- package/dist/v2/durable-core/schemas/artifacts/assessment.d.ts +2 -2
- package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.d.ts +2 -2
- package/dist/v2/durable-core/schemas/artifacts/loop-control.d.ts +6 -6
- package/dist/v2/durable-core/schemas/artifacts/review-verdict.d.ts +6 -6
- package/dist/v2/durable-core/schemas/compiled-workflow/index.d.ts +56 -56
- package/dist/v2/durable-core/schemas/execution-snapshot/blocked-snapshot.d.ts +83 -83
- package/dist/v2/durable-core/schemas/execution-snapshot/execution-snapshot.v1.d.ts +1024 -1024
- package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +2336 -2336
- package/dist/v2/durable-core/schemas/session/dag-topology.d.ts +6 -6
- package/dist/v2/durable-core/schemas/session/events.d.ts +339 -339
- package/dist/v2/durable-core/schemas/session/gaps.d.ts +30 -30
- package/dist/v2/durable-core/schemas/session/manifest.d.ts +6 -6
- package/dist/v2/durable-core/schemas/session/outputs.d.ts +8 -8
- package/dist/v2/durable-core/schemas/session/validation-event.d.ts +3 -3
- package/dist/v2/usecases/console-routes.d.ts +2 -1
- package/dist/v2/usecases/console-routes.js +29 -5
- package/dist/v2/usecases/console-service.js +14 -0
- package/dist/v2/usecases/console-types.d.ts +1 -0
- package/docs/authoring.md +16 -16
- package/docs/design/context-assembly-design-candidates.md +199 -0
- package/docs/design/context-assembly-implementation-plan.md +211 -0
- package/docs/design/context-assembly-review-findings.md +112 -0
- package/docs/design/coordinator-message-queue-drain-plan.md +241 -0
- package/docs/design/coordinator-message-queue-drain-review.md +120 -0
- package/docs/design/coordinator-message-queue-drain.md +289 -0
- package/docs/design/shaping-workflow-external-research.md +119 -0
- package/docs/discovery/late-bound-goals-impl-plan.md +147 -0
- package/docs/discovery/late-bound-goals-review.md +82 -0
- package/docs/discovery/late-bound-goals.md +118 -0
- package/docs/discovery/steer-endpoint-design-candidates.md +288 -0
- package/docs/discovery/steer-endpoint-design-review-findings.md +104 -0
- package/docs/discovery/steer-endpoint-implementation-plan.md +284 -0
- package/docs/ideas/backlog.md +356 -0
- package/docs/ideas/design-candidates-console-session-tree-impl.md +64 -0
- package/docs/ideas/design-candidates-session-tree-view.md +196 -0
- package/docs/ideas/design-review-findings-console-session-tree-impl.md +75 -0
- package/docs/ideas/design-review-findings-session-tree-view.md +88 -0
- package/docs/ideas/implementation_plan_session_tree_view.md +238 -0
- package/package.json +2 -1
- package/spec/authoring-spec.json +16 -16
- package/spec/shape.schema.json +178 -0
- package/spec/workflow-tags.json +232 -47
- package/workflows/coding-task-workflow-agentic.json +491 -480
- package/workflows/wr.shaping.json +182 -0
- package/dist/console-ui/assets/index-8dh0Psu-.css +0 -1
- package/dist/console-ui/assets/index-CXWCAonr.js +0 -28
- package/dist/infrastructure/session/DashboardHeartbeat.d.ts +0 -8
- package/dist/infrastructure/session/DashboardHeartbeat.js +0 -39
- package/dist/infrastructure/session/DashboardLockRelease.d.ts +0 -2
- package/dist/infrastructure/session/DashboardLockRelease.js +0 -29
- package/dist/infrastructure/session/HttpServer.d.ts +0 -60
- package/dist/infrastructure/session/HttpServer.js +0 -912
- package/workflows/coding-task-workflow-agentic.lean.v2.json +0 -648
- package/workflows/coding-task-workflow-agentic.v2.json +0 -324
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Design Candidates: POST /api/v2/sessions/:sessionId/steer
|
|
2
|
+
|
|
3
|
+
> Raw investigative material for main agent synthesis. Not a final decision.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Problem Understanding
|
|
8
|
+
|
|
9
|
+
### Core Tensions
|
|
10
|
+
|
|
11
|
+
**T1: Closure-scoped state vs. HTTP endpoint accessibility.**
|
|
12
|
+
`pendingSteerParts` (the fixed form of `pendingSteerText`) lives inside the `runWorkflow()` closure
|
|
13
|
+
in `src/daemon/workflow-runner.ts`. The HTTP handler lives in `mountConsoleRoutes()` in
|
|
14
|
+
`src/v2/usecases/console-routes.ts`. These modules have no shared reference. The naive fix
|
|
15
|
+
(module-level `Map`) violates the per-instance isolation invariant that motivated moving `sseClients`
|
|
16
|
+
into the `mountConsoleRoutes()` closure: module-level state is shared across test instances and
|
|
17
|
+
hypothetical daemon restarts, causing cross-session contamination. Solution: explicit DI.
|
|
18
|
+
|
|
19
|
+
**T2: Silent discard on `pendingSteerText` overwrite.**
|
|
20
|
+
The R1 finding from `design-review-findings-mid-session-signaling.md`: `onAdvance()` currently does
|
|
21
|
+
`pendingSteerText = stepText` (assignment). A coordinator steer written between step advances would be
|
|
22
|
+
silently overwritten when `onAdvance()` fires. Fix: rename to `pendingSteerParts: string[]` and use
|
|
23
|
+
`push()`. The turn_end subscriber joins all parts and clears. Order: step text first (workflow advance
|
|
24
|
+
is primary), coordinator text appended. This is a blocking prerequisite for the endpoint.
|
|
25
|
+
|
|
26
|
+
**T3: Daemon-only semantics vs. shared `mountConsoleRoutes` function.**
|
|
27
|
+
The steer endpoint is only meaningful for daemon-managed sessions. The standalone console must NOT
|
|
28
|
+
expose it. If the endpoint is always registered (even in standalone mode), it returns 404 for every
|
|
29
|
+
call -- silently wrong. Better: the endpoint is structurally absent when no steer registry is injected.
|
|
30
|
+
|
|
31
|
+
**T4: Registry lifecycle -- registration timing gap.**
|
|
32
|
+
The registry key is the WorkRail `sess_xxx` ID, decoded from the continueToken after
|
|
33
|
+
`executeStartWorkflow()` returns. For sessions using `_preAllocatedStartResponse` (dispatch path),
|
|
34
|
+
the session ID is already decoded before `runWorkflow()` is called. For sessions that call
|
|
35
|
+
`executeStartWorkflow()` internally, there is a brief window (< 1 turn) where the session exists but
|
|
36
|
+
is not yet in the registry. v1 acceptable (documented); coordinator should retry once on 404.
|
|
37
|
+
|
|
38
|
+
### Likely Seam
|
|
39
|
+
|
|
40
|
+
The real seam is the `turn_end` subscriber in `runWorkflow()` at lines ~2523-2531. This is both where
|
|
41
|
+
the fix lands (drain `pendingSteerParts`) AND where steer delivery happens. The HTTP endpoint is
|
|
42
|
+
purely the write path into the array; the delivery mechanism (`agent.steer()`) is unchanged.
|
|
43
|
+
|
|
44
|
+
### What Makes This Hard
|
|
45
|
+
|
|
46
|
+
1. The registry must be constructed by the daemon layer and passed to BOTH the HTTP server and the
|
|
47
|
+
workflow runner. These are currently linked only through `runWorkflow()` being called from
|
|
48
|
+
`console-routes.ts` or `TriggerRouter`. Threading a new dependency through both callers requires
|
|
49
|
+
identifying all call sites (`daemon-console.ts`, `console-routes.ts` direct dispatch).
|
|
50
|
+
|
|
51
|
+
2. JavaScript single-threadedness means there is NO race condition between the HTTP write path and the
|
|
52
|
+
turn_end read path -- Node.js event loop serializes them. This is the key insight junior devs miss;
|
|
53
|
+
they add unnecessary locking.
|
|
54
|
+
|
|
55
|
+
3. The `pendingSteerParts` array-based fix changes the join semantics: multiple concurrent steers from
|
|
56
|
+
the coordinator (if the coordinator calls the endpoint twice before the next turn_end) both land.
|
|
57
|
+
This is correct behavior but must be explicit in the code.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Philosophy Constraints
|
|
62
|
+
|
|
63
|
+
Sources: `/Users/etienneb/CLAUDE.md`, `docs/discovery/design-review-findings-mid-session-signaling.md`,
|
|
64
|
+
existing `console-routes.ts` route patterns.
|
|
65
|
+
|
|
66
|
+
- **DI for boundaries**: The registry must be injected, not imported as a module singleton.
|
|
67
|
+
Existing pattern: `triggerRouter?: TriggerRouter` in `mountConsoleRoutes()`.
|
|
68
|
+
- **Errors as data**: The lookup result should be a typed value (`'ok' | 'not_found'`), not a boolean
|
|
69
|
+
or thrown exception. HTTP handler maps to 200/404.
|
|
70
|
+
- **Explicit domain types over primitives**: A named `SteerRegistry` type is preferred over a raw
|
|
71
|
+
`Map<string, (text: string) => void>` even if functionally identical.
|
|
72
|
+
- **Make illegal states unrepresentable**: Standalone console should structurally not have a steer
|
|
73
|
+
registry, making it impossible to accidentally steer non-daemon sessions.
|
|
74
|
+
- **YAGNI**: No auth token in v1 (documented per O3 finding). No `waitForCoordinator` blocking gate.
|
|
75
|
+
No crash recovery for in-flight steers.
|
|
76
|
+
- **Validate at boundaries**: Endpoint validates `{ text: string }` body before calling invoke.
|
|
77
|
+
|
|
78
|
+
**Conflicts:** None found. All three candidates can be made to honor these principles.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Impact Surface
|
|
83
|
+
|
|
84
|
+
Files that must remain consistent if the boundary changes:
|
|
85
|
+
|
|
86
|
+
- `src/daemon/workflow-runner.ts` -- rename `pendingSteerText` \u2192 `pendingSteerParts`, add registry
|
|
87
|
+
registration/deregistration, add optional `steerRegistry` param
|
|
88
|
+
- `src/v2/usecases/console-routes.ts` -- add `steerRegistry` optional param, add POST endpoint
|
|
89
|
+
- `src/trigger/daemon-console.ts` -- create registry, pass to both `mountConsoleRoutes()` and the
|
|
90
|
+
workflow runner (via TriggerRouter or direct call)
|
|
91
|
+
- `src/console/standalone-console.ts` -- no change needed (passes `undefined` for new param)
|
|
92
|
+
|
|
93
|
+
Contracts that must remain consistent:
|
|
94
|
+
- `runWorkflow()` public signature -- any change must be additive (optional param, default undefined)
|
|
95
|
+
- `mountConsoleRoutes()` public signature -- same constraint
|
|
96
|
+
- `pendingSteerText` variable name change: search for any existing references (none found outside
|
|
97
|
+
`workflow-runner.ts` itself)
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Candidates
|
|
102
|
+
|
|
103
|
+
### Candidate 1: Minimal -- raw Map parameter added to existing signatures
|
|
104
|
+
|
|
105
|
+
**Summary:** Add `steerRegistry?: Map<string, (text: string) => void>` as an optional trailing param
|
|
106
|
+
to both `mountConsoleRoutes()` and `runWorkflow()`. Fix `pendingSteerParts` inline. Endpoint guarded by
|
|
107
|
+
`!steerRegistry \u2192 503`.
|
|
108
|
+
|
|
109
|
+
**Tensions resolved:**
|
|
110
|
+
- T1 (closure vs. HTTP): plain `Map` is passed explicitly.
|
|
111
|
+
- T2 (silent discard): `pendingSteerParts.push()` fix.
|
|
112
|
+
- T3 (daemon-only): absent Map \u2192 503.
|
|
113
|
+
|
|
114
|
+
**Tensions accepted:**
|
|
115
|
+
- Lifecycle of registration is caller-managed; caller must remember to deregister in a finally block
|
|
116
|
+
(not enforced by the type).
|
|
117
|
+
- `boolean` return from `Map.has()` coerces to HTTP 200/404 implicitly.
|
|
118
|
+
|
|
119
|
+
**Boundary:** `runWorkflow()` and `mountConsoleRoutes()` signatures. Additive, backward-compatible.
|
|
120
|
+
|
|
121
|
+
**Why this boundary:** These are the exact two call sites that need the registry; adding it here adds
|
|
122
|
+
no abstraction layer above what's needed.
|
|
123
|
+
|
|
124
|
+
**Failure mode:** Caller forgets to delete from Map after `runWorkflow()` completes \u2192 stale entry
|
|
125
|
+
remains. HTTP endpoint calls the stale callback, which calls `pendingSteerParts.push()` on a
|
|
126
|
+
completed session's closed-over array (harmless but semantically wrong). Mitigation: put the delete
|
|
127
|
+
inside `runWorkflow()`'s finally block (not caller-managed). Risk: low.
|
|
128
|
+
|
|
129
|
+
**Repo pattern:** Follows `triggerRouter?: TriggerRouter` exactly -- highest pattern consistency.
|
|
130
|
+
|
|
131
|
+
**Gains:** Minimal new code (\u223c15 lines net new). No new files. No new abstractions.
|
|
132
|
+
|
|
133
|
+
**Losses:** Raw `Map` type is not self-documenting. The `(text: string) => void` callback signature
|
|
134
|
+
has no name. Mild 'explicit domain types' philosophy violation.
|
|
135
|
+
|
|
136
|
+
**Impact surface:** Three files (workflow-runner.ts, console-routes.ts, daemon-console.ts). No new
|
|
137
|
+
files.
|
|
138
|
+
|
|
139
|
+
**Scope:** Best-fit. Exactly what's needed, nothing more.
|
|
140
|
+
|
|
141
|
+
**Philosophy:** Honors DI-for-boundaries, YAGNI. Mild conflict with 'prefer explicit domain types'.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Candidate 2: Encapsulated -- SteerRegistry class in src/daemon/steer-registry.ts
|
|
146
|
+
|
|
147
|
+
**Summary:** Create a named `SteerRegistry` class with `register(sessionId, cb): () => void` (returns
|
|
148
|
+
disposer), `invoke(sessionId, text): 'ok' | 'not_found'`, and private `_sessions: Map`. Pass instances
|
|
149
|
+
to `mountConsoleRoutes()` and `runWorkflow()`.
|
|
150
|
+
|
|
151
|
+
**Tensions resolved:**
|
|
152
|
+
- T1: explicit DI, same as C1.
|
|
153
|
+
- T2: `pendingSteerParts.push()` fix.
|
|
154
|
+
- T3: absent registry \u2192 503.
|
|
155
|
+
- Lifecycle: `register()` returns a disposer function. `runWorkflow()` stores it and calls it in
|
|
156
|
+
finally. Impossible to forget -- the disposer IS the deregistration.
|
|
157
|
+
|
|
158
|
+
**Tensions accepted:**
|
|
159
|
+
- New file (~50 lines). Marginal over C1.
|
|
160
|
+
|
|
161
|
+
**Boundary:** New file `src/daemon/steer-registry.ts`. Both callers import from there. The named class
|
|
162
|
+
is the domain object; the Map is an implementation detail hidden behind the API.
|
|
163
|
+
|
|
164
|
+
**Why this boundary:** Encapsulates the invariant that 'a session is registered when running and
|
|
165
|
+
deregistered when done'. The disposer pattern makes this lifecycle explicit at the type level.
|
|
166
|
+
|
|
167
|
+
**Failure mode:** Same as C1 in theory, but structurally prevented: `register()` returns the disposer,
|
|
168
|
+
so the only way to use `SteerRegistry` correctly is to store and call the disposer. Forgetting to call
|
|
169
|
+
it requires actively ignoring the return value (which TypeScript can warn about with `@typescript-eslint/no-unused-vars`).
|
|
170
|
+
|
|
171
|
+
**Repo pattern:** Consistent with `ToolCallTimingRingBuffer` (small class for narrow concern). Slight
|
|
172
|
+
departure from 'just add a param' pattern, but consistent with broader codebase style of named
|
|
173
|
+
infrastructure types.
|
|
174
|
+
|
|
175
|
+
**Gains:** Self-documenting API. `'ok' | 'not_found'` maps directly to HTTP 200/404 with no coercion.
|
|
176
|
+
Disposer pattern prevents stale registration. Testable in isolation.
|
|
177
|
+
|
|
178
|
+
**Losses:** New file. ~35 more lines than C1.
|
|
179
|
+
|
|
180
|
+
**Impact surface:** Four files (workflow-runner.ts, console-routes.ts, daemon-console.ts, new
|
|
181
|
+
steer-registry.ts).
|
|
182
|
+
|
|
183
|
+
**Scope:** Best-fit. Marginal scope increase over C1 justified by lifecycle safety.
|
|
184
|
+
|
|
185
|
+
**Philosophy:** Fully honors 'prefer explicit domain types', DI-for-boundaries, 'errors as data'.
|
|
186
|
+
No conflicts.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### Candidate 3: Ambient injection via V2ToolContext
|
|
191
|
+
|
|
192
|
+
**Summary:** Add `steerRegistry?: SteerRegistry` as an optional field to `V2Dependencies` (or
|
|
193
|
+
`V2ToolContext` directly in `src/mcp/types.ts`). `runWorkflow()` reads it from context; `mountConsoleRoutes()`
|
|
194
|
+
already receives `v2ToolContext`. No new params on either function.
|
|
195
|
+
|
|
196
|
+
**Tensions resolved:**
|
|
197
|
+
- T1: V2ToolContext flows everywhere, no new wiring needed.
|
|
198
|
+
|
|
199
|
+
**Tensions accepted:**
|
|
200
|
+
- T3: V2ToolContext is shared between daemon AND MCP server sessions. Adding a daemon-specific field
|
|
201
|
+
pollutes the shared context. MCP sessions would have `steerRegistry: undefined`, but the type allows
|
|
202
|
+
it to be set -- no structural prevention.
|
|
203
|
+
- Architecture pollution: `src/mcp/types.ts` is a high-traffic type with many consumers. Adding a
|
|
204
|
+
daemon-only field there couples the MCP abstraction to the daemon's runtime state.
|
|
205
|
+
|
|
206
|
+
**Boundary:** `src/mcp/types.ts` V2Dependencies interface. High-traffic, many consumers.
|
|
207
|
+
|
|
208
|
+
**Why this boundary is wrong:** V2ToolContext is the MCP/engine shared context. Daemon-specific state
|
|
209
|
+
(like which sessions have live agent loops) should not bleed into the MCP layer. If MCP sessions ever
|
|
210
|
+
become steer-able, C3 would be correct then; today it's premature.
|
|
211
|
+
|
|
212
|
+
**Failure mode:** Misconfigured test or future consumer accidentally sets `steerRegistry` on an MCP
|
|
213
|
+
session, making it steer-able from the HTTP endpoint. Not structurally prevented -- only prevented by
|
|
214
|
+
convention.
|
|
215
|
+
|
|
216
|
+
**Repo pattern:** Consistent with how `v2ToolContext` is used everywhere, but departs from the 'inject
|
|
217
|
+
specific dependencies for specific concerns' principle.
|
|
218
|
+
|
|
219
|
+
**Gains:** Zero new params to thread; registry flows naturally wherever V2ToolContext goes.
|
|
220
|
+
|
|
221
|
+
**Losses:** Architectural pollution. Structurally harder to reason about which sessions are
|
|
222
|
+
steer-able. High-traffic type change.
|
|
223
|
+
|
|
224
|
+
**Impact surface:** `src/mcp/types.ts` + all consumers that need to be checked for exhaustive handling.
|
|
225
|
+
|
|
226
|
+
**Scope:** Too broad. The registry is a daemon-only concern.
|
|
227
|
+
|
|
228
|
+
**Philosophy:** Conflicts with 'make illegal states unrepresentable'. Partially honors DI.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Comparison and Recommendation
|
|
233
|
+
|
|
234
|
+
### Matrix
|
|
235
|
+
|
|
236
|
+
| Factor | C1 (raw Map) | C2 (SteerRegistry) | C3 (V2ToolContext) |
|
|
237
|
+
|---|---|---|---|
|
|
238
|
+
| Resolves T1 | Yes | Yes | Yes |
|
|
239
|
+
| Resolves T2 | Yes | Yes | Yes |
|
|
240
|
+
| Resolves T3 structurally | Partially | Yes | No |
|
|
241
|
+
| Lifecycle safety | Caller convention | Enforced by disposer | Caller convention |
|
|
242
|
+
| Philosophy fit | Good | Excellent | Poor (T3) |
|
|
243
|
+
| Repo pattern | Exact match | Adapted match | Wrong boundary |
|
|
244
|
+
| New files | 0 | 1 | 0 |
|
|
245
|
+
| Impact surface | 3 files | 4 files | High (types.ts) |
|
|
246
|
+
|
|
247
|
+
### Recommendation: Candidate 2 (SteerRegistry class)
|
|
248
|
+
|
|
249
|
+
**Rationale:** The disposer pattern from `register()` structurally prevents the only meaningful
|
|
250
|
+
failure mode (stale registration). The `'ok' | 'not_found'` return type eliminates implicit coercion
|
|
251
|
+
at the HTTP layer. The new file adds \u223c50 lines but pays for itself in self-documentation and
|
|
252
|
+
testability. C1 and C2 are functionally identical; C2 wins on type safety and lifecycle enforcement.
|
|
253
|
+
|
|
254
|
+
### Self-Critique
|
|
255
|
+
|
|
256
|
+
**Strongest argument against C2:** C1 with a type alias `type SteerRegistry = Map<string, (text: string) => void>` and a disposer convention in `runWorkflow()`'s finally block is functionally
|
|
257
|
+
identical to C2. The new file in C2 is purely for encapsulation. If the codebase convention is 'small
|
|
258
|
+
files for small concerns', C2 is right. If the convention is 'minimize files', C1 wins. The codebase
|
|
259
|
+
has `ToolCallTimingRingBuffer` as a precedent for small dedicated infrastructure files -- C2 follows
|
|
260
|
+
this pattern.
|
|
261
|
+
|
|
262
|
+
**Narrower option that could work:** C1. Loses only the explicit lifecycle type; gains nothing beyond
|
|
263
|
+
fewer files. C1 is acceptable if YAGNI pressure is high.
|
|
264
|
+
|
|
265
|
+
**Broader option that might be justified:** C3 if MCP sessions become steer-able in v2. Not justified
|
|
266
|
+
today. Evidence required: MCP server calling `runWorkflow()`-style loop.
|
|
267
|
+
|
|
268
|
+
**Pivot condition:** If a new engineer misuses C2 by holding the registry object and calling
|
|
269
|
+
`invoke()` directly (bypassing the disposer), C2's safety guarantee fails. Risk: low (internal API).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Open Questions for Main Agent
|
|
274
|
+
|
|
275
|
+
1. Does `daemon-console.ts` own registry construction, or should it be constructed in `trigger-router.ts`
|
|
276
|
+
and passed down? `TriggerRouter.dispatch()` calls `runWorkflow()` -- it would need the registry.
|
|
277
|
+
`daemon-console.ts` constructs the `TriggerRouter`. The cleanest path: construct registry in
|
|
278
|
+
`daemon-console.ts`, pass to `TriggerRouter` constructor and to `mountConsoleRoutes()`.
|
|
279
|
+
|
|
280
|
+
2. Should the endpoint return `{ success: true }` (no data) on 200, or echo back something useful
|
|
281
|
+
(e.g., `{ queued: true, sessionId }`)?
|
|
282
|
+
|
|
283
|
+
3. Should coordinator steers be appended before or after the step text in `pendingSteerParts`?
|
|
284
|
+
Current proposal: step text first (via `onAdvance()` which fires first), coordinator appended.
|
|
285
|
+
The agent sees the step instructions before the coordinator enrichment.
|
|
286
|
+
|
|
287
|
+
4. Is there an existing test for `runWorkflow()` that needs to be updated when `pendingSteerText`
|
|
288
|
+
is renamed? (Check `src/daemon/workflow-runner.test.ts` or similar.)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Design Review Findings: POST /api/v2/sessions/:sessionId/steer
|
|
2
|
+
|
|
3
|
+
> Concise, actionable findings from the tradeoff review, failure mode analysis,
|
|
4
|
+
> runner-up comparison, and philosophy alignment check.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Tradeoff Review
|
|
9
|
+
|
|
10
|
+
| Tradeoff | Acceptable? | Condition for unacceptability |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| No auth on endpoint | Yes | Multi-tenant or remote daemon deployment (not today) |
|
|
13
|
+
| Registration gap window (~50ms) | Yes | Coordinator steers immediately on session creation, before first tool call |
|
|
14
|
+
| No crash recovery for in-flight steers | Yes | Real-time human approval with no retry mechanism (not a v1 use case) |
|
|
15
|
+
| `pendingSteerParts` is mutable array | Yes | Mutation is bounded to two explicit write paths, one read path |
|
|
16
|
+
| `SteerRegistry` as type alias, not class | Yes | Only if richer lifecycle API is needed (it isn't for 3 operations) |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Failure Mode Review
|
|
21
|
+
|
|
22
|
+
| Failure Mode | Risk | Design Handling |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Coordinator calls before session registered | LOW | 404 returned; gap is ~50ms not 1 LLM turn |
|
|
25
|
+
| Coordinator calls after session completes | NONE | 404 returned (registry cleared in finally block) |
|
|
26
|
+
| Stale registration leaking closure reference | NONE | Structurally prevented: disposer lambda in finally block |
|
|
27
|
+
| Concurrent push + drain (JS single-threaded) | NONE | Event loop serializes; no interleaving possible |
|
|
28
|
+
| Unbounded `pendingSteerParts` array | VERY LOW | Bounded by network pressure; no cap needed in v1 |
|
|
29
|
+
| Invalid body (missing/non-string `text`) | NONE | 400 returned after boundary validation |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Runner-Up / Simpler Alternative Review
|
|
34
|
+
|
|
35
|
+
**From C2 (SteerRegistry class):** Borrow the disposer pattern -- `register()` returns the deregistration lambda. Even with the hybrid (type alias, no class), the disposer pattern is implemented as a closure in `runWorkflow()`'s finally block. The value is in the pattern, not the wrapper.
|
|
36
|
+
|
|
37
|
+
**Simpler variant (inline Map, no type alias):** Works, but the parameter type `Map<string, (text: string) => void>` in function signatures has no name. A type alias `SteerRegistry` costs zero lines and buys readability at all call sites.
|
|
38
|
+
|
|
39
|
+
**Hybrid (selected):** Named type alias `SteerRegistry` in `workflow-runner.ts` + no new file. Satisfies 'explicit domain types' at low cost. Disposer lambda in finally block satisfies lifecycle safety. Three trivial operations (set, delete, call) don't warrant a class.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Philosophy Alignment
|
|
44
|
+
|
|
45
|
+
**Strongly satisfied:**
|
|
46
|
+
- DI for boundaries (registry injected as parameter)
|
|
47
|
+
- Errors as data (HTTP returns typed shape; no exceptions)
|
|
48
|
+
- Validate at boundaries (400/503/404 before hitting registry)
|
|
49
|
+
- YAGNI (no auth, no waitForCoordinator, no crash recovery -- all documented)
|
|
50
|
+
|
|
51
|
+
**Acceptable tension:**
|
|
52
|
+
- 'Prefer explicit domain types' -- type alias satisfies naming without full class
|
|
53
|
+
- 'Make illegal states unrepresentable' -- structurally possible to pass registry to standalone console, but mitigated by explicit `undefined` + comment
|
|
54
|
+
- 'Immutability by default' -- mutable array bounded behind two explicit write paths
|
|
55
|
+
|
|
56
|
+
**No conflicts.**
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Findings
|
|
61
|
+
|
|
62
|
+
### GREEN (no blocking issues)
|
|
63
|
+
|
|
64
|
+
The selected design (hybrid: type alias + parameter injection) satisfies all acceptance criteria, resolves all identified tensions, and has no unaddressed failure modes.
|
|
65
|
+
|
|
66
|
+
### ORANGE (significant -- address before or during implementation)
|
|
67
|
+
|
|
68
|
+
**O1: TriggerRouter constructor must receive `steerRegistry`.**
|
|
69
|
+
`TriggerRouter.dispatch()` calls `this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter)`. The steer registry must be passed here too, or daemon-triggered sessions cannot be steered. Solution: add `private readonly steerRegistry?: SteerRegistry` to TriggerRouter constructor; pass as 6th arg to `runWorkflowFn()` calls. Update `RunWorkflowFn` type to include optional `steerRegistry` param.
|
|
70
|
+
|
|
71
|
+
**O2: `runWorkflow()` registration window must be explicit in code comments.**
|
|
72
|
+
The registration gap (~50ms after `executeStartWorkflow()` returns) should be documented with a comment in `runWorkflow()` near the `steerRegistry?.set()` call, explaining that coordinators calling the endpoint immediately after session creation should retry once on 404.
|
|
73
|
+
|
|
74
|
+
### YELLOW (low risk -- watch during implementation)
|
|
75
|
+
|
|
76
|
+
**Y1: `pendingSteerParts` clearing semantics.**
|
|
77
|
+
The drain in turn_end should assign `pendingSteerParts = []` (reassign) rather than `pendingSteerParts.length = 0` (mutate-in-place). Either works (JS is single-threaded), but reassignment is more obviously immutable at the read site. Note: the steer callback closes over `pendingSteerParts` by reference to the variable, so if we reassign the variable, the closure's reference becomes stale. Solution: use `pendingSteerParts.length = 0` (truncate in-place) OR close over a container object. Prefer: `pendingSteerParts.splice(0)` (splice to empty, returns removed elements). Alternatively, declare with `const pendingSteerParts: string[] = []` and use `splice(0)` to drain.
|
|
78
|
+
|
|
79
|
+
**Y2: Ordering of parts in the joined steer message.**
|
|
80
|
+
Step text (from `onAdvance()`) should appear first, coordinator text second. This is the natural order since `onAdvance()` fires first (inside `makeContinueWorkflowTool`), then the steer callback fires from the HTTP handler (which arrives as a later event loop callback). Verify this ordering is preserved after the `pendingSteerText` rename.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Recommended Revisions
|
|
85
|
+
|
|
86
|
+
1. **Implement hybrid approach:** Named type alias `export type SteerRegistry = Map<string, (text: string) => void>` in `workflow-runner.ts`. No new file. Import alias in `console-routes.ts` and `daemon-console.ts`.
|
|
87
|
+
|
|
88
|
+
2. **Extend `RunWorkflowFn` type** in `trigger-router.ts` to include optional `steerRegistry?: SteerRegistry` as 6th param. Update both `route()` and `dispatch()` call sites. Add `steerRegistry` to `TriggerRouter` constructor.
|
|
89
|
+
|
|
90
|
+
3. **Fix `pendingSteerText` -> `pendingSteerParts`** (R1 from prior review). Use `const pendingSteerParts: string[] = []`. Drain with `pendingSteerParts.splice(0)` in turn_end subscriber (splices all items out, returns them for joining).
|
|
91
|
+
|
|
92
|
+
4. **Registration gap comment**: in `runWorkflow()` near `steerRegistry?.set(workrailSessionId, ...)`, add a comment: "Registration gap: the HTTP endpoint returns 404 for ~50ms between session creation and this call. Coordinators should retry once on 404 during session start-up."
|
|
93
|
+
|
|
94
|
+
5. **Endpoint response shape**: return `{ success: true }` on 200 (no data needed; steer is fire-and-forget from coordinator's perspective).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Residual Concerns
|
|
99
|
+
|
|
100
|
+
1. **MCP-mode injection remains deferred.** The steer endpoint is daemon-only. If MCP sessions ever become steer-able, the registry abstraction extends cleanly -- just wire the MCP session's AgentLoop to its own steer callback. Document this explicitly with a TODO comment in the endpoint.
|
|
101
|
+
|
|
102
|
+
2. **`report_issue` coexistence (Y3 from prior review).** The `signal_coordinator` tool (if added later) and `report_issue` partially overlap. This endpoint does not resolve that overlap. Track separately.
|
|
103
|
+
|
|
104
|
+
3. **The steer text is unstructured.** The coordinator pushes arbitrary `text: string`. There is no schema for what the text should contain. For v1 this is fine (coordinator constructs the text); for v2, a structured `{ role, content, signal_id }` shape would enable better audit tracing.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Implementation Plan: POST /api/v2/sessions/:sessionId/steer
|
|
2
|
+
|
|
3
|
+
**Branch:** `feat/session-steer-endpoint`
|
|
4
|
+
**Confidence:** High
|
|
5
|
+
**PR count:** 1
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem Statement
|
|
10
|
+
|
|
11
|
+
A coordinator script needs to inject text into an agent's next turn during a running daemon session.
|
|
12
|
+
The `steer()` mechanism in `AgentLoop` already delivers injected text. The gap is a bridge between
|
|
13
|
+
an HTTP endpoint and the closure-scoped `pendingSteerText` variable in `runWorkflow()`.
|
|
14
|
+
|
|
15
|
+
Additionally, the existing `pendingSteerText: string | null` is a single-value field that silently
|
|
16
|
+
drops coordinator steers when overwritten by `onAdvance()`. This must be fixed first (R1 finding).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Acceptance Criteria
|
|
21
|
+
|
|
22
|
+
1. `POST /api/v2/sessions/:sessionId/steer` with body `{ "text": "..." }` returns HTTP 200
|
|
23
|
+
`{ "success": true }` when the sessionId belongs to an active daemon session.
|
|
24
|
+
2. The injected text is delivered to the agent via `agent.steer()` on the next `turn_end` event,
|
|
25
|
+
concatenated after the step text from `onAdvance()`.
|
|
26
|
+
3. Returns HTTP 404 `{ "success": false, "error": "Session not found or not a daemon session" }`
|
|
27
|
+
when the sessionId is not in the registry.
|
|
28
|
+
4. Returns HTTP 503 `{ "success": false, "error": "Steer not available..." }` in standalone console
|
|
29
|
+
mode (no steerRegistry injected).
|
|
30
|
+
5. Returns HTTP 400 for missing or non-string `text` body.
|
|
31
|
+
6. Multiple calls to the endpoint between `turn_end` events: all injected texts are delivered in the
|
|
32
|
+
same steer message, joined with `\n\n`.
|
|
33
|
+
7. After the session completes, calling the endpoint returns 404.
|
|
34
|
+
8. The existing `pendingSteerText` variable is replaced by `pendingSteerParts: string[]` and
|
|
35
|
+
`onAdvance()` behavior is unchanged from the caller's perspective (step advance still works).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Non-Goals
|
|
40
|
+
|
|
41
|
+
- Auth token on the endpoint (v1 is localhost-only, network binding is the security layer)
|
|
42
|
+
- `waitForCoordinator` blocking gate mechanism (Phase 2B, separate task)
|
|
43
|
+
- `wr.coordinator_signal` artifact schema (Phase A, separate task)
|
|
44
|
+
- MCP-mode injection (deferred to v2)
|
|
45
|
+
- Crash recovery for in-flight steers (in-memory only, v1 known limitation)
|
|
46
|
+
- Structured request body beyond `{ text: string }` (v2 concern)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Philosophy-Driven Constraints
|
|
51
|
+
|
|
52
|
+
- **DI for boundaries**: `SteerRegistry` must be injected into `mountConsoleRoutes()` and
|
|
53
|
+
`runWorkflow()`. No module-level singletons.
|
|
54
|
+
- **Errors as data**: HTTP responses use `{ success: bool, error?: string }` shape. No thrown
|
|
55
|
+
exceptions at the route level.
|
|
56
|
+
- **Validate at boundaries**: 400 for invalid body, 503 for disabled, 404 for not-found -- all
|
|
57
|
+
checked before touching the registry.
|
|
58
|
+
- **YAGNI**: Only what's listed in acceptance criteria. No speculative extension points.
|
|
59
|
+
- **Explicit domain types**: Named type alias `SteerRegistry` (not raw `Map<string, fn>` literal).
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Invariants
|
|
64
|
+
|
|
65
|
+
1. `pendingSteerParts` is only mutated in two places: `onAdvance()` (push step text) and the steer
|
|
66
|
+
callback registered in the `SteerRegistry` (push coordinator text). No other writer.
|
|
67
|
+
2. `pendingSteerParts` is only read and drained in the `turn_end` subscriber. Single reader.
|
|
68
|
+
3. JavaScript single-threaded event loop: no race between push and drain.
|
|
69
|
+
4. The steer callback is registered after `workrailSessionId` is decoded from the continueToken,
|
|
70
|
+
and deregistered in `runWorkflow()`'s `finally` block. No stale entries possible.
|
|
71
|
+
5. The endpoint is only active when `steerRegistry` is provided to `mountConsoleRoutes()`.
|
|
72
|
+
The standalone console does not provide it.
|
|
73
|
+
6. The `steerRegistry` param is optional on all functions. No existing callers are broken.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Selected Approach
|
|
78
|
+
|
|
79
|
+
**Hybrid (type alias + parameter injection):**
|
|
80
|
+
- Named type alias `export type SteerRegistry = Map<string, (text: string) => void>` in
|
|
81
|
+
`src/daemon/workflow-runner.ts`.
|
|
82
|
+
- `pendingSteerText: string | null` replaced by `const pendingSteerParts: string[] = []`.
|
|
83
|
+
- `onAdvance()` uses `pendingSteerParts.push(stepText)`.
|
|
84
|
+
- turn_end subscriber drains with `const parts = pendingSteerParts.splice(0)` and calls
|
|
85
|
+
`agent.steer(buildUserMessage(parts.join('\n\n')))` if `parts.length > 0`.
|
|
86
|
+
- `runWorkflow()` gains optional `steerRegistry?: SteerRegistry` param. After workrailSessionId
|
|
87
|
+
is decoded, calls `steerRegistry?.set(workrailSessionId, (text) => pendingSteerParts.push(text))`.
|
|
88
|
+
In `finally`: `steerRegistry?.delete(workrailSessionId)`.
|
|
89
|
+
- `mountConsoleRoutes()` gains optional `steerRegistry?: SteerRegistry` param after `triggerRouter`.
|
|
90
|
+
- `POST /api/v2/sessions/:sessionId/steer` endpoint added in `console-routes.ts`.
|
|
91
|
+
- `TriggerRouter` constructor gains optional `steerRegistry?: SteerRegistry`; passes to
|
|
92
|
+
`runWorkflowFn()` calls in `route()` and `dispatch()`.
|
|
93
|
+
- `RunWorkflowFn` type in `trigger-router.ts` extended with optional 6th param.
|
|
94
|
+
|
|
95
|
+
**Runner-Up:** C2 (SteerRegistry class). Loses only for having a new file for 3 trivial operations.
|
|
96
|
+
Use if the registry gains additional methods or needs isolated unit tests.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Vertical Slices
|
|
101
|
+
|
|
102
|
+
### Slice 1: Fix `pendingSteerText` -> `pendingSteerParts` (R1 prerequisite)
|
|
103
|
+
|
|
104
|
+
**Files:** `src/daemon/workflow-runner.ts` only.
|
|
105
|
+
|
|
106
|
+
**Change:**
|
|
107
|
+
- Rename `pendingSteerText: string | null` to `const pendingSteerParts: string[] = []`.
|
|
108
|
+
- Update `onAdvance()`: `pendingSteerText = stepText` -> `pendingSteerParts.push(stepText)`.
|
|
109
|
+
- Update turn_end subscriber drain:
|
|
110
|
+
- Before: `if (pendingSteerText !== null && !isComplete) { ... }`
|
|
111
|
+
- After: `if (!isComplete) { const parts = pendingSteerParts.splice(0); if (parts.length > 0) { agent.steer(buildUserMessage(parts.join('\n\n'))); } }`
|
|
112
|
+
- Note: `isComplete` guard moves outside the `splice(0)` -- drain always happens, steer only if not complete and parts non-empty.
|
|
113
|
+
|
|
114
|
+
**Acceptance:** Existing behavior unchanged. Session still receives step text on each advance.
|
|
115
|
+
No coordinator injection yet. Build+type-check passes. Existing tests pass.
|
|
116
|
+
|
|
117
|
+
**Risk:** Low. Pure refactor, no behavior change from external perspective.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### Slice 2: SteerRegistry type alias + runWorkflow() registration
|
|
122
|
+
|
|
123
|
+
**Files:** `src/daemon/workflow-runner.ts`.
|
|
124
|
+
|
|
125
|
+
**Change:**
|
|
126
|
+
- Add: `export type SteerRegistry = Map<string, (text: string) => void>;`
|
|
127
|
+
- Add optional param to `runWorkflow()`: `steerRegistry?: SteerRegistry`
|
|
128
|
+
- After `workrailSessionId` is decoded (line ~2190): register callback:
|
|
129
|
+
```typescript
|
|
130
|
+
if (steerRegistry && workrailSessionId) {
|
|
131
|
+
steerRegistry.set(workrailSessionId, (text: string) => { pendingSteerParts.push(text); });
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
- In `finally` block: `if (steerRegistry && workrailSessionId) { steerRegistry.delete(workrailSessionId); }`
|
|
135
|
+
- Add code comment on the `set()` call documenting the registration gap.
|
|
136
|
+
|
|
137
|
+
**Acceptance:** `runWorkflow()` compiles with new optional param. Existing callers unchanged.
|
|
138
|
+
Manual test: if a steerRegistry Map is passed and a callback is registered/called during a session,
|
|
139
|
+
text is pushed to `pendingSteerParts`.
|
|
140
|
+
|
|
141
|
+
**Risk:** Low. Additive change, no behavior change when `steerRegistry` is undefined.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Slice 3: TriggerRouter wiring
|
|
146
|
+
|
|
147
|
+
**Files:** `src/trigger/trigger-router.ts`.
|
|
148
|
+
|
|
149
|
+
**Change:**
|
|
150
|
+
- Import `SteerRegistry` from `workflow-runner.js`.
|
|
151
|
+
- Extend `RunWorkflowFn` type with optional 6th param:
|
|
152
|
+
`steerRegistry?: SteerRegistry` after `emitter?`.
|
|
153
|
+
- Add `private readonly steerRegistry?: SteerRegistry` to `TriggerRouter`.
|
|
154
|
+
- Add `steerRegistry?: SteerRegistry` to TriggerRouter constructor params.
|
|
155
|
+
- Assign in constructor: `this.steerRegistry = steerRegistry`.
|
|
156
|
+
- Update `route()` call: `this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry)`
|
|
157
|
+
- Update `dispatch()` call: same.
|
|
158
|
+
|
|
159
|
+
**Acceptance:** TriggerRouter compiles. `runWorkflow` in production TriggerRouter path passes
|
|
160
|
+
steerRegistry to the agent loop. Existing trigger tests unaffected (registry is optional).
|
|
161
|
+
|
|
162
|
+
**Risk:** Low. Additive param. All existing test calls to `TriggerRouter` pass `undefined` or
|
|
163
|
+
omit the param.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### Slice 4: HTTP endpoint in console-routes.ts
|
|
168
|
+
|
|
169
|
+
**Files:** `src/v2/usecases/console-routes.ts`.
|
|
170
|
+
|
|
171
|
+
**Change:**
|
|
172
|
+
- Import `SteerRegistry` from `../../daemon/workflow-runner.js`.
|
|
173
|
+
- Add `steerRegistry?: SteerRegistry` to `mountConsoleRoutes()` param list (after `triggerRouter`).
|
|
174
|
+
- Add endpoint after the `POST /api/v2/auto/dispatch` block:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// POST /api/v2/sessions/:sessionId/steer
|
|
178
|
+
// Injects text into a running daemon session's next agent turn.
|
|
179
|
+
// Daemon-only: requires steerRegistry to be provided at server startup.
|
|
180
|
+
// Auth: localhost-only (127.0.0.1 binding). No token auth in v1.
|
|
181
|
+
// TODO(v2): Add token auth before any multi-user or remote deployment.
|
|
182
|
+
app.post('/api/v2/sessions/:sessionId/steer', express.json(), (req: Request, res: Response) => {
|
|
183
|
+
if (!steerRegistry) {
|
|
184
|
+
res.status(503).json({ success: false, error: 'Steer not available (not a daemon context).' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { sessionId } = req.params;
|
|
188
|
+
const body = req.body as { text?: unknown };
|
|
189
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
190
|
+
if (!text) {
|
|
191
|
+
res.status(400).json({ success: false, error: 'text is required and must be a non-empty string.' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const callback = steerRegistry.get(sessionId);
|
|
195
|
+
if (!callback) {
|
|
196
|
+
res.status(404).json({ success: false, error: 'Session not found or not a daemon session.' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
callback(text);
|
|
200
|
+
res.json({ success: true });
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Acceptance:** All 5 HTTP response cases work correctly (200, 400, 404, 503). Session receives
|
|
205
|
+
injected text on next turn_end. Standalone console returns 503.
|
|
206
|
+
|
|
207
|
+
**Risk:** Low. New endpoint, no changes to existing routes.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Slice 5: Daemon wiring in daemon-console.ts
|
|
212
|
+
|
|
213
|
+
**Files:** `src/trigger/daemon-console.ts`.
|
|
214
|
+
|
|
215
|
+
**Change:**
|
|
216
|
+
- Import `SteerRegistry` from `../daemon/workflow-runner.js`.
|
|
217
|
+
- Before constructing `TriggerRouter`: `const steerRegistry: SteerRegistry = new Map();`
|
|
218
|
+
- Pass to `TriggerRouter` constructor: `new TriggerRouter(index, ctx, apiKey, runWorkflow, execFn, ..., steerRegistry)`
|
|
219
|
+
- Pass to `mountConsoleRoutes()`: add `steerRegistry` as the last argument (after `triggerRouter`).
|
|
220
|
+
- Also update the direct `runWorkflow()` call in `console-routes.ts` `POST /auto/dispatch` path
|
|
221
|
+
(when `triggerRouter` is absent): pass `steerRegistry` as 6th arg.
|
|
222
|
+
|
|
223
|
+
**Acceptance:** End-to-end: daemon starts, `POST /auto/dispatch` creates a session, coordinator
|
|
224
|
+
calls `POST /sessions/:id/steer`, agent receives injected text on next turn.
|
|
225
|
+
|
|
226
|
+
**Risk:** Medium. This is the wiring step that connects all slices. Most likely source of missed
|
|
227
|
+
call sites.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Test Design
|
|
232
|
+
|
|
233
|
+
### Unit tests (workflow-runner.ts)
|
|
234
|
+
- Test that `onAdvance()` pushes to `pendingSteerParts`.
|
|
235
|
+
- Test that turn_end subscriber joins and steers when `pendingSteerParts.length > 0`.
|
|
236
|
+
- Test that multiple pushes (simulate both `onAdvance` and steer callback) produce joined text.
|
|
237
|
+
- Test that `steerRegistry.set()` is called after workrailSessionId decoded.
|
|
238
|
+
- Test that `steerRegistry.delete()` is called in finally (mock registry, verify delete).
|
|
239
|
+
|
|
240
|
+
### Integration test (console-routes.ts)
|
|
241
|
+
- Mock `steerRegistry` with a Map. POST to endpoint with valid body -> 200, callback called.
|
|
242
|
+
- POST with empty body -> 400.
|
|
243
|
+
- POST with unknown sessionId -> 404.
|
|
244
|
+
- POST without steerRegistry injected -> 503.
|
|
245
|
+
|
|
246
|
+
### Regression: existing tests
|
|
247
|
+
- All existing `runWorkflow()` tests must pass unchanged (optional param, default undefined).
|
|
248
|
+
- All existing `mountConsoleRoutes()` tests must pass unchanged.
|
|
249
|
+
- All existing TriggerRouter tests must pass unchanged.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Risk Register
|
|
254
|
+
|
|
255
|
+
| Risk | Likelihood | Impact | Mitigation |
|
|
256
|
+
|---|---|---|---|
|
|
257
|
+
| Missed call site for steerRegistry in dispatch/route | Medium | High | Search for all `runWorkflowFn(` calls in trigger-router.ts before submitting |
|
|
258
|
+
| `pendingSteerParts.splice(0)` stale closure ref | Low | High | splice(0) mutates in-place; closure over array variable (not array contents) is safe |
|
|
259
|
+
| Registration gap causes 404 for early steers | Very low | Low | Document with code comment; coordinator retries on 404 |
|
|
260
|
+
| `mountConsoleRoutes` callers not updated | Low | Medium | Only 3 callers: daemon-console.ts, standalone-console.ts (no change), console-routes.ts |
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## PR Packaging Strategy
|
|
265
|
+
|
|
266
|
+
Single PR on branch `feat/session-steer-endpoint`. All 5 slices together. The R1 fix (Slice 1) is
|
|
267
|
+
small enough that it doesn't need its own PR. The endpoint is only usable when all slices are present.
|
|
268
|
+
|
|
269
|
+
PR title: `feat(console): add POST /api/v2/sessions/:sessionId/steer for coordinator injection`
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Philosophy Alignment Per Slice
|
|
274
|
+
|
|
275
|
+
| Slice | Principle | Status |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| S1 pendingSteerParts | Immutability by default | Tension (mutable array) -- acceptable, mutation bounded |
|
|
278
|
+
| S1 pendingSteerParts | Compose with small pure functions | Satisfied -- drain is one expression |
|
|
279
|
+
| S2 SteerRegistry type | Explicit domain types | Satisfied -- named alias |
|
|
280
|
+
| S2 runWorkflow registration | DI for boundaries | Satisfied -- injected, not global |
|
|
281
|
+
| S3 TriggerRouter | Make illegal states unrepresentable | Satisfied -- optional param can't be confused with required |
|
|
282
|
+
| S4 HTTP endpoint | Validate at boundaries | Satisfied -- 400/503/404 before touching registry |
|
|
283
|
+
| S4 HTTP endpoint | Errors as data | Satisfied -- { success: bool } shape |
|
|
284
|
+
| S5 daemon wiring | YAGNI | Satisfied -- single Map, no extra abstraction |
|