@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
|
@@ -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}"
|
|
101
|
-
+
|
|
102
|
-
+
|
|
103
|
-
+ '
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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> {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { userInfo } from 'node:os';
|
|
2
|
+
import { readAuthoredRootMetadata } from '../authored-root.js';
|
|
3
|
+
import { HUMAN_WAIT_ID, humanAnswerPayload, readHumanAnswer, readOpenHumanWaits, resumeCommand } from '../authored-human.js';
|
|
4
|
+
import { JournalClient, JournalProtocolError } from '../journal-client.js';
|
|
5
|
+
import type { EnsureDaemonOptions } from '../daemon-lifecycle.js';
|
|
6
|
+
import { connect, emptyReport, protocolFailure, socketFor, type RunExecution } from './run.js';
|
|
7
|
+
|
|
8
|
+
export interface AnswerOptions {
|
|
9
|
+
readonly note?: string;
|
|
10
|
+
/** Who answered, when relaying a person's decision; defaults to the OS user. */
|
|
11
|
+
readonly answeredBy?: string;
|
|
12
|
+
readonly daemon?: EnsureDaemonOptions;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `flows answer <run-id> <wait-id> yes|no`: record a person's answer to a
|
|
17
|
+
* parked `f.human`. The answer is an `event.emit` keyed by the wait id; the
|
|
18
|
+
* kernel closes the `wait.human` as `human_responded` and the root becomes
|
|
19
|
+
* runnable. Nothing runs here — this process attaches no worker — so the
|
|
20
|
+
* report names the `flows resume` that continues the body with the answer.
|
|
21
|
+
*/
|
|
22
|
+
export async function answerFlow(
|
|
23
|
+
runId: string,
|
|
24
|
+
waitId: string,
|
|
25
|
+
answer: boolean,
|
|
26
|
+
dataDir: string,
|
|
27
|
+
options: AnswerOptions = {},
|
|
28
|
+
): Promise<RunExecution> {
|
|
29
|
+
const socketPath = socketFor(dataDir);
|
|
30
|
+
const base = emptyReport('answer');
|
|
31
|
+
if (!HUMAN_WAIT_ID.test(waitId)) {
|
|
32
|
+
return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
|
|
33
|
+
severity: 'refusal', kind: 'human_wait_unknown',
|
|
34
|
+
message: `"${waitId}" is not an f.human wait id; they are named human-<n> in the order the body asked.`,
|
|
35
|
+
}] } };
|
|
36
|
+
}
|
|
37
|
+
const client = new JournalClient(socketPath);
|
|
38
|
+
const connected = await connect(client, 'answer', dataDir, base, { daemon: options.daemon ?? {} });
|
|
39
|
+
if (connected !== undefined) return connected;
|
|
40
|
+
try {
|
|
41
|
+
const open = await readOpenHumanWaits(client, runId);
|
|
42
|
+
const wait = open.find(candidate => candidate.waitId === waitId);
|
|
43
|
+
if (wait === undefined) {
|
|
44
|
+
const answered = await readHumanAnswer(client, runId, waitId).catch(() => undefined);
|
|
45
|
+
const others = open.map(candidate => `${candidate.waitId} (${candidate.to}: ${JSON.stringify(candidate.question)})`);
|
|
46
|
+
return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
|
|
47
|
+
severity: 'refusal', kind: 'human_wait_unknown',
|
|
48
|
+
message: answered !== undefined
|
|
49
|
+
? `Run "${runId}" already has an answer to ${waitId} (${answered.answer ? 'yes' : 'no'}); the kernel closes a wait once.`
|
|
50
|
+
: `Run "${runId}" is not asking ${waitId}.`
|
|
51
|
+
+ (others.length === 0 ? ' It has no open question.' : ` Open: ${others.join(', ')}.`),
|
|
52
|
+
}] } };
|
|
53
|
+
}
|
|
54
|
+
const answeredBy = options.answeredBy ?? safeUsername();
|
|
55
|
+
if (answeredBy === '') {
|
|
56
|
+
return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
|
|
57
|
+
severity: 'refusal', kind: 'human_wait_unknown',
|
|
58
|
+
message: 'An answer must say who gave it and this environment has no OS user; pass --by <identity>.',
|
|
59
|
+
}] } };
|
|
60
|
+
}
|
|
61
|
+
const payload = humanAnswerPayload(answer, {
|
|
62
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
63
|
+
answeredBy,
|
|
64
|
+
});
|
|
65
|
+
const emitted = await client.eventEmit(runId, waitId, payload);
|
|
66
|
+
if (emitted.matched !== 1) {
|
|
67
|
+
return protocolFailure('answer', base, socketPath, new Error(
|
|
68
|
+
`event.emit matched ${emitted.matched} waits for ${waitId}; expected the one open question`,
|
|
69
|
+
), runId);
|
|
70
|
+
}
|
|
71
|
+
const snapshot = await client.runGet(runId);
|
|
72
|
+
const root = await readAuthoredRootMetadata(client, runId).catch(() => undefined);
|
|
73
|
+
return {
|
|
74
|
+
exitCode: 0,
|
|
75
|
+
report: {
|
|
76
|
+
...base, ok: true, runId, socketPath, status: snapshot.status,
|
|
77
|
+
answer: { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) },
|
|
78
|
+
humanWait: { waitId, question: wait.question, to: wait.to },
|
|
79
|
+
next: resumeCommand(runId, dataDir, root?.localAgentStream !== undefined),
|
|
80
|
+
diagnostics: [],
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof JournalProtocolError && error.code === 'run_not_found') {
|
|
85
|
+
return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
|
|
86
|
+
severity: 'refusal', kind: 'run_unavailable',
|
|
87
|
+
message: `Run "${runId}" is not known to relayflowd at "${socketPath}".`,
|
|
88
|
+
}] } };
|
|
89
|
+
}
|
|
90
|
+
return protocolFailure('answer', base, socketPath, error, runId);
|
|
91
|
+
} finally {
|
|
92
|
+
client.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeUsername(): string {
|
|
97
|
+
try { return userInfo().username; } catch { return ''; }
|
|
98
|
+
}
|
package/src/cli/build.ts
CHANGED
|
@@ -112,7 +112,7 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
|
|
|
112
112
|
...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
|
|
113
113
|
projectSearchStart: directory,
|
|
114
114
|
models: config.models,
|
|
115
|
-
...(config.
|
|
115
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
116
116
|
probes: {
|
|
117
117
|
cli: () => { throw new Error('deferred to deployment'); },
|
|
118
118
|
executor: () => { throw new Error('deferred to deployment'); },
|
|
@@ -2,7 +2,9 @@ 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';
|
|
7
|
+
import { flowRequirements } from '../flow-requirements.js';
|
|
6
8
|
import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -31,12 +33,21 @@ export async function checkAuthoredTriggers(path: string): Promise<{
|
|
|
31
33
|
const diagnostics = [
|
|
32
34
|
...triggerDiagnostics, ...providerDiagnostics, ...helperReport.diagnostics,
|
|
33
35
|
];
|
|
36
|
+
// A schedule is inspectable data: print what it lowers to, and say plainly
|
|
37
|
+
// when the local runner cannot drive it. Neither is a refusal — Cloud can.
|
|
38
|
+
const schedules = triggers.flatMap((trigger, handler) => {
|
|
39
|
+
if (trigger.kind !== 'schedule') return [];
|
|
40
|
+
const lowering = scheduleLowering(definition.name, trigger);
|
|
41
|
+
return [{ handler, ...lowering }];
|
|
42
|
+
});
|
|
34
43
|
return {
|
|
35
44
|
loaded,
|
|
36
45
|
report: {
|
|
37
46
|
// Severity, not emptiness: a warning must never refuse a flow.
|
|
38
47
|
ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'),
|
|
39
48
|
path, gates: [], resolutions: [], diagnostics,
|
|
49
|
+
...(schedules.length === 0 ? {} : { schedules }),
|
|
50
|
+
requirements: flowRequirements(definition, { projectCli: config.cli }),
|
|
40
51
|
...(config.path === undefined ? {} : { projectConfigPath: config.path }),
|
|
41
52
|
},
|
|
42
53
|
};
|
package/src/cli/check.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { parse as parseYaml } from 'yaml';
|
|
5
5
|
import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
|
|
6
6
|
import { helperReady } from '../yaml-helper-effect.js';
|
|
7
|
+
import { flowRequirements, type FlowRequirements } from '../flow-requirements.js';
|
|
7
8
|
import {
|
|
8
9
|
adapterIdentification,
|
|
9
10
|
authenticationProbe,
|
|
@@ -33,7 +34,15 @@ export interface ProjectConfig {
|
|
|
33
34
|
mcp?: Record<string, McpServerConfig>;
|
|
34
35
|
cli?: string;
|
|
35
36
|
executors: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Exact model allowlist. Only a flows.json that DECLARES `models` is a
|
|
39
|
+
* registry that preflight enforces: `{"cli":"claude"}` alone names the CLI
|
|
40
|
+
* and leaves model policy to the adapter defaults, exactly as no flows.json
|
|
41
|
+
* would. Otherwise every scaffolded project (`flows create` writes only
|
|
42
|
+
* `cli`) refuses its adapter's own default model as "not listed".
|
|
43
|
+
*/
|
|
36
44
|
models: string[];
|
|
45
|
+
modelRegistryPath?: string;
|
|
37
46
|
directory: string;
|
|
38
47
|
path?: string;
|
|
39
48
|
}
|
|
@@ -45,9 +54,26 @@ export interface CheckReport {
|
|
|
45
54
|
projectConfigPath?: string;
|
|
46
55
|
gates: StepGateInspection[];
|
|
47
56
|
resolutions: CliResolution[];
|
|
57
|
+
/** Authored `schedule.*` handlers and the `flows.tick` subscription each lowers to. */
|
|
58
|
+
schedules?: ScheduleInspection[];
|
|
59
|
+
/** Integrations, harnesses and MCP servers the flow declares it needs (`flow-requirements.ts`). */
|
|
60
|
+
requirements?: FlowRequirements;
|
|
48
61
|
diagnostics: Array<PreflightDiagnostic | CheckInputDiagnostic | CheckWarningDiagnostic>;
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
export interface ScheduleInspection {
|
|
65
|
+
/** Position among the flow's handlers, so two identical declarations stay distinct. */
|
|
66
|
+
handler: number;
|
|
67
|
+
cron?: string;
|
|
68
|
+
tz?: string;
|
|
69
|
+
intervalMs?: number;
|
|
70
|
+
epochMs?: number;
|
|
71
|
+
staleAfterMs?: number;
|
|
72
|
+
scheduleId: string;
|
|
73
|
+
/** Present when the local tick runner cannot drive it (only a cron-aware runner can). */
|
|
74
|
+
localUnsupported?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
export interface CheckWarningDiagnostic {
|
|
52
78
|
severity: 'warning';
|
|
53
79
|
kind: CheckWarningKind;
|
|
@@ -102,6 +128,15 @@ export function checkFlow(path: string): CheckExecution {
|
|
|
102
128
|
}
|
|
103
129
|
}
|
|
104
130
|
|
|
131
|
+
/** Requirements never turn a preflight refusal into an unrelated exception. */
|
|
132
|
+
function safeRequirements(authoring: FlowSpec, projectCli: string | undefined): FlowRequirements | undefined {
|
|
133
|
+
try {
|
|
134
|
+
return flowRequirements(authoring, projectCli === undefined ? {} : { projectCli });
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
105
140
|
/** Preflight a validated authored flow through the same path as YAML/JSON. */
|
|
106
141
|
export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConfig?: ProjectConfig): CheckExecution {
|
|
107
142
|
const absolutePath = resolve(path);
|
|
@@ -113,7 +148,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
|
|
|
113
148
|
projectConfigPath: config.path,
|
|
114
149
|
projectSearchStart: dirname(absolutePath),
|
|
115
150
|
models: config.models,
|
|
116
|
-
...(config.
|
|
151
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
117
152
|
probes,
|
|
118
153
|
});
|
|
119
154
|
const flow = result.ok
|
|
@@ -132,6 +167,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
|
|
|
132
167
|
gates: result.gates,
|
|
133
168
|
resolutions: result.resolutions,
|
|
134
169
|
diagnostics: result.diagnostics,
|
|
170
|
+
requirements: safeRequirements(authoring, config.cli),
|
|
135
171
|
},
|
|
136
172
|
...(flow !== undefined ? { flow } : {}),
|
|
137
173
|
};
|
|
@@ -181,7 +217,7 @@ export async function checkBuildableFlow(path: string): Promise<CheckExecution>
|
|
|
181
217
|
...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
|
|
182
218
|
projectSearchStart: dirname(absolutePath),
|
|
183
219
|
models: config.models,
|
|
184
|
-
...(config.
|
|
220
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
185
221
|
probes: deferred,
|
|
186
222
|
});
|
|
187
223
|
// Refusals rooted in build-machine environment probes (`probe_failed`)
|
|
@@ -314,6 +350,7 @@ export function readProjectConfig(start: string): ProjectConfig {
|
|
|
314
350
|
...(value['cli'] !== undefined ? { cli: value['cli'] as string } : {}),
|
|
315
351
|
executors: (value['executors'] as string[] | undefined) ?? [],
|
|
316
352
|
models: (value['models'] as string[] | undefined) ?? [],
|
|
353
|
+
...(value['models'] !== undefined ? { modelRegistryPath: configPath } : {}),
|
|
317
354
|
directory: dirname(configPath),
|
|
318
355
|
path: configPath,
|
|
319
356
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { loadAuthoredFlow } from '../authored-flow-loader.js';
|
|
6
|
+
import { ensureIntegrationsConnected, type ConnectPrompt, type ConnectionsOutcome } from '../cloud-connect.js';
|
|
7
|
+
import { CloudFlowError, cloudRequest, isCloudRecord, type CloudConnectionOptions } from '../cloud-http.js';
|
|
8
|
+
import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
|
|
9
|
+
import { FLOW_HARNESSES, flowRequirements, type FlowRequirements, type FlowRequirementsContext } from '../flow-requirements.js';
|
|
10
|
+
import { readProjectConfig } from './check.js';
|
|
11
|
+
import type { CliIo } from '../cli.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The hosted verbs' shared "are the integrations connected?" step. It runs
|
|
15
|
+
* before `flows deploy`, `flows schedule` and `flows run --cloud` submit
|
|
16
|
+
* anything, so a flow that would fail at its first `f.slack.post` with
|
|
17
|
+
* `helper_slack.credential_missing` is connected — or refused — first.
|
|
18
|
+
*
|
|
19
|
+
* The flow is read the same way the submission reads it (authored loader or
|
|
20
|
+
* YAML compile); a source that does not load is left for the submission to
|
|
21
|
+
* refuse with its own, better diagnostic, so this step never masks one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface FlowConnectionsInput extends FlowRequirementsContext {
|
|
25
|
+
path: string;
|
|
26
|
+
/** `undefined` refuses a missing integration instead of prompting (`--no-connect`, `--json`, no TTY). */
|
|
27
|
+
prompt: ConnectPrompt | undefined;
|
|
28
|
+
/** Known already (deploy resolved it); otherwise `whoami` supplies it. */
|
|
29
|
+
workspaceId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function flowRequirementsForPath(
|
|
33
|
+
path: string, context: FlowRequirementsContext = {},
|
|
34
|
+
): Promise<FlowRequirements | undefined> {
|
|
35
|
+
const projectCli = context.projectCli ?? projectCliFor(path);
|
|
36
|
+
const withCli = { ...context, ...(projectCli === undefined ? {} : { projectCli }) };
|
|
37
|
+
try {
|
|
38
|
+
if (/\.flow\.ts$/iu.test(path)) {
|
|
39
|
+
const loaded = await loadAuthoredFlow(path);
|
|
40
|
+
return flowRequirements(loaded.getDefinition(loaded.handle), withCli);
|
|
41
|
+
}
|
|
42
|
+
if (!/\.(?:ya?ml|json)$/iu.test(path)) return undefined;
|
|
43
|
+
const parsed: unknown = parseYaml(await readFile(path, 'utf8'));
|
|
44
|
+
let spec;
|
|
45
|
+
try {
|
|
46
|
+
spec = compileSpec(parsed);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!(error instanceof CompileError)) throw error;
|
|
49
|
+
spec = compileSpec(kernelToAuthoring(parsed));
|
|
50
|
+
}
|
|
51
|
+
return flowRequirements(spec, withCli);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function projectCliFor(path: string): string | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return readProjectConfig(dirname(resolve(path))).cli;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `whoami`'s current workspace: the one every hosted verb submits into. */
|
|
66
|
+
export async function currentWorkspaceId(options: CloudConnectionOptions): Promise<string> {
|
|
67
|
+
const whoami = await cloudRequest('/api/v1/auth/whoami', options);
|
|
68
|
+
const workspace = isCloudRecord(whoami) && isCloudRecord(whoami.currentWorkspace) ? whoami.currentWorkspace : undefined;
|
|
69
|
+
if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
|
|
70
|
+
throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
|
|
71
|
+
}
|
|
72
|
+
return workspace.id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Connects (or refuses on) every integration `path` requires. Nothing is
|
|
77
|
+
* contacted when the flow requires no integration, so a flow with no
|
|
78
|
+
* helpers, sources or repository submits exactly as before; the derived
|
|
79
|
+
* requirements still come back so a later harness refusal can name its remedy.
|
|
80
|
+
* `undefined` only when the source does not load — the submission says why.
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureFlowConnections(
|
|
83
|
+
input: FlowConnectionsInput, options: CloudConnectionOptions = {},
|
|
84
|
+
): Promise<{ requirements: FlowRequirements; outcome: ConnectionsOutcome } | undefined> {
|
|
85
|
+
const requirements = await flowRequirementsForPath(input.path, input);
|
|
86
|
+
if (requirements === undefined) return undefined;
|
|
87
|
+
if (requirements.integrations.length === 0) return { requirements, outcome: { ready: [], connected: [] } };
|
|
88
|
+
const workspaceId = input.workspaceId ?? await currentWorkspaceId(options);
|
|
89
|
+
const outcome = await ensureIntegrationsConnected(requirements, {
|
|
90
|
+
...options, workspaceId, ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
|
|
91
|
+
});
|
|
92
|
+
return { requirements, outcome };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A terminal prompt, or nothing. `--json` output is for a machine, and a
|
|
97
|
+
* pipe cannot answer a question, so both refuse instead of asking; `--no-connect`
|
|
98
|
+
* is the explicit form of the same choice.
|
|
99
|
+
*/
|
|
100
|
+
export function cliConnectPrompt(
|
|
101
|
+
io: CliIo, { noConnect, json }: { noConnect: boolean; json: boolean },
|
|
102
|
+
streams: { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream } = { stdin: process.stdin, stdout: process.stdout },
|
|
103
|
+
): ConnectPrompt | undefined {
|
|
104
|
+
if (noConnect || json || !streams.stdin.isTTY || !streams.stdout.isTTY) return undefined;
|
|
105
|
+
return {
|
|
106
|
+
info: line => io.stderr(line),
|
|
107
|
+
confirm: question => new Promise(resolveAnswer => {
|
|
108
|
+
const rl = createInterface({ input: streams.stdin, output: streams.stdout });
|
|
109
|
+
rl.question(question, answer => {
|
|
110
|
+
rl.close();
|
|
111
|
+
resolveAnswer(/^\s*(?:y(?:es)?)?\s*$/iu.test(answer));
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Cloud refuses a run or activation whose coding-agent credential is missing
|
|
119
|
+
* (`flow_model_not_connected`, `cli_credentials_missing`); there is no status
|
|
120
|
+
* route to ask first, so the refusal is where the remedy is named.
|
|
121
|
+
*/
|
|
122
|
+
export function harnessRemedy(error: unknown, harnesses: readonly string[]): string {
|
|
123
|
+
if (!(error instanceof CloudFlowError) || error.refusal === undefined) return '';
|
|
124
|
+
if (!['flow_model_not_connected', 'cli_credentials_missing'].includes(error.refusal.code)) return '';
|
|
125
|
+
if (/agent-relay cloud connect/u.test(error.refusal.error)) return '';
|
|
126
|
+
const message = error.refusal.error.toLowerCase();
|
|
127
|
+
const named = FLOW_HARNESSES.filter(harness => message.includes(harness));
|
|
128
|
+
const targets = named.length > 0 ? named : harnesses.length > 0 ? harnesses : ['claude'];
|
|
129
|
+
return ` Connect it with: ${targets.map(harness => `agent-relay cloud connect ${harness}`).join('; ')}.`;
|
|
130
|
+
}
|
package/src/cli/cloud-deploy.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
|
|
4
4
|
type FlowTriggerSource,
|
|
5
5
|
} from '../cloud-deploy.js';
|
|
6
|
+
import { describeFlowRequirements } from '../flow-requirements.js';
|
|
7
|
+
import { cliConnectPrompt, flowRequirementsForPath, harnessRemedy } from './cloud-connect-cli.js';
|
|
6
8
|
import type { CliIo } from '../cli.js';
|
|
7
9
|
|
|
8
10
|
export interface CloudDeployArgs {
|
|
@@ -14,12 +16,14 @@ export interface CloudDeployArgs {
|
|
|
14
16
|
name: string | undefined;
|
|
15
17
|
agents: string | undefined;
|
|
16
18
|
draft: boolean;
|
|
19
|
+
/** Refuse a missing integration instead of offering to connect it. */
|
|
20
|
+
noConnect: boolean;
|
|
17
21
|
json: boolean;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
|
|
22
|
-
* --approver <handle> [--name <n>] [--json]`
|
|
26
|
+
* --approver <handle> [--name <n>] [--agents <list>] [--draft] [--no-connect] [--json]`
|
|
23
27
|
*
|
|
24
28
|
* Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
|
|
25
29
|
* share nothing but the word: the digest form copies a sealed bundle into a
|
|
@@ -32,6 +36,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
|
|
|
32
36
|
let name: string | undefined;
|
|
33
37
|
let agents: string | undefined;
|
|
34
38
|
let draft = false;
|
|
39
|
+
let noConnect = false;
|
|
35
40
|
let json = false;
|
|
36
41
|
const on: string[] = [];
|
|
37
42
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -46,6 +51,11 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
|
|
|
46
51
|
draft = true;
|
|
47
52
|
continue;
|
|
48
53
|
}
|
|
54
|
+
if (arg === '--no-connect') {
|
|
55
|
+
if (noConnect) return undefined;
|
|
56
|
+
noConnect = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
49
59
|
if (arg === '--agents') {
|
|
50
60
|
const next = args[i + 1];
|
|
51
61
|
if (agents !== undefined || next === undefined || next.startsWith('-')) return undefined;
|
|
@@ -68,7 +78,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
|
|
|
68
78
|
value = arg;
|
|
69
79
|
}
|
|
70
80
|
if (value === undefined || repo === undefined || on.length === 0) return undefined;
|
|
71
|
-
return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, json };
|
|
81
|
+
return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, noConnect, json };
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function describeSource(source: FlowTriggerSource): string {
|
|
@@ -77,11 +87,19 @@ function describeSource(source: FlowTriggerSource): string {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
|
|
90
|
+
const agents = args.agents === undefined ? undefined : parseAgentHarnessesOr(args.agents);
|
|
91
|
+
// What the source declares, so a harness refusal names the right remedy
|
|
92
|
+
// even when `--agents` was not given; the loader's own failure is reported
|
|
93
|
+
// by deployToCloud, not here.
|
|
94
|
+
let harnesses: readonly string[] = agents instanceof CloudFlowError ? [] : agents ?? [];
|
|
80
95
|
try {
|
|
81
96
|
if (args.approver === undefined) {
|
|
82
97
|
throw new CloudFlowError('invalid_input',
|
|
83
98
|
'--approver <handle> is required: every launched run receives it as input.approver for f.human.');
|
|
84
99
|
}
|
|
100
|
+
if (agents instanceof CloudFlowError) throw agents;
|
|
101
|
+
const connect = cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json });
|
|
102
|
+
if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
|
|
85
103
|
const deployment = await deployToCloud({
|
|
86
104
|
path: args.value,
|
|
87
105
|
repository: parseRepository(args.repo),
|
|
@@ -89,7 +107,8 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
|
|
|
89
107
|
approver: args.approver,
|
|
90
108
|
draft: args.draft,
|
|
91
109
|
...(args.name === undefined ? {} : { name: args.name }),
|
|
92
|
-
...(
|
|
110
|
+
...(agents === undefined ? {} : { agents }),
|
|
111
|
+
...(connect === undefined ? {} : { connect }),
|
|
93
112
|
});
|
|
94
113
|
if (args.json) {
|
|
95
114
|
io.stdout(JSON.stringify({ ok: true, ...deployment }));
|
|
@@ -99,12 +118,24 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
|
|
|
99
118
|
io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
|
|
100
119
|
io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
|
|
101
120
|
for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
|
|
121
|
+
const requires = describeFlowRequirements(deployment.requirements);
|
|
122
|
+
if (requires) io.stdout(` requires: ${requires}`);
|
|
123
|
+
for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
|
|
102
124
|
io.stdout(deployment.status === 'draft'
|
|
103
125
|
? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
|
|
104
126
|
: 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
|
|
105
127
|
return 0;
|
|
106
128
|
} catch (error) {
|
|
107
|
-
return reportCloudFailure(error, args.json, io);
|
|
129
|
+
return reportCloudFailure(error, args.json, io, harnesses);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseAgentHarnessesOr(value: string): ReturnType<typeof parseAgentHarnesses> | CloudFlowError {
|
|
134
|
+
try {
|
|
135
|
+
return parseAgentHarnesses(value);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof CloudFlowError) return error;
|
|
138
|
+
throw error;
|
|
108
139
|
}
|
|
109
140
|
}
|
|
110
141
|
|
|
@@ -140,9 +171,11 @@ export async function runCloudUndeployCli({ agentId, json }: { agentId: string;
|
|
|
140
171
|
}
|
|
141
172
|
}
|
|
142
173
|
|
|
143
|
-
function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
|
|
174
|
+
function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
|
|
144
175
|
const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
|
|
145
176
|
let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
|
|
177
|
+
// Cloud names the missing coding-agent credential on activation; say how it is connected.
|
|
178
|
+
message += harnessRemedy(error, harnesses);
|
|
146
179
|
// The deploy routes take a browser session or a `cli:auth` token. A
|
|
147
180
|
// deployment (CI) token gets 403 `session_required`; say what fixes it.
|
|
148
181
|
if (error instanceof CloudFlowError && error.status === 403) {
|
|
@@ -152,6 +185,7 @@ function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
|
|
|
152
185
|
if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
|
|
153
186
|
else io.stderr(`${code}: ${message}`);
|
|
154
187
|
return error instanceof CloudFlowError
|
|
155
|
-
&& (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code)
|
|
188
|
+
&& (['configuration', 'unsupported_source', 'invalid_input', 'integration_not_connected'].includes(error.code)
|
|
189
|
+
|| error.status === 403 || error.status === 401)
|
|
156
190
|
? 2 : 1;
|
|
157
191
|
}
|
package/src/cli/cloud-run.ts
CHANGED
|
@@ -2,12 +2,13 @@ import { CloudFlowError } from '../cloud-http.js';
|
|
|
2
2
|
import { runInCloud, waitForCloudFlowRun, type RunInCloudOptions } from '../cloud-run.js';
|
|
3
3
|
import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
|
|
4
4
|
import { snapshotJsonValue } from '../json-value.js';
|
|
5
|
+
import { cliConnectPrompt, ensureFlowConnections, harnessRemedy } from './cloud-connect-cli.js';
|
|
5
6
|
import type { CliIo } from '../cli.js';
|
|
6
7
|
|
|
7
8
|
/** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
|
|
8
9
|
export async function runCloudCli(
|
|
9
|
-
{ value: path, json, wait, input, syncCode }: {
|
|
10
|
-
value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean;
|
|
10
|
+
{ value: path, json, wait, input, syncCode, noConnect = false }: {
|
|
11
|
+
value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect?: boolean;
|
|
11
12
|
},
|
|
12
13
|
io: CliIo,
|
|
13
14
|
): Promise<0 | 1 | 2> {
|
|
@@ -20,6 +21,7 @@ export async function runCloudCli(
|
|
|
20
21
|
// packing or upload — admitted nothing and is safe to retry; only an
|
|
21
22
|
// interrupted submission has unknown admission.
|
|
22
23
|
let submitting = false;
|
|
24
|
+
let harnesses: readonly string[] = [];
|
|
23
25
|
try {
|
|
24
26
|
const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
|
|
25
27
|
if (isAuthoredFlowPath(path)) {
|
|
@@ -32,6 +34,15 @@ export async function runCloudCli(
|
|
|
32
34
|
throw error;
|
|
33
35
|
}
|
|
34
36
|
}
|
|
37
|
+
// After local validation, before anything is packed or uploaded: a helper
|
|
38
|
+
// the run would call against an unconnected integration is connected
|
|
39
|
+
// here, or refused here. A command that is going to be refused for its
|
|
40
|
+
// arguments must not first open a browser.
|
|
41
|
+
const connections = await ensureFlowConnections({
|
|
42
|
+
path, prompt: cliConnectPrompt(io, { noConnect, json }),
|
|
43
|
+
}, { signal: controller.signal });
|
|
44
|
+
harnesses = connections?.requirements.harnesses ?? [];
|
|
45
|
+
for (const provider of connections?.outcome.connected ?? []) if (!json) io.stdout(`CONNECTED ${provider}`);
|
|
35
46
|
// The tree is the invoking directory, as with v1: the flow path is where
|
|
36
47
|
// the body lives, not the boundary of what the run may read.
|
|
37
48
|
if (syncCode) options.syncCode = { root: process.cwd() };
|
|
@@ -65,11 +76,12 @@ export async function runCloudCli(
|
|
|
65
76
|
: submitting
|
|
66
77
|
? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
|
|
67
78
|
: 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
|
|
68
|
-
: error instanceof Error ? error.message : 'Cloud run failed.';
|
|
79
|
+
: (error instanceof Error ? error.message : 'Cloud run failed.') + harnessRemedy(error, harnesses);
|
|
69
80
|
if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
|
|
70
81
|
else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
|
|
71
82
|
return error instanceof CloudFlowError
|
|
72
|
-
&& (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'
|
|
83
|
+
&& (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported',
|
|
84
|
+
'integration_not_connected'].includes(error.code)
|
|
73
85
|
|| (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
|
|
74
86
|
} finally {
|
|
75
87
|
process.off('SIGINT', abort);
|