@relayflows/sdk 2.0.16 → 2.0.18

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 (184) hide show
  1. package/dist/authored-flow-error.d.ts +21 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js +14 -0
  4. package/dist/authored-flow-error.js.map +1 -1
  5. package/dist/authored-flow-executor.d.ts.map +1 -1
  6. package/dist/authored-flow-executor.js +133 -3
  7. package/dist/authored-flow-executor.js.map +1 -1
  8. package/dist/authored-flow-lifecycle.d.ts +10 -0
  9. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  10. package/dist/authored-flow-lifecycle.js +7 -0
  11. package/dist/authored-flow-lifecycle.js.map +1 -1
  12. package/dist/authored-flow-operation.d.ts +11 -0
  13. package/dist/authored-flow-operation.d.ts.map +1 -1
  14. package/dist/authored-flow-operation.js +32 -7
  15. package/dist/authored-flow-operation.js.map +1 -1
  16. package/dist/authored-human.d.ts +49 -0
  17. package/dist/authored-human.d.ts.map +1 -0
  18. package/dist/authored-human.js +97 -0
  19. package/dist/authored-human.js.map +1 -0
  20. package/dist/authored-node-entry.js +3 -2
  21. package/dist/authored-node-entry.js.map +1 -1
  22. package/dist/authored-node-runner.d.ts.map +1 -1
  23. package/dist/authored-node-runner.js +52 -18
  24. package/dist/authored-node-runner.js.map +1 -1
  25. package/dist/authored-root.d.ts.map +1 -1
  26. package/dist/authored-root.js +29 -1
  27. package/dist/authored-root.js.map +1 -1
  28. package/dist/authored-worker-step.d.ts.map +1 -1
  29. package/dist/authored-worker-step.js +14 -25
  30. package/dist/authored-worker-step.js.map +1 -1
  31. package/dist/cli/answer.d.ts +17 -0
  32. package/dist/cli/answer.d.ts.map +1 -0
  33. package/dist/cli/answer.js +89 -0
  34. package/dist/cli/answer.js.map +1 -0
  35. package/dist/cli/build.js +1 -1
  36. package/dist/cli/build.js.map +1 -1
  37. package/dist/cli/check-triggers.d.ts.map +1 -1
  38. package/dist/cli/check-triggers.js +12 -0
  39. package/dist/cli/check-triggers.js.map +1 -1
  40. package/dist/cli/check.d.ts +25 -0
  41. package/dist/cli/check.d.ts.map +1 -1
  42. package/dist/cli/check.js +14 -2
  43. package/dist/cli/check.js.map +1 -1
  44. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  45. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  46. package/dist/cli/cloud-connect-cli.js +109 -0
  47. package/dist/cli/cloud-connect-cli.js.map +1 -0
  48. package/dist/cli/cloud-deploy.d.ts +3 -1
  49. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  50. package/dist/cli/cloud-deploy.js +44 -6
  51. package/dist/cli/cloud-deploy.js.map +1 -1
  52. package/dist/cli/cloud-run.d.ts +2 -1
  53. package/dist/cli/cloud-run.d.ts.map +1 -1
  54. package/dist/cli/cloud-run.js +17 -3
  55. package/dist/cli/cloud-run.js.map +1 -1
  56. package/dist/cli/cloud-schedule.d.ts +31 -0
  57. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  58. package/dist/cli/cloud-schedule.js +156 -0
  59. package/dist/cli/cloud-schedule.js.map +1 -0
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +6 -2
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/run.d.ts +22 -2
  64. package/dist/cli/run.d.ts.map +1 -1
  65. package/dist/cli/run.js +39 -4
  66. package/dist/cli/run.js.map +1 -1
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +148 -8
  69. package/dist/cli.js.map +1 -1
  70. package/dist/cloud-connect.d.ts +52 -0
  71. package/dist/cloud-connect.d.ts.map +1 -0
  72. package/dist/cloud-connect.js +138 -0
  73. package/dist/cloud-connect.js.map +1 -0
  74. package/dist/cloud-deploy.d.ts +19 -1
  75. package/dist/cloud-deploy.d.ts.map +1 -1
  76. package/dist/cloud-deploy.js +52 -2
  77. package/dist/cloud-deploy.js.map +1 -1
  78. package/dist/cloud-http.d.ts +17 -2
  79. package/dist/cloud-http.d.ts.map +1 -1
  80. package/dist/cloud-http.js +44 -8
  81. package/dist/cloud-http.js.map +1 -1
  82. package/dist/cloud-run.d.ts +30 -0
  83. package/dist/cloud-run.d.ts.map +1 -1
  84. package/dist/cloud-run.js +70 -27
  85. package/dist/cloud-run.js.map +1 -1
  86. package/dist/cloud-schedule.d.ts +55 -0
  87. package/dist/cloud-schedule.d.ts.map +1 -0
  88. package/dist/cloud-schedule.js +146 -0
  89. package/dist/cloud-schedule.js.map +1 -0
  90. package/dist/failure-kinds.d.ts +3 -3
  91. package/dist/failure-kinds.d.ts.map +1 -1
  92. package/dist/failure-kinds.js +4 -0
  93. package/dist/failure-kinds.js.map +1 -1
  94. package/dist/flow-requirements.d.ts +68 -0
  95. package/dist/flow-requirements.d.ts.map +1 -0
  96. package/dist/flow-requirements.js +152 -0
  97. package/dist/flow-requirements.js.map +1 -0
  98. package/dist/index.d.ts +5 -0
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +5 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/journal-client.d.ts +11 -0
  103. package/dist/journal-client.d.ts.map +1 -1
  104. package/dist/journal-client.js +13 -0
  105. package/dist/journal-client.js.map +1 -1
  106. package/dist/named-gate-lowering.js +9 -0
  107. package/dist/named-gate-lowering.js.map +1 -1
  108. package/dist/named-gates.d.ts +1 -1
  109. package/dist/named-gates.d.ts.map +1 -1
  110. package/dist/named-gates.js +11 -1
  111. package/dist/named-gates.js.map +1 -1
  112. package/dist/preflight.d.ts.map +1 -1
  113. package/dist/preflight.js +155 -9
  114. package/dist/preflight.js.map +1 -1
  115. package/dist/progress.d.ts +2 -1
  116. package/dist/progress.d.ts.map +1 -1
  117. package/dist/progress.js +9 -3
  118. package/dist/progress.js.map +1 -1
  119. package/dist/protocol.d.ts +25 -1
  120. package/dist/protocol.d.ts.map +1 -1
  121. package/dist/schedule-trigger.d.ts +35 -0
  122. package/dist/schedule-trigger.d.ts.map +1 -0
  123. package/dist/schedule-trigger.js +53 -0
  124. package/dist/schedule-trigger.js.map +1 -0
  125. package/dist/spec.d.ts +11 -1
  126. package/dist/spec.d.ts.map +1 -1
  127. package/dist/spec.js.map +1 -1
  128. package/dist/trigger-executor.js +1 -1
  129. package/dist/trigger-executor.js.map +1 -1
  130. package/dist/validate.js +1 -1
  131. package/dist/validate.js.map +1 -1
  132. package/dist/worker-cli.d.ts +10 -0
  133. package/dist/worker-cli.d.ts.map +1 -1
  134. package/dist/worker-cli.js +51 -22
  135. package/dist/worker-cli.js.map +1 -1
  136. package/dist/worker.d.ts.map +1 -1
  137. package/dist/worker.js +9 -0
  138. package/dist/worker.js.map +1 -1
  139. package/dist/wrapper-session.d.ts +3 -1
  140. package/dist/wrapper-session.d.ts.map +1 -1
  141. package/dist/wrapper-session.js +6 -3
  142. package/dist/wrapper-session.js.map +1 -1
  143. package/package.json +3 -3
  144. package/src/authored-flow-error.ts +30 -0
  145. package/src/authored-flow-executor.ts +141 -2
  146. package/src/authored-flow-lifecycle.ts +7 -0
  147. package/src/authored-flow-operation.ts +35 -10
  148. package/src/authored-human.ts +150 -0
  149. package/src/authored-node-entry.ts +3 -2
  150. package/src/authored-node-runner.ts +55 -18
  151. package/src/authored-root.ts +28 -1
  152. package/src/authored-worker-step.ts +15 -26
  153. package/src/cli/answer.ts +98 -0
  154. package/src/cli/build.ts +1 -1
  155. package/src/cli/check-triggers.ts +11 -0
  156. package/src/cli/check.ts +39 -2
  157. package/src/cli/cloud-connect-cli.ts +130 -0
  158. package/src/cli/cloud-deploy.ts +40 -6
  159. package/src/cli/cloud-run.ts +16 -4
  160. package/src/cli/cloud-schedule.ts +160 -0
  161. package/src/cli/direct-run.ts +6 -1
  162. package/src/cli/run.ts +53 -5
  163. package/src/cli.ts +134 -9
  164. package/src/cloud-connect.ts +187 -0
  165. package/src/cloud-deploy.ts +69 -3
  166. package/src/cloud-http.ts +52 -8
  167. package/src/cloud-run.ts +99 -26
  168. package/src/cloud-schedule.ts +175 -0
  169. package/src/failure-kinds.ts +4 -0
  170. package/src/flow-requirements.ts +201 -0
  171. package/src/index.ts +14 -0
  172. package/src/journal-client.ts +20 -0
  173. package/src/named-gate-lowering.ts +9 -0
  174. package/src/named-gates.ts +11 -1
  175. package/src/preflight.ts +132 -9
  176. package/src/progress.ts +11 -4
  177. package/src/protocol.ts +23 -0
  178. package/src/schedule-trigger.ts +73 -0
  179. package/src/spec.ts +12 -1
  180. package/src/trigger-executor.ts +1 -1
  181. package/src/validate.ts +1 -1
  182. package/src/worker-cli.ts +41 -0
  183. package/src/worker.ts +9 -0
  184. package/src/wrapper-session.ts +5 -1
package/src/preflight.ts CHANGED
@@ -133,7 +133,9 @@ export function preflightWebhookTriggers(
133
133
  triggers: readonly TriggerSource[],
134
134
  executors: readonly string[],
135
135
  ): PreflightRefusal[] {
136
- return [...new Set(triggers.map(trigger => trigger.name))]
136
+ // Schedule sources are driven by the CLI's own tick runner, not an inbox
137
+ // executor, so they need no flows.json registration.
138
+ return [...new Set(triggers.filter(trigger => trigger.kind === 'webhook').map(trigger => trigger.name))]
137
139
  .filter(name => !executors.includes(name))
138
140
  .map(name => ({
139
141
  severity: 'refusal',
@@ -584,8 +586,8 @@ function warnOnUnprovableEffects(
584
586
  diagnostics: PreflightDiagnostic[],
585
587
  ): void {
586
588
  if (step.type !== 'deterministic') return;
587
- const binary = firstCommandWord(step.command);
588
- if (binary === undefined) {
589
+ const first = firstCommandWordDetailed(step.command);
590
+ if (first === undefined) {
589
591
  diagnostics.push({
590
592
  severity: 'warning',
591
593
  kind: 'command_unprovable',
@@ -594,6 +596,19 @@ function warnOnUnprovableEffects(
594
596
  });
595
597
  return;
596
598
  }
599
+ if (first.shell) {
600
+ // `set -e`, `if …`, `cd …`: the shell supplies these, so there is nothing
601
+ // to resolve on PATH — and nothing to refuse. What they go on to run is
602
+ // the rest of the script, whose effects preflight never claimed to prove.
603
+ diagnostics.push({
604
+ severity: 'warning',
605
+ kind: 'unprovable_effects',
606
+ stepId: step.id,
607
+ message: `Step "${step.id}" starts with the shell ${first.kind} "${first.word}", whose effects cannot be proven before execution.`,
608
+ });
609
+ return;
610
+ }
611
+ const binary = first.word;
597
612
  let exists: boolean;
598
613
  try {
599
614
  exists = probes.command(binary);
@@ -628,7 +643,115 @@ function warnOnUnprovableEffects(
628
643
  });
629
644
  }
630
645
 
646
+ /** POSIX special builtins and reserved words: the shell supplies them, PATH never does. */
647
+ const SHELL_SPECIAL_BUILTINS = new Set([
648
+ '.', ':', 'break', 'continue', 'eval', 'exec', 'exit', 'export', 'readonly', 'return', 'set',
649
+ 'shift', 'times', 'trap', 'unset',
650
+ // Regular builtins that no sane flow ships as an executable.
651
+ 'cd', 'alias', 'unalias', 'local', 'source', 'wait', 'umask', 'ulimit', 'read', 'command', 'type',
652
+ ]);
653
+ const SHELL_RESERVED_WORDS = new Set([
654
+ 'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in',
655
+ 'function', 'select', 'time', '{', '}', '(', ')', '!', '[[', ']]',
656
+ ]);
657
+
658
+ interface FirstCommandWord {
659
+ word: string;
660
+ /** True when the shell itself provides the word, so there is nothing to look up. */
661
+ shell: boolean;
662
+ kind: 'command' | 'builtin' | 'reserved word';
663
+ }
664
+
631
665
  function firstCommandWord(command: string): string | undefined {
666
+ return firstCommandWordDetailed(command)?.word;
667
+ }
668
+
669
+ function firstCommandWordDetailed(command: string): FirstCommandWord | undefined {
670
+ // A lightweight lexical pass — not a shell parser. It walks the script once,
671
+ // quote-aware: comments outside quotes are dropped, quoted text is opaque
672
+ // (an apostrophe in `# don't` or a `<<EOF` inside `VALUE='<<EOF'` is data),
673
+ // and the script is split into simple-command segments on unquoted `;`,
674
+ // `&&`, `||`, `|` and newlines. The first segment that is not only
675
+ // assignments and redirections holds the command word (cloud#3777).
676
+ //
677
+ // A heredoc opener or a quote left open at the end of the script means the
678
+ // following text is data the shell never executes; if no command word was
679
+ // found before it, the answer is "cannot be proven", not "missing".
680
+ const segments = lexSimpleCommands(command);
681
+ if (segments === undefined) return undefined;
682
+ for (const segment of segments) {
683
+ const remainder = stripShellPrefixes(segment);
684
+ if (remainder === '') continue;
685
+ const match = remainder.match(/^(?:"([^"]*)"|'([^']*)'|([^\s]+))/);
686
+ const word = match?.[1] ?? match?.[2] ?? match?.[3];
687
+ if (word === undefined || word === '') continue;
688
+ if (SHELL_SPECIAL_BUILTINS.has(word)) return { word, shell: true, kind: 'builtin' };
689
+ if (SHELL_RESERVED_WORDS.has(word)) return { word, shell: true, kind: 'reserved word' };
690
+ return { word, shell: false, kind: 'command' };
691
+ }
692
+ return undefined;
693
+ }
694
+
695
+ /**
696
+ * Split a script into simple-command segments, comments removed, quotes kept.
697
+ * Returns undefined when a heredoc or an unclosed quote begins before any
698
+ * segment could be completed past it — the rest is data.
699
+ */
700
+ function lexSimpleCommands(script: string): string[] | undefined {
701
+ const segments: string[] = [];
702
+ let current = '';
703
+ let quote: string | undefined;
704
+ let heredoc = false;
705
+ const flush = (): void => {
706
+ if (current.trim() !== '') segments.push(current.trim());
707
+ current = '';
708
+ };
709
+ for (let i = 0; i < script.length; i += 1) {
710
+ const char = script[i]!;
711
+ if (quote !== undefined) {
712
+ current += char;
713
+ if (char === '\\' && quote === '"' && i + 1 < script.length) { current += script[++i]; continue; }
714
+ if (char === quote) quote = undefined;
715
+ continue;
716
+ }
717
+ if (char === '\\' && i + 1 < script.length) { current += char + script[++i]; continue; }
718
+ if (char === '"' || char === "'") { quote = char; current += char; continue; }
719
+ if (char === '#' && (current === '' || /\s$/.test(current))) {
720
+ // Comment to end of line, outside quotes only.
721
+ while (i + 1 < script.length && script[i + 1] !== '\n') i += 1;
722
+ continue;
723
+ }
724
+ if (char === '<' && script[i + 1] === '<') {
725
+ // A heredoc: everything after this line is its body, so the current
726
+ // segment is the last one the shell reads as a command.
727
+ heredoc = true;
728
+ while (i + 1 < script.length && script[i + 1] !== '\n') i += 1;
729
+ flush();
730
+ break;
731
+ }
732
+ if (char === '\n' || char === ';') { flush(); continue; }
733
+ if ((char === '&' && script[i + 1] === '&') || (char === '|' && script[i + 1] === '|')) { flush(); i += 1; continue; }
734
+ if (char === '|') { flush(); continue; }
735
+ if (char === '&') {
736
+ // `&` is a boundary only as a background operator. In `2>&1`, `>&2`,
737
+ // `<&0` and `&>file` it is part of a redirection: the `&` after a `>`
738
+ // or `<` (with an optional fd number before that), or the `&` that
739
+ // starts `&>`.
740
+ const afterRedirect = /[<>]\s*$/.test(current);
741
+ if (afterRedirect || script[i + 1] === '>') { current += char; continue; }
742
+ flush();
743
+ continue;
744
+ }
745
+ current += char;
746
+ }
747
+ if (!heredoc && quote === undefined) flush();
748
+ // Whatever was accumulated when a heredoc or an open quote cut the scan is
749
+ // not a complete command; only the segments closed before it count.
750
+ if (segments.length === 0 && (heredoc || quote !== undefined)) return undefined;
751
+ return segments;
752
+ }
753
+
754
+ function stripShellPrefixes(segment: string): string {
632
755
  // Skip the shell prefixes that can legally precede the command word.
633
756
  //
634
757
  // Review caught this on PR #47: the new path-like refusal keys on the first
@@ -638,16 +761,16 @@ function firstCommandWord(command: string): string | undefined {
638
761
  // which is exactly the "refusing would reject valid flows" failure the warn
639
762
  // behaviour exists to avoid.
640
763
  //
641
- // An assignment is NAME=value with a shell-legal name; a redirection starts
642
- // with < or > (optionally with a leading fd number). Neither is the command.
643
- let rest = command.trim();
764
+ // An assignment is NAME=value with a shell-legal name and an opaque quoted
765
+ // value; a redirection starts with < or > (optionally with a leading fd
766
+ // number). Neither is the command. Segments never contain `;`/newlines.
767
+ let rest = segment;
644
768
  for (;;) {
645
- const prefix = rest.match(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)|[0-9]*[<>]{1,2}\s*[^\s]+)\s+/);
769
+ const prefix = rest.match(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]*)|(?:[0-9]*[<>]{1,2}&?|&>>?)\s*[^\s]+)(?:\s+|$)/);
646
770
  if (prefix === null) break;
647
771
  rest = rest.slice(prefix[0].length);
648
772
  }
649
- const match = rest.match(/^(?:"([^"]+)"|'([^']+)'|([^\s]+))/);
650
- return match?.[1] ?? match?.[2] ?? match?.[3];
773
+ return rest.trim();
651
774
  }
652
775
 
653
776
  function probeNamedGate(step: StepSpec, probes: PreflightProbes, diagnostics: PreflightDiagnostic[]): void {
package/src/progress.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { CompletionReason } from './protocol.js';
2
2
  import type { StepType } from './spec.js';
3
+ import { AuthoredHumanParked } from './authored-flow-error.js';
3
4
 
4
5
  export interface ProgressEvent {
5
- type: 'step.started' | 'step.running' | 'step.completed' | 'step.failed';
6
+ /** `step.parked`: an `f.human` reached without an answer; the run parks, nothing failed. */
7
+ type: 'step.started' | 'step.running' | 'step.completed' | 'step.failed' | 'step.parked';
6
8
  stepId: string;
7
9
  stepType: StepType;
8
10
  elapsedMs: number;
@@ -12,13 +14,15 @@ export interface ProgressEvent {
12
14
  /** Pure terminal rendering: caller owns the event source, clock, and output. */
13
15
  export function renderProgress(events: Iterable<ProgressEvent>): string[] {
14
16
  return Array.from(events, event => {
15
- const icon = { 'step.started': '○', 'step.running': '↻', 'step.completed': '✓', 'step.failed': '✗' }[event.type];
17
+ const icon = { 'step.started': '○', 'step.running': '↻', 'step.completed': '✓', 'step.failed': '✗', 'step.parked': '⏸' }[event.type];
16
18
  const state = event.type.slice('step.'.length);
19
+ // A parked `f.human` is a question, not a step type the kernel ran.
20
+ const kind = event.type === 'step.parked' ? 'human' : event.stepType;
17
21
  const agent = event.stepType === 'agent' ? ` [agent: ${state === 'started' ? 'preparing' : state}]` : '';
18
22
  const reason = event.completionReason ? ` completionReason: ${event.completionReason}` : '';
19
23
  // Agent-authored names cannot inject terminal control sequences.
20
24
  const name = event.stepId.replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
21
- return `${icon} ${name} (${event.stepType})${agent} ${(Math.max(0, event.elapsedMs) / 1000).toFixed(2)}s${reason}`;
25
+ return `${icon} ${name} (${kind})${agent} ${(Math.max(0, event.elapsedMs) / 1000).toFixed(2)}s${reason}`;
22
26
  });
23
27
  }
24
28
 
@@ -45,7 +49,10 @@ export async function observeStep<T>(
45
49
  publish({ type: 'step.completed', stepId, stepType, elapsedMs: performance.now() - started, completionReason: 'success' });
46
50
  return result;
47
51
  } catch (error) {
48
- publish({ type: 'step.failed', stepId, stepType, elapsedMs: performance.now() - started });
52
+ publish({
53
+ type: error instanceof AuthoredHumanParked ? 'step.parked' : 'step.failed',
54
+ stepId, stepType, elapsedMs: performance.now() - started,
55
+ });
49
56
  throw error;
50
57
  }
51
58
  }
package/src/protocol.ts CHANGED
@@ -52,6 +52,7 @@ export type Verb =
52
52
  | 'effect.record'
53
53
  | 'effect.confirm'
54
54
  | 'step.complete'
55
+ | 'step.wait'
55
56
  | 'event.emit'
56
57
  | 'event.submit'
57
58
  | 'stream.append'
@@ -329,6 +330,27 @@ export interface StepCompleteParams {
329
330
  }
330
331
  export type StepCompleteResult = RunOutcome;
331
332
 
333
+ /**
334
+ * Park a leased attempt on a durable human question (`wait.human`, kernel
335
+ * DESIGN.md §1.5). The attempt is not completed and no iteration is charged;
336
+ * the lease is released once the wait is journaled. The answer arrives through
337
+ * `event.emit` with `event_key` = `wait_id`, which closes the wait as
338
+ * `human_responded` and makes the step runnable for a fresh attempt.
339
+ */
340
+ export interface StepWaitParams {
341
+ run_id: string;
342
+ step_id: string;
343
+ attempt: number;
344
+ idempotency_key: string;
345
+ /** Names the question for the life of the run; the kernel refuses a reuse. */
346
+ wait_id: string;
347
+ prompt: string;
348
+ requested_of: string;
349
+ options?: string[];
350
+ timeout_at_ms?: number;
351
+ }
352
+ export type StepWaitResult = RunOutcome;
353
+
332
354
  export interface EventEmitParams {
333
355
  run_id: string;
334
356
  event_key: string;
@@ -401,6 +423,7 @@ export interface VerbContract {
401
423
  'effect.record': { params: EffectRecordParams; result: EffectRecordResult };
402
424
  'effect.confirm': { params: EffectConfirmParams; result: EffectConfirmResult };
403
425
  'step.complete': { params: StepCompleteParams; result: StepCompleteResult };
426
+ 'step.wait': { params: StepWaitParams; result: StepWaitResult };
404
427
  'event.emit': { params: EventEmitParams; result: EventEmitResult };
405
428
  'event.submit': { params: EventSubmitParams; result: EventSubmitResult };
406
429
  'stream.append': { params: StreamAppendParams; result: StreamAppendResult };
@@ -0,0 +1,73 @@
1
+ import {
2
+ TICK_EVENT_TYPE, TICK_DEDUPE_KEY_TEMPLATE,
3
+ } from './tick-source.js';
4
+ import { cronMaxGapMs, parseCron, scheduleIdFor, type ScheduleTriggerSource } from '@relayflows/surface';
5
+ import type { TriggerSpec } from './spec.js';
6
+
7
+ /**
8
+ * The executor name a schedule subscription is registered under. It is the
9
+ * name `flows tick start` submits through, and `testdata/tick-heartbeat.flow.yaml`
10
+ * binds by hand; unlike a provider inbox it needs no `flows.json` entry, because
11
+ * the tick source ships with the CLI itself.
12
+ */
13
+ export const SCHEDULE_EXECUTOR = 'flows.tick';
14
+
15
+ /** Three missed slots before the kernel's liveness sweep journals `subscription.stale`. */
16
+ export const SCHEDULE_STALE_SLOTS = 3;
17
+
18
+ export interface ScheduleLowering {
19
+ readonly scheduleId: string;
20
+ /** The `flows tick start --interval-ms` value, when the schedule is exactly a tick grid. */
21
+ readonly intervalMs?: number;
22
+ /** The `flows tick start --epoch-ms` value: the grid's phase past the Unix epoch. Present with `intervalMs`. */
23
+ readonly epochMs?: number;
24
+ readonly cron?: string;
25
+ readonly tz?: string;
26
+ /** The silence budget the subscription declares, in milliseconds. */
27
+ readonly staleAfterMs?: number;
28
+ /** Why the local tick runner cannot drive this schedule, when it cannot. */
29
+ readonly localUnsupported?: string;
30
+ }
31
+
32
+ export function scheduleLowering(flowName: string, source: ScheduleTriggerSource): ScheduleLowering {
33
+ const scheduleId = scheduleIdFor(flowName, source);
34
+ const base = {
35
+ scheduleId,
36
+ ...(source.cron === undefined ? {} : { cron: source.cron }),
37
+ ...(source.tz === undefined ? {} : { tz: source.tz }),
38
+ };
39
+ if (source.intervalMs === undefined || source.epochMs === undefined) {
40
+ // Not a grid. The kernel's default silence budget is five minutes, which
41
+ // a daily cron would trip every day at 09:05 — so the budget is declared
42
+ // from the cron's own longest quiet period instead of left to default.
43
+ const gap = source.cron === undefined ? undefined : cronMaxGapMs(parseCron(source.cron));
44
+ return {
45
+ ...base,
46
+ ...(gap === undefined ? {} : { staleAfterMs: gap * SCHEDULE_STALE_SLOTS }),
47
+ localUnsupported: `cron "${source.cron}"${source.tz === undefined ? '' : ` in ${source.tz}`} is not a UTC tick grid `
48
+ + '(a fixed period dividing the hour or day, one fixed phase); the local tick runner cannot reproduce it. '
49
+ + 'Run it on Cloud with `flows schedule`, or declare schedule.every(...) for local runs.',
50
+ };
51
+ }
52
+ return { ...base, intervalMs: source.intervalMs, epochMs: source.epochMs, staleAfterMs: source.intervalMs * SCHEDULE_STALE_SLOTS };
53
+ }
54
+
55
+ /**
56
+ * Lower a `schedule.*` declaration to the `flows.tick` subscription the tick
57
+ * source drives: one event type, a pattern pinned to this flow's schedule id,
58
+ * the shared dedupe key so a re-delivered slot spawns one run, and a silence
59
+ * budget of three slots — three intervals for a grid, three times the cron's
60
+ * longest quiet period otherwise — so a dead source is journaled rather than
61
+ * silent, and a weekly cron is not declared stale on Monday at 09:05.
62
+ */
63
+ export function scheduleTriggerSpec(id: string, flowName: string, source: ScheduleTriggerSource): TriggerSpec {
64
+ const lowering = scheduleLowering(flowName, source);
65
+ return {
66
+ id,
67
+ executor: SCHEDULE_EXECUTOR,
68
+ eventType: TICK_EVENT_TYPE,
69
+ pattern: { schedule_id: lowering.scheduleId },
70
+ dedupeKeyTemplate: TICK_DEDUPE_KEY_TEMPLATE,
71
+ ...(lowering.staleAfterMs === undefined ? {} : { staleAfterMs: lowering.staleAfterMs }),
72
+ };
73
+ }
package/src/spec.ts CHANGED
@@ -82,7 +82,18 @@ export interface RegexMatchGate {
82
82
  flags?: string;
83
83
  }
84
84
 
85
- export type NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate;
85
+ /**
86
+ * Passes when the step's journaled `output.artifacts` lists `path`: a file the
87
+ * agent's worker measured as created or changed under its working directory.
88
+ * Reads the journal, never the disk, so replay and resume see the same verdict.
89
+ */
90
+ export interface ArtifactExistsGate {
91
+ type: 'artifact_exists';
92
+ /** Working-directory-relative POSIX path, as the worker journals it. */
93
+ path: string;
94
+ }
95
+
96
+ export type NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate | ArtifactExistsGate;
86
97
  export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate | NamedDataGate;
87
98
  export type VerificationSpec = ExitCodeGate | OutputVerificationSpec;
88
99
 
@@ -3,7 +3,7 @@ import type { TriggerSpec } from './spec.js';
3
3
 
4
4
  /** Lower a surface subscription to the existing inbox executor contract. */
5
5
  export function webhookTriggerSpec(id: string, source: TriggerSource): TriggerSpec {
6
- if (source.kind !== 'webhook') throw new TypeError('unsupported trigger kind');
6
+ if (source.kind !== 'webhook') throw new TypeError('unsupported trigger kind: use scheduleTriggerSpec for schedule sources');
7
7
  const trigger = webhook(source.name, source.filter);
8
8
  return {
9
9
  id,
package/src/validate.ts CHANGED
@@ -446,7 +446,7 @@ class Validator {
446
446
  } else if (Object.hasOwn(NAMED_GATE_KEYS, gate.type)) {
447
447
  for (const error of namedGateErrors(v, input, at)) this.fail(error);
448
448
  } else {
449
- this.fail(`${at}.type: unknown_gate_kind: expected exit_code | output_contains | json_schema | references_input | subprocess_gate | word_count_bounds | regex_match`);
449
+ this.fail(`${at}.type: unknown_gate_kind: expected exit_code | output_contains | json_schema | references_input | subprocess_gate | word_count_bounds | regex_match | artifact_exists`);
450
450
  }
451
451
  }
452
452
 
package/src/worker-cli.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolve } from 'node:path';
2
+ import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
1
3
  import { decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
2
4
  import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
3
5
  import { spawn } from 'node:child_process';
@@ -38,6 +40,16 @@ export interface WorkerCliResult {
38
40
  exit_code: number | null;
39
41
  stdout_tail: string;
40
42
  stderr_tail: string;
43
+ /**
44
+ * Files the agent created or changed under its working directory,
45
+ * cwd-relative POSIX paths, sorted. Measured by the worker that spawned the
46
+ * CLI — the one process provably sharing the agent's filesystem — as a
47
+ * content-hash diff of the directory before and after the run, so it is a
48
+ * journaled fact rather than a later guess. Present for every direct agent
49
+ * execution; absent for `llm` mode and for the relay transport, where the
50
+ * agent runs on another host.
51
+ */
52
+ artifacts?: string[];
41
53
  }
42
54
 
43
55
  /**
@@ -75,6 +87,22 @@ export async function runAgentCli(
75
87
  return runViaAgentRelay(kind, instruction, wakeContext, effectiveModel, relayContext, cwd, signal);
76
88
  }
77
89
 
90
+ // Artifact detection brackets the spawn: the directory the CLI runs in is
91
+ // snapshotted before and diffed after, by this process, on this
92
+ // filesystem. Only an agent execution writes artifacts; an llm step has no
93
+ // workspace to change. Executions sharing a working directory are
94
+ // serialized around their snapshot-spawn-snapshot interval, so one agent's
95
+ // writes are never attributed to a concurrent one in the same directory.
96
+ const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
97
+ return artifactRoot === undefined
98
+ ? execute()
99
+ : serializedByDirectory(artifactRoot, async () => {
100
+ const before = await snapshotWorkspaceFiles(artifactRoot);
101
+ const result = await execute();
102
+ return { ...result, artifacts: diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot)) };
103
+ });
104
+
105
+ async function execute(): Promise<WorkerCliResult> {
78
106
  if (kind === 'relayflows-wrapper-v1') {
79
107
  return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
80
108
  cli,
@@ -84,6 +112,7 @@ export async function runAgentCli(
84
112
  wrapperEnvironment(process.env),
85
113
  wrapperLimits,
86
114
  signal,
115
+ cwd,
87
116
  )), effectiveModel);
88
117
  }
89
118
 
@@ -109,6 +138,18 @@ export async function runAgentCli(
109
138
  const args = [...invocation.args];
110
139
  args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'json'] : ['--json']));
111
140
  return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd), kind), effectiveModel);
141
+ }
142
+ }
143
+
144
+ /** One agent at a time per canonical working directory, for the artifact interval. */
145
+ const directoryQueues = new Map<string, Promise<unknown>>();
146
+ function serializedByDirectory<T>(directory: string, task: () => Promise<T>): Promise<T> {
147
+ const previous = directoryQueues.get(directory) ?? Promise.resolve();
148
+ const run = previous.then(task, task);
149
+ const settled = run.then(() => undefined, () => undefined);
150
+ directoryQueues.set(directory, settled);
151
+ void settled.then(() => { if (directoryQueues.get(directory) === settled) directoryQueues.delete(directory); });
152
+ return run;
112
153
  }
113
154
 
114
155
  /** Wait under the same worker lease for an authoritative task receipt. */
package/src/worker.ts CHANGED
@@ -133,6 +133,15 @@ export class AgentWorker extends EventEmitter {
133
133
  // emitting an error JSON with exit 0. `completionReason` is
134
134
  // derived from exit code, so a CLI that exits 0 while emitting
135
135
  // `{"error":...}` will report success with an error payload.
136
+ //
137
+ // `artifacts` (worker-cli.ts) rides inside the CliResult wrapper, so on
138
+ // the wrapper path it is journaled in `output`, where the kernel's output
139
+ // binding — and therefore an `artifact_exists` gate — can read it. On the
140
+ // JSON path the author's object IS the output and is left exactly as the
141
+ // agent emitted it: no key is added that a schema, a downstream binding
142
+ // or `summary` never asked for. An agent that answers with a JSON object
143
+ // therefore journals no artifacts; gate such a step on a deterministic
144
+ // check instead. The relay transport reports none (the agent ran elsewhere).
136
145
  const output = result.relay_task?.status === 'completed' && result.exit_code === 0
137
146
  ? result.relay_task.output : parseJsonOutput(result.stdout_tail) ?? result;
138
147
 
@@ -49,6 +49,8 @@ export function runWrapperSession(
49
49
  env: NodeJS.ProcessEnv,
50
50
  overrides: Partial<WrapperSessionLimits> = {},
51
51
  signal?: AbortSignal,
52
+ /** Working directory for the wrapper process; the artifact scanner uses the same root. */
53
+ cwd?: string,
52
54
  ): Promise<WrapperSessionResult> {
53
55
  if (signal?.aborted) return Promise.reject(signal.reason);
54
56
  if (signal !== undefined && process.platform === 'win32') {
@@ -77,7 +79,7 @@ export function runWrapperSession(
77
79
  ));
78
80
  }
79
81
 
80
- return executePinnedWrapper(cli, identity, request, env, limits, signal);
82
+ return executePinnedWrapper(cli, identity, request, env, limits, signal, cwd);
81
83
  }
82
84
 
83
85
  function executePinnedWrapper(
@@ -87,6 +89,7 @@ function executePinnedWrapper(
87
89
  env: NodeJS.ProcessEnv,
88
90
  limits: WrapperSessionLimits,
89
91
  signal?: AbortSignal,
92
+ cwd?: string,
90
93
  ): Promise<WrapperSessionResult> {
91
94
  return new Promise((resolve) => {
92
95
  const ownsGroup = ownsProcessGroup(signal);
@@ -94,6 +97,7 @@ function executePinnedWrapper(
94
97
  stdio: ['pipe', 'pipe', 'pipe'],
95
98
  env,
96
99
  detached: ownsGroup,
100
+ ...(cwd === undefined ? {} : { cwd }),
97
101
  });
98
102
  const stop = childStop(child, ownsGroup);
99
103
  const stdout: string[] = [];