@ashulab/carrier 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
3
|
+
import { ChatRefused } from './runner.js';
|
|
4
|
+
// A session never works in the engine's folder (ADR 0016); the projects folder also stays out of the chat's own state, logs and worktrees.
|
|
5
|
+
const engineFolders = ['engine'];
|
|
6
|
+
const chatOwnFolders = [...engineFolders, join('chat', 'state'), join('chat', 'logs'), join('chat', 'worktrees')];
|
|
7
|
+
const realOrSelf = (path) => {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(path);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** The real path of the folder a session may work in, or a `ChatRefused` saying why not. A symlink into an engine folder is refused too. */
|
|
16
|
+
export function checkFolder(folder, home) {
|
|
17
|
+
if (isAbsolute(folder) === false)
|
|
18
|
+
throw new ChatRefused('The folder must be an absolute path');
|
|
19
|
+
let real;
|
|
20
|
+
try {
|
|
21
|
+
real = realpathSync(folder);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new ChatRefused('That folder does not exist');
|
|
25
|
+
}
|
|
26
|
+
if (statSync(real).isDirectory() === false)
|
|
27
|
+
throw new ChatRefused('That path is not a folder');
|
|
28
|
+
refuseInside(real, home, engineFolders);
|
|
29
|
+
return real;
|
|
30
|
+
}
|
|
31
|
+
function refuseInside(real, home, names) {
|
|
32
|
+
const realHome = realOrSelf(home);
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const refused = join(realHome, name);
|
|
35
|
+
if (real === refused || real.startsWith(refused + sep))
|
|
36
|
+
throw new ChatRefused(`That folder is inside Carrier's own ${name} folder; a chat never works there`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// The real path of a path that may not exist yet: its nearest existing ancestor, resolved, plus the rest.
|
|
40
|
+
const realOfNearest = (path) => {
|
|
41
|
+
try {
|
|
42
|
+
return realpathSync(path);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
const parent = dirname(path);
|
|
46
|
+
return parent === path ? path : join(realOfNearest(parent), basename(path));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** The projects folder as the host will use it (it may not exist yet), or a `ChatRefused` saying why not. The engine-folder rule of `checkFolder` plus the chat's own state, logs and worktrees, with symlinks resolved. */
|
|
50
|
+
export function checkProjectsFolder(folder, home) {
|
|
51
|
+
if (isAbsolute(folder) === false)
|
|
52
|
+
throw new ChatRefused('The projects folder must be an absolute path');
|
|
53
|
+
const real = realOfNearest(folder);
|
|
54
|
+
refuseInside(real, home, chatOwnFolders);
|
|
55
|
+
return folder;
|
|
56
|
+
}
|
package/dist/chat/git.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const TIMEOUT_MS = 20_000;
|
|
3
|
+
const MAX_OUTPUT = 16 * 1024 * 1024;
|
|
4
|
+
/** The host's own environment plus `GIT_TERMINAL_PROMPT=0`, so a command that wants a password fails instead of waiting for one. */
|
|
5
|
+
export const realGit = {
|
|
6
|
+
run: (folder, args, options) => new Promise((resolve) => {
|
|
7
|
+
const { onProgress } = options ?? {};
|
|
8
|
+
const child = execFile('git', onProgress === undefined ? args : [args[0], '--progress', ...args.slice(1)], { cwd: folder, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...options?.env }, signal: options?.signal, timeout: options?.timeoutMs ?? TIMEOUT_MS, maxBuffer: MAX_OUTPUT, encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
9
|
+
if (error === null)
|
|
10
|
+
return resolve({ ok: true, out: stdout });
|
|
11
|
+
const { code, killed } = error;
|
|
12
|
+
if (killed)
|
|
13
|
+
return resolve({ ok: false, code: null, timedOut: true, stderr: 'git took too long' });
|
|
14
|
+
resolve({ ok: false, code: typeof code === 'number' ? code : null, stderr: stderr || error.message });
|
|
15
|
+
});
|
|
16
|
+
if (onProgress !== undefined) {
|
|
17
|
+
// git ends a progress line with a carriage return, so it is not a line until the next one comes
|
|
18
|
+
let partial = '';
|
|
19
|
+
child.stderr?.on('data', (chunk) => {
|
|
20
|
+
const lines = (partial + chunk).split(/[\r\n]+/);
|
|
21
|
+
partial = lines.pop();
|
|
22
|
+
for (const line of lines)
|
|
23
|
+
onProgress(line);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { resolveBinary } from '../harnesses/common.js';
|
|
3
|
+
/** The binary to run for a chat agent: `command` when it is a path, else `command` or `fallback` looked up on the path. Throws when there is none. */
|
|
4
|
+
export const resolveAgentBinary = (fallback, command, env) => {
|
|
5
|
+
if (command?.includes('/'))
|
|
6
|
+
return accessSync(command, constants.X_OK), command;
|
|
7
|
+
return resolveBinary(command ?? fallback, env.PATH);
|
|
8
|
+
};
|
|
9
|
+
/** SIGTERM to the agent's whole group (the agent process and the servers it started), SIGKILL if it is still there after a few seconds. */
|
|
10
|
+
export async function endGroup(pid) {
|
|
11
|
+
const send = (signal) => {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(-pid, signal);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false; // the group is gone
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
if (!send('SIGTERM'))
|
|
21
|
+
return;
|
|
22
|
+
for (let i = 0; i < 30; i++) {
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
24
|
+
if (!send(0))
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
send('SIGKILL');
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { cliLoggedIn } from '../doctor/doctor.js';
|
|
3
|
+
import { resolveBinary } from '../harnesses/common.js';
|
|
4
|
+
export const PROBE_LIMIT_MS = 5_000;
|
|
5
|
+
export const HEALTH_KEPT_MS = 30_000;
|
|
6
|
+
const SYSTEM_GIT = '/usr/bin/git';
|
|
7
|
+
export const realProbes = {
|
|
8
|
+
loggedIn: (harness, timeoutMs) => cliLoggedIn(harness, undefined, timeoutMs),
|
|
9
|
+
exitCode: (command, args, timeoutMs) => new Promise((resolve, reject) => execFile(command, args, { timeout: timeoutMs }, (error) => {
|
|
10
|
+
if (error === null)
|
|
11
|
+
return resolve(0);
|
|
12
|
+
const { code } = error;
|
|
13
|
+
// a number is the exit status; anything else is a process that did not start or was killed on time
|
|
14
|
+
typeof code === 'number' ? resolve(code) : reject(error);
|
|
15
|
+
})),
|
|
16
|
+
gitPath: () => resolveBinary('git'),
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* `git` is never run when it is the system's `/usr/bin/git`: on a Mac without the Command Line Tools that is a stub that opens the
|
|
20
|
+
* installer dialog, so `xcode-select -p` asks instead. Any other `git` runs `--version`.
|
|
21
|
+
*/
|
|
22
|
+
export async function gitState({ binaryFound, probes }) {
|
|
23
|
+
if (binaryFound('git') === false)
|
|
24
|
+
return 'missing';
|
|
25
|
+
const path = probes.gitPath();
|
|
26
|
+
const code = path === SYSTEM_GIT ? await probes.exitCode('xcode-select', ['-p'], PROBE_LIMIT_MS) : await probes.exitCode(path, ['--version'], PROBE_LIMIT_MS);
|
|
27
|
+
return code === 0 ? 'ready' : 'missing';
|
|
28
|
+
}
|
|
29
|
+
/** The health of what the chat needs, kept for thirty seconds unless a refresh is asked; the probes of one request run in parallel. */
|
|
30
|
+
export function createHealth({ agents, clock, binaryFound, probes }) {
|
|
31
|
+
// A probe that fails, or does not answer in time, is `unknown`: the race holds even for a probe that ignores its own timeout.
|
|
32
|
+
const limited = (probe) => new Promise((resolve) => {
|
|
33
|
+
const cancel = clock.after(PROBE_LIMIT_MS, async () => resolve('unknown'));
|
|
34
|
+
probe().then(resolve, () => resolve('unknown')).finally(cancel);
|
|
35
|
+
});
|
|
36
|
+
const signedIn = async (ok) => ((await ok) ? 'ready' : 'signedOut');
|
|
37
|
+
const gh = async () => (binaryFound('gh') ? signedIn(probes.exitCode('gh', ['auth', 'status', '--hostname', 'github.com'], PROBE_LIMIT_MS).then((code) => code === 0)) : 'missing');
|
|
38
|
+
const agent = async ({ command, harness }) => (binaryFound(command ?? harness) ? signedIn(probes.loggedIn(harness, PROBE_LIMIT_MS)) : 'missing');
|
|
39
|
+
const probe = async () => {
|
|
40
|
+
const [git, ghState, ...states] = await Promise.all([limited(() => gitState({ binaryFound, probes })), limited(gh), ...agents.map((chosen) => limited(() => agent(chosen)))]);
|
|
41
|
+
return { git, gh: ghState, agents: agents.map(({ name, harness }, index) => ({ name, harness, state: states[index] })) };
|
|
42
|
+
};
|
|
43
|
+
let kept;
|
|
44
|
+
return ({ refresh = false }) => {
|
|
45
|
+
if (refresh || kept === undefined || clock.now() - kept.at >= HEALTH_KEPT_MS)
|
|
46
|
+
kept = { at: clock.now(), health: probe() };
|
|
47
|
+
return kept.health;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
3
|
+
import { carrierHome } from '../config/default-path.js';
|
|
4
|
+
import { installFileLogging } from '../logging/logger.js';
|
|
5
|
+
import { realClock } from '../orchestration/clock.js';
|
|
6
|
+
import { acquireLock } from '../storage/lock.js';
|
|
7
|
+
import { claudeChatRunner } from './claude.js';
|
|
8
|
+
import { codexChatRunner } from './codex.js';
|
|
9
|
+
import { chatConfigProblem, chatDirOf, chatLogsOf, chatStateOf, readChatConfig } from './config.js';
|
|
10
|
+
import { realGit } from './git.js';
|
|
11
|
+
import { createProjects, ghBranches, ghLogin, ghRepos, ghToken } from './projects.js';
|
|
12
|
+
import { openChatDatabase } from './db.js';
|
|
13
|
+
import { idleExit } from './idle.js';
|
|
14
|
+
import { startChatServer } from './server.js';
|
|
15
|
+
import { realProbes } from './health.js';
|
|
16
|
+
import { binaryFound, createSessions } from './sessions.js';
|
|
17
|
+
import { realProcesses, sweepDeadHost } from './sweep.js';
|
|
18
|
+
const HOUR_MS = 3_600_000;
|
|
19
|
+
export class HostBusy extends Error {
|
|
20
|
+
}
|
|
21
|
+
/** `chat.json` cannot be read or does not match its schema; the message names the file and each key. */
|
|
22
|
+
export class ChatConfigInvalid extends Error {
|
|
23
|
+
}
|
|
24
|
+
const realGithub = ({ githubCallTimeoutSeconds }) => ({
|
|
25
|
+
repos: (page) => ghRepos(page, githubCallTimeoutSeconds * 1000),
|
|
26
|
+
login: () => ghLogin(githubCallTimeoutSeconds * 1000),
|
|
27
|
+
branches: (repo) => ghBranches(repo, githubCallTimeoutSeconds * 1000),
|
|
28
|
+
});
|
|
29
|
+
const realDeps = (settings) => ({ installLogging: installFileLogging, clock: realClock, exit: process.exit, signals: true, runners: { claude: claudeChatRunner(), codex: codexChatRunner() }, env: process.env, git: realGit, token: ghToken, github: realGithub(settings), binaryFound, probes: realProbes, processes: realProcesses });
|
|
30
|
+
/**
|
|
31
|
+
* Runs the chat host from the Carrier folder alone (`CARRIER_HOME`, else `~/.carrier`): its own lock, database and socket under
|
|
32
|
+
* `chat/state/` and its log under `chat/logs/`, sharing nothing with the engine. Its settings are `chat/chat.json`'s, read at start (the
|
|
33
|
+
* projects folder again at each request, the session ages at each `list` and at each hourly pass); a file that is wrong at start throws
|
|
34
|
+
* ChatConfigInvalid before anything is created. Throws HostBusy when another host holds the lock. Resolves once the socket answers and
|
|
35
|
+
* the first pass of the session clock is done; the returned `stop()` is the one shutdown (`stop` request, idle exit, signals), and it
|
|
36
|
+
* waits for a pass in flight.
|
|
37
|
+
*/
|
|
38
|
+
export async function startChatHost(overrides = {}) {
|
|
39
|
+
const loaded = readChatConfig();
|
|
40
|
+
if (loaded.ok === false)
|
|
41
|
+
throw new ChatConfigInvalid(chatConfigProblem(carrierHome(), loaded.errors));
|
|
42
|
+
const { settings } = loaded;
|
|
43
|
+
const deps = { ...realDeps(settings), ...overrides };
|
|
44
|
+
const stateDir = chatStateOf();
|
|
45
|
+
const lock = acquireLock(stateDir);
|
|
46
|
+
if (!lock.ok)
|
|
47
|
+
throw new HostBusy(`Another chat host is already running on ${chatDirOf()}`);
|
|
48
|
+
deps.installLogging(join(chatLogsOf(), 'chat-host.log'), settings.logLevel);
|
|
49
|
+
const log = createScopedLogger('chat');
|
|
50
|
+
const db = openChatDatabase(join(stateDir, 'chat.db'));
|
|
51
|
+
await sweepDeadHost(db, deps.processes); // the lock is ours, so any recorded process belongs to a host that died
|
|
52
|
+
let closeServer = () => { };
|
|
53
|
+
let idle;
|
|
54
|
+
// `list` says from the ages in chat.json, read fresh; a wrong file keeps the last ages that were good.
|
|
55
|
+
let ages = settings;
|
|
56
|
+
const currentAges = () => {
|
|
57
|
+
const current = readChatConfig();
|
|
58
|
+
if (current.ok)
|
|
59
|
+
ages = current.settings;
|
|
60
|
+
return ages;
|
|
61
|
+
};
|
|
62
|
+
const projects = createProjects({ git: deps.git, token: deps.token, github: deps.github, clock: deps.clock });
|
|
63
|
+
const sessions = createSessions({ db, agents: settings.agents, clock: deps.clock, runners: deps.runners, env: deps.env, git: deps.git, projects, binaryFound: deps.binaryFound, probes: deps.probes, ages: currentAges, onLiveChange: () => idle?.recheck() });
|
|
64
|
+
// The clock's pass, at start and every hour while the host runs; the ages are read from chat.json each time, and a wrong file skips the pass.
|
|
65
|
+
let cancelPass = () => { };
|
|
66
|
+
let stopped = false;
|
|
67
|
+
let passInFlight = Promise.resolve();
|
|
68
|
+
const runPass = async () => {
|
|
69
|
+
try {
|
|
70
|
+
const current = readChatConfig();
|
|
71
|
+
if (current.ok)
|
|
72
|
+
await sessions.applyClock(current.settings);
|
|
73
|
+
else
|
|
74
|
+
log.error('the session lifecycle pass was skipped', new Error(chatConfigProblem(carrierHome(), current.errors)));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
log.error('the session lifecycle pass failed', error);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const clockPass = async () => {
|
|
81
|
+
if (stopped)
|
|
82
|
+
return;
|
|
83
|
+
passInFlight = runPass();
|
|
84
|
+
await passInFlight;
|
|
85
|
+
if (!stopped)
|
|
86
|
+
cancelPass = deps.clock.after(HOUR_MS, clockPass);
|
|
87
|
+
};
|
|
88
|
+
const close = () => {
|
|
89
|
+
stopped = true;
|
|
90
|
+
idle?.stop();
|
|
91
|
+
cancelPass();
|
|
92
|
+
closeServer();
|
|
93
|
+
db.$client.close();
|
|
94
|
+
lock.release();
|
|
95
|
+
};
|
|
96
|
+
let stopping;
|
|
97
|
+
const stop = () => (stopping ??= (async () => {
|
|
98
|
+
stopped = true;
|
|
99
|
+
await sessions.closeAll(); // also tells the pass in flight to leave the next session alone
|
|
100
|
+
await passInFlight; // so the database is not closed under a worktree that is half removed
|
|
101
|
+
close();
|
|
102
|
+
log.info('chat host stopped');
|
|
103
|
+
deps.exit(0);
|
|
104
|
+
})());
|
|
105
|
+
idle = idleExit({ clock: deps.clock, afterMs: deps.idleMs, live: sessions.liveCount, exit: stop });
|
|
106
|
+
try {
|
|
107
|
+
const clients = {
|
|
108
|
+
joined: () => void (idle?.joined(), sessions.clientJoined()),
|
|
109
|
+
left: () => void (idle?.left(), sessions.clientLeft()),
|
|
110
|
+
};
|
|
111
|
+
closeServer = (await startChatServer({ stateDir, startedAt: deps.clock.now(), controls: { ...sessions, cloneRepo: projects.cloneRepo, createProject: projects.createProject, githubRepos: projects.githubRepos, repoBranches: projects.repoBranches }, stop, clients })).close;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
close();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (deps.signals)
|
|
118
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'])
|
|
119
|
+
process.on(signal, () => void stop());
|
|
120
|
+
await clockPass();
|
|
121
|
+
log.info('chat host started', { folder: carrierHome() });
|
|
122
|
+
return { stop };
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const IDLE_EXIT_MS = 60_000;
|
|
2
|
+
/**
|
|
3
|
+
* The host's exit rule: it ends `afterMs` after the later of its start and the last client leaving, as long as no
|
|
4
|
+
* session is live. A client arriving or a session living cancels the countdown; `recheck()` re-arms it when the live count drops.
|
|
5
|
+
*/
|
|
6
|
+
export function idleExit({ clock, afterMs = IDLE_EXIT_MS, live, exit }) {
|
|
7
|
+
let clients = 0;
|
|
8
|
+
let cancel;
|
|
9
|
+
const arm = () => {
|
|
10
|
+
cancel?.();
|
|
11
|
+
cancel = clients === 0 && live() === 0 ? clock.after(afterMs, exit) : undefined;
|
|
12
|
+
};
|
|
13
|
+
arm();
|
|
14
|
+
return {
|
|
15
|
+
joined: () => void (clients++, arm()),
|
|
16
|
+
left: () => void (clients--, arm()),
|
|
17
|
+
recheck: arm,
|
|
18
|
+
stop: () => cancel?.(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE `sessions` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`agent` text NOT NULL,
|
|
4
|
+
`harness` text NOT NULL,
|
|
5
|
+
`folder` text NOT NULL,
|
|
6
|
+
`title` text,
|
|
7
|
+
`options` text,
|
|
8
|
+
`nativeId` text,
|
|
9
|
+
`state` text DEFAULT 'idle' NOT NULL,
|
|
10
|
+
`pid` integer,
|
|
11
|
+
`pgid` integer,
|
|
12
|
+
`createdAt` integer NOT NULL,
|
|
13
|
+
`lastActiveAt` integer NOT NULL,
|
|
14
|
+
`archived` integer DEFAULT false NOT NULL
|
|
15
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
CREATE TABLE `chat_events` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`sessionId` text NOT NULL,
|
|
4
|
+
`at` integer NOT NULL,
|
|
5
|
+
`kind` text NOT NULL,
|
|
6
|
+
`data` text NOT NULL,
|
|
7
|
+
FOREIGN KEY (`sessionId`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE no action
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
CREATE INDEX `chat_events_session` ON `chat_events` (`sessionId`,`id`);--> statement-breakpoint
|
|
11
|
+
CREATE TABLE `chat_turns` (
|
|
12
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
13
|
+
`sessionId` text NOT NULL,
|
|
14
|
+
`startedAt` integer NOT NULL,
|
|
15
|
+
`model` text,
|
|
16
|
+
`outcome` text NOT NULL,
|
|
17
|
+
`durationMs` integer NOT NULL,
|
|
18
|
+
`toolCalls` integer NOT NULL,
|
|
19
|
+
`usage` text,
|
|
20
|
+
FOREIGN KEY (`sessionId`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE no action
|
|
21
|
+
);
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE INDEX `chat_turns_session` ON `chat_turns` (`sessionId`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `sessions` ADD `command` text;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ALTER TABLE `sessions` ADD `workspaceKind` text DEFAULT 'folder' NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE `sessions` ADD `worktreeBranch` text;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE `sessions` ADD `worktreeFrom` text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE `sessions` ADD `worktreeBase` text;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE `sessions` ADD `worktreePath` text;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `sessions` ADD `keptAt` integer;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `sessions` ADD `plan` text;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "6",
|
|
3
|
+
"dialect": "sqlite",
|
|
4
|
+
"id": "ef674763-66c1-408a-94c1-fcfe9d8c53db",
|
|
5
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
|
+
"tables": {
|
|
7
|
+
"sessions": {
|
|
8
|
+
"name": "sessions",
|
|
9
|
+
"columns": {
|
|
10
|
+
"id": {
|
|
11
|
+
"name": "id",
|
|
12
|
+
"type": "text",
|
|
13
|
+
"primaryKey": true,
|
|
14
|
+
"notNull": true,
|
|
15
|
+
"autoincrement": false
|
|
16
|
+
},
|
|
17
|
+
"agent": {
|
|
18
|
+
"name": "agent",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true,
|
|
22
|
+
"autoincrement": false
|
|
23
|
+
},
|
|
24
|
+
"harness": {
|
|
25
|
+
"name": "harness",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true,
|
|
29
|
+
"autoincrement": false
|
|
30
|
+
},
|
|
31
|
+
"folder": {
|
|
32
|
+
"name": "folder",
|
|
33
|
+
"type": "text",
|
|
34
|
+
"primaryKey": false,
|
|
35
|
+
"notNull": true,
|
|
36
|
+
"autoincrement": false
|
|
37
|
+
},
|
|
38
|
+
"title": {
|
|
39
|
+
"name": "title",
|
|
40
|
+
"type": "text",
|
|
41
|
+
"primaryKey": false,
|
|
42
|
+
"notNull": false,
|
|
43
|
+
"autoincrement": false
|
|
44
|
+
},
|
|
45
|
+
"options": {
|
|
46
|
+
"name": "options",
|
|
47
|
+
"type": "text",
|
|
48
|
+
"primaryKey": false,
|
|
49
|
+
"notNull": false,
|
|
50
|
+
"autoincrement": false
|
|
51
|
+
},
|
|
52
|
+
"nativeId": {
|
|
53
|
+
"name": "nativeId",
|
|
54
|
+
"type": "text",
|
|
55
|
+
"primaryKey": false,
|
|
56
|
+
"notNull": false,
|
|
57
|
+
"autoincrement": false
|
|
58
|
+
},
|
|
59
|
+
"state": {
|
|
60
|
+
"name": "state",
|
|
61
|
+
"type": "text",
|
|
62
|
+
"primaryKey": false,
|
|
63
|
+
"notNull": true,
|
|
64
|
+
"autoincrement": false,
|
|
65
|
+
"default": "'idle'"
|
|
66
|
+
},
|
|
67
|
+
"pid": {
|
|
68
|
+
"name": "pid",
|
|
69
|
+
"type": "integer",
|
|
70
|
+
"primaryKey": false,
|
|
71
|
+
"notNull": false,
|
|
72
|
+
"autoincrement": false
|
|
73
|
+
},
|
|
74
|
+
"pgid": {
|
|
75
|
+
"name": "pgid",
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"primaryKey": false,
|
|
78
|
+
"notNull": false,
|
|
79
|
+
"autoincrement": false
|
|
80
|
+
},
|
|
81
|
+
"createdAt": {
|
|
82
|
+
"name": "createdAt",
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"primaryKey": false,
|
|
85
|
+
"notNull": true,
|
|
86
|
+
"autoincrement": false
|
|
87
|
+
},
|
|
88
|
+
"lastActiveAt": {
|
|
89
|
+
"name": "lastActiveAt",
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"primaryKey": false,
|
|
92
|
+
"notNull": true,
|
|
93
|
+
"autoincrement": false
|
|
94
|
+
},
|
|
95
|
+
"archived": {
|
|
96
|
+
"name": "archived",
|
|
97
|
+
"type": "integer",
|
|
98
|
+
"primaryKey": false,
|
|
99
|
+
"notNull": true,
|
|
100
|
+
"autoincrement": false,
|
|
101
|
+
"default": false
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"indexes": {},
|
|
105
|
+
"foreignKeys": {},
|
|
106
|
+
"compositePrimaryKeys": {},
|
|
107
|
+
"uniqueConstraints": {},
|
|
108
|
+
"checkConstraints": {}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"views": {},
|
|
112
|
+
"enums": {},
|
|
113
|
+
"_meta": {
|
|
114
|
+
"schemas": {},
|
|
115
|
+
"tables": {},
|
|
116
|
+
"columns": {}
|
|
117
|
+
},
|
|
118
|
+
"internal": {
|
|
119
|
+
"indexes": {}
|
|
120
|
+
}
|
|
121
|
+
}
|