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,36 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { run } from "./process.js";
|
|
5
|
+
|
|
6
|
+
async function defaultCheckout(message) {
|
|
7
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "agent-release-"));
|
|
8
|
+
await run("git", ["clone", "--branch", message.branch, "--single-branch", message.objective.repository, directory], { timeoutMs: 300_000 });
|
|
9
|
+
return directory;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ReleaseBot {
|
|
13
|
+
constructor({ checkout = defaultCheckout, publisher, sendControl, cleanup = (directory) => rm(directory, { recursive: true, force: true }) }) {
|
|
14
|
+
this.checkout = checkout;
|
|
15
|
+
this.publisher = publisher;
|
|
16
|
+
this.sendControl = sendControl;
|
|
17
|
+
this.cleanup = cleanup;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async process(message) {
|
|
21
|
+
if (message?.type !== "publish_request") throw new Error(`Unsupported release message: ${message?.type}`);
|
|
22
|
+
const directory = await this.checkout(message);
|
|
23
|
+
try {
|
|
24
|
+
const release = await this.publisher.publish({
|
|
25
|
+
directory,
|
|
26
|
+
objective: message.objective,
|
|
27
|
+
task: { title: message.objective.objective },
|
|
28
|
+
branch: message.branch,
|
|
29
|
+
results: message.results,
|
|
30
|
+
});
|
|
31
|
+
await this.sendControl({ type: "release_result", objectiveId: message.objectiveId, release });
|
|
32
|
+
} finally {
|
|
33
|
+
await this.cleanup(directory);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const APPROVAL_ROLES = new Set(["tester", "reviewer", "security"]);
|
|
2
|
+
|
|
3
|
+
export function evaluateReleaseGate(tasks, results, facts = {}) {
|
|
4
|
+
const blockers = [];
|
|
5
|
+
for (const task of tasks.filter((candidate) => APPROVAL_ROLES.has(candidate.role))) {
|
|
6
|
+
const result = results[task.id];
|
|
7
|
+
if (result?.status !== "succeeded" || result.approval !== "approved") blockers.push(`${task.role} ${task.id}: ${result?.approval ?? result?.status ?? "missing"}`);
|
|
8
|
+
}
|
|
9
|
+
const approvals = facts.approvals ?? blockers.length === 0;
|
|
10
|
+
return { approved: approvals, blockers, autoMerge: approvals && facts.policyAllows === true && facts.checksPass === true };
|
|
11
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { createBus, sendMessage } from "./bus.js";
|
|
4
|
+
import { loadRuntimeSecrets } from "./secrets.js";
|
|
5
|
+
import { run } from "./process.js";
|
|
6
|
+
import { ReleaseBot } from "./release-bot.js";
|
|
7
|
+
import { GitHubRelease } from "./release.js";
|
|
8
|
+
import { log } from "./log.js";
|
|
9
|
+
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
Object.assign(process.env, await loadRuntimeSecrets(config, undefined, ["GH_TOKEN"]));
|
|
12
|
+
await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
|
|
13
|
+
const bus = createBus(config, config.releaseQueue, config.controlQueue);
|
|
14
|
+
const bot = new ReleaseBot({
|
|
15
|
+
publisher: new GitHubRelease(config.timeoutMs),
|
|
16
|
+
sendControl: (message) => sendMessage(bus.sender, message, `${message.objectiveId}:release-result:v1`, message.objectiveId),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const subscription = bus.receiver.subscribe({
|
|
20
|
+
processMessage: async (message) => {
|
|
21
|
+
try {
|
|
22
|
+
await bot.process(message.body);
|
|
23
|
+
await bus.receiver.completeMessage(message);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
log("error", "release_failed", { deliveryCount: message.deliveryCount, error: error.message });
|
|
26
|
+
if (message.deliveryCount >= config.maxDeliveryCount) await bus.receiver.deadLetterMessage(message, { deadLetterReason: "ReleaseFailed", deadLetterErrorDescription: error.message.slice(0, 4096) });
|
|
27
|
+
else await bus.receiver.abandonMessage(message);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
processError: async ({ error, errorSource }) => log("error", "release_bus_error", { source: errorSource, error: error.message }),
|
|
31
|
+
}, { autoCompleteMessages: false, maxConcurrentCalls: 1, maxAutoLockRenewalDurationInMs: (config.timeoutMs * 2) + 300_000 });
|
|
32
|
+
|
|
33
|
+
async function shutdown() {
|
|
34
|
+
await subscription.close();
|
|
35
|
+
await bus.receiver.close();
|
|
36
|
+
await bus.sender.close();
|
|
37
|
+
await bus.client.close();
|
|
38
|
+
}
|
|
39
|
+
process.once("SIGTERM", () => shutdown().catch((error) => log("error", "release_shutdown_failed", { error: error.message })));
|
|
40
|
+
process.once("SIGINT", () => shutdown().catch((error) => log("error", "release_shutdown_failed", { error: error.message })));
|
|
41
|
+
log("info", "release_started", { queue: config.releaseQueue });
|
package/src/release.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { run } from "./process.js";
|
|
2
|
+
import { evaluateReleaseGate } from "./release-gate.js";
|
|
3
|
+
|
|
4
|
+
export { evaluateReleaseGate } from "./release-gate.js";
|
|
5
|
+
|
|
6
|
+
export function requiredChecksPass(exitCode, checks) {
|
|
7
|
+
if (checks.length === 0) return exitCode === 0 || exitCode === 1;
|
|
8
|
+
return exitCode === 0 && checks.every((check) => ["pass", "skipping"].includes(check.bucket));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseJson(value, fallback) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(value);
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class GitHubRelease {
|
|
20
|
+
constructor(timeoutMs, execute = run, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
21
|
+
this.timeoutMs = timeoutMs;
|
|
22
|
+
this.execute = execute;
|
|
23
|
+
this.sleep = sleep;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async requiredChecks(directory, branch) {
|
|
27
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
28
|
+
let command;
|
|
29
|
+
do {
|
|
30
|
+
command = await this.execute("gh", [
|
|
31
|
+
"pr", "checks", branch, "--required", "--json", "name,state,link,bucket",
|
|
32
|
+
], { cwd: directory, timeoutMs: 60_000, allowExitCodes: [0, 1, 8] });
|
|
33
|
+
if (command.code !== 8 || Date.now() >= deadline) break;
|
|
34
|
+
await this.sleep(Math.min(15_000, Math.max(0, deadline - Date.now())));
|
|
35
|
+
} while (Date.now() < deadline);
|
|
36
|
+
return command;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async publish({ directory, objective, task, branch, results }) {
|
|
40
|
+
const commits = Object.entries(results)
|
|
41
|
+
.filter(([, result]) => result.commit)
|
|
42
|
+
.map(([id, result]) => `- ${id}: \`${result.commit}\` - ${result.summary}`)
|
|
43
|
+
.join("\n");
|
|
44
|
+
const body = [
|
|
45
|
+
`Automated delivery for CEO objective \`${objective.id}\`.`,
|
|
46
|
+
"",
|
|
47
|
+
"## Validated checkpoints",
|
|
48
|
+
commits || "- No prior checkpoints",
|
|
49
|
+
"",
|
|
50
|
+
"Human review and repository branch protections remain authoritative.",
|
|
51
|
+
].join("\n");
|
|
52
|
+
const title = `[Factory AI] ${task.title}`;
|
|
53
|
+
const create = await this.execute("gh", [
|
|
54
|
+
"pr", "create", "--base", objective.baseBranch, "--head", branch,
|
|
55
|
+
"--title", title, "--body", body,
|
|
56
|
+
], { cwd: directory, timeoutMs: 120_000, allowExitCodes: [0, 1] });
|
|
57
|
+
if (create.code !== 0) {
|
|
58
|
+
await this.execute("gh", ["pr", "edit", branch, "--title", title, "--body", body], {
|
|
59
|
+
cwd: directory,
|
|
60
|
+
timeoutMs: 120_000,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const view = await this.execute("gh", [
|
|
64
|
+
"pr", "view", branch, "--json", "url,number,reviewDecision,mergeStateStatus",
|
|
65
|
+
], { cwd: directory, timeoutMs: 60_000 });
|
|
66
|
+
const pullRequest = parseJson(view.stdout, {});
|
|
67
|
+
const repository = await this.execute("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
|
|
68
|
+
cwd: directory,
|
|
69
|
+
timeoutMs: 60_000,
|
|
70
|
+
});
|
|
71
|
+
const policy = await this.execute("gh", ["api", `repos/${repository.stdout.trim()}`, "--jq", ".allow_auto_merge"], {
|
|
72
|
+
cwd: directory,
|
|
73
|
+
timeoutMs: 60_000,
|
|
74
|
+
});
|
|
75
|
+
const checksCommand = await this.requiredChecks(directory, branch);
|
|
76
|
+
const checks = parseJson(checksCommand.stdout, []);
|
|
77
|
+
const checksPass = requiredChecksPass(checksCommand.code, checks);
|
|
78
|
+
const gate = evaluateReleaseGate([], {}, {
|
|
79
|
+
approvals: true,
|
|
80
|
+
policyAllows: policy.stdout.trim() === "true",
|
|
81
|
+
checksPass,
|
|
82
|
+
});
|
|
83
|
+
if (gate.autoMerge) {
|
|
84
|
+
await this.execute("gh", ["pr", "merge", branch, "--auto", "--merge"], {
|
|
85
|
+
cwd: directory,
|
|
86
|
+
timeoutMs: 60_000,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
url: pullRequest.url,
|
|
91
|
+
number: pullRequest.number,
|
|
92
|
+
checks,
|
|
93
|
+
reviewDecision: pullRequest.reviewDecision,
|
|
94
|
+
mergeStateStatus: pullRequest.mergeStateStatus,
|
|
95
|
+
autoMergeEnabled: gate.autoMerge,
|
|
96
|
+
blockers: [
|
|
97
|
+
...(gate.autoMerge || policy.stdout.trim() === "true" ? [] : ["Repository auto-merge policy is disabled"]),
|
|
98
|
+
...(checksPass ? [] : ["Required checks are pending or failing"]),
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { aggregateDashboard, loadAzureCost, loadLocalState, loadQueueMetrics, stableStringify } from "./dashboard.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
function markdown(dashboard) {
|
|
9
|
+
const objectives = Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}=${count}`).join(", ") || "none";
|
|
10
|
+
const cost = dashboard.cost ? `\nAzure month-to-date: ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} (billing data may be delayed)\n` : "";
|
|
11
|
+
return `# Factory AI Hourly Report\n\nGenerated: ${dashboard.generatedAt}\n\nQueue: ${dashboard.queue.active} active, ${dashboard.queue.deadLetter} dead-letter\n${cost}\nObjectives: ${objectives}\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function atomicWrite(file, content) {
|
|
15
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
16
|
+
await writeFile(temporary, content, { mode: 0o640 });
|
|
17
|
+
await rename(temporary, file);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeHourlyReport(root, dashboard, { now = new Date(), retention = 168 } = {}) {
|
|
21
|
+
const reports = path.join(root, "reports");
|
|
22
|
+
await mkdir(reports, { recursive: true, mode: 0o750 });
|
|
23
|
+
const stem = now.toISOString().slice(0, 13);
|
|
24
|
+
await atomicWrite(path.join(reports, `${stem}.json`), stableStringify(dashboard));
|
|
25
|
+
await atomicWrite(path.join(reports, `${stem}.md`), markdown(dashboard));
|
|
26
|
+
const stems = [...new Set((await readdir(reports)).filter((name) => /^\d{4}-\d{2}-\d{2}T\d{2}\.(json|md)$/.test(name)).map((name) => name.slice(0, 13)))].sort();
|
|
27
|
+
for (const expired of stems.slice(0, Math.max(0, stems.length - retention))) {
|
|
28
|
+
await Promise.all(["json", "md"].map((extension) => rm(path.join(reports, `${expired}.${extension}`), { force: true })));
|
|
29
|
+
}
|
|
30
|
+
return stem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
|
|
35
|
+
const loaded = await loadLocalState(root);
|
|
36
|
+
const queue = process.env.SERVICE_BUS_NAMESPACE ? await loadQueueMetrics(loadConfig()) : {};
|
|
37
|
+
let cost = null;
|
|
38
|
+
if (process.env.AZURE_SUBSCRIPTION_ID) {
|
|
39
|
+
try { cost = await loadAzureCost(loadConfig()); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
|
|
40
|
+
}
|
|
41
|
+
const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
|
|
42
|
+
const stem = await writeHourlyReport(root, dashboard);
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ event: "hourly_report", report: stem })}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
|
|
47
|
+
process.stderr.write(`${error.message}\n`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
package/src/routing.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const ROLES = Object.freeze([
|
|
2
|
+
"scout",
|
|
3
|
+
"planner",
|
|
4
|
+
"builder",
|
|
5
|
+
"tester",
|
|
6
|
+
"debugger",
|
|
7
|
+
"reviewer",
|
|
8
|
+
"security",
|
|
9
|
+
"release",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const MODELS = Object.freeze({
|
|
13
|
+
scout: "azureai-textved/factory-gpt-5-4-nano",
|
|
14
|
+
tester: "azureai-responses/gpt-5.4",
|
|
15
|
+
planner: "azureai-textved/gpt-5.6-sol",
|
|
16
|
+
builder: "azureai-textved/gpt-5.6-sol",
|
|
17
|
+
debugger: "azureai-textved/gpt-5.6-sol",
|
|
18
|
+
reviewer: "azureai-textved/gpt-5.6-sol",
|
|
19
|
+
security: "azureai-textved/gpt-5.6-sol",
|
|
20
|
+
release: "azureai-textved/gpt-5.6-sol",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function modelForRole(role, environment = process.env) {
|
|
24
|
+
const model = environment[`FACTORY_MODEL_${role.toUpperCase()}`] ?? MODELS[role];
|
|
25
|
+
if (!model) throw new Error(`Unknown role: ${role}`);
|
|
26
|
+
return model;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelForTask(task, environment = process.env) {
|
|
30
|
+
if (task.role === "builder" && task.complexity === "simple" && !environment.FACTORY_MODEL_BUILDER) {
|
|
31
|
+
return "azureai-textved/factory-kimi-k2-7-code";
|
|
32
|
+
}
|
|
33
|
+
return modelForRole(task.role, environment);
|
|
34
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { run } from "./process.js";
|
|
3
|
+
|
|
4
|
+
const scanners = [
|
|
5
|
+
{
|
|
6
|
+
name: "trivy",
|
|
7
|
+
image: "aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f",
|
|
8
|
+
args: ["fs", "--scanners", "vuln,secret,misconfig", "--severity", "HIGH,CRITICAL", "--exit-code", "1", "--format", "table", "/workspace"],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: "gitleaks",
|
|
12
|
+
image: "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f",
|
|
13
|
+
args: ["detect", "--source", "/workspace", "--redact", "--no-banner", "--exit-code", "1"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "osv-scanner",
|
|
17
|
+
image: "ghcr.io/google/osv-scanner@sha256:f7ba4be68bac8086b1f88fd598fdca1ca67239c79ad2c2b5c78e03a82e5187c4",
|
|
18
|
+
args: ["scan", "--recursive", "/workspace", "--format", "json"],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "semgrep",
|
|
22
|
+
image: "semgrep/semgrep@sha256:183a149fb3e9700ab5294a7b4ab0241a826fd046bc8b721062fbea80fdfa438f",
|
|
23
|
+
args: ["semgrep", "scan", "--config", "p/default", "--json", "/workspace"],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function redact(value) {
|
|
28
|
+
return String(value)
|
|
29
|
+
.replaceAll(/((?:api[_-]?key|token|secret|password)\s*[=:]\s*)\S+/gi, "$1[REDACTED]")
|
|
30
|
+
.replaceAll(/\b[A-Za-z0-9+/_=-]{48,}\b/g, "[REDACTED]")
|
|
31
|
+
.slice(-20_000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ScannerSuite {
|
|
35
|
+
constructor({ execute = run } = {}) {
|
|
36
|
+
this.execute = execute;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async scan(directory) {
|
|
40
|
+
const workspace = path.resolve(directory);
|
|
41
|
+
return Promise.all(scanners.map(async (scanner) => {
|
|
42
|
+
const args = [
|
|
43
|
+
"run", "--rm", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
44
|
+
"--pids-limit", "512", "--memory", "4g", "--cpus", "1", "--tmpfs", "/tmp:rw,noexec,nosuid,size=3g",
|
|
45
|
+
"--env", "HOME=/tmp",
|
|
46
|
+
"--volume", `${workspace}:/workspace:ro`, scanner.image, ...scanner.args,
|
|
47
|
+
];
|
|
48
|
+
try {
|
|
49
|
+
const result = await this.execute("docker", args, { timeoutMs: 600_000, maxOutputBytes: 4_000_000, allowExitCodes: [0, 1, 2] });
|
|
50
|
+
const output = redact(`${result.stdout}\n${result.stderr}`);
|
|
51
|
+
const executionError = /(?:FATAL|Traceback|read-only file system|no space left on device)/i.test(output);
|
|
52
|
+
return { scanner: scanner.name, status: executionError || result.code === 2 ? "error" : result.code === 1 ? "findings" : "passed", output };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { scanner: scanner.name, status: "error", output: redact(error.message) };
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
2
|
+
import { SecretClient } from "@azure/keyvault-secrets";
|
|
3
|
+
|
|
4
|
+
export async function loadRuntimeSecrets(config, credential = new DefaultAzureCredential(), environmentNames = Object.keys(config.secretNames)) {
|
|
5
|
+
const client = new SecretClient(config.keyVaultUrl, credential);
|
|
6
|
+
const values = {};
|
|
7
|
+
for (const environmentName of environmentNames) {
|
|
8
|
+
const secretName = config.secretNames[environmentName];
|
|
9
|
+
if (!secretName) throw new Error(`Unknown runtime secret: ${environmentName}`);
|
|
10
|
+
let secret;
|
|
11
|
+
try { secret = await client.getSecret(secretName); } catch (error) {
|
|
12
|
+
if (error.statusCode === 404 || error.code === "SecretNotFound") continue;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
if (!secret.value || /[\r\n]/.test(secret.value)) {
|
|
16
|
+
throw new Error(`Key Vault secret ${secretName} is empty or invalid`);
|
|
17
|
+
}
|
|
18
|
+
values[environmentName] = secret.value;
|
|
19
|
+
}
|
|
20
|
+
if (values.GH_TOKEN) values.GITHUB_PERSONAL_ACCESS_TOKEN = values.GH_TOKEN;
|
|
21
|
+
return values;
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
const output = process.argv[2];
|
|
6
|
+
if (!output) throw new Error("Setup result path is required");
|
|
7
|
+
|
|
8
|
+
const provider = await select({
|
|
9
|
+
message: "Which model provider should the factory configure?",
|
|
10
|
+
choices: [
|
|
11
|
+
{ name: "Azure AI Foundry (recommended)", value: "azure" },
|
|
12
|
+
{ name: "AWS Bedrock", value: "bedrock" },
|
|
13
|
+
{ name: "Azure + Bedrock", value: "both" },
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
const location = await input({ message: "Azure infrastructure region", default: "centralindia" });
|
|
17
|
+
const githubOrg = await input({ message: "GitHub Enterprise organization (optional; Enter for personal repos)", default: "" });
|
|
18
|
+
let awsRegion = "us-east-1";
|
|
19
|
+
let bedrockBuilderModel = "";
|
|
20
|
+
if (provider !== "azure") {
|
|
21
|
+
awsRegion = await input({ message: "AWS Bedrock region", default: "us-east-1" });
|
|
22
|
+
bedrockBuilderModel = await input({ message: "Bedrock builder model ID", default: "us.anthropic.claude-sonnet-4-6-v1:0" });
|
|
23
|
+
}
|
|
24
|
+
const deployNow = await confirm({ message: "Deploy and start the runtime after storing credentials?", default: true });
|
|
25
|
+
const telegram = await confirm({ message: "Enable Telegram remote objective intake?", default: false });
|
|
26
|
+
await writeFile(output, `${JSON.stringify({ provider, location, githubOrg, awsRegion, bedrockBuilderModel, deployNow, telegram })}\n`, { mode: 0o600 });
|
package/src/state.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class StateStore {
|
|
5
|
+
#locks = new Map();
|
|
6
|
+
|
|
7
|
+
constructor(root) {
|
|
8
|
+
this.root = root;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
objectiveDir(id) {
|
|
12
|
+
return path.join(this.root, id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async read(id) {
|
|
16
|
+
return JSON.parse(await readFile(path.join(this.objectiveDir(id), "state.json"), "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async write(id, value) {
|
|
20
|
+
const directory = this.objectiveDir(id);
|
|
21
|
+
await mkdir(directory, { recursive: true, mode: 0o750 });
|
|
22
|
+
const temporary = path.join(directory, `state.${process.pid}.${Date.now()}.tmp`);
|
|
23
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
|
|
24
|
+
await rename(temporary, path.join(directory, "state.json"));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async update(id, operation) {
|
|
29
|
+
const previous = this.#locks.get(id) ?? Promise.resolve();
|
|
30
|
+
const current = previous.catch(() => {}).then(async () => {
|
|
31
|
+
const state = await this.read(id);
|
|
32
|
+
return this.write(id, await operation(state));
|
|
33
|
+
});
|
|
34
|
+
this.#locks.set(id, current);
|
|
35
|
+
try {
|
|
36
|
+
return await current;
|
|
37
|
+
} finally {
|
|
38
|
+
if (this.#locks.get(id) === current) this.#locks.delete(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async writeResult(id, result) {
|
|
43
|
+
const directory = this.objectiveDir(id);
|
|
44
|
+
const output = path.join(directory, "result.json");
|
|
45
|
+
const temporary = path.join(directory, `result.${process.pid}.${Date.now()}.tmp`);
|
|
46
|
+
await writeFile(temporary, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o640 });
|
|
47
|
+
await rename(temporary, output);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { AzureAgentRunner } from "./agent-runner.js";
|
|
3
|
+
import { loadRegistry } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
const input = await new Promise((resolve, reject) => {
|
|
6
|
+
let value = "";
|
|
7
|
+
process.stdin.setEncoding("utf8");
|
|
8
|
+
process.stdin.on("data", (chunk) => { value += chunk; });
|
|
9
|
+
process.stdin.on("end", () => resolve(value));
|
|
10
|
+
process.stdin.on("error", reject);
|
|
11
|
+
});
|
|
12
|
+
const packet = JSON.parse(input);
|
|
13
|
+
const registry = await loadRegistry("/opt/agent-factory/app/config/capabilities.json");
|
|
14
|
+
const runner = new AzureAgentRunner({ timeoutMs: 1_800_000 }, registry);
|
|
15
|
+
let result;
|
|
16
|
+
if (packet.mode === "plan") result = await runner.plan(packet.objective, "/workspace");
|
|
17
|
+
else if (packet.mode === "task") result = await runner.invoke({ ...packet, directory: "/workspace" });
|
|
18
|
+
else throw new Error(`Unsupported task mode: ${packet.mode}`);
|
|
19
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function validateGraph(tasks) {
|
|
2
|
+
const ids = new Set(tasks.map((task) => task.id));
|
|
3
|
+
if (ids.size !== tasks.length) throw new Error("Task IDs must be unique");
|
|
4
|
+
|
|
5
|
+
for (const task of tasks) {
|
|
6
|
+
for (const dependency of task.dependsOn) {
|
|
7
|
+
if (!ids.has(dependency)) throw new Error(`Task ${task.id} depends on unknown task ${dependency}`);
|
|
8
|
+
if (dependency === task.id) throw new Error(`Task graph contains a cycle at ${task.id}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const visiting = new Set();
|
|
13
|
+
const visited = new Set();
|
|
14
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
15
|
+
function visit(id) {
|
|
16
|
+
if (visiting.has(id)) throw new Error(`Task graph contains a cycle at ${id}`);
|
|
17
|
+
if (visited.has(id)) return;
|
|
18
|
+
visiting.add(id);
|
|
19
|
+
for (const dependency of byId.get(id).dependsOn) visit(dependency);
|
|
20
|
+
visiting.delete(id);
|
|
21
|
+
visited.add(id);
|
|
22
|
+
}
|
|
23
|
+
for (const task of tasks) visit(task.id);
|
|
24
|
+
return tasks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateDeliveryGraph(tasks) {
|
|
28
|
+
validateGraph(tasks);
|
|
29
|
+
const releaseTasks = tasks.filter((task) => task.role === "release");
|
|
30
|
+
if (releaseTasks.length !== 1) throw new Error("Plan must contain exactly one release task");
|
|
31
|
+
const release = releaseTasks[0];
|
|
32
|
+
if (tasks.some((task) => task.dependsOn.includes(release.id))) {
|
|
33
|
+
throw new Error("Release task must be terminal");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
37
|
+
const ancestors = new Set();
|
|
38
|
+
function collect(id) {
|
|
39
|
+
for (const dependency of byId.get(id).dependsOn) {
|
|
40
|
+
if (!ancestors.has(dependency)) {
|
|
41
|
+
ancestors.add(dependency);
|
|
42
|
+
collect(dependency);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
collect(release.id);
|
|
47
|
+
for (const role of ["tester", "reviewer", "security"]) {
|
|
48
|
+
const gates = tasks.filter((task) => task.role === role);
|
|
49
|
+
if (gates.length === 0) throw new Error(`Plan must contain a ${role} task`);
|
|
50
|
+
if (!gates.some((task) => ancestors.has(task.id))) {
|
|
51
|
+
throw new Error(`Release must have a ${role} ancestor`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return tasks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readyTasks(tasks, results) {
|
|
58
|
+
return tasks
|
|
59
|
+
.filter((task) => !results[task.id])
|
|
60
|
+
.filter((task) => task.dependsOn.every((id) => results[id]?.status === "succeeded"))
|
|
61
|
+
.map((task) => task.id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function applyTaskResult(state, result) {
|
|
65
|
+
if (state.results[result.taskId]) return state;
|
|
66
|
+
return { ...state, results: { ...state.results, [result.taskId]: result } };
|
|
67
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
5
|
+
import { ServiceBusClient } from "@azure/service-bus";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { loadRuntimeSecrets } from "./secrets.js";
|
|
8
|
+
import { sendMessage } from "./bus.js";
|
|
9
|
+
import { loadLocalState, loadQueueMetrics } from "./dashboard.js";
|
|
10
|
+
import { isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
|
|
11
|
+
import { log } from "./log.js";
|
|
12
|
+
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
Object.assign(process.env, await loadRuntimeSecrets(config, undefined, ["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_CHAT_IDS"]));
|
|
15
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
16
|
+
const allowed = new Set((process.env.TELEGRAM_ALLOWED_CHAT_IDS ?? "").split(",").map((value) => value.trim()).filter(Boolean));
|
|
17
|
+
if (!token || allowed.size === 0) {
|
|
18
|
+
log("info", "telegram_disabled", { reason: "missing token or chat allowlist" });
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const directory = path.join(config.stateDir, "telegram");
|
|
23
|
+
const offsetFile = path.join(directory, "offset");
|
|
24
|
+
await mkdir(directory, { recursive: true, mode: 0o750 });
|
|
25
|
+
let offset = 0;
|
|
26
|
+
try { offset = Number(await readFile(offsetFile, "utf8")) || 0; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
27
|
+
|
|
28
|
+
const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
|
|
29
|
+
const sender = client.createSender(config.controlQueue);
|
|
30
|
+
const abort = new AbortController();
|
|
31
|
+
|
|
32
|
+
async function telegram(method, body) {
|
|
33
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: abort.signal,
|
|
38
|
+
});
|
|
39
|
+
const result = await response.json();
|
|
40
|
+
if (!response.ok || !result.ok) throw new Error(`Telegram ${method} failed: ${result.error_code ?? response.status}`);
|
|
41
|
+
return result.result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function reply(chatId, text) {
|
|
45
|
+
await telegram("sendMessage", { chat_id: chatId, text: String(text).slice(0, 4000), disable_web_page_preview: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveOffset(value) {
|
|
49
|
+
const temporary = `${offsetFile}.${process.pid}.tmp`;
|
|
50
|
+
await writeFile(temporary, `${value}\n`, { mode: 0o640 });
|
|
51
|
+
await rename(temporary, offsetFile);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function statusText() {
|
|
55
|
+
const [{ states }, queue] = await Promise.all([loadLocalState(config.stateDir), loadQueueMetrics(config)]);
|
|
56
|
+
const counts = {};
|
|
57
|
+
for (const state of states) counts[state.status ?? "unknown"] = (counts[state.status ?? "unknown"] ?? 0) + 1;
|
|
58
|
+
return [`Factory AI`, `Queue: ${queue.active} active, ${queue.deadLetter} dead-letter`, `Objectives: ${Object.entries(counts).map(([name, count]) => `${name} ${count}`).join(", ") || "none"}`].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function processUpdate(update) {
|
|
62
|
+
const message = update.message;
|
|
63
|
+
if (!message?.text || !isAllowedChat(message.chat?.id, allowed)) return;
|
|
64
|
+
try {
|
|
65
|
+
const command = parseTelegramCommand(message.text);
|
|
66
|
+
if (command.type === "help") return reply(message.chat.id, "/submit OWNER/REPO objective\n/goal OWNER/REPO objective\n/loop OWNER/REPO objective\n/status\n/help");
|
|
67
|
+
if (command.type === "status") return reply(message.chat.id, await statusText());
|
|
68
|
+
const objective = objectiveFromTelegram(update.update_id, command);
|
|
69
|
+
await sendMessage(sender, objective, objective.id);
|
|
70
|
+
await reply(message.chat.id, `Queued ${objective.id}\n${command.repository}\n${command.objective}`);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
await reply(message.chat.id, `Rejected: ${String(error.message ?? error).slice(0, 500)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function shutdown(signal) {
|
|
77
|
+
log("info", "telegram_shutdown", { signal });
|
|
78
|
+
abort.abort();
|
|
79
|
+
await sender.close();
|
|
80
|
+
await client.close();
|
|
81
|
+
}
|
|
82
|
+
process.once("SIGTERM", () => shutdown("SIGTERM").catch(() => {}));
|
|
83
|
+
process.once("SIGINT", () => shutdown("SIGINT").catch(() => {}));
|
|
84
|
+
|
|
85
|
+
log("info", "telegram_started", { allowedChatCount: allowed.size });
|
|
86
|
+
while (!abort.signal.aborted) {
|
|
87
|
+
try {
|
|
88
|
+
const updates = await telegram("getUpdates", { offset, timeout: 25, allowed_updates: ["message"] });
|
|
89
|
+
for (const update of updates) {
|
|
90
|
+
await processUpdate(update);
|
|
91
|
+
offset = update.update_id + 1;
|
|
92
|
+
await saveOffset(offset);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (abort.signal.aborted) break;
|
|
96
|
+
log("error", "telegram_poll_failed", { error: String(error.message ?? error).replaceAll(token, "[REDACTED]").slice(0, 1000) });
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/telegram.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
2
|
+
|
|
3
|
+
export function isAllowedChat(chatId, allowed) {
|
|
4
|
+
return allowed.size > 0 && allowed.has(String(chatId));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseTelegramCommand(input) {
|
|
8
|
+
const text = String(input ?? "").trim();
|
|
9
|
+
const [rawCommand, repository, ...words] = text.split(/\s+/);
|
|
10
|
+
const command = rawCommand?.split("@")[0];
|
|
11
|
+
if (["/status", "/help"].includes(command) && !repository) return { type: command.slice(1) };
|
|
12
|
+
if (!["/submit", "/goal", "/loop"].includes(command)) throw new Error("Unknown command. Use /help");
|
|
13
|
+
if (!repositoryPattern.test(repository ?? "")) throw new Error("Repository must be OWNER/REPO");
|
|
14
|
+
const objective = words.join(" ").trim();
|
|
15
|
+
if (objective.length < 3) throw new Error("Objective is required");
|
|
16
|
+
if (objective.length > 8000) throw new Error("Objective is too long");
|
|
17
|
+
const prefix = command === "/submit" ? "" : `${command} `;
|
|
18
|
+
return { type: "submit", repository, objective: `${prefix}${objective}` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function objectiveFromTelegram(updateId, command, now = new Date()) {
|
|
22
|
+
if (command.type !== "submit" || !Number.isSafeInteger(updateId) || updateId < 0) throw new Error("Invalid Telegram objective input");
|
|
23
|
+
return {
|
|
24
|
+
id: `telegram-${updateId}`,
|
|
25
|
+
type: "objective",
|
|
26
|
+
objective: command.objective,
|
|
27
|
+
repository: `https://github.com/${command.repository}.git`,
|
|
28
|
+
baseBranch: "main",
|
|
29
|
+
createdAt: now.toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|