@relayflows/sdk 2.0.17 → 2.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authored-flow-error.d.ts +21 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +14 -0
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +47 -3
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-human.d.ts +49 -0
- package/dist/authored-human.d.ts.map +1 -0
- package/dist/authored-human.js +97 -0
- package/dist/authored-human.js.map +1 -0
- package/dist/authored-node-entry.js +3 -2
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +15 -4
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +29 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/cli/answer.d.ts +17 -0
- package/dist/cli/answer.d.ts.map +1 -0
- package/dist/cli/answer.js +89 -0
- package/dist/cli/answer.js.map +1 -0
- package/dist/cli/build.js +1 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +2 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +11 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +14 -2
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-connect-cli.d.ts +54 -0
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
- package/dist/cli/cloud-connect-cli.js +109 -0
- package/dist/cli/cloud-connect-cli.js.map +1 -0
- package/dist/cli/cloud-deploy.d.ts +3 -1
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +44 -6
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +2 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +17 -3
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +3 -1
- package/dist/cli/cloud-schedule.d.ts.map +1 -1
- package/dist/cli/cloud-schedule.js +31 -6
- package/dist/cli/cloud-schedule.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +5 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +22 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +34 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +114 -9
- package/dist/cli.js.map +1 -1
- package/dist/cloud-connect.d.ts +52 -0
- package/dist/cloud-connect.d.ts.map +1 -0
- package/dist/cloud-connect.js +138 -0
- package/dist/cloud-connect.js.map +1 -0
- package/dist/cloud-deploy.d.ts +19 -1
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +40 -1
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-http.d.ts +2 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-schedule.d.ts +7 -0
- package/dist/cloud-schedule.d.ts.map +1 -1
- package/dist/cloud-schedule.js +22 -1
- package/dist/cloud-schedule.js.map +1 -1
- package/dist/failure-kinds.d.ts +1 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +2 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-requirements.d.ts +68 -0
- package/dist/flow-requirements.d.ts.map +1 -0
- package/dist/flow-requirements.js +152 -0
- package/dist/flow-requirements.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +11 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +13 -0
- package/dist/journal-client.js.map +1 -1
- package/dist/progress.d.ts +2 -1
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +9 -3
- package/dist/progress.js.map +1 -1
- package/dist/protocol.d.ts +25 -1
- package/dist/protocol.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/authored-flow-error.ts +29 -0
- package/src/authored-flow-executor.ts +54 -2
- package/src/authored-human.ts +150 -0
- package/src/authored-node-entry.ts +3 -2
- package/src/authored-node-runner.ts +20 -5
- package/src/authored-root.ts +28 -1
- package/src/cli/answer.ts +98 -0
- package/src/cli/build.ts +1 -1
- package/src/cli/check-triggers.ts +2 -0
- package/src/cli/check.ts +24 -2
- package/src/cli/cloud-connect-cli.ts +130 -0
- package/src/cli/cloud-deploy.ts +40 -6
- package/src/cli/cloud-run.ts +16 -4
- package/src/cli/cloud-schedule.ts +31 -6
- package/src/cli/direct-run.ts +5 -0
- package/src/cli/run.ts +48 -2
- package/src/cli.ts +103 -10
- package/src/cloud-connect.ts +187 -0
- package/src/cloud-deploy.ts +57 -2
- package/src/cloud-http.ts +1 -1
- package/src/cloud-schedule.ts +20 -3
- package/src/failure-kinds.ts +2 -0
- package/src/flow-requirements.ts +201 -0
- package/src/index.ts +8 -0
- package/src/journal-client.ts +20 -0
- package/src/progress.ts +11 -4
- package/src/protocol.ts +23 -0
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { addPlugin } from './cli/add.js';
|
|
3
3
|
import { watchCheck } from './cli-watch.js';
|
|
4
4
|
import { checkHelperBody } from './cli/check-helper-body.js';
|
|
5
|
+
import { describeFlowRequirements } from './flow-requirements.js';
|
|
5
6
|
|
|
6
7
|
import { renderProgress, type ProgressEvent } from './progress.js';
|
|
7
8
|
import { realpathSync } from 'node:fs';
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
type RunProgress,
|
|
19
20
|
type RunReport,
|
|
20
21
|
} from './cli/run.js';
|
|
22
|
+
import { answerFlow } from './cli/answer.js';
|
|
21
23
|
import { checkAuthoredTriggers } from './cli/check-triggers.js';
|
|
22
24
|
import { parseWebhookArgs, runServeWebhook } from './cli/serve-webhook.js';
|
|
23
25
|
import { runDirectFlow } from './cli/direct-run.js';
|
|
@@ -54,7 +56,7 @@ type ParsedArgs =
|
|
|
54
56
|
| BuildArgs
|
|
55
57
|
| DeployArgs
|
|
56
58
|
| { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
|
|
57
|
-
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean }
|
|
59
|
+
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
|
|
58
60
|
| { command: 'sync'; runId: string; json: boolean; root: string }
|
|
59
61
|
| CloudDeployArgs
|
|
60
62
|
| { command: 'deployments'; json: boolean }
|
|
@@ -65,6 +67,7 @@ type ParsedArgs =
|
|
|
65
67
|
| { command: 'check'; json: boolean; watch: boolean; value: string }
|
|
66
68
|
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
67
69
|
| { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
70
|
+
| { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
|
|
68
71
|
| { command: 'observer'; dataDir: string }
|
|
69
72
|
| { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
|
|
70
73
|
| { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
|
|
@@ -76,10 +79,10 @@ const USAGE = [
|
|
|
76
79
|
'flows add <helper-name|@flows/helper-name>',
|
|
77
80
|
'flows build [--out <dir>] <flow.yaml|flow.ts>',
|
|
78
81
|
'flows build --verify <bundle-dir>',
|
|
79
|
-
'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--json]',
|
|
82
|
+
'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--no-connect] [--json]',
|
|
80
83
|
'flows deployments [--json]',
|
|
81
84
|
'flows undeploy [--json] <deployment-id>',
|
|
82
|
-
'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--json]',
|
|
85
|
+
'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--no-connect] [--json]',
|
|
83
86
|
'flows schedules [--json]',
|
|
84
87
|
'flows unschedule [--json] <schedule-id>',
|
|
85
88
|
'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
|
|
@@ -87,12 +90,13 @@ const USAGE = [
|
|
|
87
90
|
'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
|
|
88
91
|
'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
|
|
89
92
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
|
|
90
|
-
'flows run --cloud [--json] [--wait] [--sync-code] <flow.yaml|spec.json>',
|
|
91
|
-
'flows run --cloud [--json] [--wait] [--sync-code] <flow.ts> --input <inline-json-or-file>',
|
|
93
|
+
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
|
|
94
|
+
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
|
|
92
95
|
'flows sync [--json] [--dir <path>] <run-id>',
|
|
93
96
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
|
|
94
97
|
'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>',
|
|
95
98
|
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
|
|
99
|
+
'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
|
|
96
100
|
'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
|
|
97
101
|
'flows observer [--data-dir <dir>]',
|
|
98
102
|
'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
|
|
@@ -142,6 +146,15 @@ export async function runCli(
|
|
|
142
146
|
if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
|
|
143
147
|
if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
|
|
144
148
|
if (parsed.command === 'replay') return replayJournal(parsed, io);
|
|
149
|
+
if (parsed.command === 'answer') {
|
|
150
|
+
const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
|
|
151
|
+
...(parsed.note === undefined ? {} : { note: parsed.note }),
|
|
152
|
+
...(parsed.by === undefined ? {} : { answeredBy: parsed.by }),
|
|
153
|
+
daemon: { spawn: parsed.spawn && spawnAllowedByEnv() },
|
|
154
|
+
});
|
|
155
|
+
emitRunReport(execution, parsed.json, io);
|
|
156
|
+
return execution.exitCode;
|
|
157
|
+
}
|
|
145
158
|
if (parsed.command === 'build') return runBuild(parsed, io);
|
|
146
159
|
if (parsed.command === 'deploy') return runDeploy(parsed, io);
|
|
147
160
|
|
|
@@ -277,6 +290,9 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
|
|
|
277
290
|
report: {
|
|
278
291
|
...mcp.report,
|
|
279
292
|
...(triggers?.report.schedules === undefined ? {} : { schedules: triggers.report.schedules }),
|
|
293
|
+
// The authored definition sees helper flags, body use and `cli:`
|
|
294
|
+
// declarations; the compiled view underneath knows only its steps.
|
|
295
|
+
...(triggers?.report.requirements === undefined ? {} : { requirements: triggers.report.requirements }),
|
|
280
296
|
diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics],
|
|
281
297
|
ok: helper.report.ok && mcp.report.ok && triggerOk,
|
|
282
298
|
},
|
|
@@ -479,6 +495,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
479
495
|
if (command === 'tick') return parseTickArgs(args.slice(1));
|
|
480
496
|
if (command === 'observer') return parseObserverArgs(args.slice(1));
|
|
481
497
|
if (command === 'sync') return parseSyncArgs(args.slice(1));
|
|
498
|
+
if (command === 'answer') return parseAnswerArgs(args.slice(1));
|
|
482
499
|
if (command !== 'check' && command !== 'run' && command !== 'resume') return undefined;
|
|
483
500
|
|
|
484
501
|
let json = false;
|
|
@@ -486,6 +503,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
486
503
|
let cloud = false;
|
|
487
504
|
let wait = false;
|
|
488
505
|
let syncCode = false;
|
|
506
|
+
let noConnect = false;
|
|
489
507
|
let localAgent = false;
|
|
490
508
|
let allowHumanInfluenced = false;
|
|
491
509
|
let dataDir = DEFAULT_DATA_DIR;
|
|
@@ -499,11 +517,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
499
517
|
const positionals: string[] = [];
|
|
500
518
|
for (let index = 1; index < args.length; index += 1) {
|
|
501
519
|
const argument = args[index]!;
|
|
502
|
-
if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code') {
|
|
503
|
-
if (command !== 'run'
|
|
520
|
+
if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code' || argument === '--no-connect') {
|
|
521
|
+
if (command !== 'run') return undefined;
|
|
522
|
+
if (argument === '--cloud' ? cloud : argument === '--wait' ? wait : argument === '--sync-code' ? syncCode : noConnect) return undefined;
|
|
504
523
|
if (argument === '--cloud') cloud = true;
|
|
505
524
|
else if (argument === '--wait') wait = true;
|
|
506
|
-
else syncCode = true;
|
|
525
|
+
else if (argument === '--sync-code') syncCode = true;
|
|
526
|
+
else noConnect = true;
|
|
507
527
|
continue;
|
|
508
528
|
}
|
|
509
529
|
if (argument === '--allow-human-influenced') {
|
|
@@ -583,9 +603,9 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
583
603
|
// the source, so it is accepted exactly where a local run accepts it.
|
|
584
604
|
if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
|
|
585
605
|
if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
586
|
-
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode };
|
|
606
|
+
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
|
|
587
607
|
}
|
|
588
|
-
if (wait || syncCode) return undefined;
|
|
608
|
+
if (wait || syncCode || noConnect) return undefined;
|
|
589
609
|
if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
590
610
|
|
|
591
611
|
if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
|
|
@@ -596,6 +616,66 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
596
616
|
: { command, localAgent, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
|
|
597
617
|
}
|
|
598
618
|
|
|
619
|
+
/**
|
|
620
|
+
* `flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>`.
|
|
621
|
+
* The answer is a literal `yes`/`no` (also `true`/`false`) so a shell cannot
|
|
622
|
+
* hand the kernel an ambiguous word as a decision. `--by` records who answered
|
|
623
|
+
* when the invoker is relaying a person's decision (Cloud's answer route runs
|
|
624
|
+
* this inside the resumed sandbox with the caller's identity); it defaults to
|
|
625
|
+
* the OS user.
|
|
626
|
+
*/
|
|
627
|
+
function parseAnswerArgs(rest: readonly string[]): ParsedArgs | undefined {
|
|
628
|
+
let json = false;
|
|
629
|
+
let spawn = true;
|
|
630
|
+
let dataDir = DEFAULT_DATA_DIR;
|
|
631
|
+
let sawDataDir = false;
|
|
632
|
+
let note: string | undefined;
|
|
633
|
+
let by: string | undefined;
|
|
634
|
+
const positionals: string[] = [];
|
|
635
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
636
|
+
const argument = rest[index]!;
|
|
637
|
+
if (argument === '--json') {
|
|
638
|
+
if (json) return undefined;
|
|
639
|
+
json = true;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (argument === '--no-spawn') {
|
|
643
|
+
if (!spawn) return undefined;
|
|
644
|
+
spawn = false;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (argument === '--by') {
|
|
648
|
+
const value = rest[index + 1];
|
|
649
|
+
if (by !== undefined || value === undefined || value.startsWith('-') || value.trim() === '') return undefined;
|
|
650
|
+
by = value;
|
|
651
|
+
index += 1;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (argument === '--data-dir') {
|
|
655
|
+
const value = rest[index + 1];
|
|
656
|
+
if (sawDataDir || value === undefined || value.startsWith('-')) return undefined;
|
|
657
|
+
dataDir = value;
|
|
658
|
+
sawDataDir = true;
|
|
659
|
+
index += 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (argument === '--note') {
|
|
663
|
+
const value = rest[index + 1];
|
|
664
|
+
if (note !== undefined || value === undefined || value.startsWith('--')) return undefined;
|
|
665
|
+
note = value;
|
|
666
|
+
index += 1;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (argument.startsWith('-')) return undefined;
|
|
670
|
+
positionals.push(argument);
|
|
671
|
+
}
|
|
672
|
+
if (positionals.length !== 3) return undefined;
|
|
673
|
+
const [runId, waitId, word] = positionals as [string, string, string];
|
|
674
|
+
const answer = word === 'yes' || word === 'true' ? true : word === 'no' || word === 'false' ? false : undefined;
|
|
675
|
+
if (answer === undefined) return undefined;
|
|
676
|
+
return { command: 'answer', dataDir, json, spawn, note, by, runId, waitId, answer };
|
|
677
|
+
}
|
|
678
|
+
|
|
599
679
|
function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
|
|
600
680
|
const sub = rest[0];
|
|
601
681
|
if (sub !== 'start') return undefined;
|
|
@@ -790,6 +870,10 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
|
|
|
790
870
|
const model = resolution.model === undefined ? '' : ` model "${resolution.model}"`;
|
|
791
871
|
io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}"${model} from ${resolution.source}${config}`);
|
|
792
872
|
}
|
|
873
|
+
// What the workspace must have connected before this flow can run there;
|
|
874
|
+
// the hosted verbs check the same list against Cloud before submitting.
|
|
875
|
+
const requires = report.requirements === undefined ? '' : describeFlowRequirements(report.requirements);
|
|
876
|
+
if (requires) io.stdout(`REQUIRES ${requires}`);
|
|
793
877
|
if (report.ok) io.stdout(`CHECK PASSED ${report.path ?? ''}`.trimEnd());
|
|
794
878
|
}
|
|
795
879
|
|
|
@@ -810,6 +894,15 @@ function emitRunReport(
|
|
|
810
894
|
return;
|
|
811
895
|
}
|
|
812
896
|
if (report.runId === undefined) return;
|
|
897
|
+
if (report.answer !== undefined) {
|
|
898
|
+
io.stdout(`ANSWERED ${report.runId} ${report.answer.waitId} ${report.answer.answer ? 'yes' : 'no'}`
|
|
899
|
+
+ (report.answer.note === undefined ? '' : ` (${report.answer.note})`));
|
|
900
|
+
if (report.next !== undefined) io.stdout(`Continue with: ${report.next}`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// A refused answer changed nothing about the run, so there is no run
|
|
904
|
+
// outcome to summarize; the refusal above is the whole report.
|
|
905
|
+
if (report.command === 'answer') return;
|
|
813
906
|
const completed = report.completedSteps === undefined ? '' : ` (${report.completedSteps} ${report.completedSteps === 1 ? 'step' : 'steps'})`;
|
|
814
907
|
const reason = report.completionReason === undefined
|
|
815
908
|
? ''
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
|
|
3
|
+
import type { FlowIntegrationRequirement, FlowRequirements } from './flow-requirements.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration readiness before a hosted submission — the CLI form of the
|
|
7
|
+
* deploy wizard's "Connect integrations" step, and the same relayfile
|
|
8
|
+
* connect-session flow `agentworkforce deploy` runs for a proactive agent:
|
|
9
|
+
*
|
|
10
|
+
* GET /api/v1/workspaces/<id>/integrations/<provider>/status?scope=workspace
|
|
11
|
+
* POST /api/v1/workspaces/<id>/integrations/connect-session
|
|
12
|
+
* { allowedIntegrations: [<provider>], scope: { kind: "workspace" } }
|
|
13
|
+
* → { connectLink } (older: sessionUrl / url), opened in the browser,
|
|
14
|
+
* then the status is polled until it reports ready.
|
|
15
|
+
*
|
|
16
|
+
* Only `workspace` scope is used: a flow deployment runs as the workspace,
|
|
17
|
+
* which is the scope `checkFlowConnections` reads on the server. Nothing here
|
|
18
|
+
* stores a credential — Cloud's connect page does — and a refusal names the
|
|
19
|
+
* provider and the two ways to connect it (this prompt, or the dashboard).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface ConnectPrompt {
|
|
23
|
+
/** Asked once per missing provider before a browser opens; `false` refuses the submission. */
|
|
24
|
+
confirm(question: string): Promise<boolean>;
|
|
25
|
+
info(line: string): void;
|
|
26
|
+
/** Defaults to the platform opener (`open`, `xdg-open`, `start`); a failure only means the URL was printed. */
|
|
27
|
+
openUrl?(url: string): Promise<void> | void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnsureConnectionsOptions extends CloudConnectionOptions {
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
/** Omitted (`--no-connect`, or no TTY) means a missing integration is a refusal, never a prompt. */
|
|
33
|
+
prompt?: ConnectPrompt;
|
|
34
|
+
/** Status poll cadence while the browser connect is pending. Default 2 seconds. */
|
|
35
|
+
pollIntervalMs?: number;
|
|
36
|
+
/** How long one connect may stay pending. Default 5 minutes. */
|
|
37
|
+
connectTimeoutMs?: number;
|
|
38
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConnectionsOutcome {
|
|
42
|
+
/** Providers the workspace already had. */
|
|
43
|
+
ready: string[];
|
|
44
|
+
/** Providers connected through this prompt, in order. */
|
|
45
|
+
connected: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const WORKSPACE_ID = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
49
|
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
50
|
+
|
|
51
|
+
/** Whether the workspace has a ready connection for one provider. */
|
|
52
|
+
export async function integrationConnected(
|
|
53
|
+
workspaceId: string, provider: string, options: CloudConnectionOptions,
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
assertIds(workspaceId, provider);
|
|
56
|
+
const base = `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/integrations`;
|
|
57
|
+
try {
|
|
58
|
+
const status = await cloudFetch(`${base}/${encodeURIComponent(provider)}/status?scope=workspace`, options,
|
|
59
|
+
{ method: 'GET', detail: true });
|
|
60
|
+
return isReady(status);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof CloudFlowError) || error.code !== 'http_error') throw error;
|
|
63
|
+
if (error.refusal?.code === 'unknown_provider') {
|
|
64
|
+
throw new CloudFlowError('integration_not_connected',
|
|
65
|
+
`"${provider}" is not an integration this Cloud can connect; the flow declares it, so rename the declaration to a provider Cloud lists.`,
|
|
66
|
+
error.status, error.refusal);
|
|
67
|
+
}
|
|
68
|
+
// An older Cloud without the per-provider status route: the list still says which are connected.
|
|
69
|
+
if (error.status !== 404 && error.status !== 405) throw error;
|
|
70
|
+
const list = await cloudRequest(base, options);
|
|
71
|
+
const rows = Array.isArray(list) ? list : isCloudRecord(list) && Array.isArray(list.integrations) ? list.integrations : [];
|
|
72
|
+
return rows.some(row => isCloudRecord(row) && row.provider === provider && isReady(row));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every integration the requirements name is ready when this resolves; each
|
|
78
|
+
* missing one was connected through the prompt, or the whole submission is
|
|
79
|
+
* refused with `integration_not_connected` naming the first missing provider.
|
|
80
|
+
*/
|
|
81
|
+
export async function ensureIntegrationsConnected(
|
|
82
|
+
requirements: Pick<FlowRequirements, 'integrations'>, options: EnsureConnectionsOptions,
|
|
83
|
+
): Promise<ConnectionsOutcome> {
|
|
84
|
+
const outcome: ConnectionsOutcome = { ready: [], connected: [] };
|
|
85
|
+
for (const integration of requirements.integrations) {
|
|
86
|
+
options.signal?.throwIfAborted();
|
|
87
|
+
if (await integrationConnected(options.workspaceId, integration.provider, options)) {
|
|
88
|
+
outcome.ready.push(integration.provider);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (options.prompt === undefined) throw notConnected(integration, options);
|
|
92
|
+
const label = providerLabel(integration.provider);
|
|
93
|
+
options.prompt.info(`This flow needs ${label} (${integration.detail}), which is not connected to this workspace.`);
|
|
94
|
+
if (!await options.prompt.confirm(`Connect ${label} now? (opens browser) [Y/n] `)) {
|
|
95
|
+
throw notConnected(integration, options);
|
|
96
|
+
}
|
|
97
|
+
await connectIntegration(integration.provider, options, options.prompt);
|
|
98
|
+
outcome.connected.push(integration.provider);
|
|
99
|
+
}
|
|
100
|
+
return outcome;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function connectIntegration(provider: string, options: EnsureConnectionsOptions, prompt: ConnectPrompt): Promise<void> {
|
|
104
|
+
const base = `/api/v1/workspaces/${encodeURIComponent(options.workspaceId)}/integrations`;
|
|
105
|
+
const session = await cloudFetch(`${base}/connect-session`, options, {
|
|
106
|
+
method: 'POST', detail: true,
|
|
107
|
+
body: JSON.stringify({ allowedIntegrations: [provider], scope: { kind: 'workspace' } }),
|
|
108
|
+
});
|
|
109
|
+
const url = isCloudRecord(session)
|
|
110
|
+
? [session.connectLink, session.sessionUrl, session.url].find((value): value is string => typeof value === 'string' && value.length > 0)
|
|
111
|
+
: undefined;
|
|
112
|
+
if (url === undefined || !/^https:\/\//u.test(url)) {
|
|
113
|
+
throw new CloudFlowError('invalid_response', 'Cloud did not return a connect link.');
|
|
114
|
+
}
|
|
115
|
+
prompt.info(`Opening ${url}`);
|
|
116
|
+
prompt.info(`If the browser did not open, visit that link, then return here; waiting for ${providerLabel(provider)} to connect…`);
|
|
117
|
+
try {
|
|
118
|
+
await (prompt.openUrl ?? openInBrowser)(url);
|
|
119
|
+
} catch {
|
|
120
|
+
// The link was printed; the poll below is what decides.
|
|
121
|
+
}
|
|
122
|
+
const interval = options.pollIntervalMs ?? 2_000;
|
|
123
|
+
const deadline = Date.now() + (options.connectTimeoutMs ?? 300_000);
|
|
124
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
await sleep(interval, options.signal);
|
|
127
|
+
options.signal?.throwIfAborted();
|
|
128
|
+
if (await integrationConnected(options.workspaceId, provider, options)) {
|
|
129
|
+
prompt.info(`${providerLabel(provider)} connected.`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new CloudFlowError('integration_not_connected',
|
|
134
|
+
`${providerLabel(provider)} was not connected before the wait ran out. Finish at ${url}, then run the command again.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function notConnected(integration: FlowIntegrationRequirement, options: EnsureConnectionsOptions): CloudFlowError {
|
|
138
|
+
const label = providerLabel(integration.provider);
|
|
139
|
+
return new CloudFlowError('integration_not_connected',
|
|
140
|
+
`${label} is not connected to this workspace, and this flow needs it (${integration.detail}). `
|
|
141
|
+
+ `Connect it from the dashboard, or run again without --no-connect in a terminal to connect it now `
|
|
142
|
+
+ `(a browser opens a ${label} connect page for workspace ${options.workspaceId}).`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isReady(status: unknown): boolean {
|
|
146
|
+
if (!isCloudRecord(status)) return false;
|
|
147
|
+
return status.ready === true || status.state === 'ready' || status.status === 'ready' || status.status === 'connected';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertIds(workspaceId: string, provider: string): void {
|
|
151
|
+
if (!WORKSPACE_ID.test(workspaceId)) throw new CloudFlowError('invalid_response', 'Cloud reported an unusable workspace id.');
|
|
152
|
+
if (!PROVIDER_ID.test(provider)) throw new CloudFlowError('invalid_input', `"${provider}" is not an integration provider id.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `google-mail` → `Google Mail`, `github` → `GitHub`; presentation only. */
|
|
156
|
+
export function providerLabel(provider: string): string {
|
|
157
|
+
const known: Record<string, string> = { github: 'GitHub', gitlab: 'GitLab', hubspot: 'HubSpot', clickup: 'ClickUp' };
|
|
158
|
+
return known[provider] ?? provider.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
if (signal?.aborted) { reject(signal.reason); return; }
|
|
164
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
165
|
+
function onAbort(): void { clearTimeout(timer); reject(signal!.reason); }
|
|
166
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Detached so a browser that outlives the CLI never holds its stdio; every
|
|
172
|
+
* failure is ignored. `FLOWS_NO_BROWSER=1` (an SSH session, CI) leaves the
|
|
173
|
+
* printed link as the only opener.
|
|
174
|
+
*/
|
|
175
|
+
function openInBrowser(url: string): void {
|
|
176
|
+
if (process.env['FLOWS_NO_BROWSER'] === '1') return;
|
|
177
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
178
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
179
|
+
: ['xdg-open', [url]];
|
|
180
|
+
try {
|
|
181
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
182
|
+
child.on('error', () => {});
|
|
183
|
+
child.unref();
|
|
184
|
+
} catch {
|
|
185
|
+
// Printed URL is the fallback.
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/cloud-deploy.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { randomBytes, createHash } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
3
4
|
import { loadAuthoredFlow } from './authored-flow-loader.js';
|
|
5
|
+
import { ensureIntegrationsConnected, type ConnectPrompt } from './cloud-connect.js';
|
|
4
6
|
import {
|
|
5
7
|
CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
|
|
6
8
|
} from './cloud-http.js';
|
|
9
|
+
import { flowRequirements, type FlowRequirements } from './flow-requirements.js';
|
|
10
|
+
import { readProjectConfig } from './cli/check.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Hosted listener deployment: the CLI form of the agentrelay.com onboarding's
|
|
@@ -47,10 +51,22 @@ export interface DeployToCloudInput {
|
|
|
47
51
|
approver: string;
|
|
48
52
|
/** Defaults to the flow's declared name. */
|
|
49
53
|
name?: string;
|
|
50
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Coding-agent harnesses the flow uses; Cloud checks their credentials are
|
|
56
|
+
* connected. Defaults to the `cli:` declarations the source carries
|
|
57
|
+
* (`flowRequirements`), else `["claude"]`.
|
|
58
|
+
*/
|
|
51
59
|
agents?: FlowAgentHarness[];
|
|
52
60
|
/** Save without activating: no connection checks, no listener until activated. */
|
|
53
61
|
draft?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* How a missing integration is connected before activation. Omitted, a
|
|
64
|
+
* missing one refuses with `integration_not_connected` (the `--no-connect`
|
|
65
|
+
* and non-interactive behaviour); given, each is offered a browser connect.
|
|
66
|
+
*/
|
|
67
|
+
connect?: ConnectPrompt;
|
|
68
|
+
/** Skip the pre-submission integration check entirely (Cloud still checks on activation). */
|
|
69
|
+
checkConnections?: boolean;
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
export const FLOW_AGENT_HARNESSES = ['claude', 'codex'] as const;
|
|
@@ -72,6 +88,10 @@ export interface CloudDeployment {
|
|
|
72
88
|
repository: { owner: string; name: string };
|
|
73
89
|
sources: FlowTriggerSource[];
|
|
74
90
|
sourceSha256: string;
|
|
91
|
+
/** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
|
|
92
|
+
requirements: FlowRequirements;
|
|
93
|
+
/** Integrations connected through the prompt during this deploy. */
|
|
94
|
+
connected: string[];
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export function parseRepository(value: string): { owner: string; name: string } {
|
|
@@ -154,6 +174,12 @@ export async function deployToCloud(
|
|
|
154
174
|
throw new CloudFlowError('unsupported_source',
|
|
155
175
|
'Cloud deploys one self-contained .flow.ts source without use dependencies.');
|
|
156
176
|
}
|
|
177
|
+
let projectCli: string | undefined;
|
|
178
|
+
try {
|
|
179
|
+
projectCli = readProjectConfig(dirname(resolve(input.path))).cli;
|
|
180
|
+
} catch {
|
|
181
|
+
projectCli = undefined;
|
|
182
|
+
}
|
|
157
183
|
if (input.sources.length === 0 || input.sources.length > 10) {
|
|
158
184
|
throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.');
|
|
159
185
|
}
|
|
@@ -176,7 +202,30 @@ export async function deployToCloud(
|
|
|
176
202
|
if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
|
|
177
203
|
throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
|
|
178
204
|
}
|
|
179
|
-
|
|
205
|
+
// Every launched run lands in the deployment's repository, so GitHub is
|
|
206
|
+
// required even when no GitHub source wakes it.
|
|
207
|
+
const requirements = flowRequirements(definition, {
|
|
208
|
+
sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
|
|
209
|
+
});
|
|
210
|
+
// The declared harnesses become `inputs.agents`; one Cloud cannot run is
|
|
211
|
+
// refused here rather than silently replaced by Claude, which activation
|
|
212
|
+
// would then check while the deployed runs still call the declared CLI.
|
|
213
|
+
const unsupported = requirements.harnesses.filter(harness => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(harness));
|
|
214
|
+
if (input.agents === undefined && unsupported.length > 0) {
|
|
215
|
+
const uses = requirements.harnessUses.filter(use => unsupported.includes(use.harness));
|
|
216
|
+
throw new CloudFlowError('unsupported_source',
|
|
217
|
+
`This flow declares ${uses.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; `
|
|
218
|
+
+ `Cloud runs ${FLOW_AGENT_HARNESSES.join(' and ')}. Change the declaration, or pass --agents to deploy it anyway.`);
|
|
219
|
+
}
|
|
220
|
+
const declaredAgents = requirements.harnesses.filter((harness): harness is FlowAgentHarness => !unsupported.includes(harness));
|
|
221
|
+
const agents = input.agents ?? (declaredAgents.length > 0 ? declaredAgents : ['claude']);
|
|
222
|
+
// A draft activates nothing, so Cloud checks nothing; match it here.
|
|
223
|
+
const connected = input.draft || input.checkConnections === false
|
|
224
|
+
? []
|
|
225
|
+
: (await ensureIntegrationsConnected(requirements, {
|
|
226
|
+
...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }),
|
|
227
|
+
})).connected;
|
|
228
|
+
options.signal?.throwIfAborted();
|
|
180
229
|
const result = await cloudFetch('/api/v1/flows/deploy', options, { method: 'POST', detail: true, body: JSON.stringify({
|
|
181
230
|
workspaceId: workspace.id,
|
|
182
231
|
mode: input.draft ? 'draft' : 'activate',
|
|
@@ -188,6 +237,11 @@ export async function deployToCloud(
|
|
|
188
237
|
inputs: { approver, agents },
|
|
189
238
|
repository: input.repository,
|
|
190
239
|
sources,
|
|
240
|
+
requirements: {
|
|
241
|
+
integrations: requirements.integrations.map(i => i.provider),
|
|
242
|
+
harnesses: requirements.harnesses,
|
|
243
|
+
mcp: requirements.mcp,
|
|
244
|
+
},
|
|
191
245
|
}) });
|
|
192
246
|
if (!isCloudRecord(result) || typeof result.agentId !== 'string' || typeof result.status !== 'string') {
|
|
193
247
|
throw new CloudFlowError('invalid_response', 'Cloud did not return a deployment.');
|
|
@@ -196,6 +250,7 @@ export async function deployToCloud(
|
|
|
196
250
|
agentId: result.agentId, name, status: result.status,
|
|
197
251
|
repository: input.repository, sources,
|
|
198
252
|
sourceSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
253
|
+
requirements, connected,
|
|
199
254
|
};
|
|
200
255
|
}
|
|
201
256
|
|
package/src/cloud-http.ts
CHANGED
|
@@ -25,7 +25,7 @@ export class CloudFlowError extends Error {
|
|
|
25
25
|
constructor(
|
|
26
26
|
readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
|
|
27
27
|
| 'transport_error' | 'transient_error' | 'unsupported_storage_backend' | 'sync_too_large' | 'sync_unsupported'
|
|
28
|
-
| 'patch_conflict',
|
|
28
|
+
| 'patch_conflict' | 'integration_not_connected',
|
|
29
29
|
message: string,
|
|
30
30
|
readonly status?: number,
|
|
31
31
|
readonly refusal?: CloudRefusal,
|
package/src/cloud-schedule.ts
CHANGED
|
@@ -69,12 +69,29 @@ function assertTimeZone(tz: string): string {
|
|
|
69
69
|
return tz;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The argument checks `scheduleInCloud` makes before it reads the flow, so a
|
|
74
|
+
* caller can refuse a bad cron, interval or zone before any side effect (the
|
|
75
|
+
* CLI connects integrations in between). Declared-schedule resolution needs
|
|
76
|
+
* the flow and stays in `scheduleInCloud`.
|
|
77
|
+
*/
|
|
78
|
+
export function validateScheduleArgs(input: Pick<ScheduleInCloudInput, 'cron' | 'every' | 'tz'>): void {
|
|
75
79
|
if (input.cron !== undefined && input.every !== undefined) {
|
|
76
80
|
throw new CloudFlowError('invalid_input', 'Give --cron or --every, not both.');
|
|
77
81
|
}
|
|
82
|
+
if (input.cron !== undefined) {
|
|
83
|
+
try { parseCron(input.cron); } catch (error) {
|
|
84
|
+
throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (input.every !== undefined) everyToCron(input.every);
|
|
88
|
+
if (input.tz !== undefined) assertTimeZone(input.tz);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function scheduleInCloud(
|
|
92
|
+
input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
|
|
93
|
+
): Promise<CloudSchedule> {
|
|
94
|
+
validateScheduleArgs(input);
|
|
78
95
|
const submission = await prepareCloudSubmission(input.flow, {
|
|
79
96
|
...(Object.prototype.hasOwnProperty.call(input, 'input') ? { input: input.input } : {}),
|
|
80
97
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
package/src/failure-kinds.ts
CHANGED
|
@@ -113,6 +113,8 @@ export const RUN_FAILURE_KINDS = [
|
|
|
113
113
|
'run_unavailable',
|
|
114
114
|
/** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
|
|
115
115
|
'gate_failed',
|
|
116
|
+
/** `flows answer` named a wait the run is not asking: unknown, or already answered. */
|
|
117
|
+
'human_wait_unknown',
|
|
116
118
|
] as const;
|
|
117
119
|
|
|
118
120
|
/**
|