@zgeoff/atc 2.33.0 → 2.35.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/agents/agent-adapter.ts +14 -2
- package/src/agents/build-claude-bundle-settings.ts +91 -0
- package/src/agents/build-claude-guest-launch.ts +21 -6
- package/src/agents/claude-adapter.ts +45 -9
- package/src/agents/claude-config-bundle-folder.ts +8 -0
- package/src/agents/load-claude-config-bundle.ts +169 -0
- package/src/cli.ts +2 -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/build-tar-archive.ts +33 -8
- package/src/daemon/daemon-connection.ts +28 -5
- package/src/daemon/daemon-context.ts +13 -0
- package/src/daemon/daemon.ts +77 -5
- package/src/daemon/materialize-workspace.ts +83 -11
- package/src/daemon/restore-fleet.ts +66 -2
- package/src/daemon/sessions.ts +80 -13
- package/src/mcp/mcp-tools.ts +8 -2
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +16 -1
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/request-param-schemas.ts +13 -1
- package/src/{client → shared}/build-workspace-destination.ts +19 -8
- package/src/shared/config.ts +9 -0
- package/src/statusline.ts +4 -3
- package/src/store/fleet-entry.ts +5 -0
- package/src/store/run-migrations.ts +12 -0
- package/src/store/state-store.ts +35 -0
- package/src/store/workspace-materialization.ts +4 -0
|
@@ -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
|
+
}
|
|
@@ -12,8 +12,9 @@ const BLOCK = 512;
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
|
|
15
|
-
* its parent directories created on the way. A path longer than
|
|
16
|
-
*
|
|
15
|
+
* its parent directories created on the way. A path longer than 100 bytes
|
|
16
|
+
* splits at a slash into ustar's prefix and name fields, and a path that
|
|
17
|
+
* fits no such split is refused.
|
|
17
18
|
*/
|
|
18
19
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
|
|
19
20
|
export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
@@ -50,15 +51,11 @@ export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
|
50
51
|
function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
51
52
|
const encoder = new TextEncoder();
|
|
52
53
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (name.length > 100) {
|
|
56
|
-
throw new Error(`archive path is longer than 100 bytes: ${path}`);
|
|
57
|
-
}
|
|
54
|
+
const split = splitArchivePath(path);
|
|
58
55
|
|
|
59
56
|
const header = new Uint8Array(BLOCK);
|
|
60
57
|
|
|
61
|
-
header.set(name, 0);
|
|
58
|
+
header.set(encoder.encode(split.name), 0);
|
|
62
59
|
header.set(encoder.encode(formatOctal(mode, 8)), 100);
|
|
63
60
|
header.set(encoder.encode(formatOctal(0, 8)), 108);
|
|
64
61
|
header.set(encoder.encode(formatOctal(0, 8)), 116);
|
|
@@ -71,6 +68,7 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
|
71
68
|
header[156] = '0'.codePointAt(0) ?? 0;
|
|
72
69
|
|
|
73
70
|
header.set(encoder.encode('ustar\u000000'), 257);
|
|
71
|
+
header.set(encoder.encode(split.prefix), 345);
|
|
74
72
|
|
|
75
73
|
const checksum = header.reduce((total, byte) => total + byte, 0);
|
|
76
74
|
|
|
@@ -79,6 +77,33 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
|
79
77
|
return header;
|
|
80
78
|
}
|
|
81
79
|
|
|
80
|
+
const NAME_BYTES = 100;
|
|
81
|
+
const PREFIX_BYTES = 155;
|
|
82
|
+
|
|
83
|
+
// The ustar fields a path fills: the whole path as the name when it fits,
|
|
84
|
+
// otherwise the part before the last slash that leaves a name short enough,
|
|
85
|
+
// with that slash dropped, as the prefix.
|
|
86
|
+
function splitArchivePath(path: string): { readonly prefix: string; readonly name: string } {
|
|
87
|
+
if (countBytes(path) <= NAME_BYTES) {
|
|
88
|
+
return { prefix: '', name: path };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (let at = path.indexOf('/'); at !== -1; at = path.indexOf('/', at + 1)) {
|
|
92
|
+
const prefix = path.slice(0, at);
|
|
93
|
+
const name = path.slice(at + 1);
|
|
94
|
+
|
|
95
|
+
if (countBytes(prefix) <= PREFIX_BYTES && countBytes(name) <= NAME_BYTES && name !== '') {
|
|
96
|
+
return { prefix, name };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(`archive path does not fit a ustar header: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countBytes(text: string): number {
|
|
104
|
+
return new TextEncoder().encode(text).length;
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
// An octal number padded to fill its field, ending in a NUL.
|
|
83
108
|
function formatOctal(value: number, width: number): string {
|
|
84
109
|
return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
|
|
@@ -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,
|
|
@@ -1015,9 +1034,13 @@ export class DaemonConnection {
|
|
|
1015
1034
|
...(data.trustClonedWorkspace === undefined
|
|
1016
1035
|
? {}
|
|
1017
1036
|
: { trustClonedWorkspace: data.trustClonedWorkspace }),
|
|
1037
|
+
...(data.resumeInterruptedTurns === undefined
|
|
1038
|
+
? {}
|
|
1039
|
+
: { resumeInterruptedTurns: data.resumeInterruptedTurns }),
|
|
1018
1040
|
},
|
|
1019
1041
|
target,
|
|
1020
1042
|
workspace: data.workspace ?? null,
|
|
1043
|
+
autoDir: cwd === undefined,
|
|
1021
1044
|
};
|
|
1022
1045
|
};
|
|
1023
1046
|
|
|
@@ -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';
|
|
@@ -155,6 +157,11 @@ export interface DaemonOptions {
|
|
|
155
157
|
readonly restoreBootTimeoutMs?: number;
|
|
156
158
|
readonly tapGraceMs?: number;
|
|
157
159
|
|
|
160
|
+
// Whether a fleet restore sends a session that was mid-turn when the
|
|
161
|
+
// previous daemon stopped one message to carry on, for a session spawned
|
|
162
|
+
// without its own choice; off when unset.
|
|
163
|
+
readonly resumeInterruptedTurns?: boolean;
|
|
164
|
+
|
|
158
165
|
// How long a confirm token from `session.forget` stays usable.
|
|
159
166
|
readonly forgetConfirmMs?: number;
|
|
160
167
|
|
|
@@ -178,6 +185,14 @@ export interface DaemonOptions {
|
|
|
178
185
|
// or the invalid list the config holds, which refuses every git
|
|
179
186
|
// operation; https and ssh when unset.
|
|
180
187
|
readonly gitTransports?: readonly string[] | InvalidGitTransports;
|
|
188
|
+
|
|
189
|
+
// The root a git workspace without a directory lands under on any target
|
|
190
|
+
// without its own, and each target's own root, by target id; the default
|
|
191
|
+
// root under the target user's home when unset.
|
|
192
|
+
readonly workspaceRoots?: Readonly<{
|
|
193
|
+
root: string | null;
|
|
194
|
+
targetRoots: ReadonlyMap<string, string>;
|
|
195
|
+
}>;
|
|
181
196
|
}
|
|
182
197
|
|
|
183
198
|
// The TCP listener's address and the file holding the tokens a handshake
|
|
@@ -271,6 +286,11 @@ const LOCK_WAIT_MS = 2000;
|
|
|
271
286
|
* because transports guarantee byte integrity and such a line means a buggy
|
|
272
287
|
* or hostile peer.
|
|
273
288
|
*/
|
|
289
|
+
// The sender and opening of the message a restore sends a session whose
|
|
290
|
+
// turn the previous daemon's stop cut off.
|
|
291
|
+
const RESUME_SENDER = 'atc';
|
|
292
|
+
const RESUME_LEAD = 'atc restarted the daemon';
|
|
293
|
+
|
|
274
294
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
275
295
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
276
296
|
|
|
@@ -962,7 +982,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
962
982
|
? null
|
|
963
983
|
: async (
|
|
964
984
|
host: Readonly<{
|
|
965
|
-
readyHost: (
|
|
985
|
+
readyHost: (
|
|
986
|
+
attempt: number,
|
|
987
|
+
) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
966
988
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
967
989
|
}>,
|
|
968
990
|
targetIdentity: string,
|
|
@@ -1013,7 +1035,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1013
1035
|
id: SessionID,
|
|
1014
1036
|
source: SpawnWorkspaceSource,
|
|
1015
1037
|
host: Readonly<{
|
|
1016
|
-
readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1038
|
+
readyHost: (attempt: number) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1017
1039
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
1018
1040
|
}>,
|
|
1019
1041
|
targetIdentity: string,
|
|
@@ -1028,6 +1050,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1028
1050
|
dir: p.cwd,
|
|
1029
1051
|
source,
|
|
1030
1052
|
inPlace: bound.provider.kind === 'local-pty',
|
|
1053
|
+
autoDir: p.autoDir,
|
|
1031
1054
|
},
|
|
1032
1055
|
{
|
|
1033
1056
|
requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
|
|
@@ -1049,7 +1072,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1049
1072
|
materialize:
|
|
1050
1073
|
| ((
|
|
1051
1074
|
host: Readonly<{
|
|
1052
|
-
readyHost: (
|
|
1075
|
+
readyHost: (
|
|
1076
|
+
attempt: number,
|
|
1077
|
+
) => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1053
1078
|
removeClaim: (dir: string) => Promise<boolean>;
|
|
1054
1079
|
}>,
|
|
1055
1080
|
targetIdentity: string,
|
|
@@ -1075,6 +1100,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1075
1100
|
p.target,
|
|
1076
1101
|
materialize,
|
|
1077
1102
|
requireInReach,
|
|
1103
|
+
p.autoDir,
|
|
1078
1104
|
);
|
|
1079
1105
|
|
|
1080
1106
|
const runtime = runtimes.get(s.id);
|
|
@@ -1083,7 +1109,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1083
1109
|
runtime.pendingLastUsed = true;
|
|
1084
1110
|
}
|
|
1085
1111
|
|
|
1086
|
-
void store.recordSpawnDir(
|
|
1112
|
+
void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
|
|
1087
1113
|
|
|
1088
1114
|
return getDescriptor(mgr, s.id);
|
|
1089
1115
|
};
|
|
@@ -1292,6 +1318,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1292
1318
|
return record;
|
|
1293
1319
|
};
|
|
1294
1320
|
|
|
1321
|
+
const startedAt = new Date().toISOString();
|
|
1322
|
+
|
|
1323
|
+
// Sends a restored session the message that carries on a turn the
|
|
1324
|
+
// previous daemon's stop cut off. A resume message still pending from an
|
|
1325
|
+
// earlier restart covers this one, so a session never holds two.
|
|
1326
|
+
const sendResumeMessage = async (s: Session): Promise<void> => {
|
|
1327
|
+
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
1328
|
+
|
|
1329
|
+
if (
|
|
1330
|
+
pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
|
|
1331
|
+
) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const refusal = findMessageRefusal(s.id);
|
|
1336
|
+
|
|
1337
|
+
if (refusal !== null) {
|
|
1338
|
+
mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
|
|
1339
|
+
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
await writeAcceptedMessage(
|
|
1344
|
+
s.id,
|
|
1345
|
+
RESUME_SENDER,
|
|
1346
|
+
`${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
|
|
1347
|
+
mintMessageID(),
|
|
1348
|
+
() => {},
|
|
1349
|
+
);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1295
1352
|
// A retried send answers with the message's current status, so a caller
|
|
1296
1353
|
// that lost the first answer learns where its message stands now.
|
|
1297
1354
|
const loadMessageReplay = async (
|
|
@@ -1405,6 +1462,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1405
1462
|
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1406
1463
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1407
1464
|
},
|
|
1465
|
+
buildDefaultWorkspaceDir: (target, source) =>
|
|
1466
|
+
buildDefaultWorkspaceDir(source, {
|
|
1467
|
+
root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
|
|
1468
|
+
remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
|
|
1469
|
+
home: resolveHomeDir(),
|
|
1470
|
+
}),
|
|
1408
1471
|
requireAgentTarget: (agent, target) => {
|
|
1409
1472
|
mgr.requireAgentTarget(agent, target);
|
|
1410
1473
|
},
|
|
@@ -1754,7 +1817,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1754
1817
|
attachments.findEffectiveDims(sessionID) ??
|
|
1755
1818
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1756
1819
|
restoreFleet: (cols, rows) =>
|
|
1757
|
-
restoreFleet({
|
|
1820
|
+
restoreFleet({
|
|
1821
|
+
mgr,
|
|
1822
|
+
store,
|
|
1823
|
+
findRuntime,
|
|
1824
|
+
cols,
|
|
1825
|
+
rows,
|
|
1826
|
+
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1827
|
+
resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
|
|
1828
|
+
sendResumeMessage,
|
|
1829
|
+
}),
|
|
1758
1830
|
readSessionRecord: async (id, access) => {
|
|
1759
1831
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1760
1832
|
|