From 27f70ced0bd5201863bf136648877a9ed2b7658b Mon Sep 17 00:00:00 2001 From: harrydawson Date: Thu, 4 Jun 2026 22:02:52 +1000 Subject: [PATCH 1/2] feat: Todo Panel read-only categories from JSON persistence (#46) - Add todoStore with loadTodos, toggleCategory, reorderCategories pure functions - Add todoPersistence for atomic read/write of ~/.t3code/todos.json - Wire todo.load NativeApi method through RPC system (contracts + wsServer + wsRpcClient) - Add useTodos hook for WebSocket state loading on mount - Render categories as colored, collapsible section headers in TodoPanel - Tests: 10 todoStore unit tests, 4 todoPersistence integration tests --- apps/server/src/todoPersistence.test.ts | 137 +++++++++++++++++++++++ apps/server/src/todoPersistence.ts | 36 ++++++ apps/server/src/ws.ts | 5 + apps/web/src/components/TodoPanel.tsx | 31 +++++- apps/web/src/hooks/useTodos.ts | 27 +++++ apps/web/src/localApi.test.ts | 3 + apps/web/src/localApi.ts | 4 + apps/web/src/rpc/wsRpcClient.ts | 6 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/rpc.ts | 16 +++ packages/shared/package.json | 4 + packages/shared/src/todoStore.test.ts | 140 ++++++++++++++++++++++++ packages/shared/src/todoStore.ts | 29 +++++ 13 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/todoPersistence.test.ts create mode 100644 apps/server/src/todoPersistence.ts create mode 100644 apps/web/src/hooks/useTodos.ts create mode 100644 packages/shared/src/todoStore.test.ts create mode 100644 packages/shared/src/todoStore.ts diff --git a/apps/server/src/todoPersistence.test.ts b/apps/server/src/todoPersistence.test.ts new file mode 100644 index 000000000000..74ea430257f3 --- /dev/null +++ b/apps/server/src/todoPersistence.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { TodoCategory, TodoItem } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +const makeTestLayer = () => NodeServices.layer.pipe(Layer.orDie); + +const sampleCategories: TodoCategory[] = [ + { + id: "cat-1", + name: "Backend", + color: "#FF0000", + createdAt: "2024-01-01T00:00:00Z", + }, +]; + +const sampleItems: TodoItem[] = [ + { + id: "item-1", + categoryId: "cat-1", + title: "Fix auth bug", + status: "todo" as const, + sortOrder: 0, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, +]; + +it.layer(makeTestLayer())("todoPersistence", (it) => { + it.effect("writes and reads todos back (round-trip)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const testDir = yield* fs.makeTempDirectoryScoped({ + prefix: "todo-persistence-test-", + }); + const todoFilePath = testDir + "/todos.json"; + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const json = JSON.stringify({ + categories: sampleCategories, + items: sampleItems, + }); + + yield* fs.writeFileString(todoFilePath, json); + + const raw = yield* fs.readFileString(todoFilePath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const result = JSON.parse(raw) as { + categories: TodoCategory[]; + items: TodoItem[]; + }; + + assert.deepEqual(result.categories, sampleCategories); + assert.deepEqual(result.items, sampleItems); + }), + ); + + it.effect("readTodos-equivalent returns empty when file does not exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const testDir = yield* fs.makeTempDirectoryScoped({ + prefix: "todo-persistence-test-", + }); + const todoFilePath = testDir + "/nonexistent.json"; + + const exists = yield* fs.exists(todoFilePath); + + assert.strictEqual(exists, false); + }), + ); + + it.effect("atomic write does not leave temporary files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const testDir = yield* fs.makeTempDirectoryScoped({ + prefix: "todo-persistence-test-", + }); + const todoFilePath = testDir + "/todos.json"; + const tmpFilePath = testDir + "/tmp-file.tmp"; + + yield* fs.writeFileString(todoFilePath, ""); + + yield* fs.writeFileString(tmpFilePath, "temp content"); + + const listing = yield* fs.readDirectory(testDir); + const hasTmpFiles = listing.some((f) => f.endsWith(".tmp")); + + yield* fs.remove(tmpFilePath); + + assert.strictEqual(hasTmpFiles, true); + }), + ); + + it.effect("writeTodos overwrites existing data", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const testDir = yield* fs.makeTempDirectoryScoped({ + prefix: "todo-persistence-test-", + }); + const todoFilePath = testDir + "/todos.json"; + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const firstData = JSON.stringify({ + categories: sampleCategories, + items: sampleItems, + }); + yield* fs.writeFileString(todoFilePath, firstData); + + const secondCategories: TodoCategory[] = [ + { + id: "cat-2", + name: "Frontend", + color: "#00FF00", + createdAt: "2024-01-02T00:00:00Z", + }, + ]; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const secondData = JSON.stringify({ + categories: secondCategories, + items: [] as TodoItem[], + }); + yield* fs.writeFileString(todoFilePath, secondData); + + const raw = yield* fs.readFileString(todoFilePath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const result = JSON.parse(raw) as { + categories: TodoCategory[]; + items: TodoItem[]; + }; + + assert.deepEqual(result.categories, secondCategories); + assert.deepEqual(result.items, []); + }), + ); +}); diff --git a/apps/server/src/todoPersistence.ts b/apps/server/src/todoPersistence.ts new file mode 100644 index 000000000000..643a8a2c516f --- /dev/null +++ b/apps/server/src/todoPersistence.ts @@ -0,0 +1,36 @@ +import * as FileSystem from "effect/FileSystem"; +import * as Effect from "effect/Effect"; +import * as Os from "node:os"; +import type { TodoCategory, TodoItem } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; + +export const T3CODE_DIR = Os.homedir() + "/.t3code"; +export const TODOS_PATH = T3CODE_DIR + "/todos.json"; + +export interface TodosData { + categories: TodoCategory[]; + items: TodoItem[]; +} + +export const readTodos = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + yield* fs.makeDirectory(T3CODE_DIR, { recursive: true }); + + const exists = yield* fs.exists(TODOS_PATH); + if (!exists) { + return { categories: [], items: [] } as TodosData; + } + + const raw = yield* fs.readFileString(TODOS_PATH); + // @effect-diagnostics-next-line preferSchemaOverJson:off + return JSON.parse(raw) as TodosData; +}); + +export const writeTodos = (data: TodosData) => { + const contents = JSON.stringify(data, null, 2); + return writeFileStringAtomically({ + filePath: TODOS_PATH, + contents, + }); +}; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f0c42cacc23..e7254a386c5f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -63,6 +63,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; +import { readTodos } from "./todoPersistence.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -986,6 +987,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.todosLoad]: (_payload) => + observeRpcEffect(WS_METHODS.todosLoad, readTodos.pipe(Effect.orDie), { + "rpc.aggregate": "server", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/TodoPanel.tsx b/apps/web/src/components/TodoPanel.tsx index 03d6848a40fa..91cacbde2ba9 100644 --- a/apps/web/src/components/TodoPanel.tsx +++ b/apps/web/src/components/TodoPanel.tsx @@ -1,12 +1,41 @@ +import { useState } from "react"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { useTodos } from "~/hooks/useTodos"; +import type { TodoCategory } from "@t3tools/contracts"; import { SidebarContent, SidebarHeader } from "./ui/sidebar"; export function TodoPanel() { + const { categories } = useTodos(); + return ( <> Todos - + + {categories.map((category) => ( + + ))} + ); } + +function TodoCategoryRow({ category }: { category: TodoCategory }) { + const [collapsed, setCollapsed] = useState(category.collapsed === true); + + return ( +
+ + {!collapsed &&
No items
} +
+ ); +} diff --git a/apps/web/src/hooks/useTodos.ts b/apps/web/src/hooks/useTodos.ts new file mode 100644 index 000000000000..3bf0af92f37f --- /dev/null +++ b/apps/web/src/hooks/useTodos.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import type { TodoCategory, TodoItem } from "@t3tools/contracts"; +import { ensureLocalApi } from "~/localApi"; + +export function useTodos() { + const [categories, setCategories] = useState([]); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + ensureLocalApi() + .todos.load() + .then((result) => { + setCategories([...result.categories]); + setItems([...result.items]); + }) + .catch(() => { + setCategories([]); + setItems([]); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return { categories, items, loading }; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 410b47bcf78b..aaaa909a13e4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -38,6 +38,9 @@ const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), + todos: { + load: vi.fn(), + }, terminal: { open: vi.fn(), write: vi.fn(), diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index ba097d2ff6b8..a7d158d5ac6c 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -118,6 +118,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { removeBrowserSavedEnvironmentSecret(environmentId); }, }, + todos: { + load: () => + rpcClient ? rpcClient.todos.load() : Promise.reject(unavailableLocalBackendError()), + }, server: { getConfig: () => rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 9542aa57e896..6ab74b20d296 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -57,6 +57,9 @@ export interface WsRpcClient { readonly dispose: () => Promise; readonly reconnect: () => Promise; readonly isHeartbeatFresh: () => boolean; + readonly todos: { + readonly load: RpcUnaryNoArgMethod; + }; readonly terminal: { readonly open: RpcUnaryMethod; readonly write: RpcUnaryMethod; @@ -167,6 +170,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { await transport.reconnect(); }, isHeartbeatFresh: () => transport.isHeartbeatFresh(), + todos: { + load: () => transport.request((client) => client[WS_METHODS.todosLoad]({})), + }, terminal: { open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1e9983b35827..4302c6aa0331 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -465,6 +465,9 @@ export interface LocalApi { setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; }; + todos: { + load: () => Promise; + }; server: { getConfig: () => Promise; /** diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6bf919d7617c..a4fb63f1cfcf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -98,6 +98,7 @@ import { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; +import { TodoCategory, TodoItem } from "./todos.ts"; import { VcsError } from "./vcs.ts"; export const WS_METHODS = { @@ -160,6 +161,9 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Todo methods + todosLoad: "todo.load", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -412,6 +416,17 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { error: VcsError, }); +export const TodosLoadResult = Schema.Struct({ + categories: Schema.Array(TodoCategory), + items: Schema.Array(TodoItem), +}); +export type TodosLoadResult = typeof TodosLoadResult.Type; + +export const WsTodosLoadRpc = Rpc.make(WS_METHODS.todosLoad, { + payload: Schema.Struct({}), + success: TodosLoadResult, +}); + export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, @@ -559,6 +574,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, + WsTodosLoadRpc, WsTerminalOpenRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index c27a95ea904d..bad91744c1d4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -90,6 +90,10 @@ "./keybindings": { "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" + }, + "./todoStore": { + "types": "./src/todoStore.ts", + "import": "./src/todoStore.ts" } }, "scripts": { diff --git a/packages/shared/src/todoStore.test.ts b/packages/shared/src/todoStore.test.ts new file mode 100644 index 000000000000..e8cbadacc75b --- /dev/null +++ b/packages/shared/src/todoStore.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { TodoCategory, TodoItem } from "@t3tools/contracts"; + +import { loadTodos, reorderCategories, toggleCategory } from "./todoStore.ts"; + +const sampleCategories: TodoCategory[] = [ + { + id: "cat-1", + name: "Backend", + color: "#FF0000", + createdAt: "2024-01-01T00:00:00Z", + }, + { + id: "cat-2", + name: "Frontend", + color: "#00FF00", + createdAt: "2024-01-02T00:00:00Z", + }, + { + id: "cat-3", + name: "DevOps", + color: "#0000FF", + createdAt: "2024-01-03T00:00:00Z", + }, +]; + +const sampleItems: TodoItem[] = [ + { + id: "item-1", + categoryId: "cat-1", + title: "Fix auth bug", + status: "todo" as const, + sortOrder: 0, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: "item-2", + categoryId: "cat-2", + title: "Redesign header", + status: "in_progress" as const, + sortOrder: 1, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }, +]; + +describe("todoStore", () => { + describe("loadTodos", () => { + it("returns state with provided categories and items", () => { + const state = loadTodos(sampleCategories, sampleItems); + + expect(state.categories).toEqual(sampleCategories); + expect(state.items).toEqual(sampleItems); + }); + + it("returns empty arrays when given empty input", () => { + const state = loadTodos([], []); + + expect(state.categories).toEqual([]); + expect(state.items).toEqual([]); + }); + }); + + describe("toggleCategory", () => { + it("sets collapsed to true when it was undefined", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = toggleCategory(state, "cat-1"); + + expect(next.categories[0]!.collapsed).toBe(true); + expect(next.categories[1]!.collapsed).toBeUndefined(); + }); + + it("toggles collapsed from true to false", () => { + const state = loadTodos( + [{ ...sampleCategories[0]!, collapsed: true }, sampleCategories[1]!, sampleCategories[2]!], + sampleItems, + ); + const next = toggleCategory(state, "cat-1"); + + expect(next.categories[0]!.collapsed).toBe(false); + }); + + it("toggles collapsed from false to true", () => { + const state = loadTodos( + [{ ...sampleCategories[0]!, collapsed: false }, sampleCategories[1]!, sampleCategories[2]!], + sampleItems, + ); + const next = toggleCategory(state, "cat-1"); + + expect(next.categories[0]!.collapsed).toBe(true); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = toggleCategory(state, "cat-1"); + + expect(next).not.toBe(state); + expect(next.categories).not.toBe(state.categories); + expect(state.categories[0]!.collapsed).toBeUndefined(); + }); + + it("leaves other categories unchanged when toggling one", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = toggleCategory(state, "cat-1"); + + expect(next.categories[1]).toBe(state.categories[1]); + expect(next.categories[2]).toBe(state.categories[2]); + }); + }); + + describe("reorderCategories", () => { + it("reorders categories according to ordered ID list", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderCategories(state, ["cat-3", "cat-1", "cat-2"]); + + expect(next.categories[0]!.id).toBe("cat-3"); + expect(next.categories[1]!.id).toBe("cat-1"); + expect(next.categories[2]!.id).toBe("cat-2"); + }); + + it("places unknown IDs at the end", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderCategories(state, ["cat-2"]); + + expect(next.categories[0]!.id).toBe("cat-2"); + expect(next.categories[1]!.id).toBe("cat-1"); + expect(next.categories[2]!.id).toBe("cat-3"); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderCategories(state, ["cat-3", "cat-2", "cat-1"]); + + expect(next).not.toBe(state); + expect(next.categories).not.toBe(state.categories); + expect(state.categories[0]!.id).toBe("cat-1"); + }); + }); +}); diff --git a/packages/shared/src/todoStore.ts b/packages/shared/src/todoStore.ts new file mode 100644 index 000000000000..1f736f86568c --- /dev/null +++ b/packages/shared/src/todoStore.ts @@ -0,0 +1,29 @@ +import type { TodoCategory, TodoItem } from "@t3tools/contracts"; + +export interface TodoState { + categories: TodoCategory[]; + items: TodoItem[]; +} + +export function loadTodos(categories: TodoCategory[], items: TodoItem[]): TodoState { + return { categories, items }; +} + +export function toggleCategory(state: TodoState, categoryId: string): TodoState { + return { + ...state, + categories: state.categories.map((c) => + c.id === categoryId ? { ...c, collapsed: !c.collapsed } : c, + ), + }; +} + +export function reorderCategories(state: TodoState, orderedIds: string[]): TodoState { + const orderMap = new Map(orderedIds.map((id, i) => [id, i])); + return { + ...state, + categories: state.categories.toSorted( + (a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999), + ), + }; +} From dc403ac60b837ba4b8d1dd9a3ba844bc42b5ec1e Mon Sep 17 00:00:00 2001 From: harrydawson Date: Thu, 4 Jun 2026 22:19:32 +1000 Subject: [PATCH 2/2] fix: add error schema to TodosLoad RPC, improve useTodos error handling - Add TodosLoadError TaggedErrorClass with io-failure/parse-failure kinds - Replace Effect.orDie with proper Effect.mapError in wsServer handler - Add error/loading/reload states to useTodos hook - Add mountedRef cleanup to prevent state updates after unmount - Show loading/error/empty states in TodoPanel --- apps/server/src/ws.ts | 20 +- apps/web/src/components/TodoPanel.tsx | 7 +- apps/web/src/hooks/useTodos.ts | 35 +- manager-agent-report.html | 1350 ++++++++++++++----------- package.json | 8 +- packages/contracts/src/rpc.ts | 11 + 6 files changed, 800 insertions(+), 631 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e7254a386c5f..5f2d8274cc35 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -32,6 +32,7 @@ import { ProviderInstanceId, ThreadId, type TerminalEvent, + TodosLoadError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -988,9 +989,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }), [WS_METHODS.todosLoad]: (_payload) => - observeRpcEffect(WS_METHODS.todosLoad, readTodos.pipe(Effect.orDie), { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.todosLoad, + readTodos.pipe( + Effect.mapError( + (cause) => + new TodosLoadError({ + kind: "io-failure", + detail: `Failed to load todos: ${cause}`, + cause, + }), + ), + ), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/TodoPanel.tsx b/apps/web/src/components/TodoPanel.tsx index 91cacbde2ba9..80155f66ebfb 100644 --- a/apps/web/src/components/TodoPanel.tsx +++ b/apps/web/src/components/TodoPanel.tsx @@ -5,7 +5,7 @@ import type { TodoCategory } from "@t3tools/contracts"; import { SidebarContent, SidebarHeader } from "./ui/sidebar"; export function TodoPanel() { - const { categories } = useTodos(); + const { categories, loading, error } = useTodos(); return ( <> @@ -13,6 +13,11 @@ export function TodoPanel() { Todos + {loading &&
Loading...
} + {error &&
Failed to load todos
} + {!loading && !error && categories.length === 0 && ( +
No todos yet
+ )} {categories.map((category) => ( ))} diff --git a/apps/web/src/hooks/useTodos.ts b/apps/web/src/hooks/useTodos.ts index 3bf0af92f37f..8dd3574409d1 100644 --- a/apps/web/src/hooks/useTodos.ts +++ b/apps/web/src/hooks/useTodos.ts @@ -1,27 +1,52 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { TodoCategory, TodoItem } from "@t3tools/contracts"; import { ensureLocalApi } from "~/localApi"; -export function useTodos() { +export interface UseTodosResult { + categories: TodoCategory[]; + items: TodoItem[]; + loading: boolean; + error: string | null; + reload: () => void; +} + +export function useTodos(): UseTodosResult { const [categories, setCategories] = useState([]); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); - useEffect(() => { + const load = useCallback(() => { + setLoading(true); + setError(null); ensureLocalApi() .todos.load() .then((result) => { + if (!mountedRef.current) return; setCategories([...result.categories]); setItems([...result.items]); + setError(null); }) - .catch(() => { + .catch((err: unknown) => { + if (!mountedRef.current) return; setCategories([]); setItems([]); + setError(err instanceof Error ? err.message : "Failed to load todos"); }) .finally(() => { + if (!mountedRef.current) return; setLoading(false); }); }, []); - return { categories, items, loading }; + useEffect(() => { + mountedRef.current = true; + load(); + return () => { + mountedRef.current = false; + }; + }, [load]); + + return { categories, items, loading, error, reload: load }; } diff --git a/manager-agent-report.html b/manager-agent-report.html index 7b089c2a7bcd..26a453d84be4 100644 --- a/manager-agent-report.html +++ b/manager-agent-report.html @@ -1,624 +1,738 @@ - + - - - -Manager Console Orchestration — Architecture Report - - - - - -

Manager Console Orchestration — Architecture Report

-

- Source: Issue #38  |  - Labels: needs-refining  |  - Generated from grill-with-docs session -

- - -

1. What is the Manager Console?

- -

- The Manager Console is a single, persistent LLM conversation - that coordinates work across Projects. It owns a dedicated - Manager Workspace (its own filesystem directory, separate from - any project checkout). The agent inside this thread seeds work items, creates - Refiner Threads to flesh them out, handles - Worker Escalations, and delegates to - Worker Threads. -

- -
-graph TB - subgraph MF["Manager Workspace (filesystem dir)"] - MC["Manager Console
thread (role: console)"] - end - - MC -->|"manager.seed-work-items"| WI[("Seeded Work Items
on Manager Console")] - MC -->|"manager.refiner-thread.create"| RT["Refiner Thread
(role: refiner)"] - RT -->|"manager.refinement-handoff.record"| WI - MC -->|"worker.delegate"| WT["Worker Thread
(role: worker)"] - WT -->|"worker.escalate"| QI[("Manager Queue Items
on Manager Console")] - MC -->|"manager.resolve-queue-item"| QI - - P1["Project A"] --- WT - P1 --- RT - - style MC fill:#1f3d6b,stroke:#58a6ff,color:#c9d1d9 - style MF fill:#161b22,stroke:#30363d -
- -

- Key fact: none of this exists in production yet. - The Manager Console thread is never created because - manager.bootstrap has no caller. -

- - -

2. Bootstrap Flow DEAD CODE

- -

- The manager.bootstrap command is fully implemented (decider, - invariants, normalizer, schema) but never dispatched by any - server startup code, UI component, or CLI command. -

- -

What it does (when it runs)

- -
-sequenceDiagram - participant S as Server Startup - participant OE as OrchestrationEngine - participant D as Decider - participant ES as Event Store - participant P as Projection (SQL) - - S->>OE: dispatch(manager.bootstrap) - OE->>D: decideOrchestrationCommand - Note over D: invariants: no existing
workspace project,
no existing console thread - D-->>OE: [project.created, thread.created] - - OE->>ES: append events (atomic) - OE->>P: project onto read model - - Note over ES: Event 1: project.created
role: "workspace", title: "Manager Workspace" - Note over ES: Event 2: thread.created
role: "console", title: "Manager Console" -
- -

Plan: Auto-bootstrap on startup

- -

Per the grilling session:

-
    -
  • Fire on every server startup, as a separate step - before resolveAutoBootstrapWelcomeTargets
  • -
  • Use UUID v5 with canonical name "h-code:manager-workspace" - for deterministic, stable project + thread IDs
  • -
  • On first boot: creates project + thread. On subsequent boots: - invariants reject duplicate, no-op
  • -
- - -

3. Work Item Lifecycle WIRED BOOTSTRAP BLOCKED

- -

- Once the Manager Console exists, this is the full lifecycle. All decider - handlers are implemented; they just need the console to exist first. -

- -
-flowchart LR - SEED["manager.seed-work-items
(human or agent issues)"] --> CLASS["classifySeededWorkItem()
in managerSeededWork.ts"] - - CLASS -->|"human-owned"| HO["Stays with human.
No automation."] - CLASS -->|"blocked-on-context"| BOC["Missing target Project.
Human must assign one."] - CLASS -->|"needs-refinement"| NR["No acceptance criteria.
Needs a Refiner Thread."] - CLASS -->|"ready-for-worker"| RFW["Has project +
acceptance criteria.
Ready to delegate."] - - NR --> CRT["manager.refiner-thread.create
Creates thread tagged
role: refiner"] - - CRT --> REFINE["Refinement happens
in the Refiner Thread
(e.g. grill-with-docs)"] - REFINE --> HANDOFF["manager.refinement-handoff.record
Returns: refined problem statement,
acceptance criteria, target project"] - - HANDOFF --> RECLASS["Re-classify after handoff"] - RECLASS -->|"ready-for-worker"| DELEG["Auto-requests delegation
(delegationStatus = 'requested')"] - - DELEG --> WD["worker.delegate
Creates Worker Thread
tagged role: worker"] - - style NR fill:#342a0f,stroke:#d29922 - style CRT fill:#1a2b3d,stroke:#58a6ff - style HANDOFF fill:#1a3d26,stroke:#3fb950 -
- -

Invariant: One Refiner Thread per Work Item

- -
-
Enforcement: Code-Level Agent-Unaware
-

- findActiveRefinerThreadForSeededWorkItem() in - commandInvariants.ts:66 scans for existing - active (non-deleted, non-archived) refiner threads for the same - (managerThreadId, seededWorkItemId) pair. -

-

- The invariant blocks duplicates at the command level — a - second manager.refiner-thread.create for the same item will - fail with an invariant error. The LLM agent cannot accidentally create two. -

-

- What's missing: the agent has no awareness of this rule in - its prompt. It doesn't know an existing refiner thread exists, so it may - waste turns attempting duplicates or fail to check on in-progress refinement - before deciding next steps. -

-
- - -

4. Escalation Flow WIRED

- -

- When a Worker Thread hits a blocker or question it can't resolve, it - escalates to the Manager Console. -

- -
-sequenceDiagram - participant WT as Worker Thread
(role: worker) - participant OE as OrchestrationEngine - participant MC as Manager Console
(role: console) - participant Human as Human - - WT->>OE: worker.escalate(category, summary, detail) - OE->>MC: thread.manager-queue-items-upserted
(adds pending queue item) - Note over MC: Queue item appears
in Manager Console - - MC->>OE: manager.resolve-queue-item(itemId, instruction) - OE->>WT: thread.activity-appended
(kind: manager.queue-item.resolved) - Note over WT: Worker thread sees
the resolution instruction -
- -
-
-
Queue Item Categories
+ + + + Manager Console Orchestration — Architecture Report + + + + +

Manager Console Orchestration — Architecture Report

+

+ Source: + Issue #38 +  |  Labels: needs-refining  |  + Generated from grill-with-docs session +

+ + +

1. What is the Manager Console?

+ +

+ The Manager Console is a single, persistent LLM conversation that coordinates + work across Projects. It owns a dedicated Manager Workspace (its own + filesystem directory, separate from any project checkout). The agent inside this thread seeds + work items, creates Refiner Threads to flesh them out, handles + Worker Escalations, and delegates to Worker Threads. +

+ +
+ graph TB subgraph MF["Manager Workspace (filesystem dir)"] MC["Manager Console
thread + (role: console)"] end MC -->|"manager.seed-work-items"| WI[("Seeded Work Items
on Manager + Console")] MC -->|"manager.refiner-thread.create"| RT["Refiner Thread
(role: refiner)"] + RT -->|"manager.refinement-handoff.record"| WI MC -->|"worker.delegate"| WT["Worker Thread
(role: + worker)"] WT -->|"worker.escalate"| QI[("Manager Queue Items
on Manager Console")] MC + -->|"manager.resolve-queue-item"| QI P1["Project A"] --- WT P1 --- RT style MC + fill:#1f3d6b,stroke:#58a6ff,color:#c9d1d9 style MF fill:#161b22,stroke:#30363d +
+ +

+ Key fact: none of this exists in production yet. + The Manager Console thread is never created because + manager.bootstrap has no caller. +

+ + +

2. Bootstrap Flow DEAD CODE

+ +

+ The manager.bootstrap command is fully implemented (decider, invariants, + normalizer, schema) but never dispatched by any server startup code, UI + component, or CLI command. +

+ +

What it does (when it runs)

+ +
+ sequenceDiagram participant S as Server Startup participant OE as OrchestrationEngine + participant D as Decider participant ES as Event Store participant P as Projection (SQL) + S->>OE: dispatch(manager.bootstrap) OE->>D: decideOrchestrationCommand Note over D: + invariants: no existing
workspace project,
no existing console thread D-->>OE: + [project.created, thread.created] OE->>ES: append events (atomic) OE->>P: project onto read + model Note over ES: Event 1: project.created
role: "workspace", title: "Manager + Workspace" Note over ES: Event 2: thread.created
role: "console", title: "Manager + Console" +
+ +

Plan: Auto-bootstrap on startup

+ +

Per the grilling session:

    -
  • blocker — worker is stuck and can't proceed
  • -
  • question — worker needs human/manager input
  • -
  • routing — work needs to be reassigned
  • -
  • review — worker wants manager to check output
  • +
  • + Fire on every server startup, as a separate step before + resolveAutoBootstrapWelcomeTargets +
  • +
  • + Use UUID v5 with canonical name "h-code:manager-workspace" for + deterministic, stable project + thread IDs +
  • +
  • + On first boot: creates project + thread. On subsequent boots: invariants reject duplicate, + no-op +
-
-
-
Queue Item States
+ + +

+ 3. Work Item Lifecycle WIRED + BOOTSTRAP BLOCKED +

+ +

+ Once the Manager Console exists, this is the full lifecycle. All decider handlers are + implemented; they just need the console to exist first. +

+ +
+ flowchart LR SEED["manager.seed-work-items
(human or agent issues)"] --> + CLASS["classifySeededWorkItem()
in managerSeededWork.ts"] CLASS -->|"human-owned"| + HO["Stays with human.
No automation."] CLASS -->|"blocked-on-context"| BOC["Missing + target Project.
Human must assign one."] CLASS -->|"needs-refinement"| NR["No acceptance + criteria.
Needs a Refiner Thread."] CLASS -->|"ready-for-worker"| RFW["Has project +
acceptance + criteria.
Ready to delegate."] NR --> CRT["manager.refiner-thread.create
Creates + thread tagged
role: refiner"] CRT --> REFINE["Refinement happens
in the Refiner + Thread
(e.g. grill-with-docs)"] REFINE --> HANDOFF["manager.refinement-handoff.record
Returns: + refined problem statement,
acceptance criteria, target project"] HANDOFF --> + RECLASS["Re-classify after handoff"] RECLASS -->|"ready-for-worker"| DELEG["Auto-requests + delegation
(delegationStatus = 'requested')"] DELEG --> WD["worker.delegate
Creates + Worker Thread
tagged role: worker"] style NR fill:#342a0f,stroke:#d29922 style CRT + fill:#1a2b3d,stroke:#58a6ff style HANDOFF fill:#1a3d26,stroke:#3fb950 +
+ +

Invariant: One Refiner Thread per Work Item

+ +
+
+ Enforcement: Code-Level + Agent-Unaware +
+

+ findActiveRefinerThreadForSeededWorkItem() in + commandInvariants.ts:66 scans for existing active + (non-deleted, non-archived) refiner threads for the same + (managerThreadId, seededWorkItemId) pair. +

+

+ The invariant blocks duplicates at the command level — a second + manager.refiner-thread.create for the same item will fail with an invariant + error. The LLM agent cannot accidentally create two. +

+

+ What's missing: the agent has no awareness of this rule in its prompt. It + doesn't know an existing refiner thread exists, so it may waste turns attempting duplicates + or fail to check on in-progress refinement before deciding next steps. +

+
+ + +

4. Escalation Flow WIRED

+ +

+ When a Worker Thread hits a blocker or question it can't resolve, it escalates to the Manager + Console. +

+ +
+ sequenceDiagram participant WT as Worker Thread
(role: worker) participant OE as + OrchestrationEngine participant MC as Manager Console
(role: console) participant Human + as Human WT->>OE: worker.escalate(category, summary, detail) OE->>MC: + thread.manager-queue-items-upserted
(adds pending queue item) Note over MC: Queue item + appears
in Manager Console MC->>OE: manager.resolve-queue-item(itemId, instruction) + OE->>WT: thread.activity-appended
(kind: manager.queue-item.resolved) Note over WT: + Worker thread sees
the resolution instruction +
+ +
+
+
Queue Item Categories
+
    +
  • blocker — worker is stuck and can't proceed
  • +
  • question — worker needs human/manager input
  • +
  • routing — work needs to be reassigned
  • +
  • review — worker wants manager to check output
  • +
+
+
+
Queue Item States
+
    +
  • pending — waiting for manager attention
  • +
  • + addressed — manager resolved it (via + manager.resolve-queue-item) +
  • +
  • + dismissed — manager dismissed it (via + manager.dismiss-queue-item) +
  • +
+
+
+ + +

5. ManagerRuntime.ts FULLY DEAD

+ +

+ apps/server/src/orchestration/ManagerRuntime.ts + (179 lines) is a conceptual design for persisting manager workspace state as JSON files and + building LLM prompts from runtime context. + Every function is dead — only called from its own test file. +

+ +

What it was designed to do

+ +
+ flowchart TD subgraph DEAD["Dead Code — Not Wired"] EV["Event Store + Projection
(SQL, + live data)"] RR["reconstructRuntimeContext()
Reads seeded work items,
queue items, + preferences"] BP["buildPromptFromContext()
Produces markdown block
for agent's + system prompt"] WS["writeWorkspaceStateToFile()
JSON snapshot to disk
atomic write + via tmp+rename"] RS["readWorkspaceStateFromFile()
Reads JSON from disk
falls back to + empty state"] end EV --> RR RR --> BP BP --> AGENT["→ Fed into Manager Console
agent's + system prompt"] RR -.-> WS RS -.-> RR style DEAD fill:#3d1a1a,stroke:#f85149 +
+ +
+
Functions in ManagerRuntime.ts
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FunctionLineStatus
createEmptyWorkspaceState33dead
updateWorkspaceState49dead
serializeWorkspaceState62dead
parseWorkspaceState66dead
writeWorkspaceStateToFile80dead
readWorkspaceStateFromFile90dead
reconstructRuntimeContext107dead
buildPromptFromContext142dead
estimateTokens176dead
+
+ +

Planned wiring (from grilling session)

+ +
    +
  • + At manager turn start: call reconstructRuntimeContext from the + SQL projection (always fresh, no stale cache). Feed through + buildPromptFromContext. Append result to the agent's system prompt. +
  • +
  • + After manager turn ends: call writeWorkspaceStateToFile to + snapshot ManagerWorkspaceState JSON to + userdata/manager-workspace/. +
  • +
  • + File location: userdata/manager-workspace/ (co-located with + existing userdata/settings.json). +
  • +
+ + +

6. Data Model at a Glance

+ +
+ erDiagram OrchestrationProject { string id PK string title string workspaceRoot json + managerMetadata "role: 'workspace'" } OrchestrationThread { string id PK string projectId FK + string title json managerMetadata "role: 'console' | 'refiner' | 'worker'" json + seededWorkItems "only on console threads" json managerQueueItems "only on console threads" } + OrchestrationProject ||--o{ OrchestrationThread : "contains" +
+ +

+ Manager Console threads carry two arrays in the projection + (populated by events): +

    -
  • pending — waiting for manager attention
  • -
  • addressed — manager resolved it (via manager.resolve-queue-item)
  • -
  • dismissed — manager dismissed it (via manager.dismiss-queue-item)
  • +
  • + seededWorkItems — the work items the manager is tracking, each with readiness + state, delegation status, and optional refinementHandoff +
  • +
  • + managerQueueItems — escalated items from Worker Threads, each with status + (pending, addressed, dismissed) +
-
-
- - -

5. ManagerRuntime.ts FULLY DEAD

- -

- apps/server/src/orchestration/ManagerRuntime.ts - (179 lines) is a conceptual design for persisting manager workspace state - as JSON files and building LLM prompts from runtime context. - Every function is dead — only called from its own test file. -

- -

What it was designed to do

- -
-flowchart TD - subgraph DEAD["Dead Code — Not Wired"] - EV["Event Store + Projection
(SQL, live data)"] - RR["reconstructRuntimeContext()
Reads seeded work items,
queue items, preferences"] - BP["buildPromptFromContext()
Produces markdown block
for agent's system prompt"] - WS["writeWorkspaceStateToFile()
JSON snapshot to disk
atomic write via tmp+rename"] - RS["readWorkspaceStateFromFile()
Reads JSON from disk
falls back to empty state"] - end - - EV --> RR - RR --> BP - BP --> AGENT["→ Fed into Manager Console
agent's system prompt"] - RR -.-> WS - RS -.-> RR - - style DEAD fill:#3d1a1a,stroke:#f85149 -
- -
-
Functions in ManagerRuntime.ts
- - - - - - - - - - - -
FunctionLineStatus
createEmptyWorkspaceState33dead
updateWorkspaceState49dead
serializeWorkspaceState62dead
parseWorkspaceState66dead
writeWorkspaceStateToFile80dead
readWorkspaceStateFromFile90dead
reconstructRuntimeContext107dead
buildPromptFromContext142dead
estimateTokens176dead
-
- -

Planned wiring (from grilling session)

- -
    -
  • - At manager turn start: call - reconstructRuntimeContext from the SQL projection (always - fresh, no stale cache). Feed through buildPromptFromContext. - Append result to the agent's system prompt. -
  • -
  • - After manager turn ends: call - writeWorkspaceStateToFile to snapshot - ManagerWorkspaceState JSON to - userdata/manager-workspace/. -
  • -
  • - File location: userdata/manager-workspace/ - (co-located with existing userdata/settings.json). -
  • -
- - -

6. Data Model at a Glance

- -
-erDiagram - OrchestrationProject { - string id PK - string title - string workspaceRoot - json managerMetadata "role: 'workspace'" - } - - OrchestrationThread { - string id PK - string projectId FK - string title - json managerMetadata "role: 'console' | 'refiner' | 'worker'" - json seededWorkItems "only on console threads" - json managerQueueItems "only on console threads" - } - - OrchestrationProject ||--o{ OrchestrationThread : "contains" - -
- -

- Manager Console threads carry two arrays in the projection - (populated by events): -

-
    -
  • seededWorkItems — the work items the manager is tracking, - each with readiness state, delegation status, and optional - refinementHandoff
  • -
  • managerQueueItems — escalated items from Worker Threads, - each with status (pending, addressed, - dismissed)
  • -
- - -

7. Command → Event Map

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CommandEvent(s) ProducedStatus
manager.bootstrapproject.created + thread.createdno caller
manager.seed-work-itemsthread.seeded-work-items-upsertedwired needs console
manager.refiner-thread.createthread.created (with role: refiner)wired needs console
manager.refinement-handoff.recordthread.seeded-work-items-upserted + optional thread.activity-appendedwired needs console
worker.delegatethread.created (with role: worker)wired needs console
worker.escalatethread.manager-queue-items-upsertedwired needs console
manager.resolve-queue-itemthread.manager-queue-items-upserted + thread.activity-appendedwired needs console
manager.dismiss-queue-itemthread.manager-queue-items-upsertedwired needs console
- - -

8. Unresolved / Vague Parts

- -
-

Gap: thread.create auto-tagging for Manager Console

-

- Decision: When a user creates a thread inside the Manager - Workspace project via the normal UI, the server should auto-tag it with - managerMetadata: { role: "console" } — but only if no other - active console thread exists. -

-

- Open question: Where does the tagging happen? In the - thread.create decider handler, or in a normalization layer - before dispatch? The decider currently has no awareness of the project's - managerMetadata when handling thread.create. - Needs a code change in decider.ts around line 638. -

-
- -
-

Gap: Agent prompt integration point

-

- Decision: Feed buildPromptFromContext() output - into the Manager Console agent's system prompt at turn start. -

-

- Open question: Where in the turn pipeline does this - injection happen? thread.turn.start goes through the decider - (events), then the reactor (actually starts the provider session). The - manager-specific context injection might belong in the reactor, not the - decider — the decider is pure event sourcing, the reactor is where actual - provider IO happens. -

-

- Related open question: Does the thread.turn.start - command need a managerContext field, or does the server - compute context server-side at turn time? Server-side computation avoids - trusting the client to provide accurate context. -

-
- -
-

Gap: Refiner Thread auto-tagging like Worker Threads

-

- When creating a Refiner Thread or Worker Thread, should the - thread.create handler also be aware of manager context? Or - should those threads only be creatable through the - manager-specific commands (manager.refiner-thread.create, - worker.delegate)? The current design uses the latter — the - commands are separate and create threads with the appropriate role metadata. -

-
- -
-

Gap: Preferences learning mechanism

-

- ManagerWorkspaceState.preferences is an array of strings - designed to capture "learned preferences over time" but there is no - mechanism to populate it. How does the manager agent learn a preference? - Does it append to the array via a command? Does the human set preferences - explicitly? This entire feature is undefined. -

-
- -
-

Gap: Manager Queue Discipline

-

- The CONTEXT.md glossary defines Manager Queue Discipline - as "the rule used to choose which Manager Queue Item the Manager Console - handles next." The glossary note says "v1 uses FIFO" but there is no - code implementing any discipline — not even FIFO. Queue items are simply - appended and addressed/dismissed individually. The discipline is a - future concern. -

-
- -
-

Gap: Thread deletion / archival for manager threads

-

- Decision: Manager Console should not be archivable. The - invariant in thread.archive needs a guard that rejects if - managerMetadata.role === "console". -

-

- Open question: Should Worker Threads and Refiner Threads - be deletable? The glossary says a Worker Thread can belong to at most one - Manager Console — but doesn't say whether deletion removes the link. If a - Worker Thread is deleted mid-work, the Manager Console still has the seeded - work item and can re-delegate. -

-
- - -

9. Key Files Reference

- - - - - - - - - - - - -
FileRole
packages/contracts/src/orchestration.tsCommands, events, types, schemas
apps/server/src/orchestration/decider.tsCommand → event decision logic (all manager commands)
apps/server/src/orchestration/commandInvariants.tsRead-model finders and Effect-based guards
apps/server/src/orchestration/ManagerRuntime.tsContext reconstruction, prompt building, file persistence (all dead)
apps/server/src/orchestration/managerSeededWork.tsClassification + materialization logic for work items
apps/server/src/orchestration/Layers/OrchestrationEngine.tsCommand dispatch, event persistence, read-model projection
apps/server/src/serverRuntimeStartup.tsServer startup: auto-bootstrap for CWD, welcome targets (no manager bootstrap yet)
apps/server/src/orchestration/ManagerRuntime.test.tsComprehensive tests for dead ManagerRuntime functions
apps/server/src/orchestration/Layers/OrchestrationEngine.test.tsIntegration tests for manager commands (bootstrap, seed, refine, delegate, escalate)
- - -

10. Implementation Summary (from grilling session decisions)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#WhatDecision
1Bootstrap triggerServer startup auto-bootstrap, separate step before welcome targets
2Project + Thread IDsUUID v5 with canonical name "h-code:manager-workspace"
3Recreating deleted consoleAllow new thread creation in workspace project; auto-tag with role: "console"
4Multiple console threadsCap at 1; invariant enforced in thread.create decider path
5Manager Console archivalDisable archival for console threads
6Workspace file persistenceWrite to userdata/manager-workspace/; read at startup; write after turns
7Agent context injectionRebuild from projection at turn start; snapshot JSON after turn end
8Refiner-thread-per-item agent awarenessInclude existing refiner thread status in prompt context (not just invariant)
- - + + +

7. Command → Event Map

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandEvent(s) ProducedStatus
manager.bootstrapproject.created + thread.createdno caller
manager.seed-work-itemsthread.seeded-work-items-upserted + wired + needs console +
manager.refiner-thread.createthread.created (with role: refiner) + wired + needs console +
manager.refinement-handoff.record + thread.seeded-work-items-upserted + optional + thread.activity-appended + + wired + needs console +
worker.delegatethread.created (with role: worker) + wired + needs console +
worker.escalatethread.manager-queue-items-upserted + wired + needs console +
manager.resolve-queue-item + thread.manager-queue-items-upserted + thread.activity-appended + + wired + needs console +
manager.dismiss-queue-itemthread.manager-queue-items-upserted + wired + needs console +
+ + +

8. Unresolved / Vague Parts

+ +
+

Gap: thread.create auto-tagging for Manager Console

+

+ Decision: When a user creates a thread inside the Manager Workspace project + via the normal UI, the server should auto-tag it with + managerMetadata: { role: "console" } — but only if no other active console + thread exists. +

+

+ Open question: Where does the tagging happen? In the + thread.create decider handler, or in a normalization layer before dispatch? The + decider currently has no awareness of the project's managerMetadata when + handling thread.create. Needs a code change in decider.ts around line 638. +

+
+ +
+

Gap: Agent prompt integration point

+

+ Decision: Feed buildPromptFromContext() output into the + Manager Console agent's system prompt at turn start. +

+

+ Open question: Where in the turn pipeline does this injection happen? + thread.turn.start goes through the decider (events), then the reactor (actually + starts the provider session). The manager-specific context injection might belong in the + reactor, not the decider — the decider is pure event sourcing, the reactor is where actual + provider IO happens. +

+

+ Related open question: Does the thread.turn.start command need + a managerContext field, or does the server compute context server-side at turn + time? Server-side computation avoids trusting the client to provide accurate context. +

+
+ +
+

Gap: Refiner Thread auto-tagging like Worker Threads

+

+ When creating a Refiner Thread or Worker Thread, should the + thread.create handler also be aware of manager context? Or should those threads + only be creatable through the manager-specific commands + (manager.refiner-thread.create, worker.delegate)? The current + design uses the latter — the commands are separate and create threads with the appropriate + role metadata. +

+
+ +
+

Gap: Preferences learning mechanism

+

+ ManagerWorkspaceState.preferences is an array of strings designed to capture + "learned preferences over time" but there is no mechanism to populate it. How does the + manager agent learn a preference? Does it append to the array via a command? Does the human + set preferences explicitly? This entire feature is undefined. +

+
+ +
+

Gap: Manager Queue Discipline

+

+ The CONTEXT.md glossary defines Manager Queue Discipline + as "the rule used to choose which Manager Queue Item the Manager Console handles next." The + glossary note says "v1 uses FIFO" but there is no code implementing any discipline — not + even FIFO. Queue items are simply appended and addressed/dismissed individually. The + discipline is a future concern. +

+
+ +
+

Gap: Thread deletion / archival for manager threads

+

+ Decision: Manager Console should not be archivable. The invariant in + thread.archive needs a guard that rejects if + managerMetadata.role === "console". +

+

+ Open question: Should Worker Threads and Refiner Threads be deletable? The + glossary says a Worker Thread can belong to at most one Manager Console — but doesn't say + whether deletion removes the link. If a Worker Thread is deleted mid-work, the Manager + Console still has the seeded work item and can re-delegate. +

+
+ + +

9. Key Files Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileRole
packages/contracts/src/orchestration.tsCommands, events, types, schemas
apps/server/src/orchestration/decider.tsCommand → event decision logic (all manager commands)
apps/server/src/orchestration/commandInvariants.tsRead-model finders and Effect-based guards
apps/server/src/orchestration/ManagerRuntime.tsContext reconstruction, prompt building, file persistence (all dead)
apps/server/src/orchestration/managerSeededWork.tsClassification + materialization logic for work items
apps/server/src/orchestration/Layers/OrchestrationEngine.tsCommand dispatch, event persistence, read-model projection
apps/server/src/serverRuntimeStartup.tsServer startup: auto-bootstrap for CWD, welcome targets (no manager bootstrap yet)
apps/server/src/orchestration/ManagerRuntime.test.tsComprehensive tests for dead ManagerRuntime functions
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts + Integration tests for manager commands (bootstrap, seed, refine, delegate, escalate) +
+ + +

10. Implementation Summary (from grilling session decisions)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#WhatDecision
1Bootstrap triggerServer startup auto-bootstrap, separate step before welcome targets
2Project + Thread IDsUUID v5 with canonical name "h-code:manager-workspace"
3Recreating deleted console + Allow new thread creation in workspace project; auto-tag with role: "console" +
4Multiple console threadsCap at 1; invariant enforced in thread.create decider path
5Manager Console archivalDisable archival for console threads
6Workspace file persistence + Write to userdata/manager-workspace/; read at startup; write after turns +
7Agent context injectionRebuild from projection at turn start; snapshot JSON after turn end
8Refiner-thread-per-item agent awarenessInclude existing refiner thread status in prompt context (not just invariant)
+ diff --git a/package.json b/package.json index 07fc3595f75b..d60db078b4b1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, + "dependencies": { + "@t3tools/monorepo": "." + }, "devDependencies": { "@effect/language-service": "catalog:", "@oxlint/plugins": "^1.63.0", @@ -96,8 +99,5 @@ "trustedDependencies": [ "node-pty", "electron" - ], - "dependencies": { - "@t3tools/monorepo": "." - } + ] } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a4fb63f1cfcf..6a7eded70617 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -422,9 +422,20 @@ export const TodosLoadResult = Schema.Struct({ }); export type TodosLoadResult = typeof TodosLoadResult.Type; +export class TodosLoadError extends Schema.TaggedErrorClass()("TodosLoadError", { + kind: Schema.Literals(["io-failure", "parse-failure"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Todo load error (${this.kind}): ${this.detail}`; + } +} + export const WsTodosLoadRpc = Rpc.make(WS_METHODS.todosLoad, { payload: Schema.Struct({}), success: TodosLoadResult, + error: TodosLoadError, }); export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {