@werewolf-solutions/dev 0.0.0-stage → 0.1.1
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 +123 -2
- package/bin/werewolf-dev.js +36 -0
- package/package.json +26 -4
- package/src/agents.js +228 -0
- package/src/api.js +43 -0
- package/src/cli.js +81 -0
- package/src/contract.js +38 -0
- package/src/draft.js +80 -0
- package/src/mcpRunner.js +251 -0
- package/src/runner.js +168 -0
- package/werewolf-mcp.mjs +236 -0
package/src/draft.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One draft from the website agent, shared by the local worker and the remote runner:
|
|
3
|
+
* generate with the chosen runner, check it against the checklist with the same runner,
|
|
4
|
+
* and regenerate once if the check found problems. Moved from the server's
|
|
5
|
+
* services/siteBuilder/agentDraft.js; the repo's local agent uses this copy too.
|
|
6
|
+
*/
|
|
7
|
+
const { runAgent } = require('./agents');
|
|
8
|
+
const { extractJson, sanitizeFiles } = require('./contract');
|
|
9
|
+
|
|
10
|
+
const TICK_MS = 30 * 1000;
|
|
11
|
+
const seconds = (since) => `${Math.round((Date.now() - since) / 1000)}s`;
|
|
12
|
+
const kb = (value) => `${(Buffer.byteLength(String(value || '')) / 1024).toFixed(1)}KB`;
|
|
13
|
+
|
|
14
|
+
// One call to the model, with a "still working" line every 30s so a long run doesn't look stuck.
|
|
15
|
+
async function ask({ run, prompt, images = [], cwd, log, label, step }) {
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
log(`${label}: ${step}: asking ${run.provider} ${run.model}${run.effort ? ` (${run.effort})` : ''}, prompt ${kb(prompt)}${images.length ? `, ${images.length} screenshot(s)` : ''}…`);
|
|
18
|
+
const timer = setInterval(() => log(`${label}: ${step}: still waiting for ${run.provider} (${seconds(started)})`), TICK_MS);
|
|
19
|
+
try {
|
|
20
|
+
const reply = await runAgent({ ...run, prompt, images, cwd });
|
|
21
|
+
log(`${label}: ${step}: reply after ${seconds(started)}, ${kb(reply)}`);
|
|
22
|
+
return reply;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
log(`${label}: ${step}: ${run.provider} failed after ${seconds(started)}: ${error.message}`);
|
|
25
|
+
throw error;
|
|
26
|
+
} finally {
|
|
27
|
+
clearInterval(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function generate({ run, prompt, images, workspace, log = () => {}, label = 'draft', step = 'generate' }) {
|
|
32
|
+
const once = async (text, attempt) => {
|
|
33
|
+
const reply = await ask({ run, prompt: text, images, cwd: workspace, log, label, step: attempt });
|
|
34
|
+
const all = sanitizeFiles(extractJson(reply).files);
|
|
35
|
+
// Owner photos already live in assets/; generated files never replace them.
|
|
36
|
+
const files = all.filter((file) => !file.path.startsWith('assets/'));
|
|
37
|
+
if (files.length < all.length) log(`${label}: ${attempt}: dropped ${all.length - files.length} file(s) under assets/`);
|
|
38
|
+
log(`${label}: ${attempt}: ${files.length} file(s): ${files.map((file) => `${file.path} ${kb(file.content)}`).join(', ')}`);
|
|
39
|
+
return files;
|
|
40
|
+
};
|
|
41
|
+
try { return await once(prompt, step); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
log(`${label}: ${step}: unusable reply (${error.message}); asking once more for JSON only`);
|
|
44
|
+
return once(`${prompt}\n\nYour previous attempt failed with: "${error.message}". Output ONLY the JSON object this time.`, `${step} (retry)`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function selfCheck({ run, files, checklist, workspace, log = () => {}, label = 'draft' }) {
|
|
49
|
+
const index = files.find((file) => file.path === 'index.html')?.content || '';
|
|
50
|
+
const prompt = [
|
|
51
|
+
'You review a generated small-business website against a checklist. Be strict but practical.',
|
|
52
|
+
'Respond with ONLY JSON: {"pass": true|false, "problems": ["..."]}. List at most 6 concrete problems; empty when it passes.',
|
|
53
|
+
'', 'Checklist:', ...checklist.map((item) => `- ${item}`),
|
|
54
|
+
'', `Files: ${files.map((file) => file.path).join(', ')}`,
|
|
55
|
+
'', 'index.html:', index.slice(0, 60000),
|
|
56
|
+
].join('\n');
|
|
57
|
+
try {
|
|
58
|
+
const verdict = extractJson(await ask({ run, prompt, cwd: workspace, log, label, step: 'self-check' }));
|
|
59
|
+
return { pass: verdict.pass === true, problems: Array.isArray(verdict.problems) ? verdict.problems.map(String).slice(0, 6) : [] };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
log(`${label}: self-check skipped: ${error.message}`);
|
|
62
|
+
return { pass: true, problems: [] };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function buildDraft({ run, prompt, checklist, images = [], workspace, log = () => {}, label = 'draft' }) {
|
|
67
|
+
const started = Date.now();
|
|
68
|
+
let files = await generate({ run, prompt, images, workspace, log, label });
|
|
69
|
+
const check = await selfCheck({ run, files, checklist, workspace, log, label });
|
|
70
|
+
if (!check.pass && check.problems.length) {
|
|
71
|
+
log(`${label}: self-check found ${check.problems.length} problem(s), regenerating once:\n${check.problems.map((item) => ` - ${item}`).join('\n')}`);
|
|
72
|
+
files = await generate({ run, prompt: `${prompt}\n\nA review of your first draft found these problems. Fix all of them:\n${check.problems.map((item) => `- ${item}`).join('\n')}`, images, workspace, log, label, step: 'regenerate' });
|
|
73
|
+
} else {
|
|
74
|
+
log(`${label}: self-check passed`);
|
|
75
|
+
}
|
|
76
|
+
log(`${label}: draft built in ${seconds(started)}`);
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { generate, selfCheck, buildDraft };
|
package/src/mcpRunner.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `werewolf-dev mcp` with a runner token: the one connection a developer sets up. Their harness
|
|
3
|
+
* (Claude Code, Codex, Cursor, any MCP client) starts it, and it offers everything this machine
|
|
4
|
+
* has: the harness itself, plus the claude and codex CLIs and Ollama when they are installed.
|
|
5
|
+
* The developer picks on the developer page:
|
|
6
|
+
*
|
|
7
|
+
* - a CLI with a model and effort: this process builds it in the background, as the runner
|
|
8
|
+
* would, without the harness doing anything;
|
|
9
|
+
* - the harness: the run waits until the harness calls next_website_job, builds it with its own
|
|
10
|
+
* model and submits it.
|
|
11
|
+
*
|
|
12
|
+
* Either way the draft is held for review. Starting, accepting and sending back stay in the
|
|
13
|
+
* browser, so a person still reviews every draft before the owner sees it. When the harness
|
|
14
|
+
* closes, unfinished runs go back to the queue and the connection says it is leaving.
|
|
15
|
+
* Spec: .notes/impl-website-runner-package.md §3
|
|
16
|
+
*
|
|
17
|
+
* The MCP SDK and zod are passed in (startRunnerMcp), so the session itself needs no install.
|
|
18
|
+
*/
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const { problemOf } = require('./api');
|
|
21
|
+
const { createRemoteRunner, describeIdle, uploadDraft } = require('./runner');
|
|
22
|
+
const { sanitizeFiles } = require('./contract');
|
|
23
|
+
const { version } = require('../package.json');
|
|
24
|
+
|
|
25
|
+
const CLIENT = { name: '@werewolf-solutions/dev', version };
|
|
26
|
+
|
|
27
|
+
// The harness's name from the MCP handshake (e.g. "claude-code"), used as the run's model.
|
|
28
|
+
const harnessName = (info) => String(info?.name || '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'mcp';
|
|
29
|
+
|
|
30
|
+
const harnessOffer = (name) => [{
|
|
31
|
+
id: 'harness', label: `${name} via MCP`, available: true,
|
|
32
|
+
models: [{ id: name, label: name, efforts: [] }], defaultModel: name, defaultEffort: '',
|
|
33
|
+
}];
|
|
34
|
+
|
|
35
|
+
// The background loop takes every run this connection offers except the harness's own.
|
|
36
|
+
const cliProviders = (offered) => offered.filter((runner) => runner.id !== 'harness' && runner.available).map((runner) => runner.id);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `listRunners` and `buildDraft` are the runner's (src/agents.js, src/draft.js) unless given:
|
|
40
|
+
* the CLIs on this machine, and how a draft is built with one.
|
|
41
|
+
*/
|
|
42
|
+
function createHarnessSession({ api, token, fetch, host = os.hostname(), clientInfo = CLIENT, log = () => {}, listRunners, buildDraft, exec }) {
|
|
43
|
+
let name = 'mcp';
|
|
44
|
+
const worker = createRemoteRunner({
|
|
45
|
+
api, token, fetch, host, clientInfo, log, exec, kind: 'mcp',
|
|
46
|
+
listRunners: listRunners || ((env) => require('./agents').listRunners({ env: env || process.env, exec })),
|
|
47
|
+
buildDraft: buildDraft || ((...args) => require('./draft').buildDraft(...args)),
|
|
48
|
+
extraRunners: () => harnessOffer(name),
|
|
49
|
+
claimProviders: cliProviders,
|
|
50
|
+
});
|
|
51
|
+
const { client, connection, heartbeat } = worker;
|
|
52
|
+
// Harness runs this session claimed and hasn't finished, by request id.
|
|
53
|
+
const claimed = new Map();
|
|
54
|
+
|
|
55
|
+
// Is this a runner token? A business key (or any key without website_agent) gets a 403 at
|
|
56
|
+
// the check-in; it keeps the business tools. Any other failure is a real problem.
|
|
57
|
+
const detect = async () => {
|
|
58
|
+
try { return await heartbeat(); } catch (error) {
|
|
59
|
+
if (error.response?.status === 403) return null;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const setHarness = (info) => { name = harnessName(info); return heartbeat(); };
|
|
65
|
+
|
|
66
|
+
// Check in every 30s while the harness is open (keeps the offer live and claimed runs from
|
|
67
|
+
// going stale), and build the CLI runs started for this connection. Returns stop().
|
|
68
|
+
const start = (options = {}) => {
|
|
69
|
+
let stopped = false;
|
|
70
|
+
worker.loop({ ...options, shouldStop: () => stopped })
|
|
71
|
+
.catch((error) => log(`stopped building in the background: ${error.message}`));
|
|
72
|
+
return () => { stopped = true; };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const listRequests = async () => {
|
|
76
|
+
const { data } = await client.get('/developers/website-requests');
|
|
77
|
+
return (data.data?.requests || []).filter((request) => request.status === 'pending').map((request) => ({
|
|
78
|
+
requestId: String(request._id),
|
|
79
|
+
business: request.business?.name || '',
|
|
80
|
+
kind: request.kind,
|
|
81
|
+
state: request.agent?.state || 'waiting',
|
|
82
|
+
...(request.agent?.run?.provider ? { run: `${request.agent.run.provider} ${request.agent.run.model || ''}`.trim() } : {}),
|
|
83
|
+
...(request.agent?.error ? { error: request.agent.error } : {}),
|
|
84
|
+
...(request.agent?.previewPath ? { preview: `${client.baseURL.replace(/\/api\/v1$/, '')}${request.agent.previewPath}` } : {}),
|
|
85
|
+
createdAt: request.createdAt,
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const nextJob = async () => {
|
|
90
|
+
const { data } = await client.post('/developers/website-agent/claim', { connection, providers: ['harness'] });
|
|
91
|
+
const job = data.data?.job;
|
|
92
|
+
if (!job) return { job: null, idle: describeIdle(data.data?.idle) };
|
|
93
|
+
claimed.set(job.requestId, job);
|
|
94
|
+
return { job };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const claimedJob = (requestId) => {
|
|
98
|
+
const job = claimed.get(String(requestId || ''));
|
|
99
|
+
if (!job) throw new Error('This session has not claimed that request. Call next_website_job first and use the requestId it returns.');
|
|
100
|
+
return job;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// The server checks the files again; this catches the usual mistakes before the upload.
|
|
104
|
+
const submit = async ({ requestId, files }) => {
|
|
105
|
+
const job = claimedJob(requestId);
|
|
106
|
+
const all = sanitizeFiles(files);
|
|
107
|
+
const kept = all.filter((file) => !file.path.startsWith('assets/'));
|
|
108
|
+
const result = await uploadDraft(client, job, kept);
|
|
109
|
+
claimed.delete(job.requestId);
|
|
110
|
+
return { ...result, files: kept.length, dropped: all.length - kept.length };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const fail = async ({ requestId, error }) => {
|
|
114
|
+
const job = claimedJob(requestId);
|
|
115
|
+
const { data } = await client.post(`/developers/website-requests/${job.requestId}/agent/failed`, { error: String(error || 'The harness could not build it').slice(0, 300) });
|
|
116
|
+
claimed.delete(job.requestId);
|
|
117
|
+
return { retry: Boolean(data.data?.retry) };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// The harness closed with runs still claimed: hand them back, as the runner does on Ctrl+C,
|
|
121
|
+
// the one building in the background included.
|
|
122
|
+
const release = async (reason = 'The harness session ended before the draft was submitted') => {
|
|
123
|
+
const jobs = [...claimed.values()];
|
|
124
|
+
claimed.clear();
|
|
125
|
+
const background = worker.busy() ? worker.release(reason).then(() => 1, () => 0) : Promise.resolve(0);
|
|
126
|
+
await Promise.allSettled(jobs.map((job) => client.post(`/developers/website-requests/${job.requestId}/agent/failed`, { error: reason }, { timeout: 5000 })));
|
|
127
|
+
return jobs.length + await background;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return { client, connection, heartbeat, detect, setHarness, start, disconnect: worker.disconnect, listRequests, nextJob, submit, fail, release, claimed, busy: worker.busy, harness: () => name };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const textResult = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
|
|
134
|
+
const failure = (error) => ({ isError: true, content: [{ type: 'text', text: problemOf(error) }] });
|
|
135
|
+
|
|
136
|
+
// What the harness reads with a claimed run: how to hand the result back, then the brief.
|
|
137
|
+
function jobContent(job) {
|
|
138
|
+
const intro = [
|
|
139
|
+
`Website job for ${job.businessName} (requestId ${job.requestId}, ${job.kind === 'changes' ? 'changes to the current draft' : job.kind}).`,
|
|
140
|
+
'',
|
|
141
|
+
'Build the website from the brief below, using only what the brief gives you. Do not write the site into local files or the current project; it goes back to the server.',
|
|
142
|
+
'The brief asks for a {"files":[{"path","content"}]} JSON object. Instead of replying with it, call submit_website_draft with this requestId and that files list. index.html is required; files under assets/ are dropped (the owner\'s photos live there).',
|
|
143
|
+
'Before you submit, check your files against every checklist item at the end and fix what fails.',
|
|
144
|
+
'If you cannot build it, call report_website_failure with the reason.',
|
|
145
|
+
job.images?.length ? `${job.images.length} screenshot(s) of the business's current site follow the brief.` : '',
|
|
146
|
+
'',
|
|
147
|
+
'──── brief ────',
|
|
148
|
+
job.prompt,
|
|
149
|
+
'',
|
|
150
|
+
'──── checklist ────',
|
|
151
|
+
...(job.checklist || []).map((item) => `- ${item}`),
|
|
152
|
+
].filter((line, index, lines) => line !== '' || lines[index - 1] !== '');
|
|
153
|
+
return [
|
|
154
|
+
{ type: 'text', text: intro.join('\n') },
|
|
155
|
+
...(job.images || []).map((image) => ({ type: 'image', data: image.data, mimeType: image.mediaType || 'image/png' })),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function registerRunnerTools(server, session, z) {
|
|
160
|
+
server.registerTool('list_website_requests', {
|
|
161
|
+
title: 'List my website requests',
|
|
162
|
+
description: 'The open website requests assigned to you, with business, kind and run state (waiting, queued, building, review, failed). Read-only. Start a run on the developer page, choosing "via MCP" as the runner, then call next_website_job.',
|
|
163
|
+
inputSchema: {},
|
|
164
|
+
annotations: { readOnlyHint: true },
|
|
165
|
+
}, async () => {
|
|
166
|
+
try { return textResult(await session.listRequests()); } catch (error) { return failure(error); }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
server.registerTool('next_website_job', {
|
|
170
|
+
title: 'Take the next website job',
|
|
171
|
+
description: 'Claim the next website run you started on the developer page for this harness. Returns the requestId, the full brief, the checklist and screenshots of the current site. Build only from the brief, check the result against the checklist, then call submit_website_draft. With nothing queued, says why.',
|
|
172
|
+
inputSchema: {},
|
|
173
|
+
}, async () => {
|
|
174
|
+
try {
|
|
175
|
+
const { job, idle } = await session.nextJob();
|
|
176
|
+
return job ? { content: jobContent(job) } : textResult(`Nothing to build: ${idle}.`);
|
|
177
|
+
} catch (error) { return failure(error); }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
server.registerTool('submit_website_draft', {
|
|
181
|
+
title: 'Submit a website draft',
|
|
182
|
+
description: 'Upload the files you built for a job from next_website_job. The draft is held for your review on the developer page; the business owner sees nothing until you accept it there. files is the same [{path, content}] list the brief describes and must include index.html. Returns the preview link.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
requestId: z.string().describe('The requestId from next_website_job'),
|
|
185
|
+
files: z.array(z.object({ path: z.string(), content: z.string() })).min(1).describe('The complete site, e.g. [{ "path": "index.html", "content": "<!doctype html>…" }]'),
|
|
186
|
+
},
|
|
187
|
+
}, async ({ requestId, files }) => {
|
|
188
|
+
try {
|
|
189
|
+
const result = await session.submit({ requestId, files });
|
|
190
|
+
return textResult([
|
|
191
|
+
`Draft uploaded (${result.files} file(s)${result.dropped ? `, ${result.dropped} under assets/ dropped` : ''}) and held for your review.`,
|
|
192
|
+
result.previewUrl ? `Preview: ${result.previewUrl}` : '',
|
|
193
|
+
'Accept it or send it back on the developer page.',
|
|
194
|
+
].filter(Boolean).join('\n'));
|
|
195
|
+
} catch (error) { return failure(error); }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
server.registerTool('report_website_failure', {
|
|
199
|
+
title: 'Report a failed website job',
|
|
200
|
+
description: 'Give a claimed job back when you cannot build it, with the reason. The server queues it once more, then marks it failed on the developer page.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
requestId: z.string().describe('The requestId from next_website_job'),
|
|
203
|
+
error: z.string().describe('What went wrong, in one or two sentences'),
|
|
204
|
+
},
|
|
205
|
+
}, async ({ requestId, error }) => {
|
|
206
|
+
try {
|
|
207
|
+
const { retry } = await session.fail({ requestId, error });
|
|
208
|
+
return textResult(retry ? 'Reported. The run is queued again; next_website_job will hand it out once more.' : 'Reported. The run is marked failed on the developer page.');
|
|
209
|
+
} catch (caught) { return failure(caught); }
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The MCP server with the runner tools. The harness says who it is in the handshake; the
|
|
214
|
+
// run is offered under that name from then on.
|
|
215
|
+
function createRunnerServer({ session, McpServer, z, log = () => {} }) {
|
|
216
|
+
const server = new McpServer({ name: 'werewolf-dev', version });
|
|
217
|
+
registerRunnerTools(server, session, z);
|
|
218
|
+
server.server.oninitialized = () => {
|
|
219
|
+
session.setHarness(server.server.getClientVersion()).catch((error) => log(`check-in failed: ${problemOf(error)}`));
|
|
220
|
+
};
|
|
221
|
+
return server;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Serve the runner tools on stdio when `token` is a runner token. Returns false for any
|
|
226
|
+
* other key, so the caller serves the business tools instead.
|
|
227
|
+
*/
|
|
228
|
+
async function startRunnerMcp({ api, token, McpServer, StdioServerTransport, z, log = (line) => console.error(`[werewolf-dev mcp] ${line}`) }) {
|
|
229
|
+
const session = createHarnessSession({ api, token, log });
|
|
230
|
+
const me = await session.detect();
|
|
231
|
+
if (!me) return false;
|
|
232
|
+
const server = createRunnerServer({ session, McpServer, z, log });
|
|
233
|
+
const stop = session.start();
|
|
234
|
+
let closing = false;
|
|
235
|
+
const shutdown = async () => {
|
|
236
|
+
if (closing) return;
|
|
237
|
+
closing = true;
|
|
238
|
+
stop();
|
|
239
|
+
const released = await session.release().catch(() => 0);
|
|
240
|
+
if (released) log(`handed ${released} unfinished run(s) back to the server`);
|
|
241
|
+
await session.disconnect().catch(() => {});
|
|
242
|
+
process.exit(0);
|
|
243
|
+
};
|
|
244
|
+
process.stdin.on('end', shutdown);
|
|
245
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, shutdown);
|
|
246
|
+
await server.connect(new StdioServerTransport());
|
|
247
|
+
log(`signed in to ${session.client.baseURL} as ${me.developer?.name || 'developer'}; website runner tools ready`);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { createHarnessSession, createRunnerServer, registerRunnerTools, startRunnerMcp, harnessName, jobContent };
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The website runner: the website agent on a developer's own machine, building briefs from
|
|
3
|
+
* staging or production through the public API. It signs in with the developer's runner token
|
|
4
|
+
* and never touches a database. Moved here from the server's services/siteBuilder/remoteRunner.js.
|
|
5
|
+
*
|
|
6
|
+
* Loop: check in (what this machine can run) → claim the next run the developer started on the
|
|
7
|
+
* developer page → build the draft locally → upload it held for review → report the result.
|
|
8
|
+
*/
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs/promises');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { createClient, problemOf } = require('./api');
|
|
14
|
+
const { version } = require('../package.json');
|
|
15
|
+
|
|
16
|
+
const POLL_MS = 20 * 1000;
|
|
17
|
+
const HEARTBEAT_MS = 30 * 1000;
|
|
18
|
+
const CLIENT = { name: '@werewolf-solutions/dev', version };
|
|
19
|
+
|
|
20
|
+
const IDLE_REMINDER_MS = 5 * 60 * 1000;
|
|
21
|
+
const kb = (value) => `${(Buffer.byteLength(String(value || '')) / 1024).toFixed(1)}KB`;
|
|
22
|
+
|
|
23
|
+
// One line on why there is nothing to build, from the server's idle summary.
|
|
24
|
+
function describeIdle(idle = {}) {
|
|
25
|
+
const mine = idle.mine || {};
|
|
26
|
+
const parts = Object.entries(mine).filter(([, count]) => count).map(([state, count]) => `${count} ${state === 'review' ? 'waiting for your review' : state}`);
|
|
27
|
+
let line = parts.length ? `your open requests: ${parts.join(', ')}` : 'no open website requests are assigned to you';
|
|
28
|
+
if (!mine.queued) line += '. Press Start on one of your requests on the developer page';
|
|
29
|
+
if (idle.localQueued) line += `. ${idle.localQueued} run(s) are queued for the local website agent account; this runner only builds requests assigned to you, those need: npm run website-agent`;
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Upload a built draft held for review, then tell the server the run is done. Shared with the
|
|
34
|
+
// MCP runner tools, where the developer's harness built the files.
|
|
35
|
+
async function uploadDraft(client, job, files) {
|
|
36
|
+
const upload = await client.post(`/businesses/${job.businessId}/builder/projects/${job.projectId}/revisions`, {
|
|
37
|
+
files, prompt: `Website runner: ${job.kind} request ${job.requestId}`, provider: `runner:${job.run.provider}:${job.run.model}`.slice(0, 40), holdForReview: true,
|
|
38
|
+
});
|
|
39
|
+
const candidate = upload.data?.data?.revision?._id;
|
|
40
|
+
if (!candidate) throw new Error('The upload returned no draft');
|
|
41
|
+
const finished = await client.post(`/developers/website-requests/${job.requestId}/agent/finished`, { candidate });
|
|
42
|
+
const previewPath = finished.data?.data?.previewPath;
|
|
43
|
+
return { candidate, previewPath, previewUrl: previewPath ? `${client.baseURL.replace(/\/api\/v1$/, '')}${previewPath}` : null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `exec`: the --exec command, for runs the developer started with "Your command".
|
|
47
|
+
// The MCP server reuses this as its background worker: `kind: 'mcp'`, `extraRunners` adds the
|
|
48
|
+
// harness to the offer, and `claimProviders` keeps the loop to the CLI runs (the harness takes
|
|
49
|
+
// its own runs when asked).
|
|
50
|
+
function createRemoteRunner({
|
|
51
|
+
api, token, fetch, listRunners, buildDraft, log = () => {}, host = os.hostname(), exec, clientInfo = CLIENT,
|
|
52
|
+
kind = 'runner', extraRunners = () => [], claimProviders = null, connection = crypto.randomUUID(),
|
|
53
|
+
}) {
|
|
54
|
+
const client = createClient({ api, token, fetch });
|
|
55
|
+
|
|
56
|
+
// One id per process: the server keeps each connection's offer apart, matches claims to it,
|
|
57
|
+
// and keeps this connection's claims alive while it checks in.
|
|
58
|
+
let offered = [];
|
|
59
|
+
const heartbeat = async () => {
|
|
60
|
+
const runners = [...await listRunners(), ...extraRunners()];
|
|
61
|
+
const { data } = await client.post('/developers/website-agent/heartbeat', { runners, host, connection, kind, client: clientInfo });
|
|
62
|
+
offered = runners;
|
|
63
|
+
return { ...data.data, offered: runners };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Tell the server this connection is going, so the developer page updates at once.
|
|
67
|
+
const disconnect = () => client.post('/developers/website-agent/disconnect', { connection }, { timeout: 5000 });
|
|
68
|
+
|
|
69
|
+
// The run being built right now, so stopping the runner can hand it back.
|
|
70
|
+
let active = null;
|
|
71
|
+
|
|
72
|
+
// One claimed run, start to finish. Failures go back to the server, which retries once.
|
|
73
|
+
const runJob = async (job) => {
|
|
74
|
+
active = job;
|
|
75
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'website-runner-'));
|
|
76
|
+
const label = job.businessName || job.requestId;
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
log(`${label}: claimed request ${job.requestId} (${job.kind === 'changes' ? 'changes' : job.kind})`);
|
|
80
|
+
log(`${label}: run ${job.run.provider} ${job.run.model}${job.run.effort ? ` ${job.run.effort}` : ''}; prompt ${kb(job.prompt)}, ${job.checklist?.length || 0} checklist item(s), ${job.images?.length || 0} screenshot(s); workspace ${workspace}`);
|
|
81
|
+
const files = await buildDraft({ run: { ...job.run, exec }, prompt: job.prompt, checklist: job.checklist, images: job.images || [], workspace, log, label });
|
|
82
|
+
log(`${label}: uploading ${files.length} file(s), ${kb(files.map((file) => file.content).join(''))}, held for your review…`);
|
|
83
|
+
const { candidate, previewUrl } = await uploadDraft(client, job, files);
|
|
84
|
+
log(`${label}: draft ${candidate} ready for your review after ${Math.round((Date.now() - started) / 1000)}s${previewUrl ? `: ${previewUrl}` : ''}. Accept it or send it back on the developer page.`);
|
|
85
|
+
return { ok: true, candidate };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = problemOf(error).slice(0, 300);
|
|
88
|
+
log(`${label}: failed: ${message}`);
|
|
89
|
+
await client.post(`/developers/website-requests/${job.requestId}/agent/failed`, { error: message })
|
|
90
|
+
.then(() => log(`${label}: reported the failure; the server retries once, then marks the run failed`))
|
|
91
|
+
.catch((reportError) => log(`could not report the failure: ${problemOf(reportError)}`));
|
|
92
|
+
return { ok: false, error: message };
|
|
93
|
+
} finally {
|
|
94
|
+
active = null;
|
|
95
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Hands the current run back when the runner is stopped mid-build, so it isn't left claimed.
|
|
100
|
+
// The server counts it as a failed attempt and requeues it once.
|
|
101
|
+
const release = async (reason = 'The runner was stopped before it finished') => {
|
|
102
|
+
if (!active) return false;
|
|
103
|
+
await client.post(`/developers/website-requests/${active.requestId}/agent/failed`, { error: reason }, { timeout: 5000 });
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Claim and build at most one run. Returns null when there is nothing to do. The idle reason
|
|
108
|
+
// is logged when it changes, and again every 5 minutes so a quiet runner still looks alive.
|
|
109
|
+
let lastIdle = '';
|
|
110
|
+
let lastIdleAt = 0;
|
|
111
|
+
const tick = async () => {
|
|
112
|
+
const providers = claimProviders ? claimProviders(offered) : null;
|
|
113
|
+
if (providers && !providers.length) return null;
|
|
114
|
+
const { data } = await client.post('/developers/website-agent/claim', { connection, ...(providers ? { providers } : {}) });
|
|
115
|
+
const job = data.data?.job;
|
|
116
|
+
if (job) { lastIdle = ''; return runJob(job); }
|
|
117
|
+
const line = describeIdle(data.data?.idle);
|
|
118
|
+
if (line !== lastIdle || Date.now() - lastIdleAt >= IDLE_REMINDER_MS) {
|
|
119
|
+
log(`nothing to build: ${line}`);
|
|
120
|
+
lastIdle = line;
|
|
121
|
+
lastIdleAt = Date.now();
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const loop = async ({ shouldStop = () => false, pollMs = POLL_MS, heartbeatMs = HEARTBEAT_MS, retryMs = 5000 } = {}) => {
|
|
127
|
+
log(`connecting to ${client.baseURL} as ${host}…`);
|
|
128
|
+
// The server may be restarting (nodemon) or not up yet: keep trying for a couple of
|
|
129
|
+
// minutes. A refused token is final.
|
|
130
|
+
let me;
|
|
131
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
132
|
+
try { me = await heartbeat(); break; }
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.response || attempt >= 24 || shouldStop()) throw new Error(`could not sign in: ${problemOf(error)}`);
|
|
135
|
+
log(`server not answering (${problemOf(error)}); retrying in 5s`);
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
log(`signed in as ${me.developer?.name || 'developer'}. Offering:`);
|
|
140
|
+
for (const runner of me.offered) {
|
|
141
|
+
log(` ${runner.label || runner.id}: ${runner.available ? `${runner.models.length} model(s)${runner.defaultModel ? `, default ${runner.defaultModel}` : ''}` : `unavailable (${runner.reason || 'not installed'})`}`);
|
|
142
|
+
}
|
|
143
|
+
log(`checking for runs every ${pollMs / 1000}s and checking in every ${heartbeatMs / 1000}s (Ctrl+C to stop)`);
|
|
144
|
+
let checkInFailing = false;
|
|
145
|
+
const timer = setInterval(() => {
|
|
146
|
+
heartbeat()
|
|
147
|
+
.then(() => { if (checkInFailing) log('check-in working again'); checkInFailing = false; })
|
|
148
|
+
.catch((error) => { checkInFailing = true; log(`check-in failed: ${problemOf(error)}`); });
|
|
149
|
+
}, heartbeatMs);
|
|
150
|
+
try {
|
|
151
|
+
while (!shouldStop()) {
|
|
152
|
+
let result = null;
|
|
153
|
+
try { result = await tick(); }
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.response?.status === 401 || error.response?.status === 403) throw new Error(`The server refused the runner token: ${problemOf(error)}`);
|
|
156
|
+
log(`claim failed: ${problemOf(error)}`);
|
|
157
|
+
}
|
|
158
|
+
if (!result && !shouldStop()) await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
clearInterval(timer);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return { client, connection, heartbeat, disconnect, tick, runJob, loop, release, busy: () => Boolean(active) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { createRemoteRunner, describeIdle, uploadDraft };
|