Skip to content
39 changes: 39 additions & 0 deletions apps/server/src/gits/Layers/AutomodeDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { AutomodeDriver } from "../Services/AutomodeDriver.ts";
import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts";
import { AutomodeLanding, type AutomodeLandResult } from "../Services/AutomodeLanding.ts";
import { AutomodeHeldPr, type AutomodeOpenHeldPrResult } from "../Services/AutomodeHeldPr.ts";
import {
AutomodeEpisodeLedger,
type AutomodeEpisode,
} from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { AutomodeSupervisorLive } from "./AutomodeSupervisor.ts";
import { AutomodeDriverLive } from "./AutomodeDriver.ts";

Expand Down Expand Up @@ -98,6 +102,7 @@ interface MakeLayerOptions {
readonly openResult?: AutomodeOpenHeldPrResult;
readonly onOpenHeldPr?: () => void;
readonly mergeResults?: boolean[];
readonly onRecordEpisode?: (episode: AutomodeEpisode) => void;
}

// Mutable holder so a test can change what listPeers returns between ticks.
Expand Down Expand Up @@ -156,6 +161,13 @@ function makeLayer(peerStatus: { current: PeerStatus | "absent" }, options?: Mak
}),
detect_merge: () => Effect.succeed({ merged: mergeQueue.shift() ?? false }),
});
const ledger = Layer.mock(AutomodeEpisodeLedger)({
record_episode: (episode) =>
Effect.sync(() => {
options?.onRecordEpisode?.(episode);
}),
list_episodes: () => Effect.succeed([]),
});
const config = ServerConfig.layerTest(process.cwd(), {
prefix: "gits-automode-driver-test-",
}).pipe(Layer.provide(NodeServices.layer));
Expand All @@ -171,6 +183,7 @@ function makeLayer(peerStatus: { current: PeerStatus | "absent" }, options?: Mak
Layer.provide(reviewPipeline),
Layer.provide(landing),
Layer.provide(heldPr),
Layer.provide(ledger),
);
}

Expand Down Expand Up @@ -491,4 +504,30 @@ describe("AutomodeDriver", () => {
),
);
});

it.effect("records an episode after a slice lands", () => {
const peerStatus = { current: "absent" as PeerStatus | "absent" };
const episodes: AutomodeEpisode[] = [];
return Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
const driver = yield* AutomodeDriver;
yield* armAutonomous(supervisor);
yield* supervisor.enqueueGoal({ title: "Ledger me", repo: "/tmp/source-repo", prompt: "x" });
yield* driver.tickOnce(); // dispatch
peerStatus.current = "done";
yield* driver.tickOnce(); // gate → land → complete → record episode

assert.equal(episodes.length, 1);
assert.equal(episodes[0]?.goalTitle, "Ledger me");
assert.equal(episodes[0]?.verdict, "pass");
assert.equal(episodes[0]?.repo, "/tmp/source-repo");
}).pipe(
Effect.provide(
makeLayer(peerStatus, {
review: passingReview,
onRecordEpisode: (episode) => episodes.push(episode),
}),
),
);
});
});
30 changes: 30 additions & 0 deletions apps/server/src/gits/Layers/AutomodeDriver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -10,6 +11,7 @@
import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts";
import { AutomodeLanding } from "../Services/AutomodeLanding.ts";
import { AutomodeHeldPr } from "../Services/AutomodeHeldPr.ts";
import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { decide_automode_gate } from "./AutomodeReviewGate.ts";

const TICK_INTERVAL_MS = (() => {
Expand All @@ -25,7 +27,7 @@
// The snapshot sorts goals newest-first; reverse before sorting so that
// equal-timestamp goals remain in oldest-first (FIFO) order.
const queued = [...goals]
.reverse()

Check warning on line 30 in apps/server/src/gits/Layers/AutomodeDriver.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

unicorn(no-array-reverse)

Use `Array#toReversed()` instead of `Array#reverse()`.
.filter((goal) => goal.status === "queued")
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
return queued[0] ?? null;
Expand All @@ -39,6 +41,7 @@
const reviewPipeline = yield* GitsReviewPipeline;
const landing = yield* AutomodeLanding;
const heldPr = yield* AutomodeHeldPr;
const ledger = yield* AutomodeEpisodeLedger;

const tickOnce: AutomodeDriverShape["tickOnce"] = () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -149,6 +152,33 @@
}

yield* supervisor.completeGoal({ goalId: running.id });

// Record the episode (verifier output) for rehydrate. A ledger hiccup
// must not halt the run — the slice already landed and the goal is done.
const recordedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
yield* ledger
.record_episode({
id: `ep-${running.id}-${recordedAt}`,
repo: running.repo,
goalId: running.id,
goalTitle: running.title,
sliceBranch: peer.branch,
verdict: review.semantic?.verdict ?? "uncertain",
confidence: review.semantic?.confidence ?? null,
recommendation: review.recommendation,
flagged: decision.flagged,
summary: review.summary,
review,
createdAt: recordedAt,
})
.pipe(
Effect.catch((error) =>
Effect.logWarning("gits.automode.ledger.record-failed", {
goalId: running.id,
error: error.message,
}),
),
);
yield* Effect.logInfo("gits.automode.driver.goal-landed", {
goalId: running.id,
peerId: peer.id,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,5 @@ export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDe
export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError;

export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;

export type AutomodeEpisodeLedgerRepositoryError = PersistenceSqlError | PersistenceDecodeError;
91 changes: 91 additions & 0 deletions apps/server/src/persistence/Layers/AutomodeEpisodeLedger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { GitsReviewResult } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { AutomodeEpisodeLedger, type AutomodeEpisode } from "../Services/AutomodeEpisodeLedger.ts";
import { AutomodeEpisodeLedgerLive } from "./AutomodeEpisodeLedger.ts";
import { SqlitePersistenceMemory } from "./Sqlite.ts";

const review: GitsReviewResult = {
sliceId: "goal-1",
recommendation: "hold-for-review",
mechanicalPassed: true,
mechanical: {
worktree: "/tmp/wt",
confined: true,
passed: true,
results: [],
checkedAt: "2026-01-01T00:00:00.000Z",
},
semantic: {
worktree: "/tmp/wt",
verdict: "pass",
confidence: "high",
recommendation: "hold-for-review",
reasons: ["looks good"],
missed: [],
criteriaProvided: false,
model: "gpt-5.5",
checkedAt: "2026-01-01T00:00:00.000Z",
},
criteriaSource: "derived",
summary: "verdict=pass",
checkedAt: "2026-01-01T00:00:00.000Z",
};

function episode(overrides: Partial<AutomodeEpisode>): AutomodeEpisode {
return {
id: "ep-1",
repo: "/tmp/source-repo",
goalId: "goal-1",
goalTitle: "Slice one",
sliceBranch: "auto/slice/goal-1",
verdict: "pass",
confidence: "high",
recommendation: "hold-for-review",
flagged: false,
summary: "verdict=pass",
review,
createdAt: "2026-01-01T00:00:01.000Z",
...overrides,
};
}

const layer = it.layer(AutomodeEpisodeLedgerLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)));

layer("AutomodeEpisodeLedger", (it) => {
it.effect("records an episode and lists it back with the review round-tripped", () =>
Effect.gen(function* () {
const ledger = yield* AutomodeEpisodeLedger;
yield* ledger.record_episode(episode({}));
const all = yield* ledger.list_episodes({});
assert.equal(all.length, 1);
assert.equal(all[0]?.goalTitle, "Slice one");
assert.equal(all[0]?.verdict, "pass");
assert.equal(all[0]?.flagged, false);
assert.equal(all[0]?.review.semantic?.reasons[0], "looks good");
}),
);

it.effect("filters by repo and returns newest-first", () =>
Effect.gen(function* () {
const ledger = yield* AutomodeEpisodeLedger;
yield* ledger.record_episode(
episode({ id: "ep-a", repo: "/repo/a", createdAt: "2026-01-01T00:00:01.000Z" }),
);
yield* ledger.record_episode(
episode({ id: "ep-b", repo: "/repo/b", createdAt: "2026-01-01T00:00:02.000Z" }),
);
yield* ledger.record_episode(
episode({ id: "ep-c", repo: "/repo/a", createdAt: "2026-01-01T00:00:03.000Z" }),
);

const repoA = yield* ledger.list_episodes({ repo: "/repo/a" });
assert.deepEqual(
repoA.map((e) => e.id),
["ep-c", "ep-a"],
);
}),
);
});
171 changes: 171 additions & 0 deletions apps/server/src/persistence/Layers/AutomodeEpisodeLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Struct from "effect/Struct";

import {
toPersistenceDecodeError,
toPersistenceSqlError,
type AutomodeEpisodeLedgerRepositoryError,
} from "../Errors.ts";
import {
AutomodeEpisode,
AutomodeEpisodeLedger,
type AutomodeEpisodeLedgerShape,
} from "../Services/AutomodeEpisodeLedger.ts";

// DB-row mapping: `review` stored as JSON TEXT (parsed to an object on read),
// `flagged` stored as 0/1 INTEGER (number at the row boundary; converted to/from
// boolean explicitly since this codebase has no Schema.transform precedent).
const AutomodeEpisodeDbRowSchema = AutomodeEpisode.mapFields(
Struct.assign({
review: Schema.fromJsonString(Schema.Unknown),
flagged: Schema.Number,
}),
);
type AutomodeEpisodeDbRow = typeof AutomodeEpisodeDbRowSchema.Type;

const decodeEpisode = Schema.decodeUnknownEffect(AutomodeEpisode);

function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
return (cause: unknown): AutomodeEpisodeLedgerRepositoryError =>
Schema.isSchemaError(cause)
? toPersistenceDecodeError(decodeOperation)(cause)
: toPersistenceSqlError(sqlOperation)(cause);
}

const makeAutomodeEpisodeLedger = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

const insertEpisodeRow = SqlSchema.void({
Request: AutomodeEpisodeDbRowSchema,
execute: (row) =>
sql`
INSERT INTO automode_episodes (
id,
repo,
goal_id,
goal_title,
slice_branch,
verdict,
confidence,
recommendation,
flagged,
summary,
review_json,
created_at
)
VALUES (
${row.id},
${row.repo},
${row.goalId},
${row.goalTitle},
${row.sliceBranch},
${row.verdict},
${row.confidence},
${row.recommendation},
${row.flagged},
${row.summary},
${row.review},
${row.createdAt}
)
ON CONFLICT (id) DO UPDATE SET
repo = excluded.repo,
goal_id = excluded.goal_id,
goal_title = excluded.goal_title,
slice_branch = excluded.slice_branch,
verdict = excluded.verdict,
confidence = excluded.confidence,
recommendation = excluded.recommendation,
flagged = excluded.flagged,
summary = excluded.summary,
review_json = excluded.review_json,
created_at = excluded.created_at
`,
});

const listAllRows = SqlSchema.findAll({
Request: Schema.Void,
Result: AutomodeEpisodeDbRowSchema,
execute: () =>
sql`
SELECT
id,
repo,
goal_id AS "goalId",
goal_title AS "goalTitle",
slice_branch AS "sliceBranch",
verdict,
confidence,
recommendation,
flagged,
summary,
review_json AS "review",
created_at AS "createdAt"
FROM automode_episodes
ORDER BY created_at DESC, id DESC
`,
});

const listByRepoRows = SqlSchema.findAll({
Request: Schema.Struct({ repo: Schema.String }),
Result: AutomodeEpisodeDbRowSchema,
execute: ({ repo }) =>
sql`
SELECT
id,
repo,
goal_id AS "goalId",
goal_title AS "goalTitle",
slice_branch AS "sliceBranch",
verdict,
confidence,
recommendation,
flagged,
summary,
review_json AS "review",
created_at AS "createdAt"
FROM automode_episodes
WHERE repo = ${repo}
ORDER BY created_at DESC, id DESC
`,
});

const decodeRow = (row: AutomodeEpisodeDbRow) =>
decodeEpisode({ ...row, flagged: row.flagged !== 0 }).pipe(
Effect.mapError(toPersistenceDecodeError("AutomodeEpisodeLedger.list_episodes:rowToEpisode")),
);

const record_episode: AutomodeEpisodeLedgerShape["record_episode"] = (episode) =>
insertEpisodeRow({ ...episode, flagged: episode.flagged ? 1 : 0 }).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AutomodeEpisodeLedger.record_episode:query",
"AutomodeEpisodeLedger.record_episode:encodeRequest",
),
),
);

const list_episodes: AutomodeEpisodeLedgerShape["list_episodes"] = (input) =>
(input.repo === undefined ? listAllRows(undefined) : listByRepoRows({ repo: input.repo })).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AutomodeEpisodeLedger.list_episodes:query",
"AutomodeEpisodeLedger.list_episodes:decodeRows",
),
),
Effect.flatMap((rows) => Effect.forEach(rows, decodeRow, { concurrency: "unbounded" })),
Effect.map((episodes) =>
input.limit === undefined ? episodes : episodes.slice(0, input.limit),
),
);

return { record_episode, list_episodes } satisfies AutomodeEpisodeLedgerShape;
});

export const AutomodeEpisodeLedgerLive = Layer.effect(
AutomodeEpisodeLedger,
makeAutomodeEpisodeLedger,
);
Loading
Loading