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
71 changes: 71 additions & 0 deletions src/core/state-chat-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AgentChatLogEntry } from "./types";

const STATE_ROOT_DIR = path.join(".piv-loop", "projects");
const CHAT_LOGS_DIR = "chat-logs";
export const AGENT_CHAT_LOG_RETENTION = 1000;

function sanitizePathToken(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "default";
}

function skillPathHash(skillPath: string): string {
return createHash("sha1").update(skillPath).digest("hex").slice(0, 8);
}

function agentSkillLogFileName(skillPath: string): string {
const normalized = sanitizePathToken(skillPath.replace(/[\\/]+/g, "-"));
return `${normalized}-${skillPathHash(skillPath)}.json`;
}

export function agentChatLogPath(
cwd: string,
projectId: string,
agentRole: AgentChatLogEntry["agentRole"],
skillPath: string,
): string {
return path.join(
cwd,
STATE_ROOT_DIR,
projectId,
CHAT_LOGS_DIR,
sanitizePathToken(agentRole),
agentSkillLogFileName(skillPath),
);
}

export async function appendAgentChatLog(
cwd: string,
projectId: string,
entry: AgentChatLogEntry,
retention = AGENT_CHAT_LOG_RETENTION,
): Promise<void> {
const file = agentChatLogPath(
cwd,
projectId,
entry.agentRole,
entry.skillPath,
);
await mkdir(path.dirname(file), { recursive: true });

let existing: AgentChatLogEntry[] = [];
try {
const raw = await readFile(file, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
existing = parsed as AgentChatLogEntry[];
}
} catch {}

existing.push(entry);
const keep = Math.max(1, retention);
const trimmed = existing.slice(-keep);
await writeFile(file, `${JSON.stringify(trimmed, null, 2)}\n`, "utf8");
}
73 changes: 6 additions & 67 deletions src/core/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import {
appendFile,
mkdir,
Expand All @@ -7,12 +6,15 @@ import {
writeFile,
} from "node:fs/promises";
import path from "node:path";
import type { AgentChatLogEntry, RunState, WorkflowStage } from "./types";
import type { RunState, WorkflowStage } from "./types";
export {
AGENT_CHAT_LOG_RETENTION,
agentChatLogPath,
appendAgentChatLog,
} from "./state-chat-log";

const LEGACY_STATE_DIR = path.join(".piv-loop", "runs");
const STATE_ROOT_DIR = path.join(".piv-loop", "projects");
const CHAT_LOGS_DIR = "chat-logs";
export const AGENT_CHAT_LOG_RETENTION = 1000;

export function normalizeIssueKey(input: string): string {
const match = input.trim().match(/[A-Z]+-\d+/);
Expand Down Expand Up @@ -190,66 +192,3 @@ export function hasRunLeaseConflict(
}
return !isRunLeaseExpired(state, nowMs);
}

function sanitizePathToken(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "default";
}

function skillPathHash(skillPath: string): string {
return createHash("sha1").update(skillPath).digest("hex").slice(0, 8);
}

function agentSkillLogFileName(skillPath: string): string {
const normalized = sanitizePathToken(skillPath.replace(/[\\/]+/g, "-"));
return `${normalized}-${skillPathHash(skillPath)}.json`;
}

export function agentChatLogPath(
cwd: string,
projectId: string,
agentRole: AgentChatLogEntry["agentRole"],
skillPath: string,
): string {
return path.join(
cwd,
STATE_ROOT_DIR,
projectId,
CHAT_LOGS_DIR,
sanitizePathToken(agentRole),
agentSkillLogFileName(skillPath),
);
}

export async function appendAgentChatLog(
cwd: string,
projectId: string,
entry: AgentChatLogEntry,
retention = AGENT_CHAT_LOG_RETENTION,
): Promise<void> {
const file = agentChatLogPath(
cwd,
projectId,
entry.agentRole,
entry.skillPath,
);
await mkdir(path.dirname(file), { recursive: true });

let existing: AgentChatLogEntry[] = [];
try {
const raw = await readFile(file, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
existing = parsed as AgentChatLogEntry[];
}
} catch {}

existing.push(entry);
const keep = Math.max(1, retention);
const trimmed = existing.slice(-keep);
await writeFile(file, `${JSON.stringify(trimmed, null, 2)}\n`, "utf8");
}
36 changes: 25 additions & 11 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,24 @@ describe("loadConfig", () => {
});

it("loads notification settings from RESEND env vars", async () => {
const tempDir = await mkdtemp(
path.join(process.cwd(), ".tmp-config-test-"),
);
process.env.RESEND_API_KEY = "re_test_key";
process.env.RESEND_FROM = "ADHD.ai <ops@example.com>";
process.env.RESEND_TO = "a@example.com,b@example.com";
const config = await loadConfig(process.cwd());
expect(config.notifications.email.enabled).toBe(true);
expect(config.notifications.email.resendApiKey).toBe("re_test_key");
expect(config.notifications.email.from).toBe("ADHD.ai <ops@example.com>");
expect(config.notifications.email.to).toEqual([
"a@example.com",
"b@example.com",
]);
try {
const config = await loadConfig(tempDir);
expect(config.notifications.email.enabled).toBe(true);
expect(config.notifications.email.resendApiKey).toBe("re_test_key");
expect(config.notifications.email.from).toBe("ADHD.ai <ops@example.com>");
expect(config.notifications.email.to).toEqual([
"a@example.com",
"b@example.com",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it("supports disabling notifications even with RESEND_API_KEY", async () => {
Expand Down Expand Up @@ -314,12 +321,19 @@ describe("loadConfig", () => {
});

it("rejects missing sender when notifications are enabled", async () => {
const tempDir = await mkdtemp(
path.join(process.cwd(), ".tmp-config-test-"),
);
process.env.RESEND_API_KEY = "re_test_key";
process.env.RESEND_FROM = "";
process.env.RESEND_TO = "a@example.com";
await expect(loadConfig(process.cwd())).rejects.toThrow(
"notifications.email.from (or RESEND_FROM) is required when email notifications are enabled",
);
try {
await expect(loadConfig(tempDir)).rejects.toThrow(
"notifications.email.from (or RESEND_FROM) is required when email notifications are enabled",
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it("rejects project-level notification overrides", async () => {
Expand Down
80 changes: 80 additions & 0 deletions tests/file-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";

const MAX_TS_LINES = 250;
const IGNORE_DIRS = new Set([
".git",
".piv-loop",
"node_modules",
"dist",
"coverage",
]);
const KNOWN_OVERSIZED_TS_FILES = new Set([
"src/core/config.ts",
"src/core/setup.ts",
"src/core/types.ts",
"src/core/workflow.ts",
"src/services/codex-adapter.ts",
"src/services/cron.ts",
"src/services/github.ts",
"src/services/linear.ts",
"src/skills/catalog.ts",
"tests/config.test.ts",
"tests/cron.test.ts",
"tests/github.test.ts",
"tests/linear.test.ts",
"tests/setup.test.ts",
"tests/workflow.test.ts",
]);

async function collectTsFiles(root: string, dir = ""): Promise<string[]> {
const absolute = path.join(root, dir);
const entries = await readdir(absolute, { withFileTypes: true });
const collected: string[] = [];

for (const entry of entries) {
if (IGNORE_DIRS.has(entry.name)) {
continue;
}
const relativePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nested = await collectTsFiles(root, relativePath);
collected.push(...nested);
continue;
}
if (entry.isFile() && relativePath.endsWith(".ts")) {
collected.push(relativePath);
}
}

return collected;
}

function countLines(text: string): number {
if (!text) {
return 0;
}
return text.endsWith("\n")
? text.split("\n").length - 1
: text.split("\n").length;
}

describe("TypeScript file size limit", () => {
it("blocks new TypeScript files from exceeding 250 lines", async () => {
const root = path.resolve(import.meta.dir, "..");
const files = await collectTsFiles(root);
const unexpectedViolations: string[] = [];

for (const file of files) {
const contents = await readFile(path.join(root, file), "utf8");
const lineCount = countLines(contents);
const isKnownOversized = KNOWN_OVERSIZED_TS_FILES.has(file);
if (lineCount > MAX_TS_LINES && !isKnownOversized) {
unexpectedViolations.push(`${file}: ${lineCount}`);
}
}

expect(unexpectedViolations).toEqual([]);
});
});