@relayflows/sdk 2.0.30 → 2.0.31

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 (295) hide show
  1. package/dist/agent-artifacts.d.ts.map +1 -1
  2. package/dist/agent-artifacts.js +2 -9
  3. package/dist/agent-artifacts.js.map +1 -1
  4. package/dist/agent-cwd.d.ts +69 -0
  5. package/dist/agent-cwd.d.ts.map +1 -0
  6. package/dist/agent-cwd.js +140 -0
  7. package/dist/agent-cwd.js.map +1 -0
  8. package/dist/artifact-scan-policy.d.ts +35 -0
  9. package/dist/artifact-scan-policy.d.ts.map +1 -0
  10. package/dist/artifact-scan-policy.js +49 -0
  11. package/dist/artifact-scan-policy.js.map +1 -0
  12. package/dist/authored-completion-record.d.ts +35 -0
  13. package/dist/authored-completion-record.d.ts.map +1 -0
  14. package/dist/authored-completion-record.js +92 -0
  15. package/dist/authored-completion-record.js.map +1 -0
  16. package/dist/authored-completion.d.ts +99 -0
  17. package/dist/authored-completion.d.ts.map +1 -0
  18. package/dist/authored-completion.js +184 -0
  19. package/dist/authored-completion.js.map +1 -0
  20. package/dist/authored-flow-error.d.ts +13 -1
  21. package/dist/authored-flow-error.d.ts.map +1 -1
  22. package/dist/authored-flow-error.js +12 -0
  23. package/dist/authored-flow-error.js.map +1 -1
  24. package/dist/authored-flow-executor.d.ts +7 -30
  25. package/dist/authored-flow-executor.d.ts.map +1 -1
  26. package/dist/authored-flow-executor.js +54 -47
  27. package/dist/authored-flow-executor.js.map +1 -1
  28. package/dist/authored-human.d.ts.map +1 -1
  29. package/dist/authored-human.js +1 -3
  30. package/dist/authored-human.js.map +1 -1
  31. package/dist/authored-node-entry.js +4 -1
  32. package/dist/authored-node-entry.js.map +1 -1
  33. package/dist/authored-node-runner.d.ts +9 -1
  34. package/dist/authored-node-runner.d.ts.map +1 -1
  35. package/dist/authored-node-runner.js +92 -17
  36. package/dist/authored-node-runner.js.map +1 -1
  37. package/dist/authored-root.d.ts +5 -0
  38. package/dist/authored-root.d.ts.map +1 -1
  39. package/dist/authored-root.js +20 -2
  40. package/dist/authored-root.js.map +1 -1
  41. package/dist/authored-step-output.d.ts +35 -10
  42. package/dist/authored-step-output.d.ts.map +1 -1
  43. package/dist/authored-step-output.js +108 -40
  44. package/dist/authored-step-output.js.map +1 -1
  45. package/dist/authored-verdict.d.ts +28 -0
  46. package/dist/authored-verdict.d.ts.map +1 -0
  47. package/dist/authored-verdict.js +79 -0
  48. package/dist/authored-verdict.js.map +1 -0
  49. package/dist/authored-worker-step.d.ts +5 -2
  50. package/dist/authored-worker-step.d.ts.map +1 -1
  51. package/dist/authored-worker-step.js +48 -11
  52. package/dist/authored-worker-step.js.map +1 -1
  53. package/dist/cli/check-worker-surface.d.ts +27 -0
  54. package/dist/cli/check-worker-surface.d.ts.map +1 -0
  55. package/dist/cli/check-worker-surface.js +63 -0
  56. package/dist/cli/check-worker-surface.js.map +1 -0
  57. package/dist/cli/check.d.ts +19 -2
  58. package/dist/cli/check.d.ts.map +1 -1
  59. package/dist/cli/check.js +12 -4
  60. package/dist/cli/check.js.map +1 -1
  61. package/dist/cli/cloud-format.d.ts +9 -0
  62. package/dist/cli/cloud-format.d.ts.map +1 -0
  63. package/dist/cli/cloud-format.js +77 -0
  64. package/dist/cli/cloud-format.js.map +1 -0
  65. package/dist/cli/cloud-live.d.ts +39 -0
  66. package/dist/cli/cloud-live.d.ts.map +1 -0
  67. package/dist/cli/cloud-live.js +325 -0
  68. package/dist/cli/cloud-live.js.map +1 -0
  69. package/dist/cli/cloud-read.d.ts +3 -7
  70. package/dist/cli/cloud-read.d.ts.map +1 -1
  71. package/dist/cli/cloud-read.js +14 -266
  72. package/dist/cli/cloud-read.js.map +1 -1
  73. package/dist/cli/cloud-refusal.d.ts +39 -0
  74. package/dist/cli/cloud-refusal.d.ts.map +1 -0
  75. package/dist/cli/cloud-refusal.js +95 -0
  76. package/dist/cli/cloud-refusal.js.map +1 -0
  77. package/dist/cli/cloud-status-view.d.ts +7 -0
  78. package/dist/cli/cloud-status-view.d.ts.map +1 -0
  79. package/dist/cli/cloud-status-view.js +214 -0
  80. package/dist/cli/cloud-status-view.js.map +1 -0
  81. package/dist/cli/direct-run.d.ts.map +1 -1
  82. package/dist/cli/direct-run.js +16 -2
  83. package/dist/cli/direct-run.js.map +1 -1
  84. package/dist/cli/hn-monitor.d.ts +0 -13
  85. package/dist/cli/hn-monitor.d.ts.map +1 -1
  86. package/dist/cli/hn-monitor.js +2 -1
  87. package/dist/cli/hn-monitor.js.map +1 -1
  88. package/dist/cli/local-agent-remedy.d.ts +80 -0
  89. package/dist/cli/local-agent-remedy.d.ts.map +1 -0
  90. package/dist/cli/local-agent-remedy.js +116 -0
  91. package/dist/cli/local-agent-remedy.js.map +1 -0
  92. package/dist/cli/observer-session.d.ts +41 -0
  93. package/dist/cli/observer-session.d.ts.map +1 -0
  94. package/dist/cli/observer-session.js +78 -0
  95. package/dist/cli/observer-session.js.map +1 -0
  96. package/dist/cli/run.d.ts +30 -2
  97. package/dist/cli/run.d.ts.map +1 -1
  98. package/dist/cli/run.js +186 -24
  99. package/dist/cli/run.js.map +1 -1
  100. package/dist/cli/status.d.ts +9 -0
  101. package/dist/cli/status.d.ts.map +1 -1
  102. package/dist/cli/status.js +31 -2
  103. package/dist/cli/status.js.map +1 -1
  104. package/dist/cli/step-evidence.d.ts +90 -0
  105. package/dist/cli/step-evidence.d.ts.map +1 -0
  106. package/dist/cli/step-evidence.js +259 -0
  107. package/dist/cli/step-evidence.js.map +1 -0
  108. package/dist/cli/step-failure.d.ts +14 -4
  109. package/dist/cli/step-failure.d.ts.map +1 -1
  110. package/dist/cli/step-failure.js +41 -76
  111. package/dist/cli/step-failure.js.map +1 -1
  112. package/dist/cli-commands.d.ts +6 -0
  113. package/dist/cli-commands.d.ts.map +1 -1
  114. package/dist/cli-commands.js +2 -0
  115. package/dist/cli-commands.js.map +1 -1
  116. package/dist/cli.d.ts +10 -0
  117. package/dist/cli.d.ts.map +1 -1
  118. package/dist/cli.js +53 -42
  119. package/dist/cli.js.map +1 -1
  120. package/dist/cloud-read.d.ts +15 -0
  121. package/dist/cloud-read.d.ts.map +1 -1
  122. package/dist/cloud-read.js +20 -1
  123. package/dist/cloud-read.js.map +1 -1
  124. package/dist/cloud-run-record.d.ts +27 -0
  125. package/dist/cloud-run-record.d.ts.map +1 -0
  126. package/dist/cloud-run-record.js +56 -0
  127. package/dist/cloud-run-record.js.map +1 -0
  128. package/dist/cloud-run.d.ts +2 -9
  129. package/dist/cloud-run.d.ts.map +1 -1
  130. package/dist/cloud-run.js +3 -20
  131. package/dist/cloud-run.js.map +1 -1
  132. package/dist/communication/local.d.ts.map +1 -1
  133. package/dist/communication/local.js +2 -1
  134. package/dist/communication/local.js.map +1 -1
  135. package/dist/communication/worker.d.ts +1 -1
  136. package/dist/communication/worker.d.ts.map +1 -1
  137. package/dist/communication/worker.js +13 -5
  138. package/dist/communication/worker.js.map +1 -1
  139. package/dist/compile.d.ts.map +1 -1
  140. package/dist/compile.js +28 -6
  141. package/dist/compile.js.map +1 -1
  142. package/dist/direct-input.d.ts.map +1 -1
  143. package/dist/direct-input.js +21 -1
  144. package/dist/direct-input.js.map +1 -1
  145. package/dist/failure-kinds.d.ts +96 -6
  146. package/dist/failure-kinds.d.ts.map +1 -1
  147. package/dist/failure-kinds.js +29 -2
  148. package/dist/failure-kinds.js.map +1 -1
  149. package/dist/flow-requirements.d.ts.map +1 -1
  150. package/dist/flow-requirements.js +24 -9
  151. package/dist/flow-requirements.js.map +1 -1
  152. package/dist/gate-contract.d.ts +7 -0
  153. package/dist/gate-contract.d.ts.map +1 -1
  154. package/dist/gate-contract.js +6 -1
  155. package/dist/gate-contract.js.map +1 -1
  156. package/dist/helper-preflight.d.ts.map +1 -1
  157. package/dist/helper-preflight.js +10 -3
  158. package/dist/helper-preflight.js.map +1 -1
  159. package/dist/helper-reference.d.ts +25 -0
  160. package/dist/helper-reference.d.ts.map +1 -0
  161. package/dist/helper-reference.js +129 -0
  162. package/dist/helper-reference.js.map +1 -0
  163. package/dist/index.d.ts +1 -1
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js.map +1 -1
  166. package/dist/journal-client.d.ts +3 -1
  167. package/dist/journal-client.d.ts.map +1 -1
  168. package/dist/journal-client.js +17 -2
  169. package/dist/journal-client.js.map +1 -1
  170. package/dist/journal-projection.d.ts +16 -0
  171. package/dist/journal-projection.d.ts.map +1 -0
  172. package/dist/journal-projection.js +108 -0
  173. package/dist/journal-projection.js.map +1 -0
  174. package/dist/llm-worker.js +1 -1
  175. package/dist/llm-worker.js.map +1 -1
  176. package/dist/local-agent.d.ts +1 -1
  177. package/dist/local-agent.d.ts.map +1 -1
  178. package/dist/local-agent.js +4 -3
  179. package/dist/local-agent.js.map +1 -1
  180. package/dist/named-gate-lowering.js +35 -5
  181. package/dist/named-gate-lowering.js.map +1 -1
  182. package/dist/named-gate-preflight.d.ts +27 -0
  183. package/dist/named-gate-preflight.d.ts.map +1 -0
  184. package/dist/named-gate-preflight.js +47 -0
  185. package/dist/named-gate-preflight.js.map +1 -0
  186. package/dist/named-gates.d.ts +1 -1
  187. package/dist/named-gates.d.ts.map +1 -1
  188. package/dist/named-gates.js +3 -0
  189. package/dist/named-gates.js.map +1 -1
  190. package/dist/observer-link.d.ts +7 -0
  191. package/dist/observer-link.d.ts.map +1 -1
  192. package/dist/observer-link.js +3 -1
  193. package/dist/observer-link.js.map +1 -1
  194. package/dist/preflight.d.ts.map +1 -1
  195. package/dist/preflight.js +7 -0
  196. package/dist/preflight.js.map +1 -1
  197. package/dist/protocol.d.ts +2 -0
  198. package/dist/protocol.d.ts.map +1 -1
  199. package/dist/redact.d.ts +42 -0
  200. package/dist/redact.d.ts.map +1 -1
  201. package/dist/redact.js +128 -0
  202. package/dist/redact.js.map +1 -1
  203. package/dist/run-projection.d.ts +83 -0
  204. package/dist/run-projection.d.ts.map +1 -0
  205. package/dist/run-projection.js +199 -0
  206. package/dist/run-projection.js.map +1 -0
  207. package/dist/shell-word.d.ts +19 -0
  208. package/dist/shell-word.d.ts.map +1 -0
  209. package/dist/shell-word.js +23 -0
  210. package/dist/shell-word.js.map +1 -0
  211. package/dist/spec.d.ts +52 -3
  212. package/dist/spec.d.ts.map +1 -1
  213. package/dist/spec.js.map +1 -1
  214. package/dist/step-fields.d.ts +1 -1
  215. package/dist/step-fields.js +1 -1
  216. package/dist/step-fields.js.map +1 -1
  217. package/dist/steps-green.d.ts +27 -0
  218. package/dist/steps-green.d.ts.map +1 -0
  219. package/dist/steps-green.js +164 -0
  220. package/dist/steps-green.js.map +1 -0
  221. package/dist/validate.d.ts.map +1 -1
  222. package/dist/validate.js +39 -8
  223. package/dist/validate.js.map +1 -1
  224. package/dist/worker-lease.d.ts +8 -1
  225. package/dist/worker-lease.d.ts.map +1 -1
  226. package/dist/worker-lease.js +34 -4
  227. package/dist/worker-lease.js.map +1 -1
  228. package/dist/worker.d.ts +8 -0
  229. package/dist/worker.d.ts.map +1 -1
  230. package/dist/worker.js +17 -9
  231. package/dist/worker.js.map +1 -1
  232. package/package.json +3 -7
  233. package/src/agent-artifacts.ts +2 -10
  234. package/src/agent-cwd.ts +139 -0
  235. package/src/artifact-scan-policy.ts +51 -0
  236. package/src/authored-completion-record.ts +119 -0
  237. package/src/authored-completion.ts +203 -0
  238. package/src/authored-flow-error.ts +13 -1
  239. package/src/authored-flow-executor.ts +91 -58
  240. package/src/authored-human.ts +1 -4
  241. package/src/authored-node-entry.ts +4 -1
  242. package/src/authored-node-runner.ts +96 -22
  243. package/src/authored-root.ts +20 -2
  244. package/src/authored-step-output.ts +139 -42
  245. package/src/authored-verdict.ts +87 -0
  246. package/src/authored-worker-step.ts +60 -12
  247. package/src/cli/check-worker-surface.ts +66 -0
  248. package/src/cli/check.ts +35 -4
  249. package/src/cli/cloud-format.ts +82 -0
  250. package/src/cli/cloud-live.ts +366 -0
  251. package/src/cli/cloud-read.ts +13 -282
  252. package/src/cli/cloud-refusal.ts +113 -0
  253. package/src/cli/cloud-status-view.ts +213 -0
  254. package/src/cli/direct-run.ts +19 -2
  255. package/src/cli/hn-monitor.ts +2 -1
  256. package/src/cli/local-agent-remedy.ts +160 -0
  257. package/src/cli/observer-session.ts +107 -0
  258. package/src/cli/run.ts +233 -28
  259. package/src/cli/status.ts +59 -3
  260. package/src/cli/step-evidence.ts +310 -0
  261. package/src/cli/step-failure.ts +57 -81
  262. package/src/cli-commands.ts +2 -0
  263. package/src/cli.ts +64 -44
  264. package/src/cloud-read.ts +25 -1
  265. package/src/cloud-run-record.ts +64 -0
  266. package/src/cloud-run.ts +5 -24
  267. package/src/communication/local.ts +2 -1
  268. package/src/communication/worker.ts +13 -5
  269. package/src/compile.ts +28 -6
  270. package/src/direct-input.ts +22 -1
  271. package/src/failure-kinds.ts +104 -2
  272. package/src/flow-requirements.ts +24 -9
  273. package/src/gate-contract.ts +13 -1
  274. package/src/helper-preflight.ts +10 -3
  275. package/src/helper-reference.ts +130 -0
  276. package/src/index.ts +2 -0
  277. package/src/journal-client.ts +19 -2
  278. package/src/journal-projection.ts +106 -0
  279. package/src/llm-worker.ts +1 -1
  280. package/src/local-agent.ts +4 -2
  281. package/src/named-gate-lowering.ts +35 -5
  282. package/src/named-gate-preflight.ts +47 -0
  283. package/src/named-gates.ts +3 -0
  284. package/src/observer-link.ts +10 -1
  285. package/src/preflight.ts +7 -0
  286. package/src/protocol.ts +2 -0
  287. package/src/redact.ts +111 -0
  288. package/src/run-projection.ts +247 -0
  289. package/src/shell-word.ts +24 -0
  290. package/src/spec.ts +54 -3
  291. package/src/step-fields.ts +1 -1
  292. package/src/steps-green.ts +168 -0
  293. package/src/validate.ts +35 -8
  294. package/src/worker-lease.ts +35 -5
  295. package/src/worker.ts +27 -11
@@ -71,19 +71,33 @@ process.exit(artifacts.includes(${JSON.stringify(gate.path)})?0:1);`)}`;
71
71
  }
72
72
  const path = gate.type === 'subprocess_gate' ? gate.from_output
73
73
  : gate.type === 'word_count_bounds' ? undefined : gate.in_output_at;
74
+ const pathKey = gate.type === 'subprocess_gate' ? 'from_output' : 'in_output_at';
74
75
  // Only compiler-owned code is serialized. Author strings are JSON literals;
75
76
  // upstream output travels exclusively through FLOWS_INPUT, never shell text.
77
+ //
78
+ // `fail` answers a gate that exits 1 having said nothing. Every exit that is
79
+ // NOT the author's own predicate or command verdict goes through it: a
80
+ // selection that missed, input the child cannot carry, a child that never
81
+ // started, a child killed by a signal. It writes ONE bounded line to fd 2
82
+ // and nothing to fd 1 — `references_input` and `word_count_bounds` verify
83
+ // against stdout, so a stray byte there would change a verdict.
84
+ //
85
+ // `writeSync` rather than `process.stderr.write`: on a pipe the latter is
86
+ // asynchronous, and the `process.exit` on the same line would drop the
87
+ // diagnostic exactly when it is the only account of the failure there is.
76
88
  const setup = `const cp=require('node:child_process');
89
+ const fail=m=>{require('node:fs').writeSync(2,${JSON.stringify(`${gate.type}: `)}+m.replace(/[\\r\\n]+/g,' ').slice(0,400)+'\\n');process.exit(1);};
77
90
  const input=JSON.parse(process.env.FLOWS_INPUT);
78
91
  let value=input.output;
79
92
  const path=${JSON.stringify(path ?? null)};
80
93
  if(path!==null){for(const key of path){
81
94
  if(value===null||typeof value!=='object'||!Object.hasOwn(value,key)||
82
- (typeof key==='number'?!Array.isArray(value):Array.isArray(value)))process.exit(1);
95
+ (typeof key==='number'?!Array.isArray(value):Array.isArray(value)))
96
+ fail(${JSON.stringify(`${pathKey} ${JSON.stringify(path ?? null)} selected nothing: no `)}+JSON.stringify(key)+' at that position in the producer output');
83
97
  value=value[key];
84
98
  }}else if(${deterministic})value=value.stdout_tail;
85
99
  const text=typeof value==='string'?value:JSON.stringify(value);
86
- if(typeof text!=='string')process.exit(1);
100
+ if(typeof text!=='string')fail('the selected value could not be read as text');
87
101
  `;
88
102
  let body: string;
89
103
  switch (gate.type) {
@@ -93,16 +107,32 @@ if(typeof reference!=='string'||reference.length===0||!text.includes(reference))
93
107
  process.stdout.write('references_input:pass');`;
94
108
  break;
95
109
  case 'subprocess_gate':
96
- body = `if(text.includes('\\0'))process.exit(1);
110
+ // `stdio: 'inherit'` hands the child the gate step's own stdout/stderr,
111
+ // which ARE the kernel's capture pipes (relayflowd/src/exec_det.rs:78),
112
+ // so the command's output is journaled as it is produced — including
113
+ // whatever it wrote before a timeout killed the process group. Buffering
114
+ // it for a post-wait flush would lose exactly that. What was missing is
115
+ // below: `status` is null for every outcome that is not an exit, and all
116
+ // of them used to collapse into an indistinguishable bare `exit 1`.
117
+ body = `if(text.includes('\\0'))fail('the selected text contains a NUL byte and cannot be passed to the gate command');
97
118
  const result=cp.spawnSync('/bin/sh',['-c',${JSON.stringify(gate.command)}],{
98
119
  env:{...process.env,INPUT:text},stdio:'inherit'});
120
+ if(result.error)fail('could not run the gate command: '+(result.error.code||result.error.message)+' (the selected input was '+Buffer.byteLength(text)+' bytes)');
121
+ if(result.signal)fail('the gate command was terminated by '+result.signal);
99
122
  process.exit(result.status===0?0:1);`;
100
123
  break;
101
124
  case 'word_count_bounds':
125
+ // The other gate that spawns a child. Its `wc` stderr was piped and then
126
+ // discarded, so a broken or absent `wc` was reported as a word count out
127
+ // of bounds. A bounded suffix of what it said travels with the cause.
102
128
  body = `const result=cp.spawnSync('wc',['-w'],{input:text,encoding:'utf8',env:{...process.env,LC_ALL:'C'}});
103
- if(result.status!==0)process.exit(1);
129
+ const noise=(result.stderr||'').trim().slice(-200);
130
+ const said=noise===''?'':': '+noise;
131
+ if(result.error)fail('could not run wc -w: '+(result.error.code||result.error.message)+said);
132
+ if(result.signal)fail('wc -w was terminated by '+result.signal+said);
133
+ if(result.status!==0)fail('wc -w exited '+result.status+said);
104
134
  const count=result.stdout.trim();
105
- if(!/^[0-9]+$/.test(count))process.exit(1);
135
+ if(!/^[0-9]+$/.test(count))fail('wc -w printed '+JSON.stringify(count.slice(0,80))+' instead of a word count'+said);
106
136
  process.stdout.write(BigInt(count).toString());`;
107
137
  break;
108
138
  case 'regex_match':
@@ -0,0 +1,47 @@
1
+ import { unscannedArtifactPrefix } from './artifact-scan-policy.js';
2
+ import { isNamedGate } from './named-gates.js';
3
+ import type { StepSpec } from './spec.js';
4
+ import type { PreflightWarning } from './preflight.js';
5
+
6
+ /**
7
+ * Static named-gate scan coverage, decided from the compiled snapshot alone.
8
+ *
9
+ * An `artifact_exists` gate lowers to a deterministic step that asks whether
10
+ * the producer's journaled `output.artifacts` list contains the literal path
11
+ * (`named-gate-lowering.ts`). One writer of that list is the bundled agent
12
+ * worker's filesystem scan, which walks the step's cwd under the policy in
13
+ * `artifact-scan-policy.ts` and records nothing under a dot-named or
14
+ * `node_modules` entry. A path inside one of those prefixes can never come
15
+ * from that scan, on any host, in any environment.
16
+ *
17
+ * A warning rather than a refusal, because the scan is not the only writer.
18
+ * The same bundled worker promotes object-shaped JSON stdout — and a completed
19
+ * Relay task's output — straight to the journaled `output` (`worker.ts`), so
20
+ * an agent that reports its own `artifacts` array makes a hidden path pass;
21
+ * the journal protocol lets a custom worker do the same. Which of those an
22
+ * `agent` step will take is not decidable from the spec, so this reports the
23
+ * scan's limitation and leaves the verdict to the run rather than declaring a
24
+ * satisfiable gate impossible.
25
+ *
26
+ * Temporary: this describes #513. When the scan stops excluding these paths,
27
+ * delete this module and its call site.
28
+ */
29
+ export function namedGateScanCoverageDiagnostics(step: StepSpec): PreflightWarning[] {
30
+ const gate = step.verification;
31
+ if (!isNamedGate(gate) || gate.type !== 'artifact_exists') return [];
32
+ // A malformed path is already `gate_path_invalid` from compilation; only
33
+ // validated relative POSIX paths reach here.
34
+ const prefix = unscannedArtifactPrefix(gate.path);
35
+ if (prefix === undefined) return [];
36
+ return [{
37
+ severity: 'warning',
38
+ kind: 'gate_path_unscanned',
39
+ stepId: step.id,
40
+ message: `Step "${step.id}" gates on artifact_exists path "${gate.path}", but the bundled agent worker's `
41
+ + `artifact scan records nothing under "${prefix}": it skips entries named `
42
+ + '".git", ".relayflowd" and "node_modules". The gate reads the journaled output.artifacts and never the disk, so '
43
+ + 'writing the file is not enough: the step has to report the path itself — as object-shaped JSON stdout '
44
+ + 'carrying its own "artifacts" array, as a completed Relay task output, or from a custom worker. If the '
45
+ + 'gate is meant to rest on the scan, write the artifact to a path the scan records.',
46
+ }];
47
+ }
@@ -3,6 +3,9 @@ import type { NamedDataGate, VerificationSpec } from './spec.js';
3
3
 
4
4
  export const NAMED_GATE_FAILURE_KINDS = [
5
5
  'unknown_gate_kind', 'gate_pattern_invalid', 'gate_command_missing', 'gate_bound_invalid', 'gate_path_invalid',
6
+ // steps_green refusals: the gate is declarative like the named gates above,
7
+ // so it reports through the same kind channel a caller already switches on.
8
+ 'gate_host_unsupported', 'gate_ids_invalid', 'gate_source_unsupported',
6
9
  ] as const;
7
10
  export type NamedGateFailureKind = typeof NAMED_GATE_FAILURE_KINDS[number];
8
11
 
@@ -238,6 +238,13 @@ export interface MintObserverOptions {
238
238
  * (default `https://agentrelay.com`). Separate axis from `baseUrl` — the
239
239
  * mint API lives on a different subdomain from the dashboard. */
240
240
  dashboardUrl?: string;
241
+ /**
242
+ * Scope the token to this one channel, so the dashboard opens on it — the
243
+ * run's `wf-<runId>` channel — rather than on whichever channel the
244
+ * workspace lists first. DMs are excluded: a run projects its
245
+ * conversation into that channel. Omitted: the whole workspace.
246
+ */
247
+ channel?: string;
241
248
  fetch?: ObserverFetch;
242
249
  now?: () => number;
243
250
  /** Called for the token's uniquely-suffixed name; injectable for tests. */
@@ -299,7 +306,9 @@ export async function mintObserverUrl(
299
306
  name: `flows-run-${uuid}`,
300
307
  description: 'Auto-minted by `flows run` for the observer dashboard link.',
301
308
  scopes: OBSERVER_SCOPES,
302
- filters: { include_dms: true },
309
+ filters: options.channel === undefined
310
+ ? { include_dms: true }
311
+ : { channel_names: [options.channel], include_dms: false },
303
312
  expires_at: new Date(now() + OBSERVER_TOKEN_TTL_MS).toISOString(),
304
313
  };
305
314
 
package/src/preflight.ts CHANGED
@@ -12,6 +12,7 @@ import { compileSpec, CompileError } from './compile.js';
12
12
  import { helperCall } from './yaml-helpers.js';
13
13
  import { resolveCliModelSelection, type CliModelSource } from './cli-adapter.js';
14
14
  import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js';
15
+ import { namedGateScanCoverageDiagnostics } from './named-gate-preflight.js';
15
16
  import { compileScopes, type ScopeInput, type MountRegistry } from './scope-compiler.js';
16
17
  import { readMountRegistry } from './mount-registry.js';
17
18
  import type {
@@ -242,6 +243,12 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
242
243
  // permissions they declared on the same file are not enforced.
243
244
  diagnostics.push(...permissionsDiagnostics(compiled));
244
245
  diagnostics.push(...scopeDiagnostics(compiled, options));
246
+ // Same rule, same reason: whether the bundled worker's artifact scan can
247
+ // reach an `artifact_exists` path is decidable from the snapshot, so it is
248
+ // collected here rather than beside `probeNamedGate` below — which CLI
249
+ // resolution, an unknown model, a bad scope or a budget refusal all return
250
+ // before.
251
+ diagnostics.push(...compiled.steps.flatMap(namedGateScanCoverageDiagnostics));
245
252
  for (const server of new Set(options.mcpServers ?? [])) {
246
253
  if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue;
247
254
  diagnostics.push({ severity: 'refusal', kind: 'mcp_undeclared_server', server,
package/src/protocol.ts CHANGED
@@ -96,6 +96,8 @@ export type StepUsage =
96
96
  export interface RunStartParams {
97
97
  /** Caller-owned retry identity. Reuse with a different spec is refused. */
98
98
  admission_key?: string;
99
+ /** Push the new run's entries to this connection from `run.spawned` on, as `run.watch` does. */
100
+ watch?: boolean;
99
101
  reuse_from_run_id?: string;
100
102
  /**
101
103
  * The kernel spec dialect — the ONE boundary shape `RunSpec::parse`
package/src/redact.ts CHANGED
@@ -104,3 +104,114 @@ export function redactRelayError(message: string, env: NodeJS.ProcessEnv = proce
104
104
  }
105
105
  return redact(message, env);
106
106
  }
107
+
108
+ /**
109
+ * Credential heads whose value may continue past a line boundary.
110
+ *
111
+ * Every NAMED_VALUE pattern puts `\s` between the name and the value, and
112
+ * `\s` matches newlines: `authorization:` alone at the end of a poll is a
113
+ * complete, unredacted line, while the opaque value that lands on the next
114
+ * poll arrives with no recognizable credential context and would print
115
+ * verbatim. A trailing header — the colon plus only whitespace, or for
116
+ * `authorization` one scheme word — is therefore an open context. A header
117
+ * already followed by a non-space word has its value on that line and is not
118
+ * open. `Bearer` is the one bare scheme word the patterns treat as a name.
119
+ */
120
+ const OPEN_HEADER_PATTERNS: readonly RegExp[] = [
121
+ /\bauthorization:(?:\s+\w+)?\s*$/i,
122
+ /\bx-callback-token:\s*$/i,
123
+ /\bx-nightcto-evidence-token:\s*$/i,
124
+ /\bBearer\s*$/,
125
+ ];
126
+
127
+ /** A quoted JSON field name; the value side is inspected by the scanner. */
128
+ const JSON_FIELD_HEAD = /"([A-Za-z0-9_.\-]{1,64})"/g;
129
+
130
+ /**
131
+ * Where a named-credential context may still be half-arrived at the end of
132
+ * `text`.
133
+ *
134
+ * `openSecretStart` covers secret env *values*; this covers the credential
135
+ * *names* whose patterns can span a line break: a header released on one poll
136
+ * leaves its late-arriving value unrecognizable on the next, and a credential
137
+ * JSON field whose value string is not yet closed leaks its first fragment.
138
+ * Returns the earliest index at which such a context is open — the point past
139
+ * which nothing may be released until more of the stream arrives — or
140
+ * `text.length` when none is open. Holding back is line-granular upstream, so
141
+ * a false positive delays one line one poll rather than dropping it.
142
+ */
143
+ export function openCredentialStart(text: string): number {
144
+ let hold = text.length;
145
+ for (const pattern of OPEN_HEADER_PATTERNS) {
146
+ const match = pattern.exec(text);
147
+ if (match && match.index < hold) hold = match.index;
148
+ }
149
+ JSON_FIELD_HEAD.lastIndex = 0;
150
+ let head: RegExpExecArray | null;
151
+ while ((head = JSON_FIELD_HEAD.exec(text)) !== null) {
152
+ if (!isCredentialName(head[1]!)) continue;
153
+ let i = head.index + head[0].length;
154
+ while (i < text.length && /\s/.test(text[i]!)) i++;
155
+ if (i >= text.length) { hold = Math.min(hold, head.index); continue; }
156
+ if (text[i] !== ':') continue;
157
+ i++;
158
+ while (i < text.length && /\s/.test(text[i]!)) i++;
159
+ if (i >= text.length) { hold = Math.min(hold, head.index); continue; }
160
+ if (text.startsWith('[redacted]', i) || text[i] !== '"') continue;
161
+ i++;
162
+ let closed = false;
163
+ while (i < text.length) {
164
+ if (text[i] === '\\') { i += 2; continue; }
165
+ if (text[i] === '"') { closed = true; break; }
166
+ i++;
167
+ }
168
+ if (!closed) hold = Math.min(hold, head.index);
169
+ }
170
+ return hold;
171
+ }
172
+
173
+ /**
174
+ * Does `text` end on a bare credential header — a named credential line whose
175
+ * value, if it has one, begins on the NEXT line?
176
+ *
177
+ * The stream releaser needs this apart from `openCredentialStart`: that check
178
+ * sees only a header at the very end of the consumed text, but the danger is
179
+ * wider — a released block that *ends* on `x-callback-token:` prints the
180
+ * header now and its value, arriving on the next line in some later poll,
181
+ * without the context `redact` needs. `redact` folds the header's newline into
182
+ * its own named-pattern match, so the releaser's block-prefix check cannot
183
+ * detect the dependency on its own. Trailing whitespace-only lines after the
184
+ * header count as part of it — the value is still to come.
185
+ */
186
+ export function endsWithOpenCredentialHeader(text: string): boolean {
187
+ const stripped = text.replace(/\s+$/, '');
188
+ const lastLine = stripped.slice(stripped.lastIndexOf('\n') + 1);
189
+ return OPEN_HEADER_PATTERNS.some((pattern) => pattern.test(lastLine));
190
+ }
191
+
192
+ /**
193
+ * Where a secret value may still be half-arrived at the end of `text`.
194
+ *
195
+ * A stream is redacted in pieces, and `redact` only replaces a secret it can
196
+ * see whole: releasing text up to a point where a secret has begun but not
197
+ * ended would print the first half of it and never match the second. This
198
+ * returns the smallest index `i` for which `text.slice(i)` is a proper prefix
199
+ * of some secret env value — the point past which nothing may be released
200
+ * until more of the stream arrives — or `text.length` when no value is open.
201
+ *
202
+ * Values only, not the token and header *shapes*: those are bounded by
203
+ * whitespace, so a reader that releases whole lines cannot cut one in half.
204
+ */
205
+ export function openSecretStart(text: string, env: NodeJS.ProcessEnv = process.env): number {
206
+ let start = text.length;
207
+ for (const [, value] of secretEnvValues(env)) {
208
+ // A proper prefix is shorter than the value, so it can only begin inside
209
+ // the last `value.length - 1` characters; the first character narrows the
210
+ // scan to the few positions worth comparing.
211
+ const first = Math.max(0, text.length - value.length + 1);
212
+ for (let at = text.indexOf(value[0]!, first); at >= 0 && at < start; at = text.indexOf(value[0]!, at + 1)) {
213
+ if (value.startsWith(text.slice(at))) { start = at; break; }
214
+ }
215
+ }
216
+ return start;
217
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Project a run's lifecycle into its Relaycast channel, `wf-<runId>`, so the
3
+ * observer dashboard shows the flow: one message per step transition, each
4
+ * carrying the whole run snapshot under `metadata.relayflow` for a renderer
5
+ * that draws the step graph.
6
+ *
7
+ * A projection, never a source of truth (RFC-0001 settled decision 7): the
8
+ * journal is the record. Publication is serialized and fire-and-forget; a
9
+ * failure is reported once through `diagnostic` and never reaches the run.
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+ import { renderProgress, type ProgressEvent } from './progress.js';
14
+ import type { StepType } from './spec.js';
15
+
16
+ export const RELAYFLOW_METADATA_VERSION = 1;
17
+
18
+ export type ProjectedStepState = 'pending' | 'running' | 'completed' | 'failed' | 'parked';
19
+
20
+ export interface ProjectedStep {
21
+ id: string;
22
+ type: StepType;
23
+ dependsOn: string[];
24
+ state: ProjectedStepState;
25
+ attempt?: number;
26
+ elapsedMs?: number;
27
+ completionReason?: string;
28
+ }
29
+
30
+ export interface RunSnapshot {
31
+ runId: string;
32
+ flow: string;
33
+ status: 'running' | 'completed' | 'failed' | 'parked' | 'canceled';
34
+ completionReason?: string;
35
+ steps: ProjectedStep[];
36
+ }
37
+
38
+ export interface DeclaredStep {
39
+ id: string;
40
+ type: StepType;
41
+ dependsOn?: string[];
42
+ }
43
+
44
+ export interface RunProjection {
45
+ readonly channel: string;
46
+ /** Apply a transition; `publish: false` folds replayed history into the snapshot silently. */
47
+ step(event: ProgressEvent & { attempt?: number }, publish?: boolean): void;
48
+ /** Rebuild an epoch boundary from journal facts, never from old terminal state. */
49
+ epoch(states: Array<{ id: string; state: ProjectedStepState; attempt?: number; completionReason?: string }>, publish?: boolean): void;
50
+ /** Close the run; only the first call publishes. */
51
+ finish(outcome: { status: RunSnapshot['status']; completionReason?: string }): void;
52
+ /** Resolves when every queued publication settled, or after `timeoutMs`. */
53
+ drain(timeoutMs: number): Promise<void>;
54
+ /** Stop publication and remove this session's publisher after its queue settles. */
55
+ close(timeoutMs: number): Promise<void>;
56
+ }
57
+
58
+ export type ProjectionFetch = (url: string, init: {
59
+ method: string;
60
+ headers: Record<string, string>;
61
+ body?: string;
62
+ signal?: AbortSignal;
63
+ }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
64
+
65
+ export interface RunProjectionOptions {
66
+ workspaceKey: string;
67
+ /** Relaycast API base, default `https://cast.agentrelay.com`. */
68
+ baseUrl?: string;
69
+ fetch?: ProjectionFetch;
70
+ diagnostic: (message: string) => void;
71
+ }
72
+
73
+ const DEFAULT_BASE_URL = 'https://cast.agentrelay.com';
74
+ const REQUEST_TIMEOUT_MS = 5_000;
75
+
76
+ export function runChannelName(runId: string): string {
77
+ return `wf-${runId.toLowerCase()}`;
78
+ }
79
+
80
+ export function createRunProjection(
81
+ options: RunProjectionOptions,
82
+ run: { runId: string; flow: string; steps?: DeclaredStep[]; resumed?: boolean },
83
+ ): RunProjection {
84
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as ProjectionFetch);
85
+ const base = options.baseUrl ?? DEFAULT_BASE_URL;
86
+ const channel = runChannelName(run.runId);
87
+ const snapshot: RunSnapshot = {
88
+ runId: run.runId, flow: run.flow, status: 'running',
89
+ steps: (run.steps ?? []).map(step => ({
90
+ id: step.id, type: step.type, dependsOn: step.dependsOn ?? [], state: 'pending',
91
+ })),
92
+ };
93
+
94
+ const call = async (path: string, token: string, body?: unknown, idempotencyKey?: string, method?: string): Promise<unknown> => {
95
+ const response = await doFetch(new URL(`/v1${path}`, base).toString(), {
96
+ method: method ?? (body === undefined ? 'GET' : 'POST'),
97
+ headers: {
98
+ Authorization: `Bearer ${token}`,
99
+ 'Content-Type': 'application/json',
100
+ ...(idempotencyKey === undefined ? {} : { 'Idempotency-Key': idempotencyKey }),
101
+ },
102
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
103
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
104
+ });
105
+ const parsed = await response.json().catch(() => undefined) as
106
+ { data?: unknown; error?: { code?: string } } | undefined;
107
+ if (!response.ok) {
108
+ throw new RelaycastError(response.status, parsed?.error?.code ?? `http_${response.status}`);
109
+ }
110
+ return parsed?.data;
111
+ };
112
+
113
+ // A per-session publisher: agent names are unique per workspace, and a
114
+ // resumed run publishes from a fresh session into the same channel.
115
+ const publisherName = `flow-${randomUUID()}`;
116
+ let cleanup: Promise<void> | undefined;
117
+ const removePublisher = (reportFailure = true): Promise<void> => cleanup ??= (async () => {
118
+ // The unique name is known before POST. A lost create response is not
119
+ // proof that creation failed: retire it even after an ambiguous outcome.
120
+ try {
121
+ await call(`/agents/${encodeURIComponent(publisherName)}`, options.workspaceKey, undefined, undefined, 'DELETE');
122
+ } catch (error) {
123
+ if (error instanceof RelaycastError && error.status === 404) return;
124
+ if (reportFailure) options.diagnostic(`publisher cleanup for #${channel} failed; the run is unaffected`);
125
+ }
126
+ })();
127
+ const setup = (async (): Promise<string> => {
128
+ const agent = await call('/agents', options.workspaceKey, {
129
+ name: publisherName, type: 'agent', auto_join_general: false,
130
+ }) as { token?: unknown } | undefined;
131
+ if (typeof agent?.token !== 'string') throw new RelaycastError(0, 'agent_token_missing');
132
+ const token = agent.token;
133
+ try {
134
+ await call('/channels', token, { name: channel, topic: `relayflow ${run.flow} · run ${run.runId}` });
135
+ } catch (error) {
136
+ // Agent communication in the same run may have created it first.
137
+ if (!(error instanceof RelaycastError) || error.code !== 'channel_already_exists') throw error;
138
+ await call(`/channels/${encodeURIComponent(channel)}/join`, token, {});
139
+ }
140
+ return token;
141
+ })().catch(async (error: unknown) => { await removePublisher(false); throw error; });
142
+
143
+ let queue: Promise<unknown> = setup;
144
+ let failed = false;
145
+ let sequence = 0;
146
+ let closed = false;
147
+ const sessionId = randomUUID().slice(0, 8);
148
+ const publish = (event: string, text: string): void => {
149
+ if (closed) return;
150
+ const data = { relayflow: { version: RELAYFLOW_METADATA_VERSION, event, run: structuredClone(snapshot) } };
151
+ const key = `${sessionId}-${++sequence}`;
152
+ queue = queue.then(async () => {
153
+ if (failed) return;
154
+ await call(`/channels/${encodeURIComponent(channel)}/messages`, await setup, { text, data }, key);
155
+ }).catch((error: unknown) => {
156
+ if (failed) return;
157
+ failed = true;
158
+ options.diagnostic(`run projection to #${channel} failed (${errorMessage(error)}); `
159
+ + 'the observer will not show this run, which is unaffected');
160
+ });
161
+ };
162
+
163
+ const declared = snapshot.steps.length;
164
+ publish('run.started', `▶ ${run.flow} ${run.resumed ? 'resumed' : 'started'}`
165
+ + `${declared > 0 ? ` · ${declared} step${declared === 1 ? '' : 's'}` : ''} · run ${run.runId}`);
166
+
167
+ return {
168
+ channel,
169
+ epoch(states, shouldPublish = true) {
170
+ snapshot.status = 'running';
171
+ delete snapshot.completionReason;
172
+ for (const step of snapshot.steps) {
173
+ step.state = 'pending';
174
+ delete step.attempt;
175
+ delete step.elapsedMs;
176
+ delete step.completionReason;
177
+ const state = states.find(candidate => candidate.id === step.id);
178
+ if (state !== undefined) Object.assign(step, state);
179
+ }
180
+ if (shouldPublish) publish('run.started', `▶ ${run.flow} resumed · run ${run.runId}`);
181
+ },
182
+ step(event, shouldPublish = true) {
183
+ let step = snapshot.steps.find(candidate => candidate.id === event.stepId);
184
+ if (step === undefined) {
185
+ step = { id: event.stepId, type: event.stepType, dependsOn: [], state: 'pending' };
186
+ snapshot.steps.push(step);
187
+ }
188
+ step.state = STATE[event.type];
189
+ if (event.attempt !== undefined) step.attempt = event.attempt;
190
+ if (event.type === 'step.started') {
191
+ delete step.elapsedMs;
192
+ delete step.completionReason;
193
+ } else {
194
+ step.elapsedMs = Math.max(0, Math.round(event.elapsedMs));
195
+ }
196
+ if (event.completionReason !== undefined) step.completionReason = event.completionReason;
197
+ if (!shouldPublish || event.type === 'step.running') return;
198
+ publish(event.type, event.type === 'step.started'
199
+ ? `○ ${step.id} (${step.type}) started${step.attempt !== undefined && step.attempt > 1 ? ` · attempt ${step.attempt}` : ''}`
200
+ : renderProgress([event])[0]!);
201
+ },
202
+ finish(outcome) {
203
+ if (snapshot.status !== 'running') return;
204
+ snapshot.status = outcome.status;
205
+ if (outcome.completionReason !== undefined) snapshot.completionReason = outcome.completionReason;
206
+ const icon = outcome.status === 'completed' ? '■' : outcome.status === 'parked' ? '⏸' : '✗';
207
+ publish('run.completed', `${icon} ${run.flow} ${outcome.status}`
208
+ + `${outcome.completionReason === undefined ? '' : ` · completionReason: ${outcome.completionReason}`}`);
209
+ },
210
+ async drain(timeoutMs) {
211
+ let timer: NodeJS.Timeout | undefined;
212
+ await Promise.race([
213
+ queue,
214
+ new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); }),
215
+ ]);
216
+ clearTimeout(timer);
217
+ },
218
+ async close(timeoutMs) {
219
+ if (!closed) {
220
+ closed = true;
221
+ queue = queue.then(() => removePublisher(), () => removePublisher());
222
+ }
223
+ let timer: NodeJS.Timeout | undefined;
224
+ try {
225
+ await Promise.race([queue, new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); })]);
226
+ } finally { clearTimeout(timer); }
227
+ },
228
+ };
229
+ }
230
+
231
+ const STATE: Record<ProgressEvent['type'], ProjectedStepState> = {
232
+ 'step.started': 'running',
233
+ 'step.running': 'running',
234
+ 'step.completed': 'completed',
235
+ 'step.failed': 'failed',
236
+ 'step.parked': 'parked',
237
+ };
238
+
239
+ class RelaycastError extends Error {
240
+ constructor(readonly status: number, readonly code: string) {
241
+ super(status === 0 ? code : `HTTP ${status} ${code}`);
242
+ }
243
+ }
244
+
245
+ function errorMessage(error: unknown): string {
246
+ return error instanceof Error ? error.message : String(error);
247
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * POSIX shell quoting for the invocations diagnostics tell people to run.
3
+ *
4
+ * Dependency-free on purpose: every module that renders a command reaches for
5
+ * this, and two of them (`cli/run.ts` and `authored-human.ts`) already import
6
+ * each other's neighbours. A copy per call site is how one of them ends up
7
+ * emitting a path with a space in it unquoted.
8
+ */
9
+
10
+ /** `value` as a single word, quoted only when it would not survive a shell. */
11
+ export function shellWord(value: string): string {
12
+ return /^[A-Za-z0-9_./=:@%+,-]+$/.test(value) ? value : shellQuote(value);
13
+ }
14
+
15
+ /**
16
+ * `value` as a single-quoted word, always.
17
+ *
18
+ * Used where the rendered argument is arbitrary author data — a flow path or a
19
+ * JSON `--input` — and the quotes are worth keeping even when unnecessary, so
20
+ * the command reads the same for `a.flow.ts` and for `my flows/a.flow.ts`.
21
+ */
22
+ export function shellQuote(value: string): string {
23
+ return `'${value.replaceAll("'", "'\\''")}'`;
24
+ }