loopctl-mcp-server 2.101.0 → 2.104.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/README.md +20 -11
- package/index.js +437 -19
- package/lib/claim-lease.js +13 -4
- package/lib/delivery-loop.js +43 -17
- package/lib/intake-sources.js +554 -0
- package/package.json +1 -1
package/lib/delivery-loop.js
CHANGED
|
@@ -60,7 +60,7 @@ function refuse(body) {
|
|
|
60
60
|
*
|
|
61
61
|
* NOT on `place_dispatch`'s `dispatch_id`, which is an id and is forwarded unchecked. That is a
|
|
62
62
|
* decision and not an omission: it travels in the request BODY, and `Placement.place/4` casts it
|
|
63
|
-
* with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:
|
|
63
|
+
* with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:1631-1636`) as the second clause of its
|
|
64
64
|
* `with` — before the caller is resolved, before anything is minted and before the claim — so a
|
|
65
65
|
* malformed one answers `422 invalid_payload` with `details: ["dispatch_id: must be a UUID"]`
|
|
66
66
|
* (`lib/loopctl_web/controllers/dispatch_placement_controller.ex:321-326`). That names the
|
|
@@ -87,7 +87,7 @@ function refuse(body) {
|
|
|
87
87
|
* go find the right story. The check here can tell them apart, and does it without a round trip.
|
|
88
88
|
*
|
|
89
89
|
* SCOPE OF THAT TRACE. The path read end to end is force-unclaim:
|
|
90
|
-
* `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:
|
|
90
|
+
* `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:3902-3912`),
|
|
91
91
|
* which puts `story_id` straight into a `where` against a `:binary_id` column with no cast. The
|
|
92
92
|
* other verbs are NOT claimed to reach that same code. They do not need to: a shape check is
|
|
93
93
|
* worth its line on any argument that is interpolated into a URL path and must be a UUID,
|
|
@@ -242,33 +242,30 @@ export async function resolveEscalation({ story_id, to, reason } = {}, { userKey
|
|
|
242
242
|
* `POST /api/v1/stories/:id/force-unclaim`: take a story back off the agent holding it.
|
|
243
243
|
*
|
|
244
244
|
* TWO things happen. `force_unclaim_story/3` resets `agent_status` to `pending` and clears
|
|
245
|
-
* `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:
|
|
245
|
+
* `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:1607`); then, in the SAME
|
|
246
246
|
* transaction, `Stages.follow_release/5` makes the delivery stage row follow the release —
|
|
247
247
|
* from any stage a claim holds (`claimed`, `worktree`, `implementing`, `reviewing`, `pr_open`,
|
|
248
248
|
* `ci`) back to `queued`, rebound to the new claim epoch.
|
|
249
249
|
*
|
|
250
|
-
* ##
|
|
250
|
+
* ## A DELIVERY STORY GOES TO `escalated`, NOT BACK TO THE QUEUE
|
|
251
251
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* `
|
|
252
|
+
* An operator taking a story back is a human decision (loopctl US-44.4, #877), so when the
|
|
253
|
+
* release leaves the delivery stage row at `queued`, `Stages.follow_release/5` escalates it over
|
|
254
|
+
* the control-only `{queued, escalated, operator_released}` edge in the same transaction. It
|
|
255
|
+
* spends no attempt against the retry ceiling. The story is never left at `queued` + `pending`,
|
|
256
|
+
* which `Placement.claimable/2` refuses and nothing re-contracted.
|
|
257
257
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* steps runs two of the three and takes the 409 this copy exists to prevent.)
|
|
262
|
-
* (`resolve_escalation` is the one that does both for you: `Escalations.prepare_story/5`
|
|
263
|
-
* releases AND re-contracts on the `queued` route, `escalations.ex:335-342`.)
|
|
258
|
+
* To put it back to work: `resolve_escalation` with `to: queued`, which releases (a no-op by
|
|
259
|
+
* then) AND re-contracts (`Escalations.prepare_story/6`). A story with no stage row is simply
|
|
260
|
+
* left `pending`, exactly as before.
|
|
264
261
|
*
|
|
265
262
|
* ## WHEN A STORY IS ACTUALLY PARKED
|
|
266
263
|
*
|
|
267
264
|
* Not on an ordinary refusal — that path self-heals. `Placement.place/4` answers a
|
|
268
|
-
* `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:
|
|
265
|
+
* `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:917`, `:1211`),
|
|
269
266
|
* which releases the claim through this same function, unrecords the session dispatch and
|
|
270
267
|
* revokes it. If that release itself fails, the claim lease is a further backstop:
|
|
271
|
-
* `Progress.reclaim_expired_claim/3` (`progress.ex:
|
|
268
|
+
* `Progress.reclaim_expired_claim/3` (`progress.ex:1805`) releases over `:runner_lost` and
|
|
272
269
|
* requeues the stage, swept by `ReclaimExpiredClaimsWorker` every five minutes once
|
|
273
270
|
* `claimed_until` has passed.
|
|
274
271
|
*
|
|
@@ -304,3 +301,32 @@ export async function forceUnclaimStory({ story_id } = {}, { orchKey, apiCall }
|
|
|
304
301
|
|
|
305
302
|
return apiCall("POST", forceUnclaimPath(story_id), null);
|
|
306
303
|
}
|
|
304
|
+
|
|
305
|
+
export function mergePreconditionPath(storyId) {
|
|
306
|
+
return `/api/v1/stories/${encodeURIComponent(storyId)}/merge-precondition`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `merge_precondition` (epic 44, US-44.1): the second run of both delivery gates over the real
|
|
311
|
+
* pull request. `exact_role: [:orchestrator, :user]`, so it takes the ORCH key the way
|
|
312
|
+
* `force_unclaim_story` does. It sends no `trio_outputs`: since contract 1.15.0 Gate A reads
|
|
313
|
+
* the lens verdicts triage persisted, and a trio sent here would be ignored.
|
|
314
|
+
*/
|
|
315
|
+
export async function mergePrecondition(
|
|
316
|
+
{ story_id, claim_epoch, effect_proof } = {},
|
|
317
|
+
{ orchKey, apiCall } = {},
|
|
318
|
+
) {
|
|
319
|
+
if (!orchKey) return refuse(MISSING_ORCH_KEY);
|
|
320
|
+
|
|
321
|
+
const bad = uuid(story_id, "story_id");
|
|
322
|
+
if (bad) return bad;
|
|
323
|
+
|
|
324
|
+
if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
|
|
325
|
+
return refuse("claim_epoch must be a non-negative integer: the epoch the caller acts under.");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const body = { claim_epoch };
|
|
329
|
+
if (effect_proof && typeof effect_proof === "object") body.effect_proof = effect_proof;
|
|
330
|
+
|
|
331
|
+
return apiCall("POST", mergePreconditionPath(story_id), body);
|
|
332
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub intake sources — `/api/v1/intake/sources` (loopctl #803, #846).
|
|
3
|
+
*
|
|
4
|
+
* ## The defect these exist for
|
|
5
|
+
*
|
|
6
|
+
* loopctl's agent delivery loop is built end to end — webhook intake, triage, the stage
|
|
7
|
+
* machine, placement, the unattended driver — and until these tools it had never received a
|
|
8
|
+
* GitHub issue, because nothing outside the app could create the one row that makes a
|
|
9
|
+
* webhook possible. All five intake-source routes were declared `gap` in
|
|
10
|
+
* `test/route_coverage.test.js`: no tool reached any of them, and `claude-config`'s
|
|
11
|
+
* `hooks/orchestrator-guardrail.sh` refuses a `curl` carrying `loopctl.com` from every
|
|
12
|
+
* session on the fleet, deliberately. So the loop's own wiring step was reachable by a human
|
|
13
|
+
* with `iex` on the production node and by nothing else — loopctl's "an operator-facing
|
|
14
|
+
* endpoint is not done until an MCP tool calls it" failing at the step the whole loop starts
|
|
15
|
+
* from.
|
|
16
|
+
*
|
|
17
|
+
* An intake source does two things, and the second is why `place_dispatch` cares:
|
|
18
|
+
*
|
|
19
|
+
* 1. It mints the webhook secret and names the URL GitHub must POST to, so issues from one
|
|
20
|
+
* repository reach one project's queue.
|
|
21
|
+
* 2. It BINDS that project to a `repo_full_name` and a `base_branch`, which is where
|
|
22
|
+
* `Loopctl.Delivery.DispatchPayload` reads the repository and trunk from. A project
|
|
23
|
+
* bound to none is the `409 no_intake_source` refusal; bound to two, the `409
|
|
24
|
+
* ambiguous_intake_source` one.
|
|
25
|
+
*
|
|
26
|
+
* ## THE SECRET IS NEVER RETURNED BY THESE TOOLS
|
|
27
|
+
*
|
|
28
|
+
* `POST /api/v1/intake/sources` returns `webhook_secret` ONCE. It cannot be re-read: the
|
|
29
|
+
* column is `Loopctl.Vault.Binary` with `redact: true` and is absent from the schema's
|
|
30
|
+
* `@derive {Jason.Encoder, only: [...]}` list (`lib/loopctl/intake/source.ex`), so neither
|
|
31
|
+
* the list nor the update response carries it. Lose it and the only remedy is to revoke the
|
|
32
|
+
* source, enrol again, and reconfigure the GitHub webhook.
|
|
33
|
+
*
|
|
34
|
+
* A tool result is JSON-stringified into the session transcript AND the audit log, so this
|
|
35
|
+
* follows what `runner_enroll` already does with the runner credential (`lib/runners.js`):
|
|
36
|
+
* `secret_file` is REQUIRED, the secret is written there with mode 0600, and the tool returns
|
|
37
|
+
* the source row, the webhook URL and the path. The secret reaches GitHub from the file —
|
|
38
|
+
* `config[secret]=$(cat <file>)` — so it never has to pass through a transcript at all.
|
|
39
|
+
*
|
|
40
|
+
* The file is RESERVED (`O_CREAT|O_EXCL`, 0600) before the API is called, so an existing path
|
|
41
|
+
* or an unwritable directory is refused while there is still nothing to lose. Every outcome
|
|
42
|
+
* that is not a clean 2xx of the expected shape withholds the response body, because a 2xx
|
|
43
|
+
* whose JSON failed to parse reaches this code as `{error: true, status: 201, body: "<first
|
|
44
|
+
* 200 characters of the raw text>"}` — which is the secret.
|
|
45
|
+
*
|
|
46
|
+
* ## WHERE THIS DIFFERS FROM `runner_enroll`
|
|
47
|
+
*
|
|
48
|
+
* The RECOVERY is the same: an outcome that is not a clean creation revokes the source when
|
|
49
|
+
* the response proved its id, since the secret cannot be re-read and an unusable source still
|
|
50
|
+
* holds the repository's unique slot.
|
|
51
|
+
*
|
|
52
|
+
* What is not the same is the fallback when no id was proved. `enrollRunner` scrapes the
|
|
53
|
+
* runner id out of an unparseable 2xx body, because a runner found by NAME alone might be an
|
|
54
|
+
* earlier, legitimate enrollment. An intake source needs no such scraping:
|
|
55
|
+
* `intake_sources_active_repo_uidx` is unique on `(tenant_id, lower(repo_full_name))` WHERE
|
|
56
|
+
* `revoked_at IS NULL` — CASE-INSENSITIVELY, because GitHub names are — so at most one ACTIVE
|
|
57
|
+
* source ever binds a repository however it was spelled, and `intake_source_list` identifies
|
|
58
|
+
* it exactly. That branch therefore names the pair of tools rather than guessing at an id.
|
|
59
|
+
*
|
|
60
|
+
* MATCH THAT LISTING CASE-INSENSITIVELY. The uniqueness folds case; the STORED value does not
|
|
61
|
+
* — the row keeps the spelling it was enrolled with — so a source enrolled as
|
|
62
|
+
* `MKREYMAN/loopctl` refuses a re-enrolment of `mkreyman/loopctl` with a 422 while a reader
|
|
63
|
+
* comparing the two exactly concludes there is nothing to revoke.
|
|
64
|
+
*
|
|
65
|
+
* A source whose secret nobody holds is also INERT rather than dangerous: no delivery can
|
|
66
|
+
* ever be signed for it, so every POST to its URL is refused `401 invalid_signature`. What it
|
|
67
|
+
* does cost is that unique slot — a second enrolment of the same repository is refused 422
|
|
68
|
+
* until it is revoked — which is why a write failure after a successful create revokes it
|
|
69
|
+
* here rather than leaving it.
|
|
70
|
+
*
|
|
71
|
+
* ## KEY
|
|
72
|
+
*
|
|
73
|
+
* `LOOPCTL_USER_KEY`, for all four, pinned exactly (`exactKey`) the way the `runner_*` family
|
|
74
|
+
* is. The controller mounts `RequireRole, role: :user` — the HIERARCHY form, so a
|
|
75
|
+
* `:superadmin` key would pass too and this package holds no such variable — plus
|
|
76
|
+
* `RequireHumanAnchor` on create/update/delete and `RequireUnlineagedCaller` on create
|
|
77
|
+
* (`lib/loopctl_web/controllers/intake_source_controller.ex:27-29`). That last one is why a
|
|
78
|
+
* global `LOOPCTL_API_KEY` must not stand in: creating a source MINTS A CREDENTIAL that
|
|
79
|
+
* belongs to no dispatch lineage, so a caller whose own key a dispatch minted is refused
|
|
80
|
+
* `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`.
|
|
81
|
+
*
|
|
82
|
+
* ## `repo_full_name` IS NOT SHAPE-CHECKED HERE, AND THAT IS A DECISION
|
|
83
|
+
*
|
|
84
|
+
* The server's refusal is a 422 naming the field with `must be owner/name`, which is
|
|
85
|
+
* unambiguous and needs no client to disambiguate it — unlike the UUID checks below, which
|
|
86
|
+
* exist because a malformed path id and an unknown one produce the SAME 404
|
|
87
|
+
* (the long comment over `uuid()` in `lib/delivery-loop.js` has the trace). Mirroring
|
|
88
|
+
* `Source.repo_format/0` here would put a second copy of that regex in a second language,
|
|
89
|
+
* free to drift, to catch a refusal the server already words better.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import nodePath from "node:path";
|
|
93
|
+
import os from "node:os";
|
|
94
|
+
import defaultFs from "node:fs/promises";
|
|
95
|
+
|
|
96
|
+
import { uuid as uuidRefusal } from "./delivery-loop.js";
|
|
97
|
+
import { expandHome, TOKEN_DIR_MODE, TOKEN_FILE_FLAGS, TOKEN_FILE_MODE } from "./runners.js";
|
|
98
|
+
|
|
99
|
+
export const SOURCES_PATH = "/api/v1/intake/sources";
|
|
100
|
+
|
|
101
|
+
const MISSING_USER_KEY =
|
|
102
|
+
"No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to use the " +
|
|
103
|
+
"intake-source tools (intake_source_enroll, intake_source_list, intake_source_update, " +
|
|
104
|
+
"intake_source_revoke). The create path additionally requires a key NO DISPATCH MINTED — " +
|
|
105
|
+
"it mints the webhook secret, which belongs to no lineage — so an ephemeral dispatch key " +
|
|
106
|
+
"is refused 403 api_key_mint_forbidden however privileged it is.";
|
|
107
|
+
|
|
108
|
+
function refuse(body) {
|
|
109
|
+
return { error: true, status: 0, body };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `target_epic_id` when the caller sent SOMETHING for it — the shared `uuid()` check, except
|
|
114
|
+
* for a blank value.
|
|
115
|
+
*
|
|
116
|
+
* `uuid()` answers "`target_epic_id` is required" for an empty or blank string, which is the
|
|
117
|
+
* OPPOSITE of the truth for this field: it is optional everywhere it appears. A caller that
|
|
118
|
+
* has read "an explicit null CLEARS it" and cannot emit a JSON null — a model filling a
|
|
119
|
+
* `type: "string"` schema — sends `""` to mean the clear, is told the optional field is
|
|
120
|
+
* required, and its likeliest next move is to invent an epic id, which is a WRONG epic rather
|
|
121
|
+
* than a refused call.
|
|
122
|
+
*
|
|
123
|
+
* A blank is refused rather than TAKEN AS the clear, and the asymmetry is deliberate: on
|
|
124
|
+
* update, clearing is a real mutation that returns the source to escalating every report to a
|
|
125
|
+
* human, and `""` is equally consistent with a caller whose variable was empty by accident.
|
|
126
|
+
* On enrol, accepting it would silently produce the source with no epic that this tool's own
|
|
127
|
+
* description warns is not a neutral default. Both cases refuse and name the remedy.
|
|
128
|
+
*/
|
|
129
|
+
function epicRefusal(value, { clearable }) {
|
|
130
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
131
|
+
return refuse(
|
|
132
|
+
"`target_epic_id` is OPTIONAL, and an empty string is not how to say so. " +
|
|
133
|
+
(clearable
|
|
134
|
+
? "Send null to CLEAR the epic (which returns the source to escalating every report " +
|
|
135
|
+
"to a human), or leave the field out entirely to leave it exactly as it is."
|
|
136
|
+
: "Leave the field out entirely, which enrols the source with no epic — every " +
|
|
137
|
+
"report from it is then escalated to a human until intake_source_update names " +
|
|
138
|
+
"one.") +
|
|
139
|
+
" Otherwise send the epic's UUID.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return uuidRefusal(value, "target_epic_id");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sourcePath(sourceId) {
|
|
147
|
+
return `${SOURCES_PATH}/${encodeURIComponent(sourceId)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The source fields this client will put in a tool result, named one by one.
|
|
152
|
+
*
|
|
153
|
+
* AN ALLOWLIST IN THIS PROCESS, not a restatement of the server's. Every tool result is
|
|
154
|
+
* JSON-stringified into the session transcript and into `~/.claude/audit/`, so echoing the
|
|
155
|
+
* server's object verbatim would rest this module's headline property — the webhook secret
|
|
156
|
+
* never enters a transcript — entirely on `@derive {Jason.Encoder, only: [...]}` in
|
|
157
|
+
* `lib/loopctl/intake/source.ex` continuing to exclude it. One commit adding `:webhook_secret`
|
|
158
|
+
* back to that list, or adding a field derived from it, would carry it into every transcript
|
|
159
|
+
* from then on with nothing here to stop it. `enrollRunner` reshapes its response for exactly
|
|
160
|
+
* this reason (`lib/runners.js`), and this is the same defence.
|
|
161
|
+
*
|
|
162
|
+
* The cost is that a genuinely new server field is invisible until this list names it. That is
|
|
163
|
+
* the intended direction of the failure: a missing field is a visible gap, a leaked secret is
|
|
164
|
+
* not.
|
|
165
|
+
*/
|
|
166
|
+
export function publicSource(source) {
|
|
167
|
+
if (!source || typeof source !== "object") return source;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: source.id,
|
|
171
|
+
project_id: source.project_id,
|
|
172
|
+
repo_full_name: source.repo_full_name,
|
|
173
|
+
base_branch: source.base_branch,
|
|
174
|
+
target_epic_id: source.target_epic_id,
|
|
175
|
+
revoked_at: source.revoked_at,
|
|
176
|
+
inserted_at: source.inserted_at,
|
|
177
|
+
updated_at: source.updated_at,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The absolute URL to configure on the GitHub webhook, from the `webhook_path` the server
|
|
183
|
+
* returned. The path is joined to the server this process is talking to, because a webhook
|
|
184
|
+
* pointed at a different host than the one holding the source is a delivery that can never
|
|
185
|
+
* authenticate.
|
|
186
|
+
*/
|
|
187
|
+
export function webhookUrl(baseUrl, webhookPath) {
|
|
188
|
+
const base = typeof baseUrl === "string" ? baseUrl.replace(/\/+$/, "") : "";
|
|
189
|
+
return `${base}${webhookPath}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `POST /api/v1/intake/sources` — bind a GitHub repository to a work project and write the
|
|
194
|
+
* webhook secret to `secret_file`, never returning it.
|
|
195
|
+
*
|
|
196
|
+
* Resolves to `{ source, webhook_url, secret_file }` or an `{ error: true, status, body }`
|
|
197
|
+
* shape. It never throws.
|
|
198
|
+
*/
|
|
199
|
+
export async function enrollIntakeSource(
|
|
200
|
+
{ repo_full_name, project_id, target_epic_id, base_branch, secret_file } = {},
|
|
201
|
+
{ userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
|
|
202
|
+
) {
|
|
203
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
204
|
+
|
|
205
|
+
if (typeof repo_full_name !== "string" || repo_full_name.trim() === "") {
|
|
206
|
+
return refuse(
|
|
207
|
+
"`repo_full_name` is required: the repository as `owner/name`, e.g. " +
|
|
208
|
+
"`mkreyman/home_care_billing`. Not a URL.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const badProject = uuidRefusal(project_id, "project_id");
|
|
213
|
+
if (badProject) return badProject;
|
|
214
|
+
|
|
215
|
+
// OPTIONAL. Absent means the question has not been answered, and a report from a source
|
|
216
|
+
// that names no epic is ESCALATED to a human rather than landing in an epic chosen for it
|
|
217
|
+
// (`lib/loopctl/intake/source.ex`). An explicit null means the same thing on create.
|
|
218
|
+
if (target_epic_id !== undefined && target_epic_id !== null) {
|
|
219
|
+
const badEpic = epicRefusal(target_epic_id, { clearable: false });
|
|
220
|
+
if (badEpic) return badEpic;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// OPTIONAL, and read by PRESENCE at the server: omitted, the column takes its `master`
|
|
224
|
+
// default; named, the value is validated. There is no cleared state for it — every dispatch
|
|
225
|
+
// must name a branch to cut from — so a null or an empty string is a 422 there and is
|
|
226
|
+
// refused here, where the reason can be stated. This is the same shape `updateIntakeSource`
|
|
227
|
+
// refuses, and the two must not disagree about what a valid branch is.
|
|
228
|
+
if (base_branch !== undefined) {
|
|
229
|
+
if (typeof base_branch !== "string" || base_branch.trim() === "") {
|
|
230
|
+
return refuse(
|
|
231
|
+
"`base_branch` must be a non-empty branch name, or must be left out entirely. It " +
|
|
232
|
+
"cannot be null or blank: every dispatch for this repository is cut from it, so " +
|
|
233
|
+
"there is no cleared state for it and the server answers 422. Leave it out for " +
|
|
234
|
+
"`master`, or send `main` for a repository created on GitHub since 2020.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof secret_file !== "string" || secret_file.trim() === "") {
|
|
240
|
+
return refuse(
|
|
241
|
+
"`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
|
|
242
|
+
"The server returns that secret ONCE and it can never be read again, and a tool " +
|
|
243
|
+
"result lands in the session transcript and the audit log — so it is written to a " +
|
|
244
|
+
"file instead, and GitHub is configured from that file.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const secretPath = expandHome(secret_file, homedir);
|
|
249
|
+
if (!nodePath.isAbsolute(secretPath)) {
|
|
250
|
+
return refuse(`secret_file must be absolute or start with ~/ (got '${secret_file}').`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let handle;
|
|
254
|
+
try {
|
|
255
|
+
await fs.mkdir(nodePath.dirname(secretPath), { recursive: true, mode: TOKEN_DIR_MODE });
|
|
256
|
+
handle = await fs.open(secretPath, TOKEN_FILE_FLAGS, TOKEN_FILE_MODE);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err?.code === "EEXIST") {
|
|
259
|
+
return refuse(
|
|
260
|
+
`secret_file '${secretPath}' already exists; refusing to overwrite it. Nothing was ` +
|
|
261
|
+
"enrolled. Choose a new path, or remove the file yourself if its source is revoked.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return refuse(
|
|
265
|
+
`Could not create secret_file '${secretPath}' (${err?.code || "error"}). Nothing was ` +
|
|
266
|
+
"enrolled.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The identity of the file this call created, taken BEFORE anything is written, so the file
|
|
271
|
+
// can still be told apart from a replacement if the handle later becomes unusable.
|
|
272
|
+
let opened;
|
|
273
|
+
try {
|
|
274
|
+
opened = await handle.stat();
|
|
275
|
+
} catch (err) {
|
|
276
|
+
await closeQuietly(handle);
|
|
277
|
+
return refuse(
|
|
278
|
+
`Could not stat the new secret_file '${secretPath}' (${err?.code || "error"}). Nothing ` +
|
|
279
|
+
"was enrolled; an empty file may remain at that path.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const body = { repo_full_name, project_id };
|
|
284
|
+
if (target_epic_id !== undefined && target_epic_id !== null) {
|
|
285
|
+
body.target_epic_id = target_epic_id;
|
|
286
|
+
}
|
|
287
|
+
if (base_branch !== undefined) body.base_branch = base_branch;
|
|
288
|
+
|
|
289
|
+
let result;
|
|
290
|
+
try {
|
|
291
|
+
result = await apiCall("POST", SOURCES_PATH, body);
|
|
292
|
+
} catch {
|
|
293
|
+
result = { error: true, status: 0, body: "Intake-source request failed." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A 4xx is a refusal: nothing was committed, and its body is the server's error, which
|
|
297
|
+
// carries the code the caller needs. Pass it through as it came.
|
|
298
|
+
if (result && result.error === true && result.status >= 400 && result.status < 500) {
|
|
299
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
300
|
+
return removed ? result : { ...result, secret_file_not_removed: secretPath };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const source = result && result.error !== true ? result.source : undefined;
|
|
304
|
+
const secret = result && result.error !== true ? result.webhook_secret : undefined;
|
|
305
|
+
const webhookPath = result && result.error !== true ? result.webhook_path : undefined;
|
|
306
|
+
|
|
307
|
+
const sourceId =
|
|
308
|
+
source && typeof source.id === "string" && source.id !== "" ? source.id : undefined;
|
|
309
|
+
const sentPath = typeof webhookPath === "string" && webhookPath !== "" ? webhookPath : undefined;
|
|
310
|
+
const usableSecret = typeof secret === "string" && secret !== "";
|
|
311
|
+
|
|
312
|
+
// A 2xx carrying a usable source AND its secret but no `webhook_path` is RECOVERABLE, and
|
|
313
|
+
// discarding it throws away the one thing that cannot be recovered: the secret is returned
|
|
314
|
+
// once. The path is a pure function of the id — `/api/v1/intake/github/<id>`, the route this
|
|
315
|
+
// module's header names — so it is derived rather than lost. A FALLBACK only: what the
|
|
316
|
+
// server sent always wins, and the result says when the URL was derived, because a derived
|
|
317
|
+
// one is a claim this process is making and not something the server answered.
|
|
318
|
+
const derivedPath = sentPath === undefined && sourceId !== undefined;
|
|
319
|
+
const path = derivedPath ? `/api/v1/intake/github/${encodeURIComponent(sourceId)}` : sentPath;
|
|
320
|
+
|
|
321
|
+
if (!sourceId || !usableSecret || path === undefined) {
|
|
322
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
323
|
+
return ambiguousEnrollment(result, apiCall, sourceId, repo_full_name, secretPath, removed);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await handle.writeFile(secret);
|
|
328
|
+
await handle.sync();
|
|
329
|
+
await handle.close();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
332
|
+
const revoked = await revoke(apiCall, source.id);
|
|
333
|
+
return refuse(
|
|
334
|
+
`The intake source for '${repo_full_name}' (${source.id}) was created, but its webhook ` +
|
|
335
|
+
`secret could not be written to '${secretPath}' (${err?.code || "error"}). The secret ` +
|
|
336
|
+
"is returned once and cannot be re-read, so the source is unusable and " +
|
|
337
|
+
(revoked.ok
|
|
338
|
+
? "was revoked. "
|
|
339
|
+
: `revoking it FAILED (${revoked.detail}); revoke it with intake_source_revoke ` +
|
|
340
|
+
`source_id ${source.id} — until you do, a second source for this repository is ` +
|
|
341
|
+
"refused 422. ") +
|
|
342
|
+
reservationOutcome(secretPath, removed),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
source: publicSource(source),
|
|
348
|
+
webhook_url: webhookUrl(baseUrl, path),
|
|
349
|
+
...(derivedPath ? { webhook_url_derived: true } : {}),
|
|
350
|
+
secret_file: secretPath,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Every outcome that is neither a 4xx refusal nor a well-formed creation: a timeout, a
|
|
356
|
+
* network error, a 5xx from the edge after the commit, or a 2xx whose body did not parse or
|
|
357
|
+
* lacks the secret. NO RESPONSE BODY IS EVER ECHOED — a 2xx body that failed to parse is the
|
|
358
|
+
* creation itself, secret included, and `apiCall` puts its first 200 characters in `body`.
|
|
359
|
+
*
|
|
360
|
+
* WHEN THE RESPONSE PROVES AN ID, THE SOURCE IS REVOKED HERE, exactly as `enrollRunner` does:
|
|
361
|
+
* reaching this function means the secret cannot be relied on, so the source can never
|
|
362
|
+
* authenticate a delivery while still holding the repository's unique slot. Leaving that to
|
|
363
|
+
* the operator asked them to do by hand what this process could already prove and do itself.
|
|
364
|
+
*
|
|
365
|
+
* With NO id — an unparseable body, a timeout — the recovery names the tools instead, because
|
|
366
|
+
* it can: an ACTIVE source is unique per repository (`intake_sources_active_repo_uidx`), so
|
|
367
|
+
* the list identifies it exactly.
|
|
368
|
+
*/
|
|
369
|
+
async function ambiguousEnrollment(result, apiCall, sourceId, repoFullName, secretPath, removed) {
|
|
370
|
+
const status = result && Number.isInteger(result.status) ? result.status : undefined;
|
|
371
|
+
|
|
372
|
+
const what =
|
|
373
|
+
status === 0 && typeof result.body === "string"
|
|
374
|
+
? `Intake-source outcome unknown (${result.body}).`
|
|
375
|
+
: `Intake-source outcome unknown (HTTP ${status ?? "?"}; response body withheld: it may ` +
|
|
376
|
+
"contain the webhook secret).";
|
|
377
|
+
|
|
378
|
+
let next;
|
|
379
|
+
if (sourceId) {
|
|
380
|
+
const revoked = await revoke(apiCall, sourceId);
|
|
381
|
+
next = revoked.ok
|
|
382
|
+
? `The response identified source ${sourceId}; it was revoked, since its secret cannot ` +
|
|
383
|
+
"be recovered and an unusable source holds this repository's unique slot. Enrol again."
|
|
384
|
+
: `The response identified source ${sourceId}, but revoking it FAILED ` +
|
|
385
|
+
`(${revoked.detail}); revoke it with intake_source_revoke source_id ${sourceId} — ` +
|
|
386
|
+
"until you do, a second source for this repository is refused 422.";
|
|
387
|
+
} else {
|
|
388
|
+
next =
|
|
389
|
+
`A source for '${repoFullName}' may exist with a secret nobody holds — it can never ` +
|
|
390
|
+
"authenticate a delivery, and it holds that repository's unique slot, so a second " +
|
|
391
|
+
"enrolment is refused 422 until it is gone. Run intake_source_list, and revoke any " +
|
|
392
|
+
`active source whose repo_full_name equals '${repoFullName}' IGNORING CASE with ` +
|
|
393
|
+
"intake_source_revoke before enrolling again — the uniqueness is case-insensitive " +
|
|
394
|
+
"(`lower(repo_full_name)`) while the stored value keeps whatever spelling it was " +
|
|
395
|
+
"enrolled with, so an exact match can miss the row that is blocking you.";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
error: true,
|
|
400
|
+
status: status ?? 0,
|
|
401
|
+
body: `${what} ${next} ${reservationOutcome(secretPath, removed)}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** `GET /api/v1/intake/sources`, optionally including revoked ones. */
|
|
406
|
+
export async function listIntakeSources({ include_revoked } = {}, { userKey, apiCall } = {}) {
|
|
407
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
408
|
+
const query = include_revoked ? "?include_revoked=true" : "";
|
|
409
|
+
const result = await apiCall("GET", `${SOURCES_PATH}${query}`, null);
|
|
410
|
+
|
|
411
|
+
// RESHAPED, for the reason in `publicSource`: a listing is the widest surface here — every
|
|
412
|
+
// source the tenant has, in one tool result — so a server-side widening of what a source
|
|
413
|
+
// serialises to would land in the transcript N times rather than once. An error passes
|
|
414
|
+
// through untouched; only the rows are named.
|
|
415
|
+
if (!result || result.error === true || !Array.isArray(result.sources)) return result;
|
|
416
|
+
|
|
417
|
+
return { ...result, sources: result.sources.map(publicSource) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `PATCH /api/v1/intake/sources/:id` — set the epic triaged stories land in, and/or the
|
|
422
|
+
* branch dispatches for this repository are cut from.
|
|
423
|
+
*
|
|
424
|
+
* PRESENCE DECIDES, on both fields, and the controller reads it with `Map.fetch/2` — see
|
|
425
|
+
* `LoopctlWeb.IntakeSourceController.update/2` and its `put_if_present/4`: a field you do not
|
|
426
|
+
* send is left exactly as it was. So `undefined` here means "not named" and is omitted from
|
|
427
|
+
* the body. (Named by FUNCTION, not by line range: the range this cited rotted inside one PR,
|
|
428
|
+
* when that same PR added `base_branch` to `create/2` above it.)
|
|
429
|
+
*
|
|
430
|
+
* `target_epic_id: null` IS FORWARDED, because null is the endpoint's only way to clear the
|
|
431
|
+
* target and clearing it is a real operation — a source that names no epic escalates every
|
|
432
|
+
* report to a human instead of filing a story. That is the opposite of `update_story`, which
|
|
433
|
+
* refuses a null because the server would drop it and answer 200 having changed nothing
|
|
434
|
+
* (`lib/story-update.js`). The hazard is on the caller and the description says so in the
|
|
435
|
+
* imperative: do not send `target_epic_id: null` to mean "I am not changing this".
|
|
436
|
+
*
|
|
437
|
+
* `base_branch: null` is refused here. It is NOT nullable — every `implement` dispatch must
|
|
438
|
+
* name a branch to cut from, so there is no unanswered state for it — and the server's cast
|
|
439
|
+
* uses `empty_values: []` precisely so that a caller who SENT a value gets an answer about
|
|
440
|
+
* the value it sent, which is a 422.
|
|
441
|
+
*/
|
|
442
|
+
export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
|
|
443
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
444
|
+
|
|
445
|
+
const bad = uuidRefusal(args.source_id, "source_id");
|
|
446
|
+
if (bad) return bad;
|
|
447
|
+
|
|
448
|
+
const body = {};
|
|
449
|
+
|
|
450
|
+
if (args.target_epic_id !== undefined) {
|
|
451
|
+
if (args.target_epic_id !== null) {
|
|
452
|
+
const badEpic = epicRefusal(args.target_epic_id, { clearable: true });
|
|
453
|
+
if (badEpic) return badEpic;
|
|
454
|
+
}
|
|
455
|
+
body.target_epic_id = args.target_epic_id;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (args.base_branch !== undefined) {
|
|
459
|
+
if (args.base_branch === null) {
|
|
460
|
+
return refuse(
|
|
461
|
+
"`base_branch` cannot be null: every dispatch must name a branch to cut from, so " +
|
|
462
|
+
"there is no cleared state for it and the server answers 422. Send the branch you " +
|
|
463
|
+
"want (`master`, `main`, …), or leave the field out to keep the current one.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
body.base_branch = args.base_branch;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (Object.keys(body).length === 0) {
|
|
470
|
+
return refuse(
|
|
471
|
+
"Nothing to update. Name at least one of `target_epic_id` (null clears it) or " +
|
|
472
|
+
"`base_branch`. A body carrying neither is refused 422 `nothing_to_update`, because a " +
|
|
473
|
+
"field you do not send is left exactly as it was.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const result = await apiCall("PATCH", sourcePath(args.source_id), body);
|
|
478
|
+
|
|
479
|
+
if (!result || result.error === true || !result.source) return result;
|
|
480
|
+
|
|
481
|
+
return { ...result, source: publicSource(result.source) };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* `DELETE /api/v1/intake/sources/:id` — REVOKE the source. The row is kept.
|
|
486
|
+
*
|
|
487
|
+
* Named for what it does rather than for its HTTP verb: `Loopctl.Intake.revoke_source/3` sets
|
|
488
|
+
* `revoked_at`, clears `target_epic_id` and appends an `intake_source_revoked` audit entry.
|
|
489
|
+
* Nothing is destroyed, deliveries already received are kept, and the row still comes back
|
|
490
|
+
* from `intake_source_list` with `include_revoked`.
|
|
491
|
+
*/
|
|
492
|
+
export async function revokeIntakeSource({ source_id } = {}, { userKey, apiCall } = {}) {
|
|
493
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
494
|
+
|
|
495
|
+
const bad = uuidRefusal(source_id, "source_id");
|
|
496
|
+
if (bad) return bad;
|
|
497
|
+
|
|
498
|
+
const result = await apiCall("DELETE", sourcePath(source_id), null);
|
|
499
|
+
|
|
500
|
+
// The fourth path that carries a source object back, reshaped for the same reason as the
|
|
501
|
+
// other three. The reviewer named enrol, list and update; revoke returns the revoked row.
|
|
502
|
+
if (!result || result.error === true || !result.source) return result;
|
|
503
|
+
|
|
504
|
+
return { ...result, source: publicSource(result.source) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function revoke(apiCall, sourceId) {
|
|
508
|
+
try {
|
|
509
|
+
const result = await apiCall("DELETE", sourcePath(sourceId), null);
|
|
510
|
+
if (result && result.error) return { ok: false, detail: `status ${result.status}` };
|
|
511
|
+
return { ok: true };
|
|
512
|
+
} catch {
|
|
513
|
+
return { ok: false, detail: "request error" };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function reservationOutcome(secretPath, removed) {
|
|
518
|
+
return removed
|
|
519
|
+
? `The secret_file '${secretPath}' was removed; the same path can be used again.`
|
|
520
|
+
: `The secret_file '${secretPath}' could NOT be removed: delete it before enrolling again ` +
|
|
521
|
+
"at that path.";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function closeQuietly(handle) {
|
|
525
|
+
try {
|
|
526
|
+
await handle.close();
|
|
527
|
+
} catch {
|
|
528
|
+
// already closed, or the close is what failed
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Close the handle and remove the file this call created. Removal is by path, so it first
|
|
534
|
+
* checks that the path still names the file identified by `opened`, the stat taken right
|
|
535
|
+
* after the open. Resolves to whether the path no longer holds that file.
|
|
536
|
+
*/
|
|
537
|
+
async function discardReservation(fs, handle, secretPath, opened) {
|
|
538
|
+
await closeQuietly(handle);
|
|
539
|
+
|
|
540
|
+
let current;
|
|
541
|
+
try {
|
|
542
|
+
current = await fs.lstat(secretPath);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return err?.code === "ENOENT";
|
|
545
|
+
}
|
|
546
|
+
if (current.ino !== opened.ino || current.dev !== opened.dev) return false;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
await fs.unlink(secretPath);
|
|
550
|
+
return true;
|
|
551
|
+
} catch (err) {
|
|
552
|
+
return err?.code === "ENOENT";
|
|
553
|
+
}
|
|
554
|
+
}
|