Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-runner-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

`qawolf runner run --follow` now reports only the run's status — an "in progress" line, then whether it passed or failed — instead of streaming every log line the run produces. The full log stream is available behind the new `--logs` flag, and two more flags mirror further streams into the follow as JSON lines: `--run-events` for the run's progress events and `--recorder-events` for the browser actions the runner records after an anchor taken just before submission. Each stream flag implies `--follow`. Anything parsing a followed run's stdout should expect at most one in-progress status entry by default and read the outcome from the exit code; pass `--logs` to keep receiving log lines, and follow one stream flag at a time when parsing — combined mirrors interleave without a stream label.
16 changes: 13 additions & 3 deletions skills/qawolf-cli/references/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,23 @@ The call answers with a run id as soon as the run is accepted. **The outcome is
not in that answer**, it is in the `run-status` stream, whose entries carry
`runId`, `status` and an `errorMessage` when there is one.

**Pass `--follow` to `run` and let it wait for you.** It streams the run's logs
and ends on the settled status, never on the logs, so a run that prints nothing
**Pass `--follow` to `run` and let it wait for you.** It reports the run's
status — in progress, then passed or failed — and ends on the settled status.
Exit code `1` means the run did not pass. Three flags mirror more streams into
the follow, and each implies `--follow` on its own: `--logs` streams every log
line the run produces, `--run-events` streams the run's progress events as JSON
lines, and `--recorder-events` streams the browser actions the runner records
as JSON lines — the recorder is runner-wide rather than run-scoped, so that one
carries whatever is recorded after an anchor taken just before submission. Whatever mirrors are on, the
follow still ends on the status, never on them, so a run that prints nothing
still terminates the follow and a run that dies mid-sentence still reports how.
Exit code `1` means the run did not pass.
Combining mirror flags interleaves their lines with nothing saying which stream
a line came from — fine for eyeballs; when parsing, follow one stream at a time.

```sh
qawolf runner run flows/checkout.flow.ts --follow
qawolf runner run flows/checkout.flow.ts --follow --logs
qawolf runner run flows/checkout.flow.ts --follow --recorder-events
```

If you would rather submit and come back later, note that `--follow` on `events`
Expand Down
12 changes: 11 additions & 1 deletion src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,16 @@ Run a flow on an interactive runner, shipping the current directory's files with
it

Options:
--follow Stream the run's logs until it settles (default: false)
--follow Report the run's status until it settles: in progress,
then passed or failed (default: false)
--logs Stream every log line the run produces while following.
Implies --follow (default: false)
--run-events Stream the run's progress events as JSON lines while
following. Implies --follow (default: false)
--recorder-events Stream the browser actions the runner records as JSON
lines while following, from an anchor taken just before
submission: the recorder is runner-wide, not run-scoped.
Implies --follow (default: false)
--runner <id> Runner to target. Defaults to QAWOLF_RUNNER_ID, then this
directory's stored runner
--timeout <seconds> Give up following after this long. Following keeps the
Expand All @@ -339,6 +348,7 @@ Options:
Examples:
$ qawolf runner run flows/checkout.flow.ts
$ qawolf runner run flows/checkout.flow.ts --follow
$ qawolf runner run flows/checkout.flow.ts --follow --logs
"
`;

Expand Down
23 changes: 23 additions & 0 deletions src/commands/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ describe("createProgram", () => {
}
});

// A program-level option matches anywhere on the command line, so a
// subcommand redefining one gets a flag that parses but never reaches its
// handler: the program consumes it first.
it("no subcommand redefines a program-level option", () => {
const program = createProgram({ signals: noopSignals });
const globalFlags = new Set(program.options.map((option) => option.long));

const collisions: string[] = [];
const walk = (commands: readonly (typeof program)[]): void => {
for (const command of commands) {
for (const option of command.options) {
if (option.long !== undefined && globalFlags.has(option.long)) {
collisions.push(`${command.name()} ${option.long}`);
}
}
walk(command.commands as (typeof program)[]);
}
};
walk(program.commands as (typeof program)[]);

expect(collisions).toEqual([]);
});

it("throws on unknown option", () => {
let err: unknown;
try {
Expand Down
38 changes: 35 additions & 3 deletions src/commands/runner/run.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@ import { runnerDeps, runnerFlagDescription } from "./context.js";
const runExamples = `
Examples:
$ qawolf runner run flows/checkout.flow.ts
$ qawolf runner run flows/checkout.flow.ts --follow`;
$ qawolf runner run flows/checkout.flow.ts --follow
$ qawolf runner run flows/checkout.flow.ts --follow --logs`;

const eventsExamples = `
Examples:
$ qawolf runner events recorder --tail 5
$ qawolf runner events run-logs --run <runId> --follow
$ qawolf runner events console --since 120 --json`;

type RunFlags = { follow: boolean; runner?: string; timeout: string };
type RunFlags = {
follow: boolean;
logs: boolean;
recorderEvents: boolean;
runEvents: boolean;
runner?: string;
timeout: string;
};

type EventsFlags = {
follow: boolean;
Expand All @@ -40,7 +48,28 @@ export function registerRunnerRunCommands(
.description(
"Run a flow on an interactive runner, shipping the current directory's files with it",
)
.option("--follow", "Stream the run's logs until it settles", false)
.option(
"--follow",
"Report the run's status until it settles: in progress, then passed or failed",
false,
)
// Not --verbose: the program already claims that flag for debug logging,
// and Commander lets a program-level option swallow it from any position.
.option(
"--logs",
"Stream every log line the run produces while following. Implies --follow",
false,
)
.option(
"--run-events",
"Stream the run's progress events as JSON lines while following. Implies --follow",
false,
)
.option(
"--recorder-events",
"Stream the browser actions the runner records as JSON lines while following, from an anchor taken just before submission: the recorder is runner-wide, not run-scoped. Implies --follow",
false,
)
.option("--runner <id>", runnerFlagDescription)
.option(
"--timeout <seconds>",
Expand All @@ -55,6 +84,9 @@ export function registerRunnerRunCommands(
{
entryPoint: file,
follow: opts.follow,
logs: opts.logs,
recorderEvents: opts.recorderEvents,
runEvents: opts.runEvents,
runner: opts.runner,
timeout: opts.timeout,
},
Expand Down
21 changes: 21 additions & 0 deletions src/core/interactiveRunner/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ export function readRunSettlement(payload: unknown): RunSettlement {
};
}

export type SettledRun =
| { type: "passed" }
| { type: "failed"; errorMessage: string | undefined }
| { type: "unrecognized"; status: string };

/** The first settling entry in a window of `run-status` entries, if any. */
export function findSettlement(
entries: readonly { payload: unknown }[],
): SettledRun | undefined {
for (const entry of entries) {
const settlement = readRunSettlement(entry.payload);
if (settlement.type !== "settled") continue;
if (settlement.status === "passed") return { type: "passed" };
if (settlement.status === "failed") {
return { errorMessage: settlement.errorMessage, type: "failed" };
}
return { status: settlement.status, type: "unrecognized" };
}
return undefined;
}

/**
* A run's own log line, as much of it as the CLI renders. Tolerant for the same
* reason as above: an entry it cannot read is printed as JSON rather than
Expand Down
3 changes: 3 additions & 0 deletions src/core/messages/interactiveRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const interactiveRunnerMessages = {
`No runner was given, so launched ${id} for this command. Its browser is fresh: nothing has been run on it and nothing is signed in. It bills until it is stopped or idles out, so stop it with qawolf runner stop --runner ${id} when you are done.`,
followEventsTimedOut: (stream: string, seconds: number) =>
`Stopped following ${stream} after ${formatSeconds(seconds * 1000)}: reading keeps the runner alive and billing, so a follow does not run unbounded. Pass --timeout to wait longer, or follow again to continue.`,
followEndCutShort:
"The run settled, but the last window of its followed streams could not be read, so the output above may be missing its final lines.",
followTimedOut: (runId: string, runnerId: string, seconds: number) =>
`Stopped following run ${runId} after ${formatSeconds(seconds * 1000)}. The run may still be going: read it with qawolf runner events run-status --run ${runId}, and stop the runner with qawolf runner stop --runner ${runnerId} when you are done. Pass --timeout to wait longer.`,
missingPackageJson:
Expand All @@ -57,6 +59,7 @@ export const interactiveRunnerMessages = {
errorMessage === undefined
? "The run failed and reported no reason."
: `The run failed: ${errorMessage}`,
runInProgress: "The run is in progress.",
runPassed: "The run passed.",
runSettledUnknown: (status: string) =>
`The run settled as "${status}", which this version of the CLI does not recognize. Upgrade to read it.`,
Expand Down
128 changes: 128 additions & 0 deletions src/domains/interactiveRunner/followPrinters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { formatRunLogLine } from "~/core/interactiveRunner/journal.js";
import type { AuthCommandContext } from "~/shell/commandContext.js";

import {
type CursorRead,
createPrintingCursor,
createUnreachableBudget,
} from "./journalCursor.js";
import {
journalReadFailure,
readJournal,
unreachableFailure,
} from "./readJournal.js";

const anchorPollIntervalMs = 1_000;

type RecorderAnchor =
| { ok: true; sinceSequence: number }
| { ok: false; failure: ReturnType<typeof journalReadFailure> };

/**
* Where "this run's recorder events" begin: the recorder journal's current end.
* Taken before the run is submitted, so the anchor cannot sit past the run's
* first events. A runner this command just launched has a provably empty
* journal, so asking it would only wait out its boot for a knowable answer;
* only a reused runner is read.
*
* TODO NOVA-1546: the anchor exists because recorder payloads carry no runId;
* once the platform stamps them, a run filter replaces all of this.
*/
export async function resolveRecorderAnchor(
ctx: AuthCommandContext,
resolved: { runnerId: string; type: "launched" | "resolved" },
deps: { sleep: (ms: number) => Promise<void> },
): Promise<RecorderAnchor> {
if (resolved.type === "launched") return { ok: true, sinceSequence: 0 };
return anchorRecorderCursor(ctx, resolved.runnerId, deps);
}

/**
* An unreachable runner is retried on the follow's own grace, never guessed at:
* unreachable can mean a reused runner too busy to answer, and anchoring one at
* zero would replay its whole recorder history as this run's actions. A runner
* that never answers fails the command here, before anything is submitted and
* billed.
*/
async function anchorRecorderCursor(
ctx: AuthCommandContext,
runnerId: string,
deps: { sleep: (ms: number) => Promise<void> },
): Promise<RecorderAnchor> {
const unreachable = createUnreachableBudget(anchorPollIntervalMs);
for (;;) {
const anchor = await readJournal(ctx, runnerId, {
stream: "recorder",
tail: 1,
});
if (anchor.type === "read") {
return { ok: true, sinceSequence: anchor.value.nextSequence };
}
if (anchor.type === "failed") {
return { failure: journalReadFailure(anchor), ok: false };
}
if (unreachable.exhausted()) {
return { failure: { ...unreachableFailure }, ok: false };
}
await deps.sleep(anchorPollIntervalMs);
}
}

export type FollowStreamOptions = {
logs: boolean;
/**
* Where in the `recorder` stream this run's events begin, or undefined to not
* follow it. An anchor rather than a run filter, because recorder entries
* carry no `runId` — the recorder outlives runs, so "this run's recorder
* events" can only mean "recorded after this point".
*/
recorderSinceSequence: number | undefined;
runEvents: boolean;
runId: string;
runnerId: string;
};

/**
* The mirror streams a follow prints beside `run-status`, one printing cursor
* per stream a flag asked for. Log lines print as their message; the event
* streams print each payload as one JSON line, the same rendering
* `qawolf runner events` gives them.
*/
export function createFollowPrinters(
ctx: AuthCommandContext,
options: FollowStreamOptions,
): (() => Promise<CursorRead>)[] {
const jsonLine = (payload: unknown) => JSON.stringify(payload);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const printers: (() => Promise<CursorRead>)[] = [];
if (options.logs) {
printers.push(
createPrintingCursor(
ctx,
options.runnerId,
{ runId: options.runId, stream: "run-logs" },
formatRunLogLine,
),
);
}
if (options.runEvents) {
printers.push(
createPrintingCursor(
ctx,
options.runnerId,
{ runId: options.runId, stream: "run-events" },
jsonLine,
),
);
}
if (options.recorderSinceSequence !== undefined) {
printers.push(
createPrintingCursor(
ctx,
options.runnerId,
{ sinceSequence: options.recorderSinceSequence, stream: "recorder" },
jsonLine,
),
);
}
return printers;
}
Loading
Loading