@relayflows/sdk 2.0.21 → 2.0.23
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/agent-artifacts.d.ts +18 -9
- package/dist/agent-artifacts.d.ts.map +1 -1
- package/dist/agent-artifacts.js +27 -11
- package/dist/agent-artifacts.js.map +1 -1
- package/dist/agent-transcript.d.ts +186 -0
- package/dist/agent-transcript.d.ts.map +1 -0
- package/dist/agent-transcript.js +585 -0
- package/dist/agent-transcript.js.map +1 -0
- package/dist/authored-flow-error.d.ts +33 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +20 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +4 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-node-entry.js +5 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +11 -0
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +30 -1
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.js +3 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-source-authority.js +13 -0
- package/dist/authored-source-authority.js.map +1 -1
- package/dist/authored-step-index.d.ts +82 -0
- package/dist/authored-step-index.d.ts.map +1 -0
- package/dist/authored-step-index.js +120 -0
- package/dist/authored-step-index.js.map +1 -0
- package/dist/authored-step-output.d.ts +23 -2
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +47 -5
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts +10 -2
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +81 -7
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +67 -12
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-read.d.ts +31 -0
- package/dist/cli/cloud-read.d.ts.map +1 -0
- package/dist/cli/cloud-read.js +482 -0
- package/dist/cli/cloud-read.js.map +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +53 -24
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/status.d.ts +43 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +321 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/step-failure.d.ts +26 -0
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +99 -10
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +41 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +33 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-http.d.ts +12 -0
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +14 -6
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-read.d.ts +154 -0
- package/dist/cloud-read.d.ts.map +1 -0
- package/dist/cloud-read.js +373 -0
- package/dist/cloud-read.js.map +1 -0
- package/dist/cloud-transcript.d.ts +94 -0
- package/dist/cloud-transcript.d.ts.map +1 -0
- package/dist/cloud-transcript.js +342 -0
- package/dist/cloud-transcript.js.map +1 -0
- package/dist/communication/environment.d.ts +3 -0
- package/dist/communication/environment.d.ts.map +1 -0
- package/dist/communication/environment.js +34 -0
- package/dist/communication/environment.js.map +1 -0
- package/dist/communication/history.d.ts +6 -0
- package/dist/communication/history.d.ts.map +1 -0
- package/dist/communication/history.js +38 -0
- package/dist/communication/history.js.map +1 -0
- package/dist/communication/local.d.ts +6 -0
- package/dist/communication/local.d.ts.map +1 -0
- package/dist/communication/local.js +56 -0
- package/dist/communication/local.js.map +1 -0
- package/dist/communication/preflight.d.ts +6 -0
- package/dist/communication/preflight.d.ts.map +1 -0
- package/dist/communication/preflight.js +37 -0
- package/dist/communication/preflight.js.map +1 -0
- package/dist/communication/projection.d.ts +8 -0
- package/dist/communication/projection.d.ts.map +1 -0
- package/dist/communication/projection.js +23 -0
- package/dist/communication/projection.js.map +1 -0
- package/dist/communication/relay.d.ts +78 -0
- package/dist/communication/relay.d.ts.map +1 -0
- package/dist/communication/relay.js +89 -0
- package/dist/communication/relay.js.map +1 -0
- package/dist/communication/session.d.ts +24 -0
- package/dist/communication/session.d.ts.map +1 -0
- package/dist/communication/session.js +100 -0
- package/dist/communication/session.js.map +1 -0
- package/dist/communication/spec.d.ts +20 -0
- package/dist/communication/spec.d.ts.map +1 -0
- package/dist/communication/spec.js +113 -0
- package/dist/communication/spec.js.map +1 -0
- package/dist/communication/tool.d.ts +3 -0
- package/dist/communication/tool.d.ts.map +1 -0
- package/dist/communication/tool.js +23 -0
- package/dist/communication/tool.js.map +1 -0
- package/dist/communication/tools.d.ts +11 -0
- package/dist/communication/tools.d.ts.map +1 -0
- package/dist/communication/tools.js +65 -0
- package/dist/communication/tools.js.map +1 -0
- package/dist/communication/worker.d.ts +6 -0
- package/dist/communication/worker.d.ts.map +1 -0
- package/dist/communication/worker.js +120 -0
- package/dist/communication/worker.js.map +1 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +8 -1
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +30 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +9 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +4 -1
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +11 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-reader.d.ts +1 -1
- package/dist/journal-reader.d.ts.map +1 -1
- package/dist/journal-reader.js +3 -1
- package/dist/journal-reader.js.map +1 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +12 -1
- package/dist/llm-worker.js.map +1 -1
- package/dist/permissions-preflight.d.ts +22 -0
- package/dist/permissions-preflight.d.ts.map +1 -0
- package/dist/permissions-preflight.js +49 -0
- package/dist/permissions-preflight.js.map +1 -0
- package/dist/preflight.d.ts +13 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +29 -6
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +39 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/pty-sidechannel.d.ts +7 -0
- package/dist/pty-sidechannel.d.ts.map +1 -1
- package/dist/pty-sidechannel.js.map +1 -1
- package/dist/redact.d.ts +16 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +104 -0
- package/dist/redact.js.map +1 -0
- package/dist/run-state.d.ts +124 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +368 -0
- package/dist/run-state.js.map +1 -0
- package/dist/spec.d.ts +24 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-env.d.ts +25 -0
- package/dist/step-env.d.ts.map +1 -0
- package/dist/step-env.js +34 -0
- package/dist/step-env.js.map +1 -0
- package/dist/transcript-tail.d.ts +54 -0
- package/dist/transcript-tail.d.ts.map +1 -0
- package/dist/transcript-tail.js +176 -0
- package/dist/transcript-tail.js.map +1 -0
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +138 -17
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-usage.d.ts +7 -2
- package/dist/worker-usage.d.ts.map +1 -1
- package/dist/worker-usage.js +21 -3
- package/dist/worker-usage.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +41 -7
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-runtime.d.ts +7 -1
- package/dist/wrapper-runtime.d.ts.map +1 -1
- package/dist/wrapper-runtime.js +7 -1
- package/dist/wrapper-runtime.js.map +1 -1
- package/dist/wrapper-session.d.ts +8 -0
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +156 -23
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +14 -2
- package/src/agent-artifacts.ts +27 -11
- package/src/agent-transcript.ts +651 -0
- package/src/authored-flow-error.ts +17 -0
- package/src/authored-flow-executor.ts +4 -1
- package/src/authored-node-entry.ts +5 -1
- package/src/authored-node-runner.ts +33 -1
- package/src/authored-root.ts +2 -1
- package/src/authored-source-authority.ts +13 -0
- package/src/authored-step-index.ts +157 -0
- package/src/authored-step-output.ts +66 -14
- package/src/authored-worker-step.ts +88 -15
- package/src/cli/check.ts +69 -11
- package/src/cli/cloud-read.ts +518 -0
- package/src/cli/run.ts +43 -24
- package/src/cli/status.ts +349 -0
- package/src/cli/step-failure.ts +100 -10
- package/src/cli-commands.ts +33 -0
- package/src/cli.ts +27 -0
- package/src/cloud-http.ts +32 -6
- package/src/cloud-read.ts +515 -0
- package/src/cloud-transcript.ts +422 -0
- package/src/communication/environment.ts +33 -0
- package/src/communication/history.ts +38 -0
- package/src/communication/local.ts +41 -0
- package/src/communication/preflight.ts +34 -0
- package/src/communication/projection.ts +23 -0
- package/src/communication/relay.ts +102 -0
- package/src/communication/session.ts +84 -0
- package/src/communication/spec.ts +106 -0
- package/src/communication/tool.ts +22 -0
- package/src/communication/tools.ts +50 -0
- package/src/communication/worker.ts +95 -0
- package/src/compile.ts +4 -1
- package/src/failure-kinds.ts +31 -1
- package/src/index.ts +10 -1
- package/src/journal-client.ts +12 -2
- package/src/journal-reader.ts +4 -2
- package/src/llm-worker.ts +12 -1
- package/src/permissions-preflight.ts +53 -0
- package/src/preflight.ts +42 -7
- package/src/protocol.ts +15 -0
- package/src/pty-sidechannel.ts +7 -0
- package/src/redact.ts +106 -0
- package/src/run-state.ts +468 -0
- package/src/spec.ts +18 -1
- package/src/step-env.ts +47 -0
- package/src/transcript-tail.ts +193 -0
- package/src/worker-cli.ts +139 -18
- package/src/worker-usage.ts +26 -3
- package/src/worker.ts +42 -6
- package/src/wrapper-runtime.ts +7 -1
- package/src/wrapper-session.ts +159 -24
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, open, unlink, type FileHandle } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { ptySocketPath, type SidechannelContext } from './pty-sidechannel.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-attempt agent transcript: the redacted, capped `stream-json` file the
|
|
9
|
+
* worker writes beside the PTY socket, and the ≤ 8 KiB digest of it that rides
|
|
10
|
+
* in `trajectory_tail` on every completion.
|
|
11
|
+
*
|
|
12
|
+
* Every bound below is enforced here and stated in what it produces — a
|
|
13
|
+
* marker line at a seam, a suffix on a cut string, a boolean on the digest —
|
|
14
|
+
* so a reader is told what was dropped rather than left to guess.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Cap on one attempt's transcript file: the first 128 KiB whole, then a rolling tail. */
|
|
18
|
+
export const TRANSCRIPT_FILE_MAX_BYTES = 1024 * 1024;
|
|
19
|
+
export const TRANSCRIPT_HEAD_BYTES = 128 * 1024;
|
|
20
|
+
export const TRANSCRIPT_TAIL_BYTES = TRANSCRIPT_FILE_MAX_BYTES - TRANSCRIPT_HEAD_BYTES;
|
|
21
|
+
/** Cap on any one string leaf inside a frame (tool results, thinking, text). */
|
|
22
|
+
export const TRANSCRIPT_STRING_MAX_BYTES = 64 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* Cap on the serialized digest. Half the kernel's 16 KiB `trajectory_tail`
|
|
25
|
+
* boundary (relayflowd/src/server.rs), leaving room for the `relay_task`
|
|
26
|
+
* sibling the worker already journals.
|
|
27
|
+
*/
|
|
28
|
+
export const TRANSCRIPT_DIGEST_MAX_BYTES = 8 * 1024;
|
|
29
|
+
/** Cap on an llm step's `trajectory_tail.error`; over 16 KiB the kernel refuses the completion. */
|
|
30
|
+
export const LLM_ERROR_MAX_BYTES = 4 * 1024;
|
|
31
|
+
|
|
32
|
+
export const FINAL_TEXT_MAX_BYTES = 2 * 1024;
|
|
33
|
+
export const FAILURE_EXCERPT_MAX_BYTES = 1024;
|
|
34
|
+
export const TOOL_INPUT_EXCERPT_MAX_CHARS = 120;
|
|
35
|
+
/**
|
|
36
|
+
* Cap on any single provider-supplied identifier copied into `result`
|
|
37
|
+
* (`model`, `session_id`, `stop_reason`, `subtype`, `claude_code_version`) and
|
|
38
|
+
* on a tool name. These are attacker-influenceable strings that no reduction
|
|
39
|
+
* step in `boundTranscriptDigest` trims, so they are bounded where they enter.
|
|
40
|
+
*/
|
|
41
|
+
export const DIGEST_LABEL_MAX_BYTES = 256;
|
|
42
|
+
/**
|
|
43
|
+
* Bytes reserved inside `TRANSCRIPT_FILE_MAX_BYTES` for the
|
|
44
|
+
* `relayflow.truncated` marker, so a full head plus a full tail plus the
|
|
45
|
+
* marker still fits the stated per-attempt cap. The marker serializes to
|
|
46
|
+
* well under this even with 20-digit counters.
|
|
47
|
+
*/
|
|
48
|
+
export const TRUNCATION_MARKER_RESERVE = 256;
|
|
49
|
+
export const TOOL_COUNTS_MAX = 32;
|
|
50
|
+
export const TOOL_LAST_CALLS_MAX = 20;
|
|
51
|
+
export const ARTIFACT_PATHS_MAX = 50;
|
|
52
|
+
|
|
53
|
+
/** The `system/init` keys kept; everything else on that frame is local paths and inventory. */
|
|
54
|
+
const INIT_KEEP = ['type', 'subtype', 'model', 'claude_code_version', 'permissionMode', 'session_id'] as const;
|
|
55
|
+
|
|
56
|
+
export interface TranscriptFile {
|
|
57
|
+
path: string;
|
|
58
|
+
/** Bytes the redacted transcript would have occupied without the file cap. */
|
|
59
|
+
bytes_total: number;
|
|
60
|
+
bytes_kept: number;
|
|
61
|
+
frames_total: number;
|
|
62
|
+
frames_kept: number;
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
sha256: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TranscriptToolCall {
|
|
68
|
+
seq: number;
|
|
69
|
+
name: string;
|
|
70
|
+
input_excerpt: string;
|
|
71
|
+
result_bytes?: number;
|
|
72
|
+
is_error?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TranscriptDigest {
|
|
76
|
+
attempt?: number;
|
|
77
|
+
exit_code?: number | null;
|
|
78
|
+
file?: TranscriptFile;
|
|
79
|
+
result?: {
|
|
80
|
+
provider: 'claude' | 'codex';
|
|
81
|
+
model?: string;
|
|
82
|
+
claude_code_version?: string;
|
|
83
|
+
session_id?: string;
|
|
84
|
+
subtype?: string;
|
|
85
|
+
is_error?: boolean;
|
|
86
|
+
stop_reason?: string;
|
|
87
|
+
num_turns?: number;
|
|
88
|
+
duration_ms?: number;
|
|
89
|
+
duration_api_ms?: number;
|
|
90
|
+
total_cost_usd?: number;
|
|
91
|
+
usage?: { input?: number; output?: number; cache_read?: number; cache_creation?: number; thinking?: number };
|
|
92
|
+
permission_denials?: number;
|
|
93
|
+
};
|
|
94
|
+
tools?: {
|
|
95
|
+
counts: Array<{ name: string; calls: number; errors: number }>;
|
|
96
|
+
last_calls: TranscriptToolCall[];
|
|
97
|
+
total_calls: number;
|
|
98
|
+
shown_calls: number;
|
|
99
|
+
/** False when the provider's frames are only counted, not paired (Codex). */
|
|
100
|
+
complete: boolean;
|
|
101
|
+
};
|
|
102
|
+
final_text?: string;
|
|
103
|
+
final_text_truncated?: boolean;
|
|
104
|
+
failure?: { kind: 'result' | 'tool_result' | 'stderr'; excerpt: string; truncated?: boolean };
|
|
105
|
+
artifacts?: { count: number; paths: string[] };
|
|
106
|
+
/**
|
|
107
|
+
* Set when even the ordered reductions left the digest over its cap and it
|
|
108
|
+
* was cut back to fixed-size fields — every provider-supplied string but a
|
|
109
|
+
* bounded file path is gone. A reader is told rather than left guessing.
|
|
110
|
+
*/
|
|
111
|
+
core_only?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type RecordValue = Record<string, unknown>;
|
|
115
|
+
const record = (value: unknown): value is RecordValue => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
116
|
+
|
|
117
|
+
/** `…/runs/<runId>/steps/<stepId>/attempt-<n>.transcript.jsonl`, beside the PTY socket. */
|
|
118
|
+
export function transcriptPath(
|
|
119
|
+
context: Pick<SidechannelContext, 'dataDir' | 'runId' | 'stepId'>,
|
|
120
|
+
attempt: number,
|
|
121
|
+
): string {
|
|
122
|
+
if (!Number.isSafeInteger(attempt) || attempt < 1) throw new Error('Invalid transcript attempt');
|
|
123
|
+
return join(dirname(ptySocketPath(context)), `attempt-${attempt}.transcript.jsonl`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Redaction
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const SECRET_NAME = /(TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|SESSION|PRIVATE)/i;
|
|
131
|
+
/**
|
|
132
|
+
* Relay-issued token prefixes, shared with `redactRelayError` (worker-cli.ts).
|
|
133
|
+
* The tail must be at least 8 characters so journal keys such as `at_ms` are
|
|
134
|
+
* not mistaken for tokens.
|
|
135
|
+
*/
|
|
136
|
+
export const RELAY_TOKEN_PATTERN = /\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]{8,}/g;
|
|
137
|
+
const TOKEN_PATTERNS: RegExp[] = [
|
|
138
|
+
RELAY_TOKEN_PATTERN,
|
|
139
|
+
/\bsk-ant-[A-Za-z0-9_-]{8,}/g,
|
|
140
|
+
/\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{16,}/g,
|
|
141
|
+
/\bgithub_pat_[A-Za-z0-9_]{16,}/g,
|
|
142
|
+
/\bxox[abps]-[A-Za-z0-9-]{8,}/g,
|
|
143
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
144
|
+
];
|
|
145
|
+
const PEM_BLOCK = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
146
|
+
// Header forms, in prose or inside a JSON-encoded object: the header name and
|
|
147
|
+
// any `Bearer`/`Basic` scheme stay, the value goes. Cookies are redacted to the
|
|
148
|
+
// end of the line, since every pair in them is a credential.
|
|
149
|
+
const HEADER_VALUE = /\b(authorization|x-api-key|x-callback-token)("?\s*[:=]\s*"?)((?:Bearer|Basic)\s+)?(?!\[redacted)([^\s"',;\\]+)/gi;
|
|
150
|
+
const COOKIE_VALUE = /\b(cookie|set-cookie)("?\s*[:=]\s*"?)(?!\[redacted)([^\n"\\]+)/gi;
|
|
151
|
+
const BEARER = /\bBearer\s+(?!\[redacted)[A-Za-z0-9._~+/=-]{8,}/g;
|
|
152
|
+
const ENV_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/gm;
|
|
153
|
+
|
|
154
|
+
export type Redactor = (text: string) => string;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One redactor for one environment: the values of secret-named variables are
|
|
158
|
+
* resolved once, so a writer redacting thousands of strings does not rescan
|
|
159
|
+
* `process.env` for each.
|
|
160
|
+
*/
|
|
161
|
+
export function createRedactor(env: NodeJS.ProcessEnv = process.env): Redactor {
|
|
162
|
+
const byName: Array<[string, string]> = [];
|
|
163
|
+
for (const [name, value] of Object.entries(env)) {
|
|
164
|
+
if (typeof value === 'string' && value.length >= 8 && SECRET_NAME.test(name)) byName.push([name, value]);
|
|
165
|
+
}
|
|
166
|
+
// Longest first, so a value that is a prefix of another cannot leave the
|
|
167
|
+
// longer one half-redacted.
|
|
168
|
+
byName.sort((a, b) => b[1].length - a[1].length);
|
|
169
|
+
return (text: string): string => {
|
|
170
|
+
if (text.length === 0) return text;
|
|
171
|
+
// Rule 3 — env dumps by shape: `NAME=VALUE` where VALUE is that variable's
|
|
172
|
+
// value, whatever the name. Catches `env`/`printenv` output of variables
|
|
173
|
+
// whose names look harmless.
|
|
174
|
+
let out = text.replace(ENV_LINE, (line, name: string, value: string) =>
|
|
175
|
+
env[name] === value ? `${name}=[redacted:${name}]` : line);
|
|
176
|
+
// Rule 2 — secret-named variables' values, wherever they appear.
|
|
177
|
+
for (const [name, value] of byName) out = out.replaceAll(value, `[redacted:${name}]`);
|
|
178
|
+
// Rule 4 — well-known token shapes and header forms; the header name stays.
|
|
179
|
+
out = out.replace(PEM_BLOCK, '[redacted:private-key]');
|
|
180
|
+
for (const pattern of TOKEN_PATTERNS) out = out.replace(pattern, '[redacted]');
|
|
181
|
+
out = out.replace(HEADER_VALUE, (_m, name: string, sep: string, scheme: string | undefined) => `${name}${sep}${scheme ?? ''}[redacted]`);
|
|
182
|
+
out = out.replace(COOKIE_VALUE, (_m, name: string, sep: string) => `${name}${sep}[redacted]`);
|
|
183
|
+
out = out.replace(BEARER, 'Bearer [redacted]');
|
|
184
|
+
return out;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Redact one string against `env` (D6 rules 2–4). */
|
|
189
|
+
export function redactText(text: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
190
|
+
return createRedactor(env)(text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Bounded strings
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/** Cut `text` to at most `maxBytes` of UTF-8 on a code-point boundary. */
|
|
198
|
+
export function utf8Head(text: string, maxBytes: number): string {
|
|
199
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
200
|
+
if (bytes.length <= maxBytes) return text;
|
|
201
|
+
let end = maxBytes;
|
|
202
|
+
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end -= 1;
|
|
203
|
+
return bytes.subarray(0, end).toString('utf8');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The last `maxBytes` of `text`, on a code-point boundary. */
|
|
207
|
+
export function utf8Tail(text: string, maxBytes: number): string {
|
|
208
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
209
|
+
if (bytes.length <= maxBytes) return text;
|
|
210
|
+
let start = bytes.length - maxBytes;
|
|
211
|
+
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
|
|
212
|
+
return bytes.subarray(start).toString('utf8');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Head-cut with the truncation stated in the value itself. */
|
|
216
|
+
export function boundedText(text: string, maxBytes: number, label = ''): { text: string; truncated: boolean } {
|
|
217
|
+
const total = Buffer.byteLength(text, 'utf8');
|
|
218
|
+
if (total <= maxBytes) return { text, truncated: false };
|
|
219
|
+
const head = utf8Head(text, maxBytes);
|
|
220
|
+
return { text: `${head}…[${label}${total - Buffer.byteLength(head, 'utf8')} bytes truncated]`, truncated: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Frame reduction
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Redact and bound every string leaf of a parsed frame, and reduce the
|
|
229
|
+
* `system/init` frame to its whitelist. Unknown frame types are kept as they
|
|
230
|
+
* are, minus the same string treatment.
|
|
231
|
+
*/
|
|
232
|
+
export function reduceFrame(frame: unknown, redact: Redactor): unknown {
|
|
233
|
+
if (record(frame) && frame.type === 'system' && frame.subtype === 'init') {
|
|
234
|
+
const kept: RecordValue = { relayflow_reduced: true };
|
|
235
|
+
for (const key of INIT_KEEP) if (frame[key] !== undefined) kept[key] = frame[key];
|
|
236
|
+
if (Array.isArray(frame.tools)) kept.tools_count = frame.tools.length;
|
|
237
|
+
if (Array.isArray(frame.mcp_servers)) kept.mcp_servers_count = frame.mcp_servers.length;
|
|
238
|
+
return reduceValue(kept, redact);
|
|
239
|
+
}
|
|
240
|
+
return reduceValue(frame, redact);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function reduceValue(value: unknown, redact: Redactor): unknown {
|
|
244
|
+
if (typeof value === 'string') return boundedText(redact(value), TRANSCRIPT_STRING_MAX_BYTES, 'relayflow: ').text;
|
|
245
|
+
if (Array.isArray(value)) return value.map(item => reduceValue(item, redact));
|
|
246
|
+
if (record(value)) {
|
|
247
|
+
const out: RecordValue = {};
|
|
248
|
+
for (const [key, item] of Object.entries(value)) out[key] = reduceValue(item, redact);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// File writer
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export interface TranscriptWriter {
|
|
259
|
+
/** One stdout line, without its newline. Never throws; a failed file is reported by `close`. */
|
|
260
|
+
write(line: string): void;
|
|
261
|
+
/** Flush the tail, finish the file, and describe it — `undefined` when the file could not be written. */
|
|
262
|
+
close(): Promise<TranscriptFile | undefined>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Open the per-attempt transcript file. The first `TRANSCRIPT_HEAD_BYTES` are
|
|
267
|
+
* written as they arrive, so a crash mid-run still leaves the start of the
|
|
268
|
+
* transcript on disk; past that a ring of the last `TRANSCRIPT_TAIL_BYTES`
|
|
269
|
+
* is kept and flushed on close, behind one `relayflow.truncated` marker line
|
|
270
|
+
* that says how much fell between them.
|
|
271
|
+
*/
|
|
272
|
+
export async function openTranscriptWriter(
|
|
273
|
+
path: string,
|
|
274
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
275
|
+
): Promise<TranscriptWriter | undefined> {
|
|
276
|
+
let handle: FileHandle;
|
|
277
|
+
try {
|
|
278
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
279
|
+
// The agent runs as this OS user, so it can plant a symlink at the
|
|
280
|
+
// transcript path; `open(path, 'w')` would follow it and truncate whatever
|
|
281
|
+
// it points at. Remove whatever is there without following it, then create
|
|
282
|
+
// the file exclusively so a re-planted symlink loses the race rather than
|
|
283
|
+
// being followed. O_NOFOLLOW is belt to O_EXCL's braces.
|
|
284
|
+
try { await unlink(path); } catch { /* absent, or not ours to remove */ }
|
|
285
|
+
handle = await open(
|
|
286
|
+
path,
|
|
287
|
+
// eslint-disable-next-line no-bitwise
|
|
288
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
289
|
+
0o600,
|
|
290
|
+
);
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const redact = createRedactor(env);
|
|
295
|
+
const hash = createHash('sha256');
|
|
296
|
+
const tail: Buffer[] = [];
|
|
297
|
+
let pending: Promise<void> = Promise.resolve();
|
|
298
|
+
let failed = false;
|
|
299
|
+
let headBytes = 0;
|
|
300
|
+
let tailBytes = 0;
|
|
301
|
+
let bytesTotal = 0;
|
|
302
|
+
let bytesDropped = 0;
|
|
303
|
+
let framesTotal = 0;
|
|
304
|
+
let framesDropped = 0;
|
|
305
|
+
let closed = false;
|
|
306
|
+
|
|
307
|
+
const append = (bytes: Buffer): void => {
|
|
308
|
+
hash.update(bytes);
|
|
309
|
+
pending = pending.then(async () => { if (!failed) await handle.write(bytes); }).catch(() => { failed = true; });
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
write(line: string): void {
|
|
314
|
+
if (closed || line.length === 0) return;
|
|
315
|
+
let reduced: string;
|
|
316
|
+
try {
|
|
317
|
+
const frame: unknown = JSON.parse(line);
|
|
318
|
+
reduced = JSON.stringify(reduceFrame(frame, redact));
|
|
319
|
+
} catch {
|
|
320
|
+
// Not a frame: kept opaque, still redacted and bounded.
|
|
321
|
+
reduced = boundedText(redact(line), TRANSCRIPT_STRING_MAX_BYTES, 'relayflow: ').text;
|
|
322
|
+
}
|
|
323
|
+
const bytes = Buffer.from(`${reduced}\n`, 'utf8');
|
|
324
|
+
framesTotal += 1;
|
|
325
|
+
bytesTotal += bytes.length;
|
|
326
|
+
if (tail.length === 0 && headBytes + bytes.length <= TRANSCRIPT_HEAD_BYTES) {
|
|
327
|
+
headBytes += bytes.length;
|
|
328
|
+
append(bytes);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
tail.push(bytes);
|
|
332
|
+
tailBytes += bytes.length;
|
|
333
|
+
// Once anything has been dropped, `close` will write a truncation marker
|
|
334
|
+
// that is part of the file too, so the tail budget shrinks by its
|
|
335
|
+
// reservation — otherwise a full head plus a full tail plus the marker
|
|
336
|
+
// puts the file over TRANSCRIPT_FILE_MAX_BYTES.
|
|
337
|
+
const tailLimit = (): number =>
|
|
338
|
+
TRANSCRIPT_TAIL_BYTES - (bytesDropped > 0 ? TRUNCATION_MARKER_RESERVE : 0);
|
|
339
|
+
while (tailBytes > tailLimit() && tail.length > 0) {
|
|
340
|
+
const dropped = tail.shift()!;
|
|
341
|
+
tailBytes -= dropped.length;
|
|
342
|
+
bytesDropped += dropped.length;
|
|
343
|
+
framesDropped += 1;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
async close(): Promise<TranscriptFile | undefined> {
|
|
347
|
+
if (closed) return undefined;
|
|
348
|
+
closed = true;
|
|
349
|
+
if (bytesDropped > 0) {
|
|
350
|
+
append(Buffer.from(`${JSON.stringify({
|
|
351
|
+
type: 'relayflow.truncated', bytes_dropped: bytesDropped, frames_dropped: framesDropped,
|
|
352
|
+
})}\n`, 'utf8'));
|
|
353
|
+
}
|
|
354
|
+
for (const bytes of tail) append(bytes);
|
|
355
|
+
await pending;
|
|
356
|
+
try { await handle.close(); } catch { failed = true; }
|
|
357
|
+
if (failed) return undefined;
|
|
358
|
+
return {
|
|
359
|
+
path,
|
|
360
|
+
bytes_total: bytesTotal,
|
|
361
|
+
bytes_kept: headBytes + tailBytes,
|
|
362
|
+
frames_total: framesTotal,
|
|
363
|
+
frames_kept: framesTotal - framesDropped,
|
|
364
|
+
truncated: bytesDropped > 0,
|
|
365
|
+
sha256: hash.digest('hex'),
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Digest
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
const num = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
376
|
+
const str = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build the digest from the frames `decodeProviderResult` already parsed.
|
|
380
|
+
* Claude: `tool_use` blocks are paired with `tool_result` blocks by
|
|
381
|
+
* `tool_use_id`; `init` supplies model and version. Codex: `item.completed`
|
|
382
|
+
* items are counted by `item.type` only, so `tools.complete` is false.
|
|
383
|
+
* `failure` is filled only when the attempt did not succeed.
|
|
384
|
+
*/
|
|
385
|
+
export function buildTranscriptDigest(
|
|
386
|
+
frames: RecordValue[],
|
|
387
|
+
kind: 'claude' | 'codex',
|
|
388
|
+
outcome: { exit_code: number | null; stderr_tail: string },
|
|
389
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
390
|
+
): Pick<TranscriptDigest, 'result' | 'tools' | 'final_text' | 'final_text_truncated' | 'failure'> {
|
|
391
|
+
const redact = createRedactor(env);
|
|
392
|
+
const digest: ReturnType<typeof buildTranscriptDigest> = {};
|
|
393
|
+
const bounded = (text: string, max: number): { text: string; truncated: boolean } => boundedText(redact(text), max);
|
|
394
|
+
|
|
395
|
+
if (kind === 'claude') {
|
|
396
|
+
const init = frames.find(f => f.type === 'system' && f.subtype === 'init');
|
|
397
|
+
const terminal = [...frames].reverse().find(f => f.type === 'result');
|
|
398
|
+
const usage = record(terminal?.usage) ? terminal.usage : undefined;
|
|
399
|
+
const details = record(usage?.output_tokens_details) ? usage.output_tokens_details : undefined;
|
|
400
|
+
const model = label(str(init?.model) ?? frames.flatMap(f => f.type === 'assistant' && record(f.message) ? [str(f.message.model)] : []).find(Boolean));
|
|
401
|
+
const version = label(str(init?.claude_code_version));
|
|
402
|
+
const session = label(str(terminal?.session_id ?? init?.session_id));
|
|
403
|
+
const subtype = label(str(terminal?.subtype));
|
|
404
|
+
const stopReason = label(str(terminal?.stop_reason));
|
|
405
|
+
digest.result = {
|
|
406
|
+
provider: 'claude',
|
|
407
|
+
...(model === undefined ? {} : { model }),
|
|
408
|
+
...(version === undefined ? {} : { claude_code_version: version }),
|
|
409
|
+
...(session === undefined ? {} : { session_id: session }),
|
|
410
|
+
...(subtype === undefined ? {} : { subtype }),
|
|
411
|
+
...(typeof terminal?.is_error === 'boolean' ? { is_error: terminal.is_error } : {}),
|
|
412
|
+
...(stopReason === undefined ? {} : { stop_reason: stopReason }),
|
|
413
|
+
...(num(terminal?.num_turns) === undefined ? {} : { num_turns: num(terminal?.num_turns) }),
|
|
414
|
+
...(num(terminal?.duration_ms) === undefined ? {} : { duration_ms: num(terminal?.duration_ms) }),
|
|
415
|
+
...(num(terminal?.duration_api_ms) === undefined ? {} : { duration_api_ms: num(terminal?.duration_api_ms) }),
|
|
416
|
+
...(num(terminal?.total_cost_usd) === undefined ? {} : { total_cost_usd: num(terminal?.total_cost_usd) }),
|
|
417
|
+
...(usage === undefined ? {} : { usage: {
|
|
418
|
+
...(num(usage.input_tokens) === undefined ? {} : { input: num(usage.input_tokens) }),
|
|
419
|
+
...(num(usage.output_tokens) === undefined ? {} : { output: num(usage.output_tokens) }),
|
|
420
|
+
...(num(usage.cache_read_input_tokens) === undefined ? {} : { cache_read: num(usage.cache_read_input_tokens) }),
|
|
421
|
+
...(num(usage.cache_creation_input_tokens) === undefined ? {} : { cache_creation: num(usage.cache_creation_input_tokens) }),
|
|
422
|
+
...(num(details?.thinking_tokens) === undefined ? {} : { thinking: num(details?.thinking_tokens) }),
|
|
423
|
+
} }),
|
|
424
|
+
...(Array.isArray(terminal?.permission_denials) ? { permission_denials: terminal.permission_denials.length } : {}),
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const calls: Array<TranscriptToolCall & { id?: string }> = [];
|
|
428
|
+
const byId = new Map<string, TranscriptToolCall>();
|
|
429
|
+
let lastToolError: string | undefined;
|
|
430
|
+
for (const frame of frames) {
|
|
431
|
+
const content = record(frame.message) && Array.isArray(frame.message.content) ? frame.message.content : [];
|
|
432
|
+
for (const block of content) {
|
|
433
|
+
if (!record(block)) continue;
|
|
434
|
+
if (frame.type === 'assistant' && block.type === 'tool_use') {
|
|
435
|
+
const excerpt = redact(block.input === undefined ? '' : JSON.stringify(block.input));
|
|
436
|
+
const call: TranscriptToolCall & { id?: string } = {
|
|
437
|
+
seq: calls.length + 1, name: label(str(block.name)) ?? '?',
|
|
438
|
+
input_excerpt: excerpt.length > TOOL_INPUT_EXCERPT_MAX_CHARS ? `${excerpt.slice(0, TOOL_INPUT_EXCERPT_MAX_CHARS - 1)}…` : excerpt,
|
|
439
|
+
};
|
|
440
|
+
calls.push(call);
|
|
441
|
+
if (typeof block.id === 'string') byId.set(block.id, call);
|
|
442
|
+
} else if (frame.type === 'user' && block.type === 'tool_result') {
|
|
443
|
+
const call = typeof block.tool_use_id === 'string' ? byId.get(block.tool_use_id) : undefined;
|
|
444
|
+
const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
|
|
445
|
+
if (call !== undefined) {
|
|
446
|
+
call.result_bytes = Buffer.byteLength(text, 'utf8');
|
|
447
|
+
if (block.is_error === true) call.is_error = true;
|
|
448
|
+
}
|
|
449
|
+
if (block.is_error === true) lastToolError = text;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (calls.length > 0) {
|
|
454
|
+
const counts = new Map<string, { name: string; calls: number; errors: number }>();
|
|
455
|
+
for (const call of calls) {
|
|
456
|
+
const entry = counts.get(call.name) ?? { name: call.name, calls: 0, errors: 0 };
|
|
457
|
+
entry.calls += 1;
|
|
458
|
+
if (call.is_error === true) entry.errors += 1;
|
|
459
|
+
counts.set(call.name, entry);
|
|
460
|
+
}
|
|
461
|
+
const last = calls.slice(-TOOL_LAST_CALLS_MAX).map(({ id: _id, ...call }) => call);
|
|
462
|
+
digest.tools = {
|
|
463
|
+
counts: [...counts.values()].sort((a, b) => b.calls - a.calls).slice(0, TOOL_COUNTS_MAX),
|
|
464
|
+
last_calls: last, total_calls: calls.length, shown_calls: last.length, complete: true,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (typeof terminal?.result === 'string') {
|
|
468
|
+
const text = bounded(terminal.result, FINAL_TEXT_MAX_BYTES);
|
|
469
|
+
digest.final_text = text.text;
|
|
470
|
+
if (text.truncated) digest.final_text_truncated = true;
|
|
471
|
+
}
|
|
472
|
+
if (outcome.exit_code !== 0) {
|
|
473
|
+
const failedResult = terminal !== undefined
|
|
474
|
+
&& (terminal.is_error === true || (typeof terminal.subtype === 'string' && terminal.subtype !== 'success'));
|
|
475
|
+
if (failedResult) {
|
|
476
|
+
digest.failure = failure('result', typeof terminal.result === 'string' ? terminal.result : JSON.stringify(terminal), bounded);
|
|
477
|
+
} else if (lastToolError !== undefined) {
|
|
478
|
+
digest.failure = failure('tool_result', lastToolError, bounded);
|
|
479
|
+
} else if (outcome.stderr_tail.length > 0) {
|
|
480
|
+
digest.failure = failure('stderr', stderrExcerpt(outcome.stderr_tail, redact), bounded);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return digest;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Codex: only `turn.completed` and `item.completed{agent_message}` are
|
|
487
|
+
// consumed today; tool activity is counted by item type until a fixture
|
|
488
|
+
// pins the frame shapes (plan U3).
|
|
489
|
+
const terminal = [...frames].reverse().find(f => f.type === 'turn.completed');
|
|
490
|
+
const usage = record(terminal?.usage) ? terminal.usage : undefined;
|
|
491
|
+
digest.result = {
|
|
492
|
+
provider: 'codex',
|
|
493
|
+
...(usage === undefined ? {} : { usage: {
|
|
494
|
+
...(num(usage.input_tokens) === undefined ? {} : { input: num(usage.input_tokens) }),
|
|
495
|
+
...(num(usage.output_tokens) === undefined ? {} : { output: num(usage.output_tokens) }),
|
|
496
|
+
...(num(usage.cached_input_tokens) === undefined ? {} : { cache_read: num(usage.cached_input_tokens) }),
|
|
497
|
+
} }),
|
|
498
|
+
};
|
|
499
|
+
const counts = new Map<string, { name: string; calls: number; errors: number }>();
|
|
500
|
+
const texts: string[] = [];
|
|
501
|
+
for (const frame of frames) {
|
|
502
|
+
if (frame.type !== 'item.completed' || !record(frame.item)) continue;
|
|
503
|
+
const type = label(str(frame.item.type)) ?? '?';
|
|
504
|
+
if (type === 'agent_message') { if (typeof frame.item.text === 'string') texts.push(frame.item.text); continue; }
|
|
505
|
+
const entry = counts.get(type) ?? { name: type, calls: 0, errors: 0 };
|
|
506
|
+
entry.calls += 1;
|
|
507
|
+
counts.set(type, entry);
|
|
508
|
+
}
|
|
509
|
+
if (counts.size > 0) {
|
|
510
|
+
const total = [...counts.values()].reduce((sum, entry) => sum + entry.calls, 0);
|
|
511
|
+
digest.tools = {
|
|
512
|
+
counts: [...counts.values()].sort((a, b) => b.calls - a.calls).slice(0, TOOL_COUNTS_MAX),
|
|
513
|
+
last_calls: [], total_calls: total, shown_calls: 0, complete: false,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (texts.length > 0) {
|
|
517
|
+
const text = bounded(texts.join('\n'), FINAL_TEXT_MAX_BYTES);
|
|
518
|
+
digest.final_text = text.text;
|
|
519
|
+
if (text.truncated) digest.final_text_truncated = true;
|
|
520
|
+
}
|
|
521
|
+
if (outcome.exit_code !== 0 && outcome.stderr_tail.length > 0) {
|
|
522
|
+
digest.failure = failure('stderr', stderrExcerpt(outcome.stderr_tail, redact), bounded);
|
|
523
|
+
}
|
|
524
|
+
return digest;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Redact the whole stderr tail *before* cutting it. Cutting first splits a
|
|
529
|
+
* secret that straddles the boundary, and neither `replaceAll` on a known
|
|
530
|
+
* value nor a token-prefix pattern matches half a secret — so the fragment
|
|
531
|
+
* reached the journal. The decoder-refusal path already ordered it this way.
|
|
532
|
+
*/
|
|
533
|
+
function stderrExcerpt(stderrTail: string, redact: Redactor): string {
|
|
534
|
+
return utf8Tail(redact(stderrTail), FAILURE_EXCERPT_MAX_BYTES);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Provider-supplied identifiers are copied into `result` verbatim and no
|
|
539
|
+
* reduction step in `boundTranscriptDigest` trims them, so an oversized one
|
|
540
|
+
* (a 20 KiB `model`, say) would push the digest past the kernel's
|
|
541
|
+
* `trajectory_tail` bound and fail the completion outright. Bound them here.
|
|
542
|
+
*/
|
|
543
|
+
function label(value: string | undefined): string | undefined {
|
|
544
|
+
return value === undefined ? undefined : boundedText(value, DIGEST_LABEL_MAX_BYTES).text;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function failure(
|
|
548
|
+
kind: 'result' | 'tool_result' | 'stderr',
|
|
549
|
+
text: string,
|
|
550
|
+
bounded: (text: string, max: number) => { text: string; truncated: boolean },
|
|
551
|
+
): NonNullable<TranscriptDigest['failure']> {
|
|
552
|
+
const excerpt = bounded(text, FAILURE_EXCERPT_MAX_BYTES);
|
|
553
|
+
return { kind, excerpt: excerpt.text, ...(excerpt.truncated ? { truncated: true } : {}) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Serialized size of the digest as it will be journaled. */
|
|
557
|
+
export function digestBytes(digest: TranscriptDigest): number {
|
|
558
|
+
return Buffer.byteLength(JSON.stringify(digest), 'utf8');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Shrink the digest until it serializes to at most `TRANSCRIPT_DIGEST_MAX_BYTES`,
|
|
563
|
+
* in a fixed order: recent tool calls, artifact paths, final text, failure
|
|
564
|
+
* excerpt, tool counts. Each cut leaves its mark — `shown_calls` under
|
|
565
|
+
* `total_calls`, `artifacts.count` above `paths.length`, `final_text_truncated`,
|
|
566
|
+
* `failure.truncated` — so a reader knows the digest is not the whole account.
|
|
567
|
+
*/
|
|
568
|
+
export function boundTranscriptDigest(digest: TranscriptDigest): TranscriptDigest {
|
|
569
|
+
let out: TranscriptDigest = digest;
|
|
570
|
+
const steps: Array<(d: TranscriptDigest) => TranscriptDigest> = [
|
|
571
|
+
d => keepLastCalls(d, 10),
|
|
572
|
+
d => keepArtifactPaths(d, 10),
|
|
573
|
+
d => cutFinalText(d, 512),
|
|
574
|
+
d => cutFailure(d, 256),
|
|
575
|
+
d => keepLastCalls(d, 0),
|
|
576
|
+
d => keepArtifactPaths(d, 0),
|
|
577
|
+
d => d.tools === undefined ? d : { ...d, tools: { ...d.tools, counts: d.tools.counts.slice(0, 8) } },
|
|
578
|
+
d => cutFinalText(d, 0),
|
|
579
|
+
d => cutFailure(d, 0),
|
|
580
|
+
];
|
|
581
|
+
for (const step of steps) {
|
|
582
|
+
if (digestBytes(out) <= TRANSCRIPT_DIGEST_MAX_BYTES) return out;
|
|
583
|
+
out = step(out);
|
|
584
|
+
}
|
|
585
|
+
if (digestBytes(out) <= TRANSCRIPT_DIGEST_MAX_BYTES) return out;
|
|
586
|
+
// Last resort. Every step above trims a string this module produced; none of
|
|
587
|
+
// them touches a provider-supplied identifier, so a pathological `model`,
|
|
588
|
+
// `session_id` or set of tool names can still hold the digest over its cap —
|
|
589
|
+
// and the kernel refuses the whole completion when `trajectory_tail` is
|
|
590
|
+
// oversized (kernel/relayflowd/src/server.rs, step.complete). Falling back to
|
|
591
|
+
// fixed-size fields keeps the attempt recorded.
|
|
592
|
+
return coreDigest(out);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The digest reduced to fields whose size this module controls: numbers, the
|
|
597
|
+
* provider tag, a bounded path and a fixed-width hash. Nothing a provider can
|
|
598
|
+
* make arbitrarily long survives, so the result is bounded by construction.
|
|
599
|
+
*/
|
|
600
|
+
function coreDigest(d: TranscriptDigest): TranscriptDigest {
|
|
601
|
+
const core: TranscriptDigest = { core_only: true };
|
|
602
|
+
if (d.attempt !== undefined) core.attempt = d.attempt;
|
|
603
|
+
if (d.exit_code !== undefined) core.exit_code = d.exit_code;
|
|
604
|
+
if (d.file !== undefined) {
|
|
605
|
+
core.file = { ...d.file, path: utf8Head(d.file.path, DIGEST_LABEL_MAX_BYTES) };
|
|
606
|
+
}
|
|
607
|
+
if (d.result !== undefined) {
|
|
608
|
+
const { provider, usage, is_error, num_turns, duration_ms, duration_api_ms, total_cost_usd, permission_denials } = d.result;
|
|
609
|
+
core.result = {
|
|
610
|
+
provider,
|
|
611
|
+
...(is_error === undefined ? {} : { is_error }),
|
|
612
|
+
...(num_turns === undefined ? {} : { num_turns }),
|
|
613
|
+
...(duration_ms === undefined ? {} : { duration_ms }),
|
|
614
|
+
...(duration_api_ms === undefined ? {} : { duration_api_ms }),
|
|
615
|
+
...(total_cost_usd === undefined ? {} : { total_cost_usd }),
|
|
616
|
+
...(permission_denials === undefined ? {} : { permission_denials }),
|
|
617
|
+
...(usage === undefined ? {} : { usage }),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (d.tools !== undefined) {
|
|
621
|
+
core.tools = {
|
|
622
|
+
counts: [], last_calls: [], total_calls: d.tools.total_calls, shown_calls: 0, complete: d.tools.complete,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (d.artifacts !== undefined) core.artifacts = { count: d.artifacts.count, paths: [] };
|
|
626
|
+
if (d.final_text !== undefined || d.final_text_truncated === true) core.final_text_truncated = true;
|
|
627
|
+
if (d.failure !== undefined) core.failure = { kind: d.failure.kind, excerpt: '', truncated: true };
|
|
628
|
+
return core;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function keepLastCalls(d: TranscriptDigest, n: number): TranscriptDigest {
|
|
632
|
+
if (d.tools === undefined || d.tools.last_calls.length <= n) return d;
|
|
633
|
+
const last = n === 0 ? [] : d.tools.last_calls.slice(-n);
|
|
634
|
+
return { ...d, tools: { ...d.tools, last_calls: last, shown_calls: last.length } };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function keepArtifactPaths(d: TranscriptDigest, n: number): TranscriptDigest {
|
|
638
|
+
if (d.artifacts === undefined || d.artifacts.paths.length <= n) return d;
|
|
639
|
+
return { ...d, artifacts: { count: d.artifacts.count, paths: d.artifacts.paths.slice(0, n) } };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function cutFinalText(d: TranscriptDigest, maxBytes: number): TranscriptDigest {
|
|
643
|
+
if (d.final_text === undefined || Buffer.byteLength(d.final_text, 'utf8') <= maxBytes) return d;
|
|
644
|
+
if (maxBytes === 0) { const { final_text: _t, ...rest } = d; return { ...rest, final_text_truncated: true }; }
|
|
645
|
+
return { ...d, final_text: boundedText(d.final_text, maxBytes).text, final_text_truncated: true };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function cutFailure(d: TranscriptDigest, maxBytes: number): TranscriptDigest {
|
|
649
|
+
if (d.failure === undefined || Buffer.byteLength(d.failure.excerpt, 'utf8') <= maxBytes) return d;
|
|
650
|
+
return { ...d, failure: { ...d.failure, excerpt: boundedText(d.failure.excerpt, maxBytes).text, truncated: true } };
|
|
651
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HumanRecipient } from './human-to.js';
|
|
2
|
+
import type { StepFailedDetails } from './failure-kinds.js';
|
|
2
3
|
import type {
|
|
3
4
|
CompletionReason as ProtocolCompletionReason,
|
|
4
5
|
RunCompletionReason as ProtocolRunCompletionReason,
|
|
@@ -39,11 +40,27 @@ export type AuthoredFlowExecutionErrorCode =
|
|
|
39
40
|
| 'unsupported_verb';
|
|
40
41
|
|
|
41
42
|
export class AuthoredFlowExecutionError extends Error {
|
|
43
|
+
/** Set by the durable root driver after the child error crosses any IPC boundary. */
|
|
44
|
+
rootRunId?: string;
|
|
42
45
|
constructor(
|
|
43
46
|
readonly code: AuthoredFlowExecutionErrorCode,
|
|
44
47
|
message: string,
|
|
45
48
|
readonly completionReason?: ProtocolCompletionReason | ProtocolRunCompletionReason,
|
|
49
|
+
/**
|
|
50
|
+
* The CHILD run whose journal holds the evidence — an authored operation
|
|
51
|
+
* is its own single-step kernel run. Unchanged by `details`: the root run
|
|
52
|
+
* identity and the failing child identity stay distinct fields.
|
|
53
|
+
*/
|
|
46
54
|
readonly runId?: string,
|
|
55
|
+
/**
|
|
56
|
+
* What the failed step left in its journal, already extracted and bounded
|
|
57
|
+
* by `cli/step-failure.ts`. Carried structurally, not only rendered into
|
|
58
|
+
* `message`, so `authoredStepFailure` can lift it into the machine-readable
|
|
59
|
+
* `RunDiagnostic` (which already `extends StepFailedDetails`) and so every
|
|
60
|
+
* wrapper on the way out — the `lease_exceeded` remap, the authenticated
|
|
61
|
+
* Node-child error frame — can forward it instead of dropping it.
|
|
62
|
+
*/
|
|
63
|
+
readonly details?: StepFailedDetails,
|
|
47
64
|
) {
|
|
48
65
|
super(`${code}: ${message}`);
|
|
49
66
|
this.name = 'AuthoredFlowExecutionError';
|