@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.
Files changed (97) hide show
  1. package/dist/agent-transcript.d.ts +186 -0
  2. package/dist/agent-transcript.d.ts.map +1 -0
  3. package/dist/agent-transcript.js +585 -0
  4. package/dist/agent-transcript.js.map +1 -0
  5. package/dist/cli/run.js +2 -1
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/cli/status.d.ts +34 -0
  8. package/dist/cli/status.d.ts.map +1 -0
  9. package/dist/cli/status.js +307 -0
  10. package/dist/cli/status.js.map +1 -0
  11. package/dist/cli/step-failure.js +31 -7
  12. package/dist/cli/step-failure.js.map +1 -1
  13. package/dist/cli-commands.d.ts +13 -0
  14. package/dist/cli-commands.d.ts.map +1 -1
  15. package/dist/cli-commands.js +11 -0
  16. package/dist/cli-commands.js.map +1 -1
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +9 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/failure-kinds.d.ts +15 -2
  22. package/dist/failure-kinds.d.ts.map +1 -1
  23. package/dist/failure-kinds.js +8 -0
  24. package/dist/failure-kinds.js.map +1 -1
  25. package/dist/journal-reader.d.ts +1 -1
  26. package/dist/journal-reader.d.ts.map +1 -1
  27. package/dist/journal-reader.js +3 -1
  28. package/dist/journal-reader.js.map +1 -1
  29. package/dist/llm-worker.d.ts.map +1 -1
  30. package/dist/llm-worker.js +12 -1
  31. package/dist/llm-worker.js.map +1 -1
  32. package/dist/permissions-preflight.d.ts +22 -0
  33. package/dist/permissions-preflight.d.ts.map +1 -0
  34. package/dist/permissions-preflight.js +49 -0
  35. package/dist/permissions-preflight.js.map +1 -0
  36. package/dist/preflight.d.ts.map +1 -1
  37. package/dist/preflight.js +6 -0
  38. package/dist/preflight.js.map +1 -1
  39. package/dist/pty-sidechannel.d.ts +7 -0
  40. package/dist/pty-sidechannel.d.ts.map +1 -1
  41. package/dist/pty-sidechannel.js.map +1 -1
  42. package/dist/redact.d.ts +16 -0
  43. package/dist/redact.d.ts.map +1 -0
  44. package/dist/redact.js +104 -0
  45. package/dist/redact.js.map +1 -0
  46. package/dist/run-state.d.ts +124 -0
  47. package/dist/run-state.d.ts.map +1 -0
  48. package/dist/run-state.js +368 -0
  49. package/dist/run-state.js.map +1 -0
  50. package/dist/spec.d.ts +16 -1
  51. package/dist/spec.d.ts.map +1 -1
  52. package/dist/spec.js.map +1 -1
  53. package/dist/step-env.d.ts +25 -0
  54. package/dist/step-env.d.ts.map +1 -0
  55. package/dist/step-env.js +34 -0
  56. package/dist/step-env.js.map +1 -0
  57. package/dist/transcript-tail.d.ts +54 -0
  58. package/dist/transcript-tail.d.ts.map +1 -0
  59. package/dist/transcript-tail.js +176 -0
  60. package/dist/transcript-tail.js.map +1 -0
  61. package/dist/worker-cli.d.ts +10 -0
  62. package/dist/worker-cli.d.ts.map +1 -1
  63. package/dist/worker-cli.js +158 -16
  64. package/dist/worker-cli.js.map +1 -1
  65. package/dist/worker-usage.d.ts +7 -2
  66. package/dist/worker-usage.d.ts.map +1 -1
  67. package/dist/worker-usage.js +21 -3
  68. package/dist/worker-usage.js.map +1 -1
  69. package/dist/worker.d.ts.map +1 -1
  70. package/dist/worker.js +31 -6
  71. package/dist/worker.js.map +1 -1
  72. package/dist/wrapper-runtime.d.ts +7 -1
  73. package/dist/wrapper-runtime.d.ts.map +1 -1
  74. package/dist/wrapper-runtime.js +7 -1
  75. package/dist/wrapper-runtime.js.map +1 -1
  76. package/package.json +2 -2
  77. package/src/agent-transcript.ts +651 -0
  78. package/src/cli/run.ts +2 -1
  79. package/src/cli/status.ts +329 -0
  80. package/src/cli/step-failure.ts +32 -7
  81. package/src/cli-commands.ts +11 -0
  82. package/src/cli.ts +8 -0
  83. package/src/failure-kinds.ts +15 -1
  84. package/src/journal-reader.ts +4 -2
  85. package/src/llm-worker.ts +12 -1
  86. package/src/permissions-preflight.ts +53 -0
  87. package/src/preflight.ts +6 -0
  88. package/src/pty-sidechannel.ts +7 -0
  89. package/src/redact.ts +106 -0
  90. package/src/run-state.ts +468 -0
  91. package/src/spec.ts +16 -1
  92. package/src/step-env.ts +47 -0
  93. package/src/transcript-tail.ts +193 -0
  94. package/src/worker-cli.ts +157 -17
  95. package/src/worker-usage.ts +26 -3
  96. package/src/worker.ts +32 -6
  97. 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
+ }
@@ -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
- /** Permission model for an agent step (gate 8). `readonly` provably cannot write. */
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[];
@@ -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
+ }