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 +13 -0
- package/README.md +14 -1
- package/RUNBOOK.md +2 -0
- package/bootstrap/auto-update.sh +61 -0
- package/bootstrap/deploy-runtime.sh +8 -0
- package/bootstrap/factory-ai-update.service +17 -0
- package/bootstrap/factory-ai-update.timer +10 -0
- package/bootstrap/setup.sh +4 -0
- package/package.json +1 -1
- package/src/telegram-service.js +53 -3
- package/src/telegram.js +29 -5
- package/src/updater.js +42 -0
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
|
-
|
|
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
|
package/bootstrap/setup.sh
CHANGED
|
@@ -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
|
|
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",
|
package/src/telegram-service.js
CHANGED
|
@@ -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
|
|
66
|
-
|
|
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 (
|
|
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
|
-
|
|
14
|
-
const
|
|
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
|
+
});
|