loopctl-mcp-server 2.116.0 → 3.1.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 +6 -13
- package/index.js +114 -414
- package/lib/delivery-loop.js +1 -1
- package/lib/github-credential.js +2 -2
- package/lib/intake-sources.js +181 -22
- package/lib/story-update.js +1 -1
- package/package.json +1 -1
- package/lib/threads.js +0 -295
package/lib/delivery-loop.js
CHANGED
|
@@ -84,7 +84,7 @@ function refuse(body) {
|
|
|
84
84
|
* That 404 is the problem, not a 500. It is BYTE-IDENTICAL to the 404 for a perfectly
|
|
85
85
|
* well-formed id that names no story — `FallbackController` answers `{:error, :not_found}` with
|
|
86
86
|
* the same `%{error: %{status: 404, message: "Not found"}}`
|
|
87
|
-
* (`lib/loopctl_web/fallback_controller.ex:
|
|
87
|
+
* (`lib/loopctl_web/fallback_controller.ex:84-88`) — so it cannot tell an operator which
|
|
88
88
|
* of the two happened, and the two have opposite remedies: re-read the argument you passed, or
|
|
89
89
|
* go find the right story. The check here can tell them apart, and does it without a round trip.
|
|
90
90
|
*
|
package/lib/github-credential.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* `github_credential`, `set_github_credential`, `clear_github_credential` — the tenant's own
|
|
3
3
|
* GitHub token (#936), `GET|PUT|DELETE /api/v1/tenants/me/github-credential`.
|
|
4
4
|
*
|
|
5
|
-
* loopctl calls GitHub for a tenant in the merge gate, story and post-deploy verification
|
|
6
|
-
*
|
|
5
|
+
* loopctl calls GitHub for a tenant in the merge gate, story and post-deploy verification
|
|
6
|
+
* and issue closing. Each call authenticates with the tenant's own
|
|
7
7
|
* token when one is set, else with the operator's token for the (tenant, repository) pairs
|
|
8
8
|
* the operator named in VERIFICATION_OPERATOR_TOKEN_TENANTS, else not at all
|
|
9
9
|
* (`credential_unavailable`). The tenant's token is what lets loopctl serve a repository
|
package/lib/intake-sources.js
CHANGED
|
@@ -74,10 +74,28 @@
|
|
|
74
74
|
* is. The controller mounts `RequireRole, role: :user` — the HIERARCHY form, so a
|
|
75
75
|
* `:superadmin` key would pass too and this package holds no such variable — plus
|
|
76
76
|
* `RequireHumanAnchor` on create/update/delete and `RequireUnlineagedCaller` on create
|
|
77
|
-
* (`lib/loopctl_web/controllers/intake_source_controller.ex:
|
|
77
|
+
* (`lib/loopctl_web/controllers/intake_source_controller.ex:34-36`). That last one is why a
|
|
78
78
|
* global `LOOPCTL_API_KEY` must not stand in: creating a source MINTS A CREDENTIAL that
|
|
79
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`.
|
|
80
|
+
* `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`. An update that
|
|
81
|
+
* names a `chg` credential or sets `mode` to `chg` is held to the same ceiling under its own
|
|
82
|
+
* code, `403 chg_registration_forbidden`, whose remedy is the operator key itself.
|
|
83
|
+
*
|
|
84
|
+
* ## MODE `chg` AND ITS TWO CREDENTIALS (US-47.2, AC-47.7.2)
|
|
85
|
+
*
|
|
86
|
+
* A source in `mode: "chg"` has its changes landed by the change protocol (`chg submit`), and
|
|
87
|
+
* loopctl's merge precondition refuses every story on it with `409 change_protocol_enrolled`.
|
|
88
|
+
* It needs BOTH `change_events_secret` — the HMAC key chg-server signs its event webhook with,
|
|
89
|
+
* which the OPERATOR supplies because chg-server must hold the same value — and
|
|
90
|
+
* `chg_root_dispatch_id`, the parentless orchestrator-role dispatch chg-server's key was minted
|
|
91
|
+
* from (`422 chg_root_invalid` for anything else, another tenant's included).
|
|
92
|
+
*
|
|
93
|
+
* The secret is WRITE-ONLY on both sides: the server never serialises it, and `publicSource`
|
|
94
|
+
* below never names it, so no tool result carries it. A tool INPUT is recorded in the
|
|
95
|
+
* transcript too, so `change_events_secret_file` is offered beside it: the tool reads the
|
|
96
|
+
* value from that file and it never passes through a transcript at all. Exactly one trailing
|
|
97
|
+
* newline is stripped (what `openssl rand -hex 32 > file` writes); chg-server must hold the
|
|
98
|
+
* value without it.
|
|
81
99
|
*
|
|
82
100
|
* ## `repo_full_name` IS NOT SHAPE-CHECKED HERE, AND THAT IS A DECISION
|
|
83
101
|
*
|
|
@@ -144,28 +162,142 @@ function epicRefusal(value, { clearable }) {
|
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
/**
|
|
147
|
-
* The
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* refused here where the reason can be stated.
|
|
165
|
+
* The merge routes a source may take: `pr`, the default, and `chg` (US-47.2), where the change
|
|
166
|
+
* protocol lands the repository's changes and the PR route is closed. NOT nullable — the
|
|
167
|
+
* server answers 422 — so a null is refused here where the reason can be stated.
|
|
151
168
|
*/
|
|
152
|
-
export const SOURCE_MODES = ["pr", "
|
|
169
|
+
export const SOURCE_MODES = ["pr", "chg"];
|
|
153
170
|
|
|
154
171
|
export function modeRefusal(mode) {
|
|
155
172
|
if (SOURCE_MODES.includes(mode)) return null;
|
|
156
173
|
|
|
157
174
|
return refuse(
|
|
158
|
-
"`mode` must be `pr` or `
|
|
159
|
-
"
|
|
160
|
-
|
|
175
|
+
"`mode` must be `pr` or `chg`, or must be left out entirely (enrolment then takes `pr`, " +
|
|
176
|
+
"and an update keeps the current value). It cannot be null.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `chg` credentials a call NAMED, resolved into the request body fields, or a refusal.
|
|
182
|
+
*
|
|
183
|
+
* Resolves to `{ fields }` or `{ refusal }`. `clearable` is the update path, where an explicit
|
|
184
|
+
* null clears a credential on a `pr` source (the server refuses it on a `chg` one). No branch
|
|
185
|
+
* of this function puts the secret's value in a message.
|
|
186
|
+
*/
|
|
187
|
+
export async function chgCredentials(args, { clearable, fs = defaultFs, homedir = os.homedir() }) {
|
|
188
|
+
const fields = {};
|
|
189
|
+
const { change_events_secret: secret, change_events_secret_file: secretFile } = args;
|
|
190
|
+
|
|
191
|
+
if (secret !== undefined && secretFile !== undefined) {
|
|
192
|
+
return {
|
|
193
|
+
refusal: refuse(
|
|
194
|
+
"Send `change_events_secret` OR `change_events_secret_file`, not both: they are two " +
|
|
195
|
+
"ways of naming the same value.",
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (secret !== undefined) {
|
|
201
|
+
if (secret === null && clearable) {
|
|
202
|
+
fields.change_events_secret = null;
|
|
203
|
+
} else if (typeof secret !== "string" || secret === "") {
|
|
204
|
+
return {
|
|
205
|
+
refusal: refuse(
|
|
206
|
+
"`change_events_secret` must be a non-empty string (the server holds it to " +
|
|
207
|
+
"32-256 bytes)" +
|
|
208
|
+
(clearable ? ", or null to clear it on a `pr` source." : ".") +
|
|
209
|
+
" Prefer `change_events_secret_file`, which keeps the value out of the transcript.",
|
|
210
|
+
),
|
|
211
|
+
};
|
|
212
|
+
} else {
|
|
213
|
+
fields.change_events_secret = secret;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (secretFile !== undefined) {
|
|
218
|
+
const read = await readSecretFile(secretFile, { fs, homedir });
|
|
219
|
+
if (read.refusal) return read;
|
|
220
|
+
fields.change_events_secret = read.secret;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const root = args.chg_root_dispatch_id;
|
|
224
|
+
|
|
225
|
+
if (root !== undefined) {
|
|
226
|
+
if (root === null && clearable) {
|
|
227
|
+
fields.chg_root_dispatch_id = null;
|
|
228
|
+
} else {
|
|
229
|
+
const badRoot = uuidRefusal(root, "chg_root_dispatch_id");
|
|
230
|
+
if (badRoot) return { refusal: badRoot };
|
|
231
|
+
fields.chg_root_dispatch_id = root;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { fields };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readSecretFile(secretFile, { fs, homedir }) {
|
|
239
|
+
if (typeof secretFile !== "string" || secretFile.trim() === "") {
|
|
240
|
+
return {
|
|
241
|
+
refusal: refuse("`change_events_secret_file` must be a path to the file holding the secret."),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const path = expandHome(secretFile, homedir);
|
|
246
|
+
if (!nodePath.isAbsolute(path)) {
|
|
247
|
+
return {
|
|
248
|
+
refusal: refuse(
|
|
249
|
+
`change_events_secret_file must be absolute or start with ~/ (got '${secretFile}').`,
|
|
250
|
+
),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let raw;
|
|
255
|
+
try {
|
|
256
|
+
raw = await fs.readFile(path, "utf8");
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return {
|
|
259
|
+
refusal: refuse(
|
|
260
|
+
`Could not read change_events_secret_file '${path}' (${err?.code || "error"}). ` +
|
|
261
|
+
"Nothing was sent.",
|
|
262
|
+
),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const secret = raw.replace(/\r?\n$/, "");
|
|
267
|
+
if (secret === "") {
|
|
268
|
+
return { refusal: refuse(`change_events_secret_file '${path}' is empty. Nothing was sent.`) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { secret };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `mode: "chg"` at ENROLMENT needs both credentials in the same call (there is no earlier
|
|
276
|
+
* stint to keep them from), so their absence is refused here, before a secret file is
|
|
277
|
+
* reserved, with the 422 the server would answer named.
|
|
278
|
+
*/
|
|
279
|
+
function chgEnrolmentRefusal(mode, fields) {
|
|
280
|
+
if (mode !== "chg") return null;
|
|
281
|
+
|
|
282
|
+
const missing = ["change_events_secret", "chg_root_dispatch_id"].filter(
|
|
283
|
+
(field) => fields[field] === undefined,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (missing.length === 0) return null;
|
|
287
|
+
|
|
288
|
+
return refuse(
|
|
289
|
+
`\`mode: chg\` needs ${missing.map((f) => `\`${f}\``).join(" and ")} in the same call; ` +
|
|
290
|
+
"the server answers 422 without them. `chg_root_dispatch_id` is the parentless " +
|
|
291
|
+
"orchestrator-role dispatch chg-server's key was minted from (the operator mints it " +
|
|
292
|
+
"with `dispatch`, no parent), and the secret is the HMAC key chg-server signs its " +
|
|
293
|
+
"events with (`change_events_secret_file` keeps it out of the transcript).",
|
|
161
294
|
);
|
|
162
295
|
}
|
|
163
296
|
|
|
164
297
|
/**
|
|
165
|
-
* The CI checks
|
|
298
|
+
* The CI checks story verification judges a story's commit by (US-26.4.6). A list of
|
|
166
299
|
* distinct, non-blank names; `local-gate` is refused because whoever pushed posts it. The
|
|
167
|
-
* server holds the bounds
|
|
168
|
-
* stored mode this client cannot see, so only the shape is judged here.
|
|
300
|
+
* server holds the bounds, so only the shape is judged here.
|
|
169
301
|
*/
|
|
170
302
|
export function requiredChecksRefusal(checks) {
|
|
171
303
|
// The same shape the server holds (`Source.validate_required_checks/1`): non-blank, no
|
|
@@ -186,8 +318,8 @@ export function requiredChecksRefusal(checks) {
|
|
|
186
318
|
|
|
187
319
|
if (checks.includes("local-gate")) {
|
|
188
320
|
return refuse(
|
|
189
|
-
"`local-gate` can never be a required check: whoever pushed posts it, so
|
|
190
|
-
"
|
|
321
|
+
"`local-gate` can never be a required check: whoever pushed posts it, so it is the " +
|
|
322
|
+
"implementer attesting its own work.",
|
|
191
323
|
);
|
|
192
324
|
}
|
|
193
325
|
|
|
@@ -224,6 +356,8 @@ export function publicSource(source) {
|
|
|
224
356
|
base_branch: source.base_branch,
|
|
225
357
|
mode: source.mode,
|
|
226
358
|
required_checks: source.required_checks,
|
|
359
|
+
// `change_events_secret` is NEVER named here: write-only (US-47.2).
|
|
360
|
+
chg_root_dispatch_id: source.chg_root_dispatch_id,
|
|
227
361
|
target_epic_id: source.target_epic_id,
|
|
228
362
|
revoked_at: source.revoked_at,
|
|
229
363
|
inserted_at: source.inserted_at,
|
|
@@ -258,6 +392,9 @@ export async function enrollIntakeSource(
|
|
|
258
392
|
mode,
|
|
259
393
|
required_checks,
|
|
260
394
|
secret_file,
|
|
395
|
+
change_events_secret,
|
|
396
|
+
change_events_secret_file,
|
|
397
|
+
chg_root_dispatch_id,
|
|
261
398
|
} = {},
|
|
262
399
|
{ userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
|
|
263
400
|
) {
|
|
@@ -298,21 +435,30 @@ export async function enrollIntakeSource(
|
|
|
298
435
|
}
|
|
299
436
|
|
|
300
437
|
// OPTIONAL, read by PRESENCE like the branch: omitted, the source takes `pr`. Named, it
|
|
301
|
-
// must be
|
|
438
|
+
// must be a known mode; anything else (null included) is a 422 at the server and
|
|
302
439
|
// is refused here, before a secret file is reserved.
|
|
303
440
|
if (mode !== undefined) {
|
|
304
441
|
const badMode = modeRefusal(mode);
|
|
305
442
|
if (badMode) return badMode;
|
|
306
443
|
}
|
|
307
444
|
|
|
308
|
-
// OPTIONAL too
|
|
309
|
-
//
|
|
310
|
-
// depends on the stored mode, which only the server knows.
|
|
445
|
+
// OPTIONAL too: the CI checks story verification judges by. Its SHAPE is judged here,
|
|
446
|
+
// before a secret file is reserved.
|
|
311
447
|
if (required_checks !== undefined) {
|
|
312
448
|
const badChecks = requiredChecksRefusal(required_checks);
|
|
313
449
|
if (badChecks) return badChecks;
|
|
314
450
|
}
|
|
315
451
|
|
|
452
|
+
// The `chg` credentials (US-47.2), resolved and judged before a secret file is reserved.
|
|
453
|
+
const chg = await chgCredentials(
|
|
454
|
+
{ change_events_secret, change_events_secret_file, chg_root_dispatch_id },
|
|
455
|
+
{ clearable: false, fs, homedir },
|
|
456
|
+
);
|
|
457
|
+
if (chg.refusal) return chg.refusal;
|
|
458
|
+
|
|
459
|
+
const badChg = chgEnrolmentRefusal(mode, chg.fields);
|
|
460
|
+
if (badChg) return badChg;
|
|
461
|
+
|
|
316
462
|
if (typeof secret_file !== "string" || secret_file.trim() === "") {
|
|
317
463
|
return refuse(
|
|
318
464
|
"`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
|
|
@@ -364,6 +510,7 @@ export async function enrollIntakeSource(
|
|
|
364
510
|
if (base_branch !== undefined) body.base_branch = base_branch;
|
|
365
511
|
if (mode !== undefined) body.mode = mode;
|
|
366
512
|
if (required_checks !== undefined) body.required_checks = required_checks;
|
|
513
|
+
Object.assign(body, chg.fields);
|
|
367
514
|
|
|
368
515
|
let result;
|
|
369
516
|
try {
|
|
@@ -518,7 +665,10 @@ export async function listIntakeSources({ include_revoked } = {}, { userKey, api
|
|
|
518
665
|
* uses `empty_values: []` precisely so that a caller who SENT a value gets an answer about
|
|
519
666
|
* the value it sent, which is a 422.
|
|
520
667
|
*/
|
|
521
|
-
export async function updateIntakeSource(
|
|
668
|
+
export async function updateIntakeSource(
|
|
669
|
+
args = {},
|
|
670
|
+
{ userKey, apiCall, fs = defaultFs, homedir = os.homedir() } = {},
|
|
671
|
+
) {
|
|
522
672
|
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
523
673
|
|
|
524
674
|
const bad = uuidRefusal(args.source_id, "source_id");
|
|
@@ -557,11 +707,20 @@ export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
|
|
|
557
707
|
body.required_checks = args.required_checks;
|
|
558
708
|
}
|
|
559
709
|
|
|
710
|
+
// Switching to `chg` may rely on credentials an earlier `chg` stint left on the row, so their
|
|
711
|
+
// absence here is the SERVER's to judge (422 naming the missing one); only their shape is
|
|
712
|
+
// judged here.
|
|
713
|
+
const chg = await chgCredentials(args, { clearable: true, fs, homedir });
|
|
714
|
+
if (chg.refusal) return chg.refusal;
|
|
715
|
+
Object.assign(body, chg.fields);
|
|
716
|
+
|
|
560
717
|
if (Object.keys(body).length === 0) {
|
|
561
718
|
return refuse(
|
|
562
719
|
"Nothing to update. Name at least one of `target_epic_id` (null clears it), " +
|
|
563
|
-
"`base_branch`, `mode
|
|
564
|
-
"`
|
|
720
|
+
"`base_branch`, `mode`, `required_checks`, `change_events_secret` (or " +
|
|
721
|
+
"`change_events_secret_file`) or `chg_root_dispatch_id`. A body carrying none of them " +
|
|
722
|
+
"is refused 422 `nothing_to_update`, because a field you do not send is left exactly " +
|
|
723
|
+
"as it was.",
|
|
565
724
|
);
|
|
566
725
|
}
|
|
567
726
|
|
package/lib/story-update.js
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
* (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35`), and the body is
|
|
73
73
|
* `LoopctlWeb.ErrorJSON`'s generic `{"error": {"status": 404, "message": "Not found"}}` — byte
|
|
74
74
|
* for byte what `FallbackController` answers for a well-formed id naming no story
|
|
75
|
-
* (`lib/loopctl_web/fallback_controller.ex:
|
|
75
|
+
* (`lib/loopctl_web/fallback_controller.ex:84-88`). Two opposite remedies, one answer.
|
|
76
76
|
*
|
|
77
77
|
* The check is `uuid()`, IMPORTED from `lib/delivery-loop.js` rather than reimplemented, so the
|
|
78
78
|
* refusal a caller reads for a bad `story_id` does not depend on which tool they reached for.
|
package/package.json
CHANGED
package/lib/threads.js
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Change threads (loopctl US-45.1, Epic 45): read a story's thread, record a checkpoint,
|
|
3
|
-
* record an entry; and review on it (US-45.3): request a review, read its payload, record a
|
|
4
|
-
* fix. A review's findings and verdict are NOT here: they travel over the runner socket from
|
|
5
|
-
* the runner loopctl placed the review on.
|
|
6
|
-
*
|
|
7
|
-
* SINGLE SOURCE OF TRUTH. index.js injects `apiCall`; the unit suite runs this code with a
|
|
8
|
-
* recording fake. Keys are selected here, from the injected env, so which key a call travels
|
|
9
|
-
* on is testable behaviour:
|
|
10
|
-
*
|
|
11
|
-
* - thread_checkpoint travels on the key claim_story claims with: LOOPCTL_API_KEY when it is
|
|
12
|
-
* set, else LOOPCTL_AGENT_KEY (the same resolveKey order). The endpoint compares the key's
|
|
13
|
-
* agent with the story's claimant, so the checkpoint must go out on the key that claimed.
|
|
14
|
-
* - thread_entry writes a `message` on the key named by `principal` (agent by default; a
|
|
15
|
-
* person writes on LOOPCTL_USER_KEY).
|
|
16
|
-
* - thread_request_review travels on LOOPCTL_ORCH_KEY (LOOPCTL_USER_KEY for principal user).
|
|
17
|
-
* - thread_fix travels on the key claim_story claims with, as thread_checkpoint does.
|
|
18
|
-
* - thread_get reads on the first key configured, agent first, then LOOPCTL_API_KEY, the
|
|
19
|
-
* orchestrator key and the user key: reads are open to every role.
|
|
20
|
-
*
|
|
21
|
-
* Every call passes the env var it is pinned to as `keyHint`, so a missing key is reported
|
|
22
|
-
* by name rather than as some other feature's configuration error.
|
|
23
|
-
*
|
|
24
|
-
* Only presence of the required fields is checked here. Everything else is the server's
|
|
25
|
-
* judgement and passes through unchanged.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
const PRINCIPAL_KEYS = {
|
|
29
|
-
agent: "LOOPCTL_AGENT_KEY",
|
|
30
|
-
orchestrator: "LOOPCTL_ORCH_KEY",
|
|
31
|
-
user: "LOOPCTL_USER_KEY",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// One literal template per route, so `test/tool-surface.js` resolves each call site to the
|
|
35
|
-
// route it sends (a path built from a suffix argument reads as no route at all).
|
|
36
|
-
export function threadPath(storyId) {
|
|
37
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function checkpointsPath(storyId) {
|
|
41
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/checkpoints`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function entriesPath(storyId) {
|
|
45
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/entries`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function missing(field) {
|
|
49
|
-
return { error: true, status: 0, body: `\`${field}\` is required.` };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function present(value) {
|
|
53
|
-
return typeof value === "string" && value.trim() !== "";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// The key claim_story claimed with (index.js resolveKey: LOOPCTL_API_KEY first), so a
|
|
57
|
-
// checkpoint or an agent-principal entry is sent as the same agent that holds the claim.
|
|
58
|
-
function claimKeyVar(env) {
|
|
59
|
-
return env.LOOPCTL_API_KEY ? "LOOPCTL_API_KEY" : "LOOPCTL_AGENT_KEY";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// `kb_refs` (loopctl #978) is optional; when given it must be an array of strings. The server
|
|
63
|
-
// owns every other rule (UUIDs, the bound, which articles this key can read), so this only
|
|
64
|
-
// stops a call whose shape could never be right, such as one id passed as a bare string.
|
|
65
|
-
function kbRefsShape(kbRefs) {
|
|
66
|
-
return (
|
|
67
|
-
kbRefs === undefined ||
|
|
68
|
-
kbRefs === null ||
|
|
69
|
-
(Array.isArray(kbRefs) && kbRefs.every((id) => typeof id === "string"))
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function badKbRefs() {
|
|
74
|
-
return {
|
|
75
|
-
error: true,
|
|
76
|
-
status: 0,
|
|
77
|
-
body: "`kb_refs` must be an array of knowledge article ids (strings).",
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Drops keys whose value is undefined or null, so an absent optional is not sent as null.
|
|
82
|
-
function compact(obj) {
|
|
83
|
-
return Object.fromEntries(
|
|
84
|
-
Object.entries(obj).filter(([, v]) => v !== undefined && v !== null),
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** `GET /api/v1/stories/:id/thread`. */
|
|
89
|
-
export async function getThread({ story_id, after_seq, limit } = {}, { apiCall, env = process.env } = {}) {
|
|
90
|
-
if (!present(story_id)) return missing("story_id");
|
|
91
|
-
const keyVar =
|
|
92
|
-
["LOOPCTL_AGENT_KEY", "LOOPCTL_API_KEY", "LOOPCTL_ORCH_KEY", "LOOPCTL_USER_KEY"].find(
|
|
93
|
-
(name) => env[name],
|
|
94
|
-
) || "LOOPCTL_AGENT_KEY";
|
|
95
|
-
const query = compact({ after_seq, limit });
|
|
96
|
-
const qs = new URLSearchParams(query).toString();
|
|
97
|
-
return apiCall(
|
|
98
|
-
"GET",
|
|
99
|
-
qs ? `${threadPath(story_id)}?${qs}` : threadPath(story_id),
|
|
100
|
-
null,
|
|
101
|
-
env[keyVar],
|
|
102
|
-
keyVar,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** `POST /api/v1/stories/:id/thread/checkpoints` on the AGENT key. */
|
|
107
|
-
export async function recordCheckpoint(
|
|
108
|
-
{ story_id, claim_epoch, commit_sha, tree_sha, note, kb_refs } = {},
|
|
109
|
-
{ apiCall, env = process.env } = {},
|
|
110
|
-
) {
|
|
111
|
-
if (!present(story_id)) return missing("story_id");
|
|
112
|
-
if (!present(commit_sha)) return missing("commit_sha");
|
|
113
|
-
if (!kbRefsShape(kb_refs)) return badKbRefs();
|
|
114
|
-
if (!present(tree_sha)) return missing("tree_sha");
|
|
115
|
-
if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
|
|
116
|
-
return {
|
|
117
|
-
error: true,
|
|
118
|
-
status: 0,
|
|
119
|
-
body: "`claim_epoch` is required: the non-negative integer your claim returned.",
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return apiCall(
|
|
124
|
-
"POST",
|
|
125
|
-
checkpointsPath(story_id),
|
|
126
|
-
compact({ claim_epoch, commit_sha, tree_sha, note, kb_refs }),
|
|
127
|
-
env[claimKeyVar(env)],
|
|
128
|
-
claimKeyVar(env),
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function stageTransitionsPath(storyId) {
|
|
133
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/stage/transitions`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* `POST /api/v1/stories/:id/stage/transitions` on the key claim_story claimed with (US-45.9):
|
|
138
|
-
* the claimant of an INTERACTIVE thread claim reports its own story's stage transitions, as a
|
|
139
|
-
* runner reports a placed claim's.
|
|
140
|
-
*/
|
|
141
|
-
export async function reportStage(
|
|
142
|
-
{ story_id, claim_epoch, from, to, edge, reason, head_sha } = {},
|
|
143
|
-
{ apiCall, env = process.env } = {},
|
|
144
|
-
) {
|
|
145
|
-
if (!present(story_id)) return missing("story_id");
|
|
146
|
-
if (!present(from)) return missing("from");
|
|
147
|
-
if (!present(to)) return missing("to");
|
|
148
|
-
if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
|
|
149
|
-
return {
|
|
150
|
-
error: true,
|
|
151
|
-
status: 0,
|
|
152
|
-
body: "`claim_epoch` is required: the non-negative integer your claim returned.",
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return apiCall(
|
|
157
|
-
"POST",
|
|
158
|
-
stageTransitionsPath(story_id),
|
|
159
|
-
compact({
|
|
160
|
-
claim_epoch,
|
|
161
|
-
from,
|
|
162
|
-
to,
|
|
163
|
-
edge,
|
|
164
|
-
reason,
|
|
165
|
-
effects: present(head_sha) ? { head_sha } : undefined,
|
|
166
|
-
}),
|
|
167
|
-
env[claimKeyVar(env)],
|
|
168
|
-
claimKeyVar(env),
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** `POST /api/v1/stories/:id/thread/entries` on the key `principal` names. */
|
|
173
|
-
export async function recordEntry(
|
|
174
|
-
{
|
|
175
|
-
story_id,
|
|
176
|
-
kind,
|
|
177
|
-
idempotency_key,
|
|
178
|
-
body,
|
|
179
|
-
checkpoint_id,
|
|
180
|
-
kb_refs,
|
|
181
|
-
principal = "agent",
|
|
182
|
-
} = {},
|
|
183
|
-
{ apiCall, env = process.env } = {},
|
|
184
|
-
) {
|
|
185
|
-
if (!present(story_id)) return missing("story_id");
|
|
186
|
-
if (!present(kind)) return missing("kind");
|
|
187
|
-
if (!present(idempotency_key)) return missing("idempotency_key");
|
|
188
|
-
if (!present(body)) return missing("body");
|
|
189
|
-
if (!kbRefsShape(kb_refs)) return badKbRefs();
|
|
190
|
-
|
|
191
|
-
const keyVar = principal === "agent" ? claimKeyVar(env) : PRINCIPAL_KEYS[principal];
|
|
192
|
-
if (!PRINCIPAL_KEYS[principal]) {
|
|
193
|
-
return {
|
|
194
|
-
error: true,
|
|
195
|
-
status: 0,
|
|
196
|
-
body: "`principal` must be one of agent, orchestrator, user.",
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return apiCall(
|
|
201
|
-
"POST",
|
|
202
|
-
entriesPath(story_id),
|
|
203
|
-
compact({ kind, idempotency_key, body, checkpoint_id, kb_refs }),
|
|
204
|
-
env[keyVar],
|
|
205
|
-
keyVar,
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// --- US-45.3: review on the thread ------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
export function reviewsPath(storyId) {
|
|
212
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/reviews`;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function reviewPath(storyId, reviewId) {
|
|
216
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/reviews/${encodeURIComponent(reviewId)}`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function fixesPath(storyId) {
|
|
220
|
-
return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/fixes`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* `POST /api/v1/stories/:id/thread/reviews` on the orchestrator key (or the user key): ask
|
|
225
|
-
* loopctl to place a review on a runner. No credential comes back; the review's findings and
|
|
226
|
-
* verdict travel over that runner's socket, never through this server.
|
|
227
|
-
*/
|
|
228
|
-
export async function requestReview(
|
|
229
|
-
{
|
|
230
|
-
story_id,
|
|
231
|
-
runner_id,
|
|
232
|
-
dispatch_id,
|
|
233
|
-
wall_clock_seconds,
|
|
234
|
-
max_turns,
|
|
235
|
-
repo,
|
|
236
|
-
base_branch,
|
|
237
|
-
principal = "orchestrator",
|
|
238
|
-
} = {},
|
|
239
|
-
{ apiCall, env = process.env } = {},
|
|
240
|
-
) {
|
|
241
|
-
if (!present(story_id)) return missing("story_id");
|
|
242
|
-
if (!present(runner_id)) return missing("runner_id");
|
|
243
|
-
if (principal !== "orchestrator" && principal !== "user") {
|
|
244
|
-
return { error: true, status: 0, body: "`principal` must be orchestrator or user." };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const keyVar = PRINCIPAL_KEYS[principal];
|
|
248
|
-
return apiCall(
|
|
249
|
-
"POST",
|
|
250
|
-
reviewsPath(story_id),
|
|
251
|
-
compact({ runner_id, dispatch_id, wall_clock_seconds, max_turns, repo, base_branch }),
|
|
252
|
-
env[keyVar],
|
|
253
|
-
keyVar,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** `GET /api/v1/stories/:id/thread/reviews/:review_id` on the first key configured. */
|
|
258
|
-
export async function getReview({ story_id, review_id } = {}, { apiCall, env = process.env } = {}) {
|
|
259
|
-
if (!present(story_id)) return missing("story_id");
|
|
260
|
-
if (!present(review_id)) return missing("review_id");
|
|
261
|
-
const keyVar =
|
|
262
|
-
["LOOPCTL_AGENT_KEY", "LOOPCTL_API_KEY", "LOOPCTL_ORCH_KEY", "LOOPCTL_USER_KEY"].find(
|
|
263
|
-
(name) => env[name],
|
|
264
|
-
) || "LOOPCTL_AGENT_KEY";
|
|
265
|
-
return apiCall("GET", reviewPath(story_id, review_id), null, env[keyVar], keyVar);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** `POST /api/v1/stories/:id/thread/fixes` on the key claim_story claims with. */
|
|
269
|
-
export async function recordFix(
|
|
270
|
-
{ story_id, claim_epoch, checkpoint_id, finding_ids, idempotency_key, body } = {},
|
|
271
|
-
{ apiCall, env = process.env } = {},
|
|
272
|
-
) {
|
|
273
|
-
if (!present(story_id)) return missing("story_id");
|
|
274
|
-
if (!present(checkpoint_id)) return missing("checkpoint_id");
|
|
275
|
-
if (!present(idempotency_key)) return missing("idempotency_key");
|
|
276
|
-
if (!present(body)) return missing("body");
|
|
277
|
-
if (!Array.isArray(finding_ids) || finding_ids.length === 0) {
|
|
278
|
-
return { error: true, status: 0, body: "`finding_ids` must name at least one finding." };
|
|
279
|
-
}
|
|
280
|
-
if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
|
|
281
|
-
return {
|
|
282
|
-
error: true,
|
|
283
|
-
status: 0,
|
|
284
|
-
body: "`claim_epoch` is required: the non-negative integer your claim returned.",
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return apiCall(
|
|
289
|
-
"POST",
|
|
290
|
-
fixesPath(story_id),
|
|
291
|
-
{ claim_epoch, checkpoint_id, finding_ids, idempotency_key, body },
|
|
292
|
-
env[claimKeyVar(env)],
|
|
293
|
-
claimKeyVar(env),
|
|
294
|
-
);
|
|
295
|
-
}
|