factory-ai 1.1.0 → 1.2.1

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 CHANGED
@@ -11,6 +11,19 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
11
11
  - Azure and Bedrock provider wizard.
12
12
  - Durable evaluation, analytics, scaling, policy, extension, and recovery roadmap.
13
13
 
14
+ ## [1.2.1] - 2026-07-13
15
+
16
+ ### Fixed
17
+
18
+ - Make updater installation idempotent when the staged application already occupies the final runtime path.
19
+
20
+ ## [1.2.0] - 2026-07-13
21
+
22
+ ### Added
23
+
24
+ - Telegram natural-language objectives, default repositories, objective lookup, and automatic progress/completion notifications.
25
+ - Six-hour verified stable updates with CI/security gates, durable version records, major-version blocking, and rollback.
26
+
14
27
  ## [1.1.0] - 2026-07-13
15
28
 
16
29
  ### Added
package/README.md CHANGED
@@ -201,7 +201,20 @@ Only explicitly allowlisted chat IDs are accepted. Supported commands:
201
201
  /help
202
202
  ```
203
203
 
204
- Telegram cannot run shell commands, read secrets, modify release policy, or bypass review gates. Durable update offsets prevent duplicate objectives after restarts.
204
+ Set a default repository with `/repo OWNER/REPO`, then send plain-text instructions without a command. `/recent` lists recent objectives and `/objective ID` shows task-level detail. Factory AI automatically pushes deduplicated status, active-agent, completion, PR, failure, and blocker updates to the originating chat.
205
+
206
+ Telegram cannot run shell commands, read secrets, modify release policy, or bypass review gates. Durable update offsets, repository preferences, and objective subscriptions survive restarts.
207
+
208
+ ## Verified Automatic Updates
209
+
210
+ The VM checks npm stable releases every six hours with a randomized delay. Updates are accepted only when:
211
+
212
+ - The release remains within the installed major version.
213
+ - npm `gitHead` resolves to the exact GitHub commit.
214
+ - The commit has successful CI.
215
+ - A fresh isolated clone passes install, syntax, lint, tests, dependency audit, Bicep, shell validation, and Gitleaks.
216
+
217
+ The updater records the installed version on retained storage and restores the previous commit if deployment fails. Major upgrades always require explicit operator action.
205
218
 
206
219
  ## Security
207
220
 
package/RUNBOOK.md CHANGED
@@ -31,6 +31,8 @@ factory setup
31
31
 
32
32
  Override defaults with `FACTORY_RESOURCE_GROUP`, `FACTORY_VM`, and `FACTORY_SERVICE_BUS`.
33
33
 
34
+ Verified stable updates run every six hours through `factory-ai-update.timer`. Inspect with `systemctl status factory-ai-update.timer` and `journalctl -u factory-ai-update.service`. Automatic major-version upgrades are intentionally blocked.
35
+
34
36
  ## Recovery
35
37
 
36
38
  If a worker dies, systemd restarts it and Service Bus redelivers after lock expiry. Do not manually duplicate the task. Check `dashboard`, then `logs`, then queue dead-letter counts. Preserve objective state before purging dead letters.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ exec 9>/var/lock/factory-ai-update.lock
5
+ flock -n 9 || { printf 'Factory AI update already running.\n'; exit 0; }
6
+
7
+ app=/opt/agent-factory/app
8
+ state=/opt/agent-factory/state
9
+ status=$(FACTORY_PACKAGE_FILE="$app/package.json" node "$app/src/updater.js")
10
+ available=$(jq -r .updateAvailable <<<"$status")
11
+ [[ $available == true ]] || { printf 'Factory AI is current: %s\n' "$(jq -r .current <<<"$status")"; exit 0; }
12
+
13
+ version=$(jq -r .latest <<<"$status")
14
+ commit=$(jq -r .gitHead <<<"$status")
15
+ [[ $commit =~ ^[0-9a-f]{40}$ ]] || { printf 'npm release does not contain a valid gitHead.\n' >&2; exit 1; }
16
+
17
+ set -a
18
+ source /etc/agent-factory.env
19
+ set +a
20
+ github_token=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name github-token --query value --output tsv)
21
+ checks=$(GH_TOKEN="$github_token" gh api "repos/itsvedantkumar/factory-ai/commits/$commit/check-runs" --jq '[.check_runs[] | select(.name == "verify") | .conclusion] | any(. == "success")')
22
+ [[ $checks == true ]] || { printf 'Candidate commit lacks successful CI verification.\n' >&2; exit 1; }
23
+
24
+ temporary=$(mktemp -d /var/tmp/factory-ai-update.XXXXXX)
25
+ trap 'rm -rf "$temporary"; unset github_token' EXIT
26
+ previous_commit=$(jq -r '.commit // empty' "$state/runtime-version.json" 2>/dev/null || true)
27
+ cp "$app/bootstrap/deploy-runtime.sh" "$temporary/rollback-deploy.sh"
28
+ GH_TOKEN="$github_token" gh repo clone itsvedantkumar/factory-ai "$temporary/source" -- --no-checkout
29
+ git -C "$temporary/source" checkout --detach "$commit"
30
+ test "$(git -C "$temporary/source" rev-parse HEAD)" = "$commit"
31
+
32
+ npm ci --prefix "$temporary/source"
33
+ npm run check --prefix "$temporary/source"
34
+ npm run lint --prefix "$temporary/source"
35
+ npm test --prefix "$temporary/source"
36
+ npm audit --prefix "$temporary/source" --audit-level=high
37
+ az bicep build --file "$temporary/source/infra/main.bicep" --stdout >/dev/null
38
+ bash -n "$temporary/source/bootstrap/setup.sh" "$temporary/source/bootstrap/deploy-runtime.sh" "$temporary/source/bin/factory"
39
+ docker run --rm --read-only --volume "$temporary/source:/workspace:ro" \
40
+ zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
41
+ detect --source /workspace --redact --no-banner --exit-code 1
42
+
43
+ printf 'Deploying Factory AI %s (%s)\n' "$version" "$commit"
44
+ if ! bash "$temporary/source/bootstrap/deploy-runtime.sh" \
45
+ KEY_VAULT_NAME "$KEY_VAULT_NAME" \
46
+ SERVICE_BUS_NAMESPACE "$SERVICE_BUS_NAMESPACE" \
47
+ SERVICE_BUS_QUEUE code-tasks \
48
+ SOURCE_REPOSITORY itsvedantkumar/factory-ai \
49
+ SOURCE_REF "$commit"; then
50
+ printf 'Candidate deployment failed. Restoring previous runtime.\n' >&2
51
+ if [[ $previous_commit =~ ^[0-9a-f]{40}$ ]]; then
52
+ bash "$temporary/rollback-deploy.sh" \
53
+ KEY_VAULT_NAME "$KEY_VAULT_NAME" \
54
+ SERVICE_BUS_NAMESPACE "$SERVICE_BUS_NAMESPACE" \
55
+ SERVICE_BUS_QUEUE code-tasks \
56
+ SOURCE_REPOSITORY itsvedantkumar/factory-ai \
57
+ SOURCE_REF "$previous_commit"
58
+ fi
59
+ exit 1
60
+ fi
61
+ printf 'Factory AI updated to %s.\n' "$version"
@@ -72,3 +72,11 @@ FACTORY_MODEL_REVIEWER="${FACTORY_MODEL_REVIEWER:-}" \
72
72
  FACTORY_MODEL_SECURITY="${FACTORY_MODEL_SECURITY:-}" \
73
73
  FACTORY_MODEL_RELEASE="${FACTORY_MODEL_RELEASE:-}" \
74
74
  bash /opt/agent-factory/app/bootstrap/setup.sh
75
+ install -d -o factory -g factory -m 0750 /opt/agent-factory/state
76
+ version=$(node -p 'require("/opt/agent-factory/app/package.json").version')
77
+ jq -n --arg version "$version" --arg commit "$SOURCE_REF" --arg repository "$SOURCE_REPOSITORY" \
78
+ '{version:$version,commit:$commit,repository:$repository,installedAt:(now|todate)}' \
79
+ > /opt/agent-factory/state/runtime-version.json.tmp
80
+ chown factory:factory /opt/agent-factory/state/runtime-version.json.tmp
81
+ chmod 0640 /opt/agent-factory/state/runtime-version.json.tmp
82
+ mv /opt/agent-factory/state/runtime-version.json.tmp /opt/agent-factory/state/runtime-version.json
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Factory AI verified stable update
3
+ After=network-online.target docker.service
4
+ Wants=network-online.target
5
+ Requires=docker.service
6
+
7
+ [Service]
8
+ Type=oneshot
9
+ User=root
10
+ Group=root
11
+ ExecStart=/opt/agent-factory/app/bootstrap/auto-update.sh
12
+ Nice=10
13
+ IOSchedulingClass=best-effort
14
+ IOSchedulingPriority=7
15
+ PrivateTmp=true
16
+ ProtectHome=true
17
+ UMask=0027
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Check for verified Factory AI updates every six hours
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* 00/6:00:00
6
+ RandomizedDelaySec=30m
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -114,11 +114,15 @@ install -m 0644 "$APP_DIR/bootstrap/agent-factory-release.service" /etc/systemd/
114
114
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-reporter.service" /etc/systemd/system/agent-factory-reporter.service
115
115
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-reporter.timer" /etc/systemd/system/agent-factory-reporter.timer
116
116
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-telegram.service" /etc/systemd/system/agent-factory-telegram.service
117
+ chmod 0755 "$APP_DIR/bootstrap/auto-update.sh"
118
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.service" /etc/systemd/system/factory-ai-update.service
119
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.timer" /etc/systemd/system/factory-ai-update.timer
117
120
  systemctl daemon-reload
118
121
  systemctl enable --now agent-factory-worker.service
119
122
  systemctl enable --now agent-factory-control.service
120
123
  systemctl enable --now agent-factory-release.service
121
124
  systemctl enable --now agent-factory-reporter.timer
122
125
  systemctl enable --now agent-factory-telegram.service
126
+ systemctl enable --now factory-ai-update.timer
123
127
  systemctl restart agent-factory-control.service agent-factory-worker.service agent-factory-release.service
124
128
  echo "Agent factory worker installed"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { createHash } from "node:crypto";
4
5
  import { DefaultAzureCredential } from "@azure/identity";
5
6
  import { ServiceBusClient } from "@azure/service-bus";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { loadRuntimeSecrets } from "./secrets.js";
8
9
  import { sendMessage } from "./bus.js";
9
10
  import { loadLocalState, loadQueueMetrics } from "./dashboard.js";
10
- import { isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
11
+ import { formatObjectiveProgress, isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
11
12
  import { log } from "./log.js";
12
13
 
13
14
  const config = loadConfig();
@@ -21,9 +22,16 @@ if (!token || allowed.size === 0) {
21
22
 
22
23
  const directory = path.join(config.stateDir, "telegram");
23
24
  const offsetFile = path.join(directory, "offset");
25
+ const preferencesFile = path.join(directory, "preferences.json");
26
+ const subscriptionsFile = path.join(directory, "subscriptions.json");
24
27
  await mkdir(directory, { recursive: true, mode: 0o750 });
25
28
  let offset = 0;
26
29
  try { offset = Number(await readFile(offsetFile, "utf8")) || 0; } catch (error) { if (error.code !== "ENOENT") throw error; }
30
+ async function loadJson(file) {
31
+ try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (error.code === "ENOENT") return {}; throw error; }
32
+ }
33
+ const preferences = await loadJson(preferencesFile);
34
+ const subscriptions = await loadJson(subscriptionsFile);
27
35
 
28
36
  const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
29
37
  const sender = client.createSender(config.controlQueue);
@@ -51,6 +59,12 @@ async function saveOffset(value) {
51
59
  await rename(temporary, offsetFile);
52
60
  }
53
61
 
62
+ async function saveJson(file, value) {
63
+ const temporary = `${file}.${process.pid}.tmp`;
64
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
65
+ await rename(temporary, file);
66
+ }
67
+
54
68
  async function statusText() {
55
69
  const [{ states }, queue] = await Promise.all([loadLocalState(config.stateDir), loadQueueMetrics(config)]);
56
70
  const counts = {};
@@ -62,17 +76,52 @@ async function processUpdate(update) {
62
76
  const message = update.message;
63
77
  if (!message?.text || !isAllowedChat(message.chat?.id, allowed)) return;
64
78
  try {
65
- const command = parseTelegramCommand(message.text);
66
- if (command.type === "help") return reply(message.chat.id, "/submit OWNER/REPO objective\n/goal OWNER/REPO objective\n/loop OWNER/REPO objective\n/status\n/help");
79
+ const chatId = String(message.chat.id);
80
+ const command = parseTelegramCommand(message.text, preferences[chatId]?.repository);
81
+ if (command.type === "help") return reply(message.chat.id, "/repo OWNER/REPO\nPlain text objective\n/submit OWNER/REPO objective\n/goal [OWNER/REPO] objective\n/loop [OWNER/REPO] objective\n/status\n/recent\n/objective ID\n/help");
67
82
  if (command.type === "status") return reply(message.chat.id, await statusText());
83
+ if (command.type === "set_repository") {
84
+ preferences[chatId] = { repository: command.repository, updatedAt: new Date().toISOString() };
85
+ await saveJson(preferencesFile, preferences);
86
+ return reply(message.chat.id, `Default repository set to ${command.repository}. You can now send plain-text objectives.`);
87
+ }
88
+ if (command.type === "recent") {
89
+ const { states } = await loadLocalState(config.stateDir);
90
+ const text = states.slice(-10).reverse().map((state) => `${state.status ?? "unknown"} — ${state.objective?.id}\n${String(state.objective?.objective ?? "").slice(0, 150)}`).join("\n\n") || "No objectives.";
91
+ return reply(message.chat.id, text);
92
+ }
93
+ if (command.type === "objective") {
94
+ const state = JSON.parse(await readFile(path.join(config.stateDir, command.objectiveId, "state.json"), "utf8"));
95
+ return reply(message.chat.id, formatObjectiveProgress(state));
96
+ }
68
97
  const objective = objectiveFromTelegram(update.update_id, command);
69
98
  await sendMessage(sender, objective, objective.id);
99
+ subscriptions[objective.id] = { chatId, lastDigest: "", createdAt: new Date().toISOString() };
100
+ await saveJson(subscriptionsFile, subscriptions);
70
101
  await reply(message.chat.id, `Queued ${objective.id}\n${command.repository}\n${command.objective}`);
71
102
  } catch (error) {
72
103
  await reply(message.chat.id, `Rejected: ${String(error.message ?? error).slice(0, 500)}`);
73
104
  }
74
105
  }
75
106
 
107
+ async function notifyProgress() {
108
+ let changed = false;
109
+ for (const [objectiveId, subscription] of Object.entries(subscriptions)) {
110
+ let state;
111
+ try { state = JSON.parse(await readFile(path.join(config.stateDir, objectiveId, "state.json"), "utf8")); }
112
+ catch (error) { if (error.code === "ENOENT") continue; throw error; }
113
+ const text = formatObjectiveProgress(state);
114
+ const digest = createHash("sha256").update(text).digest("hex");
115
+ if (digest === subscription.lastDigest) continue;
116
+ await reply(subscription.chatId, text);
117
+ subscription.lastDigest = digest;
118
+ subscription.lastStatus = state.status;
119
+ subscription.notifiedAt = new Date().toISOString();
120
+ changed = true;
121
+ }
122
+ if (changed) await saveJson(subscriptionsFile, subscriptions);
123
+ }
124
+
76
125
  async function shutdown(signal) {
77
126
  log("info", "telegram_shutdown", { signal });
78
127
  abort.abort();
@@ -91,6 +140,7 @@ while (!abort.signal.aborted) {
91
140
  offset = update.update_id + 1;
92
141
  await saveOffset(offset);
93
142
  }
143
+ await notifyProgress();
94
144
  } catch (error) {
95
145
  if (abort.signal.aborted) break;
96
146
  log("error", "telegram_poll_failed", { error: String(error.message ?? error).replaceAll(token, "[REDACTED]").slice(0, 1000) });
package/src/telegram.js CHANGED
@@ -4,18 +4,26 @@ export function isAllowedChat(chatId, allowed) {
4
4
  return allowed.size > 0 && allowed.has(String(chatId));
5
5
  }
6
6
 
7
- export function parseTelegramCommand(input) {
7
+ export function parseTelegramCommand(input, defaultRepository) {
8
8
  const text = String(input ?? "").trim();
9
9
  const [rawCommand, repository, ...words] = text.split(/\s+/);
10
10
  const command = rawCommand?.split("@")[0];
11
- if (["/status", "/help"].includes(command) && !repository) return { type: command.slice(1) };
11
+ if (!text.startsWith("/") && defaultRepository) {
12
+ if (text.length > 8000) throw new Error("Objective is too long");
13
+ return { type: "submit", repository: defaultRepository, objective: text };
14
+ }
15
+ if (["/status", "/help", "/recent"].includes(command) && !repository) return { type: command.slice(1) };
16
+ if (command === "/objective" && /^[A-Za-z0-9_-]{1,64}$/.test(repository ?? "") && words.length === 0) return { type: "objective", objectiveId: repository };
17
+ if (command === "/repo" && repositoryPattern.test(repository ?? "") && words.length === 0) return { type: "set_repository", repository };
12
18
  if (!["/submit", "/goal", "/loop"].includes(command)) throw new Error("Unknown command. Use /help");
13
- if (!repositoryPattern.test(repository ?? "")) throw new Error("Repository must be OWNER/REPO");
14
- const objective = words.join(" ").trim();
19
+ const explicitRepository = repositoryPattern.test(repository ?? "");
20
+ const selectedRepository = explicitRepository ? repository : defaultRepository;
21
+ if (!repositoryPattern.test(selectedRepository ?? "")) throw new Error("Repository must be OWNER/REPO or configured with /repo");
22
+ const objective = (explicitRepository ? words : [repository, ...words]).join(" ").trim();
15
23
  if (objective.length < 3) throw new Error("Objective is required");
16
24
  if (objective.length > 8000) throw new Error("Objective is too long");
17
25
  const prefix = command === "/submit" ? "" : `${command} `;
18
- return { type: "submit", repository, objective: `${prefix}${objective}` };
26
+ return { type: "submit", repository: selectedRepository, objective: `${prefix}${objective}` };
19
27
  }
20
28
 
21
29
  export function objectiveFromTelegram(updateId, command, now = new Date()) {
@@ -29,3 +37,19 @@ export function objectiveFromTelegram(updateId, command, now = new Date()) {
29
37
  createdAt: now.toISOString(),
30
38
  };
31
39
  }
40
+
41
+ export function formatObjectiveProgress(state) {
42
+ const tasks = state.tasks ?? [];
43
+ const results = state.results ?? {};
44
+ const complete = tasks.filter((task) => results[task.id]?.status === "succeeded").length;
45
+ const lines = [
46
+ `Factory AI objective ${state.objective?.id ?? "unknown"}`,
47
+ state.objective?.objective ?? "",
48
+ `Status: ${state.status ?? "unknown"}`,
49
+ `${complete}/${tasks.length} tasks complete`,
50
+ ];
51
+ for (const task of tasks.slice(0, 20)) lines.push(`${task.role}: ${results[task.id]?.status ?? "blocked"} — ${task.title}`);
52
+ if (state.failure) lines.push(`Blocker: ${String(state.failure).slice(0, 500)}`);
53
+ if (state.release?.url) lines.push(`PR: ${state.release.url}`);
54
+ return lines.join("\n").slice(0, 4000);
55
+ }
package/src/updater.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ function parts(value) {
6
+ if (!/^\d+\.\d+\.\d+$/.test(value)) throw new Error(`Unsupported version: ${value}`);
7
+ return value.split(".").map(Number);
8
+ }
9
+
10
+ export function compareVersions(left, right) {
11
+ const a = parts(left);
12
+ const b = parts(right);
13
+ for (let index = 0; index < 3; index += 1) {
14
+ if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ export function shouldAutoUpdate(current, latest) {
20
+ const currentParts = parts(current);
21
+ const latestParts = parts(latest);
22
+ return currentParts[0] === latestParts[0] && compareVersions(latest, current) > 0;
23
+ }
24
+
25
+ async function main() {
26
+ const packageFile = process.env.FACTORY_PACKAGE_FILE ?? "/opt/agent-factory/app/package.json";
27
+ const current = JSON.parse(await readFile(packageFile, "utf8")).version;
28
+ const response = await fetch("https://registry.npmjs.org/factory-ai/latest", { signal: AbortSignal.timeout(30_000) });
29
+ if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`);
30
+ const latestPackage = await response.json();
31
+ process.stdout.write(`${JSON.stringify({
32
+ current,
33
+ latest: latestPackage.version,
34
+ gitHead: latestPackage.gitHead,
35
+ updateAvailable: shouldAutoUpdate(current, latestPackage.version),
36
+ })}\n`);
37
+ }
38
+
39
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
40
+ process.stderr.write(`${error.message}\n`);
41
+ process.exitCode = 1;
42
+ });