@ashulab/carrier 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +265 -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,242 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
7
|
+
import { carrierHome } from '../config/default-path.js';
|
|
8
|
+
import { listAvailableRepos } from '../integrations/available-repos.js';
|
|
9
|
+
import { repoNamePattern } from '../config/schema.js';
|
|
10
|
+
import { readChatConfig } from './config.js';
|
|
11
|
+
import { checkProjectsFolder } from './folder.js';
|
|
12
|
+
import { ChatRefused } from './runner.js';
|
|
13
|
+
import { parseOrigin } from './workspace.js';
|
|
14
|
+
const log = createScopedLogger('chat');
|
|
15
|
+
/** A project's name: one plain folder name (no empty, `.`, `..`, leading dot or slash), at most a hundred characters. */
|
|
16
|
+
export const projectNamePattern = /^\w[\w.-]{0,99}$/;
|
|
17
|
+
/** Per git command, the engine's limit. */
|
|
18
|
+
export const GIT_TIMEOUT_MS = 10 * 60_000;
|
|
19
|
+
/** The token of the person's active `gh` login. Throws a `ChatRefused` saying what to do when `gh` is missing or signed out. */
|
|
20
|
+
export const ghToken = () => new Promise((resolve, reject) => {
|
|
21
|
+
execFile('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 15_000 }, (error, stdout) => {
|
|
22
|
+
if (error?.code === 'ENOENT')
|
|
23
|
+
return reject(new ChatRefused('The GitHub CLI (gh) is not installed'));
|
|
24
|
+
const token = stdout.trim();
|
|
25
|
+
if (error !== null || token === '')
|
|
26
|
+
return reject(new ChatRefused('gh is not signed in to GitHub; run "gh auth login"'));
|
|
27
|
+
resolve(token);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
/** What a failed `gh` call comes to, in a sentence for the person: gh missing, signed out, offline or too slow. What gh printed is logged by code only, never forwarded. */
|
|
31
|
+
export function ghRefusal(error, did) {
|
|
32
|
+
const { code, killed, stderr } = error;
|
|
33
|
+
const said = typeof stderr === 'string' ? stderr : '';
|
|
34
|
+
log.error(`gh ${did} failed`, { code: typeof code === 'number' || typeof code === 'string' ? code : null, killed: killed === true });
|
|
35
|
+
if (code === 'ENOENT')
|
|
36
|
+
return new ChatRefused('The GitHub CLI (gh) is not installed');
|
|
37
|
+
if (killed === true)
|
|
38
|
+
return new ChatRefused('GitHub took too long to answer');
|
|
39
|
+
if (/gh auth login|not logged in|http 401|bad credentials/i.test(said))
|
|
40
|
+
return new ChatRefused('gh is not signed in to GitHub; run "gh auth login"');
|
|
41
|
+
if (/error connecting|could not resolve|no such host|dial tcp|network is unreachable|timed out/i.test(said))
|
|
42
|
+
return new ChatRefused('GitHub could not be reached; check the network connection');
|
|
43
|
+
return new ChatRefused(`GitHub did not answer; check "gh auth status"`);
|
|
44
|
+
}
|
|
45
|
+
/** One page of every repository the person's `gh` login can read, thirty at a time, newest push first. */
|
|
46
|
+
export const ghRepos = (page, timeoutMs) => listAvailableRepos(page, timeoutMs, 'read').catch((error) => {
|
|
47
|
+
throw ghRefusal(error, 'repos');
|
|
48
|
+
});
|
|
49
|
+
/** The login that is active in `gh`. */
|
|
50
|
+
export const ghLogin = (timeoutMs) => promisify(execFile)('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: timeoutMs }).then(({ stdout }) => stdout.trim(), (error) => {
|
|
51
|
+
throw ghRefusal(error, 'login');
|
|
52
|
+
});
|
|
53
|
+
const BRANCHES_PER_PAGE = 100;
|
|
54
|
+
/** The default branch and the first hundred branches of a repository (`owner/name`, already checked against `repoNamePattern`) the person's `gh` login can read. */
|
|
55
|
+
export async function ghBranches(repo, timeoutMs) {
|
|
56
|
+
const gh = (args) => promisify(execFile)('gh', ['api', ...args], { encoding: 'utf8', timeout: timeoutMs });
|
|
57
|
+
try {
|
|
58
|
+
const [head, listed] = await Promise.all([gh([`repos/${repo}`, '--jq', '.default_branch']), gh([`repos/${repo}/branches?per_page=${BRANCHES_PER_PAGE}`, '--jq', '.[].name'])]);
|
|
59
|
+
const branches = listed.stdout.split('\n').filter((name) => name !== '');
|
|
60
|
+
return { default: head.stdout.trim(), branches, hasMore: branches.length === BRANCHES_PER_PAGE };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw ghRefusal(error, 'branches');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// What git's stderr says, read here and never forwarded: it can hold a URL.
|
|
67
|
+
const notVisible = /repository not found|not found|authentication failed|could not read username|error: 40[134]/i;
|
|
68
|
+
const offline = /could not resolve host|failed to connect|connection (timed out|refused|reset)|network is unreachable|unable to access/i;
|
|
69
|
+
function refusal(result, did, repo) {
|
|
70
|
+
log.error(`git ${did} failed`, { repo, code: result.code, timedOut: result.timedOut === true });
|
|
71
|
+
if (result.timedOut === true)
|
|
72
|
+
return new ChatRefused(`The ${did} took more than ten minutes and was stopped`);
|
|
73
|
+
if (notVisible.test(result.stderr))
|
|
74
|
+
return new ChatRefused(`${repo} was not found, or your GitHub login cannot see it`);
|
|
75
|
+
if (offline.test(result.stderr))
|
|
76
|
+
return new ChatRefused('GitHub could not be reached; check the network connection');
|
|
77
|
+
return new ChatRefused(`git could not ${did} ${repo}`);
|
|
78
|
+
}
|
|
79
|
+
/** The projects folder of `chat.json`, read at each call. `inside` is a path below it that is checked again: a projects folder that is the Carrier folder itself would put `engine/x` in the engine's own folder. */
|
|
80
|
+
const projectsFolder = (inside) => {
|
|
81
|
+
const loaded = readChatConfig();
|
|
82
|
+
if (loaded.ok === false)
|
|
83
|
+
throw new ChatRefused(`chat.json is not valid: ${loaded.errors[0].path}: ${loaded.errors[0].message}`);
|
|
84
|
+
const folder = checkProjectsFolder(loaded.settings.projectsFolder, carrierHome());
|
|
85
|
+
return inside === undefined ? folder : checkProjectsFolder(join(folder, inside), carrierHome());
|
|
86
|
+
};
|
|
87
|
+
/** Where a new project of this name is made: `<projects folder>/local/<name>`. */
|
|
88
|
+
export function projectFolder(name) {
|
|
89
|
+
if (projectNamePattern.test(name) === false)
|
|
90
|
+
throw new ChatRefused(`"${name}" is not a plain folder name`);
|
|
91
|
+
return join(projectsFolder(), 'local', name);
|
|
92
|
+
}
|
|
93
|
+
/** Makes the project's folder and runs `git init -b main` in it. A name that is taken is refused and nothing is changed; a `git init` that fails removes what this call made. */
|
|
94
|
+
export async function createProject(git, name) {
|
|
95
|
+
const dest = projectFolder(name);
|
|
96
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(dest); // not recursive: it fails when anything is there, and what is there is not ours to touch
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === 'EEXIST')
|
|
102
|
+
throw new ChatRefused(`${name} is already a project; choose another name`);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const init = await git.run(dest, ['init', '-b', 'main']);
|
|
106
|
+
if (init.ok === false) {
|
|
107
|
+
log.error('git init failed', { name, code: init.code });
|
|
108
|
+
await rm(dest, { recursive: true, force: true });
|
|
109
|
+
throw new ChatRefused(`git could not start the project ${name}`);
|
|
110
|
+
}
|
|
111
|
+
return { folder: dest };
|
|
112
|
+
}
|
|
113
|
+
// The only stderr lines of git that leave this file: its progress, as "Receiving objects: 45%". Anything else can hold a URL.
|
|
114
|
+
const progressLine = /^(?:remote: )?((?:Counting|Compressing|Receiving|Resolving|Updating) [A-Za-z ]+: +\d+)%/;
|
|
115
|
+
/**
|
|
116
|
+
* Brings GitHub repositories into the projects folder with the person's own `gh` login (ADR 0015). The folder is read
|
|
117
|
+
* from `chat.json` at each request, so a change applies to the next one.
|
|
118
|
+
*/
|
|
119
|
+
export function createProjects({ git, token, github, clock }) {
|
|
120
|
+
const running = new Map();
|
|
121
|
+
// Not in argv, in a file or in a log: git reads the header from its environment, as `git-workspaces.ts` does.
|
|
122
|
+
const authEnv = async () => {
|
|
123
|
+
const basic = Buffer.from(`x-access-token:${await token()}`).toString('base64');
|
|
124
|
+
return { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraHeader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` };
|
|
125
|
+
};
|
|
126
|
+
async function fetchOrClone(repo, dest, signal, onProgress) {
|
|
127
|
+
if (existsSync(dest)) {
|
|
128
|
+
const origin = existsSync(join(dest, '.git')) ? await git.run(dest, ['config', '--get', 'remote.origin.url']) : undefined;
|
|
129
|
+
const found = origin?.ok === true ? parseOrigin(origin.out) : null;
|
|
130
|
+
if (found === null || `${found.owner}/${found.name}`.toLowerCase() !== repo.toLowerCase())
|
|
131
|
+
throw new ChatRefused(`${dest} is already there and is not a clone of ${repo}; it was left as it is`);
|
|
132
|
+
const fetched = await git.run(dest, ['fetch', 'origin', '--prune'], { timeoutMs: GIT_TIMEOUT_MS, env: await authEnv(), signal });
|
|
133
|
+
if (fetched.ok === false)
|
|
134
|
+
throw refusal(fetched, 'fetch', repo);
|
|
135
|
+
return { folder: dest, repo, fetched: true };
|
|
136
|
+
}
|
|
137
|
+
const env = await authEnv();
|
|
138
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(dest); // not recursive: it fails if something appeared since the check, and what is there now is ours to remove only if we made it
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new ChatRefused(`${dest} is already there and is not a clone of ${repo}; it was left as it is`);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const cloned = await git.run(dirname(dest), ['clone', '--', `https://github.com/${repo}.git`, dest], { timeoutMs: GIT_TIMEOUT_MS, env, signal, onProgress });
|
|
147
|
+
if (cloned.ok === false)
|
|
148
|
+
throw refusal(cloned, 'clone', repo);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
await rm(dest, { recursive: true, force: true });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return { folder: dest, repo, fetched: false };
|
|
155
|
+
}
|
|
156
|
+
/** `<projects folder>/<owner>/<name>`, where `repo` is or would be cloned. */
|
|
157
|
+
const repoFolder = (repo) => {
|
|
158
|
+
if (repoNamePattern.test(repo) === false)
|
|
159
|
+
throw new ChatRefused(`"${repo}" is not a repository; write it as owner/name`);
|
|
160
|
+
return projectsFolder(repo);
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
repoFolder,
|
|
164
|
+
/** What bringing `repo` is: `fetch` when it is already in the projects folder, `clone` when it is not, or what the one in progress is doing (a clone being made is not yet a repository to fetch). */
|
|
165
|
+
stepOf(repo) {
|
|
166
|
+
const dest = repoFolder(repo);
|
|
167
|
+
return running.get(dest.toLowerCase())?.mode ?? (existsSync(dest) ? 'fetch' : 'clone');
|
|
168
|
+
},
|
|
169
|
+
/** The default branch and the branches of a repository that need not be on disk, read from GitHub with the person's login. */
|
|
170
|
+
repoBranches({ repo }) {
|
|
171
|
+
if (repoNamePattern.test(repo) === false)
|
|
172
|
+
throw new ChatRefused(`"${repo}" is not a repository; write it as owner/name`);
|
|
173
|
+
return github.branches(repo);
|
|
174
|
+
},
|
|
175
|
+
/** A page of the repositories the person's login can read; page 1 also says the login. */
|
|
176
|
+
async githubRepos({ page }) {
|
|
177
|
+
if (page > 1)
|
|
178
|
+
return github.repos(page);
|
|
179
|
+
const [first, who] = await Promise.all([github.repos(page), github.login()]);
|
|
180
|
+
return { ...first, login: who };
|
|
181
|
+
},
|
|
182
|
+
/** Makes `<projects folder>/local/<name>` and runs `git init -b main` in it (the `createProject` request, for apps older than the plan). */
|
|
183
|
+
createProject: ({ name }) => createProject(git, name),
|
|
184
|
+
/**
|
|
185
|
+
* Clones `repo` to `<projects folder>/<owner>/<name>`, or fetches it when it is already there. `signal` is the waiter's: when it aborts, this waiter
|
|
186
|
+
* leaves, and git is aborted and a destination this request made is removed once nobody waits for it. `onProgress` gets git's progress lines of a
|
|
187
|
+
* clone ("Receiving objects: 45%"), at most one a second and no other line of git's.
|
|
188
|
+
*/
|
|
189
|
+
async cloneRepo({ repo }, signal, onProgress) {
|
|
190
|
+
const dest = repoFolder(repo);
|
|
191
|
+
const key = dest.toLowerCase();
|
|
192
|
+
// a clone being aborted finishes first, so a new one never meets its leftovers
|
|
193
|
+
for (let old = running.get(key); old?.abort.signal.aborted === true; old = running.get(key))
|
|
194
|
+
await old.done.catch(() => { });
|
|
195
|
+
let job = running.get(key);
|
|
196
|
+
if (job === undefined) {
|
|
197
|
+
const abort = new AbortController();
|
|
198
|
+
const listeners = new Set();
|
|
199
|
+
let last = -Infinity;
|
|
200
|
+
const report = (raw) => {
|
|
201
|
+
const found = progressLine.exec(raw.trim());
|
|
202
|
+
if (found === null || clock.now() - last < 1000)
|
|
203
|
+
return;
|
|
204
|
+
last = clock.now();
|
|
205
|
+
const line = `${found[1].replace(/ +/g, ' ')}%`;
|
|
206
|
+
started.latest = line;
|
|
207
|
+
for (const listener of listeners)
|
|
208
|
+
listener(line);
|
|
209
|
+
};
|
|
210
|
+
const started = { done: fetchOrClone(repo, dest, abort.signal, report), mode: existsSync(dest) ? 'fetch' : 'clone', waiters: 0, abort, listeners };
|
|
211
|
+
const forget = () => void (running.get(key) === started && running.delete(key));
|
|
212
|
+
started.done.then(forget, forget);
|
|
213
|
+
running.set(key, (job = started));
|
|
214
|
+
}
|
|
215
|
+
const joined = job;
|
|
216
|
+
joined.waiters++;
|
|
217
|
+
if (onProgress !== undefined) {
|
|
218
|
+
joined.listeners.add(onProgress);
|
|
219
|
+
if (joined.latest !== undefined)
|
|
220
|
+
onProgress(joined.latest);
|
|
221
|
+
}
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
// the clone stops when nobody waits for it any more, and the last one to leave hears of it once the half clone is removed
|
|
224
|
+
const leave = async () => {
|
|
225
|
+
if (onProgress !== undefined)
|
|
226
|
+
joined.listeners.delete(onProgress);
|
|
227
|
+
if (--joined.waiters === 0) {
|
|
228
|
+
joined.abort.abort();
|
|
229
|
+
await joined.done.catch(() => { });
|
|
230
|
+
}
|
|
231
|
+
reject(new ChatRefused('The clone was cancelled'));
|
|
232
|
+
};
|
|
233
|
+
if (signal.aborted)
|
|
234
|
+
return leave();
|
|
235
|
+
signal.addEventListener('abort', leave, { once: true });
|
|
236
|
+
const settled = () => (signal.removeEventListener('abort', leave), onProgress !== undefined && joined.listeners.delete(onProgress));
|
|
237
|
+
// a waiter that has left is answered by `leave`
|
|
238
|
+
joined.done.then((reply) => (settled(), signal.aborted || resolve(reply)), (error) => (settled(), signal.aborted || reject(error)));
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The chat runner: the port through which the host drives one provider (docs/specs/chat-host.md, Decisions). It speaks
|
|
2
|
+
// Carrier's own chat events whatever the provider speaks, and it is not the engine's `AgentRunner`: nothing in it is
|
|
3
|
+
// epic-shaped, there is no result file and the process is not done after one answer.
|
|
4
|
+
/** A failure whose message is for the person, in words: it travels to the client as it is. Any other error is logged and reaches the client as "internal error". */
|
|
5
|
+
export class ChatRefused extends Error {
|
|
6
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
export const sessionStates = ['working', 'waiting_approval', 'idle', 'preparing'];
|
|
3
|
+
/** A user session: one conversation with a provider's agent in a folder. */
|
|
4
|
+
export const sessions = sqliteTable('sessions', {
|
|
5
|
+
id: text().primaryKey(),
|
|
6
|
+
agent: text().notNull(), // the configured agent's name
|
|
7
|
+
harness: text({ enum: ['claude', 'codex'] }).notNull(),
|
|
8
|
+
folder: text().notNull(), // where the agent runs: the base folder, or the worktree when there is one
|
|
9
|
+
workspaceKind: text({ enum: ['folder', 'worktree'] }).notNull().default('folder'),
|
|
10
|
+
worktreeBranch: text(), // the four below are set for a worktree session only
|
|
11
|
+
worktreeFrom: text(), // the branch it was cut from, as `workspace` listed it
|
|
12
|
+
worktreeBase: text(), // the folder it was made from
|
|
13
|
+
worktreePath: text(),
|
|
14
|
+
title: text(),
|
|
15
|
+
options: text({ mode: 'json' }).$type(),
|
|
16
|
+
plan: text({ mode: 'json' }).$type(), // what the host is preparing, or failed to, with the first message; null once the agent answered it
|
|
17
|
+
nativeId: text(), // the provider's id for the conversation, to resume it
|
|
18
|
+
state: text({ enum: sessionStates }).notNull().default('idle'),
|
|
19
|
+
pid: integer(), // of the live agent process, null when closed
|
|
20
|
+
pgid: integer(),
|
|
21
|
+
command: text(), // what that process was started with, so a sweep knows it is still the agent
|
|
22
|
+
createdAt: integer().notNull(), // ms since epoch
|
|
23
|
+
lastActiveAt: integer().notNull(),
|
|
24
|
+
archived: integer({ mode: 'boolean' }).notNull().default(false),
|
|
25
|
+
keptAt: integer(), // ms since epoch: when the clock's delete spared this archived session for unsaved work; null otherwise
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* What was said and done in a session, already redacted and bounded (`transcript.ts`). The id is the cursor of the `events`
|
|
29
|
+
* request. A `delta` is never stored. `data` holds the event's fields other than its kind.
|
|
30
|
+
*/
|
|
31
|
+
export const chatEvents = sqliteTable('chat_events', {
|
|
32
|
+
id: integer().primaryKey({ autoIncrement: true }),
|
|
33
|
+
sessionId: text().notNull().references(() => sessions.id),
|
|
34
|
+
at: integer().notNull(), // ms since epoch
|
|
35
|
+
kind: text().notNull(),
|
|
36
|
+
data: text({ mode: 'json' }).$type().notNull(),
|
|
37
|
+
}, (table) => [index('chat_events_session').on(table.sessionId, table.id)]);
|
|
38
|
+
/** One row per finished turn, so statistics can be built later without touching the transcripts. */
|
|
39
|
+
export const chatTurns = sqliteTable('chat_turns', {
|
|
40
|
+
id: integer().primaryKey({ autoIncrement: true }),
|
|
41
|
+
sessionId: text().notNull().references(() => sessions.id),
|
|
42
|
+
startedAt: integer().notNull(),
|
|
43
|
+
model: text(),
|
|
44
|
+
outcome: text({ enum: ['ok', 'interrupted', 'error'] }).notNull(),
|
|
45
|
+
durationMs: integer().notNull(),
|
|
46
|
+
toolCalls: integer().notNull(),
|
|
47
|
+
usage: text({ mode: 'json' }).$type(), // as the provider gave it
|
|
48
|
+
}, (table) => [index('chat_turns_session').on(table.sessionId)]);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { repoNamePattern } from '../config/schema.js';
|
|
7
|
+
import { socketPathFits, version } from '../control/server.js';
|
|
8
|
+
import { plan } from './plan.js';
|
|
9
|
+
import { projectNamePattern } from './projects.js';
|
|
10
|
+
import { ChatRefused } from './runner.js';
|
|
11
|
+
const log = createScopedLogger('chat');
|
|
12
|
+
// 1 MiB, not the engine's 16 KiB: a pasted spec is a request.
|
|
13
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
14
|
+
const capabilities = ['agents', 'list', 'open', 'resume', 'send', 'answer', 'interrupt', 'setOption', 'agentOptions', 'events', 'close', 'archive', 'delete', 'workspace', 'fetch', 'suggestName', 'checkName', 'deletePreview', 'cloneRepo', 'createProject', 'githubRepos', 'repoBranches', 'health', 'plan', 'stop'];
|
|
15
|
+
const id = z.number().int().positive();
|
|
16
|
+
const sessionId = z.uuid();
|
|
17
|
+
// An allowlist, strict like the engine's: any other command, field or type is rejected before anything acts on it.
|
|
18
|
+
export const chatRequest = z.union([
|
|
19
|
+
z.strictObject({ id, cmd: z.literal('agents') }),
|
|
20
|
+
z.strictObject({ id, cmd: z.literal('list'), archived: z.boolean().optional() }),
|
|
21
|
+
z.strictObject({ id, cmd: z.literal('open'), agent: z.string().min(1), folder: z.string().min(1), options: z.record(z.string(), z.string()).optional(), worktree: z.strictObject({ from: z.string().min(1).max(1000), name: z.string().max(1000).optional() }).optional() }),
|
|
22
|
+
z.strictObject({ id, cmd: z.literal('open'), plan }),
|
|
23
|
+
z.strictObject({ id, cmd: z.literal('retryPreparation'), sessionId }),
|
|
24
|
+
z.strictObject({ id, cmd: z.literal('cancelPreparation'), sessionId }),
|
|
25
|
+
z.strictObject({ id, cmd: z.literal('resume'), sessionId }),
|
|
26
|
+
// a message has text, unless it invokes a command, which may take nothing after its name
|
|
27
|
+
z.strictObject({ id, cmd: z.literal('send'), sessionId, text: z.string(), command: z.string().min(1).max(1000).optional() }).refine((r) => r.text.length > 0 || r.command !== undefined),
|
|
28
|
+
z.strictObject({ id, cmd: z.literal('answer'), sessionId, approvalId: z.string().min(1), choiceId: z.string().min(1) }),
|
|
29
|
+
z.strictObject({ id, cmd: z.literal('interrupt'), sessionId }),
|
|
30
|
+
z.strictObject({ id, cmd: z.literal('agentOptions'), agent: z.string().min(1) }),
|
|
31
|
+
z.strictObject({ id, cmd: z.literal('setOption'), sessionId, option: z.string().min(1), value: z.string().min(1) }),
|
|
32
|
+
z.strictObject({ id, cmd: z.literal('events'), sessionId, after: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(200) }),
|
|
33
|
+
z.strictObject({ id, cmd: z.literal('close'), sessionId }),
|
|
34
|
+
z.strictObject({ id, cmd: z.literal('archive'), sessionId, archived: z.boolean() }),
|
|
35
|
+
z.strictObject({ id, cmd: z.literal('delete'), sessionId }),
|
|
36
|
+
z.strictObject({ id, cmd: z.literal('workspace'), folder: z.string().min(1) }),
|
|
37
|
+
z.strictObject({ id, cmd: z.literal('fetch'), folder: z.string().min(1) }),
|
|
38
|
+
z.strictObject({ id, cmd: z.literal('suggestName'), folder: z.string().min(1) }),
|
|
39
|
+
z.strictObject({ id, cmd: z.literal('checkName'), folder: z.string().min(1), name: z.string().max(1000) }),
|
|
40
|
+
z.strictObject({ id, cmd: z.literal('deletePreview'), sessionIds: z.array(sessionId).min(1).max(500) }),
|
|
41
|
+
z.strictObject({ id, cmd: z.literal('cloneRepo'), repo: z.string().regex(repoNamePattern) }),
|
|
42
|
+
z.strictObject({ id, cmd: z.literal('createProject'), name: z.string().regex(projectNamePattern) }),
|
|
43
|
+
z.strictObject({ id, cmd: z.literal('githubRepos'), page: z.int().min(1).max(100) }),
|
|
44
|
+
z.strictObject({ id, cmd: z.literal('repoBranches'), repo: z.string().regex(repoNamePattern) }),
|
|
45
|
+
z.strictObject({ id, cmd: z.literal('health'), refresh: z.boolean().optional() }),
|
|
46
|
+
z.strictObject({ id, cmd: z.literal('stop') }),
|
|
47
|
+
]);
|
|
48
|
+
async function run(request, controls, gone) {
|
|
49
|
+
switch (request.cmd) {
|
|
50
|
+
case 'agents':
|
|
51
|
+
return controls.agents();
|
|
52
|
+
case 'list':
|
|
53
|
+
return controls.list({ archived: request.archived });
|
|
54
|
+
case 'open':
|
|
55
|
+
return 'plan' in request ? controls.openPlan({ plan: request.plan }) : controls.open({ agent: request.agent, folder: request.folder, options: request.options, worktree: request.worktree });
|
|
56
|
+
case 'retryPreparation':
|
|
57
|
+
return controls.retryPreparation({ sessionId: request.sessionId });
|
|
58
|
+
case 'cancelPreparation':
|
|
59
|
+
return controls.cancelPreparation({ sessionId: request.sessionId });
|
|
60
|
+
case 'resume':
|
|
61
|
+
return controls.resume({ sessionId: request.sessionId });
|
|
62
|
+
case 'send':
|
|
63
|
+
return controls.send({ sessionId: request.sessionId, text: request.text, command: request.command });
|
|
64
|
+
case 'answer':
|
|
65
|
+
return controls.answer({ sessionId: request.sessionId, approvalId: request.approvalId, choiceId: request.choiceId });
|
|
66
|
+
case 'interrupt':
|
|
67
|
+
return controls.interrupt({ sessionId: request.sessionId });
|
|
68
|
+
case 'agentOptions':
|
|
69
|
+
return controls.agentOptions({ agent: request.agent });
|
|
70
|
+
case 'setOption':
|
|
71
|
+
return controls.setOption({ sessionId: request.sessionId, option: request.option, value: request.value });
|
|
72
|
+
case 'events':
|
|
73
|
+
return controls.events({ sessionId: request.sessionId, after: request.after, limit: request.limit });
|
|
74
|
+
case 'close':
|
|
75
|
+
return controls.close({ sessionId: request.sessionId });
|
|
76
|
+
case 'archive':
|
|
77
|
+
return controls.archive({ sessionId: request.sessionId, archived: request.archived });
|
|
78
|
+
case 'delete':
|
|
79
|
+
return controls.delete({ sessionId: request.sessionId });
|
|
80
|
+
case 'workspace':
|
|
81
|
+
return controls.workspace({ folder: request.folder });
|
|
82
|
+
case 'fetch':
|
|
83
|
+
return controls.fetch({ folder: request.folder });
|
|
84
|
+
case 'suggestName':
|
|
85
|
+
return controls.suggestName({ folder: request.folder });
|
|
86
|
+
case 'checkName':
|
|
87
|
+
return controls.checkName({ folder: request.folder, name: request.name });
|
|
88
|
+
case 'deletePreview':
|
|
89
|
+
return controls.deletePreview({ sessionIds: request.sessionIds });
|
|
90
|
+
case 'cloneRepo':
|
|
91
|
+
return controls.cloneRepo({ repo: request.repo }, gone);
|
|
92
|
+
case 'createProject':
|
|
93
|
+
return controls.createProject({ name: request.name });
|
|
94
|
+
case 'githubRepos':
|
|
95
|
+
return controls.githubRepos({ page: request.page });
|
|
96
|
+
case 'repoBranches':
|
|
97
|
+
return controls.repoBranches({ repo: request.repo });
|
|
98
|
+
case 'health':
|
|
99
|
+
return controls.health({ refresh: request.refresh });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function answer(line, controls, gone, stopAfterReply) {
|
|
103
|
+
let value;
|
|
104
|
+
try {
|
|
105
|
+
value = JSON.parse(line);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { id: null, ok: false, error: 'invalid JSON' };
|
|
109
|
+
}
|
|
110
|
+
const parsed = chatRequest.safeParse(value);
|
|
111
|
+
if (parsed.success === false) {
|
|
112
|
+
// the id is echoed only when it is a valid one, so the client can match the error
|
|
113
|
+
const given = typeof value === 'object' && value !== null ? id.safeParse(value.id) : null;
|
|
114
|
+
return { id: given?.success ? given.data : null, ok: false, error: 'invalid request' };
|
|
115
|
+
}
|
|
116
|
+
if (parsed.data.cmd === 'stop') {
|
|
117
|
+
stopAfterReply();
|
|
118
|
+
return { id: parsed.data.id, ok: true };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return { id: parsed.data.id, ok: true, data: await run(parsed.data, controls, gone) };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof ChatRefused)
|
|
125
|
+
return { id: parsed.data.id, ok: false, error: error.message };
|
|
126
|
+
log.error(`${parsed.data.cmd} failed`, error);
|
|
127
|
+
return { id: parsed.data.id, ok: false, error: 'internal error' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Opens `chat.sock` in `stateDir`: newline-delimited JSON, a hello on connect and one reply per request, like the engine's.
|
|
132
|
+
* The caller holds the chat lock, so a socket file already there is a leftover of a crash. Unlike the engine, a socket
|
|
133
|
+
* that cannot open rejects: a host without it is of no use. `clients` is told when a client connects or leaves.
|
|
134
|
+
* `close()` is synchronous and removes the file.
|
|
135
|
+
*/
|
|
136
|
+
export async function startChatServer({ stateDir, startedAt, controls, stop, clients }) {
|
|
137
|
+
const path = join(stateDir, 'chat.sock');
|
|
138
|
+
const { fits, bytes } = socketPathFits(path);
|
|
139
|
+
if (fits === false)
|
|
140
|
+
throw new Error(`${path} is ${bytes} bytes, too long for a socket; use a shorter CARRIER_HOME`);
|
|
141
|
+
const sockets = new Set();
|
|
142
|
+
const hello = { event: 'hello', protocol: 1, pid: process.pid, startedAt, version, capabilities };
|
|
143
|
+
const send = (socket, message, written) => void socket.write(`${JSON.stringify(message)}\n`, written);
|
|
144
|
+
const server = createServer((socket) => {
|
|
145
|
+
sockets.add(socket);
|
|
146
|
+
clients.joined();
|
|
147
|
+
const gone = new AbortController();
|
|
148
|
+
socket.setEncoding('utf8');
|
|
149
|
+
// a client that goes away mid-write is routine, not a failure
|
|
150
|
+
socket.on('error', (error) => log.debug('chat connection error', error));
|
|
151
|
+
socket.on('close', () => {
|
|
152
|
+
gone.abort();
|
|
153
|
+
sockets.delete(socket);
|
|
154
|
+
clients.left();
|
|
155
|
+
});
|
|
156
|
+
send(socket, hello);
|
|
157
|
+
let pending = '';
|
|
158
|
+
let replies = Promise.resolve(); // one reply per request, in the order asked: a slow `open` does not let a later reply overtake it
|
|
159
|
+
socket.on('data', (chunk) => {
|
|
160
|
+
pending += chunk;
|
|
161
|
+
for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
|
|
162
|
+
const line = pending.slice(0, end);
|
|
163
|
+
pending = pending.slice(end + 1);
|
|
164
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES)
|
|
165
|
+
return tooLong(socket);
|
|
166
|
+
// `stop`: the reply is flushed first, then the shutdown runs
|
|
167
|
+
replies = replies.then(async () => {
|
|
168
|
+
let afterReply;
|
|
169
|
+
const reply = await answer(line, controls, gone.signal, () => void (afterReply = () => void stop().catch((error) => log.error('stop failed', error))));
|
|
170
|
+
send(socket, reply, afterReply);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (Buffer.byteLength(pending) > MAX_LINE_BYTES)
|
|
174
|
+
tooLong(socket);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
const tooLong = (socket) => {
|
|
178
|
+
socket.removeAllListeners('data'); // nothing more is read from this client
|
|
179
|
+
socket.end(`${JSON.stringify({ id: null, ok: false, error: 'line too long' })}\n`);
|
|
180
|
+
};
|
|
181
|
+
server.on('error', (error) => log.error('chat socket error', error));
|
|
182
|
+
rmSync(path, { force: true });
|
|
183
|
+
await new Promise((resolve, reject) => {
|
|
184
|
+
server.once('error', reject);
|
|
185
|
+
// bind is synchronous: with this umask the file is born 0600, and the umask is back before anything else runs
|
|
186
|
+
const umask = process.umask(0o177);
|
|
187
|
+
try {
|
|
188
|
+
server.listen(path, resolve);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
process.umask(umask);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
close: () => {
|
|
196
|
+
server.close();
|
|
197
|
+
for (const socket of sockets)
|
|
198
|
+
socket.destroy();
|
|
199
|
+
rmSync(path, { force: true });
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|