@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.
- package/dist/authored-flow-error.d.ts +21 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +14 -0
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +133 -3
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +10 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +7 -0
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +11 -0
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +32 -7
- package/dist/authored-flow-operation.js.map +1 -1
- package/dist/authored-human.d.ts +49 -0
- package/dist/authored-human.d.ts.map +1 -0
- package/dist/authored-human.js +97 -0
- package/dist/authored-human.js.map +1 -0
- package/dist/authored-node-entry.js +3 -2
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +52 -18
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +29 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +14 -25
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/answer.d.ts +17 -0
- package/dist/cli/answer.d.ts.map +1 -0
- package/dist/cli/answer.js +89 -0
- package/dist/cli/answer.js.map +1 -0
- package/dist/cli/build.js +1 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +12 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +25 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +14 -2
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-connect-cli.d.ts +54 -0
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
- package/dist/cli/cloud-connect-cli.js +109 -0
- package/dist/cli/cloud-connect-cli.js.map +1 -0
- package/dist/cli/cloud-deploy.d.ts +3 -1
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +44 -6
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +2 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +17 -3
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +31 -0
- package/dist/cli/cloud-schedule.d.ts.map +1 -0
- package/dist/cli/cloud-schedule.js +156 -0
- package/dist/cli/cloud-schedule.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +6 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +22 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +39 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +148 -8
- package/dist/cli.js.map +1 -1
- package/dist/cloud-connect.d.ts +52 -0
- package/dist/cloud-connect.d.ts.map +1 -0
- package/dist/cloud-connect.js +138 -0
- package/dist/cloud-connect.js.map +1 -0
- package/dist/cloud-deploy.d.ts +19 -1
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +52 -2
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-http.d.ts +17 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +44 -8
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +30 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +70 -27
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-schedule.d.ts +55 -0
- package/dist/cloud-schedule.d.ts.map +1 -0
- package/dist/cloud-schedule.js +146 -0
- package/dist/cloud-schedule.js.map +1 -0
- package/dist/failure-kinds.d.ts +3 -3
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +4 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-requirements.d.ts +68 -0
- package/dist/flow-requirements.d.ts.map +1 -0
- package/dist/flow-requirements.js +152 -0
- package/dist/flow-requirements.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +11 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +13 -0
- package/dist/journal-client.js.map +1 -1
- package/dist/named-gate-lowering.js +9 -0
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/named-gates.d.ts +1 -1
- package/dist/named-gates.d.ts.map +1 -1
- package/dist/named-gates.js +11 -1
- package/dist/named-gates.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +155 -9
- package/dist/preflight.js.map +1 -1
- package/dist/progress.d.ts +2 -1
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +9 -3
- package/dist/progress.js.map +1 -1
- package/dist/protocol.d.ts +25 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schedule-trigger.d.ts +35 -0
- package/dist/schedule-trigger.d.ts.map +1 -0
- package/dist/schedule-trigger.js +53 -0
- package/dist/schedule-trigger.js.map +1 -0
- package/dist/spec.d.ts +11 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/trigger-executor.js +1 -1
- package/dist/trigger-executor.js.map +1 -1
- package/dist/validate.js +1 -1
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +51 -22
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +9 -0
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +3 -1
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +6 -3
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +3 -3
- package/src/authored-flow-error.ts +30 -0
- package/src/authored-flow-executor.ts +141 -2
- package/src/authored-flow-lifecycle.ts +7 -0
- package/src/authored-flow-operation.ts +35 -10
- package/src/authored-human.ts +150 -0
- package/src/authored-node-entry.ts +3 -2
- package/src/authored-node-runner.ts +55 -18
- package/src/authored-root.ts +28 -1
- package/src/authored-worker-step.ts +15 -26
- package/src/cli/answer.ts +98 -0
- package/src/cli/build.ts +1 -1
- package/src/cli/check-triggers.ts +11 -0
- package/src/cli/check.ts +39 -2
- package/src/cli/cloud-connect-cli.ts +130 -0
- package/src/cli/cloud-deploy.ts +40 -6
- package/src/cli/cloud-run.ts +16 -4
- package/src/cli/cloud-schedule.ts +160 -0
- package/src/cli/direct-run.ts +6 -1
- package/src/cli/run.ts +53 -5
- package/src/cli.ts +134 -9
- package/src/cloud-connect.ts +187 -0
- package/src/cloud-deploy.ts +69 -3
- package/src/cloud-http.ts +52 -8
- package/src/cloud-run.ts +99 -26
- package/src/cloud-schedule.ts +175 -0
- package/src/failure-kinds.ts +4 -0
- package/src/flow-requirements.ts +201 -0
- package/src/index.ts +14 -0
- package/src/journal-client.ts +20 -0
- package/src/named-gate-lowering.ts +9 -0
- package/src/named-gates.ts +11 -1
- package/src/preflight.ts +132 -9
- package/src/progress.ts +11 -4
- package/src/protocol.ts +23 -0
- package/src/schedule-trigger.ts +73 -0
- package/src/spec.ts +12 -1
- package/src/trigger-executor.ts +1 -1
- package/src/validate.ts +1 -1
- package/src/worker-cli.ts +41 -0
- package/src/worker.ts +9 -0
- 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
|
-
|
|
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
|
|
588
|
-
if (
|
|
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
|
|
642
|
-
// with < or > (optionally with a leading fd
|
|
643
|
-
|
|
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]+)
|
|
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
|
-
|
|
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
|
-
|
|
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} (${
|
|
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({
|
|
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
|
-
|
|
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
|
|
package/src/trigger-executor.ts
CHANGED
|
@@ -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
|
|
package/src/wrapper-session.ts
CHANGED
|
@@ -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[] = [];
|