factory-ai 1.4.0 → 1.5.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/Dockerfile.worker +1 -1
  3. package/README.md +8 -2
  4. package/ROADMAP.md +0 -1
  5. package/bin/factory +118 -1
  6. package/bootstrap/agent-factory-worker.service +2 -2
  7. package/bootstrap/auto-update.sh +20 -12
  8. package/bootstrap/deploy-runtime.sh +5 -3
  9. package/bootstrap/factory-ai-container-firewall.service +14 -0
  10. package/bootstrap/factory-ai-snapshot.service +1 -0
  11. package/bootstrap/factory-ai-watchdog.service +21 -0
  12. package/bootstrap/factory-ai-watchdog.timer +11 -0
  13. package/bootstrap/setup.sh +20 -9
  14. package/cmd/factory-ui/main.go +455 -0
  15. package/go.mod +39 -0
  16. package/go.sum +82 -0
  17. package/package.json +7 -3
  18. package/scripts/test.js +7 -0
  19. package/src/acp-adapter.js +26 -0
  20. package/src/acp-cli.js +16 -0
  21. package/src/activity.js +96 -0
  22. package/src/agent-executor.js +39 -5
  23. package/src/agent-runner.js +30 -10
  24. package/src/approval-policy.js +12 -0
  25. package/src/azure-harness.js +47 -14
  26. package/src/bedrock-harness.js +45 -14
  27. package/src/config.js +9 -0
  28. package/src/container-runner.js +65 -12
  29. package/src/context-compaction.js +16 -0
  30. package/src/control-plane.js +77 -16
  31. package/src/control-service.js +6 -1
  32. package/src/dashboard.js +25 -6
  33. package/src/extension-cli.js +11 -0
  34. package/src/extension-manifest.js +76 -0
  35. package/src/hooks.js +62 -0
  36. package/src/instructions.js +45 -0
  37. package/src/mcp-tools.js +19 -3
  38. package/src/objective-status.js +16 -0
  39. package/src/operator-logs.js +24 -0
  40. package/src/operator.js +31 -9
  41. package/src/process.js +3 -3
  42. package/src/release-gate.js +1 -0
  43. package/src/release-service.js +4 -1
  44. package/src/repo-map.js +128 -0
  45. package/src/reporter.js +11 -0
  46. package/src/retriever.js +18 -18
  47. package/src/routing.js +11 -1
  48. package/src/scanner-suite.js +4 -3
  49. package/src/snapshot.js +16 -0
  50. package/src/task-entry.js +28 -6
  51. package/src/task-graph.js +0 -5
  52. package/src/telegram-service.js +7 -1
  53. package/src/telegram.js +5 -1
  54. package/src/telemetry.js +173 -0
  55. package/src/tui.js +27 -8
  56. package/src/validation.js +41 -9
  57. package/src/watchdog.js +62 -0
  58. package/src/worker.js +32 -3
  59. package/src/workspace-tools.js +13 -3
  60. package/src/workspace.js +22 -2
  61. package/templates/AGENTS.md +15 -0
package/src/mcp-tools.js CHANGED
@@ -3,6 +3,16 @@ function renderContent(result) {
3
3
  return value.length > 32_000 ? `${value.slice(0, 32_000)}\n[TRUNCATED: request narrower MCP output]` : value;
4
4
  }
5
5
 
6
+ async function withTimeout(operation, timeoutMs, label) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ operation,
11
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); timer.unref?.(); }),
12
+ ]);
13
+ } finally { clearTimeout(timer); }
14
+ }
15
+
6
16
  async function defaultConnect(capability) {
7
17
  const [{ Client }, { StdioClientTransport }] = await Promise.all([
8
18
  import("@modelcontextprotocol/sdk/client/index.js"),
@@ -29,17 +39,23 @@ export async function connectMcpTools(capabilities, { connect = defaultConnect }
29
39
  const clients = [];
30
40
  const tools = {};
31
41
  try {
32
- for (const capability of capabilities.filter((item) => item.type === "mcp")) {
42
+ const mcp = capabilities.filter((item) => item.type === "mcp");
43
+ const connected = await Promise.allSettled(mcp.map(async (capability) => {
33
44
  const client = await connect(capability);
34
- clients.push(client);
35
45
  const listed = await client.listTools();
46
+ return { capability, client, listed };
47
+ }));
48
+ for (const result of connected) if (result.status === "fulfilled") clients.push(result.value.client);
49
+ const failure = connected.find((result) => result.status === "rejected");
50
+ if (failure) throw failure.reason;
51
+ for (const { capability, client, listed } of connected.map((result) => result.value)) {
36
52
  for (const definition of listed.tools) {
37
53
  const name = `${capability.name}__${definition.name}`;
38
54
  if (tools[name]) throw new Error(`Duplicate MCP tool: ${name}`);
39
55
  tools[name] = {
40
56
  description: definition.description ?? `${capability.name} ${definition.name}`,
41
57
  parameters: definition.inputSchema ?? { type: "object" },
42
- execute: async (argumentsValue) => renderContent(await client.callTool({ name: definition.name, arguments: argumentsValue })),
58
+ execute: async (argumentsValue) => renderContent(await withTimeout(client.callTool({ name: definition.name, arguments: argumentsValue }), capability.timeoutMs ?? 60_000, `MCP tool ${name}`)),
43
59
  };
44
60
  }
45
61
  }
@@ -0,0 +1,16 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const IDENTIFIER = /^[A-Za-z0-9_-]{1,64}$/;
5
+ const TERMINAL = new Set(["complete", "failed", "blocked", "cancelled", "denied", "expired"]);
6
+
7
+ export async function objectiveIsTerminal(stateDir, objectiveId) {
8
+ if (!IDENTIFIER.test(objectiveId ?? "")) return false;
9
+ try {
10
+ const state = JSON.parse(await readFile(path.join(stateDir, objectiveId, "state.json"), "utf8"));
11
+ return TERMINAL.has(state.status);
12
+ } catch (error) {
13
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return false;
14
+ throw error;
15
+ }
16
+ }
@@ -0,0 +1,24 @@
1
+ const ALLOWED_FIELDS = new Set(["timestamp", "level", "event", "objectiveId", "taskId", "type", "messageId", "source", "deliveryCount", "error", "signal", "queue", "concurrency"]);
2
+
3
+ function redact(value) {
4
+ return String(value)
5
+ .replaceAll(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16}|xox[a-z]-[A-Za-z0-9-]{10,})\b/g, "[REDACTED]")
6
+ .replaceAll(/\bbot\d+:[A-Za-z0-9_-]{20,}\b/g, "bot[REDACTED]")
7
+ .replaceAll(/((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)\S+/gi, "$1[REDACTED]")
8
+ .slice(0, 2000);
9
+ }
10
+
11
+ export function safeOperatorLogs(raw, { maxCharacters = 250_000 } = {}) {
12
+ const output = [];
13
+ for (const line of String(raw).split("\n")) {
14
+ let parsed;
15
+ try { parsed = JSON.parse(line); } catch { continue; }
16
+ if (!parsed || typeof parsed !== "object" || typeof parsed.event !== "string") continue;
17
+ const safe = {};
18
+ for (const [key, value] of Object.entries(parsed)) {
19
+ if (ALLOWED_FIELDS.has(key) && ["string", "number", "boolean"].includes(typeof value)) safe[key] = typeof value === "string" ? redact(value) : value;
20
+ }
21
+ output.push(JSON.stringify(safe));
22
+ }
23
+ return output.join("\n").slice(-maxCharacters);
24
+ }
package/src/operator.js CHANGED
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
4
4
  import { gunzipSync } from "node:zlib";
5
5
  import { DefaultAzureCredential } from "@azure/identity";
6
6
  import { BlobServiceClient } from "@azure/storage-blob";
7
+ import { ServiceBusClient } from "@azure/service-bus";
8
+ import { sendMessage } from "./bus.js";
7
9
  import { run } from "./process.js";
8
10
 
9
11
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -36,6 +38,16 @@ export function createOperator(environment = process.env) {
36
38
  }
37
39
  throw new Error("Azure Run Command remained busy");
38
40
  };
41
+ const downloadOperatorBlob = async (name) => {
42
+ if (!storageAccount) return null;
43
+ try {
44
+ const service = new BlobServiceClient(`https://${storageAccount}.blob.core.windows.net`, new DefaultAzureCredential());
45
+ return await service.getContainerClient("operator").getBlockBlobClient(name).downloadToBuffer();
46
+ } catch (error) {
47
+ if (["BlobNotFound", "ContainerNotFound"].includes(error.code)) return null;
48
+ throw error;
49
+ }
50
+ };
39
51
  const withVault = async (operation) => {
40
52
  const ip = await command("curl", ["-fsS", "https://api.ipify.org"]);
41
53
  await command("az", ["keyvault", "network-rule", "add", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]);
@@ -47,18 +59,16 @@ export function createOperator(environment = process.env) {
47
59
  return {
48
60
  dashboard: async () => {
49
61
  if (storageAccount) {
50
- try {
51
- const service = new BlobServiceClient(`https://${storageAccount}.blob.core.windows.net`, new DefaultAzureCredential());
52
- const value = await service.getContainerClient("operator").getBlockBlobClient("dashboard.json").downloadToBuffer();
53
- return JSON.parse(value.toString("utf8"));
54
- } catch (error) {
55
- if (!["BlobNotFound", "ContainerNotFound"].includes(error.code)) throw error;
56
- }
62
+ const value = await downloadOperatorBlob("dashboard.json");
63
+ if (value) return JSON.parse(value.toString("utf8"));
57
64
  }
58
65
  const encoded = await remote("sudo -u factory env $(xargs < /etc/agent-factory-control.env) node /opt/agent-factory/app/src/dashboard.js --json | gzip -c | base64 -w0");
59
66
  return JSON.parse(gunzipSync(Buffer.from(encoded, "base64")).toString("utf8"));
60
67
  },
61
- logs: async () => remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300'),
68
+ logs: async () => {
69
+ const value = await downloadOperatorBlob("logs.txt");
70
+ return value ? value.toString("utf8") : remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300');
71
+ },
62
72
  submit: async (repository, objective) => {
63
73
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || objective.trim().length < 3) throw new Error("Valid repository and objective are required");
64
74
  const output = await command(path.join(root, "bin/factory"), ["submit", repository, objective]);
@@ -69,9 +79,21 @@ export function createOperator(environment = process.env) {
69
79
  if (!["pause", "resume"].includes(action)) throw new Error("Unsupported control action");
70
80
  return command(path.join(root, "bin/factory"), [action]);
71
81
  },
82
+ approval: async ({ objectiveId, approvalId, decision, reason }) => {
83
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(objectiveId) || !/^[A-Za-z0-9_-]{1,64}$/.test(approvalId) || !["approved", "denied"].includes(decision) || !reason?.trim()) throw new Error("Valid approval decision is required");
84
+ const client = new ServiceBusClient(namespace.includes(".") ? namespace : `${namespace}.servicebus.windows.net`, new DefaultAzureCredential());
85
+ const sender = client.createSender("control-events");
86
+ const messageId = `approval-${objectiveId}-${approvalId}-${decision}`.slice(0, 64);
87
+ try { await sendMessage(sender, { type: "approval_decision", objectiveId, approvalId, decision, actor: "local-operator", reason: reason.trim().slice(0, 1000), decidedAt: new Date().toISOString(), messageId }, messageId, objectiveId); }
88
+ finally { await sender.close(); await client.close(); }
89
+ },
72
90
  capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
73
91
  config: () => ({ factoryName, factoryPurpose, resourceGroup, vm, namespace, vault, storageAccount, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
74
- listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
92
+ listSecrets: async () => {
93
+ const dashboard = await downloadOperatorBlob("dashboard.json");
94
+ if (dashboard) return JSON.parse(dashboard.toString("utf8")).secrets ?? [];
95
+ return withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"])));
96
+ },
75
97
  setSecret: async (name, value) => withVault(async () => {
76
98
  if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
77
99
  await command("az", ["keyvault", "secret", "set", "--vault-name", vault, "--name", name, "--value", value, "--output", "none"]);
package/src/process.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  export function run(command, args, options = {}) {
4
- const { cwd, env, inheritEnv = true, timeoutMs = 300_000, input, allowExitCodes = [0], maxOutputBytes = 10_000_000 } = options;
4
+ const { cwd, env, inheritEnv = true, timeoutMs = 300_000, input, allowExitCodes = [0], maxOutputBytes = 10_000_000, onStdout, onStderr } = options;
5
5
  return new Promise((resolve, reject) => {
6
6
  const child = spawn(command, args, {
7
7
  cwd,
@@ -27,8 +27,8 @@ export function run(command, args, options = {}) {
27
27
  if (stream === "stdout") stdout += chunk;
28
28
  else stderr += chunk;
29
29
  }
30
- child.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
31
- child.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
30
+ child.stdout.on("data", (chunk) => { appendOutput("stdout", chunk); onStdout?.(chunk); });
31
+ child.stderr.on("data", (chunk) => { appendOutput("stderr", chunk); onStderr?.(chunk); });
32
32
  child.on("error", (error) => {
33
33
  clearTimeout(timer);
34
34
  reject(error);
@@ -5,6 +5,7 @@ export function evaluateReleaseGate(tasks, results, facts = {}) {
5
5
  for (const task of tasks.filter((candidate) => APPROVAL_ROLES.has(candidate.role))) {
6
6
  const result = results[task.id];
7
7
  if (result?.status !== "succeeded" || result.approval !== "approved") blockers.push(`${task.role} ${task.id}: ${result?.approval ?? result?.status ?? "missing"}`);
8
+ for (const scan of result?.scannerEvidence ?? []) if (scan.status !== "passed") blockers.push(`${task.role} ${task.id}: ${scan.scanner} ${scan.status}`);
8
9
  }
9
10
  const approvals = facts.approvals ?? blockers.length === 0;
10
11
  return { approved: approvals, blockers, autoMerge: approvals && facts.policyAllows === true && facts.checksPass === true };
@@ -24,7 +24,10 @@ const subscription = bus.receiver.subscribe({
24
24
  await bus.receiver.completeMessage(message);
25
25
  } catch (error) {
26
26
  log("error", "release_failed", { deliveryCount: message.deliveryCount, error: error.message });
27
- if (message.deliveryCount >= config.maxDeliveryCount) await bus.receiver.deadLetterMessage(message, { deadLetterReason: "ReleaseFailed", deadLetterErrorDescription: error.message.slice(0, 4096) });
27
+ if (message.deliveryCount >= config.maxDeliveryCount) {
28
+ if (message.body?.objectiveId) await sendMessage(bus.sender, { type: "failure_result", objectiveId: message.body.objectiveId, taskId: "release", error: `Release failed after ${message.deliveryCount} deliveries: ${error.message}` }, `${message.body.objectiveId}:release-failure:v1`, message.body.objectiveId);
29
+ await bus.receiver.deadLetterMessage(message, { deadLetterReason: "ReleaseFailed", deadLetterErrorDescription: error.message.slice(0, 4096) });
30
+ }
28
31
  else await bus.receiver.abandonMessage(message);
29
32
  }
30
33
  },
@@ -0,0 +1,128 @@
1
+ import { run } from "./process.js";
2
+
3
+ const sourceGlob = "*.{c,cc,cpp,cs,go,h,hpp,java,js,jsx,php,py,rb,rs,sh,ts,tsx}";
4
+ const excludedGlobs = [
5
+ "!**/.git/**",
6
+ "!**/.next/**",
7
+ "!**/.turbo/**",
8
+ "!**/build/**",
9
+ "!**/coverage/**",
10
+ "!**/dist/**",
11
+ "!**/generated/**",
12
+ "!**/node_modules/**",
13
+ "!**/vendor/**",
14
+ "!**/*.generated.*",
15
+ "!**/*.min.*",
16
+ ];
17
+ const symbolPattern = String.raw`^\s*(?:(?:export|public|private|protected|static)\s+)*(?:async\s+)?(?:class|def|enum|fn|func|function|interface|struct|trait|type|const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*`;
18
+ const stopWords = new Set(["and", "for", "from", "into", "the", "this", "with"]);
19
+
20
+ function objectiveTerms(objective) {
21
+ return [...new Set(String(objective).toLowerCase().match(/[a-z_$][a-z0-9_$-]{2,}/g) ?? [])]
22
+ .filter((term) => !stopWords.has(term))
23
+ .sort();
24
+ }
25
+
26
+ function rgArgs(pattern) {
27
+ return [
28
+ "--line-number", "--no-heading", "--color", "never", "--max-count", "20", "--max-filesize", "256K",
29
+ "--glob", sourceGlob,
30
+ ...excludedGlobs.flatMap((glob) => ["--glob", glob]),
31
+ "--regexp", pattern,
32
+ ".",
33
+ ];
34
+ }
35
+
36
+ async function extract(directory, pattern) {
37
+ if (!pattern) return [];
38
+ const { stdout } = await run("rg", rgArgs(pattern), {
39
+ cwd: directory,
40
+ allowExitCodes: [0, 1],
41
+ timeoutMs: 15_000,
42
+ maxOutputBytes: 5_000_000,
43
+ });
44
+ return stdout.split("\n").flatMap((line) => {
45
+ const match = line.match(/^\.\/(.*?):(\d+):(.*)$/);
46
+ if (!match) return [];
47
+ return [{ path: match[1], startLine: Number(match[2]), endLine: Number(match[2]), content: match[3].trim().slice(0, 500) }];
48
+ });
49
+ }
50
+
51
+ function keyFor(entry) {
52
+ return `${entry.path}:${entry.startLine}:${entry.endLine}`;
53
+ }
54
+
55
+ function rank(entries, terms) {
56
+ const byRange = new Map();
57
+ for (const entry of entries) {
58
+ const path = entry.path.toLowerCase();
59
+ const content = entry.content.toLowerCase();
60
+ const score = 1 + terms.reduce((total, term) => total + (path.includes(term) ? 8 : 0) + (content.includes(term) ? 3 : 0), 0);
61
+ const ranked = { ...entry, score };
62
+ const previous = byRange.get(keyFor(ranked));
63
+ if (!previous || ranked.score > previous.score || (ranked.score === previous.score && ranked.content < previous.content)) byRange.set(keyFor(ranked), ranked);
64
+ }
65
+ return [...byRange.values()].sort((a, b) => b.score - a.score
66
+ || a.path.localeCompare(b.path)
67
+ || a.startLine - b.startLine
68
+ || a.endLine - b.endLine
69
+ || a.content.localeCompare(b.content));
70
+ }
71
+
72
+ function entriesWithinBudget(entries, maxCharacters, heading) {
73
+ let length = `${heading}\n`.length;
74
+ const selected = [];
75
+ for (const entry of entries) {
76
+ const section = `--- ${entry.path}:${entry.startLine}-${entry.endLine} score=${Number(entry.score ?? 0).toFixed(3)} ---\n${entry.content}\n`;
77
+ if (length + section.length > maxCharacters) continue;
78
+ selected.push(entry);
79
+ length += section.length;
80
+ }
81
+ return selected;
82
+ }
83
+
84
+ export function formatRepositoryEntries(entries, maxCharacters, heading = "REPOSITORY MAP (ranked symbols and objective references)") {
85
+ if (maxCharacters <= 0) return "";
86
+ let output = `${heading}\n`;
87
+ for (const entry of entriesWithinBudget(entries, maxCharacters, heading)) {
88
+ output += `--- ${entry.path}:${entry.startLine}-${entry.endLine} score=${Number(entry.score ?? 0).toFixed(3)} ---\n${entry.content}\n`;
89
+ }
90
+ return output.length <= maxCharacters ? output.trimEnd() : output.slice(0, maxCharacters);
91
+ }
92
+
93
+ export async function buildRepositoryMap(directory, objective, { maxCharacters = 8000 } = {}) {
94
+ const terms = objectiveTerms(objective);
95
+ const referencePattern = terms.length ? terms.map((term) => term.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") : "";
96
+ const [symbols, references] = await Promise.all([
97
+ extract(directory, symbolPattern),
98
+ extract(directory, referencePattern),
99
+ ]);
100
+ const ranked = rank([...symbols, ...references], terms);
101
+ const heading = "REPOSITORY MAP (ranked symbols and objective references)";
102
+ const entries = entriesWithinBudget(ranked, maxCharacters, heading);
103
+ return { entries, text: formatRepositoryEntries(entries, maxCharacters) };
104
+ }
105
+
106
+ export function mergeRepositoryContext(repositoryEntries, semanticPoints, maxCharacters = 12_000) {
107
+ const occupied = new Set(repositoryEntries.map(keyFor));
108
+ const byRange = new Map();
109
+ for (const point of semanticPoints) {
110
+ const entry = {
111
+ path: point.payload?.path ?? "",
112
+ startLine: Number(point.payload?.startLine ?? 0),
113
+ endLine: Number(point.payload?.endLine ?? point.payload?.startLine ?? 0),
114
+ content: String(point.payload?.content ?? ""),
115
+ score: Number(point.score ?? 0),
116
+ };
117
+ const key = keyFor(entry);
118
+ const previous = byRange.get(key);
119
+ if (entry.path && !occupied.has(key) && (!previous || entry.score > previous.score || (entry.score === previous.score && entry.content < previous.content))) byRange.set(key, entry);
120
+ }
121
+ const semanticEntries = [...byRange.values()].sort((a, b) => b.score - a.score
122
+ || a.path.localeCompare(b.path)
123
+ || a.startLine - b.startLine
124
+ || a.endLine - b.endLine
125
+ || a.content.localeCompare(b.content));
126
+ const semanticText = formatRepositoryEntries(semanticEntries, maxCharacters, "LOCAL SEMANTIC CONTEXT");
127
+ return { entries: [...repositoryEntries, ...semanticEntries], semanticText };
128
+ }
package/src/reporter.js CHANGED
@@ -47,6 +47,17 @@ export async function uploadDashboardSnapshot(config, dashboard, {
47
47
  return true;
48
48
  }
49
49
 
50
+ export async function uploadOperatorBlob(config, name, value, contentType = "text/plain; charset=utf-8", {
51
+ credential = new DefaultAzureCredential(),
52
+ createClient = (url, auth) => new BlobServiceClient(url, auth),
53
+ } = {}) {
54
+ if (!config.storageAccount) return false;
55
+ const service = createClient(`https://${config.storageAccount}.blob.core.windows.net`, credential);
56
+ const blob = service.getContainerClient("operator").getBlockBlobClient(name);
57
+ await blob.uploadData(Buffer.from(value), { blobHTTPHeaders: { blobContentType: contentType, blobCacheControl: "no-store" } });
58
+ return true;
59
+ }
60
+
50
61
  async function main() {
51
62
  const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
52
63
  const config = loadConfig();
package/src/retriever.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { run } from "./process.js";
5
+ import { mergeRepositoryContext } from "./repo-map.js";
5
6
 
6
7
  const textExtensions = new Set([".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx", ".json", ".md", ".mdx", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql", ".svelte", ".toml", ".ts", ".tsx", ".vue", ".yaml", ".yml"]);
7
8
  const ignoredDirectories = new Set([".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", "vendor"]);
@@ -18,15 +19,8 @@ export function chunkText(file, value, { linesPerChunk = 120, overlapLines = 20
18
19
  return chunks;
19
20
  }
20
21
 
21
- export function formatRetrievedContext(points, maxCharacters = 12_000) {
22
- let output = "";
23
- for (const point of points) {
24
- const payload = point.payload ?? {};
25
- const section = `\n--- ${payload.path}:${payload.startLine} score=${Number(point.score ?? 0).toFixed(3)} ---\n${payload.content ?? ""}\n`;
26
- if (output.length + section.length > maxCharacters) break;
27
- output += section;
28
- }
29
- return output.trim();
22
+ export function formatRetrievedContext(points, maxCharacters = 12_000, repositoryEntries = []) {
23
+ return mergeRepositoryContext(repositoryEntries, points, maxCharacters).semanticText;
30
24
  }
31
25
 
32
26
  async function collectFiles(directory, root, output, limits) {
@@ -42,6 +36,14 @@ async function collectFiles(directory, root, output, limits) {
42
36
  }
43
37
  }
44
38
 
39
+ async function workspaceRevision(directory) {
40
+ const head = (await run("git", ["-C", directory, "rev-parse", "HEAD"])).stdout.trim();
41
+ const status = (await run("git", ["-C", directory, "status", "--porcelain=v1"], { maxOutputBytes: 200_000 })).stdout;
42
+ if (!status) return head;
43
+ const diff = (await run("git", ["-C", directory, "diff", "--binary", "HEAD"], { maxOutputBytes: 2_000_000 })).stdout;
44
+ return `${head}-worktree-${createHash("sha256").update(status).update(diff).digest("hex").slice(0, 16)}`;
45
+ }
46
+
45
47
  function pointId(value) {
46
48
  const hash = createHash("sha256").update(value).digest("hex");
47
49
  return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
@@ -101,9 +103,9 @@ export class LocalRetriever {
101
103
  }
102
104
 
103
105
  async #index(directory, repository) {
104
- const commit = (await run("git", ["-C", directory, "rev-parse", "HEAD"])).stdout.trim();
106
+ const commit = await workspaceRevision(directory);
105
107
  const manifest = await this.loadManifest();
106
- if (manifest[repository]?.commit === commit && manifest[repository]?.model === this.model) return false;
108
+ if (manifest[repository]?.[commit]?.model === this.model) return false;
107
109
  const files = [];
108
110
  await collectFiles(directory, directory, files, { maxFiles: 2000, maxFileBytes: 256_000 });
109
111
  const chunks = [];
@@ -122,9 +124,6 @@ export class LocalRetriever {
122
124
  method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ vectors: { size: vectorSize, distance: "Cosine" } }),
123
125
  });
124
126
  } else if (!collectionResponse.ok) throw new Error(`Qdrant collection HTTP ${collectionResponse.status}`);
125
- await this.request(`${this.qdrantUrl}/collections/${this.collection}/points/delete?wait=true`, {
126
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filter: { must: [{ key: "repository", match: { value: repository } }] } }),
127
- });
128
127
  for (let start = 0; start < selected.length; start += 32) {
129
128
  const batch = selected.slice(start, start + 32);
130
129
  const remaining = batch.slice(1);
@@ -136,19 +135,20 @@ export class LocalRetriever {
136
135
  method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ points }),
137
136
  });
138
137
  }
139
- manifest[repository] = { commit, model: this.model, chunks: selected.length, indexedAt: new Date().toISOString() };
138
+ manifest[repository] = { ...(manifest[repository]?.commit ? {} : manifest[repository]), [commit]: { model: this.model, chunks: selected.length, indexedAt: new Date().toISOString() } };
140
139
  await this.saveManifest(manifest);
141
140
  return true;
142
141
  }
143
142
 
144
- async context(directory, repository, query, { limit = 8, maxCharacters = 12_000 } = {}) {
143
+ async context(directory, repository, query, { limit = 8, maxCharacters = 12_000, repositoryEntries = [] } = {}) {
145
144
  await this.ensureIndexed(directory, repository);
145
+ const commit = await workspaceRevision(directory);
146
146
  const [vector] = await this.embed([query]);
147
147
  const result = await this.request(`${this.qdrantUrl}/collections/${this.collection}/points/query`, {
148
148
  method: "POST",
149
149
  headers: { "content-type": "application/json" },
150
- body: JSON.stringify({ query: vector, filter: { must: [{ key: "repository", match: { value: repository } }] }, limit, with_payload: true }),
150
+ body: JSON.stringify({ query: vector, filter: { must: [{ key: "repository", match: { value: repository } }, { key: "commit", match: { value: commit } }] }, limit, with_payload: true }),
151
151
  });
152
- return formatRetrievedContext(result.result?.points ?? [], maxCharacters);
152
+ return formatRetrievedContext(result.result?.points ?? [], maxCharacters, repositoryEntries);
153
153
  }
154
154
  }
package/src/routing.js CHANGED
@@ -20,10 +20,20 @@ const MODELS = Object.freeze({
20
20
  release: "azureai-textved/gpt-5.6-sol",
21
21
  });
22
22
 
23
+ const PROVIDERS = new Set(["azureai-textved", "azureai-responses", "bedrock"]);
24
+
25
+ export function validateModelRoute(route) {
26
+ if (typeof route !== "string" || route.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(route)) throw new Error("Invalid model route");
27
+ const separator = route.indexOf("/");
28
+ if (separator < 1 || !PROVIDERS.has(route.slice(0, separator))) throw new Error("Unsupported model provider");
29
+ if (separator === route.length - 1) throw new Error("Invalid model route");
30
+ return route;
31
+ }
32
+
23
33
  export function modelForRole(role, environment = process.env) {
24
34
  const model = environment[`FACTORY_MODEL_${role.toUpperCase()}`] ?? MODELS[role];
25
35
  if (!model) throw new Error(`Unknown role: ${role}`);
26
- return model;
36
+ return validateModelRoute(model);
27
37
  }
28
38
 
29
39
  export function modelForTask(task, environment = process.env) {
@@ -20,7 +20,7 @@ const scanners = [
20
20
  {
21
21
  name: "semgrep",
22
22
  image: "semgrep/semgrep@sha256:183a149fb3e9700ab5294a7b4ab0241a826fd046bc8b721062fbea80fdfa438f",
23
- args: ["semgrep", "scan", "--config", "p/default", "--json", "/workspace"],
23
+ args: ["semgrep", "scan", "--error", "--config", "p/default", "--json", "/workspace"],
24
24
  },
25
25
  ];
26
26
 
@@ -37,9 +37,10 @@ export class ScannerSuite {
37
37
  this.execute = execute;
38
38
  }
39
39
 
40
- async scan(directory) {
40
+ async scan(directory, { names } = {}) {
41
41
  const workspace = path.resolve(directory);
42
- return Promise.all(scanners.map(async (scanner) => {
42
+ const selected = names ? scanners.filter((scanner) => names.includes(scanner.name)) : scanners;
43
+ return Promise.all(selected.map(async (scanner) => {
43
44
  const args = [
44
45
  "run", "--rm", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
45
46
  "--pids-limit", "512", "--memory", "4g", "--cpus", "1", "--tmpfs", "/tmp:rw,noexec,nosuid,size=3g",
package/src/snapshot.js CHANGED
@@ -2,6 +2,11 @@
2
2
  import { aggregateDashboard, loadAzureCost, loadLocalState, loadQueueMetrics } from "./dashboard.js";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { uploadDashboardSnapshot } from "./reporter.js";
5
+ import { uploadOperatorBlob } from "./reporter.js";
6
+ import { DefaultAzureCredential } from "@azure/identity";
7
+ import { SecretClient } from "@azure/keyvault-secrets";
8
+ import { run } from "./process.js";
9
+ import { safeOperatorLogs } from "./operator-logs.js";
5
10
 
6
11
  process.title = "factory-ai-snapshot";
7
12
  const config = loadConfig();
@@ -11,5 +16,16 @@ const queue = await loadQueueMetrics(config);
11
16
  let cost = null;
12
17
  try { cost = await loadAzureCost(config); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
13
18
  const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
19
+ try {
20
+ const secrets = [];
21
+ const client = new SecretClient(config.keyVaultUrl, new DefaultAzureCredential());
22
+ for await (const item of client.listPropertiesOfSecrets()) secrets.push({ name: item.name, updated: item.updatedOn?.toISOString() });
23
+ dashboard.secrets = secrets.sort((left, right) => left.name.localeCompare(right.name));
24
+ } catch (error) { dashboard.warnings.push(`Secret metadata unavailable: ${error.message}`); }
25
+ try {
26
+ const result = await run("journalctl", ["-o", "cat", "-u", "agent-factory-control", "-u", "agent-factory-worker", "-u", "agent-factory-release", "-u", "agent-factory-telegram", "--since", "1 hour ago", "--no-pager", "-n", "500"], { timeoutMs: 30_000, maxOutputBytes: 500_000 });
27
+ const logs = safeOperatorLogs(result.stdout);
28
+ await uploadOperatorBlob(config, "logs.txt", logs);
29
+ } catch (error) { dashboard.warnings.push(`Log snapshot unavailable: ${error.message}`); }
14
30
  await uploadDashboardSnapshot(config, dashboard);
15
31
  process.stdout.write(`${JSON.stringify({ event: "dashboard_snapshot", generatedAt: dashboard.generatedAt })}\n`);
package/src/task-entry.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { AzureAgentRunner } from "./agent-runner.js";
3
3
  import { loadRegistry } from "./registry.js";
4
+ import { createTelemetry } from "./telemetry.js";
4
5
 
5
6
  const input = await new Promise((resolve, reject) => {
6
7
  let value = "";
@@ -10,11 +11,32 @@ const input = await new Promise((resolve, reject) => {
10
11
  process.stdin.on("error", reject);
11
12
  });
12
13
  const packet = JSON.parse(input);
14
+ const runtimeEnvironment = packet.runtimeEnvironment ?? {};
15
+ delete packet.runtimeEnvironment;
13
16
  process.title = `factory-ai-${packet.task?.role ?? packet.mode}`.slice(0, 63);
14
17
  const registry = await loadRegistry("/opt/agent-factory/app/config/capabilities.json");
15
- const runner = new AzureAgentRunner({ timeoutMs: 1_800_000 }, registry);
16
- let result;
17
- if (packet.mode === "plan") result = await runner.plan(packet.objective, "/workspace");
18
- else if (packet.mode === "task") result = await runner.invoke({ ...packet, directory: "/workspace" });
19
- else throw new Error(`Unsupported task mode: ${packet.mode}`);
20
- process.stdout.write(`${JSON.stringify(result)}\n`);
18
+ let currentPhase = "starting";
19
+ const telemetry = createTelemetry({ exporter: async (record) => process.stderr.write(`@factory-event ${JSON.stringify({ type: "telemetry.recorded", telemetry: record, occurredAt: record.timestamp })}\n`) });
20
+ const eventSink = (event) => {
21
+ if (event.type !== "agent.heartbeat") currentPhase = event.tool ? `${event.type}:${event.tool}` : event.type;
22
+ process.stderr.write(`@factory-event ${JSON.stringify({ ...event, phase: currentPhase, occurredAt: new Date().toISOString() })}\n`);
23
+ const kind = event.type?.startsWith("model.") ? "model" : event.type?.startsWith("tool.") ? "tool" : null;
24
+ if (kind) void telemetry.emitEvent(kind, { objectiveId: packet.objective.id, taskId: packet.task?.id ?? "planner0", role: packet.task?.role ?? packet.mode }, { statusClass: event.type.endsWith("failed") ? "error" : event.type.endsWith("retry") ? "retry" : "ok", attempt: event.attempt ?? event.step ?? 0, inputTokens: event.usage?.input_tokens ?? event.usage?.inputTokens, outputTokens: event.usage?.output_tokens ?? event.usage?.outputTokens });
25
+ };
26
+ const runner = new AzureAgentRunner({ timeoutMs: 1_800_000 }, registry, { eventSink, environment: { ...process.env, ...runtimeEnvironment } });
27
+ const heartbeat = setInterval(() => eventSink({ type: "agent.heartbeat", role: packet.task?.role ?? packet.mode, phase: currentPhase }), 15_000);
28
+ heartbeat.unref();
29
+ try {
30
+ eventSink({ type: "agent.started", role: packet.task?.role ?? packet.mode });
31
+ let result;
32
+ if (packet.mode === "plan") result = await runner.plan(packet.objective, "/workspace", packet.context ?? []);
33
+ else if (packet.mode === "task") result = await runner.invoke({ ...packet, directory: "/workspace" });
34
+ else throw new Error(`Unsupported task mode: ${packet.mode}`);
35
+ eventSink({ type: "agent.completed", role: packet.task?.role ?? packet.mode });
36
+ process.stdout.write(`${JSON.stringify(result)}\n`);
37
+ } catch (error) {
38
+ eventSink({ type: "agent.failed", role: packet.task?.role ?? packet.mode, error: String(error.message ?? error).slice(0, 500) });
39
+ throw error;
40
+ } finally {
41
+ clearInterval(heartbeat);
42
+ }
package/src/task-graph.js CHANGED
@@ -60,8 +60,3 @@ export function readyTasks(tasks, results) {
60
60
  .filter((task) => task.dependsOn.every((id) => results[id]?.status === "succeeded"))
61
61
  .map((task) => task.id);
62
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
- }
@@ -10,6 +10,7 @@ import { sendMessage } from "./bus.js";
10
10
  import { loadLocalState, loadQueueMetrics } from "./dashboard.js";
11
11
  import { formatObjectiveProgress, isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
12
12
  import { log } from "./log.js";
13
+ import { ActivityStore } from "./activity.js";
13
14
 
14
15
  process.title = "factory-ai-telegram";
15
16
  const config = loadConfig();
@@ -37,6 +38,7 @@ const subscriptions = await loadJson(subscriptionsFile);
37
38
  const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
38
39
  const sender = client.createSender(config.controlQueue);
39
40
  const abort = new AbortController();
41
+ const activityStore = new ActivityStore(config.stateDir);
40
42
 
41
43
  async function telegram(method, body) {
42
44
  const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
@@ -93,6 +95,7 @@ async function processUpdate(update) {
93
95
  }
94
96
  if (command.type === "objective") {
95
97
  const state = JSON.parse(await readFile(path.join(config.stateDir, command.objectiveId, "state.json"), "utf8"));
98
+ state.activity = await activityStore.latestObjective(command.objectiveId);
96
99
  return reply(message.chat.id, formatObjectiveProgress(state, config.factoryName));
97
100
  }
98
101
  const objective = objectiveFromTelegram(update.update_id, command);
@@ -107,14 +110,17 @@ async function processUpdate(update) {
107
110
 
108
111
  async function notifyProgress() {
109
112
  let changed = false;
113
+ const terminal = new Set(["complete", "failed", "blocked", "denied", "expired", "cancelled"]);
110
114
  for (const [objectiveId, subscription] of Object.entries(subscriptions)) {
111
115
  let state;
112
116
  try { state = JSON.parse(await readFile(path.join(config.stateDir, objectiveId, "state.json"), "utf8")); }
113
117
  catch (error) { if (error.code === "ENOENT") continue; throw error; }
118
+ state.activity = await activityStore.latestObjective(objectiveId);
114
119
  const text = formatObjectiveProgress(state, config.factoryName);
115
120
  const digest = createHash("sha256").update(text).digest("hex");
121
+ if (digest !== subscription.lastDigest) await reply(subscription.chatId, text);
122
+ if (terminal.has(state.status)) { delete subscriptions[objectiveId]; changed = true; continue; }
116
123
  if (digest === subscription.lastDigest) continue;
117
- await reply(subscription.chatId, text);
118
124
  subscription.lastDigest = digest;
119
125
  subscription.lastStatus = state.status;
120
126
  subscription.notifiedAt = new Date().toISOString();
package/src/telegram.js CHANGED
@@ -48,7 +48,11 @@ export function formatObjectiveProgress(state, factoryName = "Factory AI") {
48
48
  `Status: ${state.status ?? "unknown"}`,
49
49
  `${complete}/${tasks.length} tasks complete`,
50
50
  ];
51
- for (const task of tasks.slice(0, 20)) lines.push(`${task.role}: ${results[task.id]?.status ?? "blocked"} — ${task.title}`);
51
+ for (const task of tasks.slice(0, 20)) {
52
+ const activity = state.activity?.[task.id];
53
+ const detail = activity ? ` — ${activity.phase ?? activity.type}${activity.tool ? ` ${activity.tool}` : ""}${activity.retryCount ? ` (${activity.retryCount} retries)` : ""}${activity.lastError ? ` [${activity.lastError}]` : ""}` : "";
54
+ lines.push(`${task.role}: ${results[task.id]?.status ?? "blocked"} — ${task.title}${detail}`);
55
+ }
52
56
  if (state.failure) lines.push(`Blocker: ${String(state.failure).slice(0, 500)}`);
53
57
  if (state.release?.url) lines.push(`PR: ${state.release.url}`);
54
58
  return lines.join("\n").slice(0, 4000);