@relayflows/sdk 2.0.17 → 2.0.19
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 +47 -3
- package/dist/authored-flow-executor.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 +15 -4
- 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/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.d.ts +9 -0
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +26 -7
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +2 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +11 -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 +9 -2
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +27 -15
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +3 -1
- package/dist/cli/cloud-schedule.d.ts.map +1 -1
- package/dist/cli/cloud-schedule.js +31 -6
- package/dist/cli/cloud-schedule.js.map +1 -1
- package/dist/cli/cloud-sync.d.ts +8 -1
- package/dist/cli/cloud-sync.d.ts.map +1 -1
- package/dist/cli/cloud-sync.js +79 -8
- package/dist/cli/cloud-sync.js.map +1 -1
- package/dist/cli/deploy.d.ts +6 -0
- package/dist/cli/deploy.d.ts.map +1 -1
- package/dist/cli/deploy.js +29 -4
- package/dist/cli/deploy.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +5 -1
- 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 +34 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/serve-webhook.d.ts +7 -1
- package/dist/cli/serve-webhook.d.ts.map +1 -1
- package/dist/cli/serve-webhook.js +19 -9
- package/dist/cli/serve-webhook.js.map +1 -1
- package/dist/cli-commands.d.ts +398 -0
- package/dist/cli-commands.d.ts.map +1 -0
- package/dist/cli-commands.js +254 -0
- package/dist/cli-commands.js.map +1 -0
- package/dist/cli-watch.d.ts +3 -1
- package/dist/cli-watch.d.ts.map +1 -1
- package/dist/cli-watch.js +4 -10
- package/dist/cli-watch.js.map +1 -1
- package/dist/cli.d.ts +127 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +195 -57
- 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 +40 -1
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-http.d.ts +2 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-schedule.d.ts +7 -0
- package/dist/cloud-schedule.d.ts.map +1 -1
- package/dist/cloud-schedule.js +22 -1
- package/dist/cloud-schedule.js.map +1 -1
- package/dist/cloud-sync.d.ts +85 -2
- package/dist/cloud-sync.d.ts.map +1 -1
- package/dist/cloud-sync.js +123 -10
- package/dist/cloud-sync.js.map +1 -1
- package/dist/failure-kinds.d.ts +1 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +2 -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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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/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/relay-cli.d.ts +50 -0
- package/dist/relay-cli.d.ts.map +1 -0
- package/dist/relay-cli.js +64 -0
- package/dist/relay-cli.js.map +1 -0
- package/package.json +7 -2
- package/src/authored-flow-error.ts +29 -0
- package/src/authored-flow-executor.ts +54 -2
- package/src/authored-human.ts +150 -0
- package/src/authored-node-entry.ts +3 -2
- package/src/authored-node-runner.ts +20 -5
- package/src/authored-root.ts +28 -1
- package/src/cli/answer.ts +98 -0
- package/src/cli/build.ts +21 -7
- package/src/cli/check-triggers.ts +2 -0
- package/src/cli/check.ts +24 -2
- package/src/cli/cloud-connect-cli.ts +130 -0
- package/src/cli/cloud-deploy.ts +40 -6
- package/src/cli/cloud-run.ts +26 -15
- package/src/cli/cloud-schedule.ts +31 -6
- package/src/cli/cloud-sync.ts +85 -8
- package/src/cli/deploy.ts +27 -5
- package/src/cli/direct-run.ts +5 -0
- package/src/cli/run.ts +48 -2
- package/src/cli/serve-webhook.ts +15 -8
- package/src/cli-commands.ts +339 -0
- package/src/cli-watch.ts +8 -9
- package/src/cli.ts +206 -54
- package/src/cloud-connect.ts +187 -0
- package/src/cloud-deploy.ts +57 -2
- package/src/cloud-http.ts +1 -1
- package/src/cloud-schedule.ts +20 -3
- package/src/cloud-sync.ts +164 -11
- package/src/failure-kinds.ts +2 -0
- package/src/flow-requirements.ts +201 -0
- package/src/index.ts +12 -2
- package/src/journal-client.ts +20 -0
- package/src/progress.ts +11 -4
- package/src/protocol.ts +23 -0
- package/src/relay-cli.ts +117 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { AuthoredFlowExecutionError, type AuthoredHumanWait } from './authored-flow-error.js';
|
|
2
|
+
import type { JournalClient } from './journal-client.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The answer contract for a parked `f.human`.
|
|
6
|
+
*
|
|
7
|
+
* The CLIENT sends `{ answer, note?, answeredBy }` as the `payload` of the
|
|
8
|
+
* `event.emit` that closes the wait (kernel DESIGN.md §5: `event_key` is the
|
|
9
|
+
* `wait_id`); `answeredBy` is required — the kernel refuses an unattributed
|
|
10
|
+
* answer. The KERNEL journals it as the `result` of
|
|
11
|
+
* `wait.completed{human_responded}` with two fields of its own: `at_ms`, the
|
|
12
|
+
* entry's clock (any client `at` is dropped — the journal says when), and
|
|
13
|
+
* `attribution: "client_asserted"`, because the daemon socket, not the kernel,
|
|
14
|
+
* is what authenticated whoever said `answeredBy`. `flows answer` and Cloud's
|
|
15
|
+
* resumed sandbox both produce the client half; the body reads the kernel half.
|
|
16
|
+
*/
|
|
17
|
+
export interface HumanAnswer {
|
|
18
|
+
readonly answer: boolean;
|
|
19
|
+
readonly note?: string;
|
|
20
|
+
readonly answeredBy: string;
|
|
21
|
+
/** Kernel clock at the journaled `wait.completed`, epoch milliseconds. */
|
|
22
|
+
readonly atMs?: number;
|
|
23
|
+
/** How `answeredBy` was established. The kernel writes `client_asserted`. */
|
|
24
|
+
readonly attribution?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type HumanAnswerPayload = Pick<HumanAnswer, 'answer' | 'note' | 'answeredBy'>;
|
|
28
|
+
|
|
29
|
+
/** An open `wait.human` on a run: asked, not yet answered. */
|
|
30
|
+
export interface OpenHumanWait extends AuthoredHumanWait {
|
|
31
|
+
readonly stepId: string;
|
|
32
|
+
readonly attempt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const HUMAN_WAIT_ID = /^human-[1-9][0-9]*$/;
|
|
36
|
+
|
|
37
|
+
export function humanAnswerPayload(answer: boolean, extra: { note?: string; answeredBy: string }): HumanAnswerPayload {
|
|
38
|
+
if (extra.answeredBy.trim() === '') {
|
|
39
|
+
throw new AuthoredFlowExecutionError('human_answer_invalid', 'an answer must say who gave it (answeredBy)');
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
answer,
|
|
43
|
+
...(extra.note === undefined || extra.note === '' ? {} : { note: extra.note }),
|
|
44
|
+
answeredBy: extra.answeredBy,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Narrow an untrusted journal `result` to the answer contract, or refuse it. */
|
|
49
|
+
export function parseHumanAnswer(value: unknown, waitId: string): HumanAnswer {
|
|
50
|
+
const record = value as { answer?: unknown; note?: unknown; answeredBy?: unknown; at_ms?: unknown; attribution?: unknown } | null;
|
|
51
|
+
if (typeof record !== 'object' || record === null || typeof record.answer !== 'boolean'
|
|
52
|
+
|| (record.note !== undefined && typeof record.note !== 'string')
|
|
53
|
+
|| typeof record.answeredBy !== 'string' || record.answeredBy.trim() === ''
|
|
54
|
+
|| (record.at_ms !== undefined && !Number.isSafeInteger(record.at_ms))
|
|
55
|
+
|| (record.attribution !== undefined && typeof record.attribution !== 'string')) {
|
|
56
|
+
throw new AuthoredFlowExecutionError(
|
|
57
|
+
'human_answer_invalid',
|
|
58
|
+
`the recorded answer to ${waitId} is not { answer: boolean, answeredBy: string }; answer it again with flows answer`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
answer: record.answer,
|
|
63
|
+
...(record.note === undefined ? {} : { note: record.note }),
|
|
64
|
+
answeredBy: record.answeredBy,
|
|
65
|
+
...(record.at_ms === undefined ? {} : { atMs: record.at_ms as number }),
|
|
66
|
+
...(record.attribution === undefined ? {} : { attribution: record.attribution }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface WaitEntry {
|
|
71
|
+
entry_type?: string;
|
|
72
|
+
step_id?: string | null;
|
|
73
|
+
attempt?: number | null;
|
|
74
|
+
payload?: {
|
|
75
|
+
wait_id?: unknown; prompt?: unknown; requested_of?: unknown;
|
|
76
|
+
completionReason?: unknown; result?: unknown;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readWaitEntries(journal: JournalClient, runId: string): Promise<WaitEntry[]> {
|
|
81
|
+
const waits: WaitEntry[] = [];
|
|
82
|
+
let fromSeq = 1;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const page = (await journal.journalRead(runId, fromSeq, 1000)).entries as Array<WaitEntry & { seq?: number }>;
|
|
85
|
+
if (page.length === 0) break;
|
|
86
|
+
for (const entry of page) {
|
|
87
|
+
if (typeof entry.seq !== 'number' || entry.seq < fromSeq) {
|
|
88
|
+
throw new AuthoredFlowExecutionError('journal_protocol_violation', `journal.read for ${runId} returned out-of-order entries`);
|
|
89
|
+
}
|
|
90
|
+
fromSeq = entry.seq + 1;
|
|
91
|
+
if (entry.entry_type === 'wait.human' || entry.entry_type === 'wait.completed') waits.push(entry);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return waits;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The recorded answer to `waitId`, or `undefined` while the question is open
|
|
99
|
+
* or not yet asked. The first `human_responded` completion wins: the kernel
|
|
100
|
+
* closes a wait once, so a second answer never reaches the journal.
|
|
101
|
+
*/
|
|
102
|
+
export async function readHumanAnswer(
|
|
103
|
+
journal: JournalClient,
|
|
104
|
+
runId: string,
|
|
105
|
+
waitId: string,
|
|
106
|
+
): Promise<HumanAnswer | undefined> {
|
|
107
|
+
for (const entry of await readWaitEntries(journal, runId)) {
|
|
108
|
+
if (entry.entry_type === 'wait.completed' && entry.payload?.wait_id === waitId
|
|
109
|
+
&& entry.payload.completionReason === 'human_responded') {
|
|
110
|
+
return parseHumanAnswer(entry.payload.result, waitId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every `wait.human` on the run that no `wait.completed` has closed. */
|
|
117
|
+
export async function readOpenHumanWaits(journal: JournalClient, runId: string): Promise<OpenHumanWait[]> {
|
|
118
|
+
const open = new Map<string, OpenHumanWait>();
|
|
119
|
+
for (const entry of await readWaitEntries(journal, runId)) {
|
|
120
|
+
const waitId = entry.payload?.wait_id;
|
|
121
|
+
if (typeof waitId !== 'string') continue;
|
|
122
|
+
if (entry.entry_type === 'wait.human') {
|
|
123
|
+
open.set(waitId, {
|
|
124
|
+
waitId,
|
|
125
|
+
question: typeof entry.payload?.prompt === 'string' ? entry.payload.prompt : '',
|
|
126
|
+
to: typeof entry.payload?.requested_of === 'string' ? entry.payload.requested_of : '',
|
|
127
|
+
stepId: typeof entry.step_id === 'string' ? entry.step_id : '',
|
|
128
|
+
attempt: typeof entry.attempt === 'number' ? entry.attempt : 0,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
open.delete(waitId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...open.values()];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How to answer a parked question from a shell, with the run's own data dir. */
|
|
138
|
+
export function answerCommand(runId: string, waitId: string, dataDir?: string): string {
|
|
139
|
+
const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
|
|
140
|
+
return `flows answer${dir} ${runId} ${waitId} yes|no`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resumeCommand(runId: string, dataDir?: string, localAgent = false): string {
|
|
144
|
+
const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
|
|
145
|
+
return `flows resume${dir}${localAgent ? ' --local-agent' : ''} ${runId}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function shellWord(value: string): string {
|
|
149
|
+
return /^[A-Za-z0-9_./=:@%+,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
150
|
+
}
|
|
@@ -5,7 +5,7 @@ import { JournalClient } from './journal-client.js';
|
|
|
5
5
|
import { executeAuthoredFlow } from './authored-flow-executor.js';
|
|
6
6
|
import { loadPinnedAuthoredSource } from './authored-source-authority.js';
|
|
7
7
|
import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-runtime-capability.js';
|
|
8
|
-
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
8
|
+
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
|
|
9
9
|
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
10
10
|
|
|
11
11
|
let channelKey: string | undefined, sequence = 0;
|
|
@@ -82,7 +82,8 @@ try {
|
|
|
82
82
|
const prefix = error instanceof AuthoredFlowExecutionError ? `${error.code}: ` : '';
|
|
83
83
|
send({ type: 'error', message: prefix && message.startsWith(prefix) ? message.slice(prefix.length) : message,
|
|
84
84
|
...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
|
|
85
|
-
completionReason: error.completionReason, runId: error.runId } : {})
|
|
85
|
+
completionReason: error.completionReason, runId: error.runId } : {}),
|
|
86
|
+
...(error instanceof AuthoredHumanParked ? { wait: error.wait } : {}) });
|
|
86
87
|
process.exitCode = 1;
|
|
87
88
|
} finally {
|
|
88
89
|
finished = true; await watchdog.terminate(); client?.close(); process.stdin.destroy();
|
|
@@ -9,7 +9,11 @@ import type { Readable } from 'node:stream';
|
|
|
9
9
|
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
10
10
|
import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
|
|
11
11
|
import { completionMarker, isLoweredCompletion } from './authored-flow-executor.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
AuthoredFlowExecutionError, AuthoredHumanParked,
|
|
14
|
+
type AuthoredFlowExecutionErrorCode, type AuthoredHumanWait,
|
|
15
|
+
} from './authored-flow-error.js';
|
|
16
|
+
import { HUMAN_WAIT_ID } from './authored-human.js';
|
|
13
17
|
import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
|
|
14
18
|
|
|
15
19
|
let embeddedSource: string | undefined;
|
|
@@ -131,10 +135,12 @@ export async function runAuthoredInNode(
|
|
|
131
135
|
else if (message.type === 'wait') options.onWait?.(message.event);
|
|
132
136
|
else if (message.type === 'result') result = { ...message.result, executionRuntime: runtime };
|
|
133
137
|
else if (message.type === 'error') {
|
|
134
|
-
failure =
|
|
135
|
-
? new
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
failure = message.code === 'human_parked' && isHumanWaitFrame(message.wait) && message.runId === rootRunId
|
|
139
|
+
? new AuthoredHumanParked(message.wait, rootRunId)
|
|
140
|
+
: typeof message.code === 'string'
|
|
141
|
+
? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
|
|
142
|
+
message.message, message.completionReason, message.runId)
|
|
143
|
+
: new Error(message.message);
|
|
138
144
|
} else throw new Error('unknown authored runtime message');
|
|
139
145
|
} catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); }
|
|
140
146
|
}
|
|
@@ -156,6 +162,15 @@ export async function runAuthoredInNode(
|
|
|
156
162
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
157
163
|
}
|
|
158
164
|
|
|
165
|
+
/** A park signal from the child names the question the parent must journal. */
|
|
166
|
+
function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
|
|
167
|
+
const wait = value as Partial<AuthoredHumanWait> | null;
|
|
168
|
+
return typeof wait === 'object' && wait !== null
|
|
169
|
+
&& typeof wait.waitId === 'string' && HUMAN_WAIT_ID.test(wait.waitId)
|
|
170
|
+
&& typeof wait.question === 'string' && wait.question !== ''
|
|
171
|
+
&& typeof wait.to === 'string' && wait.to !== '';
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
/** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
|
|
160
175
|
export async function verifyAuthoredNodeResult(
|
|
161
176
|
result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
|
package/src/authored-root.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { RunOutcome, StepDispatchEvent } from './protocol.js';
|
|
|
15
15
|
import { SPEC_SCHEMA_VERSION } from './spec.js';
|
|
16
16
|
import type { RunLifecycleOptions } from './cli/run.js';
|
|
17
17
|
import { withWorkerLease } from './worker-lease.js';
|
|
18
|
+
import { AuthoredHumanParked } from './authored-flow-error.js';
|
|
19
|
+
import { readOpenHumanWaits } from './authored-human.js';
|
|
18
20
|
import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
19
21
|
|
|
20
22
|
const ROOT_KIND = 'relayflows.authored-root.v1';
|
|
@@ -94,7 +96,8 @@ export async function executeDurableAuthoredFlow(
|
|
|
94
96
|
// active run but receives no second dispatch from start itself. Resume is
|
|
95
97
|
// safe for the first caller too: the daemon preserves a live lease and
|
|
96
98
|
// redelivers only when the former worker connection is gone.
|
|
97
|
-
await journal.runResume(outcome.run_id);
|
|
99
|
+
const resumed = await journal.runResume(outcome.run_id);
|
|
100
|
+
await assertNoOpenHumanWait(journal, resumed);
|
|
98
101
|
const dispatch = await dispatchWait.promise;
|
|
99
102
|
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
100
103
|
} finally {
|
|
@@ -133,6 +136,7 @@ export async function resumeDurableAuthoredFlow(
|
|
|
133
136
|
return await completedRootResult(journal, rootRunId);
|
|
134
137
|
}
|
|
135
138
|
assertRootCanDispatch(outcome);
|
|
139
|
+
await assertNoOpenHumanWait(journal, outcome);
|
|
136
140
|
const dispatch = await dispatchWait.promise;
|
|
137
141
|
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
138
142
|
} finally {
|
|
@@ -141,6 +145,17 @@ export async function resumeDurableAuthoredFlow(
|
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A root parked on an unanswered `f.human` will not be dispatched: the
|
|
150
|
+
* kernel holds it in `needs_human` until `event.emit` closes the wait. Report
|
|
151
|
+
* the open question instead of waiting for a dispatch that cannot arrive.
|
|
152
|
+
*/
|
|
153
|
+
async function assertNoOpenHumanWait(journal: JournalClient, outcome: RunOutcome): Promise<void> {
|
|
154
|
+
if (outcome.status !== 'parked') return;
|
|
155
|
+
const [open] = await readOpenHumanWaits(journal, outcome.run_id);
|
|
156
|
+
if (open !== undefined) throw new AuthoredHumanParked(open, outcome.run_id);
|
|
157
|
+
}
|
|
158
|
+
|
|
144
159
|
export async function readAuthoredRootMetadata(
|
|
145
160
|
journal: JournalClient,
|
|
146
161
|
runId: string,
|
|
@@ -206,6 +221,18 @@ async function driveRoot(
|
|
|
206
221
|
);
|
|
207
222
|
return Object.freeze({ ...result, rootRunId: dispatch.run_id });
|
|
208
223
|
} catch (error) {
|
|
224
|
+
if (error instanceof AuthoredHumanParked) {
|
|
225
|
+
// Not a failure: the body reached a question nobody has answered. Park
|
|
226
|
+
// THIS attempt on the kernel's `wait.human` under the body's own wait
|
|
227
|
+
// id, so the answer (`event.emit` keyed by it) re-dispatches the root
|
|
228
|
+
// and the re-run body finds it. The lease is released by the verb; the
|
|
229
|
+
// signal propagates so the CLI reports the question with exit 3.
|
|
230
|
+
await peer.stepWait(dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key, {
|
|
231
|
+
wait_id: error.wait.waitId, prompt: error.wait.question, requested_of: error.wait.to,
|
|
232
|
+
options: ['yes', 'no'],
|
|
233
|
+
});
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
209
236
|
await terminalizeRootFailure(peer, dispatch, error);
|
|
210
237
|
throw error;
|
|
211
238
|
}
|
|
@@ -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
|
@@ -12,14 +12,20 @@ import type { FlowSpec } from '../spec.js';
|
|
|
12
12
|
|
|
13
13
|
export interface BuildArgs { command: 'build'; value: string; out?: string; verify: boolean; json: boolean }
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `--verify` is an ordinary flag, not a leading mode token: the command surface
|
|
17
|
+
* lists it beside `--out` and `--json`, so every order the help implies has to
|
|
18
|
+
* parse (`build --verify --json <dir>` and `build <dir> --verify` alike).
|
|
19
|
+
*
|
|
20
|
+
* `--out` is the one combination genuinely refused: it names where a build
|
|
21
|
+
* writes, and a verify builds nothing, so accepting the pair would silently
|
|
22
|
+
* ignore the destination. The surface says so in the option's description.
|
|
23
|
+
*/
|
|
15
24
|
export function parseBuildArgs(args: readonly string[]): BuildArgs | undefined {
|
|
16
|
-
if (args[0] === '--verify') {
|
|
17
|
-
return args.length === 2 && !args[1]!.startsWith('-')
|
|
18
|
-
? { command: 'build', value: args[1]!, verify: true, json: false } : undefined;
|
|
19
|
-
}
|
|
20
25
|
let out: string | undefined;
|
|
21
26
|
let value: string | undefined;
|
|
22
27
|
let json = false;
|
|
28
|
+
let verify = false;
|
|
23
29
|
for (let i = 0; i < args.length; i++) {
|
|
24
30
|
const arg = args[i]!;
|
|
25
31
|
if (arg === '--out') {
|
|
@@ -28,10 +34,14 @@ export function parseBuildArgs(args: readonly string[]): BuildArgs | undefined {
|
|
|
28
34
|
} else if (arg === '--json') {
|
|
29
35
|
if (json) return undefined;
|
|
30
36
|
json = true;
|
|
37
|
+
} else if (arg === '--verify') {
|
|
38
|
+
if (verify) return undefined;
|
|
39
|
+
verify = true;
|
|
31
40
|
} else if (arg.startsWith('-') || value !== undefined) return undefined;
|
|
32
41
|
else value = arg;
|
|
33
42
|
}
|
|
34
|
-
|
|
43
|
+
if (value === undefined || (verify && out !== undefined)) return undefined;
|
|
44
|
+
return { command: 'build', value, out, verify, json };
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -49,7 +59,11 @@ function emitBuildCheckReport(report: CheckReport, json: boolean, io: CliIo): vo
|
|
|
49
59
|
export async function runBuild(args: BuildArgs, io: CliIo): Promise<0 | 2> {
|
|
50
60
|
try {
|
|
51
61
|
if (args.verify) {
|
|
52
|
-
|
|
62
|
+
const digest = `sha256:${await verifyBundle(args.value)}`;
|
|
63
|
+
// `--json` is declared on the verb, not on one of its forms: a verify
|
|
64
|
+
// under it emits the same single object a `--json` consumer parses.
|
|
65
|
+
if (args.json) io.stdout(JSON.stringify({ ok: true, verified: true, bundle: args.value, digest }));
|
|
66
|
+
else io.stdout(`VERIFIED ${digest}`);
|
|
53
67
|
return 0;
|
|
54
68
|
}
|
|
55
69
|
// Gate the build on the same preflight pipeline `flows check` uses.
|
|
@@ -112,7 +126,7 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
|
|
|
112
126
|
...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
|
|
113
127
|
projectSearchStart: directory,
|
|
114
128
|
models: config.models,
|
|
115
|
-
...(config.
|
|
129
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
116
130
|
probes: {
|
|
117
131
|
cli: () => { throw new Error('deferred to deployment'); },
|
|
118
132
|
executor: () => { throw new Error('deferred to deployment'); },
|
|
@@ -4,6 +4,7 @@ import { preflightWebhookTriggers } from '../preflight.js';
|
|
|
4
4
|
import { preflightProviderTriggers } from '../provider-trigger-contract.js';
|
|
5
5
|
import { scheduleLowering } from '../schedule-trigger.js';
|
|
6
6
|
import { checkSlackHelpers } from '../slack-preflight.js';
|
|
7
|
+
import { flowRequirements } from '../flow-requirements.js';
|
|
7
8
|
import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -46,6 +47,7 @@ export async function checkAuthoredTriggers(path: string): Promise<{
|
|
|
46
47
|
ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'),
|
|
47
48
|
path, gates: [], resolutions: [], diagnostics,
|
|
48
49
|
...(schedules.length === 0 ? {} : { schedules }),
|
|
50
|
+
requirements: flowRequirements(definition, { projectCli: config.cli }),
|
|
49
51
|
...(config.path === undefined ? {} : { projectConfigPath: config.path }),
|
|
50
52
|
},
|
|
51
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
|
}
|
|
@@ -47,6 +56,8 @@ export interface CheckReport {
|
|
|
47
56
|
resolutions: CliResolution[];
|
|
48
57
|
/** Authored `schedule.*` handlers and the `flows.tick` subscription each lowers to. */
|
|
49
58
|
schedules?: ScheduleInspection[];
|
|
59
|
+
/** Integrations, harnesses and MCP servers the flow declares it needs (`flow-requirements.ts`). */
|
|
60
|
+
requirements?: FlowRequirements;
|
|
50
61
|
diagnostics: Array<PreflightDiagnostic | CheckInputDiagnostic | CheckWarningDiagnostic>;
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -117,6 +128,15 @@ export function checkFlow(path: string): CheckExecution {
|
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
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
|
+
|
|
120
140
|
/** Preflight a validated authored flow through the same path as YAML/JSON. */
|
|
121
141
|
export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConfig?: ProjectConfig): CheckExecution {
|
|
122
142
|
const absolutePath = resolve(path);
|
|
@@ -128,7 +148,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
|
|
|
128
148
|
projectConfigPath: config.path,
|
|
129
149
|
projectSearchStart: dirname(absolutePath),
|
|
130
150
|
models: config.models,
|
|
131
|
-
...(config.
|
|
151
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
132
152
|
probes,
|
|
133
153
|
});
|
|
134
154
|
const flow = result.ok
|
|
@@ -147,6 +167,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
|
|
|
147
167
|
gates: result.gates,
|
|
148
168
|
resolutions: result.resolutions,
|
|
149
169
|
diagnostics: result.diagnostics,
|
|
170
|
+
requirements: safeRequirements(authoring, config.cli),
|
|
150
171
|
},
|
|
151
172
|
...(flow !== undefined ? { flow } : {}),
|
|
152
173
|
};
|
|
@@ -196,7 +217,7 @@ export async function checkBuildableFlow(path: string): Promise<CheckExecution>
|
|
|
196
217
|
...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
|
|
197
218
|
projectSearchStart: dirname(absolutePath),
|
|
198
219
|
models: config.models,
|
|
199
|
-
...(config.
|
|
220
|
+
...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
|
|
200
221
|
probes: deferred,
|
|
201
222
|
});
|
|
202
223
|
// Refusals rooted in build-machine environment probes (`probe_failed`)
|
|
@@ -329,6 +350,7 @@ export function readProjectConfig(start: string): ProjectConfig {
|
|
|
329
350
|
...(value['cli'] !== undefined ? { cli: value['cli'] as string } : {}),
|
|
330
351
|
executors: (value['executors'] as string[] | undefined) ?? [],
|
|
331
352
|
models: (value['models'] as string[] | undefined) ?? [],
|
|
353
|
+
...(value['models'] !== undefined ? { modelRegistryPath: configPath } : {}),
|
|
332
354
|
directory: dirname(configPath),
|
|
333
355
|
path: configPath,
|
|
334
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
|
+
}
|