@alisio/server 0.1.0-alpha.8 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridges/interaction-bridge.js +13 -4
- package/dist/host/goal.d.ts +36 -0
- package/dist/host/goal.js +172 -0
- package/dist/host/plan.d.ts +10 -0
- package/dist/host/plan.js +45 -0
- package/dist/host/presets.d.ts +1 -1
- package/dist/host/presets.js +13 -4
- package/dist/host/run-scheduler.d.ts +25 -2
- package/dist/host/run-scheduler.js +18 -3
- package/dist/host/sessions.d.ts +10 -1
- package/dist/host/sessions.js +26 -5
- package/dist/host/workspace-host.d.ts +19 -4
- package/dist/host/workspace-host.js +63 -7
- package/dist/http/errors.js +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +178 -10
- package/dist/routes/changelog.d.ts +9 -0
- package/dist/routes/changelog.js +32 -0
- package/dist/routes/commands.d.ts +8 -0
- package/dist/routes/commands.js +137 -12
- package/dist/routes/goal.d.ts +21 -0
- package/dist/routes/goal.js +121 -0
- package/dist/routes/management.d.ts +12 -2
- package/dist/routes/management.js +49 -6
- package/dist/routes/plugin-views.d.ts +22 -0
- package/dist/routes/plugin-views.js +96 -0
- package/dist/routes/sessions.js +2 -0
- package/dist/routes/tasks.d.ts +5 -0
- package/dist/routes/tasks.js +70 -0
- package/dist/web/assets/AgentPicker-CgYzrrsh.js +1 -0
- package/dist/web/assets/AgentsModal-DQQp4Vj3.js +2 -0
- package/dist/web/assets/ArtifactPanel-I6u3qByq.js +2 -0
- package/dist/web/assets/BtwPanel-GypuLgxx.js +1 -0
- package/dist/web/assets/ChangelogDialog-BicA9ah6.css +1 -0
- package/dist/web/assets/ChangelogDialog-Cv0qoFca.js +1 -0
- package/dist/web/assets/CopyButton-B3PSPdbl.js +1 -0
- package/dist/web/assets/GoalBar-CHV3_kfx.css +1 -0
- package/dist/web/assets/GoalBar-DBE7KAva.js +1 -0
- package/dist/web/assets/MemoryTab-BDfJaFot.js +2 -0
- package/dist/web/assets/MemoryTab-BMdPbUkv.css +1 -0
- package/dist/web/assets/PermissionsMenu-BgOVGPRL.js +1 -0
- package/dist/web/assets/PlanReviewPanel-BNJgw4ah.js +1 -0
- package/dist/web/assets/PlanReviewPanel-DIB4EV2K.css +1 -0
- package/dist/web/assets/SettingsModal-D9zkXUeA.js +1 -0
- package/dist/web/assets/SpreadsheetPanel-C3BQhxhv.js +1 -0
- package/dist/web/assets/TasksTab-D8nzSXEr.js +1 -0
- package/dist/web/assets/TasksTab-xvZOft9V.css +1 -0
- package/dist/web/assets/TrajectoryTab-CH7ttdZT.js +1 -0
- package/dist/web/assets/TrajectoryTab-PJHG3yJv.css +1 -0
- package/dist/web/assets/{abnfDiagram-VCTEODGH-BSbQI796.js → abnfDiagram-VCTEODGH-B8huo-Kk.js} +1 -1
- package/dist/web/assets/{agents.module-B4txg5O-.js → agents.module-YUJKpT3J.js} +2 -2
- package/dist/web/assets/app-D6BT6vS-.js +3 -0
- package/dist/web/assets/architecture-7GRP2DOG-DnGQjmPz.js +1 -0
- package/dist/web/assets/{architectureDiagram-5GKGNRK7-B_aO4VWn.js → architectureDiagram-5GKGNRK7-BqGd8I5j.js} +1 -1
- package/dist/web/assets/{blockDiagram-I7D4REHJ-D0iOY295.js → blockDiagram-I7D4REHJ-DtQSxJUd.js} +1 -1
- package/dist/web/assets/{c4Diagram-7LVT6UL2-CQpmIVVf.js → c4Diagram-7LVT6UL2-BuNBp5yh.js} +1 -1
- package/dist/web/assets/channel-CEI3etVU.js +1 -0
- package/dist/web/assets/{chunk-4HAMMTFA-Y_4-541N.js → chunk-4HAMMTFA-390Mn_il.js} +1 -1
- package/dist/web/assets/{chunk-75Z2AOVW-4xYGp387.js → chunk-75Z2AOVW-Chht6Xhu.js} +1 -1
- package/dist/web/assets/chunk-DU6HZSFF-CMLEkaIG.js +125 -0
- package/dist/web/assets/{chunk-F27PBJKO-Cl_dLboe.js → chunk-F27PBJKO-SKMSua5B.js} +1 -1
- package/dist/web/assets/{chunk-GMAD6QVW-CjWWJjqH.js → chunk-GMAD6QVW-Df-qsmXX.js} +1 -1
- package/dist/web/assets/{chunk-GVQU2GXP-DsUbfXw0.js → chunk-GVQU2GXP-7PgapOOA.js} +1 -1
- package/dist/web/assets/{chunk-IMKFNOWR-CaH0S04F.js → chunk-IMKFNOWR-DRbZAlkh.js} +1 -1
- package/dist/web/assets/{chunk-L3NEJ4N5-BXAFNAb0.js → chunk-L3NEJ4N5-3E0ssqUP.js} +1 -1
- package/dist/web/assets/{chunk-OSK3NFVY-7Pr5dXXm.js → chunk-OSK3NFVY-B1GP22y4.js} +1 -1
- package/dist/web/assets/{chunk-P2QGCYS3-BbUBGz5z.js → chunk-P2QGCYS3-T_PO2L1c.js} +1 -1
- package/dist/web/assets/{chunk-POPQ4Y6H-CzsriQoR.js → chunk-POPQ4Y6H-DiREf_on.js} +1 -1
- package/dist/web/assets/{chunk-PWAF6VOD-tT3Jq6dq.js → chunk-PWAF6VOD-yOR4r5Wg.js} +1 -1
- package/dist/web/assets/{chunk-SHT3W25Y-DZj7IKKK.js → chunk-SHT3W25Y-LqpptdEy.js} +1 -1
- package/dist/web/assets/{chunk-SVP7TREG-DvDxDsmp.js → chunk-SVP7TREG-BkiLiJog.js} +1 -1
- package/dist/web/assets/{chunk-TICWLB2K-Bc1tYRg0.js → chunk-TICWLB2K-F9gzgGhl.js} +1 -1
- package/dist/web/assets/classDiagram-ZZMXUADV-B7WS1Q75.js +1 -0
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-B7WS1Q75.js +1 -0
- package/dist/web/assets/{cynefin-OW5HDTMX-DnntdLgu.js → cynefin-OW5HDTMX-B_NT3eSJ.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-5FMLGOSQ-C-L_BMup.js → cynefinDiagram-5FMLGOSQ-DpNbosPc.js} +1 -1
- package/dist/web/assets/{dagre-GXQ25YYZ-Bnq701MK.js → dagre-GXQ25YYZ-BgaTchbz.js} +1 -1
- package/dist/web/assets/{diagram-S7CK7UJ4-CNM_tSLn.js → diagram-S7CK7UJ4-BC0d5yAz.js} +1 -1
- package/dist/web/assets/{diagram-UQ7AKVKN-426C_diL.js → diagram-UQ7AKVKN-Bjm2wIY2.js} +1 -1
- package/dist/web/assets/{diagram-VSXAHHWV-t87gH2-E.js → diagram-VSXAHHWV-BcCWYw5W.js} +1 -1
- package/dist/web/assets/{diagram-VX7I27RA-DIRLLYsO.js → diagram-VX7I27RA-CzGzOQ_X.js} +1 -1
- package/dist/web/assets/{diagram-Z3DM3KII-RkCiIzTw.js → diagram-Z3DM3KII-CJZ8iKLl.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-PWID7BFC-BxwCYlSo.js → ebnfDiagram-PWID7BFC-DKTo52mJ.js} +1 -1
- package/dist/web/assets/{engine-BvPI6I9U.js → engine-DE1KkajI.js} +4 -4
- package/dist/web/assets/{erDiagram-RLTQ6QDP-CjSDwe_l.js → erDiagram-RLTQ6QDP-Cssct0LD.js} +1 -1
- package/dist/web/assets/es-Bz5daKQI.js +3 -0
- package/dist/web/assets/eventmodeling-NTZA5JFV-Cro5xJnm.js +1 -0
- package/dist/web/assets/flowDiagram-HODETNUW-BiyE5zC5.js +1 -0
- package/dist/web/assets/{ganttDiagram-EL5Y4UJY-CVCoGOMO.js → ganttDiagram-EL5Y4UJY-DO1wNnfK.js} +1 -1
- package/dist/web/assets/{gitGraph-4MIJSDKK-G0WgwH1G.js → gitGraph-4MIJSDKK-CjTm6VaR.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-WWUBYQGX-91LuiqmR.js → gitGraphDiagram-WWUBYQGX-DoNpr-oE.js} +1 -1
- package/dist/web/assets/goal-BaR-xr88.js +1 -0
- package/dist/web/assets/{highlight-CTIGnBaH.js → highlight-C4F9bqUR.js} +1 -1
- package/dist/web/assets/i18n-UlAtkmCh.js +3 -0
- package/dist/web/assets/{icons-DIySg7KB.js → icons-BED7nKDs.js} +1 -1
- package/dist/web/assets/index-BTLOG0A7.css +1 -0
- package/dist/web/assets/index-CueRFPg9.js +74 -0
- package/dist/web/assets/{info-A6RAGUB7-Cd1A2Eag.js → info-A6RAGUB7-D0RLRe9r.js} +1 -1
- package/dist/web/assets/{infoDiagram-27XIBGKW-BS-OvxCz.js → infoDiagram-27XIBGKW-Xvh_yq1U.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-5VMMS53U-Bv6pOCVs.js → ishikawaDiagram-5VMMS53U-CKMcZ3YE.js} +1 -1
- package/dist/web/assets/{journeyDiagram-3NMN7TZE-B1wOa8hE.js → journeyDiagram-3NMN7TZE-CH4U_78p.js} +1 -1
- package/dist/web/assets/jsxRuntime-BPMqVzHG.js +1 -0
- package/dist/web/assets/{kanban-definition-UXKFOSKX-BEGlHqwg.js → kanban-definition-UXKFOSKX-YDba8pTP.js} +1 -1
- package/dist/web/assets/{line-DacvQsry.js → line-5g6gTA1Q.js} +1 -1
- package/dist/web/assets/{mermaid-parser.core-nJHzUWWd.js → mermaid-parser.core-DgCR7nMS.js} +3 -3
- package/dist/web/assets/{mindmap-definition-YA3MSWOX-DYdF-ojf.js → mindmap-definition-YA3MSWOX-BFhIglWE.js} +1 -1
- package/dist/web/assets/{packet-AYTQ26CC-DBiUCtRk.js → packet-AYTQ26CC-Bx6BMrJt.js} +1 -1
- package/dist/web/assets/{pegDiagram-XKGWAZYB-MdKS7UKA.js → pegDiagram-XKGWAZYB-DfFISLjH.js} +1 -1
- package/dist/web/assets/{pie-WAS4IAKB-CZpqMsqb.js → pie-WAS4IAKB-LV0HnA-W.js} +1 -1
- package/dist/web/assets/{pieDiagram-E7YTZNPT-CovIn7J1.js → pieDiagram-E7YTZNPT-CepfvSOf.js} +1 -1
- package/dist/web/assets/preload-helper-B3nfOi5I.js +1 -0
- package/dist/web/assets/{quadrantDiagram-AXDQQJYC-DW5EcMtk.js → quadrantDiagram-AXDQQJYC-xPm2UUev.js} +1 -1
- package/dist/web/assets/{radar-RG4KPBEZ-CN_aPDyn.js → radar-RG4KPBEZ-V5htrwTl.js} +1 -1
- package/dist/web/assets/{railroad-74A4TZTK-C6cv8Byh.js → railroad-74A4TZTK-b1KQjCKl.js} +1 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-DGo9EEg0.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-pz5FmZJB.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-CaGbhEvZ.js +1 -0
- package/dist/web/assets/{railroadDiagram-O6MQD6OU-ph8nMiZB.js → railroadDiagram-O6MQD6OU-C7eNLTRf.js} +1 -1
- package/dist/web/assets/{requirementDiagram-BXWQKSXE-BGVse3WU.js → requirementDiagram-BXWQKSXE-CDkBY9hC.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-P5KCCOFB-BlqSlLfF.js → sankeyDiagram-P5KCCOFB-D3kcagKm.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-WJ2MYXX4-DVZcPeDh.js → sequenceDiagram-WJ2MYXX4-4bVDlu88.js} +1 -1
- package/dist/web/assets/settings-DTaiQjCk.css +1 -0
- package/dist/web/assets/settings.module-DoTtRfeo.js +1 -0
- package/dist/web/assets/shared-BLXABM7S.js +1 -0
- package/dist/web/assets/{stateDiagram-D77RDMKH-C71hZhx8.js → stateDiagram-D77RDMKH-ByCmUnvJ.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-CRiIHTdb.js +1 -0
- package/dist/web/assets/{swimlanes-42K2YHIH-BBbsmiO3.js → swimlanes-42K2YHIH-C836H1FT.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-CK7iFVSI.js +8 -0
- package/dist/web/assets/tasks-bt4HCPLT.js +1 -0
- package/dist/web/assets/{timeline-definition-24CTP7MA-C0hBvrpl.js → timeline-definition-24CTP7MA-C4pJeiqC.js} +1 -1
- package/dist/web/assets/{treeView-Q6P3EWNA-D3YSHcSl.js → treeView-Q6P3EWNA-LZesboxH.js} +1 -1
- package/dist/web/assets/{treemap-WGGIJYW6-CGGxdU_V.js → treemap-WGGIJYW6-Cl6Q6_3w.js} +1 -1
- package/dist/web/assets/{vennDiagram-4TSXK5OY-Bddm4uv_.js → vennDiagram-4TSXK5OY-Beflgatq.js} +1 -1
- package/dist/web/assets/view-C6h2Ru4M.js +2 -0
- package/dist/web/assets/view-CMC_gonE.js +1 -0
- package/dist/web/assets/view-CN5B3WOk.js +1 -0
- package/dist/web/assets/{view-C_yQ0o_1.js → view-Cp9Y-lvL.js} +1 -1
- package/dist/web/assets/view-CqbEmEgS.js +5 -0
- package/dist/web/assets/view-DT0FGd26.js +1 -0
- package/dist/web/assets/view-DfL_xTXC.js +6 -0
- package/dist/web/assets/{view-Dzj8qJWx.js → view-DlDHrSuL.js} +2 -2
- package/dist/web/assets/view-rB_-ym6G.js +1 -0
- package/dist/web/assets/visible-DLChI0F-.js +1 -0
- package/dist/web/assets/{wardley-WFR3VGLG-DA15PMFL.js → wardley-WFR3VGLG-BPBKdRq1.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-VM6X3IG4-DdznHZAA.js → wardleyDiagram-VM6X3IG4-BoH1N_OA.js} +1 -1
- package/dist/web/assets/{xychartDiagram-S5SC5T6Z-CoDcKI66.js → xychartDiagram-S5SC5T6Z-B9h0fFPk.js} +1 -1
- package/dist/web/index.html +9 -5
- package/package.json +4 -4
- package/dist/web/assets/AgentPicker-BjlBiN4-.js +0 -1
- package/dist/web/assets/AgentsModal-Byee_0pe.js +0 -2
- package/dist/web/assets/ArtifactPanel-BcSTmXfm.js +0 -2
- package/dist/web/assets/BtwPanel-B2-T36dM.js +0 -1
- package/dist/web/assets/SettingsModal-CJ9ylIBw.js +0 -1
- package/dist/web/assets/SpreadsheetPanel-CE0J9nlK.js +0 -1
- package/dist/web/assets/app-Bg0oN1mF.js +0 -2
- package/dist/web/assets/architecture-7GRP2DOG-Cmrq8Mal.js +0 -1
- package/dist/web/assets/channel-8oC5JRJN.js +0 -1
- package/dist/web/assets/chunk-DU6HZSFF-DXftN_bq.js +0 -125
- package/dist/web/assets/classDiagram-ZZMXUADV-BAAKVLjZ.js +0 -1
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-BAAKVLjZ.js +0 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-D6SC9raY.js +0 -1
- package/dist/web/assets/flowDiagram-HODETNUW-CmC03NQq.js +0 -1
- package/dist/web/assets/index-CRb-14T4.css +0 -1
- package/dist/web/assets/index-z9ytfY6a.js +0 -74
- package/dist/web/assets/jsxRuntime-Dibtq2co.js +0 -5
- package/dist/web/assets/railroad-abnf-HS5TGJTU-qNOnds0e.js +0 -1
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-CiAajbqk.js +0 -1
- package/dist/web/assets/railroad-peg-WCYAUIDC-DWNjc-vb.js +0 -1
- package/dist/web/assets/shared-AIBQ1IP1.js +0 -1
- package/dist/web/assets/shared-DuS6eVxB.css +0 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-B_lVvrO8.js +0 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-_7TOzzT1.js +0 -8
- package/dist/web/assets/view-B9FRCDjf.js +0 -1
- package/dist/web/assets/view-BMxu6rtp.js +0 -1
- package/dist/web/assets/view-BoqU6MqN.js +0 -1
- package/dist/web/assets/view-D7N7RCo7.js +0 -2
- package/dist/web/assets/view-DNVoQwdm.js +0 -6
- package/dist/web/assets/view-DXUVAhlp.js +0 -5
- package/dist/web/assets/view-DeXp47AF.js +0 -1
package/dist/routes/commands.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { agentIdFromCommand, CommandCatalog } from "@alisio/core";
|
|
1
|
+
import { agentIdFromCommand, CommandCatalog, describePermissionStatus, FULL_ACCESS_WARNING, formatChangelogMarkdown, formatGoalStatus, formatReloadReport, GOAL_HELP, loadChangelog, modeToPreset, PERMISSION_MODE_TABLE, PERMISSION_USAGE, parseGoalCommand, parsePermissionCommand, presetToMode, selectEntries, } from "@alisio/core";
|
|
2
2
|
import { activateSessionAgent } from "../host/agents.js";
|
|
3
|
+
import { presetInfo } from "../host/presets.js";
|
|
3
4
|
import { readJson } from "../http/body.js";
|
|
4
5
|
import { HttpError } from "../http/errors.js";
|
|
5
6
|
import { is, validate } from "../schemas.js";
|
|
7
|
+
import { goalError } from "./goal.js";
|
|
6
8
|
import { RecentRequests } from "./prompts.js";
|
|
7
9
|
/** Commands that change the session's own run state: refused while it runs or compacts. */
|
|
8
|
-
const EXCLUSIVE = new Set(["model", "compact"]);
|
|
10
|
+
const EXCLUSIVE = new Set(["model", "compact", "reload"]);
|
|
9
11
|
/**
|
|
10
12
|
* The `/ask` prompt: the TUI sends the same instruction (packages/cli/src/tui/app.ts); both turn
|
|
11
13
|
* the user's question into a multiple-choice `ask_user_question` call.
|
|
@@ -22,7 +24,7 @@ const helpText = (commands) => [
|
|
|
22
24
|
"",
|
|
23
25
|
...commands.map((c) => `- \`/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""}\` — ${c.description}${c.aliases?.length ? ` (${c.aliases.map((a) => `/${a}`).join(", ")})` : ""}`),
|
|
24
26
|
"",
|
|
25
|
-
"**Keys**: Enter send · Shift+Enter newline · ↑↓ prompt history · `/` focus the composer · Esc close popovers",
|
|
27
|
+
"**Keys**: Enter send · Shift+Enter newline · Shift+Tab cycle agents · ↑↓ prompt history · `/` focus the composer · Esc close popovers",
|
|
26
28
|
].join("\n");
|
|
27
29
|
/**
|
|
28
30
|
* The shared slash-command catalog for the web (RF-07): `GET /api/commands` lists the commands
|
|
@@ -53,6 +55,8 @@ export function registerCommandRoutes(router, ctx) {
|
|
|
53
55
|
requestId: { check: is.requestId(), required: true },
|
|
54
56
|
name: { check: is.nonEmpty(200), required: true },
|
|
55
57
|
args: { check: is.string(100_000) },
|
|
58
|
+
// `/goal <objective>` over an existing goal needs the user's confirmation.
|
|
59
|
+
confirm: { check: is.boolean() },
|
|
56
60
|
});
|
|
57
61
|
if (recent.has(session.id, input.requestId))
|
|
58
62
|
return { body: { duplicate: true } };
|
|
@@ -86,6 +90,122 @@ export function registerCommandRoutes(router, ctx) {
|
|
|
86
90
|
if (descriptor.name !== "btw")
|
|
87
91
|
sessions.notify(session.id);
|
|
88
92
|
return { body: outcome };
|
|
93
|
+
/**
|
|
94
|
+
* `/permission`: web clients open the permissions popover for the bare command; API callers
|
|
95
|
+
* get the status and the saved permissions as text. `ask|auto|full` sets the session preset
|
|
96
|
+
* (the same change as the composer menu), within the server's capability ceiling.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* `/goal`: the same grammar as the TUI (`parseGoalCommand`). Web clients drive the goal bar
|
|
100
|
+
* with the goal routes and send the typed command here; `effects: ["goal_edit"]` tells a
|
|
101
|
+
* client to open its edit field (`/goal edit` without text).
|
|
102
|
+
*/
|
|
103
|
+
function goalCommand() {
|
|
104
|
+
const parsed = parseGoalCommand(args);
|
|
105
|
+
const service = opened.app.goals;
|
|
106
|
+
const done = (output, extra = {}) => {
|
|
107
|
+
ctx.goals?.refresh(session.id);
|
|
108
|
+
setImmediate(() => ctx.goals?.pump(session.id));
|
|
109
|
+
return { output, tone: "notice", effects: ["goal"], ...extra };
|
|
110
|
+
};
|
|
111
|
+
const check = (outcome) => {
|
|
112
|
+
if (!outcome.ok)
|
|
113
|
+
throw goalError(outcome.code, outcome.message);
|
|
114
|
+
return outcome.goal;
|
|
115
|
+
};
|
|
116
|
+
switch (parsed.type) {
|
|
117
|
+
case "error":
|
|
118
|
+
throw new Error(parsed.message);
|
|
119
|
+
case "help":
|
|
120
|
+
return { output: GOAL_HELP };
|
|
121
|
+
case "show": {
|
|
122
|
+
const info = ctx.goals?.info(session.id);
|
|
123
|
+
return info
|
|
124
|
+
? { output: formatGoalStatus(info), effects: ["goal"] }
|
|
125
|
+
: {
|
|
126
|
+
output: "No goal in this session. Start one with /goal <objective>.",
|
|
127
|
+
tone: "notice",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
case "pause":
|
|
131
|
+
check(service.pause(session.id));
|
|
132
|
+
return done("Goal paused: it will not continue until you resume it.");
|
|
133
|
+
case "resume":
|
|
134
|
+
check(service.resume(session.id));
|
|
135
|
+
return done("Goal resumed.");
|
|
136
|
+
case "clear":
|
|
137
|
+
check(service.clear(session.id));
|
|
138
|
+
return done("Goal cleared.");
|
|
139
|
+
case "edit":
|
|
140
|
+
if (parsed.objective === undefined) {
|
|
141
|
+
const current = service.get(session.id);
|
|
142
|
+
if (!current)
|
|
143
|
+
throw goalError("not_found", "This session has no goal.");
|
|
144
|
+
return { output: current.objective, tone: "notice", effects: ["goal_edit"] };
|
|
145
|
+
}
|
|
146
|
+
check(service.edit(session.id, parsed.objective));
|
|
147
|
+
return done("Goal objective updated.");
|
|
148
|
+
case "budget":
|
|
149
|
+
check(service.setBudget(session.id, parsed.tokens));
|
|
150
|
+
return done(parsed.tokens === null ? "Goal token budget removed." : "Goal token budget set.");
|
|
151
|
+
case "create": {
|
|
152
|
+
const replace = !!input.confirm;
|
|
153
|
+
check(service.create(session.id, {
|
|
154
|
+
objective: parsed.objective,
|
|
155
|
+
...(parsed.tokenBudget ? { tokenBudget: parsed.tokenBudget } : {}),
|
|
156
|
+
...(replace ? { replace: true } : {}),
|
|
157
|
+
}));
|
|
158
|
+
return done("Goal started.");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function permission() {
|
|
163
|
+
const parsed = parsePermissionCommand(args);
|
|
164
|
+
if (parsed.type === "invalid")
|
|
165
|
+
throw new Error(PERMISSION_USAGE);
|
|
166
|
+
const current = sessions.detail(session);
|
|
167
|
+
const status = (preset) => {
|
|
168
|
+
const info = presetInfo(preset, sessions.ceiling);
|
|
169
|
+
return [
|
|
170
|
+
describePermissionStatus({
|
|
171
|
+
mode: presetToMode(preset) ?? "locked",
|
|
172
|
+
policy: info.policy,
|
|
173
|
+
approvals: info.approvals,
|
|
174
|
+
}),
|
|
175
|
+
...(info.reason ? ["", `Note: ${info.reason}.`] : []),
|
|
176
|
+
].join("\n");
|
|
177
|
+
};
|
|
178
|
+
if (parsed.type === "set") {
|
|
179
|
+
const preset = modeToPreset(parsed.mode);
|
|
180
|
+
const info = presetInfo(preset, sessions.ceiling);
|
|
181
|
+
if (!info.available)
|
|
182
|
+
throw new HttpError("capability_ceiling", info.reason ?? "That mode is not available under the server flags");
|
|
183
|
+
sessions.setOptions(session.id, { preset });
|
|
184
|
+
sessions.resetPolicy(session.id);
|
|
185
|
+
return {
|
|
186
|
+
output: [
|
|
187
|
+
`Permission mode: **${PERMISSION_MODE_TABLE[parsed.mode].label}**. ${PERMISSION_MODE_TABLE[parsed.mode].summary}`,
|
|
188
|
+
...(parsed.mode === "full" ? ["", FULL_ACCESS_WARNING] : []),
|
|
189
|
+
...(info.reason ? ["", `Note: ${info.reason}.`] : []),
|
|
190
|
+
].join("\n"),
|
|
191
|
+
tone: "notice",
|
|
192
|
+
effects: ["preset"],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const live = opened.app.capabilityGrants.live(session.id);
|
|
196
|
+
return {
|
|
197
|
+
output: [
|
|
198
|
+
status(current.preset),
|
|
199
|
+
"",
|
|
200
|
+
"**Saved permissions**",
|
|
201
|
+
"",
|
|
202
|
+
...(live.length
|
|
203
|
+
? live.map((g) => `- ${g.capability} · allowed for this session · since ${new Date(g.createdAt).toISOString()} (${g.source})`)
|
|
204
|
+
: ["No saved permissions in this session."]),
|
|
205
|
+
].join("\n"),
|
|
206
|
+
tone: parsed.type === "status" ? "notice" : "info",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
89
209
|
async function run() {
|
|
90
210
|
if (!descriptor)
|
|
91
211
|
throw new HttpError("unknown_command", `Unknown command /${name}`);
|
|
@@ -111,16 +231,21 @@ export function registerCommandRoutes(router, ctx) {
|
|
|
111
231
|
}
|
|
112
232
|
if (descriptor.name === "help")
|
|
113
233
|
return { output: helpText(catalog.list("web")) };
|
|
114
|
-
if (descriptor.name === "
|
|
115
|
-
|
|
116
|
-
|
|
234
|
+
if (descriptor.name === "permission")
|
|
235
|
+
return permission();
|
|
236
|
+
if (descriptor.name === "goal")
|
|
237
|
+
return goalCommand();
|
|
238
|
+
if (descriptor.name === "reload") {
|
|
239
|
+
if (!ctx.reload)
|
|
240
|
+
throw new Error("/reload is unavailable here");
|
|
241
|
+
return { output: formatReloadReport(await ctx.reload(opened.id)), effects: ["catalog"] };
|
|
242
|
+
}
|
|
243
|
+
if (descriptor.name === "changelog") {
|
|
244
|
+
const selected = selectEntries(loadChangelog(), { ...(args ? { version: args } : {}) });
|
|
245
|
+
if (!selected.found)
|
|
246
|
+
throw new Error(`No changelog entry for version ${args}`);
|
|
117
247
|
return {
|
|
118
|
-
output:
|
|
119
|
-
? live
|
|
120
|
-
.map((g) => `- ${g.capability} · allowed for this session · since ${new Date(g.createdAt).toISOString()} (${g.source})`)
|
|
121
|
-
.join("\n")
|
|
122
|
-
: "No saved permissions in this session.",
|
|
123
|
-
tone: "notice",
|
|
248
|
+
output: `**Alisio ${ctx.version ?? "dev"}**\n\n${formatChangelogMarkdown(selected.entries)}`,
|
|
124
249
|
};
|
|
125
250
|
}
|
|
126
251
|
if (descriptor.name === "artifacts") {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The goal of a root session (`/goal`): read it, create or replace it, edit its objective or
|
|
3
|
+
* token budget, pause, resume, clear. The same auth, Host and Origin rules as every other `/api`
|
|
4
|
+
* route apply. Every change is a compare-and-set in the core: a client may send what it last saw
|
|
5
|
+
* (`expect: {goalId, epoch}`) and gets `409 goal_conflict` when another surface (or the agent)
|
|
6
|
+
* changed the goal since. A change that lets the goal run (create, resume, a re-armed budget)
|
|
7
|
+
* also makes it continue now; changes arrive in every window as `goal_changed` frames.
|
|
8
|
+
*
|
|
9
|
+
* Only the user changes a goal through here; the model has `update_goal` (complete / blocked).
|
|
10
|
+
*/
|
|
11
|
+
import type { GoalErrorCode } from "@alisio/core";
|
|
12
|
+
import type { GoalDriver } from "../host/goal.ts";
|
|
13
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
14
|
+
import { HttpError } from "../http/errors.ts";
|
|
15
|
+
import type { Router } from "../http/router.ts";
|
|
16
|
+
/** The HTTP error of a refused goal operation. */
|
|
17
|
+
export declare function goalError(code: GoalErrorCode, message: string): HttpError;
|
|
18
|
+
export declare function registerGoalRoutes(router: Router, ctx: {
|
|
19
|
+
sessions: SessionService;
|
|
20
|
+
goals: Pick<GoalDriver, "info" | "pump" | "refresh">;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { GOAL_OBJECTIVE_MAX } from "@alisio/core";
|
|
2
|
+
import { readJson } from "../http/body.js";
|
|
3
|
+
import { HttpError } from "../http/errors.js";
|
|
4
|
+
import { is, validate } from "../schemas.js";
|
|
5
|
+
const BUDGET = is.integer(1, 1_000_000_000);
|
|
6
|
+
const nullableBudget = (v) => v === null || BUDGET(v);
|
|
7
|
+
const EXPECT = (v) => is.object()(v) &&
|
|
8
|
+
Object.keys(v).every((key) => key === "goalId" || key === "epoch") &&
|
|
9
|
+
(v.goalId === undefined ||
|
|
10
|
+
is.nonEmpty(64)(v.goalId)) &&
|
|
11
|
+
(v.epoch === undefined ||
|
|
12
|
+
is.integer(0, Number.MAX_SAFE_INTEGER)(v.epoch));
|
|
13
|
+
/** The HTTP error of a refused goal operation. */
|
|
14
|
+
export function goalError(code, message) {
|
|
15
|
+
switch (code) {
|
|
16
|
+
case "disabled":
|
|
17
|
+
return new HttpError("goal_disabled", message);
|
|
18
|
+
case "not_found":
|
|
19
|
+
return new HttpError("goal_not_found", message);
|
|
20
|
+
case "invalid":
|
|
21
|
+
return new HttpError("validation_failed", message, { fields: ["objective"] });
|
|
22
|
+
default:
|
|
23
|
+
return new HttpError("goal_conflict", message, { reason: code });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function registerGoalRoutes(router, ctx) {
|
|
27
|
+
const { sessions, goals } = ctx;
|
|
28
|
+
/** The root session and its workspace's goal service (opened on demand). */
|
|
29
|
+
const open = async (sid, writing) => {
|
|
30
|
+
const session = sessions.get(sid ?? "");
|
|
31
|
+
if (session.parentId)
|
|
32
|
+
throw new HttpError("validation_failed", "Goals belong to a root session, not a subagent.", {
|
|
33
|
+
fields: ["sid"],
|
|
34
|
+
});
|
|
35
|
+
if (writing && session.archivedAt)
|
|
36
|
+
throw new HttpError("validation_failed", "The session is archived.", { fields: ["sid"] });
|
|
37
|
+
const { app } = await sessions.app(session);
|
|
38
|
+
return { session, service: app.goals };
|
|
39
|
+
};
|
|
40
|
+
const reply = (sessionId) => ({ body: { goal: goals.info(sessionId) ?? null } });
|
|
41
|
+
const settled = (sessionId) => {
|
|
42
|
+
goals.refresh(sessionId);
|
|
43
|
+
setImmediate(() => goals.pump(sessionId));
|
|
44
|
+
return reply(sessionId);
|
|
45
|
+
};
|
|
46
|
+
router.get("/api/sessions/:sid/goal", async ({ params }) => {
|
|
47
|
+
const { session } = await open(params.sid, false);
|
|
48
|
+
return reply(session.id);
|
|
49
|
+
});
|
|
50
|
+
router.put("/api/sessions/:sid/goal", async ({ req, params }) => {
|
|
51
|
+
const input = validate(await readJson(req), {
|
|
52
|
+
objective: { check: is.nonEmpty(GOAL_OBJECTIVE_MAX + 200), required: true },
|
|
53
|
+
tokenBudget: { check: nullableBudget },
|
|
54
|
+
replace: { check: is.boolean() },
|
|
55
|
+
expect: { check: EXPECT },
|
|
56
|
+
});
|
|
57
|
+
const { session, service } = await open(params.sid, true);
|
|
58
|
+
const outcome = service.create(session.id, {
|
|
59
|
+
objective: input.objective,
|
|
60
|
+
...(typeof input.tokenBudget === "number" ? { tokenBudget: input.tokenBudget } : {}),
|
|
61
|
+
...(input.replace ? { replace: true } : {}),
|
|
62
|
+
}, input.expect);
|
|
63
|
+
if (!outcome.ok)
|
|
64
|
+
throw goalError(outcome.code, outcome.message);
|
|
65
|
+
return settled(session.id);
|
|
66
|
+
});
|
|
67
|
+
router.patch("/api/sessions/:sid/goal", async ({ req, params }) => {
|
|
68
|
+
const input = validate(await readJson(req), {
|
|
69
|
+
objective: { check: is.nonEmpty(GOAL_OBJECTIVE_MAX + 200) },
|
|
70
|
+
tokenBudget: { check: nullableBudget },
|
|
71
|
+
expect: { check: EXPECT },
|
|
72
|
+
});
|
|
73
|
+
if (input.objective === undefined && input.tokenBudget === undefined)
|
|
74
|
+
throw new HttpError("validation_failed", "Send an objective or a tokenBudget to change", {
|
|
75
|
+
fields: ["objective", "tokenBudget"],
|
|
76
|
+
});
|
|
77
|
+
const { session, service } = await open(params.sid, true);
|
|
78
|
+
// The expectation applies to the first change; the second sees the epoch the first produced.
|
|
79
|
+
let expect = input.expect;
|
|
80
|
+
if (input.objective !== undefined) {
|
|
81
|
+
const outcome = service.edit(session.id, input.objective, expect);
|
|
82
|
+
if (!outcome.ok)
|
|
83
|
+
throw goalError(outcome.code, outcome.message);
|
|
84
|
+
expect = { goalId: outcome.goal.goalId, epoch: outcome.goal.epoch };
|
|
85
|
+
}
|
|
86
|
+
if (input.tokenBudget !== undefined) {
|
|
87
|
+
const outcome = service.setBudget(session.id, input.tokenBudget, expect);
|
|
88
|
+
if (!outcome.ok)
|
|
89
|
+
throw goalError(outcome.code, outcome.message);
|
|
90
|
+
}
|
|
91
|
+
return settled(session.id);
|
|
92
|
+
});
|
|
93
|
+
router.delete("/api/sessions/:sid/goal", async ({ params, url }) => {
|
|
94
|
+
const { session, service } = await open(params.sid, true);
|
|
95
|
+
const epoch = url.searchParams.get("epoch");
|
|
96
|
+
const goalId = url.searchParams.get("goalId");
|
|
97
|
+
const outcome = service.clear(session.id, {
|
|
98
|
+
...(goalId ? { goalId } : {}),
|
|
99
|
+
...(epoch !== null && epoch !== "" && Number.isInteger(Number(epoch))
|
|
100
|
+
? { epoch: Number(epoch) }
|
|
101
|
+
: {}),
|
|
102
|
+
});
|
|
103
|
+
if (!outcome.ok)
|
|
104
|
+
throw goalError(outcome.code, outcome.message);
|
|
105
|
+
goals.refresh(session.id);
|
|
106
|
+
return reply(session.id);
|
|
107
|
+
});
|
|
108
|
+
for (const action of ["pause", "resume"])
|
|
109
|
+
router.post(`/api/sessions/:sid/goal/${action}`, async ({ req, params }) => {
|
|
110
|
+
const input = validate(await readJson(req), {
|
|
111
|
+
expect: { check: EXPECT },
|
|
112
|
+
});
|
|
113
|
+
const { session, service } = await open(params.sid, true);
|
|
114
|
+
const outcome = action === "pause"
|
|
115
|
+
? service.pause(session.id, input.expect)
|
|
116
|
+
: service.resume(session.id, input.expect);
|
|
117
|
+
if (!outcome.ok)
|
|
118
|
+
throw goalError(outcome.code, outcome.message);
|
|
119
|
+
return settled(session.id);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ServerFrame } from "@alisio/sdk";
|
|
1
|
+
import type { ReloadReport, ServerFrame } from "@alisio/sdk";
|
|
2
2
|
import type { RunScheduler } from "../host/run-scheduler.ts";
|
|
3
3
|
import type { OpenWorkspace, ServerAppOptions, WorkspaceHost } from "../host/workspace-host.ts";
|
|
4
4
|
import type { Router } from "../http/router.ts";
|
|
@@ -8,6 +8,8 @@ type CatalogScope = Extract<ServerFrame, {
|
|
|
8
8
|
export interface ManagementContext {
|
|
9
9
|
workspaces: WorkspaceHost;
|
|
10
10
|
scheduler: RunScheduler;
|
|
11
|
+
/** Whether a workspace has live background tasks (closing its application would kill them). */
|
|
12
|
+
tasksLive?: (workspaceId: string) => boolean;
|
|
11
13
|
base: ServerAppOptions;
|
|
12
14
|
broadcast: (frame: ServerFrame) => void;
|
|
13
15
|
}
|
|
@@ -22,11 +24,19 @@ export declare class WorkspaceRecycler {
|
|
|
22
24
|
private ctx;
|
|
23
25
|
private pending;
|
|
24
26
|
constructor(ctx: ManagementContext);
|
|
25
|
-
/**
|
|
27
|
+
/** Runs or live background tasks (the application's close would kill them). */
|
|
28
|
+
private busy;
|
|
29
|
+
/** Recycles now when idle; otherwise once the workspace's runs and tasks finish. */
|
|
26
30
|
request(id: string): Promise<OpenWorkspace | undefined>;
|
|
27
31
|
/** Called when a run finishes: applies a deferred recycle once nothing runs. */
|
|
28
32
|
idle(id: string): void;
|
|
29
33
|
announce(workspaceId: string, scopes: CatalogScope[]): void;
|
|
34
|
+
/**
|
|
35
|
+
* `/reload`: refused while the workspace has runs (409 `runs_active`); otherwise validates the
|
|
36
|
+
* configuration, builds the new app next to the current one and swaps (a broken configuration
|
|
37
|
+
* leaves the workspace untouched). Every catalog is announced so open palettes refresh.
|
|
38
|
+
*/
|
|
39
|
+
reload(id: string): Promise<ReloadReport>;
|
|
30
40
|
}
|
|
31
41
|
export declare function registerManagementRoutes(router: Router, ctx: ManagementContext, recycler: WorkspaceRecycler): void;
|
|
32
42
|
export {};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* catalog broadcast `catalog_changed` so every open palette refreshes.
|
|
6
6
|
*/
|
|
7
7
|
import { isAbsolute, join } from "node:path";
|
|
8
|
-
import { agentCatalogFromState, analysisStatus, CommandCatalog, configFile, configHome, hashProjectConfig, isSettableSettingKey, pluginPrefix, resolveActiveAgent, setTrust, settableSettings, } from "@alisio/core";
|
|
8
|
+
import { agentCatalogFromState, analysisStatus, CommandCatalog, configFile, configHome, cycleableAgents, hashProjectConfig, isSettableSettingKey, pluginPrefix, resolveActiveAgent, setTrust, settableSettings, } from "@alisio/core";
|
|
9
9
|
import { readJson } from "../http/body.js";
|
|
10
10
|
import { HttpError } from "../http/errors.js";
|
|
11
11
|
import { is, validate } from "../schemas.js";
|
|
@@ -33,9 +33,13 @@ export class WorkspaceRecycler {
|
|
|
33
33
|
constructor(ctx) {
|
|
34
34
|
this.ctx = ctx;
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
36
|
+
/** Runs or live background tasks (the application's close would kill them). */
|
|
37
|
+
busy(id) {
|
|
38
|
+
return this.ctx.scheduler.busyWorkspace(id) || !!this.ctx.tasksLive?.(id);
|
|
39
|
+
}
|
|
40
|
+
/** Recycles now when idle; otherwise once the workspace's runs and tasks finish. */
|
|
37
41
|
async request(id) {
|
|
38
|
-
if (this.
|
|
42
|
+
if (this.busy(id)) {
|
|
39
43
|
this.pending.add(id);
|
|
40
44
|
return undefined;
|
|
41
45
|
}
|
|
@@ -46,7 +50,7 @@ export class WorkspaceRecycler {
|
|
|
46
50
|
}
|
|
47
51
|
/** Called when a run finishes: applies a deferred recycle once nothing runs. */
|
|
48
52
|
idle(id) {
|
|
49
|
-
if (!this.pending.has(id) || this.
|
|
53
|
+
if (!this.pending.has(id) || this.busy(id))
|
|
50
54
|
return;
|
|
51
55
|
void this.request(id).catch(() => { });
|
|
52
56
|
}
|
|
@@ -54,7 +58,41 @@ export class WorkspaceRecycler {
|
|
|
54
58
|
for (const scope of scopes)
|
|
55
59
|
this.ctx.broadcast({ t: "catalog_changed", workspaceId, scope });
|
|
56
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* `/reload`: refused while the workspace has runs (409 `runs_active`); otherwise validates the
|
|
63
|
+
* configuration, builds the new app next to the current one and swaps (a broken configuration
|
|
64
|
+
* leaves the workspace untouched). Every catalog is announced so open palettes refresh.
|
|
65
|
+
*/
|
|
66
|
+
async reload(id) {
|
|
67
|
+
const { workspaces, scheduler } = this.ctx;
|
|
68
|
+
if (!(await workspaces.pathOf(id)))
|
|
69
|
+
throw new HttpError("not_found", "Workspace not found");
|
|
70
|
+
const busy = () => scheduler.busyWorkspace(id)
|
|
71
|
+
? "Reload is only available between turns: wait for the current turn to finish."
|
|
72
|
+
: this.ctx.tasksLive?.(id)
|
|
73
|
+
? "Reload would stop the running background tasks: stop them or wait for them to finish, then reload."
|
|
74
|
+
: undefined;
|
|
75
|
+
const reason = busy();
|
|
76
|
+
if (reason)
|
|
77
|
+
throw new HttpError("runs_active", reason);
|
|
78
|
+
this.pending.delete(id);
|
|
79
|
+
if (!workspaces.get(id)) {
|
|
80
|
+
// Nothing is loaded for this workspace yet: opening it reads the current configuration.
|
|
81
|
+
const path = (await workspaces.pathOf(id));
|
|
82
|
+
await workspaces.openPath(path);
|
|
83
|
+
this.announce(id, RELOAD_SCOPES);
|
|
84
|
+
return {
|
|
85
|
+
refreshed: [],
|
|
86
|
+
restartRequired: [],
|
|
87
|
+
warnings: ["The workspace was not open: it was opened with the current configuration."],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const report = await workspaces.reload(id, { busy });
|
|
91
|
+
this.announce(id, RELOAD_SCOPES);
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
57
94
|
}
|
|
95
|
+
const RELOAD_SCOPES = ["commands", "plugins", "skills", "agents", "mcp", "models"];
|
|
58
96
|
function pluginInfo(opened, entry) {
|
|
59
97
|
const prefix = pluginPrefix(entry.id);
|
|
60
98
|
const tools = opened.app.plugins
|
|
@@ -263,7 +301,8 @@ export function registerManagementRoutes(router, ctx, recycler) {
|
|
|
263
301
|
catch {
|
|
264
302
|
/* contributions are best-effort */
|
|
265
303
|
}
|
|
266
|
-
|
|
304
|
+
// Stable order (build, plan, then the rest by name): the web cycles this array.
|
|
305
|
+
const agents = cycleableAgents(agentCatalogFromState(state));
|
|
267
306
|
const fallback = resolveActiveAgent(agents, opened.app.config.agents.active).id;
|
|
268
307
|
return {
|
|
269
308
|
body: agents.map((agent) => ({
|
|
@@ -278,6 +317,10 @@ export function registerManagementRoutes(router, ctx, recycler) {
|
|
|
278
317
|
})),
|
|
279
318
|
};
|
|
280
319
|
});
|
|
320
|
+
// ---- Reload ------------------------------------------------------------------------------
|
|
321
|
+
router.post("/api/workspaces/:wid/reload", async ({ params }) => ({
|
|
322
|
+
body: await recycler.reload(params.wid ?? ""),
|
|
323
|
+
}));
|
|
281
324
|
// ---- Workspace trust ----------------------------------------------------------------------
|
|
282
325
|
/**
|
|
283
326
|
* Grants (or withdraws) project trust from the web: the same persisted, per-directory decision
|
|
@@ -371,7 +414,7 @@ export function registerManagementRoutes(router, ctx, recycler) {
|
|
|
371
414
|
recycler.announce(opened.id, ["agents"]);
|
|
372
415
|
// Analysis tools are registered when the workspace application starts: rebuild it (after its
|
|
373
416
|
// runs) so `analysis.enabled` applies without restarting the server.
|
|
374
|
-
if (key === "analysis.enabled")
|
|
417
|
+
if (key === "analysis.enabled" || key === "tasks.enabled")
|
|
375
418
|
void recycler.request(opened.id).catch(() => undefined);
|
|
376
419
|
return { body: { message: `Saved ${key}` } };
|
|
377
420
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
2
|
+
import { type WorkspaceHost } from "../host/workspace-host.ts";
|
|
3
|
+
import type { Router } from "../http/router.ts";
|
|
4
|
+
import type { Logger } from "../log.ts";
|
|
5
|
+
/** Defaults; both can be lowered through `ServerOptions.views` (tests, constrained hosts). */
|
|
6
|
+
export declare const VIEW_TIMEOUT_MS = 5000;
|
|
7
|
+
export declare const VIEW_MAX_BYTES: number;
|
|
8
|
+
export interface PluginViewRouteContext {
|
|
9
|
+
sessions: SessionService;
|
|
10
|
+
workspaces: WorkspaceHost;
|
|
11
|
+
logger: Logger;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
maxBytes?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Plugin data views: `GET /api/sessions/:sid/views/:plugin/:view?<params>`. Views are read-only
|
|
17
|
+
* BY CONTRACT (a plugin is not a sandbox); the server enforces what it can: GET only, the same
|
|
18
|
+
* auth/Host/Origin rules as every `/api` route, enabled plugins only, validated parameters, a
|
|
19
|
+
* timeout and a response size cap. Parameters and results are never logged (they may carry
|
|
20
|
+
* sensitive project data such as memory).
|
|
21
|
+
*/
|
|
22
|
+
export declare function registerPluginViewRoutes(router: Router, ctx: PluginViewRouteContext): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ViewRunError } from "@alisio/core";
|
|
2
|
+
import { workspaceId } from "../host/workspace-host.js";
|
|
3
|
+
import { HttpError } from "../http/errors.js";
|
|
4
|
+
/** Defaults; both can be lowered through `ServerOptions.views` (tests, constrained hosts). */
|
|
5
|
+
export const VIEW_TIMEOUT_MS = 5_000;
|
|
6
|
+
export const VIEW_MAX_BYTES = 1024 * 1024;
|
|
7
|
+
const MAX_PARAMS = 16;
|
|
8
|
+
const MAX_KEY = 64;
|
|
9
|
+
const MAX_VALUE = 512;
|
|
10
|
+
/** One message for every "no such view" reason, so a probe cannot tell which plugins exist. */
|
|
11
|
+
const NOT_FOUND = "View not found";
|
|
12
|
+
/**
|
|
13
|
+
* Plugin data views: `GET /api/sessions/:sid/views/:plugin/:view?<params>`. Views are read-only
|
|
14
|
+
* BY CONTRACT (a plugin is not a sandbox); the server enforces what it can: GET only, the same
|
|
15
|
+
* auth/Host/Origin rules as every `/api` route, enabled plugins only, validated parameters, a
|
|
16
|
+
* timeout and a response size cap. Parameters and results are never logged (they may carry
|
|
17
|
+
* sensitive project data such as memory).
|
|
18
|
+
*/
|
|
19
|
+
export function registerPluginViewRoutes(router, ctx) {
|
|
20
|
+
const { sessions, workspaces, logger } = ctx;
|
|
21
|
+
const timeoutMs = ctx.timeoutMs ?? VIEW_TIMEOUT_MS;
|
|
22
|
+
const maxBytes = ctx.maxBytes ?? VIEW_MAX_BYTES;
|
|
23
|
+
router.get("/api/sessions/:sid/views/:plugin/:view", async ({ url, params, correlationId }) => {
|
|
24
|
+
const session = sessions.get(params.sid ?? "");
|
|
25
|
+
// The session's workspace must be one this server knows (and its folder must still exist).
|
|
26
|
+
if (!(await workspaces.pathOf(workspaceId(session.workspace))))
|
|
27
|
+
throw new HttpError("not_found", "Session not found");
|
|
28
|
+
const opened = await sessions.app(session);
|
|
29
|
+
const plugin = params.plugin ?? "";
|
|
30
|
+
const view = params.view ?? "";
|
|
31
|
+
const entry = opened.app.pluginCatalog().find((p) => p.id === plugin);
|
|
32
|
+
// `enabled` follows the user's choice at once; a plugin disabled while it still runs
|
|
33
|
+
// (`restart-required`) must stop answering immediately.
|
|
34
|
+
if (!entry?.enabled || !opened.app.plugins.viewsOf(plugin).some((v) => v.id === view))
|
|
35
|
+
throw new HttpError("not_found", NOT_FOUND);
|
|
36
|
+
const query = {};
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const [key, value] of url.searchParams) {
|
|
39
|
+
if (++count > MAX_PARAMS || key.length > MAX_KEY || value.length > MAX_VALUE)
|
|
40
|
+
throw new HttpError("validation_failed", "Invalid view parameters", {
|
|
41
|
+
fields: ["(query)"],
|
|
42
|
+
});
|
|
43
|
+
if (key in query)
|
|
44
|
+
throw new HttpError("validation_failed", "Invalid view parameters", { fields: [key] });
|
|
45
|
+
query[key] = value;
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
let timer;
|
|
49
|
+
const expired = new Promise((_, reject) => {
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
controller.abort(new Error("View timed out"));
|
|
52
|
+
reject(new HttpError("view_timeout", "The view took too long"));
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
});
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = await Promise.race([
|
|
58
|
+
opened.app.plugins.runView(plugin, view, query, {
|
|
59
|
+
sessionId: session.id,
|
|
60
|
+
workspace: session.workspace,
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
}),
|
|
63
|
+
expired,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof HttpError)
|
|
68
|
+
throw error;
|
|
69
|
+
if (controller.signal.aborted)
|
|
70
|
+
throw new HttpError("view_timeout", "The view took too long");
|
|
71
|
+
if (error instanceof ViewRunError) {
|
|
72
|
+
if (error.code === "not_found")
|
|
73
|
+
throw new HttpError("not_found", NOT_FOUND);
|
|
74
|
+
if (error.code === "invalid_params")
|
|
75
|
+
throw new HttpError("validation_failed", error.message, error.fields.length ? { fields: error.fields } : undefined);
|
|
76
|
+
// Only the error class is logged: its message may quote data the view was reading.
|
|
77
|
+
logger.warn("plugin view failed", {
|
|
78
|
+
correlationId,
|
|
79
|
+
sessionId: session.id,
|
|
80
|
+
plugin,
|
|
81
|
+
view,
|
|
82
|
+
error: error.cause instanceof Error ? error.cause.name : "unknown",
|
|
83
|
+
});
|
|
84
|
+
throw new HttpError("view_failed", "The view failed");
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
const text = JSON.stringify(data ?? null);
|
|
92
|
+
if (Buffer.byteLength(text) > maxBytes)
|
|
93
|
+
throw new HttpError("view_too_large", "The view answered more than the server allows");
|
|
94
|
+
return { body: JSON.parse(text) };
|
|
95
|
+
});
|
|
96
|
+
}
|
package/dist/routes/sessions.js
CHANGED
|
@@ -121,6 +121,8 @@ export function registerSessionRoutes(router, ctx) {
|
|
|
121
121
|
if (patch.preset !== undefined)
|
|
122
122
|
sessions.resetPolicy(session.id);
|
|
123
123
|
sessions.notify(session.id);
|
|
124
|
+
if (Object.keys(options).length)
|
|
125
|
+
sessions.optionsChanged(session.id);
|
|
124
126
|
return { body: sessions.detail(sessions.get(session.id)) };
|
|
125
127
|
});
|
|
126
128
|
router.get("/api/sessions/:sid/messages", ({ params, url }) => {
|