@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.
Files changed (247) hide show
  1. package/dist/agent-artifacts.d.ts +18 -9
  2. package/dist/agent-artifacts.d.ts.map +1 -1
  3. package/dist/agent-artifacts.js +27 -11
  4. package/dist/agent-artifacts.js.map +1 -1
  5. package/dist/agent-transcript.d.ts +186 -0
  6. package/dist/agent-transcript.d.ts.map +1 -0
  7. package/dist/agent-transcript.js +585 -0
  8. package/dist/agent-transcript.js.map +1 -0
  9. package/dist/authored-flow-error.d.ts +33 -1
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js +20 -1
  12. package/dist/authored-flow-error.js.map +1 -1
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +4 -1
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-node-entry.js +5 -1
  17. package/dist/authored-node-entry.js.map +1 -1
  18. package/dist/authored-node-runner.d.ts +11 -0
  19. package/dist/authored-node-runner.d.ts.map +1 -1
  20. package/dist/authored-node-runner.js +30 -1
  21. package/dist/authored-node-runner.js.map +1 -1
  22. package/dist/authored-root.js +3 -1
  23. package/dist/authored-root.js.map +1 -1
  24. package/dist/authored-source-authority.js +13 -0
  25. package/dist/authored-source-authority.js.map +1 -1
  26. package/dist/authored-step-index.d.ts +82 -0
  27. package/dist/authored-step-index.d.ts.map +1 -0
  28. package/dist/authored-step-index.js +120 -0
  29. package/dist/authored-step-index.js.map +1 -0
  30. package/dist/authored-step-output.d.ts +23 -2
  31. package/dist/authored-step-output.d.ts.map +1 -1
  32. package/dist/authored-step-output.js +47 -5
  33. package/dist/authored-step-output.js.map +1 -1
  34. package/dist/authored-worker-step.d.ts +10 -2
  35. package/dist/authored-worker-step.d.ts.map +1 -1
  36. package/dist/authored-worker-step.js +81 -7
  37. package/dist/authored-worker-step.js.map +1 -1
  38. package/dist/cli/check.d.ts.map +1 -1
  39. package/dist/cli/check.js +67 -12
  40. package/dist/cli/check.js.map +1 -1
  41. package/dist/cli/cloud-read.d.ts +31 -0
  42. package/dist/cli/cloud-read.d.ts.map +1 -0
  43. package/dist/cli/cloud-read.js +482 -0
  44. package/dist/cli/cloud-read.js.map +1 -0
  45. package/dist/cli/run.d.ts +2 -0
  46. package/dist/cli/run.d.ts.map +1 -1
  47. package/dist/cli/run.js +53 -24
  48. package/dist/cli/run.js.map +1 -1
  49. package/dist/cli/status.d.ts +43 -0
  50. package/dist/cli/status.d.ts.map +1 -0
  51. package/dist/cli/status.js +321 -0
  52. package/dist/cli/status.js.map +1 -0
  53. package/dist/cli/step-failure.d.ts +26 -0
  54. package/dist/cli/step-failure.d.ts.map +1 -1
  55. package/dist/cli/step-failure.js +99 -10
  56. package/dist/cli/step-failure.js.map +1 -1
  57. package/dist/cli-commands.d.ts +41 -0
  58. package/dist/cli-commands.d.ts.map +1 -1
  59. package/dist/cli-commands.js +33 -0
  60. package/dist/cli-commands.js.map +1 -1
  61. package/dist/cli.d.ts +4 -2
  62. package/dist/cli.d.ts.map +1 -1
  63. package/dist/cli.js +26 -0
  64. package/dist/cli.js.map +1 -1
  65. package/dist/cloud-http.d.ts +12 -0
  66. package/dist/cloud-http.d.ts.map +1 -1
  67. package/dist/cloud-http.js +14 -6
  68. package/dist/cloud-http.js.map +1 -1
  69. package/dist/cloud-read.d.ts +154 -0
  70. package/dist/cloud-read.d.ts.map +1 -0
  71. package/dist/cloud-read.js +373 -0
  72. package/dist/cloud-read.js.map +1 -0
  73. package/dist/cloud-transcript.d.ts +94 -0
  74. package/dist/cloud-transcript.d.ts.map +1 -0
  75. package/dist/cloud-transcript.js +342 -0
  76. package/dist/cloud-transcript.js.map +1 -0
  77. package/dist/communication/environment.d.ts +3 -0
  78. package/dist/communication/environment.d.ts.map +1 -0
  79. package/dist/communication/environment.js +34 -0
  80. package/dist/communication/environment.js.map +1 -0
  81. package/dist/communication/history.d.ts +6 -0
  82. package/dist/communication/history.d.ts.map +1 -0
  83. package/dist/communication/history.js +38 -0
  84. package/dist/communication/history.js.map +1 -0
  85. package/dist/communication/local.d.ts +6 -0
  86. package/dist/communication/local.d.ts.map +1 -0
  87. package/dist/communication/local.js +56 -0
  88. package/dist/communication/local.js.map +1 -0
  89. package/dist/communication/preflight.d.ts +6 -0
  90. package/dist/communication/preflight.d.ts.map +1 -0
  91. package/dist/communication/preflight.js +37 -0
  92. package/dist/communication/preflight.js.map +1 -0
  93. package/dist/communication/projection.d.ts +8 -0
  94. package/dist/communication/projection.d.ts.map +1 -0
  95. package/dist/communication/projection.js +23 -0
  96. package/dist/communication/projection.js.map +1 -0
  97. package/dist/communication/relay.d.ts +78 -0
  98. package/dist/communication/relay.d.ts.map +1 -0
  99. package/dist/communication/relay.js +89 -0
  100. package/dist/communication/relay.js.map +1 -0
  101. package/dist/communication/session.d.ts +24 -0
  102. package/dist/communication/session.d.ts.map +1 -0
  103. package/dist/communication/session.js +100 -0
  104. package/dist/communication/session.js.map +1 -0
  105. package/dist/communication/spec.d.ts +20 -0
  106. package/dist/communication/spec.d.ts.map +1 -0
  107. package/dist/communication/spec.js +113 -0
  108. package/dist/communication/spec.js.map +1 -0
  109. package/dist/communication/tool.d.ts +3 -0
  110. package/dist/communication/tool.d.ts.map +1 -0
  111. package/dist/communication/tool.js +23 -0
  112. package/dist/communication/tool.js.map +1 -0
  113. package/dist/communication/tools.d.ts +11 -0
  114. package/dist/communication/tools.d.ts.map +1 -0
  115. package/dist/communication/tools.js +65 -0
  116. package/dist/communication/tools.js.map +1 -0
  117. package/dist/communication/worker.d.ts +6 -0
  118. package/dist/communication/worker.d.ts.map +1 -0
  119. package/dist/communication/worker.js +120 -0
  120. package/dist/communication/worker.js.map +1 -0
  121. package/dist/compile.d.ts.map +1 -1
  122. package/dist/compile.js +8 -1
  123. package/dist/compile.js.map +1 -1
  124. package/dist/failure-kinds.d.ts +30 -2
  125. package/dist/failure-kinds.d.ts.map +1 -1
  126. package/dist/failure-kinds.js +9 -0
  127. package/dist/failure-kinds.js.map +1 -1
  128. package/dist/index.d.ts +3 -1
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +2 -0
  131. package/dist/index.js.map +1 -1
  132. package/dist/journal-client.d.ts +4 -1
  133. package/dist/journal-client.d.ts.map +1 -1
  134. package/dist/journal-client.js +11 -2
  135. package/dist/journal-client.js.map +1 -1
  136. package/dist/journal-reader.d.ts +1 -1
  137. package/dist/journal-reader.d.ts.map +1 -1
  138. package/dist/journal-reader.js +3 -1
  139. package/dist/journal-reader.js.map +1 -1
  140. package/dist/llm-worker.d.ts.map +1 -1
  141. package/dist/llm-worker.js +12 -1
  142. package/dist/llm-worker.js.map +1 -1
  143. package/dist/permissions-preflight.d.ts +22 -0
  144. package/dist/permissions-preflight.d.ts.map +1 -0
  145. package/dist/permissions-preflight.js +49 -0
  146. package/dist/permissions-preflight.js.map +1 -0
  147. package/dist/preflight.d.ts +13 -2
  148. package/dist/preflight.d.ts.map +1 -1
  149. package/dist/preflight.js +29 -6
  150. package/dist/preflight.js.map +1 -1
  151. package/dist/protocol.d.ts +39 -1
  152. package/dist/protocol.d.ts.map +1 -1
  153. package/dist/pty-sidechannel.d.ts +7 -0
  154. package/dist/pty-sidechannel.d.ts.map +1 -1
  155. package/dist/pty-sidechannel.js.map +1 -1
  156. package/dist/redact.d.ts +16 -0
  157. package/dist/redact.d.ts.map +1 -0
  158. package/dist/redact.js +104 -0
  159. package/dist/redact.js.map +1 -0
  160. package/dist/run-state.d.ts +124 -0
  161. package/dist/run-state.d.ts.map +1 -0
  162. package/dist/run-state.js +368 -0
  163. package/dist/run-state.js.map +1 -0
  164. package/dist/spec.d.ts +24 -1
  165. package/dist/spec.d.ts.map +1 -1
  166. package/dist/spec.js.map +1 -1
  167. package/dist/step-env.d.ts +25 -0
  168. package/dist/step-env.d.ts.map +1 -0
  169. package/dist/step-env.js +34 -0
  170. package/dist/step-env.js.map +1 -0
  171. package/dist/transcript-tail.d.ts +54 -0
  172. package/dist/transcript-tail.d.ts.map +1 -0
  173. package/dist/transcript-tail.js +176 -0
  174. package/dist/transcript-tail.js.map +1 -0
  175. package/dist/worker-cli.d.ts +10 -0
  176. package/dist/worker-cli.d.ts.map +1 -1
  177. package/dist/worker-cli.js +138 -17
  178. package/dist/worker-cli.js.map +1 -1
  179. package/dist/worker-usage.d.ts +7 -2
  180. package/dist/worker-usage.d.ts.map +1 -1
  181. package/dist/worker-usage.js +21 -3
  182. package/dist/worker-usage.js.map +1 -1
  183. package/dist/worker.d.ts +1 -0
  184. package/dist/worker.d.ts.map +1 -1
  185. package/dist/worker.js +41 -7
  186. package/dist/worker.js.map +1 -1
  187. package/dist/wrapper-runtime.d.ts +7 -1
  188. package/dist/wrapper-runtime.d.ts.map +1 -1
  189. package/dist/wrapper-runtime.js +7 -1
  190. package/dist/wrapper-runtime.js.map +1 -1
  191. package/dist/wrapper-session.d.ts +8 -0
  192. package/dist/wrapper-session.d.ts.map +1 -1
  193. package/dist/wrapper-session.js +156 -23
  194. package/dist/wrapper-session.js.map +1 -1
  195. package/package.json +14 -2
  196. package/src/agent-artifacts.ts +27 -11
  197. package/src/agent-transcript.ts +651 -0
  198. package/src/authored-flow-error.ts +17 -0
  199. package/src/authored-flow-executor.ts +4 -1
  200. package/src/authored-node-entry.ts +5 -1
  201. package/src/authored-node-runner.ts +33 -1
  202. package/src/authored-root.ts +2 -1
  203. package/src/authored-source-authority.ts +13 -0
  204. package/src/authored-step-index.ts +157 -0
  205. package/src/authored-step-output.ts +66 -14
  206. package/src/authored-worker-step.ts +88 -15
  207. package/src/cli/check.ts +69 -11
  208. package/src/cli/cloud-read.ts +518 -0
  209. package/src/cli/run.ts +43 -24
  210. package/src/cli/status.ts +349 -0
  211. package/src/cli/step-failure.ts +100 -10
  212. package/src/cli-commands.ts +33 -0
  213. package/src/cli.ts +27 -0
  214. package/src/cloud-http.ts +32 -6
  215. package/src/cloud-read.ts +515 -0
  216. package/src/cloud-transcript.ts +422 -0
  217. package/src/communication/environment.ts +33 -0
  218. package/src/communication/history.ts +38 -0
  219. package/src/communication/local.ts +41 -0
  220. package/src/communication/preflight.ts +34 -0
  221. package/src/communication/projection.ts +23 -0
  222. package/src/communication/relay.ts +102 -0
  223. package/src/communication/session.ts +84 -0
  224. package/src/communication/spec.ts +106 -0
  225. package/src/communication/tool.ts +22 -0
  226. package/src/communication/tools.ts +50 -0
  227. package/src/communication/worker.ts +95 -0
  228. package/src/compile.ts +4 -1
  229. package/src/failure-kinds.ts +31 -1
  230. package/src/index.ts +10 -1
  231. package/src/journal-client.ts +12 -2
  232. package/src/journal-reader.ts +4 -2
  233. package/src/llm-worker.ts +12 -1
  234. package/src/permissions-preflight.ts +53 -0
  235. package/src/preflight.ts +42 -7
  236. package/src/protocol.ts +15 -0
  237. package/src/pty-sidechannel.ts +7 -0
  238. package/src/redact.ts +106 -0
  239. package/src/run-state.ts +468 -0
  240. package/src/spec.ts +18 -1
  241. package/src/step-env.ts +47 -0
  242. package/src/transcript-tail.ts +193 -0
  243. package/src/worker-cli.ts +139 -18
  244. package/src/worker-usage.ts +26 -3
  245. package/src/worker.ts +42 -6
  246. package/src/wrapper-runtime.ts +7 -1
  247. package/src/wrapper-session.ts +159 -24
package/src/cli/check.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { communicationInstruction } from '../communication/spec.js';
2
+ import { checkCommunicationEnvironment } from '../communication/preflight.js';
3
+ import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
1
4
  import { accessSync, constants, readFileSync } from 'node:fs';
2
5
  import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
3
6
  import { spawnSync } from 'node:child_process';
@@ -159,6 +162,16 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
159
162
  config.directory,
160
163
  )
161
164
  : undefined;
165
+ if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
166
+ try { checkCommunicationEnvironment(flow); }
167
+ catch (error) {
168
+ result.ok = false;
169
+ result.diagnostics.push({ severity: 'refusal', kind: 'probe_failed',
170
+ message: error instanceof Error ? error.message : 'Communication environment could not be checked.' });
171
+ }
172
+ result.diagnostics.push({ severity: 'warning', kind: 'budget_unmetered',
173
+ message: 'Managed communication sessions do not report token or dollar usage. Budget ceilings cannot bound their spend; communication.timeoutMs bounds their duration.' });
174
+ }
162
175
  return {
163
176
  report: {
164
177
  ok: result.ok,
@@ -169,7 +182,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
169
182
  diagnostics: result.diagnostics,
170
183
  requirements: safeRequirements(authoring, config.cli),
171
184
  },
172
- ...(flow !== undefined ? { flow } : {}),
185
+ ...(result.ok && flow !== undefined ? { flow } : {}),
173
186
  };
174
187
  } catch (error) {
175
188
  const failure = error instanceof CheckFailure
@@ -375,7 +388,7 @@ function findConfig(start: string): string | undefined {
375
388
  function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightProbes {
376
389
  return {
377
390
  helper: helperReady,
378
- cli: (cli, source, model) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model),
391
+ cli: (cli, source, model, execution) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model, execution),
379
392
  executor: (trigger) => config.executors.includes(trigger.executor),
380
393
  // A deterministic step runs in the daemon's working directory — the
381
394
  // directory `flows run` was invoked from, or Cloud's code mount — not in
@@ -416,12 +429,22 @@ function probeCli(
416
429
  cli: string,
417
430
  directory: string,
418
431
  model?: string,
432
+ execution?: 'managed',
419
433
  ): CliProbeResult {
420
434
  const executable = resolveExecutable(cli, directory);
421
435
  if (executable === undefined) return { exists: false, authenticated: false };
422
436
  const kind = cliAdapterKind(executable);
437
+ // Relay owns interactive CLI launch/injection. Its generic PTY path is not
438
+ // the headless wrapper protocol; do not demand that protocol from Gemini,
439
+ // Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
440
+ if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
441
+ return { exists: true, supported: true, authenticated: 'unverified' };
442
+ }
443
+ const environment = execution === 'managed'
444
+ ? { ...brokerEnvironment(process.env), ...agentEnvironment(executable) } : process.env;
445
+ const probe = (invocation: CliInvocation) => runProbe(executable, directory, invocation, environment);
423
446
  const identification = adapterIdentification(kind);
424
- const identified = runProbe(executable, directory, identification.invocation);
447
+ const identified = probe(identification.invocation);
425
448
  if (
426
449
  identified.status !== 0
427
450
  || (identification.expectedStdout !== undefined
@@ -435,7 +458,7 @@ function probeCli(
435
458
  return {
436
459
  exists: true,
437
460
  supported: true,
438
- authenticated: runProbe(executable, directory, auth).status === 0,
461
+ authenticated: probe(auth).status === 0,
439
462
  authCommand,
440
463
  };
441
464
  }
@@ -445,7 +468,7 @@ function probeCli(
445
468
  // A successful real provider round trip (or identified wrapper probe)
446
469
  // proves both auth and exact-model access. On failure, run the adapter's
447
470
  // actual auth command solely to classify auth vs model access truthfully.
448
- if (runProbe(executable, directory, scoped).status === 0) {
471
+ if (probe(scoped).status === 0) {
449
472
  return {
450
473
  exists: true,
451
474
  supported: true,
@@ -455,35 +478,70 @@ function probeCli(
455
478
  modelCommand,
456
479
  };
457
480
  }
458
- const authStatus = runProbe(executable, directory, auth).status;
481
+ const authProbe = probe(auth);
482
+ const authenticated = authProbe.status === 0;
459
483
  return {
460
484
  exists: true,
461
485
  supported: true,
462
- authenticated: authStatus === 0,
486
+ authenticated,
463
487
  modelAvailable: false,
464
488
  authCommand,
465
489
  modelCommand,
490
+ // Only on failure: on success there is nothing to explain, and the output
491
+ // is the most identity-bearing thing this function touches.
492
+ ...(authenticated
493
+ ? {}
494
+ : {
495
+ authExitCode: authProbe.status,
496
+ authFailureDetail: redactProbeOutput(
497
+ `${authProbe.stderr}${authProbe.stdout}`,
498
+ ).trim().slice(0, 500),
499
+ }),
466
500
  };
467
501
  }
468
502
 
503
+ /**
504
+ * Redact anything that looks like a credential or an account identifier.
505
+ *
506
+ * `auth status` output is diagnostic, but it is also the one place an account
507
+ * email, org id or token fragment can appear. The point of surfacing it is to
508
+ * say WHY a probe failed, which survives redaction; leaking an identity into a
509
+ * refusal message that gets pasted into issues does not.
510
+ */
511
+ function redactProbeOutput(text: string): string {
512
+ return text
513
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<redacted-email>')
514
+ .replace(/\b(sk|pk|oat|rt)[-_][A-Za-z0-9._-]{8,}/gi, '<redacted-token>')
515
+ .replace(/\b[A-Fa-f0-9]{32,}\b/g, '<redacted-hex>');
516
+ }
517
+
469
518
  function runProbe(
470
519
  executable: string,
471
520
  directory: string,
472
521
  invocation: CliInvocation,
473
- ): { status: number | null; stdout: string } {
474
- const env = { ...process.env };
522
+ environment: NodeJS.ProcessEnv = process.env,
523
+ ): { status: number | null; stdout: string; stderr: string } {
524
+ const env = { ...environment };
475
525
  delete env[MODEL_ENV];
476
526
  if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
477
527
  const result = spawnSync(executable, invocation.args, {
478
528
  cwd: directory,
479
529
  encoding: 'utf8',
480
- stdio: ['ignore', 'pipe', 'ignore'],
530
+ // stderr was 'ignore'. A failing `auth status` writes its reason there, so
531
+ // discarding it made every authentication refusal structurally
532
+ // undiagnosable: the refusal could say a probe exited non-zero and never
533
+ // what it said. Captured, then redacted at the point of use.
534
+ stdio: ['ignore', 'pipe', 'pipe'],
481
535
  timeout: invocation.timeoutMs,
482
536
  env,
483
537
  });
484
538
  const failure = classifySpawnFailure(result.error, result.signal, invocation.timeoutMs);
485
539
  if (failure !== undefined) throw failure;
486
- return { status: result.status, stdout: result.stdout };
540
+ return {
541
+ status: result.status,
542
+ stdout: result.stdout,
543
+ stderr: result.stderr ?? '',
544
+ };
487
545
  }
488
546
 
489
547
  function resolveExecutable(command: string, directory: string): string | undefined {
@@ -0,0 +1,518 @@
1
+ // `flows runs`, `flows logs` and `flows status --cloud`: reading a hosted run.
2
+ //
3
+ // The local `flows status` (cli/status.ts) answers "what is this run doing"
4
+ // from a journal on the same disk. These three answer the same question about
5
+ // a run that executed in Cloud, for an agent holding its own user's Cloud
6
+ // credential -- so it does not have to hand-roll HTTP, learn four route
7
+ // shapes, or decide for itself what a 404 means.
8
+ //
9
+ // Presentation only. `cloud-read.ts` owns the requests and the field names;
10
+ // `cloud-transcript.ts` owns the JSONL vocabulary; this module owns the page
11
+ // and the refusals, and deliberately renders in the grammar cli/status.ts
12
+ // already uses (`RUN` / `steps N` / one glyph-led line per step, indented
13
+ // facts beneath it) so a reader who knows one knows the other.
14
+
15
+ import { canonicalize } from '../canonical.js';
16
+ import { CloudFlowError, type CloudConnectionOptions } from '../cloud-http.js';
17
+ import {
18
+ getCloudRunDetail, getCloudRunLog, getCloudRunSteps, listCloudRuns,
19
+ type CloudRunDetail, type CloudStep,
20
+ } from '../cloud-read.js';
21
+ import { parseAgentTranscript, renderAgentTranscript } from '../cloud-transcript.js';
22
+ import { redact } from '../redact.js';
23
+ import { formatDuration } from './status.js';
24
+ import type { CliIo } from '../cli.js';
25
+
26
+ export interface RunsArgs {
27
+ command: 'runs';
28
+ limit: number;
29
+ json: boolean;
30
+ }
31
+
32
+ export interface LogsArgs {
33
+ command: 'logs';
34
+ runId: string;
35
+ step: string | undefined;
36
+ raw: boolean;
37
+ json: boolean;
38
+ }
39
+
40
+ /** Enough runs to recognise the one you mean; `--limit` raises it. */
41
+ export const DEFAULT_RUN_LIMIT = 20;
42
+
43
+ /** Injected by tests; production takes the ambient clock and environment. */
44
+ export interface CloudReadOptions extends CloudConnectionOptions {
45
+ env?: NodeJS.ProcessEnv;
46
+ now?: () => number;
47
+ }
48
+
49
+ export function parseRunsArgs(args: readonly string[]): RunsArgs | undefined {
50
+ let limit: number | undefined;
51
+ let json = false;
52
+ for (let index = 0; index < args.length; index += 1) {
53
+ const argument = args[index]!;
54
+ if (argument === '--json') {
55
+ if (json) return undefined;
56
+ json = true;
57
+ } else if (argument === '--limit') {
58
+ const value = args[++index];
59
+ if (limit !== undefined || value === undefined || !/^\d{1,4}$/u.test(value) || Number(value) < 1) return undefined;
60
+ limit = Number(value);
61
+ } else {
62
+ return undefined;
63
+ }
64
+ }
65
+ return { command: 'runs', limit: limit ?? DEFAULT_RUN_LIMIT, json };
66
+ }
67
+
68
+ export function parseLogsArgs(args: readonly string[]): LogsArgs | undefined {
69
+ let step: string | undefined;
70
+ let raw = false;
71
+ let json = false;
72
+ const positionals: string[] = [];
73
+ for (let index = 0; index < args.length; index += 1) {
74
+ const argument = args[index]!;
75
+ if (argument === '--json') {
76
+ if (json) return undefined;
77
+ json = true;
78
+ } else if (argument === '--raw') {
79
+ if (raw) return undefined;
80
+ raw = true;
81
+ } else if (argument === '--step') {
82
+ const value = args[++index];
83
+ if (step !== undefined || value === undefined || value.length === 0 || value.startsWith('-')) return undefined;
84
+ step = value;
85
+ } else if (argument.startsWith('-')) {
86
+ return undefined;
87
+ } else {
88
+ positionals.push(argument);
89
+ }
90
+ }
91
+ if (positionals.length !== 1) return undefined;
92
+ return { command: 'logs', runId: positionals[0]!, step, raw, json };
93
+ }
94
+
95
+ /**
96
+ * Every way these verbs can refuse, as a code and a sentence.
97
+ *
98
+ * Each one names the thing to do next. A missing credential names
99
+ * `agent-relay cloud login` because that is the command that fixes it; a 404
100
+ * names `flows runs` because the usual cause is a run id from another
101
+ * workspace, and the list is how you find yours.
102
+ */
103
+ interface Refusal { code: string; message: string; exit: 1 | 2 }
104
+
105
+ function refusalFor(error: unknown, subject: string, env: NodeJS.ProcessEnv): Refusal {
106
+ const clean = (message: string): string => redact(message, env);
107
+ if (error instanceof CloudFlowError) {
108
+ if (error.code === 'configuration') {
109
+ switch (error.reason) {
110
+ case 'auth_missing':
111
+ return {
112
+ code: 'cloud_auth_missing', exit: 2,
113
+ message: 'No Cloud credential. Sign in with `agent-relay cloud login`, or set FLOWS_CLOUD_TOKEN to a '
114
+ + 'Cloud API token with workflow:runs:read (and workflow:logs:read for `flows logs`).',
115
+ };
116
+ case 'auth_expired':
117
+ return { code: 'cloud_auth_expired', exit: 2, message: clean(error.message) };
118
+ case 'url_mismatch':
119
+ return { code: 'cloud_url_mismatch', exit: 2, message: clean(error.message) };
120
+ default:
121
+ return { code: 'cloud_configuration', exit: 2, message: clean(error.message) };
122
+ }
123
+ }
124
+ if (error.code === 'http_error') {
125
+ if (error.status === 401) {
126
+ return {
127
+ code: 'cloud_auth_rejected', exit: 2,
128
+ message: 'Cloud rejected the credential (HTTP 401). The token is unknown or revoked; '
129
+ + 'sign in again with `agent-relay cloud login`.',
130
+ };
131
+ }
132
+ if (error.status === 403) {
133
+ return {
134
+ code: 'cloud_forbidden', exit: 2,
135
+ message: `The credential is valid but not allowed to read ${subject} (HTTP 403). `
136
+ + 'A run-scoped sandbox token may only read its own run; a workspace token needs '
137
+ + 'workflow:runs:read, and workflow:logs:read or workflow:invoke:read for logs.',
138
+ };
139
+ }
140
+ if (error.status === 404) {
141
+ return {
142
+ code: 'cloud_run_not_found', exit: 2,
143
+ message: `Cloud has no ${subject} visible to this credential (HTTP 404). `
144
+ + 'It may belong to another workspace; `flows runs` lists the ones this credential can read.',
145
+ };
146
+ }
147
+ return { code: 'cloud_http_error', exit: 1, message: clean(error.message) };
148
+ }
149
+ if (error.code === 'invalid_input') return { code: 'invalid_invocation', exit: 2, message: clean(error.message) };
150
+ if (error.code === 'transient_error' || error.code === 'transport_error') {
151
+ return { code: error.code === 'transient_error' ? 'cloud_unreachable' : 'cloud_transport_failed', exit: 1, message: clean(error.message) };
152
+ }
153
+ return { code: 'cloud_invalid_response', exit: 1, message: clean(error.message) };
154
+ }
155
+ return { code: 'cloud_read_failed', exit: 1, message: clean(error instanceof Error ? error.message : String(error)) };
156
+ }
157
+
158
+ function fail(refusal: Refusal, json: boolean, io: CliIo): 1 | 2 {
159
+ if (json) io.stdout(canonicalize({ v: 1, ok: false, code: refusal.code, message: refusal.message }));
160
+ else io.stderr(`REFUSED [${refusal.code}] ${refusal.message}`);
161
+ return refusal.exit;
162
+ }
163
+
164
+ function thousands(value: number): string {
165
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
166
+ }
167
+
168
+ function dollars(value: number): string {
169
+ return `$${value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '')}`;
170
+ }
171
+
172
+ /** Agent- and flow-authored names cannot inject terminal control sequences. */
173
+ function safe(text: string): string {
174
+ return text.replace(/[\u0000-\u001F\u007F-\u009F]/gu, '?');
175
+ }
176
+
177
+ /**
178
+ * Render a run's `error` as readable lines rather than one control-char smear.
179
+ *
180
+ * Cloud stores the runner's terminal output in this field, newlines and all, so
181
+ * passing it through `safe()` alone turns a 200-line tail into a single line of
182
+ * `?` separators. Long runs are dominated by lease renewals — one line every
183
+ * 10s for the life of every agent step, differing only in the deadline — which
184
+ * are worth counting, not reading.
185
+ *
186
+ * Collapsing is deliberately narrow: two adjacent lines merge only when they
187
+ * are character-for-character identical once a trailing integer is masked. A
188
+ * shared prefix is NOT line identity — runner lines put the step name, reason
189
+ * or message after a long fixed prefix, so collapsing on a prefix would hide
190
+ * distinct diagnostics behind a similarity count. The final line never
191
+ * collapses into an earlier one, because that is where the failure is.
192
+ */
193
+ const TRAILING_NUMBER = /\d+(?=\D{0,2}$)/u;
194
+
195
+ export function errorLines(text: string, indent: string): string[] {
196
+ const raw = text.split(/\r\n|\r|\n/u).map((line) => line.trimEnd()).filter((line) => line !== '');
197
+ if (raw.length === 0) return [];
198
+
199
+ const key = (line: string): string => line.replace(TRAILING_NUMBER, '#');
200
+ const collapsed: { line: string; count: number }[] = [];
201
+ raw.forEach((line, index) => {
202
+ const previous = collapsed.at(-1);
203
+ const isLast = index === raw.length - 1;
204
+ if (previous !== undefined && !isLast && key(previous.line) === key(line)) previous.count += 1;
205
+ else collapsed.push({ line, count: 1 });
206
+ });
207
+
208
+ const rendered = collapsed.map(({ line, count }) =>
209
+ count === 1 ? safe(line) : `${safe(line)} (${count} times, differing only in a number)`);
210
+
211
+ const HEAD = 2;
212
+ const TAIL = 12;
213
+ if (rendered.length <= HEAD + TAIL + 1) return rendered.map((line) => `${indent}${line}`);
214
+ const elided = rendered.length - HEAD - TAIL;
215
+ return [
216
+ ...rendered.slice(0, HEAD),
217
+ `… ${elided} more line${elided === 1 ? '' : 's'} (full text: --json)`,
218
+ ...rendered.slice(-TAIL),
219
+ ].map((line) => `${indent}${line}`);
220
+ }
221
+
222
+ /** ISO-8601 to the second: a list column, not a timestamp to do arithmetic on. */
223
+ function instant(value: string | null): string {
224
+ if (value === null) return 'unknown';
225
+ const parsed = Date.parse(value);
226
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString().replace(/\.\d{3}Z$/u, 'Z') : 'unknown';
227
+ }
228
+
229
+ function ago(value: string | null, now: number): string {
230
+ if (value === null) return 'unknown';
231
+ const parsed = Date.parse(value);
232
+ return Number.isFinite(parsed) ? `${formatDuration(now - parsed)} ago` : 'unknown';
233
+ }
234
+
235
+ // ---------------------------------------------------------------- flows runs
236
+
237
+ export async function runCloudRunsCli(
238
+ args: RunsArgs,
239
+ io: CliIo,
240
+ options: CloudReadOptions = {},
241
+ ): Promise<0 | 1 | 2> {
242
+ const env = options.env ?? process.env;
243
+ let page;
244
+ try {
245
+ page = await listCloudRuns(args.limit, options);
246
+ } catch (error) {
247
+ return fail(refusalFor(error, 'the run list', env), args.json, io);
248
+ }
249
+ const runs = page.runs.map((run) => ({
250
+ ...run,
251
+ name: redact(run.name, env),
252
+ error: run.error === null ? null : redact(run.error, env),
253
+ }));
254
+ if (args.json) {
255
+ io.stdout(canonicalize({ v: 1, ok: true, runs, more: page.more }));
256
+ return 0;
257
+ }
258
+ io.stdout(`runs ${runs.length}${page.more ? ` (more available; pass --limit)` : ''}`);
259
+ if (runs.length === 0) {
260
+ io.stdout(' no runs visible to this credential');
261
+ return 0;
262
+ }
263
+ const nameWidth = Math.max(4, ...runs.map((run) => Math.min(32, run.name.length)));
264
+ const statusWidth = Math.max(6, ...runs.map((run) => run.status.length));
265
+ for (const run of runs) {
266
+ const cells = [
267
+ run.run_id,
268
+ safe(run.name).slice(0, 32).padEnd(nameWidth),
269
+ run.status.padEnd(statusWidth),
270
+ run.completion_reason === null ? null : safe(run.completion_reason),
271
+ `started ${instant(run.created_at)}`,
272
+ `updated ${instant(run.updated_at)}`,
273
+ ].filter((cell): cell is string => cell !== null);
274
+ io.stdout(`RUN ${cells.join(' ')}`);
275
+ if (run.pull_request_url !== null) io.stdout(` pr ${run.pull_request_url}`);
276
+ if (run.error !== null) {
277
+ io.stdout(' error');
278
+ for (const line of errorLines(run.error, ' ')) io.stdout(line);
279
+ }
280
+ }
281
+ return 0;
282
+ }
283
+
284
+ // ---------------------------------------------------------------- flows logs
285
+
286
+ export async function runCloudLogsCli(
287
+ args: LogsArgs,
288
+ io: CliIo,
289
+ options: CloudReadOptions = {},
290
+ ): Promise<0 | 1 | 2> {
291
+ const env = options.env ?? process.env;
292
+ const subject = args.step === undefined ? `run ${args.runId}` : `step "${args.step}" of run ${args.runId}`;
293
+ let log;
294
+ try {
295
+ log = await getCloudRunLog(args.runId, args.step, options);
296
+ } catch (error) {
297
+ return fail(refusalFor(error, subject, env), args.json, io);
298
+ }
299
+
300
+ // The logs route answers 200 with an empty body for a sandbox id it does not
301
+ // know, so an unknown `--step` is indistinguishable from a step that has
302
+ // written nothing -- from this response alone. The step list distinguishes
303
+ // them, and is only fetched on this path, so the happy path stays one request.
304
+ if (args.step !== undefined && log.content.length === 0) {
305
+ let steps: CloudStep[];
306
+ try {
307
+ steps = await getCloudRunSteps(args.runId, options);
308
+ } catch (error) {
309
+ // The step list is the only evidence for either message, so without it
310
+ // there is no finding to report. A 403 or a timed-out read must reach
311
+ // the caller as what it is — retryable, or a scope to fix — and never
312
+ // as `cloud_step_no_transcript`, which says the invocation was wrong.
313
+ return fail(refusalFor(error, `the steps of run ${args.runId}`, env), args.json, io);
314
+ }
315
+ const named = steps.some((step) => step.sandbox_id === args.step || step.step_name === args.step);
316
+ const withLogs = steps.filter((step) => step.sandbox_id.length > 0).map((step) => step.sandbox_id);
317
+ const available = withLogs.length === 0
318
+ ? 'this run has no step with its own transcript'
319
+ : `steps with a transcript: ${withLogs.map(safe).join(', ')}`;
320
+ return fail({
321
+ code: 'cloud_step_no_transcript', exit: 2,
322
+ message: named
323
+ ? `Step "${safe(args.step)}" of run ${args.runId} has no transcript${log.done ? '' : ' yet'}. `
324
+ + 'Only an agent step writes one; a deterministic step has none.'
325
+ : `Run ${args.runId} has no step "${safe(args.step)}" — ${available}.`,
326
+ }, args.json, io);
327
+ }
328
+
329
+ const content = redact(log.content, env);
330
+ if (args.json) {
331
+ const parsed = args.step === undefined || args.raw ? undefined : parseAgentTranscript(log.content, env);
332
+ io.stdout(canonicalize({
333
+ v: 1, ok: true, run_id: args.runId, step: args.step ?? null,
334
+ bytes: log.offset, total_bytes: log.total_size, done: log.done,
335
+ ...(parsed === undefined ? { content } : { entries: parsed.entries as unknown }),
336
+ }));
337
+ return 0;
338
+ }
339
+
340
+ const where = args.step === undefined ? 'runner' : `step ${safe(args.step)}`;
341
+ io.stdout(`LOG ${args.runId} ${where} ${thousands(log.total_size)} bytes ${log.done ? 'complete' : 'still running'}`);
342
+ if (content.length === 0) {
343
+ io.stdout(' (empty)');
344
+ return 0;
345
+ }
346
+ // `--raw` means unrendered, not unredacted: a credential an agent echoed
347
+ // into its transcript must not reach a terminal because a flag was passed.
348
+ if (args.raw || args.step === undefined) {
349
+ for (const line of content.split('\n')) io.stdout(line);
350
+ return 0;
351
+ }
352
+ const parsed = parseAgentTranscript(log.content, env);
353
+ if (!parsed.stream_json) {
354
+ io.stdout(' not a stream-json transcript; printed as written');
355
+ for (const line of content.split('\n')) io.stdout(line);
356
+ return 0;
357
+ }
358
+ for (const line of renderAgentTranscript(parsed)) io.stdout(line);
359
+ return 0;
360
+ }
361
+
362
+ // -------------------------------------------------------- flows status --cloud
363
+
364
+ const GLYPH: Record<string, string> = {
365
+ completed: '✓', succeeded: '✓', failed: '✗', cancelled: '✗', running: '↻', pending: '○', queued: '○',
366
+ };
367
+
368
+ function glyph(step: CloudStep): string {
369
+ if (step.status === 'completed' && step.completion_reason !== null && step.completion_reason !== 'success') return '✗';
370
+ return GLYPH[step.status] ?? '○';
371
+ }
372
+
373
+ /** The `steps N: 3 done · 1 running` line, counted off whatever Cloud called each status. */
374
+ function stepCounts(steps: readonly CloudStep[]): string {
375
+ const counts = new Map<string, number>();
376
+ for (const step of steps) counts.set(step.status, (counts.get(step.status) ?? 0) + 1);
377
+ const parts = [...counts].map(([status, count]) => `${count} ${status}`);
378
+ return `steps ${steps.length}${parts.length === 0 ? '' : `: ${parts.join(' · ')}`}`;
379
+ }
380
+
381
+ function renderStep(step: CloudStep, runId: string, idWidth: number): string[] {
382
+ const cells = [
383
+ safe(step.step_name).slice(0, 24).padEnd(idWidth),
384
+ step.step_type.padEnd(13),
385
+ step.status.padEnd(11),
386
+ ];
387
+ const attempts = step.attempts.length;
388
+ if (attempts > 0) cells.push(`${attempts} attempt${attempts === 1 ? '' : 's'}`);
389
+ if (step.duration_ms !== null) cells.push(formatDuration(step.duration_ms));
390
+ if (step.completion_reason !== null) cells.push(safe(step.completion_reason));
391
+ if (step.gate !== null) cells.push(`gate: ${safe(step.gate.gate)} ${safe(step.gate.verdict)}`);
392
+ const lines = [` ${glyph(step)} ${cells.join(' ')}`.trimEnd()];
393
+
394
+ if (step.gate !== null && step.gate.verdict !== 'pass' && step.gate.detail.length > 0) {
395
+ lines.push(` gate: ${safe(step.gate.gate)} ${step.gate.verdict.toUpperCase()} — ${JSON.stringify(safe(step.gate.detail))}`);
396
+ }
397
+ const transcript = step.transcript;
398
+ if (transcript !== null) {
399
+ const facts = [
400
+ transcript.model === null ? null : safe(transcript.model),
401
+ transcript.num_turns === null ? null : `${transcript.num_turns} turn${transcript.num_turns === 1 ? '' : 's'}`,
402
+ transcript.total_calls === null ? null : `${transcript.total_calls} tool call${transcript.total_calls === 1 ? '' : 's'}`,
403
+ transcript.total_cost_usd === null ? null : dollars(transcript.total_cost_usd),
404
+ ].filter((fact): fact is string => fact !== null);
405
+ lines.push(` transcript (attempt ${transcript.attempt ?? step.attempts.length})${facts.length === 0 ? '' : `: ${facts.join(' · ')}`}`);
406
+ if (transcript.frames_total !== null || transcript.bytes_total !== null) {
407
+ const frames = transcript.frames_total === null ? ''
408
+ : `${thousands(transcript.frames_kept ?? transcript.frames_total)} of ${thousands(transcript.frames_total)} frames`;
409
+ const bytes = transcript.bytes_total === null ? '' : `${thousands(transcript.bytes_total)} bytes`;
410
+ const cut = transcript.truncated ? ', truncated' : '';
411
+ lines.push(` ${[frames, bytes].filter((part) => part.length > 0).join(', ')}${cut}`);
412
+ }
413
+ if (transcript.tokens_in !== null || transcript.tokens_out !== null) {
414
+ const cache = [
415
+ transcript.cache_read === null ? null : `${thousands(transcript.cache_read)} cache read`,
416
+ transcript.cache_creation === null ? null : `${thousands(transcript.cache_creation)} cache write`,
417
+ ].filter((part): part is string => part !== null);
418
+ lines.push(` ${thousands(transcript.tokens_in ?? 0)} in / ${thousands(transcript.tokens_out ?? 0)} out`
419
+ + `${cache.length === 0 ? '' : ` · ${cache.join(' · ')}`}`);
420
+ }
421
+ if (transcript.tools.length > 0) {
422
+ lines.push(` tools: ${transcript.tools.map((tool) =>
423
+ `${safe(tool.name)} ×${tool.calls ?? 0}${tool.errors ? ` (${tool.errors} error${tool.errors === 1 ? '' : 's'})` : ''}`).join(', ')}`);
424
+ }
425
+ for (const call of transcript.last_calls) {
426
+ const excerpt = call.input_excerpt === null ? '' : ` ${safe(call.input_excerpt).slice(0, 120)}`;
427
+ lines.push(` call ${call.seq ?? '?'} ${safe(call.name)}${excerpt} → ${thousands(call.result_bytes ?? 0)} bytes`);
428
+ }
429
+ lines.push(` artifacts: ${transcript.artifacts.length === 0
430
+ ? 'none' : transcript.artifacts.slice(0, 10).map(safe).join(', ')}`);
431
+ }
432
+ if (step.sandbox_id.length > 0) lines.push(` logs: flows logs ${runId} --step ${safe(step.sandbox_id)}`);
433
+ return lines;
434
+ }
435
+
436
+ function renderCloudStatus(run: CloudRunDetail, steps: readonly CloudStep[], now: number): string[] {
437
+ let tokensIn = 0;
438
+ let tokensOut = 0;
439
+ let cost = 0;
440
+ for (const step of steps) {
441
+ tokensIn += step.tokens_in ?? 0;
442
+ tokensOut += step.tokens_out ?? 0;
443
+ cost += step.cost_usd ?? 0;
444
+ }
445
+ // Summed from the step rows, because the run record carries no total. Cloud
446
+ // stores `costUsd` as a float, so this is a float sum — not the exact decimal
447
+ // addition the local journal fold does (run-state.ts `addDollars`). Shown to
448
+ // six places and labelled `spend`, the same word the local view uses.
449
+ const spend = `${thousands(tokensIn)} in / ${thousands(tokensOut)} out / ${dollars(cost)}`;
450
+ const finished = run.completion_reason === null ? '' : ` finished ${safe(run.completion_reason)}`;
451
+ const lines = [
452
+ `RUN ${run.run_id} ${safe(run.name)} ${run.status} started ${ago(run.created_at, now)}${finished} spend ${spend}`,
453
+ stepCounts(steps),
454
+ ];
455
+ if (run.authority !== null) {
456
+ const facts = [
457
+ run.authority.surface_version === null ? null : `surface ${run.authority.surface_version}`,
458
+ run.authority.artifact_sha256 === null ? null : `artifact ${run.authority.artifact_sha256.slice(0, 12)}`,
459
+ run.authority.source_commit === null ? null : `commit ${run.authority.source_commit.slice(0, 12)}`,
460
+ ].filter((fact): fact is string => fact !== null);
461
+ if (facts.length > 0) lines.push(`authority ${facts.join(' · ')}`);
462
+ }
463
+ if (run.pull_request_url !== null) lines.push(`pr ${run.pull_request_url}`);
464
+ if (run.error !== null) lines.push('error', ...errorLines(run.error, ' '));
465
+ lines.push('');
466
+ const idWidth = Math.max(4, ...steps.map((step) => Math.min(24, step.step_name.length)));
467
+ for (const step of steps) lines.push(...renderStep(step, run.run_id, idWidth));
468
+ return lines;
469
+ }
470
+
471
+ export async function runCloudStatusCli(
472
+ args: { runId?: string; json: boolean },
473
+ io: CliIo,
474
+ options: CloudReadOptions = {},
475
+ ): Promise<0 | 1 | 2> {
476
+ const env = options.env ?? process.env;
477
+ const now = (options.now ?? Date.now)();
478
+ if (args.runId === undefined || args.runId.length === 0) {
479
+ return fail({
480
+ code: 'run_unknown', exit: 2,
481
+ message: '`flows status --cloud` needs the hosted run id; `flows runs` lists them. '
482
+ + '(Without --cloud the run id defaults to RELAYFLOW_RUN_ID inside a step.)',
483
+ }, args.json, io);
484
+ }
485
+ let run: CloudRunDetail;
486
+ let steps: CloudStep[];
487
+ try {
488
+ run = await getCloudRunDetail(args.runId, options);
489
+ steps = await getCloudRunSteps(args.runId, options);
490
+ } catch (error) {
491
+ return fail(refusalFor(error, `run ${args.runId}`, env), args.json, io);
492
+ }
493
+ const scrubbed: CloudRunDetail = {
494
+ ...run,
495
+ name: redact(run.name, env),
496
+ error: run.error === null ? null : redact(run.error, env),
497
+ };
498
+ const scrubbedSteps = steps.map((step) => ({
499
+ ...step,
500
+ output_summary: step.output_summary === null ? null : redact(step.output_summary, env),
501
+ gate: step.gate === null ? null : { ...step.gate, detail: redact(step.gate.detail, env) },
502
+ transcript: step.transcript === null ? null : {
503
+ ...step.transcript,
504
+ model: step.transcript.model === null ? null : redact(step.transcript.model, env),
505
+ last_calls: step.transcript.last_calls.map((call) => ({
506
+ ...call,
507
+ input_excerpt: call.input_excerpt === null ? null : redact(call.input_excerpt, env),
508
+ })),
509
+ artifacts: step.transcript.artifacts.map((path) => redact(path, env)),
510
+ },
511
+ }));
512
+ if (args.json) {
513
+ io.stdout(canonicalize({ v: 1, ok: true, run: scrubbed, steps: scrubbedSteps, now_ms: now }));
514
+ return 0;
515
+ }
516
+ for (const line of renderCloudStatus(scrubbed, scrubbedSteps, now)) io.stdout(line);
517
+ return 0;
518
+ }