Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 157 additions & 5 deletions docs/sdlc-pipeline.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,20 @@ const McpServersSchema = z.record(z.string(), McpServerSchema).default({});
* `mcpServers` block. Normalized into an `McpServerSpec` by `McpRegistry`. */
export type RawMcpServerConfig = z.infer<typeof McpServerSchema>;

/**
* SDLC pipeline (docs/sdlc-pipeline.md). OFF by default — the daemon stays
* methodology-agnostic; nothing runs until this is enabled. When enabled, the
* daemon builds a PipelineManager sharing its DB and rehydrates non-terminal
* pipelines on boot. `defaultPack` is the methodology pack a pipeline runs when
* created without one (null = none / freestyle).
*/
const PipelineSchema = z
.object({
enabled: z.boolean().default(false),
defaultPack: z.string().nullable().default(null),
})
.default({ enabled: false, defaultPack: null });

const RootSchema = z.object({
daemonUrl: z.string().default("ws://127.0.0.1:7400"),
dbPath: z.string().default("codeoid.db"),
Expand All @@ -544,6 +558,7 @@ const RootSchema = z.object({
session: SessionSchema,
conductor: ConductorSchema,
dispatch: DispatchSchema,
pipeline: PipelineSchema,
providers: ProvidersSchema,
mcpServers: McpServersSchema,
hooks: HooksSchema,
Expand Down Expand Up @@ -678,6 +693,16 @@ export interface CodeoidConfig {
workerToolBudget: number;
retryBaseMs: number;
};
/**
* SDLC pipeline — OFF by default (docs/sdlc-pipeline.md). When enabled, a
* PipelineManager is constructed at boot sharing the daemon DB, and
* non-terminal pipelines are rehydrated (resume). Optional in the type so
* hand-built test configs stay minimal; loadConfig always populates it.
*/
pipeline?: {
enabled: boolean;
defaultPack: string | null;
};
/**
* Per-backend provider settings. Optional in the type so hand-built test
* configs stay minimal; loadConfig always populates it (schema defaults).
Expand Down Expand Up @@ -777,6 +802,10 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
// without touching config.json. Other dispatch knobs are file-config only,
// matching the conductor block's convention.
{ env: "CODEOID_DISPATCH_ENABLED", path: "dispatch.enabled", kind: "boolean" },
// Pipeline enable/kill switch — turn the SDLC pipeline on/off per-invocation
// without touching config.json (off by default). Other pipeline knobs are
// file-config only, matching the dispatch/conductor convention.
{ env: "CODEOID_PIPELINE_ENABLED", path: "pipeline.enabled", kind: "boolean" },
{ env: "CODEOID_FALLBACK_MODEL", path: "session.fallbackModel", kind: "string" },
// Hooks kill switch — disable every configured hook per-invocation without
// touching config.json. Entries themselves are file-config only.
Expand Down Expand Up @@ -978,6 +1007,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
session: parsed.session,
conductor: parsed.conductor,
dispatch: parsed.dispatch,
pipeline: parsed.pipeline,
providers: parsed.providers,
mcpServers: parsed.mcpServers,
hooks: parsed.hooks,
Expand Down
79 changes: 79 additions & 0 deletions src/daemon/pipeline/answer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import type { PhaseDef } from "./interface";
import { PipelineManager } from "./manager";
import { PipelineStore } from "./store";

const tenant = { accountId: "acct", projectId: "proj", createdBy: "user" };

function mgr(): PipelineManager {
return new PipelineManager(new PipelineStore(new Database(":memory:")));
}

/** A pipeline whose first phase halts on the `manual` gate, followed by a phase
* that passes — so answering resumes into the tail. */
const halting: PhaseDef[] = [
{ id: "gate", kind: "noop", gate: "manual" },
{ id: "tail", kind: "noop", gate: "always" },
];

describe("PipelineManager.answer (halt → resume)", () => {
test("approve resolves the halted phase and resumes to done", async () => {
const m = mgr();
const p = m.create({ name: "REQ-1", phases: halting, ...tenant });
const halted = await m.advance(p.id);
expect(halted.status).toBe("halted");
expect(halted.phases[0].state.status).toBe("halted");

const done = await m.answer(p.id, "gate:gate", { approved: true, value: "LGTM" });
expect(done.status).toBe("done");
expect(done.phases[0].state).toMatchObject({ status: "passed", summary: "LGTM" });
expect(done.phases[1].state.status).toBe("passed");
});

test("reject fails the phase and the pipeline", async () => {
const m = mgr();
const p = m.create({ name: "REQ-1", phases: halting, ...tenant });
await m.advance(p.id);
const failed = await m.answer(p.id, "gate:gate", { approved: false, value: "nope" });
expect(failed.status).toBe("failed");
expect(failed.phases[0].state).toMatchObject({ status: "failed", reason: "nope" });
});

test("approving the final phase completes without a further advance", async () => {
const m = mgr();
const p = m.create({ name: "REQ-1", phases: [{ id: "only", kind: "noop", gate: "manual" }], ...tenant });
await m.advance(p.id);
const done = await m.answer(p.id, "gate:only", { approved: true });
expect(done.status).toBe("done");
expect(done.phases[0].state).toMatchObject({ status: "passed", summary: "approved" });
});

test("rejects a stale requestId", async () => {
const m = mgr();
const p = m.create({ name: "REQ-1", phases: halting, ...tenant });
await m.advance(p.id);
await expect(m.answer(p.id, "gate:wrong", { approved: true })).rejects.toThrow("stale requestId");
});

test("throws when the pipeline is not halted", async () => {
const m = mgr();
const p = m.create({ name: "REQ-1", phases: [{ id: "one", kind: "noop", gate: "always" }], ...tenant });
await m.advance(p.id); // runs straight to done
await expect(m.answer(p.id, "gate:one", { approved: true })).rejects.toThrow("not halted");
});

test("throws on an unknown pipeline id", async () => {
await expect(mgr().answer("nope", "gate:x", { approved: true })).rejects.toThrow("not found");
});

test("the resolved state persists across a restart", async () => {
const store = new PipelineStore(new Database(":memory:"));
const m = new PipelineManager(store);
const p = m.create({ name: "REQ-1", phases: halting, ...tenant });
await m.advance(p.id);
await m.answer(p.id, "gate:gate", { approved: true });
// Fresh manager over the same store sees the completed pipeline.
expect(new PipelineManager(store).get(p.id)?.status).toBe("done");
});
});
45 changes: 45 additions & 0 deletions src/daemon/pipeline/builtin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Built-in, content-free plugins — the minimum needed to prove the primitive
* runs, persists, and survives restart without a real backend. Methodology
* content (the ADLC pack, `skill` / `panel` phase kinds, command/review gates)
* ships as separate packs in later slices; nothing here encodes an SDLC.
*/

import type { GatePlugin, PhaseKind, PipelineRegistries } from "./interface";

/** A phase kind that does nothing and immediately passes — the minimal runnable
* phase. Lets a pipeline advance end to end so the engine, store, and restart
* path are exercisable before any backend-driven kind exists. */
export const noopPhaseKind: PhaseKind = {
id: "noop",
async run() {
return { outcome: "passed" };
},
};

/** An exit gate that always passes. */
export const alwaysGate: GatePlugin = {
id: "always",
at: "exit",
async evaluate() {
return { pass: true };
},
};

/** A gate that never auto-passes — it always returns a failing verdict so the
* phase's `onFail` policy applies (a phase that must wait for a human sets
* `onFail: { action: "halt" }`, which is also the default). */
export const manualGate: GatePlugin = {
id: "manual",
at: "exit",
async evaluate() {
return { pass: false, reason: "manual gate — awaiting human decision" };
},
};

/** Register the built-in plugins into a set of registries. */
export function registerBuiltins(r: PipelineRegistries): void {
r.phases.register(noopPhaseKind);
r.gates.register(alwaysGate);
r.gates.register(manualGate);
}
169 changes: 169 additions & 0 deletions src/daemon/pipeline/engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test";
import { registerBuiltins } from "./builtin";
import { PipelineEngine } from "./engine";
import type { PhaseDef, PhaseKind, PipelineRegistries, PipelineState } from "./interface";
import { createRegistries } from "./registry";

function pipeline(phases: PhaseDef[]): PipelineState {
const ts = 1;
return {
id: "p",
name: "p",
phases: phases.map((def) => ({ def, state: { status: "pending" } })),
cursor: 0,
status: "draft",
accountId: "a",
projectId: "p",
createdBy: "u",
createdAt: ts,
updatedAt: ts,
};
}

function regs(): PipelineRegistries {
const r = createRegistries();
registerBuiltins(r);
return r;
}

describe("PipelineEngine.run", () => {
test("runs noop phases with a passing gate to done", async () => {
const out = await new PipelineEngine(regs()).run(
pipeline([
{ id: "one", kind: "noop", gate: "always" },
{ id: "two", kind: "noop", gate: "always" },
]),
);
expect(out.status).toBe("done");
expect(out.cursor).toBe(2);
expect(out.phases.every((p) => p.state.status === "passed")).toBe(true);
});

test("a phase with no gate passes", async () => {
const out = await new PipelineEngine(regs()).run(pipeline([{ id: "one", kind: "noop" }]));
expect(out.status).toBe("done");
});

test("a failing exit gate with the default onFail halts", async () => {
const out = await new PipelineEngine(regs()).run(
pipeline([{ id: "one", kind: "noop", gate: "manual" }]),
);
expect(out.status).toBe("halted");
const st = out.phases[0].state;
expect(st.status).toBe("halted");
if (st.status === "halted") expect(st.requestId).toBe("gate:one");
});

test("a failing gate with onFail:abort fails the pipeline", async () => {
const out = await new PipelineEngine(regs()).run(
pipeline([{ id: "one", kind: "noop", gate: "manual", onFail: { action: "abort" } }]),
);
expect(out.status).toBe("failed");
});

test("an unknown phase kind fails", async () => {
const out = await new PipelineEngine(regs()).run(
pipeline([{ id: "one", kind: "does-not-exist", onFail: { action: "abort" } }]),
);
expect(out.status).toBe("failed");
const st = out.phases[0].state;
if (st.status === "failed") expect(st.reason).toContain("unknown phase kind");
});

test("retry: a flaky kind succeeds within budget", async () => {
const r = regs();
let calls = 0;
const flaky: PhaseKind = {
id: "flaky",
async run() {
calls += 1;
return calls < 3 ? { outcome: "failed", reason: "not yet" } : { outcome: "passed" };
},
};
r.phases.register(flaky);
const out = await new PipelineEngine(r).run(
pipeline([{ id: "one", kind: "flaky", onFail: { action: "retry", max: 5 } }]),
);
expect(out.status).toBe("done");
expect(calls).toBe(3);
});

test("retry: an exhausted budget fails with the attempt count", async () => {
const r = regs();
r.phases.register({
id: "alwaysfail",
async run() {
return { outcome: "failed", reason: "nope" };
},
});
const out = await new PipelineEngine(r).run(
pipeline([{ id: "one", kind: "alwaysfail", onFail: { action: "retry", max: 2 } }]),
);
expect(out.status).toBe("failed");
const st = out.phases[0].state;
if (st.status === "failed") expect(st.attempts).toBe(2);
});

test("an entry gate failure halts before the kind runs", async () => {
const r = regs();
let ran = false;
r.phases.register({
id: "spy",
async run() {
ran = true;
return { outcome: "passed" };
},
});
const out = await new PipelineEngine(r).run(
pipeline([{ id: "one", kind: "spy", entryGate: "manual" }]),
);
expect(out.status).toBe("halted");
expect(ran).toBe(false);
});

test("run is a no-op on an already-terminal state", async () => {
const done: PipelineState = { ...pipeline([{ id: "one", kind: "noop" }]), status: "done" };
const out = await new PipelineEngine(regs()).run(done);
expect(out.status).toBe("done");
expect(out.phases[0].state.status).toBe("pending");
});

test("a halted pipeline is not advanced by run", async () => {
const halted: PipelineState = { ...pipeline([{ id: "one", kind: "noop" }]), status: "halted" };
const out = await new PipelineEngine(regs()).run(halted);
expect(out.status).toBe("halted");
});

test("a throwing phase kind is caught and fails the phase (no crash)", async () => {
const r = regs();
r.phases.register({
id: "boom",
async run() {
throw new Error("kaboom");
},
});
const out = await new PipelineEngine(r).run(
pipeline([{ id: "one", kind: "boom", onFail: { action: "abort" } }]),
);
expect(out.status).toBe("failed");
const st = out.phases[0].state;
if (st.status === "failed") expect(st.reason).toContain("kaboom");
});

test("a throwing gate is caught and fails the phase (no crash)", async () => {
const r = regs();
r.gates.register({
id: "boomgate",
at: "exit",
async evaluate() {
throw new Error("gate-boom");
},
});
const out = await new PipelineEngine(r).run(
pipeline([{ id: "one", kind: "noop", gate: "boomgate", onFail: { action: "abort" } }]),
);
expect(out.status).toBe("failed");
const st = out.phases[0].state;
if (st.status === "failed") expect(st.reason).toContain("gate-boom");
});
});
Loading
Loading