feat(HAI-042): add file attachment support for tasks

- Expand MIME type support for text file uploads
- Add --attach flag to CLI task create command
- Surface attachments to triage agent with image content support
- Reference attachments in executor prompt for task execution context
- Add drag-and-drop file upload on dashboard task cards
This commit is contained in:
Dustin Byrne
2026-03-26 00:06:41 -04:00
parent afcd074caa
commit 123c481dfc
13 changed files with 592 additions and 27 deletions

View File

@@ -8,21 +8,23 @@ hai — AI-orchestrated task board
Usage:
hai dashboard Start the board web UI
hai task create [desc] Create a new task (goes to triage)
hai task create [desc] [--attach f] Create a new task (goes to triage)
hai task list List all tasks
hai task show <id> Show task details, steps, log
hai task move <id> <col> Move a task to a column
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
hai task log <id> <message> Add a log entry
hai task merge <id> Merge an in-review task and close it
hai task attach <id> <file> Attach a screenshot image to a task
hai task attach <id> <file> Attach a file to a task
Options:
--port, -p <port> Dashboard port (default: 4040)
--engine Enable AI engine (auto-specify + execute tasks)
--attach <file> Attach file(s) on task create (repeatable)
--help, -h Show this help
Columns: triage, todo, in-progress, in-review, done
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
Requires configured API keys — run "pi" first to set up authentication.
@@ -55,8 +57,19 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const title = args.slice(2).join(" ");
await runTaskCreate(title || undefined);
const createArgs = args.slice(2);
const attachFiles: string[] = [];
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
attachFiles.push(createArgs[i + 1]);
i++; // skip the value
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined);
break;
}
case "list":

View File

@@ -22,7 +22,7 @@ vi.mock("@hai/core", () => {
vi.mock("@hai/engine", () => ({ aiMergeTask: vi.fn() }));
import { TaskStore } from "@hai/core";
import { runTaskShow } from "./task.js";
import { runTaskShow, runTaskCreate } from "./task.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -92,3 +92,117 @@ describe("runTaskShow", () => {
expect(headerLine![0]).not.toContain("This is the full description");
});
});
// Mock fs/promises for runTaskCreate attach tests
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
}));
describe("runTaskCreate with --attach", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockAddAttachment: ReturnType<typeof vi.fn>;
let mockReadFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockAddAttachment = vi.fn().mockResolvedValue({
filename: "abc123-test.png",
originalName: "test.png",
mimeType: "image/png",
size: 2048,
createdAt: new Date().toISOString(),
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue({
id: "HAI-002",
description: "test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
addAttachment: mockAddAttachment,
}));
const fsMod = await import("node:fs/promises");
mockReadFile = vi.mocked(fsMod.readFile);
mockReadFile.mockResolvedValue(Buffer.from("file content"));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates task and attaches files when attachFiles provided", async () => {
await runTaskCreate("test task", ["/tmp/test.png"]);
expect(mockAddAttachment).toHaveBeenCalledOnce();
expect(mockAddAttachment).toHaveBeenCalledWith(
"HAI-002",
"test.png",
expect.any(Buffer),
"image/png",
);
const attachLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Attached"),
);
expect(attachLine).toBeDefined();
});
it("attaches multiple files", async () => {
mockAddAttachment.mockResolvedValueOnce({
filename: "abc-screenshot.png",
originalName: "screenshot.png",
mimeType: "image/png",
size: 1024,
createdAt: new Date().toISOString(),
}).mockResolvedValueOnce({
filename: "def-crash.log",
originalName: "crash.log",
mimeType: "text/plain",
size: 512,
createdAt: new Date().toISOString(),
});
await runTaskCreate("test task", ["/tmp/screenshot.png", "/tmp/crash.log"]);
expect(mockAddAttachment).toHaveBeenCalledTimes(2);
});
it("skips files with unsupported extensions", async () => {
await runTaskCreate("test task", ["/tmp/file.exe"]);
expect(mockAddAttachment).not.toHaveBeenCalled();
const errLine = errorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Unsupported"),
);
expect(errLine).toBeDefined();
});
it("skips unreadable files", async () => {
mockReadFile.mockRejectedValueOnce(new Error("ENOENT"));
await runTaskCreate("test task", ["/tmp/missing.png"]);
expect(mockAddAttachment).not.toHaveBeenCalled();
const errLine = errorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Cannot read"),
);
expect(errLine).toBeDefined();
});
it("creates task without attachments when attachFiles is undefined", async () => {
await runTaskCreate("test task");
expect(mockAddAttachment).not.toHaveBeenCalled();
});
});

View File

@@ -10,7 +10,7 @@ async function getStore(): Promise<TaskStore> {
return store;
}
export async function runTaskCreate(descriptionArg?: string) {
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[]) {
let description = descriptionArg;
if (!description) {
@@ -35,6 +35,36 @@ export async function runTaskCreate(descriptionArg?: string) {
console.log(` ✓ Created ${task.id}: ${label}`);
console.log(` Column: triage`);
console.log(` Path: .hai/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
const { basename, extname, resolve } = await import("node:path");
for (const filePath of attachFiles) {
const resolvedPath = resolve(filePath);
const filename = basename(resolvedPath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
console.error(` ✗ Unsupported file type: ${ext} (${filename})`);
continue;
}
let content: Buffer;
try {
content = await readFile(resolvedPath);
} catch {
console.error(` ✗ Cannot read file: ${filePath}`);
continue;
}
const attachment = await store.addAttachment(task.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
console.log(` 📎 Attached: ${attachment.originalName} (${sizeKB} KB)`);
}
}
console.log();
}
@@ -175,6 +205,14 @@ const MIME_TYPES: Record<string, string> = {
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
export async function runTaskAttach(id: string, filePath: string) {