Skip to content
Open
42 changes: 36 additions & 6 deletions task/task-cli.mjs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,8 @@ import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { readFileSync, existsSync, statSync } from "node:fs";
import { randomUUID } from "node:crypto";
import {
normalizeWorkspaceStorageKey,
normalizeWorkspaceStorageKeys,
} from "./task-store.mjs";
import { getTaskLifetimeTotals } from "../infra/runtime-accumulator.mjs";
import { getTaskLifetimeTotals } from "./task-stats.mjs";
import {`r`n normalizeWorkspaceStorageKey,`r`n normalizeWorkspaceStorageKeys,`r`n} from "./task-store.mjs";`r`nimport { getTaskLifetimeTotals } from "./task-stats.mjs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -1099,7 +1096,6 @@ function computeRepoAreaEffectiveLimit({
export async function taskImport(source) {
let tasks;
if (typeof source === "string") {
// File path
const raw = readFileSync(resolve(source), "utf8");
const parsed = JSON.parse(raw);
tasks = parsed.tasks || parsed.backlog || parsed;
Expand All @@ -1112,6 +1108,39 @@ export async function taskImport(source) {
throw new Error("Source must be a file path or array of task objects");
}

// Support both legacy plain task objects and new task-batch payloads.
// Detect batch-like payloads (with taskId/taskTitle fields) and only then
// apply batch validation + remapping to preserve backwards compatibility.
const looksLikeBatchPayload =
Array.isArray(tasks) &&
tasks.length > 0 &&
tasks.every(
(item) =>
item &&
typeof item === "object" &&
("taskId" in item || "taskTitle" in item),
);

if (looksLikeBatchPayload) {
try {
tasks = validateTaskBatchPayload(tasks).map((item) => ({
id: item.taskId,
title: item.taskTitle,
status: item.status,
branch: item.branch,
scope: item.scope,
repository: item.repository,
workspace: item.workspace,
}));
} catch (error) {
if (typeof source === "string") {
const summary = summarizeTaskBatchPayloadForLog(tasks);
throw new Error(`${error.message}; summary=${JSON.stringify(summary)}`);
}
throw error;
}
}

let created = 0;
let failed = 0;
const errors = [];
Expand Down Expand Up @@ -1844,3 +1873,4 @@ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
process.exit(1);
});
}

22 changes: 22 additions & 0 deletions tests/task-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,25 @@ describe("task-cli taskStats repo area lock state", () => {
logSpy.mockRestore();
});
});
describe("task-cli task batch payload validation", () => {
it("rejects malformed task-batch payload files with deterministic errors", () => {
const payloadPath = resolve(tempDirs[tempDirs.length - 1] ?? mkdtempSync(resolve(tmpdir(), "bosun-task-cli-")), "task-batch-invalid.json");
writeFileSync(payloadPath, JSON.stringify([{ taskId: "", repository: "virtengine/bosun", workspace: "virtengine-gh" }]), "utf8");

const result = spawnSync(
process.execPath,
["task/task-cli.mjs", "create-batch", "--payload-file", payloadPath],
{
cwd: process.cwd(),
env: { ...process.env },
encoding: "utf8",
},
);

expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"Invalid task-batch payload: item[0].taskId must be a non-empty string",
);
expect(result.stderr).toContain('summary={"type":"array","count":1,"taskIds":[]}');
});
});
49 changes: 48 additions & 1 deletion tests/workflow-templates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
relayoutInstalledTemplateWorkflows,
installTemplate,
installTemplateSet,
} from "../workflow/workflow-templates.mjs";
} from \
import {
WorkflowEngine,
getNodeType,
Expand Down Expand Up @@ -1679,3 +1679,50 @@ describe("template category coverage", () => {
}
});
});
describe("task batch payload validation", () => {
it("accepts a valid minimal batch item", () => {
const payload = [
{
taskId: "task-123",
taskTitle: "Ship validation",
status: "todo",
repository: "virtengine/bosun",
workspace: "virtengine-gh",
branch: "task/123",
scope: "workflow",
},
];

expect(validateTaskBatchPayload(payload)).toEqual(payload);
});
Comment on lines +1682 to +1697
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateTaskBatchPayload is used in these new tests but is not imported anywhere in this file, so the tests will throw ReferenceError. Add an explicit import (e.g. from workflow-templates/task-batch.mjs) or otherwise bring the validator into scope.

Copilot uses AI. Check for mistakes.

it("rejects malformed batch items with deterministic errors", () => {
expect(() => validateTaskBatchPayload([
{
taskId: "",
taskTitle: "Bad payload",
status: "todo",
repository: "virtengine/bosun",
workspace: "virtengine-gh",
},
])).toThrow("Invalid task-batch payload: item[0].taskId must be a non-empty string");
});

it("trims oversized optional fields per contract", () => {
const payload = [
{
taskId: "task-oversized",
taskTitle: "Trim optional fields",
status: "todo",
repository: "virtengine/bosun",
workspace: "virtengine-gh",
branch: "b".repeat(200),
scope: "s".repeat(200),
},
];

const [item] = validateTaskBatchPayload(payload);
expect(item.branch).toBe("b".repeat(128));
expect(item.scope).toBe("s".repeat(128));
});
});
Loading