@ryanfw/prompt-orchestration-pipeline 1.2.12 → 1.3.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.
- package/README.md +34 -0
- package/docs/http-api.md +240 -0
- package/docs/pop-task-guide.md +120 -1
- package/package.json +7 -2
- package/src/cli/__tests__/index.test.ts +30 -0
- package/src/cli/index.ts +26 -5
- package/src/config/__tests__/models.test.ts +18 -4
- package/src/config/__tests__/statuses.test.ts +49 -2
- package/src/config/models.ts +12 -1
- package/src/config/statuses.ts +23 -2
- package/src/core/__tests__/agent-step.test.ts +446 -0
- package/src/core/__tests__/control.test.ts +241 -0
- package/src/core/__tests__/pipeline-runner.test.ts +214 -0
- package/src/core/__tests__/run-events.test.ts +63 -0
- package/src/core/__tests__/task-runner.test.ts +48 -0
- package/src/core/__tests__/validation.test.ts +168 -0
- package/src/core/agent-step.ts +210 -0
- package/src/core/agent-types.ts +58 -0
- package/src/core/control.ts +299 -0
- package/src/core/job-concurrency.ts +1 -1
- package/src/core/object-utils.ts +9 -0
- package/src/core/orchestrator.ts +27 -6
- package/src/core/pipeline-definition.ts +55 -0
- package/src/core/pipeline-runner.ts +587 -67
- package/src/core/run-events.ts +19 -0
- package/src/core/status-writer.ts +15 -1
- package/src/core/task-runner.ts +19 -0
- package/src/core/validation.ts +243 -18
- package/src/harness/__tests__/discovery.test.ts +183 -0
- package/src/harness/__tests__/mcp-io-server.test.ts +178 -0
- package/src/harness/__tests__/subprocess.test.ts +61 -0
- package/src/harness/discovery.ts +99 -0
- package/src/harness/index.ts +22 -0
- package/src/harness/mcp-io-server.ts +166 -0
- package/src/harness/subprocess.ts +83 -0
- package/src/llm/__tests__/index.test.ts +202 -0
- package/src/llm/index.ts +7 -0
- package/src/providers/__tests__/base.test.ts +6 -0
- package/src/providers/__tests__/opencode.test.ts +1359 -0
- package/src/providers/__tests__/types.test.ts +79 -1
- package/src/providers/base.ts +1 -1
- package/src/providers/opencode.ts +563 -0
- package/src/providers/types.ts +57 -0
- package/src/ui/client/__tests__/api.test.ts +19 -1
- package/src/ui/client/__tests__/job-adapter.test.ts +55 -0
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +40 -0
- package/src/ui/client/adapters/job-adapter.ts +33 -3
- package/src/ui/client/api.ts +14 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +47 -3
- package/src/ui/client/hooks/useJobListWithUpdates.ts +1 -0
- package/src/ui/client/types.ts +25 -3
- package/src/ui/components/DAGGrid.tsx +32 -11
- package/src/ui/components/JobCard.tsx +17 -2
- package/src/ui/components/JobDetail.tsx +93 -9
- package/src/ui/components/JobTable.tsx +1 -0
- package/src/ui/components/PipelineDAGGrid.tsx +17 -2
- package/src/ui/components/__tests__/DAGGrid.test.tsx +37 -0
- package/src/ui/components/__tests__/JobCard.test.tsx +47 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +80 -3
- package/src/ui/components/__tests__/JobTable.test.tsx +10 -0
- package/src/ui/components/__tests__/types.test.ts +8 -3
- package/src/ui/components/types.ts +13 -1
- package/src/ui/dist/assets/{index-HrBsHfx3.js → index-CbS3OsW7.js} +579 -168
- package/src/ui/dist/assets/index-CbS3OsW7.js.map +1 -0
- package/src/ui/dist/assets/style-BUFg3Sth.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/Code.tsx +232 -3
- package/src/ui/server/__tests__/cors.test.ts +148 -0
- package/src/ui/server/__tests__/gate-endpoints.test.ts +210 -0
- package/src/ui/server/__tests__/http-api-doc.test.ts +48 -0
- package/src/ui/server/__tests__/index.test.ts +29 -1
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +273 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +124 -0
- package/src/ui/server/__tests__/router.test.ts +224 -2
- package/src/ui/server/cors.ts +83 -0
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +29 -0
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/gate-endpoints.ts +124 -0
- package/src/ui/server/endpoints/job-control-endpoints.ts +98 -11
- package/src/ui/server/endpoints/job-endpoints.ts +28 -12
- package/src/ui/server/endpoints/meta-endpoint.ts +11 -0
- package/src/ui/server/index.ts +6 -1
- package/src/ui/server/router.ts +49 -3
- package/src/ui/state/__tests__/types.test.ts +1 -0
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +15 -0
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +56 -0
- package/src/ui/state/transformers/list-transformer.ts +3 -0
- package/src/ui/state/transformers/status-transformer.ts +32 -4
- package/src/ui/state/types.ts +20 -4
- package/src/utils/jobs.ts +1 -1
- package/src/ui/dist/assets/index-HrBsHfx3.js.map +0 -1
- package/src/ui/dist/assets/style-BKG0bHu-.css +0 -2
package/README.md
CHANGED
|
@@ -24,11 +24,13 @@ This framework is designed for AI Engineers and Systems Integrators who need to
|
|
|
24
24
|
Running long-duration AI tasks locally can be fragile. A single script crash or API timeout can waste hours of execution and dollars in token costs.
|
|
25
25
|
* **Process Isolation**: Every pipeline runs in its own dedicated child process. If one agent crashes, your orchestrator stays alive.
|
|
26
26
|
* **Resumability**: Pause, stop, and resume jobs from any specific task. Fix a bug in step 5 and restart exactly where you left off.
|
|
27
|
+
* **Run Control**: Tasks can pause for human approval, skip downstream work, or add follow-up tasks to the current run.
|
|
27
28
|
* **Atomic State**: Every stage transition is saved to disk instantly. You never lose progress.
|
|
28
29
|
|
|
29
30
|
### 2. Gain Radical Observability
|
|
30
31
|
"Black box" agents are impossible to debug. POP provides deep visibility into the "thought process" of your pipelines.
|
|
31
32
|
* **Real-Time Dashboard**: Watch jobs progress stage-by-stage via a built-in UI (Server-Sent Events).
|
|
33
|
+
* **Gate Decisions**: Approve or reject waiting jobs directly from the job detail view.
|
|
32
34
|
* **Granular Logging**: Every input, output, and internal thought is captured in dedicated log files.
|
|
33
35
|
* **Cost Tracking**: See exact token usage and cost breakdown for every task and model call.
|
|
34
36
|
|
|
@@ -48,6 +50,7 @@ Switch models globally or per-task without rewriting your logic.
|
|
|
48
50
|
* **Moonshot** (Kimi)
|
|
49
51
|
* **Zhipu** (GLM-4)
|
|
50
52
|
* **Claude Code** (CLI integration)
|
|
53
|
+
* **CLI Agents**: Tasks can also drive tool-using CLI coding agents (Claude, Codex, OpenCode) via the injected `runAgent()` helper — for file-aware, multi-turn work alongside single LLM calls. See the [Task Development Guide](docs/pop-task-guide.md#agent-api).
|
|
51
54
|
|
|
52
55
|
---
|
|
53
56
|
|
|
@@ -79,6 +82,7 @@ The system comprises three main runtime containers:
|
|
|
79
82
|
Inside the **Pipeline Runner**, the logic is structured into:
|
|
80
83
|
|
|
81
84
|
* **Task Runner**: The engine that drives a specific task (e.g., "Research") through the standardized lifecycle.
|
|
85
|
+
* **Run Control Contract**: A task can write `tasks/{taskName}/control.json` to append tasks, skip pending tasks, or create an approval gate after it succeeds.
|
|
82
86
|
* **LLM Layer**: A unified abstraction for all model providers, handling retries, cost calculation, and normalization.
|
|
83
87
|
* **Symlink Bridge**: A specialized component that ensures every task has deterministic access to `node_modules` and shared utilities, regardless of where it is defined on disk.
|
|
84
88
|
* **Status Writer**: An atomic file-writer that updates `tasks-status.json` safely, preventing data corruption.
|
|
@@ -175,3 +179,33 @@ Drop a JSON file into `pipelines/pipeline-data/pending/`:
|
|
|
175
179
|
```
|
|
176
180
|
|
|
177
181
|
The Orchestrator will pick it up, move it to `current/`, and start processing.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Building a Custom Frontend
|
|
186
|
+
|
|
187
|
+
The orchestrator exposes a documented HTTP/SSE API at `http://localhost:<port>/api/*` that any frontend can consume. See **[HTTP/SSE Protocol Reference](docs/http-api.md)** for the full contract.
|
|
188
|
+
|
|
189
|
+
Custom frontends should run against a configured fixed port, usually the default `4000` or an explicit `--port` value. Treat bind failures or port conflicts as startup failures; the protocol does not provide dynamic port discovery.
|
|
190
|
+
|
|
191
|
+
### Integration Modes
|
|
192
|
+
|
|
193
|
+
**Subprocess mode** (recommended): Run the orchestrator as a child process via `pipeline-orchestrator start`. The orchestrator runs in its own process — if it crashes, your application stays alive. This is the default and recommended approach.
|
|
194
|
+
|
|
195
|
+
**Programmatic mode**: Import `startServer` or `startOrchestrator` directly to embed the server in your process. This is simpler but forfeits process isolation — a crash in the orchestrator will take down your host process.
|
|
196
|
+
|
|
197
|
+
### Cross-Origin Access
|
|
198
|
+
|
|
199
|
+
By default, the server only accepts same-origin requests. To allow a custom frontend on a different origin:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
pipeline-orchestrator start --cors-origins "https://my-app.example,views://mainview"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
For desktop webviews that report `Origin: null`, add `--cors-allow-null-origin`. Prefer allowlisting the concrete origin (e.g. `views://mainview`) when possible.
|
|
206
|
+
|
|
207
|
+
### Protocol Versioning
|
|
208
|
+
|
|
209
|
+
The API follows a client-robustness contract: clients must ignore unknown JSON fields and unknown SSE event types. This means additive changes (new routes, new event types, new fields) are backward-compatible. Breaking changes (removing or renaming fields, changing types) require a `protocolVersion` bump and are documented in `docs/http-api.md`.
|
|
210
|
+
|
|
211
|
+
Capability negotiation is not built. Custom frontends should use the documented protocol and `GET /api/meta` instead of expecting negotiated feature flags or per-client capability exchange.
|
package/docs/http-api.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# HTTP/SSE Protocol Reference
|
|
2
|
+
|
|
3
|
+
This document is the authoritative reference for the Prompt Orchestration Pipeline HTTP API and Server-Sent Events stream. A consumer should be able to build a client from this document alone.
|
|
4
|
+
|
|
5
|
+
## Transport
|
|
6
|
+
|
|
7
|
+
- **Protocol**: HTTP/1.1 + Server-Sent Events (SSE). The API does not use WebSocket.
|
|
8
|
+
- **Default port**: `4000` (configurable via `--port`).
|
|
9
|
+
- **Port contract**: Custom frontends should connect to a configured fixed port. Bind failures or port conflicts are startup failures; the protocol does not provide dynamic port discovery.
|
|
10
|
+
- **Bind address**: `127.0.0.1` (loopback only). The server does not listen on non-loopback interfaces.
|
|
11
|
+
- **Trust model**: localhost. The `Host` header must resolve to a loopback address (`localhost`, `127.0.0.1`, or `[::1]`). Requests with a non-loopback `Host` are rejected with `403 forbidden_host`.
|
|
12
|
+
- **CORS**: Opt-in via `--cors-origins <comma-separated-origins>` and `--cors-allow-null-origin`. When no origins are configured, no `Access-Control-*` headers are emitted. Cross-origin mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) to `/api/*` are rejected before dispatch when the origin is not allowed.
|
|
13
|
+
- **Content type**: All JSON responses use `Content-Type: application/json`. SSE streams use `Content-Type: text/event-stream`.
|
|
14
|
+
|
|
15
|
+
## Response Envelope
|
|
16
|
+
|
|
17
|
+
Every JSON response uses the shape:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{ "ok": true, "data": { ... } }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
or on error:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{ "ok": false, "code": "<error_code>", "message": "<human-readable message>" }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Error Codes
|
|
30
|
+
|
|
31
|
+
| Code | HTTP Status | Meaning |
|
|
32
|
+
|------|-------------|---------|
|
|
33
|
+
| `job_not_found` / `JOB_NOT_FOUND` / `NOT_FOUND` | 404 | Job or resource does not exist |
|
|
34
|
+
| `job_running` / `JOB_RUNNING` | 409 | Job is currently executing |
|
|
35
|
+
| `conflict` / `BAD_REQUEST` (409) | 409 | Action conflicts with current state |
|
|
36
|
+
| `spawn_failed` / `SPAWN_FAILED` | 500 | Failed to spawn a pipeline runner |
|
|
37
|
+
| `task_not_found` / `TASK_NOT_FOUND` | 404 | Task does not exist in the job |
|
|
38
|
+
| `task_not_pending` / `TASK_NOT_PENDING` | 422 | Task is not in a pending state |
|
|
39
|
+
| `dependencies_not_satisfied` / `DEPENDENCIES_NOT_SATISFIED` | 412 | Task dependencies are incomplete |
|
|
40
|
+
| `unsupported_lifecycle` / `UNSUPPORTED_LIFECYCLE` | 501 | Lifecycle action not supported |
|
|
41
|
+
| `concurrency_limit_reached` | 409 | Max concurrent job slots occupied |
|
|
42
|
+
| `unknown_error` | 500 | Unspecified server error |
|
|
43
|
+
| `network_error` | — | Client-side network failure (never from server) |
|
|
44
|
+
| `malformed_response` | — | Client-side parse failure (never from server) |
|
|
45
|
+
| `forbidden_host` | 403 | `Host` header is not loopback |
|
|
46
|
+
| `forbidden_origin` | 403 | Cross-origin mutation rejected |
|
|
47
|
+
| `status_unavailable` | 500 | Job status file unreadable |
|
|
48
|
+
| `no_pending_gate` | 409 | Job has no pending gate decision |
|
|
49
|
+
| `FS_ERROR` | 500 | Filesystem operation failed |
|
|
50
|
+
| `BAD_REQUEST` | 400 | Malformed request body |
|
|
51
|
+
| `NOT_FOUND` | 404 | Route or resource not found |
|
|
52
|
+
|
|
53
|
+
## Routes
|
|
54
|
+
|
|
55
|
+
All routes are prefixed with `/api`. The server also serves a bundled SPA from `/` for non-API paths.
|
|
56
|
+
|
|
57
|
+
### Jobs
|
|
58
|
+
|
|
59
|
+
| Method | Path | Request Body | Response |
|
|
60
|
+
|--------|------|-------------|----------|
|
|
61
|
+
| `GET` | `/api/jobs` | — | `{ ok, data: JobSummary[] }` |
|
|
62
|
+
| `GET` | `/api/jobs/:jobId` | — | `{ ok, data: JobDetail }` |
|
|
63
|
+
| `POST` | `/api/jobs/:jobId/gate` | `{ action: "approve" \| "reject", note?: string }` | `{ ok, jobId, action, spawned }` (202) |
|
|
64
|
+
| `POST` | `/api/jobs/:jobId/restart` | `{ fromTask?: string, singleTask?: boolean, continueAfter?: boolean, options?: { clearTokenUsage?: boolean } }` | `{ ok, message? }` |
|
|
65
|
+
| `POST` | `/api/jobs/:jobId/stop` | — | `{ ok, message? }` |
|
|
66
|
+
| `POST` | `/api/jobs/:jobId/rescan` | — | `{ ok, message? }` |
|
|
67
|
+
| `POST` | `/api/jobs/:jobId/tasks/:taskId/start` | — | `{ ok, message? }` |
|
|
68
|
+
|
|
69
|
+
### Job Files
|
|
70
|
+
|
|
71
|
+
| Method | Path | Query Params | Response |
|
|
72
|
+
|--------|------|-------------|----------|
|
|
73
|
+
| `GET` | `/api/jobs/:jobId/tasks/:taskId/files` | `?type=artifacts\|logs\|tmp` (default: `artifacts`) | `{ ok, data: string[] }` |
|
|
74
|
+
| `GET` | `/api/jobs/:jobId/tasks/:taskId/file` | `?type=...&filename=...` | `{ ok, data: string, mime: string }` |
|
|
75
|
+
|
|
76
|
+
### Pipelines
|
|
77
|
+
|
|
78
|
+
| Method | Path | Request Body | Response |
|
|
79
|
+
|--------|------|-------------|----------|
|
|
80
|
+
| `GET` | `/api/pipelines` | — | `{ ok, data: { slug, name, description }[] }` |
|
|
81
|
+
| `GET` | `/api/pipelines/:slug` | — | `{ ok, data: PipelineDetail }` |
|
|
82
|
+
| `POST` | `/api/pipelines` | `{ name: string, description?: string }` | `{ ok, data: { slug, name, description } }` (201) |
|
|
83
|
+
| `GET` | `/api/pipelines/:slug/artifacts` | — | `{ ok, data: Artifact[] }` |
|
|
84
|
+
| `GET` | `/api/pipelines/:slug/tasks/:taskId/analysis` | — | `{ ok, data: AnalysisResult }` |
|
|
85
|
+
| `GET` | `/api/pipelines/:slug/schemas/:filename` | — | `{ ok, data: string }` (schema content) |
|
|
86
|
+
|
|
87
|
+
### Analysis and Task Planning
|
|
88
|
+
|
|
89
|
+
| Method | Path | Request Body | Response |
|
|
90
|
+
|--------|------|-------------|----------|
|
|
91
|
+
| `POST` | `/api/pipelines/:slug/analyze` | — | SSE stream: `started` → `complete` |
|
|
92
|
+
| `POST` | `/api/ai/task-plan` | — | SSE stream: `started` → `complete` |
|
|
93
|
+
|
|
94
|
+
These endpoints return `Content-Type: text/event-stream` (route-local SSE). See [Route-Local SSE Streams](#route-local-sse-streams).
|
|
95
|
+
|
|
96
|
+
### Tasks
|
|
97
|
+
|
|
98
|
+
| Method | Path | Request Body | Response |
|
|
99
|
+
|--------|------|-------------|----------|
|
|
100
|
+
| `POST` | `/api/tasks/create` | `{ slug: string, taskId: string, content: string }` | `{ ok, data: { slug, taskId } }` (201) |
|
|
101
|
+
|
|
102
|
+
### Upload
|
|
103
|
+
|
|
104
|
+
| Method | Path | Request Body | Response |
|
|
105
|
+
|--------|------|-------------|----------|
|
|
106
|
+
| `POST` | `/api/upload/seed` | JSON seed object or `multipart/form-data` (with optional `.zip`) | `{ ok, data: { jobId } }` (201) |
|
|
107
|
+
|
|
108
|
+
### System
|
|
109
|
+
|
|
110
|
+
| Method | Path | Response |
|
|
111
|
+
|--------|------|----------|
|
|
112
|
+
| `GET` | `/api/state` | `{ ok, data: StateSnapshot }` |
|
|
113
|
+
| `GET` | `/api/meta` | `{ ok, data: { name, version, protocolVersion } }` |
|
|
114
|
+
| `GET` | `/api/concurrency` | `{ ok, data: ConcurrencyStatus }` |
|
|
115
|
+
|
|
116
|
+
#### `/api/meta` Response Fields
|
|
117
|
+
|
|
118
|
+
| Field | Type | Description |
|
|
119
|
+
|-------|------|-------------|
|
|
120
|
+
| `name` | `string` | Package name from `package.json` |
|
|
121
|
+
| `version` | `string` | Package version from `package.json` |
|
|
122
|
+
| `protocolVersion` | `number` | Protocol version integer; bumped only on breaking changes (see [Protocol Versioning](#protocol-versioning)) |
|
|
123
|
+
|
|
124
|
+
#### `/api/concurrency` Response Fields
|
|
125
|
+
|
|
126
|
+
| Field | Type | Description |
|
|
127
|
+
|-------|------|-------------|
|
|
128
|
+
| `limit` | `number` | Max concurrent jobs |
|
|
129
|
+
| `runningCount` | `number` | Currently running jobs |
|
|
130
|
+
| `availableSlots` | `number` | Remaining slot capacity |
|
|
131
|
+
| `queuedCount` | `number` | Queued jobs waiting for a slot |
|
|
132
|
+
| `activeJobs` | `Array<{ jobId, pid, acquiredAt, source }>` | Jobs occupying slots |
|
|
133
|
+
| `queuedJobs` | `Array<{ jobId, queuedAt, name, pipeline }>` | Jobs waiting in queue |
|
|
134
|
+
| `staleSlots` | `Array<{ jobId, reason }>` | Slots that appear stale |
|
|
135
|
+
|
|
136
|
+
### SSE Stream (Global)
|
|
137
|
+
|
|
138
|
+
| Method | Path | Query Params | Response |
|
|
139
|
+
|--------|------|-------------|----------|
|
|
140
|
+
| `GET` | `/api/events` | `?jobId=<id>` (optional filter) | SSE stream |
|
|
141
|
+
| `GET` | `/api/sse` | `?jobId=<id>` (optional filter) | SSE stream (legacy alias) |
|
|
142
|
+
|
|
143
|
+
## SSE Event Stream
|
|
144
|
+
|
|
145
|
+
### Connection
|
|
146
|
+
|
|
147
|
+
Connect to `GET /api/events` (or the legacy `GET /api/sse`). The server responds with:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Content-Type: text/event-stream
|
|
151
|
+
Cache-Control: no-cache
|
|
152
|
+
Connection: keep-alive
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
On connect, the server sends an initial comment: `: connected\n\n`.
|
|
156
|
+
|
|
157
|
+
### Keep-Alive
|
|
158
|
+
|
|
159
|
+
The server sends a keep-alive comment every **8 seconds**:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
: keep-alive
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Clients should treat a gap longer than the keep-alive interval as a potential disconnect.
|
|
166
|
+
|
|
167
|
+
### Event Types
|
|
168
|
+
|
|
169
|
+
| Event | Data Shape | Description |
|
|
170
|
+
|-------|-----------|-------------|
|
|
171
|
+
| `state:change` | `{ jobId, changeType, ... }` | A job's state has changed |
|
|
172
|
+
| `state:summary` | `{ changeCount }` | Summary of accumulated state changes |
|
|
173
|
+
| `job:created` | `JobSummary` | A new job was detected |
|
|
174
|
+
| `job:updated` | `JobSummary` | An existing job was updated |
|
|
175
|
+
| `heartbeat` | `{ ok, timestamp }` | Application-level heartbeat (distinct from the keep-alive comment) |
|
|
176
|
+
|
|
177
|
+
### Job Filtering
|
|
178
|
+
|
|
179
|
+
Pass `?jobId=<id>` to receive only events whose data contains a matching `jobId` field. Without the parameter, all events are received.
|
|
180
|
+
|
|
181
|
+
### SSE Framing
|
|
182
|
+
|
|
183
|
+
Each event follows the standard SSE format:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
event: <type>
|
|
187
|
+
data: <JSON>
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Events are separated by a blank line (`\n\n`).
|
|
192
|
+
|
|
193
|
+
### Route-Local SSE Streams
|
|
194
|
+
|
|
195
|
+
Two endpoints return route-local SSE streams (not from the global `/api/events` stream):
|
|
196
|
+
|
|
197
|
+
**`POST /api/pipelines/:slug/analyze`**
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
event: started
|
|
201
|
+
data: {"slug":"<slug>"}
|
|
202
|
+
|
|
203
|
+
event: complete
|
|
204
|
+
data: {"slug":"<slug>"}
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Returns `409` if an analysis lock is already held for the pipeline.
|
|
209
|
+
|
|
210
|
+
**`POST /api/ai/task-plan`**
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
event: started
|
|
214
|
+
data: {"ok":true,"message":"task planning is not implemented in TypeScript yet"}
|
|
215
|
+
|
|
216
|
+
event: complete
|
|
217
|
+
data: {"ok":true}
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Client Robustness Rules
|
|
222
|
+
|
|
223
|
+
1. **Ignore unknown JSON fields.** When parsing a JSON response, ignore any fields not documented here. New fields may be added in minor versions.
|
|
224
|
+
2. **Ignore unknown SSE event types.** When processing the SSE stream, ignore any event type not documented here. New event types may be added in minor versions.
|
|
225
|
+
|
|
226
|
+
Capability negotiation is not built into this protocol. Clients discover compatibility through the documented contract and `GET /api/meta`, not through negotiated feature flags or per-client capability exchange.
|
|
227
|
+
|
|
228
|
+
## SSE Event Evolution
|
|
229
|
+
|
|
230
|
+
- **Adding a new event type** is a **minor** change (given the robustness rule above).
|
|
231
|
+
- **Changing an existing event's payload shape** (removing or renaming a field, changing a field's type) is a **breaking** change.
|
|
232
|
+
|
|
233
|
+
## Protocol Versioning
|
|
234
|
+
|
|
235
|
+
The `protocolVersion` integer returned by `GET /api/meta` is the protocol's semver indicator:
|
|
236
|
+
|
|
237
|
+
- It is **bumped** only when `docs/http-api.md` is updated for a breaking protocol change.
|
|
238
|
+
- It is **not** bumped for additive changes (new routes, new event types, new response fields) because the robustness rules make those backward-compatible.
|
|
239
|
+
|
|
240
|
+
The version is defined in `src/ui/server/endpoints/meta-endpoint.ts` as `PROTOCOL_VERSION`.
|
package/docs/pop-task-guide.md
CHANGED
|
@@ -192,6 +192,49 @@ const response = await llm.deepseek.chat({
|
|
|
192
192
|
|
|
193
193
|
---
|
|
194
194
|
|
|
195
|
+
## Agent API
|
|
196
|
+
|
|
197
|
+
Available via the `runAgent` function passed to stages. It runs a CLI coding
|
|
198
|
+
agent (the harness adapter) from inside a standard JavaScript task — the same
|
|
199
|
+
machinery behind pipeline `agent:` entries, but callable mid-task with a prompt
|
|
200
|
+
you build programmatically from upstream data.
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
export const inference = async ({ runAgent, io, data, flags }) => {
|
|
204
|
+
const result = await runAgent({
|
|
205
|
+
harness: "claude", // "claude" | "codex" | "opencode"
|
|
206
|
+
prompt: "Read 'context.md', then write a summary to 'summary.md'.",
|
|
207
|
+
// model?: string // optional, passed through to the CLI
|
|
208
|
+
// io?: boolean // default true: bridge POP read/write artifacts
|
|
209
|
+
// timeoutMs?: number // optional wall-clock cap
|
|
210
|
+
// captureDiff?: boolean // capture a git diff as 'agent.patch'
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
throw new Error(`Agent failed: ${result.error}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// result: { ok, finalMessage, artifactsWritten, usage?, costUsd?, sessionId? }
|
|
218
|
+
const summary = await io.readArtifact("summary.md");
|
|
219
|
+
return { output: { summary }, flags };
|
|
220
|
+
};
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
By default (`io` is `true`) the agent shares the task's file I/O: it can call the
|
|
224
|
+
`read_artifact` / `write_artifact` tools to read and write the same artifacts the
|
|
225
|
+
task sees, and its `agent-result.md` is written automatically. Token usage and
|
|
226
|
+
cost flow into the job status like any other LLM call.
|
|
227
|
+
|
|
228
|
+
**`runAgent` vs `llm`**: use `llm.<provider>.chat()` for a single request/response
|
|
229
|
+
LLM call; use `runAgent()` when you need a tool-using CLI agent that reads and
|
|
230
|
+
writes files over multiple turns.
|
|
231
|
+
|
|
232
|
+
**`runAgent` vs an `agent:` pipeline entry**: an `agent:` entry takes a static
|
|
233
|
+
prompt from `pipeline.json`. `runAgent()` lets a JavaScript task compose the
|
|
234
|
+
prompt from seed/stage data and post-process the result in later stages.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
195
238
|
## Validation API
|
|
196
239
|
|
|
197
240
|
Available via `validators` object in stages that need schema validation.
|
|
@@ -236,6 +279,82 @@ Pipeline jobs start from a seed file in `pending/`:
|
|
|
236
279
|
|
|
237
280
|
---
|
|
238
281
|
|
|
282
|
+
## Pipeline Task Entries
|
|
283
|
+
|
|
284
|
+
Pipeline definitions can use simple string entries or object entries. String entries are still the shortest form:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{
|
|
288
|
+
"tasks": ["research", "analysis", "synthesis"]
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Use object entries when multiple run steps should share one task implementation, when a step needs per-entry config, or when a step should pause for review after it succeeds:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"tasks": [
|
|
297
|
+
"plan",
|
|
298
|
+
{ "name": "implement-step-1", "task": "implementation-step", "config": { "step": 1 } },
|
|
299
|
+
{ "name": "review", "gate": { "message": "Approve review before merge" } }
|
|
300
|
+
],
|
|
301
|
+
"taskConfig": {
|
|
302
|
+
"implement-step-1": { "maxAttempts": 2 }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Object entry fields:
|
|
308
|
+
|
|
309
|
+
| Field | Required | Purpose |
|
|
310
|
+
|-------|----------|---------|
|
|
311
|
+
| `name` | Yes | Unique task name for this run. |
|
|
312
|
+
| `task` | No | Task registry key. Defaults to `name`. |
|
|
313
|
+
| `config` | No | Per-entry config merged over `taskConfig[name]`; entry config wins on conflicts. |
|
|
314
|
+
| `gate` | No | `true` or `{ "message": "...", "artifacts": ["..."] }` to pause after this task succeeds. |
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Run Control Files
|
|
319
|
+
|
|
320
|
+
A task can control the rest of its run by writing `control.json` in its task directory before the stage lifecycle returns success. Use `io.getTaskDir()` to locate the directory:
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
export const integration = async ({ io, flags }) => {
|
|
324
|
+
await Bun.write(`${io.getTaskDir()}/control.json`, JSON.stringify({
|
|
325
|
+
patch: {
|
|
326
|
+
add: [
|
|
327
|
+
{ name: "implement-step-2", task: "implementation-step", config: { step: 2 } }
|
|
328
|
+
]
|
|
329
|
+
},
|
|
330
|
+
skip: [
|
|
331
|
+
{ task: "optional-review", reason: "No review required for this run" }
|
|
332
|
+
],
|
|
333
|
+
pause: {
|
|
334
|
+
message: "Approve generated implementation plan",
|
|
335
|
+
artifacts: ["output.json"]
|
|
336
|
+
}
|
|
337
|
+
}, null, 2));
|
|
338
|
+
|
|
339
|
+
return { output: {}, flags };
|
|
340
|
+
};
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Supported directives:
|
|
344
|
+
|
|
345
|
+
| Directive | Purpose |
|
|
346
|
+
|-----------|---------|
|
|
347
|
+
| `patch.add` | Adds new task entries to the current run's per-run `pipeline.json`. |
|
|
348
|
+
| `patch.insertAfter` | Optional insertion point; defaults to the task that wrote the control file. |
|
|
349
|
+
| `skip` | Marks later pending tasks as `skipped`; skipped tasks count as dependency-satisfied. |
|
|
350
|
+
| `pause` | Puts the job in `waiting` and shows Approve gate / Reject gate controls in the UI. |
|
|
351
|
+
|
|
352
|
+
Validation is strict. Invalid JSON, duplicate added names, unknown task registry keys, invalid skip targets, or invalid insertion points fail the emitting task with `ControlValidationError`. These failures happen after task execution succeeds and do not burn task retry attempts.
|
|
353
|
+
|
|
354
|
+
Run mutations are durable but not event-sourced. The source of truth is `tasks-status.json` plus the per-run `pipeline.json`; `events.jsonl` is an append-only audit trail.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
239
358
|
## Context Object Reference
|
|
240
359
|
|
|
241
360
|
Each stage receives:
|
|
@@ -244,6 +363,7 @@ Each stage receives:
|
|
|
244
363
|
{
|
|
245
364
|
io, // File I/O (may be null)
|
|
246
365
|
llm, // LLM client
|
|
366
|
+
runAgent, // Run a CLI agent harness (see Agent API)
|
|
247
367
|
validators, // { validateWithSchema }
|
|
248
368
|
flags, // Control flags
|
|
249
369
|
meta: { taskName, workDir, jobId },
|
|
@@ -316,4 +436,3 @@ Each stage receives:
|
|
|
316
436
|
2. Return `{ output, flags }` from every stage
|
|
317
437
|
3. Custom helper functions are valid JavaScript but will not be called by the pipeline—only use them if called from within a valid stage
|
|
318
438
|
4. Most simple tasks need only: `ingestion` → `promptTemplating` → `inference`
|
|
319
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/ui/server/index.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
12
|
"docs/pop-task-guide.md",
|
|
13
|
+
"docs/http-api.md",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
@@ -68,7 +69,11 @@
|
|
|
68
69
|
"react-syntax-highlighter": "^15.6.1",
|
|
69
70
|
"rehype-highlight": "^7.0.2",
|
|
70
71
|
"remark-gfm": "^4.0.1",
|
|
71
|
-
"tslib": "^2.8.1"
|
|
72
|
+
"tslib": "^2.8.1",
|
|
73
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
74
|
+
"@opencode-ai/sdk": "^1.17.4",
|
|
75
|
+
"zod": "^3.25.0",
|
|
76
|
+
"local-llm-cli-adapter": "github:ryan-mahoney/local-llm-cli-adapter#2ea1aa2d8e8dbe43eb845eb4730b08a02618f476"
|
|
72
77
|
},
|
|
73
78
|
"devDependencies": {
|
|
74
79
|
"@eslint/js": "^9.37.0",
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
handleSubmit,
|
|
12
12
|
parseTaskIndex,
|
|
13
13
|
serializeTaskIndex,
|
|
14
|
+
buildUiCorsEnv,
|
|
15
|
+
program,
|
|
14
16
|
} from "../index.ts";
|
|
17
|
+
import pkg from "../../../package.json";
|
|
15
18
|
|
|
16
19
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
17
20
|
|
|
@@ -418,3 +421,30 @@ describe("serializeTaskIndex", () => {
|
|
|
418
421
|
expect(serializeTaskIndex(map).endsWith("\n")).toBe(true);
|
|
419
422
|
});
|
|
420
423
|
});
|
|
424
|
+
|
|
425
|
+
// ─── buildUiCorsEnv ─────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
describe("buildUiCorsEnv", () => {
|
|
428
|
+
it("maps corsOrigins and corsAllowNullOrigin to env vars", () => {
|
|
429
|
+
const result = buildUiCorsEnv({ port: "4000", corsOrigins: "a,b", corsAllowNullOrigin: true });
|
|
430
|
+
expect(result).toEqual({ PO_CORS_ORIGINS: "a,b", PO_CORS_ALLOW_NULL_ORIGIN: "1" });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("returns empty object when no CORS options provided", () => {
|
|
434
|
+
const result = buildUiCorsEnv({ port: "4000" });
|
|
435
|
+
expect(result).toEqual({});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("maps only corsOrigins when corsAllowNullOrigin is false", () => {
|
|
439
|
+
const result = buildUiCorsEnv({ port: "4000", corsOrigins: "https://app.example" });
|
|
440
|
+
expect(result).toEqual({ PO_CORS_ORIGINS: "https://app.example" });
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ─── version ────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
describe("CLI version", () => {
|
|
447
|
+
it("uses the real CLI program version from package.json (AC-15)", () => {
|
|
448
|
+
expect(program.version()).toBe(pkg.version);
|
|
449
|
+
});
|
|
450
|
+
});
|
package/src/cli/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { updatePipelineJson } from "./update-pipeline-json.ts";
|
|
|
9
9
|
import { analyzeTaskFile } from "./analyze-task.ts";
|
|
10
10
|
import { submitJobWithValidation, PipelineOrchestrator } from "../api/index.ts";
|
|
11
11
|
import type { Registry } from "./types.ts";
|
|
12
|
+
import pkg from "../../package.json";
|
|
12
13
|
|
|
13
14
|
// ─── init ─────────────────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -28,9 +29,24 @@ export async function handleInit(root: string): Promise<void> {
|
|
|
28
29
|
|
|
29
30
|
// ─── start ────────────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
32
|
+
export interface StartOptions {
|
|
33
|
+
root?: string;
|
|
34
|
+
port: string;
|
|
35
|
+
corsOrigins?: string;
|
|
36
|
+
corsAllowNullOrigin?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildUiCorsEnv(opts: StartOptions): Record<string, string> {
|
|
40
|
+
const env: Record<string, string> = {};
|
|
41
|
+
if (opts.corsOrigins) env["PO_CORS_ORIGINS"] = opts.corsOrigins;
|
|
42
|
+
if (opts.corsAllowNullOrigin) env["PO_CORS_ALLOW_NULL_ORIGIN"] = "1";
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
export async function handleStart(
|
|
32
47
|
root: string | undefined,
|
|
33
|
-
port: string
|
|
48
|
+
port: string,
|
|
49
|
+
opts?: StartOptions,
|
|
34
50
|
): Promise<void> {
|
|
35
51
|
const rawRoot = root ?? process.env["PO_ROOT"];
|
|
36
52
|
if (!rawRoot) {
|
|
@@ -60,6 +76,9 @@ export async function handleStart(
|
|
|
60
76
|
);
|
|
61
77
|
delete uiEnv["PO_UI_PORT"];
|
|
62
78
|
|
|
79
|
+
const corsEnv = opts ? buildUiCorsEnv(opts) : {};
|
|
80
|
+
Object.assign(uiEnv, corsEnv);
|
|
81
|
+
|
|
63
82
|
const orchEnv: Record<string, string> = Object.fromEntries(
|
|
64
83
|
Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot }).filter(
|
|
65
84
|
(entry): entry is [string, string] => entry[1] !== undefined
|
|
@@ -408,12 +427,12 @@ async function handleRunJob(jobId: string): Promise<void> {
|
|
|
408
427
|
|
|
409
428
|
// ─── Commander program ────────────────────────────────────────────────────────
|
|
410
429
|
|
|
411
|
-
const program = new Command();
|
|
430
|
+
export const program = new Command();
|
|
412
431
|
|
|
413
432
|
program
|
|
414
433
|
.name("pipeline-orchestrator")
|
|
415
434
|
.description("Prompt Orchestration Pipeline CLI")
|
|
416
|
-
.version(
|
|
435
|
+
.version(pkg.version);
|
|
417
436
|
|
|
418
437
|
program
|
|
419
438
|
.command("init")
|
|
@@ -428,8 +447,10 @@ program
|
|
|
428
447
|
.description("Start the UI server and orchestrator")
|
|
429
448
|
.option("--root <path>", "Root directory")
|
|
430
449
|
.option("--port <port>", "UI server port", "4000")
|
|
431
|
-
.
|
|
432
|
-
|
|
450
|
+
.option("--cors-origins <origins>", "Comma-separated list of allowed CORS origins")
|
|
451
|
+
.option("--cors-allow-null-origin", "Allow requests with Origin: null")
|
|
452
|
+
.action(async (opts: StartOptions) => {
|
|
453
|
+
await handleStart(opts.root, opts.port, opts);
|
|
433
454
|
});
|
|
434
455
|
|
|
435
456
|
program
|
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
} from "../models";
|
|
16
16
|
import type { ModelConfigEntry } from "../models";
|
|
17
17
|
|
|
18
|
-
const MODEL_COUNT =
|
|
19
|
-
const PROVIDER_COUNT =
|
|
18
|
+
const MODEL_COUNT = 51;
|
|
19
|
+
const PROVIDER_COUNT = 9;
|
|
20
20
|
|
|
21
21
|
describe("ModelAlias", () => {
|
|
22
22
|
it(`has exactly ${MODEL_COUNT} entries`, () => {
|
|
@@ -54,6 +54,20 @@ describe("MODEL_CONFIG", () => {
|
|
|
54
54
|
}
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it("opencode:default has zero pricing", () => {
|
|
58
|
+
const config = getModelConfig("opencode:default");
|
|
59
|
+
expect(config).not.toBeNull();
|
|
60
|
+
expect(config!.tokenCostInPerMillion).toBe(0);
|
|
61
|
+
expect(config!.tokenCostOutPerMillion).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("contains exactly one key with the opencode: prefix", () => {
|
|
65
|
+
const opencodeKeys = Object.keys(MODEL_CONFIG).filter((k) =>
|
|
66
|
+
k.startsWith("opencode:"),
|
|
67
|
+
);
|
|
68
|
+
expect(opencodeKeys).toEqual(["opencode:default"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
57
71
|
it("all entries have non-negative costs", () => {
|
|
58
72
|
for (const [alias, entry] of Object.entries(MODEL_CONFIG)) {
|
|
59
73
|
expect(entry.tokenCostInPerMillion).toBeGreaterThanOrEqual(0);
|
|
@@ -91,7 +105,7 @@ describe("VALID_MODEL_ALIASES", () => {
|
|
|
91
105
|
|
|
92
106
|
describe("DEFAULT_MODEL_BY_PROVIDER", () => {
|
|
93
107
|
it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
|
|
94
|
-
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
|
|
108
|
+
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba", "opencode"];
|
|
95
109
|
expect(Object.keys(DEFAULT_MODEL_BY_PROVIDER).length).toBe(PROVIDER_COUNT);
|
|
96
110
|
for (const provider of providers) {
|
|
97
111
|
expect(provider in DEFAULT_MODEL_BY_PROVIDER).toBe(true);
|
|
@@ -245,7 +259,7 @@ describe("FUNCTION_NAME_BY_ALIAS", () => {
|
|
|
245
259
|
|
|
246
260
|
describe("PROVIDER_FUNCTIONS", () => {
|
|
247
261
|
it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
|
|
248
|
-
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
|
|
262
|
+
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba", "opencode"];
|
|
249
263
|
for (const provider of providers) {
|
|
250
264
|
expect(provider in PROVIDER_FUNCTIONS).toBe(true);
|
|
251
265
|
}
|