@vellumai/cli 0.8.4 → 0.8.6
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/AGENTS.md +17 -1
- package/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +62 -32
- package/src/commands/flags.ts +197 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +145 -55
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/index.ts +9 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +133 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +13 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +72 -8
- package/src/lib/hatch-local.ts +15 -2
- package/src/lib/http-client.ts +1 -3
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/process.ts +109 -39
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +102 -9
- package/src/lib/sync-cloud-assistants.ts +17 -0
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { AssistantClient } from "../lib/assistant-client.js";
|
|
2
|
+
|
|
3
|
+
type FeatureFlagEntry = {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
defaultEnabled: boolean;
|
|
8
|
+
description: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type FlagsResponse = {
|
|
12
|
+
flags: FeatureFlagEntry[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function pad(s: string, w: number): string {
|
|
16
|
+
return s + " ".repeat(Math.max(0, w - s.length));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function printFlagTable(flags: FeatureFlagEntry[]): void {
|
|
20
|
+
const headers = { key: "KEY", enabled: "ENABLED", default: "DEFAULT", label: "LABEL" };
|
|
21
|
+
|
|
22
|
+
const rows = flags
|
|
23
|
+
.slice()
|
|
24
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
25
|
+
.map((f) => ({
|
|
26
|
+
key: f.enabled !== f.defaultEnabled ? `* ${f.key}` : ` ${f.key}`,
|
|
27
|
+
enabled: String(f.enabled),
|
|
28
|
+
default: String(f.defaultEnabled),
|
|
29
|
+
label: f.label,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const all = [headers, ...rows];
|
|
33
|
+
const colWidths = {
|
|
34
|
+
key: Math.max(...all.map((r) => r.key.length)),
|
|
35
|
+
enabled: Math.max(...all.map((r) => r.enabled.length)),
|
|
36
|
+
default: Math.max(...all.map((r) => r.default.length)),
|
|
37
|
+
label: Math.max(...all.map((r) => r.label.length)),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const formatRow = (r: typeof headers) =>
|
|
41
|
+
`${pad(r.key, colWidths.key)} ${pad(r.enabled, colWidths.enabled)} ${pad(r.default, colWidths.default)} ${r.label}`;
|
|
42
|
+
|
|
43
|
+
console.log(formatRow(headers));
|
|
44
|
+
console.log(
|
|
45
|
+
`${"-".repeat(colWidths.key)} ${"-".repeat(colWidths.enabled)} ${"-".repeat(colWidths.default)} ${"-".repeat(colWidths.label)}`,
|
|
46
|
+
);
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
console.log(formatRow(row));
|
|
49
|
+
}
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log("* = overridden (differs from default)");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printHelp(): void {
|
|
55
|
+
console.log("Usage: vellum flags [subcommand] [options]");
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log("Show and toggle feature flags for the active assistant.");
|
|
58
|
+
console.log("Reads from the gateway's merged flag state (persisted overrides > remote > defaults).");
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log("Subcommands:");
|
|
61
|
+
console.log(" (none) List all feature flags in a table");
|
|
62
|
+
console.log(" get <key> Show details for a single flag");
|
|
63
|
+
console.log(" set <key> <bool> Set a flag override to true or false");
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log("Options:");
|
|
66
|
+
console.log(" --help, -h Show this help");
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log("Examples:");
|
|
69
|
+
console.log(" $ vellum flags # list all flags");
|
|
70
|
+
console.log(" $ vellum flags get query-complexity-routing # inspect one flag");
|
|
71
|
+
console.log(" $ vellum flags set voice-mode true # enable a flag");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createClient(): AssistantClient {
|
|
75
|
+
try {
|
|
76
|
+
return new AssistantClient();
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"No assistant found. Hatch one with 'vellum hatch' first.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rethrowFetchError(err: unknown): never {
|
|
85
|
+
if (
|
|
86
|
+
err instanceof TypeError &&
|
|
87
|
+
(err.message.includes("fetch") || err.message.includes("connect"))
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function listFlags(): Promise<void> {
|
|
97
|
+
const client = createClient();
|
|
98
|
+
let res: Response;
|
|
99
|
+
try {
|
|
100
|
+
res = await client.get("/feature-flags");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
rethrowFetchError(err);
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const body = await res.text().catch(() => "");
|
|
106
|
+
throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
|
|
107
|
+
}
|
|
108
|
+
const data = (await res.json()) as FlagsResponse;
|
|
109
|
+
if (data.flags.length === 0) {
|
|
110
|
+
console.log("No feature flags found.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
printFlagTable(data.flags);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function getFlag(key: string): Promise<void> {
|
|
117
|
+
const client = createClient();
|
|
118
|
+
let res: Response;
|
|
119
|
+
try {
|
|
120
|
+
res = await client.get("/feature-flags");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
rethrowFetchError(err);
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const body = await res.text().catch(() => "");
|
|
126
|
+
throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
|
|
127
|
+
}
|
|
128
|
+
const data = (await res.json()) as FlagsResponse;
|
|
129
|
+
const flag = data.flags.find((f) => f.key === key);
|
|
130
|
+
if (!flag) {
|
|
131
|
+
throw new Error(`Flag "${key}" not found.`);
|
|
132
|
+
}
|
|
133
|
+
console.log(`Key: ${flag.key}`);
|
|
134
|
+
console.log(`Enabled: ${flag.enabled}`);
|
|
135
|
+
console.log(`Default: ${flag.defaultEnabled}`);
|
|
136
|
+
console.log(`Description: ${flag.description || "(none)"}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function setFlag(key: string, value: boolean): Promise<void> {
|
|
140
|
+
const client = createClient();
|
|
141
|
+
let res: Response;
|
|
142
|
+
try {
|
|
143
|
+
res = await client.patch(`/feature-flags/${key}`, { enabled: value });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
rethrowFetchError(err);
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const body = await res.text().catch(() => "");
|
|
149
|
+
throw new Error(`Failed to set flag: HTTP ${res.status} ${body}`.trim());
|
|
150
|
+
}
|
|
151
|
+
console.log(`Flag "${key}" set to ${value}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function flags(): Promise<void> {
|
|
155
|
+
const args = process.argv.slice(3);
|
|
156
|
+
|
|
157
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
158
|
+
printHelp();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const subcommand = args[0];
|
|
163
|
+
|
|
164
|
+
if (!subcommand) {
|
|
165
|
+
await listFlags();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (subcommand === "get") {
|
|
170
|
+
const key = args[1];
|
|
171
|
+
if (!key) {
|
|
172
|
+
console.error("Usage: vellum flags get <key>");
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
await getFlag(key);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (subcommand === "set") {
|
|
180
|
+
const key = args[1];
|
|
181
|
+
const rawValue = args[2];
|
|
182
|
+
if (!key || rawValue === undefined) {
|
|
183
|
+
console.error("Usage: vellum flags set <key> <true|false>");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
if (rawValue !== "true" && rawValue !== "false") {
|
|
187
|
+
console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
await setFlag(key, rawValue === "true");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
195
|
+
printHelp();
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lookupAssistantByIdentifier,
|
|
3
|
+
formatAssistantLookupError,
|
|
4
|
+
} from "../../lib/assistant-config.js";
|
|
5
|
+
import {
|
|
6
|
+
loadGuardianToken,
|
|
7
|
+
refreshGuardianToken,
|
|
8
|
+
} from "../../lib/guardian-token.js";
|
|
9
|
+
|
|
10
|
+
function printUsage(): void {
|
|
11
|
+
console.log("Usage: vellum gateway token <subcommand> <assistantId>");
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log("Manage gateway authentication tokens.");
|
|
14
|
+
console.log("");
|
|
15
|
+
console.log("Subcommands:");
|
|
16
|
+
console.log(" get Print the current guardian access token");
|
|
17
|
+
console.log(" refresh Refresh an expired access token and print it");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function gatewayToken(): Promise<void> {
|
|
21
|
+
const args = process.argv.slice(4);
|
|
22
|
+
const subcommand = args[0];
|
|
23
|
+
|
|
24
|
+
if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
|
|
25
|
+
printUsage();
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (subcommand !== "get" && subcommand !== "refresh") {
|
|
30
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
31
|
+
printUsage();
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const assistantId = args[1];
|
|
36
|
+
if (!assistantId) {
|
|
37
|
+
console.error("Missing required argument: <assistantId>");
|
|
38
|
+
printUsage();
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = lookupAssistantByIdentifier(assistantId);
|
|
43
|
+
if (result.status !== "found") {
|
|
44
|
+
console.error(formatAssistantLookupError(assistantId, result));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const entry = result.entry;
|
|
48
|
+
|
|
49
|
+
const tokenData = loadGuardianToken(entry.assistantId);
|
|
50
|
+
if (!tokenData) {
|
|
51
|
+
console.error("No guardian token found for this assistant.");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (subcommand === "get") {
|
|
56
|
+
console.log(tokenData.accessToken);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const gatewayUrl = entry.localUrl || entry.runtimeUrl;
|
|
61
|
+
if (!gatewayUrl) {
|
|
62
|
+
console.error("No gateway URL found for this assistant.");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const refreshed = await refreshGuardianToken(gatewayUrl, entry.assistantId);
|
|
67
|
+
if (!refreshed) {
|
|
68
|
+
console.error("Failed to refresh guardian token.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(refreshed.accessToken);
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { gatewayToken } from "./gateway/token.js";
|
|
2
|
+
|
|
3
|
+
function printUsage(): void {
|
|
4
|
+
console.log("Usage: vellum gateway <subcommand>");
|
|
5
|
+
console.log("");
|
|
6
|
+
console.log("Gateway management commands.");
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log("Subcommands:");
|
|
9
|
+
console.log(" token Manage gateway authentication tokens");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function gateway(): Promise<void> {
|
|
13
|
+
const args = process.argv.slice(3);
|
|
14
|
+
const subcommand = args[0];
|
|
15
|
+
|
|
16
|
+
if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
|
|
17
|
+
printUsage();
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (subcommand === "token") {
|
|
22
|
+
await gatewayToken();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
27
|
+
printUsage();
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
package/src/commands/logs.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { createInterface } from "readline";
|
|
|
4
4
|
import { watch } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
extractHostFromUrl,
|
|
9
|
+
resolveAssistant,
|
|
10
|
+
resolveCloud,
|
|
11
|
+
type AssistantEntry,
|
|
12
|
+
} from "../lib/assistant-config";
|
|
9
13
|
import { dockerResourceNames } from "../lib/docker";
|
|
10
14
|
import { getLogDir } from "../lib/xdg-log";
|
|
11
15
|
import { execOutput } from "../lib/step-runner";
|
|
@@ -112,13 +116,6 @@ function parseArgs(): LogsArgs {
|
|
|
112
116
|
|
|
113
117
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
114
118
|
|
|
115
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
116
|
-
if (entry.cloud) return entry.cloud;
|
|
117
|
-
if (entry.project) return "gcp";
|
|
118
|
-
if (entry.sshUser) return "custom";
|
|
119
|
-
return "local";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
119
|
/**
|
|
123
120
|
* Parse a relative time string like "10m", "2h", "30s" into a Date.
|
|
124
121
|
* Returns null if the string doesn't look like a relative time.
|
|
@@ -494,15 +491,6 @@ async function showGcpLogs(
|
|
|
494
491
|
}
|
|
495
492
|
}
|
|
496
493
|
|
|
497
|
-
function extractHostFromUrl(url: string): string {
|
|
498
|
-
try {
|
|
499
|
-
const parsed = new URL(url);
|
|
500
|
-
return parsed.hostname;
|
|
501
|
-
} catch {
|
|
502
|
-
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
494
|
async function showCustomLogs(
|
|
507
495
|
entry: AssistantEntry,
|
|
508
496
|
opts: LogsArgs,
|
package/src/commands/ps.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
extractHostFromUrl,
|
|
4
5
|
findAssistantByName,
|
|
5
6
|
formatAssistantLookupError,
|
|
6
7
|
formatAssistantReference,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
getDaemonPidPath,
|
|
10
11
|
loadAllAssistants,
|
|
11
12
|
lookupAssistantByIdentifier,
|
|
13
|
+
resolveCloud,
|
|
12
14
|
type AssistantEntry,
|
|
13
15
|
} from "../lib/assistant-config";
|
|
14
16
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
@@ -26,7 +28,7 @@ import { existsSync } from "fs";
|
|
|
26
28
|
import {
|
|
27
29
|
classifyProcess,
|
|
28
30
|
detectOrphanedProcesses,
|
|
29
|
-
|
|
31
|
+
isPidAlive,
|
|
30
32
|
parseRemotePs,
|
|
31
33
|
readPidFile,
|
|
32
34
|
} from "../lib/orphan-detection";
|
|
@@ -149,35 +151,25 @@ const REMOTE_PS_CMD = [
|
|
|
149
151
|
"| grep -v grep",
|
|
150
152
|
].join(" ");
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const parsed = new URL(url);
|
|
155
|
-
return parsed.hostname;
|
|
156
|
-
} catch {
|
|
157
|
-
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
162
|
-
if (entry.cloud) return entry.cloud;
|
|
163
|
-
if (entry.project) return "gcp";
|
|
164
|
-
if (entry.sshUser) return "custom";
|
|
165
|
-
return "local";
|
|
166
|
-
}
|
|
154
|
+
const REMOTE_SSH_TIMEOUT_MS = 30_000;
|
|
167
155
|
|
|
168
156
|
async function getRemoteProcessesGcp(entry: AssistantEntry): Promise<string> {
|
|
169
|
-
return execOutput(
|
|
170
|
-
"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
157
|
+
return execOutput(
|
|
158
|
+
"gcloud",
|
|
159
|
+
[
|
|
160
|
+
"compute",
|
|
161
|
+
"ssh",
|
|
162
|
+
`${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
|
|
163
|
+
`--zone=${entry.zone}`,
|
|
164
|
+
`--project=${entry.project}`,
|
|
165
|
+
`--command=${REMOTE_PS_CMD}`,
|
|
166
|
+
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
167
|
+
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
168
|
+
"--ssh-flag=-o ConnectTimeout=10",
|
|
169
|
+
"--ssh-flag=-o LogLevel=ERROR",
|
|
170
|
+
],
|
|
171
|
+
{ timeoutMs: REMOTE_SSH_TIMEOUT_MS },
|
|
172
|
+
);
|
|
181
173
|
}
|
|
182
174
|
|
|
183
175
|
async function getRemoteProcessesCustom(
|
|
@@ -185,7 +177,9 @@ async function getRemoteProcessesCustom(
|
|
|
185
177
|
): Promise<string> {
|
|
186
178
|
const host = extractHostFromUrl(entry.runtimeUrl);
|
|
187
179
|
const sshUser = entry.sshUser ?? "root";
|
|
188
|
-
return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD]
|
|
180
|
+
return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD], {
|
|
181
|
+
timeoutMs: REMOTE_SSH_TIMEOUT_MS,
|
|
182
|
+
});
|
|
189
183
|
}
|
|
190
184
|
|
|
191
185
|
interface ProcessSpec {
|
|
@@ -203,9 +197,13 @@ interface DetectedProcess {
|
|
|
203
197
|
watch: boolean;
|
|
204
198
|
}
|
|
205
199
|
|
|
200
|
+
const LOCAL_CMD_TIMEOUT_MS = 5_000;
|
|
201
|
+
|
|
206
202
|
async function isWatchMode(pid: string): Promise<boolean> {
|
|
207
203
|
try {
|
|
208
|
-
const args = await execOutput("ps", ["-p", pid, "-o", "args="]
|
|
204
|
+
const args = await execOutput("ps", ["-p", pid, "-o", "args="], {
|
|
205
|
+
timeoutMs: LOCAL_CMD_TIMEOUT_MS,
|
|
206
|
+
});
|
|
209
207
|
return args.includes("--watch");
|
|
210
208
|
} catch {
|
|
211
209
|
return false;
|
|
@@ -242,7 +240,7 @@ async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
|
|
|
242
240
|
|
|
243
241
|
// Tier 3: PID file fallback
|
|
244
242
|
const filePid = readPidFile(spec.pidFile);
|
|
245
|
-
if (filePid &&
|
|
243
|
+
if (filePid && isPidAlive(filePid)) {
|
|
246
244
|
const watch = await isWatchMode(filePid);
|
|
247
245
|
return {
|
|
248
246
|
name: spec.name,
|
|
@@ -320,12 +318,11 @@ async function getDockerContainerState(
|
|
|
320
318
|
containerName: string,
|
|
321
319
|
): Promise<string | null> {
|
|
322
320
|
try {
|
|
323
|
-
const output = await execOutput(
|
|
324
|
-
"
|
|
325
|
-
"--format",
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
]);
|
|
321
|
+
const output = await execOutput(
|
|
322
|
+
"docker",
|
|
323
|
+
["inspect", "--format", "{{.State.Status}}", containerName],
|
|
324
|
+
{ timeoutMs: LOCAL_CMD_TIMEOUT_MS },
|
|
325
|
+
);
|
|
329
326
|
return output.trim() || "unknown";
|
|
330
327
|
} catch {
|
|
331
328
|
return null;
|
|
@@ -458,9 +455,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
|
|
|
458
455
|
process.exit(1);
|
|
459
456
|
}
|
|
460
457
|
} catch (error) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
458
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
459
|
+
if (msg.includes("timed out")) {
|
|
460
|
+
console.warn(`Warning: remote process listing timed out — ${msg}`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
console.error(`Failed to list processes: ${msg}`);
|
|
464
464
|
process.exit(1);
|
|
465
465
|
}
|
|
466
466
|
|
|
@@ -496,7 +496,7 @@ async function getAssistantListHealth(
|
|
|
496
496
|
// TODO(ATL-306): Remove readPidFile/getDaemonPidPath in favor of
|
|
497
497
|
// fetching daemon PIDs via the health API (Gateway Security Migration).
|
|
498
498
|
const pid = readPidFile(getDaemonPidPath(resources));
|
|
499
|
-
const alive = pid !== null &&
|
|
499
|
+
const alive = pid !== null && isPidAlive(pid);
|
|
500
500
|
if (!alive) {
|
|
501
501
|
return { status: "sleeping", detail: null };
|
|
502
502
|
}
|
package/src/commands/recover.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
} from "fs";
|
|
2
8
|
import { homedir } from "os";
|
|
3
|
-
import { join } from "path";
|
|
9
|
+
import { basename, dirname, join } from "path";
|
|
4
10
|
|
|
5
11
|
import { saveAssistantEntry } from "../lib/assistant-config";
|
|
6
12
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
@@ -21,8 +27,22 @@ export async function recover(): Promise<void> {
|
|
|
21
27
|
"Restore a previously retired local assistant from its archive.",
|
|
22
28
|
);
|
|
23
29
|
console.log("");
|
|
30
|
+
console.log(
|
|
31
|
+
"Extracts the archived workspace data back to its original location,",
|
|
32
|
+
);
|
|
33
|
+
console.log(
|
|
34
|
+
"restores the lockfile entry, and starts the assistant and gateway.",
|
|
35
|
+
);
|
|
36
|
+
console.log(
|
|
37
|
+
"Archives are stored in $XDG_DATA_HOME/vellum/retired/ (default: ~/.local/share/vellum/retired/).",
|
|
38
|
+
);
|
|
39
|
+
console.log("");
|
|
24
40
|
console.log("Arguments:");
|
|
25
41
|
console.log(" <name> Name of the retired assistant to recover");
|
|
42
|
+
console.log("");
|
|
43
|
+
console.log("Examples:");
|
|
44
|
+
console.log(" $ vellum recover my-assistant");
|
|
45
|
+
console.log(" $ vellum recover aria-7f3a");
|
|
26
46
|
process.exit(0);
|
|
27
47
|
}
|
|
28
48
|
|
|
@@ -61,11 +81,27 @@ export async function recover(): Promise<void> {
|
|
|
61
81
|
process.exit(1);
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
// 4.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
84
|
+
// 4. Determine the original target directory, then extract and rename.
|
|
85
|
+
//
|
|
86
|
+
// retireLocal archives either the full instanceDir (named instances) or just
|
|
87
|
+
// the .vellum/ subdirectory (default instance whose instanceDir === homedir()).
|
|
88
|
+
// The directory is staged under `<archive>.staging` inside the retired dir
|
|
89
|
+
// before being packed with `tar -C <retiredDir> <stagingBasename>`, so the
|
|
90
|
+
// top-level entry inside the tarball is always `<name>.tar.gz.staging`.
|
|
91
|
+
//
|
|
92
|
+
// Correct restoration: extract to retiredDir, then rename the staging entry
|
|
93
|
+
// back to the original target path. Using homedir() as the -C target was
|
|
94
|
+
// wrong for any instance stored outside the home directory.
|
|
95
|
+
const isNamedInstance = entry.resources.instanceDir !== homedir();
|
|
96
|
+
const targetDir = isNamedInstance
|
|
97
|
+
? entry.resources.instanceDir
|
|
98
|
+
: join(entry.resources.instanceDir, ".vellum");
|
|
99
|
+
const retiredDir = dirname(archivePath);
|
|
100
|
+
const extractedPath = join(retiredDir, basename(archivePath) + ".staging");
|
|
101
|
+
|
|
102
|
+
await exec("tar", ["xzf", archivePath, "-C", retiredDir]);
|
|
103
|
+
mkdirSync(dirname(targetDir), { recursive: true });
|
|
104
|
+
renameSync(extractedPath, targetDir);
|
|
69
105
|
|
|
70
106
|
// 5. Restore lockfile entry
|
|
71
107
|
saveAssistantEntry(entry);
|
|
@@ -74,14 +110,16 @@ export async function recover(): Promise<void> {
|
|
|
74
110
|
unlinkSync(archivePath);
|
|
75
111
|
unlinkSync(metadataPath);
|
|
76
112
|
|
|
77
|
-
// 7. Persist signing key so
|
|
113
|
+
// 7. Persist signing key and bootstrap secret so they survive daemon/gateway restarts
|
|
78
114
|
const signingKey = generateLocalSigningKey();
|
|
115
|
+
const bootstrapSecret = generateLocalSigningKey();
|
|
79
116
|
entry.resources = { ...entry.resources, signingKey };
|
|
117
|
+
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
80
118
|
saveAssistantEntry(entry);
|
|
81
119
|
|
|
82
120
|
// 8. Start daemon + gateway
|
|
83
121
|
await startLocalDaemon(false, entry.resources, { signingKey });
|
|
84
|
-
await startGateway(false, entry.resources, { signingKey });
|
|
122
|
+
await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
|
|
85
123
|
|
|
86
124
|
console.log(`✅ Recovered assistant '${name}'.`);
|
|
87
125
|
}
|
package/src/commands/restore.ts
CHANGED
|
@@ -97,6 +97,7 @@ async function getAccessToken(
|
|
|
97
97
|
runtimeUrl: string,
|
|
98
98
|
assistantId: string,
|
|
99
99
|
displayName: string,
|
|
100
|
+
bootstrapSecret?: string,
|
|
100
101
|
): Promise<string> {
|
|
101
102
|
const tokenData = loadGuardianToken(assistantId);
|
|
102
103
|
|
|
@@ -105,7 +106,11 @@ async function getAccessToken(
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
try {
|
|
108
|
-
const freshToken = await leaseGuardianToken(
|
|
109
|
+
const freshToken = await leaseGuardianToken(
|
|
110
|
+
runtimeUrl,
|
|
111
|
+
assistantId,
|
|
112
|
+
bootstrapSecret,
|
|
113
|
+
);
|
|
109
114
|
return freshToken.accessToken;
|
|
110
115
|
} catch (err) {
|
|
111
116
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -574,6 +579,7 @@ export async function restore(): Promise<void> {
|
|
|
574
579
|
entry.runtimeUrl,
|
|
575
580
|
entry.assistantId,
|
|
576
581
|
name,
|
|
582
|
+
entry.guardianBootstrapSecret,
|
|
577
583
|
);
|
|
578
584
|
|
|
579
585
|
if (dryRun) {
|
|
@@ -679,6 +685,7 @@ export async function restore(): Promise<void> {
|
|
|
679
685
|
entry.runtimeUrl,
|
|
680
686
|
entry.assistantId,
|
|
681
687
|
name,
|
|
688
|
+
entry.guardianBootstrapSecret,
|
|
682
689
|
);
|
|
683
690
|
}
|
|
684
691
|
|