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.
- package/CHANGELOG.md +16 -4
- package/Dockerfile.worker +1 -1
- package/README.md +8 -2
- package/ROADMAP.md +0 -1
- package/bin/factory +118 -1
- package/bootstrap/agent-factory-worker.service +2 -2
- package/bootstrap/auto-update.sh +20 -12
- package/bootstrap/deploy-runtime.sh +5 -3
- package/bootstrap/factory-ai-container-firewall.service +14 -0
- package/bootstrap/factory-ai-snapshot.service +1 -0
- package/bootstrap/factory-ai-watchdog.service +21 -0
- package/bootstrap/factory-ai-watchdog.timer +11 -0
- package/bootstrap/setup.sh +20 -9
- package/cmd/factory-ui/main.go +455 -0
- package/go.mod +39 -0
- package/go.sum +82 -0
- package/package.json +7 -3
- package/scripts/test.js +7 -0
- package/src/acp-adapter.js +26 -0
- package/src/acp-cli.js +16 -0
- package/src/activity.js +96 -0
- package/src/agent-executor.js +39 -5
- package/src/agent-runner.js +30 -10
- package/src/approval-policy.js +12 -0
- package/src/azure-harness.js +47 -14
- package/src/bedrock-harness.js +45 -14
- package/src/config.js +9 -0
- package/src/container-runner.js +65 -12
- package/src/context-compaction.js +16 -0
- package/src/control-plane.js +77 -16
- package/src/control-service.js +6 -1
- package/src/dashboard.js +25 -6
- package/src/extension-cli.js +11 -0
- package/src/extension-manifest.js +76 -0
- package/src/hooks.js +62 -0
- package/src/instructions.js +45 -0
- package/src/mcp-tools.js +19 -3
- package/src/objective-status.js +16 -0
- package/src/operator-logs.js +24 -0
- package/src/operator.js +31 -9
- package/src/process.js +3 -3
- package/src/release-gate.js +1 -0
- package/src/release-service.js +4 -1
- package/src/repo-map.js +128 -0
- package/src/reporter.js +11 -0
- package/src/retriever.js +18 -18
- package/src/routing.js +11 -1
- package/src/scanner-suite.js +4 -3
- package/src/snapshot.js +16 -0
- package/src/task-entry.js +28 -6
- package/src/task-graph.js +0 -5
- package/src/telegram-service.js +7 -1
- package/src/telegram.js +5 -1
- package/src/telemetry.js +173 -0
- package/src/tui.js +27 -8
- package/src/validation.js +41 -9
- package/src/watchdog.js +62 -0
- package/src/worker.js +32 -3
- package/src/workspace-tools.js +13 -3
- package/src/workspace.js +22 -2
- 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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 () =>
|
|
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 () =>
|
|
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);
|
package/src/release-gate.js
CHANGED
|
@@ -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 };
|
package/src/release-service.js
CHANGED
|
@@ -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)
|
|
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
|
},
|
package/src/repo-map.js
ADDED
|
@@ -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
|
-
|
|
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 =
|
|
106
|
+
const commit = await workspaceRevision(directory);
|
|
105
107
|
const manifest = await this.loadManifest();
|
|
106
|
-
if (manifest[repository]?.commit
|
|
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) {
|
package/src/scanner-suite.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
}
|
package/src/telegram-service.js
CHANGED
|
@@ -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))
|
|
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);
|