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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { run } from "./process.js";
|
|
3
|
+
|
|
4
|
+
const AZURE_ENVIRONMENT = [
|
|
5
|
+
"TEXTVED_AZURE_BASE_URL",
|
|
6
|
+
"TEXTVED_AZURE_API_KEY",
|
|
7
|
+
"AZURE_OPENAI_BASE_URL",
|
|
8
|
+
"AZURE_OPENAI_API_KEY",
|
|
9
|
+
"AWS_ACCESS_KEY_ID",
|
|
10
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
11
|
+
"AWS_SESSION_TOKEN",
|
|
12
|
+
"AWS_REGION",
|
|
13
|
+
"AWS_DEFAULT_REGION",
|
|
14
|
+
"FACTORY_MODEL_SCOUT",
|
|
15
|
+
"FACTORY_MODEL_PLANNER",
|
|
16
|
+
"FACTORY_MODEL_BUILDER",
|
|
17
|
+
"FACTORY_MODEL_TESTER",
|
|
18
|
+
"FACTORY_MODEL_DEBUGGER",
|
|
19
|
+
"FACTORY_MODEL_REVIEWER",
|
|
20
|
+
"FACTORY_MODEL_SECURITY",
|
|
21
|
+
"FACTORY_MODEL_RELEASE",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function parseOutput(stdout) {
|
|
25
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
26
|
+
if (lines.length === 0) throw new Error("Agent container returned no result");
|
|
27
|
+
return JSON.parse(lines.at(-1));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ContainerAgentRunner {
|
|
31
|
+
constructor({ image, memoryDir, timeoutMs, execute = run }) {
|
|
32
|
+
if (!image) throw new Error("FACTORY_WORKER_IMAGE is required");
|
|
33
|
+
this.image = image;
|
|
34
|
+
this.memoryDir = memoryDir;
|
|
35
|
+
this.timeoutMs = timeoutMs;
|
|
36
|
+
this.execute = execute;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async executePacket(packet, directory) {
|
|
40
|
+
const name = `agent-${packet.objective.id}-${packet.task?.id ?? "planner"}`.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").slice(0, 63);
|
|
41
|
+
const args = [
|
|
42
|
+
"run", "-i", "--rm", "--name", name,
|
|
43
|
+
"--read-only",
|
|
44
|
+
"--user", `${process.getuid()}:${process.getgid()}`,
|
|
45
|
+
"--cap-drop", "ALL",
|
|
46
|
+
"--security-opt", "no-new-privileges",
|
|
47
|
+
"--pids-limit", "512",
|
|
48
|
+
"--memory", "8g",
|
|
49
|
+
"--cpus", "2",
|
|
50
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=1g",
|
|
51
|
+
"--volume", `${path.resolve(directory)}:/workspace:rw`,
|
|
52
|
+
...(this.memoryDir ? ["--volume", `${path.resolve(this.memoryDir)}:/memory:rw`] : []),
|
|
53
|
+
"--workdir", "/workspace",
|
|
54
|
+
"--env", "HOME=/tmp",
|
|
55
|
+
...AZURE_ENVIRONMENT.flatMap((nameValue) => ["--env", nameValue]),
|
|
56
|
+
this.image,
|
|
57
|
+
];
|
|
58
|
+
const result = await this.execute("docker", args, {
|
|
59
|
+
input: `${JSON.stringify(packet)}\n`,
|
|
60
|
+
timeoutMs: this.timeoutMs,
|
|
61
|
+
maxOutputBytes: 2_000_000,
|
|
62
|
+
});
|
|
63
|
+
return parseOutput(result.stdout);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
invoke({ objective, task, directory, prompt }) {
|
|
67
|
+
return this.executePacket({ mode: "task", objective, task, prompt }, directory);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
plan(objective, directory) {
|
|
71
|
+
return this.executePacket({
|
|
72
|
+
mode: "plan",
|
|
73
|
+
objective,
|
|
74
|
+
task: { id: "planner0", role: "planner", instructions: "Create the delivery graph.", capabilities: [] },
|
|
75
|
+
}, directory);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { parseObjective, parsePlan, parseResultMessage } from "./validation.js";
|
|
2
|
+
import { validateDeliveryGraph, readyTasks } from "./task-graph.js";
|
|
3
|
+
import { selectCapabilities } from "./capabilities.js";
|
|
4
|
+
import { evaluateReleaseGate } from "./release-gate.js";
|
|
5
|
+
|
|
6
|
+
export class ControlPlane {
|
|
7
|
+
constructor({ store, memory, registry, sendTask, sendRelease = async () => { throw new Error("Release sender is unavailable"); } }) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.memory = memory;
|
|
10
|
+
this.registry = registry;
|
|
11
|
+
this.sendTask = sendTask;
|
|
12
|
+
this.sendRelease = sendRelease;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async acceptObjective(value) {
|
|
16
|
+
const objective = parseObjective(value);
|
|
17
|
+
try {
|
|
18
|
+
await this.store.read(objective.id);
|
|
19
|
+
return;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code !== "ENOENT") throw error;
|
|
22
|
+
}
|
|
23
|
+
await this.store.write(objective.id, {
|
|
24
|
+
objective,
|
|
25
|
+
status: "planning",
|
|
26
|
+
tasks: [],
|
|
27
|
+
results: {},
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
});
|
|
30
|
+
const context = this.memory ? await this.memory.context(objective.repository) : [];
|
|
31
|
+
await this.sendTask({
|
|
32
|
+
type: "planning_task",
|
|
33
|
+
objectiveId: objective.id,
|
|
34
|
+
objective,
|
|
35
|
+
context,
|
|
36
|
+
task: {
|
|
37
|
+
id: "planner0",
|
|
38
|
+
role: "planner",
|
|
39
|
+
title: "Create delivery graph",
|
|
40
|
+
instructions: "Create the smallest safe delivery graph.",
|
|
41
|
+
dependsOn: [],
|
|
42
|
+
capabilities: [],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async acceptPlanningResult(value) {
|
|
48
|
+
if (value?.type !== "planning_result" || typeof value.objectiveId !== "string") throw new Error("Invalid planning result");
|
|
49
|
+
const delivery = parsePlan(value.delivery);
|
|
50
|
+
validateDeliveryGraph(delivery.tasks);
|
|
51
|
+
for (const task of delivery.tasks) selectCapabilities(this.registry, task.role, task.capabilities);
|
|
52
|
+
await this.store.update(value.objectiveId, (state) => ({
|
|
53
|
+
...state,
|
|
54
|
+
status: "running",
|
|
55
|
+
executiveIntent: delivery.executiveIntent,
|
|
56
|
+
tasks: delivery.tasks,
|
|
57
|
+
}));
|
|
58
|
+
await this.dispatch(value.objectiveId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async acceptTaskResult(value) {
|
|
62
|
+
const result = parseResultMessage(value);
|
|
63
|
+
await this.store.update(result.objectiveId, (state) => ({
|
|
64
|
+
...state,
|
|
65
|
+
results: {
|
|
66
|
+
...state.results,
|
|
67
|
+
[result.taskId]: { ...result, completedAt: new Date().toISOString() },
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
await this.dispatch(result.objectiveId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async acceptReleaseResult(value) {
|
|
74
|
+
if (value?.type !== "release_result" || typeof value.objectiveId !== "string" || !value.release?.url) throw new Error("Invalid release result");
|
|
75
|
+
const state = await this.store.update(value.objectiveId, (current) => ({ ...current, status: "complete", release: value.release, completedAt: new Date().toISOString() }));
|
|
76
|
+
await this.store.writeResult(value.objectiveId, {
|
|
77
|
+
objectiveId: value.objectiveId,
|
|
78
|
+
status: "complete",
|
|
79
|
+
executiveSummary: state.executiveIntent ?? state.objective.objective,
|
|
80
|
+
pullRequest: value.release.url,
|
|
81
|
+
checks: value.release.checks ?? [],
|
|
82
|
+
blockers: value.release.blockers ?? [],
|
|
83
|
+
autoMergeEnabled: value.release.autoMergeEnabled ?? false,
|
|
84
|
+
completedAt: state.completedAt,
|
|
85
|
+
});
|
|
86
|
+
if (this.memory) await this.memory.append({
|
|
87
|
+
type: "objective-completed",
|
|
88
|
+
objectiveId: value.objectiveId,
|
|
89
|
+
repository: state.objective.repository,
|
|
90
|
+
objective: state.objective.objective,
|
|
91
|
+
executiveIntent: state.executiveIntent,
|
|
92
|
+
pullRequest: value.release.url,
|
|
93
|
+
checks: value.release.checks ?? [],
|
|
94
|
+
blockers: value.release.blockers ?? [],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async acceptFailure(value) {
|
|
99
|
+
if (value?.type !== "failure_result" || typeof value.objectiveId !== "string" || typeof value.error !== "string") throw new Error("Invalid failure result");
|
|
100
|
+
const blocker = `${value.taskId ?? "unknown-task"}: ${value.error}`;
|
|
101
|
+
const state = await this.store.update(value.objectiveId, (current) => ({ ...current, status: "failed", failure: blocker, completedAt: new Date().toISOString() }));
|
|
102
|
+
await this.store.writeResult(value.objectiveId, {
|
|
103
|
+
objectiveId: value.objectiveId,
|
|
104
|
+
status: "failed",
|
|
105
|
+
executiveSummary: state.executiveIntent ?? state.objective.objective,
|
|
106
|
+
checks: [],
|
|
107
|
+
blockers: [blocker],
|
|
108
|
+
autoMergeEnabled: false,
|
|
109
|
+
completedAt: state.completedAt,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async dispatch(objectiveId) {
|
|
114
|
+
const state = await this.store.read(objectiveId);
|
|
115
|
+
for (const taskId of readyTasks(state.tasks, state.results)) {
|
|
116
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
117
|
+
const dependencyCommits = task.dependsOn.map((id) => state.results[id]?.commit).filter(Boolean);
|
|
118
|
+
await this.sendTask({ type: "agent_task", objectiveId, objective: state.objective, task, dependencyCommits });
|
|
119
|
+
await this.store.update(objectiveId, (current) => ({
|
|
120
|
+
...current,
|
|
121
|
+
results: { ...current.results, [taskId]: { status: "queued", queuedAt: new Date().toISOString() } },
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
const latest = await this.store.read(objectiveId);
|
|
125
|
+
if (latest.status !== "releasing" && latest.tasks.length > 0 && latest.tasks.every((task) => latest.results[task.id]?.status === "succeeded")) {
|
|
126
|
+
const gate = evaluateReleaseGate(latest.tasks, latest.results);
|
|
127
|
+
if (!gate.approved) {
|
|
128
|
+
await this.store.update(objectiveId, (current) => ({ ...current, status: "blocked", failure: gate.blockers.join(", ") }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const releaseTask = latest.tasks.find((task) => task.role === "release");
|
|
132
|
+
await this.sendRelease({
|
|
133
|
+
type: "publish_request",
|
|
134
|
+
objectiveId,
|
|
135
|
+
objective: latest.objective,
|
|
136
|
+
branch: latest.results[releaseTask.id].branch,
|
|
137
|
+
results: latest.results,
|
|
138
|
+
});
|
|
139
|
+
await this.store.update(objectiveId, (current) => ({ ...current, status: "releasing" }));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { createBus, sendMessage } from "./bus.js";
|
|
4
|
+
import { StateStore } from "./state.js";
|
|
5
|
+
import { ControlPlane } from "./control-plane.js";
|
|
6
|
+
import { loadRegistry } from "./registry.js";
|
|
7
|
+
import { log } from "./log.js";
|
|
8
|
+
import { ProjectMemory } from "./project-memory.js";
|
|
9
|
+
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
const bus = createBus(config, config.controlQueue, config.agentQueue);
|
|
12
|
+
const releaseSender = bus.client.createSender(config.releaseQueue);
|
|
13
|
+
const control = new ControlPlane({
|
|
14
|
+
store: new StateStore(config.stateDir),
|
|
15
|
+
memory: new ProjectMemory(config.memoryDir),
|
|
16
|
+
registry: await loadRegistry(config.registryPath),
|
|
17
|
+
sendTask: (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.task.id}:v1`, message.objectiveId),
|
|
18
|
+
sendRelease: (message) => sendMessage(releaseSender, message, `${message.objectiveId}:publish:v1`, message.objectiveId),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let shuttingDown = false;
|
|
22
|
+
const subscription = bus.receiver.subscribe({
|
|
23
|
+
processMessage: async (message) => {
|
|
24
|
+
try {
|
|
25
|
+
if (message.body?.type === "objective") await control.acceptObjective(message.body);
|
|
26
|
+
else if (message.body?.type === "planning_result") await control.acceptPlanningResult(message.body);
|
|
27
|
+
else if (message.body?.type === "result") await control.acceptTaskResult(message.body);
|
|
28
|
+
else if (message.body?.type === "release_result") await control.acceptReleaseResult(message.body);
|
|
29
|
+
else if (message.body?.type === "failure_result") await control.acceptFailure(message.body);
|
|
30
|
+
else throw new Error(`Unsupported control message type: ${message.body?.type}`);
|
|
31
|
+
await bus.receiver.completeMessage(message);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
log("error", "control_message_failed", { messageId: String(message.messageId), deliveryCount: message.deliveryCount, error: error.message });
|
|
34
|
+
if (message.deliveryCount >= config.maxDeliveryCount) {
|
|
35
|
+
await bus.receiver.deadLetterMessage(message, { deadLetterReason: "MaxDeliveryExceeded", deadLetterErrorDescription: error.message.slice(0, 4096) });
|
|
36
|
+
} else await bus.receiver.abandonMessage(message);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
processError: async ({ error, errorSource }) => log("error", "control_bus_error", { source: errorSource, error: error.message }),
|
|
40
|
+
}, { autoCompleteMessages: false, maxConcurrentCalls: 1, maxAutoLockRenewalDurationInMs: 300_000 });
|
|
41
|
+
|
|
42
|
+
async function shutdown(signal) {
|
|
43
|
+
if (shuttingDown) return;
|
|
44
|
+
shuttingDown = true;
|
|
45
|
+
log("info", "control_shutdown", { signal });
|
|
46
|
+
await subscription.close();
|
|
47
|
+
await bus.receiver.close();
|
|
48
|
+
await bus.sender.close();
|
|
49
|
+
await releaseSender.close();
|
|
50
|
+
await bus.client.close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
process.once("SIGTERM", () => shutdown("SIGTERM").catch((error) => log("error", "control_shutdown_failed", { error: error.message })));
|
|
54
|
+
process.once("SIGINT", () => shutdown("SIGINT").catch((error) => log("error", "control_shutdown_failed", { error: error.message })));
|
|
55
|
+
log("info", "control_started", { queue: config.controlQueue });
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { modelForTask } from "./routing.js";
|
|
6
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
7
|
+
import { ServiceBusAdministrationClient } from "@azure/service-bus";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
9
|
+
|
|
10
|
+
export async function loadQueueMetrics(config, {
|
|
11
|
+
createAdmin = () => new ServiceBusAdministrationClient(config.serviceBusFqdn, new DefaultAzureCredential()),
|
|
12
|
+
} = {}) {
|
|
13
|
+
const admin = createAdmin();
|
|
14
|
+
const properties = await Promise.all([config.controlQueue, config.agentQueue, config.releaseQueue].map((queue) => admin.getQueueRuntimeProperties(queue)));
|
|
15
|
+
return {
|
|
16
|
+
active: properties.reduce((total, item) => total + (item.activeMessageCount ?? 0), 0),
|
|
17
|
+
deadLetter: properties.reduce((total, item) => total + (item.deadLetterMessageCount ?? 0), 0),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadAzureCost(config, { credential = new DefaultAzureCredential(), fetch = globalThis.fetch } = {}) {
|
|
22
|
+
if (!config.subscriptionId || !config.resourceGroup) return null;
|
|
23
|
+
const access = await credential.getToken("https://management.azure.com/.default");
|
|
24
|
+
const scope = `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}`;
|
|
25
|
+
const response = await fetch(`https://management.azure.com${scope}/providers/Microsoft.CostManagement/query?api-version=2025-03-01`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { authorization: `Bearer ${access.token}`, "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
type: "ActualCost",
|
|
30
|
+
timeframe: "MonthToDate",
|
|
31
|
+
dataset: {
|
|
32
|
+
granularity: "None",
|
|
33
|
+
aggregation: { totalCost: { name: "Cost", function: "Sum" } },
|
|
34
|
+
grouping: [{ type: "Dimension", name: "ResourceType" }],
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) throw new Error(`Azure Cost Management HTTP ${response.status}`);
|
|
39
|
+
const result = await response.json();
|
|
40
|
+
const columns = result.properties?.columns?.map((column) => column.name) ?? [];
|
|
41
|
+
const costIndex = columns.indexOf("Cost");
|
|
42
|
+
const serviceIndex = columns.indexOf("ResourceType");
|
|
43
|
+
const currencyIndex = columns.indexOf("Currency");
|
|
44
|
+
const byService = {};
|
|
45
|
+
let monthToDate = 0;
|
|
46
|
+
let currency = "USD";
|
|
47
|
+
for (const row of result.properties?.rows ?? []) {
|
|
48
|
+
const amount = Number(row[costIndex] ?? 0);
|
|
49
|
+
monthToDate += amount;
|
|
50
|
+
byService[row[serviceIndex] ?? "Other"] = amount;
|
|
51
|
+
currency = row[currencyIndex] ?? currency;
|
|
52
|
+
}
|
|
53
|
+
return { monthToDate, currency, byService };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function humanDuration(seconds) {
|
|
57
|
+
const value = Math.max(0, Math.floor(seconds ?? 0));
|
|
58
|
+
if (value >= 3600) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
|
|
59
|
+
if (value >= 60) return `${Math.floor(value / 60)}m ${value % 60}s`;
|
|
60
|
+
return `${value}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sortObject(value) {
|
|
64
|
+
if (Array.isArray(value)) return value.map(sortObject);
|
|
65
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortObject(value[key])]));
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function stableStringify(value) {
|
|
70
|
+
return `${JSON.stringify(sortObject(value))}\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loadLocalState(root) {
|
|
74
|
+
const states = [];
|
|
75
|
+
const warnings = [];
|
|
76
|
+
let directories = [];
|
|
77
|
+
try {
|
|
78
|
+
directories = await readdir(root, { withFileTypes: true });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== "ENOENT") throw error;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of directories.filter((item) => item.isDirectory() && item.name !== "reports").sort((a, b) => a.name.localeCompare(b.name))) {
|
|
83
|
+
const file = path.join(root, entry.name, "state.json");
|
|
84
|
+
try {
|
|
85
|
+
states.push(JSON.parse(await readFile(file, "utf8")));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code !== "ENOENT") warnings.push(`${file}: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { states, warnings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function taskState(task, results) {
|
|
94
|
+
if (results[task.id]?.status) return results[task.id].status;
|
|
95
|
+
return task.dependsOn.every((id) => results[id]?.status === "succeeded") ? "ready" : "blocked";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function aggregateDashboard({ states = [], queue = {}, cost = null, runtime = {}, hostUptimeSeconds = 0, warnings = [], now = new Date() }) {
|
|
99
|
+
const objectives = states.map((state) => {
|
|
100
|
+
const results = state.results ?? {};
|
|
101
|
+
const tasks = (state.tasks ?? []).map((task) => ({
|
|
102
|
+
id: task.id,
|
|
103
|
+
role: task.role,
|
|
104
|
+
title: task.title,
|
|
105
|
+
model: modelForTask(task),
|
|
106
|
+
state: taskState(task, results),
|
|
107
|
+
branch: results[task.id]?.branch,
|
|
108
|
+
commit: results[task.id]?.commit,
|
|
109
|
+
elapsedSeconds: results[task.id]?.startedAt
|
|
110
|
+
? (new Date(results[task.id]?.completedAt ?? now).getTime() - new Date(results[task.id].startedAt).getTime()) / 1000
|
|
111
|
+
: 0,
|
|
112
|
+
}));
|
|
113
|
+
return {
|
|
114
|
+
id: state.objective?.id,
|
|
115
|
+
objective: state.objective?.objective,
|
|
116
|
+
repository: state.objective?.repository,
|
|
117
|
+
status: state.status,
|
|
118
|
+
tasks,
|
|
119
|
+
checks: Object.entries(results).flatMap(([id, result]) => (result.checks ?? []).map((check) => `${id}: ${check}`)),
|
|
120
|
+
blocker: state.failure,
|
|
121
|
+
pullRequest: Object.values(results).map((result) => result.release?.url).find(Boolean),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
const summary = {};
|
|
125
|
+
for (const objective of objectives) summary[objective.status ?? "unknown"] = (summary[objective.status ?? "unknown"] ?? 0) + 1;
|
|
126
|
+
const startedAt = runtime.startedAt ? new Date(runtime.startedAt) : null;
|
|
127
|
+
return {
|
|
128
|
+
generatedAt: now.toISOString(),
|
|
129
|
+
worker: {
|
|
130
|
+
status: runtime.status ?? "unknown",
|
|
131
|
+
uptimeSeconds: startedAt ? Math.max(0, (now.getTime() - startedAt.getTime()) / 1000) : hostUptimeSeconds,
|
|
132
|
+
},
|
|
133
|
+
queue: { active: queue.active ?? 0, deadLetter: queue.deadLetter ?? 0 },
|
|
134
|
+
cost,
|
|
135
|
+
summary: { objectives: summary },
|
|
136
|
+
objectives,
|
|
137
|
+
warnings,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clip(value, width) {
|
|
142
|
+
const text = String(value ?? "");
|
|
143
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderDashboard(dashboard, { width = 100 } = {}) {
|
|
147
|
+
const lines = [
|
|
148
|
+
"╔═ FACTORY AI ═╗",
|
|
149
|
+
`Worker ${dashboard.worker.status} · uptime ${humanDuration(dashboard.worker.uptimeSeconds)} · queue ${dashboard.queue.active} · DLQ ${dashboard.queue.deadLetter}`,
|
|
150
|
+
`Objectives ${Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}:${count}`).join(" ") || "none"}`,
|
|
151
|
+
];
|
|
152
|
+
if (dashboard.cost) lines.push(`Azure MTD ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} · billing data may be delayed`);
|
|
153
|
+
for (const objective of dashboard.objectives) {
|
|
154
|
+
lines.push("", `[${objective.status}] ${objective.id} ${objective.objective}`);
|
|
155
|
+
for (const task of objective.tasks) lines.push(` ${task.state.padEnd(9)} ${task.role.padEnd(9)} ${task.model} · ${task.title ?? task.id}`);
|
|
156
|
+
if (objective.pullRequest) lines.push(` PR ${objective.pullRequest}`);
|
|
157
|
+
if (objective.blocker) lines.push(` BLOCKED ${objective.blocker}`);
|
|
158
|
+
}
|
|
159
|
+
for (const warning of dashboard.warnings) lines.push(`WARN ${warning}`);
|
|
160
|
+
return lines.map((line) => clip(line, Math.max(20, width))).join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function main() {
|
|
164
|
+
const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
|
|
165
|
+
const json = process.argv.includes("--json");
|
|
166
|
+
const loaded = await loadLocalState(root);
|
|
167
|
+
let queue = {};
|
|
168
|
+
let cost = null;
|
|
169
|
+
if (process.env.SERVICE_BUS_NAMESPACE) queue = await loadQueueMetrics(loadConfig());
|
|
170
|
+
if (process.env.AZURE_SUBSCRIPTION_ID) {
|
|
171
|
+
try { cost = await loadAzureCost(loadConfig()); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
|
|
172
|
+
}
|
|
173
|
+
const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
|
|
174
|
+
process.stdout.write(json ? stableStringify(dashboard) : `${renderDashboard(dashboard, { width: process.stdout.columns ?? 100 })}\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
|
|
178
|
+
process.stderr.write(`${error.message}\n`);
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
});
|
package/src/log.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SECRET_KEY = /token|secret|password|authorization|api.?key/i;
|
|
2
|
+
|
|
3
|
+
function sanitize(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(sanitize);
|
|
5
|
+
if (!value || typeof value !== "object") return value;
|
|
6
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
7
|
+
key,
|
|
8
|
+
SECRET_KEY.test(key) ? "[REDACTED]" : sanitize(item),
|
|
9
|
+
]));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function log(level, event, fields = {}) {
|
|
13
|
+
process.stdout.write(`${JSON.stringify({
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
level,
|
|
16
|
+
event,
|
|
17
|
+
...sanitize(fields),
|
|
18
|
+
})}\n`);
|
|
19
|
+
}
|
package/src/mcp-tools.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function renderContent(result) {
|
|
2
|
+
return (result.content ?? []).map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
async function defaultConnect(capability) {
|
|
6
|
+
const [{ Client }, { StdioClientTransport }] = await Promise.all([
|
|
7
|
+
import("@modelcontextprotocol/sdk/client/index.js"),
|
|
8
|
+
import("@modelcontextprotocol/sdk/client/stdio.js"),
|
|
9
|
+
]);
|
|
10
|
+
const [command, ...args] = capability.command;
|
|
11
|
+
const transport = new StdioClientTransport({
|
|
12
|
+
command,
|
|
13
|
+
args,
|
|
14
|
+
env: {
|
|
15
|
+
PATH: process.env.PATH,
|
|
16
|
+
HOME: process.env.HOME ?? "/tmp",
|
|
17
|
+
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH ?? "/ms-playwright",
|
|
18
|
+
...(capability.environment ?? {}),
|
|
19
|
+
},
|
|
20
|
+
stderr: "pipe",
|
|
21
|
+
});
|
|
22
|
+
const client = new Client({ name: "factory-ai", version: "1.0.0" });
|
|
23
|
+
await client.connect(transport);
|
|
24
|
+
return client;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function connectMcpTools(capabilities, { connect = defaultConnect } = {}) {
|
|
28
|
+
const clients = [];
|
|
29
|
+
const tools = {};
|
|
30
|
+
try {
|
|
31
|
+
for (const capability of capabilities.filter((item) => item.type === "mcp")) {
|
|
32
|
+
const client = await connect(capability);
|
|
33
|
+
clients.push(client);
|
|
34
|
+
const listed = await client.listTools();
|
|
35
|
+
for (const definition of listed.tools) {
|
|
36
|
+
const name = `${capability.name}__${definition.name}`;
|
|
37
|
+
if (tools[name]) throw new Error(`Duplicate MCP tool: ${name}`);
|
|
38
|
+
tools[name] = {
|
|
39
|
+
description: definition.description ?? `${capability.name} ${definition.name}`,
|
|
40
|
+
parameters: definition.inputSchema ?? { type: "object" },
|
|
41
|
+
execute: async (argumentsValue) => renderContent(await client.callTool({ name: definition.name, arguments: argumentsValue })),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
tools,
|
|
47
|
+
close: async () => Promise.allSettled(clients.map((client) => client.close())),
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
await Promise.allSettled(clients.map((client) => client.close()));
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/operator.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { run } from "./process.js";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
|
|
8
|
+
function extractRunCommand(value) {
|
|
9
|
+
const match = value.match(/\[stdout\]\n([\s\S]*?)\n\[stderr\]/);
|
|
10
|
+
return (match?.[1] ?? value).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function command(name, args, options = {}) {
|
|
14
|
+
const result = await run(name, args, { timeoutMs: 180_000, maxOutputBytes: 5_000_000, ...options });
|
|
15
|
+
return result.stdout.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createOperator(environment = process.env) {
|
|
19
|
+
const resourceGroup = environment.FACTORY_RESOURCE_GROUP ?? "factory-ai-rg";
|
|
20
|
+
const vm = environment.FACTORY_VM ?? "agent-factory-vm";
|
|
21
|
+
const namespace = environment.FACTORY_SERVICE_BUS ?? "";
|
|
22
|
+
const vault = environment.FACTORY_KEY_VAULT ?? "";
|
|
23
|
+
const remote = async (script) => extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"]));
|
|
24
|
+
const withVault = async (operation) => {
|
|
25
|
+
const ip = await command("curl", ["-fsS", "https://api.ipify.org"]);
|
|
26
|
+
await command("az", ["keyvault", "network-rule", "add", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]);
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
28
|
+
try { return await operation(); } finally {
|
|
29
|
+
await command("az", ["keyvault", "network-rule", "remove", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
dashboard: async () => JSON.parse(await remote("sudo -u factory env $(xargs < /etc/agent-factory-control.env) node /opt/agent-factory/app/src/dashboard.js --json")),
|
|
34
|
+
logs: async () => remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300'),
|
|
35
|
+
submit: async (repository, objective) => {
|
|
36
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || objective.trim().length < 3) throw new Error("Valid repository and objective are required");
|
|
37
|
+
const output = await command(path.join(root, "bin/factory"), ["submit", repository, objective]);
|
|
38
|
+
const match = output.match(/\{"objectiveId"[\s\S]*?\}/);
|
|
39
|
+
return match ? JSON.parse(match[0]) : { status: "submitted" };
|
|
40
|
+
},
|
|
41
|
+
control: async (action) => {
|
|
42
|
+
if (!["pause", "resume"].includes(action)) throw new Error("Unsupported control action");
|
|
43
|
+
return command(path.join(root, "bin/factory"), [action]);
|
|
44
|
+
},
|
|
45
|
+
capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
|
|
46
|
+
config: () => ({ resourceGroup, vm, namespace, vault }),
|
|
47
|
+
listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
|
|
48
|
+
setSecret: async (name, value) => withVault(async () => {
|
|
49
|
+
if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
|
|
50
|
+
await command("az", ["keyvault", "secret", "set", "--vault-name", vault, "--name", name, "--value", value, "--output", "none"]);
|
|
51
|
+
}),
|
|
52
|
+
deleteSecret: async (name) => withVault(async () => {
|
|
53
|
+
if (!/^[A-Za-z0-9-]{1,127}$/.test(name)) throw new Error("Invalid secret name");
|
|
54
|
+
await command("az", ["keyvault", "secret", "delete", "--vault-name", vault, "--name", name, "--output", "none"]);
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/process.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function run(command, args, options = {}) {
|
|
4
|
+
const { cwd, env, inheritEnv = true, timeoutMs = 300_000, input, allowExitCodes = [0], maxOutputBytes = 10_000_000 } = options;
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
cwd,
|
|
8
|
+
env: inheritEnv ? { ...process.env, ...env } : env,
|
|
9
|
+
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
|
|
10
|
+
shell: false,
|
|
11
|
+
});
|
|
12
|
+
let stdout = "";
|
|
13
|
+
let stderr = "";
|
|
14
|
+
let timedOut = false;
|
|
15
|
+
let outputExceeded = false;
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
timedOut = true;
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
setTimeout(() => child.kill("SIGKILL"), 5000).unref();
|
|
20
|
+
}, timeoutMs).unref();
|
|
21
|
+
function appendOutput(stream, chunk) {
|
|
22
|
+
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) + chunk.length > maxOutputBytes) {
|
|
23
|
+
outputExceeded = true;
|
|
24
|
+
child.kill("SIGTERM");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (stream === "stdout") stdout += chunk;
|
|
28
|
+
else stderr += chunk;
|
|
29
|
+
}
|
|
30
|
+
child.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
|
|
31
|
+
child.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
|
|
32
|
+
child.on("error", (error) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(error);
|
|
35
|
+
});
|
|
36
|
+
child.on("close", (code, signal) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if (timedOut) return reject(new Error(`${command} timed out after ${timeoutMs}ms`));
|
|
39
|
+
if (outputExceeded) return reject(new Error(`${command} exceeded ${maxOutputBytes} output bytes`));
|
|
40
|
+
if (!allowExitCodes.includes(code)) {
|
|
41
|
+
const detail = stderr.slice(-4000).replaceAll(/(token|key|secret)=\S+/gi, "$1=[REDACTED]");
|
|
42
|
+
return reject(new Error(`${command} exited ${code ?? signal}: ${detail}`));
|
|
43
|
+
}
|
|
44
|
+
resolve({ code, stdout, stderr });
|
|
45
|
+
});
|
|
46
|
+
if (input) child.stdin.end(input);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class ProjectMemory {
|
|
5
|
+
constructor(directory) {
|
|
6
|
+
this.directory = directory;
|
|
7
|
+
this.file = path.join(directory, "project-events.jsonl");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async append(event) {
|
|
11
|
+
await mkdir(this.directory, { recursive: true, mode: 0o750 });
|
|
12
|
+
await appendFile(this.file, `${JSON.stringify({ ...event, recordedAt: new Date().toISOString() })}\n`, { mode: 0o640 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async context(repository, limit = 20) {
|
|
16
|
+
let text;
|
|
17
|
+
try { text = await readFile(this.file, "utf8"); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
18
|
+
return text.split("\n").filter(Boolean).flatMap((line) => {
|
|
19
|
+
try { const value = JSON.parse(line); return value.repository === repository ? [value] : []; } catch { return []; }
|
|
20
|
+
}).slice(-limit);
|
|
21
|
+
}
|
|
22
|
+
}
|