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.
@@ -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:786-791`) as the second clause of its
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:3467-3470`),
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:1438`); then, in the SAME
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
- * ## IT FREES THE STAGE. IT DOES NOT MAKE THE STORY PLACEABLE.
250
+ * ## A DELIVERY STORY GOES TO `escalated`, NOT BACK TO THE QUEUE
251
251
  *
252
- * This comment used to say the second half was "what makes the story PLACEABLE again", and
253
- * that is false. `Placement.claimable/2` (`placement.ex:482-490`) wants `agent_status ==
254
- * :contracted` AND stage `queued`; the release leaves the story at `:pending`, and
255
- * `@valid_transitions` (`progress.ex:3480`) has `pending: :contracted` and nothing else — so
256
- * `place_dispatch` run straight afterwards answers the IDENTICAL 409 `invalid_transition`.
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
- * The remedy is THREE CALLS, in this order, and the tool descriptions say so:
259
- * `force_unclaim_story`, then `contract_story`, then `place_dispatch`. (An earlier draft called
260
- * it "two steps" and then named three, which is worse than saying nothing: an operator counting
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:562`, `:706-711`),
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:1612`) releases over `:runner_lost` and
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.101.0",
3
+ "version": "2.104.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",