Merge branch 'puter/issue-9-gitea-smoke' into puter/issue-14-integration
# Conflicts: # packages/primitives/package.json
This commit is contained in:
7
bun.lock
7
bun.lock
@@ -5,10 +5,16 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@code/backend": "workspace:*",
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
|
"@flue/sdk": "1.0.0-beta.9",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
|
"convex": "catalog:",
|
||||||
"convex-test": "catalog:",
|
"convex-test": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"oxfmt": "latest",
|
"oxfmt": "latest",
|
||||||
"oxlint": "latest",
|
"oxlint": "latest",
|
||||||
"rolldown": "1.1.4",
|
"rolldown": "1.1.4",
|
||||||
@@ -112,6 +118,7 @@
|
|||||||
"@code/auth": "workspace:*",
|
"@code/auth": "workspace:*",
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@code/ui": "workspace:*",
|
"@code/ui": "workspace:*",
|
||||||
"@flue/react": "1.0.0-beta.9",
|
"@flue/react": "1.0.0-beta.9",
|
||||||
"@flue/sdk": "1.0.0-beta.9",
|
"@flue/sdk": "1.0.0-beta.9",
|
||||||
|
|||||||
46
docs/SMOKE.md
Normal file
46
docs/SMOKE.md
Normal file
@@ -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.
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"check": "ultracite check",
|
"check": "ultracite check",
|
||||||
"lint": "vp lint",
|
"lint": "vp lint",
|
||||||
"format": "vp fmt",
|
"format": "vp fmt",
|
||||||
|
"smoke:zopu": "bun scripts/zopu-smoke.ts",
|
||||||
"staged": "vp staged",
|
"staged": "vp staged",
|
||||||
"hooks:setup": "vp config",
|
"hooks:setup": "vp config",
|
||||||
"dev:native": "vp run --filter native dev",
|
"dev:native": "vp run --filter native dev",
|
||||||
@@ -62,16 +63,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@code/backend": "workspace:*",
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
|
"@flue/sdk": "1.0.0-beta.9",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
|
"convex": "catalog:",
|
||||||
|
"convex-test": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"oxfmt": "latest",
|
"oxfmt": "latest",
|
||||||
"oxlint": "latest",
|
"oxlint": "latest",
|
||||||
"rolldown": "1.1.4",
|
"rolldown": "1.1.4",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"ultracite": "7.9.3",
|
"ultracite": "7.9.3",
|
||||||
"vite-plus": "0.2.2",
|
"vite-plus": "0.2.2",
|
||||||
"convex-test": "catalog:",
|
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
4
packages/env/package.json
vendored
4
packages/env/package.json
vendored
@@ -8,8 +8,12 @@
|
|||||||
"./convex": "./src/convex.ts",
|
"./convex": "./src/convex.ts",
|
||||||
"./native": "./src/native.ts",
|
"./native": "./src/native.ts",
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
|
"./smoke": "./src/smoke.ts",
|
||||||
"./web": "./src/web.ts"
|
"./web": "./src/web.ts"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@t3-oss/env-core": "^0.13.11",
|
"@t3-oss/env-core": "^0.13.11",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
|
|||||||
46
packages/env/src/smoke.ts
vendored
Normal file
46
packages/env/src/smoke.ts
vendored
Normal file
@@ -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<typeof smokeEnvSchema>;
|
||||||
|
|
||||||
|
export const parseSmokeEnv = (
|
||||||
|
runtimeEnv: Record<string, string | undefined>
|
||||||
|
): 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",
|
||||||
|
});
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"./project-issue": "./src/project-issue.ts",
|
"./project-issue": "./src/project-issue.ts",
|
||||||
"./project-workspace": "./src/project-workspace.ts",
|
"./project-workspace": "./src/project-workspace.ts",
|
||||||
"./project-work": "./src/project-work.ts",
|
"./project-work": "./src/project-work.ts",
|
||||||
"./signal": "./src/signal.ts"
|
"./signal": "./src/signal.ts",
|
||||||
|
"./smoke": "./src/smoke.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./project-issue";
|
|||||||
export * from "./project-workspace";
|
export * from "./project-workspace";
|
||||||
export * from "./project-work";
|
export * from "./project-work";
|
||||||
export * from "./signal";
|
export * from "./signal";
|
||||||
|
export * from "./smoke";
|
||||||
|
|||||||
164
packages/primitives/src/smoke.test.ts
Normal file
164
packages/primitives/src/smoke.test.ts
Normal file
@@ -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 = <A, E>(effect: Effect.Effect<A, E>): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
347
packages/primitives/src/smoke.ts
Normal file
347
packages/primitives/src/smoke.ts
Normal file
@@ -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>()(
|
||||||
|
"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<SmokePhase, Partial<Record<SmokeTransition, SmokePhase>>>
|
||||||
|
> = {
|
||||||
|
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;
|
||||||
|
};
|
||||||
829
scripts/zopu-smoke.ts
Normal file
829
scripts/zopu-smoke.ts
Normal file
@@ -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<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 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 = <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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user