@enderfga/claw-orchestrator 3.7.1 → 4.0.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 +45 -3
- package/configs/autoloop-coder-prompt.md +65 -28
- package/configs/autoloop-planner-prompt.md +103 -42
- package/configs/autoloop-reviewer-prompt.md +58 -30
- package/dist/bin/cli.js +30 -7
- package/dist/bin/cli.js.map +1 -1
- package/dist/src/autoloop/dispatcher.js +16 -2
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/autoloop/messages.d.ts +0 -2
- package/dist/src/autoloop/messages.js +0 -2
- package/dist/src/autoloop/messages.js.map +1 -1
- package/dist/src/autoloop/planner-tools.d.ts +11 -6
- package/dist/src/autoloop/planner-tools.js +24 -7
- package/dist/src/autoloop/planner-tools.js.map +1 -1
- package/dist/src/autoloop/runner.d.ts +1 -2
- package/dist/src/autoloop/runner.js +1 -2
- package/dist/src/autoloop/runner.js.map +1 -1
- package/dist/src/autoloop/types.d.ts +0 -2
- package/dist/src/autoloop/types.js +0 -2
- package/dist/src/autoloop/types.js.map +1 -1
- package/dist/src/council.js +1 -0
- package/dist/src/council.js.map +1 -1
- package/dist/src/dashboard/index.html +1066 -12
- package/dist/src/embedded-server.d.ts +7 -0
- package/dist/src/embedded-server.js +359 -17
- package/dist/src/embedded-server.js.map +1 -1
- package/dist/src/index.js +222 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/session-manager.d.ts +98 -1
- package/dist/src/session-manager.js +340 -5
- package/dist/src/session-manager.js.map +1 -1
- package/dist/src/ultraapp/build-events.d.ts +46 -0
- package/dist/src/ultraapp/build-events.js +6 -0
- package/dist/src/ultraapp/build-events.js.map +1 -0
- package/dist/src/ultraapp/build.d.ts +39 -0
- package/dist/src/ultraapp/build.js +111 -0
- package/dist/src/ultraapp/build.js.map +1 -0
- package/dist/src/ultraapp/conventions.d.ts +8 -0
- package/dist/src/ultraapp/conventions.js +248 -0
- package/dist/src/ultraapp/conventions.js.map +1 -0
- package/dist/src/ultraapp/council-adapter.d.ts +49 -0
- package/dist/src/ultraapp/council-adapter.js +152 -0
- package/dist/src/ultraapp/council-adapter.js.map +1 -0
- package/dist/src/ultraapp/deploy.d.ts +45 -0
- package/dist/src/ultraapp/deploy.js +82 -0
- package/dist/src/ultraapp/deploy.js.map +1 -0
- package/dist/src/ultraapp/diff-apply.d.ts +15 -0
- package/dist/src/ultraapp/diff-apply.js +48 -0
- package/dist/src/ultraapp/diff-apply.js.map +1 -0
- package/dist/src/ultraapp/docker.d.ts +57 -0
- package/dist/src/ultraapp/docker.js +83 -0
- package/dist/src/ultraapp/docker.js.map +1 -0
- package/dist/src/ultraapp/feedback-classifier.d.ts +24 -0
- package/dist/src/ultraapp/feedback-classifier.js +85 -0
- package/dist/src/ultraapp/feedback-classifier.js.map +1 -0
- package/dist/src/ultraapp/files.d.ts +24 -0
- package/dist/src/ultraapp/files.js +79 -0
- package/dist/src/ultraapp/files.js.map +1 -0
- package/dist/src/ultraapp/fix-on-failure-session.d.ts +23 -0
- package/dist/src/ultraapp/fix-on-failure-session.js +48 -0
- package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -0
- package/dist/src/ultraapp/fix-on-failure.d.ts +39 -0
- package/dist/src/ultraapp/fix-on-failure.js +69 -0
- package/dist/src/ultraapp/fix-on-failure.js.map +1 -0
- package/dist/src/ultraapp/host-strategy.d.ts +57 -0
- package/dist/src/ultraapp/host-strategy.js +205 -0
- package/dist/src/ultraapp/host-strategy.js.map +1 -0
- package/dist/src/ultraapp/interview-parser.d.ts +41 -0
- package/dist/src/ultraapp/interview-parser.js +83 -0
- package/dist/src/ultraapp/interview-parser.js.map +1 -0
- package/dist/src/ultraapp/interview-tools.d.ts +16 -0
- package/dist/src/ultraapp/interview-tools.js +36 -0
- package/dist/src/ultraapp/interview-tools.js.map +1 -0
- package/dist/src/ultraapp/json-patch.d.ts +6 -0
- package/dist/src/ultraapp/json-patch.js +78 -0
- package/dist/src/ultraapp/json-patch.js.map +1 -0
- package/dist/src/ultraapp/lifecycle.d.ts +38 -0
- package/dist/src/ultraapp/lifecycle.js +31 -0
- package/dist/src/ultraapp/lifecycle.js.map +1 -0
- package/dist/src/ultraapp/manager.d.ts +150 -0
- package/dist/src/ultraapp/manager.js +684 -0
- package/dist/src/ultraapp/manager.js.map +1 -0
- package/dist/src/ultraapp/narrator-prompt.d.ts +10 -0
- package/dist/src/ultraapp/narrator-prompt.js +54 -0
- package/dist/src/ultraapp/narrator-prompt.js.map +1 -0
- package/dist/src/ultraapp/narrator.d.ts +49 -0
- package/dist/src/ultraapp/narrator.js +83 -0
- package/dist/src/ultraapp/narrator.js.map +1 -0
- package/dist/src/ultraapp/patcher.d.ts +35 -0
- package/dist/src/ultraapp/patcher.js +159 -0
- package/dist/src/ultraapp/patcher.js.map +1 -0
- package/dist/src/ultraapp/router.d.ts +39 -0
- package/dist/src/ultraapp/router.js +139 -0
- package/dist/src/ultraapp/router.js.map +1 -0
- package/dist/src/ultraapp/spec-delta.d.ts +18 -0
- package/dist/src/ultraapp/spec-delta.js +35 -0
- package/dist/src/ultraapp/spec-delta.js.map +1 -0
- package/dist/src/ultraapp/spec.d.ts +82 -0
- package/dist/src/ultraapp/spec.js +125 -0
- package/dist/src/ultraapp/spec.js.map +1 -0
- package/dist/src/ultraapp/store.d.ts +67 -0
- package/dist/src/ultraapp/store.js +177 -0
- package/dist/src/ultraapp/store.js.map +1 -0
- package/dist/src/ultraapp/versions.d.ts +50 -0
- package/dist/src/ultraapp/versions.js +65 -0
- package/dist/src/ultraapp/versions.js.map +1 -0
- package/openclaw.plugin.json +15 -1
- package/package.json +3 -1
- package/skills/SKILL.md +2 -2
- package/skills/references/autoloop.md +7 -4
- package/skills/references/dashboard.md +153 -0
- package/skills/references/mcp.md +2 -2
- package/skills/references/tools.md +183 -0
- package/skills/references/ultraapp.md +203 -0
- package/skills/ultraapp/SKILL.md +112 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { validateAppSpec } from './spec.js';
|
|
6
|
+
import { parseInterviewReply } from './interview-parser.js';
|
|
7
|
+
import { runToolCalls } from './interview-tools.js';
|
|
8
|
+
import { extractMetadata as defaultExtractMetadata, ingestUpload, validateLocalPath, defaultAllowedRoots, } from './files.js';
|
|
9
|
+
import { applyPatch } from './json-patch.js';
|
|
10
|
+
import { UltraappBuildQueue } from './build.js';
|
|
11
|
+
import { runCouncilSynth } from './council-adapter.js';
|
|
12
|
+
import { runFixOnFailure } from './fix-on-failure.js';
|
|
13
|
+
import { spawnFixerSessionWith } from './fix-on-failure-session.js';
|
|
14
|
+
import { deployArtifact } from './deploy.js';
|
|
15
|
+
import { dockerBuild, dockerRun, dockerRmi } from './docker.js';
|
|
16
|
+
import { hostBuild, hostRun, hostRmi } from './host-strategy.js';
|
|
17
|
+
import { startContainerAndRegister, stopContainerAndDeregister, deleteContainerAndDeregister } from './lifecycle.js';
|
|
18
|
+
import { Narrator } from './narrator.js';
|
|
19
|
+
import { classifyFeedback } from './feedback-classifier.js';
|
|
20
|
+
import { runPatcher } from './patcher.js';
|
|
21
|
+
import { startSpecDeltaInterview } from './spec-delta.js';
|
|
22
|
+
import { snapshotVersion, swapVersion, listVersions } from './versions.js';
|
|
23
|
+
const SESSION_KICKOFF = 'Begin the ultraapp interview now. Ask the first question per the skill contract.';
|
|
24
|
+
export class UltraappManager {
|
|
25
|
+
opts;
|
|
26
|
+
runs = new Map();
|
|
27
|
+
skillContent;
|
|
28
|
+
buildQueue;
|
|
29
|
+
narrators = new Map();
|
|
30
|
+
classifierSessions = new Set();
|
|
31
|
+
// Tracks fire-and-forget background work (driveTurn chains, build-queue
|
|
32
|
+
// enqueue, narrator finalisation) so tests (and dispose flows) can drain
|
|
33
|
+
// it before tearing down state. Without this, an in-flight appendChat can
|
|
34
|
+
// race afterEach's tmp-dir cleanup and surface as an unhandled ENOENT
|
|
35
|
+
// rejection that vitest treats as failure.
|
|
36
|
+
inflightWork = new Set();
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.opts = opts;
|
|
39
|
+
this.skillContent = loadSkill(opts.skillPath);
|
|
40
|
+
this.buildQueue = new UltraappBuildQueue({
|
|
41
|
+
worker: (runId, emit) => this.runBuild(runId, emit),
|
|
42
|
+
});
|
|
43
|
+
this.buildQueue.subscribe((ev) => this.onBuildEvent(ev));
|
|
44
|
+
}
|
|
45
|
+
get store() {
|
|
46
|
+
return this.opts.store;
|
|
47
|
+
}
|
|
48
|
+
async createRun() {
|
|
49
|
+
const runId = await this.opts.store.createRun();
|
|
50
|
+
const sessionName = `ultraapp-${runId}`;
|
|
51
|
+
await this.opts.sessionManager.startSession({
|
|
52
|
+
name: sessionName,
|
|
53
|
+
engine: 'claude',
|
|
54
|
+
model: 'claude-opus-4-7',
|
|
55
|
+
cwd: process.cwd(),
|
|
56
|
+
systemPrompt: this.skillContent,
|
|
57
|
+
permissionMode: 'bypassPermissions',
|
|
58
|
+
});
|
|
59
|
+
const run = { runId, sessionName, emitter: new EventEmitter() };
|
|
60
|
+
this.runs.set(runId, run);
|
|
61
|
+
this.fireDriveTurn(run, SESSION_KICKOFF);
|
|
62
|
+
return runId;
|
|
63
|
+
}
|
|
64
|
+
async submitAnswer(runId, answer) {
|
|
65
|
+
const run = this.requireRun(runId);
|
|
66
|
+
const text = answer.freeform ? answer.freeform : `I picked: ${answer.value}`;
|
|
67
|
+
await this.opts.store.appendChat(runId, { role: 'user', kind: 'answer', text });
|
|
68
|
+
this.emit(run, { type: 'chat', entry: { role: 'user', kind: 'answer', text } });
|
|
69
|
+
this.fireDriveTurn(run, text);
|
|
70
|
+
}
|
|
71
|
+
async applySpecEdit(runId, patch) {
|
|
72
|
+
const run = this.requireRun(runId);
|
|
73
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
74
|
+
const next = applyPatch(spec, patch);
|
|
75
|
+
await this.opts.store.writeSpec(runId, next, 'manual-edit');
|
|
76
|
+
this.emit(run, { type: 'spec-updated', spec: next });
|
|
77
|
+
this.fireDriveTurn(run, `[system] User manually edited spec: ${JSON.stringify(patch)}. Re-evaluate later questions.`);
|
|
78
|
+
}
|
|
79
|
+
async addFile(runId, args) {
|
|
80
|
+
const run = this.requireRun(runId);
|
|
81
|
+
let ref;
|
|
82
|
+
if (args.kind === 'upload') {
|
|
83
|
+
ref = await ingestUpload(this.opts.store.examplesDir(runId), args.filename, args.data);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
validateLocalPath(args.absolutePath, { allow: defaultAllowedRoots() });
|
|
87
|
+
ref = args.absolutePath;
|
|
88
|
+
}
|
|
89
|
+
await this.opts.store.appendChat(runId, {
|
|
90
|
+
role: 'system',
|
|
91
|
+
kind: 'free',
|
|
92
|
+
text: `[file added] ${path.basename(ref)}`,
|
|
93
|
+
payload: { ref },
|
|
94
|
+
});
|
|
95
|
+
this.fireDriveTurn(run, `[system] User added file: ${ref}. You may call extract_metadata.`);
|
|
96
|
+
return { ref };
|
|
97
|
+
}
|
|
98
|
+
subscribe(runId, listener) {
|
|
99
|
+
const run = this.requireRun(runId);
|
|
100
|
+
run.emitter.on('event', listener);
|
|
101
|
+
return () => run.emitter.off('event', listener);
|
|
102
|
+
}
|
|
103
|
+
async startBuild(runId) {
|
|
104
|
+
const run = this.requireRun(runId);
|
|
105
|
+
// Strict validate before enqueueing — pipeline cross-refs + DAG must
|
|
106
|
+
// resolve. writeSpec only does the lax shape check during interview
|
|
107
|
+
// iteration; this is the gate that catches real spec errors.
|
|
108
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
109
|
+
try {
|
|
110
|
+
validateAppSpec(spec);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
const reason = e.message;
|
|
114
|
+
const msg = `Cannot start build: spec is invalid — ${reason}`;
|
|
115
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'error', text: msg });
|
|
116
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'error', text: msg } });
|
|
117
|
+
this.emit(run, { type: 'error', message: reason });
|
|
118
|
+
throw new Error(reason);
|
|
119
|
+
}
|
|
120
|
+
await this.opts.store.setMode(runId, 'queued');
|
|
121
|
+
const text = 'Build queued.';
|
|
122
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text });
|
|
123
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text } });
|
|
124
|
+
this.trackBackground(this.buildQueue.enqueue(runId));
|
|
125
|
+
}
|
|
126
|
+
cancelBuild(runId) {
|
|
127
|
+
this.buildQueue.cancel(runId);
|
|
128
|
+
}
|
|
129
|
+
buildPosition(runId) {
|
|
130
|
+
return this.buildQueue.position(runId);
|
|
131
|
+
}
|
|
132
|
+
async runBuild(runId, emit) {
|
|
133
|
+
await this.opts.store.setMode(runId, 'building');
|
|
134
|
+
// Start the narrator before any build event fires so onBuildEvent can
|
|
135
|
+
// push synchronously without racing async session-spawn. Best-effort —
|
|
136
|
+
// narrator failure shouldn't sink the build.
|
|
137
|
+
const language = detectLanguage(await this.opts.store.readChat(runId));
|
|
138
|
+
const run = this.runs.get(runId);
|
|
139
|
+
if (run) {
|
|
140
|
+
const n = new Narrator({
|
|
141
|
+
runId,
|
|
142
|
+
sessionManager: this.opts.sessionManager,
|
|
143
|
+
language,
|
|
144
|
+
onChat: (text) => {
|
|
145
|
+
void this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text });
|
|
146
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text } });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
this.narrators.set(runId, n);
|
|
150
|
+
try {
|
|
151
|
+
await n.start();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
this.narrators.delete(runId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
emit({ type: 'build-start', runId });
|
|
158
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
159
|
+
const runDir = this.opts.store.runDirAbsolute(runId);
|
|
160
|
+
const council = await runCouncilSynth({
|
|
161
|
+
spec,
|
|
162
|
+
runId,
|
|
163
|
+
runDir,
|
|
164
|
+
sessionManager: this.opts.sessionManager,
|
|
165
|
+
});
|
|
166
|
+
if (!council.ok) {
|
|
167
|
+
emit({ type: 'build-failed', runId, phase: 'council', reason: council.reason ?? 'unknown' });
|
|
168
|
+
await this.opts.store.setMode(runId, 'failed', council.reason);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
emit({ type: 'council-consensus', runId, rounds: council.rounds });
|
|
172
|
+
emit({ type: 'fix-start', runId });
|
|
173
|
+
const useDocker = this.opts.runtimeMode === 'docker';
|
|
174
|
+
const fix = await runFixOnFailure({
|
|
175
|
+
worktreePath: council.worktreePath,
|
|
176
|
+
maxRounds: 5,
|
|
177
|
+
// Reuse the same SessionManager so the fixer doesn't spawn a fresh one per fix.
|
|
178
|
+
spawnFixer: (a) => spawnFixerSessionWith(this.opts.sessionManager, a),
|
|
179
|
+
// Host mode skips the docker-build step (no Docker required).
|
|
180
|
+
steps: useDocker
|
|
181
|
+
? undefined // default: install + build + test + docker build
|
|
182
|
+
: [
|
|
183
|
+
{ cmd: 'npm', args: ['install'] },
|
|
184
|
+
{ cmd: 'npm', args: ['run', 'build'] },
|
|
185
|
+
{ cmd: 'npm', args: ['test'] },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
if (!fix.ok) {
|
|
189
|
+
emit({
|
|
190
|
+
type: 'build-failed',
|
|
191
|
+
runId,
|
|
192
|
+
phase: 'fix-on-failure',
|
|
193
|
+
reason: fix.lastError ?? 'budget exhausted',
|
|
194
|
+
});
|
|
195
|
+
await this.opts.store.setMode(runId, 'failed', fix.lastError);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
emit({ type: 'fix-complete', runId, rounds: fix.rounds });
|
|
199
|
+
const version = 'v1';
|
|
200
|
+
await this.opts.store.recordBuildArtifact(runId, {
|
|
201
|
+
worktreePath: council.worktreePath,
|
|
202
|
+
version,
|
|
203
|
+
});
|
|
204
|
+
await this.opts.store.setMode(runId, 'build-complete');
|
|
205
|
+
emit({ type: 'build-complete', runId, worktreePath: council.worktreePath });
|
|
206
|
+
// If a router is wired, continue into deploy. Without a router (legacy v0.2
|
|
207
|
+
// wiring or test setup), build-complete is the resting state.
|
|
208
|
+
if (!this.opts.router)
|
|
209
|
+
return;
|
|
210
|
+
await this.runDeployStage({
|
|
211
|
+
runId,
|
|
212
|
+
version,
|
|
213
|
+
worktreePath: council.worktreePath,
|
|
214
|
+
slug: spec.meta.name,
|
|
215
|
+
emit,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async runDeployStage(args) {
|
|
219
|
+
const router = this.opts.router;
|
|
220
|
+
await this.opts.store.setMode(args.runId, 'deploying');
|
|
221
|
+
const taken = new Set(router.list().map((r) => r.port));
|
|
222
|
+
const deploy = this.opts.deployFn ?? deployArtifact;
|
|
223
|
+
const useDocker = this.opts.runtimeMode === 'docker';
|
|
224
|
+
const dep = await deploy({
|
|
225
|
+
runId: args.runId,
|
|
226
|
+
version: args.version,
|
|
227
|
+
worktreePath: args.worktreePath,
|
|
228
|
+
slug: args.slug,
|
|
229
|
+
hostDataDir: this.opts.store.runDirAbsolute(args.runId),
|
|
230
|
+
// Host-spawn (default) runs the codebase as a regular Node process;
|
|
231
|
+
// Docker (opt-in) runs `docker build` + `docker run`. Both use the
|
|
232
|
+
// same orchestrator interface so the deploy state machine doesn't care.
|
|
233
|
+
dockerBuild: (a) => (useDocker ? dockerBuild(a) : hostBuild({ tag: a.tag, cwd: a.cwd, buildArgs: a.buildArgs })),
|
|
234
|
+
dockerRun: (a) => useDocker
|
|
235
|
+
? dockerRun(a)
|
|
236
|
+
: hostRun({
|
|
237
|
+
...a,
|
|
238
|
+
// Host runner needs the cwd; smuggle it through env (deploy.ts
|
|
239
|
+
// passes `image: tag` so we can't use that field).
|
|
240
|
+
// Also set DATA_DIR to a writable per-run path — the conventions
|
|
241
|
+
// tell generated codebases to use process.env.DATA_DIR ?? '/data'.
|
|
242
|
+
// In Docker mode that defaults to '/data' (a mounted volume).
|
|
243
|
+
// In host mode we point it at the run's host data dir.
|
|
244
|
+
env: {
|
|
245
|
+
...a.env,
|
|
246
|
+
HOST_CWD: args.worktreePath,
|
|
247
|
+
DATA_DIR: path.join(this.opts.store.runDirAbsolute(args.runId), 'data'),
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
router,
|
|
251
|
+
fetchFn: fetch,
|
|
252
|
+
takenPorts: taken,
|
|
253
|
+
});
|
|
254
|
+
if (!dep.ok) {
|
|
255
|
+
args.emit({
|
|
256
|
+
type: 'build-failed',
|
|
257
|
+
runId: args.runId,
|
|
258
|
+
phase: 'orchestrator',
|
|
259
|
+
reason: `deploy: ${dep.reason ?? 'unknown'}`,
|
|
260
|
+
});
|
|
261
|
+
await this.opts.store.setMode(args.runId, 'failed', dep.reason);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
await this.opts.store.recordDeploy(args.runId, args.version, {
|
|
265
|
+
url: dep.url,
|
|
266
|
+
port: dep.port,
|
|
267
|
+
containerName: dep.containerName,
|
|
268
|
+
imageTag: dep.imageTag,
|
|
269
|
+
});
|
|
270
|
+
await this.opts.store.setMode(args.runId, 'done');
|
|
271
|
+
const run = this.runs.get(args.runId);
|
|
272
|
+
if (run) {
|
|
273
|
+
this.emit(run, { type: 'app-url', url: dep.url, version: args.version });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async startContainer(runId) {
|
|
277
|
+
if (!this.opts.router)
|
|
278
|
+
return { ok: false, error: 'router not configured' };
|
|
279
|
+
const arts = await this.opts.store.readArtifacts(runId);
|
|
280
|
+
const latest = arts[arts.length - 1];
|
|
281
|
+
if (!latest?.deploy)
|
|
282
|
+
return { ok: false, error: 'no deployed artifact' };
|
|
283
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
284
|
+
const useDocker = this.opts.runtimeMode === 'docker';
|
|
285
|
+
const r = await startContainerAndRegister(latest.deploy.containerName, spec.meta.name, latest.deploy.port, this.opts.router, useDocker ? undefined : { dockerStartFn: async (n) => (await import('./host-strategy.js')).hostStart(n) });
|
|
286
|
+
return r;
|
|
287
|
+
}
|
|
288
|
+
async stopContainer(runId) {
|
|
289
|
+
if (!this.opts.router)
|
|
290
|
+
return { ok: false, error: 'router not configured' };
|
|
291
|
+
const arts = await this.opts.store.readArtifacts(runId);
|
|
292
|
+
const latest = arts[arts.length - 1];
|
|
293
|
+
if (!latest?.deploy)
|
|
294
|
+
return { ok: false, error: 'no deployed artifact' };
|
|
295
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
296
|
+
const useDocker = this.opts.runtimeMode === 'docker';
|
|
297
|
+
return stopContainerAndDeregister(latest.deploy.containerName, spec.meta.name, this.opts.router, useDocker ? undefined : { dockerStopFn: async (n) => (await import('./host-strategy.js')).hostStop(n) });
|
|
298
|
+
}
|
|
299
|
+
async deleteRun(runId) {
|
|
300
|
+
const arts = await this.opts.store.readArtifacts(runId).catch(() => []);
|
|
301
|
+
const latest = arts[arts.length - 1];
|
|
302
|
+
let spec = null;
|
|
303
|
+
try {
|
|
304
|
+
spec = await this.opts.store.readSpec(runId);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* run already gone */
|
|
308
|
+
}
|
|
309
|
+
const useDocker = this.opts.runtimeMode === 'docker';
|
|
310
|
+
if (latest?.deploy && spec && this.opts.router) {
|
|
311
|
+
await deleteContainerAndDeregister(latest.deploy.containerName, spec.meta.name, this.opts.router, useDocker ? undefined : { dockerRmFn: async (n) => (await import('./host-strategy.js')).hostRm(n) });
|
|
312
|
+
const rmiFn = useDocker ? dockerRmi : hostRmi;
|
|
313
|
+
await rmiFn(latest.deploy.imageTag).catch(() => {
|
|
314
|
+
/* image may not exist */
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// Stop & forget the active run if any
|
|
318
|
+
const run = this.runs.get(runId);
|
|
319
|
+
if (run) {
|
|
320
|
+
await this.opts.sessionManager.stopSession(run.sessionName).catch(() => { });
|
|
321
|
+
this.runs.delete(runId);
|
|
322
|
+
}
|
|
323
|
+
await this.opts.store.deleteRunFiles(runId);
|
|
324
|
+
return { ok: true };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Done-mode chat message — classified into cosmetic / spec-delta /
|
|
328
|
+
* structural and routed accordingly. Cosmetic runs the patcher inline;
|
|
329
|
+
* spec-delta flips mode back to interview with a focused bootstrap;
|
|
330
|
+
* structural posts a narrator note suggesting a new run.
|
|
331
|
+
*/
|
|
332
|
+
async submitDoneModeMessage(runId, text) {
|
|
333
|
+
const run = this.requireRun(runId);
|
|
334
|
+
const state = await this.opts.store.readState(runId);
|
|
335
|
+
if (state.mode !== 'done') {
|
|
336
|
+
// Outside done mode, fall through to the standard interview answer flow
|
|
337
|
+
return this.submitAnswer(runId, { value: '', freeform: text });
|
|
338
|
+
}
|
|
339
|
+
await this.opts.store.appendChat(runId, { role: 'user', kind: 'free', text });
|
|
340
|
+
this.emit(run, { type: 'chat', entry: { role: 'user', kind: 'free', text } });
|
|
341
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
342
|
+
const language = detectLanguage(await this.opts.store.readChat(runId));
|
|
343
|
+
const cls = await classifyFeedback({
|
|
344
|
+
text,
|
|
345
|
+
currentSpec: spec,
|
|
346
|
+
language,
|
|
347
|
+
llmCall: (p) => this.classifierCall(runId, p),
|
|
348
|
+
});
|
|
349
|
+
const announce = `I read this as a ${cls.class} change. ${cls.proposedAction}. Stop me if that's wrong.`;
|
|
350
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: announce });
|
|
351
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: announce } });
|
|
352
|
+
await this.routeFeedback(runId, cls.class, text, spec);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Promote a previously-built version to the currently-deployed one.
|
|
356
|
+
* Stops the current container, starts the target's container, and updates
|
|
357
|
+
* the router map atomically.
|
|
358
|
+
*/
|
|
359
|
+
async promoteVersion(runId, toVersion) {
|
|
360
|
+
if (!this.opts.router)
|
|
361
|
+
return { ok: false, error: 'router not configured' };
|
|
362
|
+
const versions = listVersions(this.opts.store.versionsDir(runId));
|
|
363
|
+
const current = versions.find((v) => v.deploy?.containerName);
|
|
364
|
+
const target = versions.find((v) => v.version === toVersion);
|
|
365
|
+
if (!target?.deploy)
|
|
366
|
+
return { ok: false, error: `version ${toVersion} has no deploy` };
|
|
367
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
368
|
+
const r = await swapVersion({
|
|
369
|
+
versionsDir: this.opts.store.versionsDir(runId),
|
|
370
|
+
fromVersion: current?.version ?? toVersion,
|
|
371
|
+
toVersion,
|
|
372
|
+
slug: spec.meta.name,
|
|
373
|
+
router: this.opts.router,
|
|
374
|
+
startContainer: async (name) => {
|
|
375
|
+
const { dockerStart } = await import('./docker.js');
|
|
376
|
+
return dockerStart(name);
|
|
377
|
+
},
|
|
378
|
+
stopContainer: async (name) => {
|
|
379
|
+
const { dockerStop } = await import('./docker.js');
|
|
380
|
+
return dockerStop(name);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
if (r.ok) {
|
|
384
|
+
const run = this.runs.get(runId);
|
|
385
|
+
if (run)
|
|
386
|
+
this.emit(run, { type: 'app-url', url: target.deploy.url, version: toVersion });
|
|
387
|
+
}
|
|
388
|
+
return r;
|
|
389
|
+
}
|
|
390
|
+
/** Used by spec-delta to push a system-style bootstrap into the run session. */
|
|
391
|
+
async injectSystemMessage(runId, text) {
|
|
392
|
+
const run = this.requireRun(runId);
|
|
393
|
+
await this.opts.sessionManager.sendMessage(run.sessionName, `[system] ${text}`);
|
|
394
|
+
}
|
|
395
|
+
/** Used by spec-delta to flip mode back to 'interview' for the focused
|
|
396
|
+
interview, AND mark the run so the next interview-complete auto-fires
|
|
397
|
+
a build (no need for the user to click Start Build for a delta rerun). */
|
|
398
|
+
async setModeForDelta(runId) {
|
|
399
|
+
await this.opts.store.setMode(runId, 'interview');
|
|
400
|
+
const run = this.runs.get(runId);
|
|
401
|
+
if (run) {
|
|
402
|
+
run.deltaPending = true;
|
|
403
|
+
const spec = await this.opts.store.readSpec(runId);
|
|
404
|
+
this.emit(run, { type: 'spec-updated', spec });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async classifierCall(runId, prompt) {
|
|
408
|
+
const sessionName = `classifier-${runId}`;
|
|
409
|
+
if (!this.classifierSessions.has(runId)) {
|
|
410
|
+
await this.opts.sessionManager.startSession({
|
|
411
|
+
name: sessionName,
|
|
412
|
+
engine: 'claude',
|
|
413
|
+
model: 'claude-haiku-4-5-20251001',
|
|
414
|
+
permissionMode: 'bypassPermissions',
|
|
415
|
+
});
|
|
416
|
+
this.classifierSessions.add(runId);
|
|
417
|
+
}
|
|
418
|
+
return this.opts.sessionManager.sendMessage(sessionName, prompt);
|
|
419
|
+
}
|
|
420
|
+
async routeFeedback(runId, klass, text, spec) {
|
|
421
|
+
const run = this.requireRun(runId);
|
|
422
|
+
if (klass === 'cosmetic') {
|
|
423
|
+
await this.runPatcherFlow(runId, text);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (klass === 'spec-delta') {
|
|
427
|
+
await startSpecDeltaInterview(this, runId, text, spec);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// structural
|
|
431
|
+
const note = 'This sounds like a different app entirely. Click + New in the sidebar to start a fresh ultraapp run.';
|
|
432
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: note });
|
|
433
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: note } });
|
|
434
|
+
}
|
|
435
|
+
async runPatcherFlow(runId, feedback) {
|
|
436
|
+
const run = this.requireRun(runId);
|
|
437
|
+
const versions = listVersions(this.opts.store.versionsDir(runId));
|
|
438
|
+
const current = versions.find((v) => v.deploy?.containerName) ?? versions[versions.length - 1];
|
|
439
|
+
if (!current?.worktreePath) {
|
|
440
|
+
const err = 'no buildable worktree found for patcher';
|
|
441
|
+
this.emit(run, { type: 'error', message: err });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const patch = await runPatcher({
|
|
445
|
+
worktreePath: current.worktreePath,
|
|
446
|
+
feedback,
|
|
447
|
+
llmCall: (p) => this.patcherCall(runId, p),
|
|
448
|
+
validate: (a) => runFixOnFailure({ ...a, maxRounds: 3 }),
|
|
449
|
+
});
|
|
450
|
+
if (!patch.ok) {
|
|
451
|
+
const msg = `Patcher failed: ${patch.reason ?? 'unknown'}`;
|
|
452
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'error', text: msg });
|
|
453
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'error', text: msg } });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const nextVersion = snapshotVersion(this.opts.store.versionsDir(runId), {
|
|
457
|
+
worktreePath: current.worktreePath,
|
|
458
|
+
source: 'patcher',
|
|
459
|
+
});
|
|
460
|
+
const msg = `Patched and saved as ${nextVersion}. Promote it from the AppSpec column to swap the deployed container.`;
|
|
461
|
+
await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: msg });
|
|
462
|
+
this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: msg } });
|
|
463
|
+
}
|
|
464
|
+
async patcherCall(runId, prompt) {
|
|
465
|
+
const sessionName = `patcher-${runId}-${Date.now()}`;
|
|
466
|
+
await this.opts.sessionManager.startSession({
|
|
467
|
+
name: sessionName,
|
|
468
|
+
engine: 'claude',
|
|
469
|
+
model: 'claude-opus-4-7',
|
|
470
|
+
permissionMode: 'bypassPermissions',
|
|
471
|
+
});
|
|
472
|
+
try {
|
|
473
|
+
return await this.opts.sessionManager.sendMessage(sessionName, prompt);
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
await this.opts.sessionManager.stopSession(sessionName).catch(() => { });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
onBuildEvent(ev) {
|
|
480
|
+
const run = this.runs.get(ev.runId);
|
|
481
|
+
if (!run)
|
|
482
|
+
return;
|
|
483
|
+
// Always emit raw event for the dashboard mode pill.
|
|
484
|
+
this.emit(run, { type: 'build-event', event: ev });
|
|
485
|
+
// Push to the narrator (started in runBuild). The narrator owns chat
|
|
486
|
+
// narration now — no raw-line writes from this handler.
|
|
487
|
+
const narrator = this.narrators.get(ev.runId);
|
|
488
|
+
if (narrator)
|
|
489
|
+
narrator.push(ev);
|
|
490
|
+
// Tear down narrator on terminal events. push() above already triggered
|
|
491
|
+
// its urgent flush, so by the time we stop() any final summary is queued.
|
|
492
|
+
if (ev.type === 'build-complete' || ev.type === 'build-failed' || ev.type === 'build-cancelled') {
|
|
493
|
+
const n = this.narrators.get(ev.runId);
|
|
494
|
+
if (n) {
|
|
495
|
+
this.narrators.delete(ev.runId);
|
|
496
|
+
this.trackBackground(n.stop());
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Drain all in-flight background work (driveTurn chains, build-queue
|
|
502
|
+
* enqueue, narrator finalisation). Tests should await this in their
|
|
503
|
+
* cleanup hook before removing the per-run tmp directory.
|
|
504
|
+
*/
|
|
505
|
+
async waitForIdle() {
|
|
506
|
+
while (this.inflightWork.size > 0) {
|
|
507
|
+
await Promise.allSettled([...this.inflightWork]);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
fireDriveTurn(run, message) {
|
|
511
|
+
this.trackBackground(this.driveTurn(run, message));
|
|
512
|
+
}
|
|
513
|
+
trackBackground(p) {
|
|
514
|
+
const tracked = p.finally(() => this.inflightWork.delete(tracked));
|
|
515
|
+
this.inflightWork.add(tracked);
|
|
516
|
+
}
|
|
517
|
+
async driveTurn(run, message) {
|
|
518
|
+
try {
|
|
519
|
+
const { output } = await this.opts.sessionManager.sendMessage(run.sessionName, message);
|
|
520
|
+
// Debug capture for trace authoring: when UA_DEBUG_TURNS=<dir> is set,
|
|
521
|
+
// append every turn's (in, out) pair to <dir>/<runId>.turns.jsonl. No
|
|
522
|
+
// effect when unset. Used by scripts/ua-capture-trace.mjs to rebuild a
|
|
523
|
+
// replayable JSONL trace from a real interview run.
|
|
524
|
+
const debugDir = process.env.UA_DEBUG_TURNS;
|
|
525
|
+
if (debugDir) {
|
|
526
|
+
try {
|
|
527
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
528
|
+
fs.appendFileSync(path.join(debugDir, `${run.runId}.turns.jsonl`), JSON.stringify({ ts: new Date().toISOString(), in: message, out: output }) + '\n');
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
/* best-effort */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const parsed = parseInterviewReply(output);
|
|
535
|
+
if (parsed.kind === 'tools' || parsed.kind === 'tools-and-question') {
|
|
536
|
+
const results = await runToolCalls({
|
|
537
|
+
runId: run.runId,
|
|
538
|
+
store: this.opts.store,
|
|
539
|
+
extractMetadata: defaultExtractMetadata,
|
|
540
|
+
calls: parsed.toolCalls,
|
|
541
|
+
});
|
|
542
|
+
const spec = await this.opts.store.readSpec(run.runId);
|
|
543
|
+
this.emit(run, { type: 'spec-updated', spec });
|
|
544
|
+
const followup = results
|
|
545
|
+
.map((r) => `<tool_result name="${r.name}">${r.ok ? JSON.stringify(r.result) : `ERROR: ${r.error}`}</tool_result>`)
|
|
546
|
+
.join('\n');
|
|
547
|
+
if (parsed.kind === 'tools-and-question') {
|
|
548
|
+
// Claude already wrote the next question; surface it to the user
|
|
549
|
+
// immediately. Send the tool_result followup in the background so
|
|
550
|
+
// the LLM sees the tool succeeded — but we don't block the user
|
|
551
|
+
// on its reply (it's typically a "thanks, continuing" no-op).
|
|
552
|
+
await this.opts.store.appendChat(run.runId, {
|
|
553
|
+
role: 'assistant',
|
|
554
|
+
kind: 'question',
|
|
555
|
+
text: parsed.question.question,
|
|
556
|
+
payload: { ...parsed.question },
|
|
557
|
+
});
|
|
558
|
+
this.emit(run, { type: 'question', question: parsed.question });
|
|
559
|
+
this.fireDriveTurn(run, followup);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
this.fireDriveTurn(run, followup);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (parsed.kind === 'question') {
|
|
566
|
+
await this.opts.store.appendChat(run.runId, {
|
|
567
|
+
role: 'assistant',
|
|
568
|
+
kind: 'question',
|
|
569
|
+
text: parsed.question.question,
|
|
570
|
+
payload: { ...parsed.question },
|
|
571
|
+
});
|
|
572
|
+
this.emit(run, { type: 'question', question: parsed.question });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (parsed.kind === 'complete') {
|
|
576
|
+
await this.opts.store.appendChat(run.runId, {
|
|
577
|
+
role: 'assistant',
|
|
578
|
+
kind: 'narrator',
|
|
579
|
+
text: parsed.summary,
|
|
580
|
+
});
|
|
581
|
+
this.emit(run, { type: 'interview-complete', summary: parsed.summary });
|
|
582
|
+
// If this completion came out of a spec-delta focused interview, the
|
|
583
|
+
// user already signalled their intent to rebuild — auto-start so they
|
|
584
|
+
// don't have to click again. Best-effort: a strict-validation failure
|
|
585
|
+
// surfaces as an error chat entry and the user can fix + click Build.
|
|
586
|
+
if (run.deltaPending) {
|
|
587
|
+
run.deltaPending = false;
|
|
588
|
+
const note = 'Spec delta complete — auto-starting build.';
|
|
589
|
+
await this.opts.store.appendChat(run.runId, {
|
|
590
|
+
role: 'system',
|
|
591
|
+
kind: 'narrator',
|
|
592
|
+
text: note,
|
|
593
|
+
});
|
|
594
|
+
this.emit(run, {
|
|
595
|
+
type: 'chat',
|
|
596
|
+
entry: { role: 'system', kind: 'narrator', text: note },
|
|
597
|
+
});
|
|
598
|
+
this.startBuild(run.runId).catch((err) => {
|
|
599
|
+
const msg = `Auto-build failed: ${err.message}`;
|
|
600
|
+
void this.opts.store.appendChat(run.runId, {
|
|
601
|
+
role: 'system',
|
|
602
|
+
kind: 'error',
|
|
603
|
+
text: msg,
|
|
604
|
+
});
|
|
605
|
+
this.emit(run, {
|
|
606
|
+
type: 'chat',
|
|
607
|
+
entry: { role: 'system', kind: 'error', text: msg },
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (parsed.kind === 'text') {
|
|
614
|
+
await this.opts.store.appendChat(run.runId, {
|
|
615
|
+
role: 'assistant',
|
|
616
|
+
kind: 'free',
|
|
617
|
+
text: parsed.text,
|
|
618
|
+
});
|
|
619
|
+
this.emit(run, {
|
|
620
|
+
type: 'chat',
|
|
621
|
+
entry: { role: 'assistant', kind: 'free', text: parsed.text },
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.emit(run, { type: 'error', message: parsed.reason });
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
this.emit(run, { type: 'error', message: e.message });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
emit(run, ev) {
|
|
632
|
+
run.emitter.emit('event', ev);
|
|
633
|
+
}
|
|
634
|
+
requireRun(runId) {
|
|
635
|
+
const r = this.runs.get(runId);
|
|
636
|
+
if (!r)
|
|
637
|
+
throw new Error(`unknown runId: ${runId}`);
|
|
638
|
+
return r;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function detectLanguage(chat) {
|
|
642
|
+
for (const e of chat) {
|
|
643
|
+
if (e.role !== 'user')
|
|
644
|
+
continue;
|
|
645
|
+
if (/[一-龥]/.test(e.text))
|
|
646
|
+
return 'zh';
|
|
647
|
+
return 'en';
|
|
648
|
+
}
|
|
649
|
+
return 'en';
|
|
650
|
+
}
|
|
651
|
+
function loadSkill(skillPath) {
|
|
652
|
+
if (skillPath) {
|
|
653
|
+
try {
|
|
654
|
+
return fs.readFileSync(skillPath, 'utf8');
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
/* fall through */
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Resolve relative to this module: src/ultraapp/manager.ts → ../../skills/ultraapp/SKILL.md
|
|
661
|
+
// After build, dist/src/ultraapp/manager.js → ../../../skills/ultraapp/SKILL.md
|
|
662
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
663
|
+
const candidates = [
|
|
664
|
+
path.resolve(here, '../../skills/ultraapp/SKILL.md'),
|
|
665
|
+
path.resolve(here, '../../../skills/ultraapp/SKILL.md'),
|
|
666
|
+
path.resolve(process.cwd(), 'skills/ultraapp/SKILL.md'),
|
|
667
|
+
];
|
|
668
|
+
for (const c of candidates) {
|
|
669
|
+
try {
|
|
670
|
+
return fs.readFileSync(c, 'utf8');
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
/* try next */
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Test fallback — minimal but functional contract
|
|
677
|
+
return [
|
|
678
|
+
'You are running the ultraapp interview. Emit one question per turn as a fenced ```question JSON block with',
|
|
679
|
+
'{"question": str, "options": [{"label": str, "value": str}], "recommended": str, "freeformAccepted": bool, "context"?: str}.',
|
|
680
|
+
'Use <tool name="update_spec">[...JSON Patch...]</tool>, <tool name="extract_metadata">{"ref": str}</tool>,',
|
|
681
|
+
'<tool name="check_completeness">{}</tool>. End with [INTERVIEW: COMPLETE] when done.',
|
|
682
|
+
].join(' ');
|
|
683
|
+
}
|
|
684
|
+
//# sourceMappingURL=manager.js.map
|