factory-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -0
- package/ARCHITECTURE.md +9 -0
- package/CHANGELOG.md +21 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +17 -0
- package/Dockerfile.worker +19 -0
- package/GOVERNANCE.md +21 -0
- package/HANDOFF.md +23 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/ROADMAP.md +23 -0
- package/RUNBOOK.md +42 -0
- package/SECURITY.md +7 -0
- package/SUPPORT.md +7 -0
- package/bin/factory +271 -0
- package/bootstrap/agent-factory-control.service +32 -0
- package/bootstrap/agent-factory-release.service +25 -0
- package/bootstrap/agent-factory-reporter.service +17 -0
- package/bootstrap/agent-factory-reporter.timer +10 -0
- package/bootstrap/agent-factory-telegram.service +31 -0
- package/bootstrap/agent-factory-worker.service +39 -0
- package/bootstrap/cloud-init.yaml +78 -0
- package/bootstrap/deploy-runtime.sh +74 -0
- package/bootstrap/setup.sh +124 -0
- package/capabilities/autonomous-loop/SKILL.md +10 -0
- package/capabilities/code-search.md +3 -0
- package/capabilities/dependency-security.md +3 -0
- package/capabilities/frontend-verification.md +3 -0
- package/capabilities/goal-management/SKILL.md +10 -0
- package/capabilities/project-context/SKILL.md +10 -0
- package/capabilities/release-checklist.md +3 -0
- package/capabilities/secure-review.md +3 -0
- package/capabilities/systematic-debugging.md +3 -0
- package/capabilities/test-discipline.md +3 -0
- package/capabilities/test-driven-development.md +3 -0
- package/capabilities/token-efficiency.md +3 -0
- package/capabilities/verification-before-completion.md +3 -0
- package/config/agent-instructions.md +8 -0
- package/config/capabilities.json +107 -0
- package/infra/main.bicep +310 -0
- package/package.json +78 -0
- package/src/agent-executor.js +52 -0
- package/src/agent-runner.js +110 -0
- package/src/azure-harness.js +118 -0
- package/src/bedrock-harness.js +52 -0
- package/src/bus.js +20 -0
- package/src/capabilities.js +43 -0
- package/src/ceo.js +67 -0
- package/src/config.js +65 -0
- package/src/container-runner.js +77 -0
- package/src/control-plane.js +142 -0
- package/src/control-service.js +55 -0
- package/src/dashboard.js +180 -0
- package/src/log.js +19 -0
- package/src/mcp-tools.js +53 -0
- package/src/operator.js +57 -0
- package/src/process.js +48 -0
- package/src/project-memory.js +22 -0
- package/src/registry.js +6 -0
- package/src/release-bot.js +36 -0
- package/src/release-gate.js +11 -0
- package/src/release-service.js +41 -0
- package/src/release.js +102 -0
- package/src/reporter.js +49 -0
- package/src/routing.js +34 -0
- package/src/scanner-suite.js +58 -0
- package/src/secrets.js +22 -0
- package/src/setup-menu.js +26 -0
- package/src/state.js +49 -0
- package/src/task-entry.js +19 -0
- package/src/task-graph.js +67 -0
- package/src/telegram-service.js +99 -0
- package/src/telegram.js +31 -0
- package/src/tui.js +130 -0
- package/src/validation.js +74 -0
- package/src/worker.js +77 -0
- package/src/workspace-tools.js +121 -0
- package/src/workspace.js +78 -0
- package/templates/project/architecture.md +3 -0
- package/templates/project/commands.md +3 -0
- package/templates/project/decisions.md +3 -0
- package/templates/project/handoff.md +3 -0
- package/templates/project/project.md +3 -0
- package/templates/project/risks.md +3 -0
package/src/tui.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import blessed from "neo-blessed";
|
|
3
|
+
import { createOperator } from "./operator.js";
|
|
4
|
+
|
|
5
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Factory AI UI requires an interactive terminal");
|
|
6
|
+
|
|
7
|
+
const operator = createOperator();
|
|
8
|
+
const screen = blessed.screen({ smartCSR: true, title: "Factory AI", fullUnicode: true, dockBorders: true });
|
|
9
|
+
const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e0e8", muted: "#7f8b99", accent: "#78dba9", warn: "#efc46b", danger: "#ef7d7d", blue: "#77a8ff" };
|
|
10
|
+
|
|
11
|
+
blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: " {bold}{#78dba9-fg}FACTORY AI{/} {/bold} private coding organization" });
|
|
12
|
+
const menu = blessed.list({ parent: screen, top: 3, left: 0, width: 23, bottom: 2, border: { type: "line" }, label: " Navigate ", keys: true, mouse: true, vi: true, items: ["Overview", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"], style: { bg: colors.panel, fg: colors.text, border: { fg: colors.border }, selected: { bg: colors.accent, fg: "#07130d", bold: true }, item: { fg: colors.text } } });
|
|
13
|
+
const main = blessed.box({ parent: screen, top: 3, left: 23, right: 0, bottom: 2, border: { type: "line" }, label: " Overview ", tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true, vi: true, scrollbar: { ch: "▐", style: { fg: colors.accent } }, padding: { left: 2, right: 2 }, style: { bg: colors.bg, fg: colors.text, border: { fg: colors.border } } });
|
|
14
|
+
const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new objective {bold}r{/} refresh {bold}a{/} add secret {bold}p/u{/} pause/resume {bold}q{/} quit" });
|
|
15
|
+
|
|
16
|
+
let dashboard;
|
|
17
|
+
let capabilities;
|
|
18
|
+
let secrets;
|
|
19
|
+
let logs;
|
|
20
|
+
let section = "Overview";
|
|
21
|
+
|
|
22
|
+
function status(message, color = colors.muted) {
|
|
23
|
+
footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
|
|
24
|
+
screen.render();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function badge(value) {
|
|
28
|
+
const color = value === "succeeded" || value === "complete" ? colors.accent : value === "failed" || value === "blocked" ? colors.danger : value === "running" ? colors.blue : colors.warn;
|
|
29
|
+
return `{${color}-fg}${value}{/}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderOverview() {
|
|
33
|
+
const cost = dashboard?.cost ? `${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)}` : "unavailable";
|
|
34
|
+
const counts = Object.entries(dashboard?.summary?.objectives ?? {}).map(([key, value]) => `${key} ${value}`).join(" ") || "none";
|
|
35
|
+
const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${objective.objective}{/}\n ${objective.repository ?? ""}`).join("\n\n");
|
|
36
|
+
main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderObjectives() {
|
|
40
|
+
main.setContent((dashboard?.objectives ?? []).slice().reverse().map((objective) => {
|
|
41
|
+
const tasks = objective.tasks.map((task) => ` ${badge(task.state.padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${task.model}{/}\n ${task.title}`).join("\n");
|
|
42
|
+
return `${badge(objective.status)} {bold}${objective.objective}{/}\n ${objective.id}\n${tasks}${objective.pullRequest ? `\n PR ${objective.pullRequest}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${objective.blocker}{/}` : ""}`;
|
|
43
|
+
}).join("\n\n────────────────────────────────────────────────────────\n\n") || "No objectives." );
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderAgents() {
|
|
47
|
+
const agents = (dashboard?.objectives ?? []).flatMap((objective) => objective.tasks.map((task) => ({ objective: objective.objective, ...task }))).filter((task) => !["succeeded"].includes(task.state));
|
|
48
|
+
main.setContent(agents.map((task) => `${badge(task.state.padEnd(9))} {bold}${task.role}{/} ${task.model}\n ${task.title}\n {#7f8b99-fg}${task.objective}{/}`).join("\n\n") || "No active agents.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderSecrets() {
|
|
52
|
+
main.setContent(`{bold}Global Azure Key Vault{/}\n{#7f8b99-fg}Values are never displayed. Press a to add or rotate a secret.{/}\n\n${(secrets ?? []).map((item) => ` {#78dba9-fg}●{/} ${item.name.padEnd(55)} ${item.updated ?? ""}`).join("\n") || " Loading or empty."}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderCapabilities() {
|
|
56
|
+
const skills = Object.entries(capabilities?.skills ?? {}).map(([name, item]) => ` {#77a8ff-fg}skill{/} ${name.padEnd(36)} ${item.roles.join(", ")}`).join("\n");
|
|
57
|
+
const mcp = Object.entries(capabilities?.mcp ?? {}).map(([name, item]) => ` {#78dba9-fg}mcp{/} ${name.padEnd(36)} ${item.roles.join(", ")}`).join("\n");
|
|
58
|
+
main.setContent(`{bold}Skills{/}\n\n${skills}\n\n{bold}MCP servers{/}\n\n${mcp}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderSettings() {
|
|
62
|
+
const config = operator.config();
|
|
63
|
+
main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.6\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function render() {
|
|
67
|
+
main.setLabel(` ${section} `);
|
|
68
|
+
if (section === "Overview") renderOverview();
|
|
69
|
+
else if (section === "Objectives") renderObjectives();
|
|
70
|
+
else if (section === "Agents") renderAgents();
|
|
71
|
+
else if (section === "Secrets") renderSecrets();
|
|
72
|
+
else if (section === "Capabilities") renderCapabilities();
|
|
73
|
+
else if (section === "Logs") main.setContent(logs ?? "Loading logs...");
|
|
74
|
+
else renderSettings();
|
|
75
|
+
main.setScrollPerc(0);
|
|
76
|
+
screen.render();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function refresh() {
|
|
80
|
+
status("Refreshing...");
|
|
81
|
+
try {
|
|
82
|
+
dashboard = await operator.dashboard();
|
|
83
|
+
if (section === "Capabilities" && !capabilities) capabilities = await operator.capabilities();
|
|
84
|
+
if (section === "Secrets") secrets = await operator.listSecrets();
|
|
85
|
+
if (section === "Logs") logs = await operator.logs();
|
|
86
|
+
render();
|
|
87
|
+
status(`Updated ${new Date().toLocaleTimeString()}`);
|
|
88
|
+
} catch (error) { status(error.message, colors.danger); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ask(label, { secret = false } = {}) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const prompt = blessed.textbox({ parent: screen, top: "center", left: "center", width: "70%", height: 5, border: { type: "line" }, label: ` ${label} `, inputOnFocus: true, keys: true, mouse: true, censor: secret, style: { bg: colors.panel, fg: colors.text, border: { fg: colors.accent } } });
|
|
94
|
+
prompt.focus();
|
|
95
|
+
prompt.readInput((_, value) => { prompt.destroy(); screen.render(); resolve(value?.trim() ?? ""); });
|
|
96
|
+
screen.render();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function submitObjective() {
|
|
101
|
+
const repository = await ask("Repository owner/name");
|
|
102
|
+
if (!repository) return;
|
|
103
|
+
const objective = await ask("CEO objective");
|
|
104
|
+
if (!objective) return;
|
|
105
|
+
status("Submitting objective...");
|
|
106
|
+
try { const result = await operator.submit(repository, objective); status(`Queued ${result.objectiveId ?? "objective"}`, colors.accent); await refresh(); } catch (error) { status(error.message, colors.danger); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function addSecret() {
|
|
110
|
+
if (section !== "Secrets") return status("Open Secrets before adding a key", colors.warn);
|
|
111
|
+
const name = await ask("Secret name");
|
|
112
|
+
if (!name) return;
|
|
113
|
+
const value = await ask("Secret value", { secret: true });
|
|
114
|
+
if (!value) return;
|
|
115
|
+
status("Storing secret...");
|
|
116
|
+
try { await operator.setSecret(name, value); secrets = await operator.listSecrets(); renderSecrets(); status(`Stored ${name}`, colors.accent); } catch (error) { status(error.message, colors.danger); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
menu.on("select", async (_, index) => { section = menu.getItem(index).getText(); render(); await refresh(); menu.focus(); });
|
|
120
|
+
screen.key(["q", "C-c"], () => process.exit(0));
|
|
121
|
+
screen.key("r", refresh);
|
|
122
|
+
screen.key("n", submitObjective);
|
|
123
|
+
screen.key("a", addSecret);
|
|
124
|
+
screen.key("p", async () => { status("Pausing workers..."); try { await operator.control("pause"); status("Workers paused", colors.warn); } catch (error) { status(error.message, colors.danger); } });
|
|
125
|
+
screen.key("u", async () => { status("Resuming workers..."); try { await operator.control("resume"); status("Workers active", colors.accent); } catch (error) { status(error.message, colors.danger); } });
|
|
126
|
+
screen.key("tab", () => menu.focus());
|
|
127
|
+
menu.focus();
|
|
128
|
+
screen.render();
|
|
129
|
+
await refresh();
|
|
130
|
+
setInterval(refresh, 30_000).unref();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ROLES } from "./routing.js";
|
|
3
|
+
|
|
4
|
+
const identifier = z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
|
5
|
+
const repository = z.string().url().max(2048).refine((value) => {
|
|
6
|
+
const parsed = new URL(value);
|
|
7
|
+
return parsed.protocol === "https:" && parsed.hostname === "github.com" && /^\/[^/]+\/[^/]+(?:\.git)?$/.test(parsed.pathname);
|
|
8
|
+
}, "Repository must be an HTTPS github.com repository URL");
|
|
9
|
+
|
|
10
|
+
const objectiveSchema = z.object({
|
|
11
|
+
id: identifier,
|
|
12
|
+
type: z.literal("objective"),
|
|
13
|
+
objective: z.string().trim().min(3).max(12_000),
|
|
14
|
+
repository,
|
|
15
|
+
baseBranch: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/).default("main"),
|
|
16
|
+
createdAt: z.string().datetime().optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
|
|
19
|
+
export const taskSchema = z.object({
|
|
20
|
+
id: identifier,
|
|
21
|
+
role: z.enum(ROLES),
|
|
22
|
+
title: z.string().trim().min(1).max(200),
|
|
23
|
+
instructions: z.string().trim().min(3).max(20_000),
|
|
24
|
+
dependsOn: z.array(identifier).max(16).default([]),
|
|
25
|
+
capabilities: z.array(identifier).max(12).default([]),
|
|
26
|
+
complexity: z.enum(["simple", "complex"]).default("complex"),
|
|
27
|
+
}).strict();
|
|
28
|
+
|
|
29
|
+
const planSchema = z.object({
|
|
30
|
+
executiveIntent: z.string().trim().min(1).max(4000).optional(),
|
|
31
|
+
tasks: z.array(taskSchema).min(1).max(32),
|
|
32
|
+
}).strict();
|
|
33
|
+
|
|
34
|
+
const taskResultSchema = z.object({
|
|
35
|
+
summary: z.string().trim().min(1).max(4000),
|
|
36
|
+
checks: z.array(z.string().trim().min(1).max(1000)).max(50),
|
|
37
|
+
risks: z.array(z.string().trim().min(1).max(1000)).max(50),
|
|
38
|
+
approval: z.enum(["approved", "changes_requested", "not_applicable"]),
|
|
39
|
+
}).strict();
|
|
40
|
+
|
|
41
|
+
export const workMessageSchema = z.object({
|
|
42
|
+
type: z.literal("task"),
|
|
43
|
+
objectiveId: identifier,
|
|
44
|
+
task: taskSchema,
|
|
45
|
+
}).strict();
|
|
46
|
+
|
|
47
|
+
const resultMessageSchema = taskResultSchema.extend({
|
|
48
|
+
type: z.literal("result"),
|
|
49
|
+
objectiveId: identifier,
|
|
50
|
+
taskId: identifier,
|
|
51
|
+
status: z.literal("succeeded"),
|
|
52
|
+
commit: z.string().regex(/^[0-9a-f]{40,64}$/),
|
|
53
|
+
branch: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/),
|
|
54
|
+
}).strict();
|
|
55
|
+
|
|
56
|
+
export function parseObjective(value) {
|
|
57
|
+
return objectiveSchema.parse(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parsePlan(value) {
|
|
61
|
+
return planSchema.parse(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseWorkMessage(value) {
|
|
65
|
+
return workMessageSchema.parse(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseTaskResult(value) {
|
|
69
|
+
return taskResultSchema.parse(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseResultMessage(value) {
|
|
73
|
+
return resultMessageSchema.parse(value);
|
|
74
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { createBus } from "./bus.js";
|
|
4
|
+
import { WorkspaceManager } from "./workspace.js";
|
|
5
|
+
import { log } from "./log.js";
|
|
6
|
+
import { loadRuntimeSecrets } from "./secrets.js";
|
|
7
|
+
import { run } from "./process.js";
|
|
8
|
+
import { AgentExecutor } from "./agent-executor.js";
|
|
9
|
+
import { sendMessage } from "./bus.js";
|
|
10
|
+
import { ContainerAgentRunner } from "./container-runner.js";
|
|
11
|
+
import { ScannerSuite } from "./scanner-suite.js";
|
|
12
|
+
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
Object.assign(process.env, await loadRuntimeSecrets(config));
|
|
15
|
+
await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
|
|
16
|
+
const bus = createBus(config, config.agentQueue, config.controlQueue);
|
|
17
|
+
const sendControl = (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.type}:${message.taskId ?? "plan"}:v1`, message.objectiveId);
|
|
18
|
+
const executor = new AgentExecutor({
|
|
19
|
+
workspaces: new WorkspaceManager(config.workspaceDir, config.timeoutMs),
|
|
20
|
+
agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs }),
|
|
21
|
+
scannerSuite: new ScannerSuite(),
|
|
22
|
+
sendControl,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
let shuttingDown = false;
|
|
26
|
+
const subscription = bus.receiver.subscribe({
|
|
27
|
+
processMessage: async (message) => {
|
|
28
|
+
const body = message.body;
|
|
29
|
+
try {
|
|
30
|
+
await executor.process(body);
|
|
31
|
+
await bus.receiver.completeMessage(message);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
log("error", "message_failed", {
|
|
34
|
+
messageId: String(message.messageId),
|
|
35
|
+
type: body?.type,
|
|
36
|
+
deliveryCount: message.deliveryCount,
|
|
37
|
+
error: error.message,
|
|
38
|
+
});
|
|
39
|
+
const permanent = error.message.includes("content_filter") || message.deliveryCount >= config.maxDeliveryCount;
|
|
40
|
+
if (permanent) {
|
|
41
|
+
await sendControl({
|
|
42
|
+
type: "failure_result",
|
|
43
|
+
objectiveId: body?.objectiveId,
|
|
44
|
+
taskId: body?.task?.id,
|
|
45
|
+
error: String(error.message).slice(0, 2000),
|
|
46
|
+
});
|
|
47
|
+
await bus.receiver.deadLetterMessage(message, {
|
|
48
|
+
deadLetterReason: "MaxDeliveryExceeded",
|
|
49
|
+
deadLetterErrorDescription: String(error.message).slice(0, 4096),
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
await bus.receiver.abandonMessage(message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
processError: async ({ error, errorSource }) => {
|
|
57
|
+
log("error", "service_bus_error", { source: errorSource, error: error.message });
|
|
58
|
+
},
|
|
59
|
+
}, {
|
|
60
|
+
autoCompleteMessages: false,
|
|
61
|
+
maxConcurrentCalls: config.concurrency,
|
|
62
|
+
maxAutoLockRenewalDurationInMs: (config.timeoutMs * 4) + 300_000,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function shutdown(signal) {
|
|
66
|
+
if (shuttingDown) return;
|
|
67
|
+
shuttingDown = true;
|
|
68
|
+
log("info", "worker_shutdown", { signal });
|
|
69
|
+
await subscription.close();
|
|
70
|
+
await bus.receiver.close();
|
|
71
|
+
await bus.sender.close();
|
|
72
|
+
await bus.client.close();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.once("SIGTERM", () => shutdown("SIGTERM").catch((error) => log("error", "shutdown_failed", { error: error.message })));
|
|
76
|
+
process.once("SIGINT", () => shutdown("SIGINT").catch((error) => log("error", "shutdown_failed", { error: error.message })));
|
|
77
|
+
log("info", "worker_started", { concurrency: config.concurrency, queue: config.agentQueue });
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { run } from "./process.js";
|
|
5
|
+
|
|
6
|
+
const ALLOWED_COMMANDS = new Set(["git", "ls", "node", "npm", "npx", "pwd", "rg"]);
|
|
7
|
+
const DENIED_GIT_OPERATIONS = new Set(["credential", "push", "remote"]);
|
|
8
|
+
|
|
9
|
+
function lexicalPath(root, requested) {
|
|
10
|
+
const target = path.resolve(root, requested);
|
|
11
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error("Path is outside workspace");
|
|
12
|
+
return target;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function existingPath(root, requested) {
|
|
16
|
+
const target = lexicalPath(root, requested);
|
|
17
|
+
const resolved = await realpath(target);
|
|
18
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error("Path is outside workspace");
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function writeTarget(root, requested) {
|
|
23
|
+
const target = lexicalPath(root, requested);
|
|
24
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
25
|
+
const parent = await realpath(path.dirname(target));
|
|
26
|
+
if (parent !== root && !parent.startsWith(`${root}${path.sep}`)) throw new Error("Path is outside workspace");
|
|
27
|
+
try {
|
|
28
|
+
const metadata = await lstat(target);
|
|
29
|
+
if (metadata.isSymbolicLink()) throw new Error("Symbolic link writes are not allowed");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== "ENOENT") throw error;
|
|
32
|
+
}
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function walk(directory, root, output, limit) {
|
|
37
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
38
|
+
if (entry.name === ".git" || output.length >= limit) continue;
|
|
39
|
+
const absolute = path.join(directory, entry.name);
|
|
40
|
+
const relative = path.relative(root, absolute);
|
|
41
|
+
if (entry.isDirectory()) await walk(absolute, root, output, limit);
|
|
42
|
+
else if (entry.isFile()) output.push(relative);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
47
|
+
const root = realpathSync(path.resolve(rootInput));
|
|
48
|
+
return {
|
|
49
|
+
read_file: {
|
|
50
|
+
description: "Read a UTF-8 file inside the assigned workspace.",
|
|
51
|
+
parameters: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { path: { type: "string" } },
|
|
54
|
+
required: ["path"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
execute: async ({ path: requested }) => {
|
|
58
|
+
const value = await readFile(await existingPath(root, requested), "utf8");
|
|
59
|
+
if (Buffer.byteLength(value) > 512_000) throw new Error("File exceeds 512000 bytes");
|
|
60
|
+
return value;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
write_file: {
|
|
64
|
+
description: "Atomically replace a UTF-8 file inside the assigned workspace.",
|
|
65
|
+
parameters: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
68
|
+
required: ["path", "content"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
execute: async ({ path: requested, content }) => {
|
|
72
|
+
if (Buffer.byteLength(content) > 1_000_000) throw new Error("Content exceeds 1000000 bytes");
|
|
73
|
+
const target = await writeTarget(root, requested);
|
|
74
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
75
|
+
await writeFile(temporary, content, { mode: 0o640 });
|
|
76
|
+
await rename(temporary, target);
|
|
77
|
+
return "written";
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
list_files: {
|
|
81
|
+
description: "List files recursively inside the assigned workspace.",
|
|
82
|
+
parameters: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: { path: { type: "string" } },
|
|
85
|
+
required: ["path"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
execute: async ({ path: requested }) => {
|
|
89
|
+
const directory = await existingPath(root, requested);
|
|
90
|
+
const output = [];
|
|
91
|
+
await walk(directory, root, output, 2000);
|
|
92
|
+
return JSON.stringify(output);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
run_command: {
|
|
96
|
+
description: "Run an allowlisted noninteractive development command in the assigned workspace.",
|
|
97
|
+
parameters: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
command: { type: "string", enum: [...ALLOWED_COMMANDS] },
|
|
101
|
+
args: { type: "array", items: { type: "string" }, maxItems: 100 },
|
|
102
|
+
},
|
|
103
|
+
required: ["command", "args"],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
},
|
|
106
|
+
execute: async ({ command, args }) => {
|
|
107
|
+
if (!ALLOWED_COMMANDS.has(command)) throw new Error(`Command not allowed: ${command}`);
|
|
108
|
+
if (command === "git" && DENIED_GIT_OPERATIONS.has(args[0])) throw new Error(`Git operation not allowed: ${args[0]}`);
|
|
109
|
+
if (command === "npx" && args[0] !== "--no-install") throw new Error("npx requires --no-install");
|
|
110
|
+
const result = await execute(command, args, {
|
|
111
|
+
cwd: root,
|
|
112
|
+
timeoutMs: 900_000,
|
|
113
|
+
maxOutputBytes: 2_000_000,
|
|
114
|
+
inheritEnv: false,
|
|
115
|
+
env: { PATH: process.env.PATH, HOME: "/tmp/agent-home", CI: "true", NO_COLOR: "1" },
|
|
116
|
+
});
|
|
117
|
+
return `${result.stdout}${result.stderr ? `\nSTDERR:\n${result.stderr}` : ""}`.slice(-2_000_000);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { run } from "./process.js";
|
|
4
|
+
|
|
5
|
+
async function exists(target) {
|
|
6
|
+
try {
|
|
7
|
+
await stat(target);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error.code === "ENOENT") return false;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class WorkspaceManager {
|
|
16
|
+
constructor(root, timeoutMs) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.timeoutMs = timeoutMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
rootFor(objectiveId) {
|
|
22
|
+
return path.join(this.root, objectiveId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
taskDirectory(objectiveId, taskId) {
|
|
26
|
+
return path.join(this.rootFor(objectiveId), "tasks", taskId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async ensureObjective(objective) {
|
|
30
|
+
const root = this.rootFor(objective.id);
|
|
31
|
+
const control = path.join(root, "control");
|
|
32
|
+
if (!(await exists(path.join(control, ".git")))) {
|
|
33
|
+
await mkdir(root, { recursive: true, mode: 0o750 });
|
|
34
|
+
const temporary = `${control}.clone-${process.pid}`;
|
|
35
|
+
await rm(temporary, { recursive: true, force: true });
|
|
36
|
+
await run("git", ["clone", "--branch", objective.baseBranch, "--single-branch", objective.repository, temporary], {
|
|
37
|
+
timeoutMs: this.timeoutMs,
|
|
38
|
+
});
|
|
39
|
+
await rename(temporary, control);
|
|
40
|
+
await run("git", ["-C", control, "config", "user.name", "Factory AI"]);
|
|
41
|
+
await run("git", ["-C", control, "config", "user.email", "factory-ai@localhost"]);
|
|
42
|
+
}
|
|
43
|
+
return control;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async prepareTask(objective, task, dependencyCommits = []) {
|
|
47
|
+
const directory = this.taskDirectory(objective.id, task.id);
|
|
48
|
+
if (await exists(directory)) return directory;
|
|
49
|
+
await mkdir(path.dirname(directory), { recursive: true, mode: 0o750 });
|
|
50
|
+
const base = dependencyCommits[0] ?? `origin/${objective.baseBranch}`;
|
|
51
|
+
const branch = `factory-ai/${objective.id}/${task.id}`;
|
|
52
|
+
await run("git", ["clone", objective.repository, directory], { timeoutMs: this.timeoutMs });
|
|
53
|
+
await run("git", ["-C", directory, "config", "user.name", "Factory AI"]);
|
|
54
|
+
await run("git", ["-C", directory, "config", "user.email", "factory-ai@localhost"]);
|
|
55
|
+
await run("git", ["-C", directory, "checkout", "-b", branch, base], { timeoutMs: this.timeoutMs });
|
|
56
|
+
for (const commit of dependencyCommits.slice(1)) {
|
|
57
|
+
await run("git", ["-C", directory, "merge", "--no-edit", commit], { timeoutMs: this.timeoutMs });
|
|
58
|
+
}
|
|
59
|
+
await run("git", ["-C", directory, "push", "--set-upstream", "origin", branch], { timeoutMs: this.timeoutMs });
|
|
60
|
+
return directory;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async checkpoint(directory, objective, task) {
|
|
64
|
+
await run("git", ["-C", directory, "add", "-A"]);
|
|
65
|
+
const diff = await run("git", ["-C", directory, "diff", "--cached", "--quiet"], { allowExitCodes: [0, 1] });
|
|
66
|
+
if (diff.code === 1) {
|
|
67
|
+
await run("git", ["-C", directory, "commit", "-m", `factory-ai(${task.role}): ${task.title}`], {
|
|
68
|
+
timeoutMs: this.timeoutMs,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const { stdout } = await run("git", ["-C", directory, "rev-parse", "HEAD"]);
|
|
72
|
+
const commit = stdout.trim();
|
|
73
|
+
const branch = `factory-ai/${objective.id}/${task.id}`;
|
|
74
|
+
await run("git", ["-C", directory, "push", "--set-upstream", "origin", branch], { timeoutMs: this.timeoutMs });
|
|
75
|
+
return { commit, branch };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
}
|