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/telemetry.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const TELEMETRY_SCHEMA_VERSION = "factory.telemetry.v1";
|
|
4
|
+
|
|
5
|
+
const operationNames = Object.freeze({
|
|
6
|
+
model: "gen_ai.chat",
|
|
7
|
+
tool: "gen_ai.execute_tool",
|
|
8
|
+
queue: "factory.queue",
|
|
9
|
+
checkpoint: "factory.checkpoint",
|
|
10
|
+
scanner: "factory.scanner",
|
|
11
|
+
watchdog: "factory.watchdog",
|
|
12
|
+
release: "factory.release",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const aliases = Object.freeze({
|
|
16
|
+
objectiveId: "factory.objective.id",
|
|
17
|
+
taskId: "factory.task.id",
|
|
18
|
+
role: "factory.agent.role",
|
|
19
|
+
modelRoute: "gen_ai.request.model",
|
|
20
|
+
attempt: "factory.attempt",
|
|
21
|
+
toolCallId: "gen_ai.tool.call.id",
|
|
22
|
+
messageId: "messaging.message.id",
|
|
23
|
+
inputTokens: "gen_ai.usage.input_tokens",
|
|
24
|
+
outputTokens: "gen_ai.usage.output_tokens",
|
|
25
|
+
cacheReadInputTokens: "gen_ai.usage.cache_read.input_tokens",
|
|
26
|
+
durationMs: "factory.duration_ms",
|
|
27
|
+
retryCount: "factory.retry.count",
|
|
28
|
+
cacheHit: "factory.cache.hit",
|
|
29
|
+
statusClass: "factory.status.class",
|
|
30
|
+
errorCode: "error.type",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const identifier = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
34
|
+
const modelRoute = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
35
|
+
const providerName = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
36
|
+
const errorCode = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
37
|
+
const statusClasses = new Set(["ok", "error", "cancelled", "timeout", "retry"]);
|
|
38
|
+
const genAiOperations = Object.freeze({ model: "chat", tool: "execute_tool" });
|
|
39
|
+
|
|
40
|
+
function nonNegativeInteger(value) {
|
|
41
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nonNegativeNumber(value) {
|
|
45
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const validators = Object.freeze({
|
|
49
|
+
"factory.objective.id": (value) => typeof value === "string" && identifier.test(value),
|
|
50
|
+
"factory.task.id": (value) => typeof value === "string" && identifier.test(value),
|
|
51
|
+
"factory.agent.role": (value) => typeof value === "string" && identifier.test(value),
|
|
52
|
+
"gen_ai.request.model": (value) => typeof value === "string" && modelRoute.test(value) && !value.includes("://"),
|
|
53
|
+
"factory.attempt": nonNegativeInteger,
|
|
54
|
+
"gen_ai.tool.call.id": (value) => typeof value === "string" && identifier.test(value),
|
|
55
|
+
"messaging.message.id": (value) => typeof value === "string" && identifier.test(value),
|
|
56
|
+
"gen_ai.provider.name": (value) => typeof value === "string" && providerName.test(value),
|
|
57
|
+
"gen_ai.operation.name": (value) => value === "chat" || value === "execute_tool",
|
|
58
|
+
"gen_ai.usage.input_tokens": nonNegativeInteger,
|
|
59
|
+
"gen_ai.usage.output_tokens": nonNegativeInteger,
|
|
60
|
+
"gen_ai.usage.cache_read.input_tokens": nonNegativeInteger,
|
|
61
|
+
"factory.duration_ms": nonNegativeNumber,
|
|
62
|
+
"factory.retry.count": nonNegativeInteger,
|
|
63
|
+
"factory.cache.hit": (value) => typeof value === "boolean",
|
|
64
|
+
"factory.status.class": (value) => statusClasses.has(value),
|
|
65
|
+
"error.type": (value) => typeof value === "string" && errorCode.test(value),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export function safeAttributes(input = {}) {
|
|
69
|
+
const result = {};
|
|
70
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return result;
|
|
71
|
+
for (const [inputName, value] of Object.entries(input)) {
|
|
72
|
+
const name = aliases[inputName] ?? inputName;
|
|
73
|
+
if (validators[name]?.(value)) result[name] = value;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function operationName(kind) {
|
|
79
|
+
const name = operationNames[kind];
|
|
80
|
+
if (!name) throw new Error(`Unknown telemetry operation: ${kind}`);
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validHexId(value, length) {
|
|
85
|
+
return typeof value === "string" && new RegExp(`^[a-f0-9]{${length}}$`).test(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createTelemetry(options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
exporter,
|
|
91
|
+
fallback,
|
|
92
|
+
now = Date.now,
|
|
93
|
+
createTraceId = () => randomBytes(16).toString("hex"),
|
|
94
|
+
createSpanId = () => randomBytes(8).toString("hex"),
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
function ids(context) {
|
|
98
|
+
const traceId = validHexId(context?.traceId, 32) ? context.traceId : createTraceId();
|
|
99
|
+
const spanId = createSpanId();
|
|
100
|
+
if (!validHexId(traceId, 32) || !validHexId(spanId, 16)) throw new Error("Telemetry ID generators must return lowercase hexadecimal IDs");
|
|
101
|
+
return {
|
|
102
|
+
traceId,
|
|
103
|
+
spanId,
|
|
104
|
+
...(validHexId(context?.parentSpanId, 16) ? { parentSpanId: context.parentSpanId } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function deliver(record) {
|
|
109
|
+
if (typeof exporter === "function") {
|
|
110
|
+
try {
|
|
111
|
+
await exporter(record);
|
|
112
|
+
return record;
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
if (typeof fallback === "function") {
|
|
116
|
+
try { await fallback(record); } catch {}
|
|
117
|
+
}
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function baseRecord(recordType, kind, context) {
|
|
122
|
+
const timestamp = new Date(now()).toISOString();
|
|
123
|
+
return {
|
|
124
|
+
schemaVersion: TELEMETRY_SCHEMA_VERSION,
|
|
125
|
+
recordType,
|
|
126
|
+
name: operationName(kind),
|
|
127
|
+
timestamp,
|
|
128
|
+
...ids(context),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function startSpan(kind, context = {}, attributes = {}) {
|
|
133
|
+
const startedAt = now();
|
|
134
|
+
const base = baseRecord("span", kind, context);
|
|
135
|
+
const initialAttributes = safeAttributes({
|
|
136
|
+
...context,
|
|
137
|
+
...attributes,
|
|
138
|
+
...(genAiOperations[kind] ? { "gen_ai.operation.name": genAiOperations[kind] } : {}),
|
|
139
|
+
});
|
|
140
|
+
let completion;
|
|
141
|
+
return {
|
|
142
|
+
traceId: base.traceId,
|
|
143
|
+
spanId: base.spanId,
|
|
144
|
+
end(endAttributes = {}) {
|
|
145
|
+
if (!completion) {
|
|
146
|
+
const measured = endAttributes.durationMs === undefined
|
|
147
|
+
? { durationMs: Math.max(0, now() - startedAt), ...endAttributes }
|
|
148
|
+
: endAttributes;
|
|
149
|
+
const record = {
|
|
150
|
+
...base,
|
|
151
|
+
attributes: { ...initialAttributes, ...safeAttributes(measured) },
|
|
152
|
+
};
|
|
153
|
+
completion = deliver(record);
|
|
154
|
+
}
|
|
155
|
+
return completion;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function emitEvent(kind, context = {}, attributes = {}) {
|
|
161
|
+
const record = {
|
|
162
|
+
...baseRecord("event", kind, context),
|
|
163
|
+
attributes: safeAttributes({
|
|
164
|
+
...context,
|
|
165
|
+
...attributes,
|
|
166
|
+
...(genAiOperations[kind] ? { "gen_ai.operation.name": genAiOperations[kind] } : {}),
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
return deliver(record);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Object.freeze({ startSpan, emitEvent });
|
|
173
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -13,7 +13,7 @@ const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e
|
|
|
13
13
|
blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: ` {bold}{#78dba9-fg}${factoryName.toUpperCase()}{/} {/bold} ${factoryPurpose}` });
|
|
14
14
|
const menu = blessed.list({ parent: screen, top: 3, left: 0, width: 23, bottom: 2, border: { type: "line" }, label: " Navigate ", keys: true, mouse: true, vi: true, items: ["Overview", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"], style: { bg: colors.panel, fg: colors.text, border: { fg: colors.border }, selected: { bg: colors.accent, fg: "#07130d", bold: true }, item: { fg: colors.text } } });
|
|
15
15
|
const main = blessed.box({ parent: screen, top: 3, left: 23, right: 0, bottom: 2, border: { type: "line" }, label: " Overview ", tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true, vi: true, scrollbar: { ch: "▐", style: { fg: colors.accent } }, padding: { left: 2, right: 2 }, style: { bg: colors.bg, fg: colors.text, border: { fg: colors.border } } });
|
|
16
|
-
const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new
|
|
16
|
+
const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new {bold}r{/} refresh {bold}a{/} secret {bold}y/x{/} approve/deny {bold}p/u{/} pause/resume {bold}q{/} quit" });
|
|
17
17
|
|
|
18
18
|
let dashboard;
|
|
19
19
|
let capabilities;
|
|
@@ -22,6 +22,11 @@ let logs;
|
|
|
22
22
|
let section = "Overview";
|
|
23
23
|
let refreshing = false;
|
|
24
24
|
|
|
25
|
+
function safe(value) {
|
|
26
|
+
const text = String(value ?? "").replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
|
|
27
|
+
return blessed.escape ? blessed.escape(text) : text.replaceAll("{", "\\{").replaceAll("}", "\\}");
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
function status(message, color = colors.muted) {
|
|
26
31
|
footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
|
|
27
32
|
screen.render();
|
|
@@ -39,20 +44,20 @@ function renderOverview() {
|
|
|
39
44
|
const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
|
|
40
45
|
const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
|
|
41
46
|
const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
|
|
42
|
-
const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${objective.objective}{/}\n ${objective.repository ?? ""}`).join("\n\n");
|
|
47
|
+
const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${safe(objective.objective)}{/}\n ${safe(objective.repository ?? "")}`).join("\n\n");
|
|
43
48
|
main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n Tokens ${inputTokens} in · ${cachedTokens} cached · ${outputTokens} out\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
function renderObjectives() {
|
|
47
52
|
main.setContent((dashboard?.objectives ?? []).slice().reverse().map((objective) => {
|
|
48
|
-
const tasks = objective.tasks.map((task) => ` ${badge(task.state.padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${task.model}{/}\n ${task.title}`).join("\n");
|
|
49
|
-
return `${badge(objective.status)} {bold}${objective.objective}{/}\n ${objective.id}\n${tasks}${objective.pullRequest ? `\n PR ${objective.pullRequest}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${objective.blocker}{/}` : ""}`;
|
|
53
|
+
const tasks = objective.tasks.map((task) => ` ${badge((task.stale ? "stale" : task.state).padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${safe(task.model)}{/}\n ${safe(task.title)}${task.activity ? ` · ${safe(task.activity.type)}${task.activity.tool ? ` ${safe(task.activity.tool)}` : ""}` : ""}`).join("\n");
|
|
54
|
+
return `${badge(objective.status)} {bold}${safe(objective.objective)}{/}\n ${safe(objective.id)}\n${tasks}${objective.pullRequest ? `\n PR ${safe(objective.pullRequest)}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${safe(objective.blocker)}{/}` : ""}`;
|
|
50
55
|
}).join("\n\n────────────────────────────────────────────────────────\n\n") || "No objectives." );
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function renderAgents() {
|
|
54
59
|
const agents = (dashboard?.objectives ?? []).flatMap((objective) => objective.tasks.map((task) => ({ objective: objective.objective, ...task }))).filter((task) => !["succeeded"].includes(task.state));
|
|
55
|
-
main.setContent(agents.map((task) => `${badge(task.state.padEnd(9))} {bold}${task.role}{/} ${task.model}\n ${task.title}\n {#7f8b99-fg}${task.objective}{/}`).join("\n\n") || "No active agents.");
|
|
60
|
+
main.setContent(agents.map((task) => `${badge((task.stale ? "stale" : task.state).padEnd(9))} {bold}${safe(task.role)}{/} ${safe(task.model)}\n ${safe(task.title)}\n ${task.activity ? `${safe(task.activity.phase ?? task.activity.type)}${task.activity.tool ? ` · ${safe(task.activity.tool)}` : ""} · ${Math.round(task.activityAgeSeconds ?? 0)}s ago${task.retries ? ` · ${task.retries} retries` : ""}` : "No activity event yet"}${task.lastError ? `\n {#ef7d7d-fg}${safe(task.lastError)}{/}` : ""}\n {#7f8b99-fg}${safe(task.objective)}{/}`).join("\n\n") || "No active agents.");
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function renderSecrets() {
|
|
@@ -77,7 +82,7 @@ function render() {
|
|
|
77
82
|
else if (section === "Agents") renderAgents();
|
|
78
83
|
else if (section === "Secrets") renderSecrets();
|
|
79
84
|
else if (section === "Capabilities") renderCapabilities();
|
|
80
|
-
else if (section === "Logs") main.setContent(logs ?? "Loading logs...");
|
|
85
|
+
else if (section === "Logs") main.setContent(safe(logs ?? "Loading logs..."));
|
|
81
86
|
else renderSettings();
|
|
82
87
|
main.setScrollPerc(0);
|
|
83
88
|
screen.render();
|
|
@@ -90,7 +95,7 @@ async function refresh() {
|
|
|
90
95
|
try {
|
|
91
96
|
dashboard = await operator.dashboard();
|
|
92
97
|
if (section === "Capabilities" && !capabilities) capabilities = await operator.capabilities();
|
|
93
|
-
if (section === "Secrets") secrets = await operator.listSecrets();
|
|
98
|
+
if (section === "Secrets") secrets = dashboard?.secrets ?? await operator.listSecrets();
|
|
94
99
|
if (section === "Logs") logs = await operator.logs();
|
|
95
100
|
render();
|
|
96
101
|
status(`Updated ${new Date().toLocaleTimeString()}`);
|
|
@@ -123,7 +128,19 @@ async function addSecret() {
|
|
|
123
128
|
const value = await ask("Secret value", { secret: true });
|
|
124
129
|
if (!value) return;
|
|
125
130
|
status("Storing secret...");
|
|
126
|
-
try {
|
|
131
|
+
try {
|
|
132
|
+
await operator.setSecret(name, value);
|
|
133
|
+
secrets = [...(secrets ?? []).filter((item) => item.name !== name), { name, updated: new Date().toISOString() }].sort((left, right) => left.name.localeCompare(right.name));
|
|
134
|
+
renderSecrets();
|
|
135
|
+
status(`Stored ${name}`, colors.accent);
|
|
136
|
+
} catch (error) { status(error.message, colors.danger); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function decideApproval(decision) {
|
|
140
|
+
const objectiveId = await ask("Objective ID"); if (!objectiveId) return;
|
|
141
|
+
const approvalId = await ask("Approval ID"); if (!approvalId) return;
|
|
142
|
+
const reason = await ask(`${decision === "approved" ? "Approval" : "Denial"} reason`); if (!reason) return;
|
|
143
|
+
try { await operator.approval({ objectiveId, approvalId, decision, reason }); status(`Approval ${decision}`, decision === "approved" ? colors.accent : colors.danger); await refresh(); } catch (error) { status(error.message, colors.danger); }
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
menu.on("select", async (_, index) => { section = menu.getItem(index).getText(); render(); await refresh(); menu.focus(); });
|
|
@@ -131,6 +148,8 @@ screen.key(["q", "C-c"], () => process.exit(0));
|
|
|
131
148
|
screen.key("r", refresh);
|
|
132
149
|
screen.key("n", submitObjective);
|
|
133
150
|
screen.key("a", addSecret);
|
|
151
|
+
screen.key("y", () => decideApproval("approved"));
|
|
152
|
+
screen.key("x", () => decideApproval("denied"));
|
|
134
153
|
screen.key("p", async () => { status("Pausing workers..."); try { await operator.control("pause"); status("Workers paused", colors.warn); } catch (error) { status(error.message, colors.danger); } });
|
|
135
154
|
screen.key("u", async () => { status("Resuming workers..."); try { await operator.control("resume"); status("Workers active", colors.accent); } catch (error) { status(error.message, colors.danger); } });
|
|
136
155
|
screen.key("tab", () => menu.focus());
|
package/src/validation.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ROLES } from "./routing.js";
|
|
3
|
+
import { approvalPolicies } from "./approval-policy.js";
|
|
3
4
|
|
|
4
5
|
const identifier = z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
|
5
6
|
const repository = z.string().url().max(2048).refine((value) => {
|
|
@@ -48,11 +49,11 @@ const taskResultSchema = z.object({
|
|
|
48
49
|
}).optional(),
|
|
49
50
|
}).strict();
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}).strict();
|
|
52
|
+
const scannerEvidenceSchema = z.array(z.object({
|
|
53
|
+
scanner: z.string().min(1).max(100),
|
|
54
|
+
status: z.enum(["passed", "findings", "error"]),
|
|
55
|
+
output: z.string().max(6000),
|
|
56
|
+
}).strict()).max(20);
|
|
56
57
|
|
|
57
58
|
const resultMessageSchema = taskResultSchema.extend({
|
|
58
59
|
type: z.literal("result"),
|
|
@@ -61,6 +62,33 @@ const resultMessageSchema = taskResultSchema.extend({
|
|
|
61
62
|
status: z.literal("succeeded"),
|
|
62
63
|
commit: z.string().regex(/^[0-9a-f]{40,64}$/),
|
|
63
64
|
branch: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/),
|
|
65
|
+
scannerEvidence: scannerEvidenceSchema.optional(),
|
|
66
|
+
}).strict();
|
|
67
|
+
|
|
68
|
+
const approvalRequestMessageSchema = z.object({
|
|
69
|
+
type: z.literal("approval_request"),
|
|
70
|
+
objectiveId: identifier,
|
|
71
|
+
approvalId: identifier,
|
|
72
|
+
policy: z.enum(approvalPolicies),
|
|
73
|
+
reason: z.string().trim().min(1).max(1000),
|
|
74
|
+
actor: z.string().trim().min(1).max(200),
|
|
75
|
+
requestedAt: z.string().datetime(),
|
|
76
|
+
expiresAt: z.string().datetime(),
|
|
77
|
+
checkpoint: z.string().min(1).max(500).optional(),
|
|
78
|
+
}).strict().refine((value) => Date.parse(value.expiresAt) > Date.parse(value.requestedAt), {
|
|
79
|
+
message: "Approval expiration must follow its request time",
|
|
80
|
+
path: ["expiresAt"],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const approvalDecisionMessageSchema = z.object({
|
|
84
|
+
type: z.literal("approval_decision"),
|
|
85
|
+
objectiveId: identifier,
|
|
86
|
+
approvalId: identifier,
|
|
87
|
+
decision: z.enum(["approved", "denied", "expired"]),
|
|
88
|
+
actor: z.string().trim().min(1).max(200),
|
|
89
|
+
reason: z.string().trim().min(1).max(1000),
|
|
90
|
+
decidedAt: z.string().datetime(),
|
|
91
|
+
messageId: identifier,
|
|
64
92
|
}).strict();
|
|
65
93
|
|
|
66
94
|
export function parseObjective(value) {
|
|
@@ -71,10 +99,6 @@ export function parsePlan(value) {
|
|
|
71
99
|
return planSchema.parse(value);
|
|
72
100
|
}
|
|
73
101
|
|
|
74
|
-
export function parseWorkMessage(value) {
|
|
75
|
-
return workMessageSchema.parse(value);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
102
|
export function parseTaskResult(value) {
|
|
79
103
|
return taskResultSchema.parse(value);
|
|
80
104
|
}
|
|
@@ -82,3 +106,11 @@ export function parseTaskResult(value) {
|
|
|
82
106
|
export function parseResultMessage(value) {
|
|
83
107
|
return resultMessageSchema.parse(value);
|
|
84
108
|
}
|
|
109
|
+
|
|
110
|
+
export function parseApprovalRequestMessage(value) {
|
|
111
|
+
return approvalRequestMessageSchema.parse(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseApprovalDecisionMessage(value) {
|
|
115
|
+
return approvalDecisionMessageSchema.parse(value);
|
|
116
|
+
}
|
package/src/watchdog.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { ServiceBusClient } from "@azure/service-bus";
|
|
4
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { loadLocalState } from "./dashboard.js";
|
|
7
|
+
import { sendMessage } from "./bus.js";
|
|
8
|
+
import { run } from "./process.js";
|
|
9
|
+
import { ActivityStore } from "./activity.js";
|
|
10
|
+
import { objectiveIsTerminal } from "./objective-status.js";
|
|
11
|
+
|
|
12
|
+
const TERMINAL = new Set(["complete", "failed", "blocked", "cancelled", "denied", "expired"]);
|
|
13
|
+
|
|
14
|
+
export function findStaleAgents(states, now = new Date(), staleSeconds = 900) {
|
|
15
|
+
const stale = [];
|
|
16
|
+
for (const state of states) {
|
|
17
|
+
if (TERMINAL.has(state.status)) continue;
|
|
18
|
+
const tasks = state.tasks?.length ? state.tasks : state.status === "planning" ? [{ id: "planner0", role: "planner" }] : [];
|
|
19
|
+
for (const task of tasks) {
|
|
20
|
+
if (state.results?.[task.id]?.status !== "queued" && task.id !== "planner0") continue;
|
|
21
|
+
const occurredAt = state.activity?.[task.id]?.occurredAt ?? state.results?.[task.id]?.queuedAt ?? (task.id === "planner0" ? state.createdAt : undefined);
|
|
22
|
+
if (!occurredAt || now.getTime() - new Date(occurredAt).getTime() <= staleSeconds * 1000) continue;
|
|
23
|
+
stale.push({ objectiveId: state.objective.id, taskId: task.id, role: task.role, occurredAt });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return stale;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const { states } = await loadLocalState(config.stateDir);
|
|
32
|
+
const stale = findStaleAgents(states, new Date(), Number(process.env.FACTORY_WATCHDOG_STALE_SECONDS ?? 900));
|
|
33
|
+
if (stale.length === 0) return;
|
|
34
|
+
const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
|
|
35
|
+
const sender = client.createSender(config.controlQueue);
|
|
36
|
+
const activityStore = new ActivityStore(config.stateDir);
|
|
37
|
+
try {
|
|
38
|
+
for (const item of stale) {
|
|
39
|
+
await activityStore.withTaskLock(item.objectiveId, item.taskId, async () => {
|
|
40
|
+
const latest = await activityStore.latestTask(item.objectiveId, item.taskId);
|
|
41
|
+
if (await objectiveIsTerminal(config.stateDir, item.objectiveId)) return;
|
|
42
|
+
if (latest?.occurredAt && latest.occurredAt !== item.occurredAt) return;
|
|
43
|
+
await sendMessage(sender, {
|
|
44
|
+
type: "failure_result",
|
|
45
|
+
objectiveId: item.objectiveId,
|
|
46
|
+
taskId: item.taskId,
|
|
47
|
+
error: `Watchdog stopped ${item.role} after no heartbeat since ${item.occurredAt}`,
|
|
48
|
+
}, `watchdog:${item.objectiveId}:${item.taskId}:${item.occurredAt}`, item.objectiveId);
|
|
49
|
+
const container = `factory-ai-${item.objectiveId}-${item.taskId}`.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").slice(0, 63);
|
|
50
|
+
await run("docker", ["rm", "--force", container], { allowExitCodes: [0, 1], timeoutMs: 30_000 });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await sender.close();
|
|
55
|
+
await client.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
|
|
60
|
+
process.stderr.write(`${error.message}\n`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
});
|
package/src/worker.js
CHANGED
|
@@ -10,19 +10,43 @@ import { sendMessage } from "./bus.js";
|
|
|
10
10
|
import { ContainerAgentRunner } from "./container-runner.js";
|
|
11
11
|
import { ScannerSuite } from "./scanner-suite.js";
|
|
12
12
|
import { LocalRetriever } from "./retriever.js";
|
|
13
|
+
import { ActivityStore } from "./activity.js";
|
|
14
|
+
import { objectiveIsTerminal } from "./objective-status.js";
|
|
15
|
+
import { evaluateApprovalPolicy } from "./approval-policy.js";
|
|
13
16
|
|
|
14
17
|
process.title = "factory-ai-worker";
|
|
15
18
|
const config = loadConfig();
|
|
16
19
|
Object.assign(process.env, await loadRuntimeSecrets(config));
|
|
17
20
|
await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
|
|
18
21
|
const bus = createBus(config, config.agentQueue, config.controlQueue);
|
|
19
|
-
const sendControl = (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.type}:${message.taskId ?? "plan"}:v1`, message.objectiveId);
|
|
22
|
+
const sendControl = (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.type}:${message.approvalId ?? message.taskId ?? "plan"}:v1`, message.objectiveId);
|
|
23
|
+
const scannerSuite = new ScannerSuite();
|
|
24
|
+
async function requestApproval(input, context) {
|
|
25
|
+
if (context.message.approvalGranted) return { skipped: true };
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const approvalId = `approval-${context.message.task.id}`.slice(0, 64);
|
|
28
|
+
await sendControl({ type: "approval_request", objectiveId: context.message.objectiveId, approvalId, policy: input.policy, reason: input.reason, actor: "factory-policy", requestedAt: now.toISOString(), expiresAt: input.expiresAt ?? new Date(now.getTime() + 86_400_000).toISOString(), checkpoint: context.message.task.id });
|
|
29
|
+
return { approvalId };
|
|
30
|
+
}
|
|
20
31
|
const executor = new AgentExecutor({
|
|
21
32
|
workspaces: new WorkspaceManager(config.workspaceDir, config.timeoutMs),
|
|
22
|
-
agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs }),
|
|
23
|
-
scannerSuite
|
|
33
|
+
agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs, activityStore: new ActivityStore(config.stateDir) }),
|
|
34
|
+
scannerSuite,
|
|
24
35
|
retriever: new LocalRetriever({ stateDir: config.stateDir }),
|
|
36
|
+
repoMapMaxCharacters: config.repoMapMaxCharacters,
|
|
25
37
|
sendControl,
|
|
38
|
+
hooks: config.hooks,
|
|
39
|
+
hookHandlers: {
|
|
40
|
+
scanner: (input, context) => scannerSuite.scan(context.directory, { names: input.scanners }),
|
|
41
|
+
policy_check: async (input, context) => {
|
|
42
|
+
const evaluated = evaluateApprovalPolicy(Object.fromEntries(input.policies.map((policy) => [policy, new RegExp(policy.replaceAll("_", ".*"), "i").test(`${context.message.task.title} ${context.message.task.instructions}`)])));
|
|
43
|
+
if (!evaluated.required) return evaluated;
|
|
44
|
+
return { ...evaluated, ...await requestApproval({ policy: evaluated.policies[0], reason: `Policy requires approval: ${evaluated.policies.join(", ")}` }, context) };
|
|
45
|
+
},
|
|
46
|
+
notification: async (input, context) => log("info", "hook_notification", { objectiveId: context.message.objectiveId, taskId: context.message.task.id, message: input.message }),
|
|
47
|
+
snapshot: async (input) => ({ label: input.label ?? "checkpoint" }),
|
|
48
|
+
approval_request: requestApproval,
|
|
49
|
+
},
|
|
26
50
|
});
|
|
27
51
|
|
|
28
52
|
let shuttingDown = false;
|
|
@@ -30,6 +54,11 @@ const subscription = bus.receiver.subscribe({
|
|
|
30
54
|
processMessage: async (message) => {
|
|
31
55
|
const body = message.body;
|
|
32
56
|
try {
|
|
57
|
+
if (await objectiveIsTerminal(config.stateDir, body?.objectiveId)) {
|
|
58
|
+
log("info", "terminal_objective_message_discarded", { objectiveId: body.objectiveId, taskId: body.task?.id });
|
|
59
|
+
await bus.receiver.completeMessage(message);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
33
62
|
await executor.process(body);
|
|
34
63
|
await bus.receiver.completeMessage(message);
|
|
35
64
|
} catch (error) {
|
package/src/workspace-tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { run } from "./process.js";
|
|
|
5
5
|
|
|
6
6
|
const ALLOWED_COMMANDS = new Set(["git", "ls", "node", "npm", "npx", "pwd", "rg"]);
|
|
7
7
|
const DENIED_GIT_OPERATIONS = new Set(["credential", "push", "remote"]);
|
|
8
|
+
const READ_ONLY_GIT_OPERATIONS = new Set(["diff", "grep", "log", "ls-files", "rev-parse", "show", "status"]);
|
|
8
9
|
|
|
9
10
|
function lexicalPath(root, requested) {
|
|
10
11
|
const target = path.resolve(root, requested);
|
|
@@ -43,10 +44,11 @@ async function walk(directory, root, output, limit) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
47
|
+
export function createWorkspaceTools(rootInput, { execute = run, mutable = true, allowTests = false } = {}) {
|
|
47
48
|
const root = realpathSync(path.resolve(rootInput));
|
|
48
|
-
|
|
49
|
+
const tools = {
|
|
49
50
|
read_file: {
|
|
51
|
+
parallelSafe: true,
|
|
50
52
|
description: "Read a bounded UTF-8 line range inside the assigned workspace. Use offsetLine/limitLines instead of rereading large files.",
|
|
51
53
|
parameters: {
|
|
52
54
|
type: "object",
|
|
@@ -69,6 +71,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
|
69
71
|
},
|
|
70
72
|
},
|
|
71
73
|
write_file: {
|
|
74
|
+
parallelSafe: false,
|
|
72
75
|
description: "Atomically replace a UTF-8 file inside the assigned workspace.",
|
|
73
76
|
parameters: {
|
|
74
77
|
type: "object",
|
|
@@ -86,6 +89,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
|
86
89
|
},
|
|
87
90
|
},
|
|
88
91
|
list_files: {
|
|
92
|
+
parallelSafe: true,
|
|
89
93
|
description: "List files recursively inside the assigned workspace.",
|
|
90
94
|
parameters: {
|
|
91
95
|
type: "object",
|
|
@@ -101,6 +105,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
|
101
105
|
},
|
|
102
106
|
},
|
|
103
107
|
run_command: {
|
|
108
|
+
parallelSafe: false,
|
|
104
109
|
description: "Run an allowlisted noninteractive development command in the assigned workspace.",
|
|
105
110
|
parameters: {
|
|
106
111
|
type: "object",
|
|
@@ -113,7 +118,10 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
|
113
118
|
},
|
|
114
119
|
execute: async ({ command, args }) => {
|
|
115
120
|
if (!ALLOWED_COMMANDS.has(command)) throw new Error(`Command not allowed: ${command}`);
|
|
116
|
-
|
|
121
|
+
const testCommand = allowTests && ((command === "npm" && ["test", "run"].includes(args[0])) || (command === "node" && args[0] === "--test") || (command === "npx" && args[0] === "--no-install"));
|
|
122
|
+
if (!mutable && !testCommand && !["git", "ls", "pwd", "rg"].includes(command)) throw new Error(`Command not allowed for read-only role: ${command}`);
|
|
123
|
+
if (command === "git" && (args.some((item) => DENIED_GIT_OPERATIONS.has(item)) || args.some((item) => ["-c", "--config-env", "--exec-path"].includes(item)))) throw new Error("Git operation not allowed");
|
|
124
|
+
if (!mutable && command === "git" && (args[0]?.startsWith("-") || !READ_ONLY_GIT_OPERATIONS.has(args[0]))) throw new Error(`Git operation not allowed for read-only role: ${args[0]}`);
|
|
117
125
|
if (command === "npx" && args[0] !== "--no-install") throw new Error("npx requires --no-install");
|
|
118
126
|
const result = await execute(command, args, {
|
|
119
127
|
cwd: root,
|
|
@@ -127,4 +135,6 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
|
|
|
127
135
|
},
|
|
128
136
|
},
|
|
129
137
|
};
|
|
138
|
+
if (!mutable) delete tools.write_file;
|
|
139
|
+
return tools;
|
|
130
140
|
}
|
package/src/workspace.js
CHANGED
|
@@ -45,10 +45,18 @@ export class WorkspaceManager {
|
|
|
45
45
|
|
|
46
46
|
async prepareTask(objective, task, dependencyCommits = []) {
|
|
47
47
|
const directory = this.taskDirectory(objective.id, task.id);
|
|
48
|
-
|
|
48
|
+
const branch = `factory-ai/${objective.id}/${task.id}`;
|
|
49
|
+
if (await exists(directory)) {
|
|
50
|
+
const current = (await run("git", ["-C", directory, "branch", "--show-current"])).stdout.trim();
|
|
51
|
+
if (current !== branch) throw new Error(`Existing task workspace is on unexpected branch: ${current}`);
|
|
52
|
+
for (const commit of dependencyCommits) {
|
|
53
|
+
const ancestor = await run("git", ["-C", directory, "merge-base", "--is-ancestor", commit, "HEAD"], { allowExitCodes: [0, 1] });
|
|
54
|
+
if (ancestor.code !== 0) throw new Error(`Existing task workspace is missing dependency commit: ${commit}`);
|
|
55
|
+
}
|
|
56
|
+
return directory;
|
|
57
|
+
}
|
|
49
58
|
await mkdir(path.dirname(directory), { recursive: true, mode: 0o750 });
|
|
50
59
|
const base = dependencyCommits[0] ?? `origin/${objective.baseBranch}`;
|
|
51
|
-
const branch = `factory-ai/${objective.id}/${task.id}`;
|
|
52
60
|
await run("git", ["clone", objective.repository, directory], { timeoutMs: this.timeoutMs });
|
|
53
61
|
await run("git", ["-C", directory, "config", "user.name", "Factory AI"]);
|
|
54
62
|
await run("git", ["-C", directory, "config", "user.email", "factory-ai@localhost"]);
|
|
@@ -60,6 +68,13 @@ export class WorkspaceManager {
|
|
|
60
68
|
return directory;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
async recoveryContext(directory) {
|
|
72
|
+
const status = (await run("git", ["-C", directory, "status", "--short"], { maxOutputBytes: 50_000 })).stdout.trim();
|
|
73
|
+
if (!status) return "";
|
|
74
|
+
const summary = (await run("git", ["-C", directory, "diff", "--stat"], { maxOutputBytes: 50_000 })).stdout.trim();
|
|
75
|
+
return `RECOVERED DURABLE WORKTREE CHECKPOINT\nA previous attempt changed these files. Inspect and continue rather than repeating completed work.\n${status}${summary ? `\n\n${summary}` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
async checkpoint(directory, objective, task) {
|
|
64
79
|
await run("git", ["-C", directory, "add", "-A"]);
|
|
65
80
|
const diff = await run("git", ["-C", directory, "diff", "--cached", "--quiet"], { allowExitCodes: [0, 1] });
|
|
@@ -75,4 +90,9 @@ export class WorkspaceManager {
|
|
|
75
90
|
return { commit, branch };
|
|
76
91
|
}
|
|
77
92
|
|
|
93
|
+
async reference(directory, objective, task) {
|
|
94
|
+
const { stdout } = await run("git", ["-C", directory, "rev-parse", "HEAD"]);
|
|
95
|
+
return { commit: stdout.trim(), branch: `factory-ai/${objective.id}/${task.id}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Project Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Working Agreement
|
|
4
|
+
|
|
5
|
+
- Make the smallest correct change that satisfies the objective.
|
|
6
|
+
- Read existing code and tests before editing. Preserve established architecture and style.
|
|
7
|
+
- Never read, print, persist, or commit credentials or local environment files.
|
|
8
|
+
- Keep changes inside this repository. Do not push, deploy, or mutate external systems.
|
|
9
|
+
- Add or update focused tests for behavior changes.
|
|
10
|
+
- Run the project's documented format, typecheck, lint, test, and build gates before claiming completion.
|
|
11
|
+
- Report commands run, results, remaining risks, and any assumptions.
|
|
12
|
+
|
|
13
|
+
## Project Commands
|
|
14
|
+
|
|
15
|
+
Record project-specific commands and constraints in `.agent-factory/commands.md` and `.agent-factory/project.md`.
|