@tdk-landscape/tdk-cli-core 1.3.170 → 1.3.172
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 +14 -0
- package/cli/dist/agent-runtimes/custom.js +333 -0
- package/cli/dist/agent-runtimes/registry.js +34 -0
- package/cli/dist/backend-frameworks/registry.js +16 -0
- package/cli/dist/cli.js +4 -0
- package/cli/dist/commands/agent.js +117 -0
- package/cli/dist/commands/completion.js +5 -1
- package/cli/dist/commands/help.js +7 -0
- package/cli/dist/commands/resource.js +54 -14
- package/cli/dist/commands/restart.js +79 -0
- package/cli/dist/mcp/tools.js +68 -13
- package/cli/dist/types/index.js +2 -0
- package/cli/dist/utils/constants.js +4 -0
- package/cli/dist/utils/doctor-report.js +2 -1
- package/cli/dist/utils/doctor-wiring.js +1 -1
- package/cli/dist/utils/json-output.js +2 -1
- package/cli/dist/utils/machine-output.js +2 -1
- package/cli/dist/utils/project-templates.js +4 -0
- package/cli/dist/utils/resource-kind.js +1 -1
- package/cli/dist/utils/service-manifest.js +1 -0
- package/cli/dist/utils/status-ports.js +3 -1
- package/cli/dist-bundle/{chunk-MZBDLTO2.js → chunk-JF2COU6K.js} +69 -69
- package/cli/dist-bundle/cli.js +602 -282
- package/cli/dist-bundle/{cold-preflight-PZKLJJGV.js → cold-preflight-GVGTFUDZ.js} +1 -1
- package/cli/package.json +1 -1
- package/engine/schemas/service-schema.json +26 -1
- package/engine/topologies/platform/docker/compose/compose.star +31 -3
- package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +8 -0
- package/engine/topologies/tilt/manifest/loader.star +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,19 @@ No vendor lock-in. No tedious plumbing. Run a single command to natively orchest
|
|
|
28
28
|
* **Databases & Tooling:** Prisma, Drizzle, Atlas, Dbmate
|
|
29
29
|
* **Messaging & Gateway:** NATS, Traefik
|
|
30
30
|
|
|
31
|
+
### 🤖 Run your AI agents next to your services
|
|
32
|
+
|
|
33
|
+
TDK is the local runtime for agent *systems*: the agent, its MCP tools, its database, its queue, behind one gateway, one command.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Scaffold an agent, an MCP tools server, and a database in one stack
|
|
37
|
+
tdk resource bot --type agent --stack shop --yes
|
|
38
|
+
tdk resource tools-mcp --type mcp --stack shop --yes
|
|
39
|
+
tdk up shop
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Agents get durable checkpoint volumes that survive `tdk 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).
|
|
43
|
+
|
|
31
44
|
|
|
32
45
|
TDK CLI — start services on your machine. It is not a deploy and not a Compose file: define each service in `service.json`, then run `tdk up`. No Kubernetes is needed on the machine.
|
|
33
46
|
|
|
@@ -128,6 +141,7 @@ Set `TDK_DEBUG=1` before running `tdk up --verbose` to include Starlark debug lo
|
|
|
128
141
|
- [Mega cheat sheet: every command and flag](docs/cheat-sheet.md)
|
|
129
142
|
- [Documentation index](docs/README.md)
|
|
130
143
|
- [Pilot TDK on a real repository](docs/adopt-tdk.md)
|
|
144
|
+
- [Running AI agents in TDK](docs/agents/ai-agents.md)
|
|
131
145
|
- [FAQ for agencies and multi-client teams](docs/faq-teams.md)
|
|
132
146
|
- [Working alongside Helm](docs/with-helm.md)
|
|
133
147
|
- [Scope: local development, and what does not carry over to production](docs/scope.md)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `custom` agent runtime: a plain Bun HTTP service with no model SDK, no agent framework and no vendor dependency.
|
|
3
|
+
* It is the escape hatch the agent resource type is built on. Replace the `runTurn` body with your model call and tools;
|
|
4
|
+
* the process contract around it (health, readiness, session checkpoints, SIGTERM drain) is what TDK relies on.
|
|
5
|
+
*/
|
|
6
|
+
export function getCustomAgentIndexTemplate(name, durability = "files") {
|
|
7
|
+
if (durability === "postgres") {
|
|
8
|
+
return `import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
9
|
+
import { Client } from 'pg';
|
|
10
|
+
|
|
11
|
+
// Contract with TDK (see docs/agents/ai-agents.md):
|
|
12
|
+
// AGENT_DURABILITY postgres
|
|
13
|
+
// DATABASE_URL Postgres connection string injected by TDK
|
|
14
|
+
// PORT port to listen on
|
|
15
|
+
// /health: the process is alive. /ready: the checkpoint store is reachable. Idle is healthy, so /health never checks the store.
|
|
16
|
+
|
|
17
|
+
const DURABILITY = 'postgres';
|
|
18
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
19
|
+
const DRAIN_TIMEOUT_MS = 10_000;
|
|
20
|
+
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/postgres';
|
|
21
|
+
|
|
22
|
+
interface Checkpoint {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let client: Client | null = null;
|
|
28
|
+
let initialized = false;
|
|
29
|
+
|
|
30
|
+
async function getClient(): Promise<Client> {
|
|
31
|
+
if (client) return client;
|
|
32
|
+
const c = new Client({ connectionString: DATABASE_URL });
|
|
33
|
+
await c.connect();
|
|
34
|
+
if (!initialized) {
|
|
35
|
+
await c.query(\`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS tdk_agent_sessions (
|
|
37
|
+
session_id VARCHAR(128) PRIMARY KEY,
|
|
38
|
+
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
39
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
40
|
+
);
|
|
41
|
+
\`);
|
|
42
|
+
initialized = true;
|
|
43
|
+
}
|
|
44
|
+
client = c;
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const store = {
|
|
49
|
+
async ready(): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const c = await getClient();
|
|
52
|
+
await c.query('SELECT 1');
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
client = null;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async load(sessionId: string): Promise<Checkpoint> {
|
|
60
|
+
try {
|
|
61
|
+
const c = await getClient();
|
|
62
|
+
const res = await c.query('SELECT messages FROM tdk_agent_sessions WHERE session_id = $1', [sessionId]);
|
|
63
|
+
if (res.rows.length > 0 && res.rows[0].messages) {
|
|
64
|
+
return { sessionId, messages: res.rows[0].messages };
|
|
65
|
+
}
|
|
66
|
+
return { sessionId, messages: [] };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('[agent] load checkpoint failed:', err);
|
|
69
|
+
return { sessionId, messages: [] };
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async save(checkpoint: Checkpoint): Promise<void> {
|
|
73
|
+
const c = await getClient();
|
|
74
|
+
await c.query(
|
|
75
|
+
\`INSERT INTO tdk_agent_sessions (session_id, messages, updated_at)
|
|
76
|
+
VALUES ($1, $2, NOW())
|
|
77
|
+
ON CONFLICT (session_id) DO UPDATE
|
|
78
|
+
SET messages = EXCLUDED.messages, updated_at = NOW()\`,
|
|
79
|
+
[checkpoint.sessionId, JSON.stringify(checkpoint.messages)],
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
async close(): Promise<void> {
|
|
83
|
+
if (client) {
|
|
84
|
+
try {
|
|
85
|
+
await client.end();
|
|
86
|
+
} catch {}
|
|
87
|
+
client = null;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Replace this with the agent's work: call your model, run tools, return the reply. Keep it a pure function of the
|
|
93
|
+
// checkpoint so a resumed session continues from what was saved.
|
|
94
|
+
async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
|
|
95
|
+
// The caller has already appended this turn's user message, so the turn number is half the message count, rounded up.
|
|
96
|
+
return 'received ' + input + ' (turn ' + Math.ceil(checkpoint.messages.length / 2) + ')';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let draining = false;
|
|
100
|
+
let inFlight = 0;
|
|
101
|
+
|
|
102
|
+
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
103
|
+
res.statusCode = status;
|
|
104
|
+
res.setHeader('content-type', 'application/json');
|
|
105
|
+
res.end(JSON.stringify(body));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readJson(req: IncomingMessage): Promise<any> {
|
|
109
|
+
const chunks: Buffer[] = [];
|
|
110
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
111
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
112
|
+
return raw ? JSON.parse(raw) : {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const server = createServer(async (req, res) => {
|
|
116
|
+
inFlight++;
|
|
117
|
+
res.on('close', () => inFlight--);
|
|
118
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
if (url.pathname === '/health') {
|
|
122
|
+
return sendJson(res, 200, { status: 'ok', service: '${name}', draining });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (url.pathname === '/ready') {
|
|
126
|
+
const ok = !draining && (await store.ready());
|
|
127
|
+
return sendJson(res, ok ? 200 : 503, { ready: ok, durability: DURABILITY });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Session routing: the session id comes from the x-session-id header, so several instances of one agent can
|
|
131
|
+
// share a Traefik route and each session still reaches its own checkpoint.
|
|
132
|
+
if (url.pathname === '/turn' && req.method === 'POST') {
|
|
133
|
+
if (draining) return sendJson(res, 503, { error: 'draining' });
|
|
134
|
+
const sessionId = String(req.headers['x-session-id'] ?? '');
|
|
135
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) {
|
|
136
|
+
return sendJson(res, 400, { error: 'x-session-id header is required (1-128 letters, digits, - or _)' });
|
|
137
|
+
}
|
|
138
|
+
const body = await readJson(req);
|
|
139
|
+
const input = String(body.input ?? '');
|
|
140
|
+
const checkpoint = await store.load(sessionId);
|
|
141
|
+
checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
|
|
142
|
+
const reply = await runTurn(checkpoint, input);
|
|
143
|
+
checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
|
|
144
|
+
await store.save(checkpoint);
|
|
145
|
+
return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (url.pathname === '/session' && req.method === 'GET') {
|
|
149
|
+
const sessionId = String(req.headers['x-session-id'] ?? '');
|
|
150
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return sendJson(res, 400, { error: 'x-session-id header is required' });
|
|
151
|
+
const checkpoint = await store.load(sessionId);
|
|
152
|
+
return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return sendJson(res, 404, { error: 'not_found' });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('[agent] request failed:', error);
|
|
158
|
+
return sendJson(res, 500, { error: 'internal_error' });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Bind beyond loopback: Traefik reaches the container over the Docker network.
|
|
163
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
164
|
+
console.log('\\n🤖 ${name} agent on http://localhost:' + PORT + ' (durability: ' + DURABILITY + ')');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Drain on SIGTERM (tdk down, hot reload, docker stop): stop taking turns, let in-flight turns finish, then exit 0.
|
|
168
|
+
// Checkpoints are written before a turn is answered, so a drained exit loses no acknowledged turn.
|
|
169
|
+
function shutdown(signal: string) {
|
|
170
|
+
if (draining) return;
|
|
171
|
+
draining = true;
|
|
172
|
+
console.log('[agent] ' + signal + ' received, draining ' + inFlight + ' in-flight request(s)');
|
|
173
|
+
server.close(async () => {
|
|
174
|
+
await store.close();
|
|
175
|
+
});
|
|
176
|
+
const deadline = Date.now() + DRAIN_TIMEOUT_MS;
|
|
177
|
+
const timer = setInterval(async () => {
|
|
178
|
+
if (inFlight === 0 || Date.now() > deadline) {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
await store.close();
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
}, 100);
|
|
184
|
+
}
|
|
185
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
186
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
return `import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
190
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
|
|
191
|
+
import { join } from 'node:path';
|
|
192
|
+
|
|
193
|
+
// Contract with TDK (see docs/agents/ai-agents.md):
|
|
194
|
+
// AGENT_DURABILITY files (set by tdk from service.json agent.durability)
|
|
195
|
+
// AGENT_STATE_DIR where files-durability checkpoints live (a named volume that survives tdk down)
|
|
196
|
+
// PORT port to listen on
|
|
197
|
+
// /health: the process is alive. /ready: the checkpoint store is reachable. Idle is healthy, so /health never checks the store.
|
|
198
|
+
|
|
199
|
+
const DURABILITY = 'files';
|
|
200
|
+
const STATE_DIR = process.env.AGENT_STATE_DIR || join(process.cwd(), '.agent-state');
|
|
201
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
202
|
+
const DRAIN_TIMEOUT_MS = 10_000;
|
|
203
|
+
|
|
204
|
+
interface Checkpoint {
|
|
205
|
+
sessionId: string;
|
|
206
|
+
messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Session store. files: one JSON checkpoint per session, written atomically (temp file, then rename), so a kill
|
|
210
|
+
// mid-write leaves the previous checkpoint intact.
|
|
211
|
+
const store = {
|
|
212
|
+
ready(): boolean {
|
|
213
|
+
try {
|
|
214
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
215
|
+
return true;
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
load(sessionId: string): Checkpoint {
|
|
221
|
+
const file = join(STATE_DIR, sessionId + '.json');
|
|
222
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { sessionId, messages: [] };
|
|
223
|
+
},
|
|
224
|
+
save(checkpoint: Checkpoint): void {
|
|
225
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
226
|
+
const file = join(STATE_DIR, checkpoint.sessionId + '.json');
|
|
227
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
228
|
+
writeFileSync(tmp, JSON.stringify(checkpoint));
|
|
229
|
+
renameSync(tmp, file);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Replace this with the agent's work: call your model, run tools, return the reply. Keep it a pure function of the
|
|
234
|
+
// checkpoint so a resumed session continues from what was saved.
|
|
235
|
+
async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
|
|
236
|
+
// The caller has already appended this turn's user message, so the turn number is half the message count, rounded up.
|
|
237
|
+
return 'received ' + input + ' (turn ' + Math.ceil(checkpoint.messages.length / 2) + ')';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let draining = false;
|
|
241
|
+
let inFlight = 0;
|
|
242
|
+
|
|
243
|
+
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
244
|
+
res.statusCode = status;
|
|
245
|
+
res.setHeader('content-type', 'application/json');
|
|
246
|
+
res.end(JSON.stringify(body));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readJson(req: IncomingMessage): Promise<any> {
|
|
250
|
+
const chunks: Buffer[] = [];
|
|
251
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
252
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
253
|
+
return raw ? JSON.parse(raw) : {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const server = createServer(async (req, res) => {
|
|
257
|
+
inFlight++;
|
|
258
|
+
res.on('close', () => inFlight--);
|
|
259
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
if (url.pathname === '/health') {
|
|
263
|
+
return sendJson(res, 200, { status: 'ok', service: '${name}', draining });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (url.pathname === '/ready') {
|
|
267
|
+
const ok = !draining && (await store.ready());
|
|
268
|
+
return sendJson(res, ok ? 200 : 503, { ready: ok, durability: DURABILITY });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Session routing: the session id comes from the x-session-id header, so several instances of one agent can
|
|
272
|
+
// share a Traefik route and each session still reaches its own checkpoint.
|
|
273
|
+
if (url.pathname === '/turn' && req.method === 'POST') {
|
|
274
|
+
if (draining) return sendJson(res, 503, { error: 'draining' });
|
|
275
|
+
const sessionId = String(req.headers['x-session-id'] ?? '');
|
|
276
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) {
|
|
277
|
+
return sendJson(res, 400, { error: 'x-session-id header is required (1-128 letters, digits, - or _)' });
|
|
278
|
+
}
|
|
279
|
+
const body = await readJson(req);
|
|
280
|
+
const input = String(body.input ?? '');
|
|
281
|
+
const checkpoint = await store.load(sessionId);
|
|
282
|
+
checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
|
|
283
|
+
const reply = await runTurn(checkpoint, input);
|
|
284
|
+
checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
|
|
285
|
+
await store.save(checkpoint);
|
|
286
|
+
return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (url.pathname === '/session' && req.method === 'GET') {
|
|
290
|
+
const sessionId = String(req.headers['x-session-id'] ?? '');
|
|
291
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return sendJson(res, 400, { error: 'x-session-id header is required' });
|
|
292
|
+
const checkpoint = await store.load(sessionId);
|
|
293
|
+
return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return sendJson(res, 404, { error: 'not_found' });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('[agent] request failed:', error);
|
|
299
|
+
return sendJson(res, 500, { error: 'internal_error' });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Bind beyond loopback: Traefik reaches the container over the Docker network.
|
|
304
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
305
|
+
console.log('\\n🤖 ${name} agent on http://localhost:' + PORT + ' (durability: ' + DURABILITY + ')');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Drain on SIGTERM (tdk down, hot reload, docker stop): stop taking turns, let in-flight turns finish, then exit 0.
|
|
309
|
+
// Checkpoints are written before a turn is answered, so a drained exit loses no acknowledged turn.
|
|
310
|
+
function shutdown(signal: string) {
|
|
311
|
+
if (draining) return;
|
|
312
|
+
draining = true;
|
|
313
|
+
console.log('[agent] ' + signal + ' received, draining ' + inFlight + ' in-flight request(s)');
|
|
314
|
+
server.close();
|
|
315
|
+
const deadline = Date.now() + DRAIN_TIMEOUT_MS;
|
|
316
|
+
const timer = setInterval(() => {
|
|
317
|
+
if (inFlight === 0 || Date.now() > deadline) {
|
|
318
|
+
clearInterval(timer);
|
|
319
|
+
process.exit(0);
|
|
320
|
+
}
|
|
321
|
+
}, 100);
|
|
322
|
+
}
|
|
323
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
324
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
325
|
+
`;
|
|
326
|
+
}
|
|
327
|
+
export const customAgentRuntime = {
|
|
328
|
+
id: "custom",
|
|
329
|
+
label: "Custom agent runtime (Bun HTTP, no vendor dependency)",
|
|
330
|
+
dependencies: {},
|
|
331
|
+
devDependencies: {},
|
|
332
|
+
createIndex: getCustomAgentIndexTemplate,
|
|
333
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { TdkError } from "../utils/errors.js";
|
|
4
|
+
import { customAgentRuntime } from "./custom.js";
|
|
5
|
+
/**
|
|
6
|
+
* Agent runtimes an `agent` resource can be scaffolded with. No runtime is privileged: each is a scaffold plus the
|
|
7
|
+
* process contract in its index file, and `custom` is the default because it has no vendor dependency.
|
|
8
|
+
*/
|
|
9
|
+
export const AGENT_RUNTIMES = {
|
|
10
|
+
custom: customAgentRuntime,
|
|
11
|
+
};
|
|
12
|
+
export const DEFAULT_AGENT_RUNTIME = "custom";
|
|
13
|
+
export const AGENT_DURABILITY_OPTIONS = ["files", "postgres"];
|
|
14
|
+
export function resolveAgentRuntime(runtimeId) {
|
|
15
|
+
const id = runtimeId ?? DEFAULT_AGENT_RUNTIME;
|
|
16
|
+
const provider = AGENT_RUNTIMES[id];
|
|
17
|
+
if (!provider) {
|
|
18
|
+
const supported = Object.keys(AGENT_RUNTIMES).join(", ");
|
|
19
|
+
throw new TdkError(`Unknown agent runtime "${id}". Supported runtimes: ${supported}.`, [
|
|
20
|
+
`Use one of: ${supported}`,
|
|
21
|
+
`Omit --runtime to use ${DEFAULT_AGENT_RUNTIME}`,
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
return provider;
|
|
25
|
+
}
|
|
26
|
+
export function resolveAgentDurability(value) {
|
|
27
|
+
const durability = value ?? "files";
|
|
28
|
+
if (!AGENT_DURABILITY_OPTIONS.includes(durability)) {
|
|
29
|
+
throw new TdkError(`Unknown --durability "${durability}".`, [
|
|
30
|
+
`Use one of: ${AGENT_DURABILITY_OPTIONS.join(", ")}`,
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
return durability;
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { resolveAgentRuntime } from "../agent-runtimes/registry.js";
|
|
3
4
|
import { getBackendIndexTemplate } from "../backend-languages/bun.js";
|
|
4
5
|
import { TdkError } from "../utils/errors.js";
|
|
5
6
|
import { elysiaBackendProvider } from "./elysia.js";
|
|
@@ -56,6 +57,21 @@ export function resolveBackendFramework(resourceType, frameworkId, language) {
|
|
|
56
57
|
}
|
|
57
58
|
return mcpBackendProvider;
|
|
58
59
|
}
|
|
60
|
+
// `agent` is scaffolded by an agent runtime (src/agent-runtimes/), not a framework. Only the default runtime is reachable here:
|
|
61
|
+
// `tdk resource` validates --runtime before it scaffolds, so the package.json and tsconfig always match the chosen runtime.
|
|
62
|
+
if (resourceType === "agent") {
|
|
63
|
+
if (frameworkId !== undefined && frameworkId !== "custom") {
|
|
64
|
+
throw new TdkError("--framework cannot be combined with --type agent.", [
|
|
65
|
+
"An agent resource is scaffolded by an agent runtime; use --runtime <id> instead",
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
if (language?.createFiles) {
|
|
69
|
+
throw new TdkError(`--language ${language.id} cannot be combined with --type agent.`, [
|
|
70
|
+
"An agent resource is a Bun service; drop --language",
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
return resolveAgentRuntime();
|
|
74
|
+
}
|
|
59
75
|
if (resourceType !== "backend" || frameworkId === undefined)
|
|
60
76
|
return undefined;
|
|
61
77
|
if (language?.createFiles) {
|
package/cli/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import pkg from "../package.json" with { type: "json" };
|
|
7
|
+
import { agentCommand } from "./commands/agent.js";
|
|
7
8
|
import { completionCommand } from "./commands/completion.js";
|
|
8
9
|
import { configCommand } from "./commands/config.js";
|
|
9
10
|
import { doctorCommand } from "./commands/doctor.js";
|
|
@@ -19,6 +20,7 @@ import { projectCommand } from "./commands/project.js";
|
|
|
19
20
|
import { projectsCommand } from "./commands/projects.js";
|
|
20
21
|
import { resourceCommand } from "./commands/resource.js";
|
|
21
22
|
import { resourcesCommand } from "./commands/resources.js";
|
|
23
|
+
import { restartCommand } from "./commands/restart.js";
|
|
22
24
|
import { runtimeCommand } from "./commands/runtime.js";
|
|
23
25
|
import { stackCommand } from "./commands/stack.js";
|
|
24
26
|
import { stacksCommand } from "./commands/stacks.js";
|
|
@@ -67,6 +69,8 @@ program.addCommand(doctorCommand);
|
|
|
67
69
|
program.addCommand(completionCommand);
|
|
68
70
|
program.addCommand(upgradeCommand);
|
|
69
71
|
program.addCommand(networksCommand);
|
|
72
|
+
program.addCommand(agentCommand);
|
|
73
|
+
program.addCommand(restartCommand);
|
|
70
74
|
// Default: show help if no command provided
|
|
71
75
|
if (process.argv.length === 2) {
|
|
72
76
|
const result = await runColdPreflight();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createDiscoveryContext } from "../utils/discovery-context.js";
|
|
6
|
+
import { errorFactories, runCommand, TdkError } from "../utils/errors.js";
|
|
7
|
+
import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
|
|
8
|
+
import { findProjectRoot } from "../utils/paths.js";
|
|
9
|
+
const SESSION_REQUEST_TIMEOUT_MS = 10_000;
|
|
10
|
+
export const agentCommand = new Command("agent").description("Inspect and operate AI agent resources");
|
|
11
|
+
agentCommand
|
|
12
|
+
.command("sessions <resource>")
|
|
13
|
+
.description("Inspect session checkpoints from a running AI agent resource")
|
|
14
|
+
.option("--session-id <id>", "Specific session ID to fetch")
|
|
15
|
+
.option("--json", "Output one JSON object and exit", false)
|
|
16
|
+
.action(async (resourceName, options) => {
|
|
17
|
+
const action = async () => {
|
|
18
|
+
const root = findProjectRoot();
|
|
19
|
+
if (!root) {
|
|
20
|
+
const error = errorFactories.notInProject();
|
|
21
|
+
if (options.json)
|
|
22
|
+
writeMachineError(error);
|
|
23
|
+
error.exit();
|
|
24
|
+
}
|
|
25
|
+
const discovery = createDiscoveryContext();
|
|
26
|
+
const resource = discovery.resources.find((r) => r.name === resourceName);
|
|
27
|
+
if (!resource) {
|
|
28
|
+
const msg = `Resource "${resourceName}" not found in project.`;
|
|
29
|
+
if (options.json) {
|
|
30
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "UNKNOWN_RESOURCE", message: msg }])));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
throw new TdkError(msg);
|
|
34
|
+
}
|
|
35
|
+
const appType = resource.config?.appType ?? resource.type;
|
|
36
|
+
if (appType !== "agent") {
|
|
37
|
+
const msg = `Resource "${resourceName}" is of type "${appType}", not "agent".`;
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NOT_AN_AGENT", message: msg }])));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
throw new TdkError(msg);
|
|
43
|
+
}
|
|
44
|
+
const port = resource.port;
|
|
45
|
+
if (!port) {
|
|
46
|
+
const msg = `Agent "${resourceName}" has no assigned port.`;
|
|
47
|
+
if (options.json) {
|
|
48
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NO_PORT", message: msg }])));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
throw new TdkError(msg);
|
|
52
|
+
}
|
|
53
|
+
const headers = {};
|
|
54
|
+
if (options.sessionId) {
|
|
55
|
+
headers["x-session-id"] = String(options.sessionId);
|
|
56
|
+
}
|
|
57
|
+
let res;
|
|
58
|
+
let bodyText;
|
|
59
|
+
try {
|
|
60
|
+
// One signal covers both the connection and the body read, so a hung agent cannot block the CLI.
|
|
61
|
+
res = await fetch(`http://127.0.0.1:${port}/session`, {
|
|
62
|
+
method: "GET",
|
|
63
|
+
headers,
|
|
64
|
+
signal: AbortSignal.timeout(SESSION_REQUEST_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
bodyText = await res.text();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
const msg = `Agent "${resourceName}" is not responding on port ${port}. Is the stack running?`;
|
|
70
|
+
if (options.json) {
|
|
71
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "AGENT_NOT_RUNNING", message: msg }])));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
throw new TdkError(msg, ["Start the stack with: tdk up"]);
|
|
75
|
+
}
|
|
76
|
+
let body = {};
|
|
77
|
+
try {
|
|
78
|
+
body = bodyText ? JSON.parse(bodyText) : {};
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
body = { raw: bodyText };
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const detail = body?.error ?? bodyText;
|
|
85
|
+
if (options.json) {
|
|
86
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [
|
|
87
|
+
{
|
|
88
|
+
code: "AGENT_REQUEST_FAILED",
|
|
89
|
+
message: `Agent returned ${res.status}: ${detail}`,
|
|
90
|
+
},
|
|
91
|
+
])));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
throw new TdkError(`Agent error (${res.status}): ${detail}`);
|
|
95
|
+
}
|
|
96
|
+
if (options.json) {
|
|
97
|
+
console.log(JSON.stringify(createMachineEnvelope({
|
|
98
|
+
agent: resourceName,
|
|
99
|
+
port,
|
|
100
|
+
session: body,
|
|
101
|
+
})));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
console.log(chalk.bold(`Sessions for ${resourceName}:`));
|
|
105
|
+
console.log(JSON.stringify(body, null, 2));
|
|
106
|
+
};
|
|
107
|
+
if (options.json) {
|
|
108
|
+
try {
|
|
109
|
+
await action();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
writeMachineError(err);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await runCommand(action);
|
|
117
|
+
});
|
|
@@ -13,7 +13,7 @@ _tdk_completions() {
|
|
|
13
13
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
14
14
|
|
|
15
15
|
# Main commands
|
|
16
|
-
local commands="project projects stack stacks resource resources up down status ui doctor runtime version completion help upgrade mcp logs eject import config maintainers networks traefik"
|
|
16
|
+
local commands="project projects stack stacks resource resources agent restart up down status ui doctor runtime version completion help upgrade mcp logs eject import config maintainers networks traefik"
|
|
17
17
|
|
|
18
18
|
# Options for specific commands
|
|
19
19
|
case "\${prev}" in
|
|
@@ -136,6 +136,8 @@ _tdk_commands() {
|
|
|
136
136
|
'stacks:List all stacks'
|
|
137
137
|
'resource:Create new resource'
|
|
138
138
|
'resources:List all resources'
|
|
139
|
+
'agent:Inspect and operate AI agent resources'
|
|
140
|
+
'restart:Restart a running resource in the stack'
|
|
139
141
|
'up:Start services'
|
|
140
142
|
'down:Stop all services'
|
|
141
143
|
'status:Show resource status'
|
|
@@ -189,6 +191,8 @@ complete -c tdk -n '__fish_use_subcommand' -a 'stack' -d 'Organize resources int
|
|
|
189
191
|
complete -c tdk -n '__fish_use_subcommand' -a 'stacks' -d 'List all stacks'
|
|
190
192
|
complete -c tdk -n '__fish_use_subcommand' -a 'resource' -d 'Create new resource'
|
|
191
193
|
complete -c tdk -n '__fish_use_subcommand' -a 'resources' -d 'List all resources'
|
|
194
|
+
complete -c tdk -n '__fish_use_subcommand' -a 'agent' -d 'Inspect and operate AI agent resources'
|
|
195
|
+
complete -c tdk -n '__fish_use_subcommand' -a 'restart' -d 'Restart a running resource in the stack'
|
|
192
196
|
complete -c tdk -n '__fish_use_subcommand' -a 'up' -d 'Start services'
|
|
193
197
|
complete -c tdk -n '__fish_use_subcommand' -a 'down' -d 'Stop all services'
|
|
194
198
|
complete -c tdk -n '__fish_use_subcommand' -a 'status' -d 'Show resource status'
|
|
@@ -42,6 +42,11 @@ const COMMAND_GROUPS = [
|
|
|
42
42
|
commands: [
|
|
43
43
|
{ name: "resource", desc: "Create new resource with templates", alias: "" },
|
|
44
44
|
{ name: "resources", desc: "List all resources with filtering", alias: "--stack, --ports" },
|
|
45
|
+
{
|
|
46
|
+
name: "agent",
|
|
47
|
+
desc: "Inspect and operate AI agent resources",
|
|
48
|
+
alias: "sessions <resource>",
|
|
49
|
+
},
|
|
45
50
|
],
|
|
46
51
|
},
|
|
47
52
|
{
|
|
@@ -50,6 +55,7 @@ const COMMAND_GROUPS = [
|
|
|
50
55
|
commands: [
|
|
51
56
|
{ name: "up, deploy", desc: "Start services (optionally by stack)", alias: "[stack-name]" },
|
|
52
57
|
{ name: "down", desc: "Stop all tilt resources", alias: "" },
|
|
58
|
+
{ name: "restart", desc: "Restart a running resource in the stack", alias: "<resource>" },
|
|
53
59
|
{ name: "status", desc: "Show status of services and stacks", alias: "" },
|
|
54
60
|
{ name: "logs", desc: "Print recent logs from the running stack", alias: "--json" },
|
|
55
61
|
],
|
|
@@ -131,6 +137,7 @@ export function showHelp() {
|
|
|
131
137
|
${chalk.cyan("$")} tdk project # Create master configs
|
|
132
138
|
${chalk.cyan("$")} tdk resource api --type backend # Create a backend service
|
|
133
139
|
${chalk.cyan("$")} tdk resource app --type frontend # Create a frontend app
|
|
140
|
+
${chalk.cyan("$")} tdk resource bot --type agent # Create an AI agent (sessions survive tdk down)
|
|
134
141
|
${chalk.cyan("$")} tdk stacks --services # List stacks with their resources
|
|
135
142
|
${chalk.cyan("$")} tdk up my-stack # Start a stack
|
|
136
143
|
${chalk.cyan("$")} tdk networks # Show Traefik-routed URLs
|