@relayflows/sdk 2.0.49 → 2.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +4 -2
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-preflight.js +4 -4
- package/dist/authored-preflight.js.map +1 -1
- package/dist/authored-worker-step.d.ts +2 -2
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +11 -5
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.js +2 -2
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cli-probe.d.ts +1 -1
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +8 -7
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +2 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +6 -2
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli-commands.d.ts +4 -1
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +5 -1
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloud-http.d.ts +1 -0
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +1 -1
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +6 -1
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +56 -6
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-signal.d.ts +12 -0
- package/dist/cloud-signal.d.ts.map +1 -0
- package/dist/cloud-signal.js +117 -0
- package/dist/cloud-signal.js.map +1 -0
- package/dist/cloud-sync.d.ts +8 -1
- package/dist/cloud-sync.d.ts.map +1 -1
- package/dist/cloud-sync.js +36 -4
- package/dist/cloud-sync.js.map +1 -1
- package/dist/communication/environment.d.ts +1 -1
- package/dist/communication/environment.d.ts.map +1 -1
- package/dist/communication/environment.js +26 -3
- package/dist/communication/environment.js.map +1 -1
- package/dist/communication/worker.js +1 -1
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -2
- package/dist/compile.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +2 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +26 -3
- package/dist/journal-client.js.map +1 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +1 -1
- package/dist/llm-worker.js.map +1 -1
- package/dist/observer-link.d.ts +7 -7
- package/dist/observer-link.js +6 -6
- package/dist/preflight.d.ts +2 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +12 -7
- package/dist/preflight.js.map +1 -1
- package/dist/spec.d.ts +4 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +1 -1
- package/dist/step-fields.d.ts.map +1 -1
- package/dist/step-fields.js +1 -0
- package/dist/step-fields.js.map +1 -1
- package/dist/step-secrets.d.ts +20 -0
- package/dist/step-secrets.d.ts.map +1 -0
- package/dist/step-secrets.js +146 -0
- package/dist/step-secrets.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +10 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +5 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +43 -14
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/authored-flow-executor.ts +4 -2
- package/src/authored-preflight.ts +4 -4
- package/src/authored-worker-step.ts +17 -6
- package/src/cli/check.ts +3 -3
- package/src/cli/cli-probe.ts +9 -6
- package/src/cli/cloud-run.ts +7 -2
- package/src/cli-commands.ts +5 -1
- package/src/cli.ts +26 -8
- package/src/cloud-http.ts +1 -1
- package/src/cloud-run.ts +61 -8
- package/src/cloud-signal.ts +110 -0
- package/src/cloud-sync.ts +39 -4
- package/src/communication/environment.ts +28 -3
- package/src/communication/worker.ts +2 -1
- package/src/compile.ts +5 -2
- package/src/index.ts +1 -0
- package/src/journal-client.ts +29 -2
- package/src/llm-worker.ts +2 -1
- package/src/observer-link.ts +7 -7
- package/src/preflight.ts +13 -7
- package/src/spec.ts +4 -0
- package/src/step-fields.ts +1 -0
- package/src/step-secrets.ts +145 -0
- package/src/validate.ts +11 -0
- package/src/worker-cli.ts +41 -13
- package/src/worker.ts +3 -2
|
@@ -20,6 +20,7 @@ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
|
|
|
20
20
|
import type { StepFailedDetails } from './failure-kinds.js';
|
|
21
21
|
import { LOCAL_AGENT_WORKSPACE } from './local-agent-pins.js';
|
|
22
22
|
import { authoredWorkerSlots, type AuthoredWorkerSlots } from './worker-slots.js';
|
|
23
|
+
import { validateStepSecrets } from './step-secrets.js';
|
|
23
24
|
|
|
24
25
|
const AGENT_TIMEOUT = Symbol('declared agent timeout');
|
|
25
26
|
const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
|
|
@@ -177,6 +178,8 @@ export function authoredWorkerRunner(
|
|
|
177
178
|
}
|
|
178
179
|
},
|
|
179
180
|
async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
|
|
181
|
+
const secrets = validateStepSecrets((options as AgentOptions & { secrets?: unknown }).secrets,
|
|
182
|
+
agentEnvironment ?? process.env);
|
|
180
183
|
if (options.workspace !== undefined && localAgentStream !== undefined) {
|
|
181
184
|
throw new AuthoredFlowExecutionError('unsupported_workspace_permission',
|
|
182
185
|
'The local agent worker accepts stream-only steps. Remove workspace or attach a worker that holds its revision pins.');
|
|
@@ -277,6 +280,7 @@ export function authoredWorkerRunner(
|
|
|
277
280
|
: snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
|
|
278
281
|
const output = await run({
|
|
279
282
|
id, type: 'agent', instruction: options.task,
|
|
283
|
+
...(secrets.length ? { secrets } : {}),
|
|
280
284
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
281
285
|
...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
|
|
282
286
|
...(localAgentStream === undefined ? {} : { surfaces: {
|
|
@@ -323,11 +327,14 @@ export function authoredWorkerRunner(
|
|
|
323
327
|
}
|
|
324
328
|
const snapshot = options === undefined ? undefined : snapshotJsonValue(options, 'f.llm options');
|
|
325
329
|
if (snapshot !== undefined && (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot)
|
|
326
|
-
|| Object.keys(snapshot).some(key => !['output', 'cli', 'model'].includes(key)))) {
|
|
327
|
-
throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, and
|
|
330
|
+
|| Object.keys(snapshot).some(key => !['output', 'cli', 'model', 'secrets'].includes(key)))) {
|
|
331
|
+
throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, model, and secrets.');
|
|
328
332
|
}
|
|
333
|
+
const secrets = validateStepSecrets((snapshot as { secrets?: unknown } | undefined)?.secrets,
|
|
334
|
+
agentEnvironment ?? process.env);
|
|
329
335
|
const output = await run({
|
|
330
336
|
id, type: 'llm', prompt, ...snapshot as unknown as LlmOptions,
|
|
337
|
+
...(secrets.length ? { secrets } : {}),
|
|
331
338
|
...(verification === undefined ? {} : { verification }),
|
|
332
339
|
});
|
|
333
340
|
if (options === undefined && typeof output !== 'string') {
|
|
@@ -358,6 +365,7 @@ export function authoredDeterministicRunner(
|
|
|
358
365
|
function lower<T>(
|
|
359
366
|
id: string, command: string, terminal: boolean, leaseMs: number | undefined,
|
|
360
367
|
verification: NamedGate | undefined, onNonZero: 'record' | undefined,
|
|
368
|
+
secrets: string[] | undefined,
|
|
361
369
|
read: (outcome: import('./protocol.js').RunOutcome) => Promise<T>,
|
|
362
370
|
): Promise<T> {
|
|
363
371
|
const spec = toKernelSpec(compileSpec({
|
|
@@ -365,6 +373,7 @@ export function authoredDeterministicRunner(
|
|
|
365
373
|
name: `${name}/${id}`,
|
|
366
374
|
steps: [{
|
|
367
375
|
id, type: 'deterministic', command,
|
|
376
|
+
...(secrets?.length ? { secrets } : {}),
|
|
368
377
|
...(leaseMs === undefined ? {} : { lease_ms: leaseMs }),
|
|
369
378
|
...(onNonZero === undefined ? {} : { onNonZero }),
|
|
370
379
|
...(verification === undefined ? {} : { verification }),
|
|
@@ -380,12 +389,14 @@ export function authoredDeterministicRunner(
|
|
|
380
389
|
if (terminal) return journal.runStart(spec, undefined, admissionKey).then(consume);
|
|
381
390
|
return budget.execute(journal, spec, consume, admissionKey);
|
|
382
391
|
}
|
|
383
|
-
const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate
|
|
384
|
-
|
|
392
|
+
const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate,
|
|
393
|
+
secrets?: string[]): Promise<string> =>
|
|
394
|
+
lower(id, command, terminal, leaseMs, verification, undefined, secrets,
|
|
385
395
|
outcome => readSuccessfulOutput(journal, outcome, id, journalSteps, context));
|
|
386
396
|
/** `f.run(..., { onNonZero: 'record' })`: the exit code is the result, not a throw. */
|
|
387
|
-
lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate
|
|
388
|
-
|
|
397
|
+
lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate,
|
|
398
|
+
secrets?: string[]): Promise<RecordedRunOutcome> =>
|
|
399
|
+
lower(id, command, false, leaseMs, verification, 'record', secrets,
|
|
389
400
|
outcome => readRecordedOutcome(journal, outcome, id, journalSteps, context));
|
|
390
401
|
return lowerRun;
|
|
391
402
|
}
|
package/src/cli/check.ts
CHANGED
|
@@ -492,8 +492,8 @@ function findConfig(start: string): string | undefined {
|
|
|
492
492
|
function systemProbes(flowDirectory: string, config: ProjectConfig, environment?: NodeJS.ProcessEnv): PreflightProbes {
|
|
493
493
|
return {
|
|
494
494
|
helper: helperReady,
|
|
495
|
-
cli: (cli, source, model, execution) => probeCli(cli,
|
|
496
|
-
source === 'project' ? config.directory : flowDirectory, model, execution, environment),
|
|
495
|
+
cli: (cli, source, model, execution, secrets) => probeCli(cli,
|
|
496
|
+
source === 'project' ? config.directory : flowDirectory, model, execution, environment, secrets),
|
|
497
497
|
executor: (trigger) => config.executors.includes(trigger.executor),
|
|
498
498
|
// A deterministic step runs in the daemon's working directory — the
|
|
499
499
|
// directory `flows run` was invoked from, or Cloud's code mount — not in
|
|
@@ -521,7 +521,7 @@ function bindResolvedCliPaths(
|
|
|
521
521
|
if (resolution === undefined) return step;
|
|
522
522
|
const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
|
|
523
523
|
const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
|
|
524
|
-
const outcome = probeCache.get(cliProbeKey(resolution, managed));
|
|
524
|
+
const outcome = probeCache.get(cliProbeKey(resolution, managed, step.secrets));
|
|
525
525
|
const executable = outcome !== undefined && 'result' in outcome
|
|
526
526
|
? outcome.result.executable
|
|
527
527
|
: undefined;
|
package/src/cli/cli-probe.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { adapterIdentification, authenticationProbe, classifyModelProbeFailure,
|
|
|
7
7
|
import { MODEL_ENV } from '../worker-cli.js';
|
|
8
8
|
import { CliProbeError, type CliProbeResult } from '../preflight.js';
|
|
9
9
|
import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js';
|
|
10
|
+
import { agentProbeEnvironment } from '../step-secrets.js';
|
|
10
11
|
|
|
11
12
|
interface ProbeRequest {
|
|
12
13
|
argv0?: string;
|
|
@@ -30,7 +31,8 @@ export async function probeCliAsync(...args: Parameters<typeof probeSequence>):
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export function resolveExecutable(command: string, directory: string, environment: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
33
|
-
return driveSync(executableSequence(command, directory,
|
|
34
|
+
return driveSync(executableSequence(command, directory,
|
|
35
|
+
agentProbeEnvironment(environment, 'relayflows-wrapper-v1')));
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
|
|
@@ -94,23 +96,24 @@ function* probeSequence(
|
|
|
94
96
|
model?: string,
|
|
95
97
|
execution?: 'managed',
|
|
96
98
|
sourceEnvironment: NodeJS.ProcessEnv = process.env,
|
|
99
|
+
grants?: string[],
|
|
97
100
|
): Generator<ProbeRequest, CliProbeResult, ProbeOutput> {
|
|
98
|
-
const
|
|
101
|
+
const kind = cliAdapterKind(cli);
|
|
102
|
+
const environment = execution === 'managed'
|
|
103
|
+
? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment, grants) }
|
|
104
|
+
: agentProbeEnvironment(sourceEnvironment, kind, grants);
|
|
105
|
+
const executable = yield* executableSequence(cli, directory, environment);
|
|
99
106
|
if (executable === undefined) return { exists: false, authenticated: false };
|
|
100
107
|
// The declaration selects the adapter; the canonical executable selects
|
|
101
108
|
// the bytes. Package-manager links commonly name `claude` or `codex` while
|
|
102
109
|
// targeting a generic `cli.js`, whose basename must not rewrite the adapter
|
|
103
110
|
// contract after resolution.
|
|
104
|
-
const kind = cliAdapterKind(cli);
|
|
105
111
|
// Relay owns interactive CLI launch/injection. Its generic PTY path is not
|
|
106
112
|
// the headless wrapper protocol; do not demand that protocol from Gemini,
|
|
107
113
|
// Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
|
|
108
114
|
if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
|
|
109
115
|
return { exists: true, supported: true, authenticated: 'unverified', executable };
|
|
110
116
|
}
|
|
111
|
-
const environment = execution === 'managed'
|
|
112
|
-
? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) }
|
|
113
|
-
: sourceEnvironment;
|
|
114
117
|
const probe = (invocation: CliInvocation): ProbeRequest => ({
|
|
115
118
|
executable,
|
|
116
119
|
argv0: basename(cli),
|
package/src/cli/cloud-run.ts
CHANGED
|
@@ -9,8 +9,9 @@ import type { CliIo } from '../cli.js';
|
|
|
9
9
|
|
|
10
10
|
/** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
|
|
11
11
|
export async function runCloudCli(
|
|
12
|
-
{ value: path, json, wait, input, syncCode, noConnect = false }: {
|
|
12
|
+
{ value: path, json, wait, input, syncCode, noConnect = false, budgetMinutes }: {
|
|
13
13
|
value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect?: boolean;
|
|
14
|
+
budgetMinutes?: number;
|
|
14
15
|
},
|
|
15
16
|
io: CliIo,
|
|
16
17
|
/**
|
|
@@ -27,7 +28,11 @@ export async function runCloudCli(
|
|
|
27
28
|
let submitting = false;
|
|
28
29
|
let harnesses: readonly string[] = [];
|
|
29
30
|
try {
|
|
30
|
-
const options: RunInCloudOptions = {
|
|
31
|
+
const options: RunInCloudOptions = {
|
|
32
|
+
signal,
|
|
33
|
+
onSubmit: () => { submitting = true; },
|
|
34
|
+
...(budgetMinutes === undefined ? {} : { runBudgetMinutes: budgetMinutes }),
|
|
35
|
+
};
|
|
31
36
|
if (isAuthoredFlowPath(path)) {
|
|
32
37
|
// Same parse as a local direct run, so a file-or-inline argument means
|
|
33
38
|
// the same thing on both sides of `--cloud`.
|
package/src/cli-commands.ts
CHANGED
|
@@ -300,7 +300,7 @@ export const CLI_VERBS = [
|
|
|
300
300
|
},
|
|
301
301
|
{
|
|
302
302
|
name: 'run',
|
|
303
|
-
description: 'Run
|
|
303
|
+
description: 'Run locally with an Observer link (workspace key) or --cloud-mirror (Cloud dashboard), or submit with --cloud',
|
|
304
304
|
args: [{ name: 'flow', description: 'flow.yaml, flow.ts, spec.json, or <flow>@sha256:<digest>', required: true }],
|
|
305
305
|
options: [
|
|
306
306
|
...LOCAL_EXECUTION_OPTIONS,
|
|
@@ -309,6 +309,10 @@ export const CLI_VERBS = [
|
|
|
309
309
|
{ flags: '--reuse-from <run-id>', description: 'Reuse memoized step outputs from an earlier run' },
|
|
310
310
|
{ flags: '--cloud', description: 'Submit to Agent Relay Cloud instead of running locally' },
|
|
311
311
|
{ flags: '--wait', description: 'With --cloud, poll until the hosted run reaches a terminal state' },
|
|
312
|
+
{
|
|
313
|
+
flags: '--budget-minutes <n>',
|
|
314
|
+
description: 'With --cloud, set this admission’s run budget (30-180 minutes); a resume does not inherit it',
|
|
315
|
+
},
|
|
312
316
|
{
|
|
313
317
|
flags: '--sync-code',
|
|
314
318
|
description: 'With --cloud, upload the working directory as the run’s tree; pull results back with `flows sync`',
|
package/src/cli.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { runCloudLogsFollow, runCloudStatusWatch } from './cli/cloud-live.js';
|
|
|
48
48
|
import { transcriptTailSource } from './transcript-tail.js';
|
|
49
49
|
import { checkTypeScriptFlow } from './cli/check-typescript.js';
|
|
50
50
|
import { runCloudCli } from './cli/cloud-run.js';
|
|
51
|
+
import { isCloudRunBudgetMinutes } from './cloud-run.js';
|
|
51
52
|
import { runCloudSyncCli } from './cli/cloud-sync.js';
|
|
52
53
|
import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
|
|
53
54
|
import { runCloudRollbackCli, runCloudVersionsCli } from './cli/cloud-versions.js';
|
|
@@ -100,7 +101,7 @@ export type ParsedArgs =
|
|
|
100
101
|
| BuildArgs
|
|
101
102
|
| DeployArgs
|
|
102
103
|
| { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
|
|
103
|
-
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
|
|
104
|
+
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean; budgetMinutes: number | undefined }
|
|
104
105
|
| { command: 'sync'; runId: string; json: boolean; root: string; dryRun: boolean }
|
|
105
106
|
| CloudDeployArgs
|
|
106
107
|
| { command: 'deployments'; json: boolean }
|
|
@@ -147,8 +148,8 @@ const USAGE = [
|
|
|
147
148
|
'flows check [--against-daemon|--no-daemon-check] [--watch] [--json] [--explain-warnings] <flow.ts|flow.yaml|spec.json>',
|
|
148
149
|
'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
|
|
149
150
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
|
|
150
|
-
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
|
|
151
|
-
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
|
|
151
|
+
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.yaml|spec.json>',
|
|
152
|
+
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.ts> --input <inline-json-or-file>',
|
|
152
153
|
'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
|
|
153
154
|
'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>',
|
|
154
155
|
'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>',
|
|
@@ -253,6 +254,15 @@ export async function runCli(
|
|
|
253
254
|
return 2;
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
if (parsed.command === 'run' && !parsed.json && !parsed.noObserverLink
|
|
258
|
+
&& !(parsed.cloudMirror || cloudMirrorRequested(process.env))) {
|
|
259
|
+
const link = resolveObserverLinkEnv(process.env);
|
|
260
|
+
if (!link.suppressed && link.workspaceKey === undefined) {
|
|
261
|
+
io.stderr('Observer unavailable: no workspace key configured; set RELAYCAST_WORKSPACE_KEY or configure '
|
|
262
|
+
+ '~/.agentworkforce/relay/workspaces.json for an Observer link, or pass --cloud-mirror for the Cloud dashboard');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
if ((parsed.command === 'run' || parsed.command === 'resume') && parsed.detach) {
|
|
257
267
|
const result = await startDetachedRun(args, {
|
|
258
268
|
...parsed, ...(options.selfCommand === undefined ? {} : { selfCommand: options.selfCommand }),
|
|
@@ -536,9 +546,9 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
|
|
|
536
546
|
|
|
537
547
|
/**
|
|
538
548
|
* Resolve the pending observer-mint into a URL (or nothing), and route any
|
|
539
|
-
* mint warning to stderr under a `[observer]` label.
|
|
540
|
-
*
|
|
541
|
-
*
|
|
549
|
+
* mint warning to stderr under a `[observer]` label. An `undefined` input
|
|
550
|
+
* prints nothing here; any missing-key hint was already emitted while the
|
|
551
|
+
* observer session was configured.
|
|
542
552
|
*/
|
|
543
553
|
async function observerUrlFrom(
|
|
544
554
|
mint: Promise<{ observerUrl?: string; warning?: string }> | undefined,
|
|
@@ -729,6 +739,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
729
739
|
let wait = false;
|
|
730
740
|
let syncCode = false;
|
|
731
741
|
let noConnect = false;
|
|
742
|
+
let budgetMinutes: number | undefined;
|
|
732
743
|
let localAgent = false;
|
|
733
744
|
let detach = false;
|
|
734
745
|
let agentCapacity: number | undefined;
|
|
@@ -754,6 +765,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
754
765
|
else noConnect = true;
|
|
755
766
|
continue;
|
|
756
767
|
}
|
|
768
|
+
if (argument === '--budget-minutes') {
|
|
769
|
+
const value = args[++index];
|
|
770
|
+
if (command !== 'run' || budgetMinutes !== undefined || value === undefined || !/^[0-9]+$/.test(value)) return undefined;
|
|
771
|
+
budgetMinutes = Number(value);
|
|
772
|
+
if (!isCloudRunBudgetMinutes(budgetMinutes)) return undefined;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
757
775
|
if (argument === '--detach') {
|
|
758
776
|
if (command === 'check' || detach) return undefined;
|
|
759
777
|
detach = true;
|
|
@@ -865,9 +883,9 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
865
883
|
if (detach || allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || cloudMirror
|
|
866
884
|
|| reuseFromRunId !== undefined) return undefined;
|
|
867
885
|
if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
868
|
-
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
|
|
886
|
+
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect, budgetMinutes };
|
|
869
887
|
}
|
|
870
|
-
if (wait || syncCode || noConnect) return undefined;
|
|
888
|
+
if (wait || syncCode || noConnect || budgetMinutes !== undefined) return undefined;
|
|
871
889
|
if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
872
890
|
|
|
873
891
|
if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
|
package/src/cloud-http.ts
CHANGED
|
@@ -290,7 +290,7 @@ export function isCloudRecord(value: unknown): value is Record<string, unknown>
|
|
|
290
290
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
function transportError(error: unknown): CloudFlowError {
|
|
293
|
+
export function transportError(error: unknown): CloudFlowError {
|
|
294
294
|
const cause = error instanceof Error ? error.cause : undefined;
|
|
295
295
|
const code = isCloudRecord(cause) ? cause.code : undefined;
|
|
296
296
|
const transient = (error instanceof Error && error.name === 'TimeoutError')
|
package/src/cloud-run.ts
CHANGED
|
@@ -14,12 +14,23 @@ import {
|
|
|
14
14
|
type CloudConnectionOptions,
|
|
15
15
|
} from './cloud-http.js';
|
|
16
16
|
import { cloudRunState, isCloudRunActive, type CloudRunState } from './cloud-run-record.js';
|
|
17
|
-
import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
|
|
17
|
+
import { canUseCodeCache, packWorkingTree, prepareCloudSync, sha256PackedTree, uploadCloudCode } from './cloud-sync.js';
|
|
18
18
|
|
|
19
19
|
export type CloudFlowSource = FlowSpec | { path: string };
|
|
20
|
+
export const MIN_CLOUD_RUN_BUDGET_MINUTES = 30;
|
|
21
|
+
export const MAX_CLOUD_RUN_BUDGET_MINUTES = 180;
|
|
22
|
+
|
|
23
|
+
export function isCloudRunBudgetMinutes(value: number): boolean {
|
|
24
|
+
return Number.isSafeInteger(value)
|
|
25
|
+
&& value >= MIN_CLOUD_RUN_BUDGET_MINUTES
|
|
26
|
+
&& value <= MAX_CLOUD_RUN_BUDGET_MINUTES;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
export interface RunInCloudOptions extends CloudConnectionOptions {
|
|
21
30
|
/** Existing Cloud workspace to run in; provisioning/authorization remains server-side. */
|
|
22
31
|
workspaceId?: string;
|
|
32
|
+
/** Hosted run deadline in minutes. Applies to this admission only, not a later resume. */
|
|
33
|
+
runBudgetMinutes?: number;
|
|
23
34
|
/** Exact JSON input. Required at runtime for authored .flow.ts; refused for declarative source. */
|
|
24
35
|
input?: JsonValue;
|
|
25
36
|
/**
|
|
@@ -54,7 +65,7 @@ export interface CloudRunReceipt {
|
|
|
54
65
|
specHash: string;
|
|
55
66
|
/** Authenticated run API resource; this is not a public sharing URL. */
|
|
56
67
|
apiUrl: string;
|
|
57
|
-
/** Present when a working tree was synced:
|
|
68
|
+
/** Present when a working tree was synced: its file count and uncompressed size. */
|
|
58
69
|
synced?: { files: number; bytes: number; skippedLinks: string[] };
|
|
59
70
|
}
|
|
60
71
|
export interface CloudAuthoredAuthority {
|
|
@@ -108,6 +119,31 @@ function snapshotEnvSecrets(value: unknown): Record<string, string> | undefined
|
|
|
108
119
|
return Object.keys(copy).length === 0 ? undefined : copy;
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/**
|
|
123
|
+
* A Cloud refusal is Cloud's own text. Should any of it ever quote an
|
|
124
|
+
* envSecrets value — the message, or a field such as `expected`/`received` —
|
|
125
|
+
* keep only a safe refusal code and drop the rest, so the value never reaches
|
|
126
|
+
* an error message, a log line or a caller's error report.
|
|
127
|
+
*/
|
|
128
|
+
function withheldIfQuotingSecret(error: unknown, envSecrets: Record<string, string> | undefined): unknown {
|
|
129
|
+
if (envSecrets === undefined || !(error instanceof CloudFlowError) || error.refusal === undefined) return error;
|
|
130
|
+
const values = Object.values(envSecrets).filter(value => value.length > 0);
|
|
131
|
+
const quotes = (text: string): boolean => values.some(value => text.includes(value));
|
|
132
|
+
if (!quotes(error.message) && !refusalStrings(error.refusal).some(quotes)) return error;
|
|
133
|
+
const code = quotes(error.refusal.code) ? 'withheld' : error.refusal.code;
|
|
134
|
+
return new CloudFlowError(error.code,
|
|
135
|
+
`Cloud refused (${code}); its message was withheld because it quoted an envSecrets value.`,
|
|
136
|
+
error.status, { code, error: 'withheld: quoted an envSecrets value' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Every string anywhere in a refusal, so a field added later is checked too. */
|
|
140
|
+
function refusalStrings(value: unknown): string[] {
|
|
141
|
+
if (typeof value === 'string') return [value];
|
|
142
|
+
if (Array.isArray(value)) return value.flatMap(refusalStrings);
|
|
143
|
+
if (value !== null && typeof value === 'object') return Object.values(value).flatMap(refusalStrings);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
/**
|
|
112
148
|
* Submit a declarative flow to Cloud's pinned v2 runtime. Compilation only
|
|
113
149
|
* happens here; Cloud owns provisioning and the Rust engine owns execution.
|
|
@@ -252,6 +288,10 @@ export async function runInCloud(
|
|
|
252
288
|
): Promise<CloudRunReceipt> {
|
|
253
289
|
// Validate locally first: resolving the connection may refresh a stored
|
|
254
290
|
// login, which posts the refresh token and rotates the credential file.
|
|
291
|
+
if (options.runBudgetMinutes !== undefined && !isCloudRunBudgetMinutes(options.runBudgetMinutes)) {
|
|
292
|
+
throw new CloudFlowError('configuration',
|
|
293
|
+
`runBudgetMinutes must be an integer from ${MIN_CLOUD_RUN_BUDGET_MINUTES} to ${MAX_CLOUD_RUN_BUDGET_MINUTES}.`);
|
|
294
|
+
}
|
|
255
295
|
const envSecrets = snapshotEnvSecrets(options.envSecrets);
|
|
256
296
|
const submission = await prepareCloudSubmission(flow, options);
|
|
257
297
|
const { baseUrl } = await resolveCloudConnection(options);
|
|
@@ -260,31 +300,44 @@ export async function runInCloud(
|
|
|
260
300
|
// under it, so the run request below names code Cloud already holds. A
|
|
261
301
|
// refused backend or failed upload therefore never leaves a launched run
|
|
262
302
|
// pointing at a tree that is not there.
|
|
263
|
-
let synced: { runId: string; codeKey: string; files: number; bytes: number; skippedLinks: string[] } | undefined;
|
|
303
|
+
let synced: { runId: string; codeKey: string; codeCacheHit: boolean; files: number; bytes: number; skippedLinks: string[] } | undefined;
|
|
264
304
|
if (options.syncCode !== undefined) {
|
|
265
|
-
const prepared = await prepareCloudSync(options);
|
|
266
305
|
options.signal?.throwIfAborted();
|
|
267
306
|
const packed = await packWorkingTree(options.syncCode.root);
|
|
307
|
+
let prepared: Awaited<ReturnType<typeof prepareCloudSync>>;
|
|
268
308
|
try {
|
|
269
309
|
options.signal?.throwIfAborted();
|
|
270
|
-
await
|
|
310
|
+
const digest = canUseCodeCache(packed) ? await sha256PackedTree(packed) : undefined;
|
|
311
|
+
prepared = await prepareCloudSync(options, digest);
|
|
312
|
+
options.signal?.throwIfAborted();
|
|
313
|
+
if (!prepared.codeCacheHit) await uploadCloudCode(prepared, packed, options);
|
|
271
314
|
} finally {
|
|
272
315
|
packed.dispose();
|
|
273
316
|
}
|
|
274
|
-
synced = { runId: prepared.runId, codeKey: prepared.codeKey,
|
|
317
|
+
synced = { runId: prepared.runId, codeKey: prepared.codeKey, codeCacheHit: prepared.codeCacheHit === true,
|
|
318
|
+
files: packed.files.length, bytes: packed.bytes,
|
|
275
319
|
skippedLinks: packed.skippedLinks };
|
|
276
320
|
}
|
|
277
321
|
options.signal?.throwIfAborted();
|
|
278
322
|
options.onSubmit?.();
|
|
279
323
|
let result: unknown;
|
|
280
324
|
try {
|
|
281
|
-
|
|
325
|
+
// A cache hit is copied into the run prefix after admission. The R2 copy
|
|
326
|
+
// may outlast the normal 30s request budget; explicit caller limits win.
|
|
327
|
+
const submitOptions = synced?.codeCacheHit
|
|
328
|
+
? { ...options, requestTimeoutMs: options.requestTimeoutMs ?? 300_000 }
|
|
329
|
+
: options;
|
|
330
|
+
result = await cloudFetch('/api/v1/workflows/run', submitOptions, { method: 'POST', detail: true, body: JSON.stringify({
|
|
282
331
|
...cloudSubmissionBody(submission),
|
|
283
332
|
...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
|
|
333
|
+
...(options.runBudgetMinutes === undefined ? {} : { relayflowV2RunBudgetMinutes: options.runBudgetMinutes }),
|
|
284
334
|
...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
|
|
285
335
|
...(envSecrets === undefined ? {} : { envSecrets }),
|
|
286
336
|
}) });
|
|
287
|
-
} catch (
|
|
337
|
+
} catch (caught) {
|
|
338
|
+
// Withhold first: every error thrown from here, including the rewrite
|
|
339
|
+
// below, carries only a refusal that quotes no envSecrets value.
|
|
340
|
+
const error = withheldIfQuotingSecret(caught, envSecrets);
|
|
288
341
|
// Cloud accepts exactly one authored Surface (the one its sandbox runs).
|
|
289
342
|
// A CLI on another release is refused with this code; say which side is
|
|
290
343
|
// which instead of leaving the user with a bare 400 (flows#461).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, resolveCloudConnection,
|
|
3
|
+
transportError, type CloudConnectionOptions } from './cloud-http.js';
|
|
4
|
+
|
|
5
|
+
const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u;
|
|
6
|
+
const MAX_BYTES = 4 * 1024;
|
|
7
|
+
const HELD_GET_MS = 25_000;
|
|
8
|
+
const HOSTED_SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN';
|
|
9
|
+
|
|
10
|
+
function signalPath(runId: string, name: string): string {
|
|
11
|
+
cloudRunId(runId);
|
|
12
|
+
if (!SIGNAL_NAME.test(name)) {
|
|
13
|
+
throw new CloudFlowError('invalid_input', 'Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).');
|
|
14
|
+
}
|
|
15
|
+
return `/api/v1/workflows/runs/${runId}/signals/${name}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Store a run-scoped completion. Retrying the same JSON payload is safe. */
|
|
19
|
+
export async function postCloudSignal(
|
|
20
|
+
runId: string, name: string, payload: unknown, options: CloudConnectionOptions = {},
|
|
21
|
+
): Promise<{ accepted: true; duplicate: boolean }> {
|
|
22
|
+
const path = signalPath(runId, name);
|
|
23
|
+
if (payload === undefined) throw new CloudFlowError('invalid_input', 'Signal payload must be present.');
|
|
24
|
+
let body: string;
|
|
25
|
+
try { body = JSON.stringify({ payload }); }
|
|
26
|
+
catch { throw new CloudFlowError('invalid_input', 'Signal payload must be JSON serializable.'); }
|
|
27
|
+
// JSON.stringify omits a function, symbol, or toJSON() result of undefined.
|
|
28
|
+
// This envelope has only one field, so {} means the payload was lost.
|
|
29
|
+
if (body === '{}') throw new CloudFlowError('invalid_input', 'Signal payload must be JSON serializable.');
|
|
30
|
+
if (new TextEncoder().encode(body).byteLength > MAX_BYTES) {
|
|
31
|
+
throw new CloudFlowError('invalid_input', 'Signal payload exceeds 4 KiB.');
|
|
32
|
+
}
|
|
33
|
+
const result = await cloudFetch(path, options, { method: 'POST', body, detail: true });
|
|
34
|
+
if (!isCloudRecord(result) || result.accepted !== true || typeof result.duplicate !== 'boolean') {
|
|
35
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal receipt.');
|
|
36
|
+
}
|
|
37
|
+
return { accepted: true, duplicate: result.duplicate };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wait on Cloud's held GET. A normally held 204 re-arms immediately. A fast
|
|
42
|
+
* 204 backs off so a proxy or broken server cannot turn this into a hot loop.
|
|
43
|
+
*/
|
|
44
|
+
export async function waitForCloudSignal(
|
|
45
|
+
runId: string, name: string, options: CloudConnectionOptions = {},
|
|
46
|
+
): Promise<unknown> {
|
|
47
|
+
const path = signalPath(runId, name);
|
|
48
|
+
const requestTimeout = options.requestTimeoutMs ?? 30_000;
|
|
49
|
+
if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= HELD_GET_MS) {
|
|
50
|
+
throw new CloudFlowError('invalid_input', 'Signal requestTimeoutMs must exceed 25 seconds.');
|
|
51
|
+
}
|
|
52
|
+
let fast204BackoffMs = 250;
|
|
53
|
+
for (;;) {
|
|
54
|
+
options.signal?.throwIfAborted();
|
|
55
|
+
let connection: { baseUrl: string; token: string };
|
|
56
|
+
try {
|
|
57
|
+
connection = await resolveCloudConnection(options);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The hosted run's read-only credential is only a fallback for waits.
|
|
60
|
+
// Explicit Cloud credentials and stored logins retain precedence, and
|
|
61
|
+
// postCloudSignal never sees this token.
|
|
62
|
+
const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
|
|
63
|
+
if (!(error instanceof CloudFlowError) || error.code !== 'configuration'
|
|
64
|
+
|| error.reason !== 'auth_missing' || options.token !== undefined
|
|
65
|
+
|| process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error;
|
|
66
|
+
connection = await resolveCloudConnection({ ...options, token: hostedToken });
|
|
67
|
+
}
|
|
68
|
+
const { baseUrl, token } = connection;
|
|
69
|
+
const deadline = AbortSignal.timeout(requestTimeout);
|
|
70
|
+
const requestStartedAt = performance.now();
|
|
71
|
+
let response: Response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetch(`${baseUrl}${path}?waitMs=${HELD_GET_MS}`, {
|
|
74
|
+
method: 'GET',
|
|
75
|
+
headers: { authorization: `Bearer ${token}` },
|
|
76
|
+
signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
|
|
77
|
+
redirect: 'error',
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
options.signal?.throwIfAborted();
|
|
81
|
+
throw transportError(deadline.aborted ? deadline.reason : error);
|
|
82
|
+
}
|
|
83
|
+
if (response.status === 204) {
|
|
84
|
+
if (performance.now() - requestStartedAt < 1_000) {
|
|
85
|
+
try {
|
|
86
|
+
await delay(fast204BackoffMs, undefined, { signal: options.signal });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
options.signal?.throwIfAborted();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
fast204BackoffMs = Math.min(fast204BackoffMs * 2, 5_000);
|
|
92
|
+
} else {
|
|
93
|
+
fast204BackoffMs = 250;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) throw new CloudFlowError('http_error', `Cloud signal wait failed with HTTP ${response.status}.`, response.status);
|
|
98
|
+
let body: unknown;
|
|
99
|
+
try { body = await response.json(); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
options.signal?.throwIfAborted();
|
|
102
|
+
if (!(error instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : error);
|
|
103
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
|
|
104
|
+
}
|
|
105
|
+
if (!isCloudRecord(body) || !Object.hasOwn(body, 'payload') || typeof body.acceptedAt !== 'number') {
|
|
106
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
|
|
107
|
+
}
|
|
108
|
+
return body.payload;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/cloud-sync.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdtempSync, readdirSync, readlinkSync, rmSync } from 'node:fs';
|
|
3
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdtempSync, readdirSync, readlinkSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { open, readFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { Readable } from 'node:stream';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { createGzip } from 'node:zlib';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
9
10
|
import {
|
|
10
11
|
CloudFlowError, cloudFetch, cloudRequest, cloudRunId, isCloudRecord, type CloudConnectionOptions,
|
|
11
12
|
} from './cloud-http.js';
|
|
@@ -25,6 +26,8 @@ import {
|
|
|
25
26
|
|
|
26
27
|
/** Uncompressed bytes. Matches the sandbox's extraction budget with headroom. */
|
|
27
28
|
export const MAX_SYNC_BYTES = 256 * 1024 * 1024;
|
|
29
|
+
/** Above this compressed size, use the unchanged legacy upload path. */
|
|
30
|
+
export const MAX_CACHED_CODE_BYTES = 64 * 1024 * 1024;
|
|
28
31
|
const ALWAYS_SKIPPED = new Set(['.git', 'node_modules']);
|
|
29
32
|
const CODE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
30
33
|
|
|
@@ -33,11 +36,14 @@ export interface PreparedCloudSync {
|
|
|
33
36
|
codeKey: string;
|
|
34
37
|
/** Run-scoped upload credential from `prepare`; falls back to the session token. */
|
|
35
38
|
uploadToken?: string;
|
|
39
|
+
/** Cloud confirmed the immutable archive already exists in this workspace. */
|
|
40
|
+
codeCacheHit?: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** `POST /api/v1/workflows/prepare`, refusing every non-Cloud-API storage backend. */
|
|
39
|
-
export async function prepareCloudSync(options: CloudConnectionOptions): Promise<PreparedCloudSync> {
|
|
40
|
-
const receipt = await cloudFetch('/api/v1/workflows/prepare', options,
|
|
44
|
+
export async function prepareCloudSync(options: CloudConnectionOptions, codeSha256?: string): Promise<PreparedCloudSync> {
|
|
45
|
+
const receipt = await cloudFetch('/api/v1/workflows/prepare', options,
|
|
46
|
+
{ method: 'POST', ...(codeSha256 ? { body: JSON.stringify({ codeSha256 }) } : {}) });
|
|
41
47
|
if (!isCloudRecord(receipt)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned a non-object.');
|
|
42
48
|
const storage = isCloudRecord(receipt.workflowStorage) ? receipt.workflowStorage : undefined;
|
|
43
49
|
const credentials = isCloudRecord(receipt.s3Credentials) ? receipt.s3Credentials : undefined;
|
|
@@ -49,6 +55,15 @@ export async function prepareCloudSync(options: CloudConnectionOptions): Promise
|
|
|
49
55
|
}
|
|
50
56
|
const codeKey = typeof receipt.s3CodeKey === 'string' ? receipt.s3CodeKey : '';
|
|
51
57
|
if (!CODE_KEY.test(codeKey)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable code key.');
|
|
58
|
+
const codeCache = isCloudRecord(receipt.codeCache) ? receipt.codeCache : undefined;
|
|
59
|
+
let codeCacheHit: boolean | undefined;
|
|
60
|
+
if (codeSha256 && (codeCache !== undefined || codeKey.startsWith('code-sha256-'))) {
|
|
61
|
+
if (!codeCache || codeCache.sha256 !== codeSha256 || typeof codeCache.hit !== 'boolean'
|
|
62
|
+
|| codeKey !== `code-sha256-${codeSha256}.tar.gz`) {
|
|
63
|
+
throw new CloudFlowError('invalid_response', 'Cloud prepare returned an inconsistent code cache receipt.');
|
|
64
|
+
}
|
|
65
|
+
codeCacheHit = codeCache.hit;
|
|
66
|
+
}
|
|
52
67
|
const uploadToken = credentials?.cloudApiAccessToken;
|
|
53
68
|
if (uploadToken !== undefined && typeof uploadToken !== 'string') {
|
|
54
69
|
throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable storage credential.');
|
|
@@ -56,9 +71,17 @@ export async function prepareCloudSync(options: CloudConnectionOptions): Promise
|
|
|
56
71
|
return {
|
|
57
72
|
runId: cloudRunId(receipt.runId), codeKey,
|
|
58
73
|
...(uploadToken === undefined ? {} : { uploadToken }),
|
|
74
|
+
...(codeCacheHit === undefined ? {} : { codeCacheHit }),
|
|
59
75
|
};
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
/** Hash the deterministic compressed archive without loading it into memory. */
|
|
79
|
+
export async function sha256PackedTree(packed: PackedTree): Promise<string> {
|
|
80
|
+
const hash = createHash('sha256');
|
|
81
|
+
for await (const chunk of createReadStream(packed.archivePath)) hash.update(chunk);
|
|
82
|
+
return hash.digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
export interface PackedTree {
|
|
63
86
|
/** The gzip'd ustar, spooled to a temporary file; `dispose()` removes it. */
|
|
64
87
|
archivePath: string;
|
|
@@ -125,6 +148,14 @@ export async function packWorkingTree(root: string): Promise<PackedTree> {
|
|
|
125
148
|
}
|
|
126
149
|
try {
|
|
127
150
|
await pipeline(Readable.from(flatten(entries())), createGzip({ level: 6 }), createWriteStream(archivePath));
|
|
151
|
+
// zlib's OS byte can vary by platform while the tar contents are equal.
|
|
152
|
+
// 255 means unknown OS and does not affect decompression.
|
|
153
|
+
const archive = await open(archivePath, 'r+');
|
|
154
|
+
try {
|
|
155
|
+
await archive.write(Buffer.from([255]), 0, 1, 9);
|
|
156
|
+
} finally {
|
|
157
|
+
await archive.close();
|
|
158
|
+
}
|
|
128
159
|
} catch (error) {
|
|
129
160
|
dispose();
|
|
130
161
|
throw error;
|
|
@@ -132,6 +163,10 @@ export async function packWorkingTree(root: string): Promise<PackedTree> {
|
|
|
132
163
|
return { archivePath, files: members, bytes, skippedLinks, dispose };
|
|
133
164
|
}
|
|
134
165
|
|
|
166
|
+
export function canUseCodeCache(packed: PackedTree): boolean {
|
|
167
|
+
return statSync(packed.archivePath).size <= MAX_CACHED_CODE_BYTES;
|
|
168
|
+
}
|
|
169
|
+
|
|
135
170
|
/** Yield file streams chunk by chunk so `Readable.from` never buffers a whole file. */
|
|
136
171
|
async function* flatten(source: AsyncGenerator<Buffer | NodeJS.ReadableStream>): AsyncGenerator<Buffer> {
|
|
137
172
|
for await (const part of source) {
|