@zgeoff/atc 2.33.0 → 2.34.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 +1 -0
- package/src/client/index.ts +8 -0
- package/src/client/spawn-picker.ts +58 -6
- package/src/daemon/build-default-workspace-dir.ts +70 -0
- package/src/daemon/daemon-connection.ts +25 -5
- package/src/daemon/daemon-context.ts +13 -0
- package/src/daemon/daemon.ts +26 -4
- package/src/daemon/materialize-workspace.ts +83 -11
- package/src/daemon/sessions.ts +58 -11
- package/src/mcp/mcp-tools.ts +4 -2
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +9 -1
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/request-param-schemas.ts +7 -1
- package/src/{client → shared}/build-workspace-destination.ts +19 -8
- package/src/store/state-store.ts +1 -0
- package/src/store/workspace-materialization.ts +4 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -452,6 +452,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
452
452
|
principals: cfg.principals,
|
|
453
453
|
sources: sources.sources,
|
|
454
454
|
gitTransports: cfg.workspaces.gitTransports,
|
|
455
|
+
workspaceRoots: { root: cfg.workspaces.root, targetRoots: cfg.workspaces.targetRoots },
|
|
455
456
|
...(listen === null || tokenFile === null
|
|
456
457
|
? {}
|
|
457
458
|
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
package/src/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
|
|
|
2
2
|
import { createActor } from 'xstate';
|
|
3
3
|
import { countSessionStates } from '../protocol/count-session-states';
|
|
4
4
|
import { DaemonError } from '../protocol/daemon-error';
|
|
5
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
5
6
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
7
|
import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
|
|
7
8
|
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
@@ -339,12 +340,17 @@ const picker = new SpawnPicker<MirrorSession>({
|
|
|
339
340
|
attach,
|
|
340
341
|
toMirrorSession,
|
|
341
342
|
upsertMirror,
|
|
343
|
+
hasDaemonFeature,
|
|
342
344
|
});
|
|
343
345
|
|
|
344
346
|
function openPicker(resume: boolean) {
|
|
345
347
|
picker.open(resume);
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
function hasDaemonFeature(feature: DaemonFeature): boolean {
|
|
351
|
+
return daemonFeatures.has(feature);
|
|
352
|
+
}
|
|
353
|
+
|
|
348
354
|
function sendRequest(m: string, p?: Readonly<Record<string, unknown>>) {
|
|
349
355
|
return client.sendRequest(m, p);
|
|
350
356
|
}
|
|
@@ -832,6 +838,7 @@ const boot = await bootDaemonClient({
|
|
|
832
838
|
|
|
833
839
|
let client = boot.client;
|
|
834
840
|
let daemonStale = boot.stale;
|
|
841
|
+
let daemonFeatures = boot.features;
|
|
835
842
|
let daemonRestarting = false;
|
|
836
843
|
|
|
837
844
|
lastUsedAgent = boot.lastUsedAgent;
|
|
@@ -950,6 +957,7 @@ async function restartDaemon() {
|
|
|
950
957
|
|
|
951
958
|
client = next.client;
|
|
952
959
|
daemonStale = next.stale;
|
|
960
|
+
daemonFeatures = next.features;
|
|
953
961
|
lastUsedAgent = next.lastUsedAgent;
|
|
954
962
|
client.onEvent = applyDaemonEvent;
|
|
955
963
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
4
|
import type { AgentID } from '../shared/agent-id';
|
|
5
|
+
import { buildWorkspaceDestination } from '../shared/build-workspace-destination';
|
|
4
6
|
import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
|
|
5
7
|
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
6
8
|
import { loadConfig } from '../shared/config';
|
|
@@ -8,7 +10,6 @@ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
|
|
|
8
10
|
import { isGitURL } from '../shared/is-git-url';
|
|
9
11
|
import { isRecord } from '../shared/report';
|
|
10
12
|
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
11
|
-
import { buildWorkspaceDestination } from './build-workspace-destination';
|
|
12
13
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
13
14
|
import type { AgentPick } from './collect-agent-picks';
|
|
14
15
|
import { collectPathCompletions } from './collect-path-completions';
|
|
@@ -48,6 +49,7 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
48
49
|
readonly attach: (sessionID: string) => Promise<void>;
|
|
49
50
|
readonly toMirrorSession: (value: unknown) => TMirror | null;
|
|
50
51
|
readonly upsertMirror: (session: Readonly<TMirror>) => void;
|
|
52
|
+
readonly hasDaemonFeature: (feature: DaemonFeature) => boolean;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// One source `agents.list` returned, in the order the daemon offers them.
|
|
@@ -100,6 +102,9 @@ interface ChosenRef {
|
|
|
100
102
|
readonly sha: string;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// The root the daemon picks a destination under on a remote target without
|
|
106
|
+
// a configured one, as the confirm screen shows it.
|
|
107
|
+
const AUTO_ROOT = '~/.local/share/atc/workspaces';
|
|
103
108
|
const FULL_SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
104
109
|
const SHORT_SHA_PATTERN = /^[\da-f]{7,63}$/u;
|
|
105
110
|
|
|
@@ -236,6 +241,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
236
241
|
// The repository the destination was built or typed for.
|
|
237
242
|
private destinationRepoURL: string | null = null;
|
|
238
243
|
|
|
244
|
+
// The directory the daemon picks on the target when the confirm screen
|
|
245
|
+
// has no destination it can build, under the target user's home as the
|
|
246
|
+
// screen shows it, or null when the spawn gives its own.
|
|
247
|
+
private autoDestination: string | null = null;
|
|
248
|
+
|
|
239
249
|
private name = '';
|
|
240
250
|
|
|
241
251
|
private resume = false;
|
|
@@ -291,6 +301,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
291
301
|
this.destination = '';
|
|
292
302
|
this.destinationEdited = false;
|
|
293
303
|
this.destinationRepoURL = null;
|
|
304
|
+
this.autoDestination = null;
|
|
294
305
|
|
|
295
306
|
// A last-used agent that is no longer installed is not in the menu, so
|
|
296
307
|
// the selection falls to the first one that is.
|
|
@@ -462,7 +473,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
462
473
|
items: [],
|
|
463
474
|
selected: -1,
|
|
464
475
|
input: this.input,
|
|
465
|
-
placeholder: formatDirName(this.dir),
|
|
476
|
+
placeholder: formatDirName(this.autoDestination ?? this.dir),
|
|
466
477
|
hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
|
|
467
478
|
});
|
|
468
479
|
} else {
|
|
@@ -607,7 +618,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
607
618
|
],
|
|
608
619
|
selected: -1,
|
|
609
620
|
input: this.input,
|
|
610
|
-
placeholder:
|
|
621
|
+
placeholder:
|
|
622
|
+
this.autoDestination === null
|
|
623
|
+
? 'destination on the target'
|
|
624
|
+
: 'picked on the target · or type an absolute path',
|
|
611
625
|
hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
|
|
612
626
|
});
|
|
613
627
|
}
|
|
@@ -1584,6 +1598,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1584
1598
|
private openConfirmStep() {
|
|
1585
1599
|
this.step = 'confirm';
|
|
1586
1600
|
this.destinationAttempt = 1;
|
|
1601
|
+
this.autoDestination = null;
|
|
1587
1602
|
|
|
1588
1603
|
if (this.destination === '' && this.repo !== null && this.ref !== null) {
|
|
1589
1604
|
const target = this.findEffectiveTarget();
|
|
@@ -1602,6 +1617,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1602
1617
|
ref: this.ref.ref,
|
|
1603
1618
|
sha: this.ref.sha,
|
|
1604
1619
|
});
|
|
1620
|
+
} else if (target !== null && !target.inPlace && this.canPickOnTarget(target.id)) {
|
|
1621
|
+
this.autoDestination = buildWorkspaceDestination({
|
|
1622
|
+
root: this.workspaces.targetRoots.get(target.id) ?? this.workspaces.root ?? AUTO_ROOT,
|
|
1623
|
+
url: this.repo.url,
|
|
1624
|
+
ref: this.ref.ref,
|
|
1625
|
+
sha: this.ref.sha,
|
|
1626
|
+
});
|
|
1605
1627
|
} else {
|
|
1606
1628
|
this.refusal = root.message;
|
|
1607
1629
|
}
|
|
@@ -1610,10 +1632,30 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1610
1632
|
this.input = this.buildDestination();
|
|
1611
1633
|
}
|
|
1612
1634
|
|
|
1635
|
+
// Whether the daemon picks the destination on a remote target: it must
|
|
1636
|
+
// take a git workspace without a directory, and the target's configured
|
|
1637
|
+
// root, if any, must be one it resolves there, under the home.
|
|
1638
|
+
private canPickOnTarget(target: string): boolean {
|
|
1639
|
+
const configured = this.workspaces.targetRoots.get(target) ?? this.workspaces.root;
|
|
1640
|
+
|
|
1641
|
+
return (
|
|
1642
|
+
this.deps.hasDaemonFeature('spawn.workspace.autoDir') &&
|
|
1643
|
+
(configured === null || configured === '~' || configured.startsWith('~/'))
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1613
1647
|
// Takes the confirm screen's destination: absolute on its target, with a
|
|
1614
1648
|
// leading `~` expanded only on the daemon's own machine. False, with the
|
|
1615
1649
|
// reason shown, when the target cannot take it.
|
|
1616
1650
|
private applyDestination(typed: string): boolean {
|
|
1651
|
+
// An empty destination takes the one the daemon picks on the target.
|
|
1652
|
+
if (typed === '' && this.autoDestination !== null) {
|
|
1653
|
+
this.destinationRepoURL = this.repo?.url ?? null;
|
|
1654
|
+
this.dir = '';
|
|
1655
|
+
|
|
1656
|
+
return true;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1617
1659
|
const inPlace = this.findEffectiveTarget()?.inPlace ?? true;
|
|
1618
1660
|
|
|
1619
1661
|
const dir =
|
|
@@ -1635,6 +1677,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1635
1677
|
this.destinationEdited = true;
|
|
1636
1678
|
}
|
|
1637
1679
|
|
|
1680
|
+
this.autoDestination = null;
|
|
1638
1681
|
this.destinationRepoURL = this.repo?.url ?? null;
|
|
1639
1682
|
this.dir = this.buildDestination();
|
|
1640
1683
|
|
|
@@ -1649,7 +1692,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1649
1692
|
}
|
|
1650
1693
|
|
|
1651
1694
|
private formatDestination(): string {
|
|
1652
|
-
|
|
1695
|
+
const target = this.findEffectiveTarget()?.id ?? 'local';
|
|
1696
|
+
|
|
1697
|
+
return this.autoDestination === null
|
|
1698
|
+
? `${target}:${this.buildDestination()}`
|
|
1699
|
+
: `${target}:${this.autoDestination}`;
|
|
1653
1700
|
}
|
|
1654
1701
|
|
|
1655
1702
|
// The target the spawn runs on: the chosen one, else the daemon's
|
|
@@ -1792,7 +1839,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1792
1839
|
|
|
1793
1840
|
private async spawn(prompt: string) {
|
|
1794
1841
|
const params = {
|
|
1795
|
-
cwd: this.dir,
|
|
1842
|
+
...(this.isGitFlow() && this.autoDestination !== null ? {} : { cwd: this.dir }),
|
|
1796
1843
|
name: this.name,
|
|
1797
1844
|
prompt,
|
|
1798
1845
|
cols: cols(),
|
|
@@ -1885,7 +1932,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1885
1932
|
return;
|
|
1886
1933
|
}
|
|
1887
1934
|
|
|
1888
|
-
if (step === 'destination') {
|
|
1935
|
+
if (step === 'destination' && this.autoDestination !== null) {
|
|
1936
|
+
// The daemon already tried the numbered directories beside its pick.
|
|
1937
|
+
this.step = 'confirm';
|
|
1938
|
+
this.input = '';
|
|
1939
|
+
this.refusal = `${reason} · esc back`;
|
|
1940
|
+
} else if (step === 'destination') {
|
|
1889
1941
|
this.destinationAttempt += 1;
|
|
1890
1942
|
this.step = 'confirm';
|
|
1891
1943
|
this.input = this.buildDestination();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
4
|
+
import { buildWorkspaceDestination } from '../shared/build-workspace-destination';
|
|
5
|
+
|
|
6
|
+
type GitWorkspaceSource = Extract<SpawnWorkspaceSource, { kind: 'git' }>;
|
|
7
|
+
|
|
8
|
+
interface DefaultDirTarget {
|
|
9
|
+
// The workspaces root the config sets for the target, else the global
|
|
10
|
+
// one, else null for the default.
|
|
11
|
+
readonly root: string | null;
|
|
12
|
+
|
|
13
|
+
// Whether the target runs on another machine, whose home only that
|
|
14
|
+
// machine knows.
|
|
15
|
+
readonly remote: boolean;
|
|
16
|
+
|
|
17
|
+
// The home directory of the daemon's own user.
|
|
18
|
+
readonly home: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Where checkouts land without a configured root, under the target user's
|
|
22
|
+
// home.
|
|
23
|
+
const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The directory a git workspace lands in when the spawn gives none:
|
|
27
|
+
* `<root>/<repo>-<ref>-<short sha>`, with the root the config sets, else
|
|
28
|
+
* `~/.local/share/atc/workspaces`. A root under `~` resolves on the
|
|
29
|
+
* target: against the daemon user's home on the daemon's own machine, and
|
|
30
|
+
* on a remote target as a path relative to the target user's home, which
|
|
31
|
+
* the host resolves, since only that host knows its home. Any other root
|
|
32
|
+
* must be absolute.
|
|
33
|
+
*/
|
|
34
|
+
export function buildDefaultWorkspaceDir(
|
|
35
|
+
source: GitWorkspaceSource,
|
|
36
|
+
target: DefaultDirTarget,
|
|
37
|
+
): string {
|
|
38
|
+
const configured = target.root ?? DEFAULT_ROOT;
|
|
39
|
+
|
|
40
|
+
const parts = {
|
|
41
|
+
url: source.url,
|
|
42
|
+
ref: source.ref ?? null,
|
|
43
|
+
sha: source.sha ?? null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (configured.startsWith('/')) {
|
|
47
|
+
return buildWorkspaceDestination({ root: posix.normalize(configured), ...parts });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (configured !== '~' && !configured.startsWith('~/')) {
|
|
51
|
+
throw new DaemonError(
|
|
52
|
+
'bad_args',
|
|
53
|
+
`workspaces root '${configured}' must be an absolute path or start with ~/`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalized = posix.normalize(`./${configured.slice(1)}`);
|
|
58
|
+
const underHome = normalized === './' || normalized === '.' ? '' : normalized;
|
|
59
|
+
|
|
60
|
+
if (underHome === '..' || underHome.startsWith('../')) {
|
|
61
|
+
throw new DaemonError(
|
|
62
|
+
'bad_args',
|
|
63
|
+
`workspaces root '${configured}' must stay under the home directory`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return target.remote
|
|
68
|
+
? buildWorkspaceDestination({ root: underHome, ...parts })
|
|
69
|
+
: buildWorkspaceDestination({ root: posix.join(target.home, underHome), ...parts });
|
|
70
|
+
}
|
|
@@ -926,6 +926,13 @@ export class DaemonConnection {
|
|
|
926
926
|
}
|
|
927
927
|
|
|
928
928
|
const data = parsed.data;
|
|
929
|
+
const cwd = data.cwd;
|
|
930
|
+
|
|
931
|
+
if (cwd === undefined && data.workspace?.kind !== 'git') {
|
|
932
|
+
this.sendErr(req.id, 'bad_args', 'session.spawn requires a cwd');
|
|
933
|
+
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
929
936
|
|
|
930
937
|
// Every refusal is thrown from the plan, which runs only once a key is
|
|
931
938
|
// claimed: a retry of a held key answers from the key without checking
|
|
@@ -972,16 +979,28 @@ export class DaemonConnection {
|
|
|
972
979
|
ctx.requireWorkspaceTarget(target);
|
|
973
980
|
}
|
|
974
981
|
|
|
982
|
+
// A git workspace without a cwd lands where the daemon picks on the
|
|
983
|
+
// target.
|
|
984
|
+
const dir =
|
|
985
|
+
cwd === undefined && data.workspace?.kind === 'git'
|
|
986
|
+
? ctx.buildDefaultWorkspaceDir(target, data.workspace)
|
|
987
|
+
: cwd;
|
|
988
|
+
|
|
989
|
+
if (dir === undefined) {
|
|
990
|
+
throw new DaemonError('bad_args', 'session.spawn requires a cwd');
|
|
991
|
+
}
|
|
992
|
+
|
|
975
993
|
// A workspace is materialized at cwd on the target, which only an
|
|
976
|
-
// absolute path names
|
|
977
|
-
|
|
994
|
+
// absolute path names; a directory the daemon picks on a remote
|
|
995
|
+
// target is relative to the home there, which the host resolves.
|
|
996
|
+
if (data.workspace !== undefined && cwd !== undefined && !isAbsolute(cwd)) {
|
|
978
997
|
throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
|
|
979
998
|
}
|
|
980
999
|
|
|
981
1000
|
// A workspace is created, filled, and removed at the one directory
|
|
982
1001
|
// the host resolves cwd to, which a dot segment or a control
|
|
983
1002
|
// character would make differ from the path as written.
|
|
984
|
-
if (data.workspace !== undefined && !isPlainWorkspaceDir(
|
|
1003
|
+
if (data.workspace !== undefined && !isPlainWorkspaceDir(dir)) {
|
|
985
1004
|
throw new DaemonError(
|
|
986
1005
|
'bad_args',
|
|
987
1006
|
'a spawn with a workspace requires a cwd without . or .. segments or control characters',
|
|
@@ -1001,8 +1020,8 @@ export class DaemonConnection {
|
|
|
1001
1020
|
}
|
|
1002
1021
|
|
|
1003
1022
|
return {
|
|
1004
|
-
cwd:
|
|
1005
|
-
name: data.name === '' ? basename(
|
|
1023
|
+
cwd: dir,
|
|
1024
|
+
name: data.name === '' ? basename(dir) : data.name,
|
|
1006
1025
|
prompt: data.prompt,
|
|
1007
1026
|
cols: data.cols,
|
|
1008
1027
|
rows: data.rows,
|
|
@@ -1018,6 +1037,7 @@ export class DaemonConnection {
|
|
|
1018
1037
|
},
|
|
1019
1038
|
target,
|
|
1020
1039
|
workspace: data.workspace ?? null,
|
|
1040
|
+
autoDir: cwd === undefined,
|
|
1021
1041
|
};
|
|
1022
1042
|
};
|
|
1023
1043
|
|
|
@@ -39,6 +39,11 @@ export interface SpawnParams {
|
|
|
39
39
|
// Where the session's working directory comes from; null runs the
|
|
40
40
|
// session in cwd as it stands.
|
|
41
41
|
readonly workspace: SpawnWorkspaceSource | null;
|
|
42
|
+
|
|
43
|
+
// Whether the daemon picked cwd for a git workspace the spawn gave no
|
|
44
|
+
// directory for, so a held directory moves the spawn to a numbered one
|
|
45
|
+
// beside it.
|
|
46
|
+
readonly autoDir: boolean;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
interface SessionRecord {
|
|
@@ -180,6 +185,14 @@ export interface DaemonContext {
|
|
|
180
185
|
// one whose provider cannot both transfer an archive and run a command.
|
|
181
186
|
readonly requireWorkspaceTarget: (target: string) => void;
|
|
182
187
|
|
|
188
|
+
// The directory a git workspace lands in on a target when the spawn
|
|
189
|
+
// gives none: absolute on the daemon's own machine, and relative to the
|
|
190
|
+
// target user's home on a remote target.
|
|
191
|
+
readonly buildDefaultWorkspaceDir: (
|
|
192
|
+
target: string,
|
|
193
|
+
source: Extract<SpawnWorkspaceSource, { kind: 'git' }>,
|
|
194
|
+
) => string;
|
|
195
|
+
|
|
183
196
|
// Throws the refusal for an agent that takes its credential from impd's
|
|
184
197
|
// broker on a target whose provider reaches no broker.
|
|
185
198
|
readonly requireAgentTarget: (agent: AgentID, target: string) => void;
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
|
|
|
18
18
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
19
19
|
import type { MessageID } from '../shared/message-id';
|
|
20
20
|
import { isRecord } from '../shared/report';
|
|
21
|
+
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
21
22
|
import type { SessionID } from '../shared/session-id';
|
|
22
23
|
import { toMessageID } from '../shared/to-message-id';
|
|
23
24
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
@@ -34,6 +35,7 @@ import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
|
34
35
|
import { AttachRegistry } from './attach-registry';
|
|
35
36
|
import { buildAgentList } from './build-agent-list';
|
|
36
37
|
import { buildConfigRevision } from './build-config-revision';
|
|
38
|
+
import { buildDefaultWorkspaceDir } from './build-default-workspace-dir';
|
|
37
39
|
import { buildExecutionTargets } from './build-execution-targets';
|
|
38
40
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
39
41
|
import { buildFleetEvents } from './build-fleet-events';
|
|
@@ -178,6 +180,14 @@ export interface DaemonOptions {
|
|
|
178
180
|
// or the invalid list the config holds, which refuses every git
|
|
179
181
|
// operation; https and ssh when unset.
|
|
180
182
|
readonly gitTransports?: readonly string[] | InvalidGitTransports;
|
|
183
|
+
|
|
184
|
+
// The root a git workspace without a directory lands under on any target
|
|
185
|
+
// without its own, and each target's own root, by target id; the default
|
|
186
|
+
// root under the target user's home when unset.
|
|
187
|
+
readonly workspaceRoots?: Readonly<{
|
|
188
|
+
root: string | null;
|
|
189
|
+
targetRoots: ReadonlyMap<string, string>;
|
|
190
|
+
}>;
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
// The TCP listener's address and the file holding the tokens a handshake
|
|
@@ -962,7 +972,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
962
972
|
? null
|
|
963
973
|
: async (
|
|
964
974
|
host: Readonly<{
|
|
965
|
-
readyHost: (
|
|
975
|
+
readyHost: (
|
|
976
|
+
attempt: number,
|
|
977
|
+
) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
966
978
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
967
979
|
}>,
|
|
968
980
|
targetIdentity: string,
|
|
@@ -1013,7 +1025,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1013
1025
|
id: SessionID,
|
|
1014
1026
|
source: SpawnWorkspaceSource,
|
|
1015
1027
|
host: Readonly<{
|
|
1016
|
-
readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1028
|
+
readyHost: (attempt: number) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1017
1029
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
1018
1030
|
}>,
|
|
1019
1031
|
targetIdentity: string,
|
|
@@ -1028,6 +1040,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1028
1040
|
dir: p.cwd,
|
|
1029
1041
|
source,
|
|
1030
1042
|
inPlace: bound.provider.kind === 'local-pty',
|
|
1043
|
+
autoDir: p.autoDir,
|
|
1031
1044
|
},
|
|
1032
1045
|
{
|
|
1033
1046
|
requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
|
|
@@ -1049,7 +1062,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1049
1062
|
materialize:
|
|
1050
1063
|
| ((
|
|
1051
1064
|
host: Readonly<{
|
|
1052
|
-
readyHost: (
|
|
1065
|
+
readyHost: (
|
|
1066
|
+
attempt: number,
|
|
1067
|
+
) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1053
1068
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
1054
1069
|
}>,
|
|
1055
1070
|
targetIdentity: string,
|
|
@@ -1075,6 +1090,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1075
1090
|
p.target,
|
|
1076
1091
|
materialize,
|
|
1077
1092
|
requireInReach,
|
|
1093
|
+
p.autoDir,
|
|
1078
1094
|
);
|
|
1079
1095
|
|
|
1080
1096
|
const runtime = runtimes.get(s.id);
|
|
@@ -1083,7 +1099,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1083
1099
|
runtime.pendingLastUsed = true;
|
|
1084
1100
|
}
|
|
1085
1101
|
|
|
1086
|
-
void store.recordSpawnDir(
|
|
1102
|
+
void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
|
|
1087
1103
|
|
|
1088
1104
|
return getDescriptor(mgr, s.id);
|
|
1089
1105
|
};
|
|
@@ -1405,6 +1421,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1405
1421
|
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1406
1422
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1407
1423
|
},
|
|
1424
|
+
buildDefaultWorkspaceDir: (target, source) =>
|
|
1425
|
+
buildDefaultWorkspaceDir(source, {
|
|
1426
|
+
root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
|
|
1427
|
+
remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
|
|
1428
|
+
home: resolveHomeDir(),
|
|
1429
|
+
}),
|
|
1408
1430
|
requireAgentTarget: (agent, target) => {
|
|
1409
1431
|
mgr.requireAgentTarget(agent, target);
|
|
1410
1432
|
},
|
|
@@ -32,6 +32,11 @@ interface MaterializeRequest {
|
|
|
32
32
|
// Whether the target is the daemon's own host, where a directory outside
|
|
33
33
|
// any git work tree runs in place instead of being materialized.
|
|
34
34
|
readonly inPlace: boolean;
|
|
35
|
+
|
|
36
|
+
// Whether the daemon picked the directory, so one that exists or that
|
|
37
|
+
// another workspace holds moves the claim to the next attempt's
|
|
38
|
+
// directory instead of refusing the spawn.
|
|
39
|
+
readonly autoDir?: boolean;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
// The host a workspace lands on, and the directory there it is built in.
|
|
@@ -48,8 +53,9 @@ interface MaterializeDeps {
|
|
|
48
53
|
readonly log: (line: string) => void;
|
|
49
54
|
|
|
50
55
|
// Readies the host on the target the workspace lands on and resolves to
|
|
51
|
-
// it, once the source has resolved
|
|
52
|
-
|
|
56
|
+
// it, once the source has resolved, with the directory of an attempt: the
|
|
57
|
+
// first is the request's directory, and attempt n appends `-n` to it.
|
|
58
|
+
readonly readyHost: (attempt: number) => Promise<Landing>;
|
|
53
59
|
|
|
54
60
|
// Removes the directory this call claimed after a failure, and resolves
|
|
55
61
|
// to whether it did; one it leaves holds what another session needs, or
|
|
@@ -254,12 +260,13 @@ async function runMaterialization(
|
|
|
254
260
|
const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
|
|
255
261
|
const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
|
|
256
262
|
|
|
257
|
-
const landing = await deps
|
|
258
|
-
|
|
259
|
-
updateProgress({ landing });
|
|
263
|
+
const landing = await claimLanding(request, deps, updateProgress);
|
|
260
264
|
|
|
261
|
-
await
|
|
262
|
-
|
|
265
|
+
await recordPhase(request, deps, updateProgress, 'cloning', {
|
|
266
|
+
repoURL,
|
|
267
|
+
ref,
|
|
268
|
+
...(request.autoDir === true ? { dir: landing.dir } : {}),
|
|
269
|
+
});
|
|
263
270
|
|
|
264
271
|
const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
|
|
265
272
|
|
|
@@ -401,6 +408,45 @@ async function requireNoURLCredentials(url: string, cwd: string): Promise<void>
|
|
|
401
408
|
}
|
|
402
409
|
}
|
|
403
410
|
|
|
411
|
+
// How many directories a spawn whose directory the daemon picked tries
|
|
412
|
+
// before it refuses.
|
|
413
|
+
const AUTO_DIR_ATTEMPTS = 100;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Readies the host and claims the directory the workspace lands in. A
|
|
417
|
+
* directory the daemon picked moves to the next attempt while the one
|
|
418
|
+
* before exists or another workspace holds it, so repeated and concurrent
|
|
419
|
+
* spawns of one repository land side by side; a directory the caller gave
|
|
420
|
+
* is claimed once.
|
|
421
|
+
*/
|
|
422
|
+
async function claimLanding(
|
|
423
|
+
request: MaterializeRequest,
|
|
424
|
+
deps: MaterializeDeps,
|
|
425
|
+
updateProgress: ProgressTracker,
|
|
426
|
+
): Promise<Landing> {
|
|
427
|
+
const attempts = request.autoDir === true ? AUTO_DIR_ATTEMPTS : 1;
|
|
428
|
+
|
|
429
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
430
|
+
try {
|
|
431
|
+
const landing = await deps.readyHost(attempt);
|
|
432
|
+
|
|
433
|
+
updateProgress({ landing });
|
|
434
|
+
|
|
435
|
+
await claimTargetDir(request, deps, landing, updateProgress);
|
|
436
|
+
|
|
437
|
+
return landing;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
const held =
|
|
440
|
+
error instanceof DaemonError &&
|
|
441
|
+
(error.code === 'workspace_exists' || error.code === 'workspace_overlap');
|
|
442
|
+
|
|
443
|
+
if (!held || attempt >= attempts) {
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
404
450
|
/**
|
|
405
451
|
* Creates the target directory as the claim on it: `mkdir` without `-p`
|
|
406
452
|
* fails when the directory exists, so a materialization never unpacks over
|
|
@@ -412,6 +458,9 @@ async function claimTargetDir(
|
|
|
412
458
|
landing: Landing,
|
|
413
459
|
updateProgress: ProgressTracker,
|
|
414
460
|
): Promise<void> {
|
|
461
|
+
// A directory the daemon picked is reported as it landed.
|
|
462
|
+
const shown = request.autoDir === true ? landing.dir : request.dir;
|
|
463
|
+
|
|
415
464
|
const parent = await deps.requireProvider('run').runCommand({
|
|
416
465
|
argv: ['mkdir', '-p', '--', dirname(landing.dir)],
|
|
417
466
|
cwd: '/',
|
|
@@ -421,8 +470,8 @@ async function claimTargetDir(
|
|
|
421
470
|
if (parent.exitCode !== 0) {
|
|
422
471
|
throw new DaemonError(
|
|
423
472
|
'transfer_failed',
|
|
424
|
-
`cannot create ${dirname(
|
|
425
|
-
{ phase: 'resolving', dir:
|
|
473
|
+
`cannot create ${dirname(shown)} on target '${request.target}': ${parent.stderr.trim()}`,
|
|
474
|
+
{ phase: 'resolving', dir: shown },
|
|
426
475
|
);
|
|
427
476
|
}
|
|
428
477
|
|
|
@@ -430,23 +479,46 @@ async function claimTargetDir(
|
|
|
430
479
|
.requireProvider('run')
|
|
431
480
|
.runCommand({ argv: ['mkdir', '--', landing.dir], cwd: '/', host: landing.host });
|
|
432
481
|
|
|
482
|
+
// A directory that is not there failed for another reason, such as a
|
|
483
|
+
// parent the command cannot write, which no other attempt would fix.
|
|
484
|
+
if (claim.exitCode !== 0 && !(await isTargetPathPresent(deps, landing))) {
|
|
485
|
+
throw new DaemonError(
|
|
486
|
+
'transfer_failed',
|
|
487
|
+
`cannot create ${shown} on target '${request.target}': ${claim.stderr.trim()}`,
|
|
488
|
+
{ phase: 'resolving', dir: shown },
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
433
492
|
if (claim.exitCode !== 0) {
|
|
434
493
|
throw new DaemonError(
|
|
435
494
|
'workspace_exists',
|
|
436
|
-
`${
|
|
437
|
-
{ phase: 'resolving', dir:
|
|
495
|
+
`${shown} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
|
|
496
|
+
{ phase: 'resolving', dir: shown },
|
|
438
497
|
);
|
|
439
498
|
}
|
|
440
499
|
|
|
441
500
|
updateProgress({ claimed: true });
|
|
442
501
|
}
|
|
443
502
|
|
|
503
|
+
// Whether anything, a dangling symlink included, stands at the landing
|
|
504
|
+
// directory's path on its host.
|
|
505
|
+
async function isTargetPathPresent(deps: MaterializeDeps, landing: Landing): Promise<boolean> {
|
|
506
|
+
const probe = await deps.requireProvider('run').runCommand({
|
|
507
|
+
argv: ['sh', '-c', '[ -e "$1" ] || [ -L "$1" ]', 'sh', landing.dir],
|
|
508
|
+
cwd: '/',
|
|
509
|
+
host: landing.host,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return probe.exitCode === 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
444
515
|
async function recordPhase(
|
|
445
516
|
request: MaterializeRequest,
|
|
446
517
|
deps: MaterializeDeps,
|
|
447
518
|
updateProgress: ProgressTracker,
|
|
448
519
|
phase: MaterializationPhase,
|
|
449
520
|
fields: Readonly<{
|
|
521
|
+
dir?: string;
|
|
450
522
|
repoURL?: string;
|
|
451
523
|
sha?: string;
|
|
452
524
|
ref?: string | null;
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -194,14 +194,18 @@ interface MaterializedSpawn {
|
|
|
194
194
|
// Builds a spawn's workspace on a target bound to an identity, calling
|
|
195
195
|
// readyHost for the host it lands on once its source resolves and asking
|
|
196
196
|
// canRemoveClaim before a failure removes the directory it claimed, or
|
|
197
|
-
// resolves to null for a directory that runs as it stands.
|
|
197
|
+
// resolves to null for a directory that runs as it stands. readyHost takes
|
|
198
|
+
// the attempt the directory is for: the first is the spawn's directory, and
|
|
199
|
+
// attempt n is that directory with `-n` appended.
|
|
198
200
|
type SpawnMaterializer = (
|
|
199
201
|
host: SpawnHostAccess,
|
|
200
202
|
targetIdentity: string,
|
|
201
203
|
) => Promise<MaterializedSpawn | null>;
|
|
202
204
|
|
|
203
205
|
interface SpawnHostAccess {
|
|
204
|
-
readonly readyHost: (
|
|
206
|
+
readonly readyHost: (
|
|
207
|
+
attempt: number,
|
|
208
|
+
) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
205
209
|
readonly removeClaim: (dir: string) => Promise<boolean>;
|
|
206
210
|
}
|
|
207
211
|
|
|
@@ -210,7 +214,7 @@ interface SpawnHostAccess {
|
|
|
210
214
|
interface WorkspaceReservation {
|
|
211
215
|
readonly hostKey: SessionID;
|
|
212
216
|
readonly target: string;
|
|
213
|
-
|
|
217
|
+
dir: string;
|
|
214
218
|
resolved: string | null;
|
|
215
219
|
|
|
216
220
|
// A workspace spawn builds its directory and may remove it on a failure;
|
|
@@ -905,6 +909,7 @@ export class SessionManager {
|
|
|
905
909
|
target = 'local',
|
|
906
910
|
materialize: SpawnMaterializer | null = null,
|
|
907
911
|
requireInReach: () => void = () => {},
|
|
912
|
+
autoDir = false,
|
|
908
913
|
): Promise<Session> {
|
|
909
914
|
const adapter = this.findAdapter(agent);
|
|
910
915
|
|
|
@@ -967,9 +972,14 @@ export class SessionManager {
|
|
|
967
972
|
throw refusal;
|
|
968
973
|
}
|
|
969
974
|
|
|
975
|
+
// A directory the daemon picked is checked on the host once per
|
|
976
|
+
// attempt, so a held one moves the spawn to the next attempt instead of
|
|
977
|
+
// refusing it.
|
|
970
978
|
if (hostKey !== id) {
|
|
971
979
|
if (materialize === null) {
|
|
972
980
|
this.claimPlainDir(id, hostKey, target, cwd);
|
|
981
|
+
} else if (autoDir) {
|
|
982
|
+
this.reservations.set(id, { hostKey, target, dir: cwd, resolved: null, kind: 'workspace' });
|
|
973
983
|
} else {
|
|
974
984
|
this.claimWorkspace(id, hostKey, target, cwd);
|
|
975
985
|
}
|
|
@@ -1041,6 +1051,9 @@ export class SessionManager {
|
|
|
1041
1051
|
);
|
|
1042
1052
|
});
|
|
1043
1053
|
|
|
1054
|
+
// A spawn whose directory the daemon picked runs in the one it claimed,
|
|
1055
|
+
// as the host resolves it.
|
|
1056
|
+
const dir = autoDir && prepared.root !== null ? prepared.root : cwd;
|
|
1044
1057
|
const setup = prepared.setup;
|
|
1045
1058
|
const materialized = prepared.materialized;
|
|
1046
1059
|
const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
|
|
@@ -1059,7 +1072,7 @@ export class SessionManager {
|
|
|
1059
1072
|
host: hostKey,
|
|
1060
1073
|
bin: plan.bin,
|
|
1061
1074
|
args: plan.args,
|
|
1062
|
-
cwd,
|
|
1075
|
+
cwd: dir,
|
|
1063
1076
|
env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
|
|
1064
1077
|
withheldEnv: materialized?.withheldEnv ?? [],
|
|
1065
1078
|
cols,
|
|
@@ -1084,8 +1097,8 @@ export class SessionManager {
|
|
|
1084
1097
|
|
|
1085
1098
|
const session: Session = {
|
|
1086
1099
|
id,
|
|
1087
|
-
name,
|
|
1088
|
-
cwd,
|
|
1100
|
+
name: namedBy === 'auto' && dir !== cwd ? posix.basename(dir) : name,
|
|
1101
|
+
cwd: dir,
|
|
1089
1102
|
kind: 'pty',
|
|
1090
1103
|
pty,
|
|
1091
1104
|
state: 'running',
|
|
@@ -1095,7 +1108,7 @@ export class SessionManager {
|
|
|
1095
1108
|
agent,
|
|
1096
1109
|
pinned: false,
|
|
1097
1110
|
lastAttachedAt: Date.now(),
|
|
1098
|
-
repoRoot,
|
|
1111
|
+
repoRoot: dir === cwd ? repoRoot : dir,
|
|
1099
1112
|
namedBy,
|
|
1100
1113
|
createdAt: Date.now(),
|
|
1101
1114
|
parent,
|
|
@@ -1306,6 +1319,7 @@ export class SessionManager {
|
|
|
1306
1319
|
if (reservation !== undefined) {
|
|
1307
1320
|
this.requireSeparateWorkspace(id, hostKey, target, dir, [dir, physical], listed.dirs);
|
|
1308
1321
|
|
|
1322
|
+
reservation.dir = dir;
|
|
1309
1323
|
reservation.resolved = physical;
|
|
1310
1324
|
}
|
|
1311
1325
|
|
|
@@ -1535,6 +1549,28 @@ export class SessionManager {
|
|
|
1535
1549
|
return existing === '/' ? rest || '/' : `${existing}${rest}`;
|
|
1536
1550
|
}
|
|
1537
1551
|
|
|
1552
|
+
// The home directory of the user commands run as on a host, with every
|
|
1553
|
+
// symlink in it resolved.
|
|
1554
|
+
private async resolveHostHome(provider: ExecutionProvider, hostKey: SessionID): Promise<string> {
|
|
1555
|
+
const result = await provider.runCommand({
|
|
1556
|
+
argv: ['sh', '-c', 'cd && pwd -P'],
|
|
1557
|
+
cwd: '/',
|
|
1558
|
+
host: hostKey,
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
const home = result.stdout.replace(/\n$/u, '');
|
|
1562
|
+
|
|
1563
|
+
if (result.exitCode !== 0 || !posix.isAbsolute(home)) {
|
|
1564
|
+
throw new DaemonError(
|
|
1565
|
+
'host_unavailable',
|
|
1566
|
+
`the host of session ${hostKey} cannot resolve its home directory: ${result.stderr.trim()}`,
|
|
1567
|
+
{ phase: 'resolving' },
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return home;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1538
1574
|
// Materializes a spawn's workspace, readying its host once the source
|
|
1539
1575
|
// resolves, or after the workspace for a directory that runs as it
|
|
1540
1576
|
// stands, and returns the directory it created, null for one that runs
|
|
@@ -1559,15 +1595,26 @@ export class SessionManager {
|
|
|
1559
1595
|
root: null,
|
|
1560
1596
|
};
|
|
1561
1597
|
|
|
1598
|
+
const hostHome: { dir: string | null } = { dir: null };
|
|
1599
|
+
|
|
1562
1600
|
try {
|
|
1563
1601
|
const materialized = await materialize(
|
|
1564
1602
|
{
|
|
1565
|
-
readyHost: async () => {
|
|
1566
|
-
readied.setup
|
|
1603
|
+
readyHost: async (attempt) => {
|
|
1604
|
+
readied.setup ??= await setupHost();
|
|
1605
|
+
|
|
1606
|
+
// A directory the daemon picked on a remote target is relative
|
|
1607
|
+
// to the home there, which only the readied host can resolve.
|
|
1608
|
+
hostHome.dir ??= posix.isAbsolute(dir)
|
|
1609
|
+
? ''
|
|
1610
|
+
: await this.resolveHostHome(provider, hostKey);
|
|
1611
|
+
|
|
1612
|
+
const base = posix.isAbsolute(dir) ? dir : posix.join(hostHome.dir, dir);
|
|
1613
|
+
const candidate = attempt === 1 ? base : `${base}-${attempt}`;
|
|
1567
1614
|
|
|
1568
1615
|
const landing = this.hasHostLifecycle(target)
|
|
1569
|
-
? await this.claimHostDir(provider, id, hostKey, target,
|
|
1570
|
-
: await this.resolveHostDir(provider, null,
|
|
1616
|
+
? await this.claimHostDir(provider, id, hostKey, target, candidate)
|
|
1617
|
+
: await this.resolveHostDir(provider, null, candidate);
|
|
1571
1618
|
|
|
1572
1619
|
readied.root = landing;
|
|
1573
1620
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -44,7 +44,9 @@ const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
44
44
|
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
45
45
|
z.strictObject({
|
|
46
46
|
daemon: DAEMON_FIELD,
|
|
47
|
-
cwd: SPAWN_SCHEMA.shape.cwd.describe(
|
|
47
|
+
cwd: SPAWN_SCHEMA.shape.cwd.describe(
|
|
48
|
+
"Absolute path of the working directory. Required, except with a git workspace: omit it there and atc picks a new directory under the target user's home, ~/.local/share/atc/workspaces/<repo>-<ref>-<short sha> unless the config sets another root, adding -2, -3, and so on when that directory exists. The session's cwd in the result holds the path it landed in.",
|
|
49
|
+
),
|
|
48
50
|
name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
|
|
49
51
|
prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
|
|
50
52
|
agent: SPAWN_SCHEMA.shape.agent.describe(SPAWN_AGENT_DESCRIPTION),
|
|
@@ -58,7 +60,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
58
60
|
'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
|
|
59
61
|
),
|
|
60
62
|
workspace: SPAWN_SCHEMA.shape.workspace.describe(
|
|
61
|
-
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
|
|
63
|
+
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet, or for a git source without cwd into a directory atc picks: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
|
|
62
64
|
),
|
|
63
65
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
64
66
|
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
@@ -20,6 +20,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
20
20
|
'request.principal': 'the target limits of a remote MCP client',
|
|
21
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
22
22
|
'spawn.workspace.trust': "atc_session_spawn's trustClonedWorkspace",
|
|
23
|
+
'spawn.workspace.autoDir': "atc_session_spawn's git workspace without a cwd",
|
|
23
24
|
sources: 'sources.list and sources.interpret',
|
|
24
25
|
'git.probe': 'git.probe',
|
|
25
26
|
'transport.tcp': 'a TCP connection to the daemon',
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function runTool(
|
|
|
46
46
|
|
|
47
47
|
const params = {
|
|
48
48
|
...(typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {}),
|
|
49
|
-
cwd: args['cwd'],
|
|
49
|
+
...(args['cwd'] === undefined ? {} : { cwd: args['cwd'] }),
|
|
50
50
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
51
51
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
52
52
|
...(rawAgent === undefined ? {} : { agent: rawAgent }),
|
|
@@ -78,12 +78,20 @@ export function runTool(
|
|
|
78
78
|
const trustFeatures: readonly DaemonFeature[] =
|
|
79
79
|
args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
|
|
80
80
|
|
|
81
|
+
// Only a daemon that picks a git workspace's directory takes one
|
|
82
|
+
// without a cwd.
|
|
83
|
+
const autoDirFeatures: readonly DaemonFeature[] =
|
|
84
|
+
args['cwd'] === undefined && args['workspace'] !== undefined
|
|
85
|
+
? ['spawn.workspace.autoDir']
|
|
86
|
+
: [];
|
|
87
|
+
|
|
81
88
|
const required = [
|
|
82
89
|
...optionFeatures,
|
|
83
90
|
...keyFeatures,
|
|
84
91
|
...targetFeatures,
|
|
85
92
|
...workspaceFeatures,
|
|
86
93
|
...trustFeatures,
|
|
94
|
+
...autoDirFeatures,
|
|
87
95
|
];
|
|
88
96
|
|
|
89
97
|
const ok =
|
|
@@ -50,6 +50,11 @@ export const DAEMON_FEATURES = [
|
|
|
50
50
|
// `session.spawn` takes an explicit trust decision for a cloned imp workspace.
|
|
51
51
|
'spawn.workspace.trust',
|
|
52
52
|
|
|
53
|
+
// `session.spawn` with a git workspace takes no `cwd`, and the daemon
|
|
54
|
+
// picks a directory under the target user's home that no other
|
|
55
|
+
// workspace holds.
|
|
56
|
+
'spawn.workspace.autoDir',
|
|
57
|
+
|
|
53
58
|
// `session.forget` exists, and a kill of a session asleep on a target that
|
|
54
59
|
// can destroy its host answers `confirmation_required`.
|
|
55
60
|
'session.forget',
|
|
@@ -151,7 +151,13 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
151
151
|
rows: buildTerminalSize(24),
|
|
152
152
|
}),
|
|
153
153
|
'session.spawn': z.object({
|
|
154
|
-
|
|
154
|
+
// The working directory on the target. A spawn with a git workspace may
|
|
155
|
+
// leave it out, and the daemon then picks a directory under the target
|
|
156
|
+
// user's home; every other spawn requires it.
|
|
157
|
+
cwd: z
|
|
158
|
+
.string({ error: 'session.spawn requires a cwd' })
|
|
159
|
+
.min(1, 'session.spawn requires a cwd')
|
|
160
|
+
.optional(),
|
|
155
161
|
name: buildDefaultedString(''),
|
|
156
162
|
prompt: buildDefaultedString(''),
|
|
157
163
|
cols: buildTerminalSize(80),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
interface DestinationParts {
|
|
2
|
-
// The root the checkout lands under
|
|
2
|
+
// The root the checkout lands under: absolute on its target, or relative
|
|
3
|
+
// to the target user's home, where an empty root is the home itself.
|
|
3
4
|
readonly root: string;
|
|
4
5
|
|
|
5
6
|
// The repository URL the checkout comes from, as the daemon resolved it.
|
|
@@ -7,15 +8,18 @@ interface DestinationParts {
|
|
|
7
8
|
|
|
8
9
|
// The branch or tag the commit was chosen by, or null for a bare commit.
|
|
9
10
|
readonly ref: string | null;
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
// The commit checked out, or null when only the ref picks it.
|
|
13
|
+
readonly sha: string | null;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* 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
|
|
16
|
-
* is the URL's last path segment
|
|
17
|
-
* `[A-Za-z0-9._-]`, with `/`
|
|
18
|
-
* segment on any target
|
|
18
|
+
* sha>` under the root, with the ref left out for a bare commit and the
|
|
19
|
+
* short sha for a ref alone. The repo is the URL's last path segment
|
|
20
|
+
* without `.git`, and each part keeps only `[A-Za-z0-9._-]`, with `/`
|
|
21
|
+
* turned into `-`, so the name is one path segment on any target; a name
|
|
22
|
+
* with nothing left is `workspace`.
|
|
19
23
|
*/
|
|
20
24
|
export function buildWorkspaceDestination(parts: DestinationParts): string {
|
|
21
25
|
const repo =
|
|
@@ -25,12 +29,19 @@ export function buildWorkspaceDestination(parts: DestinationParts): string {
|
|
|
25
29
|
.at(-1)
|
|
26
30
|
?.replace(/\.git$/u, '') ?? '';
|
|
27
31
|
|
|
28
|
-
const name = [
|
|
32
|
+
const name = [
|
|
33
|
+
repo,
|
|
34
|
+
...(parts.ref === null ? [] : [parts.ref]),
|
|
35
|
+
...(parts.sha === null ? [] : [parts.sha.slice(0, 7)]),
|
|
36
|
+
]
|
|
29
37
|
.map((part) => toSafeSegment(part))
|
|
30
38
|
.filter((part) => part !== '')
|
|
31
39
|
.join('-');
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
const segment = name === '' ? 'workspace' : name;
|
|
42
|
+
const root = parts.root.replace(/\/+$/u, '');
|
|
43
|
+
|
|
44
|
+
return parts.root === '' ? segment : `${root}/${segment}`;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
function toSafeSegment(part: string): string {
|
package/src/store/state-store.ts
CHANGED
|
@@ -939,6 +939,7 @@ export class StateStore {
|
|
|
939
939
|
.updateTable('workspace_materialization')
|
|
940
940
|
.set({
|
|
941
941
|
phase: fields.phase,
|
|
942
|
+
...(fields.dir === undefined ? {} : { dir: fields.dir }),
|
|
942
943
|
...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
|
|
943
944
|
...(fields.sha === undefined ? {} : { sha: fields.sha }),
|
|
944
945
|
...(fields.ref === undefined ? {} : { ref: fields.ref }),
|
|
@@ -50,6 +50,10 @@ export interface WorkspaceMaterialization {
|
|
|
50
50
|
// The fields a materialization rewrites as it moves through its phases.
|
|
51
51
|
export interface MaterializationUpdate {
|
|
52
52
|
readonly phase: MaterializationPhase;
|
|
53
|
+
|
|
54
|
+
// The directory the workspace landed in, once a directory the daemon
|
|
55
|
+
// picked is claimed.
|
|
56
|
+
readonly dir?: string;
|
|
53
57
|
readonly repoURL?: string;
|
|
54
58
|
readonly sha?: string;
|
|
55
59
|
readonly ref?: string | null;
|