@opsee/cli 0.11.11 → 0.11.13
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/README.md +27 -9
- package/package.json +2 -2
- package/src/commands/foreman-debug.ts +11 -4
- package/src/commands/init.ts +4 -8
- package/src/foreman/core/short-turn.ts +80 -0
- package/src/foreman/core/start-discovery.ts +158 -0
- package/src/foreman/core/triage.ts +10 -47
- package/src/foreman/core/verifier.ts +51 -4
- package/src/foreman/run-recipe.ts +37 -8
- package/src/init/prompt.ts +17 -18
- package/src/init/run-recipe-config.ts +11 -12
package/README.md
CHANGED
|
@@ -102,15 +102,33 @@ environment, so an app that reads the variable needs no placeholder at all. Vite
|
|
|
102
102
|
kind: it ignores `PORT` and takes `--port`, and `--strictPort` makes it fail rather than drift to
|
|
103
103
|
the next free port, which is the silent-corruption hazard AGENTS.md describes for two dev servers.
|
|
104
104
|
|
|
105
|
-
`opsee init` writes the block once. `
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
`opsee init` writes the block once. `gates.test` and `gates.lint` default to `commands.test` and
|
|
106
|
+
`commands.lint`; `start` is written only when `commands.dev` (then `commands.start`) or `--start`
|
|
107
|
+
already says how to serve the app, and the readiness URL and the port variable travel with it.
|
|
108
|
+
`typecheck` has no analyzer equivalent, so it is the one value a terminal is asked for.
|
|
109
|
+
|
|
110
|
+
**It never asks how to start the app.** That question has no answer at setup time in a repository
|
|
111
|
+
with several programs: "the app" means whichever one a Task's Verification happens to exercise, and
|
|
112
|
+
no Task exists yet. A block with Gates and no `start` is a complete recipe — the Gates are the whole
|
|
113
|
+
Verdict for most repositories — and the Verifier says so when a Task does ask for a browser round.
|
|
114
|
+
The flags `--start`, `--readiness-url` and `--port-env` still write one for anyone who wants it
|
|
115
|
+
pinned.
|
|
116
|
+
|
|
117
|
+
When a Task's Verification asks for a browser round and the recipe has no `start`, the Foreman runs
|
|
118
|
+
a **Start turn** before giving up: one short unattended turn in the Task's Workspace
|
|
119
|
+
(`core/start-discovery.ts`), given that Task's Verification section, which answers with the command
|
|
120
|
+
that serves the program the Verification exercises. That is the whole reason `opsee init` can stop
|
|
121
|
+
asking — in a repository with several programs the answer depends on the Task, and a Task exists by
|
|
122
|
+
then. The answer must take the leased port (`{port}` or the port variable) or it is refused: a
|
|
123
|
+
command that serves on its own default is the failure that looks like success, because the readiness
|
|
124
|
+
URL answers from a server the Foreman never started. It is agent-authored, so it runs under the
|
|
125
|
+
Gates' filtered environment like every other command from the repository, and it lands verbatim on
|
|
126
|
+
the Run Record as the app's command.
|
|
127
|
+
|
|
128
|
+
`start` without `readiness_url` is still refused: that is half a recipe, which would serve the app
|
|
129
|
+
and never learn it came up. An existing `foreman` block, the user's or the analyzer's, is never
|
|
130
|
+
rewritten: a re-run reports `unchanged` whatever the flags say, and a block whose `verify` is not a
|
|
131
|
+
known word is reported and kept. The YAML is spliced textually, the block appended in the
|
|
114
132
|
file's own indentation with no blank line before it, because a parsed-and-reserialised document
|
|
115
133
|
would lose the analyzer's header comments; the JSON copy is re-serialised in its own indentation
|
|
116
134
|
with `foreman` after the analyzer's members. The two are only ever both written when both exist
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsee/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.13",
|
|
4
4
|
"description": "Opsee CLI — the opsee binary: login, whoami, and the home of the Foreman",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@bufbuild/protobuf": "^2.14.0",
|
|
18
18
|
"@connectrpc/connect": "^2.1.2",
|
|
19
19
|
"@connectrpc/connect-node": "^2.1.2",
|
|
20
|
-
"@opsee/mcp-server": "0.11.
|
|
20
|
+
"@opsee/mcp-server": "0.11.13",
|
|
21
21
|
"tsx": "^4.23.12"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import type { DebugTurnArgs } from "../args.js";
|
|
3
3
|
import { AccountError, type Account } from "../foreman/account.js";
|
|
4
4
|
import type { AccountStore } from "../foreman/account-store.js";
|
|
5
|
-
import { loadRunRecipe, startApp, waitForReady, type RunRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
|
|
5
|
+
import { loadRunRecipe, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
|
|
6
6
|
import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
|
|
7
7
|
import type { TurnRequest, WorkerAdapter } from "../foreman/worker-adapter.js";
|
|
8
8
|
|
|
@@ -75,7 +75,7 @@ export interface ForemanDebugServeDeps {
|
|
|
75
75
|
timeoutMs?: number;
|
|
76
76
|
/** Test seams; the real recipe functions by default. */
|
|
77
77
|
load?: (root: string) => RunRecipe;
|
|
78
|
-
start?: (recipe:
|
|
78
|
+
start?: (recipe: StartableRecipe, options: StartOptions) => AppHandle;
|
|
79
79
|
wait?: (url: string, options: WaitOptions) => Promise<Readiness>;
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -84,9 +84,16 @@ export interface ForemanDebugServeDeps {
|
|
|
84
84
|
* the app up until Ctrl-C. Exit 1 when the recipe is missing or the app never becomes ready. */
|
|
85
85
|
export async function runForemanDebugServe(deps: ForemanDebugServeDeps, port: number): Promise<number> {
|
|
86
86
|
const recipe = (deps.load ?? loadRunRecipe)(deps.root);
|
|
87
|
+
// This command exists to serve the app, so a recipe that cannot is the whole failure, said here
|
|
88
|
+
// rather than as a spawn error from a command that is the empty string.
|
|
89
|
+
const servable = startable(recipe);
|
|
90
|
+
if (!servable) {
|
|
91
|
+
deps.out(`.opsee/config: the foreman block has no start command, so there is no app to serve. Add foreman.start, or let the Verifier work it out.`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
87
94
|
const timeoutMs = deps.timeoutMs ?? 120_000;
|
|
88
|
-
const app = (deps.start ?? startApp)(
|
|
89
|
-
deps.out(`starting: ${app.command} (${
|
|
95
|
+
const app = (deps.start ?? startApp)(servable, { port, cwd: deps.root, onOutput: (line) => deps.out(` | ${line}`) });
|
|
96
|
+
deps.out(`starting: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
|
|
90
97
|
deps.out(`waiting for ${app.readinessUrl} (up to ${timeoutMs}ms)`);
|
|
91
98
|
try {
|
|
92
99
|
const ready = await (deps.wait ?? waitForReady)(app.readinessUrl, { timeoutMs, signal: app.exitSignal });
|
package/src/commands/init.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { pickProject, type ProjectChoice } from "../init/project.js";
|
|
|
15
15
|
import { planRetire, RETIRED_SKILLS } from "../init/retire.js";
|
|
16
16
|
import { listSkillFiles, skillNames } from "../init/skills.js";
|
|
17
17
|
import { findReadyLabel, READY_LABEL, renderTrackerDoc, type Named, type TrackerDocData } from "../init/tracker-doc.js";
|
|
18
|
-
import { existingRecipe, inferRecipe, mergeRecipeJson, mergeRecipeYaml,
|
|
18
|
+
import { existingRecipe, inferRecipe, mergeRecipeJson, mergeRecipeYaml, type RecipeDefaults, type RecipeFlags } from "../init/run-recipe-config.js";
|
|
19
19
|
import { OPSEE_CONFIG_JSON, OPSEE_CONFIG_YAML, readOpseeConfigFiles } from "../opsee-config.js";
|
|
20
20
|
import type { RunRecipe } from "../foreman/run-recipe.js";
|
|
21
21
|
import { NOT_LOGGED_IN } from "./whoami.js";
|
|
@@ -47,7 +47,7 @@ export interface InitDeps extends Pick<CommandDeps, "isAuthenticated" | "out"> {
|
|
|
47
47
|
recipeFlags: RecipeFlags;
|
|
48
48
|
/** Asks for the Run Recipe with inferred defaults; null when there is no start command to
|
|
49
49
|
* write. Only called when `.opsee/config` has no `foreman` block yet. */
|
|
50
|
-
askRecipe: (defaults: RecipeDefaults) => Promise<RunRecipe
|
|
50
|
+
askRecipe: (defaults: RecipeDefaults) => Promise<RunRecipe>;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export const CODEX_TRUST_HINT =
|
|
@@ -102,12 +102,8 @@ export async function runInit(deps: InitDeps): Promise<number> {
|
|
|
102
102
|
// one of the two files is copied into the other, so they stay in step without a prompt.
|
|
103
103
|
const config = readOpseeConfigFiles(deps.root);
|
|
104
104
|
const answers = existingRecipe(config) ?? (await deps.askRecipe(inferRecipe(config, deps.recipeFlags)));
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} else {
|
|
108
|
-
report.apply(OPSEE_CONFIG_YAML, mergeRecipeYaml(config.yaml, answers));
|
|
109
|
-
if (config.json !== null || config.yaml === null) report.apply(OPSEE_CONFIG_JSON, mergeRecipeJson(config.json, answers));
|
|
110
|
-
}
|
|
105
|
+
report.apply(OPSEE_CONFIG_YAML, mergeRecipeYaml(config.yaml, answers));
|
|
106
|
+
if (config.json !== null || config.yaml === null) report.apply(OPSEE_CONFIG_JSON, mergeRecipeJson(config.json, answers));
|
|
111
107
|
|
|
112
108
|
deps.out("");
|
|
113
109
|
deps.out(`Project ${project.name} (${project.key}, id ${project.id}); skills ${skillNames(skills).map((s) => `/${s}`).join(", ")}.`);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving one short unattended turn to its terminal event.
|
|
3
|
+
*
|
|
4
|
+
* The Foreman runs a few of these beside the implementer turn: the Triage turn that drafts a
|
|
5
|
+
* missing Verification section, and the Start turn that works out how to serve the app for a
|
|
6
|
+
* browser round. They differ only in their prompt and their log label — the launch, the event
|
|
7
|
+
* drain, the rate-limit capture and the output tail are the same, and were the same code twice
|
|
8
|
+
* before this module existed.
|
|
9
|
+
*/
|
|
10
|
+
import type { Account } from "../account.js";
|
|
11
|
+
import type { AdapterEvent, WorkerAdapter } from "../worker-adapter.js";
|
|
12
|
+
import { tail } from "./install.js";
|
|
13
|
+
|
|
14
|
+
export interface ShortTurnRequest {
|
|
15
|
+
worker: WorkerAdapter;
|
|
16
|
+
account: Account;
|
|
17
|
+
/** Where the turn runs: the Task's Workspace, so the Worker reads the real repository. */
|
|
18
|
+
cwd: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
/** Prefixes every log line, so a reader tells this turn from the implementer's. */
|
|
21
|
+
label: string;
|
|
22
|
+
maxTurns: number;
|
|
23
|
+
stallTimeoutMs?: number;
|
|
24
|
+
log: (line: string) => void;
|
|
25
|
+
now: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DrainedTurn {
|
|
29
|
+
terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
outputTail: string;
|
|
32
|
+
/** The vendor's rate-limit report, when one came (story 30). A short turn is a turn on the
|
|
33
|
+
* Account like any other, so a limit it hit Pauses the Account; the terminal event alone does not
|
|
34
|
+
* carry the reset the vendor named, which is what decides how long. */
|
|
35
|
+
rateLimit?: { resetAt?: string; message: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Runs one short turn to its terminal event, logging progress the way the dispatch loop does. */
|
|
39
|
+
export async function driveShortTurn(req: ShortTurnRequest): Promise<DrainedTurn> {
|
|
40
|
+
const started = req.now();
|
|
41
|
+
const handle = req.worker.launch({
|
|
42
|
+
account: req.account,
|
|
43
|
+
cwd: req.cwd,
|
|
44
|
+
prompt: req.prompt,
|
|
45
|
+
maxTurns: req.maxTurns,
|
|
46
|
+
stallTimeoutMs: req.stallTimeoutMs,
|
|
47
|
+
});
|
|
48
|
+
const output: string[] = [];
|
|
49
|
+
let terminal: DrainedTurn["terminal"] | undefined;
|
|
50
|
+
let rateLimit: DrainedTurn["rateLimit"];
|
|
51
|
+
for await (const event of handle.events) {
|
|
52
|
+
switch (event.type) {
|
|
53
|
+
case "started":
|
|
54
|
+
req.log(`${req.label}: session ${event.sessionId}`);
|
|
55
|
+
break;
|
|
56
|
+
case "output":
|
|
57
|
+
for (const line of event.text.split("\n")) {
|
|
58
|
+
output.push(line);
|
|
59
|
+
req.log(` | ${line}`);
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case "tool":
|
|
63
|
+
req.log(` tool ${event.name}`);
|
|
64
|
+
break;
|
|
65
|
+
case "rate_limited":
|
|
66
|
+
rateLimit = { resetAt: event.resetAt, message: event.message };
|
|
67
|
+
req.log(`${req.label}: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
|
|
68
|
+
break;
|
|
69
|
+
case "stalled":
|
|
70
|
+
req.log(`${req.label}: stalled, no output for ${event.silentMs}ms`);
|
|
71
|
+
break;
|
|
72
|
+
case "completed":
|
|
73
|
+
case "failed":
|
|
74
|
+
terminal = event;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
|
|
79
|
+
return { terminal, durationMs: req.now() - started, outputTail: tail(output.join("\n")), rateLimit };
|
|
80
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Start turn: how the Foreman learns to serve the app when the Run Recipe does not say.
|
|
3
|
+
*
|
|
4
|
+
* `opsee init` no longer asks for a start command, because at setup time there is no Task and
|
|
5
|
+
* therefore no answer to "which of these programs?" in a repository that holds several. The
|
|
6
|
+
* question does have an answer later: a Task's Verification section describes a user journey, the
|
|
7
|
+
* Verifier needs that Task's app on a port, and the Workspace has the repository in it. So the
|
|
8
|
+
* Foreman asks then, in the Workspace, the way it asks for a missing Verification section in a
|
|
9
|
+
* Triage turn.
|
|
10
|
+
*
|
|
11
|
+
* What comes back is a command the Foreman then runs. It runs under the Gates' filtered
|
|
12
|
+
* environment (core/gates.ts `gateEnv`), which holds no credential of the Foreman's, and it lands
|
|
13
|
+
* verbatim on the Run Record as the app's command — so a reader can see what was run and who
|
|
14
|
+
* wrote it. Treat the answer as data, never as instructions.
|
|
15
|
+
*/
|
|
16
|
+
import type { Account } from "../account.js";
|
|
17
|
+
import type { StartableRecipe } from "../run-recipe.js";
|
|
18
|
+
import type { TrackerTask } from "../tracker-adapter.js";
|
|
19
|
+
import type { WorkerAdapter } from "../worker-adapter.js";
|
|
20
|
+
import { driveShortTurn } from "./short-turn.js";
|
|
21
|
+
|
|
22
|
+
/** Reading a package manifest and naming one command is a handful of agentic turns, not a
|
|
23
|
+
* Worker's budget; the Run's own `maxTurns` lowers this, never raises it. */
|
|
24
|
+
export const START_DISCOVERY_MAX_TURNS = 6;
|
|
25
|
+
|
|
26
|
+
/** What a start command must contain to be usable: the Foreman leases a port per Task and the app
|
|
27
|
+
* has to take it. A command that ignores the port is the failure that looks like success — the app
|
|
28
|
+
* comes up on its own default, the readiness URL answers from a server nobody asked for, and two
|
|
29
|
+
* Tasks verified at once quietly share it. Vite is the common case: it ignores `PORT` and needs
|
|
30
|
+
* `--port {port} --strictPort`. */
|
|
31
|
+
export function honoursPort(command: string, portEnv: string): boolean {
|
|
32
|
+
return command.includes("{port}") || command.includes(`$${portEnv}`) || command.includes(`\${${portEnv}}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function startDiscoveryPrompt(task: Pick<TrackerTask, "identifier" | "title">, verification: string, workspace: { path: string }, portEnv: string): string {
|
|
36
|
+
return [
|
|
37
|
+
`You are a Worker run unattended by the Foreman for a Start turn on Task ${task.identifier}: ${task.title}.`,
|
|
38
|
+
"",
|
|
39
|
+
"A Verifier is about to prove this Task from the outside, in a browser. It needs the app running on a port the",
|
|
40
|
+
"Foreman leases. Nothing in this repository's Opsee config says how to start it. Work that out and report it.",
|
|
41
|
+
`Do not implement the Task and do not change any file: your working directory ${workspace.path} is the repository, read it.`,
|
|
42
|
+
"",
|
|
43
|
+
"## The Verification the Verifier will follow",
|
|
44
|
+
"",
|
|
45
|
+
verification,
|
|
46
|
+
"",
|
|
47
|
+
"## What to find",
|
|
48
|
+
"",
|
|
49
|
+
"The one program this Verification exercises — in a repository with several, the one whose pages it names —",
|
|
50
|
+
"and the command that serves it from the repository root, ready for a browser.",
|
|
51
|
+
"",
|
|
52
|
+
"## Rules for the command",
|
|
53
|
+
"",
|
|
54
|
+
`- It MUST take the port. Write the placeholder {port} where the port goes, or use $${portEnv}, which is set for it.`,
|
|
55
|
+
" A command that ignores the port is worse than none: the app comes up on its own default and the Verifier",
|
|
56
|
+
" drives a server it did not start.",
|
|
57
|
+
"- If the tool has a strict-port option, use it. Vite drifts to the next free port without --strictPort.",
|
|
58
|
+
"- It runs from the repository root, so include any directory change it needs.",
|
|
59
|
+
"- It serves; it does not build and exit.",
|
|
60
|
+
"",
|
|
61
|
+
"## Finishing",
|
|
62
|
+
"",
|
|
63
|
+
"End the turn with a Completion Report: outcome `done`, and the summary a single JSON object, nothing else:",
|
|
64
|
+
'{"start": "<command>", "readinessUrl": "<url that answers once it is up, with {port}>", "portEnv": "<variable name>"}',
|
|
65
|
+
"Report `blocked`, with the reason as a blocker, if this repository has no app a browser could drive.",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StartAnswer {
|
|
70
|
+
recipe?: Pick<StartableRecipe, "start" | "readinessUrl" | "portEnv">;
|
|
71
|
+
/** Why the answer could not be used, for the log and the skip reason. */
|
|
72
|
+
reason?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads the turn's summary back.
|
|
77
|
+
*
|
|
78
|
+
* Strict on purpose: this produces a shell command the Foreman executes, so a half-understood
|
|
79
|
+
* answer is refused rather than repaired. The port rule is the one that matters — see
|
|
80
|
+
* `honoursPort` for why a command that ignores it fails in the shape hardest to notice.
|
|
81
|
+
*/
|
|
82
|
+
export function parseStartAnswer(summary: string, fallbackPortEnv: string): StartAnswer {
|
|
83
|
+
const text = summary.trim();
|
|
84
|
+
const start = text.indexOf("{");
|
|
85
|
+
const end = text.lastIndexOf("}");
|
|
86
|
+
if (start === -1 || end <= start) return { reason: "the Start turn answered with no JSON object" };
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { reason: `the Start turn's answer is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
92
|
+
}
|
|
93
|
+
if (!parsed || typeof parsed !== "object") return { reason: "the Start turn's answer is not a JSON object" };
|
|
94
|
+
const block = parsed as Record<string, unknown>;
|
|
95
|
+
const str = (value: unknown) => (typeof value === "string" && value.trim() !== "" ? value.trim() : undefined);
|
|
96
|
+
const startCommand = str(block.start);
|
|
97
|
+
const readinessUrl = str(block.readinessUrl) ?? str(block.readiness_url);
|
|
98
|
+
const portEnv = str(block.portEnv) ?? str(block.port_env) ?? fallbackPortEnv;
|
|
99
|
+
if (!startCommand) return { reason: "the Start turn's answer has no start command" };
|
|
100
|
+
if (!readinessUrl) return { reason: "the Start turn's answer has no readiness URL" };
|
|
101
|
+
if (!honoursPort(startCommand, portEnv)) {
|
|
102
|
+
return { reason: `the Start turn's command ignores the port, so it would serve on its own: ${startCommand}` };
|
|
103
|
+
}
|
|
104
|
+
return { recipe: { start: startCommand, readinessUrl, portEnv } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface StartDiscoveryRequest {
|
|
108
|
+
worker: WorkerAdapter;
|
|
109
|
+
account: Account;
|
|
110
|
+
task: Pick<TrackerTask, "identifier" | "title">;
|
|
111
|
+
verification: string;
|
|
112
|
+
workspace: { path: string };
|
|
113
|
+
portEnv: string;
|
|
114
|
+
maxTurns?: number;
|
|
115
|
+
stallTimeoutMs?: number;
|
|
116
|
+
log: (line: string) => void;
|
|
117
|
+
now: () => number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Runs one Start turn and reads its answer. Never throws: a Start turn that fails is a browser
|
|
122
|
+
* round the Foreman cannot have, which is a skip, not an error.
|
|
123
|
+
*
|
|
124
|
+
* A rate limit here is logged and not acted on, which is the Verifier's own behaviour beside it:
|
|
125
|
+
* `VerifyOutcome` carries no way to report one, so run.ts cannot Pause the Account on a turn taken
|
|
126
|
+
* inside verification. A Triage turn does Pause, because it runs in run.ts where the Account is.
|
|
127
|
+
* Worth closing for both turns at once rather than for this one alone, and it is a spend of the
|
|
128
|
+
* cap either way rather than a wrong Verdict.
|
|
129
|
+
*/
|
|
130
|
+
export async function discoverStart(req: StartDiscoveryRequest): Promise<StartAnswer> {
|
|
131
|
+
const maxTurns = Math.min(START_DISCOVERY_MAX_TURNS, req.maxTurns ?? START_DISCOVERY_MAX_TURNS);
|
|
132
|
+
req.log(`start: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path}, working out how to serve the app (at most ${maxTurns} turns)`);
|
|
133
|
+
let drained;
|
|
134
|
+
try {
|
|
135
|
+
drained = await driveShortTurn({
|
|
136
|
+
worker: req.worker,
|
|
137
|
+
account: req.account,
|
|
138
|
+
cwd: req.workspace.path,
|
|
139
|
+
prompt: startDiscoveryPrompt(req.task, req.verification, req.workspace, req.portEnv),
|
|
140
|
+
label: "start",
|
|
141
|
+
maxTurns,
|
|
142
|
+
stallTimeoutMs: req.stallTimeoutMs,
|
|
143
|
+
log: req.log,
|
|
144
|
+
now: req.now,
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return { reason: `the Start turn failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
148
|
+
}
|
|
149
|
+
if (drained.terminal.type !== "completed") {
|
|
150
|
+
return { reason: `the Start turn did not finish: ${drained.terminal.type === "failed" ? drained.terminal.reason : "no report"}` };
|
|
151
|
+
}
|
|
152
|
+
const summary = drained.terminal.report?.summary;
|
|
153
|
+
if (!summary) return { reason: "the Start turn finished without a Completion Report" };
|
|
154
|
+
const answer = parseStartAnswer(summary, req.portEnv);
|
|
155
|
+
if (answer.recipe) req.log(`start: ${req.task.identifier} will be served with: ${answer.recipe.start} (agent-authored, read as data)`);
|
|
156
|
+
else req.log(`start: ${req.task.identifier} ${answer.reason}`);
|
|
157
|
+
return answer;
|
|
158
|
+
}
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* drains the turn; run.ts records the outcome, since the Run Record write is the commit (ADR-0009).
|
|
11
11
|
*/
|
|
12
12
|
import type { Account } from "../account.js";
|
|
13
|
+
import { driveShortTurn, type DrainedTurn } from "./short-turn.js";
|
|
13
14
|
import type { TrackerTask } from "../tracker-adapter.js";
|
|
14
|
-
import type {
|
|
15
|
-
import { tail } from "./install.js";
|
|
15
|
+
import type { WorkerAdapter } from "../worker-adapter.js";
|
|
16
16
|
import type { WorkContract } from "./work-contract.js";
|
|
17
17
|
|
|
18
18
|
/** Reading a few files and writing one section needs a handful of agentic turns, not a Worker's
|
|
@@ -81,58 +81,21 @@ export interface TriageTurnRequest {
|
|
|
81
81
|
now: () => number;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export
|
|
85
|
-
terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
|
|
86
|
-
durationMs: number;
|
|
87
|
-
outputTail: string;
|
|
88
|
-
/** The vendor's rate-limit report, when one came (story 30). A Triage turn is a turn on the
|
|
89
|
-
* Account like any other, so a limit it hit Pauses the Account; the terminal event alone does not
|
|
90
|
-
* carry the reset the vendor named, which is what decides how long. */
|
|
91
|
-
rateLimit?: { resetAt?: string; message: string };
|
|
92
|
-
}
|
|
84
|
+
export type { DrainedTurn };
|
|
93
85
|
|
|
94
|
-
/** Runs one Triage turn to its terminal event
|
|
86
|
+
/** Runs one Triage turn to its terminal event. */
|
|
95
87
|
export async function driveTriageTurn(req: TriageTurnRequest): Promise<DrainedTurn> {
|
|
96
88
|
const maxTurns = Math.min(TRIAGE_MAX_TURNS, req.maxTurns ?? TRIAGE_MAX_TURNS);
|
|
97
|
-
|
|
98
|
-
|
|
89
|
+
req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
|
|
90
|
+
return driveShortTurn({
|
|
91
|
+
worker: req.worker,
|
|
99
92
|
account: req.account,
|
|
100
93
|
cwd: req.workspace.path,
|
|
101
94
|
prompt: triagePrompt(req.task, req.contract, req.workspace),
|
|
95
|
+
label: "triage",
|
|
102
96
|
maxTurns,
|
|
103
97
|
stallTimeoutMs: req.stallTimeoutMs,
|
|
98
|
+
log: req.log,
|
|
99
|
+
now: req.now,
|
|
104
100
|
});
|
|
105
|
-
req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
|
|
106
|
-
const output: string[] = [];
|
|
107
|
-
let terminal: DrainedTurn["terminal"] | undefined;
|
|
108
|
-
let rateLimit: DrainedTurn["rateLimit"];
|
|
109
|
-
for await (const event of handle.events) {
|
|
110
|
-
switch (event.type) {
|
|
111
|
-
case "started":
|
|
112
|
-
req.log(`triage: session ${event.sessionId}`);
|
|
113
|
-
break;
|
|
114
|
-
case "output":
|
|
115
|
-
for (const line of event.text.split("\n")) {
|
|
116
|
-
output.push(line);
|
|
117
|
-
req.log(` | ${line}`);
|
|
118
|
-
}
|
|
119
|
-
break;
|
|
120
|
-
case "tool":
|
|
121
|
-
req.log(` tool ${event.name}`);
|
|
122
|
-
break;
|
|
123
|
-
case "rate_limited":
|
|
124
|
-
rateLimit = { resetAt: event.resetAt, message: event.message };
|
|
125
|
-
req.log(`triage: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
|
|
126
|
-
break;
|
|
127
|
-
case "stalled":
|
|
128
|
-
req.log(`triage: stalled, no output for ${event.silentMs}ms`);
|
|
129
|
-
break;
|
|
130
|
-
case "completed":
|
|
131
|
-
case "failed":
|
|
132
|
-
terminal = event;
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
|
|
137
|
-
return { terminal, durationMs: req.now() - started, outputTail: tail(output.join("\n")), rateLimit };
|
|
138
101
|
}
|
|
@@ -46,7 +46,8 @@ import { RunEventInputSchema, type RunEvent, type RunEventInput, type RunVerific
|
|
|
46
46
|
import { parseOpseeJson, readYamlBlock, type OpseeConfigFiles } from "../../opsee-config.js";
|
|
47
47
|
import type { Account } from "../account.js";
|
|
48
48
|
import { foremanLocalDir } from "../local-dir.js";
|
|
49
|
-
import { RECIPE_KEY, recipeFromConfig, startApp, waitForReady, type RunRecipe, type VerifyMode } from "../run-recipe.js";
|
|
49
|
+
import { RECIPE_KEY, recipeFromConfig, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type VerifyMode } from "../run-recipe.js";
|
|
50
|
+
import { discoverStart, type StartAnswer } from "./start-discovery.js";
|
|
50
51
|
import type { TrackerTask } from "../tracker-adapter.js";
|
|
51
52
|
import { parseVerdict, VERDICT_CONTRACT, type Defect, type Verdict } from "../verdict.js";
|
|
52
53
|
import type { AdapterEvent, McpServerSpec, WorkerAdapter } from "../worker-adapter.js";
|
|
@@ -423,6 +424,10 @@ export interface VerifierOptions {
|
|
|
423
424
|
* default). */
|
|
424
425
|
mcpFor?: (outputDir: string, allowedOrigins: readonly string[]) => McpServerSpec;
|
|
425
426
|
ports?: PortLease;
|
|
427
|
+
/** How the Foreman works out a start command the Run Recipe does not carry (core/
|
|
428
|
+
* start-discovery.ts). A Start turn by default; a test hands in an answer instead. Set it to a
|
|
429
|
+
* function that answers with a reason to turn discovery off. */
|
|
430
|
+
discoverStart?: (input: { task: Pick<TrackerTask, "identifier" | "title">; verification: string; workspace: { path: string }; portEnv: string }) => Promise<StartAnswer>;
|
|
426
431
|
log?: (line: string) => void;
|
|
427
432
|
now?: () => number;
|
|
428
433
|
}
|
|
@@ -461,15 +466,57 @@ export function verifierWith(options: VerifierOptions): VerifierFn {
|
|
|
461
466
|
log(`verify: ${task.identifier} skipped: ${decision.reason}; the Gates are the whole verification`);
|
|
462
467
|
return { kind: "skipped", reason: decision.reason };
|
|
463
468
|
}
|
|
464
|
-
|
|
469
|
+
// The round is wanted; whether it can happen is a second question. A block with Gates and no
|
|
470
|
+
// start is the ordinary shape of a repository that never asked for a browser -- `opsee init`
|
|
471
|
+
// stopped asking, because at setup time nobody knows which of a repository's programs a Task
|
|
472
|
+
// will exercise. Now there is a Task, a Verification section and a Workspace, so the Foreman
|
|
473
|
+
// asks a Start turn rather than giving up.
|
|
474
|
+
let servable = startable(recipe);
|
|
475
|
+
let discovered: string | undefined;
|
|
476
|
+
if (!servable) {
|
|
477
|
+
const ask =
|
|
478
|
+
options.discoverStart ??
|
|
479
|
+
((input) =>
|
|
480
|
+
discoverStart({
|
|
481
|
+
worker: options.worker,
|
|
482
|
+
account: options.account,
|
|
483
|
+
task: input.task,
|
|
484
|
+
verification: input.verification,
|
|
485
|
+
workspace: input.workspace,
|
|
486
|
+
portEnv: input.portEnv,
|
|
487
|
+
maxTurns: options.maxTurns,
|
|
488
|
+
stallTimeoutMs: options.stallTimeoutMs,
|
|
489
|
+
log,
|
|
490
|
+
now,
|
|
491
|
+
}));
|
|
492
|
+
const answer = await ask({ task, verification: input.verification, workspace, portEnv: recipe.portEnv });
|
|
493
|
+
if (answer.recipe) {
|
|
494
|
+
servable = { ...recipe, ...answer.recipe } satisfies StartableRecipe;
|
|
495
|
+
discovered = answer.recipe.start;
|
|
496
|
+
} else {
|
|
497
|
+
log(`verify: ${task.identifier} could not work out how to serve the app: ${answer.reason}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!servable) {
|
|
501
|
+
const missing = `the Run Recipe has no start command, so the app cannot be served (read from ${source})`;
|
|
502
|
+
if (recipe.verify === "browser") {
|
|
503
|
+
log(`verify: ${task.identifier} the Run Recipe says verify: browser but ${missing}; the round failed`);
|
|
504
|
+
return { kind: "failed", reason: `the Run Recipe says verify: browser but ${missing}`, durationMs: 0 };
|
|
505
|
+
}
|
|
506
|
+
log(`verify: ${task.identifier} skipped: ${missing}; the Gates are the whole verification`);
|
|
507
|
+
return { kind: "skipped", reason: missing };
|
|
508
|
+
}
|
|
509
|
+
log(
|
|
510
|
+
`verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source}${discovered ? ", start command from a Start turn" : ""})`,
|
|
511
|
+
);
|
|
465
512
|
|
|
466
513
|
const started = now();
|
|
467
514
|
const port = await ports.acquire(task.id);
|
|
468
515
|
const output: string[] = [];
|
|
469
|
-
const app = startApp(
|
|
516
|
+
const app = startApp(servable, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
|
|
470
517
|
input.onPid?.("app", app.pid);
|
|
471
518
|
const appAt = now();
|
|
472
|
-
log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${
|
|
519
|
+
log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
|
|
473
520
|
const appRun = (ready: boolean, error?: string): AppRun => ({
|
|
474
521
|
command: app.command,
|
|
475
522
|
cwd: workspace.path,
|
|
@@ -32,10 +32,16 @@ export const VERIFY_MODES = ["auto", "browser", "none"] as const;
|
|
|
32
32
|
export type VerifyMode = (typeof VERIFY_MODES)[number];
|
|
33
33
|
|
|
34
34
|
export interface RunRecipe {
|
|
35
|
-
/** Shell command that serves the app; `{port}` and the port variable are substituted.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
/** Shell command that serves the app; `{port}` and the port variable are substituted.
|
|
36
|
+
*
|
|
37
|
+
* Optional, because only the Verifier's browser round needs it and most repositories never ask
|
|
38
|
+
* for one. The Gates are the rest of the block and they run without it. `opsee init` writes it
|
|
39
|
+
* when the analyzer already knows a dev command and otherwise leaves it out rather than asking
|
|
40
|
+
* a question whose answer is "which of these six programs?" in a monorepo. */
|
|
41
|
+
start?: string;
|
|
42
|
+
/** URL that answers once the app is up; `{port}` and the port variable are substituted. Absent
|
|
43
|
+
* with `start`, present with it. */
|
|
44
|
+
readinessUrl?: string;
|
|
39
45
|
/** Name of the environment variable set to the port when `start` runs. */
|
|
40
46
|
portEnv: string;
|
|
41
47
|
gates: RunRecipeGates;
|
|
@@ -81,10 +87,17 @@ function nonEmptyString(value: unknown): string | undefined {
|
|
|
81
87
|
export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
|
|
82
88
|
if (!raw || typeof raw !== "object") throw new Error(`${source} has no foreman block; run opsee init to write the Run Recipe.`);
|
|
83
89
|
const block = raw as Record<string, unknown>;
|
|
90
|
+
// Neither is required. A block with Gates and no start is a repository that never wanted a
|
|
91
|
+
// browser round, which is most of them; the Verifier says so and the Gates give the Verdict.
|
|
92
|
+
//
|
|
93
|
+
// A start command without a readiness URL is different: it is a half-written recipe. It would
|
|
94
|
+
// start the app and never know it was up, so the round would skip for a reason that looks like
|
|
95
|
+
// "you did not configure this" when the truth is "you configured half of it".
|
|
84
96
|
const start = nonEmptyString(block.start);
|
|
85
97
|
const readinessUrl = nonEmptyString(block.readiness_url);
|
|
86
|
-
if (
|
|
87
|
-
|
|
98
|
+
if (start && !readinessUrl) {
|
|
99
|
+
throw new Error(`${source}: foreman.start is set but foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
|
|
100
|
+
}
|
|
88
101
|
const gatesRaw = block.gates && typeof block.gates === "object" ? (block.gates as Record<string, unknown>) : {};
|
|
89
102
|
const gates: RunRecipeGates = {};
|
|
90
103
|
for (const name of ["test", "lint", "typecheck"] as const) {
|
|
@@ -97,7 +110,13 @@ export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
|
|
|
97
110
|
} catch (error) {
|
|
98
111
|
throw new Error(`${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
112
|
}
|
|
100
|
-
return {
|
|
113
|
+
return {
|
|
114
|
+
...(start === undefined ? {} : { start }),
|
|
115
|
+
...(readinessUrl === undefined ? {} : { readinessUrl }),
|
|
116
|
+
portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV,
|
|
117
|
+
gates,
|
|
118
|
+
...(block.verify === undefined ? {} : { verify }),
|
|
119
|
+
};
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
/** Resolves the Run Recipe of the repo at `root`. The JSON copy is authoritative when present
|
|
@@ -146,7 +165,17 @@ export interface AppHandle {
|
|
|
146
165
|
* wrapper spawned underneath it. The child goes on this process's live registry
|
|
147
166
|
* (worker-process.ts `trackLiveProcess`) beside the Workers, so a Foreman that is signalled or
|
|
148
167
|
* exits stops the app with them rather than orphaning it. */
|
|
149
|
-
|
|
168
|
+
/** A recipe that can actually serve the app: both halves present. `startApp` takes this rather
|
|
169
|
+
* than a RunRecipe so a caller cannot forget the check, and the Verifier's "cannot start the app"
|
|
170
|
+
* branch is the one place that decides what to do about it. */
|
|
171
|
+
export type StartableRecipe = RunRecipe & { start: string; readinessUrl: string };
|
|
172
|
+
|
|
173
|
+
/** The recipe when it can serve the app, undefined when it cannot. */
|
|
174
|
+
export function startable(recipe: RunRecipe): StartableRecipe | undefined {
|
|
175
|
+
return recipe.start && recipe.readinessUrl ? (recipe as StartableRecipe) : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function startApp(recipe: StartableRecipe, options: StartOptions): AppHandle {
|
|
150
179
|
const command = substitutePort(recipe.start, options.port, recipe.portEnv);
|
|
151
180
|
const readinessUrl = substitutePort(recipe.readinessUrl, options.port, recipe.portEnv);
|
|
152
181
|
const child = spawn(command, {
|
package/src/init/prompt.ts
CHANGED
|
@@ -3,26 +3,25 @@ import type { ProjectChoice } from "./project.js";
|
|
|
3
3
|
import type { RunRecipe } from "../foreman/run-recipe.js";
|
|
4
4
|
import type { RecipeDefaults } from "./run-recipe-config.js";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Asks for the one Run Recipe value nothing else can infer: the typecheck Gate.
|
|
8
|
+
*
|
|
9
|
+
* It no longer asks how to start the app. That question has no answer at setup time in a
|
|
10
|
+
* repository with more than one program -- "the app" means whichever one a Task's Verification
|
|
11
|
+
* happens to exercise, which is not known until there is a Task. The analyzer's dev command is
|
|
12
|
+
* still written when it exists, and the Verifier works the rest out when a browser round is
|
|
13
|
+
* actually wanted.
|
|
14
|
+
*
|
|
15
|
+
* Without a terminal the inferred values stand as they are.
|
|
16
|
+
*/
|
|
17
|
+
export async function askRecipe(defaults: RecipeDefaults): Promise<RunRecipe> {
|
|
18
|
+
if (!process.stdin.isTTY) return { ...defaults };
|
|
13
19
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
-
const ask = async (label: string, fallback: string | undefined): Promise<string> => {
|
|
15
|
-
const answer = (await rl.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
|
|
16
|
-
return answer || fallback || "";
|
|
17
|
-
};
|
|
18
20
|
try {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const portEnv = await ask("Port variable", defaults.portEnv);
|
|
24
|
-
const typecheck = await ask("Typecheck command (blank for none)", defaults.gates.typecheck);
|
|
25
|
-
return { start, readinessUrl, portEnv, gates: { ...defaults.gates, typecheck: typecheck || undefined } };
|
|
21
|
+
const fallback = defaults.gates.typecheck;
|
|
22
|
+
const answer = (await rl.question(`Typecheck command for the Foreman's Gates (blank for none)${fallback ? ` [${fallback}]` : ""}: `)).trim();
|
|
23
|
+
const typecheck = answer || fallback;
|
|
24
|
+
return { ...defaults, gates: { ...defaults.gates, typecheck: typecheck || undefined } };
|
|
26
25
|
} finally {
|
|
27
26
|
rl.close();
|
|
28
27
|
}
|
|
@@ -33,18 +33,16 @@ export function commandsOf(files: OpseeConfigFiles): Record<string, string> {
|
|
|
33
33
|
* Gate has no analyzer equivalent and is only ever a flag or a prompt answer. */
|
|
34
34
|
export function inferRecipe(files: OpseeConfigFiles, flags: RecipeFlags): RecipeDefaults {
|
|
35
35
|
const commands = commandsOf(files);
|
|
36
|
+
const start = flags.start ?? commands.dev ?? commands.start;
|
|
36
37
|
return {
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// The pair travels together or not at all: a readiness URL with nothing to start is a line
|
|
39
|
+
// that answers a question nobody asked, and `recipeFromConfig` rejects the other half-pair.
|
|
40
|
+
...(start === undefined ? {} : { start, readinessUrl: flags.readinessUrl ?? DEFAULT_READINESS_URL }),
|
|
39
41
|
portEnv: flags.portEnv ?? DEFAULT_PORT_ENV,
|
|
40
42
|
gates: { test: commands.test, lint: commands.lint, typecheck: flags.typecheck },
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/** Why `init` skipped the recipe when nothing said how to start the app. */
|
|
45
|
-
export const NO_START_COMMAND =
|
|
46
|
-
"no start command: pass --start <cmd>, answer the prompt on a terminal, or set commands.dev in .opsee/config";
|
|
47
|
-
|
|
48
46
|
/** The recipe a config file already carries, so a block present in one of the two files is copied
|
|
49
47
|
* into the other rather than asked for again; null when neither has a block that can serve as a
|
|
50
48
|
* recipe. The YAML is the analyzer's primary file and wins when both have one. */
|
|
@@ -68,12 +66,13 @@ function gateEntries(gates: RunRecipeGates): Array<[string, string]> {
|
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
function yamlRecipeBlock(recipe: RunRecipe, indent: string): string {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
// start and readiness_url only when something already knew them: the analyzer's dev command, or
|
|
70
|
+
// an --start flag. A block with neither is a complete recipe for a repository whose Verdict comes
|
|
71
|
+
// from its Gates.
|
|
72
|
+
const lines = [`${RECIPE_KEY}:`];
|
|
73
|
+
if (recipe.start) lines.push(`${indent}start: ${yamlScalar(recipe.start)}`);
|
|
74
|
+
if (recipe.readinessUrl) lines.push(`${indent}readiness_url: ${yamlScalar(recipe.readinessUrl)}`);
|
|
75
|
+
lines.push(`${indent}port_env: ${yamlScalar(recipe.portEnv)}`);
|
|
77
76
|
const gates = gateEntries(recipe.gates);
|
|
78
77
|
if (gates.length > 0) {
|
|
79
78
|
lines.push(`${indent}gates:`);
|