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:
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user