@relayflows/sdk 2.0.55 → 2.0.56
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-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 +11 -2
- 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/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/pty.d.ts +29 -0
- package/dist/communication/pty.d.ts.map +1 -0
- package/dist/communication/pty.js +40 -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 +17 -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/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.map +1 -1
- package/dist/preflight.js +2 -1
- package/dist/preflight.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/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 +9 -1
- 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/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/pty.ts +47 -0
- package/src/communication/relay.ts +12 -1
- package/src/communication/skills.ts +16 -0
- package/src/communication/worker.ts +9 -26
- package/src/failure-kinds.ts +2 -0
- package/src/plugin-manifest.ts +1 -1
- package/src/preflight.ts +3 -1
- package/src/worker.ts +11 -0
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { agentEnvironment } from './environment.js';
|
|
4
|
+
import { resolveCliModel } from '../cli-adapter.js';
|
|
5
|
+
import type { RelayBroker } from './relay.js';
|
|
6
|
+
|
|
7
|
+
export const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
8
|
+
|
|
9
|
+
/** Both communication steps and retained sessions use Relay's PTY launch contract. */
|
|
10
|
+
export async function spawnFlowPty(broker: Pick<RelayBroker, 'spawnPty'>, options: {
|
|
11
|
+
name: string; cli: string; identity?: string; model?: string; task: string;
|
|
12
|
+
skipRelayPrompt?: boolean;
|
|
13
|
+
cwd: string; dataDir: string; channels: string[]; team?: string;
|
|
14
|
+
environment?: NodeJS.ProcessEnv; socket: string; token: string;
|
|
15
|
+
secrets?: string[]; scopeSecrets?: boolean;
|
|
16
|
+
metadata?: { runId: string; flow: string; step: string };
|
|
17
|
+
}) {
|
|
18
|
+
const identity = options.identity ?? options.cli;
|
|
19
|
+
const argv0 = basename(identity);
|
|
20
|
+
let command = options.cli;
|
|
21
|
+
let link: string | undefined;
|
|
22
|
+
try {
|
|
23
|
+
if (basename(command) !== argv0) {
|
|
24
|
+
const root = join(options.dataDir, 'communication');
|
|
25
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
26
|
+
link = await mkdtemp(join(root, 'cli-'));
|
|
27
|
+
command = join(link, argv0);
|
|
28
|
+
await symlink(options.cli, command, 'file');
|
|
29
|
+
}
|
|
30
|
+
const handle = await broker.spawnPty({ name: options.name, cli: argv0.replace(/\.exe$/i, ''),
|
|
31
|
+
task: options.task, channels: options.channels, skipRelayPrompt: options.skipRelayPrompt ?? true,
|
|
32
|
+
...(options.team === undefined ? {} : { team: options.team }),
|
|
33
|
+
model: resolveCliModel(identity, options.model), cwd: options.cwd,
|
|
34
|
+
harnessConfig: { runtime: 'pty', command: shellQuote(command), args: [], cwd: options.cwd,
|
|
35
|
+
env: { ...agentEnvironment(identity, options.environment ?? process.env,
|
|
36
|
+
options.secrets, options.scopeSecrets ?? true),
|
|
37
|
+
RELAYFLOW_COMMUNICATION_SOCKET: options.socket, RELAYFLOW_COMMUNICATION_TOKEN: options.token,
|
|
38
|
+
...(options.metadata === undefined ? {} : {
|
|
39
|
+
RELAYFLOW_RUN_ID: options.metadata.runId, RELAYFLOW_FLOW_NAME: options.metadata.flow,
|
|
40
|
+
RELAYFLOW_STEP_NAME: options.metadata.step,
|
|
41
|
+
}) }, delivery: { mode: 'pty-injection', format: 'relay-block' } } });
|
|
42
|
+
return { handle, close: async () => { if (link) await rm(link, { recursive: true, force: true }); } };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (link) await rm(link, { recursive: true, force: true });
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const execute = promisify(execFile);
|
|
6
|
+
/** Install full skill directories (including references) using the canonical package. */
|
|
7
|
+
export async function installBrokerSkills(skills: string[], cli: string, cwd: string, signal: AbortSignal): Promise<void> {
|
|
8
|
+
if (!skills.length) return;
|
|
9
|
+
const identity = basename(cli).replace(/\.exe$/i, '');
|
|
10
|
+
const harness = ({ claude: 'claude-code', codex: 'codex', gemini: 'gemini-cli',
|
|
11
|
+
'cursor-agent': 'cursor', opencode: 'opencode' } as Record<string, string>)[identity];
|
|
12
|
+
if (!harness) throw new Error(`Skill installation is unsupported for broker CLI ${identity}`);
|
|
13
|
+
await execute('npx', ['--yes', 'skills', 'add', 'https://github.com/AgentWorkforce/skills',
|
|
14
|
+
'--skill', ...skills, '--agent', harness, '--yes', '--copy'],
|
|
15
|
+
{ cwd, signal, timeout: 120_000, maxBuffer: 1024 * 1024 });
|
|
16
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { communicationHistory } from './history.js';
|
|
2
|
-
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
3
|
-
import { basename, join } from 'node:path';
|
|
4
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
3
|
import type { JournalClient } from '../journal-client.js';
|
|
6
4
|
import type { StepDispatchEvent } from '../protocol.js';
|
|
@@ -8,13 +6,12 @@ import type { ResolvedKernelAgentStep } from '../resolved-cli-identity.js';
|
|
|
8
6
|
import { isLeaseLost, withWorkerLease } from '../worker-lease.js';
|
|
9
7
|
import { isReadInterruptionError } from '../journal-read-policy.js';
|
|
10
8
|
import { workerInstruction } from '../worker-input.js';
|
|
11
|
-
import { resolveCliModel } from '../cli-adapter.js';
|
|
12
9
|
import { resolveAgentCwd } from '../agent-cwd.js';
|
|
13
10
|
import { channelName, type CommunicationInstruction } from './spec.js';
|
|
14
11
|
import { acquireRelayRuntime, type RelayHandle } from './relay.js';
|
|
15
12
|
import { CommunicationSession } from './session.js';
|
|
16
13
|
import { openCommunicationTools } from './tools.js';
|
|
17
|
-
import {
|
|
14
|
+
import { spawnFlowPty, shellQuote } from './pty.js';
|
|
18
15
|
|
|
19
16
|
export function requireCommunicationCli(cli: string | undefined): void {
|
|
20
17
|
if (!cli?.trim()) throw new Error('Agent communication requires a declared CLI executable');
|
|
@@ -62,7 +59,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
|
|
|
62
59
|
let pumping: Promise<void> | undefined;
|
|
63
60
|
let receipts = Promise.resolve();
|
|
64
61
|
let unsubscribe: (() => void) | undefined;
|
|
65
|
-
let
|
|
62
|
+
let pty: Awaited<ReturnType<typeof spawnFlowPty>> | undefined;
|
|
66
63
|
try {
|
|
67
64
|
let resolve!: (value: unknown) => void;
|
|
68
65
|
let reject!: (reason: unknown) => void;
|
|
@@ -74,8 +71,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
|
|
|
74
71
|
if (signal.aborted) abort();
|
|
75
72
|
const session = new CommunicationSession(client, dispatch, instruction, relay, summary => resolve({ summary }), reject);
|
|
76
73
|
tools = await openCommunicationTools(request => session.invoke(request));
|
|
77
|
-
const
|
|
78
|
-
const helper = `node ${quote(tools.helperPath)}`;
|
|
74
|
+
const helper = `node ${shellQuote(tools.helperPath)}`;
|
|
79
75
|
const history = await communicationHistory(client, dispatch, instruction);
|
|
80
76
|
const prompt = `${workerInstruction(instruction.instruction, dispatch)}${history}\n\nYou are flow agent ${dispatch.step_id}. Concurrent peers send messages automatically into your session through Relay. Do not poll for messages. Use your shell/terminal execution tool with ${helper} for flow communication:\n- send PEER STABLE_ID 'message text' (outgoing peers: ${instruction.outgoing.join(', ') || 'none'})\n- ack PEER DELIVERY_SEQ only after processing that message (incoming peers: ${instruction.incoming.join(', ') || 'none'})\n- complete 'summary' when your task and conversation are finished.\nUse these helpers for all peer communication; do not use Relay MCP messaging tools. Stay available for injected messages until the conversation is complete. Use stable semantic message IDs so retries deduplicate sends.`;
|
|
81
77
|
const name = `${relay.prefix}-${dispatch.step_id}`;
|
|
@@ -89,24 +85,11 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
|
|
|
89
85
|
});
|
|
90
86
|
void receipts.catch(reject);
|
|
91
87
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const linkRoot = join(dataDir, 'communication');
|
|
98
|
-
await mkdir(linkRoot, { recursive: true, mode: 0o700 });
|
|
99
|
-
cliLinkDirectory = await mkdtemp(join(linkRoot, 'cli-'));
|
|
100
|
-
command = join(cliLinkDirectory, argv0);
|
|
101
|
-
await symlink(spec.cli!, command, 'file');
|
|
102
|
-
}
|
|
103
|
-
handle = await relay.broker.spawnPty({ name, cli: basename(cliIdentity).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
|
|
104
|
-
model: resolveCliModel(cliIdentity, spec.model), cwd: directory,
|
|
105
|
-
harnessConfig: { runtime: 'pty', command: quote(command), args: [],
|
|
106
|
-
cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env,
|
|
107
|
-
spec.secrets, client.supportsFeature?.('step_secret_scoping') ?? true),
|
|
108
|
-
RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
|
|
109
|
-
delivery: { mode: 'pty-injection', format: 'relay-block' } } });
|
|
88
|
+
pty = await spawnFlowPty(relay.broker, { name, cli: spec.cli!, identity: spec.cli_identity,
|
|
89
|
+
model: spec.model, task: prompt, channels: [], cwd: directory, dataDir,
|
|
90
|
+
environment, socket: tools.path, token: tools.token,
|
|
91
|
+
secrets: spec.secrets, scopeSecrets: client.supportsFeature?.('step_secret_scoping') ?? true });
|
|
92
|
+
handle = pty.handle;
|
|
110
93
|
const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
|
|
111
94
|
if (ready.reason !== 'ready') throw new Error(`Communication agent did not become ready: ${ready.reason}`);
|
|
112
95
|
pumping = (async () => {
|
|
@@ -123,7 +106,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
|
|
|
123
106
|
finally { try { await handle?.release('Flow communication attempt ended', { deleteIdentity: true }); }
|
|
124
107
|
finally { try { await tools?.close(); } finally {
|
|
125
108
|
try { await relay.close(); }
|
|
126
|
-
finally {
|
|
109
|
+
finally { await pty?.close(); }
|
|
127
110
|
} } }
|
|
128
111
|
}
|
|
129
112
|
}
|
package/src/failure-kinds.ts
CHANGED
|
@@ -138,6 +138,8 @@ export const RUN_FAILURE_KINDS = [
|
|
|
138
138
|
'unsafe_parallel_runtime',
|
|
139
139
|
/** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
|
|
140
140
|
'gate_failed',
|
|
141
|
+
/** A resumed serial body reached a journaled step id with a different call; refused, not executed. */
|
|
142
|
+
'serial_replay_order_mismatch',
|
|
141
143
|
/** `flows answer` named a wait the run is not asking: unknown, or already answered. */
|
|
142
144
|
'human_wait_unknown',
|
|
143
145
|
/** `f.memory.learn` was given a finding without non-empty string fields. */
|