@relayflows/sdk 2.0.15 → 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 (155) hide show
  1. package/dist/agent-artifacts.d.ts +20 -0
  2. package/dist/agent-artifacts.d.ts.map +1 -0
  3. package/dist/agent-artifacts.js +81 -0
  4. package/dist/agent-artifacts.js.map +1 -0
  5. package/dist/authored-flow-error.d.ts +1 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js.map +1 -1
  8. package/dist/authored-flow-executor.d.ts +3 -3
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +93 -7
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-flow-lifecycle.d.ts +10 -0
  13. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  14. package/dist/authored-flow-lifecycle.js +7 -0
  15. package/dist/authored-flow-lifecycle.js.map +1 -1
  16. package/dist/authored-flow-operation.d.ts +11 -0
  17. package/dist/authored-flow-operation.d.ts.map +1 -1
  18. package/dist/authored-flow-operation.js +32 -7
  19. package/dist/authored-flow-operation.js.map +1 -1
  20. package/dist/authored-node-runner.d.ts.map +1 -1
  21. package/dist/authored-node-runner.js +37 -14
  22. package/dist/authored-node-runner.js.map +1 -1
  23. package/dist/authored-step-output.d.ts +2 -0
  24. package/dist/authored-step-output.d.ts.map +1 -1
  25. package/dist/authored-step-output.js +5 -1
  26. package/dist/authored-step-output.js.map +1 -1
  27. package/dist/authored-worker-step.d.ts.map +1 -1
  28. package/dist/authored-worker-step.js +15 -8
  29. package/dist/authored-worker-step.js.map +1 -1
  30. package/dist/cli/check-triggers.d.ts.map +1 -1
  31. package/dist/cli/check-triggers.js +10 -0
  32. package/dist/cli/check-triggers.js.map +1 -1
  33. package/dist/cli/check.d.ts +14 -0
  34. package/dist/cli/check.d.ts.map +1 -1
  35. package/dist/cli/check.js +6 -1
  36. package/dist/cli/check.js.map +1 -1
  37. package/dist/cli/cloud-deploy.d.ts +30 -0
  38. package/dist/cli/cloud-deploy.d.ts.map +1 -0
  39. package/dist/cli/cloud-deploy.js +162 -0
  40. package/dist/cli/cloud-deploy.js.map +1 -0
  41. package/dist/cli/cloud-run.d.ts +3 -1
  42. package/dist/cli/cloud-run.d.ts.map +1 -1
  43. package/dist/cli/cloud-run.js +37 -5
  44. package/dist/cli/cloud-run.js.map +1 -1
  45. package/dist/cli/cloud-schedule.d.ts +29 -0
  46. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  47. package/dist/cli/cloud-schedule.js +131 -0
  48. package/dist/cli/cloud-schedule.js.map +1 -0
  49. package/dist/cli/cloud-sync.d.ts +13 -0
  50. package/dist/cli/cloud-sync.d.ts.map +1 -0
  51. package/dist/cli/cloud-sync.js +39 -0
  52. package/dist/cli/cloud-sync.js.map +1 -0
  53. package/dist/cli/direct-run.js +1 -1
  54. package/dist/cli/direct-run.js.map +1 -1
  55. package/dist/cli/run.d.ts +1 -1
  56. package/dist/cli/run.d.ts.map +1 -1
  57. package/dist/cli/run.js +17 -4
  58. package/dist/cli/run.js.map +1 -1
  59. package/dist/cli.d.ts.map +1 -1
  60. package/dist/cli.js +117 -12
  61. package/dist/cli.js.map +1 -1
  62. package/dist/cloud-deploy.d.ts +70 -0
  63. package/dist/cloud-deploy.d.ts.map +1 -0
  64. package/dist/cloud-deploy.js +192 -0
  65. package/dist/cloud-deploy.js.map +1 -0
  66. package/dist/cloud-http.d.ts +52 -2
  67. package/dist/cloud-http.d.ts.map +1 -1
  68. package/dist/cloud-http.js +135 -9
  69. package/dist/cloud-http.js.map +1 -1
  70. package/dist/cloud-run.d.ts +53 -0
  71. package/dist/cloud-run.d.ts.map +1 -1
  72. package/dist/cloud-run.js +97 -25
  73. package/dist/cloud-run.js.map +1 -1
  74. package/dist/cloud-schedule.d.ts +48 -0
  75. package/dist/cloud-schedule.d.ts.map +1 -0
  76. package/dist/cloud-schedule.js +125 -0
  77. package/dist/cloud-schedule.js.map +1 -0
  78. package/dist/cloud-sync.d.ts +66 -0
  79. package/dist/cloud-sync.d.ts.map +1 -0
  80. package/dist/cloud-sync.js +287 -0
  81. package/dist/cloud-sync.js.map +1 -0
  82. package/dist/failure-kinds.d.ts +10 -3
  83. package/dist/failure-kinds.d.ts.map +1 -1
  84. package/dist/failure-kinds.js +10 -0
  85. package/dist/failure-kinds.js.map +1 -1
  86. package/dist/index.d.ts +5 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +5 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/named-gate-lowering.js +9 -0
  91. package/dist/named-gate-lowering.js.map +1 -1
  92. package/dist/named-gates.d.ts +1 -1
  93. package/dist/named-gates.d.ts.map +1 -1
  94. package/dist/named-gates.js +11 -1
  95. package/dist/named-gates.js.map +1 -1
  96. package/dist/preflight.d.ts.map +1 -1
  97. package/dist/preflight.js +155 -9
  98. package/dist/preflight.js.map +1 -1
  99. package/dist/schedule-trigger.d.ts +35 -0
  100. package/dist/schedule-trigger.d.ts.map +1 -0
  101. package/dist/schedule-trigger.js +53 -0
  102. package/dist/schedule-trigger.js.map +1 -0
  103. package/dist/spec.d.ts +11 -1
  104. package/dist/spec.d.ts.map +1 -1
  105. package/dist/spec.js.map +1 -1
  106. package/dist/trigger-executor.js +1 -1
  107. package/dist/trigger-executor.js.map +1 -1
  108. package/dist/validate.js +1 -1
  109. package/dist/validate.js.map +1 -1
  110. package/dist/worker-cli.d.ts +10 -0
  111. package/dist/worker-cli.d.ts.map +1 -1
  112. package/dist/worker-cli.js +51 -22
  113. package/dist/worker-cli.js.map +1 -1
  114. package/dist/worker.d.ts.map +1 -1
  115. package/dist/worker.js +9 -0
  116. package/dist/worker.js.map +1 -1
  117. package/dist/wrapper-session.d.ts +3 -1
  118. package/dist/wrapper-session.d.ts.map +1 -1
  119. package/dist/wrapper-session.js +6 -3
  120. package/dist/wrapper-session.js.map +1 -1
  121. package/package.json +3 -3
  122. package/src/agent-artifacts.ts +76 -0
  123. package/src/authored-flow-error.ts +1 -0
  124. package/src/authored-flow-executor.ts +95 -8
  125. package/src/authored-flow-lifecycle.ts +7 -0
  126. package/src/authored-flow-operation.ts +35 -10
  127. package/src/authored-node-runner.ts +35 -13
  128. package/src/authored-step-output.ts +6 -1
  129. package/src/authored-worker-step.ts +16 -9
  130. package/src/cli/check-triggers.ts +9 -0
  131. package/src/cli/check.ts +21 -1
  132. package/src/cli/cloud-deploy.ts +157 -0
  133. package/src/cli/cloud-run.ts +37 -6
  134. package/src/cli/cloud-schedule.ts +135 -0
  135. package/src/cli/cloud-sync.ts +37 -0
  136. package/src/cli/direct-run.ts +1 -1
  137. package/src/cli/run.ts +18 -5
  138. package/src/cli.ts +107 -12
  139. package/src/cloud-deploy.ts +247 -0
  140. package/src/cloud-http.ts +173 -9
  141. package/src/cloud-run.ts +143 -24
  142. package/src/cloud-schedule.ts +158 -0
  143. package/src/cloud-sync.ts +315 -0
  144. package/src/failure-kinds.ts +10 -0
  145. package/src/index.ts +14 -0
  146. package/src/named-gate-lowering.ts +9 -0
  147. package/src/named-gates.ts +11 -1
  148. package/src/preflight.ts +132 -9
  149. package/src/schedule-trigger.ts +73 -0
  150. package/src/spec.ts +12 -1
  151. package/src/trigger-executor.ts +1 -1
  152. package/src/validate.ts +1 -1
  153. package/src/worker-cli.ts +41 -0
  154. package/src/worker.ts +9 -0
  155. 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(); }
@@ -1,4 +1,4 @@
1
- import { COMPLETION_REASONS, RUN_COMPLETION_REASONS } from '@relayflows/surface';
1
+ import { COMPLETION_REASONS, RUN_COMPLETION_REASONS, FLOW_COMPLETION_REASONS, type FlowCompletionReason } from '@relayflows/surface';
2
2
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
3
3
  import type { JournalClient } from './journal-client.js';
4
4
  import type { CompletionReason as ProtocolCompletionReason, RunCompletionReason as ProtocolRunCompletionReason, RunOutcome } from './protocol.js';
@@ -93,6 +93,11 @@ export function isSurfaceRunCompletionReason(value: unknown): value is ProtocolR
93
93
  && (RUN_COMPLETION_REASONS as readonly string[]).includes(value);
94
94
  }
95
95
 
96
+ export function isSurfaceFlowCompletionReason(value: unknown): value is FlowCompletionReason {
97
+ return typeof value === 'string'
98
+ && (FLOW_COMPLETION_REASONS as readonly string[]).includes(value);
99
+ }
100
+
96
101
  function isRecord(value: unknown): value is Record<string, unknown> {
97
102
  return typeof value === 'object' && value !== null && !Array.isArray(value);
98
103
  }
@@ -7,7 +7,7 @@ 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';
@@ -96,13 +96,10 @@ export function authoredWorkerRunner(
96
96
  if (options.workspace !== undefined && WORKSPACE_PERMISSION_ANNOTATION.test(options.workspace)) {
97
97
  throw new AuthoredFlowExecutionError(
98
98
  'unsupported_workspace_permission',
99
- `flow "${definition.name}" step "${id}": workspace "${options.workspace}" declares a `
100
- + 'permission annotation ("...: readonly" / "...: readwrite"), but nothing enforces it — '
101
- + 'no parser anywhere in this package turns that annotation into a real restriction '
102
- + '(kernel/DAEMON-LIFECYCLE.md\'s permission model is untouched by f.agent). '
103
- + 'Silently accepting and ignoring it would let a flow believe a restriction is in effect '
104
- + "when it is not. Declare a bare surface name (no trailing \": readonly\"/\": readwrite\") "
105
- + '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).',
106
103
  );
107
104
  }
108
105
  if (options.cli !== undefined && typeof options.cli !== 'string') {
@@ -129,8 +126,12 @@ export function authoredWorkerRunner(
129
126
  `f.agent options.transport must be 'direct' or 'relay' (got ${JSON.stringify(options.transport)}).`,
130
127
  );
131
128
  }
129
+ const permissions = options.permissions;
130
+ const permissionsSnapshot = permissions === undefined ? undefined
131
+ : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
132
132
  const output = await run({
133
133
  id, type: 'agent', instruction: options.task,
134
+ ...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
134
135
  ...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }),
135
136
  ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
136
137
  ...(options.cli === undefined ? {} : { cli: options.cli }),
@@ -143,7 +144,13 @@ export function authoredWorkerRunner(
143
144
  throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`);
144
145
  }
145
146
  const stdout = 'stdout_tail' in output ? output.stdout_tail : undefined;
146
- return { summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts: [] };
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] : [];
153
+ return { summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts };
147
154
  },
148
155
  async llm(id: string, prompt: string, options?: LlmOptions, verification?: NamedGate): Promise<unknown> {
149
156
  if (options !== undefined && (typeof options !== 'object' || options === null || options.output === undefined)) {
@@ -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;
@@ -340,7 +355,12 @@ function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightPr
340
355
  helper: helperReady,
341
356
  cli: (cli, source, model) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model),
342
357
  executor: (trigger) => config.executors.includes(trigger.executor),
343
- command: (binary) => executableExists(binary, flowDirectory),
358
+ // A deterministic step runs in the daemon's working directory — the
359
+ // directory `flows run` was invoked from, or Cloud's code mount — not in
360
+ // the flow file's. Probing `./x` against the flow's directory answered a
361
+ // question the kernel never asks, and refused a Cloud run whose synced
362
+ // tree held the script while its source sat in the state directory.
363
+ command: (binary) => executableExists(binary, process.cwd()),
344
364
  };
345
365
  }
346
366
 
@@ -0,0 +1,157 @@
1
+ import { CloudFlowError } from '../cloud-http.js';
2
+ import {
3
+ deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
4
+ type FlowTriggerSource,
5
+ } from '../cloud-deploy.js';
6
+ import type { CliIo } from '../cli.js';
7
+
8
+ export interface CloudDeployArgs {
9
+ command: 'cloud-deploy';
10
+ value: string;
11
+ repo: string;
12
+ on: string[];
13
+ approver: string | undefined;
14
+ name: string | undefined;
15
+ agents: string | undefined;
16
+ draft: boolean;
17
+ json: boolean;
18
+ }
19
+
20
+ /**
21
+ * `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
22
+ * --approver <handle> [--name <n>] [--json]`
23
+ *
24
+ * Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
25
+ * share nothing but the word: the digest form copies a sealed bundle into a
26
+ * file bucket, this one creates a hosted listener. The positional decides.
27
+ */
28
+ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs | undefined {
29
+ let value: string | undefined;
30
+ let repo: string | undefined;
31
+ let approver: string | undefined;
32
+ let name: string | undefined;
33
+ let agents: string | undefined;
34
+ let draft = false;
35
+ let json = false;
36
+ const on: string[] = [];
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i]!;
39
+ if (arg === '--json') {
40
+ if (json) return undefined;
41
+ json = true;
42
+ continue;
43
+ }
44
+ if (arg === '--draft') {
45
+ if (draft) return undefined;
46
+ draft = true;
47
+ continue;
48
+ }
49
+ if (arg === '--agents') {
50
+ const next = args[i + 1];
51
+ if (agents !== undefined || next === undefined || next.startsWith('-')) return undefined;
52
+ agents = next;
53
+ i += 1;
54
+ continue;
55
+ }
56
+ if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on') {
57
+ const next = args[i + 1];
58
+ if (next === undefined || next.startsWith('-')) return undefined;
59
+ i += 1;
60
+ if (arg === '--on') { on.push(next); continue; }
61
+ if (arg === '--repo') { if (repo !== undefined) return undefined; repo = next; continue; }
62
+ if (arg === '--approver') { if (approver !== undefined) return undefined; approver = next; continue; }
63
+ if (name !== undefined) return undefined;
64
+ name = next;
65
+ continue;
66
+ }
67
+ if (arg.startsWith('-') || value !== undefined) return undefined;
68
+ value = arg;
69
+ }
70
+ if (value === undefined || repo === undefined || on.length === 0) return undefined;
71
+ return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, json };
72
+ }
73
+
74
+ function describeSource(source: FlowTriggerSource): string {
75
+ const settings = Object.entries(source.settings).map(([k, v]) => `${k}=${v}`).join(' ');
76
+ return settings ? `${source.provider} ${settings}` : source.provider;
77
+ }
78
+
79
+ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
80
+ try {
81
+ if (args.approver === undefined) {
82
+ throw new CloudFlowError('invalid_input',
83
+ '--approver <handle> is required: every launched run receives it as input.approver for f.human.');
84
+ }
85
+ const deployment = await deployToCloud({
86
+ path: args.value,
87
+ repository: parseRepository(args.repo),
88
+ sources: args.on.map(parseTriggerSource),
89
+ approver: args.approver,
90
+ draft: args.draft,
91
+ ...(args.name === undefined ? {} : { name: args.name }),
92
+ ...(args.agents === undefined ? {} : { agents: parseAgentHarnesses(args.agents) }),
93
+ });
94
+ if (args.json) {
95
+ io.stdout(JSON.stringify({ ok: true, ...deployment }));
96
+ return 0;
97
+ }
98
+ io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`);
99
+ io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
100
+ io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
101
+ for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
102
+ io.stdout(deployment.status === 'draft'
103
+ ? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
104
+ : 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
105
+ return 0;
106
+ } catch (error) {
107
+ return reportCloudFailure(error, args.json, io);
108
+ }
109
+ }
110
+
111
+ export async function runCloudDeploymentsCli({ json }: { json: boolean }, io: CliIo): Promise<0 | 1 | 2> {
112
+ try {
113
+ const deployments = await listCloudDeployments();
114
+ if (json) {
115
+ io.stdout(JSON.stringify({ ok: true, deployments }));
116
+ return 0;
117
+ }
118
+ if (deployments.length === 0) {
119
+ io.stdout('No flow deployments in this workspace.');
120
+ return 0;
121
+ }
122
+ for (const d of deployments) {
123
+ const repo = d.repository ? ` ${d.repository.owner}/${d.repository.name}` : '';
124
+ io.stdout(`${d.agentId} ${d.status} ${JSON.stringify(d.name)}${repo}`);
125
+ for (const source of d.sources) io.stdout(` on: ${describeSource(source)}`);
126
+ }
127
+ return 0;
128
+ } catch (error) {
129
+ return reportCloudFailure(error, json, io);
130
+ }
131
+ }
132
+
133
+ export async function runCloudUndeployCli({ agentId, json }: { agentId: string; json: boolean }, io: CliIo): Promise<0 | 1 | 2> {
134
+ try {
135
+ await undeployFromCloud(agentId);
136
+ io.stdout(json ? JSON.stringify({ ok: true, agentId, status: 'deleted' }) : `UNDEPLOYED ${agentId}`);
137
+ return 0;
138
+ } catch (error) {
139
+ return reportCloudFailure(error, json, io);
140
+ }
141
+ }
142
+
143
+ function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
144
+ const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
145
+ let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
146
+ // The deploy routes take a browser session or a `cli:auth` token. A
147
+ // deployment (CI) token gets 403 `session_required`; say what fixes it.
148
+ if (error instanceof CloudFlowError && error.status === 403) {
149
+ message += ' Deploying needs an interactive `cli:auth` credential: run `agent-relay cloud login` '
150
+ + '(a deployment token can run flows but not deploy them).';
151
+ }
152
+ if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
153
+ else io.stderr(`${code}: ${message}`);
154
+ return error instanceof CloudFlowError
155
+ && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
156
+ ? 2 : 1;
157
+ }
@@ -1,10 +1,14 @@
1
1
  import { CloudFlowError } from '../cloud-http.js';
2
- import { runInCloud, waitForCloudFlowRun } from '../cloud-run.js';
2
+ import { runInCloud, waitForCloudFlowRun, type RunInCloudOptions } from '../cloud-run.js';
3
+ import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
+ import { snapshotJsonValue } from '../json-value.js';
3
5
  import type { CliIo } from '../cli.js';
4
6
 
5
7
  /** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
6
8
  export async function runCloudCli(
7
- { value: path, json, wait }: { value: string; json: boolean; wait: boolean },
9
+ { value: path, json, wait, input, syncCode }: {
10
+ value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean;
11
+ },
8
12
  io: CliIo,
9
13
  ): Promise<0 | 1 | 2> {
10
14
  const controller = new AbortController();
@@ -12,12 +16,36 @@ export async function runCloudCli(
12
16
  process.once('SIGINT', abort);
13
17
  process.once('SIGTERM', abort);
14
18
  let runId: string | undefined;
19
+ // Flips at the run submission. An interruption before it — during prepare,
20
+ // packing or upload — admitted nothing and is safe to retry; only an
21
+ // interrupted submission has unknown admission.
22
+ let submitting = false;
15
23
  try {
16
- const receipt = await runInCloud({ path }, { signal: controller.signal });
24
+ const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
25
+ if (isAuthoredFlowPath(path)) {
26
+ // Same parse as a local direct run, so a file-or-inline argument means
27
+ // the same thing on both sides of `--cloud`.
28
+ try {
29
+ options.input = snapshotJsonValue(parseDirectInput(input), 'Cloud authored input');
30
+ } catch (error) {
31
+ if (error instanceof DirectInputError) throw new CloudFlowError('invalid_input', error.message);
32
+ throw error;
33
+ }
34
+ }
35
+ // The tree is the invoking directory, as with v1: the flow path is where
36
+ // the body lives, not the boundary of what the run may read.
37
+ if (syncCode) options.syncCode = { root: process.cwd() };
38
+ const receipt = await runInCloud({ path }, options);
17
39
  runId = receipt.runId;
18
40
  if (!json) {
19
41
  io.stdout(`ACCEPTED ${receipt.runId} (${receipt.status})`);
20
42
  io.stdout(receipt.apiUrl);
43
+ if (receipt.synced) {
44
+ io.stdout(`SYNCED ${receipt.synced.files} files (${receipt.synced.bytes} bytes); pull changes with: flows sync ${receipt.runId}`);
45
+ for (const link of receipt.synced.skippedLinks) {
46
+ io.stderr(`WARNING [sync_link_skipped] ${link} points outside the synced tree and was not uploaded`);
47
+ }
48
+ }
21
49
  }
22
50
  if (!wait) {
23
51
  if (json) io.stdout(JSON.stringify({ ok: true, ...receipt }));
@@ -29,16 +57,19 @@ export async function runCloudCli(
29
57
  else io.stdout(`${run.status.toUpperCase()} ${run.runId} completionReason: ${'completionReason' in run ? run.completionReason : 'unavailable'}`);
30
58
  return ok ? 0 : 1;
31
59
  } catch (error) {
32
- const code = controller.signal.aborted ? (runId ? 'observation_aborted' : 'admission_unknown')
60
+ const code = controller.signal.aborted
61
+ ? runId ? 'observation_aborted' : submitting ? 'admission_unknown' : 'submission_aborted'
33
62
  : error instanceof CloudFlowError ? error.code : 'cloud_run_failed';
34
63
  const message = controller.signal.aborted
35
64
  ? runId ? 'Stopped observing; the hosted run has not been cancelled.'
36
- : 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
65
+ : submitting
66
+ ? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
67
+ : 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
37
68
  : error instanceof Error ? error.message : 'Cloud run failed.';
38
69
  if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
39
70
  else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
40
71
  return error instanceof CloudFlowError
41
- && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code)
72
+ && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'].includes(error.code)
42
73
  || (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
43
74
  } finally {
44
75
  process.off('SIGINT', abort);