diff --git a/bun.lock b/bun.lock index 734798d..a9f0124 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,16 @@ "": { "name": "code", "devDependencies": { + "@code/backend": "workspace:*", "@code/config": "workspace:*", + "@code/env": "workspace:*", + "@code/primitives": "workspace:*", + "@flue/sdk": "1.0.0-beta.9", "@types/bun": "catalog:", "@types/node": "^22.13.14", + "convex": "catalog:", "convex-test": "catalog:", + "effect": "catalog:", "oxfmt": "latest", "oxlint": "latest", "rolldown": "1.1.4", @@ -112,6 +118,7 @@ "@code/auth": "workspace:*", "@code/backend": "workspace:*", "@code/env": "workspace:*", + "@code/primitives": "workspace:*", "@code/ui": "workspace:*", "@flue/react": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9", diff --git a/docs/SMOKE.md b/docs/SMOKE.md new file mode 100644 index 0000000..469223f --- /dev/null +++ b/docs/SMOKE.md @@ -0,0 +1,46 @@ +# Zopu Integration Smoke + +The smoke harness is a repeatable integration lane for the thin Zopu MVP. It probes the local web app, authenticated Convex control plane, Flue `project-manager` route, AgentOS daemon/workspace contract, and the CPA OpenAI-compatible gateway before it creates any project issue. + +The harness does not edit CPA configuration, restart services, print bearer tokens/API keys, or run parallel worker calls. `minimax-m3` is the serial worker-loop model; `glm-5.2` performs the plan and the independent review. + +## Setup + +Use a disposable/test project that already has a connected repository source and the issue-scoped artifact set. Export a Better Auth/Convex access token for the signed-in user, the local daemon id, and the CPA key/base URL. The CPA URL must include its OpenAI-compatible `/v1` path. + +```bash +export ZOPU_SMOKE_ACCESS_TOKEN='...' +export ZOPU_SMOKE_PROJECT_ID='...' +export ZOPU_SMOKE_DAEMON_ID='local-macbook' +export ZOPU_SMOKE_CPA_BASE_URL='https://ai.example.invalid/v1' +export ZOPU_SMOKE_CPA_API_KEY='...' +``` + +The harness reuses `AGENT_MODEL_*`, `CONVEX_URL`, `DAEMON_ID`, `SITE_URL`, and `VITE_FLUE_URL` when their `ZOPU_SMOKE_*` equivalents are absent. It never rewrites those values. Override the tiny feature request with `ZOPU_SMOKE_FEATURE_REQUEST` when the disposable project needs a more specific target. + +## Commands + +Run capability probes only: + +```bash +bun run smoke:zopu +``` + +Drive one issue through the project loop and review it: + +```bash +bun run smoke:zopu --run --report /tmp/zopu-smoke.json +``` + +The command prints one of these stable markers: + +- `ZOPU_SMOKE_PREFLIGHT_PASSED` means all probes passed and no mutation was requested. +- `ZOPU_SMOKE_COMPLETED` means the issue was created, the worker reported completion, the durable issue reached `completed`, and GLM approved the result. +- `ZOPU_SMOKE_CONTRACT_BLOCKED` means a required capability or configuration is missing; the JSON report names the exact check and next action. +- `ZOPU_SMOKE_FAILED` means runtime execution started but did not satisfy the worker/reviewer contract. + +Exit codes are `0` for a passing preflight or completed run, `2` for a contract block, and `1` for a runtime failure. Reports contain only endpoint labels, statuses, counts, ids, and sanitized failure text; model transcripts and credentials are intentionally omitted. + +## Current lane boundary + +The smoke intentionally stops before mutation when the project source, issue-scoped AgentOS artifacts, or online daemon contract is absent. Those checks are the integration boundary for the project-loop and daemon lanes; do not paper over them in this harness or make a live gateway change to satisfy a failed probe. diff --git a/package.json b/package.json index 6493a4f..0975630 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "check": "ultracite check", "lint": "vp lint", "format": "vp fmt", + "smoke:zopu": "bun scripts/zopu-smoke.ts", "staged": "vp staged", "hooks:setup": "vp config", "dev:native": "vp run --filter native dev", @@ -62,16 +63,22 @@ }, "dependencies": {}, "devDependencies": { + "@code/backend": "workspace:*", "@code/config": "workspace:*", + "@code/env": "workspace:*", + "@code/primitives": "workspace:*", + "@flue/sdk": "1.0.0-beta.9", "@types/bun": "catalog:", "@types/node": "^22.13.14", + "convex": "catalog:", + "convex-test": "catalog:", + "effect": "catalog:", "oxfmt": "latest", "oxlint": "latest", "rolldown": "1.1.4", "typescript": "catalog:", "ultracite": "7.9.3", "vite-plus": "0.2.2", - "convex-test": "catalog:", "vitest": "catalog:" }, "overrides": { diff --git a/packages/env/package.json b/packages/env/package.json index 027252b..4ae2318 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -8,8 +8,12 @@ "./convex": "./src/convex.ts", "./native": "./src/native.ts", "./server": "./src/server.ts", + "./smoke": "./src/smoke.ts", "./web": "./src/web.ts" }, + "scripts": { + "check-types": "tsc --noEmit" + }, "dependencies": { "@t3-oss/env-core": "^0.13.11", "zod": "catalog:" diff --git a/packages/env/src/smoke.ts b/packages/env/src/smoke.ts new file mode 100644 index 0000000..6df3abe --- /dev/null +++ b/packages/env/src/smoke.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +const smokeEnvSchema = z.object({ + accessToken: z.string().min(1), + agentModelBaseUrl: z.url().optional(), + agentModelName: z.string().min(1).optional(), + agentModelProvider: z.string().min(1).optional(), + convexUrl: z.url(), + cpaApiKey: z.string().min(1), + cpaBaseUrl: z.url(), + daemonId: z.string().min(1), + featureRequest: z.string().min(10), + flueUrl: z.url(), + projectId: z.string().min(1).optional(), + webUrl: z.url(), +}); + +export type SmokeEnv = z.infer; + +export const parseSmokeEnv = ( + runtimeEnv: Record +): SmokeEnv => + smokeEnvSchema.parse({ + accessToken: runtimeEnv.ZOPU_SMOKE_ACCESS_TOKEN, + agentModelBaseUrl: runtimeEnv.AGENT_MODEL_BASE_URL, + agentModelName: runtimeEnv.AGENT_MODEL_NAME, + agentModelProvider: runtimeEnv.AGENT_MODEL_PROVIDER, + convexUrl: runtimeEnv.ZOPU_SMOKE_CONVEX_URL ?? runtimeEnv.CONVEX_URL, + cpaApiKey: + runtimeEnv.ZOPU_SMOKE_CPA_API_KEY ?? runtimeEnv.AGENT_MODEL_API_KEY, + cpaBaseUrl: + runtimeEnv.ZOPU_SMOKE_CPA_BASE_URL ?? runtimeEnv.AGENT_MODEL_BASE_URL, + daemonId: runtimeEnv.ZOPU_SMOKE_DAEMON_ID ?? runtimeEnv.DAEMON_ID, + featureRequest: + runtimeEnv.ZOPU_SMOKE_FEATURE_REQUEST ?? + "Add a tiny accessible status control to the existing web project's primary page, keep existing behavior unchanged, and add a focused test for it.", + flueUrl: + runtimeEnv.ZOPU_SMOKE_FLUE_URL ?? + runtimeEnv.VITE_FLUE_URL ?? + "http://localhost:3583", + projectId: runtimeEnv.ZOPU_SMOKE_PROJECT_ID, + webUrl: + runtimeEnv.ZOPU_SMOKE_WEB_URL ?? + runtimeEnv.SITE_URL ?? + "http://localhost:5173", + }); diff --git a/packages/primitives/package.json b/packages/primitives/package.json index c1d8789..54d58ed 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -11,7 +11,8 @@ "./project-issue": "./src/project-issue.ts", "./project-workspace": "./src/project-workspace.ts", "./project-work": "./src/project-work.ts", - "./signal": "./src/signal.ts" + "./signal": "./src/signal.ts", + "./smoke": "./src/smoke.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7fd3b85..600f61c 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -6,3 +6,4 @@ export * from "./project-issue"; export * from "./project-workspace"; export * from "./project-work"; export * from "./signal"; +export * from "./smoke"; diff --git a/packages/primitives/src/smoke.test.ts b/packages/primitives/src/smoke.test.ts new file mode 100644 index 0000000..cdc862e --- /dev/null +++ b/packages/primitives/src/smoke.test.ts @@ -0,0 +1,164 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + SmokeContractError, + advanceSmokeLoop, + decodeSmokeChatText, + decodeSmokeModelCatalog, + decodeSmokePlan, + decodeSmokeReview, + decodeSmokeWorkerReport, + initialSmokeLoopState, + redactSmokeText, + selectSmokeModelRoles, + validateSmokeCapabilities, +} from "./smoke"; + +const validRoleSelection = () => ({ + plannerReviewerModel: "glm-5.2", + plannerReviewerProvider: "cheaptricks", + workerModel: "minimax-m3", + workerProvider: "cheaptricks", + workerToolCallMode: "serial" as const, +}); + +const run = (effect: Effect.Effect): A => Effect.runSync(effect); + +describe("smoke primitives", () => { + it("selects the planner/reviewer and serial M3 worker roles", () => { + const roles = run(selectSmokeModelRoles(validRoleSelection())); + + expect(roles.worker).toMatchObject({ + model: "minimax-m3", + role: "worker", + toolCallMode: "serial", + }); + expect(roles.plannerReviewer).toMatchObject({ + model: "glm-5.2", + role: "planner-reviewer", + }); + }); + + it("rejects a non-M3 worker or non-GLM planner", () => { + const worker = validRoleSelection(); + worker.workerModel = "glm-5.2"; + const workerError = run(Effect.flip(selectSmokeModelRoles(worker))); + expect(workerError).toBeInstanceOf(SmokeContractError); + expect(workerError.reason).toBe("InvalidModelRole"); + + const planner = validRoleSelection(); + planner.plannerReviewerModel = "minimax-m3"; + const plannerError = run(Effect.flip(selectSmokeModelRoles(planner))); + expect(plannerError.reason).toBe("InvalidModelRole"); + }); + + it("advances only through the serial smoke loop", () => { + let state = initialSmokeLoopState(); + state = run(advanceSmokeLoop(state, "preflight-passed")); + state = run(advanceSmokeLoop(state, "plan-validated")); + state = run(advanceSmokeLoop(state, "worker-succeeded")); + state = run(advanceSmokeLoop(state, "review-approved")); + + expect(state.phase).toBe("completed"); + expect(state.history).toEqual([ + "preflight", + "planning", + "worker-running", + "reviewing", + "completed", + ]); + }); + + it("turns contract failures into a terminal state", () => { + const state = run( + advanceSmokeLoop( + initialSmokeLoopState(), + "contract-failed", + "daemon is offline" + ) + ); + + expect(state.phase).toBe("failed"); + expect(state.lastError).toBe("daemon is offline"); + }); + + it("rejects out-of-order loop transitions", () => { + const error = run( + Effect.flip(advanceSmokeLoop(initialSmokeLoopState(), "review-approved")) + ); + + expect(error).toBeInstanceOf(SmokeContractError); + expect(error.reason).toBe("InvalidTransition"); + }); + + it("validates model reports at the primitive boundary", () => { + const plan = run( + decodeSmokePlan({ + acceptanceCriteria: ["The button renders"], + nonGoals: [], + steps: ["Add the button", "Run the web check"], + summary: "Add a small button to the disposable project", + }) + ); + const worker = run( + decodeSmokeWorkerReport({ + status: "completed", + summary: "Changed the project and ran the targeted check.", + }) + ); + const review = run( + decodeSmokeReview({ + approved: true, + findings: [], + summary: "The change matches the acceptance criteria.", + }) + ); + + expect(plan.steps).toHaveLength(2); + expect(worker.status).toBe("completed"); + expect(review.approved).toBe(true); + }); + + it("rejects empty or malformed model output", () => { + const empty = run( + Effect.flip( + decodeSmokeChatText({ choices: [{ message: { content: " " } }] }) + ) + ); + expect(empty.reason).toBe("InvalidModelResponse"); + + const malformed = run(Effect.flip(decodeSmokeReview({ approved: true }))); + expect(malformed.reason).toBe("InvalidReport"); + }); + + it("validates the canonical CPA model catalog", () => { + const catalog = run( + decodeSmokeModelCatalog({ + data: [{ id: "minimax-m3" }, { id: "glm-5.2" }], + }) + ); + + expect(catalog.data.map(({ id }) => id)).toEqual(["minimax-m3", "glm-5.2"]); + }); + + it("fails a capability report when any required check fails", () => { + const error = run( + Effect.flip( + validateSmokeCapabilities([ + { id: "web", message: "ready", passed: true }, + { id: "daemon", message: "daemon is offline", passed: false }, + ]) + ) + ); + + expect(error.reason).toBe("InvalidReport"); + expect(error.message).toContain("daemon is offline"); + }); + + it("redacts secrets without changing unrelated text", () => { + expect( + redactSmokeText("status token=secret-key endpoint=ok", ["secret-key"]) + ).toBe("status token=[REDACTED] endpoint=ok"); + }); +}); diff --git a/packages/primitives/src/smoke.ts b/packages/primitives/src/smoke.ts new file mode 100644 index 0000000..d55fe11 --- /dev/null +++ b/packages/primitives/src/smoke.ts @@ -0,0 +1,347 @@ +/* eslint-disable max-classes-per-file -- the smoke contract keeps its schemas, errors, and transitions together. */ +import { Effect, Schema } from "effect"; + +const MeaningfulString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +const SmokePhase = Schema.Literals([ + "preflight", + "planning", + "worker-running", + "reviewing", + "completed", + "failed", +]); +export type SmokePhase = typeof SmokePhase.Type; + +const SmokeTransition = Schema.Literals([ + "preflight-passed", + "plan-validated", + "worker-succeeded", + "review-approved", + "contract-failed", + "runtime-failed", +]); +type SmokeTransition = typeof SmokeTransition.Type; + +export const SmokeRole = Schema.Literals(["planner-reviewer", "worker"]); +export type SmokeRole = typeof SmokeRole.Type; + +const SmokeToolCallMode = Schema.Literal("serial"); + +export const SmokeRoleSelectionInput = Schema.Struct({ + plannerReviewerModel: MeaningfulString, + plannerReviewerProvider: MeaningfulString, + workerModel: MeaningfulString, + workerProvider: MeaningfulString, + workerToolCallMode: SmokeToolCallMode, +}); +export type SmokeRoleSelectionInput = typeof SmokeRoleSelectionInput.Type; + +export const SmokeModelRole = Schema.Struct({ + model: MeaningfulString, + provider: MeaningfulString, + role: SmokeRole, + toolCallMode: Schema.Literal("serial"), +}); +export type SmokeModelRole = typeof SmokeModelRole.Type; + +export const SmokeModelRoles = Schema.Struct({ + plannerReviewer: SmokeModelRole, + worker: SmokeModelRole, +}); +export type SmokeModelRoles = typeof SmokeModelRoles.Type; + +export const SmokeContractReason = Schema.Literals([ + "InvalidInput", + "InvalidModelRole", + "InvalidTransition", + "InvalidModelResponse", + "InvalidReport", +]); +export type SmokeContractReason = typeof SmokeContractReason.Type; + +export class SmokeContractError extends Schema.TaggedErrorClass()( + "SmokeContractError", + { + message: Schema.String, + reason: SmokeContractReason, + } +) {} + +export const selectSmokeModelRoles = Effect.fn("Smoke.selectModelRoles")( + function* selectSmokeModelRoles(input: unknown) { + const selection: SmokeRoleSelectionInput = + yield* Schema.decodeUnknownEffect(SmokeRoleSelectionInput)(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidInput", + }) + ) + ); + + if (selection.workerModel !== "minimax-m3") { + return yield* Effect.fail( + new SmokeContractError({ + message: "The smoke worker must use minimax-m3", + reason: "InvalidModelRole", + }) + ); + } + + if (selection.plannerReviewerModel !== "glm-5.2") { + return yield* Effect.fail( + new SmokeContractError({ + message: "The smoke planner/reviewer must use glm-5.2", + reason: "InvalidModelRole", + }) + ); + } + + return { + plannerReviewer: { + model: selection.plannerReviewerModel, + provider: selection.plannerReviewerProvider, + role: "planner-reviewer" as const, + toolCallMode: "serial" as const, + }, + worker: { + model: selection.workerModel, + provider: selection.workerProvider, + role: "worker" as const, + toolCallMode: "serial" as const, + }, + } satisfies SmokeModelRoles; + } +); + +export const SmokePlan = Schema.Struct({ + acceptanceCriteria: Schema.NonEmptyArray(MeaningfulString), + nonGoals: Schema.Array(MeaningfulString), + steps: Schema.NonEmptyArray(MeaningfulString), + summary: MeaningfulString, +}); +export type SmokePlan = typeof SmokePlan.Type; + +export const SmokeWorkerReport = Schema.Struct({ + status: Schema.Literals(["completed", "blocked", "failed"]), + summary: MeaningfulString, +}); +export type SmokeWorkerReport = typeof SmokeWorkerReport.Type; + +export const SmokeReview = Schema.Struct({ + approved: Schema.Boolean, + findings: Schema.Array(MeaningfulString), + summary: MeaningfulString, +}); +export type SmokeReview = typeof SmokeReview.Type; + +export const SmokeChatResponse = Schema.Struct({ + choices: Schema.NonEmptyArray( + Schema.Struct({ + message: Schema.Struct({ content: Schema.String }), + }) + ), +}); + +export const SmokeModelCatalog = Schema.Struct({ + data: Schema.Array(Schema.Struct({ id: MeaningfulString })), +}); +export type SmokeModelCatalog = typeof SmokeModelCatalog.Type; + +export const decodeSmokeModelCatalog = Effect.fn("Smoke.decodeModelCatalog")( + function* decodeSmokeModelCatalog(input: unknown) { + return yield* Schema.decodeUnknownEffect(SmokeModelCatalog)(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidModelResponse", + }) + ) + ); + } +); + +export const decodeSmokeChatText = Effect.fn("Smoke.decodeChatText")( + function* decodeSmokeChatText(input: unknown) { + const response = yield* Schema.decodeUnknownEffect(SmokeChatResponse)( + input + ).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidModelResponse", + }) + ) + ); + const text = response.choices[0].message.content; + if (text.trim().length === 0) { + return yield* Effect.fail( + new SmokeContractError({ + message: "The model returned an empty response", + reason: "InvalidModelResponse", + }) + ); + } + return text; + } +); + +export const decodeSmokePlan = Effect.fn("Smoke.decodePlan")( + function* decodeSmokePlan(input: unknown) { + return yield* Schema.decodeUnknownEffect(SmokePlan)(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidReport", + }) + ) + ); + } +); + +export const decodeSmokeWorkerReport = Effect.fn("Smoke.decodeWorkerReport")( + function* decodeSmokeWorkerReport(input: unknown) { + return yield* Schema.decodeUnknownEffect(SmokeWorkerReport)(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidReport", + }) + ) + ); + } +); + +export const decodeSmokeReview = Effect.fn("Smoke.decodeReview")( + function* decodeSmokeReview(input: unknown) { + return yield* Schema.decodeUnknownEffect(SmokeReview)(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidReport", + }) + ) + ); + } +); + +export const SmokeCapabilityCheck = Schema.Struct({ + id: MeaningfulString, + message: MeaningfulString, + passed: Schema.Boolean, +}); +export type SmokeCapabilityCheck = typeof SmokeCapabilityCheck.Type; + +export const validateSmokeCapabilities = Effect.fn( + "Smoke.validateCapabilities" +)(function* validateSmokeCapabilities(input: unknown) { + const checks = yield* Schema.decodeUnknownEffect( + Schema.NonEmptyArray(SmokeCapabilityCheck) + )(input).pipe( + Effect.mapError( + (cause) => + new SmokeContractError({ + message: cause.message, + reason: "InvalidInput", + }) + ) + ); + const failed = checks.filter((check) => !check.passed); + if (failed.length > 0) { + return yield* Effect.fail( + new SmokeContractError({ + message: failed + .map((check) => `${check.id}: ${check.message}`) + .join("; "), + reason: "InvalidReport", + }) + ); + } + return checks; +}); + +export const SmokeLoopState = Schema.Struct({ + history: Schema.Array(SmokePhase), + lastError: Schema.NullOr(Schema.String), + phase: SmokePhase, +}); +export type SmokeLoopState = typeof SmokeLoopState.Type; + +export const initialSmokeLoopState = (): SmokeLoopState => ({ + history: ["preflight"], + lastError: null, + phase: "preflight", +}); + +const nextPhase: Readonly< + Record>> +> = { + completed: {}, + failed: {}, + planning: { + "plan-validated": "worker-running", + "runtime-failed": "failed", + }, + preflight: { + "contract-failed": "failed", + "preflight-passed": "planning", + "runtime-failed": "failed", + }, + reviewing: { + "contract-failed": "failed", + "review-approved": "completed", + "runtime-failed": "failed", + }, + "worker-running": { + "contract-failed": "failed", + "runtime-failed": "failed", + "worker-succeeded": "reviewing", + }, +}; + +export const advanceSmokeLoop = Effect.fn("Smoke.advanceLoop")( + function* advanceSmokeLoop( + state: SmokeLoopState, + transition: SmokeTransition, + error?: string + ) { + const phase = nextPhase[state.phase][transition]; + if (phase === undefined) { + return yield* Effect.fail( + new SmokeContractError({ + message: `Cannot apply ${transition} while smoke loop is ${state.phase}`, + reason: "InvalidTransition", + }) + ); + } + return { + history: [...state.history, phase], + lastError: phase === "failed" ? (error ?? "Smoke loop failed") : null, + phase, + } satisfies SmokeLoopState; + } +); + +export const redactSmokeText = ( + text: string, + secrets: readonly string[] +): string => { + let redacted = text; + for (const secret of secrets) { + if (secret.length > 0) { + redacted = redacted.replaceAll(secret, "[REDACTED]"); + } + } + return redacted; +}; diff --git a/scripts/zopu-smoke.ts b/scripts/zopu-smoke.ts new file mode 100644 index 0000000..49f60c4 --- /dev/null +++ b/scripts/zopu-smoke.ts @@ -0,0 +1,829 @@ +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; + 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 ] [--timeout-ms ] + +Without --run, the command only probes the local web/Convex/Flue/AgentOS and CPA 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 +`; + +if (values.help || positionals.length > 0) { + console.log(usage); + process.exit(positionals.length > 0 ? 1 : 0); +} + +const runEffect = (effect: Effect.Effect): 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 | 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 => { + 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) + ); + } + } +}; + +const runSmoke = async (input: { + readonly baseReport: Omit; + readonly convex: ConvexHttpClient; + readonly cpaBaseUrl: URL; + readonly env: SmokeEnv; + readonly flueClient: ReturnType; + readonly loop: SmokeLoopState; + readonly roles: SmokeModelRoles; + readonly secrets: readonly string[]; + readonly timeoutMs: number; +}): Promise => { + 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 => { + 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, + }); + + 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;