@relayflows/sdk 2.0.42 → 2.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/base.d.ts +12 -1
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/base.js +11 -1
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +8 -4
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +15 -6
- package/dist/adapters/codex.js.map +1 -1
- package/dist/agent-resume.d.ts +8 -0
- package/dist/agent-resume.d.ts.map +1 -0
- package/dist/agent-resume.js +37 -0
- package/dist/agent-resume.js.map +1 -0
- package/dist/agent-transcript.d.ts.map +1 -1
- package/dist/agent-transcript.js +5 -3
- package/dist/agent-transcript.js.map +1 -1
- package/dist/authored-flow-error.d.ts +10 -2
- 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.map +1 -1
- package/dist/authored-flow-executor.js +16 -2
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +6 -0
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +18 -6
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.js +3 -2
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-human.d.ts +2 -0
- package/dist/authored-human.d.ts.map +1 -1
- package/dist/authored-human.js +4 -0
- package/dist/authored-human.js.map +1 -1
- package/dist/authored-memory.d.ts +47 -2
- package/dist/authored-memory.d.ts.map +1 -1
- package/dist/authored-memory.js +394 -15
- package/dist/authored-memory.js.map +1 -1
- package/dist/authored-node-entry.js +6 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +6 -3
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root-readback.d.ts +21 -0
- package/dist/authored-root-readback.d.ts.map +1 -0
- package/dist/authored-root-readback.js +90 -0
- package/dist/authored-root-readback.js.map +1 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +53 -74
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-output.d.ts +2 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +1 -0
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +26 -4
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-helper-body.d.ts +2 -2
- package/dist/cli/check-helper-body.d.ts.map +1 -1
- package/dist/cli/check-helper-body.js +7 -2
- package/dist/cli/check-helper-body.js.map +1 -1
- package/dist/cli/check-helper-surface.d.ts +8 -0
- package/dist/cli/check-helper-surface.d.ts.map +1 -0
- package/dist/cli/check-helper-surface.js +37 -0
- package/dist/cli/check-helper-surface.js.map +1 -0
- package/dist/cli/check-triggers.d.ts +4 -3
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +7 -3
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +7 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +35 -8
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-answer.d.ts +9 -0
- package/dist/cli/cloud-answer.d.ts.map +1 -0
- package/dist/cli/cloud-answer.js +39 -0
- package/dist/cli/cloud-answer.js.map +1 -0
- package/dist/cli/cloud-connect-cli.d.ts +2 -1
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -1
- package/dist/cli/cloud-connect-cli.js +10 -2
- package/dist/cli/cloud-connect-cli.js.map +1 -1
- package/dist/cli/cloud-human.d.ts +7 -0
- package/dist/cli/cloud-human.d.ts.map +1 -0
- package/dist/cli/cloud-human.js +22 -0
- package/dist/cli/cloud-human.js.map +1 -0
- package/dist/cli/cloud-live.d.ts +2 -2
- package/dist/cli/cloud-live.d.ts.map +1 -1
- package/dist/cli/cloud-live.js +9 -2
- package/dist/cli/cloud-live.js.map +1 -1
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
- package/dist/cli/cloud-mirror-session.js +3 -2
- package/dist/cli/cloud-mirror-session.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +1 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +12 -0
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/detached-record.d.ts +22 -0
- package/dist/cli/detached-record.d.ts.map +1 -0
- package/dist/cli/detached-record.js +57 -0
- package/dist/cli/detached-record.js.map +1 -0
- package/dist/cli/detached-run.d.ts +28 -0
- package/dist/cli/detached-run.d.ts.map +1 -0
- package/dist/cli/detached-run.js +122 -0
- package/dist/cli/detached-run.js.map +1 -0
- package/dist/cli/diagnostic-fold.d.ts +19 -0
- package/dist/cli/diagnostic-fold.d.ts.map +1 -0
- package/dist/cli/diagnostic-fold.js +19 -0
- package/dist/cli/diagnostic-fold.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +16 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/eval.d.ts +22 -0
- package/dist/cli/eval.d.ts.map +1 -0
- package/dist/cli/eval.js +131 -0
- package/dist/cli/eval.js.map +1 -0
- package/dist/cli/journal-timeout.d.ts +12 -0
- package/dist/cli/journal-timeout.d.ts.map +1 -0
- package/dist/cli/journal-timeout.js +42 -0
- package/dist/cli/journal-timeout.js.map +1 -0
- package/dist/cli/local-agent-remedy.d.ts.map +1 -1
- package/dist/cli/local-agent-remedy.js +5 -4
- package/dist/cli/local-agent-remedy.js.map +1 -1
- package/dist/cli/observer-session.d.ts +1 -0
- package/dist/cli/observer-session.d.ts.map +1 -1
- package/dist/cli/observer-session.js +8 -1
- package/dist/cli/observer-session.js.map +1 -1
- package/dist/cli/reuse.d.ts +8 -0
- package/dist/cli/reuse.d.ts.map +1 -1
- package/dist/cli/reuse.js +24 -0
- package/dist/cli/reuse.js.map +1 -1
- package/dist/cli/run.d.ts +8 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +72 -56
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/running-step.d.ts +9 -0
- package/dist/cli/running-step.d.ts.map +1 -0
- package/dist/cli/running-step.js +156 -0
- package/dist/cli/running-step.js.map +1 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +12 -8
- package/dist/cli/status.js.map +1 -1
- package/dist/cli-adapter.d.ts +3 -1
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +6 -2
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli-commands.d.ts +77 -11
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +41 -7
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-watch.d.ts +6 -2
- package/dist/cli-watch.d.ts.map +1 -1
- package/dist/cli-watch.js +7 -6
- package/dist/cli-watch.js.map +1 -1
- package/dist/cli.d.ts +22 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +154 -50
- package/dist/cli.js.map +1 -1
- package/dist/cloud-answer.d.ts +35 -0
- package/dist/cloud-answer.d.ts.map +1 -0
- package/dist/cloud-answer.js +194 -0
- package/dist/cloud-answer.js.map +1 -0
- package/dist/cloud-auth-store.d.ts +46 -0
- package/dist/cloud-auth-store.d.ts.map +1 -0
- package/dist/cloud-auth-store.js +171 -0
- package/dist/cloud-auth-store.js.map +1 -0
- package/dist/cloud-http.d.ts +7 -16
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +59 -40
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-human-wait.d.ts +10 -0
- package/dist/cloud-human-wait.d.ts.map +1 -0
- package/dist/cloud-human-wait.js +17 -0
- package/dist/cloud-human-wait.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -1
- package/dist/cloud-mirror-transport.js +1 -0
- package/dist/cloud-mirror-transport.js.map +1 -1
- package/dist/cloud-run-record.d.ts +7 -2
- package/dist/cloud-run-record.d.ts.map +1 -1
- package/dist/cloud-run-record.js +13 -5
- package/dist/cloud-run-record.js.map +1 -1
- package/dist/cloud-run.d.ts +9 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +36 -2
- package/dist/cloud-run.js.map +1 -1
- package/dist/communication/local.d.ts.map +1 -1
- package/dist/communication/local.js +6 -1
- package/dist/communication/local.js.map +1 -1
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +12 -2
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -3
- package/dist/compile.js.map +1 -1
- package/dist/daemon-spec-validation.d.ts +31 -0
- package/dist/daemon-spec-validation.d.ts.map +1 -0
- package/dist/daemon-spec-validation.js +115 -0
- package/dist/daemon-spec-validation.js.map +1 -0
- package/dist/failure-kinds.d.ts +6 -4
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +17 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-eval-cloud.d.ts +25 -0
- package/dist/flow-eval-cloud.d.ts.map +1 -0
- package/dist/flow-eval-cloud.js +108 -0
- package/dist/flow-eval-cloud.js.map +1 -0
- package/dist/flow-eval-error.d.ts +7 -0
- package/dist/flow-eval-error.d.ts.map +1 -0
- package/dist/flow-eval-error.js +10 -0
- package/dist/flow-eval-error.js.map +1 -0
- package/dist/flow-eval-gate.d.ts +15 -0
- package/dist/flow-eval-gate.d.ts.map +1 -0
- package/dist/flow-eval-gate.js +102 -0
- package/dist/flow-eval-gate.js.map +1 -0
- package/dist/flow-eval-local.d.ts +54 -0
- package/dist/flow-eval-local.d.ts.map +1 -0
- package/dist/flow-eval-local.js +220 -0
- package/dist/flow-eval-local.js.map +1 -0
- package/dist/flow-eval-outcome.d.ts +22 -0
- package/dist/flow-eval-outcome.d.ts.map +1 -0
- package/dist/flow-eval-outcome.js +37 -0
- package/dist/flow-eval-outcome.js.map +1 -0
- package/dist/flow-eval-report.d.ts +106 -0
- package/dist/flow-eval-report.d.ts.map +1 -0
- package/dist/flow-eval-report.js +4 -0
- package/dist/flow-eval-report.js.map +1 -0
- package/dist/flow-eval-suite.d.ts +46 -0
- package/dist/flow-eval-suite.d.ts.map +1 -0
- package/dist/flow-eval-suite.js +144 -0
- package/dist/flow-eval-suite.js.map +1 -0
- package/dist/flow-eval-version.d.ts +59 -0
- package/dist/flow-eval-version.d.ts.map +1 -0
- package/dist/flow-eval-version.js +303 -0
- package/dist/flow-eval-version.js.map +1 -0
- package/dist/flow-eval.d.ts +41 -0
- package/dist/flow-eval.d.ts.map +1 -0
- package/dist/flow-eval.js +201 -0
- package/dist/flow-eval.js.map +1 -0
- package/dist/helper-operation-use.d.ts +18 -0
- package/dist/helper-operation-use.d.ts.map +1 -0
- package/dist/helper-operation-use.js +336 -0
- package/dist/helper-operation-use.js.map +1 -0
- package/dist/helper-preflight.d.ts.map +1 -1
- package/dist/helper-preflight.js +16 -2
- package/dist/helper-preflight.js.map +1 -1
- package/dist/helper-reference.d.ts +33 -0
- package/dist/helper-reference.d.ts.map +1 -1
- package/dist/helper-reference.js +5 -5
- package/dist/helper-reference.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-budgeted-reads.d.ts +41 -0
- package/dist/journal-budgeted-reads.d.ts.map +1 -0
- package/dist/journal-budgeted-reads.js +142 -0
- package/dist/journal-budgeted-reads.js.map +1 -0
- package/dist/journal-client.d.ts +34 -29
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +45 -151
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-connection.d.ts +42 -0
- package/dist/journal-connection.d.ts.map +1 -0
- package/dist/journal-connection.js +209 -0
- package/dist/journal-connection.js.map +1 -0
- package/dist/journal-read-policy.d.ts +38 -0
- package/dist/journal-read-policy.d.ts.map +1 -0
- package/dist/journal-read-policy.js +126 -0
- package/dist/journal-read-policy.js.map +1 -0
- package/dist/named-gate-lowering.d.ts.map +1 -1
- package/dist/named-gate-lowering.js +2 -6
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/re2js-embedded.d.ts +3 -0
- package/dist/re2js-embedded.d.ts.map +1 -0
- package/dist/re2js-embedded.js +4 -0
- package/dist/re2js-embedded.js.map +1 -0
- package/dist/spec.d.ts +10 -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.js +1 -1
- package/dist/step-fields.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +15 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +3 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +15 -5
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-lease.d.ts.map +1 -1
- package/dist/worker-lease.js +11 -3
- package/dist/worker-lease.js.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +5 -3
- package/src/adapters/base.ts +16 -1
- package/src/adapters/claude.ts +8 -4
- package/src/adapters/codex.ts +15 -6
- package/src/agent-resume.ts +36 -0
- package/src/agent-transcript.ts +5 -3
- package/src/authored-flow-error.ts +9 -0
- package/src/authored-flow-executor.ts +18 -1
- package/src/authored-flow-loader.ts +27 -10
- package/src/authored-helper-effect.ts +3 -2
- package/src/authored-human.ts +5 -0
- package/src/authored-memory.ts +445 -15
- package/src/authored-node-entry.ts +6 -1
- package/src/authored-node-runner.ts +6 -3
- package/src/authored-root-readback.ts +100 -0
- package/src/authored-root.ts +58 -85
- package/src/authored-step-output.ts +3 -0
- package/src/authored-worker-step.ts +29 -4
- package/src/cli/check-helper-body.ts +9 -3
- package/src/cli/check-helper-surface.ts +43 -0
- package/src/cli/check-triggers.ts +9 -4
- package/src/cli/check.ts +37 -7
- package/src/cli/cloud-answer.ts +40 -0
- package/src/cli/cloud-connect-cli.ts +9 -2
- package/src/cli/cloud-human.ts +24 -0
- package/src/cli/cloud-live.ts +10 -5
- package/src/cli/cloud-mirror-session.ts +3 -2
- package/src/cli/cloud-run.ts +10 -1
- package/src/cli/detached-record.ts +71 -0
- package/src/cli/detached-run.ts +131 -0
- package/src/cli/diagnostic-fold.ts +28 -0
- package/src/cli/direct-run.ts +14 -1
- package/src/cli/eval.ts +141 -0
- package/src/cli/journal-timeout.ts +49 -0
- package/src/cli/local-agent-remedy.ts +6 -4
- package/src/cli/observer-session.ts +7 -1
- package/src/cli/reuse.ts +25 -0
- package/src/cli/run.ts +75 -67
- package/src/cli/running-step.ts +140 -0
- package/src/cli/status.ts +19 -13
- package/src/cli-adapter.ts +7 -1
- package/src/cli-commands.ts +43 -7
- package/src/cli-watch.ts +8 -5
- package/src/cli.ts +158 -51
- package/src/cloud-answer.ts +202 -0
- package/src/cloud-auth-store.ts +185 -0
- package/src/cloud-http.ts +61 -50
- package/src/cloud-human-wait.ts +23 -0
- package/src/cloud-mirror-transport.ts +1 -0
- package/src/cloud-run-record.ts +14 -5
- package/src/cloud-run.ts +46 -2
- package/src/communication/local.ts +6 -1
- package/src/communication/worker.ts +11 -3
- package/src/compile.ts +5 -3
- package/src/daemon-spec-validation.ts +126 -0
- package/src/failure-kinds.ts +17 -1
- package/src/flow-eval-cloud.ts +114 -0
- package/src/flow-eval-error.ts +10 -0
- package/src/flow-eval-gate.ts +114 -0
- package/src/flow-eval-local.ts +236 -0
- package/src/flow-eval-outcome.ts +43 -0
- package/src/flow-eval-report.ts +102 -0
- package/src/flow-eval-suite.ts +169 -0
- package/src/flow-eval-version.ts +316 -0
- package/src/flow-eval.ts +234 -0
- package/src/helper-operation-use.ts +285 -0
- package/src/helper-preflight.ts +17 -2
- package/src/helper-reference.ts +6 -6
- package/src/index.ts +15 -1
- package/src/journal-budgeted-reads.ts +134 -0
- package/src/journal-client.ts +59 -166
- package/src/journal-connection.ts +222 -0
- package/src/journal-read-policy.ts +123 -0
- package/src/named-gate-lowering.ts +2 -6
- package/src/re2js-embedded.ts +3 -0
- package/src/spec.ts +10 -0
- package/src/step-fields.ts +1 -1
- package/src/validate.ts +14 -0
- package/src/worker-cli.ts +14 -3
- package/src/worker-lease.ts +11 -3
- package/src/worker.ts +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
|
|
4
|
+
import { cloudRunState } from './cloud-run-record.js';
|
|
5
|
+
import { readCloudHumanWaitValue } from './cloud-human-wait.js';
|
|
6
|
+
import { readSurfaceAuthority, type SurfaceModuleAuthority } from './authored-flow-loader.js';
|
|
7
|
+
import type { CloudAuthoredAuthority } from './cloud-run.js';
|
|
8
|
+
|
|
9
|
+
export interface CloudAnswerOptions extends CloudConnectionOptions {
|
|
10
|
+
note?: string;
|
|
11
|
+
/** Exact original source, including a copy pulled with flows sync. Never executed locally. */
|
|
12
|
+
source?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CloudResumeReceipt {
|
|
16
|
+
runId: string;
|
|
17
|
+
resumedFrom: string;
|
|
18
|
+
waitId: string;
|
|
19
|
+
answer: boolean;
|
|
20
|
+
resumedByCloud: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Preserves partial success without claiming a failed/ambiguous resume did not start. */
|
|
24
|
+
export class CloudAnswerError extends Error {
|
|
25
|
+
constructor(readonly answerRecorded: boolean, readonly cause: unknown) {
|
|
26
|
+
super(answerRecorded
|
|
27
|
+
? 'The answer is recorded, but resume could not be confirmed. Inspect Cloud status before re-running the same answer command; admission may be unknown.'
|
|
28
|
+
: 'The answer request could not be confirmed. Inspect Cloud status before retrying.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const route = (runId: string): string => `/api/v1/workflows/runs/${runId}`;
|
|
33
|
+
const get = (path: string, options: CloudConnectionOptions): Promise<unknown> =>
|
|
34
|
+
cloudFetch(path, options, { method: 'GET', detail: true });
|
|
35
|
+
const post = (path: string, body: unknown, options: CloudConnectionOptions): Promise<unknown> =>
|
|
36
|
+
cloudFetch(path, options, { method: 'POST', body: JSON.stringify(body), detail: true });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Accepted answer route shapes are explicit: humanWait (or wait), or openWaits,
|
|
40
|
+
* and an optional recorded answer { waitId, answer, note? }. Unknown/ambiguous
|
|
41
|
+
* shapes refuse before writes. A closed wait is retryable only with that proof.
|
|
42
|
+
*/
|
|
43
|
+
export async function readCloudHumanWait(runId: string, options: CloudConnectionOptions = {}) {
|
|
44
|
+
cloudRunId(runId);
|
|
45
|
+
const body = await get(`${route(runId)}/answer`, options);
|
|
46
|
+
if (!isCloudRecord(body)) throw new CloudFlowError('invalid_response', 'Cloud returned no human answer record.');
|
|
47
|
+
if (body.runId !== undefined && body.runId !== runId) {
|
|
48
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned a human answer record for a different run.');
|
|
49
|
+
}
|
|
50
|
+
// An absent and a null field mean the same thing everywhere below.
|
|
51
|
+
const present = (key: string): boolean => body[key] !== undefined && body[key] !== null;
|
|
52
|
+
if (['humanWait', 'wait', 'openWaits'].filter(present).length > 1) {
|
|
53
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned competing open human wait fields.');
|
|
54
|
+
}
|
|
55
|
+
const candidates = present('openWaits') ? body.openWaits
|
|
56
|
+
: [body.humanWait ?? body.wait].filter(value => value !== undefined && value !== null);
|
|
57
|
+
if (!Array.isArray(candidates) || candidates.length > 1) {
|
|
58
|
+
throw new CloudFlowError('invalid_response', 'Cloud must report at most one open human wait.');
|
|
59
|
+
}
|
|
60
|
+
const wait = readCloudHumanWaitValue(candidates[0]);
|
|
61
|
+
if (candidates.length === 1 && !wait) throw new CloudFlowError('invalid_response', 'Cloud returned an invalid open human wait.');
|
|
62
|
+
const recorded = body.answer;
|
|
63
|
+
if (recorded !== undefined && recorded !== null
|
|
64
|
+
&& (!isCloudRecord(recorded) || !readCloudHumanWaitValue(recorded) || typeof recorded.answer !== 'boolean'
|
|
65
|
+
|| (recorded.note !== undefined && recorded.note !== null && typeof recorded.note !== 'string'))) {
|
|
66
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid recorded human answer.');
|
|
67
|
+
}
|
|
68
|
+
return { wait, recorded: isCloudRecord(recorded)
|
|
69
|
+
// Like the wait fields, a null note means absent.
|
|
70
|
+
? { waitId: recorded.waitId as string, answer: recorded.answer as boolean,
|
|
71
|
+
note: typeof recorded.note === 'string' ? recorded.note : undefined }
|
|
72
|
+
: undefined };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HEX64 = /^[a-f0-9]{64}$/u;
|
|
76
|
+
|
|
77
|
+
function isSurfaceAuthority(value: unknown): value is SurfaceModuleAuthority {
|
|
78
|
+
return isCloudRecord(value) && value.packageName === '@relayflows/surface'
|
|
79
|
+
&& typeof value.version === 'string' && value.version.length > 0
|
|
80
|
+
&& typeof value.packageSha256 === 'string' && HEX64.test(value.packageSha256)
|
|
81
|
+
&& typeof value.runtimeSha256 === 'string' && HEX64.test(value.runtimeSha256);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The admitted Surface identity. A complete stored identity is reused as-is;
|
|
86
|
+
* otherwise it is re-derived by hashing the Surface installed beside --source
|
|
87
|
+
* (never importing either), and must match the stored version.
|
|
88
|
+
*/
|
|
89
|
+
async function resumeSurface(stored: unknown, source?: string): Promise<SurfaceModuleAuthority> {
|
|
90
|
+
if (isSurfaceAuthority(stored)) {
|
|
91
|
+
const { packageName, version, packageSha256, runtimeSha256 } = stored;
|
|
92
|
+
return Object.freeze({ packageName, version, packageSha256, runtimeSha256 });
|
|
93
|
+
}
|
|
94
|
+
const version = isCloudRecord(stored) && typeof stored.version === 'string' && stored.version.length > 0
|
|
95
|
+
? stored.version : undefined;
|
|
96
|
+
if (version === undefined) {
|
|
97
|
+
throw new CloudFlowError('unsupported_source', 'Cloud run lacks its Surface version; cannot rebuild the submission authority.');
|
|
98
|
+
}
|
|
99
|
+
if (source === undefined) {
|
|
100
|
+
throw new CloudFlowError('unsupported_source', `Cloud stores only the Surface version (${version}); supply --source <path> where @relayflows/surface ${version} is installed.`);
|
|
101
|
+
}
|
|
102
|
+
let local: SurfaceModuleAuthority;
|
|
103
|
+
try { local = await readSurfaceAuthority(source); }
|
|
104
|
+
catch { throw new CloudFlowError('unsupported_source', `Cannot resolve @relayflows/surface from --source; install @relayflows/surface ${version} beside it.`); }
|
|
105
|
+
if (local.version !== version) {
|
|
106
|
+
throw new CloudFlowError('unsupported_source', `The run was admitted with @relayflows/surface ${version}, but --source resolves ${local.version}.`);
|
|
107
|
+
}
|
|
108
|
+
return local;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build from admitted bytes and authority, never re-import hosted code or resend inputs. */
|
|
112
|
+
async function resumeBody(record: Record<string, unknown>, runId: string, source?: string) {
|
|
113
|
+
// A resume without the original tree/plugins is a different execution. Until
|
|
114
|
+
// Cloud exposes a complete restorable contract, refuse their presence.
|
|
115
|
+
if (['s3CodeKey', 'codeKey', 'syncCode', 'synced'].some(key => record[key] !== undefined && record[key] !== null && record[key] !== false)) {
|
|
116
|
+
throw new CloudFlowError('unsupported_source', 'Cannot resume a run with synced code: Cloud must restore its original working tree.');
|
|
117
|
+
}
|
|
118
|
+
const authority = record.relayflowV2Authority;
|
|
119
|
+
if (!isCloudRecord(authority) || !isCloudRecord(authority.source)
|
|
120
|
+
|| typeof authority.source.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(authority.source.sha256)) {
|
|
121
|
+
throw new CloudFlowError('unsupported_source', 'Cloud run lacks source.sha256 authority; cannot verify --source bytes.');
|
|
122
|
+
}
|
|
123
|
+
for (const value of [record.extensions, authority.extensions]) {
|
|
124
|
+
if (value !== undefined && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
|
125
|
+
throw new CloudFlowError('unsupported_source', 'Cannot resume a run with extensions: Cloud must expose their original submissions.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let workflow = record.workflow;
|
|
129
|
+
if (source !== undefined) {
|
|
130
|
+
try { workflow = await readFile(source, 'utf8'); }
|
|
131
|
+
catch { throw new CloudFlowError('invalid_input', 'Cannot read --source; supply the original flow file.'); }
|
|
132
|
+
}
|
|
133
|
+
if (typeof workflow !== 'string' || createHash('sha256').update(workflow).digest('hex') !== authority.source.sha256) {
|
|
134
|
+
throw new CloudFlowError('unsupported_source', `Supply --source <path> with the original flow bytes (sha256 ${authority.source.sha256}); stored source is missing, truncated or different.`);
|
|
135
|
+
}
|
|
136
|
+
if (record.workspaceId !== undefined && record.workspaceId !== null && typeof record.workspaceId !== 'string') {
|
|
137
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid workspaceId.');
|
|
138
|
+
}
|
|
139
|
+
// The run route takes the submission contract, not the stored attestation.
|
|
140
|
+
const authoredAuthority: CloudAuthoredAuthority = Object.freeze({
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
sourceSha256: authority.source.sha256,
|
|
143
|
+
byteLength: Buffer.byteLength(workflow, 'utf8'),
|
|
144
|
+
surface: await resumeSurface(authority.source.surface, source),
|
|
145
|
+
});
|
|
146
|
+
return { workflow, authoredAuthority, fileType: 'ts', relayflowVersion: 'v2', resume: runId,
|
|
147
|
+
...(typeof record.workspaceId === 'string' ? { workspaceId: record.workspaceId } : {}) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Record a decision and resume only if Cloud still reports the original park. No POST retries. */
|
|
151
|
+
export async function answerCloudFlow(runId: string, answer: boolean, options: CloudAnswerOptions = {}): Promise<CloudResumeReceipt> {
|
|
152
|
+
cloudRunId(runId);
|
|
153
|
+
const record = await get(route(runId), options);
|
|
154
|
+
const state = cloudRunState(record, runId);
|
|
155
|
+
if (state.status !== 'needs_human') throw new CloudFlowError('invalid_input', 'Run is not parked on needs_human; no answer or resume was submitted.');
|
|
156
|
+
const { wait, recorded: lastRecorded } = await readCloudHumanWait(runId, options);
|
|
157
|
+
const waitId = wait?.waitId ?? lastRecorded?.waitId;
|
|
158
|
+
// A decision recorded for an earlier gate says nothing about the open one.
|
|
159
|
+
const recorded = lastRecorded?.waitId === waitId ? lastRecorded : undefined;
|
|
160
|
+
if (!waitId || (state.humanWait && state.humanWait.waitId !== waitId)) {
|
|
161
|
+
throw new CloudFlowError('invalid_response', 'Cloud answer record does not identify the run’s parked wait.');
|
|
162
|
+
}
|
|
163
|
+
// A recorded answer that omits its optional note does not contradict --note.
|
|
164
|
+
if (recorded && (recorded.answer !== answer
|
|
165
|
+
|| (recorded.note !== undefined && recorded.note !== options.note))) {
|
|
166
|
+
throw new CloudFlowError('invalid_input', 'The recorded answer differs; a closed human wait cannot be changed.');
|
|
167
|
+
}
|
|
168
|
+
const body = await resumeBody(record as Record<string, unknown>, runId, options.source);
|
|
169
|
+
let answerRecorded = recorded !== undefined;
|
|
170
|
+
try {
|
|
171
|
+
if (!answerRecorded) {
|
|
172
|
+
const accepted = await post(`${route(runId)}/answer`, { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) }, options);
|
|
173
|
+
// Only an explicit acknowledgement counts; {} or { ok: null } is not a recorded decision.
|
|
174
|
+
if (!isCloudRecord(accepted) || accepted.ok !== true || accepted.error !== undefined) {
|
|
175
|
+
throw new CloudFlowError('invalid_response', 'Cloud did not confirm the human answer.');
|
|
176
|
+
}
|
|
177
|
+
answerRecorded = true;
|
|
178
|
+
}
|
|
179
|
+
const current = cloudRunState(await get(route(runId), options), runId);
|
|
180
|
+
if (current.status !== 'needs_human') {
|
|
181
|
+
// The contract is that execution continued, not that it succeeded:
|
|
182
|
+
// failed (step_failed/budget_exceeded) ran steps after the park. Only a
|
|
183
|
+
// cancellation leaves the park without any execution.
|
|
184
|
+
if (current.status === 'cancelled') {
|
|
185
|
+
throw new CloudFlowError('invalid_response', 'Run was cancelled after the answer; it was not resumed.');
|
|
186
|
+
}
|
|
187
|
+
return { runId, resumedFrom: runId, waitId, answer, resumedByCloud: true };
|
|
188
|
+
}
|
|
189
|
+
// Parked on a later gate: Cloud already resumed past this one. Never
|
|
190
|
+
// resume (or retry this decision) against the new wait.
|
|
191
|
+
if (current.humanWait && current.humanWait.waitId !== waitId) {
|
|
192
|
+
return { runId, resumedFrom: runId, waitId, answer, resumedByCloud: true };
|
|
193
|
+
}
|
|
194
|
+
const receipt = await post('/api/v1/workflows/run', body, options);
|
|
195
|
+
if (!isCloudRecord(receipt) || !['pending', 'running'].includes(String(receipt.status))) {
|
|
196
|
+
throw new CloudFlowError('invalid_response', 'Cloud did not return an accepted resume.');
|
|
197
|
+
}
|
|
198
|
+
return { runId: cloudRunId(receipt.runId), resumedFrom: runId, waitId, answer, resumedByCloud: false };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new CloudAnswerError(answerRecorded, error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
|
|
8
|
+
// @agent-relay/cloud@12.4.1: types.js AUTH_FILE_PATH / DEFAULT_REFRESH_TIMEOUT_MS;
|
|
9
|
+
// auth.js writeStoredAuth, AUTH_LOCK_*, requestStoredAuthRefresh. Keep together
|
|
10
|
+
// for contract updates. flows additionally honours AGENT_RELAY_HOME on writes.
|
|
11
|
+
const AUTH_DIRECTORY = '.agentworkforce/relay';
|
|
12
|
+
const AUTH_FILE = 'cloud-auth.json';
|
|
13
|
+
const DIRECTORY_MODE = 0o700;
|
|
14
|
+
const FILE_MODE = 0o600;
|
|
15
|
+
const LOCK_RETRY_MS = 50;
|
|
16
|
+
// Interop constant: changing this can steal a live relay lock or delay recovery.
|
|
17
|
+
const LOCK_STALE_MS = 30_000;
|
|
18
|
+
const REFRESH_TIMEOUT_MS = 10_000;
|
|
19
|
+
const REFRESH_ROUTE = '/api/v1/auth/token/refresh';
|
|
20
|
+
// Deliberately shorter than relay's 30s acquire timeout for interactive reads.
|
|
21
|
+
const LOCK_ACQUIRE_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
export interface AgentRelayCloudLogin extends Record<string, unknown> {
|
|
24
|
+
apiUrl: string;
|
|
25
|
+
accessToken: string;
|
|
26
|
+
accessTokenExpiresAt?: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
refreshTokenExpiresAt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function agentRelayCloudAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
return join(env['AGENT_RELAY_HOME'] ?? join(homedir(), AUTH_DIRECTORY), AUTH_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Explicit credentials bypass this store; expired fallback logins may be refreshed. */
|
|
40
|
+
export function readAgentRelayCloudLogin(
|
|
41
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
42
|
+
read: (path: string) => string = path => readFileSync(path, 'utf8'),
|
|
43
|
+
): AgentRelayCloudLogin | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const parsed: unknown = JSON.parse(read(agentRelayCloudAuthPath(env)));
|
|
46
|
+
if (!record(parsed) || typeof parsed.apiUrl !== 'string' || typeof parsed.accessToken !== 'string'
|
|
47
|
+
|| !parsed.accessToken.trim()) return undefined;
|
|
48
|
+
const login = { ...parsed } as AgentRelayCloudLogin;
|
|
49
|
+
for (const key of ['accessTokenExpiresAt', 'refreshToken', 'refreshTokenExpiresAt'] as const) {
|
|
50
|
+
if (typeof parsed[key] !== 'string') delete login[key];
|
|
51
|
+
}
|
|
52
|
+
return login;
|
|
53
|
+
} catch { return undefined; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function expiredCloudLogin(login: AgentRelayCloudLogin, now = Date.now()): boolean {
|
|
57
|
+
const expiry = Date.parse(login.accessTokenExpiresAt ?? '');
|
|
58
|
+
return Number.isFinite(expiry) && expiry <= now;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function canRefreshCloudLogin(login: AgentRelayCloudLogin, now = Date.now()): boolean {
|
|
62
|
+
return typeof login.refreshToken === 'string' && !!login.refreshToken.trim()
|
|
63
|
+
&& !(Date.parse(login.refreshTokenExpiresAt ?? '') <= now);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Shared transport policy; undefined lets the synchronous resolver keep its refusals. */
|
|
67
|
+
export function normalizeCloudBaseUrl(raw: string): string | undefined {
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(raw);
|
|
70
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|
|
71
|
+
|| !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname)) return undefined;
|
|
72
|
+
return `${url.origin}${url.pathname.replace(/\/+$/u, '')}`;
|
|
73
|
+
} catch { return undefined; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type CloudLoginRefresh =
|
|
77
|
+
| { kind: 'refreshed' | 'current' }
|
|
78
|
+
| { kind: 'refused'; status?: number }
|
|
79
|
+
| { kind: 'blocked' }
|
|
80
|
+
| { kind: 'transport'; error: unknown }
|
|
81
|
+
| { kind: 'unwritable'; error: unknown; path: string };
|
|
82
|
+
|
|
83
|
+
interface RefreshOptions {
|
|
84
|
+
signal?: AbortSignal;
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
env?: NodeJS.ProcessEnv;
|
|
87
|
+
now?: () => number;
|
|
88
|
+
fetch?: typeof globalThis.fetch;
|
|
89
|
+
refreshTimeoutMs?: number;
|
|
90
|
+
lock?: { retryMs?: number; staleMs?: number; acquireTimeoutMs?: number };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeLogin(path: string, login: AgentRelayCloudLogin): Promise<void> {
|
|
94
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
|
|
95
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
|
|
96
|
+
try {
|
|
97
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(login, null, 2)}\n`, { encoding: 'utf8', mode: FILE_MODE });
|
|
98
|
+
await fs.chmod(temporaryPath, FILE_MODE);
|
|
99
|
+
await fs.rename(temporaryPath, path);
|
|
100
|
+
} finally {
|
|
101
|
+
await fs.rm(temporaryPath, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function requestRefresh(login: AgentRelayCloudLogin, baseUrl: string, options: RefreshOptions): Promise<{ kind: 'ready'; login: AgentRelayCloudLogin } | CloudLoginRefresh> {
|
|
106
|
+
const deadline = AbortSignal.timeout(Math.min(options.timeoutMs ?? REFRESH_TIMEOUT_MS,
|
|
107
|
+
options.refreshTimeoutMs ?? REFRESH_TIMEOUT_MS));
|
|
108
|
+
try {
|
|
109
|
+
const response = await (options.fetch ?? globalThis.fetch)(`${baseUrl}${REFRESH_ROUTE}`, {
|
|
110
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ refreshToken: login.refreshToken }), redirect: 'error',
|
|
112
|
+
signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) return { kind: 'refused', status: response.status };
|
|
115
|
+
const payload: unknown = await response.json();
|
|
116
|
+
if (!record(payload) || typeof payload.accessToken !== 'string' || !payload.accessToken.trim()
|
|
117
|
+
|| /[\r\n]/u.test(payload.accessToken) || /^(?:rk|ot)_live_/u.test(payload.accessToken.trim())
|
|
118
|
+
|| typeof payload.refreshToken !== 'string' || !payload.refreshToken.trim()
|
|
119
|
+
|| typeof payload.accessTokenExpiresAt !== 'string'
|
|
120
|
+
|| !(Date.parse(payload.accessTokenExpiresAt) > (options.now ?? Date.now)())) return { kind: 'refused' };
|
|
121
|
+
const apiUrl = typeof payload.apiUrl === 'string' && payload.apiUrl.trim() ? payload.apiUrl.trim() : login.apiUrl;
|
|
122
|
+
const refreshTokenExpiresAt = typeof payload.refreshTokenExpiresAt === 'string' && payload.refreshTokenExpiresAt.trim()
|
|
123
|
+
? payload.refreshTokenExpiresAt.trim() : login.refreshTokenExpiresAt;
|
|
124
|
+
if (normalizeCloudBaseUrl(apiUrl) !== baseUrl
|
|
125
|
+
|| (refreshTokenExpiresAt !== undefined && !Number.isFinite(Date.parse(refreshTokenExpiresAt)))) return { kind: 'refused' };
|
|
126
|
+
return { kind: 'ready', login: { ...login, apiUrl, accessToken: payload.accessToken, refreshToken: payload.refreshToken,
|
|
127
|
+
accessTokenExpiresAt: payload.accessTokenExpiresAt, refreshTokenExpiresAt } };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
options.signal?.throwIfAborted();
|
|
130
|
+
if (error instanceof SyntaxError && !deadline.aborted) return { kind: 'refused' };
|
|
131
|
+
return { kind: 'transport', error: deadline.aborted ? deadline.reason : error };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Serialize rotations with relay and re-source credentials after acquiring its lock. */
|
|
136
|
+
export async function refreshCloudLogin(
|
|
137
|
+
login: AgentRelayCloudLogin, baseUrl: string, options: RefreshOptions = {},
|
|
138
|
+
): Promise<CloudLoginRefresh> {
|
|
139
|
+
const path = agentRelayCloudAuthPath(options.env);
|
|
140
|
+
const lockPath = `${path}.lock`;
|
|
141
|
+
const now = options.now ?? Date.now;
|
|
142
|
+
const started = now();
|
|
143
|
+
const waitMs = Math.min(options.timeoutMs ?? LOCK_ACQUIRE_MS, options.lock?.acquireTimeoutMs ?? LOCK_ACQUIRE_MS);
|
|
144
|
+
let acquired = false;
|
|
145
|
+
try {
|
|
146
|
+
options.signal?.throwIfAborted();
|
|
147
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
|
|
148
|
+
while (!acquired) {
|
|
149
|
+
options.signal?.throwIfAborted();
|
|
150
|
+
try {
|
|
151
|
+
await fs.mkdir(lockPath, { mode: DIRECTORY_MODE });
|
|
152
|
+
acquired = true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!record(error) || error.code !== 'EEXIST') throw error;
|
|
155
|
+
try {
|
|
156
|
+
if (now() - (await fs.stat(lockPath)).mtimeMs > (options.lock?.staleMs ?? LOCK_STALE_MS)) {
|
|
157
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!record(error) || error.code !== 'ENOENT') throw error;
|
|
162
|
+
}
|
|
163
|
+
const remaining = waitMs - (now() - started);
|
|
164
|
+
if (remaining <= 0) return { kind: 'blocked' };
|
|
165
|
+
await delay(Math.min(options.lock?.retryMs ?? LOCK_RETRY_MS, remaining), undefined, { signal: options.signal });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const latest = readAgentRelayCloudLogin(options.env);
|
|
169
|
+
// A replaced/deleted login belongs to the other process; never overwrite it.
|
|
170
|
+
if (!latest || latest.apiUrl !== login.apiUrl || !expiredCloudLogin(latest, now())) return { kind: 'current' };
|
|
171
|
+
if (!canRefreshCloudLogin(latest, now())) return { kind: 'current' };
|
|
172
|
+
const result = await requestRefresh(latest, baseUrl, options);
|
|
173
|
+
if (result.kind !== 'ready') return result;
|
|
174
|
+
await writeLogin(path, result.login);
|
|
175
|
+
return { kind: 'refreshed' };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
options.signal?.throwIfAborted();
|
|
178
|
+
return { kind: 'unwritable', error, path };
|
|
179
|
+
} finally {
|
|
180
|
+
// Never let cleanup replace the outcome: a rotation already persisted must
|
|
181
|
+
// be used, and a cancellation must propagate. A lock left behind ages past
|
|
182
|
+
// LOCK_STALE_MS and is reclaimed by the next caller, as relay does.
|
|
183
|
+
if (acquired) await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/cloud-http.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readAgentRelayCloudLogin, expiredCloudLogin, canRefreshCloudLogin, normalizeCloudBaseUrl, refreshCloudLogin } from './cloud-auth-store.js';
|
|
2
|
+
export { agentRelayCloudAuthPath, readAgentRelayCloudLogin, type AgentRelayCloudLogin } from './cloud-auth-store.js';
|
|
4
3
|
|
|
5
4
|
export interface CloudConnectionOptions {
|
|
6
5
|
/** Cloud application base URL; defaults to https://agentrelay.com/cloud. */
|
|
@@ -33,6 +32,8 @@ export interface CloudRefusal {
|
|
|
33
32
|
export type CloudConfigurationReason =
|
|
34
33
|
| 'auth_missing'
|
|
35
34
|
| 'auth_expired'
|
|
35
|
+
| 'auth_store_unwritable'
|
|
36
|
+
| 'auth_refresh_failed'
|
|
36
37
|
| 'url_invalid'
|
|
37
38
|
| 'url_mismatch'
|
|
38
39
|
| 'timeout_invalid';
|
|
@@ -61,50 +62,13 @@ function configurationError(reason: CloudConfigurationReason, message: string):
|
|
|
61
62
|
return error;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
/**
|
|
65
|
-
* The `agent-relay cloud login` credential store. Read only when neither the
|
|
66
|
-
* `token` option nor `FLOWS_CLOUD_TOKEN` is set, so an explicit credential
|
|
67
|
-
* always wins and this file can change shape without breaking a configured
|
|
68
|
-
* caller. Its `apiUrl` becomes the default base URL for the same reason: a
|
|
69
|
-
* login against one deployment must not send its token to another.
|
|
70
|
-
*/
|
|
71
|
-
export function agentRelayCloudAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
72
|
-
return join(env['AGENT_RELAY_HOME'] ?? join(homedir(), '.agentworkforce/relay'), 'cloud-auth.json');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface AgentRelayCloudLogin {
|
|
76
|
-
apiUrl: string;
|
|
77
|
-
accessToken: string;
|
|
78
|
-
/** ISO-8601; the store carries it, so an expired login refuses with a real reason. */
|
|
79
|
-
accessTokenExpiresAt?: string;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function readAgentRelayCloudLogin(
|
|
83
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
84
|
-
read: (path: string) => string = path => readFileSync(path, 'utf8'),
|
|
85
|
-
): AgentRelayCloudLogin | undefined {
|
|
86
|
-
let parsed: unknown;
|
|
87
|
-
try {
|
|
88
|
-
parsed = JSON.parse(read(agentRelayCloudAuthPath(env)));
|
|
89
|
-
} catch {
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
if (!isCloudRecord(parsed) || typeof parsed.apiUrl !== 'string' || typeof parsed.accessToken !== 'string'
|
|
93
|
-
|| !parsed.accessToken.trim()) return undefined;
|
|
94
|
-
return {
|
|
95
|
-
apiUrl: parsed.apiUrl, accessToken: parsed.accessToken,
|
|
96
|
-
...(typeof parsed.accessTokenExpiresAt === 'string' ? { accessTokenExpiresAt: parsed.accessTokenExpiresAt } : {}),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
65
|
export function cloudConnection(options: CloudConnectionOptions): { baseUrl: string; token: string } {
|
|
101
|
-
let rawToken = options
|
|
66
|
+
let rawToken = explicitCloudToken(options);
|
|
102
67
|
let loginApiUrl: string | undefined;
|
|
103
68
|
if (rawToken === undefined) {
|
|
104
69
|
const login = readAgentRelayCloudLogin();
|
|
105
70
|
if (login !== undefined) {
|
|
106
|
-
|
|
107
|
-
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
71
|
+
if (expiredCloudLogin(login)) {
|
|
108
72
|
throw configurationError('auth_expired',
|
|
109
73
|
'The agent-relay cloud login has expired. Run `agent-relay cloud login` again, or set FLOWS_CLOUD_TOKEN.');
|
|
110
74
|
}
|
|
@@ -124,11 +88,10 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
|
|
|
124
88
|
} catch {
|
|
125
89
|
throw configurationError('url_invalid', 'FLOWS_CLOUD_URL must be an absolute Cloud application base URL.');
|
|
126
90
|
}
|
|
127
|
-
|
|
128
|
-
|
|
91
|
+
const baseUrl = normalizeCloudBaseUrl(url.href);
|
|
92
|
+
if (baseUrl === undefined) {
|
|
129
93
|
throw configurationError('url_invalid', 'Cloud URL must use HTTPS and a plain base path.');
|
|
130
94
|
}
|
|
131
|
-
const baseUrl = `${url.origin}${url.pathname.replace(/\/+$/u, '')}`;
|
|
132
95
|
// A login-store token is bound to the deployment that issued it. An explicit
|
|
133
96
|
// URL that names another deployment gets no token at all — set
|
|
134
97
|
// FLOWS_CLOUD_TOKEN for that deployment instead.
|
|
@@ -147,6 +110,57 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
|
|
|
147
110
|
return { baseUrl, token };
|
|
148
111
|
}
|
|
149
112
|
|
|
113
|
+
function explicitCloudToken(options: CloudConnectionOptions): string | undefined {
|
|
114
|
+
return options.token ?? process.env['FLOWS_CLOUD_TOKEN'];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requestTimeout(options: CloudConnectionOptions): number {
|
|
118
|
+
const timeout = options.requestTimeoutMs ?? 30_000;
|
|
119
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) {
|
|
120
|
+
throw configurationError('timeout_invalid', 'requestTimeoutMs must be a positive 32-bit integer.');
|
|
121
|
+
}
|
|
122
|
+
return timeout;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function ensureFreshCloudLogin(options: CloudConnectionOptions): Promise<void> {
|
|
126
|
+
if (explicitCloudToken(options) !== undefined) return;
|
|
127
|
+
const login = readAgentRelayCloudLogin();
|
|
128
|
+
if (!login || !expiredCloudLogin(login) || !canRefreshCloudLogin(login)) return;
|
|
129
|
+
const baseUrl = normalizeCloudBaseUrl(options.apiUrl ?? process.env['FLOWS_CLOUD_URL'] ?? login.apiUrl);
|
|
130
|
+
if (baseUrl === undefined || baseUrl !== normalizeCloudBaseUrl(login.apiUrl)) return;
|
|
131
|
+
const result = await refreshCloudLogin(login, baseUrl, { signal: options.signal, timeoutMs: requestTimeout(options) });
|
|
132
|
+
switch (result.kind) {
|
|
133
|
+
case 'current': case 'refreshed': return;
|
|
134
|
+
case 'blocked': throw new CloudFlowError('transient_error', 'Another process is refreshing the agent-relay cloud login; try again.');
|
|
135
|
+
case 'transport': throw transportError(result.error);
|
|
136
|
+
case 'unwritable': {
|
|
137
|
+
const errno = isCloudRecord(result.error) && typeof result.error.code === 'string' ? result.error.code : 'unknown error';
|
|
138
|
+
throw configurationError('auth_store_unwritable', `Cannot update the agent-relay cloud login at ${result.path} (${errno}). Check filesystem permissions and available space.`);
|
|
139
|
+
}
|
|
140
|
+
case 'refused':
|
|
141
|
+
if (result.status !== undefined && [400, 401, 403].includes(result.status)) {
|
|
142
|
+
throw configurationError('auth_expired', 'The agent-relay cloud login has expired. Run `agent-relay cloud login` again, or set FLOWS_CLOUD_TOKEN.');
|
|
143
|
+
}
|
|
144
|
+
// Only statuses a poll may retry keep http_error: read CLIs map any other
|
|
145
|
+
// http_error status (a 404 above all) to a fact about the run, not the login.
|
|
146
|
+
if (result.status !== undefined && [408, 429, 500, 502, 503, 504].includes(result.status)) {
|
|
147
|
+
throw new CloudFlowError('http_error', `Cloud login refresh failed with HTTP ${result.status}.`, result.status);
|
|
148
|
+
}
|
|
149
|
+
if (result.status !== undefined) {
|
|
150
|
+
throw configurationError('auth_refresh_failed', `Cloud login refresh failed with HTTP ${result.status}. `
|
|
151
|
+
+ 'Run `agent-relay cloud login` again, or set FLOWS_CLOUD_TOKEN.');
|
|
152
|
+
}
|
|
153
|
+
throw new CloudFlowError('invalid_response', 'Cloud returned an unusable login refresh response.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Re-read after refresh; no cache that could hide a relay-side rotation. */
|
|
158
|
+
export async function resolveCloudConnection(options: CloudConnectionOptions): Promise<{ baseUrl: string; token: string }> {
|
|
159
|
+
requestTimeout(options);
|
|
160
|
+
await ensureFreshCloudLogin(options);
|
|
161
|
+
return cloudConnection(options);
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
export async function cloudRequest(
|
|
151
165
|
path: string,
|
|
152
166
|
options: CloudConnectionOptions,
|
|
@@ -182,11 +196,8 @@ export async function cloudFetch(
|
|
|
182
196
|
options: CloudConnectionOptions,
|
|
183
197
|
init: CloudFetchInit,
|
|
184
198
|
): Promise<unknown> {
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) {
|
|
188
|
-
throw configurationError('timeout_invalid', 'requestTimeoutMs must be a positive 32-bit integer.');
|
|
189
|
-
}
|
|
199
|
+
const timeout = requestTimeout(options);
|
|
200
|
+
const { baseUrl, token } = await resolveCloudConnection(options);
|
|
190
201
|
const bearer = init.bearerToken ?? token;
|
|
191
202
|
if (/[\r\n]/u.test(bearer) || !bearer.trim()) {
|
|
192
203
|
throw new CloudFlowError('invalid_response', 'Cloud issued an unusable storage credential.');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { HUMAN_WAIT_ID } from './authored-human.js';
|
|
2
|
+
import { isCloudRecord } from './cloud-http.js';
|
|
3
|
+
import { parseHumanTo, type HumanRecipient } from './human-to.js';
|
|
4
|
+
|
|
5
|
+
export interface CloudHumanWait {
|
|
6
|
+
waitId: string;
|
|
7
|
+
question?: string;
|
|
8
|
+
to?: string;
|
|
9
|
+
recipient?: HumanRecipient;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Missing display fields do not invalidate an attested park. */
|
|
13
|
+
export function readCloudHumanWaitValue(value: unknown): CloudHumanWait | undefined {
|
|
14
|
+
if (!isCloudRecord(value) || typeof value.waitId !== 'string' || !HUMAN_WAIT_ID.test(value.waitId)) return undefined;
|
|
15
|
+
const to = typeof value.to === 'string' ? value.to : undefined;
|
|
16
|
+
const parsed = to === undefined ? undefined : parseHumanTo(to);
|
|
17
|
+
return {
|
|
18
|
+
waitId: value.waitId,
|
|
19
|
+
...(typeof value.question === 'string' ? { question: value.question } : {}),
|
|
20
|
+
...(to === undefined ? {} : { to }),
|
|
21
|
+
...(parsed?.ok ? { recipient: parsed.recipient } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -113,6 +113,7 @@ export async function registerLocalRun(
|
|
|
113
113
|
|| typeof callbackToken !== 'string' || callbackToken.length === 0) {
|
|
114
114
|
throw new CloudFlowError('invalid_response', 'Cloud registered the run without a usable credential.');
|
|
115
115
|
}
|
|
116
|
+
// Registration above already refreshed the store through cloudFetch.
|
|
116
117
|
const { baseUrl } = cloudConnection(options);
|
|
117
118
|
const runUrl = typeof result['runUrl'] === 'string' && /^https:\/\//u.test(result['runUrl'])
|
|
118
119
|
? result['runUrl']
|
package/src/cloud-run-record.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What a Cloud run record has to say before this client will act on it.
|
|
2
2
|
//
|
|
3
|
-
// One validator
|
|
3
|
+
// One validator for hosted observation and answer/resume. `waitForCloudFlowRun` (cloud-run.ts) blocks on
|
|
4
4
|
// it to decide when a hosted `flows run --wait` is over, and the live read
|
|
5
5
|
// verbs (cli/cloud-live.ts, through `getCloudRunDetailLive`) block on it to
|
|
6
6
|
// decide when `--watch` and `--follow` stop and with which exit code. Both
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
// `completed` header with no valid `completionReason` attests nothing, and
|
|
14
14
|
// must not become exit 0.
|
|
15
15
|
|
|
16
|
+
import { readCloudHumanWaitValue, type CloudHumanWait } from './cloud-human-wait.js';
|
|
16
17
|
import { RUN_COMPLETION_REASONS } from '@relayflows/surface';
|
|
17
18
|
import { CloudFlowError, isCloudRecord } from './cloud-http.js';
|
|
18
19
|
import type { RunCompletionReason } from './protocol.js';
|
|
19
20
|
|
|
20
21
|
export type CloudRunState =
|
|
22
|
+
| { runId: string; status: 'needs_human'; completionReason: 'needs_human'; humanWait?: CloudHumanWait }
|
|
21
23
|
| { runId: string; status: 'pending' | 'launching' | 'running' }
|
|
22
24
|
| { runId: string; status: 'completed' | 'failed' | 'cancelled'; completionReason: RunCompletionReason };
|
|
23
25
|
|
|
@@ -29,8 +31,7 @@ const ACTIVE_RUN_STATUSES: readonly string[] = ['pending', 'launching', 'running
|
|
|
29
31
|
*
|
|
30
32
|
* The one vocabulary for the question, so `run --cloud --wait`,
|
|
31
33
|
* `status --cloud --watch` and `logs --follow` cannot disagree about what
|
|
32
|
-
* `launching` means.
|
|
33
|
-
* not a run status and never reaches here.
|
|
34
|
+
* `launching` means. A normalized `needs_human` run is terminal for now.
|
|
34
35
|
*/
|
|
35
36
|
export function isCloudRunActive(status: string): boolean {
|
|
36
37
|
return ACTIVE_RUN_STATUSES.includes(status);
|
|
@@ -49,11 +50,19 @@ export function cloudRunState(body: unknown, runId: string): CloudRunState {
|
|
|
49
50
|
|| !['pending', 'launching', 'running', 'completed', 'failed', 'cancelled'].includes(body.status)) {
|
|
50
51
|
throw new CloudFlowError('invalid_response', 'Cloud returned an invalid v2 run record.');
|
|
51
52
|
}
|
|
53
|
+
const report = body.result;
|
|
54
|
+
const reason = isCloudRecord(report) ? report.completionReason : undefined;
|
|
55
|
+
if (reason === 'needs_human') {
|
|
56
|
+
if (body.status !== 'failed') {
|
|
57
|
+
throw new CloudFlowError('invalid_response', `Cloud reports needs_human under status ${body.status}; expected failed.`);
|
|
58
|
+
}
|
|
59
|
+
const humanWait = readCloudHumanWaitValue(isCloudRecord(report) ? report.humanWait : undefined)
|
|
60
|
+
?? readCloudHumanWaitValue(body.humanWait);
|
|
61
|
+
return { runId, status: 'needs_human', completionReason: 'needs_human', ...(humanWait ? { humanWait } : {}) };
|
|
62
|
+
}
|
|
52
63
|
if (isCloudRunActive(body.status)) {
|
|
53
64
|
return { runId, status: body.status as 'pending' | 'launching' | 'running' };
|
|
54
65
|
}
|
|
55
|
-
const report = body.result;
|
|
56
|
-
const reason = isCloudRecord(report) ? report.completionReason : undefined;
|
|
57
66
|
if (typeof reason !== 'string' || !(RUN_COMPLETION_REASONS as readonly string[]).includes(reason)
|
|
58
67
|
|| (body.status === 'completed' && (reason !== 'success' || !isCloudRecord(report) || report.ok !== true || report.status !== 'completed'))
|
|
59
68
|
|| (body.status === 'failed' && !['step_failed', 'budget_exceeded'].includes(reason))
|