@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,64 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { eq, isNotNull } from 'drizzle-orm';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { sessions } from './schema.js';
|
|
5
|
+
const log = createScopedLogger('chat');
|
|
6
|
+
export const realProcesses = {
|
|
7
|
+
commandOf(pid) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined; // ps exits 1 when there is no such process
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
groupAlive(pgid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(-pgid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return error.code === 'EPERM'; // alive but not ours
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
signalGroup(pgid, signal) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(-pgid, signal);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'ESRCH')
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
34
|
+
};
|
|
35
|
+
export const SWEEP_GRACE_MS = 5_000;
|
|
36
|
+
/**
|
|
37
|
+
* At host start: ends the agent groups a dead host left running (SIGTERM, then SIGKILL after the grace) and closes their
|
|
38
|
+
* sessions, which stay resumable by their native id. A recorded process whose command is not the recorded one is not
|
|
39
|
+
* ours any more (the pid was reused): it is left alone and only the record is cleared. A group that could not be ended
|
|
40
|
+
* keeps its record so the next start tries again. Returns how many groups it ended.
|
|
41
|
+
*/
|
|
42
|
+
export async function sweepDeadHost(db, processes = realProcesses, graceMs = SWEEP_GRACE_MS) {
|
|
43
|
+
let ended = 0;
|
|
44
|
+
for (const found of db.select().from(sessions).where(isNotNull(sessions.pid)).all()) {
|
|
45
|
+
try {
|
|
46
|
+
const { pid, pgid, command } = found;
|
|
47
|
+
const ours = pid !== null && pgid !== null && command !== null && processes.commandOf(pid)?.startsWith(command) === true;
|
|
48
|
+
if (ours && processes.groupAlive(pgid)) {
|
|
49
|
+
processes.signalGroup(pgid, 'SIGTERM');
|
|
50
|
+
for (let waited = 0; waited < graceMs && processes.groupAlive(pgid); waited += 100)
|
|
51
|
+
await processes.sleep(100);
|
|
52
|
+
if (processes.groupAlive(pgid))
|
|
53
|
+
processes.signalGroup(pgid, 'SIGKILL');
|
|
54
|
+
ended++;
|
|
55
|
+
log.info('ended an agent a dead host left running', { sessionId: found.id, pgid });
|
|
56
|
+
}
|
|
57
|
+
db.update(sessions).set({ state: 'idle', pid: null, pgid: null, command: null }).where(eq(sessions.id, found.id)).run();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
log.error('sweeping a session of a dead host failed', error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ended;
|
|
64
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { boundOutput, clean } from '../execution/events.js';
|
|
2
|
+
// Characters, not bytes. The limits of a conversation, not of an epic's log (spec: The record).
|
|
3
|
+
export const chatCaps = { message: 64_000, arg: 1024, title: 80, error: 4096 };
|
|
4
|
+
/** What is stored of an event: redacted first and then cut, so a secret is never cut in a way that hides its shape. A `delta` is not stored. */
|
|
5
|
+
export function boundEvent(event) {
|
|
6
|
+
switch (event.kind) {
|
|
7
|
+
case 'delta':
|
|
8
|
+
return undefined;
|
|
9
|
+
case 'user':
|
|
10
|
+
return { text: clean(event.text, chatCaps.message), ...(event.command !== undefined && { command: event.command }) };
|
|
11
|
+
case 'assistant':
|
|
12
|
+
return { text: clean(event.text, chatCaps.message) };
|
|
13
|
+
case 'error':
|
|
14
|
+
return { text: clean(event.text, chatCaps.error) };
|
|
15
|
+
case 'tool_start':
|
|
16
|
+
case 'tool_end': {
|
|
17
|
+
const { kind: _kind, arg, output, ...rest } = event;
|
|
18
|
+
return { ...rest, ...(arg !== undefined && { arg: clean(arg, chatCaps.arg) }), ...(output !== undefined && { output: boundOutput(output.join('\n')) }) };
|
|
19
|
+
}
|
|
20
|
+
case 'approval': {
|
|
21
|
+
const { kind: _kind, arg, ...rest } = event;
|
|
22
|
+
return { ...rest, ...(arg !== undefined && { arg: clean(arg, chatCaps.arg) }) };
|
|
23
|
+
}
|
|
24
|
+
default: {
|
|
25
|
+
const { kind: _kind, ...rest } = event;
|
|
26
|
+
return rest;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The title of a session: the first line of its first message, cut. */
|
|
31
|
+
export const titleOf = (text) => clean(text.trim().split('\n')[0], chatCaps.title);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The words a worktree's generated name is made of: `<adjective>-<unit>-<place>`. Lowercase single words from
|
|
2
|
+
// StarCraft's units, structures and tech, never a character's full name (ADR 0014).
|
|
3
|
+
export const adjectives = [
|
|
4
|
+
'psionic', 'cloaked', 'burrowed', 'sieged', 'nuclear', 'plasma', 'ionic', 'adaptive', 'stimmed', 'hallucinated',
|
|
5
|
+
'irradiated', 'parasitic', 'spawning', 'vespene', 'hardened', 'infested', 'mutated', 'consumed', 'ensnared', 'plagued',
|
|
6
|
+
'charged', 'cybernetic', 'khaydarin', 'twilight', 'dark', 'void', 'shielded', 'armored', 'swift', 'tactical',
|
|
7
|
+
'orbital', 'enslaved', 'recalled', 'warped', 'cerebrate',
|
|
8
|
+
];
|
|
9
|
+
export const units = [
|
|
10
|
+
'marine', 'firebat', 'medic', 'ghost', 'vulture', 'goliath', 'wraith', 'valkyrie', 'battlecruiser', 'dropship',
|
|
11
|
+
'zealot', 'dragoon', 'archon', 'reaver', 'corsair', 'scout', 'carrier', 'arbiter', 'observer', 'shuttle',
|
|
12
|
+
'probe', 'scv', 'zergling', 'hydralisk', 'mutalisk', 'ultralisk', 'lurker', 'defiler', 'guardian', 'devourer',
|
|
13
|
+
'overlord', 'queen', 'drone', 'scourge', 'templar', 'interceptor', 'tank', 'broodling',
|
|
14
|
+
];
|
|
15
|
+
export const places = [
|
|
16
|
+
'bunker', 'barracks', 'factory', 'starport', 'nexus', 'pylon', 'gateway', 'forge', 'cannon', 'assimilator',
|
|
17
|
+
'hatchery', 'lair', 'hive', 'spire', 'extractor', 'refinery', 'academy', 'armory', 'depot', 'turret',
|
|
18
|
+
'comsat', 'nydus', 'citadel', 'stargate', 'beacon', 'observatory', 'tribunal', 'aiur', 'char', 'korhal',
|
|
19
|
+
'tarsonis', 'shakuras', 'zerus', 'braxis', 'bhekar',
|
|
20
|
+
];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { ChatRefused } from './runner.js';
|
|
5
|
+
const log = createScopedLogger('chat');
|
|
6
|
+
const lines = (out) => out.split('\n').filter((line) => line !== '');
|
|
7
|
+
/** `owner/name` of a GitHub remote in its https or ssh form (with or without `.git`, a trailing slash or credentials); null for any other remote. */
|
|
8
|
+
export function parseOrigin(url) {
|
|
9
|
+
const found = /^(?:https:\/\/(?:[^@/\s]+@)?github\.com\/|(?:ssh:\/\/)?git@github\.com[:/])([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
10
|
+
if (found === null || [found[1], found[2]].some((part) => part === '.' || part === '..'))
|
|
11
|
+
return null;
|
|
12
|
+
return { owner: found[1], name: found[2] };
|
|
13
|
+
}
|
|
14
|
+
/** The number of lines `git status --porcelain` printed. */
|
|
15
|
+
export const countPorcelain = (out) => lines(out).length;
|
|
16
|
+
/** From `for-each-ref --format=%(refname) refs/heads refs/remotes/origin`: local branches, then `origin/x` ones, `origin/HEAD` left out. */
|
|
17
|
+
export function parseBranches(out) {
|
|
18
|
+
const found = [];
|
|
19
|
+
for (const ref of lines(out)) {
|
|
20
|
+
if (ref.startsWith('refs/heads/'))
|
|
21
|
+
found.push({ name: ref.slice('refs/heads/'.length), remote: false });
|
|
22
|
+
else if (ref.startsWith('refs/remotes/origin/') && ref !== 'refs/remotes/origin/HEAD')
|
|
23
|
+
found.push({ name: ref.slice('refs/remotes/'.length), remote: true });
|
|
24
|
+
}
|
|
25
|
+
return found.sort((a, b) => Number(a.remote) - Number(b.remote)); // stable: each group stays in git's order
|
|
26
|
+
}
|
|
27
|
+
/** `origin/HEAD` (`symbolic-ref` printed `refs/remotes/origin/main`) when set, else `main`, else `master` when listed, else null. */
|
|
28
|
+
export function parseDefaultBranch(originHead, branches) {
|
|
29
|
+
const prefix = 'refs/remotes/origin/';
|
|
30
|
+
const head = originHead?.trim() ?? '';
|
|
31
|
+
if (head.startsWith(prefix) && head.length > prefix.length)
|
|
32
|
+
return head.slice(prefix.length);
|
|
33
|
+
return ['main', 'master'].find((name) => branches.some((branch) => branch.name === name || branch.name === `origin/${name}`)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
const refused = () => new ChatRefused('Git could not read that folder');
|
|
36
|
+
/** A command whose failure with exit status 1 is an answer ("not set", "detached"): the output, or null for that answer; any other failure refuses. */
|
|
37
|
+
function answer(result, what) {
|
|
38
|
+
if (result.ok)
|
|
39
|
+
return result.out.trim();
|
|
40
|
+
if (result.code === 1)
|
|
41
|
+
return null;
|
|
42
|
+
log.error(`git ${what} failed`, { code: result.code });
|
|
43
|
+
throw refused();
|
|
44
|
+
}
|
|
45
|
+
function mustRun(result, what) {
|
|
46
|
+
if (result.ok)
|
|
47
|
+
return result.out;
|
|
48
|
+
log.error(`git ${what} failed`, { code: result.code });
|
|
49
|
+
throw refused();
|
|
50
|
+
}
|
|
51
|
+
const mtimeOf = (path) => {
|
|
52
|
+
try {
|
|
53
|
+
return Math.floor(statSync(path).mtimeMs);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/** Asks git what `folder` (already checked, a real path) is. A folder git does not know as a repository answers `git: false`. */
|
|
60
|
+
export async function inspectFolder(git, folder) {
|
|
61
|
+
const plain = { folder, git: false, bare: false, linkedWorktree: false, repo: null, branch: null, defaultBranch: null, dirty: 0, branches: [], fetchedAt: null };
|
|
62
|
+
const probe = await git.run(folder, ['rev-parse', '--is-bare-repository', '--git-dir', '--git-common-dir', '--git-path', 'FETCH_HEAD']);
|
|
63
|
+
if (probe.ok === false && probe.code === 128)
|
|
64
|
+
return plain; // "not a git repository"
|
|
65
|
+
const printed = lines(mustRun(probe, 'rev-parse'));
|
|
66
|
+
const [bare, gitDir, commonDir, fetchHead] = printed;
|
|
67
|
+
if (fetchHead === undefined) {
|
|
68
|
+
log.error('git rev-parse printed no FETCH_HEAD path', { printed: printed.length });
|
|
69
|
+
throw refused();
|
|
70
|
+
}
|
|
71
|
+
const isBare = bare === 'true';
|
|
72
|
+
const [url, branch, originHead, refs, status] = await Promise.all([
|
|
73
|
+
git.run(folder, ['config', '--get', 'remote.origin.url']),
|
|
74
|
+
git.run(folder, ['symbolic-ref', '-q', '--short', 'HEAD']),
|
|
75
|
+
git.run(folder, ['symbolic-ref', '-q', 'refs/remotes/origin/HEAD']),
|
|
76
|
+
git.run(folder, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin']),
|
|
77
|
+
isBare ? Promise.resolve({ ok: true, out: '' }) : git.run(folder, ['--no-optional-locks', 'status', '--porcelain']),
|
|
78
|
+
]);
|
|
79
|
+
const branches = parseBranches(mustRun(refs, 'for-each-ref'));
|
|
80
|
+
const origin = answer(url, 'config');
|
|
81
|
+
return {
|
|
82
|
+
folder,
|
|
83
|
+
git: true,
|
|
84
|
+
bare: isBare,
|
|
85
|
+
linkedWorktree: resolve(folder, gitDir) !== resolve(folder, commonDir),
|
|
86
|
+
repo: origin === null ? null : parseOrigin(origin),
|
|
87
|
+
branch: answer(branch, 'symbolic-ref') || null,
|
|
88
|
+
defaultBranch: parseDefaultBranch(answer(originHead, 'symbolic-ref'), branches),
|
|
89
|
+
dirty: countPorcelain(mustRun(status, 'status')),
|
|
90
|
+
branches,
|
|
91
|
+
fetchedAt: mtimeOf(resolve(folder, fetchHead)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** What a fetch may take before the host gives up on it. */
|
|
95
|
+
export const FETCH_TIMEOUT_MS = 60_000;
|
|
96
|
+
/** Runs `git fetch origin --prune` in `folder` (already checked, a real path) and answers what the folder is after it. A refusal never carries git's own words: they can hold a URL or a token. */
|
|
97
|
+
export async function fetchFolder(git, folder) {
|
|
98
|
+
const probe = await git.run(folder, ['rev-parse', '--git-dir']);
|
|
99
|
+
if (probe.ok === false && probe.code === 128)
|
|
100
|
+
throw new ChatRefused('That folder is not a git repository');
|
|
101
|
+
mustRun(probe, 'rev-parse');
|
|
102
|
+
if (answer(await git.run(folder, ['config', '--get', 'remote.origin.url']), 'config') === null)
|
|
103
|
+
throw new ChatRefused('That repository has no origin remote to fetch');
|
|
104
|
+
const fetched = await git.run(folder, ['fetch', 'origin', '--prune'], { timeoutMs: FETCH_TIMEOUT_MS });
|
|
105
|
+
if (fetched.ok === false) {
|
|
106
|
+
log.error('git fetch failed', { code: fetched.code, timedOut: fetched.timedOut === true });
|
|
107
|
+
throw new ChatRefused(fetched.timedOut === true ? 'The fetch took too long and was stopped' : 'The fetch of origin failed');
|
|
108
|
+
}
|
|
109
|
+
return inspectFolder(git, folder);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Checks out `branch` (a name `inspectFolder` lists) in `folder` (already checked, a real path), so the folder follows what the person picked. A folder
|
|
113
|
+
* with uncommitted changes is never switched: it refuses with the reason in words and the branch is left as it was. A remote branch is checked out as
|
|
114
|
+
* a local one that tracks it, or as the local one of the same name when there is one.
|
|
115
|
+
*/
|
|
116
|
+
export async function checkout(git, folder, branch) {
|
|
117
|
+
const info = await inspectFolder(git, folder);
|
|
118
|
+
if (info.git === false || info.bare)
|
|
119
|
+
throw new ChatRefused('That folder is not a git repository with a working folder, so it has no branch to check out');
|
|
120
|
+
const listed = info.branches.find((candidate) => candidate.name === branch);
|
|
121
|
+
if (listed === undefined)
|
|
122
|
+
throw new ChatRefused('That branch is not one this repository lists');
|
|
123
|
+
if (info.branch === branch)
|
|
124
|
+
return;
|
|
125
|
+
if (info.dirty > 0)
|
|
126
|
+
throw new ChatRefused(`The folder has ${info.dirty} uncommitted ${info.dirty === 1 ? 'file' : 'files'}, so its branch was not changed to ${branch}. Save the work and try again.`);
|
|
127
|
+
const local = listed.remote ? branch.slice('origin/'.length) : branch;
|
|
128
|
+
const twin = listed.remote && info.branches.some((candidate) => candidate.remote === false && candidate.name === local);
|
|
129
|
+
const switched = await git.run(folder, listed.remote && twin === false ? ['switch', '--track', branch] : ['switch', local]);
|
|
130
|
+
if (switched.ok === false) {
|
|
131
|
+
log.error('git switch failed', { code: switched.code, timedOut: switched.timedOut === true });
|
|
132
|
+
throw new ChatRefused(`Git could not check out ${branch}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { BRANCH_PREFIX, invalidReason, repository, worktreePath, worktreesDir } from './names.js';
|
|
5
|
+
import { ChatRefused } from './runner.js';
|
|
6
|
+
import { countPorcelain } from './workspace.js';
|
|
7
|
+
const log = createScopedLogger('chat');
|
|
8
|
+
/** What a checkout of a big repository may take before the host gives up on it. */
|
|
9
|
+
const ADD_TIMEOUT_MS = 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* Creates `<CARRIER_HOME>/chat/worktrees/<owner>/<repo>/<name>` on a new branch `carrier/<name>` cut from `from`, in the
|
|
12
|
+
* repository of `folder` (already checked, a real path). The base folder is only read: git adds the worktree without a
|
|
13
|
+
* checkout, stash or commit in it. Refuses, with a reason in words, a folder that is not a repository or is bare, a `from`
|
|
14
|
+
* that `workspace` does not list and a name that is invalid or taken. When git fails nothing is left behind.
|
|
15
|
+
* Callers run it one at a time, so what it checked is still true when git adds. `signal` stops the add, and what it made is taken away.
|
|
16
|
+
*/
|
|
17
|
+
export async function createWorktree(git, folder, request, signal) {
|
|
18
|
+
const { info, takenBecause, freeName } = await repository(git, folder);
|
|
19
|
+
const start = info.branches.find((branch) => branch.name === request.from);
|
|
20
|
+
if (start === undefined)
|
|
21
|
+
throw new ChatRefused('The branch to start from is not one this repository lists');
|
|
22
|
+
if (request.name !== undefined) {
|
|
23
|
+
const refusal = invalidReason(request.name) ?? takenBecause(request.name);
|
|
24
|
+
if (refusal !== null)
|
|
25
|
+
throw new ChatRefused(refusal);
|
|
26
|
+
}
|
|
27
|
+
const name = request.name ?? freeName();
|
|
28
|
+
const made = { path: worktreePath(info, name), branch: `${BRANCH_PREFIX}${name}`, from: start.name, base: folder };
|
|
29
|
+
// What is absent right before the add is what this call may take away if the add fails: a branch or folder that appears in between is someone else's.
|
|
30
|
+
const mine = {
|
|
31
|
+
branch: (await branchExists(git, folder, made.branch)) === false,
|
|
32
|
+
folder: existsSync(made.path) === false,
|
|
33
|
+
};
|
|
34
|
+
// The start point is spelled as a full ref, so it cannot read as an option, and `--no-track` leaves the new branch without an upstream.
|
|
35
|
+
const added = await git.run(folder, ['worktree', 'add', '--no-track', '-b', made.branch, made.path, `${start.remote ? 'refs/remotes/' : 'refs/heads/'}${start.name}`], { timeoutMs: ADD_TIMEOUT_MS, signal });
|
|
36
|
+
if (added.ok === false) {
|
|
37
|
+
log.error('git worktree add failed', { code: added.code, timedOut: added.timedOut === true });
|
|
38
|
+
await removeWorktree(git, made, mine);
|
|
39
|
+
throw new ChatRefused(added.timedOut === true ? 'Creating the worktree took too long and was stopped' : 'Git could not create the worktree');
|
|
40
|
+
}
|
|
41
|
+
return made;
|
|
42
|
+
}
|
|
43
|
+
/** Whether `refs/heads/<branch>` exists in `base`. A missing branch is an answer; any other git failure refuses, since guessing would decide what gets deleted. */
|
|
44
|
+
async function branchExists(git, base, branch) {
|
|
45
|
+
const found = await git.run(base, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
46
|
+
if (found.ok)
|
|
47
|
+
return true;
|
|
48
|
+
if (found.code === 1)
|
|
49
|
+
return false;
|
|
50
|
+
log.error('git could not tell whether a branch exists', { code: found.code, timedOut: found.timedOut === true });
|
|
51
|
+
throw new ChatRefused('Git could not read the branches of the repository');
|
|
52
|
+
}
|
|
53
|
+
/** Removes a folder Carrier made. One that cannot be removed is logged and left, and `false` says so. */
|
|
54
|
+
function removeFolder(path) {
|
|
55
|
+
try {
|
|
56
|
+
rmSync(path, { recursive: true, force: true });
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
log.error('a worktree folder could not be removed', error);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Takes away what `createWorktree` made, whatever state it was left in: the worktree, its folder and its branch. `mine` leaves alone the branch or the folder this call did not make. Never throws; what cannot be removed is logged. */
|
|
65
|
+
export async function removeWorktree(git, { path, branch, base }, mine = { branch: true, folder: true }) {
|
|
66
|
+
if (mine.folder) {
|
|
67
|
+
await git.run(base, ['worktree', 'remove', '--force', path]);
|
|
68
|
+
if (existsSync(path))
|
|
69
|
+
removeFolder(path);
|
|
70
|
+
await git.run(base, ['worktree', 'prune']);
|
|
71
|
+
}
|
|
72
|
+
if (mine.branch === false)
|
|
73
|
+
return;
|
|
74
|
+
const exists = await git.run(base, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
75
|
+
if (exists.ok === false) {
|
|
76
|
+
if (exists.code !== 1)
|
|
77
|
+
log.error('git could not tell whether the branch of a worktree that was not kept exists', { code: exists.code });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const deleted = await git.run(base, ['branch', '-D', branch]);
|
|
81
|
+
if (deleted.ok === false)
|
|
82
|
+
log.error('the branch of a worktree that was not kept could not be removed', { code: deleted.code });
|
|
83
|
+
}
|
|
84
|
+
/** The number `git rev-list --count` printed. */
|
|
85
|
+
export const parseCount = (out) => Number.parseInt(out.trim(), 10) || 0;
|
|
86
|
+
/** The entries a worktree folder holds besides its `.git` link: what is left to lose when git cannot read the worktree. */
|
|
87
|
+
const filesLeft = (path) => (existsSync(path) ? readdirSync(path).filter((entry) => entry !== '.git').length : 0);
|
|
88
|
+
/** The path of a recorded worktree, which must be `<worktrees>/<owner>/<repo>/<name>` with the branch `carrier/<name>`; refuses anything else, so a tampered record removes nothing. */
|
|
89
|
+
function recorded({ path, branch }) {
|
|
90
|
+
const target = resolve(path);
|
|
91
|
+
const root = resolve(worktreesDir());
|
|
92
|
+
const [owner, repo, name, ...deeper] = target.startsWith(root + sep) ? target.slice(root.length + 1).split(sep) : [];
|
|
93
|
+
if (owner === undefined || repo === undefined || name === undefined || deeper.length > 0 || invalidReason(name) !== null || branch !== `${BRANCH_PREFIX}${name}`)
|
|
94
|
+
throw new ChatRefused('This session\'s worktree is not one Carrier made, so nothing was removed');
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
97
|
+
/** What deleting a worktree would discard: the lines `git status --porcelain` prints in it and the commits of its branch that no other branch of `batch` (the branches of the sessions deleted together, this one included) or remote branch contains. With the base folder gone git reads nothing and the commits went with it, so only the files left in the worktree folder count. A branch that is already gone has no commits to lose. */
|
|
98
|
+
export async function previewWorktree(git, made, batch) {
|
|
99
|
+
if (existsSync(made.base) === false)
|
|
100
|
+
return { dirty: filesLeft(made.path), unpushed: 0 };
|
|
101
|
+
const where = existsSync(made.path) ? made.path : made.base;
|
|
102
|
+
const hasBranch = await branchExists(git, made.base, made.branch);
|
|
103
|
+
const [status, ahead] = await Promise.all([
|
|
104
|
+
where === made.path ? git.run(where, ['--no-optional-locks', 'status', '--porcelain']) : Promise.resolve({ ok: true, out: '' }),
|
|
105
|
+
// the branch is spelled as a full ref and each exclusion is a value of its own option, so neither can read as an option
|
|
106
|
+
hasBranch ? git.run(where, ['rev-list', '--count', `refs/heads/${made.branch}`, '--not', ...[...new Set([made.branch, ...batch])].map((branch) => `--exclude=${branch}`), '--branches', '--remotes']) : Promise.resolve({ ok: true, out: '0' }),
|
|
107
|
+
]);
|
|
108
|
+
if (status.ok === false || ahead.ok === false) {
|
|
109
|
+
log.error('git could not read a worktree to preview its deletion', { status: status.ok ? 0 : status.code, ahead: ahead.ok ? 0 : ahead.code });
|
|
110
|
+
throw new ChatRefused('Git could not read the worktree of a session');
|
|
111
|
+
}
|
|
112
|
+
return { dirty: countPorcelain(status.out), unpushed: parseCount(ahead.out) };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Removes the worktree a session recorded, then its recorded branch, and refuses with a reason in words when git cannot, so the session stays.
|
|
116
|
+
* Only a path under the worktrees folder is touched. When the base folder is gone git cannot do it: the worktree folder is removed directly and the branch is left.
|
|
117
|
+
*/
|
|
118
|
+
export async function deleteWorktree(git, made) {
|
|
119
|
+
const path = recorded(made);
|
|
120
|
+
if (existsSync(made.base) === false) {
|
|
121
|
+
if (removeFolder(path) === false)
|
|
122
|
+
throw new ChatRefused('Its folder could not be removed, so the session was kept');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const cannot = (what, result) => {
|
|
126
|
+
log.error(`git ${what} failed while deleting a worktree`, { code: result.code, timedOut: result.timedOut === true });
|
|
127
|
+
return new ChatRefused(`Git could not ${what === 'branch' ? 'delete the branch of' : 'remove'} the worktree, so the session was kept`);
|
|
128
|
+
};
|
|
129
|
+
// a folder that is already gone only leaves its entry for git to forget
|
|
130
|
+
const removed = await git.run(made.base, existsSync(path) ? ['worktree', 'remove', '--force', path] : ['worktree', 'prune']);
|
|
131
|
+
if (removed.ok === false)
|
|
132
|
+
throw cannot('worktree', removed);
|
|
133
|
+
const found = await git.run(made.base, ['rev-parse', '--verify', '--quiet', `refs/heads/${made.branch}`]);
|
|
134
|
+
if (found.ok === false) {
|
|
135
|
+
if (found.code === 1)
|
|
136
|
+
return; // the branch is already gone
|
|
137
|
+
throw cannot('branch', found);
|
|
138
|
+
}
|
|
139
|
+
const deleted = await git.run(made.base, ['branch', '-D', made.branch]);
|
|
140
|
+
if (deleted.ok === false)
|
|
141
|
+
throw cannot('branch', deleted);
|
|
142
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const expandHome = (path) => (path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path);
|
|
4
|
+
/** Carrier's home: `CARRIER_HOME`, else `~/.carrier`. Holds the engine's config and one subfolder per process (`engine/`, `chat/`). */
|
|
5
|
+
export const carrierHome = () => process.env.CARRIER_HOME || join(homedir(), '.carrier');
|
|
6
|
+
/** Where `carrier init` writes the config and where `carrier` looks when there is no `--config`: global, one per user. */
|
|
7
|
+
export const defaultConfigPath = () => join(carrierHome(), 'config.json');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
/** The control socket in an engine state folder; the server opens it from the folder it is given. */
|
|
3
|
+
export const controlSocketIn = (state) => join(state, 'control.sock');
|
|
4
|
+
/** The engine's places, all computed from the Carrier folder here and nowhere else. */
|
|
5
|
+
export function enginePlacesOf(folder) {
|
|
6
|
+
const dir = join(folder, 'engine');
|
|
7
|
+
const state = join(dir, 'state');
|
|
8
|
+
return { dir, state, socket: controlSocketIn(state), db: join(state, 'carrier.db'), logs: join(dir, 'logs'), repos: join(dir, 'repos'), reviews: join(dir, 'reviews'), worktrees: join(dir, 'worktrees') };
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defaults } from './load.js';
|
|
3
|
+
import { configSchema } from './schema.js';
|
|
4
|
+
// The file is merged over `defaults` before the schema sees it, so what has a default is not required of the user.
|
|
5
|
+
function optionalWhereDefaulted(schema, base) {
|
|
6
|
+
schema.required = schema.required?.filter((key) => !(key in base));
|
|
7
|
+
for (const [key, value] of Object.entries(base)) {
|
|
8
|
+
const child = schema.properties?.[key];
|
|
9
|
+
if (child && typeof value === 'object' && value !== null)
|
|
10
|
+
optionalWhereDefaulted(child, value);
|
|
11
|
+
}
|
|
12
|
+
return schema;
|
|
13
|
+
}
|
|
14
|
+
/** JSON Schema of the config file as the user writes it, for editors and for agents that write it. */
|
|
15
|
+
export const userConfigJsonSchema = () => optionalWhereDefaulted(z.toJSONSchema(configSchema, { io: 'input' }), defaults);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { configSchema } from './schema.js';
|
|
2
|
+
export const defaults = {
|
|
3
|
+
pollIntervalMinutes: 5,
|
|
4
|
+
finishedWindowDays: 15,
|
|
5
|
+
logLevel: 'info',
|
|
6
|
+
identities: 'separate',
|
|
7
|
+
limits: {
|
|
8
|
+
maxConcurrentEpics: 3,
|
|
9
|
+
maxReviews: 5,
|
|
10
|
+
maxExecutionRetries: 3,
|
|
11
|
+
maxExecutionHours: 4,
|
|
12
|
+
githubCallTimeoutSeconds: 30,
|
|
13
|
+
optionChoiceSeconds: 60,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
// Objects merge per field; anything else (scalars, arrays, mismatched types) is replaced as-is
|
|
18
|
+
// and left for the schema to reject.
|
|
19
|
+
function merge(base, override) {
|
|
20
|
+
if (!isObject(base) || !isObject(override))
|
|
21
|
+
return override;
|
|
22
|
+
const result = { ...base };
|
|
23
|
+
for (const [key, value] of Object.entries(override))
|
|
24
|
+
result[key] = merge(base[key], value);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
const movedKeys = ['option'];
|
|
28
|
+
export function loadConfig(base, user) {
|
|
29
|
+
const parsed = configSchema.safeParse(merge(base, user));
|
|
30
|
+
if (parsed.success)
|
|
31
|
+
return { ok: true, config: parsed.data };
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
errors: parsed.error.issues.flatMap((issue) => {
|
|
35
|
+
const path = issue.path.join('.') || '(root)';
|
|
36
|
+
if (issue.code === 'unrecognized_keys' && issue.path.includes('agents')) {
|
|
37
|
+
const moved = issue.keys.filter((key) => movedKeys.includes(key));
|
|
38
|
+
if (moved.length > 0) {
|
|
39
|
+
return moved.map((key) => ({
|
|
40
|
+
path: `${path}.${key}`,
|
|
41
|
+
message: `"${key}" was replaced by "provider" and "model" directly on the agent (see docs/configuration.md)`,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [{ path, message: issue.message }];
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Two names can point at the same bot or at the same `gh` login: what matters is the account that posts.
|
|
2
|
+
export const sameAccount = (a, b) => a.kind === b.kind && (a.kind === 'gh' || a.agent === b.agent);
|
|
3
|
+
/** Returns the failover order for a specific role: role-specific chain if configured, else default chain. */
|
|
4
|
+
export function failoverOrderFor(failover, role) {
|
|
5
|
+
if (!failover?.enabled)
|
|
6
|
+
return undefined;
|
|
7
|
+
return failover.roles?.[role] ?? failover.default ?? failover.order;
|
|
8
|
+
}
|
|
9
|
+
// Each repo inherits the global value of every field it does not override.
|
|
10
|
+
export function resolveRepoConfig(config, repo) {
|
|
11
|
+
const roleOf = (role) => {
|
|
12
|
+
const base = config.agents[role];
|
|
13
|
+
const repoAgent = repo.agents?.[role];
|
|
14
|
+
if (!base && !repoAgent)
|
|
15
|
+
return undefined;
|
|
16
|
+
const merged = { ...(base ?? {}), ...(repoAgent ?? {}) };
|
|
17
|
+
if (!merged.profile)
|
|
18
|
+
return undefined;
|
|
19
|
+
const providerConfig = merged.provider ? config.providers[merged.provider] : undefined;
|
|
20
|
+
return {
|
|
21
|
+
profile: merged.profile,
|
|
22
|
+
harness: providerConfig?.harness ?? 'claude',
|
|
23
|
+
model: merged.model ?? providerConfig?.model ?? '',
|
|
24
|
+
...(providerConfig?.provider && { provider: providerConfig.provider }),
|
|
25
|
+
...(providerConfig?.configDir && { configDir: providerConfig.configDir }),
|
|
26
|
+
...(merged.persona && { persona: merged.persona }),
|
|
27
|
+
option: merged.provider,
|
|
28
|
+
providerName: merged.provider,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const leadReviewer = roleOf('leadReviewer');
|
|
32
|
+
const agents = { author: roleOf('author'), reviewer: roleOf('reviewer'), ...(leadReviewer ? { leadReviewer } : {}) };
|
|
33
|
+
const [author, reviewer] = [config.profiles[agents.author?.profile], config.profiles[agents.reviewer?.profile]];
|
|
34
|
+
return {
|
|
35
|
+
name: repo.name,
|
|
36
|
+
sharedIdentity: author !== undefined && reviewer !== undefined && sameAccount(author, reviewer),
|
|
37
|
+
providers: config.providers,
|
|
38
|
+
...(config.failover && { failover: config.failover }),
|
|
39
|
+
agents,
|
|
40
|
+
};
|
|
41
|
+
}
|