feat(HAI-017): add per-task write lock, atomic writes, and defensive JSON parsing to TaskStore

- Add per-task write lock in TaskStore to prevent concurrent write corruption
- Implement defensive JSON parsing with recovery for task.json files
- Use atomic writes (write-to-temp + rename) for task.json updates
- Add vitest config and tests for lock, parsing, and atomic write behavior
- Remove concurrency module from engine, consolidating store safety in core
This commit is contained in:
Dustin Byrne
2026-03-25 21:29:10 -04:00
5 changed files with 686 additions and 208 deletions

View File

@@ -7,10 +7,12 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"typescript": "^5.7.0" "typescript": "^5.7.0",
"vitest": "^3.1.0"
} }
} }

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { TaskStore } from "./store.js";
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import type { Task } from "./types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "hai-store-test-"));
}
describe("TaskStore", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
await store.init();
});
afterEach(async () => {
store.stopWatching();
await rm(rootDir, { recursive: true, force: true });
});
async function createTestTask(): Promise<Task> {
return store.createTask({ description: "Test task" });
}
async function createTaskWithSteps(): Promise<Task> {
const task = await store.createTask({ description: "Task with steps" });
// Write a PROMPT.md with steps so updateStep works
const dir = join(rootDir, ".hai", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with steps
## Steps
### Step 0: Preflight
- [ ] Check things
### Step 1: Implementation
- [ ] Do stuff
### Step 2: Testing
- [ ] Test stuff
`,
);
return task;
}
// ── Lock serialization test ──────────────────────────────────────
describe("write lock serialization", () => {
it("serializes concurrent logEntry and updateStep calls without corruption", async () => {
const task = await createTaskWithSteps();
const id = task.id;
// Fire 20 concurrent operations: 10 logEntry + 10 updateStep (alternating steps)
const promises: Promise<Task>[] = [];
for (let i = 0; i < 20; i++) {
if (i % 2 === 0) {
promises.push(store.logEntry(id, `Log entry ${i}`));
} else {
// Toggle step 0 between in-progress and done
const status = i % 4 === 1 ? "in-progress" : "done";
promises.push(store.updateStep(id, 0, status));
}
}
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".hai", "tasks", id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Check all 10 log entries are present (plus initial "Task created" + step update logs)
const customLogs = result.log.filter((l) => l.action.startsWith("Log entry"));
expect(customLogs).toHaveLength(10);
});
});
// ── Defensive parsing test ───────────────────────────────────────
describe("defensive JSON parsing", () => {
it("recovers from corrupted task.json with trailing duplicate content", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
// Corrupt the file: append duplicate trailing content (like HAI-015)
const validJson = await readFile(taskJsonPath, "utf-8");
const corrupted = validJson + validJson.slice(validJson.length / 2);
await writeFile(taskJsonPath, corrupted);
// getTask should recover
const recovered = await store.getTask(task.id);
expect(recovered.id).toBe(task.id);
expect(recovered.description).toBe("Test task");
});
it("throws a clear error when JSON is completely unrecoverable", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
// Write completely invalid content
await writeFile(taskJsonPath, "not json at all {{{");
await expect(store.getTask(task.id)).rejects.toThrow("Failed to parse task.json");
});
});
// ── Atomic write test ────────────────────────────────────────────
describe("atomic writes", () => {
it("produces valid JSON after write with no .tmp files left behind", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".hai", "tasks", task.id);
// Perform a write
await store.logEntry(task.id, "atomic test");
// Verify valid JSON
const raw = await readFile(join(dir, "task.json"), "utf-8");
const parsed = JSON.parse(raw) as Task;
expect(parsed.log.some((l) => l.action === "atomic test")).toBe(true);
// Verify no .tmp files
const files = await readdir(dir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {
it("handles 10 parallel logEntry calls preserving all entries", async () => {
const task = await createTestTask();
const initialLogCount = task.log.length; // 1 ("Task created")
const promises = Array.from({ length: 10 }, (_, i) =>
store.logEntry(task.id, `Stress log ${i}`),
);
await Promise.all(promises);
const result = await store.getTask(task.id);
const stressLogs = result.log.filter((l) => l.action.startsWith("Stress log"));
expect(stressLogs).toHaveLength(10);
expect(result.log).toHaveLength(initialLogCount + 10);
});
});
});

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises"; import { mkdir, readFile, writeFile, readdir, rename } from "node:fs/promises";
import { join, sep } from "node:path"; import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs"; import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, Settings } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, Settings } from "./types.js";
@@ -29,6 +29,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map(); private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/** Debounce interval in ms */ /** Debounce interval in ms */
private debounceMs = 150; private debounceMs = 150;
/** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map();
constructor(private rootDir: string) { constructor(private rootDir: string) {
super(); super();
@@ -44,6 +46,74 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
} }
/**
* Serialize all mutations to a given task's task.json by chaining promises
* per task ID. Concurrent callers for the same ID will queue behind each other.
*/
private withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = this.taskLocks.get(id) ?? Promise.resolve();
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
this.taskLocks.set(id, next);
return prev.then(async () => {
try {
return await fn();
} finally {
if (this.taskLocks.get(id) === next) {
this.taskLocks.delete(id);
}
resolve!();
}
});
}
/**
* Safely read and parse a task.json file. On `SyntaxError`, attempts to
* recover by truncating the content at the last valid `}` and re-parsing.
* Logs a warning to stderr when truncation-repair is used.
*/
private async safeReadTaskJson(dir: string): Promise<Task> {
const filePath = join(dir, "task.json");
const raw = await readFile(filePath, "utf-8");
try {
return JSON.parse(raw) as Task;
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
// Attempt recovery: try truncating at each '}' from the end until valid
let pos = raw.length;
while ((pos = raw.lastIndexOf("}", pos - 1)) > 0) {
try {
const task = JSON.parse(raw.slice(0, pos + 1)) as Task;
console.warn(
`[hai] Warning: repaired corrupted task.json at ${filePath} (truncated ${raw.length - pos - 1} trailing bytes)`,
);
return task;
} catch {
// Try next position
}
}
throw new Error(
`Failed to parse task.json at ${filePath}: ${(err as Error).message}`,
);
}
}
/**
* Atomically write a task.json file by writing to a temp file first,
* then renaming it into place. The rename is atomic on POSIX filesystems,
* preventing partial writes from corrupting the file on crash/kill.
*/
private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
const taskJsonPath = join(dir, "task.json");
const tmpPath = join(dir, "task.json.tmp");
this.suppressWatcher(taskJsonPath);
await writeFile(tmpPath, JSON.stringify(task, null, 2));
await rename(tmpPath, taskJsonPath);
}
async getSettings(): Promise<Settings> { async getSettings(): Promise<Settings> {
const config = await this.readConfig(); const config = await this.readConfig();
return { ...DEFAULT_SETTINGS, ...config.settings }; return { ...DEFAULT_SETTINGS, ...config.settings };
@@ -101,9 +171,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const dir = this.taskDir(id); const dir = this.taskDir(id);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
const taskJsonPath = join(dir, "task.json"); await this.atomicWriteTaskJson(dir, task);
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
// Update cache if watcher is active // Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task }); if (this.watcher) this.taskCache.set(id, { ...task });
@@ -120,8 +188,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async getTask(id: string): Promise<TaskDetail> { async getTask(id: string): Promise<TaskDetail> {
const dir = this.taskDir(id); const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8"); const task = await this.safeReadTaskJson(dir);
const task = JSON.parse(data) as Task;
let prompt = ""; let prompt = "";
const promptPath = join(dir, "PROMPT.md"); const promptPath = join(dir, "PROMPT.md");
@@ -141,11 +208,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith("HAI-")) { if (entry.isDirectory() && entry.name.startsWith("HAI-")) {
try { try {
const data = await readFile( tasks.push(await this.safeReadTaskJson(join(this.tasksDir, entry.name)));
join(this.tasksDir, entry.name, "task.json"),
"utf-8",
);
tasks.push(JSON.parse(data));
} catch { } catch {
// skip invalid task dirs // skip invalid task dirs
} }
@@ -156,70 +219,68 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
async moveTask(id: string, toColumn: Column): Promise<Task> { async moveTask(id: string, toColumn: Column): Promise<Task> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
const validTargets = VALID_TRANSITIONS[task.column]; const validTargets = VALID_TRANSITIONS[task.column];
if (!validTargets.includes(toColumn)) { if (!validTargets.includes(toColumn)) {
throw new Error( throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` + `Invalid transition: '${task.column}' → '${toColumn}'. ` +
`Valid targets: ${validTargets.join(", ") || "none"}`, `Valid targets: ${validTargets.join(", ") || "none"}`,
); );
} }
const fromColumn = task.column; const fromColumn = task.column;
task.column = toColumn; task.column = toColumn;
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
// Clear transient fields when moving to done (matches moveToDone behavior) // Clear transient fields when moving to done (matches moveToDone behavior)
if (toColumn === "done") { if (toColumn === "done") {
task.status = undefined; task.status = undefined;
task.worktree = undefined; task.worktree = undefined;
} }
const taskJsonPath = join(dir, "task.json"); await this.atomicWriteTaskJson(dir, task);
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
// Update cache if watcher is active // Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task }); if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: fromColumn, to: toColumn }); this.emit("task:moved", { task, from: fromColumn, to: toColumn });
return task; return task;
});
} }
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null }, updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null },
): Promise<Task> { ): Promise<Task> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
if (updates.title !== undefined) task.title = updates.title; if (updates.title !== undefined) task.title = updates.title;
if (updates.description !== undefined) task.description = updates.description; if (updates.description !== undefined) task.description = updates.description;
if (updates.worktree !== undefined) task.worktree = updates.worktree; if (updates.worktree !== undefined) task.worktree = updates.worktree;
if (updates.status === null) { if (updates.status === null) {
task.status = undefined; task.status = undefined;
} else if (updates.status !== undefined) { } else if (updates.status !== undefined) {
task.status = updates.status; task.status = updates.status;
} }
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
const taskJsonPath = join(dir, "task.json"); await this.atomicWriteTaskJson(dir, task);
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
// Update cache if watcher is active // Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task }); if (this.watcher) this.taskCache.set(id, { ...task });
if (updates.prompt !== undefined) { if (updates.prompt !== undefined) {
await writeFile(join(dir, "PROMPT.md"), updates.prompt); await writeFile(join(dir, "PROMPT.md"), updates.prompt);
} }
this.emit("task:updated", task); this.emit("task:updated", task);
return task; return task;
});
} }
/** /**
@@ -230,73 +291,71 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
stepIndex: number, stepIndex: number,
status: import("./types.js").StepStatus, status: import("./types.js").StepStatus,
): Promise<Task> { ): Promise<Task> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
// Auto-initialize steps from PROMPT.md if empty // Auto-initialize steps from PROMPT.md if empty
if (task.steps.length === 0) { if (task.steps.length === 0) {
task.steps = await this.parseStepsFromPrompt(id); task.steps = await this.parseStepsFromPrompt(id);
}
if (stepIndex < 0 || stepIndex >= task.steps.length) {
throw new Error(
`Step ${stepIndex} out of range (task has ${task.steps.length} steps)`,
);
}
task.steps[stepIndex].status = status;
task.updatedAt = new Date().toISOString();
// Advance currentStep to first non-done step
if (status === "done") {
while (
task.currentStep < task.steps.length &&
task.steps[task.currentStep].status === "done"
) {
task.currentStep++;
} }
} else if (status === "in-progress") {
task.currentStep = stepIndex;
}
// Log it if (stepIndex < 0 || stepIndex >= task.steps.length) {
task.log.push({ throw new Error(
timestamp: task.updatedAt, `Step ${stepIndex} out of range (task has ${task.steps.length} steps)`,
action: `Step ${stepIndex} (${task.steps[stepIndex].name}) → ${status}`, );
}
task.steps[stepIndex].status = status;
task.updatedAt = new Date().toISOString();
// Advance currentStep to first non-done step
if (status === "done") {
while (
task.currentStep < task.steps.length &&
task.steps[task.currentStep].status === "done"
) {
task.currentStep++;
}
} else if (status === "in-progress") {
task.currentStep = stepIndex;
}
// Log it
task.log.push({
timestamp: task.updatedAt,
action: `Step ${stepIndex} (${task.steps[stepIndex].name}) → ${status}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
}); });
const taskJsonPath = join(dir, "task.json");
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
} }
/** /**
* Add a log entry to a task. * Add a log entry to a task.
*/ */
async logEntry(id: string, action: string, outcome?: string): Promise<Task> { async logEntry(id: string, action: string, outcome?: string): Promise<Task> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
task.log.push({ task.log.push({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
action, action,
outcome, outcome,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
}); });
task.updatedAt = new Date().toISOString();
const taskJsonPath = join(dir, "task.json");
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
} }
/** /**
@@ -347,21 +406,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
async deleteTask(id: string): Promise<Task> { async deleteTask(id: string): Promise<Task> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
const taskJsonPath = join(dir, "task.json"); const taskJsonPath = join(dir, "task.json");
this.suppressWatcher(taskJsonPath); this.suppressWatcher(taskJsonPath);
// Remove from cache if watcher is active // Remove from cache if watcher is active
if (this.watcher) this.taskCache.delete(id); if (this.watcher) this.taskCache.delete(id);
const { rm } = await import("node:fs/promises"); const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true }); await rm(dir, { recursive: true });
this.emit("task:deleted", task); this.emit("task:deleted", task);
return task; return task;
});
} }
/** /**
@@ -369,102 +429,103 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* clean up the worktree, and move the task to done. * clean up the worktree, and move the task to done.
*/ */
async mergeTask(id: string): Promise<MergeResult> { async mergeTask(id: string): Promise<MergeResult> {
const dir = this.taskDir(id); return this.withTaskLock(id, async () => {
const data = await readFile(join(dir, "task.json"), "utf-8"); const dir = this.taskDir(id);
const task = JSON.parse(data) as Task; const task = await this.safeReadTaskJson(dir);
if (task.column !== "in-review") { if (task.column !== "in-review") {
throw new Error( throw new Error(
`Cannot merge ${id}: task is in '${task.column}', must be in 'in-review'`, `Cannot merge ${id}: task is in '${task.column}', must be in 'in-review'`,
); );
}
const branch = `hai/${id.toLowerCase()}`;
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", id);
const result: MergeResult = {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
};
// 1. Check the branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
} catch {
// No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
}
// 2. Merge the branch
try {
execSync(`git merge "${branch}" --no-edit`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
} catch (err: any) {
// Merge conflict — abort and report
try {
execSync("git merge --abort", { cwd: this.rootDir, stdio: "pipe" });
} catch {
// already clean
} }
throw new Error(
`Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\n` +
` git merge ${branch}\n` +
` # resolve conflicts, then: hai task move ${id} done`,
);
}
// 3. Remove worktree const branch = `hai/${id.toLowerCase()}`;
if (existsSync(worktreePath)) { const worktreePath = task.worktree || join(this.rootDir, ".worktrees", id);
const result: MergeResult = {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
};
// 1. Check the branch exists
try { try {
execSync(`git worktree remove "${worktreePath}" --force`, { execSync(`git rev-parse --verify "${branch}"`, {
cwd: this.rootDir, cwd: this.rootDir,
stdio: "pipe", stdio: "pipe",
}); });
result.worktreeRemoved = true;
} catch { } catch {
// Non-fatal — worktree may already be gone // No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
} }
}
// 4. Delete the branch // 2. Merge the branch
try {
execSync(`git branch -d "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Branch might not be fully merged in some edge cases; try force
try { try {
execSync(`git branch -D "${branch}"`, { execSync(`git merge "${branch}" --no-edit`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
} catch (err: any) {
// Merge conflict — abort and report
try {
execSync("git merge --abort", { cwd: this.rootDir, stdio: "pipe" });
} catch {
// already clean
}
throw new Error(
`Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\n` +
` git merge ${branch}\n` +
` # resolve conflicts, then: hai task move ${id} done`,
);
}
// 3. Remove worktree
if (existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch {
// Non-fatal — worktree may already be gone
}
}
// 4. Delete the branch
try {
execSync(`git branch -d "${branch}"`, {
cwd: this.rootDir, cwd: this.rootDir,
stdio: "pipe", stdio: "pipe",
}); });
result.branchDeleted = true; result.branchDeleted = true;
} catch { } catch {
// Non-fatal // Branch might not be fully merged in some edge cases; try force
try {
execSync(`git branch -D "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Non-fatal
}
} }
}
// 5. Move task to done // 5. Move task to done
await this.moveToDone(task, dir); await this.moveToDone(task, dir);
result.task = { ...task, column: "done" }; result.task = { ...task, column: "done" };
this.emit("task:merged", result); this.emit("task:merged", result);
return result; return result;
});
} }
private async moveToDone(task: Task, dir: string): Promise<void> { private async moveToDone(task: Task, dir: string): Promise<void> {
@@ -473,9 +534,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.status = undefined; task.status = undefined;
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
const taskJsonPath = join(dir, "task.json"); await this.atomicWriteTaskJson(dir, task);
this.suppressWatcher(taskJsonPath);
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
// Update cache if watcher is active // Update cache if watcher is active
if (this.watcher) this.taskCache.set(task.id, { ...task }); if (this.watcher) this.taskCache.set(task.id, { ...task });
@@ -594,8 +653,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
let task: Task; let task: Task;
try { try {
const data = await readFile(filePath, "utf-8"); const taskDir = join(this.tasksDir, taskId);
task = JSON.parse(data) as Task; task = await this.safeReadTaskJson(taskDir);
} catch { } catch {
return; // File not readable or invalid JSON return; // File not readable or invalid JSON
} }

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});

252
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
vitest:
specifier: ^3.1.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
packages/dashboard: packages/dashboard:
dependencies: dependencies:
@@ -1354,9 +1357,23 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/expect@4.1.1': '@vitest/expect@4.1.1':
resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/mocker@4.1.1': '@vitest/mocker@4.1.1':
resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==}
peerDependencies: peerDependencies:
@@ -1368,18 +1385,33 @@ packages:
vite: vite:
optional: true optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@4.1.1': '@vitest/pretty-format@4.1.1':
resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/runner@4.1.1': '@vitest/runner@4.1.1':
resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/snapshot@4.1.1': '@vitest/snapshot@4.1.1':
resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/spy@4.1.1': '@vitest/spy@4.1.1':
resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@4.1.1': '@vitest/utils@4.1.1':
resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==}
@@ -1487,6 +1519,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1501,6 +1537,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chai@6.2.2: chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1525,6 +1565,10 @@ packages:
character-reference-invalid@2.0.1: character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
cli-highlight@2.1.11: cli-highlight@2.1.11:
resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==}
engines: {node: '>=8.0.0', npm: '>=5.0.0'} engines: {node: '>=8.0.0', npm: '>=5.0.0'}
@@ -1599,6 +1643,10 @@ packages:
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
degenerator@5.0.1: degenerator@5.0.1:
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -1659,6 +1707,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.0.0: es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
@@ -1948,6 +1999,9 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
jsdom@29.0.1: jsdom@29.0.1:
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
@@ -1992,6 +2046,9 @@ packages:
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@11.2.7: lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -2296,6 +2353,10 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pend@1.2.0: pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -2525,6 +2586,9 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
strnum@2.2.2: strnum@2.2.2:
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
@@ -2555,6 +2619,9 @@ packages:
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.0.4: tinyexec@1.0.4:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2563,10 +2630,22 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.1.0: tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.27: tldts-core@7.0.27:
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
@@ -2665,6 +2744,11 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@6.4.1: vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2705,6 +2789,34 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vitest@4.1.1: vitest@4.1.1:
resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -4291,6 +4403,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/expect@4.1.1': '@vitest/expect@4.1.1':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -4300,6 +4420,14 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
'@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.1(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))': '@vitest/mocker@4.1.1(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
'@vitest/spy': 4.1.1 '@vitest/spy': 4.1.1
@@ -4308,15 +4436,31 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@4.1.1': '@vitest/pretty-format@4.1.1':
dependencies: dependencies:
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/runner@4.1.1': '@vitest/runner@4.1.1':
dependencies: dependencies:
'@vitest/utils': 4.1.1 '@vitest/utils': 4.1.1
pathe: 2.0.3 pathe: 2.0.3
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/snapshot@4.1.1': '@vitest/snapshot@4.1.1':
dependencies: dependencies:
'@vitest/pretty-format': 4.1.1 '@vitest/pretty-format': 4.1.1
@@ -4324,8 +4468,18 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 2.0.3 pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/spy@4.1.1': {} '@vitest/spy@4.1.1': {}
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
'@vitest/utils@4.1.1': '@vitest/utils@4.1.1':
dependencies: dependencies:
'@vitest/pretty-format': 4.1.1 '@vitest/pretty-format': 4.1.1
@@ -4424,6 +4578,8 @@ snapshots:
bytes@3.1.2: {} bytes@3.1.2: {}
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4438,6 +4594,14 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chai@6.2.2: {} chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
@@ -4455,6 +4619,8 @@ snapshots:
character-reference-invalid@2.0.1: {} character-reference-invalid@2.0.1: {}
check-error@2.1.3: {}
cli-highlight@2.1.11: cli-highlight@2.1.11:
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
@@ -4518,6 +4684,8 @@ snapshots:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
deep-eql@5.0.2: {}
degenerator@5.0.1: degenerator@5.0.1:
dependencies: dependencies:
ast-types: 0.13.4 ast-types: 0.13.4
@@ -4566,6 +4734,8 @@ snapshots:
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-module-lexer@2.0.0: {} es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
@@ -4951,6 +5121,8 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
jsdom@29.0.1: jsdom@29.0.1:
dependencies: dependencies:
'@asamuzakjp/css-color': 5.0.1 '@asamuzakjp/css-color': 5.0.1
@@ -5010,6 +5182,8 @@ snapshots:
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loupe@3.2.1: {}
lru-cache@11.2.7: {} lru-cache@11.2.7: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@@ -5501,6 +5675,8 @@ snapshots:
pathe@2.0.3: {} pathe@2.0.3: {}
pathval@2.0.1: {}
pend@1.2.0: {} pend@1.2.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@@ -5825,6 +6001,10 @@ snapshots:
dependencies: dependencies:
min-indent: 1.0.1 min-indent: 1.0.1
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
strnum@2.2.2: {} strnum@2.2.2: {}
strtok3@10.3.5: strtok3@10.3.5:
@@ -5855,6 +6035,8 @@ snapshots:
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyexec@1.0.4: {} tinyexec@1.0.4: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@@ -5862,8 +6044,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyrainbow@3.1.0: {} tinyrainbow@3.1.0: {}
tinyspy@4.0.4: {}
tldts-core@7.0.27: {} tldts-core@7.0.27: {}
tldts@7.0.27: tldts@7.0.27:
@@ -5968,6 +6156,27 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.3 vfile-message: 4.0.3
vite-node@3.2.4(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@@ -5982,6 +6191,49 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.13
'@types/node': 25.5.0
jsdom: 29.0.1
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@4.1.1(@types/node@25.5.0)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): vitest@4.1.1(@types/node@25.5.0)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@vitest/expect': 4.1.1 '@vitest/expect': 4.1.1