@tuongaz/seeflow 0.1.3
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 +95 -0
- package/bin/seeflow +32 -0
- package/bin/seeflow-mcp +23 -0
- package/dist/web/assets/html2canvas.esm-CBrSDip1.js +22 -0
- package/dist/web/assets/index-BlhIMoXf.js +8005 -0
- package/dist/web/assets/index-CIpouxGY.css +1 -0
- package/dist/web/assets/index.es-D6Hswegt.js +18 -0
- package/dist/web/assets/purify.es-CLGrRn1w.js +3 -0
- package/dist/web/index.html +13 -0
- package/examples/ecommerce-platform/.seeflow/scripts/play.ts +2 -0
- package/examples/ecommerce-platform/.seeflow/seeflow.json +250 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +18 -0
- package/examples/order-pipeline/.seeflow/seeflow.json +86 -0
- package/examples/order-pipeline/README.md +11 -0
- package/examples/order-pipeline/package.json +6 -0
- package/package.json +55 -0
- package/public/runtime/tailwind.js +24394 -0
- package/src/api.ts +1093 -0
- package/src/cli.ts +329 -0
- package/src/demo.ts +65 -0
- package/src/diagram.ts +432 -0
- package/src/events.ts +70 -0
- package/src/mcp-shim.ts +93 -0
- package/src/mcp.ts +540 -0
- package/src/operations.ts +1192 -0
- package/src/process-spawner.ts +75 -0
- package/src/proxy.ts +393 -0
- package/src/registry.ts +139 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +441 -0
- package/src/sdk-template.ts +37 -0
- package/src/sdk-writer.ts +37 -0
- package/src/server.ts +211 -0
- package/src/shellout.ts +30 -0
- package/src/status-runner.ts +374 -0
- package/src/watcher.ts +383 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { serveStatic } from 'hono/bun';
|
|
6
|
+
import { type ProxyFacade, createApi } from './api.ts';
|
|
7
|
+
import { createDemoRouter } from './demo.ts';
|
|
8
|
+
import { type EventBus, createEventBus } from './events.ts';
|
|
9
|
+
import { createMcpServer } from './mcp.ts';
|
|
10
|
+
import { type ProcessSpawner, defaultProcessSpawner } from './process-spawner.ts';
|
|
11
|
+
import { type Registry, createRegistry } from './registry.ts';
|
|
12
|
+
import type { Spawner } from './shellout.ts';
|
|
13
|
+
import { type StatusRunner, createStatusRunner } from './status-runner.ts';
|
|
14
|
+
import { type DemoWatcher, createWatcher } from './watcher.ts';
|
|
15
|
+
|
|
16
|
+
/** Absolute path to the vendored runtime asset directory. Resolved relative
|
|
17
|
+
* to this source file so the path is stable whether the studio runs from
|
|
18
|
+
* `apps/studio/` in dev or from `node_modules/@tuongaz/seeflow/` when the
|
|
19
|
+
* package is installed as a dependency. */
|
|
20
|
+
export const RUNTIME_ASSETS_DIR = resolvePath(import.meta.dir, '../public/runtime');
|
|
21
|
+
|
|
22
|
+
export type AppMode = 'dev' | 'prod';
|
|
23
|
+
|
|
24
|
+
export interface CreateAppOptions {
|
|
25
|
+
mode?: AppMode;
|
|
26
|
+
/** Where the Vite dev server is reachable in dev mode. */
|
|
27
|
+
viteDevUrl?: string;
|
|
28
|
+
/** Filesystem root for the built web bundle in prod mode. */
|
|
29
|
+
staticRoot?: string;
|
|
30
|
+
/** Inject a registry; defaults to one persisted at ~/.seeflow/registry.json. */
|
|
31
|
+
registry?: Registry;
|
|
32
|
+
/** Inject an event bus; defaults to a fresh in-memory bus. */
|
|
33
|
+
events?: EventBus;
|
|
34
|
+
/** Inject a watcher; defaults to one wired to the registry + event bus. */
|
|
35
|
+
watcher?: DemoWatcher;
|
|
36
|
+
/** Skip starting fs.watch on registered demos. Useful for tests. */
|
|
37
|
+
watchAllOnBoot?: boolean;
|
|
38
|
+
/** Disable file watching entirely (no fs handles leaked). Useful for tests. */
|
|
39
|
+
disableWatcher?: boolean;
|
|
40
|
+
/** Inject a shellout spawner; tests use this to avoid launching $EDITOR/Finder. */
|
|
41
|
+
spawner?: Spawner;
|
|
42
|
+
/** Override the host platform for tests covering darwin/win32/linux branches. */
|
|
43
|
+
platform?: NodeJS.Platform;
|
|
44
|
+
/** Inject a StatusRunner; defaults to one wired to the registry + event bus. */
|
|
45
|
+
statusRunner?: StatusRunner;
|
|
46
|
+
/** Inject a ProcessSpawner for the play-action script; defaults to letting
|
|
47
|
+
* proxy.ts pick `defaultProcessSpawner`. Tests use this to drive runPlay
|
|
48
|
+
* with an in-memory fake spawner. */
|
|
49
|
+
processSpawner?: ProcessSpawner;
|
|
50
|
+
/** Inject a ProxyFacade — tests use this to short-circuit runPlay /
|
|
51
|
+
* runReset / stopAllPlays and assert call order. */
|
|
52
|
+
proxy?: ProxyFacade;
|
|
53
|
+
/** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
|
|
54
|
+
projectBaseDir?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_VITE_DEV_URL = 'http://localhost:5173';
|
|
58
|
+
const DEFAULT_STATIC_ROOT = resolvePath(import.meta.dir, '../dist/web');
|
|
59
|
+
|
|
60
|
+
const inferMode = (): AppMode => {
|
|
61
|
+
if (process.env.NODE_ENV === 'production') return 'prod';
|
|
62
|
+
if (process.env.NODE_ENV === 'development') return 'dev';
|
|
63
|
+
// No NODE_ENV: use prod if the built web bundle exists, dev otherwise.
|
|
64
|
+
const distIndex = resolvePath(import.meta.dir, '../dist/web/index.html');
|
|
65
|
+
return existsSync(distIndex) ? 'prod' : 'dev';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function createApp(options: CreateAppOptions = {}): Hono {
|
|
69
|
+
const mode = options.mode ?? inferMode();
|
|
70
|
+
const viteDevUrl = options.viteDevUrl ?? DEFAULT_VITE_DEV_URL;
|
|
71
|
+
const staticRoot = options.staticRoot ?? DEFAULT_STATIC_ROOT;
|
|
72
|
+
const registry = options.registry ?? createRegistry();
|
|
73
|
+
const events = options.events ?? createEventBus();
|
|
74
|
+
const watcher = options.disableWatcher
|
|
75
|
+
? undefined
|
|
76
|
+
: (options.watcher ?? createWatcher({ registry, events }));
|
|
77
|
+
const statusRunner =
|
|
78
|
+
options.statusRunner ??
|
|
79
|
+
createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
|
|
80
|
+
|
|
81
|
+
if (watcher && (options.watchAllOnBoot ?? true)) {
|
|
82
|
+
watcher.watchAll();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const app = new Hono();
|
|
86
|
+
|
|
87
|
+
app.get('/health', (c) => c.json({ ok: true }));
|
|
88
|
+
|
|
89
|
+
// Vendored runtime assets (e.g. Tailwind Play CDN for htmlNode). Served
|
|
90
|
+
// identically in dev and prod so they don't depend on the web bundle.
|
|
91
|
+
// The `{[A-Za-z0-9._-]+}` regex constrains :file to a single safe segment,
|
|
92
|
+
// making traversal (`..`, `/`) impossible by construction.
|
|
93
|
+
app.get('/runtime/:file{[A-Za-z0-9._-]+}', async (c) => {
|
|
94
|
+
const file = c.req.param('file');
|
|
95
|
+
const abs = resolvePath(RUNTIME_ASSETS_DIR, file);
|
|
96
|
+
const f = Bun.file(abs);
|
|
97
|
+
if (!(await f.exists())) return c.notFound();
|
|
98
|
+
return new Response(f.stream(), {
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': f.type || 'application/octet-stream',
|
|
101
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.route('/demo', createDemoRouter(events));
|
|
107
|
+
|
|
108
|
+
app.route(
|
|
109
|
+
'/api',
|
|
110
|
+
createApi({
|
|
111
|
+
registry,
|
|
112
|
+
events,
|
|
113
|
+
watcher,
|
|
114
|
+
spawner: options.spawner,
|
|
115
|
+
platform: options.platform,
|
|
116
|
+
statusRunner,
|
|
117
|
+
processSpawner: options.processSpawner,
|
|
118
|
+
proxy: options.proxy,
|
|
119
|
+
projectBaseDir: options.projectBaseDir,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Per-request stateless MCP transport: every /mcp call builds a fresh
|
|
124
|
+
// Server + Streamable HTTP transport pair. The transport's stateless mode
|
|
125
|
+
// forbids reuse across requests (it would collide JSON-RPC ids between
|
|
126
|
+
// clients), and a per-request server is cheap since registry/watcher are
|
|
127
|
+
// injected references. `enableJsonResponse: true` keeps responses as plain
|
|
128
|
+
// JSON instead of SSE — simpler for non-streaming clients and what the
|
|
129
|
+
// stdio shim forwards from the MCP Client.
|
|
130
|
+
app.all('/mcp', async (c) => {
|
|
131
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
132
|
+
sessionIdGenerator: undefined,
|
|
133
|
+
enableJsonResponse: true,
|
|
134
|
+
});
|
|
135
|
+
const mcpServer = createMcpServer({
|
|
136
|
+
registry,
|
|
137
|
+
watcher,
|
|
138
|
+
projectBaseDir: options.projectBaseDir,
|
|
139
|
+
});
|
|
140
|
+
await mcpServer.connect(transport);
|
|
141
|
+
try {
|
|
142
|
+
return await transport.handleRequest(c.req.raw);
|
|
143
|
+
} finally {
|
|
144
|
+
await mcpServer.close().catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (mode === 'dev') {
|
|
149
|
+
app.all('*', async (c) => {
|
|
150
|
+
const url = new URL(c.req.url);
|
|
151
|
+
if (url.pathname.startsWith('/api/') || url.pathname === '/mcp') return c.notFound();
|
|
152
|
+
|
|
153
|
+
const target = `${viteDevUrl}${url.pathname}${url.search}`;
|
|
154
|
+
try {
|
|
155
|
+
const upstream = await fetch(target, {
|
|
156
|
+
method: c.req.method,
|
|
157
|
+
headers: c.req.raw.headers,
|
|
158
|
+
body: c.req.method === 'GET' || c.req.method === 'HEAD' ? undefined : c.req.raw.body,
|
|
159
|
+
redirect: 'manual',
|
|
160
|
+
});
|
|
161
|
+
return new Response(upstream.body, {
|
|
162
|
+
status: upstream.status,
|
|
163
|
+
statusText: upstream.statusText,
|
|
164
|
+
headers: upstream.headers,
|
|
165
|
+
});
|
|
166
|
+
} catch (_err) {
|
|
167
|
+
return c.text(
|
|
168
|
+
`SeeFlow dev proxy could not reach Vite at ${viteDevUrl}.\nMake sure \`bun run dev\` is running so Vite is up.\n`,
|
|
169
|
+
502,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
app.use('/*', serveStatic({ root: staticRoot }));
|
|
175
|
+
app.get('*', serveStatic({ root: staticRoot, path: 'index.html' }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return app;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ServeOptions extends CreateAppOptions {
|
|
182
|
+
port?: number;
|
|
183
|
+
hostname?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function serve(options: ServeOptions = {}) {
|
|
187
|
+
const port = options.port ?? 4321;
|
|
188
|
+
const hostname = options.hostname ?? '0.0.0.0';
|
|
189
|
+
const app = createApp(options);
|
|
190
|
+
return Bun.serve({ port, hostname, fetch: app.fetch });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (import.meta.main) {
|
|
194
|
+
const registry = createRegistry();
|
|
195
|
+
const events = createEventBus();
|
|
196
|
+
const statusRunner = createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
|
|
197
|
+
const server = serve({ registry, events, statusRunner });
|
|
198
|
+
const shutdown = async () => {
|
|
199
|
+
try {
|
|
200
|
+
await statusRunner.stopAll();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.warn(
|
|
203
|
+
`[server] statusRunner.stopAll() failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
process.exit(0);
|
|
207
|
+
};
|
|
208
|
+
process.once('SIGINT', () => void shutdown());
|
|
209
|
+
process.once('SIGTERM', () => void shutdown());
|
|
210
|
+
console.log(`SeeFlow Studio listening on http://${server.hostname}:${server.port}`);
|
|
211
|
+
}
|
package/src/shellout.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fire-and-forget shellout used by the `/files/open` and `/files/reveal`
|
|
3
|
+
* endpoints. The spawner is injectable so tests can verify the exact command
|
|
4
|
+
* and arguments without actually launching $EDITOR or Finder.
|
|
5
|
+
*
|
|
6
|
+
* The default implementation uses `Bun.spawn` in detached fire-and-forget
|
|
7
|
+
* mode: spawn-time failures (ENOENT, EACCES) throw synchronously from
|
|
8
|
+
* `Bun.spawn` and we surface them as `{ ok: false, error }`. On success we
|
|
9
|
+
* unref the child so the studio process doesn't wait for it to exit.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ShellRunResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Spawner = (cmd: string, args: string[]) => Promise<ShellRunResult>;
|
|
18
|
+
|
|
19
|
+
export const defaultSpawner: Spawner = async (cmd, args) => {
|
|
20
|
+
try {
|
|
21
|
+
const proc = Bun.spawn({
|
|
22
|
+
cmd: [cmd, ...args],
|
|
23
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
24
|
+
});
|
|
25
|
+
proc.unref();
|
|
26
|
+
return { ok: true };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusRunner: spawns the long-running `statusAction` scripts declared on a
|
|
3
|
+
* demo's nodes, kills the previous batch on respawn, and streams each script's
|
|
4
|
+
* newline-delimited JSON stdout into `node:status` SSE events.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle per demo (held in `trackedByDemo`):
|
|
7
|
+
* restart(demoId) → kill previous batch (SIGTERM → 2s grace → SIGKILL in
|
|
8
|
+
* parallel) → re-read demo from disk → spawn each `statusAction` node in
|
|
9
|
+
* parallel.
|
|
10
|
+
* stop(demoId) / stopAll() → kill without respawn.
|
|
11
|
+
*
|
|
12
|
+
* Per-script lifecycle: spawn → drain stdout line-by-line → for each line,
|
|
13
|
+
* JSON.parse + StatusReportSchema.safeParse → on success broadcast `node:status`,
|
|
14
|
+
* on failure console.warn. A `maxLifetimeMs` timer kills the process and emits a
|
|
15
|
+
* final error report. An unsolicited exit with code !== 0 emits a final error
|
|
16
|
+
* report. A solicited kill (restart / stop / maxLifetimeMs) is silent on exit.
|
|
17
|
+
*
|
|
18
|
+
* Defense-in-depth on scriptPath mirrors proxy.ts:`resolveScript` — realpath
|
|
19
|
+
* the resolved file against `<repoPath>/.seeflow/` so a symlink-escape can't
|
|
20
|
+
* spawn arbitrary scripts outside the project.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
24
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
25
|
+
import type { EventBus } from './events.ts';
|
|
26
|
+
import { type ProcessSpawner, type SpawnHandle, defaultProcessSpawner } from './process-spawner.ts';
|
|
27
|
+
import type { DemoEntry, Registry } from './registry.ts';
|
|
28
|
+
import { type Demo, DemoSchema, type StatusAction, StatusReportSchema } from './schema.ts';
|
|
29
|
+
|
|
30
|
+
export interface StatusRunner {
|
|
31
|
+
/** Kill the current batch for `demoId` and respawn from the on-disk demo. */
|
|
32
|
+
restart(demoId: string): Promise<void>;
|
|
33
|
+
/** Kill all status scripts for `demoId`. */
|
|
34
|
+
stop(demoId: string): Promise<void>;
|
|
35
|
+
/** Kill all status scripts for every demo. Used at studio shutdown. */
|
|
36
|
+
stopAll(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CreateStatusRunnerOptions {
|
|
40
|
+
registry: Registry;
|
|
41
|
+
events: EventBus;
|
|
42
|
+
/** Injectable for tests; defaults to `defaultProcessSpawner`. */
|
|
43
|
+
spawner?: ProcessSpawner;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_MAX_LIFETIME_MS = 3_600_000;
|
|
47
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
48
|
+
const MALFORMED_LINE_TRUNCATE = 200;
|
|
49
|
+
const SCRIPT_PATH_ESCAPE = 'scriptPath escapes project root';
|
|
50
|
+
|
|
51
|
+
interface TrackedHandle {
|
|
52
|
+
nodeId: string;
|
|
53
|
+
handle: SpawnHandle;
|
|
54
|
+
lifetimeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
/** True when our own code initiated the kill (restart / stop / lifetime). */
|
|
56
|
+
expectingKill: boolean;
|
|
57
|
+
/** Resolves once stdout drain + stderr drain + exit handler have all run. */
|
|
58
|
+
lifecycle: Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ResolvedScript = { ok: true; absPath: string } | { ok: false };
|
|
62
|
+
|
|
63
|
+
function resolveScript(repoPath: string, scriptPath: string): ResolvedScript {
|
|
64
|
+
const seeflowRoot = join(repoPath, '.seeflow');
|
|
65
|
+
let realRoot: string;
|
|
66
|
+
try {
|
|
67
|
+
realRoot = realpathSync(seeflowRoot);
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: false };
|
|
70
|
+
}
|
|
71
|
+
const target = resolve(seeflowRoot, scriptPath);
|
|
72
|
+
let realTarget: string;
|
|
73
|
+
try {
|
|
74
|
+
realTarget = realpathSync(target);
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false };
|
|
77
|
+
}
|
|
78
|
+
const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
|
79
|
+
if (realTarget !== realRoot && !realTarget.startsWith(rootWithSep)) {
|
|
80
|
+
return { ok: false };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, absPath: realTarget };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
|
|
86
|
+
const env: Record<string, string> = {};
|
|
87
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
88
|
+
if (typeof v === 'string') env[k] = v;
|
|
89
|
+
}
|
|
90
|
+
return { ...env, ...extra };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function killAndWait(handle: SpawnHandle): Promise<void> {
|
|
94
|
+
handle.kill('SIGTERM');
|
|
95
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
const gracePromise = new Promise<'grace'>((res) => {
|
|
97
|
+
graceTimer = setTimeout(() => res('grace'), SIGKILL_GRACE_MS);
|
|
98
|
+
});
|
|
99
|
+
const winner = await Promise.race([handle.exited.then(() => 'exited' as const), gracePromise]);
|
|
100
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
101
|
+
if (winner === 'grace') {
|
|
102
|
+
handle.kill('SIGKILL');
|
|
103
|
+
await handle.exited;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read newline-delimited UTF-8 from `stream`, invoke `onLine` for each
|
|
108
|
+
// non-empty line (CRLF tolerated). Returns when the stream ends — including
|
|
109
|
+
// any trailing chunk that has no final newline.
|
|
110
|
+
async function streamLines(
|
|
111
|
+
stream: ReadableStream<Uint8Array>,
|
|
112
|
+
onLine: (line: string) => void,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const reader = stream.getReader();
|
|
115
|
+
const decoder = new TextDecoder();
|
|
116
|
+
let buf = '';
|
|
117
|
+
try {
|
|
118
|
+
while (true) {
|
|
119
|
+
const { value, done } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
buf += decoder.decode(value, { stream: true });
|
|
122
|
+
while (true) {
|
|
123
|
+
const nl = buf.indexOf('\n');
|
|
124
|
+
if (nl === -1) break;
|
|
125
|
+
const raw = buf.slice(0, nl);
|
|
126
|
+
buf = buf.slice(nl + 1);
|
|
127
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
128
|
+
if (line.length > 0) onLine(line);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
buf += decoder.decode();
|
|
132
|
+
const tail = buf.endsWith('\r') ? buf.slice(0, -1) : buf;
|
|
133
|
+
if (tail.length > 0) onLine(tail);
|
|
134
|
+
} finally {
|
|
135
|
+
reader.releaseLock();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function truncate(s: string, n: number): string {
|
|
140
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadDemo(entry: DemoEntry): Promise<Demo | undefined> {
|
|
144
|
+
const fullPath = isAbsolute(entry.demoPath)
|
|
145
|
+
? entry.demoPath
|
|
146
|
+
: join(entry.repoPath, entry.demoPath);
|
|
147
|
+
if (!existsSync(fullPath)) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
const raw = await Bun.file(fullPath).json();
|
|
150
|
+
const parsed = DemoSchema.safeParse(raw);
|
|
151
|
+
return parsed.success ? parsed.data : undefined;
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface StatusNode {
|
|
158
|
+
nodeId: string;
|
|
159
|
+
action: StatusAction;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectStatusNodes(demo: Demo): StatusNode[] {
|
|
163
|
+
const out: StatusNode[] = [];
|
|
164
|
+
for (const node of demo.nodes) {
|
|
165
|
+
if (node.type !== 'playNode' && node.type !== 'stateNode') continue;
|
|
166
|
+
const action = node.data.statusAction;
|
|
167
|
+
if (!action) continue;
|
|
168
|
+
out.push({ nodeId: node.id, action });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRunner {
|
|
174
|
+
const { registry, events } = options;
|
|
175
|
+
const spawner = options.spawner ?? defaultProcessSpawner;
|
|
176
|
+
const trackedByDemo = new Map<string, TrackedHandle[]>();
|
|
177
|
+
|
|
178
|
+
function spawnStatusScript(
|
|
179
|
+
demoId: string,
|
|
180
|
+
repoPath: string,
|
|
181
|
+
sn: StatusNode,
|
|
182
|
+
): TrackedHandle | undefined {
|
|
183
|
+
const { nodeId, action } = sn;
|
|
184
|
+
|
|
185
|
+
const resolved = resolveScript(repoPath, action.scriptPath);
|
|
186
|
+
if (!resolved.ok) {
|
|
187
|
+
events.broadcast({
|
|
188
|
+
type: 'node:status',
|
|
189
|
+
demoId,
|
|
190
|
+
payload: {
|
|
191
|
+
nodeId,
|
|
192
|
+
state: 'error',
|
|
193
|
+
summary: SCRIPT_PATH_ESCAPE,
|
|
194
|
+
ts: Date.now(),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const runId = crypto.randomUUID();
|
|
201
|
+
const env = buildChildEnv({
|
|
202
|
+
SEEFLOW_DEMO_ID: demoId,
|
|
203
|
+
SEEFLOW_NODE_ID: nodeId,
|
|
204
|
+
SEEFLOW_RUN_ID: runId,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let handle: SpawnHandle;
|
|
208
|
+
try {
|
|
209
|
+
handle = spawner.spawn({
|
|
210
|
+
cmd: [action.interpreter, ...(action.args ?? []), resolved.absPath],
|
|
211
|
+
cwd: repoPath,
|
|
212
|
+
env,
|
|
213
|
+
stdin: 'ignore',
|
|
214
|
+
});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
events.broadcast({
|
|
218
|
+
type: 'node:status',
|
|
219
|
+
demoId,
|
|
220
|
+
payload: {
|
|
221
|
+
nodeId,
|
|
222
|
+
state: 'error',
|
|
223
|
+
summary: 'status script failed to spawn',
|
|
224
|
+
detail: message,
|
|
225
|
+
ts: Date.now(),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const tracked: TrackedHandle = {
|
|
232
|
+
nodeId,
|
|
233
|
+
handle,
|
|
234
|
+
lifetimeTimer: undefined,
|
|
235
|
+
expectingKill: false,
|
|
236
|
+
// Assigned below; allSettled ensures lifecycle resolves regardless of
|
|
237
|
+
// which leg errored. Initialized eagerly so the struct is fully built.
|
|
238
|
+
lifecycle: Promise.resolve(),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const maxLifetimeMs = action.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS;
|
|
242
|
+
tracked.lifetimeTimer = setTimeout(() => {
|
|
243
|
+
// We're killing it; suppress the unsolicited-exit error branch.
|
|
244
|
+
tracked.expectingKill = true;
|
|
245
|
+
events.broadcast({
|
|
246
|
+
type: 'node:status',
|
|
247
|
+
demoId,
|
|
248
|
+
payload: {
|
|
249
|
+
nodeId,
|
|
250
|
+
state: 'error',
|
|
251
|
+
summary: 'status script exceeded maxLifetimeMs',
|
|
252
|
+
ts: Date.now(),
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
void killAndWait(handle);
|
|
256
|
+
}, maxLifetimeMs);
|
|
257
|
+
|
|
258
|
+
const stdoutDrain = streamLines(handle.stdout, (rawLine) => {
|
|
259
|
+
const trimmed = rawLine.trim();
|
|
260
|
+
if (trimmed.length === 0) return;
|
|
261
|
+
let parsed: unknown;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(trimmed);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
266
|
+
console.warn(
|
|
267
|
+
`[status-runner] malformed status line (demo=${demoId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const result = StatusReportSchema.safeParse(parsed);
|
|
272
|
+
if (!result.success) {
|
|
273
|
+
const reason = result.error.issues[0]?.message ?? 'schema validation failed';
|
|
274
|
+
console.warn(
|
|
275
|
+
`[status-runner] invalid status report (demo=${demoId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const report = result.data;
|
|
280
|
+
events.broadcast({
|
|
281
|
+
type: 'node:status',
|
|
282
|
+
demoId,
|
|
283
|
+
payload: {
|
|
284
|
+
nodeId,
|
|
285
|
+
state: report.state,
|
|
286
|
+
summary: report.summary,
|
|
287
|
+
detail: report.detail,
|
|
288
|
+
data: report.data,
|
|
289
|
+
ts: report.ts ?? Date.now(),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const stderrDrain = new Response(handle.stderr).text();
|
|
295
|
+
|
|
296
|
+
const onExit = handle.exited.then((code) => {
|
|
297
|
+
if (tracked.lifetimeTimer) {
|
|
298
|
+
clearTimeout(tracked.lifetimeTimer);
|
|
299
|
+
tracked.lifetimeTimer = undefined;
|
|
300
|
+
}
|
|
301
|
+
if (tracked.expectingKill) return;
|
|
302
|
+
if (code !== 0) {
|
|
303
|
+
events.broadcast({
|
|
304
|
+
type: 'node:status',
|
|
305
|
+
demoId,
|
|
306
|
+
payload: {
|
|
307
|
+
nodeId,
|
|
308
|
+
state: 'error',
|
|
309
|
+
summary: `status script exited with code ${code}`,
|
|
310
|
+
ts: Date.now(),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
tracked.lifecycle = Promise.allSettled([stdoutDrain, stderrDrain, onExit]).then(() => {
|
|
317
|
+
/* swallow — lifecycle is observed only for await-on-stop */
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return tracked;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function killBatch(existing: TrackedHandle[]): Promise<void> {
|
|
324
|
+
for (const t of existing) {
|
|
325
|
+
t.expectingKill = true;
|
|
326
|
+
if (t.lifetimeTimer) {
|
|
327
|
+
clearTimeout(t.lifetimeTimer);
|
|
328
|
+
t.lifetimeTimer = undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await Promise.all(
|
|
332
|
+
existing.map(async (t) => {
|
|
333
|
+
await killAndWait(t.handle);
|
|
334
|
+
await t.lifecycle;
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function restart(demoId: string): Promise<void> {
|
|
340
|
+
const existing = trackedByDemo.get(demoId);
|
|
341
|
+
if (existing) {
|
|
342
|
+
trackedByDemo.delete(demoId);
|
|
343
|
+
await killBatch(existing);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const entry = registry.getById(demoId);
|
|
347
|
+
if (!entry) return;
|
|
348
|
+
const demo = await loadDemo(entry);
|
|
349
|
+
if (!demo) return;
|
|
350
|
+
const statusNodes = collectStatusNodes(demo);
|
|
351
|
+
if (statusNodes.length === 0) return;
|
|
352
|
+
|
|
353
|
+
const batch: TrackedHandle[] = [];
|
|
354
|
+
for (const sn of statusNodes) {
|
|
355
|
+
const t = spawnStatusScript(demoId, entry.repoPath, sn);
|
|
356
|
+
if (t) batch.push(t);
|
|
357
|
+
}
|
|
358
|
+
if (batch.length > 0) trackedByDemo.set(demoId, batch);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function stop(demoId: string): Promise<void> {
|
|
362
|
+
const existing = trackedByDemo.get(demoId);
|
|
363
|
+
if (!existing) return;
|
|
364
|
+
trackedByDemo.delete(demoId);
|
|
365
|
+
await killBatch(existing);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function stopAll(): Promise<void> {
|
|
369
|
+
const demoIds = [...trackedByDemo.keys()];
|
|
370
|
+
await Promise.all(demoIds.map((id) => stop(id)));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { restart, stop, stopAll };
|
|
374
|
+
}
|