@replayio/app-building 1.0.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/README.md +92 -0
- package/dist/package/container-registry.d.ts +17 -0
- package/dist/package/container-registry.js +78 -0
- package/dist/package/container-utils.d.ts +5 -0
- package/dist/package/container-utils.js +30 -0
- package/dist/package/container.d.ts +29 -0
- package/dist/package/container.js +287 -0
- package/dist/package/fly.d.ts +28 -0
- package/dist/package/fly.js +118 -0
- package/dist/package/http-client.d.ts +6 -0
- package/dist/package/http-client.js +30 -0
- package/dist/package/image-ref.d.ts +1 -0
- package/dist/package/image-ref.js +4 -0
- package/dist/package/index.d.ts +6 -0
- package/dist/package/index.js +6 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# app-building
|
|
2
|
+
|
|
3
|
+
Simple and extensible platform for dark factory agentic app building: creating apps
|
|
4
|
+
according to a spec without human involvement along the way. Example use cases:
|
|
5
|
+
|
|
6
|
+
* `npm run agent -- -p "Build me an app XYZ based on this spec: ..."`
|
|
7
|
+
* `npm run agent -- -p "Continue maintaining app XYZ and fix these bugs: ..."`
|
|
8
|
+
* `npm run agent -- -i` for interactive access to the agent.
|
|
9
|
+
|
|
10
|
+
Core ideas:
|
|
11
|
+
|
|
12
|
+
* The agent runs within a docker container that clones the target repo and exposes an HTTP server for control.
|
|
13
|
+
* The host communicates with the container via HTTP — sending prompts, polling events/logs, and managing lifecycle.
|
|
14
|
+
* The agent builds by following a set of skill documents with guides
|
|
15
|
+
for breaking its work down into jobs and directives for performing those tasks.
|
|
16
|
+
* The agent commits logs for it to review later and improve its skills.
|
|
17
|
+
* All code changes are committed and pushed back to the remote from inside the container.
|
|
18
|
+
|
|
19
|
+
Containers can run locally or remotely.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Copy `.env.example` to `.env` and fill in all required API keys.
|
|
28
|
+
|
|
29
|
+
## Running the Agent
|
|
30
|
+
|
|
31
|
+
`npm run agent` starts a new container with the running agent. By default the container is local, add `--remote` to spawn the container remotely. This requires FLY_API_TOKEN in .env
|
|
32
|
+
|
|
33
|
+
### Detached mode
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run agent
|
|
37
|
+
npm run agent -- -p "<prompt>"
|
|
38
|
+
npm run agent -- --branch dev --push-branch feature/xyz -p "<prompt>"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Starts a container, optionally queues a prompt, then detaches. The container processes the prompt followed by any pending tasks, commits and pushes results, then exits.
|
|
42
|
+
|
|
43
|
+
### Interactive mode
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run agent -- -i
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Chat with the agent inside a container. Output is streamed via event polling. Press ESC to interrupt the current message. On exit, the container is detached and finishes any remaining work.
|
|
50
|
+
|
|
51
|
+
### Checking status
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm run status
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Connects to the running container's HTTP API and shows state, revision, queue depth, cost, and recent log output. Tails logs in real-time (Ctrl+C to stop). Errors if no agent is running.
|
|
58
|
+
|
|
59
|
+
### Stopping the agent
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm run stop
|
|
63
|
+
npm run stop -- <containerName>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Sends an HTTP stop signal. Without arguments, finds and stops all running containers. Pass a container name to stop a specific one.
|
|
67
|
+
|
|
68
|
+
## Skills
|
|
69
|
+
|
|
70
|
+
The provided skill documents emphasize a structured approach for autonomously building
|
|
71
|
+
high quality, well tested apps from the initial spec. During the initial app build
|
|
72
|
+
it does the following:
|
|
73
|
+
|
|
74
|
+
1. Designs a comprehensive test specification based on the initial spec.
|
|
75
|
+
2. Builds the app and writes tests to match the spec.
|
|
76
|
+
3. Gets all the tests to pass, deploys to production, and does further testing.
|
|
77
|
+
|
|
78
|
+
The initial build will not come out perfect. The agent can followup with maintenance passes
|
|
79
|
+
where it checks to make sure it closely followed the spec and skill directives and fixes
|
|
80
|
+
any issues it finds. It will also fix reported bugs and update the skills to avoid
|
|
81
|
+
similar problems in the future.
|
|
82
|
+
|
|
83
|
+
As long as each individual step the agent takes is within its capabilities (it can usually
|
|
84
|
+
do it but not always) the agent will converge on an app that follows the initial spec
|
|
85
|
+
and skill directives.
|
|
86
|
+
|
|
87
|
+
Key things to watch out for:
|
|
88
|
+
|
|
89
|
+
* Best suited for CRUD and API-calling apps up to a medium level of complexity.
|
|
90
|
+
Overly complicated or specialized apps will not work as well yet.
|
|
91
|
+
* Make sure to get a Replay API key and configure it. The agent will use Replay to identify
|
|
92
|
+
and debug problems it encounters in tests or the deployed app.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentState } from "./container";
|
|
2
|
+
export interface RegistryEntry extends AgentState {
|
|
3
|
+
startedAt: string;
|
|
4
|
+
stoppedAt?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class ContainerRegistry {
|
|
7
|
+
private filePath;
|
|
8
|
+
constructor(filePath: string);
|
|
9
|
+
private readRegistry;
|
|
10
|
+
private updateEntry;
|
|
11
|
+
log(state: AgentState): void;
|
|
12
|
+
markStopped(containerName?: string): void;
|
|
13
|
+
clearStopped(containerName: string): void;
|
|
14
|
+
getRecent(limit?: number): RegistryEntry[];
|
|
15
|
+
find(containerName: string): RegistryEntry | null;
|
|
16
|
+
findAlive(): Promise<RegistryEntry[]>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
|
|
2
|
+
import { probeAlive } from "./container-utils";
|
|
3
|
+
export class ContainerRegistry {
|
|
4
|
+
filePath;
|
|
5
|
+
constructor(filePath) {
|
|
6
|
+
this.filePath = filePath;
|
|
7
|
+
}
|
|
8
|
+
readRegistry() {
|
|
9
|
+
if (!existsSync(this.filePath))
|
|
10
|
+
return { lines: [], entries: [] };
|
|
11
|
+
const lines = readFileSync(this.filePath, "utf-8").split("\n").filter((l) => l.trim());
|
|
12
|
+
const entries = lines.map((line) => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(line);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return { lines, entries };
|
|
21
|
+
}
|
|
22
|
+
updateEntry(match, update) {
|
|
23
|
+
const { lines, entries } = this.readRegistry();
|
|
24
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
25
|
+
const entry = entries[i];
|
|
26
|
+
if (entry && match(entry)) {
|
|
27
|
+
update(entry);
|
|
28
|
+
lines[i] = JSON.stringify(entry);
|
|
29
|
+
writeFileSync(this.filePath, lines.join("\n") + "\n");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
log(state) {
|
|
35
|
+
const entry = {
|
|
36
|
+
...state,
|
|
37
|
+
startedAt: new Date().toISOString(),
|
|
38
|
+
};
|
|
39
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
40
|
+
}
|
|
41
|
+
markStopped(containerName) {
|
|
42
|
+
this.updateEntry((e) => !e.stoppedAt && (!containerName || e.containerName === containerName), (e) => { e.stoppedAt = new Date().toISOString(); });
|
|
43
|
+
}
|
|
44
|
+
clearStopped(containerName) {
|
|
45
|
+
this.updateEntry((e) => e.containerName === containerName && !!e.stoppedAt, (e) => { delete e.stoppedAt; });
|
|
46
|
+
}
|
|
47
|
+
getRecent(limit = 20) {
|
|
48
|
+
const { entries } = this.readRegistry();
|
|
49
|
+
return entries.filter((e) => e !== null).slice(-limit);
|
|
50
|
+
}
|
|
51
|
+
find(containerName) {
|
|
52
|
+
const entries = this.getRecent(100);
|
|
53
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
54
|
+
if (entries[i].containerName === containerName) {
|
|
55
|
+
return entries[i];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
async findAlive() {
|
|
61
|
+
const entries = this.getRecent();
|
|
62
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
63
|
+
const candidates = entries.filter((e) => new Date(e.startedAt).getTime() > oneDayAgo);
|
|
64
|
+
const aliveResults = await Promise.all(candidates.map(async (entry) => ({
|
|
65
|
+
entry,
|
|
66
|
+
alive: await probeAlive(entry),
|
|
67
|
+
})));
|
|
68
|
+
for (const r of aliveResults) {
|
|
69
|
+
if (r.alive && r.entry.stoppedAt) {
|
|
70
|
+
this.clearStopped(r.entry.containerName);
|
|
71
|
+
}
|
|
72
|
+
else if (!r.alive && !r.entry.stoppedAt) {
|
|
73
|
+
this.markStopped(r.entry.containerName);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return aliveResults.filter((r) => r.alive).map((r) => r.entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentState } from "./container";
|
|
2
|
+
import type { RegistryEntry } from "./container-registry";
|
|
3
|
+
import type { HttpOptions } from "./http-client";
|
|
4
|
+
export declare function httpOptsFor(state: AgentState): HttpOptions;
|
|
5
|
+
export declare function probeAlive(entry: RegistryEntry): Promise<boolean>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function httpOptsFor(state) {
|
|
2
|
+
if (state.type === "remote" && state.flyMachineId) {
|
|
3
|
+
return { headers: { "fly-force-instance-id": state.flyMachineId } };
|
|
4
|
+
}
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
export async function probeAlive(entry) {
|
|
8
|
+
try {
|
|
9
|
+
const headers = {};
|
|
10
|
+
if (entry.type === "remote" && entry.flyMachineId) {
|
|
11
|
+
headers["fly-force-instance-id"] = entry.flyMachineId;
|
|
12
|
+
}
|
|
13
|
+
const res = await fetch(`${entry.baseUrl}/status`, {
|
|
14
|
+
headers,
|
|
15
|
+
signal: AbortSignal.timeout(5000),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return false;
|
|
19
|
+
// Verify the response is actually from the expected container,
|
|
20
|
+
// not a different machine on the same Fly app.
|
|
21
|
+
const body = await res.json();
|
|
22
|
+
if (body.containerName && body.containerName !== entry.containerName) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ContainerRegistry } from "./container-registry";
|
|
2
|
+
export interface AgentState {
|
|
3
|
+
type: "local" | "remote";
|
|
4
|
+
containerName: string;
|
|
5
|
+
port: number;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
flyApp?: string;
|
|
8
|
+
flyMachineId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ContainerConfig {
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
envVars: Record<string, string>;
|
|
13
|
+
registry: ContainerRegistry;
|
|
14
|
+
flyToken?: string;
|
|
15
|
+
flyApp?: string;
|
|
16
|
+
imageRef?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RepoOptions {
|
|
19
|
+
repoUrl: string;
|
|
20
|
+
cloneBranch: string;
|
|
21
|
+
pushBranch: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function loadDotEnv(projectRoot: string): Record<string, string>;
|
|
24
|
+
export declare function buildImage(config: ContainerConfig): void;
|
|
25
|
+
export declare function startContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
|
|
26
|
+
export declare function startRemoteContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
|
|
27
|
+
export declare function stopRemoteContainer(config: ContainerConfig, state: AgentState): Promise<void>;
|
|
28
|
+
export declare function stopContainer(config: ContainerConfig, containerName: string): void;
|
|
29
|
+
export declare function spawnTestContainer(config: ContainerConfig): Promise<void>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "child_process";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { createMachine, waitForMachine, destroyMachine, listMachines } from "./fly";
|
|
5
|
+
import { getImageRef } from "./image-ref";
|
|
6
|
+
const IMAGE_NAME = "app-building";
|
|
7
|
+
export function loadDotEnv(projectRoot) {
|
|
8
|
+
const envPath = resolve(projectRoot, ".env");
|
|
9
|
+
if (!existsSync(envPath)) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
const content = readFileSync(envPath, "utf-8");
|
|
13
|
+
const vars = {};
|
|
14
|
+
for (const line of content.split("\n")) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
17
|
+
continue;
|
|
18
|
+
const eqIndex = trimmed.indexOf("=");
|
|
19
|
+
if (eqIndex === -1)
|
|
20
|
+
continue;
|
|
21
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
22
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
23
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
24
|
+
value = value.slice(1, -1);
|
|
25
|
+
}
|
|
26
|
+
vars[key] = value;
|
|
27
|
+
}
|
|
28
|
+
return vars;
|
|
29
|
+
}
|
|
30
|
+
export function buildImage(config) {
|
|
31
|
+
console.log("Building Docker image...");
|
|
32
|
+
execFileSync("docker", ["build", "--network", "host", "-t", IMAGE_NAME, config.projectRoot], {
|
|
33
|
+
stdio: "inherit",
|
|
34
|
+
timeout: 600000,
|
|
35
|
+
});
|
|
36
|
+
console.log("Docker image built successfully.");
|
|
37
|
+
}
|
|
38
|
+
function ensureImageExists(projectRoot) {
|
|
39
|
+
try {
|
|
40
|
+
execFileSync("docker", ["image", "inspect", IMAGE_NAME], {
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
console.log("Building Docker image...");
|
|
46
|
+
execFileSync("docker", ["build", "--network", "host", "-t", IMAGE_NAME, projectRoot], {
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
timeout: 600000,
|
|
49
|
+
});
|
|
50
|
+
console.log("Docker image built successfully.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function findFreePort() {
|
|
54
|
+
let port = 3100;
|
|
55
|
+
try {
|
|
56
|
+
const out = execFileSync("ss", ["-tlnH"], {
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
});
|
|
60
|
+
const usedPorts = new Set();
|
|
61
|
+
for (const match of out.matchAll(/:(\d+)\s/g)) {
|
|
62
|
+
usedPorts.add(parseInt(match[1], 10));
|
|
63
|
+
}
|
|
64
|
+
while (usedPorts.has(port))
|
|
65
|
+
port++;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// ss not available, just use default
|
|
69
|
+
}
|
|
70
|
+
return port;
|
|
71
|
+
}
|
|
72
|
+
function buildContainerEnv(repo, envVars, extra = {}) {
|
|
73
|
+
const env = {
|
|
74
|
+
REPO_URL: repo.repoUrl,
|
|
75
|
+
CLONE_BRANCH: repo.cloneBranch,
|
|
76
|
+
PUSH_BRANCH: repo.pushBranch,
|
|
77
|
+
GIT_AUTHOR_NAME: "App Builder",
|
|
78
|
+
GIT_AUTHOR_EMAIL: "app-builder@localhost",
|
|
79
|
+
GIT_COMMITTER_NAME: "App Builder",
|
|
80
|
+
GIT_COMMITTER_EMAIL: "app-builder@localhost",
|
|
81
|
+
PLAYWRIGHT_BROWSERS_PATH: "/opt/playwright",
|
|
82
|
+
...envVars,
|
|
83
|
+
...extra,
|
|
84
|
+
};
|
|
85
|
+
if (process.env.DEBUG) {
|
|
86
|
+
env.DEBUG = process.env.DEBUG;
|
|
87
|
+
}
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
export async function startContainer(config, repo) {
|
|
91
|
+
buildImage(config);
|
|
92
|
+
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
93
|
+
const containerName = `app-building-${uniqueId}`;
|
|
94
|
+
const hostPort = findFreePort();
|
|
95
|
+
const containerEnv = buildContainerEnv(repo, config.envVars, {
|
|
96
|
+
PORT: String(hostPort),
|
|
97
|
+
CONTAINER_NAME: containerName,
|
|
98
|
+
});
|
|
99
|
+
// Build docker run args
|
|
100
|
+
const args = ["run", "-d", "--rm", "--name", containerName];
|
|
101
|
+
// --network host: container shares host network stack (no -p needed)
|
|
102
|
+
args.push("--network", "host");
|
|
103
|
+
for (const [k, v] of Object.entries(containerEnv)) {
|
|
104
|
+
args.push("--env", `${k}=${v}`);
|
|
105
|
+
}
|
|
106
|
+
// Image name — CMD is baked in (server.ts)
|
|
107
|
+
args.push(IMAGE_NAME);
|
|
108
|
+
const containerId = execFileSync("docker", args, {
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: 30000,
|
|
111
|
+
}).trim();
|
|
112
|
+
console.log(`Container started: ${containerId.slice(0, 12)} (${containerName})`);
|
|
113
|
+
// Wait for the HTTP server to become ready
|
|
114
|
+
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
|
115
|
+
const maxWait = 120000; // clone can take a while
|
|
116
|
+
const interval = 1000;
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
let ready = false;
|
|
119
|
+
while (Date.now() - start < maxWait) {
|
|
120
|
+
// Check if the container is still alive (--rm removes it on exit)
|
|
121
|
+
try {
|
|
122
|
+
execFileSync("docker", ["inspect", "--format", "{{.State.Running}}", containerName], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Container is gone — grab logs from docker if possible, otherwise just report
|
|
126
|
+
let logs = "";
|
|
127
|
+
try {
|
|
128
|
+
logs = execFileSync("docker", ["logs", "--tail", "30", containerName], {
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
timeout: 5000,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Container already removed (--rm)
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Container exited during startup.${logs ? `\n\n--- container logs ---\n${logs}` : " (no logs available, container was removed)"}`);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${baseUrl}/status`);
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
ready = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Not ready yet
|
|
147
|
+
}
|
|
148
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
149
|
+
}
|
|
150
|
+
if (!ready) {
|
|
151
|
+
throw new Error("Container did not become ready within timeout");
|
|
152
|
+
}
|
|
153
|
+
const agentState = { type: "local", containerName, port: hostPort, baseUrl };
|
|
154
|
+
config.registry.log(agentState);
|
|
155
|
+
return agentState;
|
|
156
|
+
}
|
|
157
|
+
export async function startRemoteContainer(config, repo) {
|
|
158
|
+
if (!config.flyToken)
|
|
159
|
+
throw new Error("flyToken is required for remote containers");
|
|
160
|
+
if (!config.flyApp)
|
|
161
|
+
throw new Error("flyApp is required for remote containers");
|
|
162
|
+
const imageRef = config.imageRef ?? getImageRef();
|
|
163
|
+
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
164
|
+
const machineName = `app-building-${uniqueId}`;
|
|
165
|
+
// Build env vars for the machine
|
|
166
|
+
const containerEnv = buildContainerEnv(repo, config.envVars, {
|
|
167
|
+
PORT: "3000",
|
|
168
|
+
CONTAINER_NAME: machineName,
|
|
169
|
+
});
|
|
170
|
+
// Remove Fly-specific vars from container env (not needed inside)
|
|
171
|
+
delete containerEnv.FLY_API_TOKEN;
|
|
172
|
+
delete containerEnv.FLY_APP_NAME;
|
|
173
|
+
// Log existing machines (but don't destroy — multiple containers may run concurrently)
|
|
174
|
+
const existing = await listMachines(config.flyApp, config.flyToken);
|
|
175
|
+
if (existing.length > 0) {
|
|
176
|
+
console.log(`${existing.length} existing machine(s) in ${config.flyApp}:`);
|
|
177
|
+
for (const m of existing) {
|
|
178
|
+
console.log(` ${m.id} (${m.name}) — ${m.state}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Retry machine creation — the registry tag may take a moment to propagate
|
|
182
|
+
console.log("Creating Fly machine...");
|
|
183
|
+
let machineId = "";
|
|
184
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
185
|
+
try {
|
|
186
|
+
machineId = await createMachine(config.flyApp, config.flyToken, imageRef, containerEnv, machineName);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
if (msg.includes("MANIFEST_UNKNOWN") && attempt < 4) {
|
|
192
|
+
console.log("Image not yet available in registry, retrying in 5s...");
|
|
193
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
console.log(`Machine created: ${machineId}`);
|
|
200
|
+
// Register immediately so the container is tracked even if startup times out
|
|
201
|
+
const baseUrl = `https://${config.flyApp}.fly.dev`;
|
|
202
|
+
const agentState = {
|
|
203
|
+
type: "remote",
|
|
204
|
+
containerName: machineName,
|
|
205
|
+
port: 443,
|
|
206
|
+
baseUrl,
|
|
207
|
+
flyApp: config.flyApp,
|
|
208
|
+
flyMachineId: machineId,
|
|
209
|
+
};
|
|
210
|
+
config.registry.log(agentState);
|
|
211
|
+
console.log("Waiting for machine to start...");
|
|
212
|
+
await waitForMachine(config.flyApp, config.flyToken, machineId);
|
|
213
|
+
console.log("Machine started.");
|
|
214
|
+
// Poll the public URL until the HTTP server is ready, targeting this specific machine
|
|
215
|
+
const maxWait = 180000;
|
|
216
|
+
const interval = 2000;
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
let ready = false;
|
|
219
|
+
while (Date.now() - start < maxWait) {
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(`${baseUrl}/status`, {
|
|
222
|
+
headers: { "fly-force-instance-id": machineId },
|
|
223
|
+
});
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
ready = true;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Not ready yet
|
|
231
|
+
}
|
|
232
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
233
|
+
}
|
|
234
|
+
if (!ready) {
|
|
235
|
+
// Clean up machine if we can't reach it
|
|
236
|
+
console.log("Timed out waiting for machine, destroying...");
|
|
237
|
+
await destroyMachine(config.flyApp, config.flyToken, machineId).catch(() => { });
|
|
238
|
+
throw new Error("Remote container did not become ready within timeout");
|
|
239
|
+
}
|
|
240
|
+
return agentState;
|
|
241
|
+
}
|
|
242
|
+
export async function stopRemoteContainer(config, state) {
|
|
243
|
+
if (!state.flyApp || !state.flyMachineId) {
|
|
244
|
+
throw new Error("Missing flyApp or flyMachineId in agent state");
|
|
245
|
+
}
|
|
246
|
+
if (!config.flyToken)
|
|
247
|
+
throw new Error("flyToken is required to stop remote container");
|
|
248
|
+
console.log(`Destroying Fly machine ${state.flyMachineId}...`);
|
|
249
|
+
await destroyMachine(state.flyApp, config.flyToken, state.flyMachineId);
|
|
250
|
+
console.log("Machine destroyed.");
|
|
251
|
+
config.registry.markStopped(state.containerName);
|
|
252
|
+
}
|
|
253
|
+
export function stopContainer(config, containerName) {
|
|
254
|
+
try {
|
|
255
|
+
execFileSync("docker", ["stop", containerName], { stdio: "ignore", timeout: 30000 });
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Container may already be stopped
|
|
259
|
+
}
|
|
260
|
+
config.registry.markStopped(containerName);
|
|
261
|
+
}
|
|
262
|
+
export function spawnTestContainer(config) {
|
|
263
|
+
ensureImageExists(config.projectRoot);
|
|
264
|
+
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
265
|
+
const containerName = `app-building-test-${uniqueId}`;
|
|
266
|
+
const args = ["run", "-it", "--rm", "--name", containerName];
|
|
267
|
+
args.push("-v", `${config.projectRoot}:/repo`);
|
|
268
|
+
args.push("-w", "/repo");
|
|
269
|
+
args.push("--network", "host");
|
|
270
|
+
args.push("--user", `${process.getuid()}:${process.getgid()}`);
|
|
271
|
+
args.push("--env", "HOME=/repo/.agent-home");
|
|
272
|
+
args.push("--env", "PLAYWRIGHT_BROWSERS_PATH=/opt/playwright");
|
|
273
|
+
for (const [k, v] of Object.entries(config.envVars)) {
|
|
274
|
+
args.push("--env", `${k}=${v}`);
|
|
275
|
+
}
|
|
276
|
+
args.push(IMAGE_NAME, "bash");
|
|
277
|
+
return new Promise((resolvePromise, reject) => {
|
|
278
|
+
const child = spawn("docker", args, { stdio: "inherit" });
|
|
279
|
+
child.on("close", (code) => {
|
|
280
|
+
if (code === 0)
|
|
281
|
+
resolvePromise();
|
|
282
|
+
else
|
|
283
|
+
reject(new Error(`Container exited with code ${code}`));
|
|
284
|
+
});
|
|
285
|
+
child.on("error", reject);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a Fly app via the Machines API and allocate IPs so .fly.dev DNS works.
|
|
3
|
+
*/
|
|
4
|
+
export declare function createApp(token: string, name: string, org?: string): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Create a Fly Machine with the given image and env vars.
|
|
7
|
+
* Returns the machine ID.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createMachine(app: string, token: string, image: string, env: Record<string, string>, name: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Wait for a Fly Machine to reach the "started" state.
|
|
12
|
+
*/
|
|
13
|
+
export declare function waitForMachine(app: string, token: string, machineId: string, timeoutMs?: number): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Destroy a Fly Machine (force).
|
|
16
|
+
*/
|
|
17
|
+
export declare function destroyMachine(app: string, token: string, machineId: string): Promise<void>;
|
|
18
|
+
export interface FlyMachineInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
state: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
region: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* List all machines for a Fly app.
|
|
27
|
+
*/
|
|
28
|
+
export declare function listMachines(app: string, token: string): Promise<FlyMachineInfo[]>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const API_BASE = "https://api.machines.dev/v1";
|
|
2
|
+
async function flyFetch(path, token, opts = {}) {
|
|
3
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
4
|
+
...opts,
|
|
5
|
+
headers: {
|
|
6
|
+
Authorization: `Bearer ${token}`,
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
...(opts.headers ?? {}),
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok) {
|
|
12
|
+
const body = await res.text().catch(() => "");
|
|
13
|
+
throw new Error(`Fly API ${opts.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
|
14
|
+
}
|
|
15
|
+
return res;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a Fly app via the Machines API and allocate IPs so .fly.dev DNS works.
|
|
19
|
+
*/
|
|
20
|
+
export async function createApp(token, name, org) {
|
|
21
|
+
await flyFetch("/apps", token, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: JSON.stringify({ app_name: name, org_slug: org ?? "personal" }),
|
|
24
|
+
});
|
|
25
|
+
// Allocate shared IPv4 and IPv6 via GraphQL so the app gets a .fly.dev domain
|
|
26
|
+
const gqlFetch = async (query, variables) => {
|
|
27
|
+
const res = await fetch("https://api.fly.io/graphql", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${token}`,
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({ query, variables }),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const body = await res.text().catch(() => "");
|
|
37
|
+
throw new Error(`Fly GraphQL error ${res.status}: ${body}`);
|
|
38
|
+
}
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
if (data.errors?.length) {
|
|
41
|
+
throw new Error(`Fly GraphQL: ${data.errors[0].message}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const allocateMutation = `
|
|
45
|
+
mutation($input: AllocateIPAddressInput!) {
|
|
46
|
+
allocateIpAddress(input: $input) {
|
|
47
|
+
ipAddress { id address type }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
await gqlFetch(allocateMutation, { input: { appId: name, type: "shared_v4" } });
|
|
52
|
+
await gqlFetch(allocateMutation, { input: { appId: name, type: "v6" } });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a Fly Machine with the given image and env vars.
|
|
56
|
+
* Returns the machine ID.
|
|
57
|
+
*/
|
|
58
|
+
export async function createMachine(app, token, image, env, name) {
|
|
59
|
+
const res = await flyFetch(`/apps/${app}/machines`, token, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
name,
|
|
63
|
+
config: {
|
|
64
|
+
image,
|
|
65
|
+
env,
|
|
66
|
+
restart: { policy: "no" },
|
|
67
|
+
guest: {
|
|
68
|
+
cpu_kind: "shared",
|
|
69
|
+
cpus: 4,
|
|
70
|
+
memory_mb: 4096,
|
|
71
|
+
},
|
|
72
|
+
services: [
|
|
73
|
+
{
|
|
74
|
+
ports: [{ port: 443, handlers: ["tls", "http"] }],
|
|
75
|
+
protocol: "tcp",
|
|
76
|
+
internal_port: 3000,
|
|
77
|
+
autostart: false,
|
|
78
|
+
autostop: "off",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const data = (await res.json());
|
|
85
|
+
return data.id;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Wait for a Fly Machine to reach the "started" state.
|
|
89
|
+
*/
|
|
90
|
+
export async function waitForMachine(app, token, machineId, timeoutMs = 180000) {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
while (Date.now() - start < timeoutMs) {
|
|
93
|
+
try {
|
|
94
|
+
await flyFetch(`/apps/${app}/machines/${machineId}/wait?state=started&timeout=60`, token);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
99
|
+
console.log(`Still waiting for machine to start (${elapsed}s elapsed): ${e instanceof Error ? e.message : e}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Machine ${machineId} did not reach started state within ${timeoutMs / 1000}s`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Destroy a Fly Machine (force).
|
|
106
|
+
*/
|
|
107
|
+
export async function destroyMachine(app, token, machineId) {
|
|
108
|
+
await flyFetch(`/apps/${app}/machines/${machineId}?force=true`, token, {
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* List all machines for a Fly app.
|
|
114
|
+
*/
|
|
115
|
+
export async function listMachines(app, token) {
|
|
116
|
+
const res = await flyFetch(`/apps/${app}/machines`, token);
|
|
117
|
+
return (await res.json());
|
|
118
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface HttpOptions {
|
|
2
|
+
timeout?: number;
|
|
3
|
+
headers?: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export declare function httpGet(url: string, opts?: HttpOptions): Promise<any>;
|
|
6
|
+
export declare function httpPost(url: string, body?: unknown, opts?: HttpOptions): Promise<any>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
2
|
+
const MAX_RETRIES = 4;
|
|
3
|
+
const RETRY_DELAY_MS = 2000;
|
|
4
|
+
async function fetchWithRetry(url, init, timeout) {
|
|
5
|
+
for (let attempt = 0;; attempt++) {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(`${init.method ?? "GET"} ${url}: ${res.status} ${res.statusText}`);
|
|
10
|
+
return res;
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (attempt >= MAX_RETRIES)
|
|
14
|
+
throw err;
|
|
15
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function httpGet(url, opts = {}) {
|
|
20
|
+
const res = await fetchWithRetry(url, { headers: opts.headers }, opts.timeout ?? DEFAULT_TIMEOUT);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
export async function httpPost(url, body, opts = {}) {
|
|
24
|
+
const res = await fetchWithRetry(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json", ...opts.headers },
|
|
27
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
28
|
+
}, opts.timeout ?? DEFAULT_TIMEOUT);
|
|
29
|
+
return res.json();
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getImageRef(): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@replayio/app-building",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Simple agent loop for building apps with Claude Code CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/package/index.js",
|
|
9
|
+
"types": "./dist/package/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/package"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"agent": "tsx src/agent.ts",
|
|
17
|
+
"status": "tsx src/status.ts",
|
|
18
|
+
"read-log": "tsx src/read-log.ts",
|
|
19
|
+
"reset-app": "tsx src/reset-app.ts",
|
|
20
|
+
"docker:build": "docker build --no-cache --network=host -t app-building .",
|
|
21
|
+
"test-container": "tsx src/test-container.ts",
|
|
22
|
+
"stop": "tsx src/stop.ts",
|
|
23
|
+
"fly-machines": "tsx src/fly-machines.ts",
|
|
24
|
+
"add-task": "tsx scripts/add-task.ts"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.3.0",
|
|
28
|
+
"tsx": "^4.19.0",
|
|
29
|
+
"typescript": "^5.5.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"commander": "^14.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|