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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The privileged half of the verbs, wired to real commands (#126).
|
|
3
|
+
*
|
|
4
|
+
* This is the *only* module in the verb path that touches a credential, and it
|
|
5
|
+
* runs in the daemon. Push and pull-request creation are delegated to the
|
|
6
|
+
* credential boundary (#125) rather than re-shelled here, so there is one
|
|
7
|
+
* implementation of "how does a run's work reach the remote" and it is the one
|
|
8
|
+
* that scrubs the environment. The remaining verbs shell `gh` through
|
|
9
|
+
* {@link credentialedEnv} for the same reason.
|
|
10
|
+
*
|
|
11
|
+
* Kept apart from `server.ts` deliberately: every policy check above it is
|
|
12
|
+
* testable against the {@link VerbActions} interface with no repository, no
|
|
13
|
+
* network and no `gh` on PATH, which is what makes the adversarial cases cheap
|
|
14
|
+
* enough to actually write.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../credentials.ts";
|
|
18
|
+
import type { ProjectConfig, ReleaseShape } from "../types.ts";
|
|
19
|
+
import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What this daemon can actually cut.
|
|
23
|
+
*
|
|
24
|
+
* The three GitHub-shaped ones, and deliberately not `package-publish` or
|
|
25
|
+
* `deploy`: the daemon holds a GitHub credential and nothing else — no npm
|
|
26
|
+
* token, no registry login, no deploy key — and #125 exists to keep it that
|
|
27
|
+
* way. A shape outside this list is refused by name in `server.ts` rather than
|
|
28
|
+
* attempted and reported as a command failure, because "we tried and it did not
|
|
29
|
+
* work" and "we were never able to do this" are different answers and only one
|
|
30
|
+
* of them tells an operator to go and do it themselves.
|
|
31
|
+
*/
|
|
32
|
+
export const GITHUB_RELEASABLE_SHAPES: readonly ReleaseShape[] = [
|
|
33
|
+
"git-tag",
|
|
34
|
+
"git-push-tags",
|
|
35
|
+
"github-release",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** One command, its captured output, and no interpretation. */
|
|
39
|
+
export interface CommandRun {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CommandRunner = (
|
|
46
|
+
argv: string[],
|
|
47
|
+
opts: { cwd?: string; env?: Record<string, string> },
|
|
48
|
+
) => Promise<CommandRun>;
|
|
49
|
+
|
|
50
|
+
const spawnCommand: CommandRunner = async (argv, opts) => {
|
|
51
|
+
const proc = Bun.spawn(argv, {
|
|
52
|
+
// Always a closed stream, matching the tracker adapter: a command that does
|
|
53
|
+
// not read stdin sees EOF rather than a pipe nobody ends.
|
|
54
|
+
stdin: new Blob([""]),
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "pipe",
|
|
57
|
+
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
|
58
|
+
...(opts.env === undefined ? {} : { env: opts.env }),
|
|
59
|
+
});
|
|
60
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
61
|
+
new Response(proc.stdout).text(),
|
|
62
|
+
new Response(proc.stderr).text(),
|
|
63
|
+
proc.exited,
|
|
64
|
+
]);
|
|
65
|
+
return { ok: code === 0 && !proc.signalCode, stdout, stderr };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function failed(run: CommandRun, argv: string[]): ActionOutcome {
|
|
69
|
+
return { ok: false, stderr: run.stderr.trim() || `\`${argv.join(" ")}\` failed with no stderr` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The production {@link VerbActions}.
|
|
74
|
+
*
|
|
75
|
+
* `run` is injected so the release and tracker paths can be exercised without
|
|
76
|
+
* a live repository; production callers never pass it.
|
|
77
|
+
*/
|
|
78
|
+
export function githubVerbActions(project: ProjectConfig, run: CommandRunner = spawnCommand): VerbActions {
|
|
79
|
+
const env = (): Record<string, string> => credentialedEnv();
|
|
80
|
+
|
|
81
|
+
const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
|
|
82
|
+
const full = ["gh", ...argv];
|
|
83
|
+
const result = await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) });
|
|
84
|
+
return result.ok ? { ok: true, detail: result.stdout.trim() || undefined } : failed(result, full);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
releasableShapes: GITHUB_RELEASABLE_SHAPES,
|
|
89
|
+
|
|
90
|
+
// Both of these are #125's, not this module's: they know the run's own
|
|
91
|
+
// repository layout and they are the two paths that must never see an
|
|
92
|
+
// ambient credential. Re-shelling `git push` here would be a second
|
|
93
|
+
// implementation of the one thing the credential boundary exists to own.
|
|
94
|
+
push: (target) => pushRunBranch(project, target),
|
|
95
|
+
|
|
96
|
+
createPr: (target, opts) => openRunPr(project, target, opts),
|
|
97
|
+
|
|
98
|
+
updatePrBranch: (prUrl) => gh(["pr", "update-branch", prUrl]),
|
|
99
|
+
|
|
100
|
+
// `--match-head-commit` is the server-side half of the exact-head rule: the
|
|
101
|
+
// daemon already re-read the head and refused a stale one, and this makes
|
|
102
|
+
// GitHub refuse too if the branch moved in the milliseconds between. Belt
|
|
103
|
+
// and braces on the one operation that cannot be undone.
|
|
104
|
+
mergePr: (prUrl, headSha) => gh(["pr", "merge", prUrl, "--squash", "--match-head-commit", headSha]),
|
|
105
|
+
|
|
106
|
+
setLabel: async (issue, label, action) => {
|
|
107
|
+
const slug = project.tracker.repo;
|
|
108
|
+
return gh([
|
|
109
|
+
"issue",
|
|
110
|
+
"edit",
|
|
111
|
+
String(issue),
|
|
112
|
+
"--repo",
|
|
113
|
+
slug,
|
|
114
|
+
action === "add" ? "--add-label" : "--remove-label",
|
|
115
|
+
label,
|
|
116
|
+
]);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
release: async (execution: ReleaseExecution): Promise<ActionOutcome> => {
|
|
120
|
+
const slug = repoSlugFor(execution.repo);
|
|
121
|
+
const tag = execution.tag;
|
|
122
|
+
if (tag === undefined) {
|
|
123
|
+
return { ok: false, stderr: `a ${execution.shape} needs a tag; none was given` };
|
|
124
|
+
}
|
|
125
|
+
if (execution.shape === "github-release") {
|
|
126
|
+
return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
|
|
127
|
+
}
|
|
128
|
+
// The two git shapes act on the project's mirror of the repo, which is
|
|
129
|
+
// the only checkout the daemon owns. A tag is cut against the mirror's
|
|
130
|
+
// view of the default branch and pushed from there.
|
|
131
|
+
const mirror = `${project.mirrorRoot}/${execution.repo.name}.git`;
|
|
132
|
+
if (execution.shape === "git-tag") {
|
|
133
|
+
const argv = ["git", "-C", mirror, "tag", "-a", tag, "-m", `release ${tag}`, `origin/${execution.repo.defaultBranch}`];
|
|
134
|
+
const result = await run(argv, { env: env() });
|
|
135
|
+
return result.ok ? { ok: true, detail: `tagged ${tag}` } : failed(result, argv);
|
|
136
|
+
}
|
|
137
|
+
const argv = ["git", "-C", mirror, "push", "origin", `refs/tags/${tag}`];
|
|
138
|
+
const result = await run(argv, { env: env() });
|
|
139
|
+
return result.ok ? { ok: true, detail: `pushed refs/tags/${tag}` } : failed(result, argv);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The child-side half of the mutation verbs (#126): a thin client, and nothing
|
|
3
|
+
* else.
|
|
4
|
+
*
|
|
5
|
+
* This module runs **inside the untrusted per-run process**. Under #125 that is
|
|
6
|
+
* a different OS principal with no access to the daemon's `gh` config, keychain
|
|
7
|
+
* or SSH keys; before #125 it is the same user. Either way it is code the model
|
|
8
|
+
* can read, and on a bad day rewrite — so it decides nothing:
|
|
9
|
+
*
|
|
10
|
+
* - **No policy branch.** It does not know what `authority.merge` says, which
|
|
11
|
+
* release shapes are granted, or whether the fleet is paused. Every answer
|
|
12
|
+
* it renders was decided on the other side of the socket.
|
|
13
|
+
* - **No credential.** It never reads a token, an env var holding one, or a
|
|
14
|
+
* config file. Look at this file's imports: `node:net`, and the verb table.
|
|
15
|
+
* There is nothing here to steal.
|
|
16
|
+
* - **No local fallback.** No socket means the verb fails, loudly, with an
|
|
17
|
+
* explanation. A client that fell back to `git push` when the daemon was
|
|
18
|
+
* unreachable would make the boundary optional, and an optional boundary is
|
|
19
|
+
* one that is missing precisely when something has gone wrong.
|
|
20
|
+
*
|
|
21
|
+
* The identity fields are absent by construction rather than stripped: this
|
|
22
|
+
* client has no project, run or role to send, because it was never told one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { connect } from "node:net";
|
|
26
|
+
|
|
27
|
+
import { VERB_NAMES } from "../types.ts";
|
|
28
|
+
import type { VerbName } from "../types.ts";
|
|
29
|
+
import { VERB_SPECS, verbParameterSchema } from "./protocol.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where the daemon told this session its socket is.
|
|
33
|
+
*
|
|
34
|
+
* An environment variable rather than a config lookup, because a config lookup
|
|
35
|
+
* is a decision: the child would have to work out *which* run it is, and that
|
|
36
|
+
* is the question the whole transport exists to stop it answering. The daemon
|
|
37
|
+
* sets exactly one path per child, and that path is the child's whole identity.
|
|
38
|
+
*/
|
|
39
|
+
export const VERB_SOCKET_ENV = "OMP_CONDUCTOR_VERB_SOCKET";
|
|
40
|
+
|
|
41
|
+
/** Long enough for a `gh pr merge` behind branch protection, short enough that
|
|
42
|
+
* a wedged daemon does not consume the session's wall clock. */
|
|
43
|
+
const CALL_TIMEOUT_MS = 120_000;
|
|
44
|
+
|
|
45
|
+
export interface VerbCallResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
/** Rendered verbatim into the tool result. Never rewritten by this client. */
|
|
48
|
+
text: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a session is told when there is no socket to call.
|
|
53
|
+
*
|
|
54
|
+
* Fails closed and says why, rather than suggesting a workaround: the correct
|
|
55
|
+
* next step is to report and stop, and a message that hinted at `git push`
|
|
56
|
+
* would be read as permission.
|
|
57
|
+
*/
|
|
58
|
+
export function noSocketResult(verb: VerbName): VerbCallResult {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
text:
|
|
62
|
+
`${verb} is unavailable: this session has no conductor verb socket (${VERB_SOCKET_ENV} is unset ` +
|
|
63
|
+
"or the daemon did not create one). This tool is a client for the daemon, which holds the credential " +
|
|
64
|
+
"and makes the decision; there is no local way to do this and you must not attempt one. Report that " +
|
|
65
|
+
"the verb socket is missing and stop.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One request, one reply, one connection.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately not a pooled or persistent connection: the daemon asserts peer
|
|
73
|
+
* credentials at accept time, and a connection that outlives the turn it was
|
|
74
|
+
* opened for is a connection whose caller was verified once and reused many
|
|
75
|
+
* times.
|
|
76
|
+
*/
|
|
77
|
+
export async function callVerb(
|
|
78
|
+
socketPath: string | undefined,
|
|
79
|
+
verb: VerbName,
|
|
80
|
+
args: Record<string, unknown>,
|
|
81
|
+
timeoutMs = CALL_TIMEOUT_MS,
|
|
82
|
+
): Promise<VerbCallResult> {
|
|
83
|
+
if (socketPath === undefined || socketPath.length === 0) return noSocketResult(verb);
|
|
84
|
+
|
|
85
|
+
return new Promise<VerbCallResult>((resolve) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
const finish = (result: VerbCallResult): void => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
socket.destroy();
|
|
91
|
+
resolve(result);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const socket = connect(socketPath);
|
|
95
|
+
socket.setTimeout(timeoutMs, () => {
|
|
96
|
+
finish({
|
|
97
|
+
ok: false,
|
|
98
|
+
text: `${verb} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the conductor daemon. Nothing was retried; ask again or report it.`,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
socket.on("error", (err: NodeJS.ErrnoException) => {
|
|
102
|
+
finish({
|
|
103
|
+
ok: false,
|
|
104
|
+
text:
|
|
105
|
+
`${verb} could not reach the conductor daemon on ${socketPath} (${err.code ?? err.message}). ` +
|
|
106
|
+
"The daemon decides and executes this verb; there is no local fallback. Report it and stop.",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let buffer = "";
|
|
111
|
+
socket.on("data", (chunk) => {
|
|
112
|
+
buffer += chunk.toString("utf8");
|
|
113
|
+
const newline = buffer.indexOf("\n");
|
|
114
|
+
if (newline >= 0) finish(render(verb, buffer.slice(0, newline)));
|
|
115
|
+
});
|
|
116
|
+
socket.on("end", () => {
|
|
117
|
+
if (buffer.trim().length > 0) finish(render(verb, buffer));
|
|
118
|
+
else
|
|
119
|
+
finish({
|
|
120
|
+
ok: false,
|
|
121
|
+
text:
|
|
122
|
+
`${verb} was refused at the transport: the daemon closed the connection without a reply. ` +
|
|
123
|
+
"That is what an unverified caller gets — check the daemon log for an impersonation entry.",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Identity is not in this payload because this client does not have one.
|
|
128
|
+
socket.write(`${JSON.stringify({ verb, args })}\n`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render the daemon's answer. The only transformation is JSON parsing: the
|
|
134
|
+
* refusal text is the daemon's wording, verbatim, because a client that
|
|
135
|
+
* paraphrased a refusal would be a second, weaker copy of the policy.
|
|
136
|
+
*/
|
|
137
|
+
function render(verb: VerbName, line: string): VerbCallResult {
|
|
138
|
+
try {
|
|
139
|
+
const reply = JSON.parse(line) as { ok?: unknown; text?: unknown };
|
|
140
|
+
if (typeof reply.text !== "string") {
|
|
141
|
+
return { ok: false, text: `${verb}: the daemon sent a reply with no text.` };
|
|
142
|
+
}
|
|
143
|
+
return { ok: reply.ok === true, text: reply.text };
|
|
144
|
+
} catch {
|
|
145
|
+
return { ok: false, text: `${verb}: the daemon sent an unreadable reply.` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The tool-result shape the harness expects back from `execute`. */
|
|
150
|
+
interface VerbToolResult {
|
|
151
|
+
content: { type: "text"; text: string }[];
|
|
152
|
+
isError?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The members of the harness extension API this client uses. Declared
|
|
157
|
+
* structurally for the reason `ReleasePolicyPi` is: the harness is an optional
|
|
158
|
+
* peer dependency that is absent when this package is type-checked.
|
|
159
|
+
*/
|
|
160
|
+
export interface VerbPi {
|
|
161
|
+
registerTool(tool: {
|
|
162
|
+
name: string;
|
|
163
|
+
label: string;
|
|
164
|
+
description: string;
|
|
165
|
+
parameters: unknown;
|
|
166
|
+
approval?: "read" | "write" | "exec";
|
|
167
|
+
execute(
|
|
168
|
+
toolCallId: string,
|
|
169
|
+
params: Record<string, unknown>,
|
|
170
|
+
): Promise<VerbToolResult>;
|
|
171
|
+
}): void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register every verb on a session.
|
|
176
|
+
*
|
|
177
|
+
* *Every* verb, on every session, including the ones this session will always
|
|
178
|
+
* be refused. That is not an oversight: the refusal is the teaching surface. A
|
|
179
|
+
* worker that cannot see `conductor_pr_merge` learns nothing when it wants to
|
|
180
|
+
* merge, whereas a worker that calls it is told "merge authority is the
|
|
181
|
+
* orchestrator's" — and the attempt lands in the ledger, where an operator can
|
|
182
|
+
* see that a worker tried. Hiding the tool would move that signal nowhere.
|
|
183
|
+
*
|
|
184
|
+
* It is also the honest shape: filtering the list here would be this client
|
|
185
|
+
* making a policy decision, which is exactly what it must not do.
|
|
186
|
+
*/
|
|
187
|
+
export function conductorVerbs(socketPath: string | undefined): (pi: VerbPi) => void {
|
|
188
|
+
return (pi) => {
|
|
189
|
+
for (const name of VERB_NAMES) {
|
|
190
|
+
const spec = VERB_SPECS[name];
|
|
191
|
+
pi.registerTool({
|
|
192
|
+
name,
|
|
193
|
+
label: name,
|
|
194
|
+
description: spec.description,
|
|
195
|
+
parameters: verbParameterSchema(spec),
|
|
196
|
+
approval: "write",
|
|
197
|
+
execute: async (_toolCallId, params) => {
|
|
198
|
+
const result = await callVerb(socketPath, name, params ?? {});
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: "text", text: result.text }],
|
|
201
|
+
...(result.ok ? {} : { isError: true }),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for the verb action ledger (#126).
|
|
3
|
+
*
|
|
4
|
+
* The store owns the rows; this owns how an operator reads them. Separate from
|
|
5
|
+
* both because the ledger is the thing an escalation cites, and a record nobody
|
|
6
|
+
* can read at a glance is a record nobody checks: `omp-conductor ledger` and
|
|
7
|
+
* the `status` block below print through the same two functions, so the two
|
|
8
|
+
* surfaces cannot come to disagree about what a refusal was called.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { VerbLedgerEntry } from "../types.ts";
|
|
12
|
+
|
|
13
|
+
/** How many entries `status` shows before an operator has to ask for the rest. */
|
|
14
|
+
export const STATUS_LEDGER_LINES = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How many the snapshot carries.
|
|
18
|
+
*
|
|
19
|
+
* More than it prints, on purpose: the header says "N recent calls, M refused",
|
|
20
|
+
* and a count computed from only the five it shows would say "1 refused" about
|
|
21
|
+
* a fleet that refused eleven things this morning.
|
|
22
|
+
*/
|
|
23
|
+
export const STATUS_LEDGER_SCAN = 20;
|
|
24
|
+
|
|
25
|
+
function clock(at: number): string {
|
|
26
|
+
return new Date(at).toISOString().replace("T", " ").slice(0, 19);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The arguments, flattened to one scannable line.
|
|
31
|
+
*
|
|
32
|
+
* Bounded, because a `conductor_pr_create` body is a whole PR description and
|
|
33
|
+
* the ledger is a list an operator scans, not a document they read. Truncation
|
|
34
|
+
* only ever loses the tail of one value, and the untruncated payload is still
|
|
35
|
+
* in the row for anything that needs it.
|
|
36
|
+
*/
|
|
37
|
+
export function formatVerbArgs(args: Record<string, unknown>, limit = 60): string {
|
|
38
|
+
const parts = Object.entries(args).map(([key, value]) => {
|
|
39
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
40
|
+
const flat = (text ?? "undefined").replace(/\s+/g, " ").trim();
|
|
41
|
+
return `${key}=${flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat}`;
|
|
42
|
+
});
|
|
43
|
+
return parts.length === 0 ? "(no arguments)" : parts.join(" ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One entry, decision first.
|
|
48
|
+
*
|
|
49
|
+
* The decision leads the line on purpose: the question an operator brings to
|
|
50
|
+
* this list is "what got refused", and a column they have to read past the
|
|
51
|
+
* timestamp and the verb to find is a column they skim.
|
|
52
|
+
*/
|
|
53
|
+
export function formatVerbLedgerEntry(entry: VerbLedgerEntry): string[] {
|
|
54
|
+
const who = entry.runId === undefined ? entry.role : `${entry.role} #${entry.issue ?? "?"}`;
|
|
55
|
+
const head =
|
|
56
|
+
` ${clock(entry.at)} ${entry.decision === "allowed" ? "ALLOW " : "REFUSE"} ` +
|
|
57
|
+
`${entry.verb.padEnd(26)}${who}` +
|
|
58
|
+
(entry.refusal === undefined ? "" : ` [${entry.refusal}]`) +
|
|
59
|
+
(entry.sha === undefined ? "" : ` ${entry.sha.slice(0, 12)}`);
|
|
60
|
+
return [head, ` ${formatVerbArgs(entry.args)}`, ` ${entry.detail}`];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `status` block: newest first, bounded, and silent when nothing happened. */
|
|
64
|
+
export function formatVerbLedger(
|
|
65
|
+
entries: readonly VerbLedgerEntry[],
|
|
66
|
+
limit = STATUS_LEDGER_LINES,
|
|
67
|
+
): string[] {
|
|
68
|
+
if (entries.length === 0) return [];
|
|
69
|
+
const shown = entries.slice(0, limit);
|
|
70
|
+
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
71
|
+
return [
|
|
72
|
+
"",
|
|
73
|
+
`verb ledger ${entries.length} recent call(s), ${refused} refused` +
|
|
74
|
+
(entries.length > shown.length ? ` (omp-conductor ledger for the rest)` : ""),
|
|
75
|
+
...shown.flatMap(formatVerbLedgerEntry),
|
|
76
|
+
];
|
|
77
|
+
}
|