@dotdrelle/wiki-manager 0.6.47 → 0.7.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/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +25 -1
- package/package.json +2 -2
- package/src/cli/wiki-manager.js +236 -129
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +24 -2
- package/src/core/agentLoop.js +164 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/compose.js +2 -1
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +18 -4
- package/src/core/mcp.js +1 -1
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +31 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +21 -0
- package/src/runtime/client.js +108 -0
- package/src/runtime/lifecycle.js +60 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +73 -0
- package/src/runtime/server.js +160 -0
- package/src/runtime/server.test.js +53 -0
- package/src/runtime/store.js +292 -0
- package/src/runtime/store.test.js +103 -0
- package/src/runtime/supervisor.js +98 -0
- package/src/runtime/supervisor.test.js +99 -0
- package/src/shell/repl.js +131 -17
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +146 -11
- package/wiki-workspace +23 -18
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { runtimeTokenFromEnv as runtimeToken } from './auth.js';
|
|
2
|
+
|
|
3
|
+
function base(url) {
|
|
4
|
+
return url.replace(/\/$/, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function runtimeUrlFromEnv() {
|
|
8
|
+
return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function fetchRuntimeState({
|
|
12
|
+
url = runtimeUrlFromEnv(),
|
|
13
|
+
token = runtimeToken(),
|
|
14
|
+
} = {}) {
|
|
15
|
+
const response = await fetch(`${base(url)}/state`, {
|
|
16
|
+
headers: runtimeHeaders(token),
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) throw new Error(`Runtime state failed: HTTP ${response.status}`);
|
|
19
|
+
return response.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function checkRuntimeHealth({
|
|
23
|
+
url = runtimeUrlFromEnv(),
|
|
24
|
+
token = runtimeToken(),
|
|
25
|
+
} = {}) {
|
|
26
|
+
const response = await fetch(`${base(url)}/health`, {
|
|
27
|
+
headers: runtimeHeaders(token),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) return null;
|
|
30
|
+
return response.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function postRuntimeRun(input, {
|
|
34
|
+
url = runtimeUrlFromEnv(),
|
|
35
|
+
token = runtimeToken(),
|
|
36
|
+
workspace = null,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const response = await fetch(`${base(url)}/run`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
...runtimeHeaders(token),
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ input, workspace }),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) throw new Error(`Runtime run failed: HTTP ${response.status}`);
|
|
47
|
+
return response.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function postRuntimeCancel({
|
|
51
|
+
url = runtimeUrlFromEnv(),
|
|
52
|
+
token = runtimeToken(),
|
|
53
|
+
} = {}) {
|
|
54
|
+
const response = await fetch(`${base(url)}/cancel`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: runtimeHeaders(token),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok && response.status !== 501) throw new Error(`Runtime cancel failed: HTTP ${response.status}`);
|
|
59
|
+
return response.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runtimeHeaders(token) {
|
|
63
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function* streamRuntimeEvents({
|
|
67
|
+
url = runtimeUrlFromEnv(),
|
|
68
|
+
token = runtimeToken(),
|
|
69
|
+
signal = null,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const response = await fetch(`${base(url)}/events/stream`, {
|
|
72
|
+
headers: { ...runtimeHeaders(token), Accept: 'text/event-stream' },
|
|
73
|
+
signal,
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) throw new Error(`Runtime SSE connect failed: HTTP ${response.status}`);
|
|
76
|
+
const reader = response.body.getReader();
|
|
77
|
+
const decoder = new TextDecoder();
|
|
78
|
+
let buffer = '';
|
|
79
|
+
try {
|
|
80
|
+
while (true) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) break;
|
|
83
|
+
buffer += decoder.decode(value, { stream: true });
|
|
84
|
+
const blocks = buffer.split('\n\n');
|
|
85
|
+
buffer = blocks.pop() ?? '';
|
|
86
|
+
for (const block of blocks) {
|
|
87
|
+
let type = 'message';
|
|
88
|
+
let data = '';
|
|
89
|
+
for (const line of block.split('\n')) {
|
|
90
|
+
if (line.startsWith('event: ')) type = line.slice(7).trim();
|
|
91
|
+
else if (line.startsWith('data: ')) data = line.slice(6);
|
|
92
|
+
}
|
|
93
|
+
if (!data) continue;
|
|
94
|
+
try {
|
|
95
|
+
yield { type, data: JSON.parse(data) };
|
|
96
|
+
} catch {
|
|
97
|
+
// malformed frame — skip
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
reader.releaseLock();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function runtimeFetchOptions(token = runtimeToken()) {
|
|
107
|
+
return { headers: runtimeHeaders(token) };
|
|
108
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { checkRuntimeHealth, runtimeUrlFromEnv } from './client.js';
|
|
5
|
+
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
6
|
+
import { resolveRuntimeAuthToken, runtimeTokenFromEnv } from './auth.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const managerRoot = resolve(__dirname, '../..');
|
|
10
|
+
const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
|
|
11
|
+
|
|
12
|
+
export async function ensureRuntime({
|
|
13
|
+
host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
|
|
14
|
+
port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
|
|
15
|
+
stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
|
|
16
|
+
url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
|
|
17
|
+
timeoutMs = 5000,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const auth = resolveRuntimeAuthToken({ host, stateDir });
|
|
20
|
+
if (auth.token) process.env.WIKI_MANAGER_RUNTIME_TOKEN = auth.token;
|
|
21
|
+
const existing = await runtimeHealthOrNull(url, auth.token);
|
|
22
|
+
if (existing) return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
|
|
23
|
+
|
|
24
|
+
const child = spawn(process.execPath, [
|
|
25
|
+
binPath,
|
|
26
|
+
'runtime',
|
|
27
|
+
'--host',
|
|
28
|
+
host,
|
|
29
|
+
'--port',
|
|
30
|
+
String(port),
|
|
31
|
+
'--state-dir',
|
|
32
|
+
stateDir,
|
|
33
|
+
], {
|
|
34
|
+
detached: true,
|
|
35
|
+
stdio: 'ignore',
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
...(auth.token ? { WIKI_MANAGER_RUNTIME_TOKEN: auth.token } : {}),
|
|
39
|
+
WIKI_MANAGER_RUNTIME_CHILD: '1',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
|
|
44
|
+
const deadline = Date.now() + timeoutMs;
|
|
45
|
+
let health = null;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
health = await runtimeHealthOrNull(url, auth.token);
|
|
48
|
+
if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath };
|
|
49
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Runtime did not become healthy at ${url}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
|
|
55
|
+
try {
|
|
56
|
+
return await checkRuntimeHealth({ url, token });
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
+
import { sessionActivities, terminalFailures } from '../core/activity.js';
|
|
3
|
+
import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
|
|
4
|
+
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
5
|
+
|
|
6
|
+
async function waitForRuntimeActivities(session, startedActivities, { timeoutMs, signal, pollBusy }) {
|
|
7
|
+
const deadline = Date.now() + timeoutMs;
|
|
8
|
+
const trackedKeys = new Set(startedActivities.map((activity) => activity.key));
|
|
9
|
+
emitRuntimeLog(session, `activity-loop: tracking ${trackedKeys.size} activity(s)`);
|
|
10
|
+
|
|
11
|
+
let tracked = [];
|
|
12
|
+
while (Date.now() < deadline) {
|
|
13
|
+
throwIfAborted(signal, 'Runtime run cancelled.');
|
|
14
|
+
await pollActivitiesOnce(session, { pollBusy, signal });
|
|
15
|
+
tracked = sessionActivities(session).filter((activity) => trackedKeys.has(activity.key));
|
|
16
|
+
const active = tracked.filter((activity) => !activity.terminal);
|
|
17
|
+
if (active.length === 0) {
|
|
18
|
+
const failures = terminalFailures(tracked);
|
|
19
|
+
if (failures.length > 0) {
|
|
20
|
+
for (const failure of failures) {
|
|
21
|
+
emitRuntimeLog(session, `activity-loop: ${failure.label} -> ${failure.status}${failure.error ? ` (${failure.error})` : ''}`);
|
|
22
|
+
}
|
|
23
|
+
return { ok: false, completed: tracked };
|
|
24
|
+
}
|
|
25
|
+
emitRuntimeLog(session, 'activity-loop: tracked activities terminal');
|
|
26
|
+
return { ok: true, completed: tracked };
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1000));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
emitRuntimeLog(session, 'activity-loop: timeout');
|
|
32
|
+
return { ok: false, timedOut: true, completed: tracked };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy }) {
|
|
36
|
+
return runAgenticLoop(agent, session, initialInput, {
|
|
37
|
+
signal,
|
|
38
|
+
timeoutMs,
|
|
39
|
+
maxTurns,
|
|
40
|
+
runId,
|
|
41
|
+
abortMessage: 'Runtime run cancelled.',
|
|
42
|
+
waitForActivities: (turnSession, startedActivities, waitOptions) =>
|
|
43
|
+
waitForRuntimeActivities(turnSession, startedActivities, { ...waitOptions, pollBusy }),
|
|
44
|
+
onTurnStart: ({ turn, maxTurns: totalTurns }) => {
|
|
45
|
+
emitRuntimeLog(session, `agentic-loop: turn ${turn}/${totalTurns}`);
|
|
46
|
+
},
|
|
47
|
+
onAssistantMessage: ({ response }) => {
|
|
48
|
+
const lastMessage = session.agentProjection?.conversation?.at(-1);
|
|
49
|
+
if (lastMessage?.role !== 'assistant' || lastMessage.content !== response) {
|
|
50
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
51
|
+
origin: 'agent',
|
|
52
|
+
runId,
|
|
53
|
+
payload: { content: response },
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
onPlanExtracted: ({ steps }) => {
|
|
58
|
+
emitRuntimeLog(session, `agentic-loop: plan extracted from text (${steps.length} steps)`);
|
|
59
|
+
},
|
|
60
|
+
onComplete: () => {
|
|
61
|
+
emitRuntimeLog(session, 'agentic-loop: no pending activity or plan step');
|
|
62
|
+
},
|
|
63
|
+
onPendingSteps: ({ pendingSteps }) => {
|
|
64
|
+
emitRuntimeLog(session, `agentic-loop: ${pendingSteps.length} pending step(s), continuing`);
|
|
65
|
+
},
|
|
66
|
+
onActivitiesStarted: ({ activities }) => {
|
|
67
|
+
emitRuntimeLog(session, `agentic-loop: ${activities.length} new activity(s), waiting`);
|
|
68
|
+
},
|
|
69
|
+
onMaxTurns: ({ maxTurns: totalTurns }) => {
|
|
70
|
+
emitRuntimeLog(session, `agentic-loop: max turns (${totalTurns}) reached`);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { runtimeTokenFromEnv } from './auth.js';
|
|
3
|
+
|
|
4
|
+
export function startRuntimeServer({
|
|
5
|
+
host = '0.0.0.0',
|
|
6
|
+
port = 7788,
|
|
7
|
+
token = runtimeTokenFromEnv(),
|
|
8
|
+
store,
|
|
9
|
+
session,
|
|
10
|
+
run,
|
|
11
|
+
cancel,
|
|
12
|
+
} = {}) {
|
|
13
|
+
const clients = new Set();
|
|
14
|
+
let running = false;
|
|
15
|
+
let currentAbortController = null;
|
|
16
|
+
|
|
17
|
+
function publish(event) {
|
|
18
|
+
const payload = `event: agent_event\ndata: ${JSON.stringify(event)}\n\n`;
|
|
19
|
+
for (const response of clients) {
|
|
20
|
+
response.write(payload);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const server = createServer(async (request, response) => {
|
|
25
|
+
try {
|
|
26
|
+
if (!isAuthorized(request, token)) {
|
|
27
|
+
sendJson(response, 401, { error: 'Unauthorized' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
32
|
+
if (request.method === 'GET' && url.pathname === '/health') {
|
|
33
|
+
sendJson(response, 200, { ok: true, status: running ? 'running' : 'idle', dbPath: store.dbPath });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (request.method === 'GET' && url.pathname === '/state') {
|
|
37
|
+
sendJson(response, 200, store.getState(session));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (request.method === 'GET' && url.pathname === '/events') {
|
|
41
|
+
sendJson(response, 200, { events: store.listEvents() });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (request.method === 'GET' && url.pathname === '/events/stream') {
|
|
45
|
+
response.writeHead(200, {
|
|
46
|
+
'Content-Type': 'text/event-stream',
|
|
47
|
+
'Cache-Control': 'no-cache',
|
|
48
|
+
Connection: 'keep-alive',
|
|
49
|
+
'X-Accel-Buffering': 'no',
|
|
50
|
+
});
|
|
51
|
+
response.write(`event: state\ndata: ${JSON.stringify(store.getState(session))}\n\n`);
|
|
52
|
+
clients.add(response);
|
|
53
|
+
request.on('close', () => clients.delete(response));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (request.method === 'POST' && url.pathname === '/run') {
|
|
57
|
+
if (running) {
|
|
58
|
+
sendJson(response, 409, { error: 'A runtime run is already active.' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
running = true;
|
|
62
|
+
currentAbortController = new AbortController();
|
|
63
|
+
try {
|
|
64
|
+
const body = await readJson(request);
|
|
65
|
+
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
66
|
+
if (!input) {
|
|
67
|
+
running = false;
|
|
68
|
+
currentAbortController = null;
|
|
69
|
+
sendJson(response, 400, { error: 'Missing input.' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
run(body, { signal: currentAbortController.signal })
|
|
73
|
+
.catch((err) => {
|
|
74
|
+
session._onRuntimeError?.(err);
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
running = false;
|
|
78
|
+
currentAbortController = null;
|
|
79
|
+
});
|
|
80
|
+
sendJson(response, 202, { accepted: true });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
running = false;
|
|
83
|
+
currentAbortController = null;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (request.method === 'POST' && url.pathname === '/cancel') {
|
|
89
|
+
if (!running || !currentAbortController) {
|
|
90
|
+
sendJson(response, 200, { cancelled: false, reason: 'no active run' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
currentAbortController.abort();
|
|
94
|
+
await cancel?.();
|
|
95
|
+
sendJson(response, 202, { cancelled: true });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sendJson(response, 404, { error: 'Not found' });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
sendJson(response, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
server.once('error', reject);
|
|
107
|
+
server.listen(port, host, () => {
|
|
108
|
+
server.off('error', reject);
|
|
109
|
+
const address = server.address();
|
|
110
|
+
resolve({
|
|
111
|
+
host,
|
|
112
|
+
port: typeof address === 'object' && address ? address.port : port,
|
|
113
|
+
publish,
|
|
114
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
115
|
+
for (const response of clients) response.end();
|
|
116
|
+
clients.clear();
|
|
117
|
+
server.close((err) => (err ? closeReject(err) : closeResolve()));
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isAuthorized(request, token) {
|
|
125
|
+
if (!token) return true;
|
|
126
|
+
const authorization = request.headers.authorization ?? '';
|
|
127
|
+
if (authorization === `Bearer ${token}`) return true;
|
|
128
|
+
return request.headers['x-runtime-token'] === token;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sendJson(response, statusCode, value) {
|
|
132
|
+
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
133
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(request) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let raw = '';
|
|
139
|
+
request.setEncoding('utf8');
|
|
140
|
+
request.on('data', (chunk) => {
|
|
141
|
+
raw += chunk;
|
|
142
|
+
if (raw.length > 1024 * 1024) {
|
|
143
|
+
reject(new Error('Request body too large.'));
|
|
144
|
+
request.destroy();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
request.on('end', () => {
|
|
148
|
+
if (!raw.trim()) {
|
|
149
|
+
resolve({});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
resolve(JSON.parse(raw));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
reject(err);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
request.on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { startRuntimeServer } from './server.js';
|
|
4
|
+
|
|
5
|
+
test('runtime server accepts only one active run', async (t) => {
|
|
6
|
+
let releaseRun;
|
|
7
|
+
let runCount = 0;
|
|
8
|
+
let handle;
|
|
9
|
+
try {
|
|
10
|
+
handle = await startRuntimeServer({
|
|
11
|
+
host: '127.0.0.1',
|
|
12
|
+
port: 0,
|
|
13
|
+
store: {
|
|
14
|
+
dbPath: ':memory:',
|
|
15
|
+
getState: () => ({ status: 'idle' }),
|
|
16
|
+
listEvents: () => [],
|
|
17
|
+
},
|
|
18
|
+
session: {},
|
|
19
|
+
run: async () => {
|
|
20
|
+
runCount += 1;
|
|
21
|
+
await new Promise((resolve) => { releaseRun = resolve; });
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err?.code === 'EPERM') {
|
|
26
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
34
|
+
const [first, second] = await Promise.all([
|
|
35
|
+
fetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ input: 'first' }),
|
|
39
|
+
}),
|
|
40
|
+
fetch(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ input: 'second' }),
|
|
44
|
+
}),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
assert.deepEqual([first.status, second.status].sort(), [202, 409]);
|
|
48
|
+
assert.equal(runCount, 1);
|
|
49
|
+
} finally {
|
|
50
|
+
releaseRun?.();
|
|
51
|
+
await handle.close();
|
|
52
|
+
}
|
|
53
|
+
});
|