@ordewell/web 0.10.12 → 0.11.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/AUTHORS.md +37 -0
- package/NOTICE +71 -0
- package/README.md +15 -7
- package/dist/server/main.js +451 -189
- package/dist/server/main.js.map +1 -1
- package/package.json +9 -8
package/dist/server/main.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
var hono = require('hono');
|
|
5
5
|
var child_process = require('child_process');
|
|
6
6
|
var crypto = require('crypto');
|
|
7
|
+
var path = require('path');
|
|
7
8
|
var core = require('@ordewell/core');
|
|
8
9
|
var fs = require('fs');
|
|
9
|
-
var path = require('path');
|
|
10
10
|
var ws = require('ws');
|
|
11
11
|
var http = require('http');
|
|
12
12
|
|
|
@@ -28,8 +28,8 @@ function _interopNamespace(e) {
|
|
|
28
28
|
return Object.freeze(n);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
|
|
32
31
|
var path__namespace = /*#__PURE__*/_interopNamespace(path);
|
|
32
|
+
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
|
|
33
33
|
|
|
34
34
|
var WebConfig = class extends core.BaseConfig {
|
|
35
35
|
_enabledRunnersOverride;
|
|
@@ -144,16 +144,20 @@ function scanWorkspaces() {
|
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
146
|
var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
147
|
+
var SEALED_OUTPUT_GRACE_MS = 1e3;
|
|
147
148
|
function run(file, args2, opts) {
|
|
148
|
-
return new Promise((
|
|
149
|
+
return new Promise((resolve3) => {
|
|
149
150
|
try {
|
|
150
|
-
child_process.execFile(file, args2, {
|
|
151
|
+
const child = child_process.execFile(file, args2, {
|
|
151
152
|
cwd: opts.cwd,
|
|
152
153
|
timeout: opts.timeout,
|
|
153
154
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
154
155
|
encoding: "utf8",
|
|
155
156
|
shell: opts.shell,
|
|
156
157
|
env: opts.env,
|
|
158
|
+
// A sealed run's wall-time limit is a kill, not a request it may trap
|
|
159
|
+
// (ADR-0036 S1); the sandbox takes the processes it started down with it.
|
|
160
|
+
...opts.sealed ? { killSignal: "SIGKILL" } : {},
|
|
157
161
|
// Node kills the child when this aborts, and calls back with an
|
|
158
162
|
// ABORT_ERR — which lands on the `code: 1` branch below, i.e. a
|
|
159
163
|
// failed tool call, exactly like any other command that did not
|
|
@@ -162,10 +166,14 @@ function run(file, args2, opts) {
|
|
|
162
166
|
signal: opts.signal
|
|
163
167
|
}, (err, stdout, stderr) => {
|
|
164
168
|
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
165
|
-
|
|
169
|
+
resolve3({ stdout: stdout ?? "", stderr: stderr ?? "", code, timedOut: err?.killed === true });
|
|
166
170
|
});
|
|
171
|
+
if (opts.sealed) child.once("exit", () => setTimeout(() => {
|
|
172
|
+
child.stdout?.destroy();
|
|
173
|
+
child.stderr?.destroy();
|
|
174
|
+
}, SEALED_OUTPUT_GRACE_MS).unref());
|
|
167
175
|
} catch (err) {
|
|
168
|
-
|
|
176
|
+
resolve3({ stdout: "", stderr: err.message, code: null, timedOut: false });
|
|
169
177
|
}
|
|
170
178
|
});
|
|
171
179
|
}
|
|
@@ -321,16 +329,16 @@ var PoolFileSystem = class extends core.BaseFileSystem {
|
|
|
321
329
|
return { success: false, output: `Error listing ${absPath}: ${err.message}`, truncated: false };
|
|
322
330
|
}
|
|
323
331
|
}
|
|
324
|
-
async execBashImpl(command, signal) {
|
|
325
|
-
const { file, args: args2 } =
|
|
326
|
-
const result = file === null ? await run(command, [], { cwd
|
|
332
|
+
async execBashImpl(command, shell, signal, cwd) {
|
|
333
|
+
const { file, args: args2, env, wallTimeMs } = shell;
|
|
334
|
+
const result = file === null ? await run(command, [], { cwd, timeout: BASH_TIMEOUT_MS, shell: true, env, signal }) : await run(file, [...args2, command], { cwd, timeout: wallTimeMs ?? BASH_TIMEOUT_MS, env, signal, sealed: wallTimeMs !== void 0 });
|
|
327
335
|
const out = (result.stdout || "").trim();
|
|
328
336
|
const err = (result.stderr || "").trim();
|
|
329
337
|
if (signal?.aborted) return { success: false, output: core.STOPPED_TOOL_RESULT, truncated: false };
|
|
330
338
|
if (result.code !== 0) {
|
|
331
339
|
const detail = err || out || `exited with code ${result.code}`;
|
|
332
340
|
return { success: false, output: `Command exited ${result.code}:
|
|
333
|
-
${detail.slice(0, 2e4)}`, truncated: detail.length > 2e4 };
|
|
341
|
+
${detail.slice(0, 2e4)}`, truncated: detail.length > 2e4, ...result.timedOut ? { timedOut: true } : {} };
|
|
334
342
|
}
|
|
335
343
|
const combined = err ? `${out}
|
|
336
344
|
[stderr]
|
|
@@ -381,6 +389,12 @@ var PoolAwareRunner = class {
|
|
|
381
389
|
|
|
382
390
|
// server/pool/orchestratorPool.ts
|
|
383
391
|
var OrchestratorPool = class {
|
|
392
|
+
// A daemon serves several workspaces; login selection must never follow
|
|
393
|
+
// whichever session was opened most recently (ADR-0051).
|
|
394
|
+
sessionWorkspaces = /* @__PURE__ */ new Map();
|
|
395
|
+
accountLimits = /* @__PURE__ */ new Map();
|
|
396
|
+
accountLimitCollectors;
|
|
397
|
+
limitsRevision = 0;
|
|
384
398
|
sessions = /* @__PURE__ */ new Map();
|
|
385
399
|
clients = /* @__PURE__ */ new Map();
|
|
386
400
|
/**
|
|
@@ -394,6 +408,13 @@ var OrchestratorPool = class {
|
|
|
394
408
|
registry = new core.RunnerRegistry();
|
|
395
409
|
modelResolver;
|
|
396
410
|
runnerInstallation;
|
|
411
|
+
/**
|
|
412
|
+
* A host that supplied its own probe (a test, or a surface that owns one).
|
|
413
|
+
* Everything else asks {@link installationFor}, which keys a real probe by
|
|
414
|
+
* the workspace whose account it is judging (ADR-0016).
|
|
415
|
+
*/
|
|
416
|
+
suppliedRunnerInstallation;
|
|
417
|
+
workspaceInstallations = /* @__PURE__ */ new Map();
|
|
397
418
|
cachedProviderLists;
|
|
398
419
|
settingsService = new core.SettingsService();
|
|
399
420
|
plannerModelMemory = new core.PlannerModelMemory(this.settingsService);
|
|
@@ -406,13 +427,31 @@ var OrchestratorPool = class {
|
|
|
406
427
|
runner;
|
|
407
428
|
/** One probe per daemon, asked again whenever the runner list is refreshed (ADR-0033). */
|
|
408
429
|
codexSandbox;
|
|
430
|
+
leaseHost;
|
|
431
|
+
isolationPreview;
|
|
409
432
|
constructor(deps = {}) {
|
|
410
433
|
const pluginNotice = core.removedPluginNotice();
|
|
411
434
|
if (pluginNotice) console.warn(pluginNotice);
|
|
412
435
|
this.runner = deps.runner ?? new core.StructuredRunner();
|
|
413
|
-
this.
|
|
436
|
+
this.suppliedRunnerInstallation = deps.runnerInstallation;
|
|
437
|
+
this.runnerInstallation = deps.runnerInstallation ?? new core.RunnerInstallation(this.registry, void 0, void 0, {
|
|
438
|
+
env: () => core.workspaceEnvOf(process.cwd()),
|
|
439
|
+
workspace: () => process.cwd()
|
|
440
|
+
});
|
|
414
441
|
this.modelResolver = deps.modelResolver ?? new core.ModelResolver(this.registry, new WebConfig());
|
|
415
442
|
this.codexSandbox = deps.codexSandbox ?? new core.CodexSandboxAvailability(this.registry, { spawn: child_process.spawn, fetch: globalThis.fetch }, { cwd: process.cwd(), env: process.env });
|
|
443
|
+
this.leaseHost = deps.leaseHost;
|
|
444
|
+
this.isolationPreview = deps.isolationPreview ?? core.createIsolationPreview({ config: new WebConfig() });
|
|
445
|
+
this.accountLimitCollectors = deps.accountLimitCollectors ?? ((workspace) => {
|
|
446
|
+
const workspaceRoot = () => workspace;
|
|
447
|
+
const isInstalled = (runner) => async () => (await this.runnerInstallation.filterInstalled([runner])).includes(runner);
|
|
448
|
+
const server2 = this.registry.getManifest("codex")?.modelDiscovery.appServer;
|
|
449
|
+
return [
|
|
450
|
+
core.claudeAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("claude-code") }),
|
|
451
|
+
core.codexAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("codex"), command: server2?.command, args: server2?.args }),
|
|
452
|
+
core.openCodeGoAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("opencode") })
|
|
453
|
+
];
|
|
454
|
+
});
|
|
416
455
|
}
|
|
417
456
|
/**
|
|
418
457
|
* Not awaited: planning must start at once, and the daemon already warms the
|
|
@@ -431,6 +470,15 @@ var OrchestratorPool = class {
|
|
|
431
470
|
detectInstalledPlanner() {
|
|
432
471
|
return core.detectInstalledPlanner(this.runnerInstallation);
|
|
433
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* A read-only look at the repositories and branches a run would isolate in
|
|
475
|
+
* `workspace`. Free of side effects on purpose: a first screen may ask before
|
|
476
|
+
* any plan or run exists, and asking must not create one.
|
|
477
|
+
*/
|
|
478
|
+
previewIsolation(workspace) {
|
|
479
|
+
core.assertWorkspaceExists(workspace);
|
|
480
|
+
return this.isolationPreview.preview(workspace);
|
|
481
|
+
}
|
|
434
482
|
/**
|
|
435
483
|
* A new session's first planner turn. It waits for that probe at the
|
|
436
484
|
* session's own planner gate, where a reply sent meanwhile queues behind
|
|
@@ -448,12 +496,59 @@ var OrchestratorPool = class {
|
|
|
448
496
|
subscribe(sessionId, ws) {
|
|
449
497
|
if (!this.clients.has(sessionId)) this.clients.set(sessionId, /* @__PURE__ */ new Set());
|
|
450
498
|
this.clients.get(sessionId).add(ws);
|
|
499
|
+
this.connectAccountLimits(sessionId, ws);
|
|
451
500
|
}
|
|
452
501
|
unsubscribe(sessionId, ws) {
|
|
453
502
|
this.clients.get(sessionId)?.delete(ws);
|
|
454
503
|
if (this.clients.get(sessionId)?.size === 0) this.clients.delete(sessionId);
|
|
504
|
+
this.releaseUnusedAccountLimits();
|
|
505
|
+
}
|
|
506
|
+
connectAccountLimits(sessionId, ws) {
|
|
507
|
+
const workspace = this.sessionWorkspaces.get(sessionId);
|
|
508
|
+
if (!workspace) {
|
|
509
|
+
this.sendAccountLimits(sessionId, ws, []);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
let monitor = this.accountLimits.get(workspace);
|
|
513
|
+
if (!monitor) {
|
|
514
|
+
monitor = new core.AccountLimitMonitor({ collectors: this.accountLimitCollectors(workspace) });
|
|
515
|
+
this.accountLimits.set(workspace, monitor);
|
|
516
|
+
monitor.subscribe((limits) => {
|
|
517
|
+
for (const [id, clients] of this.clients) {
|
|
518
|
+
if (this.sessionWorkspaces.get(id) !== workspace) continue;
|
|
519
|
+
for (const client of clients) this.sendAccountLimits(id, client, limits);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
this.sendAccountLimits(sessionId, ws, monitor.snapshots());
|
|
524
|
+
monitor.setForeground(true);
|
|
525
|
+
monitor.surfaceConnected();
|
|
526
|
+
}
|
|
527
|
+
sendAccountLimits(sessionId, ws, limits) {
|
|
528
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
529
|
+
ws.send(JSON.stringify({ type: "account_limits", sessionId, limits, stream: this.stream, revision: ++this.limitsRevision }));
|
|
530
|
+
}
|
|
531
|
+
releaseUnusedAccountLimits() {
|
|
532
|
+
for (const [workspace, monitor] of this.accountLimits) {
|
|
533
|
+
if ([...this.clients.keys()].some((id) => this.sessionWorkspaces.get(id) === workspace)) continue;
|
|
534
|
+
monitor.dispose();
|
|
535
|
+
this.accountLimits.delete(workspace);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
registerSession(sessionId, workspace, session) {
|
|
539
|
+
this.sessions.set(sessionId, session);
|
|
540
|
+
this.sessionWorkspaces.set(sessionId, workspace);
|
|
541
|
+
for (const ws of this.clients.get(sessionId) ?? []) this.connectAccountLimits(sessionId, ws);
|
|
455
542
|
}
|
|
456
543
|
broadcast(sessionId, msg) {
|
|
544
|
+
const workspace = this.sessionWorkspaces.get(sessionId);
|
|
545
|
+
const monitor = workspace ? this.accountLimits.get(workspace) : void 0;
|
|
546
|
+
if (msg.type === "planner_usage") monitor?.invalidate();
|
|
547
|
+
else if (msg.type === "task_log" && msg.events.some((event) => event.type === "turn_end")) {
|
|
548
|
+
const task = core.flattenTasks(this.sessions.get(sessionId)?.planTasks ?? []).find((task2) => task2.id === msg.taskId);
|
|
549
|
+
const runner = task && core.assignedRunnerOf(task);
|
|
550
|
+
if (runner) monitor?.invalidate(runner);
|
|
551
|
+
}
|
|
457
552
|
const seq = (this.sequence.get(sessionId) ?? 0) + 1;
|
|
458
553
|
this.sequence.set(sessionId, seq);
|
|
459
554
|
const payload = JSON.stringify({ ...msg, stream: this.stream, seq });
|
|
@@ -495,6 +590,14 @@ var OrchestratorPool = class {
|
|
|
495
590
|
runSnapshot(sessionId) {
|
|
496
591
|
return this.sessions.get(sessionId)?.runSnapshot() ?? null;
|
|
497
592
|
}
|
|
593
|
+
/** The planner turn answering in a session this process holds, if one is. */
|
|
594
|
+
plannerTurnId(sessionId) {
|
|
595
|
+
return this.sessions.get(sessionId)?.plannerTurnId;
|
|
596
|
+
}
|
|
597
|
+
/** The planner compaction running in a session this process holds, as its start was announced; it has no turn to name. */
|
|
598
|
+
plannerCompaction(sessionId) {
|
|
599
|
+
return this.sessions.get(sessionId)?.plannerCompaction();
|
|
600
|
+
}
|
|
498
601
|
/** The planner's approval requests still waiting, as they were announced. */
|
|
499
602
|
announcedApprovals(sessionId) {
|
|
500
603
|
return this.sessions.get(sessionId)?.announcedApprovals() ?? [];
|
|
@@ -536,7 +639,8 @@ var OrchestratorPool = class {
|
|
|
536
639
|
onNotice: (notice) => this.broadcast(sessionId, notice),
|
|
537
640
|
modelResolver: this.modelResolver,
|
|
538
641
|
settings: () => this.runtimeSettings(),
|
|
539
|
-
sessionId
|
|
642
|
+
sessionId,
|
|
643
|
+
...this.leaseHost ? { leaseHost: this.leaseHost } : {}
|
|
540
644
|
});
|
|
541
645
|
}
|
|
542
646
|
/**
|
|
@@ -575,9 +679,10 @@ var OrchestratorPool = class {
|
|
|
575
679
|
return enabled ? current.includes(id) ? current : [...current, id] : current.filter((r) => r !== id);
|
|
576
680
|
});
|
|
577
681
|
}
|
|
578
|
-
getSettings() {
|
|
682
|
+
getSettings(sessionId) {
|
|
579
683
|
const config = this.plannerConfig();
|
|
580
684
|
const userSettings = this.settingsService.getAll();
|
|
685
|
+
const live = sessionId ? this.sessions.get(sessionId) : void 0;
|
|
581
686
|
return {
|
|
582
687
|
orchestratorModel: config.orchestratorModel,
|
|
583
688
|
// Who plans (ADR-0009) — a vendor id, or one of the three harness
|
|
@@ -593,19 +698,21 @@ var OrchestratorPool = class {
|
|
|
593
698
|
// The model remembered per planner backend (this task), so a surface can
|
|
594
699
|
// render what's remembered without a second round-trip.
|
|
595
700
|
plannerModels: userSettings.plannerModels,
|
|
596
|
-
|
|
701
|
+
// An open session shows what it was opened with; a closed or unknown id
|
|
702
|
+
// falls back to the file, which is what a new session starts from (ADR-0030).
|
|
703
|
+
run: live?.runSettings() ?? this.settingsService.getRunSettings()
|
|
597
704
|
};
|
|
598
705
|
}
|
|
599
706
|
/**
|
|
600
707
|
* Save the user's run settings, and apply them at once to `sessionId`'s run
|
|
601
|
-
* when that session is open here (ADR-0030). Other sessions
|
|
602
|
-
*
|
|
603
|
-
* out of range.
|
|
708
|
+
* when that session is open here (ADR-0030). Other sessions keep the values
|
|
709
|
+
* they were opened with. Throws a {@link RunSettingsError}, saving nothing,
|
|
710
|
+
* for a value out of range.
|
|
604
711
|
*/
|
|
605
712
|
updateRunSettings(changes, sessionId) {
|
|
606
713
|
this.settingsService.updateRunSettings(changes);
|
|
607
714
|
if (sessionId) this.sessions.get(sessionId)?.applyRunSettings(changes);
|
|
608
|
-
return this.getSettings();
|
|
715
|
+
return this.getSettings(sessionId);
|
|
609
716
|
}
|
|
610
717
|
/**
|
|
611
718
|
* The catalog `selectPlanner` should judge `provider`'s
|
|
@@ -629,7 +736,7 @@ var OrchestratorPool = class {
|
|
|
629
736
|
plannerProviderInEffect() {
|
|
630
737
|
return this.plannerConfig().aiProvider;
|
|
631
738
|
}
|
|
632
|
-
updateSettings(changes) {
|
|
739
|
+
updateSettings(changes, sessionId) {
|
|
633
740
|
const current = this.plannerConfig().planner;
|
|
634
741
|
const rawEnv = changes.env && typeof changes.env === "object" && !Array.isArray(changes.env) ? changes.env : void 0;
|
|
635
742
|
const admission = rawEnv ? core.admitSettingsEnv(rawEnv) : void 0;
|
|
@@ -675,13 +782,14 @@ var OrchestratorPool = class {
|
|
|
675
782
|
}
|
|
676
783
|
}
|
|
677
784
|
}
|
|
785
|
+
if (envChanges) for (const monitor of this.accountLimits.values()) monitor.refresh();
|
|
678
786
|
if (selection?.remember) {
|
|
679
787
|
const { provider, model, effort } = selection.remember;
|
|
680
788
|
const switched = provider !== current.provider;
|
|
681
789
|
this.plannerModelMemory.remember(switched ? provider : this.plannerProviderInEffect(), model, effort);
|
|
682
790
|
}
|
|
683
791
|
const rejectedEnvKeys = admission?.rejected ?? [];
|
|
684
|
-
const settings = this.getSettings();
|
|
792
|
+
const settings = this.getSettings(sessionId);
|
|
685
793
|
if (rejectedEnvKeys.length > 0) settings.rejectedEnvKeys = rejectedEnvKeys;
|
|
686
794
|
if (selection?.recall) settings.switchRecall = selection.recall;
|
|
687
795
|
return settings;
|
|
@@ -742,40 +850,89 @@ var OrchestratorPool = class {
|
|
|
742
850
|
const config = this.plannerConfig();
|
|
743
851
|
return { enabledRunners: config.enabledRunners, orchestratorModel: config.orchestratorModel };
|
|
744
852
|
}
|
|
853
|
+
/**
|
|
854
|
+
* The probe that judges a login for `workspace`, cached so its install and
|
|
855
|
+
* auth verdicts survive between runner-list refreshes. Without a workspace
|
|
856
|
+
* the daemon's cwd is what the request would plan with anyway.
|
|
857
|
+
*/
|
|
858
|
+
installationFor(workspace) {
|
|
859
|
+
if (this.suppliedRunnerInstallation) return this.suppliedRunnerInstallation;
|
|
860
|
+
if (!workspace) return this.runnerInstallation;
|
|
861
|
+
const key = path.resolve(workspace);
|
|
862
|
+
let installation = this.workspaceInstallations.get(key);
|
|
863
|
+
if (!installation) {
|
|
864
|
+
installation = new core.RunnerInstallation(this.registry, void 0, void 0, {
|
|
865
|
+
env: () => core.workspaceEnvOf(key),
|
|
866
|
+
workspace: () => key
|
|
867
|
+
});
|
|
868
|
+
this.workspaceInstallations.set(key, installation);
|
|
869
|
+
}
|
|
870
|
+
return installation;
|
|
871
|
+
}
|
|
745
872
|
/**
|
|
746
873
|
* Runners whose CLI is actually installed on the host, with display name and
|
|
747
874
|
* enabled state. Only these should be offered for selection — an uninstalled
|
|
748
|
-
* runner can't be spawned.
|
|
875
|
+
* runner can't be spawned. `workspace` names whose account the auth verdict
|
|
876
|
+
* is about; without it the daemon's cwd stands in.
|
|
749
877
|
*/
|
|
750
|
-
async getInstalledRunners() {
|
|
878
|
+
async getInstalledRunners(workspace) {
|
|
751
879
|
await this.codexSandbox.refresh();
|
|
752
|
-
this.
|
|
880
|
+
const installation = this.installationFor(workspace);
|
|
881
|
+
installation.refresh?.();
|
|
753
882
|
await this.detectInstalledPlanner();
|
|
754
883
|
const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride() });
|
|
755
884
|
const enabled = new Set(config.enabledRunners);
|
|
756
885
|
const runners = this.registry.list();
|
|
757
886
|
const installedIds = new Set(
|
|
758
|
-
await
|
|
887
|
+
await installation.filterInstalled(runners.map((p) => p.manifest.name))
|
|
888
|
+
);
|
|
889
|
+
const rows = await Promise.all(
|
|
890
|
+
runners.filter((p) => installedIds.has(p.manifest.name)).map(async (p) => {
|
|
891
|
+
const { usable, auth } = await installation.plannerUsability(p.manifest.name);
|
|
892
|
+
if (!usable) return null;
|
|
893
|
+
return {
|
|
894
|
+
id: p.manifest.name,
|
|
895
|
+
name: p.manifest.displayName,
|
|
896
|
+
enabled: enabled.has(p.manifest.name),
|
|
897
|
+
...auth ? { auth } : {}
|
|
898
|
+
};
|
|
899
|
+
})
|
|
759
900
|
);
|
|
760
|
-
return
|
|
761
|
-
id: p.manifest.name,
|
|
762
|
-
name: p.manifest.displayName,
|
|
763
|
-
enabled: enabled.has(p.manifest.name)
|
|
764
|
-
}));
|
|
901
|
+
return rows.filter((row) => row !== null);
|
|
765
902
|
}
|
|
766
903
|
async generatePlan(sessionId, goal, runners, workspace, modelOverride, options) {
|
|
767
|
-
const
|
|
904
|
+
const recheck = this.recheckSignedOutPlanner(workspace, this.plannerConfig(modelOverride).aiProvider);
|
|
905
|
+
if (recheck) await recheck;
|
|
906
|
+
const session = this.openPlanningSession(sessionId, runners, workspace, modelOverride, options);
|
|
768
907
|
return core.migratePlanState(await this.firstTurn(sessionId, session, () => session.generatePlan(goal, runners)));
|
|
769
908
|
}
|
|
909
|
+
/**
|
|
910
|
+
* Judge the planner as the workspace's account is now, not as it was when the
|
|
911
|
+
* list was last read: the signed-out refusal itself offers the runner's own
|
|
912
|
+
* `auth login` as a way back, and that happens in another process. Only a
|
|
913
|
+
* recorded signed-out verdict is asked again — unknown never blocks, and a
|
|
914
|
+
* signed-in one is the runner's own failure to report. Null when there is
|
|
915
|
+
* nothing to ask, so the turn registers at once as it always did.
|
|
916
|
+
*/
|
|
917
|
+
recheckSignedOutPlanner(workspace, provider) {
|
|
918
|
+
const runner = core.runnerForProvider(provider);
|
|
919
|
+
if (!runner || core.recordedRunnerAuth(runner, workspace)?.state !== "signed-out") return null;
|
|
920
|
+
const installation = this.installationFor(workspace);
|
|
921
|
+
installation.refresh?.();
|
|
922
|
+
return installation.plannerUsability(runner).then(() => void 0);
|
|
923
|
+
}
|
|
770
924
|
/**
|
|
771
925
|
* Build the session a new planning conversation runs in and register it in
|
|
772
926
|
* place of any live one, for {@link firstTurn} to run its first turn on.
|
|
773
927
|
*/
|
|
774
|
-
openPlanningSession(sessionId, workspace, modelOverride, options) {
|
|
928
|
+
openPlanningSession(sessionId, runners, workspace, modelOverride, options) {
|
|
775
929
|
const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
|
|
776
|
-
if (core.installedPlannerAnswered())
|
|
930
|
+
if (core.installedPlannerAnswered()) {
|
|
931
|
+
session.assertPlannerReady();
|
|
932
|
+
session.assertRunnersEnabled(runners);
|
|
933
|
+
}
|
|
777
934
|
this.destroy(sessionId);
|
|
778
|
-
this.
|
|
935
|
+
this.registerSession(sessionId, workspace, session);
|
|
779
936
|
this.warmCodexSandbox();
|
|
780
937
|
return session;
|
|
781
938
|
}
|
|
@@ -786,7 +943,9 @@ var OrchestratorPool = class {
|
|
|
786
943
|
* assistant message lives in conversationHistory).
|
|
787
944
|
*/
|
|
788
945
|
async startPlanning(sessionId, goal, runners, workspace, modelOverride, options) {
|
|
789
|
-
const
|
|
946
|
+
const recheck = this.recheckSignedOutPlanner(workspace, this.plannerConfig(modelOverride).aiProvider);
|
|
947
|
+
if (recheck) await recheck;
|
|
948
|
+
const session = this.openPlanningSession(sessionId, runners, workspace, modelOverride, options);
|
|
790
949
|
return this.firstTurn(sessionId, session, () => session.startPlanning(goal, runners));
|
|
791
950
|
}
|
|
792
951
|
/**
|
|
@@ -797,14 +956,38 @@ var OrchestratorPool = class {
|
|
|
797
956
|
* it does for every turn.
|
|
798
957
|
*/
|
|
799
958
|
async continuePlanning(sessionId, message) {
|
|
800
|
-
|
|
959
|
+
const session = this.session(sessionId);
|
|
960
|
+
const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
|
|
961
|
+
if (recheck) await recheck;
|
|
962
|
+
return session.continueConversation(message);
|
|
801
963
|
}
|
|
802
964
|
/** Condense a session's conversation. The summary turn is a planner turn, so `cancelPlanning` stops it. */
|
|
803
965
|
async compactConversation(sessionId) {
|
|
804
966
|
const session = this.session(sessionId);
|
|
967
|
+
const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
|
|
968
|
+
if (recheck) await recheck;
|
|
805
969
|
const compaction = await session.compactConversation();
|
|
806
970
|
return { ...compaction, plan: session.planState };
|
|
807
971
|
}
|
|
972
|
+
/**
|
|
973
|
+
* Send now (ADR-0023): the Session stops the planner turn in flight and
|
|
974
|
+
* answers this message in the turn that replaces it, ahead of anything
|
|
975
|
+
* waiting. The recheck is the one a reply does before the gate: a login done
|
|
976
|
+
* with the runner's own command is seen here too.
|
|
977
|
+
*/
|
|
978
|
+
async forceSendPrompt(sessionId, message) {
|
|
979
|
+
const session = this.session(sessionId);
|
|
980
|
+
const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
|
|
981
|
+
if (recheck) await recheck;
|
|
982
|
+
return session.forceSendPrompt(message);
|
|
983
|
+
}
|
|
984
|
+
/** Retry a compaction paused on the planner's context: a planner turn, and gated as one. */
|
|
985
|
+
async retryPlannerCompaction(sessionId) {
|
|
986
|
+
const session = this.session(sessionId);
|
|
987
|
+
const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
|
|
988
|
+
if (recheck) await recheck;
|
|
989
|
+
return session.retryPlannerCompaction();
|
|
990
|
+
}
|
|
808
991
|
/**
|
|
809
992
|
* Abort the planner turn in flight for a session, if any. Answers whether
|
|
810
993
|
* there was one to cancel — the route reports that rather than 404ing a
|
|
@@ -828,9 +1011,14 @@ var OrchestratorPool = class {
|
|
|
828
1011
|
const saved = core.loadSession(sessionId, workspace);
|
|
829
1012
|
if (!saved) throw new core.SessionNotFoundError();
|
|
830
1013
|
const session = this.createSessionFor(sessionId, workspace);
|
|
831
|
-
|
|
1014
|
+
try {
|
|
1015
|
+
session.loadPlan(saved.plan, saved.meta.goal, workspace, { sessionId: saved.meta.id });
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
session.destroy();
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
832
1020
|
this.destroy(sessionId);
|
|
833
|
-
this.
|
|
1021
|
+
this.registerSession(sessionId, workspace, session);
|
|
834
1022
|
return session.planState ?? saved.plan;
|
|
835
1023
|
}
|
|
836
1024
|
/**
|
|
@@ -859,9 +1047,26 @@ var OrchestratorPool = class {
|
|
|
859
1047
|
if (!session) throw new core.SessionNotFoundError();
|
|
860
1048
|
return session;
|
|
861
1049
|
}
|
|
862
|
-
|
|
1050
|
+
/**
|
|
1051
|
+
* Stop a session and drop it. A replacement under the same id (re-planning,
|
|
1052
|
+
* adopting) keeps its subscribers: they are watching the id, and the new
|
|
1053
|
+
* session's broadcasts continue on the same socket. An explicit delete or
|
|
1054
|
+
* close is different — the surface must stop drawing a session that no
|
|
1055
|
+
* longer exists — so those pass `notifyClients`.
|
|
1056
|
+
*/
|
|
1057
|
+
destroy(sessionId, options) {
|
|
863
1058
|
this.sessions.get(sessionId)?.destroy();
|
|
864
1059
|
this.sessions.delete(sessionId);
|
|
1060
|
+
this.sessionWorkspaces.delete(sessionId);
|
|
1061
|
+
this.releaseUnusedAccountLimits();
|
|
1062
|
+
if (!options?.notifyClients) return;
|
|
1063
|
+
const clients = this.clients.get(sessionId);
|
|
1064
|
+
if (!clients) return;
|
|
1065
|
+
this.broadcast(sessionId, { type: "notice", level: "info", message: "Session closed." });
|
|
1066
|
+
for (const ws of clients) {
|
|
1067
|
+
if (ws.readyState === ws.OPEN) ws.close(1001, "Session closed");
|
|
1068
|
+
}
|
|
1069
|
+
this.clients.delete(sessionId);
|
|
865
1070
|
}
|
|
866
1071
|
destroyAll() {
|
|
867
1072
|
for (const id of this.sessions.keys()) this.destroy(id);
|
|
@@ -877,8 +1082,28 @@ function removeSession(sessionId, workspace) {
|
|
|
877
1082
|
function stringField(value) {
|
|
878
1083
|
return typeof value === "string" ? value : void 0;
|
|
879
1084
|
}
|
|
1085
|
+
var InvalidBodyError = class extends Error {
|
|
1086
|
+
constructor(message) {
|
|
1087
|
+
super(message);
|
|
1088
|
+
this.name = "InvalidBodyError";
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
async function jsonObject(c) {
|
|
1092
|
+
let parsed;
|
|
1093
|
+
try {
|
|
1094
|
+
parsed = await c.req.json();
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new InvalidBodyError("The request body must be valid JSON.");
|
|
1097
|
+
}
|
|
1098
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1099
|
+
throw new InvalidBodyError("The request body must be a JSON object.");
|
|
1100
|
+
}
|
|
1101
|
+
return parsed;
|
|
1102
|
+
}
|
|
880
1103
|
function classify(err) {
|
|
1104
|
+
if (err instanceof InvalidBodyError) return { status: 400, code: "refused" };
|
|
881
1105
|
if (err instanceof core.SessionNotFoundError) return { status: 404, code: "session_not_found" };
|
|
1106
|
+
if (err instanceof core.SessionHeldError) return { status: 409, code: "session_held" };
|
|
882
1107
|
if (err instanceof core.NoPlanError) return { status: 400, code: "no_plan" };
|
|
883
1108
|
if (err instanceof core.AlreadyExecutingError) return { status: 409, code: "already_executing" };
|
|
884
1109
|
if (err instanceof core.WorkspaceNotAProjectError) return { status: 400, code: "workspace_not_a_project" };
|
|
@@ -904,7 +1129,8 @@ function failure(c, err, label, { fallback = 500, message = "Internal error" } =
|
|
|
904
1129
|
error: err instanceof Error && err.message || message,
|
|
905
1130
|
...classified ? { code: classified.code } : {},
|
|
906
1131
|
...err instanceof core.WorkspaceNotAProjectError ? { workspace: err.workspace } : {},
|
|
907
|
-
...err instanceof core.PlannerNotReadyError ? { plannerNotReady: err.reason } : {}
|
|
1132
|
+
...err instanceof core.PlannerNotReadyError ? { plannerNotReady: err.reason } : {},
|
|
1133
|
+
...err instanceof core.SessionHeldError ? { sessionHeld: err.held } : {}
|
|
908
1134
|
};
|
|
909
1135
|
return c.json(body, status);
|
|
910
1136
|
}
|
|
@@ -923,7 +1149,13 @@ function sessionsRoute(pool2) {
|
|
|
923
1149
|
const saved = core.loadSessionPlanState(id, ws);
|
|
924
1150
|
if (!saved) return refuse(c, 404, "Session not found", "session_not_found");
|
|
925
1151
|
const run2 = pool2.runSnapshot(id);
|
|
926
|
-
|
|
1152
|
+
const plannerCompaction = pool2.plannerCompaction(id);
|
|
1153
|
+
return c.json({
|
|
1154
|
+
meta: saved.meta,
|
|
1155
|
+
plan: core.surfacePlanState(pool2.getPlanState(id) ?? saved.plan),
|
|
1156
|
+
...run2 ? { run: run2 } : {},
|
|
1157
|
+
...plannerCompaction ? { plannerCompaction } : {}
|
|
1158
|
+
});
|
|
927
1159
|
});
|
|
928
1160
|
router.post("/:id/load", (c) => {
|
|
929
1161
|
const ws = c.req.query("workspace") || process.cwd();
|
|
@@ -931,7 +1163,16 @@ function sessionsRoute(pool2) {
|
|
|
931
1163
|
const id = c.req.param("id");
|
|
932
1164
|
const plan = pool2.adoptSavedSession(id, ws);
|
|
933
1165
|
const run2 = pool2.runSnapshot(id);
|
|
934
|
-
|
|
1166
|
+
const plannerTurnId = pool2.plannerTurnId(id);
|
|
1167
|
+
const plannerCompaction = pool2.plannerCompaction(id);
|
|
1168
|
+
return c.json({
|
|
1169
|
+
ok: true,
|
|
1170
|
+
plan: core.surfacePlan(plan),
|
|
1171
|
+
goal: pool2.getGoal(id),
|
|
1172
|
+
...run2 ? { run: run2 } : {},
|
|
1173
|
+
...plannerTurnId ? { plannerTurnId } : {},
|
|
1174
|
+
...plannerCompaction ? { plannerCompaction } : {}
|
|
1175
|
+
});
|
|
935
1176
|
} catch (err) {
|
|
936
1177
|
return failure(c, err, "load session", { message: "Failed to load session" });
|
|
937
1178
|
}
|
|
@@ -949,12 +1190,17 @@ function sessionsRoute(pool2) {
|
|
|
949
1190
|
router.delete("/:id", (c) => {
|
|
950
1191
|
const ws = c.req.query("workspace") || process.cwd();
|
|
951
1192
|
const id = c.req.param("id");
|
|
952
|
-
pool2.
|
|
953
|
-
|
|
954
|
-
|
|
1193
|
+
const held = pool2.hasSession(id);
|
|
1194
|
+
pool2.destroy(id, { notifyClients: true });
|
|
1195
|
+
const removed = removeSession(id, ws);
|
|
1196
|
+
const ok = held || removed;
|
|
1197
|
+
return c.json({
|
|
1198
|
+
ok,
|
|
1199
|
+
...ok ? {} : { message: `No session "${id}" was found in ${ws}.` }
|
|
1200
|
+
});
|
|
955
1201
|
});
|
|
956
1202
|
router.post("/:id/close", (c) => {
|
|
957
|
-
pool2.destroy(c.req.param("id"));
|
|
1203
|
+
pool2.destroy(c.req.param("id"), { notifyClients: true });
|
|
958
1204
|
return c.json({ ok: true });
|
|
959
1205
|
});
|
|
960
1206
|
return router;
|
|
@@ -972,7 +1218,7 @@ function plansRoute(pool2) {
|
|
|
972
1218
|
const router = new hono.Hono();
|
|
973
1219
|
router.post("/:sessionId/generate", async (c) => {
|
|
974
1220
|
try {
|
|
975
|
-
const { goal, runners, workspace, model, allowInit } = await c
|
|
1221
|
+
const { goal, runners, workspace, model, allowInit } = await jsonObject(c);
|
|
976
1222
|
if (!goal) return c.json({ error: "goal is required" }, 400);
|
|
977
1223
|
const ws = workspace || c.req.query("workspace") || process.cwd();
|
|
978
1224
|
const queryRunners = c.req.query("runners");
|
|
@@ -1012,68 +1258,19 @@ function plansRoute(pool2) {
|
|
|
1012
1258
|
const cancelled = pool2.cancelPlanning(c.req.param("sessionId"));
|
|
1013
1259
|
return c.json({ cancelled });
|
|
1014
1260
|
});
|
|
1015
|
-
router.post("/:sessionId/
|
|
1261
|
+
router.post("/:sessionId/execute-command", async (c) => {
|
|
1016
1262
|
try {
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
});
|
|
1023
|
-
for (const [segment, command] of [
|
|
1024
|
-
["run", (id) => ({ command: "run_task", task: id })],
|
|
1025
|
-
["retry", (id) => ({ command: "retry_task", task: id, from: "fresh" })],
|
|
1026
|
-
["cancel", (id) => ({ command: "stop_task", task: id })],
|
|
1027
|
-
["complete", (id) => ({ command: "mark_complete", task: id })],
|
|
1028
|
-
["uncomplete", (id) => ({ command: "mark_not_done", task: id })],
|
|
1029
|
-
// The user says the limit has reset early: the task, and every other one waiting on its runner's reset, resumes in its saved session now.
|
|
1030
|
-
["resume/now", (id) => ({ command: "retry_task", task: id, from: "session" })],
|
|
1031
|
-
["interrupt", (id) => ({ command: "interrupt_task", task: id })]
|
|
1032
|
-
]) {
|
|
1033
|
-
router.post(`/:sessionId/tasks/:taskId/${segment}`, async (c) => {
|
|
1034
|
-
try {
|
|
1035
|
-
const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), command(c.req.param("taskId")));
|
|
1036
|
-
return c.json({ ok: true, message });
|
|
1037
|
-
} catch (err) {
|
|
1038
|
-
return failure(c, err, segment);
|
|
1263
|
+
const parsed = core.parseExecutionCommand(await jsonObject(c));
|
|
1264
|
+
if (!parsed.ok) return refuse(c, 400, parsed.message, "refused");
|
|
1265
|
+
const outcome = await pool2.session(c.req.param("sessionId")).execute(parsed.command, { kind: "user" });
|
|
1266
|
+
if (!outcome.ok) {
|
|
1267
|
+
return c.json({ error: outcome.message, code: "refused", ...outcome.payload ? { payload: outcome.payload } : {} }, 400);
|
|
1039
1268
|
}
|
|
1040
|
-
|
|
1041
|
-
}
|
|
1042
|
-
router.post("/:sessionId/tasks/:taskId/skip", async (c) => {
|
|
1043
|
-
try {
|
|
1044
|
-
const body = await c.req.json().catch(() => ({}));
|
|
1045
|
-
const reason = typeof body === "object" && body !== null && "reason" in body && typeof body.reason === "string" ? body.reason : void 0;
|
|
1046
|
-
const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "skip_task", task: c.req.param("taskId"), ...reason ? { reason } : {} });
|
|
1047
|
-
return c.json({ ok: true, message });
|
|
1048
|
-
} catch (err) {
|
|
1049
|
-
return failure(c, err, "skip");
|
|
1050
|
-
}
|
|
1051
|
-
});
|
|
1052
|
-
router.post("/:sessionId/tasks/:taskId/force-start", async (c) => {
|
|
1053
|
-
try {
|
|
1054
|
-
const { message, payload } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "force_start", task: c.req.param("taskId") });
|
|
1055
|
-
return c.json({ ok: true, ...payload?.kind === "queued" ? { queued: payload.line } : {}, message });
|
|
1056
|
-
} catch (err) {
|
|
1057
|
-
return failure(c, err, "force-start");
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
const messageHandler = (send) => async (c) => {
|
|
1061
|
-
try {
|
|
1062
|
-
const body = await c.req.json().catch(() => ({}));
|
|
1063
|
-
const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
|
|
1064
|
-
if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
|
|
1065
|
-
const id = await send(pool2.session(c.req.param("sessionId")), c.req.param("taskId"), text);
|
|
1066
|
-
return c.json({ id });
|
|
1269
|
+
return c.json({ ok: true, message: outcome.message, ...outcome.payload ? { payload: outcome.payload } : {} });
|
|
1067
1270
|
} catch (err) {
|
|
1068
|
-
return failure(c, err, "
|
|
1271
|
+
return failure(c, err, "execute-command");
|
|
1069
1272
|
}
|
|
1070
|
-
};
|
|
1071
|
-
const sendMessage = (force) => messageHandler(async (session, taskId, text) => {
|
|
1072
|
-
const { payload } = await runUserCommand(session, { command: "message_task", task: taskId, text, ...force ? { force } : {} });
|
|
1073
|
-
return payload?.kind === "message" ? payload.id : "";
|
|
1074
1273
|
});
|
|
1075
|
-
router.post("/:sessionId/tasks/:taskId/messages", sendMessage(false));
|
|
1076
|
-
router.post("/:sessionId/tasks/:taskId/messages/now", sendMessage(true));
|
|
1077
1274
|
router.post("/:sessionId/tasks/:taskId/commands", async (c) => {
|
|
1078
1275
|
try {
|
|
1079
1276
|
const body = await c.req.json().catch(() => ({}));
|
|
@@ -1086,33 +1283,6 @@ function plansRoute(pool2) {
|
|
|
1086
1283
|
return failure(c, err, "task edit");
|
|
1087
1284
|
}
|
|
1088
1285
|
});
|
|
1089
|
-
router.post("/:sessionId/tasks/:taskId/messages/:messageId/now", async (c) => {
|
|
1090
|
-
try {
|
|
1091
|
-
const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "force_send_queued", task: c.req.param("taskId"), id: c.req.param("messageId") });
|
|
1092
|
-
return c.json({ sent: true, message });
|
|
1093
|
-
} catch (err) {
|
|
1094
|
-
return failure(c, err, "task edit");
|
|
1095
|
-
}
|
|
1096
|
-
});
|
|
1097
|
-
router.post("/:sessionId/tasks/:taskId/continue", async (c) => {
|
|
1098
|
-
try {
|
|
1099
|
-
const body = await c.req.json().catch(() => ({}));
|
|
1100
|
-
const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
|
|
1101
|
-
if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
|
|
1102
|
-
const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "retry_task", task: c.req.param("taskId"), from: "session", message: text });
|
|
1103
|
-
return c.json({ ok: true, message });
|
|
1104
|
-
} catch (err) {
|
|
1105
|
-
return failure(c, err, "task edit");
|
|
1106
|
-
}
|
|
1107
|
-
});
|
|
1108
|
-
router.delete("/:sessionId/tasks/:taskId/messages/:messageId", async (c) => {
|
|
1109
|
-
try {
|
|
1110
|
-
const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "remove_queued", task: c.req.param("taskId"), id: c.req.param("messageId") });
|
|
1111
|
-
return c.json({ removed: true, message });
|
|
1112
|
-
} catch (err) {
|
|
1113
|
-
return failure(c, err, "task edit");
|
|
1114
|
-
}
|
|
1115
|
-
});
|
|
1116
1286
|
for (const answer of ["approve", "reject"]) {
|
|
1117
1287
|
router.post(`/:sessionId/tasks/:taskId/checkpoint/${answer}`, async (c) => {
|
|
1118
1288
|
try {
|
|
@@ -1183,7 +1353,7 @@ function plansRoute(pool2) {
|
|
|
1183
1353
|
});
|
|
1184
1354
|
router.put("/:sessionId/tasks/:taskId", async (c) => {
|
|
1185
1355
|
try {
|
|
1186
|
-
const changes = await c
|
|
1356
|
+
const changes = await jsonObject(c);
|
|
1187
1357
|
if (changes.assignedModel === null) changes.assignedModel = void 0;
|
|
1188
1358
|
const result = await pool2.session(c.req.param("sessionId")).updateTask(c.req.param("taskId"), changes);
|
|
1189
1359
|
if (!result) return refuse(c, 404, "Task not found", "task_not_found");
|
|
@@ -1203,7 +1373,7 @@ function plansRoute(pool2) {
|
|
|
1203
1373
|
});
|
|
1204
1374
|
router.post("/:sessionId/tasks", async (c) => {
|
|
1205
1375
|
try {
|
|
1206
|
-
const body = await c
|
|
1376
|
+
const body = await jsonObject(c);
|
|
1207
1377
|
const result = await pool2.session(c.req.param("sessionId")).addTask(body);
|
|
1208
1378
|
if (!result) return refuse(c, 404, "Session not found", "session_not_found");
|
|
1209
1379
|
return c.json({ ok: true });
|
|
@@ -1222,29 +1392,9 @@ function plansRoute(pool2) {
|
|
|
1222
1392
|
return failure(c, err, "review approve");
|
|
1223
1393
|
}
|
|
1224
1394
|
});
|
|
1225
|
-
router.post("/:sessionId/tasks/merge", async (c) => {
|
|
1226
|
-
try {
|
|
1227
|
-
const { taskIds } = await c.req.json();
|
|
1228
|
-
if (!taskIds || !Array.isArray(taskIds) || taskIds.length < 2) {
|
|
1229
|
-
return c.json({ error: "taskIds array with at least two ids is required" }, 400);
|
|
1230
|
-
}
|
|
1231
|
-
const plan = await pool2.session(c.req.param("sessionId")).requestMerge(taskIds);
|
|
1232
|
-
return c.json({ plan: core.surfacePlan(plan) });
|
|
1233
|
-
} catch (err) {
|
|
1234
|
-
return failure(c, err, "merge", { fallback: 400 });
|
|
1235
|
-
}
|
|
1236
|
-
});
|
|
1237
|
-
router.post("/:sessionId/tasks/:taskId/split", async (c) => {
|
|
1238
|
-
try {
|
|
1239
|
-
const plan = await pool2.session(c.req.param("sessionId")).requestSplit(c.req.param("taskId"));
|
|
1240
|
-
return c.json({ plan: core.surfacePlan(plan) });
|
|
1241
|
-
} catch (err) {
|
|
1242
|
-
return failure(c, err, "split", { fallback: 400 });
|
|
1243
|
-
}
|
|
1244
|
-
});
|
|
1245
1395
|
router.post("/:sessionId/converse/start", async (c) => {
|
|
1246
1396
|
try {
|
|
1247
|
-
const { goal, runners, workspace, model, allowInit } = await c
|
|
1397
|
+
const { goal, runners, workspace, model, allowInit } = await jsonObject(c);
|
|
1248
1398
|
if (!goal) return c.json({ error: "goal is required" }, 400);
|
|
1249
1399
|
const ws = workspace || c.req.query("workspace") || process.cwd();
|
|
1250
1400
|
const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : void 0;
|
|
@@ -1256,7 +1406,7 @@ function plansRoute(pool2) {
|
|
|
1256
1406
|
});
|
|
1257
1407
|
router.post("/:sessionId/converse/message", async (c) => {
|
|
1258
1408
|
try {
|
|
1259
|
-
const { message } = await c
|
|
1409
|
+
const { message } = await jsonObject(c);
|
|
1260
1410
|
if (!message) return c.json({ error: "message is required" }, 400);
|
|
1261
1411
|
const plan = await pool2.continuePlanning(c.req.param("sessionId"), message);
|
|
1262
1412
|
return c.json({ plan: core.surfacePlan(plan) });
|
|
@@ -1266,9 +1416,9 @@ function plansRoute(pool2) {
|
|
|
1266
1416
|
});
|
|
1267
1417
|
router.post("/:sessionId/converse/message/now", async (c) => {
|
|
1268
1418
|
try {
|
|
1269
|
-
const { message } = await c
|
|
1419
|
+
const { message } = await jsonObject(c);
|
|
1270
1420
|
if (!message) return c.json({ error: "message is required" }, 400);
|
|
1271
|
-
const plan = await pool2.
|
|
1421
|
+
const plan = await pool2.forceSendPrompt(c.req.param("sessionId"), message);
|
|
1272
1422
|
return c.json({ plan: core.surfacePlan(plan) });
|
|
1273
1423
|
} catch (err) {
|
|
1274
1424
|
return failure(c, err, "conversation message");
|
|
@@ -1299,8 +1449,8 @@ function plansRoute(pool2) {
|
|
|
1299
1449
|
});
|
|
1300
1450
|
router.post("/:sessionId/conversation/rewind", async (c) => {
|
|
1301
1451
|
try {
|
|
1302
|
-
const { index } = await c
|
|
1303
|
-
if (!Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
|
|
1452
|
+
const { index } = await jsonObject(c);
|
|
1453
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
|
|
1304
1454
|
const rewound = pool2.rewindConversation(c.req.param("sessionId"), index);
|
|
1305
1455
|
return c.json({ ...rewound, plan: core.surfacePlan(rewound.plan) });
|
|
1306
1456
|
} catch (err) {
|
|
@@ -1315,6 +1465,14 @@ function plansRoute(pool2) {
|
|
|
1315
1465
|
return failure(c, err, "conversation edit");
|
|
1316
1466
|
}
|
|
1317
1467
|
});
|
|
1468
|
+
router.post("/:sessionId/conversation/compact/retry", async (c) => {
|
|
1469
|
+
try {
|
|
1470
|
+
const plan = await pool2.retryPlannerCompaction(c.req.param("sessionId"));
|
|
1471
|
+
return c.json({ plan: core.surfacePlan(plan) });
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
return failure(c, err, "retry compaction");
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1318
1476
|
router.get("/:sessionId/prd", (c) => {
|
|
1319
1477
|
const plan = pool2.getPlan(c.req.param("sessionId"));
|
|
1320
1478
|
if (!plan?.prdMarkdown) return refuse(c, 404, "No PRD found");
|
|
@@ -1325,7 +1483,7 @@ function plansRoute(pool2) {
|
|
|
1325
1483
|
function runnersRoute(pool2) {
|
|
1326
1484
|
const router = new hono.Hono();
|
|
1327
1485
|
router.get("/", async (c) => {
|
|
1328
|
-
const runners = await pool2.getInstalledRunners();
|
|
1486
|
+
const runners = await pool2.getInstalledRunners(c.req.query("workspace"));
|
|
1329
1487
|
const state = pool2.getRunnerState();
|
|
1330
1488
|
return c.json({
|
|
1331
1489
|
runners,
|
|
@@ -1333,11 +1491,16 @@ function runnersRoute(pool2) {
|
|
|
1333
1491
|
});
|
|
1334
1492
|
});
|
|
1335
1493
|
router.put("/:id", async (c) => {
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1494
|
+
try {
|
|
1495
|
+
await core.installedPlannerDetected();
|
|
1496
|
+
const id = c.req.param("id");
|
|
1497
|
+
const { enabled } = await jsonObject(c);
|
|
1498
|
+
if (typeof enabled !== "boolean") return refuse(c, 400, "enabled must be true or false");
|
|
1499
|
+
pool2.setRunnerEnabled(id, enabled);
|
|
1500
|
+
return c.json({ ok: true });
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
return failure(c, err, "runner toggle", { fallback: 400 });
|
|
1503
|
+
}
|
|
1341
1504
|
});
|
|
1342
1505
|
return router;
|
|
1343
1506
|
}
|
|
@@ -1349,8 +1512,18 @@ function modelsRoute(pool2) {
|
|
|
1349
1512
|
});
|
|
1350
1513
|
return router;
|
|
1351
1514
|
}
|
|
1352
|
-
function workspacesRoute() {
|
|
1515
|
+
function workspacesRoute(pool2) {
|
|
1353
1516
|
const router = new hono.Hono();
|
|
1517
|
+
router.get("/isolation-preview", async (c) => {
|
|
1518
|
+
try {
|
|
1519
|
+
const workspace = c.req.query("workspace") || process.cwd();
|
|
1520
|
+
core.assertWorkspaceExists(workspace);
|
|
1521
|
+
const preview = await pool2.previewIsolation(workspace);
|
|
1522
|
+
return c.json({ preview });
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
return failure(c, err, "read isolation preview", { message: "Could not read the repositories" });
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1354
1527
|
router.get("/", (c) => {
|
|
1355
1528
|
const workspaces = scanWorkspaces2();
|
|
1356
1529
|
return c.json({ workspaces });
|
|
@@ -1399,28 +1572,33 @@ function settingsRoute(pool2) {
|
|
|
1399
1572
|
const router = new hono.Hono();
|
|
1400
1573
|
router.get("/", async (c) => {
|
|
1401
1574
|
await core.installedPlannerDetected();
|
|
1402
|
-
const settings = pool2.getSettings();
|
|
1575
|
+
const settings = pool2.getSettings(c.req.query("sessionId") || void 0);
|
|
1403
1576
|
return c.json(settings);
|
|
1404
1577
|
});
|
|
1405
1578
|
router.patch("/", async (c) => {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
if (
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1579
|
+
try {
|
|
1580
|
+
await core.installedPlannerDetected();
|
|
1581
|
+
const body = await jsonObject(c);
|
|
1582
|
+
if (Object.keys(body).length === 0) {
|
|
1583
|
+
return refuse(c, 400, "At least one setting field is required");
|
|
1584
|
+
}
|
|
1585
|
+
const { run: run2, sessionId: namedSession, ...rest } = body;
|
|
1586
|
+
if (namedSession !== void 0 && typeof namedSession !== "string") return refuse(c, 400, "sessionId must be a string");
|
|
1587
|
+
const sessionId = namedSession || void 0;
|
|
1588
|
+
if (run2 !== void 0) {
|
|
1589
|
+
if (!run2 || typeof run2 !== "object" || Array.isArray(run2)) return refuse(c, 400, "run must be an object of run settings");
|
|
1590
|
+
try {
|
|
1591
|
+
pool2.updateRunSettings(run2, sessionId);
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
if (err instanceof core.RunSettingsError) return refuse(c, 400, err.message);
|
|
1594
|
+
throw err;
|
|
1595
|
+
}
|
|
1420
1596
|
}
|
|
1597
|
+
const result = Object.keys(rest).length > 0 ? sessionId ? pool2.updateSettings(rest, sessionId) : pool2.updateSettings(rest) : pool2.getSettings(sessionId);
|
|
1598
|
+
return c.json(result);
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
return failure(c, err, "settings update", { fallback: 400 });
|
|
1421
1601
|
}
|
|
1422
|
-
const result = Object.keys(rest).length > 0 ? pool2.updateSettings(rest) : pool2.getSettings();
|
|
1423
|
-
return c.json(result);
|
|
1424
1602
|
});
|
|
1425
1603
|
return router;
|
|
1426
1604
|
}
|
|
@@ -1464,6 +1642,8 @@ function createWsHandler(pool2) {
|
|
|
1464
1642
|
...pool2.streamPosition(sessionId)
|
|
1465
1643
|
}));
|
|
1466
1644
|
}
|
|
1645
|
+
const compaction = pool2.plannerCompaction(sessionId);
|
|
1646
|
+
if (compaction) ws.send(JSON.stringify(compaction));
|
|
1467
1647
|
pool2.subscribe(sessionId, ws);
|
|
1468
1648
|
});
|
|
1469
1649
|
return (request, socket, head) => {
|
|
@@ -1543,13 +1723,14 @@ function createApp(pool2, admission) {
|
|
|
1543
1723
|
if (!decision.admit) return c.json({ error: decision.reason }, decision.status);
|
|
1544
1724
|
await next();
|
|
1545
1725
|
});
|
|
1726
|
+
app.get("/api/server", (c) => c.json({ pid: process.pid, port: admission.port }));
|
|
1546
1727
|
app.route("/api/sessions", sessionsRoute(pool2));
|
|
1547
1728
|
app.route("/api/plans", plansRoute(pool2));
|
|
1548
1729
|
app.route("/api/runners", runnersRoute(pool2));
|
|
1549
1730
|
app.route("/api/models", modelsRoute(pool2));
|
|
1550
1731
|
app.route("/api/settings", settingsRoute(pool2));
|
|
1551
1732
|
app.route("/api/commands", commandsRoute());
|
|
1552
|
-
app.route("/api/workspaces", workspacesRoute());
|
|
1733
|
+
app.route("/api/workspaces", workspacesRoute(pool2));
|
|
1553
1734
|
app.route("/api/approvals", approvalsRoute(pool2));
|
|
1554
1735
|
return app;
|
|
1555
1736
|
}
|
|
@@ -1589,11 +1770,77 @@ ${body}`
|
|
|
1589
1770
|
}
|
|
1590
1771
|
|
|
1591
1772
|
// server/nodeAdapter.ts
|
|
1592
|
-
|
|
1773
|
+
var MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
1774
|
+
var BodyTooLargeError = class extends Error {
|
|
1775
|
+
};
|
|
1776
|
+
function refuse2(req, res, status, message) {
|
|
1777
|
+
const payload = JSON.stringify({ error: message });
|
|
1778
|
+
res.writeHead(status, {
|
|
1779
|
+
"Content-Type": "application/json",
|
|
1780
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
1781
|
+
// Nothing more is read from this request, so its connection cannot be reused.
|
|
1782
|
+
Connection: "close"
|
|
1783
|
+
});
|
|
1784
|
+
res.end(payload, () => req.destroy());
|
|
1785
|
+
}
|
|
1786
|
+
function readBody(req, max) {
|
|
1787
|
+
return new Promise((resolve3, reject) => {
|
|
1788
|
+
const chunks = [];
|
|
1789
|
+
let size = 0;
|
|
1790
|
+
const cleanup = () => {
|
|
1791
|
+
req.off("data", onData);
|
|
1792
|
+
req.off("end", onEnd);
|
|
1793
|
+
req.off("error", onError);
|
|
1794
|
+
};
|
|
1795
|
+
const onData = (chunk) => {
|
|
1796
|
+
size += chunk.length;
|
|
1797
|
+
if (size > max) {
|
|
1798
|
+
cleanup();
|
|
1799
|
+
req.pause();
|
|
1800
|
+
reject(new BodyTooLargeError());
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
chunks.push(chunk);
|
|
1804
|
+
};
|
|
1805
|
+
const onEnd = () => {
|
|
1806
|
+
cleanup();
|
|
1807
|
+
resolve3(chunks.length > 0 ? Buffer.concat(chunks).toString() : void 0);
|
|
1808
|
+
};
|
|
1809
|
+
const onError = (err) => {
|
|
1810
|
+
cleanup();
|
|
1811
|
+
reject(err);
|
|
1812
|
+
};
|
|
1813
|
+
req.on("data", onData);
|
|
1814
|
+
req.on("end", onEnd);
|
|
1815
|
+
req.on("error", onError);
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
function createRequestListener(app, admission, maxBodyBytes = MAX_BODY_BYTES) {
|
|
1593
1819
|
return async (req, res) => {
|
|
1594
|
-
const
|
|
1595
|
-
|
|
1596
|
-
|
|
1820
|
+
const decision = decideAdmission({
|
|
1821
|
+
origin: req.headers.origin ?? null,
|
|
1822
|
+
host: req.headers.host ?? null,
|
|
1823
|
+
authorization: req.headers.authorization ?? null
|
|
1824
|
+
}, admission);
|
|
1825
|
+
if (!decision.admit) {
|
|
1826
|
+
refuse2(req, res, decision.status, decision.reason);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
const declared = Number(req.headers["content-length"]);
|
|
1830
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) {
|
|
1831
|
+
refuse2(req, res, 413, `Request body must be at most ${maxBodyBytes} bytes.`);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
let body;
|
|
1835
|
+
try {
|
|
1836
|
+
body = await readBody(req, maxBodyBytes);
|
|
1837
|
+
} catch (err) {
|
|
1838
|
+
if (err instanceof BodyTooLargeError) {
|
|
1839
|
+
refuse2(req, res, 413, `Request body must be at most ${maxBodyBytes} bytes.`);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
throw err;
|
|
1843
|
+
}
|
|
1597
1844
|
const url = `http://${req.headers.host || "localhost"}${req.url}`;
|
|
1598
1845
|
const headers = new Headers();
|
|
1599
1846
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
@@ -1636,6 +1883,10 @@ if (portIdx !== -1 && args[portIdx + 1]) {
|
|
|
1636
1883
|
}
|
|
1637
1884
|
var watchParentIdx = args.indexOf("--watch-parent");
|
|
1638
1885
|
var watchParentPid = watchParentIdx !== -1 ? parseInt(args[watchParentIdx + 1], 10) : void 0;
|
|
1886
|
+
var pidFileIdx = args.indexOf("--pid-file");
|
|
1887
|
+
var pidFile = pidFileIdx !== -1 ? args[pidFileIdx + 1] : void 0;
|
|
1888
|
+
var ownsPidFile = false;
|
|
1889
|
+
core.processLeaseHost().describe({ kind: "daemon", port, ...watchParentPid ? { ownerPid: watchParentPid } : {} });
|
|
1639
1890
|
var pool = new OrchestratorPool({ runner: new core.StructuredRunner() });
|
|
1640
1891
|
void pool.detectInstalledPlanner();
|
|
1641
1892
|
var server = http.createServer();
|
|
@@ -1643,10 +1894,15 @@ var ownsToken = false;
|
|
|
1643
1894
|
server.listen(port, "127.0.0.1", () => {
|
|
1644
1895
|
const { token, file: tokenFile } = core.mintDaemonToken(port);
|
|
1645
1896
|
ownsToken = true;
|
|
1897
|
+
if (pidFile) {
|
|
1898
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
1899
|
+
fs.writeFileSync(pidFile, String(process.pid), { mode: 384 });
|
|
1900
|
+
ownsPidFile = true;
|
|
1901
|
+
}
|
|
1646
1902
|
const admission = { port, token, tokenFile };
|
|
1647
1903
|
const app = createApp(pool, admission);
|
|
1648
1904
|
app.get("/", (c) => c.json({ message: "Ordewell API", docs: "/api/workspaces" }));
|
|
1649
|
-
server.on("request", createRequestListener(app));
|
|
1905
|
+
server.on("request", createRequestListener(app, admission));
|
|
1650
1906
|
attachWsHandler(server, pool, admission);
|
|
1651
1907
|
pool.getProviderModels().catch((err) => {
|
|
1652
1908
|
console.error(`[web] model discovery warmup failed: ${err?.message ?? err}`);
|
|
@@ -1655,6 +1911,12 @@ server.listen(port, "127.0.0.1", () => {
|
|
|
1655
1911
|
});
|
|
1656
1912
|
function shutdown() {
|
|
1657
1913
|
if (ownsToken) core.clearDaemonToken(port);
|
|
1914
|
+
if (ownsPidFile && pidFile) {
|
|
1915
|
+
try {
|
|
1916
|
+
fs.unlinkSync(pidFile);
|
|
1917
|
+
} catch {
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1658
1920
|
pool.destroyAll();
|
|
1659
1921
|
server.close();
|
|
1660
1922
|
process.exit(0);
|
|
@@ -1663,7 +1925,7 @@ process.on("unhandledRejection", (reason) => {
|
|
|
1663
1925
|
console.error(`[web] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
|
|
1664
1926
|
});
|
|
1665
1927
|
process.on("SIGINT", () => {
|
|
1666
|
-
shutdown();
|
|
1928
|
+
if (!watchParentPid) shutdown();
|
|
1667
1929
|
});
|
|
1668
1930
|
process.on("SIGTERM", () => {
|
|
1669
1931
|
shutdown();
|