@flisk-sh/flisk 1.4.6 → 1.4.7
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/README.md +2 -2
- package/cli/dist/agent-runtimes/custom.js +94 -13
- package/cli/dist/commands/agent.js +144 -123
- package/cli/dist/commands/doctor.js +12 -0
- package/cli/dist/commands/logs.js +71 -4
- package/cli/dist/commands/project.js +16 -0
- package/cli/dist/commands/resource.js +3 -1
- package/cli/dist/commands/status.js +43 -24
- package/cli/dist/commands/ui.js +41 -2
- package/cli/dist/commands/up.js +54 -11
- package/cli/dist/components/DetailPanel.js +23 -3
- package/cli/dist/mcp/tools.js +9 -6
- package/cli/dist/types/index.js +0 -2
- package/cli/dist/utils/agent-format.js +18 -0
- package/cli/dist/utils/agent-sessions.js +122 -0
- package/cli/dist/utils/agent-skills.js +64 -0
- package/cli/dist/utils/cold-preflight.js +3 -0
- package/cli/dist/utils/doctor-fix-first.js +93 -0
- package/cli/dist/utils/doctor-report.js +3 -0
- package/cli/dist/utils/native-runtime.js +3 -0
- package/cli/dist/utils/native-service.js +2 -2
- package/cli/dist/utils/service-manifest.js +8 -15
- package/cli/dist/utils/services.js +7 -10
- package/cli/dist/utils/startup-report.js +45 -3
- package/cli/dist/utils/status-groups.js +23 -0
- package/cli/dist/utils/up-hints.js +42 -0
- package/cli/dist/utils/up-progress.js +117 -0
- package/cli/dist/utils/up-readiness.js +5 -0
- package/cli/dist-bundle/{chunk-U5FCQF42.js → chunk-6OW2GVQV.js} +1 -1
- package/cli/dist-bundle/chunk-B2JOM3HN.js +35 -0
- package/cli/dist-bundle/{chunk-R7DWXISM.js → chunk-F2DLIM75.js} +1 -1
- package/cli/dist-bundle/{chunk-ETXZ5LUG.js → chunk-L6O7TT3K.js} +10 -10
- package/cli/dist-bundle/chunk-MFY7RZ76.js +130 -0
- package/cli/dist-bundle/{chunk-MUDE4DZD.js → chunk-MYAYJF4Q.js} +1 -1
- package/cli/dist-bundle/{chunk-DPQK5TIS.js → chunk-P7GZ6NGM.js} +1 -1
- package/cli/dist-bundle/{chunk-IDNES5PE.js → chunk-T44DEEL6.js} +1 -1
- package/cli/dist-bundle/{chunk-C4UFBXRD.js → chunk-TIPSERWO.js} +1 -1
- package/cli/dist-bundle/{chunk-BIVAAWBX.js → chunk-VYPB7MY5.js} +1 -1
- package/cli/dist-bundle/{chunk-WCGBVETR.js → chunk-XHENT4KO.js} +1 -1
- package/cli/dist-bundle/{chunk-CAEMWNPF.js → chunk-YTBTCAWY.js} +1 -1
- package/cli/dist-bundle/cli.js +462 -377
- package/cli/dist-bundle/{cold-preflight-KUWOL2Q2.js → cold-preflight-DQVJUGBB.js} +1 -1
- package/cli/dist-bundle/{demo-stage-QCC4IWXR.js → demo-stage-F4YA7UL2.js} +1 -1
- package/cli/dist-bundle/{open-AZNX4AC5.js → open-ZUSSVPD3.js} +1 -1
- package/cli/dist-bundle/project-JFCTPRP5.js +2 -0
- package/cli/dist-bundle/{services-EY5WKPHQ.js → services-DOHDHOCT.js} +1 -1
- package/cli/dist-bundle/{tilt-port-owner-UBU6JWOA.js → tilt-port-owner-XODQHXHX.js} +1 -1
- package/cli/package.json +1 -1
- package/cli/templates/agent-skills/flisk-agent-ops/SKILL.md +39 -0
- package/cli/templates/agent-skills/flisk-build/SKILL.md +85 -0
- package/cli/templates/agent-skills/flisk-doctor/SKILL.md +28 -0
- package/cli/templates/agent-skills/flisk-recipe-ai-agent/SKILL.md +45 -0
- package/cli/templates/agent-skills/flisk-recipe-blog/SKILL.md +36 -0
- package/cli/templates/agent-skills/flisk-recipe-saas/SKILL.md +39 -0
- package/cli/templates/agent-skills/flisk-recipe-shop/SKILL.md +41 -0
- package/cli/templates/agent-skills/flisk-recipe-starters/SKILL.md +46 -0
- package/cli/templates/agent-skills/flisk-resource/SKILL.md +27 -0
- package/cli/templates/agent-skills/flisk-status/SKILL.md +25 -0
- package/cli/templates/agent-skills/flisk-troubleshoot/SKILL.md +227 -0
- package/cli/templates/agent-skills/flisk-up/SKILL.md +37 -0
- package/package.json +1 -1
- package/runtime/engine/schemas/service-schema.json +3 -15
- package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +7 -1
- package/cli/dist-bundle/chunk-CDKEHFW6.js +0 -35
- package/cli/dist-bundle/chunk-GT76VMUL.js +0 -130
- package/cli/dist-bundle/project-5SWIGCNP.js +0 -2
package/README.md
CHANGED
|
@@ -356,11 +356,11 @@ Get a running local stack in **3 commands**. Works on macOS, Linux, and Windows
|
|
|
356
356
|
For an app made of services you can start with one command each (an API, a website, a worker), put a `service.json` next to each one:
|
|
357
357
|
|
|
358
358
|
```json
|
|
359
|
-
{ "schemaVersion": 1, "
|
|
359
|
+
{ "schemaVersion": 1, "appName": "api", "appType": "backend", "command": "node server.js", "port": 4000, "healthCheckPath": "/health" }
|
|
360
360
|
```
|
|
361
361
|
|
|
362
362
|
```json
|
|
363
|
-
{ "schemaVersion": 1, "
|
|
363
|
+
{ "schemaVersion": 1, "appName": "web", "appType": "frontend", "command": "node server.js", "port": 3000, "dependsOn": ["api"] }
|
|
364
364
|
```
|
|
365
365
|
|
|
366
366
|
Then start everything from the project folder:
|
|
@@ -24,6 +24,13 @@ interface Checkpoint {
|
|
|
24
24
|
messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// One row of GET /sessions: what is stored, not what is in it, so listing never reads a whole conversation.
|
|
28
|
+
interface SessionSummary {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
lastActivity: string;
|
|
31
|
+
checkpointBytes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
let client: Client | null = null;
|
|
28
35
|
let initialized = false;
|
|
29
36
|
|
|
@@ -69,6 +76,17 @@ const store = {
|
|
|
69
76
|
return { sessionId, messages: [] };
|
|
70
77
|
}
|
|
71
78
|
},
|
|
79
|
+
async list(): Promise<SessionSummary[]> {
|
|
80
|
+
const c = await getClient();
|
|
81
|
+
const res = await c.query(
|
|
82
|
+
'SELECT session_id, updated_at, octet_length(messages::text) AS bytes FROM flisk_agent_sessions',
|
|
83
|
+
);
|
|
84
|
+
return res.rows.map((row: { session_id: string; updated_at: Date; bytes: number }) => ({
|
|
85
|
+
sessionId: row.session_id,
|
|
86
|
+
lastActivity: new Date(row.updated_at).toISOString(),
|
|
87
|
+
checkpointBytes: Number(row.bytes),
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
72
90
|
async save(checkpoint: Checkpoint): Promise<void> {
|
|
73
91
|
const c = await getClient();
|
|
74
92
|
await c.query(
|
|
@@ -100,6 +118,9 @@ async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
|
|
|
100
118
|
|
|
101
119
|
let draining = false;
|
|
102
120
|
let inFlight = 0;
|
|
121
|
+
// Turns in flight per session, so GET /sessions can tell an active session from an idle one.
|
|
122
|
+
const activeTurns = new Map<string, number>();
|
|
123
|
+
const SESSION_LIST_LIMIT = 500;
|
|
103
124
|
|
|
104
125
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
105
126
|
res.statusCode = status;
|
|
@@ -139,12 +160,19 @@ const server = createServer(async (req, res) => {
|
|
|
139
160
|
}
|
|
140
161
|
const body = await readJson(req);
|
|
141
162
|
const input = String(body.input ?? '');
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
activeTurns.set(sessionId, (activeTurns.get(sessionId) ?? 0) + 1);
|
|
164
|
+
try {
|
|
165
|
+
const checkpoint = await store.load(sessionId);
|
|
166
|
+
checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
|
|
167
|
+
const reply = await runTurn(checkpoint, input);
|
|
168
|
+
checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
|
|
169
|
+
await store.save(checkpoint);
|
|
170
|
+
return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
|
|
171
|
+
} finally {
|
|
172
|
+
const left = (activeTurns.get(sessionId) ?? 1) - 1;
|
|
173
|
+
if (left > 0) activeTurns.set(sessionId, left);
|
|
174
|
+
else activeTurns.delete(sessionId);
|
|
175
|
+
}
|
|
148
176
|
}
|
|
149
177
|
|
|
150
178
|
if (url.pathname === '/session' && req.method === 'GET') {
|
|
@@ -154,6 +182,17 @@ const server = createServer(async (req, res) => {
|
|
|
154
182
|
return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
|
|
155
183
|
}
|
|
156
184
|
|
|
185
|
+
// Read-only list of the stored sessions, most recent first. status is 'active' while this instance runs a turn
|
|
186
|
+
// for the session, otherwise 'idle'. total counts every stored session, also those past the limit.
|
|
187
|
+
if (url.pathname === '/sessions' && req.method === 'GET') {
|
|
188
|
+
const all = (await store.list()).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
189
|
+
const sessions = all.slice(0, SESSION_LIST_LIMIT).map((s) => ({
|
|
190
|
+
...s,
|
|
191
|
+
status: activeTurns.has(s.sessionId) ? 'active' : 'idle',
|
|
192
|
+
}));
|
|
193
|
+
return sendJson(res, 200, { sessions, total: all.length });
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
return sendJson(res, 404, { error: 'not_found' });
|
|
158
197
|
} catch (error) {
|
|
159
198
|
console.error('[agent] request failed:', error);
|
|
@@ -189,7 +228,7 @@ process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
189
228
|
`;
|
|
190
229
|
}
|
|
191
230
|
return `import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
192
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
|
|
231
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, existsSync } from 'node:fs';
|
|
193
232
|
import { join } from 'node:path';
|
|
194
233
|
|
|
195
234
|
// Contract with Flisk (see docs/agents/ai-agents.md):
|
|
@@ -208,6 +247,13 @@ interface Checkpoint {
|
|
|
208
247
|
messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
|
|
209
248
|
}
|
|
210
249
|
|
|
250
|
+
// One row of GET /sessions: what is stored, not what is in it, so listing never reads a whole conversation.
|
|
251
|
+
interface SessionSummary {
|
|
252
|
+
sessionId: string;
|
|
253
|
+
lastActivity: string;
|
|
254
|
+
checkpointBytes: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
211
257
|
// Session store. files: one JSON checkpoint per session, written atomically (temp file, then rename), so a kill
|
|
212
258
|
// mid-write leaves the previous checkpoint intact.
|
|
213
259
|
const store = {
|
|
@@ -223,6 +269,20 @@ const store = {
|
|
|
223
269
|
const file = join(STATE_DIR, sessionId + '.json');
|
|
224
270
|
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { sessionId, messages: [] };
|
|
225
271
|
},
|
|
272
|
+
list(): SessionSummary[] {
|
|
273
|
+
if (!existsSync(STATE_DIR)) return [];
|
|
274
|
+
// Only finished checkpoints: an in-progress write is still a .tmp file.
|
|
275
|
+
return readdirSync(STATE_DIR)
|
|
276
|
+
.filter((file) => file.endsWith('.json'))
|
|
277
|
+
.flatMap((file) => {
|
|
278
|
+
try {
|
|
279
|
+
const stat = statSync(join(STATE_DIR, file));
|
|
280
|
+
return [{ sessionId: file.slice(0, -'.json'.length), lastActivity: stat.mtime.toISOString(), checkpointBytes: stat.size }];
|
|
281
|
+
} catch {
|
|
282
|
+
return []; // removed between the directory read and the stat
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
},
|
|
226
286
|
save(checkpoint: Checkpoint): void {
|
|
227
287
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
228
288
|
const file = join(STATE_DIR, checkpoint.sessionId + '.json');
|
|
@@ -241,6 +301,9 @@ async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
|
|
|
241
301
|
|
|
242
302
|
let draining = false;
|
|
243
303
|
let inFlight = 0;
|
|
304
|
+
// Turns in flight per session, so GET /sessions can tell an active session from an idle one.
|
|
305
|
+
const activeTurns = new Map<string, number>();
|
|
306
|
+
const SESSION_LIST_LIMIT = 500;
|
|
244
307
|
|
|
245
308
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
246
309
|
res.statusCode = status;
|
|
@@ -280,12 +343,19 @@ const server = createServer(async (req, res) => {
|
|
|
280
343
|
}
|
|
281
344
|
const body = await readJson(req);
|
|
282
345
|
const input = String(body.input ?? '');
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
346
|
+
activeTurns.set(sessionId, (activeTurns.get(sessionId) ?? 0) + 1);
|
|
347
|
+
try {
|
|
348
|
+
const checkpoint = await store.load(sessionId);
|
|
349
|
+
checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
|
|
350
|
+
const reply = await runTurn(checkpoint, input);
|
|
351
|
+
checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
|
|
352
|
+
await store.save(checkpoint);
|
|
353
|
+
return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
|
|
354
|
+
} finally {
|
|
355
|
+
const left = (activeTurns.get(sessionId) ?? 1) - 1;
|
|
356
|
+
if (left > 0) activeTurns.set(sessionId, left);
|
|
357
|
+
else activeTurns.delete(sessionId);
|
|
358
|
+
}
|
|
289
359
|
}
|
|
290
360
|
|
|
291
361
|
if (url.pathname === '/session' && req.method === 'GET') {
|
|
@@ -295,6 +365,17 @@ const server = createServer(async (req, res) => {
|
|
|
295
365
|
return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
|
|
296
366
|
}
|
|
297
367
|
|
|
368
|
+
// Read-only list of the stored sessions, most recent first. status is 'active' while this instance runs a turn
|
|
369
|
+
// for the session, otherwise 'idle'. total counts every stored session, also those past the limit.
|
|
370
|
+
if (url.pathname === '/sessions' && req.method === 'GET') {
|
|
371
|
+
const all = (await store.list()).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
372
|
+
const sessions = all.slice(0, SESSION_LIST_LIMIT).map((s) => ({
|
|
373
|
+
...s,
|
|
374
|
+
status: activeTurns.has(s.sessionId) ? 'active' : 'idle',
|
|
375
|
+
}));
|
|
376
|
+
return sendJson(res, 200, { sessions, total: all.length });
|
|
377
|
+
}
|
|
378
|
+
|
|
298
379
|
return sendJson(res, 404, { error: 'not_found' });
|
|
299
380
|
} catch (error) {
|
|
300
381
|
console.error('[agent] request failed:', error);
|
|
@@ -1,57 +1,64 @@
|
|
|
1
1
|
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import * as http from "node:http";
|
|
4
3
|
import chalk from "chalk";
|
|
5
4
|
import { Command } from "commander";
|
|
5
|
+
import { formatAge } from "../utils/agent-format.js";
|
|
6
|
+
import { agentSessionListRequest, agentSessionRequest, fetchAgent, parseAgentSessionList, } from "../utils/agent-sessions.js";
|
|
6
7
|
import { createDiscoveryContext } from "../utils/discovery-context.js";
|
|
7
8
|
import { errorFactories, FliskError, runCommand } from "../utils/errors.js";
|
|
9
|
+
import { formatBytes } from "../utils/formatting.js";
|
|
8
10
|
import { getHostPortPlan } from "../utils/host-port-config.js";
|
|
9
11
|
import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
|
|
10
12
|
import { findProjectRoot } from "../utils/paths.js";
|
|
11
13
|
import { buildServicePorts } from "../utils/status-ports.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* Agent containers publish no host port; the only way in is the Traefik route that `flisk status` prints as `url`
|
|
35
|
-
* (for example http://api.<project>.localhost:8080/api/bot). `*.localhost` does not resolve everywhere (glibc without
|
|
36
|
-
* systemd-resolved), so connect to loopback and keep the route's host in the Host header, which is all Traefik matches on.
|
|
37
|
-
*/
|
|
38
|
-
export function agentSessionRequest(routeUrl, sessionId) {
|
|
39
|
-
const route = new URL(routeUrl);
|
|
40
|
-
const headers = { "x-session-id": sessionId };
|
|
41
|
-
if (route.hostname.endsWith(".localhost")) {
|
|
42
|
-
headers.host = route.host;
|
|
43
|
-
route.hostname = "127.0.0.1";
|
|
14
|
+
export { formatAge } from "../utils/agent-format.js";
|
|
15
|
+
export { agentSessionRequest, httpGet } from "../utils/agent-sessions.js";
|
|
16
|
+
const WATCH_INTERVAL_MS = 2_000;
|
|
17
|
+
/** The plain-text table `flisk agent sessions <resource>` prints, one line per session. */
|
|
18
|
+
export function formatSessionTable(list, now = Date.now()) {
|
|
19
|
+
if (list.sessions.length === 0)
|
|
20
|
+
return ["No sessions yet."];
|
|
21
|
+
const rows = list.sessions.map((s) => [
|
|
22
|
+
s.sessionId,
|
|
23
|
+
formatAge(s.lastActivity, now),
|
|
24
|
+
s.status,
|
|
25
|
+
s.checkpointBytes === null ? "-" : formatBytes(s.checkpointBytes),
|
|
26
|
+
]);
|
|
27
|
+
const header = ["SESSION", "LAST ACTIVITY", "STATUS", "CHECKPOINT"];
|
|
28
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
29
|
+
const line = (cells) => cells
|
|
30
|
+
.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i])))
|
|
31
|
+
.join(" ")
|
|
32
|
+
.trimEnd();
|
|
33
|
+
const lines = [line(header), ...rows.map(line)];
|
|
34
|
+
if (list.total > list.sessions.length) {
|
|
35
|
+
lines.push(`... ${list.total - list.sessions.length} more not shown`);
|
|
44
36
|
}
|
|
45
|
-
return
|
|
37
|
+
return lines;
|
|
46
38
|
}
|
|
47
39
|
export const agentCommand = new Command("agent").description("Inspect and operate AI agent resources");
|
|
48
40
|
agentCommand
|
|
49
41
|
.command("sessions <resource>")
|
|
50
|
-
.description("
|
|
51
|
-
.option("--session-id <id>", "Session ID to fetch
|
|
42
|
+
.description("List the sessions of a running AI agent resource, or inspect one session's checkpoint")
|
|
43
|
+
.option("--session-id <id>", "Session ID to fetch; without it, list all sessions")
|
|
44
|
+
.option("--watch", "Refresh the output every 2 seconds until interrupted", false)
|
|
52
45
|
.option("--json", "Output one JSON object and exit", false)
|
|
53
46
|
.action(async (resourceName, options) => {
|
|
47
|
+
// JSON mode prints its envelope and exits 1; human mode throws, and runCommand prints the message and hints.
|
|
48
|
+
const fail = ({ code, message, hints }) => {
|
|
49
|
+
if (options.json) {
|
|
50
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [{ code, message }])));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
throw new FliskError(message, hints);
|
|
54
|
+
};
|
|
54
55
|
const action = async () => {
|
|
56
|
+
if (options.watch && options.json) {
|
|
57
|
+
fail({
|
|
58
|
+
code: "USAGE",
|
|
59
|
+
message: "--watch refreshes a table on the terminal and cannot be combined with --json.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
55
62
|
const root = findProjectRoot();
|
|
56
63
|
if (!root) {
|
|
57
64
|
const error = errorFactories.notInProject();
|
|
@@ -62,112 +69,126 @@ agentCommand
|
|
|
62
69
|
const discovery = createDiscoveryContext();
|
|
63
70
|
const resource = discovery.resources.find((r) => r.name === resourceName);
|
|
64
71
|
if (!resource) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw new FliskError(msg);
|
|
72
|
+
fail({
|
|
73
|
+
code: "UNKNOWN_RESOURCE",
|
|
74
|
+
message: `Resource "${resourceName}" not found in project.`,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
71
77
|
}
|
|
72
78
|
const appType = resource.config?.appType ?? resource.type;
|
|
73
79
|
if (appType !== "agent") {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
throw new FliskError(msg);
|
|
80
|
+
fail({
|
|
81
|
+
code: "NOT_AN_AGENT",
|
|
82
|
+
message: `Resource "${resourceName}" is of type "${appType}", not "agent".`,
|
|
83
|
+
});
|
|
80
84
|
}
|
|
81
85
|
const port = resource.port;
|
|
82
86
|
if (!port) {
|
|
83
|
-
|
|
84
|
-
if (options.json) {
|
|
85
|
-
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NO_PORT", message: msg }])));
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
|
-
throw new FliskError(msg);
|
|
89
|
-
}
|
|
90
|
-
// The agent runtime keys GET /session by x-session-id and has no list endpoint, so a missing id
|
|
91
|
-
// would only come back as a confusing 400. Fail before any request. Checked after resource
|
|
92
|
-
// validation so UNKNOWN_RESOURCE, NOT_AN_AGENT and NO_PORT keep their precedence.
|
|
93
|
-
const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : "";
|
|
94
|
-
if (!sessionId) {
|
|
95
|
-
const msg = `--session-id is required: the agent "${resourceName}" returns the checkpoint of one session.`;
|
|
96
|
-
if (options.json) {
|
|
97
|
-
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "SESSION_ID_REQUIRED", message: msg }])));
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
throw new FliskError(msg, [`Run: flisk agent sessions ${resourceName} --session-id <id>`]);
|
|
87
|
+
fail({ code: "NO_PORT", message: `Agent "${resourceName}" has no assigned port.` });
|
|
101
88
|
}
|
|
102
89
|
const portPlan = root
|
|
103
90
|
? await getHostPortPlan(root, { inspectDocker: false }).catch(() => null)
|
|
104
91
|
: null;
|
|
105
92
|
const routeUrl = buildServicePorts(resource, portPlan?.ingressHttp).url;
|
|
106
93
|
if (!routeUrl) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new FliskError(msg);
|
|
94
|
+
fail({
|
|
95
|
+
code: "NO_ROUTE",
|
|
96
|
+
message: `Agent "${resourceName}" has no ingress route to reach it through.`,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
113
99
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const notRunning =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
100
|
+
// With an id: the one checkpoint that GET /session returns. Without: the list from GET /sessions.
|
|
101
|
+
const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : "";
|
|
102
|
+
const notRunning = {
|
|
103
|
+
code: "AGENT_NOT_RUNNING",
|
|
104
|
+
message: `Agent "${resourceName}" is not responding at ${routeUrl}. Is the stack running?`,
|
|
105
|
+
hints: ["Start the stack with: flisk up"],
|
|
106
|
+
};
|
|
107
|
+
const requestFailed = (reply) => ({
|
|
108
|
+
code: "AGENT_REQUEST_FAILED",
|
|
109
|
+
message: `Agent returned ${reply.status}: ${reply.detail}`,
|
|
110
|
+
});
|
|
111
|
+
/** One request and its rendering: the data for --json, or the lines a human sees. */
|
|
112
|
+
const load = async () => {
|
|
113
|
+
if (sessionId) {
|
|
114
|
+
const reply = await fetchAgent(agentSessionRequest(routeUrl, sessionId));
|
|
115
|
+
if (reply.kind === "not-running")
|
|
116
|
+
return { ok: false, failure: notRunning };
|
|
117
|
+
if (reply.kind === "not-found") {
|
|
118
|
+
const detail = reply.body?.error;
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
failure: requestFailed({
|
|
122
|
+
kind: "error",
|
|
123
|
+
status: 404,
|
|
124
|
+
detail: typeof detail === "string" ? detail : JSON.stringify(reply.body),
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (reply.kind === "error")
|
|
129
|
+
return { ok: false, failure: requestFailed(reply) };
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
data: { agent: resourceName, port, session: reply.body },
|
|
133
|
+
lines: [
|
|
134
|
+
chalk.bold(`Sessions for ${resourceName}:`),
|
|
135
|
+
JSON.stringify(reply.body, null, 2),
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const reply = await fetchAgent(agentSessionListRequest(routeUrl));
|
|
140
|
+
if (reply.kind === "not-running")
|
|
141
|
+
return { ok: false, failure: notRunning };
|
|
142
|
+
// The agent answered, but not for /sessions: it was scaffolded before the list endpoint existed.
|
|
143
|
+
const list = reply.kind === "ok" ? parseAgentSessionList(reply.body) : null;
|
|
144
|
+
if (reply.kind === "not-found" || (reply.kind === "ok" && !list)) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
failure: {
|
|
148
|
+
code: "SESSION_LIST_UNSUPPORTED",
|
|
149
|
+
message: `Agent "${resourceName}" does not serve GET /sessions; it was created before Flisk could list agent sessions.`,
|
|
150
|
+
hints: [
|
|
151
|
+
`Read one session with: flisk agent sessions ${resourceName} --session-id <id>`,
|
|
152
|
+
"Or copy the /sessions handler from a new scaffold (flisk resource <name> --type agent) into the agent's src/index.ts",
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
};
|
|
121
156
|
}
|
|
122
|
-
|
|
157
|
+
if (reply.kind === "error")
|
|
158
|
+
return { ok: false, failure: requestFailed(reply) };
|
|
159
|
+
const sessions = list;
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
data: { agent: resourceName, port, sessions: sessions.sessions, total: sessions.total },
|
|
163
|
+
lines: [chalk.bold(`Sessions for ${resourceName}:`), ...formatSessionTable(sessions)],
|
|
164
|
+
};
|
|
123
165
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return notRunning();
|
|
133
|
-
}
|
|
134
|
-
let body = {};
|
|
135
|
-
try {
|
|
136
|
-
body = bodyText ? JSON.parse(bodyText) : {};
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
body = { raw: bodyText };
|
|
140
|
-
}
|
|
141
|
-
// Traefik answers 502/503/504 when the route has no live container and a plain-text 404 when it has no route at
|
|
142
|
-
// all. An agent's own errors are JSON, so a non-JSON 404 is the gateway, not the agent.
|
|
143
|
-
const gatewayText = typeof body === "object" && body !== null && "raw" in body;
|
|
144
|
-
if (!res.ok &&
|
|
145
|
-
([502, 503, 504].includes(res.status) || (res.status === 404 && gatewayText))) {
|
|
146
|
-
return notRunning();
|
|
147
|
-
}
|
|
148
|
-
if (!res.ok) {
|
|
149
|
-
const detail = body?.error ?? bodyText;
|
|
166
|
+
if (!options.watch) {
|
|
167
|
+
const result = await load();
|
|
168
|
+
if (!result.ok) {
|
|
169
|
+
fail(result.failure);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
150
172
|
if (options.json) {
|
|
151
|
-
console.log(JSON.stringify(createMachineEnvelope(
|
|
152
|
-
|
|
153
|
-
code: "AGENT_REQUEST_FAILED",
|
|
154
|
-
message: `Agent returned ${res.status}: ${detail}`,
|
|
155
|
-
},
|
|
156
|
-
])));
|
|
157
|
-
process.exit(1);
|
|
173
|
+
console.log(JSON.stringify(createMachineEnvelope(result.data)));
|
|
174
|
+
return;
|
|
158
175
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (options.json) {
|
|
162
|
-
console.log(JSON.stringify(createMachineEnvelope({
|
|
163
|
-
agent: resourceName,
|
|
164
|
-
port,
|
|
165
|
-
session: body,
|
|
166
|
-
})));
|
|
176
|
+
for (const line of result.lines)
|
|
177
|
+
console.log(line);
|
|
167
178
|
return;
|
|
168
179
|
}
|
|
169
|
-
|
|
170
|
-
|
|
180
|
+
// --watch: redraw on a timer. A failed refresh (the agent restarting, say) is shown and retried, not fatal.
|
|
181
|
+
for (;;) {
|
|
182
|
+
const result = await load();
|
|
183
|
+
const lines = result.ok
|
|
184
|
+
? result.lines
|
|
185
|
+
: [chalk.red(result.failure.message), ...(result.failure.hints ?? [])];
|
|
186
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
187
|
+
for (const line of lines)
|
|
188
|
+
console.log(line);
|
|
189
|
+
console.log(chalk.dim(`\nRefreshing every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.`));
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, WATCH_INTERVAL_MS));
|
|
191
|
+
}
|
|
171
192
|
};
|
|
172
193
|
if (options.json) {
|
|
173
194
|
try {
|
|
@@ -14,6 +14,7 @@ import { AUTOGENERATED_DIR, CONTAINER_RUNTIME_CHECK, MASTER_CONFIG_FILES, PACKAG
|
|
|
14
14
|
import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
|
|
15
15
|
import { checkCrashLoopingContainers } from "../utils/doctor-crashloop.js";
|
|
16
16
|
import { applyDoctorFixes, checkDuplicateFliskInstalls, hasFailedActions, planDoctorFixes, projectTiltPids, } from "../utils/doctor-fix.js";
|
|
17
|
+
import { formatFixFirst, rankDoctorFixes } from "../utils/doctor-fix-first.js";
|
|
17
18
|
import { checkDiskSpace, dockerDesktopStuck } from "../utils/doctor-host.js";
|
|
18
19
|
import { checkNativeCommands, checkNativeDependsOn, checkNativeRuntime, checkNativeServiceHealth, } from "../utils/doctor-native.js";
|
|
19
20
|
import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
|
|
@@ -1216,6 +1217,15 @@ function printDoctorResults(list) {
|
|
|
1216
1217
|
}
|
|
1217
1218
|
export function renderDoctorOutput(machineResults, projectResults, inProject, options = {}, allResults) {
|
|
1218
1219
|
const results = allResults ?? [...machineResults, ...projectResults];
|
|
1220
|
+
const printFixFirst = () => {
|
|
1221
|
+
const lines = formatFixFirst(rankDoctorFixes(results));
|
|
1222
|
+
if (lines.length === 0)
|
|
1223
|
+
return;
|
|
1224
|
+
console.log(chalk.bold("Fix first"));
|
|
1225
|
+
for (const line of lines)
|
|
1226
|
+
console.log(line);
|
|
1227
|
+
console.log("");
|
|
1228
|
+
};
|
|
1219
1229
|
if (options.all) {
|
|
1220
1230
|
console.log(chalk.bold("Machine"));
|
|
1221
1231
|
printDoctorResults(machineResults);
|
|
@@ -1230,10 +1240,12 @@ export function renderDoctorOutput(machineResults, projectResults, inProject, op
|
|
|
1230
1240
|
}
|
|
1231
1241
|
console.log("");
|
|
1232
1242
|
console.log(formatDoctorSummary(results));
|
|
1243
|
+
printFixFirst();
|
|
1233
1244
|
}
|
|
1234
1245
|
else {
|
|
1235
1246
|
printDoctorResults(results);
|
|
1236
1247
|
console.log("");
|
|
1248
|
+
printFixFirst();
|
|
1237
1249
|
const allPassed = results.every((check) => check.didPass || check.isSkipped || check.isWarning);
|
|
1238
1250
|
if (allPassed && !inProject) {
|
|
1239
1251
|
console.log(`${chalk.gray("○")} ${chalk.gray("Not in a Flisk project, so project checks were skipped")}`);
|