@runuai/host 0.9.67 → 0.9.69
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/lib/git-identity.ts +14 -2
- package/lib/machine-exec.ts +130 -0
- package/lib/machine-provider-local.ts +283 -0
- package/lib/machine-provider.ts +76 -0
- package/package.json +1 -1
package/lib/git-identity.ts
CHANGED
|
@@ -112,11 +112,22 @@ export async function setupTaskGitIdentity(
|
|
|
112
112
|
// The key's dependent signing config, applied as one unit with the key itself.
|
|
113
113
|
// Deliberately no URL rewrite: doing that here would override a connected
|
|
114
114
|
// user's gh HTTPS credential and make SSH mandatory again.
|
|
115
|
+
//
|
|
116
|
+
// allowed_signers rides along so verification works, not just signing:
|
|
117
|
+
// without gpg.ssh.allowedSignersFile, `git log --format=%G?` prints N ("no
|
|
118
|
+
// signature") for commits that ARE signed and GitHub-verified — git reports
|
|
119
|
+
// the absence of a verification RESULT as the absence of a signature, and a
|
|
120
|
+
// crew read "all 25 commits unsigned" off perfectly signed work (live
|
|
121
|
+
// 2026-08-26). The wildcard principal is deliberate: the task key is the
|
|
122
|
+
// container's only signer, and the identity email may not be configured yet
|
|
123
|
+
// when the key lands.
|
|
115
124
|
const SSH_SIGNING_CONFIG =
|
|
116
125
|
"/usr/bin/git config --global gpg.format ssh" +
|
|
117
126
|
" && /usr/bin/git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
|
|
118
127
|
" && /usr/bin/git config --global commit.gpgsign true" +
|
|
119
|
-
" && /usr/bin/git config --global tag.gpgsign true"
|
|
128
|
+
" && /usr/bin/git config --global tag.gpgsign true" +
|
|
129
|
+
' && /usr/bin/printf \'* %s\\n\' "$(/bin/cat /home/node/.ssh/id_ed25519.pub)" > /home/node/.ssh/allowed_signers' +
|
|
130
|
+
" && /usr/bin/git config --global gpg.ssh.allowedSignersFile /home/node/.ssh/allowed_signers";
|
|
120
131
|
|
|
121
132
|
const SSH_TRANSPORT_CONFIG =
|
|
122
133
|
"/usr/bin/git config --global --unset-all 'url.https://github.com/.insteadOf' >/dev/null 2>&1 || true; " +
|
|
@@ -387,7 +398,8 @@ async function ensureTaskSshIdentityOnce(
|
|
|
387
398
|
|
|
388
399
|
const REMOVE_MANAGED_SSH_IDENTITY = [
|
|
389
400
|
"set -eu",
|
|
390
|
-
"/usr/bin/rm -f -- /home/node/.ssh/id_ed25519 /home/node/.ssh/id_ed25519.pub",
|
|
401
|
+
"/usr/bin/rm -f -- /home/node/.ssh/id_ed25519 /home/node/.ssh/id_ed25519.pub /home/node/.ssh/allowed_signers",
|
|
402
|
+
"/usr/bin/git config --global --unset-all gpg.ssh.allowedSignersFile >/dev/null 2>&1 || true",
|
|
391
403
|
"/usr/bin/git config --global --unset-all user.signingkey >/dev/null 2>&1 || true",
|
|
392
404
|
"/usr/bin/git config --global --unset-all commit.gpgsign >/dev/null 2>&1 || true",
|
|
393
405
|
"/usr/bin/git config --global --unset-all tag.gpgsign >/dev/null 2>&1 || true",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: command transport to a task machine — SSH within the private
|
|
3
|
+
* network (VPC for cloud backends, the daemon bridge on the Linux guinea
|
|
4
|
+
* pig). The recommended-and-recorded pick over SSM: it matches the exec
|
|
5
|
+
* plumbing every consumer already speaks, and nothing in it is
|
|
6
|
+
* cloud-specific.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `dockerCli`'s contract exactly — never rejects, resolves
|
|
9
|
+
* `{status, stdout, stderr}` with `status: null` on spawn error/timeout —
|
|
10
|
+
* so machine-backed call sites read like container-backed ones.
|
|
11
|
+
*
|
|
12
|
+
* Host-key policy is `accept-new`, the same posture task containers get for
|
|
13
|
+
* git (`core.sshCommand` in git-identity.ts): machines are freshly minted
|
|
14
|
+
* by US moments before first contact, so TOFU is sound — but a CHANGED key
|
|
15
|
+
* for a known address must fail loudly, never be silently re-accepted.
|
|
16
|
+
* BatchMode forbids prompts: an unreachable or unauthenticated machine is
|
|
17
|
+
* an error result, not a hang.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
import type { DockerResult } from "./docker-exec";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
25
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
/** SIGKILLed ssh normally closes at once; an unreapable child must not hold
|
|
27
|
+
* the promise open past the caller's own deadline. */
|
|
28
|
+
const KILL_SETTLE_MS = 2_000;
|
|
29
|
+
|
|
30
|
+
export interface MachineExecTarget {
|
|
31
|
+
/** Reachable address from MachineInfo — VPC-private or bridge IP. */
|
|
32
|
+
address: string;
|
|
33
|
+
/** Path to the private key the machine's image authorizes. */
|
|
34
|
+
keyPath: string;
|
|
35
|
+
user?: string;
|
|
36
|
+
port?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MachineExecOptions {
|
|
40
|
+
input?: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
maxOutputBytes?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function machineSshArgs(
|
|
46
|
+
target: MachineExecTarget,
|
|
47
|
+
argv: string[],
|
|
48
|
+
): string[] {
|
|
49
|
+
return [
|
|
50
|
+
"-o",
|
|
51
|
+
"BatchMode=yes",
|
|
52
|
+
"-o",
|
|
53
|
+
"StrictHostKeyChecking=accept-new",
|
|
54
|
+
"-o",
|
|
55
|
+
"ConnectTimeout=10",
|
|
56
|
+
"-i",
|
|
57
|
+
target.keyPath,
|
|
58
|
+
"-p",
|
|
59
|
+
String(target.port ?? 22),
|
|
60
|
+
`${target.user ?? "node"}@${target.address}`,
|
|
61
|
+
"--",
|
|
62
|
+
...argv,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function machineExec(
|
|
67
|
+
target: MachineExecTarget,
|
|
68
|
+
argv: string[],
|
|
69
|
+
opts: MachineExecOptions = {},
|
|
70
|
+
): Promise<DockerResult> {
|
|
71
|
+
return runSsh(machineSshArgs(target, argv), opts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runSsh(
|
|
75
|
+
args: string[],
|
|
76
|
+
opts: MachineExecOptions,
|
|
77
|
+
): Promise<DockerResult> {
|
|
78
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
79
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
80
|
+
return new Promise<DockerResult>((resolve) => {
|
|
81
|
+
let stdout = "";
|
|
82
|
+
let stderr = "";
|
|
83
|
+
let settled = false;
|
|
84
|
+
let killedForCause = false;
|
|
85
|
+
const child = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
86
|
+
const settle = (result: DockerResult): void => {
|
|
87
|
+
if (settled) return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve(result);
|
|
91
|
+
};
|
|
92
|
+
const killAndSettle = (): void => {
|
|
93
|
+
killedForCause = true;
|
|
94
|
+
child.kill("SIGKILL");
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
settle({ status: null, stdout, stderr, outputTruncated: true });
|
|
97
|
+
}, KILL_SETTLE_MS).unref?.();
|
|
98
|
+
};
|
|
99
|
+
const timer = setTimeout(killAndSettle, timeoutMs);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
const capOutput = (): void => {
|
|
102
|
+
if (stdout.length + stderr.length > maxOutputBytes) killAndSettle();
|
|
103
|
+
};
|
|
104
|
+
child.stdout.setEncoding("utf8");
|
|
105
|
+
child.stderr.setEncoding("utf8");
|
|
106
|
+
child.stdout.on("data", (chunk: string) => {
|
|
107
|
+
stdout += chunk;
|
|
108
|
+
capOutput();
|
|
109
|
+
});
|
|
110
|
+
child.stderr.on("data", (chunk: string) => {
|
|
111
|
+
stderr += chunk;
|
|
112
|
+
capOutput();
|
|
113
|
+
});
|
|
114
|
+
child.once("error", () => {
|
|
115
|
+
settle({ status: null, stdout, stderr });
|
|
116
|
+
});
|
|
117
|
+
child.once("close", (code) => {
|
|
118
|
+
settle({
|
|
119
|
+
status: killedForCause ? null : code,
|
|
120
|
+
stdout,
|
|
121
|
+
stderr,
|
|
122
|
+
...(killedForCause ? { outputTruncated: true } : {}),
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
if (opts.input !== undefined) {
|
|
126
|
+
child.stdin.write(opts.input);
|
|
127
|
+
}
|
|
128
|
+
child.stdin.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the `local` MachineProvider — task "machines" as containers on
|
|
3
|
+
* the local Docker daemon, so the whole machine-backed path (launch, in-env
|
|
4
|
+
* clone, SSH exec, tunnels, recovery-by-describe) is exercised on the
|
|
5
|
+
* uai-linux guinea pig VM with zero cloud spend.
|
|
6
|
+
*
|
|
7
|
+
* Machines are containers named `uai-machine-<taskId>` carrying the label
|
|
8
|
+
* `com.uai.machine=<taskId>`. The label is deliberately NOT `com.uai.task`:
|
|
9
|
+
* task-app containers own that label and every existing sweep/proof keys on
|
|
10
|
+
* it — a machine must never be mistaken for an app container by GC that
|
|
11
|
+
* predates machines.
|
|
12
|
+
*
|
|
13
|
+
* Absence grammar: `docker inspect` answers a missing object with exit 1,
|
|
14
|
+
* empty stdout, and an "Error: No such object: <id>" line naming the EXACT
|
|
15
|
+
* id. Only that whole answer proves absence (a not-found for "<id>-shadow"
|
|
16
|
+
* proves nothing about "<id>"); anything else is `unknown`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
20
|
+
import type {
|
|
21
|
+
MachineInfo,
|
|
22
|
+
MachineProvider,
|
|
23
|
+
MachineSpec,
|
|
24
|
+
MachineState,
|
|
25
|
+
} from "./machine-provider";
|
|
26
|
+
|
|
27
|
+
export const MACHINE_LABEL = "com.uai.machine";
|
|
28
|
+
|
|
29
|
+
export function localMachineName(taskId: string): string {
|
|
30
|
+
return `uai-machine-${taskId.toLowerCase()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Runner = (args: string[]) => Promise<DockerResult>;
|
|
34
|
+
|
|
35
|
+
const defaultRunner: Runner = (args) => dockerCli(args, { timeoutMs: 60_000 });
|
|
36
|
+
|
|
37
|
+
function machineAbsent(id: string, res: DockerResult): boolean {
|
|
38
|
+
return (
|
|
39
|
+
typeof res.status === "number" &&
|
|
40
|
+
res.status !== 0 &&
|
|
41
|
+
res.stdout.trim() === "" &&
|
|
42
|
+
res.stderr
|
|
43
|
+
.split("\n")
|
|
44
|
+
.some((line) => line.trim() === `Error: No such object: ${id}`)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateFromDocker(status: unknown): MachineState {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case "running":
|
|
51
|
+
return "running";
|
|
52
|
+
case "created":
|
|
53
|
+
case "restarting":
|
|
54
|
+
return "pending";
|
|
55
|
+
case "paused":
|
|
56
|
+
case "exited":
|
|
57
|
+
case "dead":
|
|
58
|
+
return "stopped";
|
|
59
|
+
default:
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface InspectShape {
|
|
65
|
+
name: string;
|
|
66
|
+
taskLabel: string | null;
|
|
67
|
+
state: MachineState;
|
|
68
|
+
address: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Strict single-record parse. Exactly one record whose Name is the
|
|
72
|
+
* requested id — a confused or multi-record answer proves nothing and maps
|
|
73
|
+
* to null (the caller reports `unknown`). */
|
|
74
|
+
function parseInspect(id: string, stdout: string): InspectShape | null {
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(stdout);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) return null;
|
|
82
|
+
const record = parsed[0];
|
|
83
|
+
if (typeof record !== "object" || record === null) return null;
|
|
84
|
+
const rawName = (record as { Name?: unknown }).Name;
|
|
85
|
+
if (typeof rawName !== "string") return null;
|
|
86
|
+
const name = rawName.replace(/^\//, "");
|
|
87
|
+
if (name !== id) return null;
|
|
88
|
+
const stateObj = (record as { State?: { Status?: unknown } }).State;
|
|
89
|
+
const labels = (record as { Config?: { Labels?: unknown } }).Config?.Labels;
|
|
90
|
+
let taskLabel: string | null = null;
|
|
91
|
+
if (labels !== undefined && labels !== null) {
|
|
92
|
+
if (typeof labels !== "object" || Array.isArray(labels)) return null;
|
|
93
|
+
const value = (labels as Record<string, unknown>)[MACHINE_LABEL];
|
|
94
|
+
if (value !== undefined && typeof value !== "string") return null;
|
|
95
|
+
taskLabel = typeof value === "string" ? value : null;
|
|
96
|
+
}
|
|
97
|
+
const networks = (
|
|
98
|
+
record as {
|
|
99
|
+
NetworkSettings?: { Networks?: Record<string, { IPAddress?: unknown }> };
|
|
100
|
+
}
|
|
101
|
+
).NetworkSettings?.Networks;
|
|
102
|
+
let address: string | null = null;
|
|
103
|
+
if (networks && typeof networks === "object") {
|
|
104
|
+
for (const net of Object.values(networks)) {
|
|
105
|
+
const ip = net?.IPAddress;
|
|
106
|
+
if (typeof ip === "string" && ip.length > 0) {
|
|
107
|
+
address = ip;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
taskLabel,
|
|
115
|
+
state: stateFromDocker(stateObj?.Status),
|
|
116
|
+
address,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unknown(id: string, detail: string): MachineInfo {
|
|
121
|
+
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createLocalMachineProvider(
|
|
125
|
+
runner: Runner = defaultRunner,
|
|
126
|
+
): MachineProvider {
|
|
127
|
+
async function describe(id: string): Promise<MachineInfo> {
|
|
128
|
+
const res = await runner(["inspect", id]);
|
|
129
|
+
if (res.status === 0) {
|
|
130
|
+
const record = parseInspect(id, res.stdout);
|
|
131
|
+
if (record === null) {
|
|
132
|
+
return unknown(id, "inspect answered with a confusing record");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
id,
|
|
136
|
+
taskLabel: record.taskLabel,
|
|
137
|
+
state: record.state,
|
|
138
|
+
address: record.state === "running" ? record.address : null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (machineAbsent(id, res)) {
|
|
142
|
+
return { id, taskLabel: null, state: "absent", address: null };
|
|
143
|
+
}
|
|
144
|
+
return unknown(
|
|
145
|
+
id,
|
|
146
|
+
res.stderr.trim() || `inspect exited ${res.status ?? "killed"}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
kind: "local",
|
|
152
|
+
|
|
153
|
+
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
154
|
+
const name = localMachineName(spec.taskId);
|
|
155
|
+
const args = [
|
|
156
|
+
"run",
|
|
157
|
+
"-d",
|
|
158
|
+
"--name",
|
|
159
|
+
name,
|
|
160
|
+
"--label",
|
|
161
|
+
`${MACHINE_LABEL}=${spec.taskId}`,
|
|
162
|
+
"--cpus",
|
|
163
|
+
String(spec.cpus),
|
|
164
|
+
"--memory",
|
|
165
|
+
`${spec.memoryMiB}m`,
|
|
166
|
+
spec.image,
|
|
167
|
+
...(spec.command ?? []),
|
|
168
|
+
];
|
|
169
|
+
const res = await runner(args);
|
|
170
|
+
if (res.status !== 0) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`machine launch failed for ${name}: ${
|
|
173
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
174
|
+
}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const info = await describe(name);
|
|
178
|
+
if (info.state === "absent" || info.state === "unknown") {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`machine ${name} launched but could not be described (${info.state})`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return info;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async stop(id: string): Promise<void> {
|
|
187
|
+
const res = await runner(["stop", "--time", "10", id]);
|
|
188
|
+
if (res.status !== 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`machine stop failed for ${id}: ${
|
|
191
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
192
|
+
}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async start(id: string): Promise<MachineInfo> {
|
|
198
|
+
const res = await runner(["start", id]);
|
|
199
|
+
if (res.status !== 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`machine start failed for ${id}: ${
|
|
202
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
203
|
+
}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return describe(id);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async terminate(id: string): Promise<void> {
|
|
210
|
+
// Removal is allowed to fail (already gone, races) — absence is not.
|
|
211
|
+
await runner(["rm", "-f", id]);
|
|
212
|
+
const after = await describe(id);
|
|
213
|
+
if (after.state !== "absent") {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`machine ${id} could not be proven absent after terminate (${after.state}${
|
|
216
|
+
after.detail ? `: ${after.detail}` : ""
|
|
217
|
+
})`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
describe,
|
|
223
|
+
|
|
224
|
+
async list(): Promise<MachineInfo[]> {
|
|
225
|
+
const ids = await runner([
|
|
226
|
+
"ps",
|
|
227
|
+
"-a",
|
|
228
|
+
"--filter",
|
|
229
|
+
`label=${MACHINE_LABEL}`,
|
|
230
|
+
"--format",
|
|
231
|
+
"{{.Names}}",
|
|
232
|
+
]);
|
|
233
|
+
if (ids.status !== 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`machine listing failed: ${
|
|
236
|
+
ids.stderr.trim() || `exit ${ids.status ?? "killed"}`
|
|
237
|
+
}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const names = ids.stdout
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((line) => line.trim())
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
if (names.length === 0) return [];
|
|
245
|
+
const inspected = await runner(["inspect", ...names]);
|
|
246
|
+
if (inspected.status !== 0) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`machine inventory inspect failed: ${
|
|
249
|
+
inspected.stderr.trim() || `exit ${inspected.status ?? "killed"}`
|
|
250
|
+
}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
let parsed: unknown;
|
|
254
|
+
try {
|
|
255
|
+
parsed = JSON.parse(inspected.stdout);
|
|
256
|
+
} catch {
|
|
257
|
+
throw new Error("machine inventory was unparseable");
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(parsed) || parsed.length !== names.length) {
|
|
260
|
+
throw new Error("machine inventory was unparseable");
|
|
261
|
+
}
|
|
262
|
+
const machines: MachineInfo[] = [];
|
|
263
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
264
|
+
const record = parseInspect(
|
|
265
|
+
names[index]!,
|
|
266
|
+
JSON.stringify([parsed[index]]),
|
|
267
|
+
);
|
|
268
|
+
if (record === null) {
|
|
269
|
+
// A partially-readable inventory could hide exactly the labeled
|
|
270
|
+
// machine an orphan proof needs to see — no answer at all.
|
|
271
|
+
throw new Error("machine inventory was unparseable");
|
|
272
|
+
}
|
|
273
|
+
machines.push({
|
|
274
|
+
id: record.name,
|
|
275
|
+
taskLabel: record.taskLabel,
|
|
276
|
+
state: record.state,
|
|
277
|
+
address: record.state === "running" ? record.address : null,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return machines;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the per-task MACHINE lifecycle seam.
|
|
3
|
+
*
|
|
4
|
+
* A "machine" is the isolated computer a task runs on — an EC2 instance, a
|
|
5
|
+
* GCP instance, or (for the zero-cloud-spend guinea pig) an SSH-reachable
|
|
6
|
+
* container on the local daemon. The seam is deliberately small enough that
|
|
7
|
+
* nothing in it is EC2-specific: launch / stop / start / terminate /
|
|
8
|
+
* describe / list, with an ownership tag and a reachable address.
|
|
9
|
+
*
|
|
10
|
+
* State discipline (this repo's absence rule, applied to machines):
|
|
11
|
+
* - `absent` means PROVEN absent — the provider observed its backend's exact
|
|
12
|
+
* not-found answer for this exact id. Only `absent` authorizes forgetting
|
|
13
|
+
* a machine or deleting anything derived from it.
|
|
14
|
+
* - `unknown` means the query failed or answered confusingly. Unknown is
|
|
15
|
+
* never absence; callers defer and retry.
|
|
16
|
+
* - `terminate` does not return until absence is proven; an unprovable
|
|
17
|
+
* termination throws rather than reporting success.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type MachineState =
|
|
21
|
+
| "pending"
|
|
22
|
+
| "running"
|
|
23
|
+
| "stopped"
|
|
24
|
+
| "absent"
|
|
25
|
+
| "unknown";
|
|
26
|
+
|
|
27
|
+
export interface MachineSpec {
|
|
28
|
+
/** Task ownership — recorded on the machine (tag/label) so recovery and
|
|
29
|
+
* orphan sweeps key on it, mirroring `com.uai.task` on containers. */
|
|
30
|
+
taskId: string;
|
|
31
|
+
/** Backend image reference: AMI id, GCP image, or container image. */
|
|
32
|
+
image: string;
|
|
33
|
+
cpus: number;
|
|
34
|
+
memoryMiB: number;
|
|
35
|
+
/** Optional long-running entrypoint override (the local backend's images
|
|
36
|
+
* must keep the machine alive themselves — typically sshd). */
|
|
37
|
+
command?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MachineInfo {
|
|
41
|
+
/** Backend-native identifier: instance id or container id/name. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** The recorded task ownership label, null when the machine carries none
|
|
44
|
+
* (a foreign machine occupying a reserved name is never ours to touch). */
|
|
45
|
+
taskLabel: string | null;
|
|
46
|
+
state: MachineState;
|
|
47
|
+
/** Address the orchestrator can dial for SSH/exec/tunnels, when running.
|
|
48
|
+
* Reachability is a deployment property: VPC-private IPs (cloud) and
|
|
49
|
+
* bridge IPs (local daemon on the SAME machine as the orchestrator) are
|
|
50
|
+
* reachable; container bridge IPs are NOT host-routable from macOS —
|
|
51
|
+
* the local backend is for the Linux guinea pig, not Mac hosts. */
|
|
52
|
+
address: string | null;
|
|
53
|
+
/** Present only alongside `unknown` — what the backend actually said. */
|
|
54
|
+
detail?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MachineProvider {
|
|
58
|
+
readonly kind: "local" | "aws" | "gcp";
|
|
59
|
+
/** Create and start a machine. Rejects on a name/identity conflict —
|
|
60
|
+
* adopting an existing machine is a caller decision, never implicit. */
|
|
61
|
+
launch(spec: MachineSpec): Promise<MachineInfo>;
|
|
62
|
+
/** Graceful stop (sleep-when-idle; cloud backends keep the disk). */
|
|
63
|
+
stop(id: string): Promise<void>;
|
|
64
|
+
/** Start a stopped machine and report its (possibly new) address. */
|
|
65
|
+
start(id: string): Promise<MachineInfo>;
|
|
66
|
+
/** Destroy the machine. Resolves only after absence is PROVEN. */
|
|
67
|
+
terminate(id: string): Promise<void>;
|
|
68
|
+
/** One machine's state. `absent` only on the backend's exact not-found
|
|
69
|
+
* answer for this exact id; anything else unprovable is `unknown`. */
|
|
70
|
+
describe(id: string): Promise<MachineInfo>;
|
|
71
|
+
/** Every machine carrying this provider's ownership labels — the orphan
|
|
72
|
+
* sweep's inventory. A partially-readable answer is no answer: malformed
|
|
73
|
+
* rows reject the whole listing (they could hide exactly the labeled
|
|
74
|
+
* machine a proof needs to see). */
|
|
75
|
+
list(): Promise<MachineInfo[]>;
|
|
76
|
+
}
|