@zgeoff/atc 2.16.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1683 -84
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +120 -0
- package/src/shared/config.ts +30 -1
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -161,6 +161,10 @@ const main = defineCommand({
|
|
|
161
161
|
const gateway = await import('./agents/gateway-adapter');
|
|
162
162
|
const headless = await import('./agents/start-claude-headless-run');
|
|
163
163
|
const targets = await import('./daemon/build-execution-targets');
|
|
164
|
+
const sourceOrder = await import('./sources/build-sources');
|
|
165
|
+
const builtinSources = await import('./sources/collect-builtin-sources');
|
|
166
|
+
const zoxide = await import('./shared/collect-zoxide-dirs');
|
|
167
|
+
const home = await import('./shared/resolve-home-dir');
|
|
164
168
|
|
|
165
169
|
// Test harnesses shrink the outbound queue to force overflow
|
|
166
170
|
// deterministically; unset means the production default.
|
|
@@ -178,10 +182,27 @@ const main = defineCommand({
|
|
|
178
182
|
console.error(`atc daemon: config: ${line}`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
for (const problem of cfg.principalErrors) {
|
|
185
|
+
for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
|
|
182
186
|
console.error(`atc daemon: config: ${problem}`);
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
const sources = sourceOrder.buildSources(
|
|
190
|
+
builtinSources.collectBuiltinSources({
|
|
191
|
+
roots: cfg.dirs.roots,
|
|
192
|
+
githubOwner: cfg.workspaces.githubOwner,
|
|
193
|
+
ghBin: 'gh',
|
|
194
|
+
homeDir: home.resolveHomeDir(),
|
|
195
|
+
collectZoxideDirs: zoxide.collectZoxideDirs,
|
|
196
|
+
}),
|
|
197
|
+
cfg.workspaces.sources,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
for (const id of sources.missing) {
|
|
201
|
+
console.error(
|
|
202
|
+
`atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
// Cap on how long a fleet restore waits for one revived session to
|
|
186
207
|
// report it has booted before moving to the next. Tests pin it to
|
|
187
208
|
// keep timing deterministic; unset means the production default.
|
|
@@ -220,6 +241,8 @@ const main = defineCommand({
|
|
|
220
241
|
defaultTarget: cfg.defaultTarget,
|
|
221
242
|
targetErrors,
|
|
222
243
|
principals: cfg.principals,
|
|
244
|
+
sources: sources.sources,
|
|
245
|
+
gitTransports: cfg.workspaces.gitTransports,
|
|
223
246
|
restoreBootTimeoutMs,
|
|
224
247
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
225
248
|
? { tapGraceMs: graceOverride }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
interface DestinationParts {
|
|
2
|
+
// The root the checkout lands under, absolute on its target.
|
|
3
|
+
readonly root: string;
|
|
4
|
+
|
|
5
|
+
// The repository URL the checkout comes from, as the daemon resolved it.
|
|
6
|
+
readonly url: string;
|
|
7
|
+
|
|
8
|
+
// The branch or tag the commit was chosen by, or null for a bare commit.
|
|
9
|
+
readonly ref: string | null;
|
|
10
|
+
readonly sha: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The default directory a git workspace lands in: `<repo>-<ref>-<short
|
|
15
|
+
* sha>` under the root, with the ref left out for a bare commit. The repo
|
|
16
|
+
* is the URL's last path segment without `.git`, and each part keeps only
|
|
17
|
+
* `[A-Za-z0-9._-]`, with `/` turned into `-`, so the name is one path
|
|
18
|
+
* segment on any target.
|
|
19
|
+
*/
|
|
20
|
+
export function buildWorkspaceDestination(parts: DestinationParts): string {
|
|
21
|
+
const repo =
|
|
22
|
+
parts.url
|
|
23
|
+
.replace(/\/+$/u, '')
|
|
24
|
+
.split(/[/:]/u)
|
|
25
|
+
.at(-1)
|
|
26
|
+
?.replace(/\.git$/u, '') ?? '';
|
|
27
|
+
|
|
28
|
+
const name = [repo, ...(parts.ref === null ? [] : [parts.ref]), parts.sha.slice(0, 7)]
|
|
29
|
+
.map((part) => toSafeSegment(part))
|
|
30
|
+
.filter((part) => part !== '')
|
|
31
|
+
.join('-');
|
|
32
|
+
|
|
33
|
+
return `${parts.root.replace(/\/+$/u, '')}/${name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toSafeSegment(part: string): string {
|
|
37
|
+
return part.replaceAll('/', '-').replaceAll(/[^\w.-]/gu, '');
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
export interface TargetPick {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
readonly available: boolean;
|
|
7
|
+
readonly isDefault: boolean;
|
|
8
|
+
|
|
9
|
+
// Whether a workspace can be materialized there: the daemon has the
|
|
10
|
+
// provider, and it can both transfer an archive and run a command.
|
|
11
|
+
readonly takesWorkspace: boolean;
|
|
12
|
+
|
|
13
|
+
// Whether a session there runs on the daemon's own machine, where a
|
|
14
|
+
// local directory runs in place.
|
|
15
|
+
readonly inPlace: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The execution targets an `agents.list` answer holds, in config order. An
|
|
20
|
+
* answer from a daemon without targets, or a malformed entry, yields none.
|
|
21
|
+
*/
|
|
22
|
+
export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
|
|
23
|
+
const targets = answer['targets'];
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(targets)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return targets.flatMap((entry: unknown): TargetPick[] => {
|
|
30
|
+
if (
|
|
31
|
+
!isRecord(entry) ||
|
|
32
|
+
typeof entry['id'] !== 'string' ||
|
|
33
|
+
typeof entry['provider'] !== 'string'
|
|
34
|
+
) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
|
|
39
|
+
const available = entry['available'] === true;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
id: entry['id'],
|
|
44
|
+
provider: entry['provider'],
|
|
45
|
+
available,
|
|
46
|
+
isDefault: entry['default'] === true,
|
|
47
|
+
takesWorkspace:
|
|
48
|
+
available && capabilities['transfer'] === true && capabilities['run'] === true,
|
|
49
|
+
inPlace: entry['provider'] === 'local-pty',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/client/dirs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
|
-
import { collectRootDirs } from '
|
|
4
|
+
import { collectRootDirs } from '../shared/collect-root-dirs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Every source the picker lists, merged in the order the reader expects
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The steps of the git spawn flow a refused spawn can return to.
|
|
2
|
+
type RefusalStep = 'confirm' | 'destination' | 'ref' | 'source';
|
|
3
|
+
|
|
4
|
+
const REFUSAL_STEPS: ReadonlyMap<string, RefusalStep> = new Map<string, RefusalStep>([
|
|
5
|
+
['workspace_exists', 'destination'],
|
|
6
|
+
['ref_not_found', 'ref'],
|
|
7
|
+
['invalid_git_url', 'source'],
|
|
8
|
+
['credential_in_url', 'source'],
|
|
9
|
+
['credential_missing', 'source'],
|
|
10
|
+
['clone_failed', 'source'],
|
|
11
|
+
['has_submodules', 'confirm'],
|
|
12
|
+
['lfs_unsupported', 'confirm'],
|
|
13
|
+
['unreadable_tree', 'confirm'],
|
|
14
|
+
['sanitize_failed', 'confirm'],
|
|
15
|
+
['tar_failed', 'confirm'],
|
|
16
|
+
['transfer_failed', 'confirm'],
|
|
17
|
+
['workspace_mismatch', 'confirm'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The step of the git spawn flow that can fix a workspace spawn's
|
|
22
|
+
* refusal: the destination for one that already exists, the ref for one
|
|
23
|
+
* the upstream lacks, the source step for a URL or clone failure, and the
|
|
24
|
+
* confirm screen for a refusal of the commit or the target's copy of it.
|
|
25
|
+
* Null for any other refusal, which stays on the step that sent the spawn.
|
|
26
|
+
*/
|
|
27
|
+
export function pickRefusalStep(code: string): RefusalStep | null {
|
|
28
|
+
return REFUSAL_STEPS.get(code) ?? null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
|
|
2
|
+
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
3
|
+
|
|
4
|
+
type WorkspaceRoot =
|
|
5
|
+
| { readonly ok: true; readonly root: string }
|
|
6
|
+
| { readonly ok: false; readonly message: string };
|
|
7
|
+
|
|
8
|
+
interface RootTarget {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
|
|
11
|
+
// Whether the target is the daemon's own machine, where `~` is this
|
|
12
|
+
// user's home.
|
|
13
|
+
readonly inPlace: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Where checkouts land on the daemon's own machine without a configured root.
|
|
17
|
+
const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The directory git workspaces land under on a target: the target's own
|
|
21
|
+
* root, else the global root, else the default. On the daemon's own
|
|
22
|
+
* machine a leading `~` expands to the home directory. On any other target
|
|
23
|
+
* the root must be absolute, since `~` there is a home this client cannot
|
|
24
|
+
* see, so a root that is not is refused with the config key to set.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarget): WorkspaceRoot {
|
|
27
|
+
const configured = config.targetRoots.get(target.id) ?? config.root;
|
|
28
|
+
|
|
29
|
+
if (target.inPlace) {
|
|
30
|
+
const root = expandHome(configured ?? DEFAULT_ROOT);
|
|
31
|
+
|
|
32
|
+
return root.startsWith('/')
|
|
33
|
+
? { ok: true, root }
|
|
34
|
+
: { ok: false, message: `workspaces root '${root}' must be an absolute path` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (configured === null || !configured.startsWith('/')) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
message: `set workspaces.targets.${target.id} in config.json to an absolute path on that target`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { ok: true, root: configured };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function expandHome(dir: string): string {
|
|
48
|
+
return dir === '~' || dir.startsWith('~/') ? `${resolveHomeDir()}${dir.slice(1)}` : dir;
|
|
49
|
+
}
|