@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/src/commands/dev.ts
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import type {CommandModule} from 'yargs';
|
|
8
8
|
import type {ChildProcess} from 'node:child_process';
|
|
9
|
-
import {existsSync, readFileSync} from 'node:fs';
|
|
9
|
+
import {existsSync, readFileSync, statSync} from 'node:fs';
|
|
10
10
|
import {createRequire} from 'node:module';
|
|
11
11
|
import {spawn} from 'node:child_process';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import {fileURLToPath} from 'node:url';
|
|
14
14
|
import {createLocalServer, initLogLevel, interceptConsole, log} from '@amodalai/runtime';
|
|
15
15
|
import {ensureAdminAgent, getAdminAgentConfig, getAdminAgentVersion, checkRegistryVersion} from '@amodalai/core';
|
|
16
|
-
import {
|
|
16
|
+
import {findRepoRootOrCwd} from '../shared/repo-discovery.js';
|
|
17
17
|
import {createServer} from 'node:net';
|
|
18
18
|
import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
|
|
19
19
|
import {resolveEnv} from '../shared/env-resolution.js';
|
|
@@ -24,6 +24,8 @@ import {getDb, ensureSchema, closeDb} from '@amodalai/db';
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
26
|
const DEFAULT_RUNTIME_PORT = 3847;
|
|
27
|
+
const DEFAULT_STUDIO_PORT = 3848;
|
|
28
|
+
const DEFAULT_ADMIN_PORT = 3849;
|
|
27
29
|
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Port checking
|
|
@@ -79,8 +81,6 @@ function resolveStudioDir(): string | null {
|
|
|
79
81
|
export interface DevOptions {
|
|
80
82
|
cwd?: string;
|
|
81
83
|
port?: number;
|
|
82
|
-
studioPort?: number;
|
|
83
|
-
adminPort?: number;
|
|
84
84
|
host?: string;
|
|
85
85
|
resume?: string;
|
|
86
86
|
verbose?: number;
|
|
@@ -106,9 +106,130 @@ interface ManagedProcess {
|
|
|
106
106
|
* with a label. Lines are buffered per-stream to avoid interleaved output
|
|
107
107
|
* from concurrent subprocesses.
|
|
108
108
|
*/
|
|
109
|
+
/**
|
|
110
|
+
* Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
|
|
111
|
+
* format change in the runtime logger can't silently break dev observability.
|
|
112
|
+
*
|
|
113
|
+
* Passes warnings/errors and the Phase 4 intent-routing telemetry through
|
|
114
|
+
* (the latter is exactly what makes intent vs LLM ratios visible in dev).
|
|
115
|
+
*/
|
|
116
|
+
export function passesQuietFilter(line: string): boolean {
|
|
117
|
+
return (
|
|
118
|
+
line.includes('[WARN]') ||
|
|
119
|
+
line.includes('[ERROR]') ||
|
|
120
|
+
line.includes('Error') ||
|
|
121
|
+
line.includes('intent_') ||
|
|
122
|
+
line.includes('agent_loop_start') ||
|
|
123
|
+
line.includes('route_intent') ||
|
|
124
|
+
line.includes('route_llm')
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Best-effort pretty-printer for routing telemetry. The runtime emits
|
|
130
|
+
* `[INFO] route_intent {…json…}` style lines (for production aggregators);
|
|
131
|
+
* in the dev terminal that's mostly visual noise. This rewrites the few
|
|
132
|
+
* route/intent lifecycle events into one-liner status lines so a human
|
|
133
|
+
* scanning their terminal can answer "is intent routing working?" at a
|
|
134
|
+
* glance. Falls back to the original line when parsing fails so we never
|
|
135
|
+
* eat a line that the user might need.
|
|
136
|
+
*
|
|
137
|
+
* Returns:
|
|
138
|
+
* - a string (possibly the same as input) — print it verbatim
|
|
139
|
+
* - null — suppress the line entirely (e.g. dropping intent_matched
|
|
140
|
+
* once route_intent has already been printed for the same turn)
|
|
141
|
+
*/
|
|
142
|
+
export function formatLineForDev(line: string): string | null {
|
|
143
|
+
// Only touch our recognized events. Match `[LEVEL] event_name {json}` form.
|
|
144
|
+
const m = /^\[(INFO|WARN|ERROR)\] ([a-z_]+) (\{.*\})\s*$/.exec(line);
|
|
145
|
+
if (!m) return line;
|
|
146
|
+
|
|
147
|
+
const [, , event, jsonStr] = m;
|
|
148
|
+
let parsed: unknown;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(jsonStr);
|
|
151
|
+
} catch {
|
|
152
|
+
return line;
|
|
153
|
+
}
|
|
154
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
155
|
+
return line;
|
|
156
|
+
}
|
|
157
|
+
const data: Record<string, unknown> = Object.fromEntries(
|
|
158
|
+
Object.entries(parsed),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const str = (k: string): string =>
|
|
162
|
+
typeof data[k] === 'string' ? (data[k]) : '';
|
|
163
|
+
const num = (k: string): number =>
|
|
164
|
+
typeof data[k] === 'number' ? (data[k]) : 0;
|
|
165
|
+
|
|
166
|
+
switch (event) {
|
|
167
|
+
case 'route_intent': {
|
|
168
|
+
const preview = str('userMessagePreview');
|
|
169
|
+
return `→ INTENT ${str('intentId').padEnd(22)} "${preview}"`;
|
|
170
|
+
}
|
|
171
|
+
case 'route_llm': {
|
|
172
|
+
const reason = str('reason');
|
|
173
|
+
const detail = str('intentId') || str('userMessagePreview');
|
|
174
|
+
return `→ LLM ${reason.padEnd(22)} ${detail ? `"${detail}"` : ''}`.trimEnd();
|
|
175
|
+
}
|
|
176
|
+
case 'intent_completed': {
|
|
177
|
+
const toolCount = num('toolCount');
|
|
178
|
+
const ms = num('durationMs');
|
|
179
|
+
return ` ✓ ${str('intentId')} done (${String(toolCount)} tools, ${String(ms)}ms)`;
|
|
180
|
+
}
|
|
181
|
+
case 'intent_fell_through': {
|
|
182
|
+
const ms = num('durationMs');
|
|
183
|
+
return ` ↓ ${str('intentId')} fell through to LLM (${String(ms)}ms)`;
|
|
184
|
+
}
|
|
185
|
+
case 'intent_errored': {
|
|
186
|
+
return ` ✗ ${str('intentId')} ERRORED: ${str('error')}`;
|
|
187
|
+
}
|
|
188
|
+
case 'intent_blocked_by_confirmation': {
|
|
189
|
+
return ` ✗ ${str('intentId')} blocked: ${str('toolName')} requires confirmation`;
|
|
190
|
+
}
|
|
191
|
+
case 'intent_returned_null_after_committing': {
|
|
192
|
+
return ` ⚠ ${str('intentId')} returned null after ${String(num('toolCallsStarted'))} tool calls — treating as completion`;
|
|
193
|
+
}
|
|
194
|
+
case 'intent_matched':
|
|
195
|
+
case 'agent_loop_start': {
|
|
196
|
+
// Both are redundant in dev: intent_matched duplicates the
|
|
197
|
+
// route_intent line that fires a millisecond earlier, and
|
|
198
|
+
// agent_loop_start always follows a route_llm line (manager.ts
|
|
199
|
+
// emits route_llm immediately before invoking the LLM). Drop
|
|
200
|
+
// them to keep the dev terminal scannable; production
|
|
201
|
+
// aggregators still get them on stderr from the runtime
|
|
202
|
+
// process directly (this formatter only runs in pipeWithLabel).
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
case 'tool_log': {
|
|
206
|
+
// Tools call ctx.log(...) for noteworthy progress (e.g.
|
|
207
|
+
// install_template emits "Cloned whodatdev/template-X into
|
|
208
|
+
// agent repo (N connection packages installed)"). When fired
|
|
209
|
+
// during an intent run the callId starts with `intent_`, so
|
|
210
|
+
// these lines pass the quiet filter — but as raw JSON they
|
|
211
|
+
// bury the useful message inside callId/session noise. Strip
|
|
212
|
+
// to a clean nested bullet.
|
|
213
|
+
const msg = str('message');
|
|
214
|
+
if (!msg) return null;
|
|
215
|
+
return ` · ${msg}`;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return line;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
109
222
|
function pipeWithLabel(child: ChildProcess, label: string, opts?: {quiet?: boolean}): void {
|
|
110
223
|
const prefix = `[${label}] `;
|
|
111
224
|
const quiet = opts?.quiet ?? false;
|
|
225
|
+
|
|
226
|
+
const writeLine = (line: string): void => {
|
|
227
|
+
if (quiet && !passesQuietFilter(line)) return;
|
|
228
|
+
const pretty = formatLineForDev(line);
|
|
229
|
+
if (pretty === null) return;
|
|
230
|
+
process.stderr.write(`${prefix}${pretty}\n`);
|
|
231
|
+
};
|
|
232
|
+
|
|
112
233
|
for (const stream of [child.stdout, child.stderr]) {
|
|
113
234
|
if (!stream) continue;
|
|
114
235
|
let buffer = '';
|
|
@@ -117,16 +238,11 @@ function pipeWithLabel(child: ChildProcess, label: string, opts?: {quiet?: boole
|
|
|
117
238
|
buffer += chunk;
|
|
118
239
|
const lines = buffer.split('\n');
|
|
119
240
|
buffer = lines.pop() ?? '';
|
|
120
|
-
for (const line of lines)
|
|
121
|
-
if (quiet && !line.includes('[WARN]') && !line.includes('[ERROR]') && !line.includes('Error')) continue;
|
|
122
|
-
process.stderr.write(`${prefix}${line}\n`);
|
|
123
|
-
}
|
|
241
|
+
for (const line of lines) writeLine(line);
|
|
124
242
|
});
|
|
125
243
|
stream.on('end', () => {
|
|
126
244
|
if (buffer.length > 0) {
|
|
127
|
-
|
|
128
|
-
process.stderr.write(`${prefix}${buffer}\n`);
|
|
129
|
-
}
|
|
245
|
+
writeLine(buffer);
|
|
130
246
|
buffer = '';
|
|
131
247
|
}
|
|
132
248
|
});
|
|
@@ -179,7 +295,6 @@ function spawnStudio(opts: {
|
|
|
179
295
|
repoPath: string;
|
|
180
296
|
agentId?: string;
|
|
181
297
|
adminAgentUrl?: string;
|
|
182
|
-
basePath?: string;
|
|
183
298
|
}): StudioSpawnResult | null {
|
|
184
299
|
const studioDir = resolveStudioDir();
|
|
185
300
|
if (!studioDir) {
|
|
@@ -198,7 +313,6 @@ function spawnStudio(opts: {
|
|
|
198
313
|
HOSTNAME: '0.0.0.0',
|
|
199
314
|
...(opts.agentId ? {AGENT_ID: opts.agentId} : {}),
|
|
200
315
|
...(opts.adminAgentUrl ? {ADMIN_AGENT_URL: opts.adminAgentUrl} : {}),
|
|
201
|
-
...(opts.basePath ? {BASE_PATH: opts.basePath} : {}),
|
|
202
316
|
};
|
|
203
317
|
|
|
204
318
|
// Pre-built server (npm install): dist-server/studio-server.js
|
|
@@ -331,6 +445,9 @@ async function spawnAdminAgent(opts: {
|
|
|
331
445
|
AMODAL_NO_STUDIO: '1',
|
|
332
446
|
REPO_PATH: opts.repoPath,
|
|
333
447
|
};
|
|
448
|
+
if (opts.studioUrl) {
|
|
449
|
+
env['STUDIO_URL'] = opts.studioUrl;
|
|
450
|
+
}
|
|
334
451
|
|
|
335
452
|
const child = spawn(
|
|
336
453
|
process.execPath,
|
|
@@ -371,24 +488,13 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
|
|
|
371
488
|
initLogLevel({verbosity: options.verbose ?? 0, quiet: options.quiet ?? false});
|
|
372
489
|
interceptConsole();
|
|
373
490
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
Create a new agent:
|
|
382
|
-
|
|
383
|
-
amodal init Initialize this directory
|
|
384
|
-
amodal dev Start the dev server
|
|
385
|
-
|
|
386
|
-
Or if your agent is in another directory:
|
|
387
|
-
|
|
388
|
-
cd /path/to/agent && amodal dev
|
|
389
|
-
|
|
390
|
-
`);
|
|
391
|
-
process.exit(1);
|
|
491
|
+
// Empty directories are allowed: the create flow in Studio scaffolds
|
|
492
|
+
// amodal.json from a chosen template (or admin-agent conversation), so
|
|
493
|
+
// the user can `amodal dev` before they have a project at all. We just
|
|
494
|
+
// skip the runtime in that case — it can't loadRepo without a manifest.
|
|
495
|
+
const {root: repoPath, hasManifest} = findRepoRootOrCwd(options.cwd);
|
|
496
|
+
if (!hasManifest) {
|
|
497
|
+
log.info('dev_create_flow_mode', {repoPath});
|
|
392
498
|
}
|
|
393
499
|
|
|
394
500
|
// -------------------------------------------------------------------------
|
|
@@ -419,7 +525,18 @@ Or add it to your agent's .env file:
|
|
|
419
525
|
// Make DATABASE_URL available to child processes (runtime, Studio)
|
|
420
526
|
process.env['DATABASE_URL'] = databaseUrl;
|
|
421
527
|
|
|
422
|
-
//
|
|
528
|
+
// Resolve AGENT_ID — must be set BEFORE spawning subprocesses so
|
|
529
|
+
// Studio, runtime, and admin-agent all key `setup_state` rows by
|
|
530
|
+
// the same id. Three sources, in priority order:
|
|
531
|
+
// 1. amodal.json#name when the file exists (post-setup repos)
|
|
532
|
+
// 2. The repo dir basename (pre-setup repos — what the user calls
|
|
533
|
+
// their working directory; stable across the setup flow)
|
|
534
|
+
// 3. 'default' as a last-ditch fallback
|
|
535
|
+
// Without this, the admin-agent process would compute its own id
|
|
536
|
+
// from its own bundle (name: "admin") and Studio's `getAgentId()`
|
|
537
|
+
// would fall back to "default", leaving `commit_setup` marking a
|
|
538
|
+
// different row than Studio reads — IndexPage would loop the user
|
|
539
|
+
// back to /setup even after a successful commit.
|
|
423
540
|
const amodalJsonPath = path.join(repoPath, 'amodal.json');
|
|
424
541
|
let agentId: string | undefined;
|
|
425
542
|
if (existsSync(amodalJsonPath)) {
|
|
@@ -432,7 +549,6 @@ Or add it to your agent's .env file:
|
|
|
432
549
|
const nameValue = parsed !== undefined ? (parsed as Record<string, unknown>)['name'] : undefined;
|
|
433
550
|
if (typeof nameValue === 'string') {
|
|
434
551
|
agentId = nameValue;
|
|
435
|
-
process.env['AGENT_ID'] = agentId;
|
|
436
552
|
}
|
|
437
553
|
} catch (err: unknown) {
|
|
438
554
|
log.warn('amodal_json_parse_error', {
|
|
@@ -441,6 +557,14 @@ Or add it to your agent's .env file:
|
|
|
441
557
|
});
|
|
442
558
|
}
|
|
443
559
|
}
|
|
560
|
+
if (!agentId) {
|
|
561
|
+
// Pre-setup fallback. `path.basename(repoPath)` gives "test-empty"
|
|
562
|
+
// or whatever the user named their working dir — stable enough
|
|
563
|
+
// for setup_state coordination, and the agent name will switch
|
|
564
|
+
// to amodal.json#name on the next CLI invocation post-commit.
|
|
565
|
+
agentId = path.basename(repoPath) || 'default';
|
|
566
|
+
}
|
|
567
|
+
process.env['AGENT_ID'] = agentId;
|
|
444
568
|
|
|
445
569
|
// -------------------------------------------------------------------------
|
|
446
570
|
// Run schema migrations
|
|
@@ -466,15 +590,17 @@ Or add it to your agent's .env file:
|
|
|
466
590
|
// -------------------------------------------------------------------------
|
|
467
591
|
|
|
468
592
|
const runtimePort = options.port ?? DEFAULT_RUNTIME_PORT;
|
|
469
|
-
const studioPort =
|
|
470
|
-
const adminPort =
|
|
593
|
+
const studioPort = DEFAULT_STUDIO_PORT;
|
|
594
|
+
const adminPort = DEFAULT_ADMIN_PORT;
|
|
471
595
|
|
|
472
|
-
|
|
596
|
+
if (hasManifest) {
|
|
597
|
+
await assertPortFree(runtimePort);
|
|
598
|
+
}
|
|
473
599
|
if (!options.noStudio) await assertPortFree(studioPort);
|
|
474
600
|
if (!options.noAdmin) await assertPortFree(adminPort);
|
|
475
601
|
|
|
476
602
|
log.debug('ports_allocated', {
|
|
477
|
-
runtime: runtimePort,
|
|
603
|
+
runtime: hasManifest ? runtimePort : null,
|
|
478
604
|
studio: options.noStudio ? null : studioPort,
|
|
479
605
|
admin: options.noAdmin ? null : adminPort,
|
|
480
606
|
});
|
|
@@ -516,52 +642,71 @@ Or add it to your agent's .env file:
|
|
|
516
642
|
}
|
|
517
643
|
|
|
518
644
|
// -------------------------------------------------------------------------
|
|
519
|
-
// Start the runtime server
|
|
645
|
+
// Start the runtime server (skipped when there's no amodal.json — the
|
|
646
|
+
// runtime can't loadRepo on an empty directory; the create flow in Studio
|
|
647
|
+
// takes the user from an empty repo to a configured one, after which they
|
|
648
|
+
// ctrl+C and re-run `amodal dev` to pick up the runtime).
|
|
520
649
|
// -------------------------------------------------------------------------
|
|
521
650
|
|
|
522
|
-
log.debug('starting_dev_server', {repoPath});
|
|
651
|
+
log.debug('starting_dev_server', {repoPath, hasManifest});
|
|
523
652
|
|
|
524
653
|
try {
|
|
525
|
-
let
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
654
|
+
let server: Awaited<ReturnType<typeof createLocalServer>> | null = null;
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Boot the runtime server. Factored out so the empty-repo
|
|
658
|
+
* branch (Phase E.9) can call it lazily when amodal.json lands.
|
|
659
|
+
*/
|
|
660
|
+
const bootRuntime = async (): Promise<typeof server> => {
|
|
661
|
+
let staticAppDir: string | undefined;
|
|
662
|
+
|
|
663
|
+
// Use pre-built static assets for the SPA.
|
|
664
|
+
// Vite dev middleware is only used inside the monorepo with `pnpm dev`.
|
|
665
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
666
|
+
const candidates = [
|
|
667
|
+
// esbuild bundle: bundle/app/
|
|
668
|
+
path.resolve(scriptDir, 'app'),
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
// Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
|
|
672
|
+
const require = createRequire(import.meta.url);
|
|
673
|
+
const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
|
|
674
|
+
candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
|
|
675
|
+
|
|
676
|
+
for (const dir of candidates) {
|
|
677
|
+
if (existsSync(path.join(dir, 'index.html'))) {
|
|
678
|
+
log.debug('serving_prebuilt_app', {path: staticAppDir});
|
|
679
|
+
staticAppDir = dir;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
545
682
|
}
|
|
546
|
-
}
|
|
547
683
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
684
|
+
const created = await createLocalServer({
|
|
685
|
+
repoPath,
|
|
686
|
+
port: runtimePort,
|
|
687
|
+
host,
|
|
688
|
+
hotReload: true,
|
|
689
|
+
corsOrigin: '*',
|
|
690
|
+
staticAppDir,
|
|
691
|
+
resumeSessionId: options.resume,
|
|
692
|
+
studioUrl: studioUrl ?? undefined,
|
|
693
|
+
adminAgentUrl: adminAgentUrl ?? undefined,
|
|
694
|
+
});
|
|
695
|
+
await created.start();
|
|
696
|
+
return created;
|
|
697
|
+
};
|
|
559
698
|
|
|
560
|
-
|
|
699
|
+
if (hasManifest) {
|
|
700
|
+
server = await bootRuntime();
|
|
701
|
+
}
|
|
561
702
|
|
|
562
703
|
// Print clean startup summary
|
|
563
704
|
process.stderr.write('\n');
|
|
564
|
-
|
|
705
|
+
if (server) {
|
|
706
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
|
|
707
|
+
} else {
|
|
708
|
+
process.stderr.write(' Runtime: waiting for amodal.json (auto-boots when Studio finishes setup)\n');
|
|
709
|
+
}
|
|
565
710
|
if (studioUrl) {
|
|
566
711
|
process.stderr.write(` Studio: ${studioUrl}\n`);
|
|
567
712
|
}
|
|
@@ -575,28 +720,140 @@ Or add it to your agent's .env file:
|
|
|
575
720
|
process.stderr.write(` Database: ${redactedUrl}\n`);
|
|
576
721
|
process.stderr.write('\n');
|
|
577
722
|
|
|
578
|
-
// Preflight connection check (non-blocking)
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
723
|
+
// Preflight connection check (non-blocking) — only meaningful when
|
|
724
|
+
// there's a manifest to load connections from.
|
|
725
|
+
if (hasManifest) {
|
|
726
|
+
const preflight = await runConnectionPreflight(repoPath);
|
|
727
|
+
if (preflight.results.length > 0) {
|
|
728
|
+
process.stderr.write('\n');
|
|
729
|
+
printPreflightTable(preflight.results);
|
|
730
|
+
if (preflight.hasFailures) {
|
|
731
|
+
process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
|
|
732
|
+
}
|
|
733
|
+
process.stderr.write('\n');
|
|
585
734
|
}
|
|
586
|
-
process.stderr.write('\n');
|
|
587
735
|
}
|
|
588
736
|
|
|
737
|
+
// -------------------------------------------------------------------
|
|
738
|
+
// Phase E.9 — runtime auto-(re)spawn on amodal.json change.
|
|
739
|
+
//
|
|
740
|
+
// Two flows watched by the same poller:
|
|
741
|
+
//
|
|
742
|
+
// 1. AUTO-BOOT — runtime didn't start at CLI launch (no manifest
|
|
743
|
+
// yet). Once amodal.json lands (commit_setup, the user-button
|
|
744
|
+
// commit-setup endpoint, or init-repo's skip-onboarding
|
|
745
|
+
// write), boot the runtime in place. Studio's runtime URL
|
|
746
|
+
// probe picks it up on the next tick.
|
|
747
|
+
//
|
|
748
|
+
// 2. AUTO-RESTART — runtime is already up but amodal.json has
|
|
749
|
+
// been rewritten since the last spawn. Happens after a
|
|
750
|
+
// Restart-Setup → re-commit, or when the user edits
|
|
751
|
+
// amodal.json by hand (adding a connection package, etc.).
|
|
752
|
+
// Without a restart, the running runtime keeps the stale
|
|
753
|
+
// bundle in memory and the new packages/config never load.
|
|
754
|
+
//
|
|
755
|
+
// 500ms debounce after detecting a change — lets the writer
|
|
756
|
+
// (commit_setup's atomic rename, init-repo's full write) settle
|
|
757
|
+
// before loadRepo tries to read.
|
|
758
|
+
// -------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
const RUNTIME_WATCH_INTERVAL_MS = 2_000;
|
|
761
|
+
let runtimeWatcher: ReturnType<typeof setTimeout> | null = null;
|
|
762
|
+
let lastManifestMtime: number | null = null;
|
|
763
|
+
|
|
764
|
+
const stopRuntimeWatch = (): void => {
|
|
765
|
+
if (runtimeWatcher !== null) {
|
|
766
|
+
clearTimeout(runtimeWatcher);
|
|
767
|
+
runtimeWatcher = null;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const manifestPath = path.join(repoPath, 'amodal.json');
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Read the manifest's last-modified timestamp without throwing.
|
|
775
|
+
* Returns null when the file is missing.
|
|
776
|
+
*/
|
|
777
|
+
const manifestMtime = (): number | null => {
|
|
778
|
+
try {
|
|
779
|
+
if (!existsSync(manifestPath)) return null;
|
|
780
|
+
return statSync(manifestPath).mtimeMs;
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// Seed lastManifestMtime if the runtime was booted at startup so
|
|
787
|
+
// we don't immediately self-restart on the first poll.
|
|
788
|
+
if (server) lastManifestMtime = manifestMtime();
|
|
789
|
+
|
|
790
|
+
const watchForRuntime = (): void => {
|
|
791
|
+
const mtime = manifestMtime();
|
|
792
|
+
const exists = mtime !== null;
|
|
793
|
+
|
|
794
|
+
// Auto-boot path: no runtime yet, manifest just appeared.
|
|
795
|
+
if (!server && exists) {
|
|
796
|
+
runtimeWatcher = setTimeout(() => {
|
|
797
|
+
(async () => {
|
|
798
|
+
try {
|
|
799
|
+
process.stderr.write('\n[dev] amodal.json appeared — booting runtime...\n');
|
|
800
|
+
server = await bootRuntime();
|
|
801
|
+
lastManifestMtime = manifestMtime();
|
|
802
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n\n`);
|
|
803
|
+
} catch (err: unknown) {
|
|
804
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
805
|
+
process.stderr.write(`[dev] Runtime auto-boot failed: ${msg}\n`);
|
|
806
|
+
process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
|
|
807
|
+
}
|
|
808
|
+
})().catch(() => undefined);
|
|
809
|
+
}, 500);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Auto-restart path: runtime is up, manifest was rewritten.
|
|
814
|
+
if (server && exists && lastManifestMtime !== null && mtime > lastManifestMtime) {
|
|
815
|
+
const previousServer = server;
|
|
816
|
+
// Clear server immediately so a second mtime change while
|
|
817
|
+
// we're restarting doesn't re-enter this branch.
|
|
818
|
+
server = null;
|
|
819
|
+
runtimeWatcher = setTimeout(() => {
|
|
820
|
+
(async () => {
|
|
821
|
+
try {
|
|
822
|
+
process.stderr.write('\n[dev] amodal.json changed — restarting runtime...\n');
|
|
823
|
+
await previousServer.stop();
|
|
824
|
+
server = await bootRuntime();
|
|
825
|
+
lastManifestMtime = manifestMtime();
|
|
826
|
+
process.stderr.write(` Runtime: http://localhost:${String(runtimePort)} (restarted)\n\n`);
|
|
827
|
+
} catch (err: unknown) {
|
|
828
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
829
|
+
process.stderr.write(`[dev] Runtime restart failed: ${msg}\n`);
|
|
830
|
+
process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
|
|
831
|
+
}
|
|
832
|
+
})().catch(() => undefined);
|
|
833
|
+
}, 500);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
|
|
841
|
+
|
|
589
842
|
// Graceful shutdown
|
|
590
843
|
const shutdown = async (signal: string): Promise<void> => {
|
|
591
844
|
process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
|
|
592
845
|
|
|
846
|
+
stopRuntimeWatch();
|
|
847
|
+
|
|
593
848
|
// Kill subprocesses first
|
|
594
849
|
if (managedProcesses.length > 0) {
|
|
595
850
|
log.debug('subprocess_shutdown', {count: managedProcesses.length});
|
|
596
851
|
await killAll(managedProcesses);
|
|
597
852
|
}
|
|
598
853
|
|
|
599
|
-
|
|
854
|
+
if (server) {
|
|
855
|
+
await server.stop();
|
|
856
|
+
}
|
|
600
857
|
process.exit(0);
|
|
601
858
|
};
|
|
602
859
|
|
|
@@ -641,14 +898,6 @@ export const devCommand: CommandModule = {
|
|
|
641
898
|
describe: 'Only show errors',
|
|
642
899
|
default: false,
|
|
643
900
|
},
|
|
644
|
-
'studio-port': {
|
|
645
|
-
type: 'number',
|
|
646
|
-
describe: 'Port for Studio (defaults to port + 1)',
|
|
647
|
-
},
|
|
648
|
-
'admin-port': {
|
|
649
|
-
type: 'number',
|
|
650
|
-
describe: 'Port for admin agent (defaults to port + 2)',
|
|
651
|
-
},
|
|
652
901
|
'no-studio': {
|
|
653
902
|
type: 'boolean',
|
|
654
903
|
describe: 'Do not spawn Studio subprocess',
|
|
@@ -671,17 +920,12 @@ export const devCommand: CommandModule = {
|
|
|
671
920
|
const verbose = argv['verbose'] as number;
|
|
672
921
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
673
922
|
const quiet = argv['quiet'] as boolean;
|
|
674
|
-
|
|
675
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
676
|
-
const studioPort = argv['studio-port'] as number | undefined;
|
|
677
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
678
|
-
const adminPort = argv['admin-port'] as number | undefined;
|
|
679
923
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
680
924
|
const noStudio = (argv['no-studio'] as boolean) || process.env['AMODAL_NO_STUDIO'] === '1';
|
|
681
925
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
682
926
|
const noAdmin = (argv['no-admin'] as boolean) || process.env['AMODAL_NO_ADMIN'] === '1';
|
|
683
927
|
try {
|
|
684
|
-
await runDev({port,
|
|
928
|
+
await runDev({port, host, resume, verbose, quiet, noStudio, noAdmin});
|
|
685
929
|
} catch (err: unknown) {
|
|
686
930
|
const msg = err instanceof Error ? err.message : String(err);
|
|
687
931
|
process.stderr.write(`\n Error: ${msg}\n\n`);
|
|
@@ -238,8 +238,8 @@ export class PlatformClient {
|
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
240
|
* Trigger a remote build:
|
|
241
|
-
* 1. Get
|
|
242
|
-
* 2. Upload the tarball directly to R2
|
|
241
|
+
* 1. Get scoped R2 temp credentials from the platform API
|
|
242
|
+
* 2. Upload the tarball directly to R2 with those creds
|
|
243
243
|
* 3. Tell the platform API to trigger a Fly Machine build
|
|
244
244
|
*
|
|
245
245
|
* Returns a buildId for polling.
|
|
@@ -250,27 +250,45 @@ export class PlatformClient {
|
|
|
250
250
|
tarballPath: string,
|
|
251
251
|
message?: string,
|
|
252
252
|
): Promise<{ buildId: string }> {
|
|
253
|
-
// Step 1:
|
|
254
|
-
|
|
255
|
-
const uploadInfo = await this.request<{
|
|
253
|
+
// Step 1: Mint scoped R2 temp credentials for the upload
|
|
254
|
+
|
|
255
|
+
const uploadInfo = await this.request<{
|
|
256
|
+
buildId: string;
|
|
257
|
+
tarballKey: string;
|
|
258
|
+
bucket: string;
|
|
259
|
+
endpoint: string;
|
|
260
|
+
accessKeyId: string;
|
|
261
|
+
secretAccessKey: string;
|
|
262
|
+
sessionToken: string;
|
|
263
|
+
}>(
|
|
256
264
|
'POST',
|
|
257
265
|
'/api/deploys/build?action=upload-url',
|
|
258
266
|
{appId},
|
|
259
267
|
);
|
|
260
268
|
|
|
261
|
-
// Step 2: Upload tarball directly to R2
|
|
269
|
+
// Step 2: Upload tarball directly to R2 with the scoped temp creds
|
|
262
270
|
const {readFileSync} = await import('node:fs');
|
|
263
271
|
const tarball = readFileSync(tarballPath);
|
|
264
272
|
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
const {S3Client, PutObjectCommand} = await import('@aws-sdk/client-s3');
|
|
274
|
+
const s3 = new S3Client({
|
|
275
|
+
region: 'auto',
|
|
276
|
+
endpoint: uploadInfo.endpoint,
|
|
277
|
+
credentials: {
|
|
278
|
+
accessKeyId: uploadInfo.accessKeyId,
|
|
279
|
+
secretAccessKey: uploadInfo.secretAccessKey,
|
|
280
|
+
sessionToken: uploadInfo.sessionToken,
|
|
281
|
+
},
|
|
269
282
|
});
|
|
270
283
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
284
|
+
await s3.send(
|
|
285
|
+
new PutObjectCommand({
|
|
286
|
+
Bucket: uploadInfo.bucket,
|
|
287
|
+
Key: uploadInfo.tarballKey,
|
|
288
|
+
Body: tarball,
|
|
289
|
+
ContentType: 'application/gzip',
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
274
292
|
|
|
275
293
|
// Step 3: Trigger the build
|
|
276
294
|
|