@relayflows/sdk 2.0.55 → 2.0.57
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/adapters/base.d.ts +2 -0
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/devin.d.ts +4 -0
- package/dist/adapters/devin.d.ts.map +1 -0
- package/dist/adapters/devin.js +20 -0
- package/dist/adapters/devin.js.map +1 -0
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +5 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/authored-broker.d.ts +13 -0
- package/dist/authored-broker.d.ts.map +1 -0
- package/dist/authored-broker.js +75 -0
- package/dist/authored-broker.js.map +1 -0
- package/dist/authored-budget.d.ts +1 -1
- package/dist/authored-budget.d.ts.map +1 -1
- package/dist/authored-budget.js +8 -2
- package/dist/authored-budget.js.map +1 -1
- package/dist/authored-flow-error.d.ts +6 -0
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +11 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +104 -20
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +42 -3
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.d.ts.map +1 -1
- package/dist/authored-helper-effect.js +1 -1
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-hooks.d.ts +2 -1
- package/dist/authored-hooks.d.ts.map +1 -1
- package/dist/authored-hooks.js +2 -2
- package/dist/authored-hooks.js.map +1 -1
- package/dist/authored-human.d.ts +18 -0
- package/dist/authored-human.d.ts.map +1 -1
- package/dist/authored-human.js +32 -4
- package/dist/authored-human.js.map +1 -1
- package/dist/authored-mcp.d.ts.map +1 -1
- package/dist/authored-mcp.js +3 -2
- package/dist/authored-mcp.js.map +1 -1
- package/dist/authored-node-entry.js +4 -0
- 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 +1 -0
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-plugin-effect.d.ts.map +1 -1
- package/dist/authored-plugin-effect.js +3 -2
- package/dist/authored-plugin-effect.js.map +1 -1
- package/dist/authored-preflight.d.ts.map +1 -1
- package/dist/authored-preflight.js +2 -1
- package/dist/authored-preflight.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +13 -3
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-serial-ordinals.d.ts +41 -0
- package/dist/authored-serial-ordinals.d.ts.map +1 -0
- package/dist/authored-serial-ordinals.js +45 -0
- package/dist/authored-serial-ordinals.js.map +1 -0
- package/dist/authored-worker-step.d.ts +5 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +19 -2
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/budget-preflight.d.ts.map +1 -1
- package/dist/budget-preflight.js +18 -3
- package/dist/budget-preflight.js.map +1 -1
- package/dist/bundle-transport.d.ts +2 -0
- package/dist/bundle-transport.d.ts.map +1 -1
- package/dist/bundle-transport.js +4 -0
- package/dist/bundle-transport.js.map +1 -1
- package/dist/cached-flow-runtime.d.ts +3 -0
- package/dist/cached-flow-runtime.d.ts.map +1 -0
- package/dist/cached-flow-runtime.js +37 -0
- package/dist/cached-flow-runtime.js.map +1 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +13 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +72 -11
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/cloud-deploy.js +6 -5
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-mirror-session.d.ts +8 -2
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
- package/dist/cli/cloud-mirror-session.js +20 -27
- package/dist/cli/cloud-mirror-session.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +2 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +19 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +39 -18
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +3 -3
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +15 -9
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-flow-source.d.ts +13 -0
- package/dist/cloud-flow-source.d.ts.map +1 -0
- package/dist/cloud-flow-source.js +55 -0
- package/dist/cloud-flow-source.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts +2 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -1
- package/dist/cloud-mirror-transport.js +2 -0
- package/dist/cloud-mirror-transport.js.map +1 -1
- package/dist/cloud-mirror.d.ts.map +1 -1
- package/dist/cloud-mirror.js +10 -1
- package/dist/cloud-mirror.js.map +1 -1
- package/dist/communication/broker-preflight.d.ts +3 -0
- package/dist/communication/broker-preflight.d.ts.map +1 -0
- package/dist/communication/broker-preflight.js +22 -0
- package/dist/communication/broker-preflight.js.map +1 -0
- package/dist/communication/broker-spec.d.ts +20 -0
- package/dist/communication/broker-spec.d.ts.map +1 -0
- package/dist/communication/broker-spec.js +46 -0
- package/dist/communication/broker-spec.js.map +1 -0
- package/dist/communication/broker-worker.d.ts +17 -0
- package/dist/communication/broker-worker.d.ts.map +1 -0
- package/dist/communication/broker-worker.js +214 -0
- package/dist/communication/broker-worker.js.map +1 -0
- package/dist/communication/environment.d.ts.map +1 -1
- package/dist/communication/environment.js +1 -0
- package/dist/communication/environment.js.map +1 -1
- package/dist/communication/pty.d.ts +29 -0
- package/dist/communication/pty.d.ts.map +1 -0
- package/dist/communication/pty.js +44 -0
- package/dist/communication/pty.js.map +1 -0
- package/dist/communication/relay.d.ts +18 -0
- package/dist/communication/relay.d.ts.map +1 -1
- package/dist/communication/relay.js +4 -0
- package/dist/communication/relay.js.map +1 -1
- package/dist/communication/skills.d.ts +3 -0
- package/dist/communication/skills.d.ts.map +1 -0
- package/dist/communication/skills.js +18 -0
- package/dist/communication/skills.js.map +1 -0
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +9 -26
- package/dist/communication/worker.js.map +1 -1
- package/dist/devin-runtime.d.ts +10 -0
- package/dist/devin-runtime.d.ts.map +1 -0
- package/dist/devin-runtime.js +37 -0
- package/dist/devin-runtime.js.map +1 -0
- 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/plugin-manifest.js +1 -1
- package/dist/plugin-manifest.js.map +1 -1
- package/dist/preflight.d.ts +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +14 -4
- package/dist/preflight.js.map +1 -1
- package/dist/step-secrets.d.ts.map +1 -1
- package/dist/step-secrets.js +1 -0
- package/dist/step-secrets.js.map +1 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +15 -3
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +12 -0
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +2 -0
- package/src/adapters/devin.ts +22 -0
- package/src/adapters/index.ts +5 -2
- package/src/authored-broker.ts +73 -0
- package/src/authored-budget.ts +6 -2
- package/src/authored-flow-error.ts +6 -0
- package/src/authored-flow-executor.ts +115 -20
- package/src/authored-flow-loader.ts +43 -3
- package/src/authored-helper-effect.ts +2 -1
- package/src/authored-hooks.ts +4 -3
- package/src/authored-human.ts +35 -4
- package/src/authored-mcp.ts +7 -2
- package/src/authored-node-entry.ts +4 -1
- package/src/authored-node-runner.ts +1 -0
- package/src/authored-plugin-effect.ts +7 -2
- package/src/authored-preflight.ts +2 -1
- package/src/authored-root.ts +13 -3
- package/src/authored-serial-ordinals.ts +76 -0
- package/src/authored-worker-step.ts +17 -2
- package/src/budget-preflight.ts +16 -2
- package/src/bundle-transport.ts +4 -0
- package/src/cached-flow-runtime.ts +32 -0
- package/src/cli/check.ts +11 -1
- package/src/cli/cli-probe.ts +65 -11
- package/src/cli/cloud-deploy.ts +5 -5
- package/src/cli/cloud-mirror-session.ts +23 -29
- package/src/cli/direct-run.ts +2 -1
- package/src/cli/run.ts +18 -3
- package/src/cli.ts +31 -13
- package/src/cloud-deploy.ts +19 -14
- package/src/cloud-flow-source.ts +58 -0
- package/src/cloud-mirror-transport.ts +4 -0
- package/src/cloud-mirror.ts +9 -1
- package/src/communication/broker-preflight.ts +22 -0
- package/src/communication/broker-spec.ts +48 -0
- package/src/communication/broker-worker.ts +198 -0
- package/src/communication/environment.ts +1 -0
- package/src/communication/pty.ts +51 -0
- package/src/communication/relay.ts +12 -1
- package/src/communication/skills.ts +17 -0
- package/src/communication/worker.ts +9 -26
- package/src/devin-runtime.ts +33 -0
- package/src/failure-kinds.ts +2 -0
- package/src/plugin-manifest.ts +1 -1
- package/src/preflight.ts +15 -4
- package/src/step-secrets.ts +1 -0
- package/src/worker-cli.ts +13 -5
- package/src/worker.ts +11 -0
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { resolveNamedFlow, type NamedFlowSource } from './cloud-flow-source.js';
|
|
3
|
+
import { isFlowName } from './bundle-transport.js';
|
|
4
|
+
import { CloudFlowError } from './cloud-http.js';
|
|
2
5
|
import { unavailableValidation, validationAnnotation, type DaemonValidationMode } from './daemon-spec-validation.js';
|
|
3
6
|
import { foldUnprovableEffects, type FoldOptions } from './cli/diagnostic-fold.js';
|
|
4
7
|
import { startDetachedRun, emitDetachedHandle } from './cli/detached-run.js';
|
|
@@ -134,6 +137,7 @@ const USAGE = [
|
|
|
134
137
|
'flows plugin update [--json] [--yes] [--to <ref>] [<name>]',
|
|
135
138
|
'flows build [--out <dir>] <flow.yaml|flow.ts>',
|
|
136
139
|
'flows build --verify <bundle-dir>',
|
|
140
|
+
'flows deploy <flow.ts> [--name <name>] [--json]',
|
|
137
141
|
'flows deploy <flow.ts> --repo <owner/name|gitlab:group/project> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
|
|
138
142
|
'flows deploy <flow.ts> --flow <name|listener-id> [--plugin <ref>] [--no-connect] [--json]',
|
|
139
143
|
'flows deployments [--json]',
|
|
@@ -152,6 +156,7 @@ const USAGE = [
|
|
|
152
156
|
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.ts> --input <inline-json-or-file>',
|
|
153
157
|
'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
|
|
154
158
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
|
|
159
|
+
'flows run <name> [--input <inline-json-or-file>]',
|
|
155
160
|
'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
|
|
156
161
|
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
|
|
157
162
|
'flows answer --cloud [--json] [--note <text>] [--source <path>] <run-id> <yes|no>',
|
|
@@ -259,7 +264,7 @@ export async function runCli(
|
|
|
259
264
|
const link = resolveObserverLinkEnv(process.env);
|
|
260
265
|
if (!link.suppressed && link.workspaceKey === undefined) {
|
|
261
266
|
io.stderr('Observer unavailable: no workspace key configured; set RELAYCAST_WORKSPACE_KEY or configure '
|
|
262
|
-
+ '~/.agentworkforce/relay/workspaces.json for an Observer link, or
|
|
267
|
+
+ '~/.agentworkforce/relay/workspaces.json for an Observer link, or sign in with agent-relay cloud login for the Cloud dashboard');
|
|
263
268
|
}
|
|
264
269
|
}
|
|
265
270
|
|
|
@@ -379,6 +384,27 @@ export async function runCli(
|
|
|
379
384
|
}, io) as CliExitCode);
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
let named: NamedFlowSource | undefined;
|
|
388
|
+
if (parsed.command === 'run') {
|
|
389
|
+
try {
|
|
390
|
+
named = await resolveNamedFlow(parsed.value);
|
|
391
|
+
if (!named && parsed.input !== undefined && !isAuthoredFlowPath(parsed.value)) {
|
|
392
|
+
throw new CloudFlowError('invalid_input', '--input requires an authored .flow.ts or a saved flow name.');
|
|
393
|
+
}
|
|
394
|
+
if (named) {
|
|
395
|
+
if (parsed.reuseFromRunId !== undefined) throw new CloudFlowError('invalid_input', '--reuse-from is not supported for authored named flows.');
|
|
396
|
+
parsed.value = named.path;
|
|
397
|
+
parsed.input ??= '{}';
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
const code = error instanceof CloudFlowError ? error.refusal?.code ?? error.code : 'flow_resolution_failed';
|
|
401
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
402
|
+
if (parsed.json) io.stdout(JSON.stringify({ ok: false, code, message }));
|
|
403
|
+
else io.stderr(`${code}: ${message}`);
|
|
404
|
+
return 2;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
382
408
|
// Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
|
|
383
409
|
// single `connect()` seam immediately before journal-client.ts is used --
|
|
384
410
|
// not here. Hoisting it above the dispatch would start a daemon as a side
|
|
@@ -388,21 +414,13 @@ export async function runCli(
|
|
|
388
414
|
onObserverUrl: url => options.detachedReceipt?.observerUrl(url),
|
|
389
415
|
});
|
|
390
416
|
const runnerLog: string[] = [];
|
|
391
|
-
|
|
392
|
-
// way to watch a local run: it is free, it needs only a workspace key, and
|
|
393
|
-
// it carries a step projection. The dashboard is the richer, hosted view —
|
|
394
|
-
// it stores the flow source, every step's transcript and this invocation's
|
|
395
|
-
// own output — so a local run joins it because someone asked, never because
|
|
396
|
-
// a login happened to be lying around.
|
|
397
|
-
const mirror = !(parsed.cloudMirror || cloudMirrorRequested(process.env))
|
|
398
|
-
? undefined
|
|
399
|
-
: createCloudMirrorSession({
|
|
417
|
+
const mirror = createCloudMirrorSession({
|
|
400
418
|
source: parsed.command === 'run'
|
|
401
|
-
? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir)
|
|
419
|
+
? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir, named)
|
|
402
420
|
: mirrorSourceFromJournal(parsed.dataDir),
|
|
403
421
|
dataDir: parsed.dataDir,
|
|
404
422
|
log: () => runnerLog,
|
|
405
|
-
requested: parsed.cloudMirror ? 'flag' : 'env',
|
|
423
|
+
requested: parsed.cloudMirror ? 'flag' : cloudMirrorRequested(process.env) ? 'env' : 'login',
|
|
406
424
|
}, io);
|
|
407
425
|
// Everything this invocation prints, kept in order, so the mirror can upload
|
|
408
426
|
// it as the run's `runner.log` — the object the dashboard's log pane reads,
|
|
@@ -888,7 +906,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
888
906
|
if (wait || syncCode || noConnect || budgetMinutes !== undefined) return undefined;
|
|
889
907
|
if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
890
908
|
|
|
891
|
-
if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
909
|
+
if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!) && !isFlowName(positionals[0]!)) return undefined;
|
|
892
910
|
return command === 'check'
|
|
893
911
|
? { command, json, watch, explainWarnings, value: positionals[0]!,
|
|
894
912
|
daemonValidation: daemonValidation ?? (process.env['FLOWS_NO_DAEMON_CHECK'] === '1' ? 'off'
|
package/src/cloud-deploy.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isFlowName } from './bundle-transport.js';
|
|
1
2
|
import { randomBytes, createHash } from 'node:crypto';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
@@ -73,10 +74,10 @@ export interface FlowTriggerSource {
|
|
|
73
74
|
|
|
74
75
|
export interface DeployToCloudInput {
|
|
75
76
|
path: string;
|
|
76
|
-
repository
|
|
77
|
+
repository?: DeployRepository;
|
|
77
78
|
sources: FlowTriggerSource[];
|
|
78
79
|
/** The `f.human` approver handle every launched run receives as `input.approver`. */
|
|
79
|
-
approver
|
|
80
|
+
approver?: string;
|
|
80
81
|
/** Defaults to the flow's declared name. */
|
|
81
82
|
name?: string;
|
|
82
83
|
/**
|
|
@@ -118,7 +119,7 @@ export interface CloudDeployment {
|
|
|
118
119
|
agentId: string;
|
|
119
120
|
name: string;
|
|
120
121
|
status: string;
|
|
121
|
-
repository
|
|
122
|
+
repository?: DeployRepository;
|
|
122
123
|
sources: FlowTriggerSource[];
|
|
123
124
|
sourceSha256: string;
|
|
124
125
|
/** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
|
|
@@ -255,28 +256,32 @@ export async function deployToCloud(
|
|
|
255
256
|
input: DeployToCloudInput, options: CloudConnectionOptions = {},
|
|
256
257
|
): Promise<CloudDeployment> {
|
|
257
258
|
const { bytes, source, definition, extensions, projectCli } = await loadDeploySource(input);
|
|
258
|
-
if (input.sources.length
|
|
259
|
-
throw new CloudFlowError('invalid_input', 'Give
|
|
259
|
+
if (input.sources.length > 10) {
|
|
260
|
+
throw new CloudFlowError('invalid_input', 'Give at most ten --on trigger sources.');
|
|
260
261
|
}
|
|
261
262
|
if (new Set(input.sources.map(s => s.provider)).size !== input.sources.length) {
|
|
262
263
|
throw new CloudFlowError('invalid_input', 'Each trigger provider can be given once.');
|
|
263
264
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
265
|
+
const manual = input.sources.length === 0;
|
|
266
|
+
const approver = input.approver?.trim();
|
|
267
|
+
if (!manual && !input.repository) throw new CloudFlowError('invalid_input', 'Triggered deployments require --repo.');
|
|
268
|
+
if (!manual && !approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.');
|
|
266
269
|
const name = (input.name ?? definition.name).trim();
|
|
267
270
|
if (!name || name.length > 100) throw new CloudFlowError('invalid_input', 'Deployment name must be 1-100 characters.');
|
|
271
|
+
if (manual && !isFlowName(name)) throw new CloudFlowError('invalid_input',
|
|
272
|
+
'Manual flow names must start with a letter or digit and contain only letters, digits, underscores or hyphens (up to 100 characters). Use --name.');
|
|
268
273
|
// Scope same-host sources to the target rather than waking on every project.
|
|
269
|
-
const target = `${input.repository.owner}/${input.repository.name}
|
|
274
|
+
const target = input.repository ? `${input.repository.owner}/${input.repository.name}` : undefined;
|
|
270
275
|
const sources = input.sources.map(s => {
|
|
271
276
|
if (s.provider === 'github' && s.settings['repository'] === undefined) {
|
|
272
|
-
if (input.repository
|
|
277
|
+
if (input.repository?.host === 'gitlab') {
|
|
273
278
|
throw new CloudFlowError('invalid_input',
|
|
274
279
|
'A GitLab target needs an explicit --on github:repository=owner/name for a GitHub source.');
|
|
275
280
|
}
|
|
276
|
-
return { ...s, settings: { ...s.settings, repository: target } };
|
|
281
|
+
return { ...s, settings: { ...s.settings, repository: target! } };
|
|
277
282
|
}
|
|
278
|
-
if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository
|
|
279
|
-
return { ...s, settings: { ...s.settings, project: target } };
|
|
283
|
+
if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository?.host === 'gitlab') {
|
|
284
|
+
return { ...s, settings: { ...s.settings, project: target! } };
|
|
280
285
|
}
|
|
281
286
|
return s;
|
|
282
287
|
});
|
|
@@ -298,7 +303,7 @@ export async function deployToCloud(
|
|
|
298
303
|
// refused here rather than silently replaced by Claude, which activation
|
|
299
304
|
// would then check while the deployed runs still call the declared CLI.
|
|
300
305
|
const unsupported = requirements.harnesses.filter(harness => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(harness));
|
|
301
|
-
if (input.agents === undefined && unsupported.length > 0) {
|
|
306
|
+
if (!manual && input.agents === undefined && unsupported.length > 0) {
|
|
302
307
|
const uses = requirements.harnessUses.filter(use => unsupported.includes(use.harness));
|
|
303
308
|
throw new CloudFlowError('unsupported_source',
|
|
304
309
|
`This flow declares ${uses.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; `
|
|
@@ -307,7 +312,7 @@ export async function deployToCloud(
|
|
|
307
312
|
const declaredAgents = requirements.harnesses.filter((harness): harness is FlowAgentHarness => !unsupported.includes(harness));
|
|
308
313
|
const agents = input.agents ?? (declaredAgents.length > 0 ? declaredAgents : ['claude']);
|
|
309
314
|
// A draft activates nothing, so Cloud checks nothing; match it here.
|
|
310
|
-
const connected = input.draft || input.checkConnections === false
|
|
315
|
+
const connected = manual || input.draft || input.checkConnections === false
|
|
311
316
|
? []
|
|
312
317
|
: (await ensureIntegrationsConnected(requirements, {
|
|
313
318
|
...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { isFlowName } from './bundle-transport.js';
|
|
7
|
+
import { cloudFetch, CloudFlowError, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
|
|
8
|
+
import { prepareCachedFlowRuntime } from './cached-flow-runtime.js';
|
|
9
|
+
|
|
10
|
+
export interface NamedFlowSource {
|
|
11
|
+
path: string;
|
|
12
|
+
flowId: string;
|
|
13
|
+
flowVersionId: string;
|
|
14
|
+
sourceSha256: string;
|
|
15
|
+
source: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Files win over names; paths and digest references retain their existing meaning. */
|
|
19
|
+
export async function resolveNamedFlow(
|
|
20
|
+
value: string,
|
|
21
|
+
options: CloudConnectionOptions & { cacheRoot?: string } = {},
|
|
22
|
+
): Promise<NamedFlowSource | undefined> {
|
|
23
|
+
if (!isFlowName(value)) return undefined;
|
|
24
|
+
try {
|
|
25
|
+
if ((await stat(value)).isFile()) return undefined;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
28
|
+
}
|
|
29
|
+
const result = await cloudFetch(`/api/v1/flows/by-name/${encodeURIComponent(value)}`, options, { method: 'GET', detail: true });
|
|
30
|
+
if (!isCloudRecord(result) || result.name !== value || result.fileType !== 'ts'
|
|
31
|
+
|| typeof result.source !== 'string' || Buffer.byteLength(result.source) > 256_000
|
|
32
|
+
|| typeof result.sourceSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(result.sourceSha256)
|
|
33
|
+
|| typeof result.flowId !== 'string' || !result.flowId
|
|
34
|
+
|| typeof result.versionId !== 'string' || !result.versionId) {
|
|
35
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid named .flow.ts source.');
|
|
36
|
+
}
|
|
37
|
+
const digest = (source: string | Buffer): string => createHash('sha256').update(source).digest('hex');
|
|
38
|
+
if (digest(result.source) !== result.sourceSha256) {
|
|
39
|
+
throw new CloudFlowError('invalid_response', `Source hash verification failed for flow "${value}".`);
|
|
40
|
+
}
|
|
41
|
+
const directory = join(options.cacheRoot ?? join(homedir(), '.agentworkforce', 'flows', 'cache'), value);
|
|
42
|
+
const path = join(directory, `${result.sourceSha256}.flow.ts`);
|
|
43
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
if (digest(await readFile(path)) !== result.sourceSha256) {
|
|
46
|
+
throw new CloudFlowError('invalid_input', `Cached source hash verification failed: ${path}. Remove this file and retry.`);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(temporary, result.source, { mode: 0o600, flag: 'wx' });
|
|
52
|
+
await rename(temporary, path);
|
|
53
|
+
} finally { await rm(temporary, { force: true }); }
|
|
54
|
+
}
|
|
55
|
+
await prepareCachedFlowRuntime(directory);
|
|
56
|
+
return { path, flowId: result.flowId, flowVersionId: result.versionId,
|
|
57
|
+
sourceSha256: result.sourceSha256, source: result.source };
|
|
58
|
+
}
|
|
@@ -69,6 +69,8 @@ export interface MirrorRunSource {
|
|
|
69
69
|
* red "Needs review" row can see it was answered.
|
|
70
70
|
*/
|
|
71
71
|
resumedFromRunId?: string;
|
|
72
|
+
flowId?: string;
|
|
73
|
+
flowVersionId?: string;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/** One lifecycle event, in the vocabulary Cloud's session event stream uses. */
|
|
@@ -98,6 +100,8 @@ export async function registerLocalRun(
|
|
|
98
100
|
body: JSON.stringify({
|
|
99
101
|
workflow: source.workflow,
|
|
100
102
|
fileType: source.fileType,
|
|
103
|
+
...(source.flowId === undefined ? {} : { flowId: source.flowId }),
|
|
104
|
+
...(source.flowVersionId === undefined ? {} : { flowVersionId: source.flowVersionId }),
|
|
101
105
|
relayflowVersion: 'v2',
|
|
102
106
|
...(source.inputs === undefined ? {} : { inputs: source.inputs }),
|
|
103
107
|
...(source.resumedFromRunId === undefined ? {} : { resumedFromRunId: source.resumedFromRunId }),
|
package/src/cloud-mirror.ts
CHANGED
|
@@ -304,6 +304,7 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
|
|
|
304
304
|
let stopped = false;
|
|
305
305
|
/** Set immediately before the terminal callback; nothing may publish after it. */
|
|
306
306
|
let terminalReported = false;
|
|
307
|
+
const pendingEvents = new Set<Promise<boolean>>();
|
|
307
308
|
let sequence = 0;
|
|
308
309
|
let acknowledged: string | undefined;
|
|
309
310
|
let acknowledgedAt = now();
|
|
@@ -558,7 +559,10 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
|
|
|
558
559
|
timer.unref?.();
|
|
559
560
|
},
|
|
560
561
|
event(event) {
|
|
561
|
-
|
|
562
|
+
if (stopped || terminalReported || pendingEvents.size >= 256) return;
|
|
563
|
+
const pending = options.client.publishEvent(event);
|
|
564
|
+
pendingEvents.add(pending);
|
|
565
|
+
void pending.finally(() => pendingEvents.delete(pending));
|
|
562
566
|
},
|
|
563
567
|
async finish(outcome) {
|
|
564
568
|
if (timer !== undefined) { clearInterval(timer); timer = undefined; }
|
|
@@ -592,6 +596,10 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
|
|
|
592
596
|
}
|
|
593
597
|
await uploadTranscripts(deadline);
|
|
594
598
|
await publishFinal(deadline);
|
|
599
|
+
await Promise.race([
|
|
600
|
+
Promise.allSettled(pendingEvents),
|
|
601
|
+
new Promise<void>(done => { setTimeout(done, remaining(deadline)).unref?.(); }),
|
|
602
|
+
]);
|
|
595
603
|
// Last, always: this transition revokes the credential every call
|
|
596
604
|
// above depends on.
|
|
597
605
|
terminalReported = true;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
/** Read-only SDK connection probe: never starts a broker or an agent harness. */
|
|
8
|
+
export function checkBrokerEnvironment(cwd = process.cwd()): void {
|
|
9
|
+
let driver: string;
|
|
10
|
+
try {
|
|
11
|
+
require.resolve('@agent-relay/sdk');
|
|
12
|
+
driver = require.resolve('@agent-relay/harness-driver');
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error('Broker agents require optional @agent-relay/harness-driver and @agent-relay/sdk packages (>=12.3.1).');
|
|
15
|
+
}
|
|
16
|
+
const probe = spawnSync(process.execPath, ['--input-type=module', '-e',
|
|
17
|
+
'const { HarnessDriverClient } = await import(process.argv[1]); const client = HarnessDriverClient.connect({ cwd: process.argv[2] }); try { await client.healthCheck(); } finally { await client.shutdown(); }',
|
|
18
|
+
pathToFileURL(driver).href, cwd], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
19
|
+
if (probe.status !== 0) {
|
|
20
|
+
throw new Error(`Broker agents require a reachable local Relay broker for ${JSON.stringify(cwd)}. Run agent-relay up in the run root or set AGENT_RELAY_STATE_DIR to its connection directory.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AgentOptions } from '@relayflows/surface';
|
|
3
|
+
|
|
4
|
+
export const BROKER_TYPE = 'relayflows.broker.v1';
|
|
5
|
+
export interface BrokerInstruction {
|
|
6
|
+
type: typeof BROKER_TYPE;
|
|
7
|
+
operation: 'spawn' | 'message' | 'release';
|
|
8
|
+
owner: string;
|
|
9
|
+
flow: string;
|
|
10
|
+
step: string;
|
|
11
|
+
agentName: string;
|
|
12
|
+
task: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
skills: string[];
|
|
15
|
+
channel: string;
|
|
16
|
+
keepAlive: boolean;
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
export function brokerAgentName(flow: string, step: string, owner: string, id: string): string {
|
|
20
|
+
const label = `${flow}-${step}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+/, '') || 'flow-agent';
|
|
21
|
+
return `${label.slice(0, 46)}-${createHash('sha256').update(JSON.stringify([owner, id])).digest('hex').slice(0, 16)}`;
|
|
22
|
+
}
|
|
23
|
+
export function brokerOptionsError(options: AgentOptions): string | undefined {
|
|
24
|
+
if (options.transport !== 'broker') {
|
|
25
|
+
if (options.skills !== undefined || options.keepAlive !== undefined || options.channel !== undefined) {
|
|
26
|
+
return 'skills, keepAlive, and channel require transport: broker';
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (options.resume !== undefined) return 'broker sessions use f.message instead of resume';
|
|
31
|
+
if (options.keepAlive !== undefined && typeof options.keepAlive !== 'boolean') return 'keepAlive must be a boolean';
|
|
32
|
+
if (options.channel !== undefined && (typeof options.channel !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(options.channel))) return 'channel must be a channel name';
|
|
33
|
+
if (options.skills !== undefined && (!Array.isArray(options.skills)
|
|
34
|
+
|| !options.skills.every(skill => typeof skill === 'string' && /^[a-z0-9][a-z0-9-]{0,99}$/.test(skill)))) return 'skills must be canonical skill names';
|
|
35
|
+
if (options.cwd !== undefined && (typeof options.cwd !== 'string' || !options.cwd.trim() || options.cwd.includes('\0'))) return 'cwd must be a nonempty directory path';
|
|
36
|
+
}
|
|
37
|
+
export function brokerInstruction(raw: unknown): BrokerInstruction | undefined {
|
|
38
|
+
if (typeof raw !== 'string' || !raw.startsWith('{')) return;
|
|
39
|
+
let value: any;
|
|
40
|
+
try { value = JSON.parse(raw); } catch { return; }
|
|
41
|
+
if (value?.type !== BROKER_TYPE) return;
|
|
42
|
+
if (!['spawn', 'message', 'release'].includes(value.operation)
|
|
43
|
+
|| !['owner', 'flow', 'step', 'agentName', 'task', 'channel'].every(key => typeof value[key] === 'string' && value[key].length > 0)
|
|
44
|
+
|| !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 3_600_000
|
|
45
|
+
|| !Array.isArray(value.skills) || typeof value.keepAlive !== 'boolean'
|
|
46
|
+
|| brokerOptionsError({ ...value, transport: 'broker' })) throw new Error('Invalid broker agent instruction');
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { JournalClient } from '../journal-client.js';
|
|
4
|
+
import type { StepDispatchEvent } from '../protocol.js';
|
|
5
|
+
import type { ResolvedKernelAgentStep } from '../resolved-cli-identity.js';
|
|
6
|
+
import { isLeaseLost, withWorkerLease } from '../worker-lease.js';
|
|
7
|
+
import { isReadInterruptionError } from '../journal-read-policy.js';
|
|
8
|
+
import { workerInstruction } from '../worker-input.js';
|
|
9
|
+
import { connectLocalBroker, type LocalBroker } from './relay.js';
|
|
10
|
+
import { spawnFlowPty, shellQuote } from './pty.js';
|
|
11
|
+
import { installBrokerSkills } from './skills.js';
|
|
12
|
+
import { openCommunicationTools } from './tools.js';
|
|
13
|
+
import type { BrokerInstruction } from './broker-spec.js';
|
|
14
|
+
|
|
15
|
+
interface Turn {
|
|
16
|
+
id: string;
|
|
17
|
+
completed?: boolean;
|
|
18
|
+
resolve(summary: string): void;
|
|
19
|
+
reject(error: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
interface Session {
|
|
22
|
+
owner: string;
|
|
23
|
+
broker: LocalBroker;
|
|
24
|
+
pty?: Awaited<ReturnType<typeof spawnFlowPty>>;
|
|
25
|
+
tools?: Awaited<ReturnType<typeof openCommunicationTools>>;
|
|
26
|
+
turn?: Turn;
|
|
27
|
+
unsubscribe?: () => void;
|
|
28
|
+
exited?: string;
|
|
29
|
+
releasing?: Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Worker-owned live resources. Only journaled agent dispatches start a turn. */
|
|
33
|
+
export class BrokerSessions {
|
|
34
|
+
private readonly sessions = new Map<string, Session>();
|
|
35
|
+
constructor(private readonly runRoot: string = process.cwd(), private readonly environment?: NodeJS.ProcessEnv) {}
|
|
36
|
+
|
|
37
|
+
async close(): Promise<void> {
|
|
38
|
+
const results = await Promise.allSettled([...this.sessions.keys()].map(name => this.release(name)));
|
|
39
|
+
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
|
40
|
+
if (errors.length) throw new AggregateError(errors, 'Failed to release flow broker sessions');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async release(name: string): Promise<void> {
|
|
44
|
+
const session = this.sessions.get(name);
|
|
45
|
+
if (!session) return;
|
|
46
|
+
if (session.releasing) return session.releasing;
|
|
47
|
+
session.releasing = Promise.resolve().then(() => this.releaseSession(name, session));
|
|
48
|
+
try { await session.releasing; }
|
|
49
|
+
finally { session.releasing = undefined; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async releaseSession(name: string, session: Session): Promise<void> {
|
|
53
|
+
session.turn?.reject(new Error('Broker session released'));
|
|
54
|
+
await session.pty?.handle.release('Flow broker session ended', { deleteIdentity: true });
|
|
55
|
+
session.unsubscribe?.();
|
|
56
|
+
try { await session.tools?.close(); }
|
|
57
|
+
finally { try { await session.pty?.close(); } finally { await session.broker.shutdown(); } }
|
|
58
|
+
this.sessions.delete(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async complete(client: JournalClient, dispatch: StepDispatchEvent, instruction: BrokerInstruction, dataDir: string): Promise<void> {
|
|
62
|
+
let output: unknown;
|
|
63
|
+
let reason: 'success' | 'worker_error' | 'timeout' = 'success';
|
|
64
|
+
try {
|
|
65
|
+
output = await withWorkerLease(client, dispatch, signal => this.run(dispatch, instruction, dataDir, signal,
|
|
66
|
+
client.supportsFeature?.('step_secret_scoping') ?? true));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isLeaseLost(error) || isReadInterruptionError(error)) throw error;
|
|
69
|
+
reason = error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'worker_error';
|
|
70
|
+
output = { error: error instanceof Error ? error.message : String(error) };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await client.stepComplete(dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key,
|
|
74
|
+
reason, { output, usage: { tokens_in: 0, tokens_out: 0, dollars_unmetered: true },
|
|
75
|
+
started_pins: dispatch.pins, end_pins: dispatch.pins });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await this.release(instruction.agentName);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async run(dispatch: StepDispatchEvent, instruction: BrokerInstruction, dataDir: string, lease: AbortSignal,
|
|
83
|
+
scopeSecrets = true): Promise<unknown> {
|
|
84
|
+
const { agentName: name, owner } = instruction;
|
|
85
|
+
let session = this.sessions.get(name);
|
|
86
|
+
if (session && session.owner !== owner) throw new Error('Broker agent belongs to another flow');
|
|
87
|
+
if (session?.turn) throw new Error('Broker agent already has work in progress');
|
|
88
|
+
if (instruction.operation === 'release') {
|
|
89
|
+
await this.release(name);
|
|
90
|
+
return { released: true };
|
|
91
|
+
}
|
|
92
|
+
if (session?.exited) {
|
|
93
|
+
await this.release(name);
|
|
94
|
+
throw new Error(`Broker agent ${name}: ${session.exited}`);
|
|
95
|
+
}
|
|
96
|
+
if (instruction.operation === 'message' && !session) {
|
|
97
|
+
throw new Error(`Broker agent ${name} is no longer available; its worker session was lost or released`);
|
|
98
|
+
}
|
|
99
|
+
if (instruction.operation === 'spawn' && session) throw new Error(`Broker agent ${name} already exists`);
|
|
100
|
+
const deadline = AbortSignal.timeout(instruction.timeoutMs);
|
|
101
|
+
const signal = AbortSignal.any([lease, deadline]);
|
|
102
|
+
const turnId = `${dispatch.run_id}:${dispatch.step_id}:${dispatch.attempt}`;
|
|
103
|
+
let succeed!: (summary: string) => void;
|
|
104
|
+
let fail!: (error: unknown) => void;
|
|
105
|
+
const result = new Promise<string>((yes, no) => { succeed = yes; fail = no; });
|
|
106
|
+
void result.catch(() => {});
|
|
107
|
+
let abortWait!: (error: unknown) => void;
|
|
108
|
+
const aborted = new Promise<never>((_, reject) => { abortWait = reject; });
|
|
109
|
+
void aborted.catch(() => {});
|
|
110
|
+
const abort = () => { fail(signal.reason); abortWait(signal.reason); };
|
|
111
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
112
|
+
if (signal.aborted) abort();
|
|
113
|
+
let succeeded = false;
|
|
114
|
+
try {
|
|
115
|
+
if (instruction.operation === 'spawn') {
|
|
116
|
+
const spec = dispatch.spec as ResolvedKernelAgentStep;
|
|
117
|
+
if (!spec.cli) throw new Error('Broker agent requires a declared CLI');
|
|
118
|
+
const cwd = await realpath(resolve(this.runRoot, instruction.cwd ?? '.'));
|
|
119
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error(`Broker cwd is not a directory: ${cwd}`);
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
await installBrokerSkills(instruction.skills, spec.cli_identity ?? spec.cli, cwd, signal);
|
|
122
|
+
const broker = await connectLocalBroker(this.runRoot);
|
|
123
|
+
session = { owner, broker, turn: { id: turnId, resolve: succeed, reject: fail } };
|
|
124
|
+
this.sessions.set(name, session);
|
|
125
|
+
const current = session;
|
|
126
|
+
session.tools = await openCommunicationTools(async ({ operation, values }) => {
|
|
127
|
+
if (operation !== 'complete' || values.length !== 2 || values[0] !== current.turn?.id || current.turn?.completed) {
|
|
128
|
+
throw new Error('Use complete CURRENT_TURN_ID JSON_SUMMARY; stale and duplicate completions are refused');
|
|
129
|
+
}
|
|
130
|
+
const summary = JSON.stringify(JSON.parse(values[1]!));
|
|
131
|
+
const turn = current.turn;
|
|
132
|
+
turn!.completed = true;
|
|
133
|
+
// Allow the helper's response to flush before releasing the PTY.
|
|
134
|
+
setTimeout(() => turn!.resolve(summary), 100);
|
|
135
|
+
return { completed: true };
|
|
136
|
+
});
|
|
137
|
+
session.unsubscribe = broker.onEvent(event => {
|
|
138
|
+
if (event.name !== name || !['agent_exit', 'agent_exited', 'agent_released'].includes(event.kind)) return;
|
|
139
|
+
current.exited = event.kind;
|
|
140
|
+
if (!current.turn?.completed) current.turn?.reject(new Error(`Broker agent ${name}: ${event.kind}`));
|
|
141
|
+
// An idle exit retires resources immediately. A failed release stays
|
|
142
|
+
// in the map and is retried (and surfaced) on the next call or close.
|
|
143
|
+
if (!current.turn) void this.release(name).catch(() => {});
|
|
144
|
+
});
|
|
145
|
+
broker.connectEvents();
|
|
146
|
+
const task = `${workerInstruction(instruction.task, dispatch)}\n\n${this.completionPrompt(session, turnId)}\n${instruction.keepAlive ? 'Stay alive after completing; later flow steps can send more work.' : 'The flow releases this session after completion.'}`;
|
|
147
|
+
const spawned = spawnFlowPty(broker, { name, cli: spec.cli, identity: spec.cli_identity,
|
|
148
|
+
model: spec.model, task, cwd, dataDir, skipRelayPrompt: false, channels: [instruction.channel], team: `flow-${owner}`,
|
|
149
|
+
environment: this.environment, socket: session.tools.path, token: session.tools.token,
|
|
150
|
+
secrets: spec.secrets, scopeSecrets,
|
|
151
|
+
metadata: { runId: owner, flow: instruction.flow, step: instruction.step } });
|
|
152
|
+
let pty: Awaited<ReturnType<typeof spawnFlowPty>> | undefined;
|
|
153
|
+
try {
|
|
154
|
+
pty = await Promise.race([spawned, aborted]);
|
|
155
|
+
signal.throwIfAborted();
|
|
156
|
+
session.pty = pty;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// Spawn can resolve after the timeout. The session release below
|
|
159
|
+
// only sees a handle that was stored in time.
|
|
160
|
+
if (session.pty === undefined) void spawned.then(async late => {
|
|
161
|
+
try { await late.handle.release('Flow broker session ended', { deleteIdentity: true }); }
|
|
162
|
+
catch { /* The timed-out step is already journaled. */ }
|
|
163
|
+
try { await late.close(); }
|
|
164
|
+
catch { /* A failed link removal must not reject this detached cleanup. */ }
|
|
165
|
+
}, () => {});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
const ready = await Promise.race([session.pty.handle.waitForReady(Math.min(instruction.timeoutMs, 90_000)),
|
|
169
|
+
result.then(() => ({ reason: 'ready' }))]);
|
|
170
|
+
if (ready.reason !== 'ready') throw new Error(`Broker agent did not become ready: ${ready.reason}`);
|
|
171
|
+
} else {
|
|
172
|
+
session!.turn = { id: turnId, resolve: succeed, reject: fail };
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
const receipt = await Promise.race([session!.broker.sendMessage({ to: name, from: `flow-${owner}`,
|
|
175
|
+
text: `${instruction.task}\n\n${this.completionPrompt(session!, turnId)}`, mode: 'wait',
|
|
176
|
+
data: { runId: owner, flow: instruction.flow, step: instruction.step, turnId } }), aborted]);
|
|
177
|
+
if (!receipt.targets.includes(name) || receipt.event_id === 'unsupported_operation') {
|
|
178
|
+
throw new Error(`Broker did not accept the message for ${name}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const summary = await result;
|
|
182
|
+
signal.throwIfAborted();
|
|
183
|
+
succeeded = true;
|
|
184
|
+
return { completionReason: 'success', summary, artifacts: [], agentName: name };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (signal.aborted) throw signal.reason;
|
|
187
|
+
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
signal.removeEventListener('abort', abort);
|
|
190
|
+
if (session) session.turn = undefined;
|
|
191
|
+
if (!succeeded || !instruction.keepAlive || session?.exited) await this.release(name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private completionPrompt(session: Session, turn: string): string {
|
|
196
|
+
return `Report completion with your shell tool: node ${shellQuote(session.tools!.helperPath)} complete ${shellQuote(turn)} 'JSON_SUMMARY'. The summary must be valid JSON (object, array, or string). Call exactly once for this turn, after the work is finished.`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -25,6 +25,7 @@ const providerNames: Readonly<Record<string, readonly string[]>> = {
|
|
|
25
25
|
// AgentWorkforce/cloud#4026's house-provider run contract gives Grok an
|
|
26
26
|
// isolated home plus an xAI-compatible base URL. These remain Grok-scoped.
|
|
27
27
|
grok: ['XAI_API_KEY', 'GROK_XAI_API_BASE_URL', 'GROK_HOME'],
|
|
28
|
+
devin: ['DEVIN_API_KEY', 'DEVIN_MODEL'],
|
|
28
29
|
...Object.fromEntries(['opencode', 'aider', 'goose', 'pi', 'deepagents'].map(cli => [cli, multiProvider])),
|
|
29
30
|
};
|
|
30
31
|
/** @internal Names-only test seam; credential lists remain private. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { prepareDevinRuntime } from '../devin-runtime.js';
|
|
4
|
+
import { agentEnvironment } from './environment.js';
|
|
5
|
+
import { resolveCliModel } from '../cli-adapter.js';
|
|
6
|
+
import type { RelayBroker } from './relay.js';
|
|
7
|
+
|
|
8
|
+
export const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
9
|
+
|
|
10
|
+
/** Both communication steps and retained sessions use Relay's PTY launch contract. */
|
|
11
|
+
export async function spawnFlowPty(broker: Pick<RelayBroker, 'spawnPty'>, options: {
|
|
12
|
+
name: string; cli: string; identity?: string; model?: string; task: string;
|
|
13
|
+
skipRelayPrompt?: boolean;
|
|
14
|
+
cwd: string; dataDir: string; channels: string[]; team?: string;
|
|
15
|
+
environment?: NodeJS.ProcessEnv; socket: string; token: string;
|
|
16
|
+
secrets?: string[]; scopeSecrets?: boolean;
|
|
17
|
+
metadata?: { runId: string; flow: string; step: string };
|
|
18
|
+
}) {
|
|
19
|
+
const identity = options.identity ?? options.cli;
|
|
20
|
+
const argv0 = basename(identity);
|
|
21
|
+
let command = options.cli;
|
|
22
|
+
let link: string | undefined;
|
|
23
|
+
const environment = agentEnvironment(identity, options.environment ?? process.env,
|
|
24
|
+
options.secrets, options.scopeSecrets ?? true);
|
|
25
|
+
const prepared = argv0.replace(/\.exe$/i, '') === 'devin' ? prepareDevinRuntime(environment) : undefined;
|
|
26
|
+
try {
|
|
27
|
+
if (basename(command) !== argv0) {
|
|
28
|
+
const root = join(options.dataDir, 'communication');
|
|
29
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
30
|
+
link = await mkdtemp(join(root, 'cli-'));
|
|
31
|
+
command = join(link, argv0);
|
|
32
|
+
await symlink(options.cli, command, 'file');
|
|
33
|
+
}
|
|
34
|
+
const handle = await broker.spawnPty({ name: options.name, cli: argv0.replace(/\.exe$/i, ''),
|
|
35
|
+
task: options.task, channels: options.channels, skipRelayPrompt: options.skipRelayPrompt ?? true,
|
|
36
|
+
...(options.team === undefined ? {} : { team: options.team }),
|
|
37
|
+
model: resolveCliModel(identity, options.model), cwd: options.cwd,
|
|
38
|
+
harnessConfig: { runtime: 'pty', command: shellQuote(command), args: [], cwd: options.cwd,
|
|
39
|
+
env: { ...(prepared?.env ?? environment),
|
|
40
|
+
RELAYFLOW_COMMUNICATION_SOCKET: options.socket, RELAYFLOW_COMMUNICATION_TOKEN: options.token,
|
|
41
|
+
...(options.metadata === undefined ? {} : {
|
|
42
|
+
RELAYFLOW_RUN_ID: options.metadata.runId, RELAYFLOW_FLOW_NAME: options.metadata.flow,
|
|
43
|
+
RELAYFLOW_STEP_NAME: options.metadata.step,
|
|
44
|
+
}) }, delivery: { mode: 'pty-injection', format: 'relay-block' } } });
|
|
45
|
+
return { handle, close: async () => { prepared?.close(); if (link) await rm(link, { recursive: true, force: true }); } };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
prepared?.close();
|
|
48
|
+
if (link) await rm(link, { recursive: true, force: true });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -25,7 +25,8 @@ export interface RelayMessaging {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
interface RelayModules {
|
|
28
|
-
HarnessDriverClient: { spawn(options: Record<string, unknown>): Promise<RelayBroker
|
|
28
|
+
HarnessDriverClient: { spawn(options: Record<string, unknown>): Promise<RelayBroker>;
|
|
29
|
+
connect(options: { cwd: string }): LocalBroker };
|
|
29
30
|
RelaycastMessagingClient: new (options: Record<string, unknown>) => RelayMessaging;
|
|
30
31
|
}
|
|
31
32
|
export async function loadRelayModules(): Promise<RelayModules> {
|
|
@@ -100,3 +101,13 @@ async function createRuntime(dataDir: string, runId: string): Promise<RelayRunti
|
|
|
100
101
|
throw error;
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Connected clients disconnect on shutdown; they never terminate the user's broker. */
|
|
106
|
+
export interface LocalBroker extends RelayBroker {
|
|
107
|
+
connectEvents(): void;
|
|
108
|
+
sendMessage(input: { to: string; text: string; from: string; mode: 'wait'; data: Record<string, unknown> }): Promise<{ event_id: string; targets: string[] }>;
|
|
109
|
+
}
|
|
110
|
+
export async function connectLocalBroker(cwd: string): Promise<LocalBroker> {
|
|
111
|
+
const { HarnessDriverClient } = await loadRelayModules();
|
|
112
|
+
return HarnessDriverClient.connect({ cwd });
|
|
113
|
+
}
|