@relayflows/sdk 2.0.21 → 2.0.22
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-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/cli/run.js +2 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/status.d.ts +34 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +307 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/step-failure.js +31 -7
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +13 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +11 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/failure-kinds.d.ts +15 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +8 -0
- package/dist/failure-kinds.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.map +1 -1
- package/dist/preflight.js +6 -0
- package/dist/preflight.js.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 +16 -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 +158 -16
- 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.map +1 -1
- package/dist/worker.js +31 -6
- 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/package.json +2 -2
- package/src/agent-transcript.ts +651 -0
- package/src/cli/run.ts +2 -1
- package/src/cli/status.ts +329 -0
- package/src/cli/step-failure.ts +32 -7
- package/src/cli-commands.ts +11 -0
- package/src/cli.ts +8 -0
- package/src/failure-kinds.ts +15 -1
- 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 +6 -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 +16 -1
- package/src/step-env.ts +47 -0
- package/src/transcript-tail.ts +193 -0
- package/src/worker-cli.ts +157 -17
- package/src/worker-usage.ts +26 -3
- package/src/worker.ts +32 -6
- package/src/wrapper-runtime.ts +7 -1
package/src/redact.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Redaction for free text an agent-facing surface may print.
|
|
2
|
+
//
|
|
3
|
+
// `flows status` renders gate `detail`, transcript tails and error messages
|
|
4
|
+
// to a process that may be an agent, in a sandbox whose stdout is captured.
|
|
5
|
+
// A direct agent inherits the worker's whole environment (worker-cli.ts), so
|
|
6
|
+
// anything the operator exported can be echoed back into that text. This
|
|
7
|
+
// module is the one place that decides what never reaches the page.
|
|
8
|
+
//
|
|
9
|
+
// Applied to free text only. Identifiers — run ids, step ids, hashes and env
|
|
10
|
+
// NAMES — are never rewritten, so the view stays greppable.
|
|
11
|
+
|
|
12
|
+
/** Env names whose values are treated as secret when they are long enough. */
|
|
13
|
+
const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
|
|
14
|
+
|
|
15
|
+
/** Shorter values are flags and modes (`AUTH_MODE=off`), not material. */
|
|
16
|
+
const MIN_SECRET_LENGTH = 8;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Token shapes replaced wholesale. The first is the relay token family
|
|
20
|
+
* `redactRelayError` in worker-cli.ts already scrubbed; the rest are the
|
|
21
|
+
* bearer, header and vendor shapes a gate render can carry.
|
|
22
|
+
*/
|
|
23
|
+
const TOKEN_PATTERNS: readonly RegExp[] = [
|
|
24
|
+
/\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]+/g,
|
|
25
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g,
|
|
26
|
+
/\bghp_[A-Za-z0-9]{20,}/g,
|
|
27
|
+
/\bxox[abp]-[A-Za-z0-9-]+/g,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Header and assignment shapes: keep the name (and an auth scheme), replace
|
|
32
|
+
* the value. A value already replaced by an earlier pass is left as it is,
|
|
33
|
+
* so the reader still sees which env name it came from.
|
|
34
|
+
*/
|
|
35
|
+
const NAMED_VALUE_PATTERNS: readonly RegExp[] = [
|
|
36
|
+
/\b(authorization:\s*(?:\w+\s+)?)(?!\[redacted)\S+/gi,
|
|
37
|
+
/\b(Bearer\s+)(?!\[redacted)\S+/g,
|
|
38
|
+
/\b(x-callback-token:\s*)(?!\[redacted)\S+/gi,
|
|
39
|
+
/\b(x-nightcto-evidence-token:\s*)(?!\[redacted)\S+/gi,
|
|
40
|
+
/\b([A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD)=)(?!\[redacted)\S+/gi,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Credential fields in serialized JSON. A gate detail or a transcript line
|
|
45
|
+
* often carries a request or config body, where the value is an opaque string
|
|
46
|
+
* that matches no vendor token shape and — for anything this process did not
|
|
47
|
+
* export — no environment value either: `{"authToken":"opaque-secret"}` went
|
|
48
|
+
* through untouched. The field name is kept, so the reader knows what was cut.
|
|
49
|
+
*
|
|
50
|
+
* Quoted string values only — including escaped ones: a serialized private
|
|
51
|
+
* key or nested JSON reaches these fields as `\n` and `\"`, and a value
|
|
52
|
+
* class that stopped at the first backslash left every one of them intact.
|
|
53
|
+
* Only where the field NAME ends in a credential noun. Containing one is not enough: `monkey` and `keyboard` are
|
|
54
|
+
* ordinary fields, so the name is split on separators and camelCase humps and
|
|
55
|
+
* the last word decides.
|
|
56
|
+
*/
|
|
57
|
+
const JSON_STRING_FIELD = /("([A-Za-z0-9_.\-]{1,64})"\s*:\s*")(?!\[redacted)((?:[^"\\]|\\.){4,})(")/g;
|
|
58
|
+
const CREDENTIAL_NOUN = /^(?:token|secret|key|password|passwd|credential|cookie)s?$/i;
|
|
59
|
+
|
|
60
|
+
function isCredentialName(name: string): boolean {
|
|
61
|
+
if (name.toLowerCase() === 'authorization') return true;
|
|
62
|
+
const words = name.split(/[_\-.]+/).flatMap((word) => word.split(/(?<=[a-z0-9])(?=[A-Z])/u));
|
|
63
|
+
const last = words.at(-1);
|
|
64
|
+
return last !== undefined && CREDENTIAL_NOUN.test(last);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Secret env values, longest first so a value that contains another is replaced whole. */
|
|
68
|
+
function secretEnvValues(env: NodeJS.ProcessEnv): Array<[name: string, value: string]> {
|
|
69
|
+
const secrets: Array<[string, string]> = [];
|
|
70
|
+
for (const [name, value] of Object.entries(env)) {
|
|
71
|
+
if (typeof value !== 'string' || value.length < MIN_SECRET_LENGTH) continue;
|
|
72
|
+
if (SECRET_NAME.test(name)) secrets.push([name, value]);
|
|
73
|
+
}
|
|
74
|
+
return secrets.sort((a, b) => b[1].length - a[1].length);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Scrub secret env values and known token shapes out of `text`.
|
|
79
|
+
*
|
|
80
|
+
* An env value is replaced by `[redacted:<NAME>]` so the reader learns which
|
|
81
|
+
* variable leaked without learning its value. Token shapes are replaced by
|
|
82
|
+
* `[redacted]`; header and `NAME=value` shapes keep their name.
|
|
83
|
+
*/
|
|
84
|
+
export function redact(text: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
85
|
+
let out = text;
|
|
86
|
+
for (const [name, value] of secretEnvValues(env)) out = out.replaceAll(value, `[redacted:${name}]`);
|
|
87
|
+
for (const pattern of TOKEN_PATTERNS) out = out.replace(pattern, '[redacted]');
|
|
88
|
+
for (const pattern of NAMED_VALUE_PATTERNS) out = out.replace(pattern, '$1[redacted]');
|
|
89
|
+
out = out.replace(JSON_STRING_FIELD, (match, open: string, name: string, _value: string, close: string) =>
|
|
90
|
+
isCredentialName(name) ? `${open}[redacted]${close}` : match);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The relay-transport error scrub `worker-cli.ts` has always applied: the two
|
|
96
|
+
* relay credential names by exact match, at any length, spelled `[redacted]`.
|
|
97
|
+
* Everything `redact` adds is applied after, so the historical output for
|
|
98
|
+
* those two names and the token pattern is unchanged.
|
|
99
|
+
*/
|
|
100
|
+
export function redactRelayError(message: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
101
|
+
for (const key of ['RELAY_AGENT_TOKEN', 'RELAY_API_KEY']) {
|
|
102
|
+
const secret = env[key];
|
|
103
|
+
if (secret) message = message.replaceAll(secret, '[redacted]');
|
|
104
|
+
}
|
|
105
|
+
return redact(message, env);
|
|
106
|
+
}
|
package/src/run-state.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Pure fold of a run's journal into the view `flows status` renders.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the kernel's `RunState::fold` (kernel/relayflowd-core/src/state.rs)
|
|
4
|
+
// transition for transition, but keeps only what a status view needs: step
|
|
5
|
+
// state, attempt, lease, backoff, wait, the last completion's verdict, and
|
|
6
|
+
// spend. Nothing here reads a prompt, an input binding, an output body, a
|
|
7
|
+
// wake context or a pin — the model has no field for them, so their absence
|
|
8
|
+
// from the rendered view is structural rather than a matter of care.
|
|
9
|
+
//
|
|
10
|
+
// No I/O, no clock: the caller passes `now_ms`, so a test folds a hand-built
|
|
11
|
+
// journal against a fixed instant and asserts exact elapsed and overdue values.
|
|
12
|
+
|
|
13
|
+
import type { JournalEvent } from './journal-reader.js';
|
|
14
|
+
|
|
15
|
+
export type RunStatus = 'running' | 'parked' | 'completed' | 'failed' | 'cancelling' | 'cancelled';
|
|
16
|
+
|
|
17
|
+
/** `StepState` variant names from state.rs, snake_case. */
|
|
18
|
+
export type StepStateName = 'pending' | 'runnable' | 'running' | 'backoff' | 'waiting' | 'needs_human' | 'done';
|
|
19
|
+
|
|
20
|
+
export interface Spend {
|
|
21
|
+
tokens_in: number;
|
|
22
|
+
tokens_out: number;
|
|
23
|
+
/** Decimal string, as the journal stores it; never a float. */
|
|
24
|
+
dollars: string;
|
|
25
|
+
dollars_unmetered: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StepCounts {
|
|
29
|
+
total: number;
|
|
30
|
+
done: number;
|
|
31
|
+
running: number;
|
|
32
|
+
/** `pending` and `runnable` both: neither has started an attempt. */
|
|
33
|
+
pending: number;
|
|
34
|
+
backoff: number;
|
|
35
|
+
waiting: number;
|
|
36
|
+
needs_human: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The part of the attempt's journaled transcript digest (flows#491, written to
|
|
41
|
+
* `trajectory_tail.transcript`) that a status view shows. Not the whole digest:
|
|
42
|
+
* the tool roster and per-call excerpts are the transcript file's job, and this
|
|
43
|
+
* view has to stay small and greppable. Every string here was redacted when the
|
|
44
|
+
* digest was built and is redacted again on the way out.
|
|
45
|
+
*/
|
|
46
|
+
export interface AttemptTranscript {
|
|
47
|
+
/** Where the full per-attempt transcript is, so a reader can go and open it. */
|
|
48
|
+
path: string | null;
|
|
49
|
+
bytes: number | null;
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
model: string | null;
|
|
52
|
+
num_turns: number | null;
|
|
53
|
+
total_cost_usd: number | null;
|
|
54
|
+
tool_calls: number | null;
|
|
55
|
+
/** Why the attempt failed, as the digest recorded it; already bounded. */
|
|
56
|
+
failure: { kind: string; excerpt: string } | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LastAttempt {
|
|
60
|
+
attempt: number;
|
|
61
|
+
completion_reason: string;
|
|
62
|
+
disposition: string;
|
|
63
|
+
verification: { gate: string; verdict: string; detail: string } | null;
|
|
64
|
+
human_intervention: boolean;
|
|
65
|
+
/** Count only; the refs carry surface paths and idempotency keys. */
|
|
66
|
+
effects: number;
|
|
67
|
+
ended_at_ms: number;
|
|
68
|
+
/** Null when the attempt journaled no digest — an llm step, or a pre-#491 run. */
|
|
69
|
+
transcript: AttemptTranscript | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StepView {
|
|
73
|
+
id: string;
|
|
74
|
+
type: string;
|
|
75
|
+
state: StepStateName;
|
|
76
|
+
attempt: number;
|
|
77
|
+
max_iterations: number | null;
|
|
78
|
+
started_at_ms: number | null;
|
|
79
|
+
elapsed_ms: number | null;
|
|
80
|
+
lease: { deadline_ms: number; overdue_ms: number } | null;
|
|
81
|
+
backoff_until_ms: number | null;
|
|
82
|
+
wait: { wait_id: string; kind: 'human' | 'event' | 'timer'; timeout_at_ms: number | null } | null;
|
|
83
|
+
last_attempt: LastAttempt | null;
|
|
84
|
+
/**
|
|
85
|
+
* Why this step finished, once it is `done`. Separate from `last_attempt`
|
|
86
|
+
* because an epoch summary carries `steps_done[id].completionReason`
|
|
87
|
+
* (kernel `entry.rs` `StepDoneSummary`) but no attempt record — so after a
|
|
88
|
+
* compaction this is the only place the fact survives. Dependency readiness
|
|
89
|
+
* and the failure glyph both read this, never `last_attempt`.
|
|
90
|
+
*/
|
|
91
|
+
completion_reason: string | null;
|
|
92
|
+
/** Paths the worker journaled for the last attempt, when its output carried them. */
|
|
93
|
+
artifacts: { paths: string[]; journaled: boolean };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface RunView {
|
|
97
|
+
run_id: string;
|
|
98
|
+
name: string;
|
|
99
|
+
status: RunStatus;
|
|
100
|
+
completion_reason: string | null;
|
|
101
|
+
spawned_at_ms: number;
|
|
102
|
+
now_ms: number;
|
|
103
|
+
spend: Spend;
|
|
104
|
+
counts: StepCounts;
|
|
105
|
+
steps: StepView[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class RunStateError extends Error {
|
|
109
|
+
constructor(message: string) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = 'RunStateError';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Payload = Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
function payloadOf(event: JournalEvent): Payload {
|
|
118
|
+
return event.payload !== null && typeof event.payload === 'object' && !Array.isArray(event.payload)
|
|
119
|
+
? event.payload as Payload : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function integer(value: unknown): number | null {
|
|
123
|
+
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function text(value: unknown, fallback = ''): string {
|
|
127
|
+
return typeof value === 'string' ? value : fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sum two decimal dollar strings exactly, at whatever scale they carry — the
|
|
132
|
+
* kernel's budget fold (`relayflowd-core/src/state/budget.rs`) aligns every
|
|
133
|
+
* fractional digit before adding, and it accepts arbitrary precision. Scaling
|
|
134
|
+
* to a fixed six fractional digits dropped a charge of `0.0000001` to zero, so
|
|
135
|
+
* `flows status` underreported spend the kernel had totalled exactly.
|
|
136
|
+
*
|
|
137
|
+
* A value that is not a decimal number is left out of the sum rather than
|
|
138
|
+
* counted as zero, and the caller is told by `malformed`.
|
|
139
|
+
*/
|
|
140
|
+
export function addDollars(left: string, right: string): string {
|
|
141
|
+
const parsed = [left, right].map((value) => /^\d+(?:\.\d+)?$/.test(value) ? value : null);
|
|
142
|
+
const usable = parsed.filter((value): value is string => value !== null);
|
|
143
|
+
if (usable.length === 0) return '0';
|
|
144
|
+
if (usable.length === 1) return normalizeDollars(usable[0]!);
|
|
145
|
+
const scale = Math.max(...usable.map((value) => (value.split('.')[1] ?? '').length));
|
|
146
|
+
const scaled = usable.map((value) => {
|
|
147
|
+
const [whole, fraction = ''] = value.split('.');
|
|
148
|
+
return BigInt(`${whole}${fraction.padEnd(scale, '0')}`);
|
|
149
|
+
});
|
|
150
|
+
return fromScaled(scaled[0]! + scaled[1]!, scale);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `12.3400` -> `12.34`, `0.000` -> `0`; the journal's own spelling otherwise. */
|
|
154
|
+
function normalizeDollars(value: string): string {
|
|
155
|
+
const [whole, fraction = ''] = value.split('.');
|
|
156
|
+
return fromScaled(BigInt(`${whole}${fraction}`), fraction.length);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function fromScaled(total: bigint, scale: number): string {
|
|
160
|
+
if (scale === 0) return String(total);
|
|
161
|
+
const digits = String(total).padStart(scale + 1, '0');
|
|
162
|
+
const whole = digits.slice(0, digits.length - scale);
|
|
163
|
+
const fraction = digits.slice(digits.length - scale).replace(/0+$/, '');
|
|
164
|
+
return fraction.length === 0 ? whole : `${whole}.${fraction}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reduce `trajectory_tail.transcript` (the digest flows#491 journals) to the
|
|
169
|
+
* handful of facts a status view shows. Every field is taken defensively: the
|
|
170
|
+
* journal is evidence written by a worker that may predate this reader, so a
|
|
171
|
+
* missing or wrong-typed field becomes null rather than throwing.
|
|
172
|
+
*/
|
|
173
|
+
function attemptTranscript(trajectoryTail: unknown): AttemptTranscript | null {
|
|
174
|
+
// Arrays are objects too, and a malformed digest is exactly the shape that
|
|
175
|
+
// would otherwise reach the view as `{ kind: 'unknown', excerpt: '' }`.
|
|
176
|
+
const object = (value: unknown): Payload | null =>
|
|
177
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Payload : null;
|
|
178
|
+
const digest = object(object(trajectoryTail)?.['transcript']);
|
|
179
|
+
if (digest === null) return null;
|
|
180
|
+
const file = object(digest['file']) ?? {};
|
|
181
|
+
const result = object(digest['result']) ?? {};
|
|
182
|
+
const tools = object(digest['tools']) ?? {};
|
|
183
|
+
const failure = object(digest['failure']);
|
|
184
|
+
return {
|
|
185
|
+
path: typeof file['path'] === 'string' ? file['path'] : null,
|
|
186
|
+
bytes: integer(file['bytes_kept']),
|
|
187
|
+
truncated: file['truncated'] === true,
|
|
188
|
+
model: typeof result['model'] === 'string' ? result['model'] : null,
|
|
189
|
+
num_turns: integer(result['num_turns']),
|
|
190
|
+
total_cost_usd: typeof result['total_cost_usd'] === 'number' && Number.isFinite(result['total_cost_usd'])
|
|
191
|
+
? result['total_cost_usd'] : null,
|
|
192
|
+
tool_calls: integer(tools['total_calls']),
|
|
193
|
+
failure: failure === null ? null : {
|
|
194
|
+
kind: text(failure['kind'], 'unknown'),
|
|
195
|
+
excerpt: text(failure['excerpt']),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function addSpend(total: Spend, charge: unknown): Spend {
|
|
201
|
+
const budget = charge !== null && typeof charge === 'object' ? charge as Payload : {};
|
|
202
|
+
return {
|
|
203
|
+
tokens_in: total.tokens_in + (integer(budget['tokens_in']) ?? 0),
|
|
204
|
+
tokens_out: total.tokens_out + (integer(budget['tokens_out']) ?? 0),
|
|
205
|
+
dollars: addDollars(total.dollars, text(budget['dollars'], '0')),
|
|
206
|
+
dollars_unmetered: total.dollars_unmetered || budget['dollars_unmetered'] === true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ZERO_SPEND: Spend = { tokens_in: 0, tokens_out: 0, dollars: '0', dollars_unmetered: false };
|
|
211
|
+
|
|
212
|
+
interface StepFold {
|
|
213
|
+
view: StepView;
|
|
214
|
+
depends_on: string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function freshStep(id: string, type: string, maxIterations: number | null, dependsOn: string[]): StepFold {
|
|
218
|
+
return {
|
|
219
|
+
depends_on: dependsOn,
|
|
220
|
+
view: {
|
|
221
|
+
id, type, state: 'pending', attempt: 0, max_iterations: maxIterations,
|
|
222
|
+
started_at_ms: null, elapsed_ms: null, lease: null, backoff_until_ms: null, wait: null,
|
|
223
|
+
last_attempt: null, completion_reason: null, artifacts: { paths: [], journaled: false },
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Leave the transient facts of one state behind when entering another. */
|
|
229
|
+
function enter(view: StepView, state: StepStateName): void {
|
|
230
|
+
view.state = state;
|
|
231
|
+
view.lease = null;
|
|
232
|
+
view.backoff_until_ms = null;
|
|
233
|
+
view.wait = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function artifactsOf(output: unknown): { paths: string[]; journaled: boolean } {
|
|
237
|
+
// worker.ts journals `artifacts` inside the CliResult wrapper only when the
|
|
238
|
+
// agent's stdout was not a JSON object; on the JSON path the author's
|
|
239
|
+
// object is the output and carries no such key.
|
|
240
|
+
if (output === null || typeof output !== 'object' || Array.isArray(output)) return { paths: [], journaled: false };
|
|
241
|
+
const paths = (output as Payload)['artifacts'];
|
|
242
|
+
if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) return { paths: [], journaled: false };
|
|
243
|
+
return { paths: [...paths as string[]], journaled: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fold journal events, in sequence order, into a {@link RunView} as of
|
|
248
|
+
* `now_ms`. Throws {@link RunStateError} on a journal the kernel would also
|
|
249
|
+
* refuse (no `run.spawned` first, an unknown step).
|
|
250
|
+
*/
|
|
251
|
+
export function foldRunState(events: readonly JournalEvent[], now_ms: number): RunView {
|
|
252
|
+
const first = events[0];
|
|
253
|
+
if (first === undefined || first.entry_type !== 'run.spawned') {
|
|
254
|
+
throw new RunStateError('Journal does not begin with run.spawned.');
|
|
255
|
+
}
|
|
256
|
+
const spawned = payloadOf(first);
|
|
257
|
+
const spec = spawned['spec'] !== null && typeof spawned['spec'] === 'object' ? spawned['spec'] as Payload : {};
|
|
258
|
+
const declared = Array.isArray(spec['steps']) ? spec['steps'] as unknown[] : [];
|
|
259
|
+
const steps = new Map<string, StepFold>();
|
|
260
|
+
for (const entry of declared) {
|
|
261
|
+
const step = entry !== null && typeof entry === 'object' ? entry as Payload : {};
|
|
262
|
+
const id = text(step['id']);
|
|
263
|
+
if (id.length === 0) throw new RunStateError('Spec step without an id.');
|
|
264
|
+
const dependsOn = Array.isArray(step['depends_on']) ? (step['depends_on'] as unknown[]).filter((d): d is string => typeof d === 'string') : [];
|
|
265
|
+
steps.set(id, freshStep(id, text(step['type'], 'unknown'), integer(step['max_iterations']), dependsOn));
|
|
266
|
+
}
|
|
267
|
+
const budget = spec['budget'] !== null && typeof spec['budget'] === 'object' ? spec['budget'] as Payload : {};
|
|
268
|
+
// An authored child run carries its parent's totals so ceilings hold across
|
|
269
|
+
// the family (state.rs `prior_spend`); the view reports the same running total.
|
|
270
|
+
let spend = addSpend(ZERO_SPEND, budget['prior_spend']);
|
|
271
|
+
let completion: string | null = null;
|
|
272
|
+
let cancelRequested = false;
|
|
273
|
+
|
|
274
|
+
const stepOf = (event: JournalEvent): StepView => {
|
|
275
|
+
if (event.step_id === null) throw new RunStateError(`Entry seq ${event.seq} (${event.entry_type}) names no step.`);
|
|
276
|
+
const step = steps.get(event.step_id);
|
|
277
|
+
if (step === undefined) throw new RunStateError(`Entry seq ${event.seq} names unknown step ${JSON.stringify(event.step_id)}.`);
|
|
278
|
+
return step.view;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
for (const event of events.slice(1)) {
|
|
282
|
+
const payload = payloadOf(event);
|
|
283
|
+
switch (event.entry_type) {
|
|
284
|
+
case 'step.attempt.started': {
|
|
285
|
+
const view = stepOf(event);
|
|
286
|
+
const attempt = event.attempt ?? view.attempt;
|
|
287
|
+
view.attempt = Math.max(view.attempt, attempt);
|
|
288
|
+
view.max_iterations = integer(payload['max_iterations']) ?? view.max_iterations;
|
|
289
|
+
view.started_at_ms = event.at_ms;
|
|
290
|
+
enter(view, 'running');
|
|
291
|
+
const deadline = integer(payload['lease_deadline_ms']) ?? event.at_ms;
|
|
292
|
+
view.lease = { deadline_ms: deadline, overdue_ms: 0 };
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'step.completed': {
|
|
296
|
+
const view = stepOf(event);
|
|
297
|
+
const attempt = event.attempt ?? view.attempt;
|
|
298
|
+
view.attempt = Math.max(view.attempt, attempt);
|
|
299
|
+
spend = addSpend(spend, payload['budget']);
|
|
300
|
+
const verification = payload['verification'] !== null && typeof payload['verification'] === 'object'
|
|
301
|
+
? payload['verification'] as Payload : undefined;
|
|
302
|
+
view.last_attempt = {
|
|
303
|
+
attempt,
|
|
304
|
+
completion_reason: text(payload['completionReason'], 'unknown'),
|
|
305
|
+
disposition: text(payload['disposition'], 'unknown'),
|
|
306
|
+
verification: verification === undefined ? null : {
|
|
307
|
+
gate: text(verification['gate']), verdict: text(verification['verdict']), detail: text(verification['detail']),
|
|
308
|
+
},
|
|
309
|
+
human_intervention: payload['human_intervention'] === true,
|
|
310
|
+
effects: Array.isArray(payload['effects']) ? payload['effects'].length : 0,
|
|
311
|
+
ended_at_ms: event.at_ms,
|
|
312
|
+
transcript: attemptTranscript(payload['trajectory_tail']),
|
|
313
|
+
};
|
|
314
|
+
view.artifacts = artifactsOf(payload['output']);
|
|
315
|
+
const disposition = payload['disposition'];
|
|
316
|
+
if (disposition === 'retry') {
|
|
317
|
+
enter(view, 'backoff');
|
|
318
|
+
view.backoff_until_ms = integer(payload['next_attempt_at_ms']) ?? event.at_ms;
|
|
319
|
+
} else if (disposition === 'park') {
|
|
320
|
+
enter(view, 'needs_human');
|
|
321
|
+
view.wait = { wait_id: `park-${view.id}-${attempt}`, kind: 'human', timeout_at_ms: null };
|
|
322
|
+
} else {
|
|
323
|
+
enter(view, 'done');
|
|
324
|
+
view.completion_reason = view.last_attempt.completion_reason;
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
case 'sleep.until': {
|
|
329
|
+
const view = stepOf(event);
|
|
330
|
+
enter(view, 'backoff');
|
|
331
|
+
view.backoff_until_ms = integer(payload['wake_at_ms']) ?? event.at_ms;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case 'wait.event': {
|
|
335
|
+
const view = stepOf(event);
|
|
336
|
+
enter(view, 'waiting');
|
|
337
|
+
view.wait = { wait_id: text(payload['wait_id']), kind: 'event', timeout_at_ms: integer(payload['timeout_at_ms']) };
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case 'wait.human': {
|
|
341
|
+
const view = stepOf(event);
|
|
342
|
+
enter(view, 'needs_human');
|
|
343
|
+
view.wait = { wait_id: text(payload['wait_id']), kind: 'human', timeout_at_ms: integer(payload['timeout_at_ms']) };
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case 'wait.completed': {
|
|
347
|
+
const view = stepOf(event);
|
|
348
|
+
if (payload['completionReason'] === 'canceled') {
|
|
349
|
+
enter(view, 'done');
|
|
350
|
+
view.last_attempt = view.last_attempt ?? {
|
|
351
|
+
attempt: view.attempt, completion_reason: 'canceled', disposition: 'step_done',
|
|
352
|
+
verification: null, human_intervention: false, effects: 0, ended_at_ms: event.at_ms,
|
|
353
|
+
transcript: null,
|
|
354
|
+
};
|
|
355
|
+
view.completion_reason = 'canceled';
|
|
356
|
+
} else {
|
|
357
|
+
enter(view, 'runnable');
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case 'epoch.summary': {
|
|
362
|
+
// A new epoch restarts every step (state.rs `apply_epoch`); the
|
|
363
|
+
// summary then restores the ones it carries forward.
|
|
364
|
+
for (const step of steps.values()) {
|
|
365
|
+
const { id, type, max_iterations } = step.view;
|
|
366
|
+
step.view = freshStep(id, type, max_iterations, step.depends_on).view;
|
|
367
|
+
}
|
|
368
|
+
spend = addSpend(ZERO_SPEND, payload['budget_spent']);
|
|
369
|
+
const done = payload['steps_done'] !== null && typeof payload['steps_done'] === 'object' ? payload['steps_done'] as Payload : {};
|
|
370
|
+
for (const [id, summary] of Object.entries(done)) {
|
|
371
|
+
const view = steps.get(id)?.view;
|
|
372
|
+
if (view === undefined) throw new RunStateError(`Epoch summary names unknown step ${JSON.stringify(id)}.`);
|
|
373
|
+
enter(view, 'done');
|
|
374
|
+
// `StepDoneSummary` (kernel entry.rs) carries `completionReason` and
|
|
375
|
+
// nothing else about the attempt. Keep the reason, which dependency
|
|
376
|
+
// readiness and the glyph need; invent no attempt record around it.
|
|
377
|
+
const facts = summary !== null && typeof summary === 'object' ? summary as Payload : {};
|
|
378
|
+
view.completion_reason = text(facts['completionReason'], 'unknown');
|
|
379
|
+
}
|
|
380
|
+
const open = payload['steps_open'] !== null && typeof payload['steps_open'] === 'object' ? payload['steps_open'] as Payload : {};
|
|
381
|
+
for (const [id, summary] of Object.entries(open)) {
|
|
382
|
+
const view = steps.get(id)?.view;
|
|
383
|
+
if (view === undefined) throw new RunStateError(`Epoch summary names unknown step ${JSON.stringify(id)}.`);
|
|
384
|
+
const facts = summary !== null && typeof summary === 'object' ? summary as Payload : {};
|
|
385
|
+
view.attempt = integer(facts['attempt']) ?? 0;
|
|
386
|
+
const state = facts['state'];
|
|
387
|
+
if (state === 'running') {
|
|
388
|
+
enter(view, 'running');
|
|
389
|
+
// The summary carries no original attempt start, and the kernel
|
|
390
|
+
// does not track one. Stamping the epoch's own time here made the
|
|
391
|
+
// live `attempt-<n>.*.tail` header look stale to
|
|
392
|
+
// `readTranscriptTail` and restarted elapsed at the compaction.
|
|
393
|
+
// Unknown is the honest value: elapsed goes unrendered and the
|
|
394
|
+
// tail's staleness check is skipped rather than failed.
|
|
395
|
+
view.started_at_ms = null;
|
|
396
|
+
view.lease = { deadline_ms: integer(facts['lease_deadline_ms']) ?? event.at_ms, overdue_ms: 0 };
|
|
397
|
+
} else if (state === 'backoff') {
|
|
398
|
+
enter(view, 'backoff');
|
|
399
|
+
view.backoff_until_ms = integer(facts['wake_at_ms']) ?? event.at_ms;
|
|
400
|
+
} else if (state === 'needs_human') {
|
|
401
|
+
enter(view, 'needs_human');
|
|
402
|
+
view.wait = { wait_id: `epoch-${integer(payload['epoch']) ?? 0}-${id}`, kind: 'human', timeout_at_ms: null };
|
|
403
|
+
} else if (state === 'waiting') {
|
|
404
|
+
enter(view, 'waiting');
|
|
405
|
+
view.wait = { wait_id: text(facts['wait_id']), kind: 'event', timeout_at_ms: null };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'run.completed':
|
|
411
|
+
completion = text(payload['completionReason'], 'unknown');
|
|
412
|
+
spend = addSpend(ZERO_SPEND, payload['budget_total']);
|
|
413
|
+
break;
|
|
414
|
+
case 'run.cancel.requested':
|
|
415
|
+
cancelRequested = true;
|
|
416
|
+
break;
|
|
417
|
+
default:
|
|
418
|
+
// Routing, subscriptions, channels, streams, effects, memory and
|
|
419
|
+
// segment records change nothing a status view shows.
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// state.rs `refresh_runnable`: a pending step whose dependencies all
|
|
425
|
+
// succeeded is runnable, whether or not a worker has picked it up.
|
|
426
|
+
for (const step of steps.values()) {
|
|
427
|
+
if (step.view.state !== 'pending') continue;
|
|
428
|
+
const ready = step.depends_on.every((dependency) => {
|
|
429
|
+
const upstream = steps.get(dependency)?.view;
|
|
430
|
+
return upstream?.state === 'done' && upstream.completion_reason === 'success';
|
|
431
|
+
});
|
|
432
|
+
if (ready) step.view.state = 'runnable';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const views: StepView[] = [];
|
|
436
|
+
const counts: StepCounts = { total: 0, done: 0, running: 0, pending: 0, backoff: 0, waiting: 0, needs_human: 0 };
|
|
437
|
+
for (const { view } of steps.values()) {
|
|
438
|
+
if (view.started_at_ms !== null) {
|
|
439
|
+
// `ended_at_ms` belongs to the attempt that ended. A retry, wait or
|
|
440
|
+
// sleep starts a newer attempt whose `started_at_ms` is later than that,
|
|
441
|
+
// and measuring to the older end clamped a live wait's elapsed to zero.
|
|
442
|
+
const ended = view.last_attempt?.ended_at_ms ?? null;
|
|
443
|
+
const end = ended === null || ended < view.started_at_ms ? now_ms : ended;
|
|
444
|
+
view.elapsed_ms = Math.max(0, (view.state === 'running' ? now_ms : end) - view.started_at_ms);
|
|
445
|
+
}
|
|
446
|
+
if (view.lease !== null) view.lease.overdue_ms = Math.max(0, now_ms - view.lease.deadline_ms);
|
|
447
|
+
counts.total += 1;
|
|
448
|
+
if (view.state === 'pending' || view.state === 'runnable') counts.pending += 1;
|
|
449
|
+
else counts[view.state] += 1;
|
|
450
|
+
views.push(view);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const status: RunStatus = completion === null
|
|
454
|
+
? cancelRequested ? 'cancelling' : counts.needs_human > 0 ? 'parked' : 'running'
|
|
455
|
+
: completion === 'success' ? 'completed' : completion === 'canceled' ? 'cancelled' : 'failed';
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
run_id: first.run_id,
|
|
459
|
+
name: text(spec['name'], first.run_id),
|
|
460
|
+
status,
|
|
461
|
+
completion_reason: completion,
|
|
462
|
+
spawned_at_ms: first.at_ms,
|
|
463
|
+
now_ms,
|
|
464
|
+
spend,
|
|
465
|
+
counts,
|
|
466
|
+
steps: views,
|
|
467
|
+
};
|
|
468
|
+
}
|
package/src/spec.ts
CHANGED
|
@@ -125,7 +125,22 @@ export interface AgentSurfaces {
|
|
|
125
125
|
external?: string[];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Per-step permission declarations for an agent step.
|
|
130
|
+
*
|
|
131
|
+
* Validated by gate 1 and recorded in the compiled step spec (as `file_globs`,
|
|
132
|
+
* `network_allowlist` and `access_preset`), but **not currently enforced**:
|
|
133
|
+
* nothing reads these fields to gate a file or network access. Enforcement is
|
|
134
|
+
* gate 8 (#442). `accessPreset: 'readonly'` does not prevent writes today.
|
|
135
|
+
*
|
|
136
|
+
* `flows check` emits a `permissions_unenforced` warning for every agent step
|
|
137
|
+
* that declares this block in a declarative spec — YAML/JSON, or a `FlowSpec`
|
|
138
|
+
* handed to the check API. A declaration inside an authored `.flow.ts` body is
|
|
139
|
+
* **not** inspected: checking TypeScript preflights the flow header without
|
|
140
|
+
* executing the body, so those steps do not exist yet and the check passes
|
|
141
|
+
* silently. A clean check is not evidence that no unenforced declaration is
|
|
142
|
+
* present; the paragraph above is, for every dialect.
|
|
143
|
+
*/
|
|
129
144
|
export interface PermissionsSpec {
|
|
130
145
|
fileGlobs?: string[];
|
|
131
146
|
networkAllowlist?: string[];
|
package/src/step-env.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The four names a worker exports so an agent can find its own run on disk.
|
|
2
|
+
//
|
|
3
|
+
// Set on every direct claude/codex attempt that has a data directory
|
|
4
|
+
// (worker-cli.ts), and read by `flows status` when it is invoked with no run
|
|
5
|
+
// id. Identifiers and a path only: with these an agent can open its journal
|
|
6
|
+
// and nothing else. Env rather than a file in cwd because a cwd file pollutes
|
|
7
|
+
// the workspace, goes stale after a crash and breaks when `spec.cwd` is
|
|
8
|
+
// shared; the environment dies with the attempt.
|
|
9
|
+
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const DATA_DIR_ENV = 'RELAYFLOW_DATA_DIR';
|
|
13
|
+
export const RUN_ID_ENV = 'RELAYFLOW_RUN_ID';
|
|
14
|
+
export const STEP_ID_ENV = 'RELAYFLOW_STEP_ID';
|
|
15
|
+
export const ATTEMPT_ENV = 'RELAYFLOW_ATTEMPT';
|
|
16
|
+
|
|
17
|
+
export const STEP_ENV_NAMES = [DATA_DIR_ENV, RUN_ID_ENV, STEP_ID_ENV, ATTEMPT_ENV] as const;
|
|
18
|
+
|
|
19
|
+
export interface StepIdentity {
|
|
20
|
+
dataDir: string;
|
|
21
|
+
runId: string;
|
|
22
|
+
stepId: string;
|
|
23
|
+
/**
|
|
24
|
+
* Optional for the same reason it is optional on `SidechannelContext`: a
|
|
25
|
+
* spawn that names no attempt has none to export, and an absent
|
|
26
|
+
* `RELAYFLOW_ATTEMPT` is the honest form. `flows status` needs the run and
|
|
27
|
+
* the step to resolve itself; the attempt only picks a transcript tail.
|
|
28
|
+
*/
|
|
29
|
+
attempt?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Overwrite the four names in `env` from `identity`, or remove them when there
|
|
34
|
+
* is none. Removal matters: a worker that is itself running inside a step
|
|
35
|
+
* inherits its parent's values, and an agent must not mistake them for its own.
|
|
36
|
+
* The data dir is made absolute because the agent's cwd may be `spec.cwd`.
|
|
37
|
+
*/
|
|
38
|
+
export function applyStepEnvironment(env: NodeJS.ProcessEnv, identity: StepIdentity | undefined): void {
|
|
39
|
+
for (const name of STEP_ENV_NAMES) delete env[name];
|
|
40
|
+
if (identity === undefined) return;
|
|
41
|
+
env[DATA_DIR_ENV] = resolve(identity.dataDir);
|
|
42
|
+
env[RUN_ID_ENV] = identity.runId;
|
|
43
|
+
env[STEP_ID_ENV] = identity.stepId;
|
|
44
|
+
// Left unset rather than set to "undefined": the four names are deleted
|
|
45
|
+
// above, so an agent sees no attempt instead of a bogus one.
|
|
46
|
+
if (identity.attempt !== undefined) env[ATTEMPT_ENV] = String(identity.attempt);
|
|
47
|
+
}
|