Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 1 addition & 8 deletions actions/setup/js/create_ledger_projection.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs");
const { readLedgerConfig } = require("./push_ledger_changes.cjs");
const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs");
const { materializeReplay } = require("./ledger_replay.cjs");
const { NOTE_STATE_COLUMNS, NOTE_STATE_VIEW, replayBuiltin } = require("./ledger_builtin.cjs");
const { replayBuiltin } = require("./ledger_builtin.cjs");

const PROJECTION_ROOT = "/tmp/gh-aw/ledgers";
const MAX_PROJECTION_BYTES = 100 * 1024 * 1024;
Expand Down Expand Up @@ -114,13 +114,6 @@ function createProjection({ sourceDir, databasePath, config }) {
const maxRows = config.type === "notes" ? Object.values(output.tables).reduce((count, table) => count + table.rows.length, 0) : state.records.length;
materializeReplay(database, config.name, `builtin:${config.type}`, state.records, output, maxRows);
tables = output.tables;
if (config.type === "notes") {
database.exec(NOTE_STATE_VIEW);
database
.prepare("INSERT INTO replay_metadata SELECT ledger_name, projection_version, record_count, script_sha256, output_version, ?, ? FROM replay_metadata WHERE table_name = 'notes'")
.run("note_state", JSON.stringify(NOTE_STATE_COLUMNS));
tables = { ...output.tables, note_state: { columns: NOTE_STATE_COLUMNS } };
}
}
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
fs.rmSync(databasePath, { force: true });
Expand Down
32 changes: 32 additions & 0 deletions actions/setup/js/create_ledger_projection.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ test("notes projection derives vote state from canonical records across compacti
const db = new DatabaseSync(file, { readOnly: true });
try {
return {
stateType: db.prepare("SELECT type FROM sqlite_master WHERE name = 'note_state'").get()?.type,
notes: db
.prepare("SELECT * FROM notes ORDER BY id")
.all()
Expand Down Expand Up @@ -242,6 +243,7 @@ test("notes projection derives vote state from canonical records across compacti
};
const projected = inspect(before);
assert.deepEqual(inspect(after), projected);
assert.equal(projected.stateType, "table");
assert.deepEqual(projected.notes, [{ id: noteId, subject: "README", note: "Contains instructions", reason: "Read the source", created_at: envelopes.get(noteId).timestamp, record_sha: envelopes.get(noteId).sha }]);
assert.deepEqual(projected.citations, [
{ note_id: noteId, ordinal: 0, citation_type: "repository", path: "README.md", start_line: 1, end_line: null },
Expand Down Expand Up @@ -273,6 +275,36 @@ test("notes projection derives vote state from canonical records across compacti
}
});

test("notes projection materializes empty tables for read-only first-run validation", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-notes-empty-projection-"));
const sourceDir = path.join(root, "source");
const databasePath = path.join(root, "projection", "ledger.db");
fs.mkdirSync(sourceDir, { recursive: true });
try {
createProjection({
sourceDir,
databasePath,
config: { name: "knowledge", type: "notes", max_record_kb: 32, max_segment_kb: 100, max_patch_kb: 10 },
});
const db = new DatabaseSync(databasePath, { readOnly: true });
try {
assert.equal(db.prepare("SELECT type FROM sqlite_master WHERE name = 'note_state'").get()?.type, "table");
assert.deepEqual(
db
.prepare("PRAGMA table_info(note_state)")
.all()
.map(column => column.name),
["note_id", "upvotes", "downvotes", "net_votes", "last_vote_at", "last_positive_vote_at"]
);
assert.deepEqual(db.prepare("SELECT * FROM note_state").all(), []);
} finally {
db.close();
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

test("independent votes reconcile to identical immutable rows and note state in either arrival order", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-notes-concurrent-"));
const base = path.join(root, "base");
Expand Down
5 changes: 4 additions & 1 deletion actions/setup/js/ledger_append.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ async function main(config = {}) {

const requests = [];
const handleLedgerAppend = async message => {
requests.push(message);
if (!message || typeof message !== "object" || Array.isArray(message)) throw new TypeError("Invalid ledger append message");
const { type, ...request } = message;
if (type !== undefined && type !== "ledger_append") throw new TypeError("Invalid ledger append message type");
Comment thread
pelikhan marked this conversation as resolved.
Resolved
requests.push(request);
return { success: true, queued: true };
};

Expand Down
21 changes: 21 additions & 0 deletions actions/setup/js/ledger_append.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ test("collects validated appends and writes the versioned artifact", async () =>
}
});

test("normalizes the safe-output type discriminator before validating built-in appends", async () => {
const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-append-envelope-"));
const previousRunnerTemp = process.env.RUNNER_TEMP;
process.env.RUNNER_TEMP = runnerTemp;
try {
const handler = await main({ ledgers: [{ name: "events", type: "log" }] });
await handler({ type: "ledger_append", ledger: "events", operation: "append", value: { ok: true } });
handler.finalize();

const artifact = JSON.parse(fs.readFileSync(transactionPath(), "utf8"));
assert.equal(artifact.ledgers.events.appends.length, 1);
const append = artifact.ledgers.events.appends[0].record;
assert.match(append.id, /^ldg-/);
assert.deepEqual({ operation: append.operation, value: append.value }, { operation: "append", value: { ok: true } });
} finally {
if (previousRunnerTemp === undefined) delete process.env.RUNNER_TEMP;
else process.env.RUNNER_TEMP = previousRunnerTemp;
fs.rmSync(runnerTemp, { recursive: true, force: true });
}
});

test("rejects invalid records at finalization", async () => {
const handler = await main({ ledgers: [{ name: "findings", schema: { type: "object", required: ["subject"] } }] });
await handler({ record: { other: true } });
Expand Down
47 changes: 28 additions & 19 deletions actions/setup/js/ledger_builtin.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,6 @@ const OPERATIONS = Object.freeze({
const MAX_REPLAY_CELL_BYTES = 65536;
const NOTE_LIMITS = { subject: 512, note: 4096, reason: 1024, citations: 32, citationBytes: 2048 };
const NOTE_STATE_COLUMNS = { note_id: "text", upvotes: "integer", downvotes: "integer", net_votes: "integer", last_vote_at: "text", last_positive_vote_at: "text" };
const NOTE_STATE_VIEW = `CREATE VIEW note_state AS
WITH vote_state AS (
SELECT note_id,
sum(CASE WHEN vote = 'up' THEN 1 ELSE 0 END) AS upvotes,
sum(CASE WHEN vote = 'down' THEN 1 ELSE 0 END) AS downvotes,
max(created_at) AS last_vote_at,
max(CASE WHEN vote = 'up' THEN created_at END) AS last_positive_vote_at
FROM note_votes
GROUP BY note_id
)
SELECT c.id AS note_id,
coalesce(v.upvotes, 0) AS upvotes,
coalesce(v.downvotes, 0) AS downvotes,
coalesce(v.upvotes, 0) - coalesce(v.downvotes, 0) AS net_votes,
v.last_vote_at,
v.last_positive_vote_at
FROM notes c
LEFT JOIN vote_state v ON v.note_id = c.id`;

function validateCitation(citation) {
if (!citation || typeof citation !== "object" || Array.isArray(citation)) return false;
Expand Down Expand Up @@ -187,6 +169,18 @@ function createReducer(config) {
if (config.type === "notes") {
const noteRows = [...notes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const voteRows = [...votes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const voteState = new Map();
for (const [, { record, timestamp }] of voteRows) {
const state = voteState.get(record.note_id) || { upvotes: 0, downvotes: 0, last_vote_at: null, last_positive_vote_at: null };
Comment thread
pelikhan marked this conversation as resolved.
Resolved
if (record.vote === "up") {
state.upvotes++;
if (timestamp !== null && (state.last_positive_vote_at === null || timestamp > state.last_positive_vote_at)) state.last_positive_vote_at = timestamp;
} else {
state.downvotes++;
Comment thread
pelikhan marked this conversation as resolved.
Resolved
}
if (timestamp !== null && (state.last_vote_at === null || timestamp > state.last_vote_at)) state.last_vote_at = timestamp;
voteState.set(record.note_id, state);
}
const tables = {
notes: {
columns: { id: "text", subject: "text", note: "text", reason: "text", created_at: "text", record_sha: "text" },
Expand All @@ -212,6 +206,21 @@ function createReducer(config) {
primaryKey: ["record_id"],
rows: voteRows.map(([, { record, recordId, timestamp }]) => ({ record_id: recordId, note_id: record.note_id, vote: record.vote, reason: record.reason ?? null, created_at: timestamp })),
},
note_state: {
columns: NOTE_STATE_COLUMNS,
primaryKey: ["note_id"],
rows: noteRows.map(([note_id]) => {
const state = voteState.get(note_id) || { upvotes: 0, downvotes: 0, last_vote_at: null, last_positive_vote_at: null };
Comment thread
pelikhan marked this conversation as resolved.
Resolved
return {
note_id,
upvotes: state.upvotes,
downvotes: state.downvotes,
net_votes: state.upvotes - state.downvotes,
last_vote_at: state.last_vote_at,
last_positive_vote_at: state.last_positive_vote_at,
};
}),
},
};
return { version: 1, tables };
}
Expand Down Expand Up @@ -255,4 +264,4 @@ function replayBuiltin(config, records) {
return reducer.output();
}

module.exports = { NOTE_STATE_COLUMNS, NOTE_STATE_VIEW, OPERATIONS, createReducer, replayBuiltin, validateOperation };
module.exports = { NOTE_STATE_COLUMNS, OPERATIONS, createReducer, replayBuiltin, validateOperation };
5 changes: 2 additions & 3 deletions actions/setup/js/ledger_builtin.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { NOTE_STATE_VIEW, createReducer, replayBuiltin, validateOperation } from "./ledger_builtin.cjs";
import { createReducer, replayBuiltin, validateOperation } from "./ledger_builtin.cjs";
import { normalizeLedgerAppends } from "./ledger_transactions.cjs";
import { finalId } from "./ledger_transactions.cjs";
import { validateTransactions } from "./push_ledger_changes.cjs";
Expand Down Expand Up @@ -252,8 +252,7 @@ test("note_state derives zero votes and null last-vote timestamps", () => {
const output = replayBuiltin({ type: "notes" }, records);
const db = new DatabaseSync(":memory:");
try {
materializeReplay(db, "notes", "builtin:notes", records, output, 2);
db.exec(NOTE_STATE_VIEW);
materializeReplay(db, "notes", "builtin:notes", records, output, 3);
assert.deepEqual({ ...db.prepare("SELECT * FROM note_state").get() }, { note_id: records[0].id, upvotes: 0, downvotes: 0, net_votes: 0, last_vote_at: null, last_positive_vote_at: null });
} finally {
db.close();
Expand Down
Loading