@nanobpm/nano-workforce 0.26.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/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- package/workers/select-wave/worker.ts +84 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
# nano-workforce — specification (draft)
|
|
2
|
+
|
|
3
|
+
A Nano **Urban app** that drives GitHub pull requests to convergence against an
|
|
4
|
+
automated reviewer (e.g. GitHub Copilot's PR review), one durable, multi-round
|
|
5
|
+
loop per PR. The reviewer-agent is **decoupled**: from this app's point of view
|
|
6
|
+
it is just a BPMN service task with a `taskType` and a job payload. Whether a
|
|
7
|
+
Copilot instance (via `c8ctl nano hire`/`work`), a script, or anything else
|
|
8
|
+
services that job is entirely the worker's concern — this app never names it.
|
|
9
|
+
|
|
10
|
+
Status: **design draft** — decisions below are agreed; domain model + web
|
|
11
|
+
surface are proposed and open for adjustment.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Goal
|
|
16
|
+
|
|
17
|
+
- Submit a PR (web form or webhook) → the app runs a durable convergence loop:
|
|
18
|
+
address the reviewer's comments, push, re-request review, **wait** for the
|
|
19
|
+
next review, repeat until the latest review has nothing actionable.
|
|
20
|
+
- A web UI shows PRs **currently converging** (collapsible detail) and
|
|
21
|
+
**historical converged** PRs, with their round-by-round data.
|
|
22
|
+
- Persist everything in **SQLite**.
|
|
23
|
+
- Handle **escalation**: if the agent needs to ask a question mid-round, pause
|
|
24
|
+
and let a human answer, then resume.
|
|
25
|
+
|
|
26
|
+
## 2. Architecture (decoupled)
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
submit (form / webhook)
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
┌───────────────────┐ review-ready (msg) ┌──────────┐
|
|
33
|
+
│ convergence-loop │◀───────────────────────────────│ poller │
|
|
34
|
+
│ (BPMN) │ escalation-answered (msg)└────┬─────┘
|
|
35
|
+
└─────────┬─────────┘◀───────────────┐ │ polls
|
|
36
|
+
│ senior:pr-review job │ answer POST │ GitHub
|
|
37
|
+
▼ │ ▼
|
|
38
|
+
┌───────────────────┐ ┌──────┴───────┐ ┌────────────┐
|
|
39
|
+
│ decoupled agent │ │ web UI + │ │ SQLite │
|
|
40
|
+
│ (c8ctl nano work) │ │ API routes │────▶│ (app.db) │
|
|
41
|
+
└───────────────────┘ └──────────────┘ └────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Engine**: embedded Nano (the Urban app deploys its BPMN + runs the loop).
|
|
45
|
+
- **Agent**: external worker subscribed to `senior:pr-review`. Short jobs — one
|
|
46
|
+
round then return. It never blocks on the wait.
|
|
47
|
+
- **BPMN owns the durable wait** between rounds (message catch events), so
|
|
48
|
+
agent worker slots and job timeouts are never held hostage to Copilot's reply
|
|
49
|
+
latency.
|
|
50
|
+
- **Poller**: an in-app background loop that watches waiting PRs and publishes
|
|
51
|
+
the `review-ready` message when a new review lands (no GitHub webhook needed;
|
|
52
|
+
works behind NAT).
|
|
53
|
+
|
|
54
|
+
## 3. Repository layout
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
nano-workforce/
|
|
58
|
+
nano.app.json # manifest (ADR 0027): sqlite data, domain types, submit webhook trigger
|
|
59
|
+
main.ts # Deno entrypoint: deploy + start workers + Deno.serve (page runtime + action overrides + poller)
|
|
60
|
+
deno.json
|
|
61
|
+
pages/
|
|
62
|
+
home.page.json # the screen, authored declaratively (ADR 0042 Page Composer)
|
|
63
|
+
scripts/
|
|
64
|
+
purge-db.ts # `deno task purge`: wipe + re-migrate the app db
|
|
65
|
+
resources/
|
|
66
|
+
processes/
|
|
67
|
+
convergence-loop.bpmn # the durable convergence process
|
|
68
|
+
db/
|
|
69
|
+
migrations/
|
|
70
|
+
001_init.sql # sqlite schema
|
|
71
|
+
prompts/
|
|
72
|
+
review-round.md # agent instructions asset (injected into job data)
|
|
73
|
+
components/
|
|
74
|
+
review-round.json # Zeebe element template for the senior:pr-review service task
|
|
75
|
+
SPEC.md # this document
|
|
76
|
+
README.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 4. Convergence process (`convergence-loop.bpmn`)
|
|
80
|
+
|
|
81
|
+
Correlation key for all messages: **`prKey = "<owner>/<repo>#<number>"`** — stable,
|
|
82
|
+
known at submit time, carried as a process variable and stored on the DB row.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
(start: pr-submitted) vars in: { repo, prNumber, prUrl, prKey }
|
|
86
|
+
│
|
|
87
|
+
▼
|
|
88
|
+
[Register PR] (script/handler) → insert DB row; round = 1
|
|
89
|
+
│ (base prompt delivered via the {{review-round}} model
|
|
90
|
+
│ template header, not a process variable)
|
|
91
|
+
▼
|
|
92
|
+
┌──▶ [Review round] (service task, taskType: senior:pr-review)
|
|
93
|
+
│ in : prUrl, repo, prNumber, round, answer? (prompt via task header)
|
|
94
|
+
│ out: status, summary, question?
|
|
95
|
+
│ │
|
|
96
|
+
│ ▼
|
|
97
|
+
│ <gateway: status>
|
|
98
|
+
│ ├── converged → [Mark converged] → (end: converged)
|
|
99
|
+
│ │
|
|
100
|
+
│ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
|
|
101
|
+
│ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
|
|
102
|
+
│ │ └── =reviewWaitTimeout (timer catch) │
|
|
103
|
+
│ │ → [Escalate: review stalled] (blocked) │
|
|
104
|
+
│ │ → [Wait: escalation-answered] ──────────────────────┤
|
|
105
|
+
│ │ │
|
|
106
|
+
│ └── needs_input [Record escalation] │ │
|
|
107
|
+
│ or blocked → (kind = question | blocker) │ │
|
|
108
|
+
│ → [Wait: escalation-answered] (msg catch) │ │
|
|
109
|
+
│ → set answer ──────────────────────────────┤ │
|
|
110
|
+
│ │ │
|
|
111
|
+
└────────────────────────────────────────────────────────────────────┴───────────────┘
|
|
112
|
+
|
|
113
|
+
Both `needs_input` (the agent has a question) and `blocked` (the agent is stuck
|
|
114
|
+
on something external — auth, a failing push, a missing secret) route to the
|
|
115
|
+
**same escalation path**: record it, sleep at `escalation-answered`, then retry
|
|
116
|
+
the same round with the human's `answer`. They differ only by escalation `kind`,
|
|
117
|
+
which the UI uses to label the card. Neither ends the run — a human always gets
|
|
118
|
+
a chance to unblock and resume.
|
|
119
|
+
|
|
120
|
+
Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
|
|
121
|
+
("not converged after N rounds") so a human decides, rather than looping forever.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Notes:
|
|
125
|
+
- On `addressed`, the loop parks at an **event-based gateway** that races a
|
|
126
|
+
`review-ready` message (correlated by the poller when a fresh review lands)
|
|
127
|
+
against a `=reviewWaitTimeout` timer (seeded at submit from
|
|
128
|
+
`NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT20M`). Whichever fires first
|
|
129
|
+
withdraws the other — the message arm advances `round`, the timer arm escalates
|
|
130
|
+
a **stalled review** (`blocked`) so a human decides rather than the instance
|
|
131
|
+
hanging forever. Because `persist-round` already recorded this `round` as
|
|
132
|
+
`addressed` before the gateway, the timer arm opens the escalation **without
|
|
133
|
+
re-recording the round** (it passes `recordRound=false`), so a single round is
|
|
134
|
+
never logged as both `addressed` and `blocked`. This replaced a bare
|
|
135
|
+
`review-ready` catch that could hang
|
|
136
|
+
indefinitely: Copilot won't re-review a round with no new commit and routinely
|
|
137
|
+
dismisses a re-request, so with no timeout a review that never arrives wedged
|
|
138
|
+
the loop (observed: three convergence processes stalled ~22h). The poller's
|
|
139
|
+
auto re-request (§10) is the primary liveness mechanism; this timer is the
|
|
140
|
+
backstop when even repeated nudges fail.
|
|
141
|
+
- On `needs_input`, the same `round` is retried after the answer (the answer is
|
|
142
|
+
added to the agent's context; the round number does not advance).
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## 5. Agent job contract (`senior:pr-review`)
|
|
146
|
+
|
|
147
|
+
**Input** (`job.variables`):
|
|
148
|
+
| var | type | notes |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `prUrl` | string | canonical PR URL |
|
|
151
|
+
| `repo` | string | `owner/name` |
|
|
152
|
+
| `prNumber` | int | |
|
|
153
|
+
| `round` | int | 1-based round counter |
|
|
154
|
+
| `answer` | string? | present only when resuming from an escalation |
|
|
155
|
+
|
|
156
|
+
The base instructions are **not** a job variable: they are delivered as a model
|
|
157
|
+
**template header** on the `senior:pr-review` task — header key
|
|
158
|
+
`io.nanobpm.agentTask.task.prompt` with value `{{review-round}}`, substituted with
|
|
159
|
+
`prompts/review-round.md` at deploy time.
|
|
160
|
+
|
|
161
|
+
**Output** (job result variables):
|
|
162
|
+
| var | type | notes |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `status` | enum | `converged` \| `addressed` \| `needs_input` \| `blocked` |
|
|
165
|
+
| `summary` | string | human-readable account of what the round did |
|
|
166
|
+
| `question` | string? | required when `status = needs_input` or `blocked` — the question/blocker text a human must resolve |
|
|
167
|
+
|
|
168
|
+
The agent is responsible, within a round, for: reading the latest review,
|
|
169
|
+
triaging, editing/replying/pushing, and (when `addressed`) re-requesting review.
|
|
170
|
+
|
|
171
|
+
### Workspace isolation (host mode)
|
|
172
|
+
|
|
173
|
+
Workspace isolation is the **worker harness's** responsibility, not this app's and
|
|
174
|
+
not the prompt's. The `c8ctl nano work` host-git provisioning (frozen v1 envelope)
|
|
175
|
+
gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
|
|
176
|
+
`cwd` set to it (`AGENT_WORKSPACE`/`REPO_URL`/`REPO_BRANCH`/`REPO_REF` env), and
|
|
177
|
+
**reaps that run-dir when the job ends**. So multiple agents on one host do **not**
|
|
178
|
+
collide even in host mode — the isolation lives below the agent.
|
|
179
|
+
|
|
180
|
+
Consequences the prompt (`prompts/review-round.md`) encodes:
|
|
181
|
+
- The agent works only inside its provided `cwd`; it must **not** re-clone or create
|
|
182
|
+
a separate `git worktree`, and must not touch global/host state.
|
|
183
|
+
- The agent **cleans up anything it creates outside the commit** before returning
|
|
184
|
+
(worktrees, scratch branches/clones, temp files), so host mode does not leak.
|
|
185
|
+
- The harness checks out the PR's **existing head branch** and pushes back to it
|
|
186
|
+
(no new branch/PR). The `c8ctl` integration provisions the repo and resolves the
|
|
187
|
+
head branch from `prNumber`/`prUrl` — the app does not pass a `headBranch` var.
|
|
188
|
+
|
|
189
|
+
## 6. Signals
|
|
190
|
+
|
|
191
|
+
| message | correlationKey | published by | payload |
|
|
192
|
+
|---|---|---|---|
|
|
193
|
+
| `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
|
|
194
|
+
| `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
|
|
195
|
+
| `escalation-answered` | `prKey` | UI answer route | `{answer, escalationId}` |
|
|
196
|
+
| `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
|
|
197
|
+
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
|
|
198
|
+
| `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
|
|
199
|
+
|
|
200
|
+
Note `escalation-answered` is reused by both processes (`convergence-loop` and
|
|
201
|
+
`merge-loop`); only one is ever active for a given `prKey`, so correlation is
|
|
202
|
+
unambiguous. Each `.bpmn` gives it a distinct message **id** (and distinct
|
|
203
|
+
envelope shape ids) to avoid duplicate-id collisions when the manifest deploys
|
|
204
|
+
both files.
|
|
205
|
+
|
|
206
|
+
## 7. Domain model (SQLite — `db/migrations/001_init.sql`) — PROPOSED
|
|
207
|
+
|
|
208
|
+
```sql
|
|
209
|
+
CREATE TABLE pull_requests (
|
|
210
|
+
pr_key TEXT PRIMARY KEY, -- "<owner>/<repo>#<number>"
|
|
211
|
+
repo TEXT NOT NULL, -- "<owner>/<repo>"
|
|
212
|
+
number INTEGER NOT NULL,
|
|
213
|
+
url TEXT NOT NULL,
|
|
214
|
+
title TEXT, -- fetched from GitHub
|
|
215
|
+
status TEXT NOT NULL, -- review: converging | waiting_review | escalated | converged; merge: waiting_deps | waiting_merge | queued | merging | merged; abandoned
|
|
216
|
+
current_round INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
process_key TEXT, -- engine process-instance key
|
|
218
|
+
waiting_since TEXT, -- ISO ts we began waiting for a review (poller cursor)
|
|
219
|
+
last_review_id INTEGER, -- last GitHub review id we reacted to
|
|
220
|
+
outcome TEXT, -- final summary
|
|
221
|
+
created_at TEXT NOT NULL,
|
|
222
|
+
updated_at TEXT NOT NULL,
|
|
223
|
+
converged_at TEXT,
|
|
224
|
+
merged_at TEXT -- set by `pr.mark-merged` (migration 004)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
CREATE TABLE rounds (
|
|
228
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
229
|
+
pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
|
|
230
|
+
round_no INTEGER NOT NULL,
|
|
231
|
+
status TEXT, -- converged | addressed | needs_input | blocked
|
|
232
|
+
summary TEXT,
|
|
233
|
+
started_at TEXT NOT NULL,
|
|
234
|
+
ended_at TEXT
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
CREATE TABLE escalations (
|
|
238
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
239
|
+
pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
|
|
240
|
+
round_no INTEGER NOT NULL,
|
|
241
|
+
kind TEXT NOT NULL, -- question | blocker
|
|
242
|
+
question TEXT NOT NULL,
|
|
243
|
+
answer TEXT,
|
|
244
|
+
status TEXT NOT NULL, -- open | answered
|
|
245
|
+
asked_at TEXT NOT NULL,
|
|
246
|
+
answered_at TEXT
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
CREATE INDEX idx_pr_status ON pull_requests(status);
|
|
250
|
+
CREATE INDEX idx_rounds_pr ON rounds(pr_key);
|
|
251
|
+
CREATE INDEX idx_esc_pr ON escalations(pr_key);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## 8. Screen + routes (`main.ts` + `pages/home.page.json`)
|
|
255
|
+
|
|
256
|
+
The UI is authored declaratively as `pages/home.page.json` and served by the
|
|
257
|
+
generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SPA.
|
|
258
|
+
The page defines status-filtered tabs (active vs. history), a submit form, a
|
|
259
|
+
per-row **Cancel** action, and an expandable detail with the round/escalation
|
|
260
|
+
child grids, a lazily-loaded transcript, and a conditional **answer** form shown
|
|
261
|
+
when the PR has an open escalation (`open_escalation_id`, denormalised onto the
|
|
262
|
+
row by migration `003`).
|
|
263
|
+
|
|
264
|
+
`main.ts` delegates to the runtime and intercepts only the three actions that
|
|
265
|
+
carry app-specific business logic:
|
|
266
|
+
|
|
267
|
+
| method | route | purpose |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `POST` | `/app/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process |
|
|
270
|
+
| `POST` | `/app/actions/cancel` | cancel the engine instance + mark the PR `abandoned` |
|
|
271
|
+
| `POST` | `/app/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
|
|
272
|
+
| `POST` | `/hooks/submit` | webhook submit (shared-secret auth) → start the process |
|
|
273
|
+
|
|
274
|
+
Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
|
|
275
|
+
served by the runtime. `deno task purge` wipes and re-migrates the app db (used
|
|
276
|
+
when the engine data is purged, to keep app state and engine state consistent).
|
|
277
|
+
|
|
278
|
+
## 9. Prompt delivery — model-authored template headers
|
|
279
|
+
|
|
280
|
+
Each agent task's base prompt lives **only** in its `prompts/*.md` side-car and is
|
|
281
|
+
authored **into the model** as a deploy-time `{{stem}}` template. `nano.app.json`
|
|
282
|
+
declares `models.templates: ["prompts/*.md"]`, and each agent service task carries a
|
|
283
|
+
`io.nanobpm.agentTask.task.prompt = {{stem}}` task header (`{{review-round}}`,
|
|
284
|
+
`{{plan}}`, `{{plan-review}}`, `{{feature}}`, `{{fix-ci}}`). At deploy the template
|
|
285
|
+
substitutes the file content into the header, so the host no longer reads prompt
|
|
286
|
+
assets or carries them as process variables.
|
|
287
|
+
|
|
288
|
+
Per-instance dynamic context rides **`appendPrompt`**: an ioMapping sets a job-local
|
|
289
|
+
`appendPrompt` string (a plan's rejection findings, a feature task's brief, the
|
|
290
|
+
failing-check list) which the agent harness concatenates **verbatim** onto the header
|
|
291
|
+
base — the model owns any separator, and a null/empty append leaves the base
|
|
292
|
+
untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
|
|
293
|
+
attribute escaping would corrupt a FEEL string literal), so composition happens via
|
|
294
|
+
this append seam rather than inline in FEEL. Requires `@nanobpm/urban` with
|
|
295
|
+
deploy-time template substitution.
|
|
296
|
+
|
|
297
|
+
## 10. Poller
|
|
298
|
+
|
|
299
|
+
An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
|
|
300
|
+
1. `SELECT pr_key, repo, number, waiting_since, last_review_id, last_nudge_at FROM pull_requests WHERE status = 'waiting_review'`.
|
|
301
|
+
2. For each, GET the PR's reviews from GitHub; find the newest review submitted
|
|
302
|
+
after `waiting_since` with id > `last_review_id`.
|
|
303
|
+
3. If found → publish `review-ready` (key = `pr_key`, `{reviewId, ...}`) and set
|
|
304
|
+
`last_review_id`.
|
|
305
|
+
4. If **not** found → ensure a review is in flight: unless Copilot is already a
|
|
306
|
+
pending reviewer, **re-request** it (REST `requested_reviewers`, exact login
|
|
307
|
+
`copilot-pull-request-reviewer[bot]`) and record `last_nudge_at`. This is
|
|
308
|
+
throttled to one attempt per `NANO_PR_REVIEW_NUDGE_MINUTES` window (default 5m)
|
|
309
|
+
so a re-request Copilot dismisses is retried without hammering the API. A repo
|
|
310
|
+
where Copilot isn't an assignable reviewer (HTTP 422) is left to the process's
|
|
311
|
+
review-wait timer (§4). This closes the stall where Copilot won't spontaneously
|
|
312
|
+
re-review and silently dismisses a re-request, so no `review-ready` ever fires.
|
|
313
|
+
|
|
314
|
+
Requires a GitHub token (`GITHUB_TOKEN`) or the host `gh` CLI. One cheap API call
|
|
315
|
+
per waiting PR per interval (plus at most one reviewer-state check + re-request per
|
|
316
|
+
nudge window).
|
|
317
|
+
|
|
318
|
+
## 11. Merge stage (`merge-loop.bpmn`)
|
|
319
|
+
|
|
320
|
+
With `NANO_PR_AUTO_MERGE` on (default), the `pr.finalize` worker does not stop at
|
|
321
|
+
`converged` — it starts a **second** durable process, `merge-loop`, keyed on the
|
|
322
|
+
same `prKey`, sharing the datasource and poller. It merges the PR, honouring
|
|
323
|
+
merge-queue branches and cross-PR dependencies, and reuses the review stage's
|
|
324
|
+
escalation machinery for anything it can't resolve autonomously.
|
|
325
|
+
|
|
326
|
+
Flow:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─ ready ─► merge ─┬─ merged ─► mark merged ─► end
|
|
330
|
+
(deps-cleared) (waiting_merge) (merge-ready)│ ├─ queued ─► wait: landed ─► mark merged
|
|
331
|
+
│ │ (merge-landed)
|
|
332
|
+
│ └─ blocked ──────────────► escalate ─┐
|
|
333
|
+
├─ conflict ─────────────────────────────► escalate ─┤
|
|
334
|
+
└─ blocked (failing checks) ─► auto-fix CI? │
|
|
335
|
+
├─ within budget ─► [senior:fix-ci] ─┐ │
|
|
336
|
+
└─ budget exhausted ─► escalate ─┐ │ │
|
|
337
|
+
(re-arm) ◄── fixed ─┬────────────────────────────────────────────│────┘ │
|
|
338
|
+
└─ could not fix ─► escalate ─┐ │ │
|
|
339
|
+
▼ ▼ ▼
|
|
340
|
+
wait: answered ─► (re-arm) ◄──────── (all escalations)
|
|
341
|
+
(escalation-answered)
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- **CI auto-fix** — a `blocked` verdict means a **required check failed**
|
|
345
|
+
(`classifyMergeability`). Rather than escalate immediately, the stage dispatches a
|
|
346
|
+
`senior:fix-ci` agent (base prompt via the `{{fix-ci}}` template header; the failing
|
|
347
|
+
check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
|
|
348
|
+
poller. It repeats while `ciFixRound < ciFixMax`
|
|
349
|
+
(`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
|
|
350
|
+
exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
|
|
351
|
+
through to the human escalation path.
|
|
352
|
+
|
|
353
|
+
- **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
|
|
354
|
+
Declared two ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
|
|
355
|
+
submit) and/or a `dependsOn` array on the submit request. `merge-loop` parks at
|
|
356
|
+
*wait: deps merged*; the poller checks each dependency (own tracked row first,
|
|
357
|
+
else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
|
|
358
|
+
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
|
|
359
|
+
`CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
|
|
360
|
+
`BLOCKED` → `blocked` if a required check is failing, else keep waiting;
|
|
361
|
+
`DRAFT`/`UNKNOWN`/empty → keep waiting. It publishes `merge-ready {mergeState}`.
|
|
362
|
+
- **Merge** — `pr.merge` attempts the merge (`NANO_PR_MERGE_METHOD`, default
|
|
363
|
+
`squash`). GitHub auto-enqueues on merge-queue-required branches → the process
|
|
364
|
+
waits for `merge-landed` (poller detects the landed PR). Every attempt is
|
|
365
|
+
recorded in the `merges` audit table.
|
|
366
|
+
- **Escalation** — a conflict or a failing gate raises the same
|
|
367
|
+
`pr.persist-escalation` worker / UI answer form as the review stage (status
|
|
368
|
+
`escalated`); answering re-arms and retries.
|
|
369
|
+
- **Terminal** — `merged` (with `merged_at`), or `converged` when
|
|
370
|
+
`NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
|
|
371
|
+
|
|
372
|
+
Poller status choreography mirrors the review stage: before publishing a
|
|
373
|
+
resuming message the poller flips the row to a **transient** status the scan
|
|
374
|
+
queries skip (`merging`), so a slow pass can't double-signal.
|
|
375
|
+
|
|
376
|
+
## 12. Configuration (env, `${VAR:-default}` in the manifest)
|
|
377
|
+
|
|
378
|
+
| var | default | purpose |
|
|
379
|
+
|---|---|---|
|
|
380
|
+
| `PORT` | 8090 | app HTTP port |
|
|
381
|
+
| `NANO_APP_DB_URL` | `file:./app.db` | sqlite |
|
|
382
|
+
| `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
|
|
383
|
+
| `NANO_PR_POLL_MS` | 60000 | poll interval |
|
|
384
|
+
| `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
|
|
385
|
+
| `NANO_PR_WEBHOOK_SECRET` | — | HMAC for `/hooks/submit` |
|
|
386
|
+
| `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only) |
|
|
387
|
+
| `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
|
|
388
|
+
| `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
|
|
389
|
+
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT20M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
|
|
390
|
+
| `NANO_PR_REVIEW_NUDGE_MINUTES` | 5 | cooldown between poller Copilot re-request nudges per PR (clamped 1–1440) |
|
|
391
|
+
|
|
392
|
+
## 13. Planning fan-out (`plan-fanout.bpmn`) — issue #14
|
|
393
|
+
|
|
394
|
+
A second process turns a **GitHub issue** into a fleet of PRs. It is the "series
|
|
395
|
+
then parallel" flat form: plan once, then fan out over the tasks in parallel, then
|
|
396
|
+
hand every produced PR to the convergence loop of §4.
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
Start(issue) → plan → record-plan → implement (parallel MI) → record-results → End
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- **`plan`** — service task, job type `senior:plan`. Its base prompt is delivered
|
|
403
|
+
via the `{{plan}}` model template header (`prompts/plan.md`); when a prior review
|
|
404
|
+
rejected the plan, the rejection findings ride `appendPrompt` (an ioMapping over
|
|
405
|
+
`planFindings`) rather than being concatenated in FEEL. The agent reads the issue
|
|
406
|
+
via `gh` and emits `tasks: [{ id, title, prompt }]`.
|
|
407
|
+
- **`record-plan`** — app worker `pr.record-plan`. Normalizes the tasks (assigns a
|
|
408
|
+
stable `id`/index), writes one `plan_tasks` row each, sets `plans.task_count` and
|
|
409
|
+
status `dispatched`, and **re-emits** the normalized `tasks` so the fan-out
|
|
410
|
+
iterates the canonical list.
|
|
411
|
+
- **`implement`** — service task, job type `senior:feature`, **parallel
|
|
412
|
+
multi-instance** over `=tasks` (`inputElement="task"`,
|
|
413
|
+
`outputCollection="results"`). Its base prompt is delivered via the `{{feature}}`
|
|
414
|
+
model template header (`prompts/feature.md`); each child's per-task brief
|
|
415
|
+
(`"\n\n---\n\n" + task.prompt`) rides `appendPrompt` — an input mapping evaluated
|
|
416
|
+
**per child** (Zeebe parity: the inner activity keeps its own `zeebe:ioMapping`,
|
|
417
|
+
applied on each inner-instance activation with `task`/`loopCounter` bound). Each
|
|
418
|
+
agent opens a PR and returns `{ status, summary, pr }`; `outputElement` collects
|
|
419
|
+
those into `results[i]`, index-aligned with `tasks[i]`.
|
|
420
|
+
- **`record-results`** — app worker `pr.record-results`. Zips `results` back onto
|
|
421
|
+
`plan_tasks` by index, and for each opened `pr` calls the same idempotent
|
|
422
|
+
`submitPr` as §4 — **the handoff**: every fleet-produced PR enrols into the
|
|
423
|
+
review-convergence loop. Sets `plans` status `done`.
|
|
424
|
+
|
|
425
|
+
**Payloads are untyped** (no `nano:shapes`/`io.nanobpm.dataEnvelope`): the vocab is
|
|
426
|
+
scalar-only and cannot express the `tasks`/`results` lists, so the workers self-type
|
|
427
|
+
`job.variables` inline (like `finalize`). `urban gen` still emits the four task types.
|
|
428
|
+
|
|
429
|
+
**Domain model** (`db/migrations/004_planning.sql`): `plans` (one row per issue) +
|
|
430
|
+
`plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
|
|
431
|
+
|
|
432
|
+
**Entry points**: the page's "Hand an issue to the fleet" form
|
|
433
|
+
(`startProcess plan-fanout` → `actions/plan-start.ts`), or `POST /hooks/plan`
|
|
434
|
+
(`{ issue | url }`, optional `X-Hook-Secret`).
|
|
435
|
+
|
|
436
|
+
**Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
|
|
437
|
+
History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
|
|
438
|
+
status and the PR it produced (`pr_key` cross-references the Pull requests grid for
|
|
439
|
+
convergence status).
|
|
440
|
+
|
|
441
|
+
### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
|
|
442
|
+
|
|
443
|
+
The flat `implement → record-results` shape above evolved into a **wave loop**. The
|
|
444
|
+
planner may emit `dependsOn` edges; `record-plan` levelizes them into ordered
|
|
445
|
+
**waves** (`app/waves.ts` `computeWaves`, `plan_tasks.wave` + `plan_task_deps`), and
|
|
446
|
+
the loop runs one parallel `implement` MI fan-out per wave:
|
|
447
|
+
|
|
448
|
+
```
|
|
449
|
+
… → select-wave → implement (parallel MI) → record-wave → gw-more
|
|
450
|
+
↑ │ more
|
|
451
|
+
└───────────── wait-wave-merged ←────────────────────┘
|
|
452
|
+
│ done
|
|
453
|
+
▼
|
|
454
|
+
record-results
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
- **`select-wave`** (`pr.select-wave`) emits the current wave's still-`pending`
|
|
458
|
+
tasks as `waveTasks`; a task whose dependency ended `blocked`/`skipped` is marked
|
|
459
|
+
`skipped` (the failure cascades) rather than dispatched.
|
|
460
|
+
- **`record-wave`** (`pr.record-wave`) records each slice's outcome, hands every
|
|
461
|
+
opened PR to the convergence loop via `submitPr` (declaring dependency PRs as
|
|
462
|
+
`dependsOn`), and advances `currentWave`.
|
|
463
|
+
- **Wave-merge barrier** (`wait-wave-merged`): when a wave has a successor,
|
|
464
|
+
`record-wave` sets `plans.gate_wave` to that wave's index and the process parks at
|
|
465
|
+
the `wait-wave-merged` catch event. The poller's `pollWaveGates` pass publishes the
|
|
466
|
+
`wave-merged` message (correlated on `planKey`) once **every opened PR in that wave
|
|
467
|
+
has merged** (`app/waves.ts` `waveMergeTargets` selects the PRs to wait on;
|
|
468
|
+
`blocked`/`skipped`/keyless tasks clear vacuously), then clears `gate_wave`
|
|
469
|
+
single-shot. So a `dependsOn` means the dependent wave is not **implemented** until
|
|
470
|
+
its prerequisites have **landed on the base branch** — not merely opened. This lets
|
|
471
|
+
a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
|
|
472
|
+
next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
|
|
473
|
+
- **Adopting a decomposed epic** (`prompts/plan.md` Step 0): when adopting existing
|
|
474
|
+
sub-issues, the planner honours an explicit `Depends-on: #N` / `Blocked by #N`
|
|
475
|
+
directive in a sub-issue body, mapping each prerequisite `#M` to `issue-M` in the
|
|
476
|
+
adopted task's `dependsOn` — so a human-declared blocking order survives adoption.
|
|
477
|
+
|
|
478
|
+
## 14. Open questions / future
|
|
479
|
+
|
|
480
|
+
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
481
|
+
integration provisions the repo and checks out the PR's head branch (it must
|
|
482
|
+
already give the worker repo access to work at all), resolving the branch from
|
|
483
|
+
`prNumber`/`prUrl`. The app does **not** pass a `headBranch` job variable; the
|
|
484
|
+
job stays engine-shaped and the worker stays a pure provisioner.
|
|
485
|
+
- **review-ready via GitHub webhook** — same message, swappable faster trigger,
|
|
486
|
+
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
487
|
+
- **Supervised vs external worker** — the agent runs as an external
|
|
488
|
+
`c8ctl nano work` daemon by default; a supervised in-server mode is possible
|
|
489
|
+
later (ADR 0041 decision).
|
|
490
|
+
- **Prompt versioning/hash** per PR for auditability.
|
|
491
|
+
- **Auth on the web UI** — the manifest `security` block (ADR 0028) if this is
|
|
492
|
+
exposed beyond localhost.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tests for the GET /hooks/abandon endpoint (issue #76).
|
|
2
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
3
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
+
import handler from "./abandon.ts";
|
|
5
|
+
|
|
6
|
+
// deno-lint-ignore no-explicit-any
|
|
7
|
+
function memApp(): { app: AppApi } {
|
|
8
|
+
// deno-lint-ignore no-explicit-any
|
|
9
|
+
const stores: Record<string, any[]> = {};
|
|
10
|
+
function tbl(name: string) {
|
|
11
|
+
// deno-lint-ignore no-explicit-any
|
|
12
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
13
|
+
return {
|
|
14
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
15
|
+
async insert(row: any) {
|
|
16
|
+
rows.push({ ...row });
|
|
17
|
+
return row.pr_key;
|
|
18
|
+
},
|
|
19
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
20
|
+
async findOne(where: any = {}) {
|
|
21
|
+
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// deno-lint-ignore no-explicit-any
|
|
26
|
+
const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
|
|
27
|
+
return { app };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function req(method: string, query: Record<string, string>) {
|
|
31
|
+
return {
|
|
32
|
+
method,
|
|
33
|
+
path: "/hooks/abandon",
|
|
34
|
+
query: new URLSearchParams(query),
|
|
35
|
+
headers: new Headers(),
|
|
36
|
+
text: async () => "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function call(app: AppApi, method: string, query: Record<string, string>) {
|
|
41
|
+
// deno-lint-ignore no-explicit-any
|
|
42
|
+
const res = await handler({ req: req(method, query) as any, body: undefined }, app);
|
|
43
|
+
// deno-lint-ignore no-explicit-any
|
|
44
|
+
return res as any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function seedPr(app: AppApi, prKey: string, token: string, status: string) {
|
|
48
|
+
await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Deno.test("missing token → 400", async () => {
|
|
52
|
+
const { app } = memApp();
|
|
53
|
+
assertEquals((await call(app, "GET", {})).status, 400);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
Deno.test("unknown token → 404 (does not reveal PRs)", async () => {
|
|
57
|
+
const { app } = memApp();
|
|
58
|
+
await seedPr(app, "o/r#1", "good", "converging");
|
|
59
|
+
assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
Deno.test("a running PR → { abandoned: false }", async () => {
|
|
63
|
+
const { app } = memApp();
|
|
64
|
+
await seedPr(app, "o/r#1", "tok", "converging");
|
|
65
|
+
const res = await call(app, "GET", { token: "tok" });
|
|
66
|
+
assertEquals(res.status, 200);
|
|
67
|
+
assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("a cancelled PR → { abandoned: true }", async () => {
|
|
71
|
+
const { app } = memApp();
|
|
72
|
+
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
73
|
+
const res = await call(app, "GET", { token: "tok" });
|
|
74
|
+
assertEquals(res.status, 200);
|
|
75
|
+
assertEquals(res.body.abandoned, true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
Deno.test("token via header is accepted", async () => {
|
|
79
|
+
const { app } = memApp();
|
|
80
|
+
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
81
|
+
const r = req("GET", {});
|
|
82
|
+
r.headers.set("x-abandon-token", "tok");
|
|
83
|
+
// deno-lint-ignore no-explicit-any
|
|
84
|
+
const res = await handler({ req: r as any, body: undefined }, app) as any;
|
|
85
|
+
assertEquals(res.status, 200);
|
|
86
|
+
assertEquals(res.body.abandoned, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
Deno.test("non-GET → 405", async () => {
|
|
90
|
+
const { app } = memApp();
|
|
91
|
+
await seedPr(app, "o/r#1", "tok", "converging");
|
|
92
|
+
assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
|
|
93
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GET /hooks/abandon?token=<capabilityToken> — the cooperative abandon check (issue #76).
|
|
2
|
+
//
|
|
3
|
+
// A DIRECT side-channel for a running `senior:*` agent to learn whether its run was cancelled
|
|
4
|
+
// before it performs an irreversible side effect (push / open PR / request review / merge). The
|
|
5
|
+
// per-PR capability token (query string) IS the credential: it scopes the read to exactly one PR,
|
|
6
|
+
// so no shared secret is needed — the agent curls the exact URL it was handed in its prompt. An
|
|
7
|
+
// unknown token is a 404 (never leaks which PRs exist).
|
|
8
|
+
//
|
|
9
|
+
// GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
|
|
10
|
+
// which `cancelRun` sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
|
|
11
|
+
import type { ActionHandler } from "@nanobpm/urban";
|
|
12
|
+
import { abandonStatusForToken } from "../app/abandon.ts";
|
|
13
|
+
|
|
14
|
+
const handler: ActionHandler = async ({ req }, app) => {
|
|
15
|
+
if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
|
|
16
|
+
const token = (req.query.get("token") ?? req.headers.get("x-abandon-token") ?? "").trim();
|
|
17
|
+
if (!token) return { status: 400, body: { error: "missing abandon token" } };
|
|
18
|
+
const state = await abandonStatusForToken(app.data, token);
|
|
19
|
+
if (!state) return { status: 404, body: { error: "unknown abandon token" } };
|
|
20
|
+
return { status: 200, body: state };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default handler;
|