omp-conductor 0.3.25 → 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.
- package/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/types.ts
CHANGED
|
@@ -7,6 +7,32 @@
|
|
|
7
7
|
* is `DEFAULT_CAPS`, which is data, not behaviour.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A threshold on one provider-reported allowance window.
|
|
12
|
+
*
|
|
13
|
+
* Both fields exist because of what the real `omp usage --json` payload looks
|
|
14
|
+
* like. `limits` is a *list* — one Anthropic account reports `anthropic:5h`,
|
|
15
|
+
* `anthropic:7d` and `anthropic:7d:fable` at once — so the window is named
|
|
16
|
+
* rather than positional. And `unit` differs per provider (`percent` for
|
|
17
|
+
* Anthropic, `unknown` with raw counts for `xai-oauth`), so the threshold is a
|
|
18
|
+
* fraction of the allowance and never a raw count.
|
|
19
|
+
*/
|
|
20
|
+
export interface PlanUsageCap {
|
|
21
|
+
/**
|
|
22
|
+
* Which allowance this threshold is about: the fully-qualified limit id
|
|
23
|
+
* (`"anthropic:7d"`), or a bare window key (`"7d"`) when exactly one
|
|
24
|
+
* reported allowance carries it. An id nothing reports holds dispatch and
|
|
25
|
+
* says so — it is never quietly treated as unmetered.
|
|
26
|
+
*/
|
|
27
|
+
windowId: string;
|
|
28
|
+
/**
|
|
29
|
+
* Consumed share, `0`–`1`, at which new claims stop. `0.85` holds at 85% of
|
|
30
|
+
* the allowance. A fraction rather than a percentage so the config cannot
|
|
31
|
+
* read `85` and mean "never".
|
|
32
|
+
*/
|
|
33
|
+
maxUsedFraction: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* Hard limits enforced in code, never by the model. A worker that is asked to
|
|
12
38
|
* respect a budget will eventually talk itself out of it, so the dispatcher
|
|
@@ -21,6 +47,13 @@ export interface Caps {
|
|
|
21
47
|
* still apply). `0` is a hard stop — deliberate, not "unset".
|
|
22
48
|
*/
|
|
23
49
|
dailySpendUsd: number | null;
|
|
50
|
+
/**
|
|
51
|
+
* Subscription/plan allowance guard, independent of `dailySpendUsd` and
|
|
52
|
+
* enforced from the provider's own reported usage rather than from a dollar
|
|
53
|
+
* figure conductor made up (#110). `null` means unmetered: the fleet has no
|
|
54
|
+
* plan allowance worth guarding, or none this host can read.
|
|
55
|
+
*/
|
|
56
|
+
planUsage: PlanUsageCap | null;
|
|
24
57
|
/** Turn ceiling for one worker — catches loops that are burning tokens
|
|
25
58
|
* without converging. */
|
|
26
59
|
workerMaxTurns: number;
|
|
@@ -97,16 +130,94 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
|
97
130
|
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
98
131
|
|
|
99
132
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
133
|
+
* The irreversible tool-call shapes this package recognises and gates. Data
|
|
134
|
+
* here rather than in `release-policy.ts` because the config validator, the
|
|
135
|
+
* setup wizard and the status renderer now all enumerate them (#122): a sixth
|
|
136
|
+
* shape must fail to compile in each of those places rather than silently
|
|
137
|
+
* resolve to "not granted" in one of them.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately generic. Fleet-specific release topology — a suite pin, a
|
|
140
|
+
* registry, an IaC stack — belongs in the operator's POLICY.md, not here: a
|
|
141
|
+
* conductor installed with no npm and no Kubernetes must neither carry those as
|
|
142
|
+
* dead config nor be asked about them by the wizard.
|
|
143
|
+
*/
|
|
144
|
+
export const RELEASE_SHAPES = [
|
|
145
|
+
"git-tag",
|
|
146
|
+
"git-push-tags",
|
|
147
|
+
"package-publish",
|
|
148
|
+
"github-release",
|
|
149
|
+
"deploy",
|
|
150
|
+
] as const;
|
|
151
|
+
|
|
152
|
+
export type ReleaseShape = (typeof RELEASE_SHAPES)[number];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Mechanical permission per release shape. `authority.release` says who owns
|
|
156
|
+
* the *decision*; this key is the enforcement gate that decides whether an
|
|
157
|
+
* autonomous session may invoke the tools at all.
|
|
158
|
+
*
|
|
159
|
+
* A map rather than the binary it replaced: under the old `"operator-brief"`
|
|
160
|
+
* one grant covered cutting a tag and mutating a running environment, and on
|
|
161
|
+
* 2026-08-09 a stale grant was enough for an orchestrator session to invoke
|
|
162
|
+
* Komodo `DeployStack` (#122). Producing an artifact and changing what is
|
|
163
|
+
* running are different acts with different blast radii and different rollback
|
|
164
|
+
* owners, so they are granted separately or not at all.
|
|
165
|
+
*
|
|
166
|
+
* Legacy string forms still parse and are migrated at load; absent keys deny.
|
|
167
|
+
*/
|
|
168
|
+
export type ReleasePolicy = "none" | Partial<Record<ReleaseShape, AuthorityHolder>>;
|
|
169
|
+
|
|
170
|
+
/** After normalisation: every shape present, absent keys denied as `"human"`. */
|
|
171
|
+
export type ResolvedGrants = Record<ReleaseShape, AuthorityHolder>;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The on-disk spellings from before the grant map. Read and migrated, never
|
|
175
|
+
* written again — data so the validator's error message and the migration table
|
|
176
|
+
* cannot come to name different sets.
|
|
177
|
+
*/
|
|
178
|
+
export const LEGACY_RELEASE_POLICIES = ["none", "operator-brief"] as const;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fail-closed baseline: nothing granted to any session. Written out rather than
|
|
182
|
+
* derived from {@link RELEASE_SHAPES} so `Record` makes a new shape a compile
|
|
183
|
+
* error here — a shape this table forgot would read as `undefined` at the gate,
|
|
184
|
+
* and `undefined !== role` is only accidentally safe.
|
|
185
|
+
*/
|
|
186
|
+
export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
|
|
187
|
+
"git-tag": "human",
|
|
188
|
+
"git-push-tags": "human",
|
|
189
|
+
"package-publish": "human",
|
|
190
|
+
"github-release": "human",
|
|
191
|
+
deploy: "human",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* What the legacy `"operator-brief"` string migrates to: the safe reading of
|
|
196
|
+
* what operators believed they were enabling when they opened the binary gate.
|
|
197
|
+
* `deploy` stays with the human, because that is the grant nobody knowingly
|
|
198
|
+
* gave — see #122.
|
|
103
199
|
*/
|
|
104
|
-
export const
|
|
200
|
+
export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = {
|
|
201
|
+
"git-tag": "orchestrator",
|
|
202
|
+
"git-push-tags": "orchestrator",
|
|
203
|
+
"package-publish": "orchestrator",
|
|
204
|
+
"github-release": "orchestrator",
|
|
205
|
+
deploy: "human",
|
|
206
|
+
};
|
|
105
207
|
|
|
106
|
-
|
|
208
|
+
/**
|
|
209
|
+
* What kind of session is asking. Explicit at every gate rather than inferred
|
|
210
|
+
* from confinement or from which module made the call: a worker's refusal must
|
|
211
|
+
* not depend on a boolean some future call site forgets to pass.
|
|
212
|
+
*
|
|
213
|
+
* Note the two sets: a grant is an {@link AuthorityHolder}, never a
|
|
214
|
+
* `SessionRole`, so `"worker"` can never equal a grant and a worker is refused
|
|
215
|
+
* every shape whatever the config says. The type does that enforcing, not a
|
|
216
|
+
* check some path can miss.
|
|
217
|
+
*/
|
|
218
|
+
export const SESSION_ROLES = ["worker", "orchestrator"] as const;
|
|
107
219
|
|
|
108
|
-
|
|
109
|
-
export const DEFAULT_RELEASE_POLICY: ReleasePolicy = "none";
|
|
220
|
+
export type SessionRole = (typeof SESSION_ROLES)[number];
|
|
110
221
|
|
|
111
222
|
/**
|
|
112
223
|
* Who holds an authority the daemon itself never exercises. Declared as data
|
|
@@ -125,6 +236,290 @@ export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
|
|
|
125
236
|
*/
|
|
126
237
|
export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
|
|
127
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Whether a merge candidate must be up to date with the branch it targets.
|
|
241
|
+
*
|
|
242
|
+
* Declared as data for the reason {@link AUTHORITY_HOLDERS} is, and extracted
|
|
243
|
+
* from POLICY.md prose for a sharper one (#129): until this key existed the
|
|
244
|
+
* answer was a sentence an operator wrote and a session re-read every tick, so
|
|
245
|
+
* two ticks could reach two answers and neither left a trace of having decided.
|
|
246
|
+
* A set-membership check leaves the same trace every time.
|
|
247
|
+
*/
|
|
248
|
+
export const BASE_FRESHNESS = ["up-to-date", "any"] as const;
|
|
249
|
+
|
|
250
|
+
export type BaseFreshness = (typeof BASE_FRESHNESS)[number];
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a draft pull request is to the merge gate. `block` by default: a draft is
|
|
254
|
+
* the author's own statement that the work is not finished, and an autonomous
|
|
255
|
+
* merge that overrides that has read the diff rather than the intent.
|
|
256
|
+
*/
|
|
257
|
+
export const DRAFT_POLICIES = ["block", "allow"] as const;
|
|
258
|
+
|
|
259
|
+
export type DraftPolicy = (typeof DRAFT_POLICIES)[number];
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* What to do with an otherwise-mergeable pull request that has fallen behind its
|
|
263
|
+
* base branch.
|
|
264
|
+
*
|
|
265
|
+
* `update-branch` is the package floor's one sanctioned write into a worker's
|
|
266
|
+
* branch — `gh pr update-branch`, then a wait for the fresh run — and it is the
|
|
267
|
+
* default because merging promptly is itself what stops the next PR going stale.
|
|
268
|
+
* `hold` leaves it for whoever refreshes it; `escalate` pages rather than
|
|
269
|
+
* guessing. Closing it and an admin bypass are absent on purpose: neither was
|
|
270
|
+
* ever an answer, and a vocabulary that cannot spell them cannot be argued into
|
|
271
|
+
* one.
|
|
272
|
+
*/
|
|
273
|
+
export const BEHIND_BASE_ACTIONS = ["update-branch", "hold", "escalate"] as const;
|
|
274
|
+
|
|
275
|
+
export type BehindBaseAction = (typeof BEHIND_BASE_ACTIONS)[number];
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What must already have landed before a release may be cut. Each member is
|
|
279
|
+
* something the daemon can settle against its own store or the tracker — that is
|
|
280
|
+
* the entry price for being in this list rather than in POLICY.md.
|
|
281
|
+
*/
|
|
282
|
+
export const RELEASE_REQUIREMENTS = [
|
|
283
|
+
/** Every run this release covers reached `merged`, not merely `pushed-green`. */
|
|
284
|
+
"runs-settled",
|
|
285
|
+
/** No pull request is still open against the branch being released. */
|
|
286
|
+
"no-open-prs",
|
|
287
|
+
/** Nothing still carries the queue label: the batch is finished, not paused. */
|
|
288
|
+
"queue-drained",
|
|
289
|
+
/** The epic this release closes has no open children left. */
|
|
290
|
+
"epic-children-closed",
|
|
291
|
+
] as const;
|
|
292
|
+
|
|
293
|
+
export type ReleaseRequirement = (typeof RELEASE_REQUIREMENTS)[number];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* What must be true before a pull request may be merged.
|
|
297
|
+
*
|
|
298
|
+
* These four were sentences in the shipped POLICY.md until #129, which meant a
|
|
299
|
+
* verb could honour them only by asking a model to read prose and agree with it.
|
|
300
|
+
* Typed here, the decision is a pure function of config: the same PR gets the
|
|
301
|
+
* same answer on Friday as on Tuesday, and a refusal can name the field that
|
|
302
|
+
* produced it.
|
|
303
|
+
*/
|
|
304
|
+
export interface MergePreconditions {
|
|
305
|
+
/**
|
|
306
|
+
* Named checks that must have concluded successfully. Empty means *every*
|
|
307
|
+
* check the pull request reports — the strict reading, and the one a project
|
|
308
|
+
* that never answered the question wants.
|
|
309
|
+
*/
|
|
310
|
+
requiredChecks: string[];
|
|
311
|
+
/** Whether the head must be up to date with its base. See {@link BASE_FRESHNESS}. */
|
|
312
|
+
baseFreshness: BaseFreshness;
|
|
313
|
+
/** What a draft pull request is. See {@link DRAFT_POLICIES}. */
|
|
314
|
+
drafts: DraftPolicy;
|
|
315
|
+
/**
|
|
316
|
+
* What to do when `baseFreshness` is `"up-to-date"` and the head is not.
|
|
317
|
+
* Consulted only then: under `"any"` there is no behind-base case to answer.
|
|
318
|
+
*/
|
|
319
|
+
whenBehindBase: BehindBaseAction;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* What must be true before a release may be cut: what has landed, what it
|
|
324
|
+
* produces, and where it may be pushed.
|
|
325
|
+
*
|
|
326
|
+
* `artefacts` and `environments` are empty by default, and empty denies. A
|
|
327
|
+
* project that never named an artefact has not authorised releasing one, and a
|
|
328
|
+
* project that named no environment has authorised no deploy target — the same
|
|
329
|
+
* posture as an absent release grant (#122). The config says yes out loud, or it
|
|
330
|
+
* has not said yes.
|
|
331
|
+
*/
|
|
332
|
+
export interface ReleasePreconditions {
|
|
333
|
+
/** What must already have landed. See {@link RELEASE_REQUIREMENTS}. */
|
|
334
|
+
requires: ReleaseRequirement[];
|
|
335
|
+
/**
|
|
336
|
+
* Named checks that must be green on the branch being released. Empty means
|
|
337
|
+
* every check that branch reports.
|
|
338
|
+
*/
|
|
339
|
+
requiredChecks: string[];
|
|
340
|
+
/** Packages or images this project releases. Empty denies every artefact. */
|
|
341
|
+
artefacts: string[];
|
|
342
|
+
/** Environments a deploy may target. Empty denies every environment. */
|
|
343
|
+
environments: string[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The gating conditions #126's verbs read instead of POLICY.md. Optional on disk
|
|
348
|
+
* and complete after the loader has seen it, so no consumer has to know which of
|
|
349
|
+
* these fields the operator happened to spell.
|
|
350
|
+
*/
|
|
351
|
+
export interface ProjectPolicy {
|
|
352
|
+
merge: MergePreconditions;
|
|
353
|
+
release: ReleasePreconditions;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* What a project that never answered the question gets: the strictest reading of
|
|
358
|
+
* the prose these fields replaced. Every check green, the base fresh, drafts
|
|
359
|
+
* refused, a behind-base PR refreshed rather than merged, and a release only of
|
|
360
|
+
* work that actually landed — with no artefact and no environment declared, so a
|
|
361
|
+
* release verb has nothing to act on until an operator says what this project
|
|
362
|
+
* ships.
|
|
363
|
+
*/
|
|
364
|
+
export const DEFAULT_PROJECT_POLICY: ProjectPolicy = {
|
|
365
|
+
merge: {
|
|
366
|
+
requiredChecks: [],
|
|
367
|
+
baseFreshness: "up-to-date",
|
|
368
|
+
drafts: "block",
|
|
369
|
+
whenBehindBase: "update-branch",
|
|
370
|
+
},
|
|
371
|
+
release: {
|
|
372
|
+
requires: ["runs-settled"],
|
|
373
|
+
requiredChecks: [],
|
|
374
|
+
artefacts: [],
|
|
375
|
+
environments: [],
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Why a merge is being asked for. A closed set rather than free text, for the
|
|
381
|
+
* reason every other vocabulary here is closed: a `reason` the daemon matches on
|
|
382
|
+
* is a field that decides, and a decision made out of a model's own wording is
|
|
383
|
+
* one no two ticks spell the same way.
|
|
384
|
+
*/
|
|
385
|
+
export const MERGE_REASONS = [
|
|
386
|
+
/** Every condition in {@link MergePreconditions} is satisfied as it stands. */
|
|
387
|
+
"preconditions-met",
|
|
388
|
+
/** It was behind its base, was refreshed, and came back green. */
|
|
389
|
+
"behind-base-refreshed",
|
|
390
|
+
/** A human in the loop asked for this one specifically. */
|
|
391
|
+
"operator-instructed",
|
|
392
|
+
/** It is what a release otherwise ready to cut is waiting on. */
|
|
393
|
+
"release-blocking",
|
|
394
|
+
] as const;
|
|
395
|
+
|
|
396
|
+
export type MergeReason = (typeof MERGE_REASONS)[number];
|
|
397
|
+
|
|
398
|
+
/** Why a release is being cut now. Closed for the reason {@link MERGE_REASONS} is. */
|
|
399
|
+
export const RELEASE_REASONS = [
|
|
400
|
+
/** The batching unit the operator described in POLICY.md has been reached. */
|
|
401
|
+
"batch-complete",
|
|
402
|
+
/** The epic this release closes has no open children left. */
|
|
403
|
+
"epic-closed",
|
|
404
|
+
/** A fix that cannot wait for the batch. */
|
|
405
|
+
"hotfix",
|
|
406
|
+
/** A human in the loop asked for this one specifically. */
|
|
407
|
+
"operator-instructed",
|
|
408
|
+
] as const;
|
|
409
|
+
|
|
410
|
+
export type ReleaseReason = (typeof RELEASE_REASONS)[number];
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Why a tracker label is being changed. Closed for the reason
|
|
414
|
+
* {@link MERGE_REASONS} is, and load-bearing past bookkeeping: the queue label is
|
|
415
|
+
* the claim gate, so "why did this issue become claimable" has to be answerable
|
|
416
|
+
* from the ledger rather than from a session transcript nobody kept.
|
|
417
|
+
*/
|
|
418
|
+
export const LABEL_REASONS = [
|
|
419
|
+
/** Groomed and promoted onto the queue. */
|
|
420
|
+
"promoted-to-queue",
|
|
421
|
+
/** Re-briefed after a failed attempt, and put back on the queue. */
|
|
422
|
+
"re-briefed",
|
|
423
|
+
/** Parked: it needs a decision only a human can make. */
|
|
424
|
+
"needs-human",
|
|
425
|
+
"duplicate",
|
|
426
|
+
"superseded",
|
|
427
|
+
"out-of-scope",
|
|
428
|
+
] as const;
|
|
429
|
+
|
|
430
|
+
export type LabelReason = (typeof LABEL_REASONS)[number];
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A model-supplied justification: one value out of a closed set, plus prose that
|
|
434
|
+
* is written down and read by nothing that decides.
|
|
435
|
+
*
|
|
436
|
+
* The split is the whole point. `reason` is matched, so it is an enum the daemon
|
|
437
|
+
* enumerates. `rationale` is logged verbatim for the human who later asks why,
|
|
438
|
+
* and nothing branches on it — the moment something does, the vocabulary stops
|
|
439
|
+
* being the enum above and becomes whatever a model felt like typing.
|
|
440
|
+
*/
|
|
441
|
+
export interface VerbJustification<R extends string> {
|
|
442
|
+
reason: R;
|
|
443
|
+
/** Free-form. Logged, never parsed, never matched. */
|
|
444
|
+
rationale?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export type MergeJustification = VerbJustification<MergeReason>;
|
|
448
|
+
|
|
449
|
+
export type ReleaseJustification = VerbJustification<ReleaseReason>;
|
|
450
|
+
|
|
451
|
+
export type LabelJustification = VerbJustification<LabelReason>;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* How hard a boundary separates model-executed code from the operator's GitHub
|
|
455
|
+
* write credential (#125).
|
|
456
|
+
*
|
|
457
|
+
* Three values, ordered strongest first, because the difference between them is
|
|
458
|
+
* a difference in what anyone can honestly claim:
|
|
459
|
+
*
|
|
460
|
+
* - `per-run` — every session runs as its own OS principal that cannot read the
|
|
461
|
+
* daemon's `gh` config, keychain, `~/.ssh` or `~/.npmrc`, and cannot write a
|
|
462
|
+
* sibling run's checkout. Satisfied **only** by the `uid-pool` and
|
|
463
|
+
* `sandbox-exec` mechanisms.
|
|
464
|
+
* - `group-mode` — same uid as the daemon, cross-run separation by group and
|
|
465
|
+
* mode only. It bounds accidents and does **not** contain a determined bash
|
|
466
|
+
* escape. It exists as a config value rather than as a fallback so that an
|
|
467
|
+
* operator who takes it has said the weaker sentence out loud: a `per-run`
|
|
468
|
+
* request that quietly resolved to this would let somebody believe they had
|
|
469
|
+
* configured the full boundary and shipped the lesser one, which is precisely
|
|
470
|
+
* the "never the silent default" failure #125 exists to end.
|
|
471
|
+
* - `none` — the session runs as the daemon's own user and the env scrubbing in
|
|
472
|
+
* `credentials.ts` is *all* that stands between the model and the credential,
|
|
473
|
+
* which same-uid code defeats in one line.
|
|
474
|
+
*
|
|
475
|
+
* A request is never downgraded. If the host cannot build what was asked for,
|
|
476
|
+
* dispatch refuses and names the missing mechanism.
|
|
477
|
+
*/
|
|
478
|
+
export const CREDENTIAL_ISOLATIONS = ["per-run", "group-mode", "none"] as const;
|
|
479
|
+
|
|
480
|
+
export type CredentialIsolation = (typeof CREDENTIAL_ISOLATIONS)[number];
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* What the host can actually enforce, decided by the startup probe rather than
|
|
484
|
+
* by config. Kept separate from {@link CREDENTIAL_ISOLATIONS} on purpose: the
|
|
485
|
+
* operator asks for a boundary, the host says which one it can build, and
|
|
486
|
+
* `status` reports the difference instead of letting either side guess.
|
|
487
|
+
*
|
|
488
|
+
* - `uid-pool` — one unprivileged account per run slot, entered through a
|
|
489
|
+
* privilege-dropping launcher. The only mechanism that claims to contain a
|
|
490
|
+
* determined bash escape on Linux.
|
|
491
|
+
* - `group-mode` — same uid as the daemon, cross-run separation by group and
|
|
492
|
+
* mode only. Bounds accidents, does **not** contain an escape.
|
|
493
|
+
* - `sandbox-exec` — the macOS equivalent of `uid-pool` for a dev host.
|
|
494
|
+
* - `none` — no boundary at all.
|
|
495
|
+
*/
|
|
496
|
+
export const ISOLATION_MECHANISMS = ["uid-pool", "group-mode", "sandbox-exec", "none"] as const;
|
|
497
|
+
|
|
498
|
+
export type IsolationMechanism = (typeof ISOLATION_MECHANISMS)[number];
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The two groups the filesystem model needs, named once so the probe, the
|
|
502
|
+
* provisioning docs and the adversarial tests cannot drift apart (#125).
|
|
503
|
+
*
|
|
504
|
+
* `daemon` holds the daemon account **only** and is what lets it fetch, salvage
|
|
505
|
+
* and reclaim every run repo. `runs` holds every slot principal and grants
|
|
506
|
+
* read-only access to the shared mirror. A slot principal in `daemon` would
|
|
507
|
+
* void the whole cross-run boundary, so the probe asserts the absence.
|
|
508
|
+
*/
|
|
509
|
+
export const CONDUCTOR_GROUPS = { daemon: "conductor-daemon", runs: "conductor-runs" } as const;
|
|
510
|
+
|
|
511
|
+
/** Per-project credential boundary settings. See {@link CREDENTIAL_ISOLATIONS}. */
|
|
512
|
+
export interface CredentialConfig {
|
|
513
|
+
isolation: CredentialIsolation;
|
|
514
|
+
/**
|
|
515
|
+
* Optional **read-scoped** token handed to sessions so they can look things
|
|
516
|
+
* up on GitHub. Absent is the default and the safe reading: a worker then
|
|
517
|
+
* works from its dispatch brief and the daemon's mediated verbs, which is
|
|
518
|
+
* why the brief must not tell it to run `gh pr view`.
|
|
519
|
+
*/
|
|
520
|
+
readToken?: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
128
523
|
/**
|
|
129
524
|
* Where the session that triages escalations lives. `embedded` is the daemon's
|
|
130
525
|
* own child session; `external` means an operator already runs one — a visible
|
|
@@ -174,21 +569,55 @@ export interface ProjectConfig {
|
|
|
174
569
|
*/
|
|
175
570
|
authority: { merge: AuthorityHolder; release: AuthorityHolder };
|
|
176
571
|
/**
|
|
177
|
-
*
|
|
178
|
-
* written before the tripwire existed; omission
|
|
179
|
-
*
|
|
572
|
+
* Per-shape tool-call enforcement for releases and deploys. Optional only for
|
|
573
|
+
* configs written before the tripwire existed; both omission and any shape key
|
|
574
|
+
* this map leaves out resolve fail-closed to `"human"`.
|
|
575
|
+
*
|
|
576
|
+
* Never read directly — go through `resolveReleaseGrants`, which is where the
|
|
577
|
+
* legacy `"none"` / `"operator-brief"` strings are migrated and where the
|
|
578
|
+
* absent-key denial is spelled once.
|
|
180
579
|
*/
|
|
181
580
|
releasePolicy?: ReleasePolicy;
|
|
581
|
+
/**
|
|
582
|
+
* The gating conditions a merge or a release must satisfy (#129), extracted
|
|
583
|
+
* from POLICY.md so a verb decides from typed config rather than from prose it
|
|
584
|
+
* re-interprets every tick.
|
|
585
|
+
*
|
|
586
|
+
* Optional only for configs written before the key existed. The loader always
|
|
587
|
+
* produces a complete value, so read it through `resolvePolicy` rather than
|
|
588
|
+
* reaching for `.merge` on a project some test hand-built.
|
|
589
|
+
*/
|
|
590
|
+
policy?: ProjectPolicy;
|
|
182
591
|
/**
|
|
183
592
|
* How loud the orchestrator is. Optional on disk — a config written before
|
|
184
593
|
* this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
|
|
185
594
|
* `resolveReportScope` rather than reaching for `.scope` directly.
|
|
186
595
|
*/
|
|
187
596
|
reporting?: { scope: ReportScope };
|
|
597
|
+
/**
|
|
598
|
+
* The credential boundary this project's sessions run behind (#125).
|
|
599
|
+
*
|
|
600
|
+
* Optional only on disk, and only for one release: a config written before
|
|
601
|
+
* this key existed is migrated to an explicit `{ isolation: "none" }` and
|
|
602
|
+
* rewritten, so the operator ends up with the answer in the file rather than
|
|
603
|
+
* inheriting a silent default. Never read directly — go through
|
|
604
|
+
* `resolveCredentials`, which is where that migration is spelled once.
|
|
605
|
+
*/
|
|
606
|
+
credentials?: CredentialConfig;
|
|
188
607
|
/** Parent directory for per-run worktrees. */
|
|
189
608
|
workspaceRoot: string;
|
|
190
609
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
191
610
|
mirrorRoot: string;
|
|
611
|
+
/**
|
|
612
|
+
* Extra roots an orchestrator session may *read*, on top of the state
|
|
613
|
+
* directory and its own briefs. Absolute (or `~`-expandable) paths only,
|
|
614
|
+
* rejected at load rather than resolved against a cwd nobody can name.
|
|
615
|
+
*
|
|
616
|
+
* It cannot widen the gate past the roots #127 exists to close: the denied
|
|
617
|
+
* list — every worker checkout, the mirror cache, this package's own
|
|
618
|
+
* install — outranks anything named here.
|
|
619
|
+
*/
|
|
620
|
+
orchestratorReadPaths?: string[];
|
|
192
621
|
}
|
|
193
622
|
|
|
194
623
|
/**
|
|
@@ -244,6 +673,75 @@ export interface PrVerification {
|
|
|
244
673
|
reason: string;
|
|
245
674
|
}
|
|
246
675
|
|
|
676
|
+
/**
|
|
677
|
+
* One file in a pull request's diff, in git's own terms rather than any
|
|
678
|
+
* tracker's. The settlement audit (#128) has to tell a deleted test file from a
|
|
679
|
+
* renamed one, and only the rename header says which — so `status` and
|
|
680
|
+
* `previousPath` are load-bearing, not decoration.
|
|
681
|
+
*/
|
|
682
|
+
export interface PrDiffFile {
|
|
683
|
+
path: string;
|
|
684
|
+
/** Pre-image path, set only for a rename. */
|
|
685
|
+
previousPath?: string;
|
|
686
|
+
status: "added" | "modified" | "removed" | "renamed";
|
|
687
|
+
/** The file's unified-diff hunks verbatim, absent for a binary, mode-only or
|
|
688
|
+
* pure-rename change that has none. */
|
|
689
|
+
hunks?: string;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** A pull request's diff, as the settlement audit reads it. */
|
|
693
|
+
export interface PrDiff {
|
|
694
|
+
files: PrDiffFile[];
|
|
695
|
+
/** True when the adapter had to cut the diff short. The audit's silence is
|
|
696
|
+
* then only silence about the part it saw, and it says so. */
|
|
697
|
+
truncated: boolean;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* What the settlement audit can say about a worker's own account of its work.
|
|
702
|
+
*
|
|
703
|
+
* Declared as data for the same reason `REPORT_SCOPES` is: the analyser, the
|
|
704
|
+
* settlement report and the orchestrator brief all enumerate these, and a
|
|
705
|
+
* vocabulary that lives in three places drifts in three directions.
|
|
706
|
+
*/
|
|
707
|
+
export const SETTLEMENT_FLAG_KINDS = [
|
|
708
|
+
/** The PR touched a file the report's `changed:` line never mentioned — the
|
|
709
|
+
* direction that matters, an undisclosed edit. */
|
|
710
|
+
"undisclosed-file",
|
|
711
|
+
/** The report carried no usable `changed:` line, so nothing was disclosed. */
|
|
712
|
+
"changed-line-missing",
|
|
713
|
+
/** `changed:` named a path the PR never touched. The weaker direction. */
|
|
714
|
+
"unmatched-claim",
|
|
715
|
+
/** A test file left the tree and no rename in the PR accounts for it. */
|
|
716
|
+
"test-file-deleted",
|
|
717
|
+
/** A skip/only/focus marker appears on a line the PR added. */
|
|
718
|
+
"test-disabled",
|
|
719
|
+
/** Assertions were commented out, or more left the file than entered it. */
|
|
720
|
+
"assertions-removed",
|
|
721
|
+
/** A named timeout in a test file went up. */
|
|
722
|
+
"test-timeout-raised",
|
|
723
|
+
] as const;
|
|
724
|
+
|
|
725
|
+
export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* One advisory finding about a settled run. Advisory is the whole design: a
|
|
729
|
+
* flag never changes a run's state, because the judgement it invites — "was
|
|
730
|
+
* deleting that test correct?" — needs context the daemon does not have.
|
|
731
|
+
*/
|
|
732
|
+
export interface SettlementFlag {
|
|
733
|
+
kind: SettlementFlagKind;
|
|
734
|
+
/** Repo-relative path, or `(report)` for a finding about the report itself. */
|
|
735
|
+
file: string;
|
|
736
|
+
/** 1-based line in the side of the diff the evidence came from: the
|
|
737
|
+
* post-image for an added line, the pre-image for a removed one. */
|
|
738
|
+
line?: number;
|
|
739
|
+
detail: string;
|
|
740
|
+
/** True when the dispatching issue never names this file — the "tests you
|
|
741
|
+
* didn't write" case, which is what makes a flag loud rather than routine. */
|
|
742
|
+
unattributed?: boolean;
|
|
743
|
+
}
|
|
744
|
+
|
|
247
745
|
/** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
|
|
248
746
|
export type IssueState = "open" | "closed";
|
|
249
747
|
|
|
@@ -327,6 +825,17 @@ export interface Tracker {
|
|
|
327
825
|
* head, and has a non-empty terminal-success check rollup.
|
|
328
826
|
*/
|
|
329
827
|
verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
|
|
828
|
+
/**
|
|
829
|
+
* The pull request's diff, or undefined when this adapter could not produce
|
|
830
|
+
* one — an unparseable URL, a deleted PR, a flaky network.
|
|
831
|
+
*
|
|
832
|
+
* Undefined never means "nothing changed". Its only consumer is the
|
|
833
|
+
* settlement audit (#128), which is advisory, so a failure here costs one
|
|
834
|
+
* unaudited settlement and never a run's state. That is also why it does not
|
|
835
|
+
* retry: unlike {@link Tracker.verifyPr}, nothing downstream is waiting for a
|
|
836
|
+
* better answer on a later tick.
|
|
837
|
+
*/
|
|
838
|
+
prDiff(url: string): Promise<PrDiff | undefined>;
|
|
330
839
|
}
|
|
331
840
|
|
|
332
841
|
/**
|
|
@@ -388,6 +897,11 @@ export interface RunRecord {
|
|
|
388
897
|
endedAt?: number;
|
|
389
898
|
/** Last failure text, surfaced verbatim in escalations. */
|
|
390
899
|
lastError?: string;
|
|
900
|
+
/** Advisory settlement-audit findings for this attempt (#128). Deliberately
|
|
901
|
+
* never consulted by anything that decides `state`: a flagged run settles
|
|
902
|
+
* exactly as an unflagged one does, and the flags are evidence for whoever
|
|
903
|
+
* reviews the PR. Absent means the audit found nothing, or never ran. */
|
|
904
|
+
settlementFlags?: SettlementFlag[];
|
|
391
905
|
}
|
|
392
906
|
|
|
393
907
|
export type AdmissionHoldReason =
|
|
@@ -401,6 +915,12 @@ export type AdmissionHoldReason =
|
|
|
401
915
|
| "open-pr"
|
|
402
916
|
| "unsalvaged-wip"
|
|
403
917
|
| "daily-spend-cap"
|
|
918
|
+
| "plan-usage-cap"
|
|
919
|
+
/** `credentials.isolation: "per-run"` on a host whose probe found no
|
|
920
|
+
* mechanism. The operator asked for a boundary this host cannot build, so
|
|
921
|
+
* dispatch refuses rather than running unprotected under a config that says
|
|
922
|
+
* otherwise (#125). */
|
|
923
|
+
| "credential-boundary"
|
|
404
924
|
| "unroutable:no-repo-label"
|
|
405
925
|
| "unroutable:multiple-repo-labels"
|
|
406
926
|
| "unroutable:unknown-repo";
|
|
@@ -464,6 +984,95 @@ export interface FrictionSignal {
|
|
|
464
984
|
}
|
|
465
985
|
|
|
466
986
|
|
|
987
|
+
/**
|
|
988
|
+
* What a report is *for*. Both reporting scopes promise exactly one `digest` a
|
|
989
|
+
* day; a `material` report is one event as it happens. The daemon puts both on
|
|
990
|
+
* the wire identically — the kind exists so the *ledger* can answer "has
|
|
991
|
+
* today's digest already been handed over?" without asking the model to
|
|
992
|
+
* remember, which is the memory #123 found unreliable.
|
|
993
|
+
*
|
|
994
|
+
* Declared as data for the same reason as {@link REPORT_SCOPES}: the CLI verb
|
|
995
|
+
* that accepts a kind and the outbox that renders one enumerate the same list,
|
|
996
|
+
* so a third kind cannot be added while either still knows only two.
|
|
997
|
+
*/
|
|
998
|
+
export const REPORT_KINDS = ["material", "digest"] as const;
|
|
999
|
+
|
|
1000
|
+
export type ReportKind = (typeof REPORT_KINDS)[number];
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Where one report is in the outbox. The two open states are deliberately
|
|
1004
|
+
* distinct because they need different responses from an operator:
|
|
1005
|
+
*
|
|
1006
|
+
* - `pending` — nothing is in flight. Either nothing was ever attempted, or the
|
|
1007
|
+
* last attempt failed in a way Telegram told us about ({@link
|
|
1008
|
+
* ReportRecord.lastError} says which). Nobody has this report.
|
|
1009
|
+
* - `sending` — a request left this process and its outcome was never learned:
|
|
1010
|
+
* the daemon died, or the fetch never came back. It may well have been
|
|
1011
|
+
* delivered. The Bot API accepts no client-supplied idempotency key, so there
|
|
1012
|
+
* is nothing to replay against and no bot-readable record to reconcile with —
|
|
1013
|
+
* delivery is therefore **at-least-once** and a retry may duplicate. A
|
|
1014
|
+
* duplicate page is acceptable; a silently dropped one is the whole of #123.
|
|
1015
|
+
*
|
|
1016
|
+
* `failed` is the bounded retry budget running out, and is itself news: a
|
|
1017
|
+
* report nobody can deliver escalates as tier 2 in its own right.
|
|
1018
|
+
*/
|
|
1019
|
+
export const REPORT_DELIVERY_STATES = ["pending", "sending", "delivered", "failed"] as const;
|
|
1020
|
+
|
|
1021
|
+
export type ReportDeliveryState = (typeof REPORT_DELIVERY_STATES)[number];
|
|
1022
|
+
|
|
1023
|
+
/** One rendered report, and everything the daemon knows about delivering it. */
|
|
1024
|
+
export interface ReportRecord {
|
|
1025
|
+
/** Short, stable, and printed inside the message itself. This transport
|
|
1026
|
+
* offers no reconciliation, so an id an operator can compare by eye is the
|
|
1027
|
+
* only way to recognise a duplicate as one. */
|
|
1028
|
+
id: string;
|
|
1029
|
+
project: string;
|
|
1030
|
+
kind: ReportKind;
|
|
1031
|
+
/** The model's rendered text, stored verbatim — authorship stays theirs. */
|
|
1032
|
+
body: string;
|
|
1033
|
+
state: ReportDeliveryState;
|
|
1034
|
+
/** Attempts *started*, including one currently in flight. */
|
|
1035
|
+
attempts: number;
|
|
1036
|
+
/** Names the in-flight attempt. Every terminal transition quotes it, so a
|
|
1037
|
+
* request that returns after its row was reclaimed cannot overwrite a newer
|
|
1038
|
+
* attempt's outcome — this is what keeps one report from being concurrently
|
|
1039
|
+
* in flight twice. Absent whenever nothing is in flight. */
|
|
1040
|
+
attemptId?: string;
|
|
1041
|
+
/** True once an attempt's outcome was lost. From then on every message for
|
|
1042
|
+
* this report says it may be a repeat, because it may be. */
|
|
1043
|
+
ambiguous: boolean;
|
|
1044
|
+
/** At-most-one guard, e.g. `digest:2026-08-09`. Absent for material reports. */
|
|
1045
|
+
dedupeKey?: string;
|
|
1046
|
+
createdAt: number;
|
|
1047
|
+
updatedAt: number;
|
|
1048
|
+
/** Backoff gate: a `pending` row is due only once this has passed. */
|
|
1049
|
+
nextAttemptAt: number;
|
|
1050
|
+
/** Telegram's own id for the delivered message, read out of the response
|
|
1051
|
+
* body rather than inferred from the HTTP status. */
|
|
1052
|
+
messageId?: number;
|
|
1053
|
+
deliveredAt?: number;
|
|
1054
|
+
/** Bounded text of the last known failure, surfaced verbatim by `status`. */
|
|
1055
|
+
lastError?: string;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** What a caller hands over. The store owns identity, state and every timestamp. */
|
|
1059
|
+
export interface ReportDraft {
|
|
1060
|
+
project: string;
|
|
1061
|
+
kind: ReportKind;
|
|
1062
|
+
body: string;
|
|
1063
|
+
dedupeKey?: string;
|
|
1064
|
+
at: number;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Result of handing a report over. `deduped` is how the daily digest is
|
|
1068
|
+
* suppressed — decided from the ledger, never from the model's memory of the
|
|
1069
|
+
* last tick — and the caller is told which happened rather than left to guess
|
|
1070
|
+
* from a row it cannot tell apart from its own. */
|
|
1071
|
+
export interface ReportEnqueue {
|
|
1072
|
+
report: ReportRecord;
|
|
1073
|
+
deduped: boolean;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
467
1076
|
/**
|
|
468
1077
|
* Bookkeeping only — GitHub labels remain the source of truth. The store
|
|
469
1078
|
* exists to answer cap questions cheaply and to survive a restart; if it is
|
|
@@ -515,6 +1124,68 @@ export interface Store {
|
|
|
515
1124
|
/** Start the cooldown only after a tick carrying these signals was sent. */
|
|
516
1125
|
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
|
|
517
1126
|
markNotified(key: string): void;
|
|
1127
|
+
/**
|
|
1128
|
+
* Persist a rendered report `pending`, before anything is sent — the whole
|
|
1129
|
+
* point of #123 is that an undelivered report is a queryable row rather than
|
|
1130
|
+
* an absence. A draft whose `dedupeKey` is already in the ledger returns that
|
|
1131
|
+
* row untouched and `deduped: true`, which is how the daily digest is
|
|
1132
|
+
* suppressed without asking the model what it sent yesterday.
|
|
1133
|
+
*/
|
|
1134
|
+
enqueueReport(draft: ReportDraft): ReportEnqueue;
|
|
1135
|
+
getReport(id: string): ReportRecord | undefined;
|
|
1136
|
+
/** `pending` rows whose backoff has elapsed, oldest first. */
|
|
1137
|
+
dueReports(project: string, now: number, limit: number): ReportRecord[];
|
|
1138
|
+
/** Atomically take a due `pending` row into `sending` under `attemptId`, so
|
|
1139
|
+
* the row says a request is about to leave *before* one does. `undefined`
|
|
1140
|
+
* means somebody else claimed it first — the guard against the same report
|
|
1141
|
+
* being concurrently in flight twice. */
|
|
1142
|
+
claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined;
|
|
1143
|
+
/** Record the message id Telegram returned, and the moment it did. */
|
|
1144
|
+
markReportDelivered(id: string, attemptId: string, messageId: number | undefined, at: number): boolean;
|
|
1145
|
+
/** The attempt ended without an answer: Telegram may hold the message. The
|
|
1146
|
+
* row *stays* `sending`, because that state means exactly "outcome unknown",
|
|
1147
|
+
* and is flagged {@link ReportRecord.ambiguous} so the retry that stale
|
|
1148
|
+
* recovery eventually hands back says it may be a repeat. Deliberately does
|
|
1149
|
+
* not touch `updatedAt`: the staleness clock started when the request left,
|
|
1150
|
+
* and restarting it here would make the daemon wait out a second window for
|
|
1151
|
+
* an attempt already known to be over. */
|
|
1152
|
+
markReportUncertain(id: string, attemptId: string, error: string): boolean;
|
|
1153
|
+
/** Known-failed: back to `pending` behind `nextAttemptAt`, attempt counted. */
|
|
1154
|
+
markReportPending(id: string, attemptId: string, nextAttemptAt: number, error: string, at: number): boolean;
|
|
1155
|
+
/** Retry budget exhausted. Terminal, and worth escalating in its own right. */
|
|
1156
|
+
markReportFailed(id: string, attemptId: string, error: string, at: number): boolean;
|
|
1157
|
+
/** `sending` rows untouched since `staleAt` — what a dead process left behind.
|
|
1158
|
+
* Returns them to `pending` flagged {@link ReportRecord.ambiguous}, so the
|
|
1159
|
+
* retry says it may be a repeat instead of pretending it is the first try. */
|
|
1160
|
+
recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[];
|
|
1161
|
+
/** Everything an operator still has to care about: pending, sending, failed. */
|
|
1162
|
+
openReports(project: string): ReportRecord[];
|
|
1163
|
+
/**
|
|
1164
|
+
* Append one decided verb call to the run-scoped action ledger (#126).
|
|
1165
|
+
* Written for refusals as well as approvals, and written *before* the
|
|
1166
|
+
* privileged half runs is deliberately NOT the contract: the entry carries
|
|
1167
|
+
* the resulting sha, so it is written once the outcome is known.
|
|
1168
|
+
*/
|
|
1169
|
+
appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry;
|
|
1170
|
+
/** Newest first. `runId` narrows to one run's own record. */
|
|
1171
|
+
verbLedger(project: string, opts?: { runId?: string; issue?: number; limit?: number }): VerbLedgerEntry[];
|
|
1172
|
+
/**
|
|
1173
|
+
* Claim the project's single merge slot, or `undefined` when another holder
|
|
1174
|
+
* has it. Locks older than `staleAfterMs` are broken first: a daemon killed
|
|
1175
|
+
* mid-merge must not wedge the project forever, and the exact-head recheck
|
|
1176
|
+
* makes a stolen lock safe — the second merge re-reads the live head anyway.
|
|
1177
|
+
*/
|
|
1178
|
+
acquireMergeLock(
|
|
1179
|
+
project: string,
|
|
1180
|
+
holder: string,
|
|
1181
|
+
prUrl: string,
|
|
1182
|
+
now: number,
|
|
1183
|
+
staleAfterMs: number,
|
|
1184
|
+
): MergeLock | undefined;
|
|
1185
|
+
/** Release a lock this holder owns. A lock it does not own is left alone. */
|
|
1186
|
+
releaseMergeLock(project: string, holder: string): void;
|
|
1187
|
+
/** The live lock, for `status` and for tests. */
|
|
1188
|
+
mergeLock(project: string): MergeLock | undefined;
|
|
518
1189
|
close(): void;
|
|
519
1190
|
}
|
|
520
1191
|
|
|
@@ -544,8 +1215,174 @@ export interface Escalation {
|
|
|
544
1215
|
export const DEFAULT_CAPS: Caps = {
|
|
545
1216
|
maxConcurrentWorkers: 2,
|
|
546
1217
|
dailySpendUsd: 25,
|
|
1218
|
+
// Off unless an operator names a window. A default threshold would need a
|
|
1219
|
+
// default window id, and guessing which allowance a fleet lives on is how a
|
|
1220
|
+
// guard silently watches the wrong meter (#110).
|
|
1221
|
+
planUsage: null,
|
|
547
1222
|
workerMaxTurns: 120,
|
|
548
1223
|
workerWallClockMs: 90 * 60 * 1000,
|
|
549
1224
|
maxAttemptsPerIssue: 2,
|
|
550
1225
|
maxContinuationsPerIssue: 2,
|
|
551
1226
|
};
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* The conductor-owned mutation verbs (#126). Data, for the reason every other
|
|
1230
|
+
* vocabulary here is data: the daemon's dispatch table, the ledger renderer and
|
|
1231
|
+
* the table-driven "every verb declares an allowed-caller set" test all
|
|
1232
|
+
* enumerate this list, so a seventh verb cannot be added to one of them alone.
|
|
1233
|
+
*/
|
|
1234
|
+
export const VERB_NAMES = [
|
|
1235
|
+
"conductor_push",
|
|
1236
|
+
"conductor_pr_create",
|
|
1237
|
+
"conductor_pr_update_branch",
|
|
1238
|
+
"conductor_pr_merge",
|
|
1239
|
+
"conductor_label",
|
|
1240
|
+
"conductor_release",
|
|
1241
|
+
/** The one read verb. A session with no credential cannot otherwise observe
|
|
1242
|
+
* the pull request it just pushed, and "pushed-green" would go back to being
|
|
1243
|
+
* a claim a worker makes about itself. */
|
|
1244
|
+
"conductor_pr_status",
|
|
1245
|
+
] as const;
|
|
1246
|
+
|
|
1247
|
+
export type VerbName = (typeof VERB_NAMES)[number];
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Field names a verb request may never carry.
|
|
1251
|
+
*
|
|
1252
|
+
* Identity is derived from *which socket the connection arrived on* plus the
|
|
1253
|
+
* verified peer uid, so a payload that spells any of these is either a buggy
|
|
1254
|
+
* client or one probing for a daemon that trusts them — `turnLimitResponse`
|
|
1255
|
+
* trusting a body-supplied `project` on the unauthenticated HTTP port is the
|
|
1256
|
+
* failure mode this list exists to keep off the socket. Refused outright and
|
|
1257
|
+
* named in the refusal rather than ignored, because a client that is silently
|
|
1258
|
+
* stripped keeps sending them and nobody ever learns.
|
|
1259
|
+
*/
|
|
1260
|
+
export const RESERVED_VERB_FIELDS = [
|
|
1261
|
+
"project",
|
|
1262
|
+
"run",
|
|
1263
|
+
"runId",
|
|
1264
|
+
"issue",
|
|
1265
|
+
"role",
|
|
1266
|
+
"uid",
|
|
1267
|
+
"caller",
|
|
1268
|
+
] as const;
|
|
1269
|
+
|
|
1270
|
+
export type ReservedVerbField = (typeof RESERVED_VERB_FIELDS)[number];
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Why a verb call ended the way it did. Closed, and matched by the ledger
|
|
1274
|
+
* renderer and by tests: a refusal an operator cannot name is a refusal nobody
|
|
1275
|
+
* can audit, and free-text reasons drift one wording per call site.
|
|
1276
|
+
*/
|
|
1277
|
+
export const VERB_REFUSALS = [
|
|
1278
|
+
/** The socket's role is not in the verb's allowed-caller set. */
|
|
1279
|
+
"role-not-allowed",
|
|
1280
|
+
/** The caller's role is not the holder configured in `authority`. */
|
|
1281
|
+
"authority-holder",
|
|
1282
|
+
/** Claiming, ticks, or the fleet as a whole is stopped. */
|
|
1283
|
+
"fleet-paused",
|
|
1284
|
+
/** The run row this socket belongs to is no longer live. */
|
|
1285
|
+
"run-not-live",
|
|
1286
|
+
/** The request carried a {@link RESERVED_VERB_FIELDS} field. */
|
|
1287
|
+
"identity-in-payload",
|
|
1288
|
+
/** An argument this verb does not declare — closed by default, never ignored. */
|
|
1289
|
+
"unknown-argument",
|
|
1290
|
+
/** A declared argument was missing or the wrong type. */
|
|
1291
|
+
"malformed-argument",
|
|
1292
|
+
/** `reason` was not a member of the verb's closed enum (#129). */
|
|
1293
|
+
"reason-not-in-enum",
|
|
1294
|
+
/** A ref other than the run's own branch. */
|
|
1295
|
+
"ref-not-run-branch",
|
|
1296
|
+
/** The base branch is not the repo's configured `defaultBranch`. */
|
|
1297
|
+
"base-not-default-branch",
|
|
1298
|
+
/** An open pull request already closes this issue (#25's guard). */
|
|
1299
|
+
"open-pr-exists",
|
|
1300
|
+
/** The tracker could not say whether one does. Fail closed. */
|
|
1301
|
+
"open-pr-lookup-error",
|
|
1302
|
+
/** The named pull request is not this run's, or not this project's. */
|
|
1303
|
+
"pr-not-this-run",
|
|
1304
|
+
/** The run has no pull request to act on. */
|
|
1305
|
+
"pr-missing",
|
|
1306
|
+
/**
|
|
1307
|
+
* The pull request is closed, merged, or its state could not be read.
|
|
1308
|
+
* Distinct from {@link VERB_REFUSALS} `checks-not-green` on purpose: "you
|
|
1309
|
+
* cannot update a merged PR" and "this PR is red" invite different next
|
|
1310
|
+
* moves, and a ledger that spelled both the same way would lose that.
|
|
1311
|
+
*/
|
|
1312
|
+
"pr-not-open",
|
|
1313
|
+
/** `headSha` does not equal the live head at execution time. */
|
|
1314
|
+
"head-stale",
|
|
1315
|
+
/** The live head could not be read at all. Fail closed. */
|
|
1316
|
+
"head-unresolvable",
|
|
1317
|
+
/** Required checks are pending or red at the live head, or the PR is not open. */
|
|
1318
|
+
"checks-not-green",
|
|
1319
|
+
/** Another merge is in flight for this project. */
|
|
1320
|
+
"merge-in-flight",
|
|
1321
|
+
/** The label is not in this project's own vocabulary. */
|
|
1322
|
+
"label-not-in-vocabulary",
|
|
1323
|
+
/** The label is a lifecycle label; those transitions stay the daemon's (#26). */
|
|
1324
|
+
"label-is-lifecycle",
|
|
1325
|
+
/** The release grant does not permit this shape for this caller. */
|
|
1326
|
+
"release-not-granted",
|
|
1327
|
+
/** The artefact or environment is not one this project declared (#129). */
|
|
1328
|
+
"release-target-not-declared",
|
|
1329
|
+
/** A shape the daemon holds no credential for and will not pretend to cut. */
|
|
1330
|
+
"release-shape-not-executable",
|
|
1331
|
+
/** Config unreadable, project unknown, repo unrouted. Fail closed. */
|
|
1332
|
+
"config-unreadable",
|
|
1333
|
+
/** The privileged half ran and the underlying command failed. */
|
|
1334
|
+
"action-failed",
|
|
1335
|
+
] as const;
|
|
1336
|
+
|
|
1337
|
+
export type VerbRefusal = (typeof VERB_REFUSALS)[number];
|
|
1338
|
+
|
|
1339
|
+
/** How a verb call was decided. `allowed` means the privileged half then ran. */
|
|
1340
|
+
export const VERB_DECISIONS = ["allowed", "refused"] as const;
|
|
1341
|
+
|
|
1342
|
+
export type VerbDecision = (typeof VERB_DECISIONS)[number];
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* One line of the run-scoped action ledger (#126): what was asked, by whom,
|
|
1346
|
+
* what the daemon decided, and what came back.
|
|
1347
|
+
*
|
|
1348
|
+
* Written for refusals as well as approvals — a ledger that records only what
|
|
1349
|
+
* happened cannot answer "what did it try", which is the question an escalation
|
|
1350
|
+
* actually asks.
|
|
1351
|
+
*/
|
|
1352
|
+
export interface VerbLedgerEntry {
|
|
1353
|
+
id: string;
|
|
1354
|
+
project: string;
|
|
1355
|
+
/** The run whose socket carried the call. Absent for the orchestrator's. */
|
|
1356
|
+
runId?: string;
|
|
1357
|
+
/** The issue that run is servicing, or the one a verb targeted. */
|
|
1358
|
+
issue?: number;
|
|
1359
|
+
verb: VerbName;
|
|
1360
|
+
/** Resolved from the channel, never from the payload. */
|
|
1361
|
+
role: SessionRole;
|
|
1362
|
+
/** The arguments as received, redacted of nothing: this is the audit record. */
|
|
1363
|
+
args: Record<string, unknown>;
|
|
1364
|
+
decision: VerbDecision;
|
|
1365
|
+
/** Present exactly when `decision` is `"refused"`. */
|
|
1366
|
+
refusal?: VerbRefusal;
|
|
1367
|
+
/** What the caller was told, verbatim. */
|
|
1368
|
+
detail: string;
|
|
1369
|
+
/** The commit, tag or merge sha the approved call produced, if any. */
|
|
1370
|
+
sha?: string;
|
|
1371
|
+
at: number;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** What a caller hands the ledger. The store owns `id` and `at`. */
|
|
1375
|
+
export type VerbLedgerDraft = Omit<VerbLedgerEntry, "id" | "at">;
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The single-flight merge claim for one project, held in the store because the
|
|
1379
|
+
* CLI, the daemon and every session are separate processes: an in-memory mutex
|
|
1380
|
+
* would serialise one process against itself and nothing else (#126).
|
|
1381
|
+
*/
|
|
1382
|
+
export interface MergeLock {
|
|
1383
|
+
project: string;
|
|
1384
|
+
/** Opaque holder id — the run id, or the orchestrator channel's id. */
|
|
1385
|
+
holder: string;
|
|
1386
|
+
prUrl: string;
|
|
1387
|
+
at: number;
|
|
1388
|
+
}
|