@wrongstack/webui-server 0.302.2 → 0.305.0

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.
@@ -12,22 +12,41 @@ export interface WorklistContext {
12
12
  send: (ws: WebSocket, msg: WSServerMessage) => void;
13
13
  broadcast: (msg: WSServerMessage) => void;
14
14
  replaceTodos?: ((todos: TodoItem[]) => void) | undefined;
15
+ mutateTodos?: ((todos: TodoItem[]) => Promise<{
16
+ todos: TodoItem[];
17
+ warnings?: string[] | undefined;
18
+ }>) | undefined;
19
+ mutateTaskStatus?: ((id: string, status: 'pending' | 'in_progress' | 'blocked' | 'failed' | 'review' | 'completed') => Promise<{
20
+ ok: boolean;
21
+ message: string;
22
+ }>) | undefined;
23
+ mutatePlan?: ((operation: {
24
+ action: 'template_use';
25
+ template: string;
26
+ } | {
27
+ action: 'status';
28
+ target: string;
29
+ status: 'open' | 'in_progress' | 'done';
30
+ }) => Promise<{
31
+ ok: boolean;
32
+ message: string;
33
+ }>) | undefined;
15
34
  }
16
35
  export interface WorklistMessage {
17
36
  type: string;
18
37
  payload?: unknown;
19
38
  }
20
39
  export declare function handleTodosGet(ctx: WorklistContext, ws: WebSocket): void;
21
- export declare function handleTodosClear(ctx: WorklistContext, ws: WebSocket): void;
40
+ export declare function handleTodosClear(ctx: WorklistContext, ws: WebSocket): Promise<void>;
22
41
  export declare function handleTodosRemove(ctx: WorklistContext, ws: WebSocket, payload: {
23
42
  id?: string | undefined;
24
43
  index?: number | undefined;
25
- } | undefined): void;
44
+ } | undefined): Promise<void>;
26
45
  export declare function handleTodoUpdate(ctx: WorklistContext, ws: WebSocket, payload: {
27
- id: string;
46
+ id?: string | undefined;
28
47
  status?: TodoItem['status'] | undefined;
29
48
  activeForm?: string | undefined;
30
- }): void;
49
+ } | undefined): Promise<void>;
31
50
  export declare function handleTasksGet(ctx: WorklistContext, ws: WebSocket): Promise<void>;
32
51
  export declare function handleTaskUpdate(ctx: WorklistContext, ws: WebSocket, payload: {
33
52
  id: string;
@@ -55,12 +55,34 @@ function handleTodosGet(ctx, ws) {
55
55
  payload: sessionPayload(ctx, { todos: [...ctx.context.todos] })
56
56
  });
57
57
  }
58
- function handleTodosClear(ctx, ws) {
59
- ctx.replaceTodos?.([]);
60
- sendResult(ctx, ws, true, "Todos cleared");
61
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: [] }) });
58
+ async function commitTodos(ctx, todos) {
59
+ if (ctx.mutateTodos) {
60
+ const result = await ctx.mutateTodos(todos);
61
+ return { todos: result.todos, warnings: result.warnings ?? [] };
62
+ }
63
+ ctx.replaceTodos?.(todos);
64
+ return { todos: [...todos], warnings: [] };
65
+ }
66
+ function managedProjectionMessage() {
67
+ return "Kanban-bound todos are task projections. Change or remove the task from Kanban.";
62
68
  }
63
- function handleTodosRemove(ctx, ws, payload) {
69
+ async function handleTodosClear(ctx, ws) {
70
+ if (ctx.context.todos.some((todo) => todo.kanbanBoardId && todo.kanbanTaskId)) {
71
+ sendResult(ctx, ws, false, managedProjectionMessage());
72
+ return;
73
+ }
74
+ try {
75
+ const result = await commitTodos(ctx, []);
76
+ sendResult(ctx, ws, true, "Todos cleared");
77
+ ctx.broadcast({
78
+ type: "todos.updated",
79
+ payload: sessionPayload(ctx, { todos: result.todos })
80
+ });
81
+ } catch (error) {
82
+ sendResult(ctx, ws, false, error instanceof Error ? error.message : String(error));
83
+ }
84
+ }
85
+ async function handleTodosRemove(ctx, ws, payload) {
64
86
  if (!payload) {
65
87
  sendResult(ctx, ws, false, "Missing id or index");
66
88
  return;
@@ -77,12 +99,27 @@ function handleTodosRemove(ctx, ws, payload) {
77
99
  sendResult(ctx, ws, false, "Todo not found");
78
100
  return;
79
101
  }
102
+ if (removed.kanbanBoardId && removed.kanbanTaskId) {
103
+ sendResult(ctx, ws, false, managedProjectionMessage());
104
+ return;
105
+ }
80
106
  const next = [...todos.slice(0, targetIndex), ...todos.slice(targetIndex + 1)];
81
- ctx.replaceTodos?.(next);
82
- sendResult(ctx, ws, true, `Removed: ${removed.content}`);
83
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
107
+ try {
108
+ const result = await commitTodos(ctx, next);
109
+ sendResult(ctx, ws, true, `Removed: ${removed.content}`);
110
+ ctx.broadcast({
111
+ type: "todos.updated",
112
+ payload: sessionPayload(ctx, { todos: result.todos })
113
+ });
114
+ } catch (error) {
115
+ sendResult(ctx, ws, false, error instanceof Error ? error.message : String(error));
116
+ }
84
117
  }
85
- function handleTodoUpdate(ctx, ws, payload) {
118
+ async function handleTodoUpdate(ctx, ws, payload) {
119
+ if (!payload || typeof payload.id !== "string" || payload.status !== void 0 && payload.status !== "pending" && payload.status !== "in_progress" && payload.status !== "completed" || payload.activeForm !== void 0 && typeof payload.activeForm !== "string") {
120
+ sendResult(ctx, ws, false, "Invalid todo update payload");
121
+ return;
122
+ }
86
123
  const index = ctx.context.todos.findIndex((todo) => todo.id === payload.id);
87
124
  const existing = ctx.context.todos[index];
88
125
  if (index === -1 || !existing) {
@@ -95,9 +132,25 @@ function handleTodoUpdate(ctx, ws, payload) {
95
132
  status: payload.status ?? existing.status,
96
133
  activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
97
134
  };
98
- ctx.replaceTodos?.(next);
99
- sendResult(ctx, ws, true, `Todo "${existing.content}" updated`);
100
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
135
+ try {
136
+ const result = await commitTodos(ctx, next);
137
+ const projected = result.todos.find((todo) => todo.id === existing.id);
138
+ const requestedStatus = payload.status ?? existing.status;
139
+ const projectionRejected = Boolean(existing.kanbanBoardId && existing.kanbanTaskId) && projected?.status !== requestedStatus;
140
+ const warning = result.warnings[0];
141
+ sendResult(
142
+ ctx,
143
+ ws,
144
+ !projectionRejected,
145
+ projectionRejected ? warning ?? `Kanban kept "${existing.content}" at ${projected?.status ?? "its current state"}.` : warning ? `Todo "${existing.content}" updated. ${warning}` : `Todo "${existing.content}" updated`
146
+ );
147
+ ctx.broadcast({
148
+ type: "todos.updated",
149
+ payload: sessionPayload(ctx, { todos: result.todos })
150
+ });
151
+ } catch (error) {
152
+ sendResult(ctx, ws, false, error instanceof Error ? error.message : String(error));
153
+ }
101
154
  }
102
155
  async function handleTasksGet(ctx, ws) {
103
156
  const taskPath = taskPathOf(ctx);
@@ -125,14 +178,32 @@ async function handleTaskUpdate(ctx, ws, payload) {
125
178
  return;
126
179
  }
127
180
  try {
128
- const file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
129
- const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
130
- if (!task) return tasks;
131
- task.status = payload.status;
132
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
133
- return tasks;
134
- });
135
- sendResult(ctx, ws, true, `Task status updated to "${payload.status}".`);
181
+ let file;
182
+ if (ctx.mutateTaskStatus) {
183
+ const result = await ctx.mutateTaskStatus(payload.id, payload.status);
184
+ if (!result.ok) {
185
+ sendResult(ctx, ws, false, result.message);
186
+ return;
187
+ }
188
+ file = await loadTasks(taskPath);
189
+ if (!file) throw new Error("Task mutation succeeded but its persisted snapshot is missing.");
190
+ sendResult(ctx, ws, true, result.message);
191
+ } else {
192
+ let matched = false;
193
+ file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
194
+ const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
195
+ if (!task) return tasks;
196
+ matched = true;
197
+ task.status = payload.status;
198
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
199
+ return tasks;
200
+ });
201
+ if (!matched) {
202
+ sendResult(ctx, ws, false, `Task "${payload.id}" not found.`);
203
+ return;
204
+ }
205
+ sendResult(ctx, ws, true, `Task status updated to "${payload.status}".`);
206
+ }
136
207
  ctx.broadcast({
137
208
  type: "tasks.updated",
138
209
  payload: sessionPayload(ctx, { tasks: file.tasks })
@@ -179,6 +250,18 @@ async function handlePlanTemplateUse(ctx, ws, template) {
179
250
  return;
180
251
  }
181
252
  try {
253
+ if (ctx.mutatePlan) {
254
+ const result = await ctx.mutatePlan({ action: "template_use", template });
255
+ if (!result.ok) {
256
+ sendResult(ctx, ws, false, result.message);
257
+ return;
258
+ }
259
+ const plan2 = await loadPlan(planPath);
260
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
261
+ sendResult(ctx, ws, true, result.message);
262
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
263
+ return;
264
+ }
182
265
  const templateDefinition = getPlanTemplate(template);
183
266
  if (!templateDefinition) {
184
267
  sendResult(ctx, ws, false, `Unknown template "${template}".`);
@@ -207,6 +290,22 @@ async function handlePlanItemUpdate(ctx, ws, payload) {
207
290
  return;
208
291
  }
209
292
  try {
293
+ if (ctx.mutatePlan) {
294
+ const result = await ctx.mutatePlan({
295
+ action: "status",
296
+ target: payload.target,
297
+ status: payload.status
298
+ });
299
+ if (!result.ok) {
300
+ sendResult(ctx, ws, false, result.message);
301
+ return;
302
+ }
303
+ const plan2 = await loadPlan(planPath);
304
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
305
+ sendResult(ctx, ws, true, result.message);
306
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
307
+ return;
308
+ }
210
309
  let changed = false;
211
310
  const plan = await mutatePlan(planPath, currentSessionId(ctx), async (currentPlan) => {
212
311
  const before = currentPlan.updatedAt;
@@ -230,13 +329,17 @@ async function handleWorklistMessage(ctx, ws, message) {
230
329
  handleTodosGet(ctx, ws);
231
330
  return;
232
331
  case "todos.clear":
233
- handleTodosClear(ctx, ws);
332
+ await handleTodosClear(ctx, ws);
234
333
  return;
235
334
  case "todos.remove":
236
- handleTodosRemove(ctx, ws, message.payload);
335
+ await handleTodosRemove(
336
+ ctx,
337
+ ws,
338
+ message.payload
339
+ );
237
340
  return;
238
341
  case "todo.update":
239
- handleTodoUpdate(
342
+ await handleTodoUpdate(
240
343
  ctx,
241
344
  ws,
242
345
  message.payload
@@ -0,0 +1,8 @@
1
+ import type { WebSocket } from 'ws';
2
+ import type { WSServerMessage } from './types.js';
3
+ export interface KanbanContractRouteContext {
4
+ projectRoot: string;
5
+ broadcast?: ((msg: WSServerMessage) => void) | undefined;
6
+ }
7
+ export declare function handleKanbanContractRoute(ws: WebSocket, type: string, payload: Record<string, unknown> | undefined, ctx: KanbanContractRouteContext): Promise<boolean>;
8
+ //# sourceMappingURL=kanban-contract-routes.d.ts.map
@@ -1,2 +1,2 @@
1
- export declare const KANBAN_CLIENT_MESSAGE_TYPES: readonly ['kanban.capabilities', 'kanban.column.add', 'kanban.column.remove', 'kanban.create', 'kanban.decomposition.approve', 'kanban.decomposition.reject', 'kanban.delete', 'kanban.duplicate', 'kanban.generate', 'kanban.get', 'kanban.health', 'kanban.list', 'kanban.snapshot', 'kanban.supervisor.audit', 'kanban.supervisor.status', 'kanban.task.activity', 'kanban.task.activity.add', 'kanban.task.add', 'kanban.task.assign', 'kanban.task.chain', 'kanban.task.chain.get', 'kanban.task.check.add', 'kanban.task.check.update', 'kanban.task.claim', 'kanban.task.copy', 'kanban.task.dispatch', 'kanban.task.get', 'kanban.task.merge', 'kanban.task.metric.add', 'kanban.task.metric.update', 'kanban.task.move', 'kanban.task.note.add', 'kanban.task.ready', 'kanban.task.release', 'kanban.task.remove', 'kanban.task.split', 'kanban.task.transfer', 'kanban.task.transition', 'kanban.task.update', 'kanban.task.verify', 'kanban.taskgraph.export', 'kanban.taskgraph.sync', 'kanban.update'];
1
+ export declare const KANBAN_CLIENT_MESSAGE_TYPES: readonly ['kanban.capabilities', 'kanban.contract.configure', 'kanban.contract.edge.add', 'kanban.contract.edge.remove', 'kanban.contract.get', 'kanban.contract.node.remove', 'kanban.contract.node.upsert', 'kanban.create', 'kanban.decomposition.approve', 'kanban.decomposition.reject', 'kanban.delete', 'kanban.duplicate', 'kanban.generate', 'kanban.get', 'kanban.health', 'kanban.list', 'kanban.snapshot', 'kanban.supervisor.audit', 'kanban.supervisor.status', 'kanban.task.activity', 'kanban.task.activity.add', 'kanban.task.add', 'kanban.task.assign', 'kanban.task.chain', 'kanban.task.chain.get', 'kanban.task.check.add', 'kanban.task.check.update', 'kanban.task.claim', 'kanban.task.copy', 'kanban.task.dispatch', 'kanban.task.get', 'kanban.task.merge', 'kanban.task.metric.add', 'kanban.task.metric.update', 'kanban.task.move', 'kanban.task.note.add', 'kanban.task.ready', 'kanban.task.release', 'kanban.task.remove', 'kanban.task.split', 'kanban.task.transfer', 'kanban.task.transition', 'kanban.task.update', 'kanban.task.verify', 'kanban.taskgraph.export', 'kanban.taskgraph.sync', 'kanban.update', 'kanban.workbench'];
2
2
  //# sourceMappingURL=kanban-route-protocol.d.ts.map
@@ -1,10 +1,10 @@
1
1
  import type { Context } from '@wrongstack/core/agent';
2
2
  import type { WebSocket } from 'ws';
3
- import type { WSClientMessage, WSServerMessage } from './types.js';
4
3
  import { type KanbanTaskDispatcher } from './kanban-dispatch.js';
5
4
  import type { KanbanSupervisor } from './kanban-supervisor.js';
5
+ import type { WSClientMessage, WSServerMessage } from './types.js';
6
+ export { type KanbanBoardPage, paginateKanbanBoards } from './kanban-route-pagination.js';
6
7
  export { KANBAN_CLIENT_MESSAGE_TYPES } from './kanban-route-protocol.js';
7
- export { paginateKanbanBoards, type KanbanBoardPage } from './kanban-route-pagination.js';
8
8
  export interface KanbanRouteContext {
9
9
  projectRoot: string;
10
10
  context?: Context | undefined;
@@ -90,6 +90,12 @@ export declare function handleSageDelete(ws: WebSocket, msg: unknown, memoryStor
90
90
  * - otherwise returns the freshly-restored memory
91
91
  */
92
92
  export declare function handleSageRecover(ws: WebSocket, msg: unknown, memoryStore: MemoryPort): Promise<void>;
93
+ /**
94
+ * List review-queue candidates (hygiene / triage proposals).
95
+ * Request: { type: 'memory.sage.listCandidates', payload?: { includeResolved?: boolean } }
96
+ * Response: { type: 'memory.sage.listCandidates', payload: { candidates } }
97
+ */
98
+ export declare function handleSageListCandidates(ws: WebSocket, msg: unknown, memoryStore: MemoryPort): Promise<void>;
93
99
  /**
94
100
  * Resolve a pending hygiene review candidate (PR #1).
95
101
  * Request: { type: 'memory.sage.candidateResolve', payload: { candidateId, action: 'accept'|'reject', reason? } }
@@ -26,8 +26,8 @@ export interface ModeHandlersContext {
26
26
  modelCapabilities: ModelCapabilities;
27
27
  context: Context;
28
28
  toolRegistry: ToolRegistry;
29
- config: Pick<Config, 'provider' | 'model' | 'systemPrompt'>;
30
- getConfig?: () => Pick<Config, 'provider' | 'model' | 'systemPrompt'>;
29
+ config: Pick<Config, 'provider' | 'model' | 'systemPrompt' | 'features'>;
30
+ getConfig?: () => Pick<Config, 'provider' | 'model' | 'systemPrompt' | 'features'>;
31
31
  projectRoot: string;
32
32
  globalRoot: string;
33
33
  clients: Map<WebSocket, ConnectedClient>;
@@ -21,8 +21,8 @@
21
21
  */
22
22
  import { Context } from '@wrongstack/core/agent';
23
23
  import { DefaultPromptLoader, DefaultSkillLoader } from '@wrongstack/core/execution';
24
- import { type Container, EventBus } from '@wrongstack/core/kernel';
25
24
  import { DefaultTokenCounter } from '@wrongstack/core/infrastructure';
25
+ import { type Container, EventBus } from '@wrongstack/core/kernel';
26
26
  import { DefaultModeStore } from '@wrongstack/core/models';
27
27
  import { ProviderRegistry, ToolRegistry } from '@wrongstack/core/registry';
28
28
  import { SkillInstaller } from '@wrongstack/core/skills';
@@ -1,6 +1,18 @@
1
+ /**
2
+ * Server lifecycle helpers for the standalone WebUI server.
3
+ *
4
+ * Phase 1e of the god-module split: the port resolution, WS auth/server
5
+ * creation, event arming, session-start payload builder, HTTP server
6
+ * startup, and graceful shutdown registration all moved here from
7
+ * `start-webui.ts` so the orchestrator reads as connect-the-dots.
8
+ *
9
+ * Each function is a pure construction step — no behaviour change. The
10
+ * WS/HTTP/shutdown wiring that used to be inline (~370 lines) now lives
11
+ * behind four focused entry points.
12
+ */
1
13
  import type { Config, ModelsRegistry } from '@wrongstack/core/types';
2
- import { WebSocketServer, type WebSocket } from 'ws';
3
- import { setupEvents, type FileWatcherMetrics } from './setup-events.js';
14
+ import { type WebSocket, WebSocketServer } from 'ws';
15
+ import { type FileWatcherMetrics, setupEvents } from './setup-events.js';
4
16
  import type { ConnectedClient } from './types.js';
5
17
  interface ResolvedPorts {
6
18
  wsHost: string;
@@ -1,8 +1,8 @@
1
1
  import type { EventName, Listener } from '@wrongstack/core/kernel';
2
2
  import type { WstackPaths } from '@wrongstack/core/utils';
3
3
  import type { WebSocket } from 'ws';
4
- import type { ConnectedClient, WSServerMessage } from './types.js';
5
4
  import { type FileWatcherMetrics } from './setup-events-watcher.js';
5
+ import type { ConnectedClient, WSServerMessage } from './types.js';
6
6
  export interface SetupEventsStatusWatcherDeps {
7
7
  wpaths?: WstackPaths | undefined;
8
8
  watcherMetrics?: FileWatcherMetrics | undefined;
@@ -7,7 +7,7 @@ export declare const WEBUI_WS_MAX_BUFFERED_BYTES: number;
7
7
  * A socket above the cap cannot be trusted to catch up: keeping it alive would
8
8
  * let `ws` retain every subsequent broadcast in memory.
9
9
  */
10
- export declare function sendSerialized(ws: WebSocket, data: string): boolean;
10
+ export declare function sendSerialized(ws: WebSocket, data: string, frameBytes?: number): boolean;
11
11
  /**
12
12
  * Send a JSON message to a single WebSocket client.
13
13
  * No-op when the socket is not in OPEN state (disconnected / closing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/webui-server",
3
- "version": "0.302.2",
3
+ "version": "0.305.0",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack WebUI HTTP/WebSocket server module — extracted from @wrongstack/webui in PR #243/244 to remove the CLI -> @wrongstack/webui/server cross-package edge (audit §3.1.1). Pure backend: HTTP routes, WebSocket handlers, MCP tool wrappers, HTML serving. The web frontend lives in @wrongstack/webui; this package is the standalone server it can run on.",
6
6
  "keywords": [
@@ -40,16 +40,16 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "ws": "^8.21.1",
43
- "@wrongstack/core": "0.302.2",
44
- "@wrongstack/sdd": "0.302.2",
45
- "@wrongstack/mcp": "0.302.2",
46
- "@wrongstack/providers": "0.302.2",
47
- "@wrongstack/kanban": "0.302.2",
48
- "@wrongstack/sage": "0.302.2",
49
- "@wrongstack/requirement-intake": "0.302.2",
50
- "@wrongstack/runtime": "0.302.2",
51
- "@wrongstack/techstack": "0.302.2",
52
- "@wrongstack/tools": "0.302.2"
43
+ "@wrongstack/runtime": "0.305.0",
44
+ "@wrongstack/mcp": "0.305.0",
45
+ "@wrongstack/providers": "0.305.0",
46
+ "@wrongstack/kanban": "0.305.0",
47
+ "@wrongstack/core": "0.305.0",
48
+ "@wrongstack/requirement-intake": "0.305.0",
49
+ "@wrongstack/techstack": "0.305.0",
50
+ "@wrongstack/sage": "0.305.0",
51
+ "@wrongstack/tools": "0.305.0",
52
+ "@wrongstack/sdd": "0.305.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^26.1.2",