omp-conductor 0.3.24 → 0.4.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.
@@ -0,0 +1,465 @@
1
+ /**
2
+ * The wire contract for the conductor-owned mutation verbs (#126).
3
+ *
4
+ * Everything here is pure: no sockets, no store, no `gh`. The daemon's request
5
+ * handler and the child's thin client both parse against this one table, so the
6
+ * two halves of the RPC cannot come to disagree about what a verb takes — and
7
+ * the adversarial tests can drive the parser directly, without a live daemon.
8
+ *
9
+ * Two properties are load-bearing and both are decided here rather than at a
10
+ * call site:
11
+ *
12
+ * - **Identity is never an argument.** {@link RESERVED_VERB_FIELDS} are refused
13
+ * outright wherever they appear in a payload. `project`, `run`, `issue` and
14
+ * the caller's role come from *which socket the call arrived on*; a request
15
+ * that spells them is either buggy or probing, and both deserve an answer
16
+ * that names what it sent.
17
+ * - **Closed by default.** A verb declares the roles it accepts and the
18
+ * arguments it takes. An unlisted role is refused, and an undeclared
19
+ * argument is refused rather than ignored: a verb that forgets to declare
20
+ * something must fail shut, not open.
21
+ */
22
+
23
+ import {
24
+ LABEL_REASONS,
25
+ MERGE_REASONS,
26
+ RELEASE_REASONS,
27
+ RELEASE_SHAPES,
28
+ RESERVED_VERB_FIELDS,
29
+ VERB_NAMES,
30
+ } from "../types.ts";
31
+ import type { ReservedVerbField, SessionRole, VerbName, VerbRefusal } from "../types.ts";
32
+
33
+ /** One declared argument. Untyped values are refused, never coerced. */
34
+ export interface VerbArg {
35
+ type: "string" | "number";
36
+ required: boolean;
37
+ description: string;
38
+ /** Closed vocabulary this argument is drawn from, when it has one. */
39
+ oneOf?: readonly string[];
40
+ }
41
+
42
+ /**
43
+ * One verb, as both halves of the RPC see it.
44
+ *
45
+ * `allowedRoles` is the whole access-control story for the channel: the daemon
46
+ * resolves a role from the socket and compares it against this set. The
47
+ * per-verb authority check (`authority.merge`, `authority.release`) is a
48
+ * *second*, narrower gate on top — a session can be the right kind of caller
49
+ * and still not be the configured holder.
50
+ */
51
+ export interface VerbSpec {
52
+ name: VerbName;
53
+ allowedRoles: readonly SessionRole[];
54
+ /**
55
+ * Whether this verb changes anything.
56
+ *
57
+ * Two consequences, both deliberate. A mutating verb is refused while the
58
+ * fleet is stopped and lands in the ledger; a read is neither. A run that is
59
+ * finishing its report during a `hold` still has to be able to say what it
60
+ * saw, and a poll every thirty seconds would bury the ledger's actual news —
61
+ * the refusals — under its own noise.
62
+ */
63
+ mutating: boolean;
64
+ /** What the model is told this verb does. Also the registered tool's description. */
65
+ description: string;
66
+ args: Readonly<Record<string, VerbArg>>;
67
+ /**
68
+ * What a caller of the wrong kind is told. Worded per verb because the
69
+ * refusal is the only explanation the model gets, and "role not allowed" does
70
+ * not tell a worker where merge authority actually lives.
71
+ */
72
+ roleRefusalText: (role: SessionRole) => string;
73
+ }
74
+
75
+ const RATIONALE_ARG: VerbArg = {
76
+ type: "string",
77
+ required: false,
78
+ description: "Free-form justification. Logged verbatim; nothing branches on it.",
79
+ };
80
+
81
+ /**
82
+ * The dispatch table. Keyed by {@link VerbName}, and the table-driven test
83
+ * walks `VERB_NAMES` rather than this object's keys, so a verb added to the
84
+ * vocabulary and not to this table fails a test instead of shipping ungated.
85
+ */
86
+ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
87
+ conductor_push: {
88
+ name: "conductor_push",
89
+ mutating: true,
90
+ allowedRoles: ["worker"],
91
+ description:
92
+ "Push this run's own branch to its remote, fast-forward only. The ref, the repository and " +
93
+ "the credential are the daemon's; there is no force path and no way to name another ref.",
94
+ args: {
95
+ // Accepted so a model that states its intent is answered precisely rather
96
+ // than pushed somewhere else silently. There is no ref this verb can be
97
+ // talked into: a mismatch is a refusal, never a redirect.
98
+ ref: {
99
+ type: "string",
100
+ required: false,
101
+ description: "Optional assertion: must be exactly refs/heads/<this run's branch>.",
102
+ },
103
+ },
104
+ roleRefusalText: (role) =>
105
+ `conductor_push pushes a run's own branch and only a worker owns one; this is a ${role} session.`,
106
+ },
107
+ conductor_pr_create: {
108
+ name: "conductor_pr_create",
109
+ mutating: true,
110
+ allowedRoles: ["worker"],
111
+ description:
112
+ "Open the pull request for this run. Head is this run's branch and base is the repo's " +
113
+ "configured default branch — both are the daemon's, not arguments you choose.",
114
+ args: {
115
+ title: { type: "string", required: true, description: "Pull request title." },
116
+ body: { type: "string", required: true, description: "Pull request body." },
117
+ base: {
118
+ type: "string",
119
+ required: false,
120
+ description: "Optional assertion: must be the repo's configured defaultBranch.",
121
+ },
122
+ head: {
123
+ type: "string",
124
+ required: false,
125
+ description: "Optional assertion: must be this run's branch.",
126
+ },
127
+ },
128
+ roleRefusalText: (role) =>
129
+ `conductor_pr_create opens a run's own pull request; this is a ${role} session, which owns no run.`,
130
+ },
131
+ conductor_pr_update_branch: {
132
+ name: "conductor_pr_update_branch",
133
+ mutating: true,
134
+ allowedRoles: ["worker", "orchestrator"],
135
+ description:
136
+ "Server-side merge of the base branch into an open pull request that has fallen behind. " +
137
+ "Destroys nothing and rewrites nothing. A worker may only name its own run's PR.",
138
+ args: {
139
+ prUrl: {
140
+ type: "string",
141
+ required: true,
142
+ description:
143
+ "Full pull request URL. Must belong to this project, and to this run if you are a worker.",
144
+ },
145
+ },
146
+ roleRefusalText: (role) => `conductor_pr_update_branch is not open to a ${role} session.`,
147
+ },
148
+ conductor_pr_merge: {
149
+ name: "conductor_pr_merge",
150
+ mutating: true,
151
+ allowedRoles: ["orchestrator"],
152
+ description:
153
+ "Merge one open pull request. The daemon re-reads the live head immediately before merging " +
154
+ "and refuses on any mismatch with headSha, and one merge is in flight per project at a time.",
155
+ args: {
156
+ prUrl: {
157
+ type: "string",
158
+ required: true,
159
+ description: "Full pull request URL, belonging to a run in this project.",
160
+ },
161
+ headSha: {
162
+ type: "string",
163
+ required: true,
164
+ description:
165
+ "The head you believe you are merging. Re-read live before the merge; a stale one is refused.",
166
+ },
167
+ reason: {
168
+ type: "string",
169
+ required: true,
170
+ description: `Why this merge, from the closed set: ${MERGE_REASONS.join(", ")}.`,
171
+ oneOf: MERGE_REASONS,
172
+ },
173
+ rationale: RATIONALE_ARG,
174
+ },
175
+ // The exact sentence #126 asks a refused worker to be given. `authority`
176
+ // has two holders, so a "not human" test would have let a worker through;
177
+ // the check compares the caller against the configured holder, and so does
178
+ // this wording.
179
+ roleRefusalText: () => "merge authority is the orchestrator's, never a worker session's.",
180
+ },
181
+ conductor_label: {
182
+ name: "conductor_label",
183
+ mutating: true,
184
+ allowedRoles: ["orchestrator"],
185
+ description:
186
+ "Add or remove one label from this project's own vocabulary on one of its issues. " +
187
+ "Lifecycle labels stay the daemon's and are refused here.",
188
+ args: {
189
+ issueUrl: {
190
+ type: "string",
191
+ required: true,
192
+ description: "Full issue URL. Must be in this project's tracker repository.",
193
+ },
194
+ label: {
195
+ type: "string",
196
+ required: true,
197
+ description: "A label in this project's own vocabulary.",
198
+ },
199
+ action: {
200
+ type: "string",
201
+ required: true,
202
+ description: "add or remove.",
203
+ oneOf: ["add", "remove"],
204
+ },
205
+ reason: {
206
+ type: "string",
207
+ required: true,
208
+ description: `Why, from the closed set: ${LABEL_REASONS.join(", ")}.`,
209
+ oneOf: LABEL_REASONS,
210
+ },
211
+ rationale: RATIONALE_ARG,
212
+ },
213
+ roleRefusalText: (role) =>
214
+ `label authority is the orchestrator's; this is a ${role} session, and a worker never edits the queue.`,
215
+ },
216
+ conductor_release: {
217
+ name: "conductor_release",
218
+ mutating: true,
219
+ allowedRoles: ["orchestrator"],
220
+ description:
221
+ "Cut one release of one declared shape. The caller's role must equal the configured release " +
222
+ "holder and the per-shape grant must permit it; a worker is refused whatever the config says.",
223
+ args: {
224
+ shape: {
225
+ type: "string",
226
+ required: true,
227
+ description: `What is being released: ${RELEASE_SHAPES.join(", ")}.`,
228
+ oneOf: RELEASE_SHAPES,
229
+ },
230
+ repo: {
231
+ type: "string",
232
+ required: true,
233
+ description: "The routed repository name this release targets.",
234
+ },
235
+ reason: {
236
+ type: "string",
237
+ required: true,
238
+ description: `Why now, from the closed set: ${RELEASE_REASONS.join(", ")}.`,
239
+ oneOf: RELEASE_REASONS,
240
+ },
241
+ tag: {
242
+ type: "string",
243
+ required: false,
244
+ description: "Tag to cut, for the git-tag and github-release shapes.",
245
+ },
246
+ artefact: {
247
+ type: "string",
248
+ required: false,
249
+ description: "Package or image, for package-publish. Must be one this project declared.",
250
+ },
251
+ environment: {
252
+ type: "string",
253
+ required: false,
254
+ description: "Deploy target, for deploy. Must be one this project declared.",
255
+ },
256
+ rationale: RATIONALE_ARG,
257
+ },
258
+ roleRefusalText: (role) =>
259
+ `release authority is the orchestrator's, and this is a ${role} session. ` +
260
+ "A worker session holds no release grant whatever the config says.",
261
+ },
262
+ conductor_pr_status: {
263
+ name: "conductor_pr_status",
264
+ mutating: false,
265
+ allowedRoles: ["worker", "orchestrator"],
266
+ description:
267
+ "Read one pull request's live state: is it open, is its head still the sha you pushed, and are " +
268
+ "its checks green. This is the daemon's own merge-gate verdict, not a summary of it. A poll, not " +
269
+ "a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.",
270
+ args: {
271
+ prUrl: {
272
+ type: "string",
273
+ required: false,
274
+ description: "Full pull request URL. Omit as a worker to read your own run's.",
275
+ },
276
+ headSha: {
277
+ type: "string",
278
+ required: true,
279
+ description:
280
+ "The head you believe is on the branch — the sha conductor_push returned. If the branch has " +
281
+ "moved, the answer names both shas.",
282
+ },
283
+ },
284
+ roleRefusalText: (role) => `conductor_pr_status is not open to a ${role} session.`,
285
+ },
286
+ };
287
+
288
+ export interface VerbRequest {
289
+ verb: VerbName;
290
+ args: Record<string, unknown>;
291
+ }
292
+
293
+ export type VerbParse =
294
+ | { ok: true; request: VerbRequest }
295
+ | { ok: false; refusal: VerbRefusal; detail: string; verb?: VerbName };
296
+
297
+ /** What the daemon writes back. One reply per connection, then it closes. */
298
+ export interface VerbReply {
299
+ ok: boolean;
300
+ verb?: VerbName;
301
+ refusal?: VerbRefusal;
302
+ /** What the model reads. Always populated, refusal or not. */
303
+ text: string;
304
+ /** The commit, tag or merge sha an approved call produced, when there is one. */
305
+ sha?: string;
306
+ }
307
+
308
+ function isVerbName(value: unknown): value is VerbName {
309
+ return VERB_NAMES.some((name) => name === value);
310
+ }
311
+
312
+ /**
313
+ * Every reserved field the payload spells, top level or one deep in `args`.
314
+ * Collected rather than short-circuited: a refusal that names one field of
315
+ * three teaches a probing client to remove them one at a time.
316
+ */
317
+ export function reservedFieldsIn(payload: Record<string, unknown>): ReservedVerbField[] {
318
+ const args = payload["args"];
319
+ const nested =
320
+ args !== null && typeof args === "object" && !Array.isArray(args)
321
+ ? (args as Record<string, unknown>)
322
+ : {};
323
+ return RESERVED_VERB_FIELDS.filter(
324
+ (field) => Object.hasOwn(payload, field) || Object.hasOwn(nested, field),
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Parse one raw request against {@link VERB_SPECS}.
330
+ *
331
+ * Fails shut at every step, and names what it saw: an unparseable payload, a
332
+ * verb nobody registered, an identity field, an argument the verb never
333
+ * declared, a missing or mistyped one, or a value outside a closed vocabulary.
334
+ */
335
+ export function parseVerbRequest(raw: unknown): VerbParse {
336
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
337
+ return {
338
+ ok: false,
339
+ refusal: "malformed-argument",
340
+ detail: "refused: a verb request must be a JSON object.",
341
+ };
342
+ }
343
+ const payload = raw as Record<string, unknown>;
344
+
345
+ // Before the verb is even resolved: a payload carrying identity is refused
346
+ // whatever it was asking for, because the interesting thing about it is not
347
+ // which verb it named.
348
+ const reserved = reservedFieldsIn(payload);
349
+ if (reserved.length > 0) {
350
+ return {
351
+ ok: false,
352
+ refusal: "identity-in-payload",
353
+ ...(isVerbName(payload["verb"]) ? { verb: payload["verb"] } : {}),
354
+ detail:
355
+ `refused: this request carries ${reserved.map((f) => `"${f}"`).join(", ")}. ` +
356
+ "Identity is never an argument — the daemon derives the project, the run, the issue and " +
357
+ "your role from the socket this call arrived on and the verified peer uid. Send the verb " +
358
+ "and its own arguments only.",
359
+ };
360
+ }
361
+
362
+ const verb = payload["verb"];
363
+ if (!isVerbName(verb)) {
364
+ return {
365
+ ok: false,
366
+ refusal: "malformed-argument",
367
+ detail: `refused: ${JSON.stringify(verb)} is not a conductor verb. Registered: ${VERB_NAMES.join(", ")}.`,
368
+ };
369
+ }
370
+ const spec = VERB_SPECS[verb];
371
+
372
+ const rawArgs = payload["args"] ?? {};
373
+ if (rawArgs === null || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
374
+ return {
375
+ ok: false,
376
+ verb,
377
+ refusal: "malformed-argument",
378
+ detail: `refused: ${verb} args must be an object.`,
379
+ };
380
+ }
381
+ const args = rawArgs as Record<string, unknown>;
382
+
383
+ const undeclared = Object.keys(args).filter((key) => !Object.hasOwn(spec.args, key));
384
+ if (undeclared.length > 0) {
385
+ return {
386
+ ok: false,
387
+ verb,
388
+ refusal: "unknown-argument",
389
+ detail:
390
+ `refused: ${verb} does not take ${undeclared.map((k) => `"${k}"`).join(", ")}. ` +
391
+ `It takes ${Object.keys(spec.args).join(", ") || "no arguments"}. Undeclared arguments are ` +
392
+ "refused rather than ignored: a verb that quietly drops one is a verb whose gate you cannot " +
393
+ "read off its signature.",
394
+ };
395
+ }
396
+
397
+ for (const [key, arg] of Object.entries(spec.args)) {
398
+ const value = args[key];
399
+ if (value === undefined) {
400
+ if (arg.required) {
401
+ return {
402
+ ok: false,
403
+ verb,
404
+ refusal: "malformed-argument",
405
+ detail: `refused: ${verb} needs "${key}".`,
406
+ };
407
+ }
408
+ continue;
409
+ }
410
+ if (typeof value !== arg.type) {
411
+ return {
412
+ ok: false,
413
+ verb,
414
+ refusal: "malformed-argument",
415
+ detail: `refused: ${verb} argument "${key}" must be a ${arg.type}, got ${typeof value}.`,
416
+ };
417
+ }
418
+ if (arg.oneOf !== undefined && !arg.oneOf.some((allowed) => allowed === value)) {
419
+ // `reason` is the field #129 closed on purpose, so it gets its own
420
+ // refusal code: an out-of-enum reason is vocabulary drift, not a typo.
421
+ return {
422
+ ok: false,
423
+ verb,
424
+ refusal: key === "reason" ? "reason-not-in-enum" : "malformed-argument",
425
+ detail:
426
+ `refused: ${verb} argument "${key}" must be one of ${arg.oneOf.join(", ")}, ` +
427
+ `got ${JSON.stringify(value)}.`,
428
+ };
429
+ }
430
+ }
431
+
432
+ return { ok: true, request: { verb, args } };
433
+ }
434
+
435
+ /**
436
+ * The refusal for a caller of the wrong kind, or `undefined` when the role is
437
+ * listed. Closed by default: a spec with an empty `allowedRoles` refuses
438
+ * everybody, which is the correct answer for a verb nobody finished wiring.
439
+ */
440
+ export function roleRefusal(spec: VerbSpec, role: SessionRole): string | undefined {
441
+ return spec.allowedRoles.some((allowed) => allowed === role)
442
+ ? undefined
443
+ : spec.roleRefusalText(role);
444
+ }
445
+
446
+ /**
447
+ * The JSON Schema the harness advertises for one verb.
448
+ *
449
+ * Generated from the same {@link VerbSpec} the daemon validates against, so the
450
+ * schema the model is shown and the gate it is held to cannot drift — the
451
+ * failure mode that makes "the tool wouldn't let me" unfalsifiable.
452
+ */
453
+ export function verbParameterSchema(spec: VerbSpec): Record<string, unknown> {
454
+ const properties: Record<string, unknown> = {};
455
+ const required: string[] = [];
456
+ for (const [key, arg] of Object.entries(spec.args)) {
457
+ properties[key] = {
458
+ type: arg.type,
459
+ description: arg.description,
460
+ ...(arg.oneOf === undefined ? {} : { enum: [...arg.oneOf] }),
461
+ };
462
+ if (arg.required) required.push(key);
463
+ }
464
+ return { type: "object", properties, required, additionalProperties: false };
465
+ }