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
45 changes: 41 additions & 4 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { type } from "arktype";
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
Expand Down Expand Up @@ -82,9 +82,11 @@ import {
ProjectExportMaterial,
} from "@corbits-builder/schemas/project";
import JSZip from "jszip";
import type {
AgentActivity,
ProjectActivity,
import {
COMMAND_PROGRESS_FILE,
CommandProgress,
type AgentActivity,
type ProjectActivity,
} from "@corbits-builder/schemas/activity";
import {
artifactApproval,
Expand Down Expand Up @@ -171,6 +173,8 @@ const APPROVAL_COVERS: Record<string, (kind: string) => boolean> = {
// Whole documents, the filed app and approval package among them.
const MAX_READ_CHARS = MAX_APPROVAL_PACKAGE_BYTES;
const RECENT_FAILURES = 5;
// A command's progress line older than this was left by a command that died; it is not shown.
const COMMAND_PROGRESS_STALE_MS = 5 * 60_000;
// A run held on an approval is mid-turn, so its activity reads as work; the pending record is what
// says it waits on the person. Otherwise it is at work or parked between turns, which is idle.
const inFlightState = (
Expand Down Expand Up @@ -862,6 +866,36 @@ export function createArtifactService(
// or running between turns.
async activity(actor: Actor, projectId: string): Promise<ProjectActivity> {
await requireProject(actor, projectId, "read");
// What a long command the run executes last said about itself, from the file the sandbox
// keeps beside the workspace on this machine's sidecar. A command that died leaves its file
// behind, so a stale line is not shown.
const commandProgress = async (
address: string | null,
bundle: unknown,
): Promise<CommandProgress | undefined> => {
if (!sidecarDataDir || address === null) return undefined;
const step = spawnedWorkflowStep(bundle);
if (!step) return undefined;
const file = join(
dirname(warmStepWorkspace(sidecarDataDir, address, step.stepId)),
COMMAND_PROGRESS_FILE,
);
// No file is the usual case: nothing long is running. A file caught mid-write reads as
// broken JSON for a moment and is read again on the next poll.
let raw: unknown;
try {
raw = await Bun.file(file).json();
} catch (error) {
if (hasCode(error) && error.code === "ENOENT") return undefined;
if (error instanceof SyntaxError) return undefined;
throw error;
}
const progress = CommandProgress(raw);
if (progress instanceof type.errors) return undefined;
if (Date.now() - Date.parse(progress.at) > COMMAND_PROGRESS_STALE_MS)
return undefined;
return progress;
};
const agents: AgentActivity[] = [];
const coordinator = await activeCoordinator(projectId);
const seen = coordinator && tracker?.of(coordinator.runId);
Expand All @@ -876,6 +910,7 @@ export function createArtifactService(
const specialists = await rawDB
.select({
runId: workflowRun.id,
address: workflowRun.address,
bundle: workflowRunLaunchSpec.frozenApprovalBundle,
})
.from(workflowRun)
Expand All @@ -897,12 +932,14 @@ export function createArtifactService(
// A finished specialist stays parked for follow-up mail; only a turn in progress is work,
// timed from that turn's start rather than the run's deploy.
if (!shown?.working || !shown.since) continue;
const command = await commandProgress(run.address, run.bundle);
agents.push({
runId: run.runId,
role,
since: shown.since,
tools: shown.tools,
...(shown.said ? { said: shown.said } : {}),
...(command ? { command } : {}),
});
}
return { agents };
Expand Down
94 changes: 88 additions & 6 deletions apps/hub/src/builds/posix-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,89 @@ import { requestErrorMetadata } from "../request-error.ts";
import type { buildPackageLayout } from "./package-layout.ts";
import { BROWSER_ARGS } from "./browser.ts";
import { CODER_REGISTRY_HOSTS } from "../config/packages.ts";
import { COMMAND_PROGRESS_FILE } from "@corbits-builder/schemas/activity";

const log = getLogger(["corbits-builder", "hub", "posix-sandbox"]);

// A command's own word on its progress: a stderr line with this prefix, which the sandbox relays
// to the hub and keeps out of the command's output. Set for commands the hub runs, so a command
// the agent runs itself prints no such lines into what the agent reads.
const PROGRESS_PREFIX = "progress: ";
const PROGRESS_ENV = "SB_PROGRESS";
const MAX_PROGRESS_LINE = 200;
// Progress is read on a poll; writing it more often than this changes nothing the person sees.
const PROGRESS_MIN_INTERVAL_MS = 1_000;

/** Where a command's latest word about itself sits: beside the workspace, out of the sandbox's reach. */
const progressFile = (workspace: string) =>
join(dirname(workspace), COMMAND_PROGRESS_FILE);

/**
* Records what a long command in the workspace is doing now, for the hub to show the person. A
* note that cannot be written costs the person a line, never the command, so it is logged instead.
*/
export async function noteCommandProgress(workspace: string, line: string) {
try {
await writeFile(
progressFile(workspace),
JSON.stringify({
line: line.replace(/\s+/g, " ").trim().slice(0, MAX_PROGRESS_LINE),
at: new Date().toISOString(),
}),
);
} catch (error) {
log.warn("Command progress could not be noted", {
workspace,
...requestErrorMetadata(error),
});
}
}

/** Forgets the command's progress once it has ended. */
export async function clearCommandProgress(workspace: string) {
try {
await rm(progressFile(workspace), { force: true });
} catch (error) {
log.warn("Command progress could not be cleared", {
workspace,
...requestErrorMetadata(error),
});
}
}

/**
* Relays a command's progress lines to the hub as they arrive and keeps the rest of its stderr
* for a failure. A progress line is noted at most once a second, since it is read on a poll.
*/
async function relayStderr(
stderr: ReadableStream<Uint8Array>,
workspace: string,
errors: string[],
) {
const decoder = new TextDecoder();
let pending = "";
let noted = 0;
const take = async (line: string) => {
if (!line.startsWith(PROGRESS_PREFIX)) {
errors.push(line);
return;
}
const now = Date.now();
if (now - noted < PROGRESS_MIN_INTERVAL_MS) return;
noted = now;
await noteCommandProgress(workspace, line.slice(PROGRESS_PREFIX.length));
};
for await (const chunk of stderr) {
pending += decoder.decode(chunk, { stream: true });
const end = pending.lastIndexOf("\n");
if (end === -1) continue;
const lines = pending.slice(0, end).split("\n");
pending = pending.slice(end + 1);
for (const line of lines) await take(line);
}
if (pending !== "") await take(pending);
}

export type BuildPackageLayout = Awaited<ReturnType<typeof buildPackageLayout>>;
// The workspace's dev app: its address, whether it runs and its recent output. stopped names the
// workspace whose dev script exited while the rest kept running, as `bun --filter` names it.
Expand Down Expand Up @@ -947,7 +1027,7 @@ export async function createPosixBuildExecutor(
],
{
cwd,
env: childEnv,
env: { ...childEnv, [PROGRESS_ENV]: "1" },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
Expand All @@ -958,17 +1038,18 @@ export async function createPosixBuildExecutor(
const stop = () => kill(child);
const timer = setTimeout(stop, limits.commandTimeoutMs);
signal.addEventListener("abort", stop, { once: true });
// Progress lines are relayed as they arrive; the rest of stderr is kept for a failure.
const errors: string[] = [];
const relay = relayStderr(child.stderr, cwd, errors);
try {
const [output, errors] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
const output = await new Response(child.stdout).text();
await relay;
const exitCode = await child.exited;
// A command that died before printing its checklist would otherwise fail with nothing said.
const text =
exitCode === 0
? output
: [output, errors.slice(-2000)]
: [output, errors.join("\n").slice(-2000)]
.filter((part) => part.trim())
.join("\n");
return { exitCode, output: text.replaceAll(cwd + sep, "") };
Expand All @@ -978,6 +1059,7 @@ export async function createPosixBuildExecutor(
// Chrome may outlive the command that started it.
stop();
commands.delete(child);
await clearCommandProgress(cwd);
}
},
/** What the running app complained of since the last call: its server's errors and its page's. */
Expand Down
36 changes: 25 additions & 11 deletions apps/hub/src/builds/sidecar-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts";
import { WorkflowHttpError } from "../workflows/http-json.ts";
import { APP_STATUS_TOOL, createCodingTools } from "./coding-tools.ts";
import { createPosixBuildExecutor } from "./posix-sandbox.ts";
import {
clearCommandProgress,
createPosixBuildExecutor,
noteCommandProgress,
} from "./posix-sandbox.ts";
import { browserTools } from "./browser.ts";
import type { BrowserCatalog } from "./browser-catalog.ts";
import {
Expand Down Expand Up @@ -154,6 +158,8 @@ const passedAppCheck = (call: ToolCall, result: ToolResult) =>

// A cold install; under the executor's own ceiling.
const SETUP_TIMEOUT_MS = 115_000;
// What the person reads while the workspace installs its packages, which can take the whole timeout.
const SETUP_PROGRESS = "installing the app's packages";

// A source-closure-safe declaration: only explicit policy is captured. The
// native child supplies the step workspace; the sandbox is built inside it.
Expand Down Expand Up @@ -282,17 +288,25 @@ export function createSidecarCodingTools(policy: {
);
try {
if (setup) {
const result = await executor.run(
{
id: "workspace-setup",
name: "run_shell",
arguments: {
command: setup.command,
timeout: SETUP_TIMEOUT_MS,
// The install says nothing the hub can relay until it ends, so the person is told what
// is running for its whole length.
await noteCommandProgress(workspace, SETUP_PROGRESS);
let result: ToolResult;
try {
result = await executor.run(
{
id: "workspace-setup",
name: "run_shell",
arguments: {
command: setup.command,
timeout: SETUP_TIMEOUT_MS,
},
},
},
signal,
);
signal,
);
} finally {
await clearCommandProgress(workspace);
}
if (!succeeded(result))
throw new Error(
`workspace_setup_failed: ${String(result.content).slice(-2000)}`,
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/pages/workspace/agent-activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import { activityPhrase } from "./agent-activity.tsx";

const coder = {
runId: "run-coder",
role: "coder" as const,
since: "2026-11-02T01:41:30.000Z",
tools: [
{
name: "read_file",
subject: ".inputs/brief.md",
running: true,
at: "2026-11-02T01:41:31.000Z",
},
],
};

test("a long command's own word on its progress replaces the reading of the call", () => {
expect(activityPhrase(coder).phrase).toMatch(/brief/);
expect(
activityPhrase({
...coder,
command: {
line: "installing the app's packages",
at: "2026-11-02T01:41:40.000Z",
},
}),
).toEqual({ phrase: "installing the app's packages", asking: false });
});
3 changes: 3 additions & 0 deletions apps/web/src/pages/workspace/agent-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export function activityPhrase(agent: AgentActivity): {
phrase: string;
asking: boolean;
} {
// A long command's own word on what it is doing beats a reading of the call that started it.
if (agent.command !== undefined)
return { phrase: agent.command.line, asking: false };
const latest = agent.tools.at(-1);
if (latest === undefined) {
if (agent.role === "coordinator")
Expand Down
13 changes: 13 additions & 0 deletions packages/schemas/src/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ export const ActivityTool = type({
});
export type ActivityTool = typeof ActivityTool.infer;

// What a long command a run is executing last said about itself: a line in the person's words,
// such as "installing the app's packages" or "checking the mockup: page 7 of 18", and when. The
// sandbox writes it beside the run's workspace while the command runs and removes it after, and
// the hub reads it from a sidecar on its own machine.
export const COMMAND_PROGRESS_FILE = "command-progress.json";
export const CommandProgress = type({
"+": "reject",
line: "string > 0",
at: "string > 0",
});
export type CommandProgress = typeof CommandProgress.infer;

// A run at work in the project, with what its own inference events last showed. A run the hub
// has seen no event from since the hub started has no tools and no text.
export const AgentActivity = type({
Expand All @@ -21,6 +33,7 @@ export const AgentActivity = type({
since: "string > 0",
tools: ActivityTool.array().atMostLength(3),
"said?": "string > 0",
"command?": CommandProgress,
});
export type AgentActivity = typeof AgentActivity.infer;

Expand Down
10 changes: 10 additions & 0 deletions workflows/coder/check/check-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ const WIDTHS = [
const MAX_PAGES = 30;
// run_shell's executor stops a command at 120 s; stop crawling early enough to print a result.
const DEADLINE = Date.now() + 100_000;
// A word on progress for the person watching, on stderr with the prefix the hub's sandbox relays.
// Only when the hub runs this check: an agent running it itself reads stderr and needs no such lines.
const progress = (line: string) => {
if (process.env["SB_PROGRESS"]) console.error(`progress: ${line}`);
};

const problems: Problem[] = [];
// Overlap misfired 30 times across 8 well-built sites at two widths, so it is reported, not failed.
Expand Down Expand Up @@ -630,6 +635,7 @@ if (staticRoot) {
}
const port = await freePort();
origin = `http://127.0.0.1:${port}`;
progress("checking the app: starting it");
const server = Bun.spawn(["bun", "run", "start"], {
env: { ...process.env, PORT: String(port) },
stdout: "ignore",
Expand Down Expand Up @@ -836,6 +842,10 @@ while (queue.length && visited.length < MAX_PAGES && Date.now() < DEADLINE) {
const path = queue.shift()!;
if (visited.includes(path)) continue;
visited.push(path);
const ahead = new Set(queue.filter((link) => !visited.includes(link))).size;
progress(
`${staticRoot ? "checking the mockup" : "checking the app"}: page ${String(visited.length)}, ${String(ahead)} more to open`,
);
page = path;
for (const [w, h] of WIDTHS) {
width = w;
Expand Down