Extend the smoke harness with Orb execution-plane preflight probes (Docker, OpenCode, Gitea reachability/creds/PR-creation, repository writable) that report BLOCKED with exact reasons when dependencies are absent. Add scripts/orb-project-run.ts as the missing connection point between OrbProjectManager and the Orb/Git lifecycle. Write docs/DOGFOOD_V0.md covering architecture, startup order, env vars, local/server flows, demo procedure, health checks, failure recovery, cleanup, known limitations, and next milestones. The smoke harness now runs 18 preflight checks across control-plane and Orb execution-plane layers, all with stable markers and sanitized JSON reports. The orb:run script (ORB_RUN=1) drives one issue through the full Orb project-manager lifecycle.
1081 lines
30 KiB
TypeScript
1081 lines
30 KiB
TypeScript
import { parseArgs } from "node:util";
|
|
|
|
import { api } from "@code/backend/convex/_generated/api";
|
|
import { parseSmokeEnv } from "@code/env/smoke";
|
|
import type { SmokeEnv } from "@code/env/smoke";
|
|
import {
|
|
advanceSmokeLoop,
|
|
decodeSmokeChatText,
|
|
decodeSmokeModelCatalog,
|
|
decodeSmokePlan,
|
|
decodeSmokeReview,
|
|
decodeSmokeWorkerReport,
|
|
initialSmokeLoopState,
|
|
redactSmokeText,
|
|
selectSmokeModelRoles,
|
|
validateSmokeCapabilities,
|
|
} from "@code/primitives/smoke";
|
|
import type {
|
|
SmokeCapabilityCheck,
|
|
SmokeLoopState,
|
|
SmokeModelRoles,
|
|
} from "@code/primitives/smoke";
|
|
import { createFlueClient } from "@flue/sdk";
|
|
import { ConvexHttpClient } from "convex/browser";
|
|
import { Effect } from "effect";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
const REQUIRED_ARTIFACTS = [
|
|
"agent.md",
|
|
"work.md",
|
|
"steps.md",
|
|
"artifacts.md",
|
|
"signals.md",
|
|
"agent-manager.md",
|
|
"context.md",
|
|
"card.md",
|
|
] as const;
|
|
|
|
type SmokeStatus =
|
|
| "preflight-passed"
|
|
| "completed"
|
|
| "contract-blocked"
|
|
| "failed";
|
|
|
|
interface SmokeReport {
|
|
readonly checks: readonly SmokeCapabilityCheck[];
|
|
readonly featureRequest: string;
|
|
readonly issueId?: string;
|
|
readonly loop: SmokeLoopState;
|
|
readonly mode: "preflight" | "run";
|
|
readonly roles?: Pick<SmokeModelRoles, "plannerReviewer" | "worker">;
|
|
readonly review?: {
|
|
readonly approved: boolean;
|
|
readonly findingsCount: number;
|
|
};
|
|
readonly status: SmokeStatus;
|
|
readonly worker?: {
|
|
readonly status: "completed" | "blocked" | "failed";
|
|
};
|
|
}
|
|
|
|
interface ChatMessage {
|
|
readonly content: string;
|
|
readonly role: "system" | "user";
|
|
}
|
|
|
|
const { values, positionals } = parseArgs({
|
|
allowPositionals: true,
|
|
args: process.argv.slice(2),
|
|
options: {
|
|
help: { default: false, short: "h", type: "boolean" },
|
|
report: { type: "string" },
|
|
run: { default: false, type: "boolean" },
|
|
"timeout-ms": { default: String(DEFAULT_TIMEOUT_MS), type: "string" },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const usage = `Usage: bun run smoke:zopu [--run] [--report <path>] [--timeout-ms <ms>]
|
|
|
|
Without --run, the command probes the control-plane (web/Convex/Flue/AgentOS/CPA)
|
|
and the Orb execution-plane (Docker/OpenCode/Gitea/repository) contracts.
|
|
With --run, it creates one issue in the explicit smoke project, drives the worker, and sends the result to the GLM planner/reviewer.
|
|
|
|
Required environment:
|
|
ZOPU_SMOKE_ACCESS_TOKEN Better Auth/Convex bearer token
|
|
ZOPU_SMOKE_PROJECT_ID disposable/test project id
|
|
ZOPU_SMOKE_CPA_API_KEY CPA key (or AGENT_MODEL_API_KEY)
|
|
ZOPU_SMOKE_CPA_BASE_URL CPA OpenAI-compatible /v1 URL (or AGENT_MODEL_BASE_URL)
|
|
ZOPU_SMOKE_CONVEX_URL Convex URL (or CONVEX_URL)
|
|
ZOPU_SMOKE_DAEMON_ID online local daemon id (or DAEMON_ID)
|
|
|
|
Optional environment:
|
|
ZOPU_SMOKE_FEATURE_REQUEST override the tiny feature request
|
|
ZOPU_SMOKE_WEB_URL default: SITE_URL or http://localhost:5173
|
|
ZOPU_SMOKE_FLUE_URL default: VITE_FLUE_URL or http://localhost:3583
|
|
|
|
Orb execution-plane environment (probed and reported as BLOCKED when absent):
|
|
GITEA_URL self-hosted Gitea base URL
|
|
GITEA_TOKEN Gitea API token for PR creation
|
|
ZOPU_SMOKE_REPOSITORY_PATH owner/repo slug for the PR-creation probe
|
|
ORB_DOCKER_IMAGE optional Docker image override
|
|
ORB_DOCKER_WORKSPACE optional Orb workspace root (default /tmp/orb-*)
|
|
`;
|
|
|
|
if (values.help || positionals.length > 0) {
|
|
console.log(usage);
|
|
process.exit(positionals.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
const runEffect = <A, E>(effect: Effect.Effect<A, E>): A =>
|
|
Effect.runSync(effect);
|
|
|
|
const errorMessage = (error: unknown, secrets: readonly string[]): string => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return redactSmokeText(message, secrets).replaceAll(/\s+/gu, " ").trim();
|
|
};
|
|
|
|
const withTimeout = (timeoutMs: number): AbortSignal =>
|
|
AbortSignal.timeout(timeoutMs);
|
|
|
|
const normalizeBaseUrl = (value: string): URL => {
|
|
const url = new URL(value);
|
|
if (!url.pathname.endsWith("/")) {
|
|
url.pathname = `${url.pathname}/`;
|
|
}
|
|
return url;
|
|
};
|
|
|
|
const endpoint = (baseUrl: URL, path: string): string =>
|
|
new URL(path, baseUrl).toString();
|
|
|
|
const addCheck = (
|
|
checks: SmokeCapabilityCheck[],
|
|
id: string,
|
|
passed: boolean,
|
|
message: string
|
|
) => {
|
|
checks.push({ id, message, passed });
|
|
};
|
|
|
|
const probeHttp = async (
|
|
checks: SmokeCapabilityCheck[],
|
|
id: string,
|
|
label: string,
|
|
url: string,
|
|
timeoutMs: number
|
|
): Promise<void> => {
|
|
try {
|
|
const response = await fetch(url, {
|
|
redirect: "manual",
|
|
signal: withTimeout(timeoutMs),
|
|
});
|
|
const passed = response.status >= 200 && response.status < 400;
|
|
addCheck(
|
|
checks,
|
|
id,
|
|
passed,
|
|
passed
|
|
? `${label} responded with HTTP ${response.status}`
|
|
: `${label} returned HTTP ${response.status}`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
id,
|
|
false,
|
|
`${label} is unreachable: ${errorMessage(error, [])}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const callOpenAiChat = async (input: {
|
|
readonly apiKey: string;
|
|
readonly baseUrl: URL;
|
|
readonly messages: readonly ChatMessage[];
|
|
readonly model: string;
|
|
readonly timeoutMs: number;
|
|
}): Promise<string> => {
|
|
const response = await fetch(endpoint(input.baseUrl, "chat/completions"), {
|
|
body: JSON.stringify({
|
|
max_tokens: 512,
|
|
messages: input.messages,
|
|
model: input.model,
|
|
temperature: 0,
|
|
}),
|
|
headers: {
|
|
Authorization: `Bearer ${input.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
method: "POST",
|
|
signal: withTimeout(input.timeoutMs),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`CPA ${input.model} completion returned HTTP ${response.status}`
|
|
);
|
|
}
|
|
const body = (await response.json()) as unknown;
|
|
return await Effect.runPromise(decodeSmokeChatText(body));
|
|
};
|
|
|
|
const parseJsonObject = (text: string): unknown => {
|
|
const withoutFence = text
|
|
.trim()
|
|
.replace(/^```(?:json)?\s*/iu, "")
|
|
.replace(/\s*```$/u, "");
|
|
const start = withoutFence.indexOf("{");
|
|
const end = withoutFence.lastIndexOf("}");
|
|
if (start === -1 || end <= start) {
|
|
throw new Error("model response did not contain a JSON object");
|
|
}
|
|
return JSON.parse(withoutFence.slice(start, end + 1)) as unknown;
|
|
};
|
|
|
|
const buildPlanPrompt = (featureRequest: string): readonly ChatMessage[] => [
|
|
{
|
|
content:
|
|
"You are the planner/reviewer for a disposable Zopu web-project smoke. Return JSON only with summary, steps, acceptanceCriteria, and nonGoals. Keep the plan tiny and testable.",
|
|
role: "system",
|
|
},
|
|
{ content: featureRequest, role: "user" },
|
|
];
|
|
|
|
const buildWorkerPrompt = (input: {
|
|
readonly featureRequest: string;
|
|
readonly plan: {
|
|
readonly acceptanceCriteria: readonly string[];
|
|
readonly nonGoals: readonly string[];
|
|
readonly steps: readonly string[];
|
|
readonly summary: string;
|
|
};
|
|
}): string => `Execute this one disposable web-project smoke issue.
|
|
|
|
Feature request: ${input.featureRequest}
|
|
|
|
Plan:
|
|
${JSON.stringify(input.plan)}
|
|
|
|
Rules:
|
|
- Work only in the bound project workspace.
|
|
- Use one tool call at a time; never issue parallel tool calls because this worker is minimax-m3.
|
|
- Inspect the bound repository before editing.
|
|
- Run the narrowest relevant test or check.
|
|
- Do not claim completion without observed changed files and command evidence.
|
|
- Return JSON only: {"status":"completed"|"blocked"|"failed","summary":"..."}`;
|
|
|
|
const buildReviewPrompt = (input: {
|
|
readonly featureRequest: string;
|
|
readonly plan: unknown;
|
|
readonly workerReport: unknown;
|
|
}): readonly ChatMessage[] => [
|
|
{
|
|
content:
|
|
"You are the independent GLM-5.2 planner/reviewer. Review the worker report against the feature request and plan. Return JSON only with approved, summary, and findings (an array of concise strings). Reject missing evidence or a non-completed worker.",
|
|
role: "system",
|
|
},
|
|
{
|
|
content: JSON.stringify({
|
|
featureRequest: input.featureRequest,
|
|
plan: input.plan,
|
|
workerReport: input.workerReport,
|
|
}),
|
|
role: "user",
|
|
},
|
|
];
|
|
|
|
const printReport = (report: SmokeReport): void => {
|
|
console.log(`ZOPU_SMOKE_${report.status.toUpperCase().replaceAll("-", "_")}`);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
};
|
|
|
|
const writeReport = async (
|
|
path: string | undefined,
|
|
report: SmokeReport
|
|
): Promise<void> => {
|
|
if (path !== undefined) {
|
|
await Bun.write(path, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
};
|
|
|
|
const waitForIssueStatus = async (
|
|
convex: ConvexHttpClient,
|
|
projectId: string,
|
|
issueId: string,
|
|
timeoutMs: number
|
|
): Promise<string | null> => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between durable reads.
|
|
const issues = await convex.query(api.projectIssues.list, {
|
|
projectId: projectId as never,
|
|
});
|
|
const issue = issues.find((candidate) => candidate._id === issueId);
|
|
if (issue?.status === "completed" || issue?.status === "failed") {
|
|
return issue.status;
|
|
}
|
|
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between durable reads.
|
|
await Bun.sleep(500);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const probeModelRoles = (
|
|
env: SmokeEnv,
|
|
cpaBaseUrl: URL,
|
|
checks: SmokeCapabilityCheck[],
|
|
secrets: readonly string[]
|
|
): SmokeModelRoles | undefined => {
|
|
try {
|
|
const roles = runEffect(
|
|
selectSmokeModelRoles({
|
|
plannerReviewerModel: "glm-5.2",
|
|
plannerReviewerProvider: "cheaptricks",
|
|
workerModel: env.agentModelName ?? "",
|
|
workerProvider: env.agentModelProvider ?? "cheaptricks",
|
|
workerToolCallMode: "serial",
|
|
})
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"model-roles",
|
|
true,
|
|
"worker=minimax-m3; planner-reviewer=glm-5.2"
|
|
);
|
|
const workerGatewayMatches =
|
|
env.agentModelBaseUrl !== undefined &&
|
|
normalizeBaseUrl(env.agentModelBaseUrl).toString() ===
|
|
cpaBaseUrl.toString();
|
|
addCheck(
|
|
checks,
|
|
"worker-gateway-config",
|
|
workerGatewayMatches,
|
|
workerGatewayMatches
|
|
? "the local worker points at the configured CPA base URL"
|
|
: "AGENT_MODEL_BASE_URL must match the CPA base URL; the harness will not rewrite it"
|
|
);
|
|
return roles;
|
|
} catch (error) {
|
|
addCheck(checks, "model-roles", false, errorMessage(error, secrets));
|
|
addCheck(
|
|
checks,
|
|
"worker-gateway-config",
|
|
false,
|
|
"worker gateway configuration could not be checked because the model role contract failed"
|
|
);
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const probeControlPlane = async (input: {
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly convex: ConvexHttpClient;
|
|
readonly daemonId: string;
|
|
readonly projectId: string | undefined;
|
|
readonly secrets: readonly string[];
|
|
}): Promise<void> => {
|
|
const { checks, convex, daemonId, projectId, secrets } = input;
|
|
try {
|
|
const health = await convex.query(api.healthCheck.get, {});
|
|
addCheck(
|
|
checks,
|
|
"convex",
|
|
health === "OK",
|
|
health === "OK"
|
|
? "Convex health query returned OK"
|
|
: "Convex health query returned an unexpected value"
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"convex",
|
|
false,
|
|
`Convex health query failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const organization = await convex.query(api.organizations.getCurrent, {});
|
|
addCheck(
|
|
checks,
|
|
"organization",
|
|
organization !== null,
|
|
organization
|
|
? "authenticated personal organization is available"
|
|
: "no personal organization; sign in through the web app before running the smoke"
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"organization",
|
|
false,
|
|
`organization query failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
|
|
if (projectId === undefined) {
|
|
addCheck(
|
|
checks,
|
|
"project-contract",
|
|
false,
|
|
"ZOPU_SMOKE_PROJECT_ID is required and must point at a disposable/test project"
|
|
);
|
|
} else {
|
|
// eslint-disable-next-line no-use-before-define -- the project probe is a focused helper below the control-plane flow.
|
|
await probeProjectContract({ checks, convex, projectId, secrets });
|
|
}
|
|
|
|
try {
|
|
const daemon = await convex.query(api.daemons.get, { daemonId });
|
|
const online = daemon.presence?.status === "online";
|
|
addCheck(
|
|
checks,
|
|
"agentos-daemon",
|
|
online,
|
|
online
|
|
? `daemon ${daemonId} is online`
|
|
: `daemon ${daemonId} is not online; start the local AgentOS daemon without changing CPA`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"agentos-daemon",
|
|
false,
|
|
`daemon probe failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const probeProjectContract = async (input: {
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly convex: ConvexHttpClient;
|
|
readonly projectId: string;
|
|
readonly secrets: readonly string[];
|
|
}): Promise<void> => {
|
|
const { checks, convex, projectId, secrets } = input;
|
|
try {
|
|
const project = await convex.query(api.projects.get, {
|
|
projectId: projectId as never,
|
|
});
|
|
const hasSource = (project?.sources.length ?? 0) > 0;
|
|
let projectMessage =
|
|
"project has no connected repository source; land the project backend lane first";
|
|
if (project === null) {
|
|
projectMessage =
|
|
"project was not found or is not accessible to the supplied token";
|
|
} else if (hasSource) {
|
|
projectMessage = "project source is available";
|
|
}
|
|
addCheck(
|
|
checks,
|
|
"project-contract",
|
|
project !== null && hasSource,
|
|
projectMessage
|
|
);
|
|
|
|
const artifacts = await convex.query(api.projectArtifacts.list, {
|
|
projectId: projectId as never,
|
|
});
|
|
const paths = new Set(artifacts.map((artifact) => artifact.path));
|
|
const missing = REQUIRED_ARTIFACTS.filter((path) => !paths.has(path));
|
|
addCheck(
|
|
checks,
|
|
"agentos-workspace-contract",
|
|
missing.length === 0,
|
|
missing.length === 0
|
|
? "issue-scoped AgentOS artifact contract is present"
|
|
: `missing AgentOS artifacts: ${missing.join(", ")}; land the project loop lane first`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"project-contract",
|
|
false,
|
|
`project query failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"agentos-workspace-contract",
|
|
false,
|
|
"project artifacts could not be inspected"
|
|
);
|
|
}
|
|
};
|
|
|
|
const probeFlue = async (input: {
|
|
readonly accessToken: string;
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly flueUrl: string;
|
|
readonly secrets: readonly string[];
|
|
}): Promise<ReturnType<typeof createFlueClient> | undefined> => {
|
|
const { accessToken, checks, flueUrl, secrets } = input;
|
|
const flueClient = createFlueClient({
|
|
baseUrl: flueUrl,
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
try {
|
|
await flueClient.agents.history(
|
|
"project-manager",
|
|
"zopu-smoke-capability-probe"
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"flue",
|
|
true,
|
|
"project-manager history endpoint is reachable"
|
|
);
|
|
return flueClient;
|
|
} catch (error) {
|
|
const message = errorMessage(error, secrets);
|
|
const streamMissing = message.includes("[stream_not_found]");
|
|
addCheck(
|
|
checks,
|
|
"flue",
|
|
streamMissing,
|
|
streamMissing
|
|
? "project-manager history route is reachable; the probe stream is absent as expected"
|
|
: `Flue project-manager endpoint failed: ${message}`
|
|
);
|
|
return streamMissing ? flueClient : undefined;
|
|
}
|
|
};
|
|
|
|
const probeGateway = async (input: {
|
|
readonly apiKey: string;
|
|
readonly baseUrl: URL;
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly secrets: readonly string[];
|
|
readonly timeoutMs: number;
|
|
}): Promise<void> => {
|
|
const { apiKey, baseUrl, checks, secrets, timeoutMs } = input;
|
|
try {
|
|
const response = await fetch(endpoint(baseUrl, "models"), {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: withTimeout(timeoutMs),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`CPA models returned HTTP ${response.status}`);
|
|
}
|
|
const catalog = await Effect.runPromise(
|
|
decodeSmokeModelCatalog(await response.json())
|
|
);
|
|
const modelIds = new Set(catalog.data.map((entry) => entry.id));
|
|
const missing = ["minimax-m3", "glm-5.2"].filter(
|
|
(model) => !modelIds.has(model)
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"cpa-model-catalog",
|
|
missing.length === 0,
|
|
missing.length === 0
|
|
? "CPA exposes minimax-m3 and glm-5.2"
|
|
: `CPA is missing canonical models: ${missing.join(", ")}`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"cpa-model-catalog",
|
|
false,
|
|
`CPA model probe failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
|
|
for (const model of ["minimax-m3", "glm-5.2"] as const) {
|
|
try {
|
|
// oxlint-disable-next-line no-await-in-loop -- M3 must never be probed in parallel with another worker call.
|
|
const text = await callOpenAiChat({
|
|
apiKey,
|
|
baseUrl,
|
|
messages: [{ content: "Reply with exactly OK.", role: "user" }],
|
|
model,
|
|
timeoutMs,
|
|
});
|
|
addCheck(
|
|
checks,
|
|
`cpa-completion-${model}`,
|
|
text.trim().length > 0,
|
|
`${model} accepted a no-tools OpenAI-compatible completion`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
`cpa-completion-${model}`,
|
|
false,
|
|
errorMessage(error, secrets)
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orb-path preflight probes — Docker, OpenCode, Git/Gitea, repository writable
|
|
//
|
|
// These probes check the Orb execution-plane dependencies that the
|
|
// OrbProjectManager relies on. They are intentionally separate from the
|
|
// control-plane/gateway probes: a missing Docker daemon or Gitea token
|
|
// reports as a BLOCKED preflight item, never a fake success.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const probeDocker = async (checks: SmokeCapabilityCheck[]): Promise<void> => {
|
|
try {
|
|
const proc = Bun.spawn(
|
|
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
{ stderr: "pipe", stdout: "pipe" }
|
|
);
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const exitCode = await proc.exited;
|
|
const version = stdout.trim();
|
|
if (exitCode === 0 && version.length > 0) {
|
|
addCheck(
|
|
checks,
|
|
"docker-daemon",
|
|
true,
|
|
`Docker server version ${version}`
|
|
);
|
|
} else {
|
|
addCheck(
|
|
checks,
|
|
"docker-daemon",
|
|
false,
|
|
"Docker daemon is not running; start Docker Desktop or dockerd"
|
|
);
|
|
}
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"docker-daemon",
|
|
false,
|
|
`Docker CLI unavailable: ${errorMessage(error, [])}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const probeOpenCodePackage = async (
|
|
checks: SmokeCapabilityCheck[]
|
|
): Promise<void> => {
|
|
try {
|
|
await import("@agentos-software/opencode");
|
|
addCheck(
|
|
checks,
|
|
"opencode-package",
|
|
true,
|
|
"@agentos-software/opencode is resolvable"
|
|
);
|
|
} catch {
|
|
addCheck(
|
|
checks,
|
|
"opencode-package",
|
|
false,
|
|
"@agentos-software/opencode is not installed; Orb AgentOS VM cannot link OpenCode"
|
|
);
|
|
}
|
|
};
|
|
|
|
const probeGitea = async (input: {
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly giteaToken: string | undefined;
|
|
readonly giteaUrl: string | undefined;
|
|
readonly repositoryPath: string | undefined;
|
|
readonly secrets: readonly string[];
|
|
readonly timeoutMs: number;
|
|
}): Promise<void> => {
|
|
const { checks, giteaToken, giteaUrl, repositoryPath, secrets, timeoutMs } =
|
|
input;
|
|
|
|
if (giteaUrl === undefined) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-reachable",
|
|
false,
|
|
"GITEA_URL is not set; set it to the self-hosted Gitea base URL for the Orb Git lifecycle"
|
|
);
|
|
} else {
|
|
try {
|
|
const response = await fetch(
|
|
`${giteaUrl.replace(/\/$/u, "")}/api/v1/version`,
|
|
{
|
|
headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {},
|
|
signal: withTimeout(timeoutMs),
|
|
}
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"gitea-reachable",
|
|
response.ok,
|
|
response.ok
|
|
? "Gitea API version endpoint is reachable"
|
|
: `Gitea API returned HTTP ${response.status}`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-reachable",
|
|
false,
|
|
`Gitea API is unreachable: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (giteaUrl === undefined || giteaToken === undefined) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-creds-valid",
|
|
false,
|
|
giteaToken === undefined
|
|
? "GITEA_TOKEN is not set; the Orb Git lifecycle needs it to create pull requests"
|
|
: "Gitea URL is not configured; cannot validate token"
|
|
);
|
|
} else {
|
|
try {
|
|
const response = await fetch(
|
|
`${giteaUrl.replace(/\/$/u, "")}/api/v1/user`,
|
|
{
|
|
headers: { Authorization: `token ${giteaToken}` },
|
|
signal: withTimeout(timeoutMs),
|
|
}
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"gitea-creds-valid",
|
|
response.ok,
|
|
response.ok
|
|
? "Gitea token is valid"
|
|
: `Gitea token returned HTTP ${response.status}`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-creds-valid",
|
|
false,
|
|
`Gitea credential probe failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
repositoryPath === undefined ||
|
|
giteaToken === undefined ||
|
|
giteaUrl === undefined
|
|
) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-pr-creation",
|
|
false,
|
|
repositoryPath === undefined
|
|
? "ZOPU_SMOKE_REPOSITORY_PATH (owner/repo) is not set; cannot probe PR creation"
|
|
: "Gitea URL or token is missing; cannot probe PR creation"
|
|
);
|
|
} else {
|
|
try {
|
|
const path = repositoryPath
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
const response = await fetch(
|
|
`${giteaUrl.replace(/\/$/u, "")}/api/v1/repos/${path}`,
|
|
{
|
|
headers: { Authorization: `token ${giteaToken}` },
|
|
signal: withTimeout(timeoutMs),
|
|
}
|
|
);
|
|
addCheck(
|
|
checks,
|
|
"gitea-pr-creation",
|
|
response.ok,
|
|
response.ok
|
|
? `Repository ${repositoryPath} is accessible for PR creation`
|
|
: `Repository ${repositoryPath} returned HTTP ${response.status}`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"gitea-pr-creation",
|
|
false,
|
|
`Gitea repository probe failed: ${errorMessage(error, secrets)}`
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const probeRepositoryWritable = async (
|
|
checks: SmokeCapabilityCheck[]
|
|
): Promise<void> => {
|
|
const { mkdir, rm, writeFile } = await import("node:fs/promises");
|
|
const workspace =
|
|
process.env.ORB_DOCKER_WORKSPACE ?? "/tmp/orb-smoke-workspace-probe";
|
|
try {
|
|
await mkdir(workspace, { recursive: true });
|
|
const probe = `${workspace}/.orb-write-probe`;
|
|
await writeFile(probe, "ok");
|
|
await rm(probe);
|
|
addCheck(
|
|
checks,
|
|
"repository-writable",
|
|
true,
|
|
`Orb workspace root ${workspace} is writable`
|
|
);
|
|
} catch (error) {
|
|
addCheck(
|
|
checks,
|
|
"repository-writable",
|
|
false,
|
|
`Orb workspace root is not writable: ${errorMessage(error, [])}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const probeOrbCapabilities = async (input: {
|
|
readonly checks: SmokeCapabilityCheck[];
|
|
readonly env: SmokeEnv;
|
|
readonly secrets: readonly string[];
|
|
readonly timeoutMs: number;
|
|
}): Promise<void> => {
|
|
const { checks, env, secrets, timeoutMs } = input;
|
|
await probeDocker(checks);
|
|
await probeOpenCodePackage(checks);
|
|
await probeGitea({
|
|
checks,
|
|
giteaToken: env.giteaToken,
|
|
giteaUrl: env.giteaUrl,
|
|
repositoryPath: env.repositoryPath,
|
|
secrets,
|
|
timeoutMs,
|
|
});
|
|
await probeRepositoryWritable(checks);
|
|
};
|
|
|
|
const runSmoke = async (input: {
|
|
readonly baseReport: Omit<SmokeReport, "status">;
|
|
readonly convex: ConvexHttpClient;
|
|
readonly cpaBaseUrl: URL;
|
|
readonly env: SmokeEnv;
|
|
readonly flueClient: ReturnType<typeof createFlueClient>;
|
|
readonly loop: SmokeLoopState;
|
|
readonly roles: SmokeModelRoles;
|
|
readonly secrets: readonly string[];
|
|
readonly timeoutMs: number;
|
|
}): Promise<number> => {
|
|
const {
|
|
baseReport,
|
|
convex,
|
|
cpaBaseUrl,
|
|
env,
|
|
flueClient,
|
|
roles,
|
|
secrets,
|
|
timeoutMs,
|
|
} = input;
|
|
const { projectId } = env;
|
|
if (projectId === undefined) {
|
|
throw new Error("run requested without ZOPU_SMOKE_PROJECT_ID");
|
|
}
|
|
let { loop } = input;
|
|
let issueId: string | undefined;
|
|
let worker: SmokeReport["worker"];
|
|
let review: SmokeReport["review"];
|
|
|
|
try {
|
|
const planText = await callOpenAiChat({
|
|
apiKey: env.cpaApiKey,
|
|
baseUrl: cpaBaseUrl,
|
|
messages: buildPlanPrompt(env.featureRequest),
|
|
model: roles.plannerReviewer.model,
|
|
timeoutMs,
|
|
});
|
|
const plan = await Effect.runPromise(
|
|
decodeSmokePlan(parseJsonObject(planText))
|
|
);
|
|
loop = runEffect(advanceSmokeLoop(loop, "plan-validated"));
|
|
|
|
issueId = await convex.mutation(api.projectIssues.create, {
|
|
body: `${env.featureRequest}\n\nSmoke plan:\n${JSON.stringify(plan)}`,
|
|
projectId: projectId as never,
|
|
title: "Zopu smoke: tiny web feature",
|
|
});
|
|
await convex.mutation(api.projectIssues.begin, {
|
|
issueId: issueId as never,
|
|
});
|
|
|
|
const workerResult = await flueClient.agents.prompt(
|
|
"project-manager",
|
|
issueId,
|
|
{
|
|
message: buildWorkerPrompt({
|
|
featureRequest: env.featureRequest,
|
|
plan,
|
|
}),
|
|
signal: withTimeout(timeoutMs),
|
|
}
|
|
);
|
|
const workerReport = await Effect.runPromise(
|
|
decodeSmokeWorkerReport(parseJsonObject(workerResult.result.text))
|
|
);
|
|
worker = { status: workerReport.status };
|
|
if (workerReport.status !== "completed") {
|
|
throw new Error(
|
|
`worker returned ${workerReport.status}: ${workerReport.summary}`
|
|
);
|
|
}
|
|
loop = runEffect(advanceSmokeLoop(loop, "worker-succeeded"));
|
|
|
|
const issueStatus = await waitForIssueStatus(
|
|
convex,
|
|
projectId,
|
|
issueId,
|
|
timeoutMs
|
|
);
|
|
if (issueStatus !== "completed") {
|
|
throw new Error(
|
|
`worker reported completed but durable issue status is ${issueStatus ?? "missing"}`
|
|
);
|
|
}
|
|
|
|
const reviewText = await callOpenAiChat({
|
|
apiKey: env.cpaApiKey,
|
|
baseUrl: cpaBaseUrl,
|
|
messages: buildReviewPrompt({
|
|
featureRequest: env.featureRequest,
|
|
plan,
|
|
workerReport,
|
|
}),
|
|
model: roles.plannerReviewer.model,
|
|
timeoutMs,
|
|
});
|
|
const reviewReport = await Effect.runPromise(
|
|
decodeSmokeReview(parseJsonObject(reviewText))
|
|
);
|
|
review = {
|
|
approved: reviewReport.approved,
|
|
findingsCount: reviewReport.findings.length,
|
|
};
|
|
if (!reviewReport.approved) {
|
|
throw new Error(
|
|
`GLM review rejected the worker result: ${reviewReport.summary}`
|
|
);
|
|
}
|
|
loop = runEffect(advanceSmokeLoop(loop, "review-approved"));
|
|
} catch (error) {
|
|
const message = errorMessage(error, secrets);
|
|
if (issueId !== undefined) {
|
|
await convex.mutation(api.projectIssues.markDispatchFailed, {
|
|
error: message,
|
|
issueId: issueId as never,
|
|
});
|
|
}
|
|
loop = runEffect(advanceSmokeLoop(loop, "runtime-failed", message));
|
|
const report: SmokeReport = {
|
|
...baseReport,
|
|
issueId,
|
|
loop,
|
|
status: "failed",
|
|
...(review === undefined ? {} : { review }),
|
|
...(worker === undefined ? {} : { worker }),
|
|
};
|
|
await writeReport(values.report, report);
|
|
printReport(report);
|
|
return 1;
|
|
}
|
|
|
|
const report: SmokeReport = {
|
|
...baseReport,
|
|
issueId,
|
|
loop,
|
|
review,
|
|
status: "completed",
|
|
worker,
|
|
};
|
|
await writeReport(values.report, report);
|
|
printReport(report);
|
|
return 0;
|
|
};
|
|
|
|
const main = async (): Promise<number> => {
|
|
const timeoutMs = Number(values["timeout-ms"] ?? DEFAULT_TIMEOUT_MS);
|
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
console.error(
|
|
"ZOPU_SMOKE_CONTRACT_BLOCKED: --timeout-ms must be a positive integer"
|
|
);
|
|
return 2;
|
|
}
|
|
|
|
let env: SmokeEnv;
|
|
try {
|
|
env = parseSmokeEnv(process.env);
|
|
} catch (error) {
|
|
console.error(
|
|
`ZOPU_SMOKE_CONTRACT_BLOCKED: environment contract failed: ${errorMessage(error, [])}`
|
|
);
|
|
return 2;
|
|
}
|
|
|
|
const secrets = [env.accessToken, env.cpaApiKey];
|
|
const checks: SmokeCapabilityCheck[] = [];
|
|
const convex = new ConvexHttpClient(env.convexUrl);
|
|
convex.setAuth(env.accessToken);
|
|
const cpaBaseUrl = normalizeBaseUrl(env.cpaBaseUrl);
|
|
const roles = probeModelRoles(env, cpaBaseUrl, checks, secrets);
|
|
await probeHttp(checks, "web", "web app", env.webUrl, timeoutMs);
|
|
await probeControlPlane({
|
|
checks,
|
|
convex,
|
|
daemonId: env.daemonId,
|
|
projectId: env.projectId,
|
|
secrets,
|
|
});
|
|
const flueClient = await probeFlue({
|
|
accessToken: env.accessToken,
|
|
checks,
|
|
flueUrl: env.flueUrl,
|
|
secrets,
|
|
});
|
|
await probeGateway({
|
|
apiKey: env.cpaApiKey,
|
|
baseUrl: cpaBaseUrl,
|
|
checks,
|
|
secrets,
|
|
timeoutMs,
|
|
});
|
|
|
|
await probeOrbCapabilities({
|
|
checks,
|
|
env,
|
|
secrets,
|
|
timeoutMs,
|
|
});
|
|
const initialLoop = initialSmokeLoopState();
|
|
let loop: SmokeLoopState;
|
|
try {
|
|
runEffect(validateSmokeCapabilities(checks));
|
|
loop = runEffect(advanceSmokeLoop(initialLoop, "preflight-passed"));
|
|
} catch (error) {
|
|
loop = runEffect(
|
|
advanceSmokeLoop(
|
|
initialLoop,
|
|
"contract-failed",
|
|
errorMessage(error, secrets)
|
|
)
|
|
);
|
|
}
|
|
|
|
const baseReport = {
|
|
checks,
|
|
featureRequest: redactSmokeText(env.featureRequest, secrets),
|
|
loop,
|
|
mode: values.run ? ("run" as const) : ("preflight" as const),
|
|
...(roles === undefined ? {} : { roles }),
|
|
};
|
|
if (loop.phase === "failed") {
|
|
const report = { ...baseReport, status: "contract-blocked" as const };
|
|
await writeReport(values.report, report);
|
|
printReport(report);
|
|
return 2;
|
|
}
|
|
if (!values.run) {
|
|
const report = { ...baseReport, status: "preflight-passed" as const };
|
|
await writeReport(values.report, report);
|
|
printReport(report);
|
|
return 0;
|
|
}
|
|
if (roles === undefined || flueClient === undefined) {
|
|
throw new Error("run prerequisites were not retained after preflight");
|
|
}
|
|
return await runSmoke({
|
|
baseReport,
|
|
convex,
|
|
cpaBaseUrl,
|
|
env,
|
|
flueClient,
|
|
loop,
|
|
roles,
|
|
secrets,
|
|
timeoutMs,
|
|
});
|
|
};
|
|
|
|
const exitCode = await main().catch((error: unknown) => {
|
|
console.error(`ZOPU_SMOKE_FAILED: ${errorMessage(error, [])}`);
|
|
return 1;
|
|
});
|
|
process.exitCode = exitCode;
|