@amodalai/amodal 0.3.48 → 0.3.50
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/CHANGELOG.md +34 -0
- package/dist/src/commands/dev.d.ts +28 -2
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +322 -90
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/shared/platform-client.d.ts +2 -2
- package/dist/src/shared/platform-client.d.ts.map +1 -1
- package/dist/src/shared/platform-client.js +19 -11
- package/dist/src/shared/platform-client.js.map +1 -1
- package/dist/src/shared/repo-discovery.d.ts +11 -0
- package/dist/src/shared/repo-discovery.d.ts.map +1 -1
- package/dist/src/shared/repo-discovery.js +16 -0
- package/dist/src/shared/repo-discovery.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/commands/dev-quiet-filter.test.ts +210 -0
- package/src/commands/dev.ts +340 -96
- package/src/shared/platform-client.ts +31 -13
- package/src/shared/repo-discovery.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @amodalai/amodal
|
|
2
2
|
|
|
3
|
+
## 0.3.50
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [1a0732b]
|
|
8
|
+
- Updated dependencies [1a0732b]
|
|
9
|
+
- Updated dependencies [1a0732b]
|
|
10
|
+
- Updated dependencies [1a0732b]
|
|
11
|
+
- Updated dependencies [1a0732b]
|
|
12
|
+
- Updated dependencies [1a0732b]
|
|
13
|
+
- Updated dependencies [1a0732b]
|
|
14
|
+
- Updated dependencies [1a0732b]
|
|
15
|
+
- Updated dependencies [1a0732b]
|
|
16
|
+
- Updated dependencies [1a0732b]
|
|
17
|
+
- Updated dependencies [1a0732b]
|
|
18
|
+
- @amodalai/types@0.3.50
|
|
19
|
+
- @amodalai/runtime@0.3.50
|
|
20
|
+
- @amodalai/studio@0.3.50
|
|
21
|
+
- @amodalai/core@0.3.50
|
|
22
|
+
- @amodalai/db@0.3.50
|
|
23
|
+
- @amodalai/runtime-app@0.3.50
|
|
24
|
+
|
|
25
|
+
## 0.3.49
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [fadad93]
|
|
30
|
+
- @amodalai/runtime@0.3.49
|
|
31
|
+
- @amodalai/types@0.3.49
|
|
32
|
+
- @amodalai/core@0.3.49
|
|
33
|
+
- @amodalai/runtime-app@0.3.49
|
|
34
|
+
- @amodalai/db@0.3.49
|
|
35
|
+
- @amodalai/studio@0.3.49
|
|
36
|
+
|
|
3
37
|
## 0.3.48
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
|
@@ -7,8 +7,6 @@ import type { CommandModule } from 'yargs';
|
|
|
7
7
|
export interface DevOptions {
|
|
8
8
|
cwd?: string;
|
|
9
9
|
port?: number;
|
|
10
|
-
studioPort?: number;
|
|
11
|
-
adminPort?: number;
|
|
12
10
|
host?: string;
|
|
13
11
|
resume?: string;
|
|
14
12
|
verbose?: number;
|
|
@@ -18,6 +16,34 @@ export interface DevOptions {
|
|
|
18
16
|
/** Disable admin agent subprocess. */
|
|
19
17
|
noAdmin?: boolean;
|
|
20
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Pipe a child process's stdout/stderr to the parent's stderr, prefixed
|
|
21
|
+
* with a label. Lines are buffered per-stream to avoid interleaved output
|
|
22
|
+
* from concurrent subprocesses.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
|
|
26
|
+
* format change in the runtime logger can't silently break dev observability.
|
|
27
|
+
*
|
|
28
|
+
* Passes warnings/errors and the Phase 4 intent-routing telemetry through
|
|
29
|
+
* (the latter is exactly what makes intent vs LLM ratios visible in dev).
|
|
30
|
+
*/
|
|
31
|
+
export declare function passesQuietFilter(line: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort pretty-printer for routing telemetry. The runtime emits
|
|
34
|
+
* `[INFO] route_intent {…json…}` style lines (for production aggregators);
|
|
35
|
+
* in the dev terminal that's mostly visual noise. This rewrites the few
|
|
36
|
+
* route/intent lifecycle events into one-liner status lines so a human
|
|
37
|
+
* scanning their terminal can answer "is intent routing working?" at a
|
|
38
|
+
* glance. Falls back to the original line when parsing fails so we never
|
|
39
|
+
* eat a line that the user might need.
|
|
40
|
+
*
|
|
41
|
+
* Returns:
|
|
42
|
+
* - a string (possibly the same as input) — print it verbatim
|
|
43
|
+
* - null — suppress the line entirely (e.g. dropping intent_matched
|
|
44
|
+
* once route_intent has already been printed for the same turn)
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatLineForDev(line: string): string | null;
|
|
21
47
|
/**
|
|
22
48
|
* Starts a local development server for the repo with hot reload enabled,
|
|
23
49
|
* and optionally spawns Studio and admin agent as subprocesses.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/commands/dev.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/commands/dev.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,OAAO,CAAC;AA0EzC,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAYD;;;;GAIG;AACH;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUvD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA8E5D;AAuQD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAgYpE;AAED,eAAO,MAAM,UAAU,EAAE,aA8DxB,CAAC"}
|
package/dist/src/commands/dev.js
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Copyright 2025 Amodal Labs, Inc.
|
|
4
4
|
* SPDX-License-Identifier: MIT
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { createLocalServer, initLogLevel, interceptConsole, log } from '@amodalai/runtime';
|
|
12
12
|
import { ensureAdminAgent, getAdminAgentConfig, getAdminAgentVersion, checkRegistryVersion } from '@amodalai/core';
|
|
13
|
-
import {
|
|
13
|
+
import { findRepoRootOrCwd } from '../shared/repo-discovery.js';
|
|
14
14
|
import { createServer } from 'node:net';
|
|
15
15
|
import { runConnectionPreflight, printPreflightTable } from '../shared/connection-preflight.js';
|
|
16
16
|
import { resolveEnv } from '../shared/env-resolution.js';
|
|
@@ -19,6 +19,8 @@ import { getDb, ensureSchema, closeDb } from '@amodalai/db';
|
|
|
19
19
|
// Constants
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
const DEFAULT_RUNTIME_PORT = 3847;
|
|
22
|
+
const DEFAULT_STUDIO_PORT = 3848;
|
|
23
|
+
const DEFAULT_ADMIN_PORT = 3849;
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
25
|
// Port checking
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
@@ -66,9 +68,122 @@ function resolveStudioDir() {
|
|
|
66
68
|
* with a label. Lines are buffered per-stream to avoid interleaved output
|
|
67
69
|
* from concurrent subprocesses.
|
|
68
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
|
|
73
|
+
* format change in the runtime logger can't silently break dev observability.
|
|
74
|
+
*
|
|
75
|
+
* Passes warnings/errors and the Phase 4 intent-routing telemetry through
|
|
76
|
+
* (the latter is exactly what makes intent vs LLM ratios visible in dev).
|
|
77
|
+
*/
|
|
78
|
+
export function passesQuietFilter(line) {
|
|
79
|
+
return (line.includes('[WARN]') ||
|
|
80
|
+
line.includes('[ERROR]') ||
|
|
81
|
+
line.includes('Error') ||
|
|
82
|
+
line.includes('intent_') ||
|
|
83
|
+
line.includes('agent_loop_start') ||
|
|
84
|
+
line.includes('route_intent') ||
|
|
85
|
+
line.includes('route_llm'));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Best-effort pretty-printer for routing telemetry. The runtime emits
|
|
89
|
+
* `[INFO] route_intent {…json…}` style lines (for production aggregators);
|
|
90
|
+
* in the dev terminal that's mostly visual noise. This rewrites the few
|
|
91
|
+
* route/intent lifecycle events into one-liner status lines so a human
|
|
92
|
+
* scanning their terminal can answer "is intent routing working?" at a
|
|
93
|
+
* glance. Falls back to the original line when parsing fails so we never
|
|
94
|
+
* eat a line that the user might need.
|
|
95
|
+
*
|
|
96
|
+
* Returns:
|
|
97
|
+
* - a string (possibly the same as input) — print it verbatim
|
|
98
|
+
* - null — suppress the line entirely (e.g. dropping intent_matched
|
|
99
|
+
* once route_intent has already been printed for the same turn)
|
|
100
|
+
*/
|
|
101
|
+
export function formatLineForDev(line) {
|
|
102
|
+
// Only touch our recognized events. Match `[LEVEL] event_name {json}` form.
|
|
103
|
+
const m = /^\[(INFO|WARN|ERROR)\] ([a-z_]+) (\{.*\})\s*$/.exec(line);
|
|
104
|
+
if (!m)
|
|
105
|
+
return line;
|
|
106
|
+
const [, , event, jsonStr] = m;
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(jsonStr);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return line;
|
|
113
|
+
}
|
|
114
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
115
|
+
return line;
|
|
116
|
+
}
|
|
117
|
+
const data = Object.fromEntries(Object.entries(parsed));
|
|
118
|
+
const str = (k) => typeof data[k] === 'string' ? (data[k]) : '';
|
|
119
|
+
const num = (k) => typeof data[k] === 'number' ? (data[k]) : 0;
|
|
120
|
+
switch (event) {
|
|
121
|
+
case 'route_intent': {
|
|
122
|
+
const preview = str('userMessagePreview');
|
|
123
|
+
return `→ INTENT ${str('intentId').padEnd(22)} "${preview}"`;
|
|
124
|
+
}
|
|
125
|
+
case 'route_llm': {
|
|
126
|
+
const reason = str('reason');
|
|
127
|
+
const detail = str('intentId') || str('userMessagePreview');
|
|
128
|
+
return `→ LLM ${reason.padEnd(22)} ${detail ? `"${detail}"` : ''}`.trimEnd();
|
|
129
|
+
}
|
|
130
|
+
case 'intent_completed': {
|
|
131
|
+
const toolCount = num('toolCount');
|
|
132
|
+
const ms = num('durationMs');
|
|
133
|
+
return ` ✓ ${str('intentId')} done (${String(toolCount)} tools, ${String(ms)}ms)`;
|
|
134
|
+
}
|
|
135
|
+
case 'intent_fell_through': {
|
|
136
|
+
const ms = num('durationMs');
|
|
137
|
+
return ` ↓ ${str('intentId')} fell through to LLM (${String(ms)}ms)`;
|
|
138
|
+
}
|
|
139
|
+
case 'intent_errored': {
|
|
140
|
+
return ` ✗ ${str('intentId')} ERRORED: ${str('error')}`;
|
|
141
|
+
}
|
|
142
|
+
case 'intent_blocked_by_confirmation': {
|
|
143
|
+
return ` ✗ ${str('intentId')} blocked: ${str('toolName')} requires confirmation`;
|
|
144
|
+
}
|
|
145
|
+
case 'intent_returned_null_after_committing': {
|
|
146
|
+
return ` ⚠ ${str('intentId')} returned null after ${String(num('toolCallsStarted'))} tool calls — treating as completion`;
|
|
147
|
+
}
|
|
148
|
+
case 'intent_matched':
|
|
149
|
+
case 'agent_loop_start': {
|
|
150
|
+
// Both are redundant in dev: intent_matched duplicates the
|
|
151
|
+
// route_intent line that fires a millisecond earlier, and
|
|
152
|
+
// agent_loop_start always follows a route_llm line (manager.ts
|
|
153
|
+
// emits route_llm immediately before invoking the LLM). Drop
|
|
154
|
+
// them to keep the dev terminal scannable; production
|
|
155
|
+
// aggregators still get them on stderr from the runtime
|
|
156
|
+
// process directly (this formatter only runs in pipeWithLabel).
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
case 'tool_log': {
|
|
160
|
+
// Tools call ctx.log(...) for noteworthy progress (e.g.
|
|
161
|
+
// install_template emits "Cloned whodatdev/template-X into
|
|
162
|
+
// agent repo (N connection packages installed)"). When fired
|
|
163
|
+
// during an intent run the callId starts with `intent_`, so
|
|
164
|
+
// these lines pass the quiet filter — but as raw JSON they
|
|
165
|
+
// bury the useful message inside callId/session noise. Strip
|
|
166
|
+
// to a clean nested bullet.
|
|
167
|
+
const msg = str('message');
|
|
168
|
+
if (!msg)
|
|
169
|
+
return null;
|
|
170
|
+
return ` · ${msg}`;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
69
176
|
function pipeWithLabel(child, label, opts) {
|
|
70
177
|
const prefix = `[${label}] `;
|
|
71
178
|
const quiet = opts?.quiet ?? false;
|
|
179
|
+
const writeLine = (line) => {
|
|
180
|
+
if (quiet && !passesQuietFilter(line))
|
|
181
|
+
return;
|
|
182
|
+
const pretty = formatLineForDev(line);
|
|
183
|
+
if (pretty === null)
|
|
184
|
+
return;
|
|
185
|
+
process.stderr.write(`${prefix}${pretty}\n`);
|
|
186
|
+
};
|
|
72
187
|
for (const stream of [child.stdout, child.stderr]) {
|
|
73
188
|
if (!stream)
|
|
74
189
|
continue;
|
|
@@ -78,17 +193,12 @@ function pipeWithLabel(child, label, opts) {
|
|
|
78
193
|
buffer += chunk;
|
|
79
194
|
const lines = buffer.split('\n');
|
|
80
195
|
buffer = lines.pop() ?? '';
|
|
81
|
-
for (const line of lines)
|
|
82
|
-
|
|
83
|
-
continue;
|
|
84
|
-
process.stderr.write(`${prefix}${line}\n`);
|
|
85
|
-
}
|
|
196
|
+
for (const line of lines)
|
|
197
|
+
writeLine(line);
|
|
86
198
|
});
|
|
87
199
|
stream.on('end', () => {
|
|
88
200
|
if (buffer.length > 0) {
|
|
89
|
-
|
|
90
|
-
process.stderr.write(`${prefix}${buffer}\n`);
|
|
91
|
-
}
|
|
201
|
+
writeLine(buffer);
|
|
92
202
|
buffer = '';
|
|
93
203
|
}
|
|
94
204
|
});
|
|
@@ -136,7 +246,6 @@ function spawnStudio(opts) {
|
|
|
136
246
|
HOSTNAME: '0.0.0.0',
|
|
137
247
|
...(opts.agentId ? { AGENT_ID: opts.agentId } : {}),
|
|
138
248
|
...(opts.adminAgentUrl ? { ADMIN_AGENT_URL: opts.adminAgentUrl } : {}),
|
|
139
|
-
...(opts.basePath ? { BASE_PATH: opts.basePath } : {}),
|
|
140
249
|
};
|
|
141
250
|
// Pre-built server (npm install): dist-server/studio-server.js
|
|
142
251
|
// Source mode (monorepo dev): src/server/studio-server.ts via tsx
|
|
@@ -244,6 +353,9 @@ async function spawnAdminAgent(opts) {
|
|
|
244
353
|
AMODAL_NO_STUDIO: '1',
|
|
245
354
|
REPO_PATH: opts.repoPath,
|
|
246
355
|
};
|
|
356
|
+
if (opts.studioUrl) {
|
|
357
|
+
env['STUDIO_URL'] = opts.studioUrl;
|
|
358
|
+
}
|
|
247
359
|
const child = spawn(process.execPath, [cliEntrypoint, 'dev', '--port', String(opts.port)], {
|
|
248
360
|
cwd: adminAgentPath,
|
|
249
361
|
env,
|
|
@@ -274,25 +386,13 @@ async function spawnAdminAgent(opts) {
|
|
|
274
386
|
export async function runDev(options = {}) {
|
|
275
387
|
initLogLevel({ verbosity: options.verbose ?? 0, quiet: options.quiet ?? false });
|
|
276
388
|
interceptConsole();
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
Create a new agent:
|
|
286
|
-
|
|
287
|
-
amodal init Initialize this directory
|
|
288
|
-
amodal dev Start the dev server
|
|
289
|
-
|
|
290
|
-
Or if your agent is in another directory:
|
|
291
|
-
|
|
292
|
-
cd /path/to/agent && amodal dev
|
|
293
|
-
|
|
294
|
-
`);
|
|
295
|
-
process.exit(1);
|
|
389
|
+
// Empty directories are allowed: the create flow in Studio scaffolds
|
|
390
|
+
// amodal.json from a chosen template (or admin-agent conversation), so
|
|
391
|
+
// the user can `amodal dev` before they have a project at all. We just
|
|
392
|
+
// skip the runtime in that case — it can't loadRepo without a manifest.
|
|
393
|
+
const { root: repoPath, hasManifest } = findRepoRootOrCwd(options.cwd);
|
|
394
|
+
if (!hasManifest) {
|
|
395
|
+
log.info('dev_create_flow_mode', { repoPath });
|
|
296
396
|
}
|
|
297
397
|
// -------------------------------------------------------------------------
|
|
298
398
|
// Require DATABASE_URL
|
|
@@ -319,7 +419,18 @@ Or add it to your agent's .env file:
|
|
|
319
419
|
}
|
|
320
420
|
// Make DATABASE_URL available to child processes (runtime, Studio)
|
|
321
421
|
process.env['DATABASE_URL'] = databaseUrl;
|
|
322
|
-
//
|
|
422
|
+
// Resolve AGENT_ID — must be set BEFORE spawning subprocesses so
|
|
423
|
+
// Studio, runtime, and admin-agent all key `setup_state` rows by
|
|
424
|
+
// the same id. Three sources, in priority order:
|
|
425
|
+
// 1. amodal.json#name when the file exists (post-setup repos)
|
|
426
|
+
// 2. The repo dir basename (pre-setup repos — what the user calls
|
|
427
|
+
// their working directory; stable across the setup flow)
|
|
428
|
+
// 3. 'default' as a last-ditch fallback
|
|
429
|
+
// Without this, the admin-agent process would compute its own id
|
|
430
|
+
// from its own bundle (name: "admin") and Studio's `getAgentId()`
|
|
431
|
+
// would fall back to "default", leaving `commit_setup` marking a
|
|
432
|
+
// different row than Studio reads — IndexPage would loop the user
|
|
433
|
+
// back to /setup even after a successful commit.
|
|
323
434
|
const amodalJsonPath = path.join(repoPath, 'amodal.json');
|
|
324
435
|
let agentId;
|
|
325
436
|
if (existsSync(amodalJsonPath)) {
|
|
@@ -332,7 +443,6 @@ Or add it to your agent's .env file:
|
|
|
332
443
|
const nameValue = parsed !== undefined ? parsed['name'] : undefined;
|
|
333
444
|
if (typeof nameValue === 'string') {
|
|
334
445
|
agentId = nameValue;
|
|
335
|
-
process.env['AGENT_ID'] = agentId;
|
|
336
446
|
}
|
|
337
447
|
}
|
|
338
448
|
catch (err) {
|
|
@@ -342,6 +452,14 @@ Or add it to your agent's .env file:
|
|
|
342
452
|
});
|
|
343
453
|
}
|
|
344
454
|
}
|
|
455
|
+
if (!agentId) {
|
|
456
|
+
// Pre-setup fallback. `path.basename(repoPath)` gives "test-empty"
|
|
457
|
+
// or whatever the user named their working dir — stable enough
|
|
458
|
+
// for setup_state coordination, and the agent name will switch
|
|
459
|
+
// to amodal.json#name on the next CLI invocation post-commit.
|
|
460
|
+
agentId = path.basename(repoPath) || 'default';
|
|
461
|
+
}
|
|
462
|
+
process.env['AGENT_ID'] = agentId;
|
|
345
463
|
// -------------------------------------------------------------------------
|
|
346
464
|
// Run schema migrations
|
|
347
465
|
// -------------------------------------------------------------------------
|
|
@@ -363,15 +481,17 @@ Or add it to your agent's .env file:
|
|
|
363
481
|
// Port allocation
|
|
364
482
|
// -------------------------------------------------------------------------
|
|
365
483
|
const runtimePort = options.port ?? DEFAULT_RUNTIME_PORT;
|
|
366
|
-
const studioPort =
|
|
367
|
-
const adminPort =
|
|
368
|
-
|
|
484
|
+
const studioPort = DEFAULT_STUDIO_PORT;
|
|
485
|
+
const adminPort = DEFAULT_ADMIN_PORT;
|
|
486
|
+
if (hasManifest) {
|
|
487
|
+
await assertPortFree(runtimePort);
|
|
488
|
+
}
|
|
369
489
|
if (!options.noStudio)
|
|
370
490
|
await assertPortFree(studioPort);
|
|
371
491
|
if (!options.noAdmin)
|
|
372
492
|
await assertPortFree(adminPort);
|
|
373
493
|
log.debug('ports_allocated', {
|
|
374
|
-
runtime: runtimePort,
|
|
494
|
+
runtime: hasManifest ? runtimePort : null,
|
|
375
495
|
studio: options.noStudio ? null : studioPort,
|
|
376
496
|
admin: options.noAdmin ? null : adminPort,
|
|
377
497
|
});
|
|
@@ -408,44 +528,63 @@ Or add it to your agent's .env file:
|
|
|
408
528
|
}
|
|
409
529
|
}
|
|
410
530
|
// -------------------------------------------------------------------------
|
|
411
|
-
// Start the runtime server
|
|
531
|
+
// Start the runtime server (skipped when there's no amodal.json — the
|
|
532
|
+
// runtime can't loadRepo on an empty directory; the create flow in Studio
|
|
533
|
+
// takes the user from an empty repo to a configured one, after which they
|
|
534
|
+
// ctrl+C and re-run `amodal dev` to pick up the runtime).
|
|
412
535
|
// -------------------------------------------------------------------------
|
|
413
|
-
log.debug('starting_dev_server', { repoPath });
|
|
536
|
+
log.debug('starting_dev_server', { repoPath, hasManifest });
|
|
414
537
|
try {
|
|
415
|
-
let
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
538
|
+
let server = null;
|
|
539
|
+
/**
|
|
540
|
+
* Boot the runtime server. Factored out so the empty-repo
|
|
541
|
+
* branch (Phase E.9) can call it lazily when amodal.json lands.
|
|
542
|
+
*/
|
|
543
|
+
const bootRuntime = async () => {
|
|
544
|
+
let staticAppDir;
|
|
545
|
+
// Use pre-built static assets for the SPA.
|
|
546
|
+
// Vite dev middleware is only used inside the monorepo with `pnpm dev`.
|
|
547
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
548
|
+
const candidates = [
|
|
549
|
+
// esbuild bundle: bundle/app/
|
|
550
|
+
path.resolve(scriptDir, 'app'),
|
|
551
|
+
];
|
|
552
|
+
// Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
|
|
553
|
+
const require = createRequire(import.meta.url);
|
|
554
|
+
const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
|
|
555
|
+
candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
|
|
556
|
+
for (const dir of candidates) {
|
|
557
|
+
if (existsSync(path.join(dir, 'index.html'))) {
|
|
558
|
+
log.debug('serving_prebuilt_app', { path: staticAppDir });
|
|
559
|
+
staticAppDir = dir;
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
432
562
|
}
|
|
563
|
+
const created = await createLocalServer({
|
|
564
|
+
repoPath,
|
|
565
|
+
port: runtimePort,
|
|
566
|
+
host,
|
|
567
|
+
hotReload: true,
|
|
568
|
+
corsOrigin: '*',
|
|
569
|
+
staticAppDir,
|
|
570
|
+
resumeSessionId: options.resume,
|
|
571
|
+
studioUrl: studioUrl ?? undefined,
|
|
572
|
+
adminAgentUrl: adminAgentUrl ?? undefined,
|
|
573
|
+
});
|
|
574
|
+
await created.start();
|
|
575
|
+
return created;
|
|
576
|
+
};
|
|
577
|
+
if (hasManifest) {
|
|
578
|
+
server = await bootRuntime();
|
|
433
579
|
}
|
|
434
|
-
const server = await createLocalServer({
|
|
435
|
-
repoPath,
|
|
436
|
-
port: runtimePort,
|
|
437
|
-
host,
|
|
438
|
-
hotReload: true,
|
|
439
|
-
corsOrigin: '*',
|
|
440
|
-
staticAppDir,
|
|
441
|
-
resumeSessionId: options.resume,
|
|
442
|
-
studioUrl: studioUrl ?? undefined,
|
|
443
|
-
adminAgentUrl: adminAgentUrl ?? undefined,
|
|
444
|
-
});
|
|
445
|
-
await server.start();
|
|
446
580
|
// Print clean startup summary
|
|
447
581
|
process.stderr.write('\n');
|
|
448
|
-
|
|
582
|
+
if (server) {
|
|
583
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
process.stderr.write(' Runtime: waiting for amodal.json (auto-boots when Studio finishes setup)\n');
|
|
587
|
+
}
|
|
449
588
|
if (studioUrl) {
|
|
450
589
|
process.stderr.write(` Studio: ${studioUrl}\n`);
|
|
451
590
|
}
|
|
@@ -455,25 +594,130 @@ Or add it to your agent's .env file:
|
|
|
455
594
|
const redactedUrl = databaseUrl.replace(/\/\/([^:]+):([^@]+)@/, '//$1:***@');
|
|
456
595
|
process.stderr.write(` Database: ${redactedUrl}\n`);
|
|
457
596
|
process.stderr.write('\n');
|
|
458
|
-
// Preflight connection check (non-blocking)
|
|
459
|
-
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
597
|
+
// Preflight connection check (non-blocking) — only meaningful when
|
|
598
|
+
// there's a manifest to load connections from.
|
|
599
|
+
if (hasManifest) {
|
|
600
|
+
const preflight = await runConnectionPreflight(repoPath);
|
|
601
|
+
if (preflight.results.length > 0) {
|
|
602
|
+
process.stderr.write('\n');
|
|
603
|
+
printPreflightTable(preflight.results);
|
|
604
|
+
if (preflight.hasFailures) {
|
|
605
|
+
process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
|
|
606
|
+
}
|
|
607
|
+
process.stderr.write('\n');
|
|
465
608
|
}
|
|
466
|
-
process.stderr.write('\n');
|
|
467
609
|
}
|
|
610
|
+
// -------------------------------------------------------------------
|
|
611
|
+
// Phase E.9 — runtime auto-(re)spawn on amodal.json change.
|
|
612
|
+
//
|
|
613
|
+
// Two flows watched by the same poller:
|
|
614
|
+
//
|
|
615
|
+
// 1. AUTO-BOOT — runtime didn't start at CLI launch (no manifest
|
|
616
|
+
// yet). Once amodal.json lands (commit_setup, the user-button
|
|
617
|
+
// commit-setup endpoint, or init-repo's skip-onboarding
|
|
618
|
+
// write), boot the runtime in place. Studio's runtime URL
|
|
619
|
+
// probe picks it up on the next tick.
|
|
620
|
+
//
|
|
621
|
+
// 2. AUTO-RESTART — runtime is already up but amodal.json has
|
|
622
|
+
// been rewritten since the last spawn. Happens after a
|
|
623
|
+
// Restart-Setup → re-commit, or when the user edits
|
|
624
|
+
// amodal.json by hand (adding a connection package, etc.).
|
|
625
|
+
// Without a restart, the running runtime keeps the stale
|
|
626
|
+
// bundle in memory and the new packages/config never load.
|
|
627
|
+
//
|
|
628
|
+
// 500ms debounce after detecting a change — lets the writer
|
|
629
|
+
// (commit_setup's atomic rename, init-repo's full write) settle
|
|
630
|
+
// before loadRepo tries to read.
|
|
631
|
+
// -------------------------------------------------------------------
|
|
632
|
+
const RUNTIME_WATCH_INTERVAL_MS = 2_000;
|
|
633
|
+
let runtimeWatcher = null;
|
|
634
|
+
let lastManifestMtime = null;
|
|
635
|
+
const stopRuntimeWatch = () => {
|
|
636
|
+
if (runtimeWatcher !== null) {
|
|
637
|
+
clearTimeout(runtimeWatcher);
|
|
638
|
+
runtimeWatcher = null;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const manifestPath = path.join(repoPath, 'amodal.json');
|
|
642
|
+
/**
|
|
643
|
+
* Read the manifest's last-modified timestamp without throwing.
|
|
644
|
+
* Returns null when the file is missing.
|
|
645
|
+
*/
|
|
646
|
+
const manifestMtime = () => {
|
|
647
|
+
try {
|
|
648
|
+
if (!existsSync(manifestPath))
|
|
649
|
+
return null;
|
|
650
|
+
return statSync(manifestPath).mtimeMs;
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
// Seed lastManifestMtime if the runtime was booted at startup so
|
|
657
|
+
// we don't immediately self-restart on the first poll.
|
|
658
|
+
if (server)
|
|
659
|
+
lastManifestMtime = manifestMtime();
|
|
660
|
+
const watchForRuntime = () => {
|
|
661
|
+
const mtime = manifestMtime();
|
|
662
|
+
const exists = mtime !== null;
|
|
663
|
+
// Auto-boot path: no runtime yet, manifest just appeared.
|
|
664
|
+
if (!server && exists) {
|
|
665
|
+
runtimeWatcher = setTimeout(() => {
|
|
666
|
+
(async () => {
|
|
667
|
+
try {
|
|
668
|
+
process.stderr.write('\n[dev] amodal.json appeared — booting runtime...\n');
|
|
669
|
+
server = await bootRuntime();
|
|
670
|
+
lastManifestMtime = manifestMtime();
|
|
671
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n\n`);
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
675
|
+
process.stderr.write(`[dev] Runtime auto-boot failed: ${msg}\n`);
|
|
676
|
+
process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
|
|
677
|
+
}
|
|
678
|
+
})().catch(() => undefined);
|
|
679
|
+
}, 500);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Auto-restart path: runtime is up, manifest was rewritten.
|
|
683
|
+
if (server && exists && lastManifestMtime !== null && mtime > lastManifestMtime) {
|
|
684
|
+
const previousServer = server;
|
|
685
|
+
// Clear server immediately so a second mtime change while
|
|
686
|
+
// we're restarting doesn't re-enter this branch.
|
|
687
|
+
server = null;
|
|
688
|
+
runtimeWatcher = setTimeout(() => {
|
|
689
|
+
(async () => {
|
|
690
|
+
try {
|
|
691
|
+
process.stderr.write('\n[dev] amodal.json changed — restarting runtime...\n');
|
|
692
|
+
await previousServer.stop();
|
|
693
|
+
server = await bootRuntime();
|
|
694
|
+
lastManifestMtime = manifestMtime();
|
|
695
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)} (restarted)\n\n`);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
699
|
+
process.stderr.write(`[dev] Runtime restart failed: ${msg}\n`);
|
|
700
|
+
process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
|
|
701
|
+
}
|
|
702
|
+
})().catch(() => undefined);
|
|
703
|
+
}, 500);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
|
|
707
|
+
};
|
|
708
|
+
runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
|
|
468
709
|
// Graceful shutdown
|
|
469
710
|
const shutdown = async (signal) => {
|
|
470
711
|
process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
|
|
712
|
+
stopRuntimeWatch();
|
|
471
713
|
// Kill subprocesses first
|
|
472
714
|
if (managedProcesses.length > 0) {
|
|
473
715
|
log.debug('subprocess_shutdown', { count: managedProcesses.length });
|
|
474
716
|
await killAll(managedProcesses);
|
|
475
717
|
}
|
|
476
|
-
|
|
718
|
+
if (server) {
|
|
719
|
+
await server.stop();
|
|
720
|
+
}
|
|
477
721
|
process.exit(0);
|
|
478
722
|
};
|
|
479
723
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
@@ -517,14 +761,6 @@ export const devCommand = {
|
|
|
517
761
|
describe: 'Only show errors',
|
|
518
762
|
default: false,
|
|
519
763
|
},
|
|
520
|
-
'studio-port': {
|
|
521
|
-
type: 'number',
|
|
522
|
-
describe: 'Port for Studio (defaults to port + 1)',
|
|
523
|
-
},
|
|
524
|
-
'admin-port': {
|
|
525
|
-
type: 'number',
|
|
526
|
-
describe: 'Port for admin agent (defaults to port + 2)',
|
|
527
|
-
},
|
|
528
764
|
'no-studio': {
|
|
529
765
|
type: 'boolean',
|
|
530
766
|
describe: 'Do not spawn Studio subprocess',
|
|
@@ -548,15 +784,11 @@ export const devCommand = {
|
|
|
548
784
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
549
785
|
const quiet = argv['quiet'];
|
|
550
786
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
551
|
-
const studioPort = argv['studio-port'];
|
|
552
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
553
|
-
const adminPort = argv['admin-port'];
|
|
554
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
555
787
|
const noStudio = argv['no-studio'] || process.env['AMODAL_NO_STUDIO'] === '1';
|
|
556
788
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
557
789
|
const noAdmin = argv['no-admin'] || process.env['AMODAL_NO_ADMIN'] === '1';
|
|
558
790
|
try {
|
|
559
|
-
await runDev({ port,
|
|
791
|
+
await runDev({ port, host, resume, verbose, quiet, noStudio, noAdmin });
|
|
560
792
|
}
|
|
561
793
|
catch (err) {
|
|
562
794
|
const msg = err instanceof Error ? err.message : String(err);
|