feat(HAI-023): complete Step 1 — core types and store methods for attachments
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "./store.js";
|
||||||
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { Task } from "./types.js";
|
import type { Task } from "./types.js";
|
||||||
|
|
||||||
@@ -165,6 +165,85 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Attachment tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("attachments", () => {
|
||||||
|
const TINY_PNG = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("adds an attachment and persists metadata in task.json", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "screenshot.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
expect(attachment.originalName).toBe("screenshot.png");
|
||||||
|
expect(attachment.mimeType).toBe("image/png");
|
||||||
|
expect(attachment.size).toBe(TINY_PNG.length);
|
||||||
|
expect(attachment.filename).toMatch(/^\d+-screenshot\.png$/);
|
||||||
|
|
||||||
|
// Verify metadata persisted
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
expect(updated.attachments).toHaveLength(1);
|
||||||
|
expect(updated.attachments![0].filename).toBe(attachment.filename);
|
||||||
|
|
||||||
|
// Verify file on disk
|
||||||
|
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
expect(content).toEqual(TINY_PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-image mime types", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.addAttachment(task.id, "file.txt", Buffer.from("hello"), "text/plain"),
|
||||||
|
).rejects.toThrow("Invalid mime type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized files", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const bigBuffer = Buffer.alloc(6 * 1024 * 1024); // 6MB
|
||||||
|
await expect(
|
||||||
|
store.addAttachment(task.id, "big.png", bigBuffer, "image/png"),
|
||||||
|
).rejects.toThrow("File too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets attachment path and mime type", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "shot.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
const result = await store.getAttachment(task.id, attachment.filename);
|
||||||
|
expect(result.mimeType).toBe("image/png");
|
||||||
|
expect(result.path).toContain(attachment.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an attachment from disk and metadata", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "del.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
const updated = await store.deleteAttachment(task.id, attachment.filename);
|
||||||
|
expect(updated.attachments).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify file removed from disk
|
||||||
|
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when getting non-existent attachment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.getAttachment(task.id, "nonexistent.png"),
|
||||||
|
).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when deleting non-existent attachment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.deleteAttachment(task.id, "nonexistent.png"),
|
||||||
|
).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Concurrent stress test ───────────────────────────────────────
|
// ── Concurrent stress test ───────────────────────────────────────
|
||||||
|
|
||||||
describe("concurrent stress", () => {
|
describe("concurrent stress", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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, rename } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, readdir, rename, unlink } 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, TaskAttachment, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||||
|
|
||||||
export interface TaskStoreEvents {
|
export interface TaskStoreEvents {
|
||||||
@@ -709,6 +709,118 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ALLOWED_MIME_TYPES = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
async addAttachment(
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
content: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<TaskAttachment> {
|
||||||
|
if (!TaskStore.ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid mime type '${mimeType}'. Allowed: ${[...TaskStore.ALLOWED_MIME_TYPES].join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (content.length > TaskStore.MAX_ATTACHMENT_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`File too large (${content.length} bytes). Maximum: ${TaskStore.MAX_ATTACHMENT_SIZE} bytes (5MB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const attachDir = join(dir, "attachments");
|
||||||
|
await mkdir(attachDir, { recursive: true });
|
||||||
|
|
||||||
|
// Sanitize filename: keep alphanumeric, dots, hyphens, underscores
|
||||||
|
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const storedName = `${Date.now()}-${sanitized}`;
|
||||||
|
await writeFile(join(attachDir, storedName), content);
|
||||||
|
|
||||||
|
const attachment: TaskAttachment = {
|
||||||
|
filename: storedName,
|
||||||
|
originalName: filename,
|
||||||
|
mimeType,
|
||||||
|
size: content.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
if (!task.attachments) task.attachments = [];
|
||||||
|
task.attachments.push(attachment);
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachment(
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<{ path: string; mimeType: string }> {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
const attachment = task.attachments?.find((a) => a.filename === filename);
|
||||||
|
if (!attachment) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error(
|
||||||
|
`Attachment '${filename}' not found on task ${id}`,
|
||||||
|
);
|
||||||
|
err.code = "ENOENT";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: join(dir, "attachments", filename),
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
const idx = task.attachments?.findIndex((a) => a.filename === filename) ?? -1;
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error(
|
||||||
|
`Attachment '${filename}' not found on task ${id}`,
|
||||||
|
);
|
||||||
|
err.code = "ENOENT";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove file from disk
|
||||||
|
const filePath = join(dir, "attachments", filename);
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
|
||||||
|
task.attachments!.splice(idx, 1);
|
||||||
|
if (task.attachments!.length === 0) {
|
||||||
|
task.attachments = undefined;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getRootDir(): string {
|
getRootDir(): string {
|
||||||
return this.rootDir;
|
return this.rootDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export interface TaskLogEntry {
|
|||||||
outcome?: string;
|
outcome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskAttachment {
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -24,6 +32,7 @@ export interface Task {
|
|||||||
steps: TaskStep[];
|
steps: TaskStep[];
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
attachments?: TaskAttachment[];
|
||||||
log: TaskLogEntry[];
|
log: TaskLogEntry[];
|
||||||
size?: "S" | "M" | "L";
|
size?: "S" | "M" | "L";
|
||||||
reviewLevel?: number;
|
reviewLevel?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user