@dfsramos/foreman 0.0.0-stage → 0.2.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 (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +77 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +60 -0
  43. package/dist/sources/git.js +430 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +190 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +253 -0
  59. package/public/ui.js +165 -0
package/dist/cli.js ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { consoleUrl, parseCommand, USAGE } from './cli-args.js';
8
+ import { loadConfig } from './config.js';
9
+ import { isAlive, readLivePid, removePidFile } from './pidfile.js';
10
+ import { LOG_FILE } from './paths.js';
11
+ /** Start renames a log larger than this to foreman.log.1, replacing the previous one. */
12
+ const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
13
+ const START_TIMEOUT_MS = 15_000;
14
+ /** `status` exits with this when foreman is not running, as LSB init scripts do. */
15
+ const EXIT_NOT_RUNNING = 3;
16
+ async function fetchStatus(url) {
17
+ try {
18
+ const response = await fetch(`${url}/api/status`, { signal: AbortSignal.timeout(2000) });
19
+ return response.ok ? (await response.json()) : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function tail(file, lines) {
26
+ return fs.readFileSync(file, 'utf8').split('\n').slice(-lines - 1).join('\n');
27
+ }
28
+ async function start(env, url) {
29
+ const running = readLivePid(env.dataDir);
30
+ if (running !== null) {
31
+ console.log(`foreman is already running (pid ${running}): ${url}`);
32
+ return 0;
33
+ }
34
+ removePidFile(env.dataDir);
35
+ fs.mkdirSync(env.dataDir, { recursive: true });
36
+ const log = path.join(env.dataDir, LOG_FILE);
37
+ if (fs.existsSync(log) && fs.statSync(log).size > LOG_ROTATE_BYTES)
38
+ fs.renameSync(log, `${log}.1`);
39
+ const fd = fs.openSync(log, 'a');
40
+ const startedAt = fs.statSync(log).size;
41
+ const child = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', fileURLToPath(import.meta.url), 'run'], {
42
+ detached: true,
43
+ stdio: ['ignore', fd, fd],
44
+ env: process.env,
45
+ });
46
+ fs.closeSync(fd);
47
+ let exited = false;
48
+ child.on('exit', () => {
49
+ exited = true;
50
+ });
51
+ child.unref();
52
+ const deadline = Date.now() + START_TIMEOUT_MS;
53
+ while (Date.now() < deadline && !exited) {
54
+ // The pid file appears only once the console is listening, so it tells this foreman from another on the same port.
55
+ if (readLivePid(env.dataDir) === child.pid && (await fetchStatus(url))) {
56
+ console.log(`foreman started (pid ${child.pid}): ${url}`);
57
+ console.log(`Log: ${log}`);
58
+ return 0;
59
+ }
60
+ await sleep(250);
61
+ }
62
+ const output = fs.readFileSync(log, 'utf8').slice(startedAt).trimEnd().split('\n').slice(-20).join('\n');
63
+ console.error(exited ? 'foreman exited while starting:' : `foreman did not answer on ${url} within ${START_TIMEOUT_MS / 1000} seconds:`);
64
+ if (output)
65
+ console.error(output);
66
+ console.error(`Log: ${log}`);
67
+ return 1;
68
+ }
69
+ async function stop(env, url, now) {
70
+ const pid = readLivePid(env.dataDir);
71
+ if (pid === null) {
72
+ console.log('foreman is not running');
73
+ return 0;
74
+ }
75
+ process.kill(pid, now ? 'SIGINT' : 'SIGTERM');
76
+ let reported = '';
77
+ while (isAlive(pid)) {
78
+ const status = await fetchStatus(url);
79
+ const runs = status?.activeRuns ?? [];
80
+ const message = runs.length > 0
81
+ ? `Waiting for ${runs.length} run(s) in progress: ${runs.map((run) => run.identifier).join(', ')}.${now ? '' : ' Use foreman stop --now to abort them.'}`
82
+ : '';
83
+ if (message && message !== reported)
84
+ console.log(message);
85
+ reported = message;
86
+ await sleep(reported ? 2000 : 250);
87
+ }
88
+ console.log('foreman stopped');
89
+ return 0;
90
+ }
91
+ async function status(env, url) {
92
+ const pid = readLivePid(env.dataDir);
93
+ if (pid === null) {
94
+ console.log('foreman is not running');
95
+ return EXIT_NOT_RUNNING;
96
+ }
97
+ const view = await fetchStatus(url);
98
+ if (!view) {
99
+ console.log(`foreman is running (pid ${pid}) but not answering on ${url}`);
100
+ return 1;
101
+ }
102
+ console.log(`foreman is running (pid ${pid}): ${url}`);
103
+ console.log(`Data: ${env.dataDir}`);
104
+ console.log(view.projects.length === 0 ? 'No projects yet.' : 'Projects:');
105
+ for (const project of view.projects) {
106
+ console.log(` ${project.name}: ${project.state}${project.lastError ? ` (${project.lastError})` : ''}`);
107
+ }
108
+ if (view.activeRuns.length > 0) {
109
+ console.log('Runs in progress:');
110
+ for (const run of view.activeRuns) {
111
+ console.log(` ${run.identifier}${run.projectName ? ` (${run.projectName})` : ''}: ${run.agent}, ${run.step}`);
112
+ }
113
+ }
114
+ return 0;
115
+ }
116
+ function logs(env, follow, lines) {
117
+ const log = path.join(env.dataDir, LOG_FILE);
118
+ if (!fs.existsSync(log)) {
119
+ console.error(`There is no log yet at ${log}. foreman start writes it; foreman run prints to the terminal instead.`);
120
+ return Promise.resolve(1);
121
+ }
122
+ if (!follow) {
123
+ process.stdout.write(`${tail(log, lines).trimEnd()}\n`);
124
+ return Promise.resolve(0);
125
+ }
126
+ return new Promise((resolve) => {
127
+ spawn('tail', ['-n', String(lines), '-F', log], { stdio: 'inherit' }).on('exit', (code) => resolve(code ?? 0));
128
+ });
129
+ }
130
+ async function main() {
131
+ if (process.platform === 'win32') {
132
+ console.error('foreman runs on Linux, macOS and WSL. On Windows, install it inside WSL.');
133
+ return 1;
134
+ }
135
+ const command = parseCommand(process.argv.slice(2));
136
+ if ('error' in command) {
137
+ console.error(`${command.error}\n\n${USAGE}`);
138
+ return 2;
139
+ }
140
+ if (command.command === 'help') {
141
+ console.log(USAGE);
142
+ return 0;
143
+ }
144
+ if (command.command === 'version') {
145
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
146
+ console.log(pkg.version);
147
+ return 0;
148
+ }
149
+ let env;
150
+ try {
151
+ env = loadConfig();
152
+ }
153
+ catch (error) {
154
+ console.error(error instanceof Error ? error.message : error);
155
+ return 1;
156
+ }
157
+ const url = consoleUrl(env.host, env.port);
158
+ switch (command.command) {
159
+ case 'run': {
160
+ const { runDaemon } = await import('./daemon.js');
161
+ try {
162
+ await runDaemon(env);
163
+ }
164
+ catch (error) {
165
+ if (error.code !== 'EADDRINUSE')
166
+ throw error;
167
+ console.error(`Port ${env.port} on ${env.host} is already in use. Set PORT in the environment or in ${path.join(env.dataDir, '.env')}.`);
168
+ return 1;
169
+ }
170
+ return -1; // The daemon keeps the process alive and exits it itself.
171
+ }
172
+ case 'start':
173
+ return start(env, url);
174
+ case 'stop':
175
+ return stop(env, url, command.now);
176
+ case 'status':
177
+ return status(env, url);
178
+ case 'logs':
179
+ return logs(env, command.follow, command.lines);
180
+ }
181
+ }
182
+ const code = await main();
183
+ if (code >= 0)
184
+ process.exit(code);
package/dist/config.js ADDED
@@ -0,0 +1,102 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import dotenv from 'dotenv';
4
+ import { z } from 'zod';
5
+ import { defaultDataDir, ENV_FILE } from './paths.js';
6
+ export const DEFAULT_ALLOWED_TOOLS = 'Read,Glob,Grep,Edit,Write,Skill,Task,TodoWrite,Bash(git:*),Bash(gh pr:*),Bash(npm:*),Bash(npx:*),Bash(node:*)';
7
+ export const POLL_INTERVAL_RANGE = { min: 10, max: 86_400 };
8
+ export const AGENT_TIMEOUT_RANGE = { min: 1, max: 1440 };
9
+ const stateName = z.string().trim().min(1, 'must not be empty');
10
+ const pollInterval = z.coerce
11
+ .number()
12
+ .int('must be a whole number')
13
+ .min(POLL_INTERVAL_RANGE.min, `must be at least ${POLL_INTERVAL_RANGE.min}`)
14
+ .max(POLL_INTERVAL_RANGE.max, `must be at most ${POLL_INTERVAL_RANGE.max}`);
15
+ const timeoutMinutes = z.coerce
16
+ .number()
17
+ .int('must be a whole number')
18
+ .min(AGENT_TIMEOUT_RANGE.min, `must be at least ${AGENT_TIMEOUT_RANGE.min}`)
19
+ .max(AGENT_TIMEOUT_RANGE.max, `must be at most ${AGENT_TIMEOUT_RANGE.max}`);
20
+ // Projects live in the database. The process itself needs only where to listen and where to keep
21
+ // its data; the remaining variables are read once, to seed the first project and the claude agent.
22
+ const envSchema = z.object({
23
+ DATA_DIR: z.string().optional(),
24
+ HOST: z.string().default('127.0.0.1'),
25
+ PORT: z.coerce.number().int().min(1).max(65535).default(4310),
26
+ CLAUDE_BIN: z.string().default('claude'),
27
+ AGENT_ALLOWED_TOOLS: z.string().default(DEFAULT_ALLOWED_TOOLS).transform(parseToolList),
28
+ LINEAR_API_KEY: z.string().optional(),
29
+ LINEAR_TEAM_ID: z.string().optional(),
30
+ LINEAR_PROJECT_ID: z.string().optional(),
31
+ LINEAR_TARGET_STATE: stateName.default('Todo'),
32
+ LINEAR_IN_PROGRESS_STATE: stateName.default('In Progress'),
33
+ LINEAR_SUCCESS_STATE: stateName.default('In Review'),
34
+ POLL_INTERVAL_SECONDS: pollInterval.default(60),
35
+ REPO_PATH: z.string().optional(),
36
+ WORKTREE_ROOT: z.string().optional(),
37
+ AGENT_TIMEOUT_MINUTES: timeoutMinutes.default(30),
38
+ AUTOSTART: z.enum(['true', 'false']).default('false'),
39
+ });
40
+ export function parseToolList(value) {
41
+ return value
42
+ .split(',')
43
+ .map((tool) => tool.trim())
44
+ .filter((tool) => tool.length > 0);
45
+ }
46
+ const hostDefaults = () => ({ platform: process.platform, home: os.homedir() });
47
+ /**
48
+ * Loads the configuration the way the command does: the data directory comes from the environment
49
+ * (or the platform default), then `.env` in that directory fills in what the environment leaves unset.
50
+ * A DATA_DIR in that `.env` is ignored, since the file could only be found once the directory was known.
51
+ * The values are written into `env` (by default `process.env`), so agents started by foreman inherit them,
52
+ * and DATA_DIR is set to the resolved absolute path, so a child foreman process uses the same directory.
53
+ */
54
+ export function loadConfig(env = process.env, cwd = process.cwd(), defaults = hostDefaults()) {
55
+ const { dataDir } = loadEnv(env, cwd, defaults);
56
+ const fromFile = {};
57
+ dotenv.config({ path: path.join(dataDir, ENV_FILE), processEnv: fromFile, quiet: true });
58
+ for (const [key, value] of Object.entries(fromFile)) {
59
+ if (key !== 'DATA_DIR' && env[key] === undefined)
60
+ env[key] = value;
61
+ }
62
+ env.DATA_DIR = dataDir;
63
+ return loadEnv(env, cwd, defaults);
64
+ }
65
+ export function loadEnv(env, cwd = process.cwd(), defaults = hostDefaults()) {
66
+ // An empty value (`WORKTREE_ROOT=`) means "not set".
67
+ const present = Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined && value.trim() !== ''));
68
+ const parsed = envSchema.safeParse(present);
69
+ if (!parsed.success) {
70
+ const lines = parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`);
71
+ throw new Error(`Invalid configuration:\n${lines.join('\n')}`);
72
+ }
73
+ const e = parsed.data;
74
+ const dataDir = e.DATA_DIR ? path.resolve(cwd, e.DATA_DIR) : defaultDataDir(defaults.platform, env, defaults.home);
75
+ const legacy = e.LINEAR_API_KEY && e.LINEAR_TEAM_ID && e.LINEAR_PROJECT_ID && e.REPO_PATH
76
+ ? {
77
+ apiKey: e.LINEAR_API_KEY,
78
+ teamId: e.LINEAR_TEAM_ID,
79
+ projectId: e.LINEAR_PROJECT_ID,
80
+ targetState: e.LINEAR_TARGET_STATE,
81
+ inProgressState: e.LINEAR_IN_PROGRESS_STATE,
82
+ successState: e.LINEAR_SUCCESS_STATE,
83
+ pollIntervalSeconds: e.POLL_INTERVAL_SECONDS,
84
+ repoPath: path.resolve(cwd, e.REPO_PATH),
85
+ worktreeRoot: e.WORKTREE_ROOT ? path.resolve(cwd, e.WORKTREE_ROOT) : path.join(dataDir, 'worktrees'),
86
+ agentTimeoutMinutes: e.AGENT_TIMEOUT_MINUTES,
87
+ autostart: e.AUTOSTART === 'true',
88
+ }
89
+ : null;
90
+ return {
91
+ dataDir,
92
+ host: e.HOST,
93
+ port: e.PORT,
94
+ claudeBin: e.CLAUDE_BIN,
95
+ agentAllowedTools: e.AGENT_ALLOWED_TOOLS.length > 0 ? e.AGENT_ALLOWED_TOOLS : parseToolList(DEFAULT_ALLOWED_TOOLS),
96
+ legacy,
97
+ };
98
+ }
99
+ /** The environment as it may be logged: without the legacy project's API key. */
100
+ export function redactEnv(env) {
101
+ return { ...env, legacy: env.legacy ? { ...env.legacy, apiKey: '[set]' } : null };
102
+ }
package/dist/daemon.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createAvailability, loadAgentsConfig, resolveAgents } from './agent/config.js';
4
+ import { runAgent } from './agent/process.js';
5
+ import { ADAPTERS, checkSignIn } from './agent/registry.js';
6
+ import { openDb } from './db.js';
7
+ import { createGitApi } from './git/worktree.js';
8
+ import { removePidFile, writePidFile } from './pidfile.js';
9
+ import { migrateLegacy } from './projects.js';
10
+ import { Runner } from './runner.js';
11
+ import { buildApp } from './server/app.js';
12
+ import { getSourceType } from './sources/registry.js';
13
+ /**
14
+ * SIGTERM lets the runs in progress finish; SIGINT, or any signal during that wait, aborts them;
15
+ * a signal while aborting exits at once.
16
+ */
17
+ export function nextShutdownAction(phase, signal) {
18
+ if (phase === 'aborting')
19
+ return 'exit';
20
+ if (phase === 'draining')
21
+ return 'abort';
22
+ return signal === 'SIGTERM' ? 'drain' : 'abort';
23
+ }
24
+ /** Aborting stops each agent within a bounded time, but nothing may keep the process alive indefinitely. */
25
+ const ABORT_DEADLINE_MS = 30_000;
26
+ /** Runs foreman in this process until a signal stops it. */
27
+ export async function runDaemon(env) {
28
+ fs.mkdirSync(env.dataDir, { recursive: true });
29
+ const db = openDb(path.join(env.dataDir, 'foreman.db'));
30
+ const migrated = migrateLegacy(db, env);
31
+ if (migrated) {
32
+ console.log(`Created the project "${migrated.name}" from the settings in the environment; they can now be removed from .env.`);
33
+ }
34
+ const availability = createAvailability();
35
+ const runner = new Runner({
36
+ db,
37
+ git: createGitApi(),
38
+ runAgent,
39
+ getProjects: () => db.listProjects(),
40
+ createSource: (project) => {
41
+ const type = getSourceType(project.sourceType);
42
+ if (!type)
43
+ throw new Error(`Unknown source type "${project.sourceType}"`);
44
+ return type.create(project.sourceSettings, { repoPath: project.repoPath, dataDir: env.dataDir });
45
+ },
46
+ getAgents: () => resolveAgents(loadAgentsConfig(db, env), availability),
47
+ checkSignIn: (agent) => checkSignIn(agent.adapter, agent.bin),
48
+ });
49
+ const app = buildApp({ runner, db, env, availability });
50
+ let phase = 'running';
51
+ let closing = null;
52
+ // A drain that turns into an abort settles twice; the server and database close once.
53
+ const close = () => (closing ??= app.close().then(() => db.close()));
54
+ const settleAndExit = async () => {
55
+ try {
56
+ await runner.settled();
57
+ await close();
58
+ process.exit(0);
59
+ }
60
+ catch (error) {
61
+ console.error('Error during shutdown:', error);
62
+ process.exit(1);
63
+ }
64
+ };
65
+ const onSignal = (signal) => {
66
+ const action = nextShutdownAction(phase, signal);
67
+ if (action === 'exit') {
68
+ console.error(`Received ${signal} again; exiting immediately.`);
69
+ process.exit(1);
70
+ }
71
+ runner.stopAll();
72
+ if (action === 'drain') {
73
+ phase = 'draining';
74
+ const running = runner.getStatus().activeRunIds.length;
75
+ console.log(running > 0
76
+ ? `Received ${signal}; projects stopped, waiting for ${running} run(s) in progress. Send SIGINT to abort them.`
77
+ : `Received ${signal}; shutting down.`);
78
+ }
79
+ else {
80
+ phase = 'aborting';
81
+ const aborted = runner.abortAll();
82
+ console.log(`Received ${signal}; ${aborted > 0 ? `aborting ${aborted} run(s) in progress` : 'shutting down'}.`);
83
+ setTimeout(() => {
84
+ console.error('Shutdown timed out; exiting.');
85
+ process.exit(1);
86
+ }, ABORT_DEADLINE_MS).unref();
87
+ }
88
+ void settleAndExit();
89
+ };
90
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
91
+ process.on('SIGINT', () => onSignal('SIGINT'));
92
+ await availability.refresh(loadAgentsConfig(db, env));
93
+ for (const adapter of ADAPTERS) {
94
+ const probe = availability.get(adapter.id);
95
+ console.log(`Agent ${adapter.id}: ${probe?.available ? `available (${probe.version ?? 'unknown version'})` : `not available (${probe?.error ?? 'not probed'})`}`);
96
+ }
97
+ await runner.recoverInterrupted();
98
+ await app.listen({ host: env.host, port: env.port });
99
+ writePidFile(env.dataDir, process.pid);
100
+ process.on('exit', () => removePidFile(env.dataDir));
101
+ console.log(`Foreman is listening on http://${env.host}:${env.port}`);
102
+ const projects = db.listProjects();
103
+ if (projects.length === 0)
104
+ console.log('No projects yet; add one on the Projects tab.');
105
+ for (const project of projects) {
106
+ const started = project.autostart && runner.startProject(project.id);
107
+ console.log(`Project "${project.name}": ${started ? 'started' : 'stopped'}`);
108
+ }
109
+ }