@mnemom/mnemom 0.17.34-next.2 → 0.17.34-next.4
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/dist/commands/agent-run.d.ts +16 -6
- package/dist/commands/agent-run.js +52 -27
- package/dist/commands/agent-share-test-issuer.d.ts +67 -0
- package/dist/commands/agent-share-test-issuer.js +139 -0
- package/dist/commands/agent-share.d.ts +7 -1
- package/dist/commands/agent-share.js +14 -4
- package/dist/commands/agent.d.ts +7 -5
- package/dist/commands/agent.js +84 -45
- package/dist/commands/durable-lock.d.ts +8 -1
- package/dist/commands/durable-lock.js +9 -4
- package/dist/commands/scan.d.ts +12 -1
- package/dist/commands/scan.js +22 -3
- package/dist/commands/wrap.js +1 -1
- package/dist/durable/adopt-live.js +5 -8
- package/dist/durable/adopt.d.ts +8 -0
- package/dist/durable/adopt.js +27 -6
- package/dist/durable/cwd-rule.d.ts +9 -1
- package/dist/durable/cwd-rule.js +18 -0
- package/dist/durable/launch-spec.d.ts +2 -0
- package/dist/durable/launch-spec.js +1 -1
- package/dist/durable/test-mode.mjs +25 -1
- package/dist/host/client.d.ts +3 -1
- package/dist/host/daemon.d.ts +7 -3
- package/dist/host/daemon.js +13 -3
- package/dist/host/owner.d.ts +3 -0
- package/dist/host/owner.js +2 -0
- package/dist/host/run.d.ts +2 -3
- package/dist/host/run.js +5 -20
- package/dist/host/s1-names.d.ts +1 -0
- package/dist/host/s1-names.js +1 -0
- package/dist/host/verbs.d.ts +2 -0
- package/dist/host/verbs.js +11 -0
- package/dist/index.js +13 -9
- package/dist/lib/agent.d.ts +5 -3
- package/dist/lib/agent.js +6 -3
- package/dist/lib/claude-bin.d.ts +6 -0
- package/dist/lib/claude-bin.js +26 -0
- package/dist/lib/commit-report.d.ts +5 -4
- package/dist/lib/commit-report.js +6 -9
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +28 -0
- package/dist/lib/durable/launch.d.ts +28 -11
- package/dist/lib/durable/launch.js +33 -15
- package/dist/lib/durable/live.d.ts +15 -19
- package/dist/lib/durable/live.js +41 -43
- package/dist/lib/launcher-config.d.ts +6 -0
- package/dist/lib/launcher-config.js +11 -0
- package/dist/lib/runner/exec-append.d.ts +15 -0
- package/dist/lib/runner/exec-append.js +2 -0
- package/dist/lib/runner/executor-client.d.ts +2 -1
- package/dist/lib/runner/executor-state.d.ts +10 -0
- package/dist/lib/runner/executor-state.js +24 -0
- package/dist/lib/runner/github-base.d.ts +24 -0
- package/dist/lib/runner/github-base.js +27 -0
- package/dist/lib/runner/job.d.ts +11 -4
- package/dist/lib/runner/job.js +59 -16
- package/dist/lib/runner/oidc.d.ts +3 -2
- package/dist/lib/runner/oidc.js +7 -4
- package/dist/lib/runner/options.d.ts +6 -0
- package/dist/lib/runner/options.js +19 -1
- package/dist/lib/runner/outcome.js +2 -3
- package/dist/lib/runner/phases.d.ts +7 -4
- package/dist/lib/runner/phases.js +8 -4
- package/dist/lib/runner/proxy.d.ts +5 -1
- package/dist/lib/runner/proxy.js +7 -1
- package/dist/lib/scan/cost-per-pr.d.ts +93 -0
- package/dist/lib/scan/cost-per-pr.js +101 -0
- package/dist/lib/scan/record.d.ts +27 -1
- package/dist/lib/scan/record.js +114 -16
- package/dist/lib/scan/report-email.d.ts +25 -5
- package/dist/lib/scan/report-email.js +23 -2
- package/dist/lib/scan/scan.d.ts +3 -0
- package/dist/lib/scan/scan.js +13 -1
- package/dist/lib/session-key.d.ts +4 -3
- package/dist/vendor/durable-contract/index.js +207 -169
- package/dist/vendor/shared/durable-contract/doors.d.ts +26 -1
- package/dist/vendor/shared/durable-contract/doors.js +37 -1
- package/dist/vendor/shared/durable-contract/events.d.ts +1 -1
- package/dist/vendor/shared/durable-contract/events.js +13 -3
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +2 -2
- package/dist/vendor/shared/durable-contract/schema.d.ts +5 -0
- package/dist/vendor/shared/durable-contract/schema.js +6 -0
- package/dist/vendor/shared/durable-contract/states.d.ts +2 -1
- package/dist/vendor/shared/durable-contract/states.js +2 -1
- package/package.json +1 -1
- package/dist/lib/durable/first-turn.d.ts +0 -50
- package/dist/lib/durable/first-turn.js +0 -102
package/dist/lib/runner/job.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Every side effect is in `JobDeps`, so each arm is tested; `liveJobDeps` (commands/agent-run.ts)
|
|
12
12
|
* is the thin driver.
|
|
13
13
|
*/
|
|
14
|
-
import { RUNNER_OPEN_MODE_TOLERATED, claimNeedsKeyRetry, } from "../../vendor/durable-contract/index.js";
|
|
14
|
+
import { EXECUTOR_WRITABLE_KINDS, RUNNER_OPEN_MODE_TOLERATED, claimNeedsKeyRetry, } from "../../vendor/durable-contract/index.js";
|
|
15
|
+
import { spawnIdempotencyKey } from "./exec-append.js";
|
|
15
16
|
import { canonicalJson } from "../canonical-json.js";
|
|
16
17
|
import { verifyServedPhaseBundle } from "./bundle.js";
|
|
17
18
|
import { sealedContractHeader } from "./contract-header.js";
|
|
@@ -133,6 +134,8 @@ async function claimSession(deps, sessionId) {
|
|
|
133
134
|
},
|
|
134
135
|
};
|
|
135
136
|
}
|
|
137
|
+
/** A header-safe agent id; the gateway compares it with the lease's session. */
|
|
138
|
+
const AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
136
139
|
function parseJob(body) {
|
|
137
140
|
if (!body || typeof body !== "object")
|
|
138
141
|
return null;
|
|
@@ -147,7 +150,11 @@ function parseJob(body) {
|
|
|
147
150
|
return null;
|
|
148
151
|
if (sha === null || version === null || typeof b.contract !== "string")
|
|
149
152
|
return null;
|
|
150
|
-
|
|
153
|
+
// No agent id, no job: a model request without x-mnemom-agent-id would be refused anyway.
|
|
154
|
+
const agentId = str(b.agent_id);
|
|
155
|
+
if (agentId === null || !AGENT_ID_RE.test(agentId))
|
|
156
|
+
return null;
|
|
157
|
+
return { repo, agent_id: agentId, contract_sha256: sha, version, contract: b.contract };
|
|
151
158
|
}
|
|
152
159
|
/**
|
|
153
160
|
* Run one candidate session to an outcome. Never throws for a Mnemom error: every one is an
|
|
@@ -189,15 +196,19 @@ export async function runJob(sessionId, opts, deps) {
|
|
|
189
196
|
};
|
|
190
197
|
const refuse = async (reason, detail) => {
|
|
191
198
|
deps.log(`refused (${reason}): ${detail}`);
|
|
192
|
-
|
|
199
|
+
// A refusal hands the lease back: S1's fail reasons are closed, and the api refuses
|
|
200
|
+
// runner_refused as durable_reason_invalid, which left the session leased until it lapsed.
|
|
201
|
+
await release(deps, sessionId, claim.epoch);
|
|
193
202
|
return { kind: "outcome", outcome: refusedOutcome(facts, reason) };
|
|
194
203
|
};
|
|
195
204
|
try {
|
|
196
205
|
// Register the spawn, then hello: the session moves to running and the lease is ours.
|
|
197
206
|
const ap = await deps.api.append(sessionId, {
|
|
198
|
-
kind:
|
|
199
|
-
|
|
207
|
+
kind: EXECUTOR_WRITABLE_KINDS[0], // executor.spawned
|
|
208
|
+
refs: { spawn_id: spawnId },
|
|
209
|
+
idempotency_key: spawnIdempotencyKey(sessionId, claim.epoch, spawnId),
|
|
200
210
|
expected_epoch: claim.epoch,
|
|
211
|
+
nonce: deps.nonce,
|
|
201
212
|
});
|
|
202
213
|
if (!ok2xx(ap))
|
|
203
214
|
return await refuse(routeRefusal(ap), `register spawn: ${ap.code ?? ap.status}`);
|
|
@@ -279,7 +290,11 @@ export async function runJob(sessionId, opts, deps) {
|
|
|
279
290
|
await reportFail(deps, sessionId, claim.epoch, "runner_failed", reason);
|
|
280
291
|
return { kind: "outcome", outcome: failedOutcome(facts, reason) };
|
|
281
292
|
}
|
|
282
|
-
const state = {
|
|
293
|
+
const state = {
|
|
294
|
+
contractValue: header.value,
|
|
295
|
+
leaseKey: claim.leaseKey,
|
|
296
|
+
agentId: job.agent_id,
|
|
297
|
+
};
|
|
283
298
|
proxy = await deps.startProxy(state);
|
|
284
299
|
let opensPr = false;
|
|
285
300
|
for (const phase of phases) {
|
|
@@ -309,7 +324,7 @@ export async function runJob(sessionId, opts, deps) {
|
|
|
309
324
|
if (proxy.contractMismatch && !amended) {
|
|
310
325
|
// 34.11: the charter moved. Continue only on a higher sealed version with both the job
|
|
311
326
|
// and a fresh bundle bound to it; then the phase runs again under the new seal.
|
|
312
|
-
const next = await amendedSeal(deps, sessionId, claim, bundle);
|
|
327
|
+
const next = await amendedSeal(deps, sessionId, claim, bundle, job.agent_id);
|
|
313
328
|
if (!next)
|
|
314
329
|
return await refuse("contract_mismatch_unrecoverable", "the amended seal did not check out");
|
|
315
330
|
state.contractValue = next.value;
|
|
@@ -333,6 +348,12 @@ export async function runJob(sessionId, opts, deps) {
|
|
|
333
348
|
}
|
|
334
349
|
if (opensPr) {
|
|
335
350
|
try {
|
|
351
|
+
// Checked before the push, so an empty diff never reaches GitHub's 422 (amendment 59).
|
|
352
|
+
if ((await deps.commitsAhead(ws)) === 0) {
|
|
353
|
+
deps.log("no commits ahead of the base: there is no PR to open");
|
|
354
|
+
await reportFail(deps, sessionId, claim.epoch, "runner_failed", "pr_no_changes");
|
|
355
|
+
return { kind: "outcome", outcome: failedOutcome(facts, "pr_no_changes") };
|
|
356
|
+
}
|
|
336
357
|
facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`);
|
|
337
358
|
}
|
|
338
359
|
catch (e) {
|
|
@@ -381,19 +402,29 @@ async function fetchBundle(deps, sessionId, claim) {
|
|
|
381
402
|
detail: `jwks: ${jwksRes.networkError ?? jwksRes.status}`,
|
|
382
403
|
};
|
|
383
404
|
}
|
|
384
|
-
if (
|
|
385
|
-
|
|
405
|
+
if (deps.executorId !== null &&
|
|
406
|
+
claim.executorId !== null &&
|
|
407
|
+
deps.executorId !== claim.executorId) {
|
|
408
|
+
return {
|
|
409
|
+
kind: "refused",
|
|
410
|
+
reason: "bundle_binding_mismatch",
|
|
411
|
+
detail: "the claim answer names another executor_id than this runner's",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const executorId = deps.executorId ?? claim.executorId;
|
|
415
|
+
if (executorId === null) {
|
|
416
|
+
// Neither the runner nor the claim answer names the executor id, so the bundle cannot be bound.
|
|
386
417
|
return {
|
|
387
418
|
kind: "refused",
|
|
388
419
|
reason: "bundle_binding_mismatch",
|
|
389
|
-
detail:
|
|
420
|
+
detail: `no executor_id: set MNEMOM_EXECUTOR_ID, the claim answer named none`,
|
|
390
421
|
};
|
|
391
422
|
}
|
|
392
423
|
const v = verifyServedPhaseBundle(res.text, jwksRes.body, {
|
|
393
424
|
org_id: orgOf(res.body),
|
|
394
425
|
session_id: sessionId,
|
|
395
426
|
epoch: claim.epoch,
|
|
396
|
-
executor_id:
|
|
427
|
+
executor_id: executorId,
|
|
397
428
|
contract_sha256: claim.contractSha256,
|
|
398
429
|
contract_version: claim.contractVersion,
|
|
399
430
|
}, { now: deps.now });
|
|
@@ -410,11 +441,14 @@ function orgOf(body) {
|
|
|
410
441
|
const o = body?.org_id;
|
|
411
442
|
return typeof o === "string" ? o : "";
|
|
412
443
|
}
|
|
413
|
-
async function amendedSeal(deps, sessionId, claim, running) {
|
|
444
|
+
async function amendedSeal(deps, sessionId, claim, running, agentId) {
|
|
414
445
|
const jr = await deps.api.job(sessionId);
|
|
415
446
|
const job = jr.status === 200 ? parseJob(jr.body) : null;
|
|
416
447
|
if (!job || job.version <= claim.contractVersion)
|
|
417
448
|
return null;
|
|
449
|
+
// An amend never moves the session to another agent.
|
|
450
|
+
if (job.agent_id !== agentId)
|
|
451
|
+
return null;
|
|
418
452
|
const fresh = await fetchBundle(deps, sessionId, {
|
|
419
453
|
...claim,
|
|
420
454
|
contractSha256: job.contract_sha256,
|
|
@@ -438,9 +472,9 @@ function sameWork(a, b) {
|
|
|
438
472
|
canonicalJson({ workflow: b.workflow, phases: b.phases }));
|
|
439
473
|
}
|
|
440
474
|
/**
|
|
441
|
-
* Report a failed
|
|
442
|
-
* Durable lead's to rule (A3 accepts only transcript_lost today); a
|
|
443
|
-
* never changes the outcome, because the lease lapses on its own.
|
|
475
|
+
* Report a failed job after claim (34.12 (6), (10)); a refusal releases the lease instead. The
|
|
476
|
+
* fail reason vocabulary is the Durable lead's to rule (A3 accepts only transcript_lost today); a
|
|
477
|
+
* fail the api refuses is logged and never changes the outcome, because the lease lapses on its own.
|
|
444
478
|
*/
|
|
445
479
|
export async function reportFail(deps, sessionId, epoch, kind, reason) {
|
|
446
480
|
const r = await deps.api.fail(sessionId, {
|
|
@@ -449,8 +483,17 @@ export async function reportFail(deps, sessionId, epoch, kind, reason) {
|
|
|
449
483
|
reason: kind,
|
|
450
484
|
refs: { reason },
|
|
451
485
|
});
|
|
452
|
-
if (!ok2xx(r))
|
|
486
|
+
if (!ok2xx(r)) {
|
|
453
487
|
deps.log(`exec_fail ${kind}/${reason}: ${errorCode(r.body) ?? r.networkError ?? r.status}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Amendment 59: the api decides retry or fail; the runner only says which.
|
|
491
|
+
const b = (r.body ?? {});
|
|
492
|
+
const job = `job ${sessionId.slice(0, 8)}`;
|
|
493
|
+
if (b.state === "ready" && Number.isInteger(b.attempt) && Number.isInteger(b.retries))
|
|
494
|
+
deps.log(`${job} returned to the pool for retry (attempt ${b.attempt} of ${b.retries})`);
|
|
495
|
+
else if (b.state === "failed" && Number.isInteger(b.attempt))
|
|
496
|
+
deps.log(`${job} failed after ${b.attempt} attempt${b.attempt === 1 ? "" : "s"}`);
|
|
454
497
|
}
|
|
455
498
|
async function release(deps, sessionId, epoch) {
|
|
456
499
|
const r = await deps.api.release(sessionId, { epoch, nonce: deps.nonce });
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
|
|
9
9
|
* reaches argv, a log line or an outcome.
|
|
10
10
|
*
|
|
11
|
-
* S1 names the route but not its answer's
|
|
12
|
-
* with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
|
|
11
|
+
* S1 names the route but not its answer's fields; `executor_key` (and `executor_id`, which binds
|
|
12
|
+
* the bundle) is this runner's reading, open with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
|
|
13
13
|
*/
|
|
14
14
|
import { type FetchFn } from "./executor-client.js";
|
|
15
15
|
import type { RefusedReason } from "./outcome.js";
|
|
16
16
|
export type OidcExchange = {
|
|
17
17
|
ok: true;
|
|
18
18
|
executorKey: string;
|
|
19
|
+
executorId: string | null;
|
|
19
20
|
} | {
|
|
20
21
|
ok: false;
|
|
21
22
|
reason: RefusedReason;
|
package/dist/lib/runner/oidc.js
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
|
|
9
9
|
* reaches argv, a log line or an outcome.
|
|
10
10
|
*
|
|
11
|
-
* S1 names the route but not its answer's
|
|
12
|
-
* with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
|
|
11
|
+
* S1 names the route but not its answer's fields; `executor_key` (and `executor_id`, which binds
|
|
12
|
+
* the bundle) is this runner's reading, open with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
|
|
13
13
|
*/
|
|
14
14
|
import { OIDC_EXCHANGE_AUDIENCE, OIDC_EXCHANGE_REFUSALS, routePath, } from "../../vendor/durable-contract/index.js";
|
|
15
15
|
import { errorCode } from "./executor-client.js";
|
|
16
|
+
import { UUID_RE } from "./options.js";
|
|
16
17
|
const unavailable = (detail) => ({
|
|
17
18
|
ok: false,
|
|
18
19
|
reason: "oidc_unavailable",
|
|
@@ -72,9 +73,11 @@ export async function exchangeOidc(opts) {
|
|
|
72
73
|
}
|
|
73
74
|
const body = await readJson(res);
|
|
74
75
|
if (res.status === 200) {
|
|
75
|
-
const
|
|
76
|
+
const b = body;
|
|
77
|
+
const key = b?.executor_key;
|
|
78
|
+
const id = typeof b?.executor_id === "string" && UUID_RE.test(b.executor_id) ? b.executor_id : null;
|
|
76
79
|
if (typeof key === "string" && key !== "")
|
|
77
|
-
return { ok: true, executorKey: key };
|
|
80
|
+
return { ok: true, executorKey: key, executorId: id };
|
|
78
81
|
return unavailable("exchange answer has no executor_key");
|
|
79
82
|
}
|
|
80
83
|
const code = errorCode(body);
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { type RunnerFailMode } from "./settings.js";
|
|
8
8
|
export declare const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
|
|
9
|
+
/** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
|
|
10
|
+
export declare const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
|
|
9
11
|
export declare const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
|
|
10
12
|
export declare const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
|
|
11
13
|
export interface RunFlags {
|
|
@@ -28,6 +30,8 @@ export interface RunPlan {
|
|
|
28
30
|
failMode: RunnerFailMode;
|
|
29
31
|
json: boolean;
|
|
30
32
|
credential: RunCredential;
|
|
33
|
+
/** This runner's executor id, when the env names one; else the claim or exchange answer's. */
|
|
34
|
+
executorId: string | null;
|
|
31
35
|
/** Warnings to print on stderr (an unknown fail mode reads as closed). */
|
|
32
36
|
warnings: string[];
|
|
33
37
|
}
|
|
@@ -38,4 +42,6 @@ export type RunPlanResult = {
|
|
|
38
42
|
ok: false;
|
|
39
43
|
usage: string;
|
|
40
44
|
};
|
|
45
|
+
/** A lowercase uuid: the executor id's only accepted form. */
|
|
46
|
+
export declare const UUID_RE: RegExp;
|
|
41
47
|
export declare function resolveRunPlan(flags: RunFlags, env: NodeJS.ProcessEnv): RunPlanResult;
|
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { resolveFailMode, RUNNER_FAIL_MODE_ENV } from "./settings.js";
|
|
8
8
|
export const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
|
|
9
|
+
/** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
|
|
10
|
+
export const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
|
|
9
11
|
export const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
|
|
10
12
|
export const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
|
|
11
13
|
const usage = (message) => ({ ok: false, usage: message });
|
|
12
14
|
const set = (v) => v !== undefined && v.trim() !== "";
|
|
15
|
+
/** A lowercase uuid: the executor id's only accepted form. */
|
|
16
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
13
17
|
export function resolveRunPlan(flags, env) {
|
|
14
18
|
if (Boolean(flags.watch) === Boolean(flags.once)) {
|
|
15
19
|
return usage("mnemom agent run: give exactly one of --watch or --once");
|
|
@@ -40,8 +44,22 @@ export function resolveRunPlan(flags, env) {
|
|
|
40
44
|
}
|
|
41
45
|
credential = { kind: "executor_key", key: key.trim() };
|
|
42
46
|
}
|
|
47
|
+
let executorId = null;
|
|
48
|
+
if (set(env[EXECUTOR_ID_ENV])) {
|
|
49
|
+
executorId = env[EXECUTOR_ID_ENV].trim();
|
|
50
|
+
if (!UUID_RE.test(executorId)) {
|
|
51
|
+
return usage(`mnemom agent run: ${EXECUTOR_ID_ENV} is not a lowercase uuid`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
43
54
|
return {
|
|
44
55
|
ok: true,
|
|
45
|
-
plan: {
|
|
56
|
+
plan: {
|
|
57
|
+
form,
|
|
58
|
+
failMode: fail.mode,
|
|
59
|
+
json: Boolean(flags.json),
|
|
60
|
+
credential,
|
|
61
|
+
executorId,
|
|
62
|
+
warnings,
|
|
63
|
+
},
|
|
46
64
|
};
|
|
47
65
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* a reason outside S1's closed lists is a bug in the runner, so `outcomeLine` throws on one rather
|
|
7
7
|
* than print a line no consumer can read.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { isRunnerFailedReason, RUNNER_EXIT_CODES, RUNNER_OUTCOME_FIELDS, RUNNER_REFUSED_REASONS, } from "../../vendor/durable-contract/index.js";
|
|
10
10
|
/** `phase_<order>_<name>`: the name lowercased, anything outside [a-z0-9_] replaced by `_`. */
|
|
11
11
|
export function phaseReason(order, name) {
|
|
12
12
|
return `phase_${order}_${name.toLowerCase().replace(/[^a-z0-9_]/g, "_")}`;
|
|
@@ -20,8 +20,7 @@ function reasonOk(outcome, reason) {
|
|
|
20
20
|
if (reason === null)
|
|
21
21
|
return false;
|
|
22
22
|
if (outcome === "failed")
|
|
23
|
-
return (
|
|
24
|
-
RUNNER_FAILED_PHASE_RE.test(reason));
|
|
23
|
+
return isRunnerFailedReason(reason);
|
|
25
24
|
return isRefusedReason(reason);
|
|
26
25
|
}
|
|
27
26
|
const pick = (facts, keep) => ({
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
|
|
6
6
|
* tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
|
|
7
7
|
* phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
|
|
8
|
-
* HOME, the model URL of the runner's own proxy and
|
|
9
|
-
* the lease key or a GitHub token.
|
|
8
|
+
* HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
|
|
9
|
+
* provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
|
|
10
|
+
* phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
|
|
10
11
|
*/
|
|
11
12
|
import type { ServedPhase } from "./bundle.js";
|
|
12
13
|
/** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
|
|
@@ -14,8 +15,10 @@ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
|
|
|
14
15
|
tools: string[];
|
|
15
16
|
allowed: string[];
|
|
16
17
|
}>>;
|
|
17
|
-
/** Env names a phase may inherit:
|
|
18
|
-
export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "
|
|
18
|
+
/** Env names a phase may inherit: a working PATH and locale. Nothing else. */
|
|
19
|
+
export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "LANG", "TZ"];
|
|
20
|
+
/** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
|
|
21
|
+
export declare const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
|
|
19
22
|
export type ToolGrant = {
|
|
20
23
|
ok: true;
|
|
21
24
|
tools: string[];
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
|
|
6
6
|
* tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
|
|
7
7
|
* phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
|
|
8
|
-
* HOME, the model URL of the runner's own proxy and
|
|
9
|
-
* the lease key or a GitHub token.
|
|
8
|
+
* HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
|
|
9
|
+
* provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
|
|
10
|
+
* phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
|
|
10
11
|
*/
|
|
11
12
|
/** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
|
|
12
13
|
export const CAPABILITY_TOOLS = Object.freeze({
|
|
@@ -20,8 +21,10 @@ export const CAPABILITY_TOOLS = Object.freeze({
|
|
|
20
21
|
// The runner opens the PR itself once every phase has passed; the model gets no tool for it.
|
|
21
22
|
"pr.open": { tools: [], allowed: [] },
|
|
22
23
|
});
|
|
23
|
-
/** Env names a phase may inherit:
|
|
24
|
-
export const PHASE_ENV_PASSTHROUGH = ["PATH", "
|
|
24
|
+
/** Env names a phase may inherit: a working PATH and locale. Nothing else. */
|
|
25
|
+
export const PHASE_ENV_PASSTHROUGH = ["PATH", "LANG", "TZ"];
|
|
26
|
+
/** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
|
|
27
|
+
export const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
|
|
25
28
|
export function toolsFor(capabilityIds) {
|
|
26
29
|
const unknown = capabilityIds.filter((id) => !Object.hasOwn(CAPABILITY_TOOLS, id));
|
|
27
30
|
if (unknown.length > 0)
|
|
@@ -54,6 +57,7 @@ export function phaseEnv(parent, home, modelUrl) {
|
|
|
54
57
|
env[name] = v;
|
|
55
58
|
}
|
|
56
59
|
env.HOME = home;
|
|
60
|
+
env.ANTHROPIC_API_KEY = PHASE_MODEL_KEY_PLACEHOLDER;
|
|
57
61
|
env.ANTHROPIC_BASE_URL = modelUrl;
|
|
58
62
|
env.MNEMOM_RUNNER_MODEL_URL = modelUrl;
|
|
59
63
|
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
|
|
5
5
|
* loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
|
|
6
|
-
* any `x-mnemom
|
|
6
|
+
* any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
|
|
7
|
+
* x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
|
|
7
8
|
* sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
|
|
8
9
|
* sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
|
|
9
10
|
* which refetches and continues only on a higher sealed version, else fails closed.
|
|
10
11
|
*/
|
|
11
12
|
import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
|
|
12
13
|
export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
|
|
14
|
+
export declare const AGENT_ID_HEADER = "x-mnemom-agent-id";
|
|
13
15
|
export interface ProxyState {
|
|
14
16
|
/** The sealed header value to send; swapped in place after an accepted amend. */
|
|
15
17
|
contractValue: string;
|
|
16
18
|
leaseKey: string;
|
|
19
|
+
/** The session's agent, from the job read; always sent, a phase's own value never is. */
|
|
20
|
+
agentId: string;
|
|
17
21
|
}
|
|
18
22
|
export interface ForwardedRequest {
|
|
19
23
|
method: string;
|
package/dist/lib/runner/proxy.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
|
|
5
5
|
* loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
|
|
6
|
-
* any `x-mnemom
|
|
6
|
+
* any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
|
|
7
|
+
* x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
|
|
7
8
|
* sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
|
|
8
9
|
* sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
|
|
9
10
|
* which refetches and continues only on a higher sealed version, else fails closed.
|
|
@@ -12,6 +13,7 @@ import { createServer, request as httpRequest, } from "node:http";
|
|
|
12
13
|
import { request as httpsRequest } from "node:https";
|
|
13
14
|
import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
|
|
14
15
|
export const LEASE_KEY_HEADER = "x-mnemom-api-key";
|
|
16
|
+
export const AGENT_ID_HEADER = "x-mnemom-agent-id";
|
|
15
17
|
/** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
|
|
16
18
|
const DROP = new Set([
|
|
17
19
|
"connection",
|
|
@@ -23,6 +25,9 @@ const DROP = new Set([
|
|
|
23
25
|
"trailer",
|
|
24
26
|
"host",
|
|
25
27
|
"content-length",
|
|
28
|
+
// The phase's model key is a placeholder; the gateway authenticates on the lease key alone.
|
|
29
|
+
"x-api-key",
|
|
30
|
+
"authorization",
|
|
26
31
|
]);
|
|
27
32
|
/** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
|
|
28
33
|
export function outboundHeaders(incoming, state) {
|
|
@@ -34,6 +39,7 @@ export function outboundHeaders(incoming, state) {
|
|
|
34
39
|
kept[lower] = value;
|
|
35
40
|
}
|
|
36
41
|
kept[LEASE_KEY_HEADER] = state.leaseKey;
|
|
42
|
+
kept[AGENT_ID_HEADER] = state.agentId;
|
|
37
43
|
return applySealedContract(kept, state.contractValue);
|
|
38
44
|
}
|
|
39
45
|
/** The largest 409 body the proxy reads for a contract mismatch. */
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost per merged PR and cost by PR size, for the Org Scan report. Pure: numbers in, numbers out.
|
|
3
|
+
*
|
|
4
|
+
* Source of the spend. Measured per-request cost cannot be tied to a PR with the data the scan has:
|
|
5
|
+
* the Anthropic usage reads return one whole-org total (no per-request or per-PR rows) and the Mnemom
|
|
6
|
+
* Record totals are one window sum (model cost and a request count, no PR key). So every figure here is
|
|
7
|
+
* `label: "estimate"`: the period's spend split across the merged PRs in proportion to lines changed.
|
|
8
|
+
* Each figure names the spend it was split from (`spend_source`); sources are never mixed in one report.
|
|
9
|
+
*
|
|
10
|
+
* anthropic_usage the whole-org Anthropic cost for the period (the CTO's admin key)
|
|
11
|
+
* modelled_range our modelled estimate (a low to high range, so the figures are ranges)
|
|
12
|
+
*
|
|
13
|
+
* Size is per PR, in lines changed = additions + deletions, exactly as GitHub reports them. Lockfiles and
|
|
14
|
+
* generated files are NOT excluded. small < 50; medium 50..500 inclusive; large > 500.
|
|
15
|
+
*
|
|
16
|
+
* Each size figure is the average cost of ONE PR of that size: spend * (mean lines of the bucket) /
|
|
17
|
+
* (total lines of the PRs in scope). Scope: the modelled estimate prices AI-assisted PRs only, so it is
|
|
18
|
+
* split across those only (`pr_scope: "ai_assisted"`, buckets too); the others use every merged PR
|
|
19
|
+
* (`pr_scope: "all_merged"`). `pr_count` counts PRs in that scope. Usage-key figures also carry
|
|
20
|
+
* `spend_scope: "whole_org"` (the key covers the whole organization, not just the scanned repos). The cost per merged PR is the spend over the count of merged PRs.
|
|
21
|
+
*
|
|
22
|
+
* Missing data is an absent field, never 0 or NaN: no usable spend, zero merged PRs in the period, an
|
|
23
|
+
* empty bucket, a scan that did not read every merged PR in the period, or a result past safe-integer
|
|
24
|
+
* range. Reverts are listed without sizes, so they are not counted as merged PRs here.
|
|
25
|
+
*/
|
|
26
|
+
import type { SpendEstimate } from "./spend/spend-estimate.js";
|
|
27
|
+
/** Strictly below this is small. 50 is medium. */
|
|
28
|
+
export declare const SMALL_MAX_EXCLUSIVE = 50;
|
|
29
|
+
/** At or below this (and not small) is medium. 500 is medium; 501 is large. */
|
|
30
|
+
export declare const MEDIUM_MAX_INCLUSIVE = 500;
|
|
31
|
+
export type SizeBucket = "small" | "medium" | "large";
|
|
32
|
+
export type CostSpendSourceName = "anthropic_usage" | "modelled_range";
|
|
33
|
+
export interface PrSize {
|
|
34
|
+
/** YYYY-MM-DD, UTC. */
|
|
35
|
+
mergedDay: string;
|
|
36
|
+
/** additions + deletions. */
|
|
37
|
+
lines: number;
|
|
38
|
+
/** The PR carries an AI-tool mark (the same rule the modelled spend uses to count AI-assisted PRs). */
|
|
39
|
+
aiAssisted: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Where the spend comes from. USD, never cents. */
|
|
42
|
+
export type CostSpend = {
|
|
43
|
+
source: "anthropic_usage";
|
|
44
|
+
usd: number;
|
|
45
|
+
} | {
|
|
46
|
+
source: "modelled_range";
|
|
47
|
+
lowUsd: number;
|
|
48
|
+
highUsd: number;
|
|
49
|
+
};
|
|
50
|
+
/** One figure: a single value, or a low to high range for the modelled source. Integer cents. */
|
|
51
|
+
export type PrScope = "ai_assisted" | "all_merged";
|
|
52
|
+
export type CostFigure = {
|
|
53
|
+
/** "measured" is not possible with current data (see header); every figure is an estimate. */
|
|
54
|
+
label: "estimate";
|
|
55
|
+
spend_source: CostSpendSourceName;
|
|
56
|
+
/**
|
|
57
|
+
* Which PRs the average is over. The modelled estimate prices AI-assisted PRs only, so it is split
|
|
58
|
+
* across those only ("ai_assisted"); the usage key covers all spend, so they use "all_merged".
|
|
59
|
+
*/
|
|
60
|
+
pr_scope: PrScope;
|
|
61
|
+
/** PRs in that scope. */
|
|
62
|
+
pr_count: number;
|
|
63
|
+
/** Present on anthropic_usage only: the key's usage is the whole organization's, not just these PRs'. */
|
|
64
|
+
spend_scope?: "whole_org";
|
|
65
|
+
} & ({
|
|
66
|
+
value_cents: number;
|
|
67
|
+
} | {
|
|
68
|
+
low_cents: number;
|
|
69
|
+
high_cents: number;
|
|
70
|
+
});
|
|
71
|
+
export interface CostPerPr {
|
|
72
|
+
cost_per_merged_pr?: CostFigure;
|
|
73
|
+
cost_by_pr_size?: Partial<Record<SizeBucket, CostFigure>>;
|
|
74
|
+
}
|
|
75
|
+
export declare const sizeBucket: (lines: number) => SizeBucket;
|
|
76
|
+
/** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
|
|
77
|
+
export declare function periodDays(p: {
|
|
78
|
+
start: string;
|
|
79
|
+
end: string;
|
|
80
|
+
}): number | null;
|
|
81
|
+
/** The period's spend in USD as [low, high]; null when there is no usable spend. */
|
|
82
|
+
export declare function spendForPeriod(spend: CostSpend): [number, number] | null;
|
|
83
|
+
/** The modelled estimate as a spend source, or null when the estimate is "none". */
|
|
84
|
+
export declare const modelledSpend: (e: SpendEstimate) => CostSpend | null;
|
|
85
|
+
/**
|
|
86
|
+
* @param prs every merged PR the scan read, with merge day and size
|
|
87
|
+
* @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
|
|
88
|
+
* large PR, none unreadable, no cap hit). Otherwise the average is not defined.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildCostPerPr(prs: readonly PrSize[] | undefined, period: {
|
|
91
|
+
start: string;
|
|
92
|
+
end: string;
|
|
93
|
+
}, spend: CostSpend | null, exhaustive: boolean): CostPerPr;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Strictly below this is small. 50 is medium. */
|
|
2
|
+
export const SMALL_MAX_EXCLUSIVE = 50;
|
|
3
|
+
/** At or below this (and not small) is medium. 500 is medium; 501 is large. */
|
|
4
|
+
export const MEDIUM_MAX_INCLUSIVE = 500;
|
|
5
|
+
export const sizeBucket = (lines) => lines < SMALL_MAX_EXCLUSIVE ? "small" : lines <= MEDIUM_MAX_INCLUSIVE ? "medium" : "large";
|
|
6
|
+
const DAY_MS = 86_400_000;
|
|
7
|
+
/** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
|
|
8
|
+
export function periodDays(p) {
|
|
9
|
+
const s = Date.parse(`${p.start}T00:00:00Z`);
|
|
10
|
+
const e = Date.parse(`${p.end}T00:00:00Z`);
|
|
11
|
+
if (!Number.isFinite(s) || !Number.isFinite(e) || e < s)
|
|
12
|
+
return null;
|
|
13
|
+
return Math.round((e - s) / DAY_MS) + 1;
|
|
14
|
+
}
|
|
15
|
+
const pos = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
16
|
+
/** The period's spend in USD as [low, high]; null when there is no usable spend. */
|
|
17
|
+
export function spendForPeriod(spend) {
|
|
18
|
+
let r;
|
|
19
|
+
if (spend.source === "anthropic_usage") {
|
|
20
|
+
if (!pos(spend.usd))
|
|
21
|
+
return null;
|
|
22
|
+
r = [spend.usd, spend.usd];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
if (!pos(spend.highUsd) || !(spend.lowUsd >= 0) || spend.lowUsd > spend.highUsd)
|
|
26
|
+
return null;
|
|
27
|
+
r = [spend.lowUsd, spend.highUsd];
|
|
28
|
+
}
|
|
29
|
+
return r.every((x) => Number.isFinite(x) && x * 100 <= Number.MAX_SAFE_INTEGER) ? r : null;
|
|
30
|
+
}
|
|
31
|
+
/** The modelled estimate as a spend source, or null when the estimate is "none". */
|
|
32
|
+
export const modelledSpend = (e) => e.kind === "range" ? { source: "modelled_range", lowUsd: e.lowUsd, highUsd: e.highUsd } : null;
|
|
33
|
+
/**
|
|
34
|
+
* `share` is the fraction of the period's spend one average PR here carries: always in (0, 1] by
|
|
35
|
+
* construction, and the spend is already bounded to safe-integer cents by spendForPeriod, so the
|
|
36
|
+
* rounded cents are finite safe integers without a further guard.
|
|
37
|
+
*/
|
|
38
|
+
function figure(spend, [low, high], count, share, scope) {
|
|
39
|
+
const c = (usd) => Math.round(usd * share * 100);
|
|
40
|
+
const base = {
|
|
41
|
+
label: "estimate",
|
|
42
|
+
spend_source: spend.source,
|
|
43
|
+
pr_scope: scope,
|
|
44
|
+
pr_count: count,
|
|
45
|
+
...(spend.source === "anthropic_usage" ? { spend_scope: "whole_org" } : {}),
|
|
46
|
+
};
|
|
47
|
+
return spend.source === "modelled_range"
|
|
48
|
+
? { ...base, low_cents: c(low), high_cents: c(high) }
|
|
49
|
+
: { ...base, value_cents: c(low) };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* @param prs every merged PR the scan read, with merge day and size
|
|
53
|
+
* @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
|
|
54
|
+
* large PR, none unreadable, no cap hit). Otherwise the average is not defined.
|
|
55
|
+
*/
|
|
56
|
+
export function buildCostPerPr(prs, period, spend, exhaustive) {
|
|
57
|
+
if (!exhaustive || !spend || !Array.isArray(prs))
|
|
58
|
+
return {};
|
|
59
|
+
if (periodDays(period) === null)
|
|
60
|
+
return {};
|
|
61
|
+
const total = spendForPeriod(spend);
|
|
62
|
+
if (!total)
|
|
63
|
+
return {};
|
|
64
|
+
const inPeriod = prs.filter((p) => p.mergedDay >= period.start &&
|
|
65
|
+
p.mergedDay <= period.end &&
|
|
66
|
+
Number.isSafeInteger(p.lines) &&
|
|
67
|
+
p.lines >= 0);
|
|
68
|
+
// The modelled estimate is AI-tool spend, so it is split across AI-assisted PRs only; the usage key is
|
|
69
|
+
// the whole spend, so it is split across every merged PR.
|
|
70
|
+
const scope = spend.source === "modelled_range" ? "ai_assisted" : "all_merged";
|
|
71
|
+
const scoped = scope === "ai_assisted" ? inPeriod.filter((p) => p.aiAssisted) : inPeriod;
|
|
72
|
+
const n = scoped.length;
|
|
73
|
+
if (n === 0)
|
|
74
|
+
return {};
|
|
75
|
+
const out = {};
|
|
76
|
+
out.cost_per_merged_pr = figure(spend, total, n, 1 / n, scope);
|
|
77
|
+
let allLines = 0;
|
|
78
|
+
const agg = {
|
|
79
|
+
small: { n: 0, lines: 0 },
|
|
80
|
+
medium: { n: 0, lines: 0 },
|
|
81
|
+
large: { n: 0, lines: 0 },
|
|
82
|
+
};
|
|
83
|
+
for (const p of scoped) {
|
|
84
|
+
allLines += p.lines;
|
|
85
|
+
const a = agg[sizeBucket(p.lines)];
|
|
86
|
+
a.n += 1;
|
|
87
|
+
a.lines += p.lines;
|
|
88
|
+
}
|
|
89
|
+
// Splitting by lines needs lines: with none (or a sum past safe range) there is no size figure.
|
|
90
|
+
if (allLines <= 0 || !Number.isSafeInteger(allLines))
|
|
91
|
+
return out;
|
|
92
|
+
const bySize = {};
|
|
93
|
+
for (const b of ["small", "medium", "large"]) {
|
|
94
|
+
const a = agg[b];
|
|
95
|
+
if (a.n === 0)
|
|
96
|
+
continue;
|
|
97
|
+
bySize[b] = figure(spend, total, a.n, a.lines / a.n / allLines, scope);
|
|
98
|
+
}
|
|
99
|
+
out.cost_by_pr_size = bySize;
|
|
100
|
+
return out;
|
|
101
|
+
}
|