factory-ai 1.3.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 (65) hide show
  1. package/CHANGELOG.md +24 -4
  2. package/Dockerfile.worker +1 -1
  3. package/README.md +10 -2
  4. package/ROADMAP.md +0 -1
  5. package/bin/factory +130 -3
  6. package/bootstrap/agent-factory-worker.service +3 -3
  7. package/bootstrap/auto-update.sh +20 -12
  8. package/bootstrap/deploy-runtime.sh +7 -3
  9. package/bootstrap/factory-ai-container-firewall.service +14 -0
  10. package/bootstrap/factory-ai-ollama.service +16 -0
  11. package/bootstrap/factory-ai-qdrant.service +16 -0
  12. package/bootstrap/factory-ai-snapshot.service +1 -0
  13. package/bootstrap/factory-ai-watchdog.service +21 -0
  14. package/bootstrap/factory-ai-watchdog.timer +11 -0
  15. package/bootstrap/setup.sh +33 -8
  16. package/cmd/factory-ui/main.go +455 -0
  17. package/go.mod +39 -0
  18. package/go.sum +82 -0
  19. package/package.json +7 -3
  20. package/scripts/test.js +7 -0
  21. package/src/acp-adapter.js +26 -0
  22. package/src/acp-cli.js +16 -0
  23. package/src/activity.js +96 -0
  24. package/src/agent-executor.js +47 -3
  25. package/src/agent-runner.js +34 -12
  26. package/src/approval-policy.js +12 -0
  27. package/src/azure-harness.js +47 -14
  28. package/src/bedrock-harness.js +45 -14
  29. package/src/config.js +13 -0
  30. package/src/container-runner.js +65 -12
  31. package/src/context-compaction.js +16 -0
  32. package/src/control-plane.js +77 -16
  33. package/src/control-service.js +6 -1
  34. package/src/dashboard.js +25 -6
  35. package/src/extension-cli.js +11 -0
  36. package/src/extension-manifest.js +76 -0
  37. package/src/hooks.js +62 -0
  38. package/src/instructions.js +45 -0
  39. package/src/mcp-tools.js +19 -3
  40. package/src/objective-status.js +16 -0
  41. package/src/operator-logs.js +24 -0
  42. package/src/operator.js +34 -10
  43. package/src/process.js +3 -3
  44. package/src/release-gate.js +1 -0
  45. package/src/release-service.js +4 -1
  46. package/src/release.js +1 -1
  47. package/src/repo-map.js +128 -0
  48. package/src/reporter.js +12 -1
  49. package/src/retriever.js +154 -0
  50. package/src/routing.js +11 -1
  51. package/src/scanner-suite.js +4 -3
  52. package/src/setup-menu.js +3 -1
  53. package/src/snapshot.js +16 -0
  54. package/src/task-entry.js +28 -6
  55. package/src/task-graph.js +0 -5
  56. package/src/telegram-service.js +10 -4
  57. package/src/telegram.js +7 -3
  58. package/src/telemetry.js +173 -0
  59. package/src/tui.js +31 -10
  60. package/src/validation.js +41 -9
  61. package/src/watchdog.js +62 -0
  62. package/src/worker.js +34 -3
  63. package/src/workspace-tools.js +13 -3
  64. package/src/workspace.js +22 -2
  65. package/templates/AGENTS.md +15 -0
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}`, {
@@ -70,7 +72,7 @@ async function statusText() {
70
72
  const [{ states }, queue] = await Promise.all([loadLocalState(config.stateDir), loadQueueMetrics(config)]);
71
73
  const counts = {};
72
74
  for (const state of states) counts[state.status ?? "unknown"] = (counts[state.status ?? "unknown"] ?? 0) + 1;
73
- return [`Factory AI`, `Queue: ${queue.active} active, ${queue.deadLetter} dead-letter`, `Objectives: ${Object.entries(counts).map(([name, count]) => `${name} ${count}`).join(", ") || "none"}`].join("\n");
75
+ return [config.factoryName, `Queue: ${queue.active} active, ${queue.deadLetter} dead-letter`, `Objectives: ${Object.entries(counts).map(([name, count]) => `${name} ${count}`).join(", ") || "none"}`].join("\n");
74
76
  }
75
77
 
76
78
  async function processUpdate(update) {
@@ -93,7 +95,8 @@ 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"));
96
- return reply(message.chat.id, formatObjectiveProgress(state));
98
+ state.activity = await activityStore.latestObjective(command.objectiveId);
99
+ return reply(message.chat.id, formatObjectiveProgress(state, config.factoryName));
97
100
  }
98
101
  const objective = objectiveFromTelegram(update.update_id, command);
99
102
  await sendMessage(sender, objective, objective.id);
@@ -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; }
114
- const text = formatObjectiveProgress(state);
118
+ state.activity = await activityStore.latestObjective(objectiveId);
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
@@ -38,17 +38,21 @@ export function objectiveFromTelegram(updateId, command, now = new Date()) {
38
38
  };
39
39
  }
40
40
 
41
- export function formatObjectiveProgress(state) {
41
+ export function formatObjectiveProgress(state, factoryName = "Factory AI") {
42
42
  const tasks = state.tasks ?? [];
43
43
  const results = state.results ?? {};
44
44
  const complete = tasks.filter((task) => results[task.id]?.status === "succeeded").length;
45
45
  const lines = [
46
- `Factory AI objective ${state.objective?.id ?? "unknown"}`,
46
+ `${factoryName} objective ${state.objective?.id ?? "unknown"}`,
47
47
  state.objective?.objective ?? "",
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);
@@ -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
@@ -5,13 +5,15 @@ import { createOperator } from "./operator.js";
5
5
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Factory AI UI requires an interactive terminal");
6
6
 
7
7
  const operator = createOperator();
8
- const screen = blessed.screen({ smartCSR: true, title: "Factory AI", fullUnicode: true, dockBorders: true });
8
+ const factoryName = process.env.FACTORY_NAME ?? "Factory AI";
9
+ const factoryPurpose = process.env.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
10
+ const screen = blessed.screen({ smartCSR: true, title: factoryName, fullUnicode: true, dockBorders: true });
9
11
  const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e0e8", muted: "#7f8b99", accent: "#78dba9", warn: "#efc46b", danger: "#ef7d7d", blue: "#77a8ff" };
10
12
 
11
- 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}FACTORY AI{/} {/bold} private coding organization" });
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}` });
12
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 } } });
13
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 } } });
14
- 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 objective {bold}r{/} refresh {bold}a{/} add secret {bold}p/u{/} pause/resume {bold}q{/} quit" });
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" });
15
17
 
16
18
  let dashboard;
17
19
  let capabilities;
@@ -20,6 +22,11 @@ let logs;
20
22
  let section = "Overview";
21
23
  let refreshing = false;
22
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
+
23
30
  function status(message, color = colors.muted) {
24
31
  footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
25
32
  screen.render();
@@ -37,20 +44,20 @@ function renderOverview() {
37
44
  const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
38
45
  const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
39
46
  const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
40
- 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");
41
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."}`);
42
49
  }
43
50
 
44
51
  function renderObjectives() {
45
52
  main.setContent((dashboard?.objectives ?? []).slice().reverse().map((objective) => {
46
- const tasks = objective.tasks.map((task) => ` ${badge(task.state.padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${task.model}{/}\n ${task.title}`).join("\n");
47
- 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)}{/}` : ""}`;
48
55
  }).join("\n\n────────────────────────────────────────────────────────\n\n") || "No objectives." );
49
56
  }
50
57
 
51
58
  function renderAgents() {
52
59
  const agents = (dashboard?.objectives ?? []).flatMap((objective) => objective.tasks.map((task) => ({ objective: objective.objective, ...task }))).filter((task) => !["succeeded"].includes(task.state));
53
- 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.");
54
61
  }
55
62
 
56
63
  function renderSecrets() {
@@ -75,7 +82,7 @@ function render() {
75
82
  else if (section === "Agents") renderAgents();
76
83
  else if (section === "Secrets") renderSecrets();
77
84
  else if (section === "Capabilities") renderCapabilities();
78
- else if (section === "Logs") main.setContent(logs ?? "Loading logs...");
85
+ else if (section === "Logs") main.setContent(safe(logs ?? "Loading logs..."));
79
86
  else renderSettings();
80
87
  main.setScrollPerc(0);
81
88
  screen.render();
@@ -88,7 +95,7 @@ async function refresh() {
88
95
  try {
89
96
  dashboard = await operator.dashboard();
90
97
  if (section === "Capabilities" && !capabilities) capabilities = await operator.capabilities();
91
- if (section === "Secrets") secrets = await operator.listSecrets();
98
+ if (section === "Secrets") secrets = dashboard?.secrets ?? await operator.listSecrets();
92
99
  if (section === "Logs") logs = await operator.logs();
93
100
  render();
94
101
  status(`Updated ${new Date().toLocaleTimeString()}`);
@@ -121,7 +128,19 @@ async function addSecret() {
121
128
  const value = await ask("Secret value", { secret: true });
122
129
  if (!value) return;
123
130
  status("Storing secret...");
124
- try { await operator.setSecret(name, value); secrets = await operator.listSecrets(); renderSecrets(); status(`Stored ${name}`, colors.accent); } catch (error) { status(error.message, colors.danger); }
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); }
125
144
  }
126
145
 
127
146
  menu.on("select", async (_, index) => { section = menu.getItem(index).getText(); render(); await refresh(); menu.focus(); });
@@ -129,6 +148,8 @@ screen.key(["q", "C-c"], () => process.exit(0));
129
148
  screen.key("r", refresh);
130
149
  screen.key("n", submitObjective);
131
150
  screen.key("a", addSecret);
151
+ screen.key("y", () => decideApproval("approved"));
152
+ screen.key("x", () => decideApproval("denied"));
132
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); } });
133
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); } });
134
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
- export const workMessageSchema = z.object({
52
- type: z.literal("task"),
53
- objectiveId: identifier,
54
- task: taskSchema,
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
+ }
@@ -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
@@ -9,18 +9,44 @@ import { AgentExecutor } from "./agent-executor.js";
9
9
  import { sendMessage } from "./bus.js";
10
10
  import { ContainerAgentRunner } from "./container-runner.js";
11
11
  import { ScannerSuite } from "./scanner-suite.js";
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";
12
16
 
13
17
  process.title = "factory-ai-worker";
14
18
  const config = loadConfig();
15
19
  Object.assign(process.env, await loadRuntimeSecrets(config));
16
20
  await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
17
21
  const bus = createBus(config, config.agentQueue, config.controlQueue);
18
- 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
+ }
19
31
  const executor = new AgentExecutor({
20
32
  workspaces: new WorkspaceManager(config.workspaceDir, config.timeoutMs),
21
- agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs }),
22
- scannerSuite: new ScannerSuite(),
33
+ agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs, activityStore: new ActivityStore(config.stateDir) }),
34
+ scannerSuite,
35
+ retriever: new LocalRetriever({ stateDir: config.stateDir }),
36
+ repoMapMaxCharacters: config.repoMapMaxCharacters,
23
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
+ },
24
50
  });
25
51
 
26
52
  let shuttingDown = false;
@@ -28,6 +54,11 @@ const subscription = bus.receiver.subscribe({
28
54
  processMessage: async (message) => {
29
55
  const body = message.body;
30
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
+ }
31
62
  await executor.process(body);
32
63
  await bus.receiver.completeMessage(message);
33
64
  } catch (error) {