@mnemom/mnemom 0.17.34-next.4 → 0.17.35
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-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +8 -3
- package/dist/commands/agent-goal.js +22 -8
- package/dist/commands/agent-run.d.ts +10 -2
- package/dist/commands/agent-run.js +19 -6
- package/dist/commands/agent-slack.js +32 -0
- package/dist/commands/durable-inbox.d.ts +51 -0
- package/dist/commands/durable-inbox.js +134 -0
- package/dist/commands/durable-questions.d.ts +64 -0
- package/dist/commands/durable-questions.js +161 -0
- package/dist/durable/seat-proxy.d.ts +12 -1
- package/dist/durable/seat-proxy.js +55 -9
- package/dist/durable/seat-socket.d.ts +20 -2
- package/dist/durable/seat-socket.js +58 -0
- package/dist/durable/turn-chain.d.ts +8 -0
- package/dist/durable/turn-chain.js +25 -4
- package/dist/host/client.d.ts +41 -0
- package/dist/host/client.js +7 -0
- package/dist/host/control.d.ts +7 -0
- package/dist/host/control.js +15 -0
- package/dist/host/daemon.d.ts +20 -0
- package/dist/host/daemon.js +74 -1
- package/dist/host/inbox-delivery.d.ts +174 -0
- package/dist/host/inbox-delivery.js +453 -0
- package/dist/host/run.d.ts +18 -0
- package/dist/host/run.js +61 -0
- package/dist/host/s1-names.d.ts +2 -0
- package/dist/host/s1-names.js +2 -0
- package/dist/host/tmux.d.ts +7 -0
- package/dist/host/tmux.js +19 -0
- package/dist/index.js +59 -1
- package/dist/lib/api.d.ts +5 -0
- package/dist/lib/api.js +16 -0
- package/dist/lib/durable/api.d.ts +11 -0
- package/dist/lib/durable/api.js +28 -3
- package/dist/lib/durable/gate.d.ts +1 -1
- package/dist/lib/durable/gate.js +8 -1
- package/dist/lib/durable/live.d.ts +5 -0
- package/dist/lib/durable/live.js +19 -0
- package/dist/lib/experimental.js +3 -0
- package/dist/lib/goal-api.d.ts +11 -4
- package/dist/lib/goal-api.js +4 -3
- package/dist/lib/launcher-config.d.ts +5 -0
- package/dist/lib/launcher-config.js +17 -2
- package/dist/lib/prompt.d.ts +4 -2
- package/dist/lib/prompt.js +15 -5
- package/dist/lib/runner/job.d.ts +2 -2
- package/dist/lib/runner/job.js +16 -3
- package/dist/lib/runner/pr-body.d.ts +17 -0
- package/dist/lib/runner/pr-body.js +42 -0
- package/dist/lib/runner/proxy.d.ts +18 -3
- package/dist/lib/runner/proxy.js +24 -5
- package/dist/lib/scan/record.d.ts +60 -9
- package/dist/lib/scan/record.js +157 -39
- package/dist/lib/scan/report.d.ts +6 -8
- package/dist/lib/scan/report.js +21 -7
- package/dist/lib/scan/share.d.ts +5 -1
- package/dist/lib/scan/share.js +15 -5
- package/dist/lib/slack-durable.d.ts +123 -0
- package/dist/lib/slack-durable.js +237 -0
- package/dist/lib/slack-router.d.ts +7 -0
- package/dist/lib/slack-router.js +50 -4
- package/dist/vendor/durable-contract/index.js +992 -34
- package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
- package/dist/vendor/shared/durable-contract/admission.js +319 -2
- package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
- package/dist/vendor/shared/durable-contract/codes.js +22 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
- package/dist/vendor/shared/durable-contract/events.js +55 -1
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
- package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
- package/dist/vendor/shared/durable-contract/inbox.js +462 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
- package/dist/vendor/shared/durable-contract/index.js +2 -0
- package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
- package/dist/vendor/shared/durable-contract/question.js +373 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
- package/dist/vendor/shared/durable-contract/routes.js +43 -1
- package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
- package/dist/vendor/shared/durable-contract/turn.js +33 -1
- package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
- package/dist/vendor/shared/durable-contract/vectors.js +49 -0
- package/package.json +1 -1
- package/plugins/integrity-manifest.json +6 -2
- package/plugins/mnemom/hooks/hooks.json +12 -0
- package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
|
@@ -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-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the
|
|
6
|
+
* any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the runner's own
|
|
7
|
+
* Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
|
|
7
8
|
* x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
|
|
8
9
|
* sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
|
|
9
10
|
* sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
|
|
@@ -36,7 +37,20 @@ export interface ModelProxy {
|
|
|
36
37
|
close(): Promise<void>;
|
|
37
38
|
}
|
|
38
39
|
/** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
|
|
39
|
-
export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState): Record<string, string | string[] | undefined>;
|
|
40
|
+
export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState, modelKey: string): Record<string, string | string[] | undefined>;
|
|
41
|
+
/** What the job's model requests carried: each contract by its sha256, in first-seen order. */
|
|
42
|
+
export interface RequestSummary {
|
|
43
|
+
count: number;
|
|
44
|
+
contracts: {
|
|
45
|
+
contract_sha256: string;
|
|
46
|
+
requests: number;
|
|
47
|
+
}[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One job's forwarded requests, summarised for the operator's log and for the door2-path header
|
|
51
|
+
* check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
|
|
52
|
+
*/
|
|
53
|
+
export declare function requestSummary(forwarded: readonly ForwardedRequest[]): RequestSummary;
|
|
40
54
|
/** The largest 409 body the proxy reads for a contract mismatch. */
|
|
41
55
|
export declare const MISMATCH_BODY_MAX: number;
|
|
42
56
|
/** The client for the gateway's scheme: TLS for https, plain only for a local http gateway. */
|
|
@@ -46,4 +60,5 @@ export declare function requestFor(protocol: string): typeof httpRequest;
|
|
|
46
60
|
* cut, so a truncated body is never mistaken for a whole one.
|
|
47
61
|
*/
|
|
48
62
|
export declare function upstreamFailed(res: Pick<ServerResponse, "headersSent" | "writeHead" | "end" | "destroy">, e: Error): void;
|
|
49
|
-
|
|
63
|
+
/** `modelKey` is the runner process's own ANTHROPIC_API_KEY; it never enters a phase's env. */
|
|
64
|
+
export declare function startModelProxy(gatewayUrl: string, state: ProxyState, modelKey: string): Promise<ModelProxy>;
|
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-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the
|
|
6
|
+
* any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the runner's own
|
|
7
|
+
* Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
|
|
7
8
|
* x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
|
|
8
9
|
* sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
|
|
9
10
|
* sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
*/
|
|
12
13
|
import { createServer, request as httpRequest, } from "node:http";
|
|
13
14
|
import { request as httpsRequest } from "node:https";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
14
16
|
import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
|
|
15
17
|
export const LEASE_KEY_HEADER = "x-mnemom-api-key";
|
|
16
18
|
export const AGENT_ID_HEADER = "x-mnemom-agent-id";
|
|
@@ -25,12 +27,12 @@ const DROP = new Set([
|
|
|
25
27
|
"trailer",
|
|
26
28
|
"host",
|
|
27
29
|
"content-length",
|
|
28
|
-
// The phase's model key is a placeholder; the
|
|
30
|
+
// The phase's model key is a placeholder; the runner's own key replaces it (34.30.1).
|
|
29
31
|
"x-api-key",
|
|
30
32
|
"authorization",
|
|
31
33
|
]);
|
|
32
34
|
/** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
|
|
33
|
-
export function outboundHeaders(incoming, state) {
|
|
35
|
+
export function outboundHeaders(incoming, state, modelKey) {
|
|
34
36
|
const kept = {};
|
|
35
37
|
for (const [name, value] of Object.entries(incoming)) {
|
|
36
38
|
const lower = name.toLowerCase();
|
|
@@ -39,9 +41,25 @@ export function outboundHeaders(incoming, state) {
|
|
|
39
41
|
kept[lower] = value;
|
|
40
42
|
}
|
|
41
43
|
kept[LEASE_KEY_HEADER] = state.leaseKey;
|
|
44
|
+
kept["x-api-key"] = modelKey;
|
|
42
45
|
kept[AGENT_ID_HEADER] = state.agentId;
|
|
43
46
|
return applySealedContract(kept, state.contractValue);
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* One job's forwarded requests, summarised for the operator's log and for the door2-path header
|
|
50
|
+
* check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
|
|
51
|
+
*/
|
|
52
|
+
export function requestSummary(forwarded) {
|
|
53
|
+
const by = new Map();
|
|
54
|
+
for (const f of forwarded) {
|
|
55
|
+
const sha = createHash("sha256").update(Buffer.from(f.contract, "base64url")).digest("hex");
|
|
56
|
+
by.set(sha, (by.get(sha) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
count: forwarded.length,
|
|
60
|
+
contracts: [...by].map(([contract_sha256, requests]) => ({ contract_sha256, requests })),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
45
63
|
/** The largest 409 body the proxy reads for a contract mismatch. */
|
|
46
64
|
export const MISMATCH_BODY_MAX = 64 * 1024;
|
|
47
65
|
function mismatchVersion(body) {
|
|
@@ -74,7 +92,8 @@ export function upstreamFailed(res, e) {
|
|
|
74
92
|
res.writeHead(502, { "content-type": "application/json" });
|
|
75
93
|
res.end(JSON.stringify({ error: { code: "runner_proxy_upstream", message: e.message } }));
|
|
76
94
|
}
|
|
77
|
-
|
|
95
|
+
/** `modelKey` is the runner process's own ANTHROPIC_API_KEY; it never enters a phase's env. */
|
|
96
|
+
export async function startModelProxy(gatewayUrl, state, modelKey) {
|
|
78
97
|
const upstream = new URL(gatewayUrl);
|
|
79
98
|
const send = requestFor(upstream.protocol);
|
|
80
99
|
const basePath = upstream.pathname.replace(/\/+$/, "");
|
|
@@ -86,7 +105,7 @@ export async function startModelProxy(gatewayUrl, state) {
|
|
|
86
105
|
close: async () => { },
|
|
87
106
|
};
|
|
88
107
|
const server = createServer((req, res) => {
|
|
89
|
-
const headers = outboundHeaders(req.headers, state);
|
|
108
|
+
const headers = outboundHeaders(req.headers, state, modelKey);
|
|
90
109
|
const sent = headers[CONTRACT_HEADER];
|
|
91
110
|
const out = send({
|
|
92
111
|
protocol: upstream.protocol,
|
|
@@ -13,15 +13,28 @@
|
|
|
13
13
|
* "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
|
|
14
14
|
* at its midpoint, recursively, and the pieces are summed. The API's window is half-open
|
|
15
15
|
* [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
|
|
16
|
-
* bounded (
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* bounded (the read budget scales with the depth the halving reaches, up to a hard ceiling; no
|
|
17
|
+
* piece split below RECORD_MIN_SPLIT_MS per half).
|
|
18
|
+
*
|
|
19
|
+
* A slow or failing piece must not blank all 30 days. A piece that times out, errors at the
|
|
20
|
+
* network, or gets a 429 or 5xx is retried (at most RECORD_MAX_TRIES tries, jittered exponential
|
|
21
|
+
* backoff, honoring Retry-After). A piece that still fails on a timeout, network error or 5xx is
|
|
22
|
+
* too dense to total in one read, so it is halved exactly as a 400 "narrow it" is (a 429 is rate
|
|
23
|
+
* limiting and is not halved). The same halving applies when the next wait (backoff or Retry-After)
|
|
24
|
+
* would pass RECORD_MAX_RETRY_WAIT_MS or the time left. Outage guard: while nothing at all has been
|
|
25
|
+
* read, only RECORD_MAX_FRUITLESS_SPLITS such split is allowed, so a total outage costs a bounded
|
|
26
|
+
* number of reads instead of halving down to RECORD_MIN_SPLIT_MS; once any piece has been read,
|
|
27
|
+
* halving recurses. A piece that cannot be split further (RECORD_MIN_SPLIT_MS) or that
|
|
28
|
+
* the budget never reached is left out; the readable pieces are summed and the result carries `coverage`, which
|
|
29
|
+
* says how many of the 30 days the numbers cover. Partial totals are never presented as complete.
|
|
30
|
+
* A sum that cannot be formed (an out-of-range total, an unparsable as_of) is not a slow piece but
|
|
31
|
+
* bad data, so it fails the whole read as "unavailable".
|
|
19
32
|
*
|
|
20
33
|
* Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
|
|
21
34
|
* and the credential never reach the terminal:
|
|
22
|
-
* ok totals read and shape-checked
|
|
23
|
-
* not_in_cohort 404: the org is not in the Record cohort (or
|
|
24
|
-
* unavailable
|
|
35
|
+
* ok totals read and shape-checked; `coverage.complete` false when pieces are missing
|
|
36
|
+
* not_in_cohort 404 on the whole window: the org is not in the Record cohort (or unknown).
|
|
37
|
+
* unavailable no piece could be read at all, or the pieces could not be summed. Scan continues.
|
|
25
38
|
* auth 401/403 or a missing login: throws RecordAuthError (fail closed).
|
|
26
39
|
*/
|
|
27
40
|
import { type RecordWindowTotals } from "../api.js";
|
|
@@ -37,12 +50,35 @@ export declare const RECORD_TIMEOUT_MS = 10000;
|
|
|
37
50
|
* within 60s of the API's in either direction; ending earlier would trade away the floor side.
|
|
38
51
|
*/
|
|
39
52
|
export declare const RECORD_FLOOR_MARGIN_MS = 60000;
|
|
40
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The read budget is sized to the work, not fixed. The halving tree is discovered as it is read, so
|
|
55
|
+
* the budget grows with the deepest level reached: a window that has to be halved to depth
|
|
56
|
+
* RECORD_BASE_DEPTH (63 reads) fits the base budget; each level past that doubles the reads
|
|
57
|
+
* allowed and adds RECORD_TIME_PER_LEVEL_MS of wall clock. Both are capped by a HARD CEILING
|
|
58
|
+
* (RECORD_MAX_REQUESTS_CEILING reads, RECORD_TOTAL_TIMEOUT_CEILING_MS) that no depth can exceed, so
|
|
59
|
+
* a very dense org stays bounded. What the budget does not reach is reported as uncovered days.
|
|
60
|
+
*/
|
|
61
|
+
export declare const RECORD_BASE_DEPTH = 5;
|
|
62
|
+
/** Base read budget (the first full-window read and every retry included). */
|
|
41
63
|
export declare const RECORD_MAX_REQUESTS = 64;
|
|
42
|
-
/**
|
|
64
|
+
/** Hard ceiling on reads for one scan, however deep the halving goes. */
|
|
65
|
+
export declare const RECORD_MAX_REQUESTS_CEILING = 256;
|
|
66
|
+
/** Base wall-clock budget for all the reads of one scan. */
|
|
43
67
|
export declare const RECORD_TOTAL_TIMEOUT_MS = 60000;
|
|
68
|
+
/** Wall clock added per halving level past RECORD_BASE_DEPTH. */
|
|
69
|
+
export declare const RECORD_TIME_PER_LEVEL_MS = 30000;
|
|
70
|
+
/** Hard ceiling on wall clock for one scan, however deep the halving goes. */
|
|
71
|
+
export declare const RECORD_TOTAL_TIMEOUT_CEILING_MS = 180000;
|
|
72
|
+
/** At most this many tries per piece (first try included). */
|
|
73
|
+
export declare const RECORD_MAX_TRIES = 3;
|
|
74
|
+
/** First backoff before jitter; doubles per retry. */
|
|
75
|
+
export declare const RECORD_BACKOFF_BASE_MS = 500;
|
|
76
|
+
/** A Retry-After longer than this is not waited for: the piece is left unread. */
|
|
77
|
+
export declare const RECORD_MAX_RETRY_WAIT_MS = 30000;
|
|
44
78
|
/** A window is split only if both halves stay at least this long. */
|
|
45
79
|
export declare const RECORD_MIN_SPLIT_MS: number;
|
|
80
|
+
/** Timeout/5xx splits allowed before any piece has been read (past it the API is down, not dense). */
|
|
81
|
+
export declare const RECORD_MAX_FRUITLESS_SPLITS = 1;
|
|
46
82
|
export declare const isOrgId: (v: unknown) => v is string;
|
|
47
83
|
export declare const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
|
|
48
84
|
export declare class RecordAuthError extends Error {
|
|
@@ -52,10 +88,21 @@ export interface RecordWindow {
|
|
|
52
88
|
from: string;
|
|
53
89
|
to: string;
|
|
54
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* How much of the window the totals cover. `complete` is false whenever any piece was left out;
|
|
93
|
+
* every consumer must check it before treating the totals as the whole 30 days.
|
|
94
|
+
*/
|
|
95
|
+
export interface RecordCoverage {
|
|
96
|
+
complete: boolean;
|
|
97
|
+
/** Whole days read (rounded down, so never overstated); equals days_total when complete. */
|
|
98
|
+
days_covered: number;
|
|
99
|
+
days_total: number;
|
|
100
|
+
}
|
|
55
101
|
export type RecordOutcome = {
|
|
56
102
|
status: "ok";
|
|
57
103
|
totals: RecordWindowTotals;
|
|
58
104
|
window: RecordWindow;
|
|
105
|
+
coverage: RecordCoverage;
|
|
59
106
|
} | {
|
|
60
107
|
status: "not_in_cohort";
|
|
61
108
|
} | {
|
|
@@ -65,8 +112,12 @@ export interface RecordDeps {
|
|
|
65
112
|
fetchTotals?: (orgId: string, from: string, to: string, signal: AbortSignal) => Promise<unknown>;
|
|
66
113
|
now?: () => number;
|
|
67
114
|
timeoutMs?: number;
|
|
68
|
-
/**
|
|
115
|
+
/** Base budget for every read of one total together (default RECORD_TOTAL_TIMEOUT_MS); scales up with depth. */
|
|
69
116
|
totalTimeoutMs?: number;
|
|
117
|
+
/** Backoff wait; default setTimeout. Injected so tests do not sleep. */
|
|
118
|
+
sleep?: (ms: number) => Promise<void>;
|
|
119
|
+
/** Jitter source in [0, 1); default Math.random. */
|
|
120
|
+
random?: () => number;
|
|
70
121
|
}
|
|
71
122
|
export declare function recordWindow(nowMs: number): RecordWindow;
|
|
72
123
|
/** Shape-check the body: a malformed answer is "unavailable", never rendered. */
|
package/dist/lib/scan/record.js
CHANGED
|
@@ -13,15 +13,28 @@
|
|
|
13
13
|
* "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
|
|
14
14
|
* at its midpoint, recursively, and the pieces are summed. The API's window is half-open
|
|
15
15
|
* [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
|
|
16
|
-
* bounded (
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* bounded (the read budget scales with the depth the halving reaches, up to a hard ceiling; no
|
|
17
|
+
* piece split below RECORD_MIN_SPLIT_MS per half).
|
|
18
|
+
*
|
|
19
|
+
* A slow or failing piece must not blank all 30 days. A piece that times out, errors at the
|
|
20
|
+
* network, or gets a 429 or 5xx is retried (at most RECORD_MAX_TRIES tries, jittered exponential
|
|
21
|
+
* backoff, honoring Retry-After). A piece that still fails on a timeout, network error or 5xx is
|
|
22
|
+
* too dense to total in one read, so it is halved exactly as a 400 "narrow it" is (a 429 is rate
|
|
23
|
+
* limiting and is not halved). The same halving applies when the next wait (backoff or Retry-After)
|
|
24
|
+
* would pass RECORD_MAX_RETRY_WAIT_MS or the time left. Outage guard: while nothing at all has been
|
|
25
|
+
* read, only RECORD_MAX_FRUITLESS_SPLITS such split is allowed, so a total outage costs a bounded
|
|
26
|
+
* number of reads instead of halving down to RECORD_MIN_SPLIT_MS; once any piece has been read,
|
|
27
|
+
* halving recurses. A piece that cannot be split further (RECORD_MIN_SPLIT_MS) or that
|
|
28
|
+
* the budget never reached is left out; the readable pieces are summed and the result carries `coverage`, which
|
|
29
|
+
* says how many of the 30 days the numbers cover. Partial totals are never presented as complete.
|
|
30
|
+
* A sum that cannot be formed (an out-of-range total, an unparsable as_of) is not a slow piece but
|
|
31
|
+
* bad data, so it fails the whole read as "unavailable".
|
|
19
32
|
*
|
|
20
33
|
* Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
|
|
21
34
|
* and the credential never reach the terminal:
|
|
22
|
-
* ok totals read and shape-checked
|
|
23
|
-
* not_in_cohort 404: the org is not in the Record cohort (or
|
|
24
|
-
* unavailable
|
|
35
|
+
* ok totals read and shape-checked; `coverage.complete` false when pieces are missing
|
|
36
|
+
* not_in_cohort 404 on the whole window: the org is not in the Record cohort (or unknown).
|
|
37
|
+
* unavailable no piece could be read at all, or the pieces could not be summed. Scan continues.
|
|
25
38
|
* auth 401/403 or a missing login: throws RecordAuthError (fail closed).
|
|
26
39
|
*/
|
|
27
40
|
import { getRecordWindowTotals, MnemomApiError } from "../api.js";
|
|
@@ -37,12 +50,35 @@ export const RECORD_TIMEOUT_MS = 10_000;
|
|
|
37
50
|
* within 60s of the API's in either direction; ending earlier would trade away the floor side.
|
|
38
51
|
*/
|
|
39
52
|
export const RECORD_FLOOR_MARGIN_MS = 60_000;
|
|
40
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The read budget is sized to the work, not fixed. The halving tree is discovered as it is read, so
|
|
55
|
+
* the budget grows with the deepest level reached: a window that has to be halved to depth
|
|
56
|
+
* RECORD_BASE_DEPTH (63 reads) fits the base budget; each level past that doubles the reads
|
|
57
|
+
* allowed and adds RECORD_TIME_PER_LEVEL_MS of wall clock. Both are capped by a HARD CEILING
|
|
58
|
+
* (RECORD_MAX_REQUESTS_CEILING reads, RECORD_TOTAL_TIMEOUT_CEILING_MS) that no depth can exceed, so
|
|
59
|
+
* a very dense org stays bounded. What the budget does not reach is reported as uncovered days.
|
|
60
|
+
*/
|
|
61
|
+
export const RECORD_BASE_DEPTH = 5;
|
|
62
|
+
/** Base read budget (the first full-window read and every retry included). */
|
|
41
63
|
export const RECORD_MAX_REQUESTS = 64;
|
|
42
|
-
/**
|
|
64
|
+
/** Hard ceiling on reads for one scan, however deep the halving goes. */
|
|
65
|
+
export const RECORD_MAX_REQUESTS_CEILING = 256;
|
|
66
|
+
/** Base wall-clock budget for all the reads of one scan. */
|
|
43
67
|
export const RECORD_TOTAL_TIMEOUT_MS = 60_000;
|
|
68
|
+
/** Wall clock added per halving level past RECORD_BASE_DEPTH. */
|
|
69
|
+
export const RECORD_TIME_PER_LEVEL_MS = 30_000;
|
|
70
|
+
/** Hard ceiling on wall clock for one scan, however deep the halving goes. */
|
|
71
|
+
export const RECORD_TOTAL_TIMEOUT_CEILING_MS = 180_000;
|
|
72
|
+
/** At most this many tries per piece (first try included). */
|
|
73
|
+
export const RECORD_MAX_TRIES = 3;
|
|
74
|
+
/** First backoff before jitter; doubles per retry. */
|
|
75
|
+
export const RECORD_BACKOFF_BASE_MS = 500;
|
|
76
|
+
/** A Retry-After longer than this is not waited for: the piece is left unread. */
|
|
77
|
+
export const RECORD_MAX_RETRY_WAIT_MS = 30_000;
|
|
44
78
|
/** A window is split only if both halves stay at least this long. */
|
|
45
79
|
export const RECORD_MIN_SPLIT_MS = 60 * 60 * 1000;
|
|
80
|
+
/** Timeout/5xx splits allowed before any piece has been read (past it the API is down, not dense). */
|
|
81
|
+
export const RECORD_MAX_FRUITLESS_SPLITS = 1;
|
|
46
82
|
/** The API's bigint ceiling: a larger sum cannot be a real total. */
|
|
47
83
|
const MAX_MMU = 9223372036854775807n;
|
|
48
84
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -122,11 +158,20 @@ function addTotals(a, b) {
|
|
|
122
158
|
as_of: laterAsOf(a.as_of, b.as_of),
|
|
123
159
|
};
|
|
124
160
|
}
|
|
125
|
-
/**
|
|
126
|
-
class
|
|
161
|
+
/** A sum that cannot be formed is bad data, not a slow piece: the whole read is unavailable. */
|
|
162
|
+
class MergeFailed extends Error {
|
|
127
163
|
}
|
|
128
164
|
class NotInCohort extends Error {
|
|
129
165
|
}
|
|
166
|
+
/** Worth another try: a timeout or network failure, a 429, or a 5xx. Everything else is final. */
|
|
167
|
+
function isTransient(e) {
|
|
168
|
+
if (e instanceof MnemomApiError) {
|
|
169
|
+
const s = e.effectiveStatus;
|
|
170
|
+
return s === 429 || s >= 500;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
130
175
|
/** Never throws except RecordAuthError (401/403, fail closed). */
|
|
131
176
|
export async function fetchRecordTotals(orgId, deps = {}) {
|
|
132
177
|
if (!isOrgId(orgId))
|
|
@@ -134,44 +179,103 @@ export async function fetchRecordTotals(orgId, deps = {}) {
|
|
|
134
179
|
const window = recordWindow((deps.now ?? Date.now)());
|
|
135
180
|
const fetchTotals = deps.fetchTotals ?? getRecordWindowTotals;
|
|
136
181
|
const timeoutMs = deps.timeoutMs ?? RECORD_TIMEOUT_MS;
|
|
137
|
-
const
|
|
182
|
+
const baseBudgetMs = deps.totalTimeoutMs ?? RECORD_TOTAL_TIMEOUT_MS;
|
|
183
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
184
|
+
const random = deps.random ?? Math.random;
|
|
185
|
+
const startedAt = Date.now();
|
|
138
186
|
let requests = 0;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
187
|
+
let deepest = 0;
|
|
188
|
+
let sum = null;
|
|
189
|
+
let coveredMs = 0;
|
|
190
|
+
let fruitlessSplits = 0;
|
|
191
|
+
// Sized to the work (see RECORD_BASE_DEPTH), under a hard ceiling.
|
|
192
|
+
const extraLevels = () => Math.max(0, deepest - RECORD_BASE_DEPTH);
|
|
193
|
+
const maxReads = () => Math.min(RECORD_MAX_REQUESTS_CEILING, RECORD_MAX_REQUESTS * 2 ** extraLevels());
|
|
194
|
+
const budgetMs = () => Math.min(Math.max(baseBudgetMs, RECORD_TOTAL_TIMEOUT_CEILING_MS), baseBudgetMs + RECORD_TIME_PER_LEVEL_MS * extraLevels());
|
|
195
|
+
const timeLeft = () => startedAt + budgetMs() - Date.now();
|
|
196
|
+
/**
|
|
197
|
+
* Read [fromMs, toMs) and fold what is readable into `sum`. A piece that cannot be read is simply
|
|
198
|
+
* not added (and not counted as covered). Throws RecordAuthError, NotInCohort (root 404 only) or
|
|
199
|
+
* MergeFailed.
|
|
200
|
+
*/
|
|
201
|
+
const read = async (fromMs, toMs, depth) => {
|
|
202
|
+
deepest = Math.max(deepest, depth);
|
|
203
|
+
// Halve at the midpoint and read both halves; a piece whose half would be under
|
|
204
|
+
// RECORD_MIN_SPLIT_MS is left unread. Reads stay under the budget and ceiling (checked per try).
|
|
205
|
+
const split = async () => {
|
|
206
|
+
const mid = fromMs + Math.floor((toMs - fromMs) / 2);
|
|
207
|
+
if (mid - fromMs < RECORD_MIN_SPLIT_MS || toMs - mid < RECORD_MIN_SPLIT_MS)
|
|
208
|
+
return;
|
|
209
|
+
await read(fromMs, mid, depth + 1);
|
|
210
|
+
await read(mid, toMs, depth + 1);
|
|
211
|
+
};
|
|
212
|
+
for (let attempt = 1; attempt <= RECORD_MAX_TRIES; attempt += 1) {
|
|
213
|
+
const left = timeLeft();
|
|
214
|
+
if (requests >= maxReads() || left <= 0)
|
|
215
|
+
return;
|
|
216
|
+
requests += 1;
|
|
217
|
+
let failure;
|
|
218
|
+
try {
|
|
219
|
+
const body = await fetchTotals(orgId, new Date(fromMs).toISOString(), new Date(toMs).toISOString(), AbortSignal.timeout(Math.min(timeoutMs, left)));
|
|
220
|
+
const totals = parseWindowTotals(body);
|
|
221
|
+
if (!totals)
|
|
222
|
+
return; // off-shape: not retried, piece left out
|
|
223
|
+
try {
|
|
224
|
+
sum = sum === null ? totals : addTotals(sum, totals);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new MergeFailed();
|
|
228
|
+
}
|
|
229
|
+
coveredMs += toMs - fromMs;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
if (e instanceof MergeFailed)
|
|
234
|
+
throw e;
|
|
235
|
+
failure = e;
|
|
236
|
+
}
|
|
237
|
+
if (failure instanceof MnemomApiError) {
|
|
238
|
+
const s = failure.effectiveStatus;
|
|
155
239
|
if (s === 401 || s === 403)
|
|
156
240
|
throw new RecordAuthError();
|
|
157
|
-
if (s === 404 &&
|
|
241
|
+
if (s === 404 && depth === 0)
|
|
158
242
|
throw new NotInCohort();
|
|
159
|
-
if (isWindowTooBig(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
throw new SplitFailed();
|
|
163
|
-
}
|
|
164
|
-
const first = await read(fromMs, mid, false);
|
|
165
|
-
return addTotals(first, await read(mid, toMs, false));
|
|
243
|
+
if (isWindowTooBig(failure)) {
|
|
244
|
+
await split();
|
|
245
|
+
return;
|
|
166
246
|
}
|
|
167
247
|
}
|
|
168
|
-
|
|
169
|
-
|
|
248
|
+
if (!isTransient(failure))
|
|
249
|
+
return;
|
|
250
|
+
// A piece too dense to total in one read times out or 5xxs: once its tries (or its allowed
|
|
251
|
+
// wait) are used up, halve it like a 400 "narrow it". A 429 is rate limiting, not density:
|
|
252
|
+
// halving would only add reads, so it is left unread.
|
|
253
|
+
const splittable = !(failure instanceof MnemomApiError && failure.effectiveStatus === 429);
|
|
254
|
+
// Outage guard: while nothing at all has been read, one such split is allowed; a second means
|
|
255
|
+
// the API is down rather than the piece dense, and halving further would only burn the budget.
|
|
256
|
+
const giveUp = async () => {
|
|
257
|
+
if (!splittable)
|
|
258
|
+
return;
|
|
259
|
+
if (coveredMs === 0) {
|
|
260
|
+
if (fruitlessSplits >= RECORD_MAX_FRUITLESS_SPLITS)
|
|
261
|
+
return;
|
|
262
|
+
fruitlessSplits += 1;
|
|
263
|
+
}
|
|
264
|
+
await split();
|
|
265
|
+
};
|
|
266
|
+
if (attempt === RECORD_MAX_TRIES)
|
|
267
|
+
return giveUp();
|
|
268
|
+
// Jittered exponential backoff (500ms, 1s before jitter), never shorter than Retry-After.
|
|
269
|
+
const retryAfter = failure instanceof MnemomApiError ? failure.retryAfterMs : undefined;
|
|
270
|
+
const backoff = RECORD_BACKOFF_BASE_MS * 2 ** (attempt - 1) * (0.5 + 0.5 * random());
|
|
271
|
+
const wait = Math.max(backoff, retryAfter ?? 0);
|
|
272
|
+
if (wait > RECORD_MAX_RETRY_WAIT_MS || wait >= timeLeft())
|
|
273
|
+
return giveUp();
|
|
274
|
+
await sleep(wait);
|
|
170
275
|
}
|
|
171
276
|
};
|
|
172
277
|
try {
|
|
173
|
-
|
|
174
|
-
return { status: "ok", totals, window };
|
|
278
|
+
await read(Date.parse(window.from), Date.parse(window.to), 0);
|
|
175
279
|
}
|
|
176
280
|
catch (e) {
|
|
177
281
|
if (e instanceof RecordAuthError)
|
|
@@ -180,4 +284,18 @@ export async function fetchRecordTotals(orgId, deps = {}) {
|
|
|
180
284
|
return { status: "not_in_cohort" };
|
|
181
285
|
return { status: "unavailable" };
|
|
182
286
|
}
|
|
287
|
+
if (sum === null)
|
|
288
|
+
return { status: "unavailable" };
|
|
289
|
+
const windowMs = Date.parse(window.to) - Date.parse(window.from);
|
|
290
|
+
const complete = coveredMs >= windowMs;
|
|
291
|
+
return {
|
|
292
|
+
status: "ok",
|
|
293
|
+
totals: sum,
|
|
294
|
+
window,
|
|
295
|
+
coverage: {
|
|
296
|
+
complete,
|
|
297
|
+
days_covered: complete ? RECORD_WINDOW_DAYS : Math.floor(coveredMs / DAY_MS),
|
|
298
|
+
days_total: RECORD_WINDOW_DAYS,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
183
301
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
* Renders the scan preview report from values already in memory: the local PR scan plus the org's
|
|
3
|
-
* Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
|
|
4
|
-
* labelled "last 30 days"; no other window number is invented. Money is integer millimu
|
|
5
|
-
* (1 MU = 1000 mmu), formatted with BigInt so values above 2^53 stay exact.
|
|
6
|
-
*/
|
|
7
|
-
import { type RecordOutcome } from "./record.js";
|
|
1
|
+
import { type RecordCoverage, type RecordOutcome } from "./record.js";
|
|
8
2
|
export declare const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
|
|
9
3
|
export declare const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
|
|
10
4
|
export declare const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
|
|
11
|
-
/** "12.5
|
|
5
|
+
/** "12.5 μ" from "12500". Caller guarantees a canonical mmu string. */
|
|
12
6
|
export declare function formatMu(mmu: string): string;
|
|
7
|
+
/** "$0.13 (12.5 μ)": dollars first, exact μ beside it. */
|
|
8
|
+
export declare function describe(mmu: string): string;
|
|
13
9
|
export declare function renderRecordSection(outcome: RecordOutcome): string[];
|
|
10
|
+
/** The fixed partial-coverage sentence. */
|
|
11
|
+
export declare function recordCoverageNote(c: RecordCoverage): string;
|
package/dist/lib/scan/report.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Renders the scan preview report from values already in memory: the local PR scan plus the org's
|
|
3
3
|
* Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
|
|
4
|
-
* labelled "last 30 days"; no other window number is invented. Money is
|
|
5
|
-
* (1
|
|
4
|
+
* labelled "last 30 days"; no other window number is invented. Money is shown in dollars with the exact μ
|
|
5
|
+
* beside it (1 μ = 1 cent; the ledger unit is never printed), formatted with BigInt so values above 2^53 stay exact.
|
|
6
6
|
*/
|
|
7
|
+
import { formatUsd } from "../conduct-receipt.js";
|
|
7
8
|
import { RECORD_WINDOW_LABEL } from "./record.js";
|
|
8
9
|
export const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
|
|
9
10
|
export const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
|
|
10
11
|
export const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
|
|
11
|
-
/** "12.5
|
|
12
|
+
/** "12.5 μ" from "12500". Caller guarantees a canonical mmu string. */
|
|
12
13
|
export function formatMu(mmu) {
|
|
13
14
|
const n = BigInt(mmu);
|
|
14
15
|
const whole = (n / 1000n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
15
16
|
const frac = (n % 1000n).toString().padStart(3, "0").replace(/0+$/, "");
|
|
16
|
-
return `${whole}${frac ? `.${frac}` : ""}
|
|
17
|
+
return `${whole}${frac ? `.${frac}` : ""} μ`;
|
|
18
|
+
}
|
|
19
|
+
/** "$0.13 (12.5 μ)": dollars first, exact μ beside it. */
|
|
20
|
+
export function describe(mmu) {
|
|
21
|
+
return `${formatUsd(BigInt(mmu))} (${formatMu(mmu)})`;
|
|
17
22
|
}
|
|
18
23
|
export function renderRecordSection(outcome) {
|
|
19
24
|
if (outcome.status === "not_in_cohort")
|
|
@@ -21,10 +26,19 @@ export function renderRecordSection(outcome) {
|
|
|
21
26
|
if (outcome.status === "unavailable")
|
|
22
27
|
return [RECORD_UNAVAILABLE_NOTE];
|
|
23
28
|
const t = outcome.totals;
|
|
29
|
+
const c = outcome.coverage;
|
|
30
|
+
// Partial totals are never labelled as the full window: the label and an explicit coverage line change.
|
|
31
|
+
const head = c.complete
|
|
32
|
+
? [`Org Record (${RECORD_WINDOW_LABEL}, read from Mnemom; not stored):`]
|
|
33
|
+
: ["Org Record (partial, read from Mnemom; not stored):", ` ${recordCoverageNote(c)}`];
|
|
24
34
|
return [
|
|
25
|
-
|
|
26
|
-
` Model cost: ${
|
|
27
|
-
` Charged: ${t.mu_charged_mmu === null ? "not charged" :
|
|
35
|
+
...head,
|
|
36
|
+
` Model cost: ${describe(t.model_cost_mmu)}`,
|
|
37
|
+
` Charged: ${t.mu_charged_mmu === null ? "not charged" : describe(t.mu_charged_mmu)}`,
|
|
28
38
|
` Requests recorded: ${t.row_count}`,
|
|
29
39
|
];
|
|
30
40
|
}
|
|
41
|
+
/** The fixed partial-coverage sentence. */
|
|
42
|
+
export function recordCoverageNote(c) {
|
|
43
|
+
return `Record totals cover ${c.days_covered} of ${c.days_total} days (some periods could not be read).`;
|
|
44
|
+
}
|
package/dist/lib/scan/share.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ScanResult } from "./scan.js";
|
|
2
2
|
export type ShareLevel = "summary" | "full" | "none";
|
|
3
|
+
/** The level preselected at the prompt (Enter picks it). */
|
|
4
|
+
export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
|
|
3
5
|
export declare const SHARE_OPTIONS: ReadonlyArray<{
|
|
4
6
|
level: ShareLevel;
|
|
5
7
|
label: string;
|
|
6
8
|
}>;
|
|
7
|
-
/**
|
|
9
|
+
/** Shown just above the share prompt, so pressing Enter is an informed choice. */
|
|
10
|
+
export declare const SHARE_DISCLOSURE: string;
|
|
11
|
+
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
8
12
|
export declare function promptShareLevel(): Promise<ShareLevel>;
|
|
9
13
|
export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
|
|
10
14
|
/** Who receives the report. Sent only with a non-"none" share level. */
|
package/dist/lib/scan/share.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Share level: what, if anything, may leave the machine. Asked before any output is built for sharing.
|
|
3
|
-
*
|
|
3
|
+
* Full is the preselected choice (MNE-9619): pressing Enter at the prompt picks it. "none" yields no
|
|
4
|
+
* payload at all, and is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
|
|
4
5
|
*/
|
|
5
6
|
import { askSelect } from "../prompt.js";
|
|
7
|
+
/** The level preselected at the prompt (Enter picks it). */
|
|
8
|
+
export const DEFAULT_SHARE_LEVEL = "full";
|
|
6
9
|
export const SHARE_OPTIONS = [
|
|
7
|
-
{ level: "summary", label: "Summary metrics only (counts and one estimate line
|
|
8
|
-
{ level: "full", label: "Full report (summary plus per-repo counts)" },
|
|
10
|
+
{ level: "summary", label: "Summary metrics only (counts and one estimate line)" },
|
|
11
|
+
{ level: "full", label: "Full report (summary plus per-repo counts; default)" },
|
|
9
12
|
{ level: "none", label: "Nothing (keep results on this machine)" },
|
|
10
13
|
];
|
|
11
|
-
/**
|
|
14
|
+
/** Shown just above the share prompt, so pressing Enter is an informed choice. */
|
|
15
|
+
export const SHARE_DISCLOSURE = [
|
|
16
|
+
"Pressing Enter shares the Full report: the whole report, including your repo names and per-repo counts.",
|
|
17
|
+
"It is emailed to the report recipients. To share less, choose 1 (Summary: counts only, no repo names) or 3 (Nothing).",
|
|
18
|
+
].join("\n");
|
|
19
|
+
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
12
20
|
export async function promptShareLevel() {
|
|
13
|
-
|
|
21
|
+
// eslint-disable-next-line no-console -- consent text must show on stdout directly above the prompt (askSelect also prints with console.log)
|
|
22
|
+
console.log(SHARE_DISCLOSURE);
|
|
23
|
+
const answer = await askSelect("What may leave this machine?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
|
|
14
24
|
if (answer === null)
|
|
15
25
|
return "none";
|
|
16
26
|
return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
|