refactor(sense-generator): split roles into separate directories
Following nerve-dev best practice: each role gets its own directory. Structure: index.ts — 31 lines (WorkflowDefinition + moderator) roles/planner/index.ts — 48 lines (createCursorRole) roles/coder/index.ts — 33 lines (createCursorRole) roles/tester/index.ts — 122 lines (hand-written smoke test) roles/shared.ts — 63 lines (providers, helpers) roles/types.ts — 5 lines (SenseMeta) Was: single 416-line index.ts Refs uncaged/nerve#210 小橘 🍊(NEKO Team)
This commit is contained in:
parent
e05c71d6b0
commit
2d63639ed1
@ -1,305 +1,18 @@
|
||||
import type {
|
||||
RoleResult,
|
||||
StartStep,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
import { createCursorRole, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { buildPlannerRole } from "./roles/planner/index.js";
|
||||
import { buildCoderRole } from "./roles/coder/index.js";
|
||||
import { tester } from "./roles/tester/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||
const SENSES_DIR = join(NERVE_ROOT, "senses");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") return error.message;
|
||||
if (error.kind === "timeout") return `timeout (stdout=${error.stdout.slice(0, 200)})`;
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
|
||||
}
|
||||
|
||||
async function cfgGet(key: string): Promise<string | null> {
|
||||
const result = await spawnSafe("cfg", ["get", key], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!result.ok) return null;
|
||||
return result.value.stdout.trim() || null;
|
||||
}
|
||||
|
||||
async function resolveDashScopeProvider(): Promise<{
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
} | null> {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
|
||||
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
|
||||
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
|
||||
if (!apiKey || !baseUrl) return null;
|
||||
return { apiKey, baseUrl, model };
|
||||
}
|
||||
|
||||
function getNerveYaml(): string {
|
||||
try {
|
||||
return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8");
|
||||
} catch {
|
||||
return "# nerve.yaml unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSenseExamples(): string {
|
||||
const examples: string[] = [];
|
||||
for (const name of ["cpu-usage", "linux-system-health"]) {
|
||||
const dir = join(SENSES_DIR, name);
|
||||
if (!existsSync(dir)) continue;
|
||||
const indexFile = existsSync(join(dir, "index.js"))
|
||||
? readFileSync(join(dir, "index.js"), "utf-8")
|
||||
: "";
|
||||
const schema = existsSync(join(dir, "schema.ts"))
|
||||
? readFileSync(join(dir, "schema.ts"), "utf-8")
|
||||
: "";
|
||||
const migrationDir = join(dir, "migrations");
|
||||
let migration = "";
|
||||
if (existsSync(join(migrationDir, "0001_init.sql"))) {
|
||||
migration = readFileSync(join(migrationDir, "0001_init.sql"), "utf-8");
|
||||
}
|
||||
examples.push(
|
||||
`### Example sense: ${name}\n\n` +
|
||||
`**index.js:**\n\`\`\`js\n${indexFile}\n\`\`\`\n\n` +
|
||||
`**schema.ts:**\n\`\`\`ts\n${schema}\n\`\`\`\n\n` +
|
||||
`**migrations/0001_init.sql:**\n\`\`\`sql\n${migration}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
return examples.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
async function runSenseSmokeTest(
|
||||
senseName: string,
|
||||
): Promise<{ ok: boolean; log: string; reason: string }> {
|
||||
const logParts: string[] = [];
|
||||
|
||||
const runNerve = async (
|
||||
args: string[],
|
||||
): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
|
||||
const result = await spawnSafe("nerve", args, {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) };
|
||||
return { ok: true, out: result.value.stdout };
|
||||
};
|
||||
|
||||
const statusRun = await runNerve(["status"]);
|
||||
if (!statusRun.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
log: `=== nerve status ===\nERROR: ${statusRun.err}`,
|
||||
reason: `Smoke test command failed: ${statusRun.err}`,
|
||||
};
|
||||
}
|
||||
logParts.push("=== nerve status ===\n" + statusRun.out);
|
||||
if (!statusRun.out.includes(senseName)) {
|
||||
return {
|
||||
ok: false,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: `Sense "${senseName}" not listed in \`nerve status\` output`,
|
||||
};
|
||||
}
|
||||
|
||||
const triggerRun = await runNerve(["sense", "trigger", senseName]);
|
||||
if (!triggerRun.ok) {
|
||||
logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`);
|
||||
return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` };
|
||||
}
|
||||
logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
|
||||
|
||||
let lastQuery = "";
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const queryRun = await runNerve(["sense", "query", senseName]);
|
||||
if (!queryRun.ok) {
|
||||
logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`);
|
||||
} else {
|
||||
lastQuery = queryRun.out;
|
||||
logParts.push(`=== query attempt ${i + 1} ===\n${lastQuery}`);
|
||||
if (!lastQuery.includes("(0 rows)")) {
|
||||
return {
|
||||
ok: true,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: "Trigger succeeded and query returned at least one row",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: lastQuery.includes("(0 rows)")
|
||||
? "Query still returned 0 rows after trigger"
|
||||
: "Timed out waiting for successful sense query",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta — routing-only signals for the moderator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SenseMeta = {
|
||||
planner: { senseName: string };
|
||||
coder: { filesCreated: boolean };
|
||||
tester: { passed: boolean; attempt: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bake static context (read once at module load, not per-call)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseExamples = buildSenseExamples();
|
||||
const nerveYaml = getNerveYaml();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildPlannerRole() {
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
return createCursorRole<SenseMeta["planner"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
mode: "ask",
|
||||
prompt: async (threadId) =>
|
||||
`You are planning a new Nerve sense.
|
||||
|
||||
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
|
||||
|
||||
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
|
||||
|
||||
## Sense Design
|
||||
### Name — kebab-case
|
||||
### Fields — name, type (integer/real/text), description
|
||||
### Compute Logic — step-by-step, specific Node.js APIs or shell commands
|
||||
### Trigger Config — group, interval, throttle, timeout
|
||||
|
||||
Reference senses:
|
||||
${senseExamples}
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${nerveYaml}
|
||||
\`\`\`
|
||||
|
||||
Output ONLY the plan. Be precise and implementation-ready.`,
|
||||
extract: {
|
||||
provider,
|
||||
schema: z.object({
|
||||
senseName: z.string().describe("kebab-case sense name from the plan"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCoderRole() {
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
return createCursorRole<SenseMeta["coder"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
mode: "default",
|
||||
prompt: async (threadId) =>
|
||||
`Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\`
|
||||
|
||||
Implement the sense. Create exactly:
|
||||
1. The sense directory under ${SENSES_DIR}/<sense-name>/
|
||||
2. index.js — export async function compute(db, _peers), import schema from "./schema.ts"
|
||||
3. schema.ts — drizzle-orm/sqlite-core
|
||||
4. migrations/0001_init.sql — must match schema.ts
|
||||
5. Update ${NERVE_ROOT}/nerve.yaml — add sense config + reflex entry
|
||||
|
||||
Follow the patterns from existing senses. Create all files now.`,
|
||||
extract: {
|
||||
provider,
|
||||
schema: z.object({
|
||||
filesCreated: z.boolean().describe("true if the sense files were created"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Tester: pure CLI logic — stays hand-written
|
||||
async function tester(
|
||||
_start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<SenseMeta["tester"]>> {
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
|
||||
// Get senseName from planner meta
|
||||
const plannerStep = messages.find((m) => m.role === "planner");
|
||||
const senseName = plannerStep
|
||||
? (plannerStep.meta as SenseMeta["planner"]).senseName
|
||||
: "";
|
||||
|
||||
if (senseName.length === 0) {
|
||||
return {
|
||||
content: "FAIL — no senseName from planner",
|
||||
meta: { passed: false, attempt },
|
||||
};
|
||||
}
|
||||
|
||||
// Check files exist
|
||||
const senseDir = join(SENSES_DIR, senseName);
|
||||
const missing = [
|
||||
existsSync(join(senseDir, "index.js")) ? null : "index.js",
|
||||
existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts",
|
||||
existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql",
|
||||
].filter((x) => x !== null);
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `FAIL — missing files: ${missing.join(", ")}`,
|
||||
meta: { passed: false, attempt },
|
||||
};
|
||||
}
|
||||
|
||||
// Smoke test
|
||||
const smoke = await runSenseSmokeTest(senseName);
|
||||
return {
|
||||
content: `${smoke.ok ? "PASS" : "FAIL"} — ${smoke.reason}`,
|
||||
meta: { passed: smoke.ok, attempt },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow definition
|
||||
// ---------------------------------------------------------------------------
|
||||
import type { SenseMeta } from "./roles/types.js";
|
||||
|
||||
async function buildWorkflow(): Promise<WorkflowDefinition<SenseMeta>> {
|
||||
const plannerRole = await buildPlannerRole();
|
||||
const coderRole = await buildCoderRole();
|
||||
const planner = await buildPlannerRole();
|
||||
const coder = await buildCoderRole();
|
||||
|
||||
return {
|
||||
name: "sense-generator",
|
||||
roles: {
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
tester,
|
||||
},
|
||||
roles: { planner, coder, tester },
|
||||
moderator(context) {
|
||||
if (context.steps.length === 0) return "planner";
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
33
workflows/sense-generator/roles/coder/index.ts
Normal file
33
workflows/sense-generator/roles/coder/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import { resolveDashScopeProvider, NERVE_ROOT, SENSES_DIR } from "../shared.js";
|
||||
|
||||
import type { SenseMeta } from "../types.js";
|
||||
|
||||
export async function buildCoderRole() {
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
return createCursorRole<SenseMeta["coder"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
mode: "default",
|
||||
prompt: async (threadId) =>
|
||||
`Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\`
|
||||
|
||||
Implement the sense. Create exactly:
|
||||
1. The sense directory under ${SENSES_DIR}/<sense-name>/
|
||||
2. index.js — export async function compute(db, _peers), import schema from "./schema.ts"
|
||||
3. schema.ts — drizzle-orm/sqlite-core
|
||||
4. migrations/0001_init.sql — must match schema.ts
|
||||
5. Update ${NERVE_ROOT}/nerve.yaml — add sense config + reflex entry
|
||||
|
||||
Follow the patterns from existing senses. Create all files now.`,
|
||||
extract: {
|
||||
provider,
|
||||
schema: z.object({
|
||||
filesCreated: z.boolean().describe("true if the sense files were created"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
48
workflows/sense-generator/roles/planner/index.ts
Normal file
48
workflows/sense-generator/roles/planner/index.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { resolveDashScopeProvider, buildSenseExamples, getNerveYaml, NERVE_ROOT } from "../shared.js";
|
||||
import type { SenseMeta } from "../types.js";
|
||||
|
||||
const senseExamples = buildSenseExamples();
|
||||
const nerveYaml = getNerveYaml();
|
||||
|
||||
export async function buildPlannerRole() {
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
return createCursorRole<SenseMeta["planner"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
mode: "ask",
|
||||
prompt: async (threadId) =>
|
||||
`You are planning a new Nerve sense.
|
||||
|
||||
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
|
||||
|
||||
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
|
||||
|
||||
## Sense Design
|
||||
### Name — kebab-case
|
||||
### Fields — name, type (integer/real/text), description
|
||||
### Compute Logic — step-by-step, specific Node.js APIs or shell commands
|
||||
### Trigger Config — group, interval, throttle, timeout
|
||||
|
||||
Reference senses:
|
||||
${senseExamples}
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${nerveYaml}
|
||||
\`\`\`
|
||||
|
||||
Output ONLY the plan. Be precise and implementation-ready.`,
|
||||
extract: {
|
||||
provider,
|
||||
schema: z.object({
|
||||
senseName: z.string().describe("kebab-case sense name from the plan"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
63
workflows/sense-generator/roles/shared.ts
Normal file
63
workflows/sense-generator/roles/shared.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const HOME = process.env.HOME ?? "/home/azureuser";
|
||||
export const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||
export const SENSES_DIR = join(NERVE_ROOT, "senses");
|
||||
|
||||
export async function cfgGet(key: string): Promise<string | null> {
|
||||
const result = await spawnSafe("cfg", ["get", key], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!result.ok) return null;
|
||||
return result.value.stdout.trim() || null;
|
||||
}
|
||||
|
||||
export async function resolveDashScopeProvider(): Promise<{
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
} | null> {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
|
||||
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
|
||||
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
|
||||
if (!apiKey || !baseUrl) return null;
|
||||
return { apiKey, baseUrl, model };
|
||||
}
|
||||
|
||||
export function getNerveYaml(): string {
|
||||
try {
|
||||
return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8");
|
||||
} catch {
|
||||
return "# nerve.yaml unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSenseExamples(): string {
|
||||
const examples: string[] = [];
|
||||
for (const name of ["cpu-usage", "linux-system-health"]) {
|
||||
const dir = join(SENSES_DIR, name);
|
||||
if (!existsSync(dir)) continue;
|
||||
const indexFile = existsSync(join(dir, "index.js"))
|
||||
? readFileSync(join(dir, "index.js"), "utf-8")
|
||||
: "";
|
||||
const schema = existsSync(join(dir, "schema.ts"))
|
||||
? readFileSync(join(dir, "schema.ts"), "utf-8")
|
||||
: "";
|
||||
const migrationDir = join(dir, "migrations");
|
||||
let migration = "";
|
||||
if (existsSync(join(migrationDir, "0001_init.sql"))) {
|
||||
migration = readFileSync(join(migrationDir, "0001_init.sql"), "utf-8");
|
||||
}
|
||||
examples.push(
|
||||
`### Example sense: ${name}\n\n` +
|
||||
`**index.js:**\n\`\`\`js\n${indexFile}\n\`\`\`\n\n` +
|
||||
`**schema.ts:**\n\`\`\`ts\n${schema}\n\`\`\`\n\n` +
|
||||
`**migrations/0001_init.sql:**\n\`\`\`sql\n${migration}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
return examples.join("\n\n---\n\n");
|
||||
}
|
||||
122
workflows/sense-generator/roles/tester/index.ts
Normal file
122
workflows/sense-generator/roles/tester/index.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { NERVE_ROOT, SENSES_DIR } from "../shared.js";
|
||||
|
||||
import type { SenseMeta } from "../types.js";
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") return error.message;
|
||||
if (error.kind === "timeout") return `timeout (stdout=${error.stdout.slice(0, 200)})`;
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
|
||||
}
|
||||
|
||||
async function runSenseSmokeTest(
|
||||
senseName: string,
|
||||
): Promise<{ ok: boolean; log: string; reason: string }> {
|
||||
const logParts: string[] = [];
|
||||
|
||||
const runNerve = async (
|
||||
args: string[],
|
||||
): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
|
||||
const result = await spawnSafe("nerve", args, {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) };
|
||||
return { ok: true, out: result.value.stdout };
|
||||
};
|
||||
|
||||
const statusRun = await runNerve(["status"]);
|
||||
if (!statusRun.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
log: `=== nerve status ===\nERROR: ${statusRun.err}`,
|
||||
reason: `Smoke test command failed: ${statusRun.err}`,
|
||||
};
|
||||
}
|
||||
logParts.push("=== nerve status ===\n" + statusRun.out);
|
||||
if (!statusRun.out.includes(senseName)) {
|
||||
return {
|
||||
ok: false,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: `Sense "${senseName}" not listed in \`nerve status\` output`,
|
||||
};
|
||||
}
|
||||
|
||||
const triggerRun = await runNerve(["sense", "trigger", senseName]);
|
||||
if (!triggerRun.ok) {
|
||||
logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`);
|
||||
return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` };
|
||||
}
|
||||
logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
|
||||
|
||||
let lastQuery = "";
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const queryRun = await runNerve(["sense", "query", senseName]);
|
||||
if (!queryRun.ok) {
|
||||
logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`);
|
||||
} else {
|
||||
lastQuery = queryRun.out;
|
||||
logParts.push(`=== query attempt ${i + 1} ===\n${lastQuery}`);
|
||||
if (!lastQuery.includes("(0 rows)")) {
|
||||
return {
|
||||
ok: true,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: "Trigger succeeded and query returned at least one row",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: lastQuery.includes("(0 rows)")
|
||||
? "Query still returned 0 rows after trigger"
|
||||
: "Timed out waiting for successful sense query",
|
||||
};
|
||||
}
|
||||
|
||||
export async function tester(
|
||||
_start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<SenseMeta["tester"]>> {
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
|
||||
const plannerStep = messages.find((m) => m.role === "planner");
|
||||
const senseName = plannerStep
|
||||
? (plannerStep.meta as SenseMeta["planner"]).senseName
|
||||
: "";
|
||||
|
||||
if (senseName.length === 0) {
|
||||
return {
|
||||
content: "FAIL — no senseName from planner",
|
||||
meta: { passed: false, attempt },
|
||||
};
|
||||
}
|
||||
|
||||
const senseDir = join(SENSES_DIR, senseName);
|
||||
const missing = [
|
||||
existsSync(join(senseDir, "index.js")) ? null : "index.js",
|
||||
existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts",
|
||||
existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql",
|
||||
].filter((x) => x !== null);
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `FAIL — missing files: ${missing.join(", ")}`,
|
||||
meta: { passed: false, attempt },
|
||||
};
|
||||
}
|
||||
|
||||
const smoke = await runSenseSmokeTest(senseName);
|
||||
return {
|
||||
content: `${smoke.ok ? "PASS" : "FAIL"} — ${smoke.reason}`,
|
||||
meta: { passed: smoke.ok, attempt },
|
||||
};
|
||||
}
|
||||
5
workflows/sense-generator/roles/types.ts
Normal file
5
workflows/sense-generator/roles/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type SenseMeta = {
|
||||
planner: { senseName: string };
|
||||
coder: { filesCreated: boolean };
|
||||
tester: { passed: boolean; attempt: number };
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user