mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 22:02:38 +00:00
fix(runtime): keep daemon alive and localize package installs
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
fa208bca73
commit
3f04822f58
38 changed files with 2051 additions and 1939 deletions
|
|
@ -1,75 +1,75 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { runHook } from "./hooks";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
|
||||
describe("runHook", () => {
|
||||
const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
|
||||
const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
|
||||
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Optional: Clean up created scripts
|
||||
const files = ["success_hook.sh", "fail_hook.sh"];
|
||||
files.forEach(f => {
|
||||
const p = path.join(hooksDir, f);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
// Optional: Clean up created scripts
|
||||
const files = ["success_hook.sh", "fail_hook.sh"];
|
||||
files.forEach((f) => {
|
||||
const p = path.join(hooksDir, f);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true if hook script does not exist", async () => {
|
||||
const result = await runHook("test_team", "non_existent_hook", { data: "test" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("should return true if hook script does not exist", async () => {
|
||||
const result = await runHook("test_team", "non_existent_hook", { data: "test" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if hook script succeeds", async () => {
|
||||
const hookName = "success_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
|
||||
// Create a simple script that exits with 0
|
||||
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
|
||||
it("should return true if hook script succeeds", async () => {
|
||||
const hookName = "success_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
|
||||
const result = await runHook("test_team", hookName, { data: "test" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
// Create a simple script that exits with 0
|
||||
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
|
||||
|
||||
it("should return false if hook script fails", async () => {
|
||||
const hookName = "fail_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
|
||||
// Create a simple script that exits with 1
|
||||
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
|
||||
const result = await runHook("test_team", hookName, { data: "test" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// Mock console.error to avoid noise in test output
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
it("should return false if hook script fails", async () => {
|
||||
const hookName = "fail_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
|
||||
const result = await runHook("test_team", hookName, { data: "test" });
|
||||
expect(result).toBe(false);
|
||||
// Create a simple script that exits with 1
|
||||
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
// Mock console.error to avoid noise in test output
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
it("should pass the payload to the hook script", async () => {
|
||||
const hookName = "payload_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
const outputFile = path.join(hooksDir, "payload_output.txt");
|
||||
const result = await runHook("test_team", hookName, { data: "test" });
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Create a script that writes its first argument to a file
|
||||
fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 });
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
const payload = { key: "value", "special'char": true };
|
||||
const result = await runHook("test_team", hookName, payload);
|
||||
it("should pass the payload to the hook script", async () => {
|
||||
const hookName = "payload_hook";
|
||||
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
||||
const outputFile = path.join(hooksDir, "payload_output.txt");
|
||||
|
||||
expect(result).toBe(true);
|
||||
const output = fs.readFileSync(outputFile, "utf-8").trim();
|
||||
expect(JSON.parse(output)).toEqual(payload);
|
||||
// Create a script that writes its first argument to a file
|
||||
fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 });
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(scriptPath);
|
||||
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
||||
});
|
||||
const payload = { key: "value", "special'char": true };
|
||||
const result = await runHook("test_team", hookName, payload);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const output = fs.readFileSync(outputFile, "utf-8").trim();
|
||||
expect(JSON.parse(output)).toEqual(payload);
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(scriptPath);
|
||||
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -15,21 +15,21 @@ const execFileAsync = promisify(execFile);
|
|||
* @returns true if the hook doesn't exist or executes successfully; false otherwise.
|
||||
*/
|
||||
export async function runHook(teamName: string, hookName: string, payload: any): Promise<boolean> {
|
||||
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
|
||||
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
|
||||
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
return true;
|
||||
}
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
// Use execFile: More secure (no shell interpolation) and asynchronous
|
||||
await execFileAsync(hookPath, [payloadStr], {
|
||||
env: { ...process.env, PI_TEAM: teamName },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Hook ${hookName} failed:`, error);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
// Use execFile: More secure (no shell interpolation) and asynchronous
|
||||
await execFileAsync(hookPath, [payloadStr], {
|
||||
env: { ...process.env, PI_TEAM: teamName },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Hook ${hookName} failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withLock } from "./lock";
|
||||
|
||||
describe("withLock race conditions", () => {
|
||||
const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now());
|
||||
const lockPath = path.join(testDir, "test");
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now());
|
||||
const lockPath = path.join(testDir, "test");
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should handle multiple concurrent attempts to acquire the lock", async () => {
|
||||
let counter = 0;
|
||||
const iterations = 20;
|
||||
const concurrentCount = 5;
|
||||
it("should handle multiple concurrent attempts to acquire the lock", async () => {
|
||||
let counter = 0;
|
||||
const iterations = 20;
|
||||
const concurrentCount = 5;
|
||||
|
||||
const runTask = async () => {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await withLock(lockPath, async () => {
|
||||
const current = counter;
|
||||
// Add a small delay to increase the chance of race conditions if locking fails
|
||||
await new Promise(resolve => setTimeout(resolve, Math.random() * 10));
|
||||
counter = current + 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
const runTask = async () => {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await withLock(lockPath, async () => {
|
||||
const current = counter;
|
||||
// Add a small delay to increase the chance of race conditions if locking fails
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
|
||||
counter = current + 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < concurrentCount; i++) {
|
||||
promises.push(runTask());
|
||||
}
|
||||
const promises = [];
|
||||
for (let i = 0; i < concurrentCount; i++) {
|
||||
promises.push(runTask());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(counter).toBe(iterations * concurrentCount);
|
||||
});
|
||||
expect(counter).toBe(iterations * concurrentCount);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,49 @@
|
|||
// Project: pi-teams
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withLock } from "./lock";
|
||||
|
||||
describe("withLock", () => {
|
||||
const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now());
|
||||
const lockPath = path.join(testDir, "test");
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now());
|
||||
const lockPath = path.join(testDir, "test");
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should successfully acquire and release the lock", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("result");
|
||||
const result = await withLock(lockPath, fn);
|
||||
it("should successfully acquire and release the lock", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("result");
|
||||
const result = await withLock(lockPath, fn);
|
||||
|
||||
expect(result).toBe("result");
|
||||
expect(fn).toHaveBeenCalled();
|
||||
expect(fs.existsSync(lockFile)).toBe(false);
|
||||
});
|
||||
expect(result).toBe("result");
|
||||
expect(fn).toHaveBeenCalled();
|
||||
expect(fs.existsSync(lockFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail to acquire lock if already held", async () => {
|
||||
// Manually create lock file
|
||||
fs.writeFileSync(lockFile, "9999");
|
||||
it("should fail to acquire lock if already held", async () => {
|
||||
// Manually create lock file
|
||||
fs.writeFileSync(lockFile, "9999");
|
||||
|
||||
const fn = vi.fn().mockResolvedValue("result");
|
||||
|
||||
// Test with only 2 retries to speed up the failure
|
||||
await expect(withLock(lockPath, fn, 2)).rejects.toThrow("Could not acquire lock");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
const fn = vi.fn().mockResolvedValue("result");
|
||||
|
||||
it("should release lock even if function fails", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("failure"));
|
||||
// Test with only 2 retries to speed up the failure
|
||||
await expect(withLock(lockPath, fn, 2)).rejects.toThrow("Could not acquire lock");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
|
||||
expect(fs.existsSync(lockFile)).toBe(false);
|
||||
});
|
||||
it("should release lock even if function fails", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("failure"));
|
||||
|
||||
await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
|
||||
expect(fs.existsSync(lockFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,43 +6,43 @@ const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
|
|||
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
|
||||
|
||||
export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retries: number = 50): Promise<T> {
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
|
||||
while (retries > 0) {
|
||||
try {
|
||||
// Check if lock exists and is stale
|
||||
if (fs.existsSync(lockFile)) {
|
||||
const stats = fs.statSync(lockFile);
|
||||
const age = Date.now() - stats.mtimeMs;
|
||||
if (age > STALE_LOCK_TIMEOUT) {
|
||||
// Attempt to remove stale lock
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
} catch (e) {
|
||||
// ignore, another process might have already removed it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
|
||||
break;
|
||||
} catch (e) {
|
||||
retries--;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
|
||||
if (retries === 0) {
|
||||
throw new Error("Could not acquire lock");
|
||||
}
|
||||
while (retries > 0) {
|
||||
try {
|
||||
// Check if lock exists and is stale
|
||||
if (fs.existsSync(lockFile)) {
|
||||
const stats = fs.statSync(lockFile);
|
||||
const age = Date.now() - stats.mtimeMs;
|
||||
if (age > STALE_LOCK_TIMEOUT) {
|
||||
// Attempt to remove stale lock
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
} catch (e) {
|
||||
// ignore, another process might have already removed it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
|
||||
break;
|
||||
} catch (e) {
|
||||
retries--;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (retries === 0) {
|
||||
throw new Error("Could not acquire lock");
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,100 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { appendMessage, readInbox, sendPlainMessage, broadcastMessage } from "./messaging";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appendMessage, broadcastMessage, readInbox, sendPlainMessage } from "./messaging";
|
||||
import * as paths from "./paths";
|
||||
|
||||
// Mock the paths to use a temporary directory
|
||||
const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
|
||||
|
||||
describe("Messaging Utilities", () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Override paths to use testDir
|
||||
vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => {
|
||||
return path.join(testDir, "inboxes", `${agentName}.json`);
|
||||
});
|
||||
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockImplementation((teamName) => {
|
||||
return path.join(testDir, "config.json");
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
// Override paths to use testDir
|
||||
vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => {
|
||||
return path.join(testDir, "inboxes", `${agentName}.json`);
|
||||
});
|
||||
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockImplementation((teamName) => {
|
||||
return path.join(testDir, "config.json");
|
||||
});
|
||||
});
|
||||
|
||||
it("should append a message successfully", async () => {
|
||||
const msg = { from: "sender", text: "hello", timestamp: "now", read: false };
|
||||
await appendMessage("test-team", "receiver", msg);
|
||||
|
||||
const inbox = await readInbox("test-team", "receiver", false, false);
|
||||
expect(inbox.length).toBe(1);
|
||||
expect(inbox[0].text).toBe("hello");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should handle concurrent appends (Stress Test)", async () => {
|
||||
const numMessages = 100;
|
||||
const promises = [];
|
||||
for (let i = 0; i < numMessages; i++) {
|
||||
promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const inbox = await readInbox("test-team", "receiver", false, false);
|
||||
expect(inbox.length).toBe(numMessages);
|
||||
|
||||
// Verify all messages are present
|
||||
const texts = inbox.map(m => m.text).sort();
|
||||
for (let i = 0; i < numMessages; i++) {
|
||||
expect(texts).toContain(`msg-${i}`);
|
||||
}
|
||||
});
|
||||
it("should append a message successfully", async () => {
|
||||
const msg = { from: "sender", text: "hello", timestamp: "now", read: false };
|
||||
await appendMessage("test-team", "receiver", msg);
|
||||
|
||||
it("should mark messages as read", async () => {
|
||||
await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1");
|
||||
await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2");
|
||||
|
||||
// Read only unread messages
|
||||
const unread = await readInbox("test-team", "receiver", true, true);
|
||||
expect(unread.length).toBe(2);
|
||||
|
||||
// Now all should be read
|
||||
const all = await readInbox("test-team", "receiver", false, false);
|
||||
expect(all.length).toBe(2);
|
||||
expect(all.every(m => m.read)).toBe(true);
|
||||
});
|
||||
const inbox = await readInbox("test-team", "receiver", false, false);
|
||||
expect(inbox.length).toBe(1);
|
||||
expect(inbox[0].text).toBe("hello");
|
||||
});
|
||||
|
||||
it("should broadcast message to all members except the sender", async () => {
|
||||
// Setup team config
|
||||
const config = {
|
||||
name: "test-team",
|
||||
members: [
|
||||
{ name: "sender" },
|
||||
{ name: "member1" },
|
||||
{ name: "member2" }
|
||||
]
|
||||
};
|
||||
const configFilePath = path.join(testDir, "config.json");
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(config));
|
||||
|
||||
await broadcastMessage("test-team", "sender", "broadcast text", "summary");
|
||||
it("should handle concurrent appends (Stress Test)", async () => {
|
||||
const numMessages = 100;
|
||||
const promises = [];
|
||||
for (let i = 0; i < numMessages; i++) {
|
||||
promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`));
|
||||
}
|
||||
|
||||
// Check member1's inbox
|
||||
const inbox1 = await readInbox("test-team", "member1", false, false);
|
||||
expect(inbox1.length).toBe(1);
|
||||
expect(inbox1[0].text).toBe("broadcast text");
|
||||
expect(inbox1[0].from).toBe("sender");
|
||||
await Promise.all(promises);
|
||||
|
||||
// Check member2's inbox
|
||||
const inbox2 = await readInbox("test-team", "member2", false, false);
|
||||
expect(inbox2.length).toBe(1);
|
||||
expect(inbox2[0].text).toBe("broadcast text");
|
||||
expect(inbox2[0].from).toBe("sender");
|
||||
const inbox = await readInbox("test-team", "receiver", false, false);
|
||||
expect(inbox.length).toBe(numMessages);
|
||||
|
||||
// Check sender's inbox (should be empty)
|
||||
const inboxSender = await readInbox("test-team", "sender", false, false);
|
||||
expect(inboxSender.length).toBe(0);
|
||||
});
|
||||
// Verify all messages are present
|
||||
const texts = inbox.map((m) => m.text).sort();
|
||||
for (let i = 0; i < numMessages; i++) {
|
||||
expect(texts).toContain(`msg-${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("should mark messages as read", async () => {
|
||||
await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1");
|
||||
await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2");
|
||||
|
||||
// Read only unread messages
|
||||
const unread = await readInbox("test-team", "receiver", true, true);
|
||||
expect(unread.length).toBe(2);
|
||||
|
||||
// Now all should be read
|
||||
const all = await readInbox("test-team", "receiver", false, false);
|
||||
expect(all.length).toBe(2);
|
||||
expect(all.every((m) => m.read)).toBe(true);
|
||||
});
|
||||
|
||||
it("should broadcast message to all members except the sender", async () => {
|
||||
// Setup team config
|
||||
const config = {
|
||||
name: "test-team",
|
||||
members: [{ name: "sender" }, { name: "member1" }, { name: "member2" }],
|
||||
};
|
||||
const configFilePath = path.join(testDir, "config.json");
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(config));
|
||||
|
||||
await broadcastMessage("test-team", "sender", "broadcast text", "summary");
|
||||
|
||||
// Check member1's inbox
|
||||
const inbox1 = await readInbox("test-team", "member1", false, false);
|
||||
expect(inbox1.length).toBe(1);
|
||||
expect(inbox1[0].text).toBe("broadcast text");
|
||||
expect(inbox1[0].from).toBe("sender");
|
||||
|
||||
// Check member2's inbox
|
||||
const inbox2 = await readInbox("test-team", "member2", false, false);
|
||||
expect(inbox2.length).toBe(1);
|
||||
expect(inbox2[0].text).toBe("broadcast text");
|
||||
expect(inbox2[0].from).toBe("sender");
|
||||
|
||||
// Check sender's inbox (should be empty)
|
||||
const inboxSender = await readInbox("test-team", "sender", false, false);
|
||||
expect(inboxSender.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { InboxMessage } from "./models";
|
||||
import { withLock } from "./lock";
|
||||
import type { InboxMessage } from "./models";
|
||||
import { inboxPath } from "./paths";
|
||||
import { readConfig } from "./teams";
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export async function appendMessage(teamName: string, agentName: string, message: InboxMessage) {
|
||||
const p = inboxPath(teamName, agentName);
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const p = inboxPath(teamName, agentName);
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
await withLock(p, async () => {
|
||||
let msgs: InboxMessage[] = [];
|
||||
if (fs.existsSync(p)) {
|
||||
msgs = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
msgs.push(message);
|
||||
fs.writeFileSync(p, JSON.stringify(msgs, null, 2));
|
||||
});
|
||||
await withLock(p, async () => {
|
||||
let msgs: InboxMessage[] = [];
|
||||
if (fs.existsSync(p)) {
|
||||
msgs = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
msgs.push(message);
|
||||
fs.writeFileSync(p, JSON.stringify(msgs, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
export async function readInbox(
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
unreadOnly = false,
|
||||
markAsRead = true
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
unreadOnly = false,
|
||||
markAsRead = true,
|
||||
): Promise<InboxMessage[]> {
|
||||
const p = inboxPath(teamName, agentName);
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const p = inboxPath(teamName, agentName);
|
||||
if (!fs.existsSync(p)) return [];
|
||||
|
||||
return await withLock(p, async () => {
|
||||
const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
let result = allMsgs;
|
||||
return await withLock(p, async () => {
|
||||
const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
let result = allMsgs;
|
||||
|
||||
if (unreadOnly) {
|
||||
result = allMsgs.filter(m => !m.read);
|
||||
}
|
||||
if (unreadOnly) {
|
||||
result = allMsgs.filter((m) => !m.read);
|
||||
}
|
||||
|
||||
if (markAsRead && result.length > 0) {
|
||||
for (const m of allMsgs) {
|
||||
if (result.includes(m)) {
|
||||
m.read = true;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2));
|
||||
}
|
||||
if (markAsRead && result.length > 0) {
|
||||
for (const m of allMsgs) {
|
||||
if (result.includes(m)) {
|
||||
m.read = true;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPlainMessage(
|
||||
teamName: string,
|
||||
fromName: string,
|
||||
toName: string,
|
||||
text: string,
|
||||
summary: string,
|
||||
color?: string
|
||||
teamName: string,
|
||||
fromName: string,
|
||||
toName: string,
|
||||
text: string,
|
||||
summary: string,
|
||||
color?: string,
|
||||
) {
|
||||
const msg: InboxMessage = {
|
||||
from: fromName,
|
||||
text,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
summary,
|
||||
color,
|
||||
};
|
||||
await appendMessage(teamName, toName, msg);
|
||||
const msg: InboxMessage = {
|
||||
from: fromName,
|
||||
text,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
summary,
|
||||
color,
|
||||
};
|
||||
await appendMessage(teamName, toName, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,27 +82,27 @@ export async function sendPlainMessage(
|
|||
* @param color An optional color for the message
|
||||
*/
|
||||
export async function broadcastMessage(
|
||||
teamName: string,
|
||||
fromName: string,
|
||||
text: string,
|
||||
summary: string,
|
||||
color?: string
|
||||
teamName: string,
|
||||
fromName: string,
|
||||
text: string,
|
||||
summary: string,
|
||||
color?: string,
|
||||
) {
|
||||
const config = await readConfig(teamName);
|
||||
const config = await readConfig(teamName);
|
||||
|
||||
// Create an array of delivery promises for all members except the sender
|
||||
const deliveryPromises = config.members
|
||||
.filter((member) => member.name !== fromName)
|
||||
.map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color));
|
||||
// Create an array of delivery promises for all members except the sender
|
||||
const deliveryPromises = config.members
|
||||
.filter((member) => member.name !== fromName)
|
||||
.map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color));
|
||||
|
||||
// Execute deliveries in parallel and wait for all to settle
|
||||
const results = await Promise.allSettled(deliveryPromises);
|
||||
// Execute deliveries in parallel and wait for all to settle
|
||||
const results = await Promise.allSettled(deliveryPromises);
|
||||
|
||||
// Log failures for diagnostics
|
||||
const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`);
|
||||
// Optionally log individual errors
|
||||
failures.forEach((f) => console.error(`- Delivery error:`, f.reason));
|
||||
}
|
||||
// Log failures for diagnostics
|
||||
const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`);
|
||||
// Optionally log individual errors
|
||||
failures.forEach((f) => console.error(`- Delivery error:`, f.reason));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
export interface Member {
|
||||
agentId: string;
|
||||
name: string;
|
||||
agentType: string;
|
||||
model?: string;
|
||||
joinedAt: number;
|
||||
tmuxPaneId: string;
|
||||
windowId?: string;
|
||||
cwd: string;
|
||||
subscriptions: any[];
|
||||
prompt?: string;
|
||||
color?: string;
|
||||
thinking?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
planModeRequired?: boolean;
|
||||
backendType?: string;
|
||||
isActive?: boolean;
|
||||
agentId: string;
|
||||
name: string;
|
||||
agentType: string;
|
||||
model?: string;
|
||||
joinedAt: number;
|
||||
tmuxPaneId: string;
|
||||
windowId?: string;
|
||||
cwd: string;
|
||||
subscriptions: any[];
|
||||
prompt?: string;
|
||||
color?: string;
|
||||
thinking?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
planModeRequired?: boolean;
|
||||
backendType?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
leadAgentId: string;
|
||||
leadSessionId: string;
|
||||
members: Member[];
|
||||
defaultModel?: string;
|
||||
separateWindows?: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
leadAgentId: string;
|
||||
leadSessionId: string;
|
||||
members: Member[];
|
||||
defaultModel?: string;
|
||||
separateWindows?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskFile {
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
|
||||
plan?: string;
|
||||
planFeedback?: string;
|
||||
blocks: string[];
|
||||
blockedBy: string[];
|
||||
owner?: string;
|
||||
metadata?: Record<string, any>;
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
|
||||
plan?: string;
|
||||
planFeedback?: string;
|
||||
blocks: string[];
|
||||
blockedBy: string[];
|
||||
owner?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
summary?: string;
|
||||
color?: string;
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
summary?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
export const PI_DIR = path.join(os.homedir(), ".pi");
|
||||
export const TEAMS_DIR = path.join(PI_DIR, "teams");
|
||||
export const TASKS_DIR = path.join(PI_DIR, "tasks");
|
||||
|
||||
export function ensureDirs() {
|
||||
if (!fs.existsSync(PI_DIR)) fs.mkdirSync(PI_DIR);
|
||||
if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR);
|
||||
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
|
||||
if (!fs.existsSync(PI_DIR)) fs.mkdirSync(PI_DIR);
|
||||
if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR);
|
||||
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
|
||||
}
|
||||
|
||||
export function sanitizeName(name: string): string {
|
||||
// Allow only alphanumeric characters, hyphens, and underscores.
|
||||
if (/[^a-zA-Z0-9_-]/.test(name)) {
|
||||
throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
|
||||
}
|
||||
return name;
|
||||
// Allow only alphanumeric characters, hyphens, and underscores.
|
||||
if (/[^a-zA-Z0-9_-]/.test(name)) {
|
||||
throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function teamDir(teamName: string) {
|
||||
return path.join(TEAMS_DIR, sanitizeName(teamName));
|
||||
return path.join(TEAMS_DIR, sanitizeName(teamName));
|
||||
}
|
||||
|
||||
export function taskDir(teamName: string) {
|
||||
return path.join(TASKS_DIR, sanitizeName(teamName));
|
||||
return path.join(TASKS_DIR, sanitizeName(teamName));
|
||||
}
|
||||
|
||||
export function inboxPath(teamName: string, agentName: string) {
|
||||
return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`);
|
||||
return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`);
|
||||
}
|
||||
|
||||
export function configPath(teamName: string) {
|
||||
return path.join(teamDir(teamName), "config.json");
|
||||
return path.join(teamDir(teamName), "config.json");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { teamDir, inboxPath, sanitizeName } from "./paths";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inboxPath, sanitizeName, teamDir } from "./paths";
|
||||
|
||||
describe("Security Audit - Path Traversal (Prevention Check)", () => {
|
||||
it("should throw an error for path traversal via teamName", () => {
|
||||
const maliciousTeamName = "../../etc";
|
||||
expect(() => teamDir(maliciousTeamName)).toThrow();
|
||||
});
|
||||
it("should throw an error for path traversal via teamName", () => {
|
||||
const maliciousTeamName = "../../etc";
|
||||
expect(() => teamDir(maliciousTeamName)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error for path traversal via agentName", () => {
|
||||
const teamName = "audit-team";
|
||||
const maliciousAgentName = "../../../.ssh/id_rsa";
|
||||
expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
|
||||
});
|
||||
it("should throw an error for path traversal via agentName", () => {
|
||||
const teamName = "audit-team";
|
||||
const maliciousAgentName = "../../../.ssh/id_rsa";
|
||||
expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error for path traversal via taskId", () => {
|
||||
const teamName = "audit-team";
|
||||
const maliciousTaskId = "../../../etc/passwd";
|
||||
// We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
|
||||
// But since we already tested sanitizeName via other paths, this is just for completeness.
|
||||
expect(() => sanitizeName(maliciousTaskId)).toThrow();
|
||||
});
|
||||
it("should throw an error for path traversal via taskId", () => {
|
||||
const teamName = "audit-team";
|
||||
const maliciousTaskId = "../../../etc/passwd";
|
||||
// We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
|
||||
// But since we already tested sanitizeName via other paths, this is just for completeness.
|
||||
expect(() => sanitizeName(maliciousTaskId)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Audit - Command Injection (Fixed)", () => {
|
||||
it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => {
|
||||
const maliciousCwd = "; rm -rf / ;";
|
||||
const name = "attacker";
|
||||
const team_name = "audit-team";
|
||||
const piBinary = "pi";
|
||||
const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
|
||||
|
||||
// Simulating what happens in spawn_teammate (extensions/index.ts)
|
||||
const itermCmd = `cd '${maliciousCwd}' && ${cmd}`;
|
||||
|
||||
// The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi
|
||||
expect(itermCmd).toContain("cd '; rm -rf / ;' &&");
|
||||
expect(itermCmd).not.toContain("cd ; rm -rf / ; &&");
|
||||
});
|
||||
it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => {
|
||||
const maliciousCwd = "; rm -rf / ;";
|
||||
const name = "attacker";
|
||||
const team_name = "audit-team";
|
||||
const piBinary = "pi";
|
||||
const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
|
||||
|
||||
// Simulating what happens in spawn_teammate (extensions/index.ts)
|
||||
const itermCmd = `cd '${maliciousCwd}' && ${cmd}`;
|
||||
|
||||
// The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi
|
||||
expect(itermCmd).toContain("cd '; rm -rf / ;' &&");
|
||||
expect(itermCmd).not.toContain("cd ; rm -rf / ; &&");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createTask, listTasks } from "./tasks";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as paths from "./paths";
|
||||
import { createTask, listTasks } from "./tasks";
|
||||
|
||||
const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now());
|
||||
|
||||
describe("Tasks Race Condition Bug", () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
||||
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
||||
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
||||
});
|
||||
|
||||
it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
|
||||
const numTasks = 20;
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < numTasks; i++) {
|
||||
promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const ids = results.map(r => r.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
|
||||
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
|
||||
// this test might still pass because createTask locks the directory.
|
||||
// WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
|
||||
// Let's re-verify createTask in src/utils/tasks.ts
|
||||
|
||||
expect(uniqueIds.size).toBe(numTasks);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
|
||||
const numTasks = 20;
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < numTasks; i++) {
|
||||
promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const ids = results.map((r) => r.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
|
||||
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
|
||||
// this test might still pass because createTask locks the directory.
|
||||
// WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
|
||||
// Let's re-verify createTask in src/utils/tasks.ts
|
||||
|
||||
expect(uniqueIds.size).toBe(numTasks);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,142 +1,151 @@
|
|||
// Project: pi-teams
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createTask, updateTask, readTask, listTasks, submitPlan, evaluatePlan } from "./tasks";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as paths from "./paths";
|
||||
import { createTask, evaluatePlan, listTasks, readTask, submitPlan, updateTask } from "./tasks";
|
||||
import * as teams from "./teams";
|
||||
|
||||
// Mock the paths to use a temporary directory
|
||||
const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
|
||||
|
||||
describe("Tasks Utilities", () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Override paths to use testDir
|
||||
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
||||
|
||||
// Create a dummy team config
|
||||
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
// Override paths to use testDir
|
||||
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
||||
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
||||
|
||||
it("should create a task successfully", async () => {
|
||||
const task = await createTask("test-team", "Test Subject", "Test Description");
|
||||
expect(task.id).toBe("1");
|
||||
expect(task.subject).toBe("Test Subject");
|
||||
expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
|
||||
});
|
||||
// Create a dummy team config
|
||||
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
||||
});
|
||||
|
||||
it("should update a task successfully", async () => {
|
||||
await createTask("test-team", "Test Subject", "Test Description");
|
||||
const updated = await updateTask("test-team", "1", { status: "in_progress" });
|
||||
expect(updated.status).toBe("in_progress");
|
||||
|
||||
const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8"));
|
||||
expect(taskData.status).toBe("in_progress");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should submit a plan successfully", async () => {
|
||||
const task = await createTask("test-team", "Test Subject", "Test Description");
|
||||
const plan = "Step 1: Do something\nStep 2: Profit";
|
||||
const updated = await submitPlan("test-team", task.id, plan);
|
||||
expect(updated.status).toBe("planning");
|
||||
expect(updated.plan).toBe(plan);
|
||||
|
||||
const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"));
|
||||
expect(taskData.status).toBe("planning");
|
||||
expect(taskData.plan).toBe(plan);
|
||||
});
|
||||
it("should create a task successfully", async () => {
|
||||
const task = await createTask("test-team", "Test Subject", "Test Description");
|
||||
expect(task.id).toBe("1");
|
||||
expect(task.subject).toBe("Test Subject");
|
||||
expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail to submit an empty plan", async () => {
|
||||
const task = await createTask("test-team", "Empty Test", "Should fail");
|
||||
await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty");
|
||||
await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty");
|
||||
});
|
||||
it("should update a task successfully", async () => {
|
||||
await createTask("test-team", "Test Subject", "Test Description");
|
||||
const updated = await updateTask("test-team", "1", { status: "in_progress" });
|
||||
expect(updated.status).toBe("in_progress");
|
||||
|
||||
it("should list tasks", async () => {
|
||||
await createTask("test-team", "Task 1", "Desc 1");
|
||||
await createTask("test-team", "Task 2", "Desc 2");
|
||||
const tasksList = await listTasks("test-team");
|
||||
expect(tasksList.length).toBe(2);
|
||||
expect(tasksList[0].id).toBe("1");
|
||||
expect(tasksList[1].id).toBe("2");
|
||||
});
|
||||
const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8"));
|
||||
expect(taskData.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("should have consistent lock paths (Fixed BUG 2)", async () => {
|
||||
// This test verifies that both updateTask and readTask now use the same lock path
|
||||
// Both should now lock `${taskId}.json.lock`
|
||||
|
||||
await createTask("test-team", "Bug Test", "Testing lock consistency");
|
||||
const taskId = "1";
|
||||
|
||||
const taskFile = path.join(testDir, `${taskId}.json`);
|
||||
const commonLockFile = `${taskFile}.lock`;
|
||||
|
||||
// 1. Holding the common lock
|
||||
fs.writeFileSync(commonLockFile, "9999");
|
||||
|
||||
// 2. Try updateTask, it should fail
|
||||
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout
|
||||
await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow("Could not acquire lock");
|
||||
it("should submit a plan successfully", async () => {
|
||||
const task = await createTask("test-team", "Test Subject", "Test Description");
|
||||
const plan = "Step 1: Do something\nStep 2: Profit";
|
||||
const updated = await submitPlan("test-team", task.id, plan);
|
||||
expect(updated.status).toBe("planning");
|
||||
expect(updated.plan).toBe(plan);
|
||||
|
||||
// 3. Try readTask, it should fail too
|
||||
await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock");
|
||||
|
||||
fs.unlinkSync(commonLockFile);
|
||||
});
|
||||
const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"));
|
||||
expect(taskData.status).toBe("planning");
|
||||
expect(taskData.plan).toBe(plan);
|
||||
});
|
||||
|
||||
it("should approve a plan successfully", async () => {
|
||||
const task = await createTask("test-team", "Plan Test", "Should be approved");
|
||||
await submitPlan("test-team", task.id, "Wait for it...");
|
||||
|
||||
const approved = await evaluatePlan("test-team", task.id, "approve");
|
||||
expect(approved.status).toBe("in_progress");
|
||||
expect(approved.planFeedback).toBe("");
|
||||
});
|
||||
it("should fail to submit an empty plan", async () => {
|
||||
const task = await createTask("test-team", "Empty Test", "Should fail");
|
||||
await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty");
|
||||
await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty");
|
||||
});
|
||||
|
||||
it("should reject a plan with feedback", async () => {
|
||||
const task = await createTask("test-team", "Plan Test", "Should be rejected");
|
||||
await submitPlan("test-team", task.id, "Wait for it...");
|
||||
|
||||
const feedback = "Not good enough!";
|
||||
const rejected = await evaluatePlan("test-team", task.id, "reject", feedback);
|
||||
expect(rejected.status).toBe("planning");
|
||||
expect(rejected.planFeedback).toBe(feedback);
|
||||
});
|
||||
it("should list tasks", async () => {
|
||||
await createTask("test-team", "Task 1", "Desc 1");
|
||||
await createTask("test-team", "Task 2", "Desc 2");
|
||||
const tasksList = await listTasks("test-team");
|
||||
expect(tasksList.length).toBe(2);
|
||||
expect(tasksList[0].id).toBe("1");
|
||||
expect(tasksList[1].id).toBe("2");
|
||||
});
|
||||
|
||||
it("should fail to evaluate a task not in 'planning' status", async () => {
|
||||
const task = await createTask("test-team", "Status Test", "Invalid status for eval");
|
||||
// status is "pending"
|
||||
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status");
|
||||
});
|
||||
it("should have consistent lock paths (Fixed BUG 2)", async () => {
|
||||
// This test verifies that both updateTask and readTask now use the same lock path
|
||||
// Both should now lock `${taskId}.json.lock`
|
||||
|
||||
it("should fail to evaluate a task without a plan", async () => {
|
||||
const task = await createTask("test-team", "Plan Missing Test", "No plan submitted");
|
||||
await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
|
||||
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted");
|
||||
});
|
||||
await createTask("test-team", "Bug Test", "Testing lock consistency");
|
||||
const taskId = "1";
|
||||
|
||||
it("should fail to reject a plan without feedback", async () => {
|
||||
const task = await createTask("test-team", "Feedback Test", "Should require feedback");
|
||||
await submitPlan("test-team", task.id, "My plan");
|
||||
await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow("Feedback is required when rejecting a plan");
|
||||
await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow("Feedback is required when rejecting a plan");
|
||||
});
|
||||
const taskFile = path.join(testDir, `${taskId}.json`);
|
||||
const commonLockFile = `${taskFile}.lock`;
|
||||
|
||||
it("should sanitize task IDs in all file operations", async () => {
|
||||
const dirtyId = "../evil-id";
|
||||
// sanitizeName should throw on this dirtyId
|
||||
await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/);
|
||||
await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow(/Invalid name: "..\/evil-id"/);
|
||||
await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
|
||||
});
|
||||
// 1. Holding the common lock
|
||||
fs.writeFileSync(commonLockFile, "9999");
|
||||
|
||||
// 2. Try updateTask, it should fail
|
||||
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout
|
||||
await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow(
|
||||
"Could not acquire lock",
|
||||
);
|
||||
|
||||
// 3. Try readTask, it should fail too
|
||||
await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock");
|
||||
|
||||
fs.unlinkSync(commonLockFile);
|
||||
});
|
||||
|
||||
it("should approve a plan successfully", async () => {
|
||||
const task = await createTask("test-team", "Plan Test", "Should be approved");
|
||||
await submitPlan("test-team", task.id, "Wait for it...");
|
||||
|
||||
const approved = await evaluatePlan("test-team", task.id, "approve");
|
||||
expect(approved.status).toBe("in_progress");
|
||||
expect(approved.planFeedback).toBe("");
|
||||
});
|
||||
|
||||
it("should reject a plan with feedback", async () => {
|
||||
const task = await createTask("test-team", "Plan Test", "Should be rejected");
|
||||
await submitPlan("test-team", task.id, "Wait for it...");
|
||||
|
||||
const feedback = "Not good enough!";
|
||||
const rejected = await evaluatePlan("test-team", task.id, "reject", feedback);
|
||||
expect(rejected.status).toBe("planning");
|
||||
expect(rejected.planFeedback).toBe(feedback);
|
||||
});
|
||||
|
||||
it("should fail to evaluate a task not in 'planning' status", async () => {
|
||||
const task = await createTask("test-team", "Status Test", "Invalid status for eval");
|
||||
// status is "pending"
|
||||
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status");
|
||||
});
|
||||
|
||||
it("should fail to evaluate a task without a plan", async () => {
|
||||
const task = await createTask("test-team", "Plan Missing Test", "No plan submitted");
|
||||
await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
|
||||
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted");
|
||||
});
|
||||
|
||||
it("should fail to reject a plan without feedback", async () => {
|
||||
const task = await createTask("test-team", "Feedback Test", "Should require feedback");
|
||||
await submitPlan("test-team", task.id, "My plan");
|
||||
await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow(
|
||||
"Feedback is required when rejecting a plan",
|
||||
);
|
||||
await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow(
|
||||
"Feedback is required when rejecting a plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("should sanitize task IDs in all file operations", async () => {
|
||||
const dirtyId = "../evil-id";
|
||||
// sanitizeName should throw on this dirtyId
|
||||
await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/);
|
||||
await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow(
|
||||
/Invalid name: "..\/evil-id"/,
|
||||
);
|
||||
await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,81 +1,85 @@
|
|||
// Project: pi-teams
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { TaskFile } from "./models";
|
||||
import { taskDir, sanitizeName } from "./paths";
|
||||
import { teamExists } from "./teams";
|
||||
import { withLock } from "./lock";
|
||||
import { runHook } from "./hooks";
|
||||
import { withLock } from "./lock";
|
||||
import type { TaskFile } from "./models";
|
||||
import { sanitizeName, taskDir } from "./paths";
|
||||
import { teamExists } from "./teams";
|
||||
|
||||
export function getTaskId(teamName: string): string {
|
||||
const dir = taskDir(teamName);
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
||||
const ids = files.map(f => parseInt(path.parse(f).name, 10)).filter(id => !isNaN(id));
|
||||
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
|
||||
const dir = taskDir(teamName);
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||
const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !isNaN(id));
|
||||
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
|
||||
}
|
||||
|
||||
function getTaskPath(teamName: string, taskId: string): string {
|
||||
const dir = taskDir(teamName);
|
||||
const safeTaskId = sanitizeName(taskId);
|
||||
return path.join(dir, `${safeTaskId}.json`);
|
||||
const dir = taskDir(teamName);
|
||||
const safeTaskId = sanitizeName(taskId);
|
||||
return path.join(dir, `${safeTaskId}.json`);
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
teamName: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
activeForm = "",
|
||||
metadata?: Record<string, any>
|
||||
teamName: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
activeForm = "",
|
||||
metadata?: Record<string, any>,
|
||||
): Promise<TaskFile> {
|
||||
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
|
||||
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
|
||||
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
|
||||
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
|
||||
|
||||
const dir = taskDir(teamName);
|
||||
const lockPath = dir;
|
||||
const dir = taskDir(teamName);
|
||||
const lockPath = dir;
|
||||
|
||||
return await withLock(lockPath, async () => {
|
||||
const id = getTaskId(teamName);
|
||||
const task: TaskFile = {
|
||||
id,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata,
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2));
|
||||
return task;
|
||||
});
|
||||
return await withLock(lockPath, async () => {
|
||||
const id = getTaskId(teamName);
|
||||
const task: TaskFile = {
|
||||
id,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata,
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2));
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
updates: Partial<TaskFile>,
|
||||
retries?: number
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
updates: Partial<TaskFile>,
|
||||
retries?: number,
|
||||
): Promise<TaskFile> {
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
|
||||
return await withLock(p, async () => {
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
const updated = { ...task, ...updates };
|
||||
return await withLock(
|
||||
p,
|
||||
async () => {
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
const updated = { ...task, ...updates };
|
||||
|
||||
if (updates.status === "deleted") {
|
||||
fs.unlinkSync(p);
|
||||
return updated;
|
||||
}
|
||||
if (updates.status === "deleted") {
|
||||
fs.unlinkSync(p);
|
||||
return updated;
|
||||
}
|
||||
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
|
||||
if (updates.status === "completed") {
|
||||
await runHook(teamName, "task_completed", updated);
|
||||
}
|
||||
if (updates.status === "completed") {
|
||||
await runHook(teamName, "task_completed", updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}, retries);
|
||||
return updated;
|
||||
},
|
||||
retries,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,8 +90,8 @@ export async function updateTask(
|
|||
* @returns The updated task
|
||||
*/
|
||||
export async function submitPlan(teamName: string, taskId: string, plan: string): Promise<TaskFile> {
|
||||
if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
|
||||
return await updateTask(teamName, taskId, { status: "planning", plan });
|
||||
if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
|
||||
return await updateTask(teamName, taskId, { status: "planning", plan });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,86 +104,95 @@ export async function submitPlan(teamName: string, taskId: string, plan: string)
|
|||
* @returns The updated task
|
||||
*/
|
||||
export async function evaluatePlan(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
action: "approve" | "reject",
|
||||
feedback?: string,
|
||||
retries?: number
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
action: "approve" | "reject",
|
||||
feedback?: string,
|
||||
retries?: number,
|
||||
): Promise<TaskFile> {
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
|
||||
return await withLock(p, async () => {
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return await withLock(
|
||||
p,
|
||||
async () => {
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
|
||||
// 1. Validate state: Only "planning" tasks can be evaluated
|
||||
if (task.status !== "planning") {
|
||||
throw new Error(
|
||||
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
|
||||
`Tasks must be in 'planning' status to be evaluated.`
|
||||
);
|
||||
}
|
||||
// 1. Validate state: Only "planning" tasks can be evaluated
|
||||
if (task.status !== "planning") {
|
||||
throw new Error(
|
||||
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
|
||||
`Tasks must be in 'planning' status to be evaluated.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Validate plan presence
|
||||
if (!task.plan || !task.plan.trim()) {
|
||||
throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`);
|
||||
}
|
||||
// 2. Validate plan presence
|
||||
if (!task.plan || !task.plan.trim()) {
|
||||
throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`);
|
||||
}
|
||||
|
||||
// 3. Require feedback for rejections
|
||||
if (action === "reject" && (!feedback || !feedback.trim())) {
|
||||
throw new Error("Feedback is required when rejecting a plan.");
|
||||
}
|
||||
// 3. Require feedback for rejections
|
||||
if (action === "reject" && (!feedback || !feedback.trim())) {
|
||||
throw new Error("Feedback is required when rejecting a plan.");
|
||||
}
|
||||
|
||||
// 4. Perform update
|
||||
const updates: Partial<TaskFile> = action === "approve"
|
||||
? { status: "in_progress", planFeedback: "" }
|
||||
: { status: "planning", planFeedback: feedback };
|
||||
// 4. Perform update
|
||||
const updates: Partial<TaskFile> =
|
||||
action === "approve"
|
||||
? { status: "in_progress", planFeedback: "" }
|
||||
: { status: "planning", planFeedback: feedback };
|
||||
|
||||
const updated = { ...task, ...updates };
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
}, retries);
|
||||
const updated = { ...task, ...updates };
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
},
|
||||
retries,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
return await withLock(p, async () => {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}, retries);
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
return await withLock(
|
||||
p,
|
||||
async () => {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
},
|
||||
retries,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTasks(teamName: string): Promise<TaskFile[]> {
|
||||
const dir = taskDir(teamName);
|
||||
return await withLock(dir, async () => {
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
||||
const tasks: TaskFile[] = files
|
||||
.map(f => {
|
||||
const id = parseInt(path.parse(f).name, 10);
|
||||
if (isNaN(id)) return null;
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
||||
})
|
||||
.filter(t => t !== null);
|
||||
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
||||
});
|
||||
const dir = taskDir(teamName);
|
||||
return await withLock(dir, async () => {
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||
const tasks: TaskFile[] = files
|
||||
.map((f) => {
|
||||
const id = parseInt(path.parse(f).name, 10);
|
||||
if (isNaN(id)) return null;
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
||||
})
|
||||
.filter((t) => t !== null);
|
||||
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetOwnerTasks(teamName: string, agentName: string) {
|
||||
const dir = taskDir(teamName);
|
||||
const lockPath = dir;
|
||||
const dir = taskDir(teamName);
|
||||
const lockPath = dir;
|
||||
|
||||
await withLock(lockPath, async () => {
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
||||
for (const f of files) {
|
||||
const p = path.join(dir, f);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
if (task.owner === agentName) {
|
||||
task.owner = undefined;
|
||||
if (task.status !== "completed") {
|
||||
task.status = "pending";
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
await withLock(lockPath, async () => {
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||
for (const f of files) {
|
||||
const p = path.join(dir, f);
|
||||
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
if (task.owner === agentName) {
|
||||
task.owner = undefined;
|
||||
if (task.status !== "completed") {
|
||||
task.status = "pending";
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,90 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { TeamConfig, Member } from "./models";
|
||||
import { configPath, teamDir, taskDir } from "./paths";
|
||||
import { withLock } from "./lock";
|
||||
import type { Member, TeamConfig } from "./models";
|
||||
import { configPath, taskDir, teamDir } from "./paths";
|
||||
|
||||
export function teamExists(teamName: string) {
|
||||
return fs.existsSync(configPath(teamName));
|
||||
return fs.existsSync(configPath(teamName));
|
||||
}
|
||||
|
||||
export function createTeam(
|
||||
name: string,
|
||||
sessionId: string,
|
||||
leadAgentId: string,
|
||||
description = "",
|
||||
defaultModel?: string,
|
||||
separateWindows?: boolean
|
||||
name: string,
|
||||
sessionId: string,
|
||||
leadAgentId: string,
|
||||
description = "",
|
||||
defaultModel?: string,
|
||||
separateWindows?: boolean,
|
||||
): TeamConfig {
|
||||
const dir = teamDir(name);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const dir = teamDir(name);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const tasksDir = taskDir(name);
|
||||
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
|
||||
const tasksDir = taskDir(name);
|
||||
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
const leadMember: Member = {
|
||||
agentId: leadAgentId,
|
||||
name: "team-lead",
|
||||
agentType: "lead",
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: process.env.TMUX_PANE || "",
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
};
|
||||
const leadMember: Member = {
|
||||
agentId: leadAgentId,
|
||||
name: "team-lead",
|
||||
agentType: "lead",
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: process.env.TMUX_PANE || "",
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
const config: TeamConfig = {
|
||||
name,
|
||||
description,
|
||||
createdAt: Date.now(),
|
||||
leadAgentId,
|
||||
leadSessionId: sessionId,
|
||||
members: [leadMember],
|
||||
defaultModel,
|
||||
separateWindows,
|
||||
};
|
||||
const config: TeamConfig = {
|
||||
name,
|
||||
description,
|
||||
createdAt: Date.now(),
|
||||
leadAgentId,
|
||||
leadSessionId: sessionId,
|
||||
members: [leadMember],
|
||||
defaultModel,
|
||||
separateWindows,
|
||||
};
|
||||
|
||||
fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
|
||||
return config;
|
||||
fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
|
||||
return config;
|
||||
}
|
||||
|
||||
function readConfigRaw(p: string): TeamConfig {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
export async function readConfig(teamName: string): Promise<TeamConfig> {
|
||||
const p = configPath(teamName);
|
||||
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
|
||||
return await withLock(p, async () => {
|
||||
return readConfigRaw(p);
|
||||
});
|
||||
const p = configPath(teamName);
|
||||
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
|
||||
return await withLock(p, async () => {
|
||||
return readConfigRaw(p);
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMember(teamName: string, member: Member) {
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
config.members.push(member);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
});
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
config.members.push(member);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMember(teamName: string, agentName: string) {
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
config.members = config.members.filter(m => m.name !== agentName);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
});
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
config.members = config.members.filter((m) => m.name !== agentName);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMember(teamName: string, agentName: string, updates: Partial<Member>) {
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
const m = config.members.find(m => m.name === agentName);
|
||||
if (m) {
|
||||
Object.assign(m, updates);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
}
|
||||
});
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
const m = config.members.find((m) => m.name === agentName);
|
||||
if (m) {
|
||||
Object.assign(m, updates);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Terminal Adapter Interface
|
||||
*
|
||||
*
|
||||
* Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij)
|
||||
* to provide a unified API for spawning, managing, and terminating panes.
|
||||
*/
|
||||
|
|
@ -11,120 +11,123 @@ import { spawnSync } from "node:child_process";
|
|||
* Options for spawning a new terminal pane or window
|
||||
*/
|
||||
export interface SpawnOptions {
|
||||
/** Name/identifier for the pane/window */
|
||||
name: string;
|
||||
/** Working directory for the new pane/window */
|
||||
cwd: string;
|
||||
/** Command to execute in the pane/window */
|
||||
command: string;
|
||||
/** Environment variables to set (key-value pairs) */
|
||||
env: Record<string, string>;
|
||||
/** Team name for window title formatting (e.g., "team: agent") */
|
||||
teamName?: string;
|
||||
/** Name/identifier for the pane/window */
|
||||
name: string;
|
||||
/** Working directory for the new pane/window */
|
||||
cwd: string;
|
||||
/** Command to execute in the pane/window */
|
||||
command: string;
|
||||
/** Environment variables to set (key-value pairs) */
|
||||
env: Record<string, string>;
|
||||
/** Team name for window title formatting (e.g., "team: agent") */
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal Adapter Interface
|
||||
*
|
||||
*
|
||||
* Implementations provide terminal-specific logic for pane management.
|
||||
*/
|
||||
export interface TerminalAdapter {
|
||||
/** Unique name identifier for this terminal type */
|
||||
readonly name: string;
|
||||
/** Unique name identifier for this terminal type */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Detect if this terminal is currently available/active.
|
||||
* Should check for terminal-specific environment variables or processes.
|
||||
*
|
||||
* @returns true if this terminal should be used
|
||||
*/
|
||||
detect(): boolean;
|
||||
/**
|
||||
* Detect if this terminal is currently available/active.
|
||||
* Should check for terminal-specific environment variables or processes.
|
||||
*
|
||||
* @returns true if this terminal should be used
|
||||
*/
|
||||
detect(): boolean;
|
||||
|
||||
/**
|
||||
* Spawn a new terminal pane with the given options.
|
||||
*
|
||||
* @param options - Spawn configuration
|
||||
* @returns Pane ID that can be used for subsequent operations
|
||||
* @throws Error if spawn fails
|
||||
*/
|
||||
spawn(options: SpawnOptions): string;
|
||||
/**
|
||||
* Spawn a new terminal pane with the given options.
|
||||
*
|
||||
* @param options - Spawn configuration
|
||||
* @returns Pane ID that can be used for subsequent operations
|
||||
* @throws Error if spawn fails
|
||||
*/
|
||||
spawn(options: SpawnOptions): string;
|
||||
|
||||
/**
|
||||
* Kill/terminate a terminal pane.
|
||||
* Should be idempotent - no error if pane doesn't exist.
|
||||
*
|
||||
* @param paneId - The pane ID returned from spawn()
|
||||
*/
|
||||
kill(paneId: string): void;
|
||||
/**
|
||||
* Kill/terminate a terminal pane.
|
||||
* Should be idempotent - no error if pane doesn't exist.
|
||||
*
|
||||
* @param paneId - The pane ID returned from spawn()
|
||||
*/
|
||||
kill(paneId: string): void;
|
||||
|
||||
/**
|
||||
* Check if a terminal pane is still alive/active.
|
||||
*
|
||||
* @param paneId - The pane ID returned from spawn()
|
||||
* @returns true if pane exists and is active
|
||||
*/
|
||||
isAlive(paneId: string): boolean;
|
||||
/**
|
||||
* Check if a terminal pane is still alive/active.
|
||||
*
|
||||
* @param paneId - The pane ID returned from spawn()
|
||||
* @returns true if pane exists and is active
|
||||
*/
|
||||
isAlive(paneId: string): boolean;
|
||||
|
||||
/**
|
||||
* Set the title of the current terminal pane/window.
|
||||
* Used for identifying panes in the terminal UI.
|
||||
*
|
||||
* @param title - The title to set
|
||||
*/
|
||||
setTitle(title: string): void;
|
||||
/**
|
||||
* Set the title of the current terminal pane/window.
|
||||
* Used for identifying panes in the terminal UI.
|
||||
*
|
||||
* @param title - The title to set
|
||||
*/
|
||||
setTitle(title: string): void;
|
||||
|
||||
/**
|
||||
* Check if this terminal supports spawning separate OS windows.
|
||||
* Terminals like tmux and Zellij only support panes/tabs within a session.
|
||||
*
|
||||
* @returns true if spawnWindow() is supported
|
||||
*/
|
||||
supportsWindows(): boolean;
|
||||
/**
|
||||
* Check if this terminal supports spawning separate OS windows.
|
||||
* Terminals like tmux and Zellij only support panes/tabs within a session.
|
||||
*
|
||||
* @returns true if spawnWindow() is supported
|
||||
*/
|
||||
supportsWindows(): boolean;
|
||||
|
||||
/**
|
||||
* Spawn a new separate OS window with the given options.
|
||||
* Only available if supportsWindows() returns true.
|
||||
*
|
||||
* @param options - Spawn configuration
|
||||
* @returns Window ID that can be used for subsequent operations
|
||||
* @throws Error if spawn fails or not supported
|
||||
*/
|
||||
spawnWindow(options: SpawnOptions): string;
|
||||
/**
|
||||
* Spawn a new separate OS window with the given options.
|
||||
* Only available if supportsWindows() returns true.
|
||||
*
|
||||
* @param options - Spawn configuration
|
||||
* @returns Window ID that can be used for subsequent operations
|
||||
* @throws Error if spawn fails or not supported
|
||||
*/
|
||||
spawnWindow(options: SpawnOptions): string;
|
||||
|
||||
/**
|
||||
* Set the title of a specific window.
|
||||
* Used for identifying windows in the OS window manager.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
* @param title - The title to set
|
||||
*/
|
||||
setWindowTitle(windowId: string, title: string): void;
|
||||
/**
|
||||
* Set the title of a specific window.
|
||||
* Used for identifying windows in the OS window manager.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
* @param title - The title to set
|
||||
*/
|
||||
setWindowTitle(windowId: string, title: string): void;
|
||||
|
||||
/**
|
||||
* Kill/terminate a window.
|
||||
* Should be idempotent - no error if window doesn't exist.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
*/
|
||||
killWindow(windowId: string): void;
|
||||
/**
|
||||
* Kill/terminate a window.
|
||||
* Should be idempotent - no error if window doesn't exist.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
*/
|
||||
killWindow(windowId: string): void;
|
||||
|
||||
/**
|
||||
* Check if a window is still alive/active.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
* @returns true if window exists and is active
|
||||
*/
|
||||
isWindowAlive(windowId: string): boolean;
|
||||
/**
|
||||
* Check if a window is still alive/active.
|
||||
*
|
||||
* @param windowId - The window ID returned from spawnWindow()
|
||||
* @returns true if window exists and is active
|
||||
*/
|
||||
isWindowAlive(windowId: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base helper for adapters to execute commands synchronously.
|
||||
*/
|
||||
export function execCommand(command: string, args: string[]): { stdout: string; stderr: string; status: number | null } {
|
||||
const result = spawnSync(command, args, { encoding: "utf-8" });
|
||||
return {
|
||||
stdout: result.stdout?.toString() ?? "",
|
||||
stderr: result.stderr?.toString() ?? "",
|
||||
status: result.status,
|
||||
};
|
||||
export function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
): { stdout: string; stderr: string; status: number | null } {
|
||||
const result = spawnSync(command, args, { encoding: "utf-8" });
|
||||
return {
|
||||
stdout: result.stdout?.toString() ?? "",
|
||||
stderr: result.stderr?.toString() ?? "",
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue