@relayflows/sdk 2.0.16 → 2.0.17

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 (121) hide show
  1. package/dist/authored-flow-error.d.ts +1 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js.map +1 -1
  4. package/dist/authored-flow-executor.d.ts.map +1 -1
  5. package/dist/authored-flow-executor.js +86 -0
  6. package/dist/authored-flow-executor.js.map +1 -1
  7. package/dist/authored-flow-lifecycle.d.ts +10 -0
  8. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  9. package/dist/authored-flow-lifecycle.js +7 -0
  10. package/dist/authored-flow-lifecycle.js.map +1 -1
  11. package/dist/authored-flow-operation.d.ts +11 -0
  12. package/dist/authored-flow-operation.d.ts.map +1 -1
  13. package/dist/authored-flow-operation.js +32 -7
  14. package/dist/authored-flow-operation.js.map +1 -1
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +37 -14
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-worker-step.d.ts.map +1 -1
  19. package/dist/authored-worker-step.js +14 -25
  20. package/dist/authored-worker-step.js.map +1 -1
  21. package/dist/cli/check-triggers.d.ts.map +1 -1
  22. package/dist/cli/check-triggers.js +10 -0
  23. package/dist/cli/check-triggers.js.map +1 -1
  24. package/dist/cli/check.d.ts +14 -0
  25. package/dist/cli/check.d.ts.map +1 -1
  26. package/dist/cli/check.js.map +1 -1
  27. package/dist/cli/cloud-schedule.d.ts +29 -0
  28. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  29. package/dist/cli/cloud-schedule.js +131 -0
  30. package/dist/cli/cloud-schedule.js.map +1 -0
  31. package/dist/cli/direct-run.js +1 -1
  32. package/dist/cli/direct-run.js.map +1 -1
  33. package/dist/cli/run.d.ts.map +1 -1
  34. package/dist/cli/run.js +5 -3
  35. package/dist/cli/run.js.map +1 -1
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +35 -0
  38. package/dist/cli.js.map +1 -1
  39. package/dist/cloud-deploy.d.ts.map +1 -1
  40. package/dist/cloud-deploy.js +12 -1
  41. package/dist/cloud-deploy.js.map +1 -1
  42. package/dist/cloud-http.d.ts +16 -1
  43. package/dist/cloud-http.d.ts.map +1 -1
  44. package/dist/cloud-http.js +44 -8
  45. package/dist/cloud-http.js.map +1 -1
  46. package/dist/cloud-run.d.ts +30 -0
  47. package/dist/cloud-run.d.ts.map +1 -1
  48. package/dist/cloud-run.js +70 -27
  49. package/dist/cloud-run.js.map +1 -1
  50. package/dist/cloud-schedule.d.ts +48 -0
  51. package/dist/cloud-schedule.d.ts.map +1 -0
  52. package/dist/cloud-schedule.js +125 -0
  53. package/dist/cloud-schedule.js.map +1 -0
  54. package/dist/failure-kinds.d.ts +3 -3
  55. package/dist/failure-kinds.d.ts.map +1 -1
  56. package/dist/failure-kinds.js +2 -0
  57. package/dist/failure-kinds.js.map +1 -1
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/named-gate-lowering.js +9 -0
  63. package/dist/named-gate-lowering.js.map +1 -1
  64. package/dist/named-gates.d.ts +1 -1
  65. package/dist/named-gates.d.ts.map +1 -1
  66. package/dist/named-gates.js +11 -1
  67. package/dist/named-gates.js.map +1 -1
  68. package/dist/preflight.d.ts.map +1 -1
  69. package/dist/preflight.js +155 -9
  70. package/dist/preflight.js.map +1 -1
  71. package/dist/schedule-trigger.d.ts +35 -0
  72. package/dist/schedule-trigger.d.ts.map +1 -0
  73. package/dist/schedule-trigger.js +53 -0
  74. package/dist/schedule-trigger.js.map +1 -0
  75. package/dist/spec.d.ts +11 -1
  76. package/dist/spec.d.ts.map +1 -1
  77. package/dist/spec.js.map +1 -1
  78. package/dist/trigger-executor.js +1 -1
  79. package/dist/trigger-executor.js.map +1 -1
  80. package/dist/validate.js +1 -1
  81. package/dist/validate.js.map +1 -1
  82. package/dist/worker-cli.d.ts +10 -0
  83. package/dist/worker-cli.d.ts.map +1 -1
  84. package/dist/worker-cli.js +51 -22
  85. package/dist/worker-cli.js.map +1 -1
  86. package/dist/worker.d.ts.map +1 -1
  87. package/dist/worker.js +9 -0
  88. package/dist/worker.js.map +1 -1
  89. package/dist/wrapper-session.d.ts +3 -1
  90. package/dist/wrapper-session.d.ts.map +1 -1
  91. package/dist/wrapper-session.js +6 -3
  92. package/dist/wrapper-session.js.map +1 -1
  93. package/package.json +3 -3
  94. package/src/authored-flow-error.ts +1 -0
  95. package/src/authored-flow-executor.ts +87 -0
  96. package/src/authored-flow-lifecycle.ts +7 -0
  97. package/src/authored-flow-operation.ts +35 -10
  98. package/src/authored-node-runner.ts +35 -13
  99. package/src/authored-worker-step.ts +15 -26
  100. package/src/cli/check-triggers.ts +9 -0
  101. package/src/cli/check.ts +15 -0
  102. package/src/cli/cloud-schedule.ts +135 -0
  103. package/src/cli/direct-run.ts +1 -1
  104. package/src/cli/run.ts +5 -3
  105. package/src/cli.ts +32 -0
  106. package/src/cloud-deploy.ts +12 -1
  107. package/src/cloud-http.ts +51 -7
  108. package/src/cloud-run.ts +99 -26
  109. package/src/cloud-schedule.ts +158 -0
  110. package/src/failure-kinds.ts +2 -0
  111. package/src/index.ts +6 -0
  112. package/src/named-gate-lowering.ts +9 -0
  113. package/src/named-gates.ts +11 -1
  114. package/src/preflight.ts +132 -9
  115. package/src/schedule-trigger.ts +73 -0
  116. package/src/spec.ts +12 -1
  117. package/src/trigger-executor.ts +1 -1
  118. package/src/validate.ts +1 -1
  119. package/src/worker-cli.ts +41 -0
  120. package/src/worker.ts +9 -0
  121. package/src/wrapper-session.ts +5 -1
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  /** Slice-P kinds the surface `.gate(config)` accepts and the SDK lowers. */
10
10
  const NAMED_GATE_KINDS = new Set([
11
- 'references_input', 'subprocess_gate', 'word_count_bounds', 'regex_match',
11
+ 'references_input', 'subprocess_gate', 'word_count_bounds', 'regex_match', 'artifact_exists',
12
12
  ]);
13
13
 
14
14
  function isNamedGateConfig(candidate: unknown): candidate is NamedGate {
@@ -31,6 +31,14 @@ export class AuthoredFlowOperation<T> {
31
31
  * still throws `unsupported_gate` and never sets this field.
32
32
  */
33
33
  namedGate: NamedGate | undefined = undefined;
34
+ /**
35
+ * Predicate gate attached via `.gate(fn, because?)`. Author code: it runs
36
+ * in this process after the step completes, and its verdict is journaled as
37
+ * a lowered `<id>.gate` deterministic step (docs/SURFACE.md §6), so replay
38
+ * and resume see the recorded verdict and never re-run the closure. The
39
+ * function itself is never serialized. `flows check` cannot prove it.
40
+ */
41
+ predicateGate: { predicate: (value: T) => boolean; because?: string } | undefined = undefined;
34
42
  private state: OperationState = 'created';
35
43
  private thenInvoked = false;
36
44
  private rootFailureRecorded = false;
@@ -61,20 +69,29 @@ export class AuthoredFlowOperation<T> {
61
69
  const operation = this;
62
70
  const step: Step<T> = {
63
71
  gate(configOrPredicate: NamedGate | ((value: T) => boolean), _because?: string): Step<T> {
72
+ if (operation.namedGate !== undefined || operation.predicateGate !== undefined) {
73
+ throw new AuthoredFlowExecutionError('unsupported_gate', 'a step takes one .gate().');
74
+ }
64
75
  if (isNamedGateConfig(configOrPredicate)) {
65
76
  // Config-object gate: lowers into the compiled StepSpec's
66
77
  // `verification:` field via slice-P named-gate lowering.
67
78
  operation.namedGate = configOrPredicate;
68
79
  return step;
69
80
  }
70
- // Predicate gate: closures cannot be journaled (covenant 1
71
- // journal-as-truth). Refuseauthors should use a config-object
72
- // gate or the declarative `verification:` block.
73
- throw new AuthoredFlowExecutionError(
74
- 'unsupported_gate',
75
- 'postfix .gate(predicate) closures cannot be journaled; '
76
- + 'use .gate({type: "…", …}) with a slice-P named gate instead.',
77
- );
81
+ // Predicate gate: runtime control flow. The closure cannot be
82
+ // journaled, but its VERDICT can the executor runs it once after
83
+ // the step completes and records pass/fail as a `<id>.gate` step.
84
+ if (typeof configOrPredicate !== 'function') {
85
+ throw new AuthoredFlowExecutionError(
86
+ 'unsupported_gate',
87
+ '.gate() takes a named gate config ({type: "…", …}) or a predicate function.',
88
+ );
89
+ }
90
+ if (_because !== undefined && typeof _because !== 'string') {
91
+ throw new AuthoredFlowExecutionError('unsupported_gate', '.gate(predicate, because) takes a string reason.');
92
+ }
93
+ operation.predicateGate = { predicate: configOrPredicate, ...(_because === undefined ? {} : { because: _because }) };
94
+ return step;
78
95
  },
79
96
  then<TResult1 = T, TResult2 = never>(
80
97
  onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
@@ -144,7 +161,15 @@ export class AuthoredFlowOperation<T> {
144
161
  try {
145
162
  this.assertCanStart();
146
163
  this.state = 'running';
147
- const value = await this.start();
164
+ const started = await this.start();
165
+ // The predicate gate, when present, is applied here for every kind of
166
+ // operation, so a helper or plugin step cannot carry a gate that never
167
+ // runs. The executor installs the applier; without one, a predicate
168
+ // gate is refused rather than skipped.
169
+ const value = this.predicateGate === undefined ? started
170
+ : this.scope.applyPredicateGate === undefined
171
+ ? (() => { throw new AuthoredFlowExecutionError('unsupported_gate', 'this runtime cannot apply predicate gates'); })()
172
+ : await this.scope.applyPredicateGate(this as unknown as { id: string; predicateGate: unknown }, started);
148
173
  this.state = 'fulfilled';
149
174
  this.resolve(value);
150
175
  } catch (error) {
@@ -161,30 +161,44 @@ export async function verifyAuthoredNodeResult(
161
161
  result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
162
162
  rootRunId: string, socketPath: string,
163
163
  ): Promise<void> {
164
- const invalid = (): never => { throw new Error('authored runtime result has no matching durable completion'); };
164
+ const invalid = (why = ''): never => { throw new Error(`authored runtime result has no matching durable completion${process.env['FLOWS_VERIFIER_DEBUG'] && why ? ` (${why})` : ''}`); };
165
165
  if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
166
166
  || !isLoweredCompletion(result.completionReason)
167
- || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid();
167
+ || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid('frame');
168
168
  const terminal = result.journalSteps.at(-1)!;
169
- if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid();
169
+ if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid('terminal');
170
170
  const count = Number(terminal.id.slice('complete-'.length));
171
- if (!Number.isSafeInteger(count) || result.journalSteps.length !== count) invalid();
171
+ // A predicate gate is journaled as `<step>.gate`: a child run subordinate
172
+ // to the authored step it judges, in the same `<step>.gate` shape a named
173
+ // gate lowers to inside its step's own spec. Neither consumes an ordinal —
174
+ // `complete-N` counts the operations the author wrote (SURFACE.md §6) —
175
+ // so gates are set aside from the count and the contiguity check, and
176
+ // verified separately: every gate must name a claimed parent, and is then
177
+ // held to the same durable-completion evidence as any other child run.
178
+ const isGate = (id: string): boolean => /\.gate$/.test(id);
179
+ const authored = result.journalSteps.filter(step => typeof step?.id === 'string' && !isGate(step.id));
180
+ const gates = result.journalSteps.filter(step => typeof step?.id === 'string' && isGate(step.id));
181
+ if (!Number.isSafeInteger(count) || authored.length !== count || isGate(terminal.id)) invalid(`count ${authored.length}!=${count}`);
172
182
  const ordinal = (id: string): number => Number(/-([1-9][0-9]*)$/.exec(id)?.[1]);
173
183
  // Parallel awaits may finish in either order; validate a copy in declaration order.
174
- const ordered = [...result.journalSteps].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
184
+ const ordered = [...authored].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
185
+ const authoredIds = new Set(ordered.map(step => step.id));
186
+ for (const gate of gates) {
187
+ if (!authoredIds.has(gate.id.slice(0, -'.gate'.length))) invalid(`orphan ${gate.id}`);
188
+ }
175
189
  const runs = new Set<string>();
176
190
  const journal = new JournalClient(socketPath);
177
191
  await journal.connect();
178
192
  try {
179
193
  await journal.hello('flows-authored-result-verifier');
180
- for (const [index, claimed] of ordered.entries()) {
194
+ for (const [index, claimed] of [...ordered, ...gates].entries()) {
181
195
  if (!claimed || typeof claimed.id !== 'string' || typeof claimed.runId !== 'string'
182
- || ordinal(claimed.id) !== index+1 || claimed.completionReason !== 'success'
183
- || runs.has(claimed.runId)) invalid();
196
+ || (index < ordered.length && ordinal(claimed.id) !== index+1) || claimed.completionReason !== 'success'
197
+ || runs.has(claimed.runId)) invalid(`claim ${claimed?.id}`);
184
198
  runs.add(claimed.runId);
185
199
  const state = await journal.runGet(claimed.runId);
186
200
  if (state.run_id !== claimed.runId || state.status !== 'completed'
187
- || state.steps[claimed.id]?.state !== 'done') invalid();
201
+ || state.steps[claimed.id]?.state !== 'done') invalid(`state ${claimed.id} ${state.status} ${state.steps[claimed.id]?.state}`);
188
202
  const entries: Array<{
189
203
  seq: number; entry_type: string; step_id?: string; payload?: {
190
204
  completionReason?: string; spec?: { name?: string; steps?: Array<{id?:string;type?:string;command?:string}> };
@@ -196,7 +210,7 @@ export async function verifyAuthoredNodeResult(
196
210
  if (page.length === 0) break;
197
211
  for (const raw of page) {
198
212
  const entry = raw as typeof entries[number];
199
- if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid();
213
+ if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid('seq');
200
214
  fromSeq = entry.seq + 1;
201
215
  // Keep only completion evidence; streaming logs can span many pages.
202
216
  if (['run.spawned', 'step.completed', 'run.completed'].includes(entry.entry_type)) entries.push(entry);
@@ -204,12 +218,20 @@ export async function verifyAuthoredNodeResult(
204
218
  }
205
219
  const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
206
220
  const step = spec?.steps?.[0];
221
+ // A named gate lowers INTO the step's own spec as a second, dependent
222
+ // `<id>.gate` step (named-gate-lowering.ts); that is the only other
223
+ // step a child spec may carry, and it must have completed too.
224
+ const lowered = spec?.steps ?? [];
225
+ const specShape = lowered.length === 1 || (lowered.length === 2 && lowered[1]?.id === `${claimed.id}.gate`);
207
226
  const completed = entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === claimed.id);
227
+ const gateCompleted = lowered.length === 2
228
+ ? entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === `${claimed.id}.gate`) : [];
208
229
  const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
209
- if (spec?.name !== `${metadata.flowName}/${claimed.id}` || spec?.steps?.length !== 1
230
+ if (spec?.name !== `${metadata.flowName}/${claimed.id}` || !specShape
210
231
  || step?.id !== claimed.id || completed.length !== 1
211
232
  || completed[0]?.payload?.completionReason !== 'success'
212
- || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid();
233
+ || (lowered.length === 2 && (gateCompleted.length !== 1 || gateCompleted[0]?.payload?.completionReason !== 'success'))
234
+ || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed.length}`);
213
235
  if (claimed === terminal) {
214
236
  // The claimed verdict must match the marker the journal actually
215
237
  // recorded, so an IPC frame cannot claim `success` over a run whose
@@ -219,7 +241,7 @@ export async function verifyAuthoredNodeResult(
219
241
  // the runtime validation of untrusted IPC, and it is why nothing has
220
242
  // to be re-asserted here just to satisfy the type.
221
243
  if (step?.type !== 'deterministic'
222
- || step.command !== completionMarker(result.completionReason)) invalid();
244
+ || step.command !== completionMarker(result.completionReason)) invalid('marker');
223
245
  }
224
246
  }
225
247
  } finally { journal.close(); }
@@ -7,12 +7,11 @@ import { classifyOutcome, type RunLifecycleOptions } from './cli/run.js';
7
7
  import type { PreflightDiagnostic } from './preflight.js';
8
8
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
9
9
  import type { JournalClient } from './journal-client.js';
10
- import { SPEC_SCHEMA_VERSION, type FlowSpec, type StepSpec } from './spec.js';
10
+ import { SPEC_SCHEMA_VERSION, type FlowSpec, type PermissionsSpec, type StepSpec } from './spec.js';
11
11
  import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput } from './authored-step-output.js';
12
12
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
13
13
  import { snapshotJsonValue } from './json-value.js';
14
14
  import { authoredChildAdmissionKey } from './authored-admission.js';
15
- import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
16
15
 
17
16
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
18
17
 
@@ -97,13 +96,10 @@ export function authoredWorkerRunner(
97
96
  if (options.workspace !== undefined && WORKSPACE_PERMISSION_ANNOTATION.test(options.workspace)) {
98
97
  throw new AuthoredFlowExecutionError(
99
98
  'unsupported_workspace_permission',
100
- `flow "${definition.name}" step "${id}": workspace "${options.workspace}" declares a `
101
- + 'permission annotation ("...: readonly" / "...: readwrite"), but nothing enforces it — '
102
- + 'no parser anywhere in this package turns that annotation into a real restriction '
103
- + '(kernel/DAEMON-LIFECYCLE.md\'s permission model is untouched by f.agent). '
104
- + 'Silently accepting and ignoring it would let a flow believe a restriction is in effect '
105
- + "when it is not. Declare a bare surface name (no trailing \": readonly\"/\": readwrite\") "
106
- + 'if you do not need enforcement, or use the declarative spec\'s `permissions` field, which is real.',
99
+ `flow "${definition.name}" step "${id}": workspace "${options.workspace}": `
100
+ + "Workspace permission suffixes are unsupported. Use a bare workspace name and f.agent's "
101
+ + "permissions option, for example permissions: { fileGlobs: ['src/**'], accessPreset: 'readonly' }. "
102
+ + 'This declaration is validated and recorded with the step spec; it is not currently enforced (gate 8 / #442).',
107
103
  );
108
104
  }
109
105
  if (options.cli !== undefined && typeof options.cli !== 'string') {
@@ -130,22 +126,12 @@ export function authoredWorkerRunner(
130
126
  `f.agent options.transport must be 'direct' or 'relay' (got ${JSON.stringify(options.transport)}).`,
131
127
  );
132
128
  }
133
- // Artifact detection only tells the truth for the local-agent DIRECT
134
- // path: that is the only case that runs in this same process, on this
135
- // same filesystem, so `options.cwd` (or `process.cwd()`) is provably
136
- // where the CLI actually wrote — a workspace-scoped step never reaches
137
- // here with a local agent attached (refused above). `transport: 'relay'`
138
- // dispatches to agent-relay, which executes on a remote host even
139
- // though a local agent stream is still attached, so it gets no local
140
- // snapshot either. Any other worker attachment may execute on a
141
- // different host entirely; snapshotting this process's filesystem for
142
- // that case would be a guess, not a fact, so `artifacts` stays `[]`
143
- // there, exactly as before this fix.
144
- const artifactRoot = localAgentStream === undefined || options.transport === 'relay'
145
- ? undefined : options.cwd ?? process.cwd();
146
- const before = artifactRoot === undefined ? undefined : await snapshotWorkspaceFiles(artifactRoot);
129
+ const permissions = options.permissions;
130
+ const permissionsSnapshot = permissions === undefined ? undefined
131
+ : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
147
132
  const output = await run({
148
133
  id, type: 'agent', instruction: options.task,
134
+ ...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
149
135
  ...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }),
150
136
  ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
151
137
  ...(options.cli === undefined ? {} : { cli: options.cli }),
@@ -158,9 +144,12 @@ export function authoredWorkerRunner(
158
144
  throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`);
159
145
  }
160
146
  const stdout = 'stdout_tail' in output ? output.stdout_tail : undefined;
161
- const artifacts = before === undefined || artifactRoot === undefined
162
- ? []
163
- : diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot));
147
+ // The worker that ran the CLI measured the artifacts and journaled them
148
+ // in the step's output; read that fact back rather than re-scanning a
149
+ // directory this process may not even share with the agent.
150
+ const journaled = 'artifacts' in output ? output.artifacts : undefined;
151
+ const artifacts = Array.isArray(journaled) && journaled.every(entry => typeof entry === 'string')
152
+ ? [...journaled] : [];
164
153
  return { summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts };
165
154
  },
166
155
  async llm(id: string, prompt: string, options?: LlmOptions, verification?: NamedGate): Promise<unknown> {
@@ -2,6 +2,7 @@ import { dirname, resolve } from 'node:path';
2
2
  import { loadAuthoredFlow, type LoadedAuthoredFlow } from '../authored-flow-loader.js';
3
3
  import { preflightWebhookTriggers } from '../preflight.js';
4
4
  import { preflightProviderTriggers } from '../provider-trigger-contract.js';
5
+ import { scheduleLowering } from '../schedule-trigger.js';
5
6
  import { checkSlackHelpers } from '../slack-preflight.js';
6
7
  import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js';
7
8
 
@@ -31,12 +32,20 @@ export async function checkAuthoredTriggers(path: string): Promise<{
31
32
  const diagnostics = [
32
33
  ...triggerDiagnostics, ...providerDiagnostics, ...helperReport.diagnostics,
33
34
  ];
35
+ // A schedule is inspectable data: print what it lowers to, and say plainly
36
+ // when the local runner cannot drive it. Neither is a refusal — Cloud can.
37
+ const schedules = triggers.flatMap((trigger, handler) => {
38
+ if (trigger.kind !== 'schedule') return [];
39
+ const lowering = scheduleLowering(definition.name, trigger);
40
+ return [{ handler, ...lowering }];
41
+ });
34
42
  return {
35
43
  loaded,
36
44
  report: {
37
45
  // Severity, not emptiness: a warning must never refuse a flow.
38
46
  ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'),
39
47
  path, gates: [], resolutions: [], diagnostics,
48
+ ...(schedules.length === 0 ? {} : { schedules }),
40
49
  ...(config.path === undefined ? {} : { projectConfigPath: config.path }),
41
50
  },
42
51
  };
package/src/cli/check.ts CHANGED
@@ -45,9 +45,24 @@ export interface CheckReport {
45
45
  projectConfigPath?: string;
46
46
  gates: StepGateInspection[];
47
47
  resolutions: CliResolution[];
48
+ /** Authored `schedule.*` handlers and the `flows.tick` subscription each lowers to. */
49
+ schedules?: ScheduleInspection[];
48
50
  diagnostics: Array<PreflightDiagnostic | CheckInputDiagnostic | CheckWarningDiagnostic>;
49
51
  }
50
52
 
53
+ export interface ScheduleInspection {
54
+ /** Position among the flow's handlers, so two identical declarations stay distinct. */
55
+ handler: number;
56
+ cron?: string;
57
+ tz?: string;
58
+ intervalMs?: number;
59
+ epochMs?: number;
60
+ staleAfterMs?: number;
61
+ scheduleId: string;
62
+ /** Present when the local tick runner cannot drive it (only a cron-aware runner can). */
63
+ localUnsupported?: string;
64
+ }
65
+
51
66
  export interface CheckWarningDiagnostic {
52
67
  severity: 'warning';
53
68
  kind: CheckWarningKind;
@@ -0,0 +1,135 @@
1
+ import { CloudFlowError } from '../cloud-http.js';
2
+ import { listCloudSchedules, scheduleInCloud, unscheduleInCloud, type CloudSchedule } from '../cloud-schedule.js';
3
+ import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
+ import { snapshotJsonValue, type JsonValue } from '../json-value.js';
5
+ import type { CliIo } from '../cli.js';
6
+
7
+ export interface CloudScheduleArgs {
8
+ command: 'schedule';
9
+ value: string;
10
+ cron: string | undefined;
11
+ every: string | undefined;
12
+ tz: string | undefined;
13
+ input: string | undefined;
14
+ name: string | undefined;
15
+ json: boolean;
16
+ }
17
+
18
+ /**
19
+ * `flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>]
20
+ * [--tz <IANA>] [--input <json|file>] [--name <n>] [--json]`
21
+ *
22
+ * With neither `--cron` nor `--every`, the flow's own `schedule.*` handler
23
+ * supplies the cron, so a declared schedule and its hosted registration
24
+ * cannot drift.
25
+ */
26
+ export function parseCloudScheduleArgs(args: readonly string[]): CloudScheduleArgs | undefined {
27
+ let value: string | undefined;
28
+ const flags: Record<'cron' | 'every' | 'tz' | 'input' | 'name', string | undefined> = {
29
+ cron: undefined, every: undefined, tz: undefined, input: undefined, name: undefined,
30
+ };
31
+ let json = false;
32
+ for (let i = 0; i < args.length; i++) {
33
+ const arg = args[i]!;
34
+ if (arg === '--json') {
35
+ if (json) return undefined;
36
+ json = true;
37
+ continue;
38
+ }
39
+ if (arg === '--cron' || arg === '--every' || arg === '--tz' || arg === '--input' || arg === '--name') {
40
+ const key = arg.slice(2) as keyof typeof flags;
41
+ const next = args[i + 1];
42
+ // A cron expression legitimately starts with `*`; only a flag-shaped value is refused.
43
+ if (flags[key] !== undefined || next === undefined || next.startsWith('--')) return undefined;
44
+ flags[key] = next;
45
+ i += 1;
46
+ continue;
47
+ }
48
+ if (arg.startsWith('-') || value !== undefined) return undefined;
49
+ value = arg;
50
+ }
51
+ if (value === undefined) return undefined;
52
+ if (flags.cron !== undefined && flags.every !== undefined) return undefined;
53
+ if (flags.input !== undefined && !isAuthoredFlowPath(value)) return undefined;
54
+ return { command: 'schedule', value, ...flags, json };
55
+ }
56
+
57
+ function describe(schedule: CloudSchedule): string {
58
+ const last = schedule.lastTriggeredAt === undefined ? ''
59
+ : ` last ${schedule.lastTriggeredAt}${schedule.lastTriggerStatus === undefined ? '' : ` (${schedule.lastTriggerStatus})`}`;
60
+ return `${schedule.id} ${schedule.status} ${JSON.stringify(schedule.name)} cron "${schedule.cronExpression}" tz ${schedule.timezone}${last}`;
61
+ }
62
+
63
+ export async function runCloudScheduleCli(args: CloudScheduleArgs, io: CliIo): Promise<0 | 1 | 2> {
64
+ try {
65
+ let input: JsonValue | undefined;
66
+ let inputPresent = false;
67
+ if (isAuthoredFlowPath(args.value)) {
68
+ try {
69
+ input = snapshotJsonValue(parseDirectInput(args.input), 'Cloud authored input');
70
+ inputPresent = true;
71
+ } catch (error) {
72
+ if (error instanceof DirectInputError) throw new CloudFlowError('invalid_input', error.message);
73
+ throw error;
74
+ }
75
+ }
76
+ const schedule = await scheduleInCloud({
77
+ flow: { path: args.value },
78
+ ...(args.cron === undefined ? {} : { cron: args.cron }),
79
+ ...(args.every === undefined ? {} : { every: args.every }),
80
+ ...(args.tz === undefined ? {} : { tz: args.tz }),
81
+ ...(args.name === undefined ? {} : { name: args.name }),
82
+ ...(inputPresent ? { input } : {}),
83
+ });
84
+ if (args.json) {
85
+ io.stdout(JSON.stringify({ ok: true, ...schedule }));
86
+ return 0;
87
+ }
88
+ io.stdout(`SCHEDULED ${describe(schedule)}`);
89
+ io.stdout('Each fire submits this exact source through the same admission as flows run --cloud; list with: flows schedules');
90
+ return 0;
91
+ } catch (error) {
92
+ return reportFailure(error, args.json, io);
93
+ }
94
+ }
95
+
96
+ export async function runCloudSchedulesCli({ json }: { json: boolean }, io: CliIo): Promise<0 | 1 | 2> {
97
+ try {
98
+ const schedules = await listCloudSchedules();
99
+ if (json) {
100
+ io.stdout(JSON.stringify({ ok: true, schedules }));
101
+ return 0;
102
+ }
103
+ if (schedules.length === 0) io.stdout('No workflow schedules in this workspace.');
104
+ for (const schedule of schedules) io.stdout(describe(schedule));
105
+ return 0;
106
+ } catch (error) {
107
+ return reportFailure(error, json, io);
108
+ }
109
+ }
110
+
111
+ export async function runCloudUnscheduleCli(
112
+ { scheduleId, json }: { scheduleId: string; json: boolean }, io: CliIo,
113
+ ): Promise<0 | 1 | 2> {
114
+ try {
115
+ await unscheduleInCloud(scheduleId);
116
+ io.stdout(json ? JSON.stringify({ ok: true, scheduleId, deleted: true }) : `UNSCHEDULED ${scheduleId}`);
117
+ return 0;
118
+ } catch (error) {
119
+ return reportFailure(error, json, io);
120
+ }
121
+ }
122
+
123
+ function reportFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
124
+ const code = error instanceof CloudFlowError ? error.code : 'cloud_schedule_failed';
125
+ let message = error instanceof Error ? error.message : 'Cloud schedule failed.';
126
+ // The schedule routes take a browser session or a `cli:auth` token.
127
+ if (error instanceof CloudFlowError && error.status === 403) {
128
+ message += ' Scheduling needs an interactive `cli:auth` credential: run `agent-relay cloud login`.';
129
+ }
130
+ if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
131
+ else io.stderr(`${code}: ${message}`);
132
+ return error instanceof CloudFlowError
133
+ && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
134
+ ? 2 : 1;
135
+ }
@@ -172,7 +172,7 @@ export async function runDirectFlow(
172
172
  // diagnostic carried up from `classifyOutcome` already names the step, its
173
173
  // exit code and its output tail; this branch is what lets it reach the
174
174
  // terminal. `resumeFlow` takes the same branch, through the same helper.
175
- if (error instanceof AuthoredFlowExecutionError && error.code === 'step_failed') {
175
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
176
176
  return authoredStepFailure('run', base, socketPath, error);
177
177
  }
178
178
  const runId = error instanceof AuthoredFlowExecutionError ? error.runId : undefined;
package/src/cli/run.ts CHANGED
@@ -223,7 +223,7 @@ export async function resumeFlow(
223
223
  // leaving it on `protocolFailure` meant `flows run` printed the evidence
224
224
  // while `flows resume` still printed `protocol_error` and
225
225
  // `RUN <id> unknown` for the identical failure.
226
- if (error instanceof AuthoredFlowExecutionError && error.code === 'step_failed') {
226
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
227
227
  return authoredStepFailure('resume', base, socketPath, error, runId);
228
228
  }
229
229
  if (!(error instanceof JournalProtocolError) || error.code !== 'run_not_found') {
@@ -287,10 +287,12 @@ export function authoredStepFailure(
287
287
  completionReason: 'step_failed',
288
288
  diagnostics: [...base.diagnostics, {
289
289
  severity: 'failure',
290
- kind: 'step_failed',
290
+ // A predicate gate that judged false is a run failure with its own
291
+ // name, so the report says which kind of check the body did not pass.
292
+ kind: error.code === 'gate_failed' ? 'gate_failed' : 'step_failed',
291
293
  // The `step_failed: ` prefix `AuthoredFlowExecutionError` adds is
292
294
  // redundant once the diagnostic is labelled `[step_failed]`.
293
- message: error.message.replace(/^step_failed: /, ''),
295
+ message: error.message.replace(/^(?:step_failed|gate_failed): /, ''),
294
296
  }],
295
297
  },
296
298
  };
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ import { checkTypeScriptFlow } from './cli/check-typescript.js';
26
26
  import { runCloudCli } from './cli/cloud-run.js';
27
27
  import { runCloudSyncCli } from './cli/cloud-sync.js';
28
28
  import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
29
+ import { parseCloudScheduleArgs, runCloudScheduleCli, runCloudSchedulesCli, runCloudUnscheduleCli, type CloudScheduleArgs } from './cli/cloud-schedule.js';
29
30
  import { isAuthoredFlowPath } from './direct-input.js';
30
31
  import { parseDeployArgs, runDeploy, type DeployArgs } from './cli/deploy.js';
31
32
  import { parseDigestReference } from './bundle-transport.js';
@@ -58,6 +59,9 @@ type ParsedArgs =
58
59
  | CloudDeployArgs
59
60
  | { command: 'deployments'; json: boolean }
60
61
  | { command: 'undeploy'; agentId: string; json: boolean }
62
+ | CloudScheduleArgs
63
+ | { command: 'schedules'; json: boolean }
64
+ | { command: 'unschedule'; scheduleId: string; json: boolean }
61
65
  | { command: 'check'; json: boolean; watch: boolean; value: string }
62
66
  | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
63
67
  | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
@@ -75,6 +79,9 @@ const USAGE = [
75
79
  'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--json]',
76
80
  'flows deployments [--json]',
77
81
  'flows undeploy [--json] <deployment-id>',
82
+ 'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--json]',
83
+ 'flows schedules [--json]',
84
+ 'flows unschedule [--json] <schedule-id>',
78
85
  'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
79
86
  'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
80
87
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
@@ -131,6 +138,9 @@ export async function runCli(
131
138
  if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
132
139
  if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
133
140
  if (parsed.command === 'undeploy') return runCloudUndeployCli(parsed, io);
141
+ if (parsed.command === 'schedule') return runCloudScheduleCli(parsed, io);
142
+ if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
143
+ if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
134
144
  if (parsed.command === 'replay') return replayJournal(parsed, io);
135
145
  if (parsed.command === 'build') return runBuild(parsed, io);
136
146
  if (parsed.command === 'deploy') return runDeploy(parsed, io);
@@ -266,6 +276,7 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
266
276
  return {
267
277
  report: {
268
278
  ...mcp.report,
279
+ ...(triggers?.report.schedules === undefined ? {} : { schedules: triggers.report.schedules }),
269
280
  diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics],
270
281
  ok: helper.report.ok && mcp.report.ok && triggerOk,
271
282
  },
@@ -440,6 +451,18 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
440
451
  const source = args.slice(1).find(a => !a.startsWith('-') && isAuthoredFlowPath(a));
441
452
  return source !== undefined ? parseCloudDeployArgs(args.slice(1)) : parseDeployArgs(args.slice(1));
442
453
  }
454
+ if (command === 'schedule') return parseCloudScheduleArgs(args.slice(1));
455
+ if (command === 'schedules') {
456
+ const rest = args.slice(1);
457
+ if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--json')) return undefined;
458
+ return { command: 'schedules', json: rest.length === 1 };
459
+ }
460
+ if (command === 'unschedule') {
461
+ const rest = args.slice(1).filter(a => a !== '--json');
462
+ const json = args.length - 1 - rest.length;
463
+ if (json > 1 || rest.length !== 1 || rest[0]!.startsWith('-')) return undefined;
464
+ return { command: 'unschedule', scheduleId: rest[0]!, json: json === 1 };
465
+ }
443
466
  if (command === 'undeploy') {
444
467
  const rest = args.slice(1).filter(a => a !== '--json');
445
468
  const json = args.length - 1 - rest.length;
@@ -751,6 +774,15 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
751
774
  const vacuous = gate.acceptsAnyOutput === true ? ' [json_schema accepts any output]' : '';
752
775
  io.stdout(`GATE step "${gate.stepId}" ${gate.checks.join('+')} from data (kernel, journal-replayable)${vacuous}`);
753
776
  }
777
+ for (const schedule of report.schedules ?? []) {
778
+ const declared = schedule.cron !== undefined
779
+ ? `cron "${schedule.cron}"${schedule.tz === undefined ? '' : ` tz ${schedule.tz}`}`
780
+ : `every ${schedule.intervalMs}ms`;
781
+ const local = schedule.localUnsupported !== undefined
782
+ ? `Cloud only: ${schedule.localUnsupported}`
783
+ : `local: flows tick start --schedule-id ${schedule.scheduleId} --interval-ms ${schedule.intervalMs} --epoch-ms ${schedule.epochMs}`;
784
+ io.stdout(`SCHEDULE handler ${schedule.handler} ${declared} -> flows.tick schedule_id ${schedule.scheduleId} [${local}]`);
785
+ }
754
786
  for (const resolution of report.resolutions) {
755
787
  const config = resolution.source === 'project' && report.projectConfigPath !== undefined
756
788
  ? ` (${report.projectConfigPath})`
@@ -21,7 +21,9 @@ export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number];
21
21
 
22
22
  /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */
23
23
  const PROVIDER_SETTINGS: Record<FlowTriggerProvider, readonly string[]> = {
24
- github: ['repository', 'labels', 'contains'],
24
+ // `events`: `issues` (default) or `pull_request` — which GitHub records
25
+ // wake the listener (AgentWorkforce/cloud#3772).
26
+ github: ['repository', 'labels', 'contains', 'events'],
25
27
  slack: ['channel', 'contains'],
26
28
  linear: ['team', 'contains'],
27
29
  jira: ['project', 'contains'],
@@ -102,6 +104,15 @@ export function parseTriggerSource(value: string): FlowTriggerSource {
102
104
  if (!setting || setting.length > MAX_SETTING_LENGTH || key in settings) {
103
105
  throw new CloudFlowError('invalid_input', `Trigger setting "${key}" must be given once with a non-empty value.`);
104
106
  }
107
+ if (key === 'events') {
108
+ // Cloud's enum is lowercase; send it that way whatever the shell typed.
109
+ const events = setting.toLowerCase();
110
+ if (!['issues', 'pull_request'].includes(events)) {
111
+ throw new CloudFlowError('invalid_input', `github events must be "issues" or "pull_request", got "${setting}".`);
112
+ }
113
+ settings[key] = events;
114
+ continue;
115
+ }
105
116
  settings[key] = setting;
106
117
  }
107
118
  }