@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,46 @@
|
|
|
1
|
+
import { enginePlacesOf } from '../../../config/engine-places.js';
|
|
2
|
+
import { CliError } from '../cli-error.js';
|
|
3
|
+
import { connectControl, ControlUnreachable } from '../control-client.js';
|
|
4
|
+
const requests = { stop: 'stop', pause: 'pause', resume: 'resume', check: 'checkNow' };
|
|
5
|
+
const confirmations = { stop: 'Carrier stopped', pause: 'Intake paused', resume: 'Intake resumed', check: 'Checking GitHub now' };
|
|
6
|
+
const STOP_WAIT_MS = 15_000; // the same limit as the app
|
|
7
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
/** Polls until nothing answers on the socket any more; false when it still does after `STOP_WAIT_MS`. */
|
|
9
|
+
export async function socketGone(path) {
|
|
10
|
+
for (const deadline = Date.now() + STOP_WAIT_MS; Date.now() < deadline; await sleep(100)) {
|
|
11
|
+
try {
|
|
12
|
+
(await connectControl(path, { timeoutMs: 500 })).close();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
/** `stop`, `pause`, `resume` and `check` over the control socket of the Carrier running in this Carrier folder. Returns the exit code. */
|
|
21
|
+
export async function controlCommand(name, folder) {
|
|
22
|
+
const path = enginePlacesOf(folder).socket;
|
|
23
|
+
let client;
|
|
24
|
+
try {
|
|
25
|
+
client = await connectControl(path);
|
|
26
|
+
await client.request(requests[name]);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof ControlUnreachable === false)
|
|
30
|
+
throw new CliError(1, `Carrier refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
31
|
+
if (error.reason === 'incompatible')
|
|
32
|
+
throw new CliError(2, 'The running Carrier speaks a protocol this command does not (it expects 1). Update one of them.');
|
|
33
|
+
if (error.reason === 'no-answer')
|
|
34
|
+
throw new CliError(1, `Carrier does not answer on ${path}`);
|
|
35
|
+
if (name === 'stop') {
|
|
36
|
+
process.stdout.write('Carrier is not running\n');
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
throw new CliError(1, 'Carrier is not running');
|
|
40
|
+
}
|
|
41
|
+
client.close();
|
|
42
|
+
if (name === 'stop' && (await socketGone(path)) === false)
|
|
43
|
+
throw new CliError(1, `Carrier still answers on ${path} after ${STOP_WAIT_MS / 1000} seconds`);
|
|
44
|
+
process.stdout.write(`${confirmations[name]}\n`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { runDoctor } from '../../../doctor/doctor.js';
|
|
2
|
+
import { makeClackUi, noPromptUi, printCheck } from '../../prompts/clack.js';
|
|
3
|
+
/** Lists every check (this command is asked for them) and returns the exit code: 1 when any failed. With `json`, stdout is the checks as one JSON array and nothing else. */
|
|
4
|
+
export async function doctorCommand(config, folder, json = false, overrides = {}) {
|
|
5
|
+
if (json) {
|
|
6
|
+
const checks = await runDoctor(config, folder, overrides); // no spinner or progress: the app reads stdout whole
|
|
7
|
+
process.stdout.write(`${JSON.stringify(checks)}\n`);
|
|
8
|
+
return checks.some((c) => c.status === 'fail') ? 1 : 0;
|
|
9
|
+
}
|
|
10
|
+
const ui = process.stdout.isTTY ? makeClackUi() : { ...noPromptUi, check: printCheck }; // a spinner per check only on a terminal
|
|
11
|
+
const checks = await runDoctor(config, folder, overrides, ui.check, ui.checkStart);
|
|
12
|
+
const failed = checks.filter((c) => c.status === 'fail').length;
|
|
13
|
+
process.stdout.write(failed > 0 ? `\n${failed} of ${checks.length} checks failed\n` : `\nAll ${checks.length} checks passed\n`);
|
|
14
|
+
return failed > 0 ? 1 : 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { runInit } from '../../wizard/wizard.js';
|
|
2
|
+
import { cancelled, makeClackUi, noPromptUi } from '../../prompts/clack.js';
|
|
3
|
+
import { Cancelled } from '../../prompts/types.js';
|
|
4
|
+
import { CliError } from '../cli-error.js';
|
|
5
|
+
import { runCommand } from './run.js';
|
|
6
|
+
/** The guided setup. Returns the exit code, or undefined when "Start Carrier now?" was answered yes and it is running. */
|
|
7
|
+
export async function initCommand(flags, folder) {
|
|
8
|
+
const ui = flags.yes || !process.stdin.isTTY ? noPromptUi : makeClackUi({ compact: true });
|
|
9
|
+
let started;
|
|
10
|
+
try {
|
|
11
|
+
started = await runInit({
|
|
12
|
+
repo: flags.repo,
|
|
13
|
+
identities: flags.identities,
|
|
14
|
+
authorProfile: flags['author-profile'],
|
|
15
|
+
reviewerProfile: flags['reviewer-profile'],
|
|
16
|
+
leadReviewerProfile: flags['lead-reviewer-profile'],
|
|
17
|
+
author: flags.author,
|
|
18
|
+
reviewer: flags.reviewer,
|
|
19
|
+
leadReviewer: flags['lead-reviewer'],
|
|
20
|
+
authorProvider: flags['author-provider'],
|
|
21
|
+
reviewerProvider: flags['reviewer-provider'],
|
|
22
|
+
leadReviewerProvider: flags['lead-reviewer-provider'],
|
|
23
|
+
authorPersonaName: flags['author-persona-name'],
|
|
24
|
+
authorPersonaPrompt: flags['author-persona-prompt'],
|
|
25
|
+
reviewerPersonaName: flags['reviewer-persona-name'],
|
|
26
|
+
reviewerPersonaPrompt: flags['reviewer-persona-prompt'],
|
|
27
|
+
leadReviewerPersonaName: flags['lead-reviewer-persona-name'],
|
|
28
|
+
leadReviewerPersonaPrompt: flags['lead-reviewer-persona-prompt'],
|
|
29
|
+
out: flags.out,
|
|
30
|
+
yes: flags.yes,
|
|
31
|
+
}, ui, undefined, folder);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Cancelled) {
|
|
35
|
+
cancelled();
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
throw new CliError(1, error instanceof Error ? error.message : String(error));
|
|
39
|
+
}
|
|
40
|
+
if (started.start)
|
|
41
|
+
return void (await runCommand(started.config, folder, { framed: true })); // the same process, until Ctrl+C
|
|
42
|
+
return started.checks.some((c) => c.status === 'fail') ? 1 : 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { enginePlacesOf } from '../../../config/engine-places.js';
|
|
2
|
+
import { cancelled, makeClackUi } from '../../prompts/clack.js';
|
|
3
|
+
import { Cancelled } from '../../prompts/types.js';
|
|
4
|
+
import { CliError } from '../cli-error.js';
|
|
5
|
+
import { connectControl, ControlUnreachable } from '../control-client.js';
|
|
6
|
+
// The engine waits up to 30 seconds for the epic to start again before it refuses.
|
|
7
|
+
const RESTART_WAIT_MS = 40_000;
|
|
8
|
+
// What the engine's short refusal means for the person, and what to do next.
|
|
9
|
+
const refusals = {
|
|
10
|
+
'unknown epic': (target) => `Carrier does not know ${target}. Use the owner/repo#issue that carrier status shows.`,
|
|
11
|
+
'not stopped': (target) => `${target} is not stopped, so there is nothing to restart.`,
|
|
12
|
+
'github unavailable': () => 'GitHub did not answer, so nothing changed. Try again in a moment.',
|
|
13
|
+
'did not restart': () => 'The ready label is back on the issue, but the epic did not start within 30 seconds. The next check will take it.',
|
|
14
|
+
'unknown option': () => 'That option is not in agentOptions of the configuration. Use a name declared there.',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* One question per role with the role's current option preselected, and the option of the role that stopped the epic marked.
|
|
18
|
+
* When that role ran out of usage its option is not preselected: two Enters would run the same option again.
|
|
19
|
+
* Nothing is asked of an epic that has fewer than two options to choose from (or predates options).
|
|
20
|
+
*/
|
|
21
|
+
export async function askRestartOptions(epic, ui) {
|
|
22
|
+
const options = epic?.options;
|
|
23
|
+
if (!options || options.names.length < 2)
|
|
24
|
+
return {};
|
|
25
|
+
const failed = epic.stage === 'review' ? 'reviewer' : 'author';
|
|
26
|
+
const chosen = {};
|
|
27
|
+
for (const role of ['author', 'reviewer']) {
|
|
28
|
+
const current = options[role];
|
|
29
|
+
const ranOut = role === failed && epic.needsHumanKind === 'usage_limit';
|
|
30
|
+
chosen[role] = await ui.select({
|
|
31
|
+
message: `Option for the ${role}`,
|
|
32
|
+
initialValue: (ranOut ? options.names.find((name) => name !== current) : current) ?? undefined,
|
|
33
|
+
options: options.names.map((name) => ({ value: name, label: name, ...(name === current && { hint: ranOut ? 'ran out' : role === failed ? 'current, failed' : 'current' }) })),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return chosen;
|
|
37
|
+
}
|
|
38
|
+
/** `carrier restart <owner/repo>#<issue>`: resumes a stopped epic over the control socket. Returns the exit code. */
|
|
39
|
+
export async function restartCommand(folder, { repo, issue }, flags) {
|
|
40
|
+
const path = enginePlacesOf(folder).socket;
|
|
41
|
+
let client;
|
|
42
|
+
try {
|
|
43
|
+
client = await connectControl(path, { timeoutMs: RESTART_WAIT_MS });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof ControlUnreachable === false)
|
|
47
|
+
throw error;
|
|
48
|
+
if (error.reason === 'incompatible')
|
|
49
|
+
throw new CliError(2, 'The running Carrier speaks a protocol this command does not (it expects 1). Update one of them.');
|
|
50
|
+
if (error.reason === 'no-answer')
|
|
51
|
+
throw new CliError(1, `Carrier does not answer on ${path}`);
|
|
52
|
+
throw new CliError(1, 'Carrier is not running');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (client.hello.capabilities.includes('agentOptions') === false)
|
|
56
|
+
throw new CliError(1, 'The running Carrier is too old to restart an epic on a chosen option (it lacks the agentOptions capability). Update it.');
|
|
57
|
+
let chosen = { ...(flags.author && { author: flags.author }), ...(flags.reviewer && { reviewer: flags.reviewer }) };
|
|
58
|
+
if (flags.author === undefined && flags.reviewer === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
59
|
+
const { epics } = (await client.request('snapshot'));
|
|
60
|
+
const epic = epics.find((e) => e.repo === repo && e.issue === issue);
|
|
61
|
+
// Before any question: an epic the engine would refuse is not worth asking about.
|
|
62
|
+
if (epic === undefined)
|
|
63
|
+
throw new CliError(1, refusals['unknown epic'](`${repo}#${issue}`));
|
|
64
|
+
if (epic.status !== 'needs_human')
|
|
65
|
+
throw new CliError(1, refusals['not stopped'](`${repo}#${issue}`));
|
|
66
|
+
try {
|
|
67
|
+
chosen = await askRestartOptions(epic, makeClackUi({ compact: true }));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof Cancelled === false)
|
|
71
|
+
throw error;
|
|
72
|
+
cancelled();
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
process.stdout.write(`Restarting ${repo}#${issue}…\n`); // the engine waits up to 30 seconds
|
|
77
|
+
await client.request('resumeEpic', { repo, issue, ...chosen });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof CliError)
|
|
81
|
+
throw error;
|
|
82
|
+
if (error instanceof ControlUnreachable)
|
|
83
|
+
throw new CliError(1, `Carrier does not answer on ${path}`);
|
|
84
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new CliError(1, refusals[reason]?.(`${repo}#${issue}`) ?? `Carrier refused: ${reason}`);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
client.close();
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(`Restarted ${repo}#${issue}\n`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
2
|
+
import { CliError } from '../cli-error.js';
|
|
3
|
+
import { InstanceBusy, startCarrier } from '../compose.js';
|
|
4
|
+
/**
|
|
5
|
+
* Starts the orchestrator; it keeps the process alive until Ctrl+C. On a terminal: "Starting…" until the engine is up,
|
|
6
|
+
* then the arrival line, then the panel. `framed`: the caller (init) already opened the frame the spinner lives in.
|
|
7
|
+
*/
|
|
8
|
+
export async function runCommand(config, folder, { framed = false } = {}) {
|
|
9
|
+
const announce = process.stdout.isTTY ? (await import('../../prompts/clack.js')).announceStart({ framed }) : undefined;
|
|
10
|
+
try {
|
|
11
|
+
await startCarrier(config, folder, { onArrived: announce?.arrived });
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
announce?.failed();
|
|
15
|
+
if (error instanceof InstanceBusy)
|
|
16
|
+
throw new CliError(1, error.message);
|
|
17
|
+
createScopedLogger('cli').error('startup failed', error); // logging is installed by now: the stack goes to the log file
|
|
18
|
+
throw new CliError(1, `carrier failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { enginePlacesOf } from '../../../config/engine-places.js';
|
|
3
|
+
import { pidAt, startDetached } from '../detached.js';
|
|
4
|
+
/**
|
|
5
|
+
* Starts Carrier as a detached run (ADR 0010): the same program with `--config`, in its own session, its output in
|
|
6
|
+
* `<folder>/engine/logs/start.log`. Returns once the control socket answers; a Carrier already answering is left alone.
|
|
7
|
+
* `folder` is the Carrier folder; `configPath` is absolute: the child does not share this process's working directory.
|
|
8
|
+
*/
|
|
9
|
+
export async function startCommand(folder, configPath) {
|
|
10
|
+
const places = enginePlacesOf(folder);
|
|
11
|
+
const socketPath = places.socket;
|
|
12
|
+
const logPath = join(places.logs, 'start.log');
|
|
13
|
+
const running = await pidAt(socketPath);
|
|
14
|
+
if (running !== undefined) {
|
|
15
|
+
process.stdout.write(`Carrier is already running (pid ${running}).\n`);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
const pid = await startDetached({ label: 'Carrier', args: ['--config', configPath], socketPath, logPath });
|
|
19
|
+
process.stdout.write(`Carrier is running (pid ${pid}). Log: ${logPath}\n`);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { enginePlacesOf } from '../../../config/engine-places.js';
|
|
2
|
+
import { renderDashboard } from '../../dashboard/dashboard.js';
|
|
3
|
+
import { connectControl, ControlUnreachable } from '../control-client.js';
|
|
4
|
+
const out = (text) => process.stdout.write(`${text}\n`);
|
|
5
|
+
const err = (text) => process.stderr.write(`${text}\n`);
|
|
6
|
+
/** Connects to the Carrier running in this Carrier folder; when there is none to use, says why and returns the exit code instead: 1 not running or silent, 2 another protocol. */
|
|
7
|
+
export async function attach(folder) {
|
|
8
|
+
try {
|
|
9
|
+
return await connectControl(enginePlacesOf(folder).socket);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error instanceof ControlUnreachable === false)
|
|
13
|
+
throw error;
|
|
14
|
+
if (error.reason === 'incompatible') {
|
|
15
|
+
err(`This carrier speaks control protocol 1; the running one speaks ${error.protocol === undefined ? 'something else' : `protocol ${String(error.protocol)}`}. Update the older one so both match.`);
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
err(error.reason === 'no-answer' ? 'Carrier is running but does not answer. Try again, or check its log.' : 'Carrier is not running. Start it with "carrier start".');
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* An epic's stages over the socket; null when there are none to know (the engine's hello does not list `sessions` and nothing is sent,
|
|
24
|
+
* or it answers `unknown epic`) and undefined when the request failed this time. Either way the epic is drawn without detail, which
|
|
25
|
+
* never breaks the panel; only the failure is worth asking again.
|
|
26
|
+
*/
|
|
27
|
+
export async function stagesOf(client, repo, issue) {
|
|
28
|
+
if (client.hello.capabilities.includes('sessions') === false)
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
return (await client.request('sessions', { repo, issue })).sessions;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return error instanceof Error && error.message === 'unknown epic' ? null : undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Prints the panel of the running Carrier once. Exit 0 it runs; 1 it is not running or does not answer; 2 it speaks another protocol. */
|
|
38
|
+
export async function statusCommand(folder) {
|
|
39
|
+
const client = await attach(folder);
|
|
40
|
+
if (typeof client === 'number')
|
|
41
|
+
return client;
|
|
42
|
+
try {
|
|
43
|
+
const snapshot = (await client.request('snapshot'));
|
|
44
|
+
const tty = process.stdout.isTTY === true;
|
|
45
|
+
// Every epic on the panel shows its stages; one print, so one request each.
|
|
46
|
+
const shown = [...snapshot.epics, ...snapshot.done];
|
|
47
|
+
const stages = new Map(await Promise.all(shown.map(async (e) => [`${e.repo}#${e.issue}`, await stagesOf(client, e.repo, e.issue)])));
|
|
48
|
+
const detail = (repo, issue) => stages.get(`${repo}#${issue}`) ?? null;
|
|
49
|
+
out(renderDashboard(snapshot, { color: tty, columns: tty ? process.stdout.columns : 100, detail }).join('\n'));
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof ControlUnreachable === false)
|
|
54
|
+
throw error;
|
|
55
|
+
err('Carrier is running but does not answer. Try again, or check its log.');
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
client.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { renderDashboard } from '../../dashboard/dashboard.js';
|
|
2
|
+
import { startInteractivePanel } from '../../dashboard/interactive.js';
|
|
3
|
+
import { startPanel } from '../../dashboard/panel.js';
|
|
4
|
+
import { ControlUnreachable } from '../control-client.js';
|
|
5
|
+
import { openUrl } from '../open-url.js';
|
|
6
|
+
import { attach, stagesOf } from './status.js';
|
|
7
|
+
const REFRESH_MS = 1000;
|
|
8
|
+
const TAIL_LINES = 18; // what the logs screen shows; the cards' "now" lines take the end of the same answer
|
|
9
|
+
/**
|
|
10
|
+
* The panel's `Controls` and snapshot over a control socket. The panel reads both synchronously, so the snapshot and
|
|
11
|
+
* each execution's output are kept from the last answer and renewed in the background: `refresh` for the snapshot,
|
|
12
|
+
* a new `tail` request at each read of the output. `lost` is called when the socket stops answering.
|
|
13
|
+
* The stages of each epic work the same way: the panel's cache rule decides when to ask again, the request goes out
|
|
14
|
+
* in the background, the last answer stays on screen meanwhile and the next redraw shows the new one.
|
|
15
|
+
*/
|
|
16
|
+
export function socketPanel(client, { open, quit, lost }) {
|
|
17
|
+
let latest;
|
|
18
|
+
const tails = new Map();
|
|
19
|
+
// Another failure (the engine refused one request) leaves the panel as it was.
|
|
20
|
+
const guard = (request) => request.catch((error) => error instanceof ControlUnreachable && lost());
|
|
21
|
+
const stages = new Map();
|
|
22
|
+
const detail = (repo, issue) => {
|
|
23
|
+
const key = `${repo}#${issue}`;
|
|
24
|
+
const known = stages.get(key) ?? { detail: null, asking: false, arrived: false };
|
|
25
|
+
stages.set(key, known);
|
|
26
|
+
if (known.arrived) {
|
|
27
|
+
known.arrived = false;
|
|
28
|
+
return known.detail;
|
|
29
|
+
}
|
|
30
|
+
if (known.asking === false) {
|
|
31
|
+
known.asking = true;
|
|
32
|
+
// A failed request is not an answer: the last one stays and the next draw asks again.
|
|
33
|
+
void stagesOf(client, repo, issue).then((answer) => Object.assign(known, answer === undefined ? { asking: false } : { detail: answer, asking: false, arrived: true }));
|
|
34
|
+
}
|
|
35
|
+
// ponytail: an answer that was asked before the epic's latest change is shown as if current until the next change; per-ask tokens if it ever shows.
|
|
36
|
+
return undefined; // not remembered: the panel asks again at its next draw
|
|
37
|
+
};
|
|
38
|
+
const controls = {
|
|
39
|
+
detail,
|
|
40
|
+
poll: () => guard(client.request('checkNow')),
|
|
41
|
+
setPaused: (paused) => guard(client.request(paused ? 'pause' : 'resume')),
|
|
42
|
+
// An engine that does not list agentOptions cannot be asked, so the panel is given no way to ask.
|
|
43
|
+
// The engine's refusal (`not waiting`, `unknown option`) goes back to the panel, which tells the person.
|
|
44
|
+
...(client.hello.capabilities.includes('agentOptions') && {
|
|
45
|
+
chooseOptions: (repo, issue, chosen) => client.request('chooseOptions', { repo, issue, ...chosen }).catch((error) => {
|
|
46
|
+
if (error instanceof ControlUnreachable)
|
|
47
|
+
lost();
|
|
48
|
+
else
|
|
49
|
+
throw error;
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
open,
|
|
53
|
+
quit,
|
|
54
|
+
logs: (executionId, count) => {
|
|
55
|
+
const tail = tails.get(executionId) ?? { lines: [], asking: false };
|
|
56
|
+
tails.set(executionId, tail);
|
|
57
|
+
if (tail.asking === false) {
|
|
58
|
+
tail.asking = true;
|
|
59
|
+
void guard(client.request('tail', { executionId, lines: TAIL_LINES }).then((lines) => (tail.lines = lines))).finally(() => (tail.asking = false));
|
|
60
|
+
}
|
|
61
|
+
return tail.lines.slice(-count);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
controls,
|
|
66
|
+
snapshot: () => latest,
|
|
67
|
+
/** Asks for the snapshot; rejects when the socket does not answer. */
|
|
68
|
+
refresh: async () => {
|
|
69
|
+
latest = (await client.request('snapshot'));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Shows the live panel of the running Carrier, with the foreground panel's keys; `q` and Ctrl+C leave and Carrier keeps running. Exit 0 left; 1 not running, or it stopped; 2 another protocol. */
|
|
74
|
+
export async function watchCommand(folder) {
|
|
75
|
+
const client = await attach(folder);
|
|
76
|
+
if (typeof client === 'number')
|
|
77
|
+
return client;
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let stop = () => { };
|
|
80
|
+
let timer;
|
|
81
|
+
let finished = false;
|
|
82
|
+
const finish = (code, message) => {
|
|
83
|
+
if (finished)
|
|
84
|
+
return;
|
|
85
|
+
finished = true;
|
|
86
|
+
clearInterval(timer);
|
|
87
|
+
process.off('SIGINT', leave);
|
|
88
|
+
stop(); // the cursor and the keys back before anything else is printed
|
|
89
|
+
client.close();
|
|
90
|
+
if (message)
|
|
91
|
+
process.stderr.write(`${message}\n`);
|
|
92
|
+
resolve(code);
|
|
93
|
+
};
|
|
94
|
+
const leave = () => finish(0);
|
|
95
|
+
const panel = socketPanel(client, { open: openUrl, quit: leave, lost: () => finish(1, 'Carrier stopped.') });
|
|
96
|
+
process.once('SIGINT', leave); // a Ctrl+C that is not a key: no raw mode, because stdin is not a terminal
|
|
97
|
+
panel.refresh().then(() => {
|
|
98
|
+
const { stdout, stdin } = process;
|
|
99
|
+
stop =
|
|
100
|
+
stdout.isTTY && stdin.isTTY
|
|
101
|
+
? startInteractivePanel({ snapshot: panel.snapshot, out: stdout, stdin, controls: panel.controls, quit: 'leave' })
|
|
102
|
+
: startPanel(panel.snapshot, stdout, REFRESH_MS, (s, columns) => renderDashboard(s, { columns, quit: 'leave', detail: panel.controls.detail }));
|
|
103
|
+
let asking = false;
|
|
104
|
+
timer = setInterval(() => {
|
|
105
|
+
if (asking)
|
|
106
|
+
return;
|
|
107
|
+
asking = true;
|
|
108
|
+
panel.refresh().catch(() => finish(1, 'Carrier stopped.')).finally(() => (asking = false));
|
|
109
|
+
}, REFRESH_MS);
|
|
110
|
+
}, () => finish(1, 'Carrier is running but does not answer. Try again, or check its log.'));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { enginePlacesOf } from '../../config/engine-places.js';
|
|
5
|
+
import { resolveRepoConfig } from '../../config/resolve.js';
|
|
6
|
+
import { GitWorkspaces, profileToken } from '../../execution/git-workspaces.js';
|
|
7
|
+
import { epicOf } from '../../control/epic.js';
|
|
8
|
+
import { eventsOf, lastEventOf } from '../../control/events.js';
|
|
9
|
+
import { fileDiff, filesOf } from '../../control/files.js';
|
|
10
|
+
import { inheritedConfigDirs, sessionsOf } from '../../control/sessions.js';
|
|
11
|
+
import { startControlServer, version } from '../../control/server.js';
|
|
12
|
+
import { listAvailableRepos } from '../../integrations/available-repos.js';
|
|
13
|
+
import { forgeCwd } from '../../integrations/forge-cwd.js';
|
|
14
|
+
import { GhGitHub } from '../../integrations/github.js';
|
|
15
|
+
import { installFileLogging } from '../../logging/logger.js';
|
|
16
|
+
import { realClock } from '../../orchestration/clock.js';
|
|
17
|
+
import { createEngine } from '../../orchestration/engine.js';
|
|
18
|
+
import { AuthError } from '../../orchestration/github.js';
|
|
19
|
+
import { sessionDirsFrom } from '../../orchestration/retention.js';
|
|
20
|
+
import { renderDashboard } from '../dashboard/dashboard.js';
|
|
21
|
+
import { startInteractivePanel } from '../dashboard/interactive.js';
|
|
22
|
+
import { tailExecution } from '../dashboard/logs.js';
|
|
23
|
+
import { startPanel } from '../dashboard/panel.js';
|
|
24
|
+
import { buildSnapshot, watchedOf } from '../../status/snapshot.js';
|
|
25
|
+
import { profileCredentials } from '../../harnesses/credentials.js';
|
|
26
|
+
import { HostAgentRunner } from '../../harnesses/host-runner.js';
|
|
27
|
+
import { openDatabase } from '../../storage/db.js';
|
|
28
|
+
import { acquireLock } from '../../storage/lock.js';
|
|
29
|
+
import { stopOnSigint } from './interrupt.js';
|
|
30
|
+
import { openUrl } from './open-url.js';
|
|
31
|
+
export class InstanceBusy extends Error {
|
|
32
|
+
}
|
|
33
|
+
const realDeps = () => ({
|
|
34
|
+
installLogging: installFileLogging,
|
|
35
|
+
clock: realClock,
|
|
36
|
+
makeGitHub: (options) => new GhGitHub(options),
|
|
37
|
+
makeWorkspaces: (options) => new GitWorkspaces(options),
|
|
38
|
+
makeRunner: (options) => new HostAgentRunner(options),
|
|
39
|
+
mint: (profile, repo) => profileToken(profile)(repo),
|
|
40
|
+
sessionDirs: sessionDirsFrom(),
|
|
41
|
+
listRepos: listAvailableRepos,
|
|
42
|
+
panelOut: process.stdout,
|
|
43
|
+
onSigint: stopOnSigint,
|
|
44
|
+
stdin: process.stdin,
|
|
45
|
+
openUrl,
|
|
46
|
+
quit: () => process.kill(process.pid, 'SIGINT'),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Starts carrier on a validated config and the Carrier folder it keeps its files in: the instance lock, file logging, the engine with the real GitHub (the
|
|
50
|
+
* orchestrator's profile, through Forge), workspaces and the runner of each harness, the panel, and Ctrl+C.
|
|
51
|
+
* Throws InstanceBusy when another carrier holds the folder's `engine/state/`.
|
|
52
|
+
*/
|
|
53
|
+
export async function startCarrier(config, folder, overrides = {}) {
|
|
54
|
+
const deps = { ...realDeps(), ...overrides };
|
|
55
|
+
const places = enginePlacesOf(folder);
|
|
56
|
+
// First side effect of startup: one instance per Carrier folder.
|
|
57
|
+
const lock = acquireLock(places.state);
|
|
58
|
+
if (!lock.ok)
|
|
59
|
+
throw new InstanceBusy(`Another carrier instance is already running on ${places.state}`);
|
|
60
|
+
mkdirSync(places.logs, { recursive: true });
|
|
61
|
+
chmodSync(places.logs, 0o700); // execution output is for the user only
|
|
62
|
+
// From here on console.* goes to the log file; the terminal is for the panel.
|
|
63
|
+
deps.installLogging(join(places.logs, 'orchestrator.log'), config.logLevel);
|
|
64
|
+
createScopedLogger('cli').info('carrier started', { folder, repos: config.repos.length });
|
|
65
|
+
const startedAt = deps.clock.now();
|
|
66
|
+
const db = openDatabase(places.db);
|
|
67
|
+
const repos = config.repos.map((repo) => resolveRepoConfig(config, repo));
|
|
68
|
+
const { limits } = config;
|
|
69
|
+
// Git over the network (clone, fetch) runs as the repo's author profile.
|
|
70
|
+
const authorProfile = (repo) => config.profiles[repos.find((r) => r.name === repo).agents.author.profile];
|
|
71
|
+
const github = deps.makeGitHub({
|
|
72
|
+
profile: config.profiles[config.orchestratorProfile],
|
|
73
|
+
cwd: forgeCwd(places),
|
|
74
|
+
timeoutMs: limits.githubCallTimeoutSeconds * 1000,
|
|
75
|
+
});
|
|
76
|
+
const engine = createEngine({
|
|
77
|
+
db,
|
|
78
|
+
clock: deps.clock,
|
|
79
|
+
github,
|
|
80
|
+
workspaces: deps.makeWorkspaces({ places, token: (repo) => deps.mint(authorProfile(repo), repo) }),
|
|
81
|
+
runner: deps.makeRunner({ credentials: profileCredentials({ profiles: config.profiles, mint: deps.mint, botId: deps.botId }) }),
|
|
82
|
+
repos,
|
|
83
|
+
pollIntervalMs: config.pollIntervalMinutes * 60_000,
|
|
84
|
+
maxConcurrentEpics: limits.maxConcurrentEpics,
|
|
85
|
+
maxReviews: limits.maxReviews,
|
|
86
|
+
maxExecutionRetries: limits.maxExecutionRetries,
|
|
87
|
+
optionChoiceSeconds: limits.optionChoiceSeconds,
|
|
88
|
+
maxExecutionHours: limits.maxExecutionHours,
|
|
89
|
+
places,
|
|
90
|
+
sessionDirs: deps.sessionDirs,
|
|
91
|
+
});
|
|
92
|
+
let stopPanel = () => { }; // the panel starts once the engine is up
|
|
93
|
+
let stopControl = () => { };
|
|
94
|
+
const close = () => {
|
|
95
|
+
stopControl(); // before the lock goes: a socket without the lock is what a takeover looks like
|
|
96
|
+
stopPanel();
|
|
97
|
+
engine.stop();
|
|
98
|
+
db.$client.close();
|
|
99
|
+
lock.release();
|
|
100
|
+
};
|
|
101
|
+
// Before the engine starts: its recovery may launch agents already, and Ctrl+C has to stop them.
|
|
102
|
+
const shutdown = deps.onSigint({ engine, close });
|
|
103
|
+
try {
|
|
104
|
+
await engine.start();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await engine.shutdown(); // recovery may have launched agents already
|
|
108
|
+
close();
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
// Only now: until the engine is up the terminal belongs to whoever is waiting for it (the "Starting…" spinner).
|
|
112
|
+
deps.onArrived?.();
|
|
113
|
+
const snapshot = () => {
|
|
114
|
+
const limit = github.rateLimit();
|
|
115
|
+
return buildSnapshot(db, {
|
|
116
|
+
now: deps.clock.now(),
|
|
117
|
+
maxConcurrentEpics: limits.maxConcurrentEpics,
|
|
118
|
+
maxReviews: limits.maxReviews,
|
|
119
|
+
finishedWindowDays: config.finishedWindowDays,
|
|
120
|
+
startedAt,
|
|
121
|
+
repos: config.repos.length,
|
|
122
|
+
nextCheckAt: engine.nextPollAt,
|
|
123
|
+
problem: engine.error instanceof AuthError ? `authentication error for profile ${engine.error.profile}` : engine.error ? 'GitHub is unavailable: it will be tried again' : null,
|
|
124
|
+
paused: engine.paused,
|
|
125
|
+
slices: engine.slices,
|
|
126
|
+
version,
|
|
127
|
+
pollIntervalMs: config.pollIntervalMinutes * 60_000,
|
|
128
|
+
rateLimit: limit && { ...limit, profile: 'orchestrator' },
|
|
129
|
+
watched: watchedOf(repos, engine.lastCheck),
|
|
130
|
+
lastEvent: (executionId) => lastEventOf(db, executionId),
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const controls = {
|
|
134
|
+
poll: () => engine.pollNow(),
|
|
135
|
+
setPaused: (paused) => engine.setPaused(paused),
|
|
136
|
+
open: deps.openUrl,
|
|
137
|
+
quit: deps.quit,
|
|
138
|
+
logs: (id, count) => tailExecution(places.logs, id, count),
|
|
139
|
+
sessions: (repo, issue) => sessionsOf(db, repo, issue, inheritedConfigDirs()),
|
|
140
|
+
events: (executionId, after, limit) => eventsOf(db, executionId, after, limit),
|
|
141
|
+
epic: (repo, issue) => epicOf(db, repo, issue),
|
|
142
|
+
files: (repo, issue, executionId) => filesOf(db, places.repos, repo, issue, executionId),
|
|
143
|
+
fileDiff: (repo, issue, section, path) => fileDiff(db, places.repos, repo, issue, section, path),
|
|
144
|
+
availableRepos: (page) => deps.listRepos(page, limits.githubCallTimeoutSeconds * 1000),
|
|
145
|
+
resumeEpic: (repo, issue, chosen) => engine.resumeEpic(repo, issue, chosen),
|
|
146
|
+
chooseOptions: (repo, issue, chosen) => engine.chooseOptions(repo, issue, chosen),
|
|
147
|
+
};
|
|
148
|
+
stopControl = (await startControlServer({ stateDir: places.state, snapshot, startedAt, controls, stop: shutdown })).close;
|
|
149
|
+
const detail = (repo, issue) => controls.sessions(repo, issue)?.sessions ?? null;
|
|
150
|
+
// With a terminal on both sides the panel takes keys; otherwise it only draws (or writes plain lines).
|
|
151
|
+
stopPanel =
|
|
152
|
+
deps.panelOut.isTTY && deps.stdin?.isTTY
|
|
153
|
+
? startInteractivePanel({
|
|
154
|
+
snapshot,
|
|
155
|
+
out: deps.panelOut,
|
|
156
|
+
stdin: deps.stdin,
|
|
157
|
+
intervalMs: deps.panelIntervalMs,
|
|
158
|
+
now: () => deps.clock.now(),
|
|
159
|
+
controls: { ...controls, detail },
|
|
160
|
+
})
|
|
161
|
+
: startPanel(snapshot, deps.panelOut, deps.panelIntervalMs, (s, columns) => renderDashboard(s, { columns, detail }));
|
|
162
|
+
return { engine, db, close };
|
|
163
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { defaultConfigPath } from '../../config/default-path.js';
|
|
4
|
+
import { defaults, loadConfig } from '../../config/load.js';
|
|
5
|
+
import { CliError } from './cli-error.js';
|
|
6
|
+
/** The explicit `--config` wins; otherwise the global file `carrier init` writes, if it is there. */
|
|
7
|
+
export const configPathFor = (explicit) => (typeof explicit === 'string' ? explicit : existsSync(defaultConfigPath()) ? defaultConfigPath() : undefined);
|
|
8
|
+
/** When there is no config to use: what to do about it. */
|
|
9
|
+
export const noConfig = (...first) => new CliError(2, ...first, `No config at ${defaultConfigPath()}: run "carrier init" to create one.`);
|
|
10
|
+
/** Reads, merges over the defaults and validates; every problem is reported at once, with its field. */
|
|
11
|
+
export function loadConfigFile(path) {
|
|
12
|
+
const file = resolve(path);
|
|
13
|
+
let user;
|
|
14
|
+
try {
|
|
15
|
+
user = JSON.parse(readFileSync(file, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new CliError(1, `Cannot read config ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
}
|
|
20
|
+
const result = loadConfig(defaults, user);
|
|
21
|
+
if (!result.ok)
|
|
22
|
+
throw new CliError(1, `Invalid config ${file}:`, ...result.errors.map((e) => ` ${e.path}: ${e.message}`));
|
|
23
|
+
return result.config;
|
|
24
|
+
}
|