@flisk-sh/flisk 1.4.5 → 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 +37 -14
- package/cli/dist/agent-runtimes/custom.js +94 -13
- package/cli/dist/commands/agent.js +144 -123
- package/cli/dist/commands/doctor.js +62 -12
- package/cli/dist/commands/down.js +33 -1
- package/cli/dist/commands/logs.js +107 -3
- package/cli/dist/commands/project.js +27 -0
- package/cli/dist/commands/resource.js +3 -1
- package/cli/dist/commands/status.js +82 -34
- package/cli/dist/commands/ui.js +41 -2
- package/cli/dist/commands/up.js +173 -12
- package/cli/dist/commands/upgrade.js +2 -2
- 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/bun-requirement.js +7 -4
- package/cli/dist/utils/cold-preflight.js +55 -32
- package/cli/dist/utils/doctor-fix-first.js +93 -0
- package/cli/dist/utils/doctor-native.js +96 -0
- package/cli/dist/utils/doctor-report.js +3 -0
- package/cli/dist/utils/native-project.js +41 -0
- package/cli/dist/utils/native-runtime.js +516 -0
- package/cli/dist/utils/native-service.js +95 -0
- package/cli/dist/utils/native-up.js +156 -0
- package/cli/dist/utils/platform.js +1 -1
- package/cli/dist/utils/project-templates.js +1 -1
- package/cli/dist/utils/service-manifest.js +10 -1
- package/cli/dist/utils/services.js +11 -1
- package/cli/dist/utils/startup-report.js +45 -3
- package/cli/dist/utils/status-groups.js +23 -0
- package/cli/dist/utils/tilt-logs.js +8 -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-Z5QEE4SW.js → chunk-6OW2GVQV.js} +1 -1
- package/cli/dist-bundle/chunk-B2JOM3HN.js +35 -0
- package/cli/dist-bundle/{chunk-VMVTECCK.js → chunk-F2DLIM75.js} +1 -1
- package/cli/dist-bundle/chunk-L6O7TT3K.js +18 -0
- package/cli/dist-bundle/chunk-MFY7RZ76.js +130 -0
- package/cli/dist-bundle/{chunk-SFOFTVYM.js → chunk-MYAYJF4Q.js} +1 -1
- package/cli/dist-bundle/{chunk-LUUJ4JB7.js → chunk-P7GZ6NGM.js} +1 -1
- package/cli/dist-bundle/{chunk-BP2M4OJ4.js → chunk-QEBN6ZVQ.js} +1 -1
- package/cli/dist-bundle/{chunk-4J2FLFUA.js → chunk-T44DEEL6.js} +1 -1
- package/cli/dist-bundle/{chunk-U42XZWXB.js → chunk-TIPSERWO.js} +1 -1
- package/cli/dist-bundle/{chunk-V36PT2HF.js → chunk-VYPB7MY5.js} +1 -1
- package/cli/dist-bundle/{chunk-TIYTGN5R.js → chunk-XHENT4KO.js} +1 -1
- package/cli/dist-bundle/{chunk-CGTWXWBU.js → chunk-YTBTCAWY.js} +1 -1
- package/cli/dist-bundle/cli.js +486 -396
- package/cli/dist-bundle/cold-preflight-DQVJUGBB.js +2 -0
- package/cli/dist-bundle/{demo-stage-G4W23BXW.js → demo-stage-F4YA7UL2.js} +1 -1
- package/cli/dist-bundle/{open-HQFT6WDC.js → open-ZUSSVPD3.js} +1 -1
- package/cli/dist-bundle/project-JFCTPRP5.js +2 -0
- package/cli/dist-bundle/{project-templates-IMJ3OX5I.js → project-templates-2VQPUBSQ.js} +1 -1
- package/cli/dist-bundle/{services-GLFKZBBZ.js → services-DOHDHOCT.js} +1 -1
- package/cli/dist-bundle/{tilt-port-owner-PJN4MPYE.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/examples/{tdk-example → flisk-example}/README.md +2 -2
- package/package.json +2 -2
- package/runtime/discovery/AGENTS.md +1 -1
- package/runtime/discovery/TILT_DISCOVERY.star +1 -1
- package/runtime/discovery/deterministic.star +1 -1
- package/runtime/engine/AGENTS.md +3 -3
- package/runtime/engine/README.md +1 -1
- package/runtime/engine/REFACTORING_MANIFEST_SYSTEM.md +1 -1
- package/runtime/engine/assets/docker/wake-gateway/decide.ts +1 -1
- package/runtime/engine/assets/docker/wake-gateway/server.ts +2 -2
- package/runtime/engine/assets/scripts/mac-m1-resource-monitor.sh +6 -6
- package/runtime/engine/schemas/service-schema.json +87 -21
- package/runtime/engine/scripts/debug-container.sh +1 -1
- package/runtime/engine/scripts/generate-manifest.sh +2 -2
- package/runtime/engine/topologies/platform/docker/compose/traefik_standalone.star +19 -4
- package/runtime/engine/topologies/platform/docker/constants.star +2 -2
- package/runtime/engine/topologies/platform/docker/networking/traefik_static_routes.star +2 -2
- package/runtime/engine/topologies/tilt/manifest/mapper.star +1 -1
- package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +8 -2
- package/runtime/ext/ide-components/README.md +1 -1
- package/runtime/ext/ui-enhancements/extension.yaml +2 -2
- package/runtime/specs/specs/TILT_SERVICE_DEFAULTS.star +1 -1
- package/runtime/specs/specs/TILT_TECH_STACK.star +1 -1
- package/cli/dist-bundle/chunk-BMHPPX2Q.js +0 -35
- package/cli/dist-bundle/chunk-PEZQEZVV.js +0 -17
- package/cli/dist-bundle/chunk-ULMQVD7J.js +0 -122
- package/cli/dist-bundle/cold-preflight-3BNDOLGS.js +0 -2
- package/cli/dist-bundle/project-JZO2CPZZ.js +0 -2
- /package/examples/{tdk-example → flisk-example}/LICENSE +0 -0
- /package/examples/{tdk-example → flisk-example}/TILT_SERVICE_DEFAULTS.star +0 -0
- /package/examples/{tdk-example → flisk-example}/TILT_TECH_STACK.star +0 -0
- /package/examples/{tdk-example → flisk-example}/Tiltfile +0 -0
- /package/examples/{tdk-example → flisk-example}/services/platform/messaging/docker-compose.yml +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/src/index.ts +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/index.html +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/public/.gitkeep +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/main.tsx +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/orders.tsx +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/style.css +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/src/index.ts +0 -0
package/README.md
CHANGED
|
@@ -114,13 +114,13 @@ flisk resource shop-web --type frontend --stack shop --yes
|
|
|
114
114
|
flisk up shop
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
You get an orders API and a storefront behind one local gateway, with Postgres and live reload. You need Docker, [Tilt](https://docs.tilt.dev/install.html), Node.js 22.12+, and [Bun](https://bun.sh/) 1.2+; `flisk doctor` checks your machine. Prefer to answer questions instead? `npm init create-microservices my-project` asks what to create. See the [quick start](#quick-start) for every option.
|
|
117
|
+
You get an orders API and a storefront behind one local gateway, with Postgres and live reload. You need Docker, [Tilt](https://docs.tilt.dev/install.html), Node.js 22.12+, and [Bun](https://bun.sh/) 1.2+; `flisk doctor` checks your machine. A simpler app needs none of those: give each service a `command` and [run it as local processes](docs/run-without-docker.md). Prefer to answer questions instead? `npm init create-microservices my-project` asks what to create. See the [quick start](#quick-start) for every option.
|
|
118
118
|
|
|
119
119
|
<div align="center">
|
|
120
120
|
|
|
121
121
|
<p><strong><a href="#quick-start">Quick start</a> · <a href="#why-flisk-cli">Why Flisk</a> · <a href="#examples">Examples</a> · <a href="#installation">Installation</a> · <a href="#faq">FAQ</a> · <a href="#docs">Documentation</a></strong></p>
|
|
122
122
|
|
|
123
|
-
[Website](https://flisk.sh/) · [Quickstart](https://flisk.sh/docs/quickstart/) · [Examples](https://flisk.sh/docs/examples/) · [Awesome Flisk](https://github.com/flisk-sh/awesome-
|
|
123
|
+
[Website](https://flisk.sh/) · [Quickstart](https://flisk.sh/docs/quickstart/) · [Examples](https://flisk.sh/docs/examples/) · [Awesome Flisk](https://github.com/flisk-sh/awesome-flisk-framework) · [Demo](https://flisk-sh.github.io/flisk-demo-animation/) · [Report a bug](https://github.com/flisk-sh/flisk/issues)
|
|
124
124
|
|
|
125
125
|
<a id="readme-languages"></a>
|
|
126
126
|
|
|
@@ -245,7 +245,7 @@ flisk up shop
|
|
|
245
245
|
Agents get durable checkpoint volumes that survive `flisk down`, sticky session routing via `x-session-id`, graceful SIGTERM drain during hot reloads, a Docker health check on `/health`, and application-level readiness checks on `/ready`. See the [AI agents guide](docs/agents/ai-agents.md) and [honest comparison with agent frameworks](docs/compare-honest.md#ai-agent-frameworks-and-workflow-engines-libfx-langgraph-mastra-vercel-workflows-temporal).
|
|
246
246
|
|
|
247
247
|
|
|
248
|
-
**What Flisk is:** a local development runtime. Define each service in `service.json` and run `flisk up`. Docker runs the containers, Tilt live-updates them while you code, and Flisk writes the configuration both use. No local Kubernetes is needed.
|
|
248
|
+
**What Flisk is:** a local development runtime. Define each service in `service.json` and run `flisk up`. A service with a `command` runs as a local process, with nothing else installed. When you need a database, one gateway URL, or the container you deploy, Docker runs the containers, Tilt live-updates them while you code, and Flisk writes the configuration both use. No local Kubernetes is needed.
|
|
249
249
|
|
|
250
250
|
**What Flisk is not:** a deploy tool or a Compose file. Production deployment stays with Helm, Argo CD, or Kustomize.
|
|
251
251
|
|
|
@@ -337,19 +337,42 @@ Get a running local stack in **3 commands**. Works on macOS, Linux, and Windows
|
|
|
337
337
|
|
|
338
338
|
### Prerequisites (install once)
|
|
339
339
|
|
|
340
|
-
| Tool | Version | Install |
|
|
341
|
-
|
|
342
|
-
| **
|
|
343
|
-
| **
|
|
344
|
-
| **
|
|
345
|
-
| **
|
|
340
|
+
| Tool | Version | Install | Needed for |
|
|
341
|
+
|------|---------|---------|------------|
|
|
342
|
+
| **Your app's runtime** | whatever your app uses | Node.js, Python, Go, Bun, ... | Every path: it runs your code |
|
|
343
|
+
| **Node.js** | 22.12+ | [nodejs.org](https://nodejs.org/) | npm install (`npm install -g @flisk-sh/flisk`) |
|
|
344
|
+
| **Docker** | Engine 25+, Compose 2.20.2+ | [docker.com](https://www.docker.com/products/docker-desktop/) | Docker + Tilt path only |
|
|
345
|
+
| **Tilt** | 0.25+ | `curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh \| bash` | Docker + Tilt path only |
|
|
346
|
+
| **Bun** | 1.2+ | `curl -fsSL https://bun.sh/install \| bash` | Bun install (`bun add -g @flisk-sh/flisk`), or generated JavaScript services |
|
|
346
347
|
|
|
347
|
-
> **
|
|
348
|
+
> **Two paths.** A project whose services each declare a `command` runs them as local processes: you need only Flisk and your app's runtime ([simplest path](#simplest-path-no-docker)). Docker and Tilt are needed for the Docker + Tilt path: databases such as Postgres, one gateway URL for every service, and running the same container you deploy. `flisk doctor` checks only what your project uses.
|
|
348
349
|
|
|
349
350
|
> **Windows?** Use WSL2 Ubuntu. See the [Windows & WSL2 guide](docs/wsl2.md) for the recommended setup.
|
|
350
351
|
|
|
351
352
|
---
|
|
352
353
|
|
|
354
|
+
### Simplest path: no Docker
|
|
355
|
+
|
|
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
|
+
|
|
358
|
+
```json
|
|
359
|
+
{ "schemaVersion": 1, "appName": "api", "appType": "backend", "command": "node server.js", "port": 4000, "healthCheckPath": "/health" }
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{ "schemaVersion": 1, "appName": "web", "appType": "frontend", "command": "node server.js", "port": 3000, "dependsOn": ["api"] }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Then start everything from the project folder:
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
flisk up
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Flisk starts the API, waits until it answers, starts the website, prints both URLs, prefixes each line of output with the service name, and restarts a service that crashes. Ctrl+C or `flisk down` stops them. No Docker, no Tilt, no generated files. See [Run your app without Docker](docs/run-without-docker.md) for every field, and for when to switch to the Docker + Tilt path.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
353
376
|
### Fastest path: one-command demo (no manual steps)
|
|
354
377
|
|
|
355
378
|
```bash
|
|
@@ -596,11 +619,11 @@ Runnable examples show Flisk on real stacks. Start with the smallest one:
|
|
|
596
619
|
| [one-backend-helm](examples/one-backend-helm/README.md) | Working alongside Helm |
|
|
597
620
|
| [byo](examples/byo/README.md) | Bringing your own existing resources |
|
|
598
621
|
| [byo-jobs](examples/byo-jobs/README.md) | Bring-your-own jobs |
|
|
599
|
-
| [
|
|
622
|
+
| [flisk-example](examples/flisk-example/README.md) | A full multi-service stack |
|
|
600
623
|
|
|
601
624
|
## Use Flisk with your AI agent
|
|
602
625
|
|
|
603
|
-
The repository ships
|
|
626
|
+
The repository ships agent skills for common Flisk work under [`.agents/skills`](.agents/skills): `flisk-build` (turn "build me a shop" into Flisk steps, check every URL, and report them), `flisk-recipe-starters` (start from a working restaurant, SaaS, store, AI agent or user-management app), recipes for a shop, a SaaS with login and billing, an AI agent with MCP tools and a database, and a blog (`flisk-recipe-shop`, `flisk-recipe-saas`, `flisk-recipe-ai-agent`, `flisk-recipe-blog`), `flisk-doctor` (environment checks), `flisk-up` (preview and start a stack), `flisk-resource` (create a service), `flisk-status` (health and URLs), and `flisk-troubleshoot` (fixing `flisk` and Tilt errors). More prompts to copy, by category (spreadsheets, recruiting, product management, shops, AI agents), are in the [prompt gallery](docs/recipes/build-with-your-agent.md). Agents working in this repo also follow [AGENTS.md](AGENTS.md). Use these skills to let your agent check your setup and explain failures instead of guessing at Docker or Tilt configuration.
|
|
604
627
|
|
|
605
628
|
## Installation
|
|
606
629
|
|
|
@@ -648,7 +671,7 @@ Set `FLISK_DEBUG=1` before running `flisk up --verbose` to include Starlark debu
|
|
|
648
671
|
- [Generated service files and drift checks](docs/generated-files.md)
|
|
649
672
|
- [Runnable one-backend example](examples/one-backend/README.md)
|
|
650
673
|
- [Runnable Python backend example](examples/one-backend-python/README.md)
|
|
651
|
-
- [Full multi-service example](examples/
|
|
674
|
+
- [Full multi-service example](examples/flisk-example/README.md)
|
|
652
675
|
- [Features and license limits](docs/FEATURES.md)
|
|
653
676
|
- [Tool comparison: docker-compose, Tilt, Skaffold, DevSpace, kind](docs/comparison.md)
|
|
654
677
|
- [Honest comparison and known limits](docs/compare-honest.md)
|
|
@@ -686,7 +709,7 @@ On native Windows, `flisk --version`, `flisk doctor`, and `flisk up --dry-run` a
|
|
|
686
709
|
|
|
687
710
|
Core stays free. Premium is a separate key for the extras above; no key required to run `flisk up`.
|
|
688
711
|
|
|
689
|
-
**Ecosystem map:** examples, articles, related tools, and notes live in [awesome-
|
|
712
|
+
**Ecosystem map:** examples, articles, related tools, and notes live in [awesome-flisk-framework](https://github.com/flisk-sh/awesome-flisk-framework). Star this repo (`flisk`) if the CLI is what you run; use the awesome list to browse the rest.
|
|
690
713
|
|
|
691
714
|
<p align="center">
|
|
692
715
|
|
|
@@ -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 {
|