From ed3dc5529a208c7f1c0142444a3977713f44c368 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Sun, 12 Jul 2026 23:51:48 +0000 Subject: [PATCH] sync: update from private repo (9a86f49b) --- CHANGELOG.md | 19 + logs/bash-history.jsonl | 4 + .../server-route-order.test.ts.snap | 4 + src/bridge/a2a-subsystem.ts | 13 +- src/bridge/auth-subsystem.ts | 3 + .../chat/chat-connector-bindings-api.test.ts | 270 +++++++++ .../chat/chat-connector-bindings-api.ts | 197 +++++++ .../chat/chat-connector-service.test.ts | 290 ++++++++++ src/bridge/chat/chat-connector-service.ts | 266 +++++++++ src/bridge/chat/chat-connector-types.ts | 34 ++ src/bridge/chat/chat-subsystem.test.ts | 220 ++++++++ src/bridge/chat/chat-subsystem.ts | 139 +++++ src/bridge/chat/slack-adapter.test.ts | 193 +++++++ src/bridge/chat/slack-adapter.ts | 155 ++++++ src/bridge/llm-workers-api.test.ts | 205 +++++++ src/bridge/llm-workers-api.ts | 108 ++++ src/bridge/local-tasks-api.test.ts | 12 + src/bridge/local-tasks-api.ts | 11 + src/bridge/local-tasks-comments-api.ts | 7 + src/bridge/local-tasks-control-api.ts | 5 + ...local-tasks-crud-api.llm-selection.test.ts | 384 +++++++++++++ src/bridge/local-tasks-crud-api.ts | 85 ++- .../local-tasks-movement-history-api.test.ts | 47 ++ .../local-tasks-movement-history-api.ts | 108 ++++ src/bridge/server-subsystems.test.ts | 17 +- src/bridge/server.ts | 20 +- src/bridge/setup-api.ts | 11 +- src/bridge/space-api.calendar.test.ts | 27 + src/bridge/space-calendar-api.ts | 27 +- src/bridge/ssh-subsystem.ts | 16 +- src/config.ts | 22 + src/db/migrate.chat-connector-events.test.ts | 30 + src/db/migrate.llm-selection.test.ts | 221 ++++++++ src/db/migrate.ts | 70 +++ src/db/repositories/calendar.ts | 46 +- src/db/repositories/chat-connectors.test.ts | 140 +++++ src/db/repositories/chat-connectors.ts | 206 +++++++ src/db/repositories/jobs.claim-pin.test.ts | 227 ++++++++ .../repositories/jobs.llm-selection.test.ts | 55 ++ src/db/repositories/jobs.ts | 99 +++- src/db/repositories/local-tasks.ts | 32 +- src/db/repositories/reminders.ts | 46 ++ src/db/repositories/schema.ts | 17 + src/db/repository.calendar.test.ts | 10 + src/db/repository.llm-selection.test.ts | 61 +++ src/db/repository.reminders.test.ts | 92 ++++ src/db/repository.ts | 66 ++- src/db/schema.sql | 66 ++- src/engine/tools/calendar.test.ts | 30 + src/engine/tools/calendar.ts | 76 +++ src/llm/effort-injection.test.ts | 85 +++ src/llm/effort-injection.ts | 58 ++ src/worker.execute-job-gates.test.ts | 45 ++ src/worker.extra-body.test.ts | 49 ++ src/worker.inherit-job-context.test.ts | 2 + src/worker.inherit-llm-selection.test.ts | 72 +++ src/worker.llm-selection-fixes.test.ts | 166 ++++++ src/worker.ts | 58 +- ui/src/App.tsx | 58 +- ui/src/api.ts | 1 + ui/src/api/calendar.ts | 24 +- ui/src/api/chat-connectors.ts | 117 ++++ ui/src/api/tasks.ts | 57 +- ui/src/api/workers.ts | 26 + ui/src/components/chat/ChatAttachmentList.tsx | 18 + ui/src/components/chat/ChatComposer.tsx | 172 ++++++ ui/src/components/chat/ChatComposerPanel.tsx | 226 ++++++++ ui/src/components/chat/ChatMessage.tsx | 8 + .../chat/ChatMessageFeed.scroll.test.tsx | 120 ++++ ui/src/components/chat/ChatMessageFeed.tsx | 198 +++++++ .../components/chat/ChatMessageFeedPanel.tsx | 92 ++++ .../components/chat/ChatPane.drafts.test.tsx | 54 +- .../chat/ChatPane.llm-selection.test.tsx | 296 ++++++++++ ui/src/components/chat/ChatPane.tsx | 515 +----------------- ui/src/components/chat/ChatTimeline.tsx | 215 ++++++++ .../components/chat/LlmSelectionControl.tsx | 272 +++++++++ ui/src/components/chat/MovementGroup.tsx | 29 +- ui/src/components/chat/MovementMap.test.tsx | 151 +++++ ui/src/components/chat/MovementMap.tsx | 304 +++++++++++ .../chat/ScrollToLatestButton.test.tsx | 40 ++ .../components/chat/ScrollToLatestButton.tsx | 35 ++ .../chat/WaterContextGauge.test.tsx | 37 ++ ui/src/components/chat/WaterContextGauge.tsx | 53 ++ .../chat/modelSelectorPosition.test.ts | 76 +++ .../components/chat/modelSelectorPosition.ts | 46 ++ .../components/chat/movementHistory.test.ts | 48 ++ ui/src/components/chat/movementHistory.ts | 151 +++++ .../create/CreateTaskDialog.dismiss.test.tsx | 12 + .../CreateTaskDialog.llm-selection.test.tsx | 158 ++++++ ui/src/components/create/CreateTaskDialog.tsx | 398 +++----------- .../CreateTaskDialogAdvancedSection.tsx | 267 +++++++++ .../create/CreateTaskDialogCoreSection.tsx | 172 ++++++ ui/src/components/create/PromptCoachPanel.tsx | 8 +- .../notifications/ToastHost.test.tsx | 36 ++ ui/src/components/notifications/ToastHost.tsx | 44 ++ ui/src/components/notifications/ToastPet.tsx | 21 + ui/src/components/pets/ChatPetOverlay.tsx | 91 +++- ui/src/components/pets/PetSprite.test.tsx | 129 +++++ ui/src/components/pets/PetSprite.tsx | 148 +++-- ui/src/components/pets/ToolSpark.test.tsx | 161 ++++++ ui/src/components/pets/ToolSpark.tsx | 126 ++++- .../schedules/ScheduleEditorSections.test.tsx | 39 ++ .../schedules/ScheduleEditorSections.tsx | 94 ++++ .../schedules/scheduleEditorTypes.ts | 23 + .../settings/ChatConnectorsForm.test.tsx | 89 +++ .../settings/ChatConnectorsForm.tsx | 248 +++++++++ .../ConfigForm.extra-body-validity.test.tsx | 37 +- ui/src/components/settings/ConfigForm.tsx | 87 ++- ui/src/components/settings/LlmWorkersForm.tsx | 15 +- .../settings/SettingsSidebar.test.tsx | 21 + .../components/settings/SettingsSidebar.tsx | 49 +- ui/src/components/settings/SshConfigForm.tsx | 5 +- ui/src/components/settings/ToolsWebForm.tsx | 13 +- ui/src/components/settings/formUtils.tsx | 8 +- .../settings/settingsSearchIndex.test.ts | 11 +- .../settings/settingsSearchIndex.ts | 20 + ui/src/components/settings/types.ts | 9 + ui/src/components/spaces/SpaceCalendar.tsx | 17 +- .../spaces/SpaceDetail.initialTab.test.tsx | 43 ++ ui/src/components/spaces/SpaceDetail.tsx | 36 +- ui/src/components/spaces/SpaceRail.test.tsx | 138 ++++- ui/src/components/spaces/SpaceRail.tsx | 223 ++++++-- ui/src/components/spaces/SpacesPage.tsx | 8 +- ui/src/content/help/00-changelog.md | 95 ++++ ui/src/content/help/02-tasks.md | 14 +- ui/src/content/help/03-running.md | 18 +- ui/src/content/help/07-notifications.md | 4 + ui/src/content/help/09-userfolder.md | 2 + ui/src/content/help/16-tools.md | 2 +- ui/src/content/help/17-settings.md | 10 +- ui/src/content/help/21-workspaces.md | 4 +- ui/src/content/help/24-chat-connectors.md | 83 +++ ui/src/hooks/useJobStream.test.ts | 42 +- ui/src/hooks/useJobStream.ts | 46 +- ui/src/hooks/useSetupState.ts | 6 + ui/src/hooks/useStableNodePromotion.test.ts | 71 +++ ui/src/hooks/useStableNodePromotion.ts | 55 ++ ui/src/hooks/useTaskDetail.ts | 10 + ui/src/hooks/useTaskNotifications.test.tsx | 43 ++ ui/src/hooks/useTaskNotifications.ts | 8 + ui/src/hooks/useToast.test.tsx | 11 + ui/src/hooks/useToast.ts | 59 +- ui/src/i18n/locales/en/chat.json | 28 + ui/src/i18n/locales/en/chatConnectors.json | 51 ++ ui/src/i18n/locales/en/create.json | 6 + ui/src/i18n/locales/en/detail.json | 1 + ui/src/i18n/locales/en/settings.json | 10 +- ui/src/i18n/locales/ja/chat.json | 28 + ui/src/i18n/locales/ja/chatConnectors.json | 51 ++ ui/src/i18n/locales/ja/create.json | 6 + ui/src/i18n/locales/ja/detail.json | 1 + ui/src/i18n/locales/ja/settings.json | 10 +- ui/src/index.css | 345 ++++++++++-- ui/src/lib/urlState.ts | 1 + ui/src/pages/SchedulesPage.tsx | 421 +------------- ui/src/pages/SettingsPage.test.tsx | 182 +++++++ ui/src/pages/SettingsPage.tsx | 74 ++- vitest.config.test.ts | 8 + vitest.config.ts | 3 + 159 files changed, 11696 insertions(+), 1600 deletions(-) create mode 100644 logs/bash-history.jsonl create mode 100644 src/bridge/chat/chat-connector-bindings-api.test.ts create mode 100644 src/bridge/chat/chat-connector-bindings-api.ts create mode 100644 src/bridge/chat/chat-connector-service.test.ts create mode 100644 src/bridge/chat/chat-connector-service.ts create mode 100644 src/bridge/chat/chat-connector-types.ts create mode 100644 src/bridge/chat/chat-subsystem.test.ts create mode 100644 src/bridge/chat/chat-subsystem.ts create mode 100644 src/bridge/chat/slack-adapter.test.ts create mode 100644 src/bridge/chat/slack-adapter.ts create mode 100644 src/bridge/llm-workers-api.test.ts create mode 100644 src/bridge/llm-workers-api.ts create mode 100644 src/bridge/local-tasks-crud-api.llm-selection.test.ts create mode 100644 src/bridge/local-tasks-movement-history-api.test.ts create mode 100644 src/bridge/local-tasks-movement-history-api.ts create mode 100644 src/db/migrate.chat-connector-events.test.ts create mode 100644 src/db/migrate.llm-selection.test.ts create mode 100644 src/db/repositories/chat-connectors.test.ts create mode 100644 src/db/repositories/chat-connectors.ts create mode 100644 src/db/repositories/jobs.claim-pin.test.ts create mode 100644 src/db/repositories/jobs.llm-selection.test.ts create mode 100644 src/db/repositories/reminders.ts create mode 100644 src/db/repository.llm-selection.test.ts create mode 100644 src/db/repository.reminders.test.ts create mode 100644 src/llm/effort-injection.test.ts create mode 100644 src/llm/effort-injection.ts create mode 100644 src/worker.inherit-llm-selection.test.ts create mode 100644 src/worker.llm-selection-fixes.test.ts create mode 100644 ui/src/api/chat-connectors.ts create mode 100644 ui/src/components/chat/ChatAttachmentList.tsx create mode 100644 ui/src/components/chat/ChatComposer.tsx create mode 100644 ui/src/components/chat/ChatComposerPanel.tsx create mode 100644 ui/src/components/chat/ChatMessageFeed.scroll.test.tsx create mode 100644 ui/src/components/chat/ChatMessageFeed.tsx create mode 100644 ui/src/components/chat/ChatMessageFeedPanel.tsx create mode 100644 ui/src/components/chat/ChatPane.llm-selection.test.tsx create mode 100644 ui/src/components/chat/ChatTimeline.tsx create mode 100644 ui/src/components/chat/LlmSelectionControl.tsx create mode 100644 ui/src/components/chat/MovementMap.test.tsx create mode 100644 ui/src/components/chat/MovementMap.tsx create mode 100644 ui/src/components/chat/ScrollToLatestButton.test.tsx create mode 100644 ui/src/components/chat/ScrollToLatestButton.tsx create mode 100644 ui/src/components/chat/WaterContextGauge.test.tsx create mode 100644 ui/src/components/chat/WaterContextGauge.tsx create mode 100644 ui/src/components/chat/modelSelectorPosition.test.ts create mode 100644 ui/src/components/chat/modelSelectorPosition.ts create mode 100644 ui/src/components/chat/movementHistory.test.ts create mode 100644 ui/src/components/chat/movementHistory.ts create mode 100644 ui/src/components/create/CreateTaskDialog.llm-selection.test.tsx create mode 100644 ui/src/components/create/CreateTaskDialogAdvancedSection.tsx create mode 100644 ui/src/components/create/CreateTaskDialogCoreSection.tsx create mode 100644 ui/src/components/notifications/ToastHost.test.tsx create mode 100644 ui/src/components/notifications/ToastHost.tsx create mode 100644 ui/src/components/notifications/ToastPet.tsx create mode 100644 ui/src/components/pets/PetSprite.test.tsx create mode 100644 ui/src/components/pets/ToolSpark.test.tsx create mode 100644 ui/src/components/schedules/ScheduleEditorSections.test.tsx create mode 100644 ui/src/components/schedules/ScheduleEditorSections.tsx create mode 100644 ui/src/components/schedules/scheduleEditorTypes.ts create mode 100644 ui/src/components/settings/ChatConnectorsForm.test.tsx create mode 100644 ui/src/components/settings/ChatConnectorsForm.tsx create mode 100644 ui/src/components/spaces/SpaceDetail.initialTab.test.tsx create mode 100644 ui/src/content/help/24-chat-connectors.md create mode 100644 ui/src/hooks/useStableNodePromotion.test.ts create mode 100644 ui/src/hooks/useStableNodePromotion.ts create mode 100644 ui/src/hooks/useTaskNotifications.test.tsx create mode 100644 ui/src/i18n/locales/en/chatConnectors.json create mode 100644 ui/src/i18n/locales/ja/chatConnectors.json create mode 100644 ui/src/pages/SettingsPage.test.tsx create mode 100644 vitest.config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3e38a..5c6bda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follow semantic versioning. ### Added +- A compact Movement Map in chat shows the current step, completed work, + retries, and user messages without taking over the conversation. Nodes can + be used for navigation, while concise tooltips explain each step + (2026-07-12). +- Slack channel connections can be managed from Settings, including + channel-to-workspace bindings and reliable mention ingestion and replies. + Reminders and in-app notifications make scheduled and background work easier + to follow (2026-07-12). +- Chat now provides per-task model and reasoning-effort selection, plus prompt + coaching before sending follow-up instructions (2026-07-11). - Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write date-stamped outputs (e.g. `output/report-2026-07-09.md`, `output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace @@ -24,6 +34,15 @@ follow semantic versioning. ### Fixed +- Model selection menus no longer get clipped by the chat viewport, and the + “jump to latest” control stays anchored above the composer + (2026-07-12). +- Pet and star animations now retain stable identities and smoother timing, + reducing flicker and visible jumps around worker activity + (2026-07-12). +- Repeated Slack mention events no longer create duplicate tasks or replies, + and connector status and failure feedback are clearer in Settings + (2026-07-12). - Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns) regardless of actual activity; tool events now carry their own run attribution (2026-07-09). diff --git a/logs/bash-history.jsonl b/logs/bash-history.jsonl new file mode 100644 index 0000000..936154c --- /dev/null +++ b/logs/bash-history.jsonl @@ -0,0 +1,4 @@ +{"timestamp":"2026-07-12T23:49:58.285Z","command":"pip install pypdf","isError":true,"durationMs":0,"blocked":true} +{"timestamp":"2026-07-12T23:49:58.286Z","command":"npm install left-pad","isError":true,"durationMs":1,"blocked":true} +{"timestamp":"2026-07-12T23:49:58.287Z","command":"echo hello","isError":false,"durationMs":1,"outputBytes":6} +{"timestamp":"2026-07-12T23:49:58.310Z","command":"node -e \"process.stdout.write(String(process.env.MCP_ENCRYPTION_KEY))\"","isError":false,"durationMs":22,"outputBytes":9} diff --git a/src/bridge/__snapshots__/server-route-order.test.ts.snap b/src/bridge/__snapshots__/server-route-order.test.ts.snap index edf4be8..4b5aace 100644 --- a/src/bridge/__snapshots__/server-route-order.test.ts.snap +++ b/src/bridge/__snapshots__/server-route-order.test.ts.snap @@ -22,6 +22,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf "USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)", "USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)", "USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)", "USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)", "USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)", "USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)", @@ -79,6 +80,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf "ROUTE post /api/local/tasks/:taskId/regenerate-title", "ROUTE post /api/local/tasks/evaluate-prompt", "ROUTE get /api/local/tasks/:taskId/stream", + "ROUTE get /api/local/tasks/:taskId/movement-history", "ROUTE get /api/local/tasks/:taskId/files", "ROUTE get /api/local/tasks/:taskId/files/content", "ROUTE get /api/local/tasks/:taskId/files/provenance", @@ -92,6 +94,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf "USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)", "USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)", "USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)", "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files", "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*", "ROUTE get /api/jobs/:jobId", @@ -198,6 +201,7 @@ exports[`createCoreServer route registration order > pins the no-auth minimal bo "ROUTE post /api/local/tasks/:taskId/regenerate-title", "ROUTE post /api/local/tasks/evaluate-prompt", "ROUTE get /api/local/tasks/:taskId/stream", + "ROUTE get /api/local/tasks/:taskId/movement-history", "ROUTE get /api/local/tasks/:taskId/files", "ROUTE get /api/local/tasks/:taskId/files/content", "ROUTE get /api/local/tasks/:taskId/files/provenance", diff --git a/src/bridge/a2a-subsystem.ts b/src/bridge/a2a-subsystem.ts index 4113bc3..1000b20 100644 --- a/src/bridge/a2a-subsystem.ts +++ b/src/bridge/a2a-subsystem.ts @@ -20,11 +20,19 @@ import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a * `a2a.enabled: true` in config.yaml. Registration order relative to the * other /api/local mounts is preserved by the call site in server.ts. */ +export interface A2aSubsystemResult { + /** The A2A governance limiter, when a2a.enabled. Shared with the chat + * connector subsystem (see chat-subsystem.ts) so rate/concurrency/ + * skill-budget counters are keyed per-grant regardless of entry point. */ + limiter?: A2aLimiter; +} + export function setupA2aSubsystem( app: express.Express, deps: { repo: Repository; authActive: boolean }, -): void { +): A2aSubsystemResult { const { repo, authActive } = deps; + let limiter: A2aLimiter | undefined; // --- A2A OAuth2 Authorization Server --- const a2aCfg = (loadConfig() as any).a2a ?? {}; @@ -54,6 +62,7 @@ export function setupA2aSubsystem( // JSON-RPC と拡張カード(REST)の両経路で 1 grant のレート予算を共有するため、 // limiter は 1 インスタンスをここで生成して両者に渡す。 const a2aLimiter = new A2aLimiter(resolveA2aLimits(a2aCfg.limits)); + limiter = a2aLimiter; app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter })); // JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由) // /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。 @@ -73,6 +82,8 @@ export function setupA2aSubsystem( logger.info('[a2a-oidc] a2a authorization server enabled'); } catch (err) { logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`); + limiter = undefined; } } + return { limiter }; } diff --git a/src/bridge/auth-subsystem.ts b/src/bridge/auth-subsystem.ts index a37cf9b..569b7b1 100644 --- a/src/bridge/auth-subsystem.ts +++ b/src/bridge/auth-subsystem.ts @@ -161,6 +161,9 @@ export function mountAuthPipeline( // is enforced inside pieces-api.ts handlers. app.use('/api/pieces', requireAuth); app.use('/api/usage', requireAuth); + // LLM ワーカー選択 API(タスクへの pin 用): 秘密値は含まないが、未認証の + // 列挙は不要なので他の /api/* と同様 requireAuth で揃える。 + app.use('/api/llm/workers', requireAuth); // 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは // Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。 app.use('/api/calendar', requireAuth); diff --git a/src/bridge/chat/chat-connector-bindings-api.test.ts b/src/bridge/chat/chat-connector-bindings-api.test.ts new file mode 100644 index 0000000..18dc3fa --- /dev/null +++ b/src/bridge/chat/chat-connector-bindings-api.test.ts @@ -0,0 +1,270 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { randomBytes, randomUUID } from 'crypto'; +import { Repository } from '../../db/repository.js'; +import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js'; + +/** + * Minimal admin gate mirroring requireAdmin's observable contract + * (401 unauthenticated / 403 non-admin / next() for admin), following the + * same pattern as mcp-api.test.ts and dashboard-api.auth-guard.test.ts — + * real requireAdmin depends on passport's req.isAuthenticated() which needs + * a full session harness this router-level test doesn't need. + */ +function adminGate(user: { id: string; role: 'admin' | 'user' } | null): express.RequestHandler { + return (req, res, next) => { + if (!user) { res.status(401).json({ error: 'Unauthorized' }); return; } + (req as any).user = user; + if (user.role !== 'admin') { res.status(403).json({ error: 'Forbidden' }); return; } + next(); + }; +} + +function appWith(repo: Repository, user: { id: string; role: 'admin' | 'user' } | null = { id: 'admin1', role: 'admin' }) { + const app = express(); + app.use(express.json()); + app.use('/api/admin/chat/bindings', adminGate(user), createChatConnectorBindingsAdminRouter(repo, true)); + return app; +} + +describe('chat connector bindings admin API', () => { + let repo: Repository; + let ownerId = ''; + let spaceId = ''; + let clientId = ''; + + beforeEach(async () => { + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + repo = new Repository(':memory:'); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + ownerId = owner.id; + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' }); + spaceId = space.id; + clientId = `a2a_${randomUUID()}`; + repo.createA2aClient({ + clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'], + secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null, + keyFingerprint: null, status: 'active', createdBy: null, + }); + }); + + afterEach(() => { repo.close(); }); + + function seedDelegation(opts: { grantedSpaceIds?: string[]; revokedAt?: string | null; grantId?: string | null } = {}): string { + const id = randomUUID(); + repo.createA2aDelegation({ + id, userId: ownerId, clientId, + grantId: opts.grantId === undefined ? randomUUID() : opts.grantId, + grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId], + grantedSkills: ['chat'], + audience: null, expiresAt: null, revokedAt: opts.revokedAt ?? null, + }); + return id; + } + + const validBody = (delegationId: string, overrides: Record = {}) => ({ + platform: 'slack', + externalWorkspaceId: 'T123', + externalChannelId: 'C456', + a2aDelegationId: delegationId, + botCredentials: { signingSecret: 'sec', botToken: 'xoxb-test' }, + ...overrides, + }); + + // ── admin gate ───────────────────────────────────────────────────────── + it('rejects unauthenticated requests with 401', async () => { + const app = appWith(repo, null); + const res = await request(app).get('/api/admin/chat/bindings'); + expect(res.status).toBe(401); + }); + + it('rejects non-admin authenticated requests with 403', async () => { + const app = appWith(repo, { id: 'u1', role: 'user' }); + const res = await request(app).get('/api/admin/chat/bindings'); + expect(res.status).toBe(403); + }); + + it('allows admin requests through to the router', async () => { + const app = appWith(repo, { id: 'admin1', role: 'admin' }); + const res = await request(app).get('/api/admin/chat/bindings'); + expect(res.status).toBe(200); + expect(res.body.bindings).toEqual([]); + }); + + // ── create ───────────────────────────────────────────────────────────── + it('creates a binding for a delegation granting exactly one space', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + expect(res.status).toBe(201); + expect(res.body.spaceId).toBe(spaceId); + expect(res.body.a2aDelegationId).toBe(delegationId); + expect(res.body.status).toBe('active'); + // Credentials must never appear in the response. + expect(res.body.botCredentialsEnc).toBeUndefined(); + expect(res.body.botCredentials).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain('xoxb-test'); + }); + + it('rejects a delegation granting zero spaces (1 delegation = 1 space enforcement)', async () => { + const app = appWith(repo); + const delegationId = seedDelegation({ grantedSpaceIds: [] }); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/exactly one space/); + }); + + it('rejects a delegation granting more than one space (1 delegation = 1 space enforcement)', async () => { + const app = appWith(repo); + const space2 = await repo.createSpace({ kind: 'case', title: 'Case 2', ownerId, visibility: 'private' }); + const delegationId = seedDelegation({ grantedSpaceIds: [spaceId, space2.id] }); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/exactly one space/); + }); + + it('derives spaceId from the delegation, ignoring any client-supplied spaceId', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings') + .send(validBody(delegationId, { spaceId: 'attacker-supplied-space' })); + expect(res.status).toBe(201); + expect(res.body.spaceId).toBe(spaceId); + }); + + it('rejects a revoked delegation', async () => { + const app = appWith(repo); + const delegationId = seedDelegation({ revokedAt: new Date().toISOString() }); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + expect(res.status).toBe(400); + }); + + it('rejects a delegation with no grant_id', async () => { + const app = appWith(repo); + const delegationId = seedDelegation({ grantId: null }); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + expect(res.status).toBe(400); + }); + + it('rejects an unknown delegation id', async () => { + const app = appWith(repo); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody('nonexistent')); + expect(res.status).toBe(400); + }); + + it('rejects an unknown platform', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId, { platform: 'discord' })); + expect(res.status).toBe(400); + }); + + it('rejects a request missing botCredentials', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings') + .send(validBody(delegationId, { botCredentials: undefined })); + expect(res.status).toBe(400); + }); + + it('rejects a duplicate (platform, team, channel) binding with 409', async () => { + const app = appWith(repo); + const delegationId1 = seedDelegation(); + await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId1)); + const delegationId2 = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId2)); + expect(res.status).toBe(409); + }); + + it('sets created_by to the authenticated admin, not any client-supplied value', async () => { + const app = appWith(repo, { id: 'admin-xyz', role: 'admin' }); + const delegationId = seedDelegation(); + const res = await request(app).post('/api/admin/chat/bindings') + .send(validBody(delegationId, { createdBy: 'attacker' })); + expect(res.status).toBe(201); + expect(res.body.createdBy).toBe('admin-xyz'); + }); + + // ── read ─────────────────────────────────────────────────────────────── + it('GET /:id returns 404 for an unknown id', async () => { + const app = appWith(repo); + const res = await request(app).get('/api/admin/chat/bindings/nonexistent'); + expect(res.status).toBe(404); + }); + + it('GET list never includes bot_credentials_enc', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + const res = await request(app).get('/api/admin/chat/bindings'); + expect(res.status).toBe(200); + expect(res.body.bindings).toHaveLength(1); + expect(JSON.stringify(res.body)).not.toContain('xoxb-test'); + }); + + // ── update ───────────────────────────────────────────────────────────── + it('PATCH disables and re-enables a binding', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + const id = created.body.id; + + const disabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'disabled' }); + expect(disabled.status).toBe(200); + expect(disabled.body.status).toBe('disabled'); + + const enabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'active' }); + expect(enabled.status).toBe(200); + expect(enabled.body.status).toBe('active'); + }); + + it('PATCH re-encrypts credentials on re-entry and never echoes them back', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + const id = created.body.id; + + const res = await request(app).patch(`/api/admin/chat/bindings/${id}`) + .send({ botCredentials: { signingSecret: 'new-sec', botToken: 'xoxb-new-token' } }); + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain('xoxb-new-token'); + // Verify the stored ciphertext actually changed. + const stored = repo.getChatConnectorBindingById(id)!; + const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex'); + const { decrypt } = await import('../../mcp/crypto.js'); + expect(JSON.parse(decrypt(stored.botCredentialsEnc, key)).botToken).toBe('xoxb-new-token'); + }); + + it('PATCH with no updatable fields returns 400', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + const res = await request(app).patch(`/api/admin/chat/bindings/${created.body.id}`).send({}); + expect(res.status).toBe(400); + }); + + it('PATCH on an unknown id returns 404', async () => { + const app = appWith(repo); + const res = await request(app).patch('/api/admin/chat/bindings/nonexistent').send({ status: 'disabled' }); + expect(res.status).toBe(404); + }); + + // ── delete ───────────────────────────────────────────────────────────── + it('DELETE removes a binding', async () => { + const app = appWith(repo); + const delegationId = seedDelegation(); + const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId)); + const id = created.body.id; + const res = await request(app).delete(`/api/admin/chat/bindings/${id}`); + expect(res.status).toBe(200); + expect(repo.getChatConnectorBindingById(id)).toBeNull(); + }); + + it('DELETE on an unknown id returns 404', async () => { + const app = appWith(repo); + const res = await request(app).delete('/api/admin/chat/bindings/nonexistent'); + expect(res.status).toBe(404); + }); +}); diff --git a/src/bridge/chat/chat-connector-bindings-api.ts b/src/bridge/chat/chat-connector-bindings-api.ts new file mode 100644 index 0000000..6a0b7a7 --- /dev/null +++ b/src/bridge/chat/chat-connector-bindings-api.ts @@ -0,0 +1,197 @@ +/** + * chat-connector-bindings-api: admin CRUD for chat_connector_bindings + * (issue #801, PR1 — Slack MVP). No UI wizard yet; this is the manual-seed + * surface an admin (or a script) calls directly. requireAdmin is applied by + * the mount call site (see chat-subsystem.ts), mirroring a2a-clients-admin-api.ts. + * + * SECURITY: + * - The response DTO (toPublicBinding) NEVER includes bot_credentials_enc + * or a decrypted credential. Once saved, credentials are write-only — + * re-entering them means a PATCH with a fresh { botCredentials } object. + * - 1 delegation = 1 space is enforced at create time: the referenced + * a2a_delegation must have exactly one granted space id, and that id + * (not any client-supplied value) becomes the binding's space_id. This + * keeps the chat channel's effective space deterministic (skill-router.ts + * always resolves `effectiveSpaceIds[0]`). + * - "acting user" for job execution is always the delegation's own user_id + * (the person who consented via the A2A OAuth flow) — never client input. + * `created_by` on the binding row is the authenticated admin, also never + * client input. + */ +import { Router, type Request, type Response } from 'express'; +import type { Repository, ChatConnectorBindingRecord } from '../../db/repository.js'; +import { isDelegationLive } from '../a2a/delegation.js'; +import { encrypt, loadKeyFromEnv } from '../../mcp/crypto.js'; +import { logger } from '../../logger.js'; + +const KNOWN_PLATFORMS = ['slack'] as const; +const MAX_ID_LENGTH = 256; + +function toPublicBinding(b: ChatConnectorBindingRecord) { + return { + id: b.id, + platform: b.platform, + externalWorkspaceId: b.externalWorkspaceId, + externalChannelId: b.externalChannelId, + spaceId: b.spaceId, + a2aClientId: b.a2aClientId, + a2aDelegationId: b.a2aDelegationId, + status: b.status, + createdBy: b.createdBy, + createdAt: b.createdAt, + updatedAt: b.updatedAt, + }; +} + +function isNonEmptyString(v: unknown, max = MAX_ID_LENGTH): v is string { + return typeof v === 'string' && v.trim().length > 0 && v.length <= max; +} + +export function createChatConnectorBindingsAdminRouter(repo: Repository, _authActive: boolean): Router { + const router = Router(); + + router.get('/', (_req: Request, res: Response) => { + res.json({ bindings: repo.listChatConnectorBindings().map(toPublicBinding) }); + }); + + router.get('/:id', (req: Request, res: Response) => { + const binding = repo.getChatConnectorBindingById(req.params.id); + if (!binding) { res.status(404).json({ error: 'not found' }); return; } + res.json(toPublicBinding(binding)); + }); + + router.post('/', (req: Request, res: Response) => { + const body = req.body ?? {}; + const rawPlatform = body.platform; + if (typeof rawPlatform !== 'string' || !(KNOWN_PLATFORMS as readonly string[]).includes(rawPlatform)) { + res.status(400).json({ error: `platform must be one of: ${KNOWN_PLATFORMS.join(', ')}` }); + return; + } + const platform = rawPlatform as (typeof KNOWN_PLATFORMS)[number]; + if (!isNonEmptyString(body.externalWorkspaceId) || !isNonEmptyString(body.externalChannelId)) { + res.status(400).json({ error: 'externalWorkspaceId and externalChannelId are required' }); + return; + } + if (!isNonEmptyString(body.a2aDelegationId)) { + res.status(400).json({ error: 'a2aDelegationId is required' }); + return; + } + const creds = body.botCredentials; + if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) { + res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' }); + return; + } + + const delegation = repo.getA2aDelegationById(body.a2aDelegationId); + if (!delegation || !delegation.grantId) { + res.status(400).json({ error: 'a2aDelegationId does not reference a live delegation with an active grant' }); + return; + } + if (!isDelegationLive(delegation, new Date().toISOString())) { + res.status(400).json({ error: 'delegation is expired or revoked' }); + return; + } + // 1 delegation = 1 space, enforced (not client-suppliable). + if (delegation.grantedSpaceIds.length !== 1) { + res.status(400).json({ error: 'delegation must grant exactly one space to back a chat binding' }); + return; + } + const spaceId = delegation.grantedSpaceIds[0]; + + let botCredentialsEnc: Buffer; + try { + botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv()); + } catch (err) { + logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`); + res.status(503).json({ error: 'credential encryption is not configured on this server' }); + return; + } + + const admin = (req.user as Express.User | undefined)?.id ?? null; + try { + const created = repo.createChatConnectorBinding({ + platform, + externalWorkspaceId: body.externalWorkspaceId, + externalChannelId: body.externalChannelId, + spaceId, + a2aClientId: delegation.clientId, + a2aDelegationId: delegation.id, + a2aGrantId: delegation.grantId, + botCredentialsEnc, + createdBy: admin, + }); + logger.info(`[chat-connector-bindings-api] created binding=${created.id} platform=${platform} space=${spaceId} by=${admin}`); + res.status(201).json(toPublicBinding(created)); + } catch (err) { + // UNIQUE constraint on (platform, external_workspace_id, external_channel_id) + const msg = (err as Error).message ?? ''; + if (/UNIQUE constraint failed/.test(msg)) { + res.status(409).json({ error: 'a binding already exists for this platform/workspace/channel' }); + return; + } + logger.error(`[chat-connector-bindings-api] POST failed: ${msg}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // PATCH — update status (enable/disable) and/or re-enter credentials. + router.patch('/:id', (req: Request, res: Response) => { + const binding = repo.getChatConnectorBindingById(req.params.id); + if (!binding) { res.status(404).json({ error: 'not found' }); return; } + + const body = req.body ?? {}; + const patch: Parameters[1] = {}; + + if (body.status !== undefined) { + if (body.status !== 'active' && body.status !== 'disabled') { + res.status(400).json({ error: "status must be 'active' or 'disabled'" }); + return; + } + patch.status = body.status; + } + if (body.botCredentials !== undefined) { + const creds = body.botCredentials; + if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) { + res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' }); + return; + } + try { + patch.botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv()); + } catch (err) { + logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`); + res.status(503).json({ error: 'credential encryption is not configured on this server' }); + return; + } + } + + if (Object.keys(patch).length === 0) { + res.status(400).json({ error: 'no updatable fields provided' }); + return; + } + + try { + const updated = repo.updateChatConnectorBinding(binding.id, patch); + if (!updated) { res.status(404).json({ error: 'not found' }); return; } + logger.info(`[chat-connector-bindings-api] updated binding=${binding.id} fields=${Object.keys(patch).join(',')}`); + res.json(toPublicBinding(updated)); + } catch (err) { + logger.error(`[chat-connector-bindings-api] PATCH failed binding=${binding.id}: ${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + router.delete('/:id', (req: Request, res: Response) => { + const binding = repo.getChatConnectorBindingById(req.params.id); + if (!binding) { res.status(404).json({ error: 'not found' }); return; } + try { + repo.deleteChatConnectorBinding(binding.id); + logger.info(`[chat-connector-bindings-api] deleted binding=${binding.id}`); + res.json({ ok: true }); + } catch (err) { + logger.error(`[chat-connector-bindings-api] DELETE failed binding=${binding.id}: ${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + return router; +} diff --git a/src/bridge/chat/chat-connector-service.test.ts b/src/bridge/chat/chat-connector-service.test.ts new file mode 100644 index 0000000..d29dda8 --- /dev/null +++ b/src/bridge/chat/chat-connector-service.test.ts @@ -0,0 +1,290 @@ +// @vitest-environment node +/** + * chat-connector-service tests. Uses a real (temp-file) Repository — matching + * webhook-service.test.ts's convention — plus mocked executor/chat-client + * factories so we exercise binding resolution, delegation liveness, + * governance, and reply-building without a real MaestroA2aExecutor job run + * (that path is covered by executor.test.ts). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomBytes, randomUUID } from 'crypto'; +import { Repository } from '../../db/repository.js'; +import type { ChatConnectorBindingRecord } from '../../db/repository.js'; +import { encrypt } from '../../mcp/crypto.js'; +import { handleSlackMention, type ChatConnectorServiceDeps } from './chat-connector-service.js'; +import type { ChatMentionEvent } from './chat-connector-types.js'; +import { A2aLimiter, DEFAULT_A2A_LIMITS } from '../a2a/limiter.js'; + +describe('chat-connector-service: handleSlackMention', () => { + let tempDir = ''; + let repo: Repository; + let ownerId = ''; + let spaceId = ''; + let clientId = ''; + + beforeEach(async () => { + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-svc-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + ownerId = owner.id; + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' }); + spaceId = space.id; + clientId = `a2a_${randomUUID()}`; + repo.createA2aClient({ + clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'], + secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null, + keyFingerprint: null, status: 'active', createdBy: null, + }); + }); + + afterEach(() => { + repo.close(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + function seedDelegation(opts: { grantId?: string | null; grantedSpaceIds?: string[]; revokedAt?: string | null; expiresAt?: string | null } = {}): string { + const id = randomUUID(); + repo.createA2aDelegation({ + id, userId: ownerId, clientId, + grantId: opts.grantId === undefined ? randomUUID() : opts.grantId, + grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId], + grantedSkills: ['chat'], + audience: null, + expiresAt: opts.expiresAt ?? null, + revokedAt: opts.revokedAt ?? null, + }); + return id; + } + + function encryptCreds(): Buffer { + const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex'); + return encrypt(JSON.stringify({ signingSecret: 'sec', botToken: 'xoxb-test' }), key); + } + + function seedBinding(delegationId: string, grantId: string, opts: { status?: 'active' | 'disabled'; team?: string; channel?: string } = {}): ChatConnectorBindingRecord { + return repo.createChatConnectorBinding({ + platform: 'slack', + externalWorkspaceId: opts.team ?? 'T123', + externalChannelId: opts.channel ?? 'C456', + spaceId, + a2aClientId: clientId, + a2aDelegationId: delegationId, + a2aGrantId: grantId, + botCredentialsEnc: encryptCreds(), + createdBy: ownerId, + }); + } + + function makeEvent(overrides: Partial = {}): ChatMentionEvent { + return { + platform: 'slack', + externalWorkspaceId: 'T123', + externalChannelId: 'C456', + text: 'summarize the thread', + messageTs: '1700000000.000100', + ...overrides, + }; + } + + function makeChatClientFactory() { + const postMessage = vi.fn().mockResolvedValue(true); + const factory = () => ({ postMessage }); + return { factory, postMessage }; + } + + it('ignores a mention when no active binding exists (fail-closed, no reply)', async () => { + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps); + expect(postMessage).not.toHaveBeenCalled(); + expect(executorFactory).not.toHaveBeenCalled(); + }); + + it('ignores a mention when the binding is disabled', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId, { status: 'active' }); + // Immediately disable it. + const binding = repo.findActiveChatConnectorBinding('slack', 'T123', 'C456')!; + repo.updateChatConnectorBinding(binding.id, { status: 'disabled' }); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps); + expect(postMessage).not.toHaveBeenCalled(); + expect(executorFactory).not.toHaveBeenCalled(); + }); + + it('ignores a mention when the delegation has been revoked (fail-closed)', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId, revokedAt: new Date().toISOString() }); + seedBinding(delegationId, grantId); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps); + expect(postMessage).not.toHaveBeenCalled(); + expect(executorFactory).not.toHaveBeenCalled(); + }); + + it('ignores a mention when the delegation has expired (fail-closed)', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId, expiresAt: new Date(Date.now() - 60_000).toISOString() }); + seedBinding(delegationId, grantId); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps); + expect(postMessage).not.toHaveBeenCalled(); + expect(executorFactory).not.toHaveBeenCalled(); + }); + + it('ignores a mention with an empty (whitespace-only) text', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent({ text: ' ' }), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps); + expect(postMessage).not.toHaveBeenCalled(); + expect(executorFactory).not.toHaveBeenCalled(); + }); + + it('runs a mention through the executor and replies with the local task result comment + link', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + + const localTask = await repo.createLocalTask({ + title: 'from slack', body: 'summarize the thread', pieceName: 'chat', + ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent', + }); + await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\nHere is the summary.', 'result'); + + const { factory, postMessage } = makeChatClientFactory(); + const executeSpy = vi.fn(async (_ctx: unknown, bus: any) => { + bus.publish({ + kind: 'task', id: 'a2a-task-1', contextId: 'ctx-1', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + metadata: { maestroLocalTaskId: localTask.id, maestroJobId: 'job-1' }, + }); + bus.publish({ + kind: 'status-update', taskId: 'a2a-task-1', contextId: 'ctx-1', + status: { state: 'completed', timestamp: new Date().toISOString() }, final: true, + }); + bus.finished(); + }); + const executorFactory = vi.fn(() => ({ execute: executeSpy })); + + await handleSlackMention(makeEvent(), { + repo, chatClientFactory: factory, executorFactory, + publicBaseUrl: 'https://maestro.example.com', + } as unknown as ChatConnectorServiceDeps); + + expect(executeSpy).toHaveBeenCalledTimes(1); + // requestContext carries the in-process A2A principal built from the delegation row. + const [ctxArg] = executeSpy.mock.calls[0]; + expect((ctxArg as any).context.user.a2aPrincipal.actingUserId).toBe(ownerId); + expect((ctxArg as any).context.user.a2aPrincipal.grantId).toBe(grantId); + expect((ctxArg as any).userMessage.parts[0].text).toBe('summarize the thread'); + + expect(postMessage).toHaveBeenCalledTimes(1); + const [replyArgs] = postMessage.mock.calls[0]; + expect(replyArgs.channel).toBe('C456'); + expect(replyArgs.threadTs).toBe('1700000000.000100'); + expect(replyArgs.text).toContain('Here is the summary.'); + expect(replyArgs.text).toContain(`space=${spaceId}`); + expect(replyArgs.text).toContain(`chat=${localTask.id}`); + }); + + it('replies with the task-detail link using threadTs (not messageTs) when the mention was inside a thread', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + const localTask = await repo.createLocalTask({ + title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent', + }); + await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\ndone', 'result'); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(() => ({ + execute: async (_ctx: unknown, bus: any) => { + bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } }); + bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true }); + bus.finished(); + }, + })); + + await handleSlackMention(makeEvent({ threadTs: '1699999999.000001', messageTs: '1700000000.000100' }), { + repo, chatClientFactory: factory, executorFactory, + } as unknown as ChatConnectorServiceDeps); + + expect(postMessage.mock.calls[0][0].threadTs).toBe('1699999999.000001'); + }); + + it('governance: rejects an oversized mention without invoking the executor', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 8 }); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await handleSlackMention(makeEvent({ text: 'this text is definitely longer than 8 bytes' }), { + repo, chatClientFactory: factory, executorFactory, limiter, + } as unknown as ChatConnectorServiceDeps); + + expect(executorFactory).not.toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0].text).toContain('長すぎます'); + }); + + it('governance: reuses the shared A2aLimiter so a second mention from the same delegation within the rate window is rejected', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + // ratePerMinute: 1 — the very next call from the same grant must be throttled. + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }); + + const localTask = await repo.createLocalTask({ + title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent', + }); + await repo.addLocalTaskComment(localTask.id, 'agent', '✅ done', 'result'); + const executorFactory = vi.fn(() => ({ + execute: async (_ctx: unknown, bus: any) => { + bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } }); + bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true }); + bus.finished(); + }, + })); + const { factory, postMessage } = makeChatClientFactory(); + + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps); + await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps); + + expect(executorFactory).toHaveBeenCalledTimes(1); // second call was rate-limited before reaching the executor + expect(postMessage).toHaveBeenCalledTimes(2); + expect(postMessage.mock.calls[1][0].text).toContain('多すぎます'); + }); + + it('fails closed (no reply, no throw) when bot credentials cannot be decrypted', async () => { + const grantId = randomUUID(); + const delegationId = seedDelegation({ grantId }); + seedBinding(delegationId, grantId); + // Swap the encryption key after the binding was created — decrypt() will now fail (auth tag mismatch). + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + + const { factory, postMessage } = makeChatClientFactory(); + const executorFactory = vi.fn(); + await expect( + handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps), + ).resolves.not.toThrow(); + expect(executorFactory).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bridge/chat/chat-connector-service.ts b/src/bridge/chat/chat-connector-service.ts new file mode 100644 index 0000000..fe52fb7 --- /dev/null +++ b/src/bridge/chat/chat-connector-service.ts @@ -0,0 +1,266 @@ +/** + * chat-connector-service: mention → binding lookup → in-process A2A principal + * → MaestroA2aExecutor.execute() → reply (issue #801, PR1 — Slack MVP). + * + * This module is the single place that bridges an external chat mention into + * the existing A2A execution path. It deliberately reuses, rather than + * reimplements: + * - MaestroA2aExecutor.execute() (src/bridge/a2a/executor.ts) for job + * creation, polling, and terminal-state handling. + * - computeEffectiveScope / isDelegationLive (src/bridge/a2a/delegation.ts) + * for the AND-intersected authorization scope and fail-closed revocation + * checks — invoked transitively by the executor. + * - A2aLimiter (src/bridge/a2a/limiter.ts) for governance. Because this + * path bypasses the JSON-RPC AuthGatedRequestHandler (request-handler.ts), + * which normally enforces payload-size + rate-limit checks BEFORE calling + * execute(), this module replicates that same ingress gate explicitly + * (see `enforceIngress` below) using the identical A2aLimiter instance — + * so rate/concurrency/skill-budget counters are shared across the A2A + * JSON-RPC path and the chat path for the same grant. + * - local_task_comments 'result'/'ask' rows (via Repository.getLatestResultComment) + * for the reply summary, written by LocalProgressReporter.reportFinalResult / + * reportAsk — no new result-reporting path is introduced. + * + * fail-closed points (all silently ignore rather than error, per the issue's + * "existing binding" model — an attacker probing channel/team ids should not + * learn whether a binding exists): + * - no active binding for (platform, team, channel) + * - delegation missing / revoked / expired / no grant_id + * - bot credential decryption failure + * - payload too large / rate limit exceeded (replies with a short notice — + * these are legitimate-binding-holder-visible failures, not enumeration risks) + */ +import { randomUUID } from 'node:crypto'; +import type { Repository, A2aDelegationRow } from '../../db/repository.js'; +import { isDelegationLive } from '../a2a/delegation.js'; +import type { A2aPrincipal } from '../a2a/token-auth.js'; +import { MaestroA2aExecutor } from '../a2a/executor.js'; +import { A2aLimiter } from '../a2a/limiter.js'; +import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js'; +import { SlackChatClient } from './slack-adapter.js'; +import type { ChatMentionEvent, SlackBotCredentials } from './chat-connector-types.js'; +import { logger } from '../../logger.js'; + +const MAX_REPLY_CHARS = 800; + +export interface ChatConnectorServiceDeps { + repo: Repository; + /** Shared with the A2A JSON-RPC path (see a2a-subsystem.ts) so governance + * counters are keyed per-grant regardless of entry point. Omit only in + * tests that don't care about governance. */ + limiter?: A2aLimiter; + /** Absolute base URL for task-detail links in replies. Omit to send no link. */ + publicBaseUrl?: string; + now?: () => string; + /** Test seam: inject a fake executor instead of a real MaestroA2aExecutor. */ + executorFactory?: (deps: { repo: Repository; limiter?: A2aLimiter }) => { execute: MaestroA2aExecutor['execute'] }; + /** Test seam: inject a fake Slack client instead of a real network call. */ + chatClientFactory?: (credentials: SlackBotCredentials) => Pick; +} + +/** Minimal ExecutionEventBus — collects published events and resolves once execute() calls finished(). */ +function makeCollectingEventBus() { + const events: Array> = []; + let resolveFinished!: () => void; + const finishedPromise = new Promise((resolve) => { resolveFinished = resolve; }); + const bus = { + publish(event: Record) { events.push(event); }, + finished() { resolveFinished(); }, + on() { return bus; }, + off() { return bus; }, + once() { return bus; }, + removeAllListeners() { return bus; }, + }; + return { bus, events, finishedPromise }; +} + +function extractText(message: any): string | undefined { + const part = message?.parts?.find((p: any) => p.kind === 'text'); + return typeof part?.text === 'string' ? part.text : undefined; +} + +interface ExecutorOutcome { + state: string; + message?: string; + localTaskId?: number; +} + +function extractOutcome(events: Array>): ExecutorOutcome { + let localTaskId: number | undefined; + let state = 'unknown'; + let message: string | undefined; + for (const ev of events) { + if (ev.kind === 'task') { + if (ev.metadata?.maestroLocalTaskId != null) localTaskId = ev.metadata.maestroLocalTaskId; + } + if (ev.kind === 'task' || ev.kind === 'status-update') { + if (ev.status?.state) { + state = ev.status.state; + const text = extractText(ev.status.message); + if (text) message = text; + } + } + } + return { state, message, localTaskId }; +} + +function truncate(text: string, max: number): string { + const trimmed = text.trim(); + if (trimmed.length <= max) return trimmed; + return trimmed.slice(0, max) + '…(省略)'; +} + +async function buildReply(outcome: ExecutorOutcome, repo: Repository, spaceId: string, publicBaseUrl?: string): Promise { + const link = (publicBaseUrl && outcome.localTaskId != null) + ? `\n${publicBaseUrl.replace(/\/$/, '')}/?page=spaces&space=${encodeURIComponent(spaceId)}&chat=${outcome.localTaskId}` + : ''; + + if (outcome.localTaskId != null) { + let resultComment: { body: string; kind: string } | null = null; + try { + resultComment = await repo.getLatestResultComment(outcome.localTaskId); + } catch (err) { + logger.warn(`[chat-connector] getLatestResultComment failed taskId=${outcome.localTaskId}: ${err}`); + } + if (resultComment) { + return `${truncate(resultComment.body, MAX_REPLY_CHARS)}${link}`; + } + if (outcome.message) return `${truncate(outcome.message, MAX_REPLY_CHARS)}${link}`; + return `タスクを実行しました。${link}`; + } + + // No local task was ever created (denied before task creation, e.g. out-of-scope or governance reject). + return outcome.message ? `⚠️ ${truncate(outcome.message, MAX_REPLY_CHARS)}` : '⚠️ リクエストを処理できませんでした。'; +} + +function buildPrincipal(delegation: A2aDelegationRow): A2aPrincipal { + return { + actingUserId: delegation.userId, + clientId: delegation.clientId, + grantId: delegation.grantId as string, + delegation: { + id: delegation.id, + userId: delegation.userId, + clientId: delegation.clientId, + grantId: delegation.grantId as string, + grantedSpaceIds: delegation.grantedSpaceIds, + grantedSkills: delegation.grantedSkills, + expiresAt: delegation.expiresAt, + revokedAt: delegation.revokedAt, + }, + }; +} + +/** + * Handles one inbound Slack app_mention: resolves the binding, builds an + * in-process A2A principal from the bound delegation, runs the message + * through MaestroA2aExecutor, and posts the result back to Slack. + * + * Never throws — all failure paths are logged and either silently ignored + * (fail-closed authorization gaps) or answered with a short notice + * (governance gaps visible to a legitimate binding holder). + */ +export async function handleSlackMention(event: ChatMentionEvent, deps: ChatConnectorServiceDeps): Promise { + const { repo, limiter, publicBaseUrl } = deps; + const now = deps.now ?? (() => new Date().toISOString()); + + const binding = repo.findActiveChatConnectorBinding(event.platform, event.externalWorkspaceId, event.externalChannelId); + if (!binding) { + logger.info(`[chat-connector] no active binding platform=${event.platform} team=${event.externalWorkspaceId} channel=${event.externalChannelId} — ignoring`); + return; + } + + const delegation = repo.getA2aDelegationById(binding.a2aDelegationId); + const nowIso = now(); + if (!delegation || !delegation.grantId || !isDelegationLive(delegation, nowIso)) { + logger.info(`[chat-connector] delegation not live binding=${binding.id} — ignoring`); + return; + } + + if (!event.text.trim()) { + logger.info(`[chat-connector] empty mention text binding=${binding.id} — ignoring`); + return; + } + + let credentials: SlackBotCredentials; + try { + const plaintext = decrypt(binding.botCredentialsEnc, loadKeyFromEnv()); + credentials = JSON.parse(plaintext) as SlackBotCredentials; + } catch (err) { + logger.warn(`[chat-connector] failed to decrypt bot credentials binding=${binding.id}: ${err}`); + return; + } + + const client = deps.chatClientFactory ? deps.chatClientFactory(credentials) : new SlackChatClient(credentials); + const threadTs = event.threadTs ?? event.messageTs; + const reply = (text: string) => client.postMessage({ channel: event.externalChannelId, text, threadTs }).catch((err) => { + logger.warn(`[chat-connector] reply post failed binding=${binding.id}: ${err}`); + }); + + const principal = buildPrincipal(delegation); + + // Governance: replicate request-handler.ts's enforceIngress (payload size + rate) + // using the SAME A2aLimiter instance passed in from a2a-subsystem.ts, so this + // path shares budget/counters with the JSON-RPC A2A path for the same grant. + // Concurrency + skill-budget are enforced inside MaestroA2aExecutor.execute() + // itself (unchanged) — only rate + payload need to be replicated here because + // they normally live in the JSON-RPC ingress layer this path bypasses. + if (limiter) { + const bytes = Buffer.byteLength(event.text, 'utf8'); + if (bytes > limiter.limits.maxPayloadBytes) { + logger.info(`[chat-connector] payload too large binding=${binding.id} bytes=${bytes}`); + await reply('⚠️ メッセージが長すぎます。短くしてもう一度お試しください。'); + return; + } + if (!limiter.tryConsumeRate(principal.grantId)) { + logger.info(`[chat-connector] rate limit exceeded binding=${binding.id} grant=${principal.grantId}`); + await reply('⚠️ リクエストが多すぎます。しばらく待ってから再度お試しください。'); + return; + } + } + + const executor = deps.executorFactory + ? deps.executorFactory({ repo, limiter }) + : new MaestroA2aExecutor(repo, { limiter, pollMs: 1000 }); + + const taskId = randomUUID(); + const contextId = randomUUID(); + const requestContext = { + taskId, + contextId, + userMessage: { + kind: 'message', + messageId: randomUUID(), + role: 'user', + parts: [{ kind: 'text', text: event.text }], + contextId, + metadata: {}, + }, + context: { user: { a2aPrincipal: principal, isAuthenticated: true, userName: principal.actingUserId } }, + } as unknown as Parameters[0]; + + const { bus, events, finishedPromise } = makeCollectingEventBus(); + try { + await executor.execute(requestContext, bus as any); + await finishedPromise; + } catch (err) { + logger.warn(`[chat-connector] executor error binding=${binding.id}: ${err}`); + await reply('⚠️ 内部エラーによりタスクを実行できませんでした。'); + return; + } + + const outcome = extractOutcome(events); + try { + await repo.addAuditLog(null, 'chat.mention.handled', 'chat', { + bindingId: binding.id, + platform: event.platform, + spaceId: binding.spaceId, + grantId: principal.grantId, + state: outcome.state, + localTaskId: outcome.localTaskId ?? null, + }); + } catch { /* best-effort audit */ } + + const replyText = await buildReply(outcome, repo, binding.spaceId, publicBaseUrl); + await reply(replyText); +} diff --git a/src/bridge/chat/chat-connector-types.ts b/src/bridge/chat/chat-connector-types.ts new file mode 100644 index 0000000..4ffb85e --- /dev/null +++ b/src/bridge/chat/chat-connector-types.ts @@ -0,0 +1,34 @@ +/** + * chat-connector-types: shared types for the external chat connector + * (issue #801, PR1 — Slack MVP). Platform-agnostic so PR2 (Discord) / PR3 + * (Teams) can reuse chat-connector-service.ts without changes. + */ + +/** Bot credentials, decrypted from chat_connector_bindings.bot_credentials_enc. + * Never logged, never included in an API response. */ +export interface SlackBotCredentials { + signingSecret: string; + botToken: string; +} + +/** Normalized inbound mention event, platform-agnostic. */ +export interface ChatMentionEvent { + platform: 'slack'; + externalWorkspaceId: string; + externalChannelId: string; + /** Mention text with the bot's own `<@BOTID>` token stripped. */ + text: string; + /** Thread to reply into, if the mention occurred inside a thread. */ + threadTs?: string; + /** Platform-native message timestamp, used to reply in-channel when not threaded. */ + messageTs?: string; + /** External user id who sent the mention (best-effort, for audit logging only). */ + externalUserId?: string; +} + +/** Result of running a mention through the A2A executor bridge. */ +export interface ChatRunResult { + ok: boolean; + /** Human-readable summary to post back to the chat, already truncated. */ + replyText: string; +} diff --git a/src/bridge/chat/chat-subsystem.test.ts b/src/bridge/chat/chat-subsystem.test.ts new file mode 100644 index 0000000..e0c9bc5 --- /dev/null +++ b/src/bridge/chat/chat-subsystem.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment node +/** + * chat-subsystem HTTP-layer tests: config gating, url_verification handshake, + * and the fail-closed dispatch gate (no binding / bad signature both ack 200 + * without calling handleSlackMention — see slack-adapter.test.ts and + * chat-connector-service.test.ts for the unit-level signature/service tests + * this route delegates to). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { randomBytes, randomUUID, createHmac } from 'crypto'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from '../../db/repository.js'; +import { encrypt } from '../../mcp/crypto.js'; + +let mockChatConfig: any = { enabled: false }; +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, loadConfig: vi.fn(() => ({ chat: mockChatConfig })) }; +}); + +const handleSlackMentionMock = vi.fn().mockResolvedValue(undefined); +vi.mock('./chat-connector-service.js', () => ({ + handleSlackMention: (...args: unknown[]) => handleSlackMentionMock(...args), +})); + +vi.mock('../auth.js', async () => { + const actual = await vi.importActual('../auth.js'); + return { ...actual, requireAdmin: (_req: any, _res: any, next: any) => next() }; +}); + +import { setupChatSubsystem } from './chat-subsystem.js'; + +function sign(secret: string, timestamp: string, body: string): string { + return 'v0=' + createHmac('sha256', secret).update(`v0:${timestamp}:${body}`, 'utf8').digest('hex'); +} + +describe('chat-subsystem: Slack Events webhook', () => { + let tempDir = ''; + let repo: Repository; + let ownerId = ''; + let spaceId = ''; + let clientId = ''; + let delegationId = ''; + let grantId = ''; + + beforeEach(async () => { + handleSlackMentionMock.mockClear(); + mockChatConfig = { enabled: true, slack: {} }; + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-subsys-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + ownerId = owner.id; + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' }); + spaceId = space.id; + clientId = `a2a_${randomUUID()}`; + repo.createA2aClient({ + clientId, name: 'Test App', redirectUris: ['https://example.com/cb'], + secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null, + keyFingerprint: null, status: 'active', createdBy: null, + }); + delegationId = randomUUID(); + grantId = randomUUID(); + repo.createA2aDelegation({ + id: delegationId, userId: ownerId, clientId, grantId, + grantedSpaceIds: [spaceId], grantedSkills: ['chat'], + audience: null, expiresAt: null, revokedAt: null, + }); + }); + + afterEach(() => { + repo.close(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + function seedBinding(signingSecret = 'correct-secret') { + const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex'); + return repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: encrypt(JSON.stringify({ signingSecret, botToken: 'xoxb-test' }), key), + }); + } + + function appWith(a2aLimiter: any = {}): express.Express { + const app = express(); + setupChatSubsystem(app, { repo, authActive: true, a2aLimiter }); + return app; + } + + it('does not mount the webhook route when chat.enabled is false', async () => { + mockChatConfig = { enabled: false }; + const app = appWith(); + const res = await request(app).post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ type: 'url_verification', challenge: 'abc' })); + expect(res.status).toBe(404); + }); + + it('does not mount the webhook when A2A governance is unavailable', async () => { + const app = appWith(null); + const res = await request(app).post('/api/chat/slack/events').send('{}'); + expect(res.status).toBe(404); + }); + + it('echoes the url_verification challenge with no binding lookup and no dispatch', async () => { + const app = appWith(); + const res = await request(app) + .post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ type: 'url_verification', challenge: 'chal-123' })); + expect(res.status).toBe(200); + expect(res.body.challenge).toBe('chal-123'); + expect(handleSlackMentionMock).not.toHaveBeenCalled(); + }); + + it('acks 200 without dispatching when no binding matches the (team, channel) — fail-closed, no enumeration signal', async () => { + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', team_id: 'T-unknown', + event: { type: 'app_mention', channel: 'C-unknown', text: 'hi', ts: '1.1' }, + }); + const res = await request(app).post('/api/chat/slack/events').set('Content-Type', 'application/json').send(body); + expect(res.status).toBe(200); + expect(handleSlackMentionMock).not.toHaveBeenCalled(); + }); + + it('acks 200 without dispatching on an invalid signature — same status as the no-binding case', async () => { + seedBinding(); + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', event_id: 'Ev-valid-1', team_id: 'T1', + event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' }, + }); + const res = await request(app).post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .set('X-Slack-Signature', 'v0=' + '0'.repeat(64)) + .set('X-Slack-Request-Timestamp', String(Math.floor(Date.now() / 1000))) + .send(body); + expect(res.status).toBe(200); + expect(handleSlackMentionMock).not.toHaveBeenCalled(); + }); + + it('acks 200 without dispatching on a replayed (stale) timestamp', async () => { + seedBinding(); + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', event_id: 'Ev-valid-dispatch-1', team_id: 'T1', + event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' }, + }); + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600); // 1h old + const res = await request(app).post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .set('X-Slack-Signature', sign('correct-secret', staleTimestamp, body)) + .set('X-Slack-Request-Timestamp', staleTimestamp) + .send(body); + expect(res.status).toBe(200); + expect(handleSlackMentionMock).not.toHaveBeenCalled(); + }); + + it('dispatches handleSlackMention exactly once on a validly signed app_mention', async () => { + seedBinding(); + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', event_id: 'Ev-valid-mention-1', team_id: 'T1', + event: { type: 'app_mention', channel: 'C1', text: '<@U1> hi there', ts: '1700000000.0001' }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const res = await request(app).post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .set('X-Slack-Signature', sign('correct-secret', timestamp, body)) + .set('X-Slack-Request-Timestamp', timestamp) + .send(body); + expect(res.status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handleSlackMentionMock).toHaveBeenCalledTimes(1); + const [event] = handleSlackMentionMock.mock.calls[0]; + expect(event.externalWorkspaceId).toBe('T1'); + expect(event.externalChannelId).toBe('C1'); + expect(event.text).toBe('hi there'); + }); + + it('acknowledges a Slack retry without dispatching the same event twice', async () => { + seedBinding(); + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', event_id: 'Ev-retry-1', team_id: 'T1', + event: { type: 'app_mention', channel: 'C1', text: '<@U1> once', ts: '1700000000.0001' }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const headers = { 'Content-Type': 'application/json', 'X-Slack-Signature': sign('correct-secret', timestamp, body), 'X-Slack-Request-Timestamp': timestamp }; + expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handleSlackMentionMock).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch for a non-app_mention event_callback (e.g. plain message)', async () => { + seedBinding(); + const app = appWith(); + const body = JSON.stringify({ + type: 'event_callback', team_id: 'T1', + event: { type: 'message', channel: 'C1', text: 'not a mention', ts: '1.1' }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const res = await request(app).post('/api/chat/slack/events') + .set('Content-Type', 'application/json') + .set('X-Slack-Signature', sign('correct-secret', timestamp, body)) + .set('X-Slack-Request-Timestamp', timestamp) + .send(body); + expect(res.status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handleSlackMentionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bridge/chat/chat-subsystem.ts b/src/bridge/chat/chat-subsystem.ts new file mode 100644 index 0000000..a993c76 --- /dev/null +++ b/src/bridge/chat/chat-subsystem.ts @@ -0,0 +1,139 @@ +/** + * chat-subsystem: mounts the external chat connector (issue #801, PR1 — + * Slack MVP). No-op unless `chat.enabled: true` in config.yaml. Follows the + * same "verbatim extraction, gated by config" shape as a2a-subsystem.ts. + */ +import express from 'express'; +import type { Repository } from '../../db/repository.js'; +import { logger } from '../../logger.js'; +import { loadConfig } from '../../config.js'; +import { requireAdmin } from '../auth.js'; +import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js'; +import { + verifySlackSignature, + isUrlVerification, + parseAppMentionEvent, + DEFAULT_SLACK_SIGNING_MAX_AGE_SEC, +} from './slack-adapter.js'; +import { handleSlackMention } from './chat-connector-service.js'; +import type { A2aLimiter } from '../a2a/limiter.js'; +import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js'; + +export interface ChatSubsystemDeps { + repo: Repository; + authActive: boolean; + /** Shared A2A governance limiter (see a2a-subsystem.ts). Undefined when a2a + * is not enabled — in that case the chat path still runs but with no + * rate/concurrency/skill-budget governance beyond what the executor itself + * enforces without a limiter (none), so operators should enable a2a too. */ + a2aLimiter?: A2aLimiter; +} + +export function setupChatSubsystem(app: express.Express, deps: ChatSubsystemDeps): void { + const { repo, authActive, a2aLimiter } = deps; + const chatCfg = (loadConfig() as any).chat ?? {}; + if (!chatCfg.enabled) return; + + if (!authActive) { + logger.warn('[chat-connector] chat.enabled but auth is not active; admin binding API will reject all requests'); + } + // --- Admin CRUD for bindings (no UI wizard in PR1 — manual seed only) --- + app.use('/api/admin/chat/bindings', express.json(), requireAdmin, createChatConnectorBindingsAdminRouter(repo, authActive)); + + // The mention path must share A2A's limiter. Do not mount it when A2A did + // not initialize: otherwise an external webhook bypasses rate/concurrency + // and skill-budget governance despite the admin configuration saying it is + // disabled. + if (!a2aLimiter) { + logger.warn('[chat-connector] chat.enabled but a2a.enabled is false (or a2a failed to mount); Slack webhook is disabled'); + return; + } + + const maxAgeSec: number = chatCfg.slack?.signingSecretMaxAgeSec ?? DEFAULT_SLACK_SIGNING_MAX_AGE_SEC; + const publicBaseUrl: string | undefined = chatCfg.publicBaseUrl; + + // --- Slack Events API webhook --- + // express.raw() (not json()) — signature verification requires the exact + // raw bytes Slack signed. Must ack within Slack's ~3s budget: verification + // is synchronous/local (DB lookup + HMAC, no outbound network), so we + // finish it before responding, then run the actual task fire-and-forget. + app.post('/api/chat/slack/events', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => { + const rawBody = req.body as Buffer; + let parsed: unknown; + try { + parsed = JSON.parse(rawBody.toString('utf8')); + } catch { + res.status(400).json({ error: 'invalid JSON' }); + return; + } + + // url_verification handshake: Slack sends this once, when the Events API + // Request URL is first configured, before any binding necessarily exists + // for that team. There is no channel to key a per-binding signing secret + // lookup on, so PR1 does not verify this specific call — it has no side + // effects (it only echoes back the challenge value Slack itself sent). + if (isUrlVerification(parsed)) { + res.status(200).json({ challenge: parsed.challenge }); + return; + } + + const teamId = (parsed as any)?.team_id; + const channelId = (parsed as any)?.event?.channel; + if (typeof teamId !== 'string' || typeof channelId !== 'string') { + res.status(200).json({ ok: true }); // nothing routable — ack and drop + return; + } + + const binding = repo.findActiveChatConnectorBinding('slack', teamId, channelId); + if (!binding) { + // fail-closed, no enumeration signal: identical 200 ack whether or not + // a binding exists for this team/channel. + res.status(200).json({ ok: true }); + return; + } + + let signingSecret: string; + try { + const decrypted = decrypt(binding.botCredentialsEnc, loadKeyFromEnv()); + signingSecret = (JSON.parse(decrypted) as { signingSecret: string }).signingSecret; + } catch (err) { + logger.warn(`[chat-connector] failed to decrypt signing secret binding=${binding.id}: ${err}`); + res.status(200).json({ ok: true }); + return; + } + + const verified = verifySlackSignature( + { signature: req.header('X-Slack-Signature'), timestamp: req.header('X-Slack-Request-Timestamp') }, + rawBody, + signingSecret, + Math.floor(Date.now() / 1000), + maxAgeSec, + ); + if (!verified) { + // Same 200 ack as "no binding" — do not leak signature-validity via status code. + logger.warn(`[chat-connector] signature verification failed binding=${binding.id}`); + res.status(200).json({ ok: true }); + return; + } + + const eventId = (parsed as any)?.event_id; + if (typeof eventId !== 'string' || !repo.claimChatConnectorEvent(eventId)) { + // Slack retries use the same envelope event_id. Acknowledge the retry + // without creating a second task or posting a duplicate thread reply. + res.status(200).json({ ok: true }); + return; + } + + // Verified — ack immediately, then run the task in the background. + res.status(200).json({ ok: true }); + + const event = parseAppMentionEvent(parsed); + if (!event) return; // not an app_mention (or bot/edited-message echo) — nothing to do + + handleSlackMention(event, { repo, limiter: a2aLimiter, publicBaseUrl }).catch((err) => { + logger.warn(`[chat-connector] handleSlackMention failed: ${err}`); + }); + }); + + logger.info('[chat-connector] chat connector subsystem enabled (slack)'); +} diff --git a/src/bridge/chat/slack-adapter.test.ts b/src/bridge/chat/slack-adapter.test.ts new file mode 100644 index 0000000..057ce09 --- /dev/null +++ b/src/bridge/chat/slack-adapter.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment node +import { createHmac } from 'node:crypto'; +import { describe, it, expect, vi } from 'vitest'; +import { + verifySlackSignature, + isUrlVerification, + parseAppMentionEvent, + SlackChatClient, + DEFAULT_SLACK_SIGNING_MAX_AGE_SEC, +} from './slack-adapter.js'; + +const SECRET = 'test-signing-secret'; + +function sign(timestamp: string, body: string, secret: string = SECRET): string { + const base = `v0:${timestamp}:${body}`; + return 'v0=' + createHmac('sha256', secret).update(base, 'utf8').digest('hex'); +} + +describe('verifySlackSignature', () => { + it('accepts a correctly signed, fresh request', () => { + const nowSec = 1_700_000_000; + const body = JSON.stringify({ hello: 'world' }); + const timestamp = String(nowSec - 5); + const signature = sign(timestamp, body); + expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(true); + }); + + it('rejects a tampered body (signature mismatch)', () => { + const nowSec = 1_700_000_000; + const timestamp = String(nowSec - 5); + const signature = sign(timestamp, JSON.stringify({ hello: 'world' })); + const tamperedBody = JSON.stringify({ hello: 'mallory' }); + expect(verifySlackSignature({ signature, timestamp }, tamperedBody, SECRET, nowSec)).toBe(false); + }); + + it('rejects a signature produced with the wrong secret', () => { + const nowSec = 1_700_000_000; + const body = JSON.stringify({ hello: 'world' }); + const timestamp = String(nowSec - 5); + const signature = sign(timestamp, body, 'wrong-secret'); + expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(false); + }); + + it('rejects a replayed (stale) timestamp beyond maxAgeSec', () => { + const nowSec = 1_700_000_000; + const body = JSON.stringify({ hello: 'world' }); + const staleTimestamp = String(nowSec - (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60)); + const signature = sign(staleTimestamp, body); + expect(verifySlackSignature({ signature, timestamp: staleTimestamp }, body, SECRET, nowSec)).toBe(false); + }); + + it('rejects a timestamp far in the future (clock-skew abuse)', () => { + const nowSec = 1_700_000_000; + const body = JSON.stringify({ hello: 'world' }); + const futureTimestamp = String(nowSec + (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60)); + const signature = sign(futureTimestamp, body); + expect(verifySlackSignature({ signature, timestamp: futureTimestamp }, body, SECRET, nowSec)).toBe(false); + }); + + it('fail-closed: rejects when the signature header is missing', () => { + const nowSec = 1_700_000_000; + expect(verifySlackSignature({ signature: undefined, timestamp: String(nowSec) }, '{}', SECRET, nowSec)).toBe(false); + }); + + it('fail-closed: rejects when the timestamp header is missing', () => { + const nowSec = 1_700_000_000; + expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: undefined }, '{}', SECRET, nowSec)).toBe(false); + }); + + it('fail-closed: rejects a non-numeric timestamp', () => { + const nowSec = 1_700_000_000; + expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: 'not-a-number' }, '{}', SECRET, nowSec)).toBe(false); + }); + + it('accepts a Buffer body identically to the equivalent string', () => { + const nowSec = 1_700_000_000; + const body = JSON.stringify({ hello: 'world' }); + const timestamp = String(nowSec - 5); + const signature = sign(timestamp, body); + expect(verifySlackSignature({ signature, timestamp }, Buffer.from(body, 'utf8'), SECRET, nowSec)).toBe(true); + }); +}); + +describe('isUrlVerification', () => { + it('recognizes a well-formed url_verification payload', () => { + expect(isUrlVerification({ type: 'url_verification', challenge: 'abc123' })).toBe(true); + }); + + it('rejects payloads missing the challenge field', () => { + expect(isUrlVerification({ type: 'url_verification' })).toBe(false); + }); + + it('rejects event_callback payloads', () => { + expect(isUrlVerification({ type: 'event_callback', event: {} })).toBe(false); + }); + + it('rejects null/non-object input', () => { + expect(isUrlVerification(null)).toBe(false); + expect(isUrlVerification('challenge')).toBe(false); + }); +}); + +describe('parseAppMentionEvent', () => { + const envelope = (event: Record, teamId = 'T123') => ({ + type: 'event_callback', + team_id: teamId, + event: { type: 'app_mention', channel: 'C456', text: '<@U000BOT> summarize the thread', ts: '1700000000.000100', ...event }, + }); + + it('parses a genuine app_mention and strips the bot mention token', () => { + const result = parseAppMentionEvent(envelope({}), 'U000BOT'); + expect(result).toEqual({ + platform: 'slack', + externalWorkspaceId: 'T123', + externalChannelId: 'C456', + text: 'summarize the thread', + threadTs: undefined, + messageTs: '1700000000.000100', + externalUserId: undefined, + }); + }); + + it('carries thread_ts and user when present', () => { + const result = parseAppMentionEvent(envelope({ thread_ts: '1700000000.000050', user: 'U999' }), 'U000BOT'); + expect(result?.threadTs).toBe('1700000000.000050'); + expect(result?.externalUserId).toBe('U999'); + }); + + it('best-effort strips a leading mention token when botUserId is unknown', () => { + const result = parseAppMentionEvent(envelope({})); + expect(result?.text).toBe('summarize the thread'); + }); + + it('ignores non-app_mention event types', () => { + expect(parseAppMentionEvent(envelope({ type: 'message' }))).toBeNull(); + }); + + it('ignores bot-authored messages (bot_id present)', () => { + expect(parseAppMentionEvent(envelope({ bot_id: 'B123' }))).toBeNull(); + }); + + it('ignores edited/deleted echoes (subtype present)', () => { + expect(parseAppMentionEvent(envelope({ subtype: 'message_changed' }))).toBeNull(); + }); + + it('ignores non-event_callback envelopes', () => { + expect(parseAppMentionEvent({ type: 'url_verification', challenge: 'x' })).toBeNull(); + }); + + it('ignores envelopes missing team_id/channel/text', () => { + expect(parseAppMentionEvent({ type: 'event_callback', event: { type: 'app_mention' } })).toBeNull(); + }); + + it('returns null for non-object input', () => { + expect(parseAppMentionEvent(null)).toBeNull(); + expect(parseAppMentionEvent('nope')).toBeNull(); + }); +}); + +describe('SlackChatClient.postMessage', () => { + it('posts to chat.postMessage with the bot token and returns true on ok', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }); + const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch); + const result = await client.postMessage({ channel: 'C456', text: 'hello', threadTs: '1700000000.000100' }); + expect(result).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('https://slack.com/api/chat.postMessage'); + expect(init.headers.Authorization).toBe('Bearer test-slack-bot-token'); + const body = JSON.parse(init.body); + expect(body).toEqual({ channel: 'C456', text: 'hello', thread_ts: '1700000000.000100' }); + }); + + it('returns false and does not throw on a Slack API error response', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, error: 'channel_not_found' }), + }); + const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch); + const result = await client.postMessage({ channel: 'C456', text: 'hello' }); + expect(result).toBe(false); + }); + + it('returns false and does not throw on a network error', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNRESET')); + const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch); + const result = await client.postMessage({ channel: 'C456', text: 'hello' }); + expect(result).toBe(false); + }); +}); diff --git a/src/bridge/chat/slack-adapter.ts b/src/bridge/chat/slack-adapter.ts new file mode 100644 index 0000000..c0c16ed --- /dev/null +++ b/src/bridge/chat/slack-adapter.ts @@ -0,0 +1,155 @@ +/** + * slack-adapter: Slack Events API signature verification, app_mention + * parsing, and the `chat.postMessage` reply client (issue #801, PR1). + * + * Signature verification follows Slack's documented scheme: + * https://api.slack.com/authentication/verifying-requests-from-slack + * base = "v0:" + timestamp + ":" + rawBody + * expected = "v0=" + HMAC_SHA256(signingSecret, base) + * fail-closed: any missing header, malformed signature, or a timestamp older + * than `maxAgeSec` (replay protection) is rejected. + */ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { SlackBotCredentials, ChatMentionEvent } from './chat-connector-types.js'; +import { logger } from '../../logger.js'; + +export const DEFAULT_SLACK_SIGNING_MAX_AGE_SEC = 300; // 5 minutes, matches Slack's own recommendation + +export interface SlackSignatureHeaders { + /** X-Slack-Signature header value, e.g. "v0=abcdef...". */ + signature: string | undefined; + /** X-Slack-Request-Timestamp header value (unix seconds as string). */ + timestamp: string | undefined; +} + +/** + * Verifies a Slack Events API request signature against the raw request body. + * fail-closed: returns false on any missing/malformed input, signature + * mismatch, or stale timestamp (replay protection). + */ +export function verifySlackSignature( + headers: SlackSignatureHeaders, + rawBody: Buffer | string, + signingSecret: string, + nowSec: number = Math.floor(Date.now() / 1000), + maxAgeSec: number = DEFAULT_SLACK_SIGNING_MAX_AGE_SEC, +): boolean { + const { signature, timestamp } = headers; + if (!signature || !timestamp) return false; + if (!/^\d+$/.test(timestamp)) return false; + + const tsNum = Number(timestamp); + if (!Number.isFinite(tsNum)) return false; + // Replay protection: reject requests whose timestamp is too old OR + // suspiciously in the future (clock skew abuse). fail-closed both ways. + if (Math.abs(nowSec - tsNum) > maxAgeSec) return false; + + const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8'); + const base = `v0:${timestamp}:${body}`; + const expected = 'v0=' + createHmac('sha256', signingSecret).update(base, 'utf8').digest('hex'); + + const expectedBuf = Buffer.from(expected, 'utf8'); + const actualBuf = Buffer.from(signature, 'utf8'); + if (expectedBuf.length !== actualBuf.length) return false; + try { + return timingSafeEqual(expectedBuf, actualBuf); + } catch { + return false; + } +} + +/** Slack Events API `url_verification` handshake payload. */ +export interface SlackUrlVerificationPayload { + type: 'url_verification'; + challenge: string; +} + +export function isUrlVerification(body: unknown): body is SlackUrlVerificationPayload { + return !!body && typeof body === 'object' && (body as any).type === 'url_verification' + && typeof (body as any).challenge === 'string'; +} + +interface SlackEventEnvelope { + type?: string; + team_id?: string; + event?: { + type?: string; + channel?: string; + text?: string; + thread_ts?: string; + ts?: string; + user?: string; + bot_id?: string; + subtype?: string; + }; +} + +/** + * Parses a Slack Events API `event_callback` envelope into a normalized + * ChatMentionEvent. Returns null when the event is not an app_mention, is + * missing required fields, or originates from a bot (including our own bot + * — Slack echoes app_mention only for genuine @mentions, but subtype/bot_id + * guards against edited/bot-authored messages that could otherwise loop). + */ +export function parseAppMentionEvent(body: unknown, botUserId?: string): ChatMentionEvent | null { + if (!body || typeof body !== 'object') return null; + const envelope = body as SlackEventEnvelope; + if (envelope.type !== 'event_callback') return null; + const event = envelope.event; + if (!event || event.type !== 'app_mention') return null; + if (event.bot_id) return null; // never act on bot-authored messages + if (event.subtype) return null; // ignore message_changed/deleted echoes etc. + if (!envelope.team_id || !event.channel || typeof event.text !== 'string') return null; + + // Strip the bot's own mention token (e.g. "<@U012ABC> do the thing" → "do the thing"). + let text = event.text; + if (botUserId) { + text = text.replace(new RegExp(`<@${botUserId}>`, 'g'), '').trim(); + } else { + // Best-effort: strip the first leading mention token even if we don't know our own id. + text = text.replace(/^\s*<@[A-Z0-9]+>\s*/, '').trim(); + } + + return { + platform: 'slack', + externalWorkspaceId: envelope.team_id, + externalChannelId: event.channel, + text, + threadTs: event.thread_ts, + messageTs: event.ts, + externalUserId: event.user, + }; +} + +const SLACK_API_BASE = 'https://slack.com/api'; + +/** Thin `chat.postMessage` client. Never logs the bot token. */ +export class SlackChatClient { + constructor(private readonly credentials: SlackBotCredentials, private readonly fetchImpl: typeof fetch = fetch) {} + + async postMessage(opts: { channel: string; text: string; threadTs?: string }): Promise { + try { + const res = await this.fetchImpl(`${SLACK_API_BASE}/chat.postMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${this.credentials.botToken}`, + }, + body: JSON.stringify({ + channel: opts.channel, + text: opts.text, + ...(opts.threadTs ? { thread_ts: opts.threadTs } : {}), + }), + }); + const json = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null; + if (!res.ok || !json?.ok) { + logger.warn(`[slack-adapter] chat.postMessage failed status=${res.status} error=${json?.error ?? 'unknown'}`); + return false; + } + return true; + } catch (err) { + logger.warn(`[slack-adapter] chat.postMessage error: ${err}`); + return false; + } + } +} diff --git a/src/bridge/llm-workers-api.test.ts b/src/bridge/llm-workers-api.test.ts new file mode 100644 index 0000000..e24871f --- /dev/null +++ b/src/bridge/llm-workers-api.test.ts @@ -0,0 +1,205 @@ +/** + * GET /api/llm/workers + validateLlmSelection tests. + * + * Coverage: + * - only workers with an execution role (auto|fast|quality) are returned + * - config order preserved + * - response never leaks endpoint / apiKey / extraBody + * - response shape is exactly {id, model, roles, reasoningEfforts, vlm, enabled} + * - validateLlmSelection: one case per rule + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { ConfigManager } from '../config-manager.js'; +import type { WorkerDef } from '../config.js'; +import { createLlmWorkersRouter, validateLlmSelection } from './llm-workers-api.js'; + +describe('GET /api/llm/workers', () => { + let app: express.Application; + let cm: ConfigManager; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-')); + writeFileSync(join(tempDir, 'config.yaml'), [ + 'config_version: 2', + 'llm:', + ' workers:', + ' - id: w-auto', + ' connection_type: direct', + ' endpoint: http://localhost:11434/v1', + ' model: auto-model', + ' roles: [auto, fast]', + ' reasoning_efforts: [low, high]', + ' api_key: super-secret-key', + ' extra_body:', + " foo: 'bar'", + " reasoning_effort: 'max'", + ' enabled: true', + ' - id: w-title-only', + ' connection_type: direct', + ' endpoint: http://localhost:11435/v1', + ' model: title-model', + ' roles: [title]', + ' enabled: true', + ' - id: w-reflection-only', + ' connection_type: direct', + ' endpoint: http://localhost:11436/v1', + ' model: reflection-model', + ' roles: [reflection]', + ' enabled: true', + ' - id: w-quality-disabled', + ' connection_type: direct', + ' endpoint: http://localhost:11437/v1', + ' model: quality-model', + ' roles: [quality]', + ' vlm: true', + ' enabled: false', + ].join('\n')); + cm = new ConfigManager(join(tempDir, 'config.yaml')); + app = express(); + app.use('/api/llm/workers', createLlmWorkersRouter(cm)); + }); + + it('returns only execution-role workers, in config order', async () => { + const res = await request(app).get('/api/llm/workers'); + expect(res.status).toBe(200); + const ids = res.body.workers.map((w: { id: string }) => w.id); + expect(ids).toEqual(['w-auto', 'w-quality-disabled']); + }); + + it('response objects contain exactly the 6 documented fields', async () => { + const res = await request(app).get('/api/llm/workers'); + const w = res.body.workers.find((x: { id: string }) => x.id === 'w-auto'); + expect(Object.keys(w).sort()).toEqual( + ['enabled', 'id', 'model', 'reasoningEfforts', 'roles', 'vlm'].sort(), + ); + expect(w).toMatchObject({ + id: 'w-auto', + model: 'auto-model', + roles: ['auto', 'fast'], + reasoningEfforts: ['low', 'high'], + vlm: false, + enabled: true, + }); + }); + + it('reflects enabled:false and vlm:true correctly', async () => { + const res = await request(app).get('/api/llm/workers'); + const w = res.body.workers.find((x: { id: string }) => x.id === 'w-quality-disabled'); + expect(w).toMatchObject({ enabled: false, vlm: true, roles: ['quality'] }); + }); + + it('NEVER leaks endpoint / apiKey / extraBody (secret regression guard)', async () => { + const res = await request(app).get('/api/llm/workers'); + const raw = JSON.stringify(res.body); + expect(raw).not.toContain('endpoint'); + expect(raw).not.toContain('apiKey'); + expect(raw).not.toContain('extraBody'); + // Sentinel values that WOULD serialize if the corresponding field leaked. + // w-auto seeds a real api_key AND a real extra_body ({foo:'bar', ...}); a + // non-empty extraBody is what makes the extraBody guard non-vacuous (an + // undefined value would be dropped by JSON.stringify and pass trivially). + expect(raw).not.toContain('super-secret-key'); // api_key sentinel + expect(raw).not.toContain('foo'); // extra_body sentinel + const wAuto = res.body.workers.find((x: { id: string }) => x.id === 'w-auto'); + expect(wAuto).not.toHaveProperty('extraBody'); + }); + + it('excludes title-only and reflection-only workers entirely', async () => { + const res = await request(app).get('/api/llm/workers'); + const ids = res.body.workers.map((w: { id: string }) => w.id); + expect(ids).not.toContain('w-title-only'); + expect(ids).not.toContain('w-reflection-only'); + }); +}); + +// requireAuth gating (401 when auth is active) is wired in auth-subsystem.ts as +// `app.use('/api/llm/workers', requireAuth)`, mirroring the shared pattern used by +// every other /api/* namespace (see auth-subsystem.ts). That middleware is exercised +// by the auth-subsystem integration tests; here we confirm the no-auth path (the +// router mounted standalone, as server.ts does when auth is inactive) returns 200. +describe('GET /api/llm/workers — no-auth mode', () => { + it('returns 200 without any auth middleware in front', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-noauth-')); + writeFileSync(join(tempDir, 'config.yaml'), [ + 'config_version: 2', + 'llm:', + ' workers:', + ' - id: w1', + ' connection_type: direct', + ' endpoint: http://localhost:11434/v1', + ' model: m', + ' roles: [auto]', + ' enabled: true', + ].join('\n')); + const cm2 = new ConfigManager(join(tempDir, 'config.yaml')); + const app2 = express(); + app2.use('/api/llm/workers', createLlmWorkersRouter(cm2)); + const res = await request(app2).get('/api/llm/workers'); + expect(res.status).toBe(200); + expect(res.body.workers).toHaveLength(1); + }); +}); + +describe('validateLlmSelection', () => { + const workers: WorkerDef[] = [ + { id: 'w-auto', endpoint: 'x', model: 'm', roles: ['auto'], reasoningEfforts: ['low', 'high'], enabled: true }, + { id: 'w-disabled', endpoint: 'x', model: 'm', roles: ['auto'], enabled: false }, + { id: 'w-title-only', endpoint: 'x', model: 'm', roles: ['title'], enabled: true }, + { id: 'w-no-efforts', endpoint: 'x', model: 'm', roles: ['fast'], enabled: true }, + ]; + + it('both null/undefined → ok (clearing selection)', () => { + expect(validateLlmSelection(workers, null, null)).toEqual({ ok: true }); + expect(validateLlmSelection(workers, undefined, undefined)).toEqual({ ok: true }); + }); + + it('effort provided without workerId → error', () => { + const res = validateLlmSelection(workers, null, 'high'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('ワーカー指定'); + }); + + it('workerId provided but not found in list → error', () => { + const res = validateLlmSelection(workers, 'does-not-exist', null); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('存在しません'); + }); + + it('matching worker exists but disabled → error', () => { + const res = validateLlmSelection(workers, 'w-disabled', null); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('無効化'); + }); + + it('matching worker exists but has no execution role → error', () => { + const res = validateLlmSelection(workers, 'w-title-only', null); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('実行できません'); + }); + + it('effort not in worker reasoningEfforts → error', () => { + const res = validateLlmSelection(workers, 'w-auto', 'max'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('対応していません'); + }); + + it('effort provided but worker declares no reasoningEfforts at all → error', () => { + const res = validateLlmSelection(workers, 'w-no-efforts', 'low'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('対応していません'); + }); + + it('workerId only (no effort), valid + enabled + execution role → ok', () => { + expect(validateLlmSelection(workers, 'w-auto', null)).toEqual({ ok: true }); + }); + + it('workerId + valid effort → ok', () => { + expect(validateLlmSelection(workers, 'w-auto', 'high')).toEqual({ ok: true }); + }); +}); diff --git a/src/bridge/llm-workers-api.ts b/src/bridge/llm-workers-api.ts new file mode 100644 index 0000000..eee1183 --- /dev/null +++ b/src/bridge/llm-workers-api.ts @@ -0,0 +1,108 @@ +import { Router, Request, Response } from 'express'; +import type { ConfigManager } from '../config-manager.js'; +import type { WorkerDef } from '../config.js'; +import { logger } from '../logger.js'; + +/** + * Phase 1 (LLM 選択): GET /api/llm/workers — タスクにピン留め可能なワーカー一覧。 + * + * config.provider.workers から、実行ロール(auto|fast|quality)を1つ以上持つ + * ワーカーだけを config 順で返す。title/reflection 専用ワーカーはジョブを + * 実行できないため、選択肢から除外する。 + * + * レスポンスは endpoint / apiKey / extraBody を絶対に含めない(UI に渡す + * 必要がない秘密値・内部接続先のため)。GET /api/workers(config-api.ts)は + * 管理系ダッシュボード向けで endpoint を返すが、こちらは選択 UI 専用の + * 別エンドポイント。 + * + * requireAuth は auth-subsystem.ts の `app.use('/api/llm/workers', requireAuth)` + * (認証有効時のみ登録)が担う。このルーター自身は認証状態を意識しない。 + */ + +/** タスクにピン留めしてジョブを実行できるロール。title/reflection 専用は含まない。 */ +const PINNABLE_EXECUTION_ROLES = new Set(['auto', 'fast', 'quality']); + +/** roles が未設定/空の場合は全実行ロールを持つ扱い(config.ts の normalizeWorkerDefs の既定と揃える)。 */ +export function hasPinnableExecutionRole(worker: WorkerDef): boolean { + if (!Array.isArray(worker.roles) || worker.roles.length === 0) return true; + return worker.roles.some((r) => PINNABLE_EXECUTION_ROLES.has(r)); +} + +export interface LlmWorkerListItem { + id: string; + model: string; + roles: string[]; + reasoningEfforts: string[]; + vlm: boolean; + enabled: boolean; +} + +function toListItem(w: WorkerDef): LlmWorkerListItem { + return { + id: w.id, + model: w.model ?? '', + roles: Array.isArray(w.roles) ? w.roles : [], + reasoningEfforts: Array.isArray(w.reasoningEfforts) ? w.reasoningEfforts : [], + vlm: w.vlm === true, + enabled: w.enabled !== false, + }; +} + +export function createLlmWorkersRouter(configManager: ConfigManager): Router { + const router = Router(); + + router.get('/', (_req: Request, res: Response) => { + try { + const cfg = configManager.getConfig(); + const workers = (cfg.provider?.workers ?? []) + .filter(hasPinnableExecutionRole) + .map(toListItem); + res.json({ workers }); + } catch (e) { + logger.error(`[llm-workers-api] GET / failed: ${String(e)}`); + res.status(500).json({ error: 'ワーカー一覧の取得に失敗しました' }); + } + }); + + return router; +} + +/** + * タスクへの LLM ワーカー / reasoning effort ピン留めが妥当かを検証する純関数。 + * config アクセスは呼び出し側の責務(workers は呼び出し側が渡す)— Task 11 + * の task API・Task 13/14 の UI どちらからも import できるようにするため。 + */ +export function validateLlmSelection( + workers: WorkerDef[], + workerId: string | null | undefined, + effort: string | null | undefined, +): { ok: true } | { ok: false; error: string } { + const hasWorkerId = workerId !== null && workerId !== undefined && workerId !== ''; + const hasEffort = effort !== null && effort !== undefined && effort !== ''; + + if (!hasWorkerId && !hasEffort) { + return { ok: true }; + } + if (hasEffort && !hasWorkerId) { + return { ok: false, error: 'reasoning effort はワーカー指定とセットの場合のみ指定できます' }; + } + + const worker = workers.find((w) => w.id === workerId); + if (!worker) { + return { ok: false, error: '指定されたワーカーは存在しません' }; + } + if (worker.enabled === false) { + return { ok: false, error: '指定されたワーカーは無効化されています' }; + } + if (!hasPinnableExecutionRole(worker)) { + return { ok: false, error: 'このワーカーはジョブを実行できません' }; + } + if (hasEffort) { + const efforts = Array.isArray(worker.reasoningEfforts) ? worker.reasoningEfforts : []; + if (!efforts.includes(effort as string)) { + return { ok: false, error: 'このワーカーは指定の reasoning effort に対応していません' }; + } + } + + return { ok: true }; +} diff --git a/src/bridge/local-tasks-api.test.ts b/src/bridge/local-tasks-api.test.ts index 16b31e7..6d5305f 100644 --- a/src/bridge/local-tasks-api.test.ts +++ b/src/bridge/local-tasks-api.test.ts @@ -1289,6 +1289,18 @@ describe('POST /api/local/tasks/:id/continue', () => { expect(res.body.error).toBe('piece_required'); }); + it('continuation carries over the task-level LLM pin (llmWorkerId/llmEffort) to the new job', async () => { + const { task } = await setupTaskWithTerminalJob(); + await repo.updateLocalTask(task.id, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any); + const res = await request(app) + .post(`/api/local/tasks/${task.id}/continue`) + .send({ piece: 'ssh-ops', instruction: 'go with pin' }); + expect(res.status).toBe(201); + const newJob = await repo.getJob(res.body.jobId); + expect(newJob?.requiredWorkerId).toBe('worker-gpu-1'); + expect(newJob?.reasoningEffort).toBe('high'); + }); + it('all DB-valid terminal states allow continuation', async () => { // jobs.status CHECK constraint permits these four terminal states. // 'aborted' is intentionally absent — the worker maps abort outcomes to diff --git a/src/bridge/local-tasks-api.ts b/src/bridge/local-tasks-api.ts index cd498ec..b2b10d6 100644 --- a/src/bridge/local-tasks-api.ts +++ b/src/bridge/local-tasks-api.ts @@ -8,6 +8,7 @@ import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests- import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js'; import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js'; import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js'; +import { registerLocalTaskMovementHistoryRoutes } from './local-tasks-movement-history-api.js'; export interface LocalTasksApiOptions { repo: Repository; @@ -69,6 +70,15 @@ export interface LocalTasksApiOptions { * approving a package request fails with 400 (feature disabled). */ getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined; + /** + * LLM 選択 Phase 1: タスク作成 / PATCH で llmWorkerId・llmEffort を + * validateLlmSelection (llm-workers-api.ts) にかける際の worker 一覧。 + * Called per request so config changes take effect without a restart. + * When unset, validation runs against an empty worker list (=常に + * 「指定されたワーカーは存在しません」で拒否 — worker 選択 UI が無い + * 旧来のテスト/デプロイでは llmWorkerId/llmEffort を送らない前提)。 + */ + getLlmWorkers?: () => import('../config.js').WorkerDef[]; } /** @@ -98,4 +108,5 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) registerLocalTaskCommentsRoutes(app, deps); registerLocalTaskControlRoutes(app, deps); registerLocalTaskStreamRoutes(app, deps); + registerLocalTaskMovementHistoryRoutes(app, deps); } diff --git a/src/bridge/local-tasks-comments-api.ts b/src/bridge/local-tasks-comments-api.ts index ef5190e..534b913 100644 --- a/src/bridge/local-tasks-comments-api.ts +++ b/src/bridge/local-tasks-comments-api.ts @@ -204,6 +204,13 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas visibilityScopeOrgId: task!.visibilityScopeOrgId, browserSessionProfileId: task!.browserSessionProfileId ?? null, spaceId: task!.spaceId ?? null, + // LLM 選択 Phase 1: タスクの sticky pin をフォローアップコメントで + // 生成される job にも引き継ぐ。PATCH 経由の変更は + // updateJobsLlmSelectionForTask が既存 queued/retry job に反映するが、 + // ここでの createJobIfNoPending は「新規生成 or まだ pending が無い」 + // ケースを担うので、task の現在値をそのまま渡す。 + requiredWorkerId: task!.llmWorkerId ?? null, + reasoningEffort: task!.llmEffort ?? null, }, { blockOnToolRequestPause: true }); // Blocked by a tool/package-approval pause → return BEFORE persisting the // comment so a rejected post leaves no orphan comment tied to no job. diff --git a/src/bridge/local-tasks-control-api.ts b/src/bridge/local-tasks-control-api.ts index 14b832d..be60064 100644 --- a/src/bridge/local-tasks-control-api.ts +++ b/src/bridge/local-tasks-control-api.ts @@ -114,6 +114,11 @@ export function registerLocalTaskControlRoutes(app: Application, deps: LocalTask visibilityScopeOrgId: task!.visibilityScopeOrgId, browserSessionProfileId: task!.browserSessionProfileId ?? null, spaceId: task!.spaceId ?? null, + // タスクの sticky pin を継承する(comment 経路と同じくタスクを正とする)。 + // これを渡さないと、pin 済みタスクを継続した瞬間に profile ルーティングへ + // 戻ってしまう (codex P1)。 + requiredWorkerId: task!.llmWorkerId ?? null, + reasoningEffort: task!.llmEffort ?? null, }); await repo.updateLocalTask(taskId, { pieceName: piece }); diff --git a/src/bridge/local-tasks-crud-api.llm-selection.test.ts b/src/bridge/local-tasks-crud-api.llm-selection.test.ts new file mode 100644 index 0000000..fc3a9d0 --- /dev/null +++ b/src/bridge/local-tasks-crud-api.llm-selection.test.ts @@ -0,0 +1,384 @@ +/** + * PATCH /api/local/tasks/:id + POST /api/local/tasks — LLM 選択 Phase 1 + * (Task 11): sticky worker/effort の検証・永続化・queued/retry job 反映、 + * および作成時の atomicity(不正なら task 行そのものが作られない)。 + * + * Coverage: + * 1) PATCH llmWorkerId=実在 worker → 200、task に保存、queued job に伝播 + * 2) PATCH llmWorkerId=null → 解除、queued job も NULL に戻る + * 3) PATCH llmEffort のみ(既存 llmWorkerId とペアで検証)→ 200 + * 4) PATCH llmEffort のみ(workerId 未設定タスク)→ 400 + * 5) PATCH llmWorkerId=title専用ワーカー → 400 + * 6) PATCH llmEffort=宣言外の値 → 400 + * 7) タスク作成 POST で不正 workerId → 400 かつタスク行が作られない(atomicity) + * 8) コメント POST → 生成 job に task の sticky が乗る + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository, localTaskRepoName } from '../db/repository.js'; +import { mountLocalTasksApi } from './local-tasks-api.js'; +import type { WorkerDef } from '../config.js'; + +const WORKERS: WorkerDef[] = [ + { + id: 'w1', + endpoint: 'http://localhost:11434/v1', + model: 'w1-model', + roles: ['auto', 'fast'], + reasoningEfforts: ['low', 'high'], + enabled: true, + }, + { + id: 'wt', + endpoint: 'http://localhost:11435/v1', + model: 'wt-model', + roles: ['title'], + enabled: true, + }, +]; + +describe('PATCH /api/local/tasks/:id — llmWorkerId/llmEffort sticky selection', () => { + let tempDir = ''; + let repo: Repository; + let app: express.Application; + let aliceUser: Express.User; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); + aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null }; + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: Express.User }).user = aliceUser; + next(); + }); + mountLocalTasksApi(app, { + repo, + worktreeDir: join(tempDir, 'workspaces'), + getLlmWorkers: () => WORKERS, + }); + }); + + afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + async function makeTaskWithQueuedJob(): Promise<{ taskId: number; jobId: string }> { + const task = await repo.createLocalTask({ + title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private', + workspacePath: join(tempDir, 'ws'), + }); + const job = await repo.createJob({ + repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + }); + return { taskId: task.id, jobId: job.id }; + } + + function requiredWorkerIdOf(jobId: string): string | null { + const row = repo.getDb() + .prepare('SELECT required_worker_id FROM jobs WHERE id = ?') + .get(jobId) as { required_worker_id: string | null }; + return row.required_worker_id; + } + + it('1) sets llmWorkerId, persists on the task, and propagates to a queued job', async () => { + const { taskId, jobId } = await makeTaskWithQueuedJob(); + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' }); + expect(res.status).toBe(200); + expect(res.body.task.llmWorkerId).toBe('w1'); + expect(requiredWorkerIdOf(jobId)).toBe('w1'); + }); + + it('2) clearing llmWorkerId with explicit null reverts the queued job to NULL', async () => { + const { taskId, jobId } = await makeTaskWithQueuedJob(); + await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' }); + expect(requiredWorkerIdOf(jobId)).toBe('w1'); + + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null }); + expect(res.status).toBe(200); + expect(res.body.task.llmWorkerId).toBeNull(); + expect(requiredWorkerIdOf(jobId)).toBeNull(); + }); + + it('2b) clearing llmWorkerId alone (no llmEffort in the body) while llmEffort is still set on the task also clears the effort (asymmetric-clear hardening)', async () => { + // Given: worker + effort が両方セット済み(non-UI クライアントが片方だけ + // 送ってくるケースを再現 — 一次 UI は常にペアで送るが A2A/bench/curl はそうとは限らない)。 + const { taskId, jobId } = await makeTaskWithQueuedJob(); + const setBoth = await request(app) + .patch(`/api/local/tasks/${taskId}`) + .send({ llmWorkerId: 'w1', llmEffort: 'high' }); + expect(setBoth.status).toBe(200); + expect(requiredWorkerIdOf(jobId)).toBe('w1'); + + // When: llmWorkerId のみを null で送る(llmEffort フィールドは body に含めない)。 + // 修正前は nextEffort が旧値 'high' のまま残り、effort-without-worker として 400 になっていた。 + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null }); + + // Then: 200 で成功し、worker/effort の両方が null に揃って解除される。 + expect(res.status).toBe(200); + expect(res.body.task.llmWorkerId).toBeNull(); + expect(res.body.task.llmEffort).toBeNull(); + expect(requiredWorkerIdOf(jobId)).toBeNull(); + }); + + it('3) sets llmEffort alone, validated as a pair against the task\'s existing llmWorkerId', async () => { + const { taskId } = await makeTaskWithQueuedJob(); + const setWorker = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' }); + expect(setWorker.status).toBe(200); + + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' }); + expect(res.status).toBe(200); + expect(res.body.task.llmWorkerId).toBe('w1'); + expect(res.body.task.llmEffort).toBe('high'); + }); + + it('4) rejects llmEffort alone when the task has no llmWorkerId (pair invalid)', async () => { + const { taskId } = await makeTaskWithQueuedJob(); + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' }); + expect(res.status).toBe(400); + }); + + it('5) rejects a title-only worker (no pinnable execution role)', async () => { + const { taskId } = await makeTaskWithQueuedJob(); + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'wt' }); + expect(res.status).toBe(400); + }); + + it('6) rejects an effort not declared by the worker', async () => { + const { taskId } = await makeTaskWithQueuedJob(); + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'bogus' }); + expect(res.status).toBe(400); + }); + + it('does not touch llmWorkerId/llmEffort when neither field is present in the PATCH body', async () => { + const { taskId, jobId } = await makeTaskWithQueuedJob(); + await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'high' }); + const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ title: 'renamed' }); + expect(res.status).toBe(200); + expect(res.body.task.llmWorkerId).toBe('w1'); + expect(res.body.task.llmEffort).toBe('high'); + expect(requiredWorkerIdOf(jobId)).toBe('w1'); + }); +}); + +describe('POST /api/local/tasks — llmWorkerId/llmEffort validated before insert (atomicity)', () => { + let tempDir = ''; + let repo: Repository; + let app: express.Application; + let aliceUser: Express.User; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-create-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); + aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null }; + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: Express.User }).user = aliceUser; + next(); + }); + mountLocalTasksApi(app, { + repo, + worktreeDir: join(tempDir, 'workspaces'), + getLlmWorkers: () => WORKERS, + }); + }); + + afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('7) rejects an unknown llmWorkerId with 400 and creates no task row', async () => { + const countBefore = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c; + const res = await request(app).post('/api/local/tasks').send({ + body: 'hello', piece: 'chat', llmWorkerId: 'does-not-exist', + }); + expect(res.status).toBe(400); + const countAfter = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c; + expect(countAfter).toBe(countBefore); + }); + + it('accepts a valid llmWorkerId/llmEffort pair at creation and stamps the spawned job', async () => { + const res = await request(app).post('/api/local/tasks').send({ + body: 'hello', piece: 'chat', llmWorkerId: 'w1', llmEffort: 'low', + }); + expect(res.status).toBe(201); + expect(res.body.task.llmWorkerId).toBe('w1'); + expect(res.body.task.llmEffort).toBe('low'); + const jobRow = repo.getDb() + .prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?') + .get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null }; + expect(jobRow.required_worker_id).toBe('w1'); + expect(jobRow.reasoning_effort).toBe('low'); + }); +}); + +describe('PATCH /api/local/tasks/:id — llmWorkerId cascade reaches unstarted subtask descendants (codex P1)', () => { + let tempDir = ''; + let repo: Repository; + let app: express.Application; + let aliceUser: Express.User; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-subtask-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); + aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null }; + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: Express.User }).user = aliceUser; + next(); + }); + mountLocalTasksApi(app, { + repo, + worktreeDir: join(tempDir, 'workspaces'), + getLlmWorkers: () => WORKERS, + }); + }); + + afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + function requiredWorkerIdOf(jobId: string): string | null { + const row = repo.getDb() + .prepare('SELECT required_worker_id FROM jobs WHERE id = ?') + .get(jobId) as { required_worker_id: string | null }; + return row.required_worker_id; + } + + it('clears the pin on a queued subtask child while leaving the waiting_subtasks parent untouched', async () => { + // Given: タスク直下ジョブが 'w1' にピン留めされ waiting_subtasks で停車中 + // (SpawnSubTask がピンを継承した queued の子を抱えている)。w1 が + // 停滞/削除されると子は永久に claim されず、親も永久に waiting_subtasks + // のまま — これが codex P1 のシナリオ。 + const task = await repo.createLocalTask({ + title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private', + workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high', + }); + const rootJob = await repo.createJob({ + repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + requiredWorkerId: 'w1', reasoningEffort: 'high', + } as any); + await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' }); + + const subtaskJob = await repo.createJob({ + repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id, + } as any); + + // When: ユーザーがピンを解除する(UI の「自動に戻す」復旧操作)。 + const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null }); + expect(res.status).toBe(200); + + // Then: queued subtask child は復旧(NULL = auto)— これが修正前は + // 'w1' のまま残り、削除済みワーカーを永遠に待ち続けていた (RED)。 + expect(requiredWorkerIdOf(subtaskJob.id)).toBeNull(); + + // And: waiting_subtasks の親自身は queued/retry ではないので不変 + // (走行中扱いの行を横から書き換えない)。 + const parentRow = repo.getDb() + .prepare('SELECT required_worker_id, status FROM jobs WHERE id = ?') + .get(rootJob.id) as { required_worker_id: string | null; status: string }; + expect(parentRow.status).toBe('waiting_subtasks'); + expect(parentRow.required_worker_id).toBe('w1'); + }); + + it('cascades through a 2-level-deep subtask (grandchild) as well', async () => { + const task = await repo.createLocalTask({ + title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private', + workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high', + }); + const rootJob = await repo.createJob({ + repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + requiredWorkerId: 'w1', reasoningEffort: 'high', + } as any); + await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' }); + + const childJob = await repo.createJob({ + repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id, + } as any); + await repo.updateJob(childJob.id, { status: 'waiting_subtasks' }); + + const grandchildJob = await repo.createJob({ + repo: `subtask/${childJob.id}`, issueNumber: 1, instruction: 'sub sub work', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: childJob.id, + } as any); + + const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null }); + expect(res.status).toBe(200); + + expect(requiredWorkerIdOf(grandchildJob.id)).toBeNull(); + }); +}); + +describe('POST /api/local/tasks/:id/comments — inherits the task sticky selection into the spawned job', () => { + let tempDir = ''; + let repo: Repository; + let app: express.Application; + let aliceUser: Express.User; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-comment-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); + aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null }; + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: Express.User }).user = aliceUser; + next(); + }); + mountLocalTasksApi(app, { + repo, + worktreeDir: join(tempDir, 'workspaces'), + getLlmWorkers: () => WORKERS, + }); + }); + + afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('8) a comment on a task pinned to w1 spawns a job with required_worker_id=w1', async () => { + const task = await repo.createLocalTask({ + title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private', + workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high', + }); + // Prior job already terminal so the comment spawns a NEW job (not reuse). + const prev = await repo.createJob({ + repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go', + ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null, + }); + await repo.updateJob(prev.id, { status: 'succeeded' }); + + const res = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'again', author: 'user' }); + expect(res.status).toBe(201); + expect(res.body.jobId).not.toBe(prev.id); + + const jobRow = repo.getDb() + .prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?') + .get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null }; + expect(jobRow.required_worker_id).toBe('w1'); + expect(jobRow.reasoning_effort).toBe('high'); + }); +}); diff --git a/src/bridge/local-tasks-crud-api.ts b/src/bridge/local-tasks-crud-api.ts index 0ca1482..1bdb405 100644 --- a/src/bridge/local-tasks-crud-api.ts +++ b/src/bridge/local-tasks-crud-api.ts @@ -10,6 +10,7 @@ import { buildTitleFallback } from '../title-generation.js'; import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js'; import { spaceRunsDir } from '../spaces/runtime-paths.js'; import { type LocalTasksDeps, makeDynamicJson } from './local-tasks-shared.js'; +import { validateLlmSelection } from './llm-workers-api.js'; /** * Core task lifecycle CRUD: list / create / detail / patch / delete. @@ -82,6 +83,27 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe browserSessionProfileId = n; } + // LLM 選択 Phase 1: タスク作成時の pin。CreateTaskDialog (Task 13) が + // llmWorkerId/llmEffort を送る想定。insert前にvalidateLlmSelectionで + // 検証し、不正なら「タスクは作られない」原子性を保つ(fail-before-insert)。 + const rawLlmWorkerId = req.body?.llmWorkerId; + const rawLlmEffort = req.body?.llmEffort; + if (rawLlmWorkerId !== undefined && rawLlmWorkerId !== null && typeof rawLlmWorkerId !== 'string') { + res.status(400).json({ error: 'llmWorkerId must be a string or null' }); + return; + } + if (rawLlmEffort !== undefined && rawLlmEffort !== null && typeof rawLlmEffort !== 'string') { + res.status(400).json({ error: 'llmEffort must be a string or null' }); + return; + } + const llmWorkerId: string | null = (typeof rawLlmWorkerId === 'string' && rawLlmWorkerId.trim() !== '') ? rawLlmWorkerId : null; + const llmEffort: string | null = (typeof rawLlmEffort === 'string' && rawLlmEffort.trim() !== '') ? rawLlmEffort : null; + const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], llmWorkerId, llmEffort); + if (!llmValidation.ok) { + res.status(400).json({ error: llmValidation.error }); + return; + } + const userTitle = (body.title ?? '').trim(); const rawPiece = (body.piece ?? 'auto').trim(); const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[]; @@ -153,6 +175,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe visibilityScopeOrgId: effectiveScopeOrgId, browserSessionProfileId, options: taskOptions, + llmWorkerId, + llmEffort, }); // 実効スペースを解決し、mode に応じてワークスペースパスを決める。 @@ -224,6 +248,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe browserSessionProfileId: task.browserSessionProfileId ?? null, spaceId: task.spaceId ?? null, payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined, + requiredWorkerId: task.llmWorkerId ?? null, + reasoningEffort: task.llmEffort ?? null, }); await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id }); @@ -265,7 +291,14 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined }); if (!checkTaskOwnership(req, res, task)) return; - const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {}; + const updates: { + title?: string; + titleSource?: 'user'; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + llmWorkerId?: string | null; + llmEffort?: string | null; + } = {}; if (req.body.title !== undefined) { if (typeof req.body.title !== 'string') { res.status(400).json({ error: 'title must be a string' }); return; @@ -298,6 +331,46 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe if (updates.visibility && updates.visibility !== 'org') { updates.visibilityScopeOrgId = null; } + + // LLM 選択 Phase 1: sticky worker/effort の部分 PATCH。片方だけ送られた + // 場合は、もう片方をタスクの既存値と組み合わせて「ペアとして」検証する + // (例: effort だけ送ると既に pin 済みの workerId と組み合わせて妥当性を + // 見る — effort 単体の妥当性は worker 抜きでは判定できないため)。 + // 明示 null は解除(ワーカー未選択に戻し、従来の profile ルーティングへ)。 + const hasWorkerIdField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmWorkerId'); + const hasEffortField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmEffort'); + if (hasWorkerIdField || hasEffortField) { + if (hasWorkerIdField && req.body.llmWorkerId !== null && typeof req.body.llmWorkerId !== 'string') { + res.status(400).json({ error: 'llmWorkerId must be a string or null' }); return; + } + if (hasEffortField && req.body.llmEffort !== null && typeof req.body.llmEffort !== 'string') { + res.status(400).json({ error: 'llmEffort must be a string or null' }); return; + } + const nextWorkerId: string | null = hasWorkerIdField + ? ((typeof req.body.llmWorkerId === 'string' && req.body.llmWorkerId.trim() !== '') ? req.body.llmWorkerId : null) + : (task!.llmWorkerId ?? null); + let nextEffort: string | null = hasEffortField + ? ((typeof req.body.llmEffort === 'string' && req.body.llmEffort.trim() !== '') ? req.body.llmEffort : null) + : (task!.llmEffort ?? null); + // ワーカーが「このリクエストで」明示的に null 指定された場合(解除)、 + // 既存の effort が残っていると「effort だけがワーカー無しで残る」不整合 + // な組み合わせになり、後段の validateLlmSelection が effort-without-worker + // として 400 を返してしまう。ワーカー解除は常に成功すべき操作なので、 + // effort も道連れで null にする(バリデーション前)。 + // 注意: hasWorkerIdField で「このリクエストが worker フィールドに触れたか」 + // を見る必要がある —— worker フィールド自体が未送信で、たまたま既存の + // task に worker が無い(かつ effort だけを新規に送った)ケースは + // 「clear」ではなく単なる effort-without-worker の不正入力なので、 + // 引き続き 400 になるべき(ケース4のテストが固定している)。 + if (hasWorkerIdField && nextWorkerId == null) nextEffort = null; + const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], nextWorkerId, nextEffort); + if (!llmValidation.ok) { + res.status(400).json({ error: llmValidation.error }); return; + } + updates.llmWorkerId = nextWorkerId; + updates.llmEffort = nextEffort; + } + await repo.updateLocalTask(taskId, updates); const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined }); if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) { @@ -306,6 +379,16 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null, }); } + // Sticky ジョブ反映: PATCH 直前に生成された queued/retry ジョブ(例えば + // 数瞬前のコメントで作られた分)が古い(または未選択の)pin を持ったまま + // にならないよう、確定した選択を即座に伝播する。ワーカーが消えた場合の + // 解除(null)も同じ経路で待機中ジョブへ届き、通常ルーティングに戻る。 + if ((updates.llmWorkerId !== undefined || updates.llmEffort !== undefined) && refreshed) { + await repo.updateJobsLlmSelectionForTask(taskId, { + workerId: refreshed.llmWorkerId ?? null, + effort: refreshed.llmEffort ?? null, + }); + } res.json({ task: refreshed }); } catch (err) { logger.error(`Patch local task API error: ${err}`); diff --git a/src/bridge/local-tasks-movement-history-api.test.ts b/src/bridge/local-tasks-movement-history-api.test.ts new file mode 100644 index 0000000..5e70702 --- /dev/null +++ b/src/bridge/local-tasks-movement-history-api.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { readMovementHistory } from './local-tasks-movement-history-api.js'; + +const dirs: string[] = []; + +const event = (kind: string, payload: Record, seq: number) => JSON.stringify({ + v: 1, + ts: `2026-07-12T00:00:0${seq}.000Z`, + seq, + eventId: `e${seq}`, + runId: 'run-1', + movement: 'verify', + iteration: 2, + kind, + payload, +}); + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('readMovementHistory', () => { + it('returns only movement events and strips free-form retry reasons', async () => { + const dir = mkdtempSync(join(tmpdir(), 'movement-history-')); + dirs.push(dir); + const path = join(dir, 'events.jsonl'); + writeFileSync(path, [ + event('movement_start', { instruction: 'secret' }, 1), + event('tool_call', { args: { token: 'secret' } }, 2), + event('llm_call_retry', { attempt: 2, maxAttempts: 3, reason: 'Bearer secret', errorClass: 'http', httpStatus: 503, delayMs: 500 }, 3), + event('movement_complete', { next: 'done', outputPreview: 'private output' }, 4), + ].join('\n')); + + const result = await readMovementHistory(path); + expect(result.map(item => item.kind)).toEqual(['movement_start', 'llm_call_retry', 'movement_complete']); + expect(result[1]?.payload).toEqual({ attempt: 2, maxAttempts: 3, errorClass: 'http', httpStatus: 503, delayMs: 500 }); + expect(JSON.stringify(result)).not.toContain('secret'); + expect(JSON.stringify(result)).not.toContain('private output'); + }); + + it('returns an empty history for a missing log', async () => { + await expect(readMovementHistory('/does/not/exist/events.jsonl')).resolves.toEqual([]); + }); +}); diff --git a/src/bridge/local-tasks-movement-history-api.ts b/src/bridge/local-tasks-movement-history-api.ts new file mode 100644 index 0000000..4e6638a --- /dev/null +++ b/src/bridge/local-tasks-movement-history-api.ts @@ -0,0 +1,108 @@ +import { type Application, type Request, type Response } from 'express'; +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { logger } from '../logger.js'; +import { parseEventLine } from '../progress/event-log.js'; +import { logRoot } from '../spaces/runtime-paths.js'; +import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js'; +import { type LocalTasksDeps } from './local-tasks-shared.js'; +import { parseTaskId } from './validation.js'; + +const MAX_HISTORY_BYTES = 8 * 1024 * 1024; +const MAX_HISTORY_EVENTS = 2_000; +const ALLOWED_KINDS = new Set(['movement_start', 'movement_complete', 'llm_call_retry']); + +export interface MovementHistoryEvent { + eventId: string; + ts: string; + seq: number; + line: number; + runId: string; + kind: 'movement_start' | 'movement_complete' | 'llm_call_retry'; + movement: string | null; + iteration: number | null; + payload: Record; +} + +const readTailLines = async (path: string): Promise => { + let file; + try { + file = await open(path, 'r'); + const size = (await file.stat()).size; + const start = Math.max(0, size - MAX_HISTORY_BYTES); + const buffer = Buffer.alloc(size - start); + await file.read(buffer, 0, buffer.length, start); + const text = buffer.toString('utf8'); + const lines = text.split('\n'); + if (start > 0) lines.shift(); + return lines; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } finally { + await file?.close(); + } +}; + +const safePayload = (kind: string, payload: unknown): Record => { + const source = payload && typeof payload === 'object' ? payload as Record : {}; + if (kind === 'movement_complete') { + return { + next: typeof source.next === 'string' ? source.next : null, + waitReason: typeof source.waitReason === 'string' ? source.waitReason : null, + }; + } + if (kind === 'llm_call_retry') { + return { + attempt: typeof source.attempt === 'number' ? source.attempt : null, + maxAttempts: typeof source.maxAttempts === 'number' ? source.maxAttempts : null, + errorClass: typeof source.errorClass === 'string' ? source.errorClass : null, + httpStatus: typeof source.httpStatus === 'number' ? source.httpStatus : null, + delayMs: typeof source.delayMs === 'number' ? source.delayMs : null, + }; + } + return {}; +}; + +export const readMovementHistory = async (path: string): Promise => { + const events: MovementHistoryEvent[] = []; + const lines = await readTailLines(path); + for (let line = 0; line < lines.length; line++) { + const raw = lines[line]; + if (!raw?.trim()) continue; + const parsed = parseEventLine(raw); + if (parsed.kind !== 'ok' || !ALLOWED_KINDS.has(parsed.event.kind)) continue; + events.push({ + eventId: parsed.event.eventId, + ts: parsed.event.ts, + seq: parsed.event.seq, + line, + runId: parsed.event.runId, + kind: parsed.event.kind as MovementHistoryEvent['kind'], + movement: parsed.event.movement ?? null, + iteration: parsed.event.iteration ?? null, + payload: safePayload(parsed.event.kind, parsed.event.payload), + }); + } + return events.slice(-MAX_HISTORY_EVENTS); +}; + +export const registerLocalTaskMovementHistoryRoutes = (app: Application, deps: LocalTasksDeps): void => { + app.get('/api/local/tasks/:taskId/movement-history', async (req: Request, res: Response) => { + try { + const taskId = parseTaskId(req.params.taskId); + if (taskId === null) { + res.status(400).json({ error: 'Invalid task ID' }); + return; + } + const viewer = req.user as Express.User | undefined; + const task = await deps.repo.getLocalTask(taskId, viewer ? { viewer } : undefined); + if (!canViewTask(req, res, task, resolveSpaceAccess(deps.repo, task, viewer))) return; + const path = join(logRoot(task!), 'events.jsonl'); + res.json({ events: await readMovementHistory(path) }); + } catch (err) { + logger.error(`Movement history API error: ${err}`); + res.status(500).json({ error: 'Failed to fetch movement history' }); + } + }); +}; diff --git a/src/bridge/server-subsystems.test.ts b/src/bridge/server-subsystems.test.ts index 1473986..c7e3e18 100644 --- a/src/bridge/server-subsystems.test.ts +++ b/src/bridge/server-subsystems.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import request from 'supertest'; @@ -28,6 +28,7 @@ describe('createCoreServer subsystem wiring', () => { afterEach(() => { delete process.env.MCP_ENCRYPTION_KEY; + delete process.env.AAO_CONFIG; repo.close(); rmSync(tempDir, { recursive: true, force: true }); }); @@ -57,6 +58,20 @@ describe('createCoreServer subsystem wiring', () => { expect(res.status).toBe(404); }); + it('mounts the inactive console status stub when SSH is enabled but the key is absent', async () => { + const configPath = join(tempDir, 'config.yaml'); + writeFileSync(configPath, 'ssh:\n enabled: true\n'); + process.env.AAO_CONFIG = configPath; + delete process.env.MCP_ENCRYPTION_KEY; + + const { app, sshConsole } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') }); + expect(sshConsole).toBeNull(); + + const statusRes = await request(app).get('/api/local/tasks/some-task/console/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body).toEqual({ active: false }); + }); + it('boots and serves /api/local/tasks regardless of MCP key', async () => { const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') }); const res = await request(app).get('/api/local/tasks'); diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 8175f5f..ac027fe 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -18,6 +18,7 @@ import { createBrowserSessionApi } from './browser-session-api.js'; import { createSubtaskActivityRouter } from './subtask-activity-api.js'; import { createDelegateRunsRouter } from './delegate-runs-api.js'; import { createUsageRouter } from './usage-api.js'; +import { createLlmWorkersRouter } from './llm-workers-api.js'; import { SessionManager } from '../engine/browser-session.js'; import { setupNovncWebSocketProxy } from './novnc-proxy.js'; import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js'; @@ -62,6 +63,7 @@ import { attachConsoleWs } from './console-ws-api.js'; import type { GatewayMountHandle } from './gateway-mount.js'; import { mountBridgeGateway } from './bridge-gateway-mount.js'; import { setupA2aSubsystem } from './a2a-subsystem.js'; +import { setupChatSubsystem } from './chat/chat-subsystem.js'; import { createServer as createHttpsServer } from 'https'; import { X509Certificate } from 'crypto'; import { mergeServerConfig, resolveListenPort } from '../server/config.js'; @@ -282,6 +284,10 @@ export function createCoreServer(opts: CoreServerOptions): { : () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50, // Package-request approvals install into the task's space overlay. getPythonPackagesConfig: () => loadConfig().pythonPackages, + // LLM 選択 Phase 1: タスク作成/PATCH の validateLlmSelection に渡す worker 一覧。 + getLlmWorkers: opts.configManager + ? () => opts.configManager!.getConfig().provider?.workers ?? [] + : () => loadConfig().provider?.workers ?? [], }); // --- Local files API --- @@ -294,8 +300,20 @@ export function createCoreServer(opts: CoreServerOptions): { // 横断(全スペース)カレンダー: GET /api/calendar/cross app.use('/api/calendar', createCrossCalendarApi({ repo, authActive })); + // LLM ワーカー選択 API(タスクへの pin 用): GET /api/llm/workers + // 実行ロール(auto/fast/quality)を持つワーカーのみ・秘密値非返却。 + // requireAuth は auth-subsystem.ts 側(認証有効時のみ)で配線。 + if (opts.configManager) { + app.use('/api/llm/workers', createLlmWorkersRouter(opts.configManager)); + } + // --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) --- - setupA2aSubsystem(app, { repo, authActive }); + const { limiter: a2aLimiter } = setupA2aSubsystem(app, { repo, authActive }); + + // --- External chat connector (see chat/chat-subsystem.ts; no-op unless chat.enabled) --- + // Reuses the A2A limiter instance above so governance counters are shared + // per-grant across the JSON-RPC A2A path and the chat path. + setupChatSubsystem(app, { repo, authActive, a2aLimiter }); // --- Subtask files API (listing MUST come before wildcard) --- mountSubtaskFilesApi(app, repo); diff --git a/src/bridge/setup-api.ts b/src/bridge/setup-api.ts index 30e361d..076e833 100644 --- a/src/bridge/setup-api.ts +++ b/src/bridge/setup-api.ts @@ -306,11 +306,16 @@ export function mountSetupApi( // when enabled. Expose the flag so the UI can hide the per-user A2A Delegations // settings section instead of showing a load error against the missing route. const a2aEnabled = ((configManager.getConfig() as { a2a?: { enabled?: boolean } })?.a2a?.enabled) === true; - res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled }); + // chat is off by default and mounts its /api/admin/chat/bindings route only + // when enabled (see chat-subsystem.ts). Expose the flag the same way a2a + // does so the UI can hide the admin Chat Connectors settings section + // instead of showing a load error against the missing route. + const chatEnabled = ((configManager.getConfig() as { chat?: { enabled?: boolean } })?.chat?.enabled) === true; + res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled, chatEnabled }); } catch (e) { logger.warn(`[setup-api] status failed: ${String(e)}`); - // Fail-closed: on error the UI treats a2a as disabled and hides the section. - res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, error: 'status unavailable' }); + // Fail-closed: on error the UI treats a2a/chat as disabled and hides the sections. + res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, chatEnabled: false, error: 'status unavailable' }); } }); diff --git a/src/bridge/space-api.calendar.test.ts b/src/bridge/space-api.calendar.test.ts index 9a72192..39087eb 100644 --- a/src/bridge/space-api.calendar.test.ts +++ b/src/bridge/space-api.calendar.test.ts @@ -191,6 +191,33 @@ describe('space-api calendar', () => { }); }); + describe('reminder validation', () => { + it('rejects removing the start time while an existing reminder remains', async () => { + const created = await request(appAs(owner)) + .post(`/api/local/spaces/${spaceId}/calendar/events`) + .send({ date: '2026-07-01', title: 'remind me', time: '09:00', reminder_minutes: 10 }); + expect(created.status).toBe(201); + + const patched = await request(appAs(owner)) + .patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`) + .send({ time: null }); + expect(patched.status).toBe(400); + expect(patched.body.error).toContain('reminder_minutes requires a start time'); + }); + + it('allows removing a reminder while changing the event to all-day', async () => { + const created = await request(appAs(owner)) + .post(`/api/local/spaces/${spaceId}/calendar/events`) + .send({ date: '2026-07-01', title: 'clear reminder', time: '09:00', reminder_minutes: 10 }); + const patched = await request(appAs(owner)) + .patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`) + .send({ time: null, reminder_minutes: null }); + expect(patched.status).toBe(200); + expect(patched.body.time).toBeNull(); + expect(patched.body.reminderMinutes).toBeNull(); + }); + }); + describe('multi-day events via HTTP', () => { it('creates a span and surfaces it on every covered day in the month view', async () => { const created = await request(appAs(owner)) diff --git a/src/bridge/space-calendar-api.ts b/src/bridge/space-calendar-api.ts index 6ac6daf..9808bea 100644 --- a/src/bridge/space-calendar-api.ts +++ b/src/bridge/space-calendar-api.ts @@ -152,6 +152,9 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): const multiDay = endDate != null && endDate > date; if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' }); } + const reminderMinutes = req.body?.reminder_minutes == null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes); + if (reminderMinutes != null && ![0, 5, 10, 30, 60].includes(reminderMinutes)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' }); + if (reminderMinutes != null && time == null) return res.status(400).json({ error: 'reminder_minutes requires a start time' }); const ownerId = viewer.id === 'local' ? null : viewer.id; const ev = await repo.createCalendarEvent({ spaceId: space.id, @@ -160,6 +163,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): endDate, time, endTime, + reminderMinutes, title, description: req.body?.description != null ? String(req.body.description) : null, createdBy: 'user', @@ -179,7 +183,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): const eventId = Number(req.params.eventId); const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null; if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' }); - const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null } = {}; + const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null } = {}; if (req.body?.date !== undefined) { const d = String(req.body.date); if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' }); @@ -211,12 +215,21 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' }); patch.endTime = et; } + if (req.body?.reminder_minutes !== undefined) { + const value = req.body.reminder_minutes === null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes); + if (value != null && ![0, 5, 10, 30, 60].includes(value)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' }); + patch.reminderMinutes = value; + } // 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて // end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、 // 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。 { const effTime = patch.time !== undefined ? patch.time : existing.time; const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime; + const effReminderMinutes = patch.reminderMinutes !== undefined ? patch.reminderMinutes : existing.reminderMinutes; + if (effReminderMinutes != null && effTime == null) { + return res.status(400).json({ error: 'reminder_minutes requires a start time' }); + } if (effEndTime != null) { if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' }); const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate; @@ -251,4 +264,16 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): await repo.deleteCalendarEvent(eventId); res.status(204).end(); }); + + // 表示中のクライアントが期限到来通知を一度だけ取得する。サーバー現在時刻で + // 判定し、クライアントが未来時刻を指定して先取りできないようにする。 + router.post('/:id/calendar/reminders/claim', jsonParser, async (req, res) => { + const viewer = viewerOf(req, deps.authActive); + const space = await repo.getSpace(req.params.id, { viewer }); + if (!space) return res.status(404).json({ error: 'not found' }); + const current = new Date(); + const now = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')} ${String(current.getHours()).padStart(2, '0')}:${String(current.getMinutes()).padStart(2, '0')}`; + const events = await repo.claimDueCalendarReminders(space.id, now); + res.json({ events }); + }); } diff --git a/src/bridge/ssh-subsystem.ts b/src/bridge/ssh-subsystem.ts index 0cd22ba..bd33b16 100644 --- a/src/bridge/ssh-subsystem.ts +++ b/src/bridge/ssh-subsystem.ts @@ -92,17 +92,19 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe let sshConsole: SshConsoleDeps | null = null; const sshConfig = mergeSshConfig(loadConfig().ssh); - if (!sshConfig.enabled) { + const sshAvailable = sshConfig.enabled && isKeyConfigured(); + if (!sshAvailable) { setSshSubsystem(null); __setActiveSessionLookup(null); // SSH is off: the real console/status router (and the registry / // resolveTask / resolveSshAccess it needs) never gets mounted below, // but the UI still polls GET .../console/status every 5s regardless of // SSH config. Without a stand-in, that poll 404s forever and spams the - // DevTools console (issue #785). Mount a minimal stub that always - // answers `{ active: false }` — scope note: the sibling - // `!isKeyConfigured()` branch below has the SAME 404-polling problem - // but is deliberately left untouched here (separate issue). + // DevTools console (issues #785, #813). Mount a minimal stub that always + // answers `{ active: false }`. + if (sshConfig.enabled) { + logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled'); + } app.use( '/api', createConsoleStatusStubRouter({ @@ -110,10 +112,6 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), }), ); - } else if (!isKeyConfigured()) { - logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled'); - setSshSubsystem(null); - __setActiveSessionLookup(null); } else { try { bootstrapSystemDek(repo.getDb()); diff --git a/src/config.ts b/src/config.ts index ae78236..b4414fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -540,6 +540,27 @@ export interface WebhooksConfig { publicBaseUrl?: string; } +/** + * External chat connector (issue #801, PR1 — Slack MVP). Master switch is + * `chat.enabled` (default false). Reuses the existing A2A execution/governance + * path in-process; there is no separate authz surface. snake_case in YAML: + * chat.{enabled,public_base_url,slack.signing_secret_max_age_sec} + */ +export interface ChatConfig { + /** Master switch for the chat connector subsystem. Default false. */ + enabled?: boolean; + /** Absolute base URL used to build task-detail links in reply messages + * (e.g. "https://maestro.example.com"). Falls back to a relative path + * when unset. */ + publicBaseUrl?: string; + slack?: { + /** Reject Slack Events API requests whose X-Slack-Request-Timestamp is + * older than this many seconds (replay protection). Default 300 (5 min), + * matching Slack's own recommendation. */ + signingSecretMaxAgeSec?: number; + }; +} + export interface AppConfig { /** * Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1` @@ -616,6 +637,7 @@ export interface AppConfig { maxConcurrentResubscribe?: number; }; }; + chat?: ChatConfig; } const DEFAULT_REFLECTION: ReflectionConfig = { diff --git a/src/db/migrate.chat-connector-events.test.ts b/src/db/migrate.chat-connector-events.test.ts new file mode 100644 index 0000000..43081c9 --- /dev/null +++ b/src/db/migrate.chat-connector-events.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from './migrate.js'; +import { claimChatConnectorEvent } from './repositories/chat-connectors.js'; + +describe('chat connector processed-event migration', () => { + it('adds an idempotent event claim table to an existing bindings database', () => { + const db = new Database(':memory:'); + try { + db.exec(` + CREATE TABLE chat_connector_bindings ( + id TEXT PRIMARY KEY, platform TEXT NOT NULL, external_workspace_id TEXT NOT NULL, + external_channel_id TEXT NOT NULL, space_id TEXT NOT NULL, a2a_client_id TEXT NOT NULL, + a2a_delegation_id TEXT NOT NULL, a2a_grant_id TEXT NOT NULL, + bot_credentials_enc BLOB NOT NULL, key_version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + runMigrations(db); + + expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_connector_processed_events'").get()).toBeTruthy(); + expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(true); + expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(false); + } finally { + db.close(); + } + }); +}); diff --git a/src/db/migrate.llm-selection.test.ts b/src/db/migrate.llm-selection.test.ts new file mode 100644 index 0000000..1b24e8a --- /dev/null +++ b/src/db/migrate.llm-selection.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import Database from 'better-sqlite3'; +import { Repository } from './repository.js'; +import { runMigrations } from './migrate.js'; + +describe('LLM selection Phase 1: jobs / local_tasks columns', () => { + let dir: string; + let r: Repository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'llm-selection-cols-')); + r = new Repository(join(dir, 'db.sqlite')); + }); + + afterEach(() => { + r.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('jobs table has required_worker_id and reasoning_effort columns (nullable)', () => { + const cols = r.getDb() + .prepare("PRAGMA table_info(jobs)") + .all() as Array<{ name: string; notnull: number }>; + const requiredWorkerId = cols.find(c => c.name === 'required_worker_id'); + const reasoningEffort = cols.find(c => c.name === 'reasoning_effort'); + expect(requiredWorkerId).toBeTruthy(); + expect(requiredWorkerId?.notnull).toBe(0); + expect(reasoningEffort).toBeTruthy(); + expect(reasoningEffort?.notnull).toBe(0); + }); + + it('local_tasks table has llm_worker_id and llm_effort columns (nullable)', () => { + const cols = r.getDb() + .prepare("PRAGMA table_info(local_tasks)") + .all() as Array<{ name: string; notnull: number }>; + const llmWorkerId = cols.find(c => c.name === 'llm_worker_id'); + const llmEffort = cols.find(c => c.name === 'llm_effort'); + expect(llmWorkerId).toBeTruthy(); + expect(llmWorkerId?.notnull).toBe(0); + expect(llmEffort).toBeTruthy(); + expect(llmEffort?.notnull).toBe(0); + }); + + it('runMigrations is idempotent when run twice on the same DB (ALTER does not throw)', () => { + // Repository constructor already ran migrations once via initSchema/migrate path. + // Running again must not throw ("duplicate column name"). + expect(() => runMigrations(r.getDb())).not.toThrow(); + expect(() => runMigrations(r.getDb())).not.toThrow(); + }); + + it('ALTER TABLE ADD COLUMN path works starting from a DB missing the 4 columns', () => { + // Simulate an old DB: create fresh jobs/local_tasks tables WITHOUT the new + // columns (pre-Phase-1 shape), then run migrations and assert they appear. + const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-old-')); + try { + const db = new Database(join(oldDir, 'old.sqlite')); + db.exec(` + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + repo TEXT NOT NULL, + issue_number INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + piece_name TEXT NOT NULL DEFAULT 'general', + required_profile TEXT NOT NULL DEFAULT 'auto', + task_class TEXT NOT NULL DEFAULT 'auto', + instruction TEXT NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 3, + ask_count INTEGER NOT NULL DEFAULT 0, + subtask_depth INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE local_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + piece_name TEXT NOT NULL DEFAULT 'general', + profile TEXT NOT NULL DEFAULT 'auto', + output_format TEXT NOT NULL DEFAULT 'markdown', + ask_policy TEXT NOT NULL DEFAULT 'low', + priority TEXT NOT NULL DEFAULT 'medium', + state TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + const beforeCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>; + expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false); + + runMigrations(db); + // Run twice to prove idempotency from the old-schema starting point too. + runMigrations(db); + + const afterJobsCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>; + expect(afterJobsCols.some(c => c.name === 'required_worker_id')).toBe(true); + expect(afterJobsCols.some(c => c.name === 'reasoning_effort')).toBe(true); + + const afterTasksCols = db.prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>; + expect(afterTasksCols.some(c => c.name === 'llm_worker_id')).toBe(true); + expect(afterTasksCols.some(c => c.name === 'llm_effort')).toBe(true); + + db.close(); + } finally { + rmSync(oldDir, { recursive: true, force: true }); + } + }); +}); + +describe('LLM selection Phase 1: bare `new Repository()` compat-init path (no runMigrations)', () => { + // Repository's constructor calls initSchema() only (src/db/repository.ts:58), + // NOT runMigrations(). initSchema is a separate compat-upgrade path + // (src/db/repositories/schema.ts) that must independently ALTER in the 4 + // new columns, or any code path that does `new Repository(dbPath)` against + // a pre-Phase-1 DB (tests, tools, integration paths that skip + // runMigrations) will hit "no column required_worker_id" in createJob. + it('adds the 4 columns and allows createJob(requiredWorkerId=...) on a pre-Phase-1 DB opened via bare Repository construction', async () => { + const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-bare-')); + try { + const dbPath = join(oldDir, 'old.sqlite'); + const raw = new Database(dbPath); + raw.exec(` + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + repo TEXT NOT NULL, + issue_number INTEGER NOT NULL, + pr_number INTEGER, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')), + piece_name TEXT NOT NULL DEFAULT 'general', + required_profile TEXT NOT NULL DEFAULT 'auto', + task_class TEXT NOT NULL DEFAULT 'auto', + current_movement TEXT, + instruction TEXT NOT NULL DEFAULT '', + branch_name TEXT, + worktree_path TEXT, + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 3, + next_retry_at TEXT, + error_summary TEXT, + resume_movement TEXT, + ask_count INTEGER NOT NULL DEFAULT 0, + worker_id TEXT, + parent_job_id TEXT, + subtask_depth INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE local_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + piece_name TEXT NOT NULL DEFAULT 'general', + profile TEXT NOT NULL DEFAULT 'auto', + output_format TEXT NOT NULL DEFAULT 'markdown', + ask_policy TEXT NOT NULL DEFAULT 'low', + priority TEXT NOT NULL DEFAULT 'medium', + state TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + const beforeCols = raw.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>; + expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false); + raw.close(); + + // Bare construction — deliberately does NOT call runMigrations. + const repo = new Repository(dbPath); + try { + const jobsCols = repo.getDb().prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>; + expect(jobsCols.some(c => c.name === 'required_worker_id')).toBe(true); + expect(jobsCols.some(c => c.name === 'reasoning_effort')).toBe(true); + + const taskCols = repo.getDb().prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>; + expect(taskCols.some(c => c.name === 'llm_worker_id')).toBe(true); + expect(taskCols.some(c => c.name === 'llm_effort')).toBe(true); + + // End-to-end: createJob with a pin must not throw a SQLite + // "no column required_worker_id" error on this bare-init DB. + const job = await repo.createJob({ + repo: 'local/task-1', + issueNumber: 1, + instruction: 'go', + pieceName: 'general', + requiredWorkerId: 'worker-gpu-1', + reasoningEffort: 'high', + }); + expect(job.requiredWorkerId).toBe('worker-gpu-1'); + expect(job.reasoningEffort).toBe('high'); + } finally { + repo.close?.(); + } + } finally { + rmSync(oldDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 4ef2d18..7381ff3 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -222,6 +222,22 @@ export function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'"); }); + // LLM 選択 Phase 1: per-job worker/effort pin + per-task スティッキー選択。 + // NULL = 従来の profile ルーティング(後方互換)。 + // spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md + addColumnIfMissing(db, 'jobs', 'required_worker_id', () => { + db.exec('ALTER TABLE jobs ADD COLUMN required_worker_id TEXT'); + }); + addColumnIfMissing(db, 'jobs', 'reasoning_effort', () => { + db.exec('ALTER TABLE jobs ADD COLUMN reasoning_effort TEXT'); + }); + addColumnIfMissing(db, 'local_tasks', 'llm_worker_id', () => { + db.exec('ALTER TABLE local_tasks ADD COLUMN llm_worker_id TEXT'); + }); + addColumnIfMissing(db, 'local_tasks', 'llm_effort', () => { + db.exec('ALTER TABLE local_tasks ADD COLUMN llm_effort TEXT'); + }); + migrateMcpTables(db); migrateSshTables(db); migrateDashboardWidgets(db); @@ -233,6 +249,7 @@ export function runMigrations(db: Database.Database): void { migrateSpaceUserPrefs(db); migrateSpaceMembers(db); migrateCalendarEvents(db); + migrateAgentReminders(db); migrateSpaceInvites(db); migrateAppShareLinks(db); migratePrivatizeSpaceRows(db); @@ -245,6 +262,7 @@ export function runMigrations(db: Database.Database): void { migrateBackfillTaskCommentIndex(db); migrateA2aTaskDelegationColumns(db); migrateSpaceWebhooksTables(db); + migrateChatConnectorBindings(db); } /** @@ -523,6 +541,23 @@ function migrateCalendarEvents(db: Database.Database): void { addColumnIfMissing(db, 'calendar_events', 'end_time', () => { db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT"); }); + addColumnIfMissing(db, 'calendar_events', 'reminder_minutes', () => { + db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER"); + }); + addColumnIfMissing(db, 'calendar_events', 'reminder_delivered_at', () => { + db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_delivered_at TEXT"); + }); +} + +function migrateAgentReminders(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS agent_reminders ( + id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT, + title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL, + delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL; + `); } /** @@ -1421,3 +1456,38 @@ function migrateA2aTaskDelegationColumns(db: Database.Database): void { db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT'); }); } + +/** + * chat_connector_bindings (issue #801, PR1 — Slack MVP). + * dual-path: schema.sql has the same CREATE TABLE for fresh DBs. + */ +function migrateChatConnectorBindings(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS chat_connector_bindings ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL DEFAULT 'slack', + external_workspace_id TEXT NOT NULL, + external_channel_id TEXT NOT NULL, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + a2a_client_id TEXT NOT NULL, + a2a_delegation_id TEXT NOT NULL, + a2a_grant_id TEXT NOT NULL, + bot_credentials_enc BLOB NOT NULL, + key_version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel + ON chat_connector_bindings (platform, external_workspace_id, external_channel_id); + CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id); + CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id); + CREATE TABLE IF NOT EXISTS chat_connector_processed_events ( + event_id TEXT PRIMARY KEY, + received_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at + ON chat_connector_processed_events (received_at); + `); +} diff --git a/src/db/repositories/calendar.ts b/src/db/repositories/calendar.ts index 78d3f02..2591919 100644 --- a/src/db/repositories/calendar.ts +++ b/src/db/repositories/calendar.ts @@ -24,6 +24,8 @@ export interface CalendarEvent { endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日) time: string | null; // 開始 HH:MM。null = 終日 endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) + reminderMinutes: number | null; + reminderDeliveredAt: string | null; title: string; description: string | null; createdBy: 'user' | 'agent'; @@ -41,6 +43,8 @@ export interface CalendarEventRow { end_date: string | null; time: string | null; end_time: string | null; + reminder_minutes: number | null; + reminder_delivered_at: string | null; title: string; description: string | null; created_by: string; @@ -80,6 +84,8 @@ export function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent { endDate: row.end_date ?? null, time: row.time ?? null, endTime: row.end_time ?? null, + reminderMinutes: row.reminder_minutes ?? null, + reminderDeliveredAt: row.reminder_delivered_at ?? null, title: row.title, description: row.description ?? null, createdBy: row.created_by === 'agent' ? 'agent' : 'user', @@ -145,6 +151,7 @@ export interface CrossSpaceCalendarMonth { endDate?: string | null; time?: string | null; endTime?: string | null; + reminderMinutes?: number | null; title: string; description?: string | null; createdBy?: 'user' | 'agent'; @@ -157,8 +164,8 @@ export interface CrossSpaceCalendarMonth { const endTime = time ? (params.endTime ?? null) : null; const result = db .prepare( - `INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, reminder_minutes, title, description, created_by, source_task_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( params.spaceId, @@ -167,6 +174,7 @@ export interface CrossSpaceCalendarMonth { endDate, time, endTime, + params.reminderMinutes ?? null, params.title, params.description ?? null, params.createdBy ?? 'user', @@ -210,7 +218,7 @@ export interface CrossSpaceCalendarMonth { } - export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise { + export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise { const sets: string[] = []; const args: unknown[] = []; if (fields.date !== undefined) { @@ -244,6 +252,13 @@ export interface CrossSpaceCalendarMonth { sets.push('description = ?'); args.push(fields.description); } + if (fields.reminderMinutes !== undefined) { + sets.push('reminder_minutes = ?'); + args.push(fields.reminderMinutes); + } + if (fields.date !== undefined || fields.time !== undefined || fields.reminderMinutes !== undefined) { + sets.push('reminder_delivered_at = NULL'); + } if (sets.length === 0) return getCalendarEvent(db, eventId); sets.push(`updated_at = datetime('now')`); args.push(eventId); @@ -256,6 +271,31 @@ export interface CrossSpaceCalendarMonth { db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId); } + /** + * 指定したローカル時刻までに到来した予定を一度だけ claim する。 + * UPDATE の条件にも未配信を入れるため、複数タブ/プロセスが同時に呼んでも重複しない。 + */ + export async function claimDueCalendarReminders(db: Database.Database, spaceId: string, nowLocal: string): Promise { + const claim = db.transaction(() => { + const candidates = db.prepare( + `SELECT * FROM calendar_events + WHERE space_id = ? AND time IS NOT NULL AND reminder_minutes IS NOT NULL + AND reminder_delivered_at IS NULL + AND datetime(date || ' ' || time, '-' || reminder_minutes || ' minutes') <= datetime(?)` + ).all(spaceId, nowLocal) as CalendarEventRow[]; + const updated: CalendarEvent[] = []; + const mark = db.prepare( + `UPDATE calendar_events SET reminder_delivered_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND reminder_delivered_at IS NULL` + ); + for (const row of candidates) { + if (mark.run(row.id).changes === 1) updated.push(rowToCalendarEvent({ ...row, reminder_delivered_at: new Date().toISOString() })); + } + return updated; + }); + return claim(); + } + /** * 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。 diff --git a/src/db/repositories/chat-connectors.test.ts b/src/db/repositories/chat-connectors.test.ts new file mode 100644 index 0000000..6041351 --- /dev/null +++ b/src/db/repositories/chat-connectors.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { Repository } from '../repository.js'; + +describe('chat_connector_bindings repository', () => { + let tempDir = ''; + let repo: Repository; + let spaceId = ''; + let clientId = ''; + let delegationId = ''; + let grantId = ''; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-connectors-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' }); + spaceId = space.id; + clientId = `a2a_${randomUUID()}`; + repo.createA2aClient({ + clientId, name: 'Test App', redirectUris: ['https://example.com/cb'], + secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null, + keyFingerprint: null, status: 'active', createdBy: null, + }); + delegationId = randomUUID(); + grantId = randomUUID(); + repo.createA2aDelegation({ + id: delegationId, userId: owner.id, clientId, grantId, + grantedSpaceIds: [spaceId], grantedSkills: ['chat'], + audience: null, expiresAt: null, revokedAt: null, + }); + }); + + afterEach(() => { + repo.close(); + if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } + }); + + const credsEnc = () => Buffer.from('fake-ciphertext-not-real-crypto'); + + it('creates a binding and returns the full record (credentials as an opaque Buffer)', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), createdBy: 'admin1', + }); + expect(created.id).toBeTruthy(); + expect(created.status).toBe('active'); + expect(created.botCredentialsEnc).toBeInstanceOf(Buffer); + expect(repo.getChatConnectorBindingById(created.id)).toEqual(created); + }); + + it('findActiveChatConnectorBinding resolves by (platform, team, channel)', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + const found = repo.findActiveChatConnectorBinding('slack', 'T1', 'C1'); + expect(found?.id).toBe(created.id); + }); + + it('findActiveChatConnectorBinding returns null for an unknown (team, channel)', () => { + expect(repo.findActiveChatConnectorBinding('slack', 'T-unknown', 'C-unknown')).toBeNull(); + }); + + it('findActiveChatConnectorBinding returns null once the binding is disabled (fail-closed)', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + repo.updateChatConnectorBinding(created.id, { status: 'disabled' }); + expect(repo.findActiveChatConnectorBinding('slack', 'T1', 'C1')).toBeNull(); + }); + + it('enforces a unique (platform, team, channel) constraint', () => { + repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + expect(() => repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + })).toThrow(/UNIQUE constraint failed/); + }); + + it('updateChatConnectorBinding re-encrypts credentials and bumps updated_at', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + const newCreds = Buffer.from('rotated-ciphertext'); + const updated = repo.updateChatConnectorBinding(created.id, { botCredentialsEnc: newCreds }); + expect(updated?.botCredentialsEnc.equals(newCreds)).toBe(true); + }); + + it('listChatConnectorBindingsForDelegation returns bindings backed by a given delegation', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + const list = repo.listChatConnectorBindingsForDelegation(delegationId); + expect(list.map(b => b.id)).toEqual([created.id]); + expect(repo.listChatConnectorBindingsForDelegation('nonexistent')).toEqual([]); + }); + + it('deleteChatConnectorBinding removes the row', () => { + const created = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + repo.deleteChatConnectorBinding(created.id); + expect(repo.getChatConnectorBindingById(created.id)).toBeNull(); + }); + + it('listChatConnectorBindings lists all bindings regardless of status', () => { + const a = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + repo.updateChatConnectorBinding(a.id, { status: 'disabled' }); + const b = repo.createChatConnectorBinding({ + platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C2', + spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId, + botCredentialsEnc: credsEnc(), + }); + const all = repo.listChatConnectorBindings(); + expect(all.map(x => x.id).sort()).toEqual([a.id, b.id].sort()); + }); +}); diff --git a/src/db/repositories/chat-connectors.ts b/src/db/repositories/chat-connectors.ts new file mode 100644 index 0000000..6ec8a09 --- /dev/null +++ b/src/db/repositories/chat-connectors.ts @@ -0,0 +1,206 @@ +// chat_connector_bindings repository domain (issue #801, PR1 — Slack MVP). +// Follows the same sub-repository + thin-delegation-on-Repository pattern as +// webhooks.ts / space_webhooks. +// +// SECURITY: bot_credentials_enc is an AES-256-GCM envelope-encrypted BLOB +// (src/mcp/crypto.ts, same scheme as space_webhooks.url_enc). This module +// never decrypts — callers (chat-connector-service.ts) decrypt just-in-time +// and never log or return the plaintext. rowToChatConnectorBinding() keeps +// bot_credentials_enc as an opaque Buffer; the admin API layer +// (chat-connector-bindings-api.ts) must never place it, or a decrypted +// credential, into a JSON response. +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + +export type ChatConnectorPlatform = 'slack'; +export type ChatConnectorBindingStatus = 'active' | 'disabled'; + +export interface ChatConnectorBindingRecord { + id: string; + platform: ChatConnectorPlatform; + externalWorkspaceId: string; + externalChannelId: string; + spaceId: string; + a2aClientId: string; + a2aDelegationId: string; + a2aGrantId: string; + botCredentialsEnc: Buffer; + keyVersion: number; + status: ChatConnectorBindingStatus; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateChatConnectorBindingInput { + platform: ChatConnectorPlatform; + externalWorkspaceId: string; + externalChannelId: string; + spaceId: string; + a2aClientId: string; + a2aDelegationId: string; + a2aGrantId: string; + botCredentialsEnc: Buffer; + keyVersion?: number; + createdBy?: string | null; +} + +export interface UpdateChatConnectorBindingInput { + /** Present only when the caller re-entered credentials (write-only). */ + botCredentialsEnc?: Buffer; + keyVersion?: number; + status?: ChatConnectorBindingStatus; +} + +interface ChatConnectorBindingRow { + id: string; + platform: string; + external_workspace_id: string; + external_channel_id: string; + space_id: string; + a2a_client_id: string; + a2a_delegation_id: string; + a2a_grant_id: string; + bot_credentials_enc: Buffer; + key_version: number; + status: string; + created_by: string | null; + created_at: string; + updated_at: string; +} + +function rowToBinding(row: ChatConnectorBindingRow): ChatConnectorBindingRecord { + return { + id: row.id, + platform: row.platform as ChatConnectorPlatform, + externalWorkspaceId: row.external_workspace_id, + externalChannelId: row.external_channel_id, + spaceId: row.space_id, + a2aClientId: row.a2a_client_id, + a2aDelegationId: row.a2a_delegation_id, + a2aGrantId: row.a2a_grant_id, + botCredentialsEnc: row.bot_credentials_enc, + keyVersion: row.key_version, + status: row.status as ChatConnectorBindingStatus, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +const SELECT_COLUMNS = `id, platform, external_workspace_id, external_channel_id, space_id, + a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version, + status, created_by, created_at, updated_at`; + +export function createChatConnectorBinding( + db: Database.Database, + input: CreateChatConnectorBindingInput, +): ChatConnectorBindingRecord { + const id = randomUUID(); + db.prepare( + `INSERT INTO chat_connector_bindings + (id, platform, external_workspace_id, external_channel_id, space_id, + a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + input.platform, + input.externalWorkspaceId, + input.externalChannelId, + input.spaceId, + input.a2aClientId, + input.a2aDelegationId, + input.a2aGrantId, + input.botCredentialsEnc, + input.keyVersion ?? 1, + input.createdBy ?? null, + ); + return getChatConnectorBindingById(db, id)!; +} + +export function listChatConnectorBindings(db: Database.Database): ChatConnectorBindingRecord[] { + const rows = db + .prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings ORDER BY created_at ASC`) + .all() as ChatConnectorBindingRow[]; + return rows.map(rowToBinding); +} + +export function getChatConnectorBindingById(db: Database.Database, id: string): ChatConnectorBindingRecord | null { + const row = db + .prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE id = ?`) + .get(id) as ChatConnectorBindingRow | undefined; + return row ? rowToBinding(row) : null; +} + +/** + * Lookup used by the inbound mention handler: (platform, team, channel) → binding. + * Only returns 'active' bindings — disabled bindings must be invisible to the + * inbound path (fail-closed; the caller should treat a null return as "ignore"). + */ +export function findActiveChatConnectorBinding( + db: Database.Database, + platform: ChatConnectorPlatform, + externalWorkspaceId: string, + externalChannelId: string, +): ChatConnectorBindingRecord | null { + const row = db + .prepare( + `SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings + WHERE platform = ? AND external_workspace_id = ? AND external_channel_id = ? AND status = 'active'`, + ) + .get(platform, externalWorkspaceId, externalChannelId) as ChatConnectorBindingRow | undefined; + return row ? rowToBinding(row) : null; +} + +/** Every delegation the schema allows to back at most one space (validated at the API layer). */ +export function listChatConnectorBindingsForDelegation( + db: Database.Database, + a2aDelegationId: string, +): ChatConnectorBindingRecord[] { + const rows = db + .prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE a2a_delegation_id = ?`) + .all(a2aDelegationId) as ChatConnectorBindingRow[]; + return rows.map(rowToBinding); +} + +export function updateChatConnectorBinding( + db: Database.Database, + id: string, + patch: UpdateChatConnectorBindingInput, +): ChatConnectorBindingRecord | null { + const sets: string[] = []; + const params: Array = []; + if (patch.botCredentialsEnc !== undefined) { + sets.push('bot_credentials_enc = ?'); + params.push(patch.botCredentialsEnc); + } + if (patch.keyVersion !== undefined) { + sets.push('key_version = ?'); + params.push(patch.keyVersion); + } + if (patch.status !== undefined) { + sets.push('status = ?'); + params.push(patch.status); + } + if (sets.length === 0) return getChatConnectorBindingById(db, id); + sets.push("updated_at = datetime('now')"); + params.push(id); + db.prepare(`UPDATE chat_connector_bindings SET ${sets.join(', ')} WHERE id = ?`).run(...params); + return getChatConnectorBindingById(db, id); +} + +export function deleteChatConnectorBinding(db: Database.Database, id: string): void { + db.prepare('DELETE FROM chat_connector_bindings WHERE id = ?').run(id); +} + +/** Atomically reserves a Slack envelope. False means a retry already ran. */ +export function claimChatConnectorEvent(db: Database.Database, eventId: string): boolean { + // Slack retries are bounded to minutes. Retaining a week absorbs delayed + // retries without letting an external event stream grow the DB forever. + db.prepare("DELETE FROM chat_connector_processed_events WHERE received_at < datetime('now', '-7 days')").run(); + const result = db.prepare( + `INSERT INTO chat_connector_processed_events (event_id) VALUES (?) + ON CONFLICT(event_id) DO NOTHING`, + ).run(eventId); + return result.changes === 1; +} diff --git a/src/db/repositories/jobs.claim-pin.test.ts b/src/db/repositories/jobs.claim-pin.test.ts new file mode 100644 index 0000000..d20610e --- /dev/null +++ b/src/db/repositories/jobs.claim-pin.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Repository } from '../repository.js'; + +// Task 8: claim SQL 共通フラグメント化 + required_worker_id pin 条件。 +// claimNextJob / claimNextRetryJob / peekNextClaimable の3クエリ(4箇所)が +// 同じマッチング条件(CLAIMABLE_MATCH_SQL)を共有していることを、pin あり/なし +// 双方のケースで確認する。 +describe('claim SQL: required_worker_id pin (Phase 1 Task 8)', () => { + let dir: string; + let r: Repository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'jobs-claim-pin-')); + r = new Repository(join(dir, 'db.sqlite')); + }); + + afterEach(() => { + r.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + + async function seedWorker(opts: { + workerId: string; + roles: string[]; + enabled?: boolean; + healthy?: boolean; + }): Promise { + await r.upsertWorkerNode({ + workerId: opts.workerId, + endpoint: `http://${opts.workerId}.local:11434`, + enabled: opts.enabled ?? true, + healthy: opts.healthy ?? true, + roles: opts.roles, + }); + } + + describe('claimNextJob', () => { + it('1) unpinned job: claimed by a worker whose profile_tags match required_profile (baseline unchanged)', async () => { + await seedWorker({ workerId: 'worker-auto-1', roles: ['auto'] }); + const job = await r.createJob({ + repo: 'local/pin-1', + issueNumber: 1, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + } as any); + expect(job.requiredWorkerId).toBeNull(); + + const claimed = await r.claimNextJob('worker-auto-1'); + expect(claimed?.id).toBe(job.id); + }); + + it('2) pinned job: only the pinned worker can claim it; a different enabled worker with matching profile cannot', async () => { + await seedWorker({ workerId: 'worker-pinned', roles: ['auto'] }); + await seedWorker({ workerId: 'worker-other', roles: ['auto'] }); + const job = await r.createJob({ + repo: 'local/pin-2', + issueNumber: 2, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-pinned', + } as any); + expect(job.requiredWorkerId).toBe('worker-pinned'); + + // The non-pinned worker must NOT be able to claim it, even though its + // profile_tags match required_profile. + const claimedByOther = await r.claimNextJob('worker-other'); + expect(claimedByOther).toBeNull(); + + // The pinned worker CAN claim it. + const claimedByPinned = await r.claimNextJob('worker-pinned'); + expect(claimedByPinned?.id).toBe(job.id); + }); + + it('3) pinned worker without any execution role (auto/fast/quality) cannot claim (stays queued)', async () => { + await seedWorker({ workerId: 'worker-reflection-only', roles: ['reflection'] }); + const job = await r.createJob({ + repo: 'local/pin-3', + issueNumber: 3, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-reflection-only', + } as any); + + const claimed = await r.claimNextJob('worker-reflection-only'); + expect(claimed).toBeNull(); + + const stillQueued = await r.getJob(job.id); + expect(stillQueued?.status).toBe('queued'); + }); + + it('3b) pinned worker with title-only role cannot claim either', async () => { + await seedWorker({ workerId: 'worker-title-only', roles: ['title'] }); + const job = await r.createJob({ + repo: 'local/pin-3b', + issueNumber: 4, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-title-only', + } as any); + + const claimed = await r.claimNextJob('worker-title-only'); + expect(claimed).toBeNull(); + }); + + it('4) pinned worker disabled: not claimable', async () => { + await seedWorker({ workerId: 'worker-disabled', roles: ['auto'], enabled: false }); + const job = await r.createJob({ + repo: 'local/pin-4', + issueNumber: 5, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-disabled', + } as any); + + const claimed = await r.claimNextJob('worker-disabled'); + expect(claimed).toBeNull(); + + const stillQueued = await r.getJob(job.id); + expect(stillQueued?.status).toBe('queued'); + }); + }); + + describe('claimNextRetryJob', () => { + async function seedRetryJob(params: { + repo: string; + issueNumber: number; + requiredWorkerId?: string | null; + }) { + const job = await r.createJob({ + repo: params.repo, + issueNumber: params.issueNumber, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: params.requiredWorkerId ?? null, + } as any); + const pastRetryAt = new Date(Date.now() - 60_000).toISOString(); + await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 }); + return job; + } + + it('2) pinned retry job: only the pinned worker can claim it', async () => { + await seedWorker({ workerId: 'worker-pinned-r', roles: ['auto'] }); + await seedWorker({ workerId: 'worker-other-r', roles: ['auto'] }); + const job = await seedRetryJob({ repo: 'local/pin-retry-2', issueNumber: 1, requiredWorkerId: 'worker-pinned-r' }); + + const claimedByOther = await r.claimNextRetryJob('worker-other-r'); + expect(claimedByOther).toBeNull(); + + const claimedByPinned = await r.claimNextRetryJob('worker-pinned-r'); + expect(claimedByPinned?.id).toBe(job.id); + }); + + it('3) pinned retry job: worker without execution role cannot claim', async () => { + await seedWorker({ workerId: 'worker-reflection-r', roles: ['reflection'] }); + await seedRetryJob({ repo: 'local/pin-retry-3', issueNumber: 2, requiredWorkerId: 'worker-reflection-r' }); + + const claimed = await r.claimNextRetryJob('worker-reflection-r'); + expect(claimed).toBeNull(); + }); + }); + + describe('peekNextClaimable', () => { + it('2) pinned queued job: peek returns it for the pinned worker but not for another', async () => { + await seedWorker({ workerId: 'worker-pinned-p', roles: ['auto'] }); + await seedWorker({ workerId: 'worker-other-p', roles: ['auto'] }); + const job = await r.createJob({ + repo: 'local/pin-peek-2', + issueNumber: 1, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-pinned-p', + } as any); + + const peekOther = await r.peekNextClaimable('worker-other-p'); + expect(peekOther).toBeNull(); + + const peekPinned = await r.peekNextClaimable('worker-pinned-p'); + expect(peekPinned?.id).toBe(job.id); + }); + + it('3) pinned queued job: worker without execution role does not peek it', async () => { + await seedWorker({ workerId: 'worker-reflection-p', roles: ['reflection'] }); + await r.createJob({ + repo: 'local/pin-peek-3', + issueNumber: 2, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-reflection-p', + } as any); + + const peek = await r.peekNextClaimable('worker-reflection-p'); + expect(peek).toBeNull(); + }); + + it('2r) pinned retry job takes priority in peek and is scoped to the pinned worker', async () => { + await seedWorker({ workerId: 'worker-pinned-pr', roles: ['auto'] }); + await seedWorker({ workerId: 'worker-other-pr', roles: ['auto'] }); + const job = await r.createJob({ + repo: 'local/pin-peek-retry-2', + issueNumber: 3, + instruction: 'do the thing', + pieceName: 'chat', + role: 'auto', + requiredWorkerId: 'worker-pinned-pr', + } as any); + const pastRetryAt = new Date(Date.now() - 60_000).toISOString(); + await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 }); + + const peekOther = await r.peekNextClaimable('worker-other-pr'); + expect(peekOther).toBeNull(); + + const peekPinned = await r.peekNextClaimable('worker-pinned-pr'); + expect(peekPinned?.id).toBe(job.id); + }); + }); +}); diff --git a/src/db/repositories/jobs.llm-selection.test.ts b/src/db/repositories/jobs.llm-selection.test.ts new file mode 100644 index 0000000..0c84f2c --- /dev/null +++ b/src/db/repositories/jobs.llm-selection.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Repository } from '../repository.js'; + +describe('jobs.required_worker_id / reasoning_effort (LLM selection Phase 1)', () => { + let dir: string; + let r: Repository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'jobs-llm-selection-')); + r = new Repository(join(dir, 'db.sqlite')); + }); + + afterEach(() => { + r.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('createJob accepts requiredWorkerId + reasoningEffort and getJob round-trips both', async () => { + const created = await r.createJob({ + repo: 'local/llm-select-1', + issueNumber: 1, + instruction: 'do the thing', + pieceName: 'chat', + requiredWorkerId: 'worker-gpu-2', + reasoningEffort: 'high', + } as any); + + expect(created.requiredWorkerId).toBe('worker-gpu-2'); + expect(created.reasoningEffort).toBe('high'); + + const fetched = await r.getJob(created.id); + expect(fetched).toBeTruthy(); + expect(fetched!.requiredWorkerId).toBe('worker-gpu-2'); + expect(fetched!.reasoningEffort).toBe('high'); + }); + + it('createJob defaults requiredWorkerId/reasoningEffort to null when omitted', async () => { + const created = await r.createJob({ + repo: 'local/llm-select-2', + issueNumber: 2, + instruction: 'do the thing', + pieceName: 'chat', + } as any); + + expect(created.requiredWorkerId).toBeNull(); + expect(created.reasoningEffort).toBeNull(); + + const fetched = await r.getJob(created.id); + expect(fetched!.requiredWorkerId).toBeNull(); + expect(fetched!.reasoningEffort).toBeNull(); + }); +}); diff --git a/src/db/repositories/jobs.ts b/src/db/repositories/jobs.ts index 5790b21..4318fc5 100644 --- a/src/db/repositories/jobs.ts +++ b/src/db/repositories/jobs.ts @@ -62,6 +62,17 @@ export interface Job { requiredRole: JobRole; /** @deprecated Use requiredRole */ requiredProfile: JobRole; + /** + * LLM 選択 Phase 1: pinned worker id for this job. NULL = 従来の + * profile ルーティング(自動選択)。設定→ワーカー/エフォート選択で + * ユーザーが明示ピンした場合のみ非 null。claim SQL での消費は後続タスク。 + */ + requiredWorkerId: string | null; + /** + * LLM 選択 Phase 1: per-job pinned reasoning effort (e.g. 'low' | 'medium' | + * 'high', worker の reasoning_efforts に依存). NULL = ワーカー既定を使用。 + */ + reasoningEffort: string | null; ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null; @@ -104,6 +115,10 @@ export interface CreateJobParams { spaceId?: string | null; /** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */ runtimeDir?: string | null; + /** LLM 選択 Phase 1: pinned worker id。未指定/null は従来の profile ルーティング。 */ + requiredWorkerId?: string | null; + /** LLM 選択 Phase 1: per-job pinned reasoning effort。未指定/null はワーカー既定。 */ + reasoningEffort?: string | null; } @@ -148,6 +163,8 @@ export interface JobRow { subtask_depth: number; required_profile: string; task_class: string; + required_worker_id: string | null; + reasoning_effort: string | null; owner_id: string | null; visibility: string | null; visibility_scope_org_id: string | null; @@ -209,6 +226,8 @@ export function rowToJob(row: JobRow): Job { subtaskDepth: row.subtask_depth ?? 0, requiredRole: normalizeJobRole(row.required_profile), requiredProfile: normalizeJobRole(row.required_profile), + requiredWorkerId: row.required_worker_id ?? null, + reasoningEffort: row.reasoning_effort ?? null, ownerId: row.owner_id ?? null, visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'), visibilityScopeOrgId: row.visibility_scope_org_id ?? null, @@ -243,8 +262,8 @@ export function rowToJob(row: JobRow): Job { db .prepare( - `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at) - VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)` + `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, required_worker_id, reasoning_effort, created_at, updated_at) + VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @requiredWorkerId, @reasoningEffort, @now, @now)` ) .run({ id, @@ -267,6 +286,8 @@ export function rowToJob(row: JobRow): Job { payload: params.payload ?? null, spaceId: params.spaceId ?? null, runtimeDir: params.runtimeDir ?? null, + requiredWorkerId: params.requiredWorkerId ?? null, + reasoningEffort: params.reasoningEffort ?? null, now, }); @@ -566,6 +587,27 @@ export function rowToJob(row: JobRow): Job { } + /** + * queued/retry ジョブと worker のマッチング条件(claimNextJob / + * claimNextRetryJob / peekNextClaimable の計4クエリで共有)。 + * - 直指定あり: そのワーカーのみ。かつ実行ロール(auto/fast/quality)を持つこと + * (title/reflection 専用ワーカーがユーザー指定で通常ジョブを掴む/永久 queued を防ぐ) + * - 直指定なし: 従来の required_profile マッチ + */ + const CLAIMABLE_MATCH_SQL = ` + w.enabled = 1 + AND w.healthy = 1 + AND ( + (j.required_worker_id IS NOT NULL + AND j.required_worker_id = w.worker_id + AND (instr(w.profile_tags, ',auto,') > 0 + OR instr(w.profile_tags, ',fast,') > 0 + OR instr(w.profile_tags, ',quality,') > 0)) + OR (j.required_worker_id IS NULL + AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0) + )`; + + export async function claimNextJob(db: Database.Database, workerId: string): Promise { const row = db.prepare(` UPDATE jobs @@ -575,9 +617,7 @@ export function rowToJob(row: JobRow): Job { FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'queued' - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND ${CLAIMABLE_MATCH_SQL} AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number @@ -604,9 +644,7 @@ export function rowToJob(row: JobRow): Job { JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'retry' AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND ${CLAIMABLE_MATCH_SQL} AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number @@ -633,9 +671,7 @@ export function rowToJob(row: JobRow): Job { JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'retry' AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND ${CLAIMABLE_MATCH_SQL} AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number @@ -650,9 +686,7 @@ export function rowToJob(row: JobRow): Job { FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'queued' - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND ${CLAIMABLE_MATCH_SQL} AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number @@ -823,6 +857,43 @@ export function rowToJob(row: JobRow): Job { } + /** + * LLM 選択 Phase 1: PATCH /api/local/tasks/:id でタスクの sticky + * worker/effort が変わったとき、まだ実行が始まっていないジョブ(queued / + * retry)へ即反映する。running/dispatching/waiting_subtasks 中のジョブは + * 対象外(既に pinned worker で claim 済み・走行中に差し替えると齟齬が + * 出るため)。 + * サブタスク(repo='subtask/')は parent_job_id チェーンで + * タスク直下ジョブの子孫として再帰的に含める — SpawnSubTask がピンを + * 継承するため、子孫が queued のまま停滞ワーカーを抱え続けると「ピンを + * 解除すれば復旧する」という UI の案内が成立しなくなる(親が + * waiting_subtasks で永久に止まる)。visibility cascade + * (updateVisibilityForTaskCascade) と同じ再帰パターン。 + */ + export async function updateJobsLlmSelectionForTask( + db: Database.Database, + taskId: number, + sel: { workerId: string | null; effort: string | null }, + ): Promise { + const repoName = localTaskRepoName(taskId); + db + .prepare(` + WITH RECURSIVE task_tree(id) AS ( + SELECT id FROM jobs WHERE repo = @repo AND issue_number = @taskId + UNION ALL + SELECT j.id FROM jobs j JOIN task_tree t ON j.parent_job_id = t.id + ) + UPDATE jobs + SET required_worker_id = @workerId, + reasoning_effort = @effort, + updated_at = datetime('now') + WHERE id IN (SELECT id FROM task_tree) + AND status IN ('queued','retry') + `) + .run({ repo: repoName, taskId, workerId: sel.workerId, effort: sel.effort }); + } + + export async function getSubJobs(db: Database.Database, parentJobId: string): Promise { // 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す // ROW_NUMBER() + rowid で同一 created_at でも一意に決定する diff --git a/src/db/repositories/local-tasks.ts b/src/db/repositories/local-tasks.ts index dc245cf..2d32df6 100644 --- a/src/db/repositories/local-tasks.ts +++ b/src/db/repositories/local-tasks.ts @@ -69,6 +69,14 @@ export interface LocalTask { spaceId: string | null; /** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */ runtimeDir: string | null; + /** + * LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで + * 新規生成される job にも引き継がれる(引き継ぎ配線は claim SQL/worker 側の + * 後続タスク)。null = 未選択(従来の profile ルーティング)。 + */ + llmWorkerId: string | null; + /** LLM 選択 Phase 1: タスク単位のスティッキー effort。null = 未選択。 */ + llmEffort: string | null; createdAt: string; updatedAt: string; feedbackRating: 'good' | 'bad' | null; @@ -162,6 +170,13 @@ export interface CreateLocalTaskParams { spaceId?: string | null; /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */ options?: Record; + /** + * LLM 選択 Phase 1: タスク作成時に pin する worker/effort。呼び出し側 + * (local-tasks-crud-api.ts の POST) が insert 前に validateLlmSelection で + * 検証済みである前提。未指定/null は従来の profile ルーティング。 + */ + llmWorkerId?: string | null; + llmEffort?: string | null; } @@ -179,6 +194,8 @@ export interface LocalTaskRow { workspace_path: string | null; workspace_mode: string | null; runtime_dir: string | null; + llm_worker_id: string | null; + llm_effort: string | null; owner_id: string | null; owner_name?: string | null; visibility: string | null; @@ -232,6 +249,8 @@ export function rowToLocalTask(row: LocalTaskRow): LocalTask { visibilityScopeOrgName: row.visibility_scope_org_name ?? null, spaceId: row.space_id ?? null, runtimeDir: row.runtime_dir ?? null, + llmWorkerId: row.llm_worker_id ?? null, + llmEffort: row.llm_effort ?? null, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null, @@ -329,8 +348,8 @@ export function parseCommentAttachments(raw: string | null): string[] { export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise { const result = db .prepare( - `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options) - VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)` + `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options, llm_worker_id, llm_effort) + VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options, @llmWorkerId, @llmEffort)` ) .run({ title: params.title, @@ -349,6 +368,8 @@ export function parseCommentAttachments(raw: string | null): string[] { browserSessionProfileId: params.browserSessionProfileId ?? null, spaceId: params.spaceId ?? null, options: JSON.stringify(params.options ?? {}), + llmWorkerId: params.llmWorkerId ?? null, + llmEffort: params.llmEffort ?? null, }); const task = await getLocalTask(db, Number(result.lastInsertRowid)); @@ -680,6 +701,11 @@ export function parseCommentAttachments(raw: string | null): string[] { subtask_depth: row.job_subtask_depth ?? 0, required_profile: row.job_required_profile!, task_class: row.job_task_class!, + // この join は latestJob 表示用の縮小列セットで required_worker_id / + // reasoning_effort は取得していない。pin 済み表示が必要になったら + // 上の SELECT 列に j.required_worker_id / j.reasoning_effort を追加する。 + required_worker_id: null, + reasoning_effort: null, owner_id: row.job_owner_id ?? null, visibility: row.job_visibility ?? null, visibility_scope_org_id: row.job_visibility_scope_org_id ?? null, @@ -764,6 +790,8 @@ export function parseCommentAttachments(raw: string | null): string[] { runtimeDir: 'runtime_dir', visibility: 'visibility', visibilityScopeOrgId: 'visibility_scope_org_id', + llmWorkerId: 'llm_worker_id', + llmEffort: 'llm_effort', }; for (const [jsKey, dbCol] of Object.entries(fieldMap)) { diff --git a/src/db/repositories/reminders.ts b/src/db/repositories/reminders.ts new file mode 100644 index 0000000..08a32cf --- /dev/null +++ b/src/db/repositories/reminders.ts @@ -0,0 +1,46 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'node:crypto'; + +export interface AgentReminder { + id: string; + ownerId: string | null; + spaceId: string | null; + title: string | null; + body: string; + imageUrl: string | null; + dueAt: string; + deliveredAt: string | null; + cancelledAt: string | null; + createdAt: string; +} + +function fromRow(row: Record): AgentReminder { + return { + id: String(row.id), ownerId: row.owner_id as string | null, spaceId: row.space_id as string | null, + title: row.title as string | null, body: String(row.body), imageUrl: row.image_url as string | null, + dueAt: String(row.due_at), deliveredAt: row.delivered_at as string | null, + cancelledAt: row.cancelled_at as string | null, createdAt: String(row.created_at), + }; +} + +export function createAgentReminder(db: Database.Database, input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder { + const id = randomUUID(); + db.prepare('INSERT INTO agent_reminders (id, owner_id, space_id, title, body, image_url, due_at) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(id, input.ownerId ?? null, input.spaceId ?? null, input.title ?? null, input.body, input.imageUrl ?? null, input.dueAt); + return fromRow(db.prepare('SELECT * FROM agent_reminders WHERE id = ?').get(id) as Record); +} + +export function listAgentReminders(db: Database.Database, ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] { + const conditions = ['cancelled_at IS NULL', 'delivered_at IS NULL']; + const args: unknown[] = []; + if (ownerId == null) conditions.push('owner_id IS NULL'); + else { conditions.push('owner_id = ?'); args.push(ownerId); } + if (spaceId != null) { conditions.push('space_id = ?'); args.push(spaceId); } + return (db.prepare(`SELECT * FROM agent_reminders WHERE ${conditions.join(' AND ')} ORDER BY due_at ASC`).all(...args) as Record[]).map(fromRow); +} + +export function cancelAgentReminder(db: Database.Database, id: string, ownerId: string | null | undefined): boolean { + const scope = ownerId == null ? 'owner_id IS NULL' : 'owner_id = ?'; + const args = ownerId == null ? [id] : [id, ownerId]; + return db.prepare(`UPDATE agent_reminders SET cancelled_at = datetime('now') WHERE id = ? AND ${scope} AND delivered_at IS NULL AND cancelled_at IS NULL`).run(...args).changes > 0; +} diff --git a/src/db/repositories/schema.ts b/src/db/repositories/schema.ts index e7b65c9..f577527 100644 --- a/src/db/repositories/schema.ts +++ b/src/db/repositories/schema.ts @@ -159,6 +159,8 @@ const __dirname = dirname(__filename); end_date TEXT, time TEXT, end_time TEXT, + reminder_minutes INTEGER, + reminder_delivered_at TEXT, title TEXT NOT NULL, description TEXT, created_by TEXT NOT NULL DEFAULT 'user', @@ -167,6 +169,13 @@ const __dirname = dirname(__filename); updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date); + + CREATE TABLE IF NOT EXISTS agent_reminders ( + id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT, + title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL, + delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL; `); // スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。 @@ -470,6 +479,14 @@ const __dirname = dirname(__filename); CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour ON llm_usage_hourly (user_id, hour); `); + + // LLM selection Phase 1: sticky worker/effort pin. Mirrors schema.sql + + // migrate.ts (dual-path rule) — this is the third compat-upgrade path + // that runs on every bare `new Repository()` (no runMigrations call). + ensureColumn(db, 'jobs', 'required_worker_id', 'TEXT'); + ensureColumn(db, 'jobs', 'reasoning_effort', 'TEXT'); + ensureColumn(db, 'local_tasks', 'llm_worker_id', 'TEXT'); + ensureColumn(db, 'local_tasks', 'llm_effort', 'TEXT'); } diff --git a/src/db/repository.calendar.test.ts b/src/db/repository.calendar.test.ts index a382ff6..f4141a3 100644 --- a/src/db/repository.calendar.test.ts +++ b/src/db/repository.calendar.test.ts @@ -39,6 +39,16 @@ describe('Repository calendar_events', () => { expect(got?.title).toBe('kickoff'); }); + it('claims a due reminder only once', async () => { + const event = await repo.createCalendarEvent({ + spaceId: 'space-A', date: '2026-07-10', time: '10:00', title: 'remind', reminderMinutes: 10, + }); + expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:49')).toEqual([]); + const first = await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:50'); + expect(first.map(item => item.id)).toEqual([event.id]); + expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 10:00')).toEqual([]); + }); + it('defaults: createdBy=user, all-day (time null), description null', async () => { const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-06-20', title: 'allday' }); expect(ev.createdBy).toBe('user'); diff --git a/src/db/repository.llm-selection.test.ts b/src/db/repository.llm-selection.test.ts new file mode 100644 index 0000000..460ca0c --- /dev/null +++ b/src/db/repository.llm-selection.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from './repository.js'; + +let tempDir = ''; +let dbPath = ''; +let repo: Repository; + +async function seedTask(): Promise { + const created = await repo.createLocalTask({ + title: 'seed', + body: 'do the thing', + pieceName: 'auto', + ownerId: 'owner-1', + visibility: 'private', + }); + return created.id; +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'maestro-llm-selection-')); + dbPath = join(tempDir, 'db.sqlite'); + repo = new Repository(dbPath); +}); + +afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('local_tasks.llm_worker_id / llm_effort (LLM selection Phase 1)', () => { + it('is null on a freshly created task', async () => { + const taskId = await seedTask(); + const task = await repo.getLocalTask(taskId); + expect(task).toBeTruthy(); + expect(task!.llmWorkerId).toBeNull(); + expect(task!.llmEffort).toBeNull(); + }); + + it('updateLocalTask sets llmWorkerId/llmEffort and getLocalTask reads them back', async () => { + const taskId = await seedTask(); + await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'medium' } as any); + + const task = await repo.getLocalTask(taskId); + expect(task).toBeTruthy(); + expect(task!.llmWorkerId).toBe('worker-gpu-1'); + expect(task!.llmEffort).toBe('medium'); + }); + + it('updateLocalTask can clear a pinned worker/effort back to null', async () => { + const taskId = await seedTask(); + await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any); + await repo.updateLocalTask(taskId, { llmWorkerId: null, llmEffort: null } as any); + + const task = await repo.getLocalTask(taskId); + expect(task!.llmWorkerId).toBeNull(); + expect(task!.llmEffort).toBeNull(); + }); +}); diff --git a/src/db/repository.reminders.test.ts b/src/db/repository.reminders.test.ts new file mode 100644 index 0000000..d81bd22 --- /dev/null +++ b/src/db/repository.reminders.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Repository } from './repository.js'; +import { runMigrations } from './migrate.js'; + +describe('agent_reminders schema and repository', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'reminders-repo-test-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('Repository.initSchema creates agent_reminders with the expected columns', () => { + const repo = new Repository(join(dir, 'fresh.db')); + const cols = repo.getDb().prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>; + expect(cols.map((c) => c.name)).toEqual( + expect.arrayContaining([ + 'id', + 'owner_id', + 'space_id', + 'title', + 'body', + 'image_url', + 'due_at', + 'delivered_at', + 'cancelled_at', + 'created_at', + ]), + ); + const indexes = repo.getDb().prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>; + expect(indexes).toHaveLength(1); + }); + + it('runMigrations creates agent_reminders on an upgrade path DB', () => { + const db = new Database(':memory:'); + runMigrations(db); + const cols = db.prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>; + expect(cols.map((c) => c.name)).toEqual( + expect.arrayContaining([ + 'id', + 'owner_id', + 'space_id', + 'title', + 'body', + 'image_url', + 'due_at', + 'delivered_at', + 'cancelled_at', + 'created_at', + ]), + ); + const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>; + expect(indexes).toHaveLength(1); + db.close(); + }); + + it('stores, filters, and cancels reminders through the repository facade', () => { + const repo = new Repository(join(dir, 'crud.db')); + const reminder = repo.createAgentReminder({ + ownerId: 'u1', + spaceId: 'space-a', + title: '休憩', + body: 'stretch', + imageUrl: 'https://example.com/pin.png', + dueAt: '2026-07-10T06:30:00.000Z', + }); + + const listed = repo.listAgentReminders('u1', 'space-a'); + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ + id: reminder.id, + ownerId: 'u1', + spaceId: 'space-a', + title: '休憩', + body: 'stretch', + imageUrl: 'https://example.com/pin.png', + dueAt: '2026-07-10T06:30:00.000Z', + deliveredAt: null, + cancelledAt: null, + }); + + expect(repo.cancelAgentReminder(reminder.id, 'u1')).toBe(true); + expect(repo.listAgentReminders('u1', 'space-a')).toHaveLength(0); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index ddb9a64..4bbf349 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -24,6 +24,8 @@ import * as notificationsRepo from './repositories/notifications.js'; import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; import * as webhooksRepo from './repositories/webhooks.js'; import { SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js'; +import * as chatConnectorsRepo from './repositories/chat-connectors.js'; +import { ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js'; import * as auditRepo from './repositories/audit.js'; import * as appSharesRepo from './repositories/app-shares.js'; import * as provenanceRepo from './repositories/provenance.js'; @@ -34,10 +36,14 @@ export type { TitleSource, LocalTask, MissionBriefField, MissionBrief, LocalTask import * as taskSearchRepo from './repositories/task-search.js'; import * as calendarRepo from './repositories/calendar.js'; import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMonth } from './repositories/calendar.js'; +import * as remindersRepo from './repositories/reminders.js'; +import { AgentReminder } from './repositories/reminders.js'; export { enumerateLocalDays } from './repositories/calendar.js'; export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js'; +export type { AgentReminder } from './repositories/reminders.js'; export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; export type { WebhookProvider, WebhookEventType, SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js'; +export type { ChatConnectorPlatform, ChatConnectorBindingStatus, ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js'; export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js'; export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js'; @@ -615,6 +621,10 @@ export class Repository { return jobsRepo.updateJobsVisibilityForTask(this.db, taskId, updates); } + async updateJobsLlmSelectionForTask(taskId: number, sel: { workerId: string | null; effort: string | null }): Promise { + return jobsRepo.updateJobsLlmSelectionForTask(this.db, taskId, sel); + } + async getSubJobs(parentJobId: string): Promise { return jobsRepo.getSubJobs(this.db, parentJobId); } @@ -842,6 +852,7 @@ export class Repository { endDate?: string | null; time?: string | null; endTime?: string | null; + reminderMinutes?: number | null; title: string; description?: string | null; createdBy?: 'user' | 'agent'; @@ -932,7 +943,7 @@ export class Repository { return calendarRepo.getCalendarEvent(this.db, eventId); } - async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise { + async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise { return calendarRepo.updateCalendarEvent(this.db, eventId, fields); } @@ -940,6 +951,22 @@ export class Repository { return calendarRepo.deleteCalendarEvent(this.db, eventId); } + createAgentReminder(input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder { + return remindersRepo.createAgentReminder(this.db, input); + } + + listAgentReminders(ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] { + return remindersRepo.listAgentReminders(this.db, ownerId, spaceId); + } + + cancelAgentReminder(id: string, ownerId: string | null | undefined): boolean { + return remindersRepo.cancelAgentReminder(this.db, id, ownerId); + } + + async claimDueCalendarReminders(spaceId: string, nowLocal: string): Promise { + return calendarRepo.claimDueCalendarReminders(this.db, spaceId, nowLocal); + } + private calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } { return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias); } @@ -1130,6 +1157,43 @@ export class Repository { return webhooksRepo.markSpaceWebhookFailure(this.db, id); } + // ── Chat connector bindings (issue #801, PR1 — Slack MVP) ────────────── + createChatConnectorBinding(input: CreateChatConnectorBindingInput): ChatConnectorBindingRecord { + return chatConnectorsRepo.createChatConnectorBinding(this.db, input); + } + + listChatConnectorBindings(): ChatConnectorBindingRecord[] { + return chatConnectorsRepo.listChatConnectorBindings(this.db); + } + + getChatConnectorBindingById(id: string): ChatConnectorBindingRecord | null { + return chatConnectorsRepo.getChatConnectorBindingById(this.db, id); + } + + findActiveChatConnectorBinding( + platform: chatConnectorsRepo.ChatConnectorPlatform, + externalWorkspaceId: string, + externalChannelId: string, + ): ChatConnectorBindingRecord | null { + return chatConnectorsRepo.findActiveChatConnectorBinding(this.db, platform, externalWorkspaceId, externalChannelId); + } + + listChatConnectorBindingsForDelegation(a2aDelegationId: string): ChatConnectorBindingRecord[] { + return chatConnectorsRepo.listChatConnectorBindingsForDelegation(this.db, a2aDelegationId); + } + + updateChatConnectorBinding(id: string, patch: UpdateChatConnectorBindingInput): ChatConnectorBindingRecord | null { + return chatConnectorsRepo.updateChatConnectorBinding(this.db, id, patch); + } + + deleteChatConnectorBinding(id: string): void { + return chatConnectorsRepo.deleteChatConnectorBinding(this.db, id); + } + + claimChatConnectorEvent(eventId: string): boolean { + return chatConnectorsRepo.claimChatConnectorEvent(this.db, eventId); + } + close(): void { this.db.close(); } diff --git a/src/db/schema.sql b/src/db/schema.sql index 4590d12..71bdeac 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -34,6 +34,10 @@ CREATE TABLE IF NOT EXISTS jobs ( space_id TEXT, -- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。 runtime_dir TEXT, + -- LLM 選択 Phase 1: per-job worker/effort pin。NULL = 従来の profile ルーティング。 + -- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md + required_worker_id TEXT, + reasoning_effort TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -82,7 +86,13 @@ CREATE TABLE IF NOT EXISTS local_tasks ( -- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)(計画3) workspace_mode TEXT NOT NULL DEFAULT 'persistent', -- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。 - runtime_dir TEXT + runtime_dir TEXT, + -- LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで + -- 生成される後続 job にも引き継がれる(引き継ぎ配線は後続タスク)。 + -- NULL = 未選択(従来の profile ルーティング)。 + -- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md + llm_worker_id TEXT, + llm_effort TEXT ); CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC); @@ -156,6 +166,8 @@ CREATE TABLE IF NOT EXISTS calendar_events ( end_date TEXT, -- 終了日 YYYY-MM-DD。NULL = date と同じ(単日) time TEXT, -- 開始 HH:MM。NULL = 終日 end_time TEXT, -- 終了 HH:MM。NULL = 終了時刻なし(time が NULL なら常に NULL) + reminder_minutes INTEGER, -- 開始何分前に通知するか。NULL = 通知なし + reminder_delivered_at TEXT, -- 配信済み UTC。予定/通知設定変更時に NULL へ戻す title TEXT NOT NULL, description TEXT, created_by TEXT NOT NULL DEFAULT 'user', -- 'user' / 'agent' @@ -166,6 +178,20 @@ CREATE TABLE IF NOT EXISTS calendar_events ( CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date); +CREATE TABLE IF NOT EXISTS agent_reminders ( + id TEXT PRIMARY KEY, + owner_id TEXT, + space_id TEXT, + title TEXT, + body TEXT NOT NULL, + image_url TEXT, + due_at TEXT NOT NULL, + delivered_at TEXT, + cancelled_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL; + -- ─── スペース招待リンク(再利用トークン)───────────────────────────── -- 組織非依存の招待経路。canManageSpace が発行し、リンクを知る相手が参加する。 -- スペースごとに有効リンクは最大1本(再生成で旧 invite を削除して回す)。 @@ -935,6 +961,44 @@ CREATE TABLE IF NOT EXISTS space_webhooks ( ); CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id); +-- Chat connector bindings (issue #801, PR1 — Slack MVP): links an external chat +-- channel to a MAESTRO space + an existing A2A delegation. The chat-connector +-- service reuses the delegation's AND-intersected scope (space/skills) and the +-- A2A governance limiter — no separate authz path. bot_credentials_enc is an +-- AES-256-GCM envelope-encrypted BLOB (src/mcp/crypto.ts, same scheme as +-- space_webhooks.url_enc) holding the Slack signing secret + bot token JSON. +-- Never decrypted in this table's repo module; only chat-connector-service.ts +-- decrypts just-in-time and never logs/returns the plaintext. +CREATE TABLE IF NOT EXISTS chat_connector_bindings ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL DEFAULT 'slack', -- 'slack' (PR1 only) + external_workspace_id TEXT NOT NULL, -- Slack team id + external_channel_id TEXT NOT NULL, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + a2a_client_id TEXT NOT NULL, -- a2a_clients.client_id + a2a_delegation_id TEXT NOT NULL, -- a2a_delegations.id + a2a_grant_id TEXT NOT NULL, -- a2a_delegations.grant_id (snapshot) + bot_credentials_enc BLOB NOT NULL, + key_version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'disabled' + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel + ON chat_connector_bindings (platform, external_workspace_id, external_channel_id); +CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id); +CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id); + +-- Slack can retry a successfully delivered Events API envelope. Claiming its +-- event_id before dispatch keeps task creation and replies exactly-once. +CREATE TABLE IF NOT EXISTS chat_connector_processed_events ( + event_id TEXT PRIMARY KEY, + received_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at + ON chat_connector_processed_events (received_at); + -- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created -- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts -- (upgrade path) so the task-list display SELECT never hits "no such table". diff --git a/src/engine/tools/calendar.test.ts b/src/engine/tools/calendar.test.ts index aa6a416..2461bce 100644 --- a/src/engine/tools/calendar.test.ts +++ b/src/engine/tools/calendar.test.ts @@ -34,6 +34,36 @@ describe('calendar tools', () => { it('exposes TOOL_DEFS entries', () => { expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent'); expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents'); + expect(TOOL_DEFS.CreateReminder?.function.name).toBe('CreateReminder'); + expect(TOOL_DEFS.ListReminders?.function.name).toBe('ListReminders'); + expect(TOOL_DEFS.CancelReminder?.function.name).toBe('CancelReminder'); + }); + + it('creates, lists, and cancels an owner-scoped relative reminder', async () => { + const created = await executeTool('CreateReminder', { body: '休憩する', delay_minutes: 20, title: '休憩' }, ctx()); + expect(created?.isError).toBe(false); + const id = created!.output.match(/id=([^)]+)/)?.[1]!; + expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id); + expect((await executeTool('CancelReminder', { id }, ctx()))?.isError).toBe(false); + expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain('ありません'); + }); + + it('rejects past or ambiguous reminder times', async () => { + expect((await executeTool('CreateReminder', { body: 'x', due_at: 'not-a-time' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-02-30T09:00:00Z' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T24:00Z' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00+23:59' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: '2020-01-01T00:00:00.000Z' }, ctx()))?.isError).toBe(true); + expect((await executeTool('CreateReminder', { body: 'x', due_at: new Date(Date.now() + 60_000).toISOString(), delay_minutes: 1 }, ctx()))?.isError).toBe(true); + }); + + it('does not expose or cancel another owner\'s reminder', async () => { + const created = await executeTool('CreateReminder', { body: 'private', delay_minutes: 10 }, ctx()); + const id = created!.output.match(/id=([^)]+)/)?.[1]!; + expect((await executeTool('ListReminders', {}, ctx({ ownerId: 'u2' })))?.output).toContain('ありません'); + expect((await executeTool('CancelReminder', { id }, ctx({ ownerId: 'u2' })))?.isError).toBe(true); + expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id); }); it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => { diff --git a/src/engine/tools/calendar.ts b/src/engine/tools/calendar.ts index 4a98108..39df8ee 100644 --- a/src/engine/tools/calendar.ts +++ b/src/engine/tools/calendar.ts @@ -6,6 +6,14 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; const MAX_TITLE_LEN = 200; const MAX_DESC_LEN = 4000; +const MAX_REMINDER_BODY_LEN = 4000; +const ISO_WITH_TIMEZONE_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))$/; + +function isValidIsoWithTimezone(value: string): boolean { + if (!ISO_WITH_TIMEZONE_PATTERN.test(value) || Number.isNaN(Date.parse(value))) return false; + const [year, month, day] = value.slice(0, 10).split('-').map(Number); + return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate(); +} let _repo: Repository | null = null; @@ -51,9 +59,29 @@ const LIST_CALENDAR_EVENTS_DEF: ToolDef = { }, }; +const CREATE_REMINDER_DEF: ToolDef = { + type: 'function', + function: { + name: 'CreateReminder', + description: '指定時刻または指定分後に、実行ユーザー向けの通知を登録する。過去時刻や曖昧な時刻は登録しない。', + parameters: { type: 'object', properties: { + body: { type: 'string', description: '通知本文(必須)' }, + due_at: { type: 'string', description: 'UTC ISO 8601 時刻。delay_minutes と排他' }, + delay_minutes: { type: 'number', description: '現在からの分数。due_at と排他、1以上' }, + title: { type: 'string', description: '任意のタイトル' }, image_url: { type: 'string', description: '任意の HTTPS 画像 URL' }, + }, required: ['body'] }, + }, +}; + +const LIST_REMINDERS_DEF: ToolDef = { type: 'function', function: { name: 'ListReminders', description: '実行ユーザーが未配信かつ取消していないリマインダーを一覧する。', parameters: { type: 'object', properties: {}, required: [] } } }; +const CANCEL_REMINDER_DEF: ToolDef = { type: 'function', function: { name: 'CancelReminder', description: '実行ユーザーの未配信リマインダーを ID で取消する。', parameters: { type: 'object', properties: { id: { type: 'string', description: 'ListReminders で確認した ID' } }, required: ['id'] } } }; + export const TOOL_DEFS: Record = { AddCalendarEvent: ADD_CALENDAR_EVENT_DEF, ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF, + CreateReminder: CREATE_REMINDER_DEF, + ListReminders: LIST_REMINDERS_DEF, + CancelReminder: CANCEL_REMINDER_DEF, }; export async function executeTool( @@ -63,9 +91,57 @@ export async function executeTool( ): Promise { if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx); if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx); + if (name === 'CreateReminder') return executeCreateReminder(input, ctx); + if (name === 'ListReminders') return executeListReminders(ctx); + if (name === 'CancelReminder') return executeCancelReminder(input, ctx); return null; } +function requireReminderOwner(ctx: ToolContext): string | null { + return typeof ctx.ownerId === 'string' && ctx.ownerId.length > 0 ? ctx.ownerId : null; +} + +async function executeCreateReminder(input: Record, ctx: ToolContext): Promise { + if (!_repo) return { output: 'Reminder repo is not initialized', isError: true }; + const ownerId = requireReminderOwner(ctx); + if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true }; + const body = input.body; + if (typeof body !== 'string' || !body.trim() || body.length > MAX_REMINDER_BODY_LEN) return { output: `body は1〜${MAX_REMINDER_BODY_LEN}文字で指定してください`, isError: true }; + const title = input.title; + if (title !== undefined && (typeof title !== 'string' || title.length > MAX_TITLE_LEN)) return { output: `title は最大${MAX_TITLE_LEN}文字です`, isError: true }; + const imageUrl = input.image_url; + if (imageUrl !== undefined && (typeof imageUrl !== 'string' || !imageUrl.startsWith('https://'))) return { output: 'image_url は HTTPS URL で指定してください', isError: true }; + const hasDueAt = input.due_at !== undefined; + const hasDelay = input.delay_minutes !== undefined; + if (hasDueAt === hasDelay) return { output: 'due_at または delay_minutes のどちらか一方を指定してください', isError: true }; + let due: Date; + if (hasDueAt) { + if (typeof input.due_at !== 'string' || !isValidIsoWithTimezone(input.due_at)) return { output: 'due_at は実在する、タイムゾーン付き ISO 8601 形式で指定してください', isError: true }; + due = new Date(input.due_at); + } else { + if (typeof input.delay_minutes !== 'number' || !Number.isFinite(input.delay_minutes) || input.delay_minutes < 1) return { output: 'delay_minutes は1以上の分数で指定してください', isError: true }; + due = new Date(Date.now() + input.delay_minutes * 60_000); + } + if (due.getTime() <= Date.now()) return { output: '過去の時刻には登録できません。明示的な未来時刻を指定してください', isError: true }; + const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString() }); + return { output: `リマインダーを登録しました: ${reminder.dueAt} (id=${reminder.id})`, isError: false }; +} + +async function executeListReminders(ctx: ToolContext): Promise { + if (!_repo) return { output: 'Reminder repo is not initialized', isError: true }; + const ownerId = requireReminderOwner(ctx); + if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true }; + const reminders = _repo.listAgentReminders(ownerId); + return { output: reminders.length ? reminders.map(r => `- ${r.dueAt} 「${r.title ?? r.body}」(id=${r.id})`).join('\n') : '未配信のリマインダーはありません', isError: false }; +} + +async function executeCancelReminder(input: Record, ctx: ToolContext): Promise { + if (!_repo) return { output: 'Reminder repo is not initialized', isError: true }; + const ownerId = requireReminderOwner(ctx); + if (!ownerId || typeof input.id !== 'string' || !input.id) return { output: 'id とユーザー所有者が必要です', isError: true }; + return _repo.cancelAgentReminder(input.id, ownerId) ? { output: 'リマインダーを取消しました', isError: false } : { output: '未配信のリマインダーが見つかりません', isError: true }; +} + async function executeAddCalendarEvent( input: Record, ctx: ToolContext, diff --git a/src/llm/effort-injection.test.ts b/src/llm/effort-injection.test.ts new file mode 100644 index 0000000..b3b91db --- /dev/null +++ b/src/llm/effort-injection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { buildJobExtraBody, type EffortInjectionWorker } from './effort-injection.js'; + +describe('buildJobExtraBody', () => { + it('jobEffort null → returns worker.extraBody unchanged', () => { + const extraBody = { top_k: 20 }; + const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] }; + const result = buildJobExtraBody(worker, null); + expect(result).toEqual(extraBody); + expect(extraBody).toEqual({ top_k: 20 }); // not mutated + }); + + it('jobEffort undefined → returns worker.extraBody unchanged', () => { + const extraBody = { top_k: 20 }; + const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] }; + const result = buildJobExtraBody(worker, undefined); + expect(result).toEqual(extraBody); + }); + + it('mode unset (default body) + effort in reasoningEfforts → injected at top level', () => { + const extraBody = { top_k: 20 }; + const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] }; + const result = buildJobExtraBody(worker, 'max'); + expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' }); + }); + + it("mode 'chat_template_kwargs' + effort in efforts → nested, preserving existing sub-keys", () => { + const extraBody = { top_k: 20, chat_template_kwargs: { enable_thinking: true } }; + const worker: EffortInjectionWorker = { + extraBody, + reasoningEfforts: ['low', 'high'], + reasoningEffortMode: 'chat_template_kwargs', + }; + const result = buildJobExtraBody(worker, 'high'); + expect(result).toEqual({ + top_k: 20, + chat_template_kwargs: { enable_thinking: true, reasoning_effort: 'high' }, + }); + }); + + it('effort NOT in reasoningEfforts → no injection, extraBody unchanged', () => { + const extraBody = { top_k: 20 }; + const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low'] }; + const result = buildJobExtraBody(worker, 'high'); + expect(result).toEqual(extraBody); + }); + + it('reasoningEfforts undefined + effort set → no injection', () => { + const extraBody = { top_k: 20 }; + const worker: EffortInjectionWorker = { extraBody }; + const result = buildJobExtraBody(worker, 'high'); + expect(result).toEqual(extraBody); + }); + + it('extraBody undefined + effort valid → injects into a fresh object', () => { + const worker: EffortInjectionWorker = { reasoningEfforts: ['max'] }; + const result = buildJobExtraBody(worker, 'max'); + expect(result).toEqual({ reasoning_effort: 'max' }); + }); + + it('chat_template_kwargs is a non-object → does not throw, replaced with fresh object', () => { + const extraBody = { chat_template_kwargs: 'oops' as unknown as Record }; + const worker: EffortInjectionWorker = { + extraBody, + reasoningEfforts: ['high'], + reasoningEffortMode: 'chat_template_kwargs', + }; + let result: Record | undefined; + expect(() => { + result = buildJobExtraBody(worker, 'high'); + }).not.toThrow(); + expect(result).toEqual({ chat_template_kwargs: { reasoning_effort: 'high' } }); + }); + + it('input not mutated: frozen extraBody → returns a new object without throwing', () => { + const extraBody = Object.freeze({ top_k: 20 }); + const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['max'] }; + let result: Record | undefined; + expect(() => { + result = buildJobExtraBody(worker, 'max'); + }).not.toThrow(); + expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' }); + expect(result).not.toBe(extraBody); + }); +}); diff --git a/src/llm/effort-injection.ts b/src/llm/effort-injection.ts new file mode 100644 index 0000000..3259b83 --- /dev/null +++ b/src/llm/effort-injection.ts @@ -0,0 +1,58 @@ +import { logger } from '../logger.js'; + +export interface EffortInjectionWorker { + extraBody?: Record; + reasoningEfforts?: string[]; + reasoningEffortMode?: 'body' | 'chat_template_kwargs'; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Overlay a job's requested reasoning effort onto the worker's extra_body. + * + * - jobEffort null/undefined → return worker.extraBody unchanged + * - effort not declared in worker.reasoningEfforts (or none declared) → do + * NOT inject; return worker.extraBody unchanged and log a warning + * (defensive: the enqueue path validates, so this only fires on a + * config-reload race) + * - mode 'body' (or unset) → { ...extraBody, reasoning_effort: jobEffort } + * - mode 'chat_template_kwargs' → nested under chat_template_kwargs, + * preserving any existing chat_template_kwargs sub-keys + */ +export function buildJobExtraBody( + worker: EffortInjectionWorker, + jobEffort: string | null | undefined, +): Record | undefined { + if (jobEffort === null || jobEffort === undefined) { + return worker.extraBody; + } + + const supported = worker.reasoningEfforts ?? []; + if (!supported.includes(jobEffort)) { + logger.warn(`[effort-injection] effort not supported by worker, skipping effort=${jobEffort}`); + return worker.extraBody; + } + + const extraBody = worker.extraBody ?? {}; + + if (worker.reasoningEffortMode === 'chat_template_kwargs') { + const existingKwargs = isPlainObject(extraBody.chat_template_kwargs) + ? extraBody.chat_template_kwargs + : {}; + return { + ...extraBody, + chat_template_kwargs: { + ...existingKwargs, + reasoning_effort: jobEffort, + }, + }; + } + + return { + ...extraBody, + reasoning_effort: jobEffort, + }; +} diff --git a/src/worker.execute-job-gates.test.ts b/src/worker.execute-job-gates.test.ts index cdec282..f62873d 100644 --- a/src/worker.execute-job-gates.test.ts +++ b/src/worker.execute-job-gates.test.ts @@ -193,4 +193,49 @@ describe('executeJob pre-flight gates (behaviour pinning)', () => { expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false); expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77); }); + + it('does NOT requeue a model-mismatched job when requiredWorkerId pins it to this worker (pin wins over piece.model)', async () => { + // Given: 同じ model-mismatch 条件(worker が exotic-model をサーブできない)だが、 + // job が worker-1 に requiredWorkerId でピン留めされている。ピン留めされたジョブは + // worker-1 にしか claim されないため(Task 8 claim SQL)、ここで requeue すると + // claim → mismatch → requeue の無限ループになる。 + // + // 実行経路は requeueOnModelMismatch の直後に startPieceRun(実 LLM 呼び出し込みの + // ReAct ループ)へ進んでしまい、そこまでモックするのは過剰なため、 + // executeJob から切り出し済みの private ゲートメソッドを直接呼ぶ(既存の + // 「requeue する」ケースは executeJob 経由で pin-gate-piece 一式ごと検証済みなので、 + // ここでは pin による早期リターンだけを焦点にする)。 + const { repo, updateJobCalls } = makeRepoMock(); + const worker = new Worker( + 'worker-1', + 'http://localhost:11434/v1', + 'test-model', + repo as never, + makeConfig({ worktreeDir: '/tmp/worker-gate-pin-model' }), + ); + (worker as unknown as { availableModels: Set }).availableModels = new Set(['test-model']); + + const piece = { + name: 'pin-gate-piece', + description: 'gate pinning test piece', + max_movements: 30, + initial_movement: 'respond', + model: 'exotic-model', + movements: [ + { name: 'respond', persona: 'assistant', instruction: 'answer', rules: [], default_next: 'COMPLETE' }, + ], + }; + + // When: requeueOnModelMismatch を requiredWorkerId='worker-1' の job で直接呼ぶ + const requeued = await ( + worker as unknown as { + requeueOnModelMismatch: (job: Job, piece: unknown) => Promise; + } + ).requeueOnModelMismatch(makeLocalTaskJob({ requiredWorkerId: 'worker-1' }), piece); + + // Then: 「requeue しない」(false) が返り、queued への書き戻し・監査ログのどちらも発生しない + expect(requeued).toBe(false); + expect(updateJobCalls.some(([, patch]) => patch.status === 'queued')).toBe(false); + expect(repo.addAuditLog).not.toHaveBeenCalled(); + }); }); diff --git a/src/worker.extra-body.test.ts b/src/worker.extra-body.test.ts index ed8b171..ba0f1ad 100644 --- a/src/worker.extra-body.test.ts +++ b/src/worker.extra-body.test.ts @@ -111,4 +111,53 @@ describe('Worker LLM client construction — extraBody propagation', () => { const options = constructorSpy.mock.calls[0]?.[8]; expect(options?.extraBody).toBeUndefined(); }); + + it('injects the job reasoning_effort into extraBody when the worker declares it as supported', async () => { + const { Worker } = await import('./worker.js'); + const { LocalProgressReporter } = await import('./progress/local-reporter.js'); + + const extraBody = { top_k: 20 }; + + const config: AppConfig = { + provider: { + model: 'test-model', + workers: [ + { + id: 'worker-1', + endpoint: 'http://localhost:11434/v1', + extraBody, + reasoningEfforts: ['low', 'max'], + }, + ], + }, + worktreeDir: '/tmp/worker-extra-body-test', + concurrency: 1, + maxMovements: 30, + retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] }, + ask: { maxPerJob: 2 }, + subtasks: { maxDepth: 2 }, + tools: { + searxngUrl: 'http://localhost:8080', + visionModel: 'vision', + visionTimeout: 60, + visionMaxTokens: 1024, + webfetchTimeout: 30, + websearchTimeout: 15, + webfetchAllowedHosts: [], + }, + } as AppConfig; + + const repo = {} as Repository; + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'test-model', repo, config); + + const job = { id: 'job-1', requiredRole: 'auto', reasoningEffort: 'max' } as unknown as Job; + const piece = {} as PieceDef; + const reporter = {} as InstanceType; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (worker as any).createJobLlmClient(job, piece, reporter); + + const options = constructorSpy.mock.calls[0]?.[8]; + expect(options?.extraBody).toEqual({ top_k: 20, reasoning_effort: 'max' }); + }); }); diff --git a/src/worker.inherit-job-context.test.ts b/src/worker.inherit-job-context.test.ts index d4765fc..2177da4 100644 --- a/src/worker.inherit-job-context.test.ts +++ b/src/worker.inherit-job-context.test.ts @@ -65,6 +65,8 @@ describe('inheritJobContext (derived-job session inheritance)', () => { visibilityScopeOrgId: 'org-9', spaceId: 'case-42', browserSessionProfileId: null, + requiredWorkerId: null, + reasoningEffort: null, }); }); diff --git a/src/worker.inherit-llm-selection.test.ts b/src/worker.inherit-llm-selection.test.ts new file mode 100644 index 0000000..0104a21 --- /dev/null +++ b/src/worker.inherit-llm-selection.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Repository } from './db/repository.js'; +import { inheritJobContext } from './worker.js'; + +// Task 12 (Phase 1 LLM 選択): a job pinned to a specific worker/reasoning +// effort (requiredWorkerId/reasoningEffort, added in Task 7) must propagate +// that pin to derived jobs — subtask spawn and subtask ASK re-enqueue both +// build their createJob params by spreading inheritJobContext(parent). Without +// this, "use the big model at max effort" would silently fall back to +// auto-routing inside subtasks. +describe('inheritJobContext (worker/reasoning-effort inheritance)', () => { + let dir: string; + let repo: Repository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'inherit-llm-')); + repo = new Repository(join(dir, 'db.sqlite')); + repo.getDb().prepare( + `INSERT INTO users (id, email, role, status, created_at, updated_at) + VALUES ('u-1', 'u1@example.com', 'user', 'active', 0, 0)`, + ).run(); + }); + afterEach(() => { + repo.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('carries the parent job requiredWorkerId and reasoningEffort', async () => { + const parent = await repo.createJob({ + repo: 'local/task-1', issueNumber: 1, instruction: 'x', pieceName: 'general', + ownerId: 'u-1', requiredWorkerId: 'big-worker', reasoningEffort: 'max', + } as never); + + const inherited = inheritJobContext(parent); + expect(inherited.requiredWorkerId).toBe('big-worker'); + expect(inherited.reasoningEffort).toBe('max'); + }); + + it('returns null for both when the parent has no pin', async () => { + const parent = await repo.createJob({ + repo: 'local/task-2', issueNumber: 2, instruction: 'x', pieceName: 'general', + ownerId: 'u-1', + } as never); + + const inherited = inheritJobContext(parent); + expect(inherited.requiredWorkerId).toBeNull(); + expect(inherited.reasoningEffort).toBeNull(); + }); + + // Mirrors worker.ts subtask spawn (~line 1515-1533): the derived job's + // createJob params spread inheritJobContext(parent) — assert the pin + // actually persists on the child row, not just on the intermediate object. + it('a derived job spreading inheritJobContext persists the worker/effort pin', async () => { + const parent = await repo.createJob({ + repo: 'local/task-3', issueNumber: 3, instruction: 'parent', pieceName: 'general', + ownerId: 'u-1', requiredWorkerId: 'big-worker', reasoningEffort: 'max', + } as never); + + const sub = await repo.createJob({ + repo: `subtask/${parent.id}`, issueNumber: 0, instruction: 'child', + pieceName: 'general', parentJobId: parent.id, subtaskDepth: 1, + ...inheritJobContext(parent), + } as never); + + const reloaded = await repo.getJob(sub.id); + expect(reloaded?.requiredWorkerId).toBe('big-worker'); + expect(reloaded?.reasoningEffort).toBe('max'); + }); +}); diff --git a/src/worker.llm-selection-fixes.test.ts b/src/worker.llm-selection-fixes.test.ts new file mode 100644 index 0000000..e713345 --- /dev/null +++ b/src/worker.llm-selection-fixes.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { AppConfig } from './config.js'; +import type { PieceDef } from './engine/piece-runner.js'; +import type { Repository, Job } from './db/repository.js'; + +// codex round3 findings on the Phase 1 LLM-selection branch: +// +// Fix A (P1): resolveModel(piece) prioritized piece.model over the worker's +// own configured model even when the job was pinned to this worker +// (job.requiredWorkerId set). A pinned job must run on the pinned worker's +// model, not whatever model the piece happens to declare. +// +// Fix B (P2): answerSubtaskAsk built its OpenAICompatClient with the raw +// worker extraBody, never applying buildJobExtraBody(workerDef, +// subtaskJob.reasoningEffort) — so an effort-pinned task's auto-answered +// subtask ASK ran at default effort instead of the task's chosen effort. + +const constructorSpy = vi.fn(); + +vi.mock('./llm/openai-compat.js', async (importOriginal) => { + const actual = await importOriginal(); + class SpyOpenAICompatClient extends actual.OpenAICompatClient { + constructor(...args: ConstructorParameters) { + constructorSpy(...args); + super(...args); + } + async *chat() { + yield { type: 'text', text: 'ok' } as never; + } + } + return { ...actual, OpenAICompatClient: SpyOpenAICompatClient }; +}); + +function baseConfig(): AppConfig { + return { + provider: { + model: 'big-model', + workers: [ + { + id: 'worker-1', + endpoint: 'http://localhost:11434/v1', + reasoningEfforts: ['low', 'max'], + }, + ], + }, + worktreeDir: '/tmp/worker-llm-selection-fixes-test', + concurrency: 1, + maxMovements: 30, + retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] }, + ask: { maxPerJob: 2 }, + subtasks: { maxDepth: 2 }, + tools: { + searxngUrl: 'http://localhost:8080', + visionModel: 'vision', + visionTimeout: 60, + visionMaxTokens: 1024, + webfetchTimeout: 30, + websearchTimeout: 15, + webfetchAllowedHosts: [], + }, + } as AppConfig; +} + +describe('Fix A (P1): pinned job runs on the pinned worker model, not piece.model', () => { + beforeEach(() => { + constructorSpy.mockClear(); + }); + + it('resolveModel ignores piece.model when pinned=true, even if the piece model is available', async () => { + const { Worker } = await import('./worker.js'); + const repo = {} as Repository; + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, baseConfig()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (worker as any).availableModels = new Set(['big-model', 'small-model']); + + const piece = { model: 'small-model' } as PieceDef; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pinnedResult = (worker as any).resolveModel(piece, { pinned: true }); + expect(pinnedResult).toBe('big-model'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unpinnedResult = (worker as any).resolveModel(piece, { pinned: false }); + expect(unpinnedResult).toBe('small-model'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaultResult = (worker as any).resolveModel(piece); + expect(defaultResult).toBe('small-model'); + }); + + it('createJobLlmClient resolves the pinned worker model (not piece.model) end-to-end when job.requiredWorkerId is set', async () => { + const { Worker } = await import('./worker.js'); + const { LocalProgressReporter } = await import('./progress/local-reporter.js'); + const repo = {} as Repository; + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, baseConfig()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (worker as any).availableModels = new Set(['big-model', 'small-model']); + + const piece = { model: 'small-model' } as PieceDef; + const reporter = {} as InstanceType; + + const pinnedJob = { id: 'job-1', requiredRole: 'auto', requiredWorkerId: 'worker-1' } as unknown as Job; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (worker as any).createJobLlmClient(pinnedJob, piece, reporter); + expect(constructorSpy.mock.calls[0]?.[1]).toBe('big-model'); + + constructorSpy.mockClear(); + const unpinnedJob = { id: 'job-2', requiredRole: 'auto', requiredWorkerId: null } as unknown as Job; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (worker as any).createJobLlmClient(unpinnedJob, piece, reporter); + expect(constructorSpy.mock.calls[0]?.[1]).toBe('small-model'); + }); +}); + +describe('Fix B (P2): subtask ASK auto-answer applies the subtask job reasoning effort', () => { + beforeEach(() => { + constructorSpy.mockClear(); + }); + + it('answerSubtaskAsk injects reasoning_effort from the subtask job into extraBody', async () => { + const { Worker } = await import('./worker.js'); + const config = baseConfig(); + config.provider.workers![0].extraBody = { top_k: 20 }; + const repo = { + getJob: vi.fn().mockResolvedValue({ id: 'parent-1', instruction: 'parent instruction', ownerId: 'u-1' }), + } as unknown as Repository; + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, config); + + const subtaskJob = { + id: 'subtask-1', + requiredRole: 'auto', + instruction: 'subtask instruction', + reasoningEffort: 'max', + } as unknown as Job; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (worker as any).answerSubtaskAsk(subtaskJob, 'parent-1', 'need a decision'); + + expect(constructorSpy).toHaveBeenCalledTimes(1); + const options = constructorSpy.mock.calls[0]?.[8]; + expect(options?.extraBody).toEqual({ top_k: 20, reasoning_effort: 'max' }); + }); + + it('leaves extraBody unchanged when the subtask job has no reasoning effort pin', async () => { + const { Worker } = await import('./worker.js'); + const config = baseConfig(); + config.provider.workers![0].extraBody = { top_k: 20 }; + const repo = { + getJob: vi.fn().mockResolvedValue({ id: 'parent-1', instruction: 'parent instruction', ownerId: 'u-1' }), + } as unknown as Repository; + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, config); + + const subtaskJob = { + id: 'subtask-2', + requiredRole: 'auto', + instruction: 'subtask instruction', + reasoningEffort: null, + } as unknown as Job; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (worker as any).answerSubtaskAsk(subtaskJob, 'parent-1', 'need a decision'); + + const options = constructorSpy.mock.calls[0]?.[8]; + expect(options?.extraBody).toEqual({ top_k: 20 }); + }); +}); diff --git a/src/worker.ts b/src/worker.ts index a2c1ff2..771a51f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -5,6 +5,7 @@ import { assertProfileOwner } from './engine/browser-session-auth.js'; import { decryptProfileState } from './crypto/profile-dek.js'; import { OpenAICompatClient } from './llm/openai-compat.js'; import { llmRoutingKey, shouldRequeueForModelMismatch } from './llm/routing-key.js'; +import { buildJobExtraBody } from './llm/effort-injection.js'; import { loadPiece, runPiece, PieceRunCallbacks, PieceDef, type PieceRunResult } from './engine/piece-runner.js'; import { LocalProgressReporter } from './progress/local-reporter.js'; import { buildLocalConversationContext } from './engine/local-context.js'; @@ -241,6 +242,8 @@ export function inheritJobContext(job: Job): { visibilityScopeOrgId: string | null; spaceId: string | null; browserSessionProfileId: number | null; + requiredWorkerId: string | null; + reasoningEffort: string | null; } { return { ownerId: job.ownerId, @@ -248,6 +251,11 @@ export function inheritJobContext(job: Job): { visibilityScopeOrgId: job.visibilityScopeOrgId, spaceId: job.spaceId ?? null, browserSessionProfileId: job.browserSessionProfileId ?? null, + // LLM 選択 Phase 1 (Task 12): 派生ジョブ(サブタスク spawn / ASK 再投入)は + // 親のワーカー pin / reasoning effort をそのまま引き継ぐ。null なら従来通り + // 自動ルーティング。 + requiredWorkerId: job.requiredWorkerId, + reasoningEffort: job.reasoningEffort, }; } @@ -1015,7 +1023,7 @@ export class Worker { if (siblings && siblings.length > 1 && this.repo.peekNextClaimable) { const peek = await this.repo.peekNextClaimable(this.workerId); if (peek && peek.id !== this.lastYieldedJobId) { - const idler = this.findIdlerCompetitor(peek.requiredRole); + const idler = peek.requiredWorkerId ? null : this.findIdlerCompetitor(peek.requiredRole); if (idler) { this.lastYieldedJobId = peek.id; idler.pokePoll(); @@ -1099,7 +1107,7 @@ export class Worker { { proxy: workerDefForAnswer.proxy === true, maxStreamMs: this.resolveMaxStreamMs(), - extraBody: workerDefForAnswer.extraBody, + extraBody: buildJobExtraBody(workerDefForAnswer, subtaskJob.reasoningEffort), }, ); @@ -1411,6 +1419,13 @@ export class Worker { */ private async requeueOnModelMismatch(job: Job, piece: PieceDef): Promise { const jobId = job.id; + // A user-pinned job can only ever be claimed by this one worker, so + // requeue-on-model-mismatch would loop forever (claim → mismatch → requeue). + // The explicit worker pin wins over the piece's model hint: proceed with + // the pinned worker's model instead of requeuing. + if (job.requiredWorkerId) { + return false; + } if ( shouldRequeueForModelMismatch({ isGateway: this.getWorkerDef().proxy === true, @@ -1481,7 +1496,7 @@ export class Worker { const resolvedModel = llmRoutingKey({ isGateway: isProxyWorker, role: job.requiredRole, - resolveDirectModel: () => this.resolveModel(piece), + resolveDirectModel: () => this.resolveModel(piece, { pinned: !!job.requiredWorkerId }), }); const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000; const llmClient = new OpenAICompatClient( @@ -1497,7 +1512,7 @@ export class Worker { proxy: isProxyWorker, maxStreamMs: this.resolveMaxStreamMs(), requestPromptProgress: workerDefForLlm.returnProgress === true, - extraBody: workerDefForLlm.extraBody, + extraBody: buildJobExtraBody(workerDefForLlm, job.reasoningEffort), }, ); return { llmClient, isProxyWorker }; @@ -3146,8 +3161,8 @@ export class Worker { } } - private resolveModel(piece: PieceDef): string | undefined { - if (piece.model) { + private resolveModel(piece: PieceDef, opts?: { pinned?: boolean }): string | undefined { + if (piece.model && !opts?.pinned) { if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) { return piece.model; } @@ -3170,6 +3185,27 @@ export class Worker { ): Promise<'requeued_unhealthy' | 'retry' | 'failed'> { const { id: jobId, attempt, maxAttempts } = job; + const recordRetry = async (data: { + disposition: 'retry' | 'requeued_unhealthy'; + nextAttempt: number; + nextRetryAt: string | null; + }): Promise => { + if (job.repo !== localTaskRepoName(job.issueNumber)) return; + const body = JSON.stringify({ + type: 'job_retry', + disposition: data.disposition, + attempt, + nextAttempt: data.nextAttempt, + maxAttempts, + nextRetryAt: data.nextRetryAt, + }); + try { + await this.repo.addLocalTaskComment(job.issueNumber, 'system', body, 'progress'); + } catch (err) { + logger.warn(`[worker:${this.workerId}] failed to record retry history for ${jobId}: ${err}`); + } + }; + const isLlmConnectionFatal = /connection error:\s*fetch failed|econnrefused|enotfound|etimedout|network error/i.test(errorMsg); if (isLlmConnectionFatal) { this.healthy = false; @@ -3195,6 +3231,11 @@ export class Worker { nextRetryAt: null, disposition: 'requeued_unhealthy', }); + await recordRetry({ + disposition: 'requeued_unhealthy', + nextAttempt: attempt, + nextRetryAt: null, + }); logger.warn(`[worker:${this.workerId}] job ${jobId} requeued after LLM connection error; worker marked unhealthy`); return 'requeued_unhealthy'; } @@ -3229,6 +3270,11 @@ export class Worker { nextRetryAt, disposition: 'retry', }); + await recordRetry({ + disposition: 'retry', + nextAttempt: attempt + 1, + nextRetryAt, + }); logger.info(`[worker:${this.workerId}] job ${jobId} scheduled for retry ${attempt + 1}/${maxAttempts} at ${nextRetryAt}`); return 'retry'; } else { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 18cf1db..1b96391 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,8 +4,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSetupState } from './hooks/useSetupState'; import { SetupWizard } from './components/setup/SetupWizard'; import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api'; +import { claimSpaceCalendarReminders } from './api/calendar'; import { useUrlState } from './hooks/useUrlState'; import { useToast } from './hooks/useToast'; +import { ToastHost } from './components/notifications/ToastHost'; +import { ToastPet } from './components/notifications/ToastPet'; import { useLocalTaskList } from './hooks/useTaskList'; import { useBranding } from './hooks/useBranding'; import { useLocalStorageState } from './hooks/useLocalStorageState'; @@ -182,11 +185,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable // 個人スペースを所有するので user=null でも従来通り解決する。 // 一覧が空/ローディング中なら personalSpaceId は null(graceful skeleton)。 const { data: spaces } = useSpaces(); + const { toasts, showToast, dismissToast } = useToast(); + const [settingsSpaceId, setSettingsSpaceId] = useState(null); const personalSpaceId = spaces?.find( s => s.kind === 'personal' && (user == null || s.ownerId === user.id), )?.id ?? null; + useEffect(() => { + if (!spaces?.length) return; + let cancelled = false; + const poll = async () => { + const events = await Promise.all(spaces.map(space => claimSpaceCalendarReminders(space.id).then(items => ({ space, items })).catch(() => null))); + if (cancelled) return; + for (const result of events) { + if (!result) continue; + for (const event of result.items) { + showToast(event.time ? `${event.date} ${event.time}` : event.date, 'info', { + id: `calendar-event-${event.id}`, + title: `予定: ${event.title}`, + actionLabel: 'カレンダーを開く', + onAction: () => setUrlState(prev => ({ ...prev, page: 'calendar', spaceId: result.space.id, spaceTaskId: undefined })), + }); + } + } + }; + void poll(); + const timer = window.setInterval(() => void poll(), 30_000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [spaces, showToast, setUrlState]); + // Legacy `?page=tasks` deep links (the Tasks tab was removed in M2) are // normalized onto the workspace model once spaces have loaded. We wait for the // personal workspace to resolve so the rewrite lands on the right rail, then @@ -298,9 +326,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable */ const [createInitialPiece, setCreateInitialPiece] = useState(null); - // Toast - const { toast, showToast } = useToast(); - // URL sync. While a legacy Tasks deep link is still pending normalization, // skip the push: otherwise this would pushState the parsed fallback (page=tasks // → spaces) and pollute history *before* the legacy redirect's replaceState @@ -450,6 +475,16 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable onNotificationClick: (taskId) => { handleOpenTaskInSpace(taskId); }, + onInAppNotification: (notification) => { + const taskId = notification.data.taskId; + showToast(notification.body, notification.title.startsWith('❌') ? 'error' : 'info', { + id: notification.tag, + title: notification.title, + actionLabel: 'タスクを開く', + onAction: () => handleOpenTaskInSpace(taskId), + visual: notification.tag.endsWith('-succeeded') ? : undefined, + }); + }, }); // V2: SW posts `open-task` when the user clicks an OS notification and the @@ -509,20 +544,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable onCompactChange={setCompactMode} /> -
- {toast && ( -
- {toast.message} -
- )} -
+ - {page === 'settings' &&
} - {page === 'spaces' &&
setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} />
} + {page === 'settings' &&
space.id === urlState.spaceId)?.title} onOpenWorkspaceSettings={urlState.spaceId ? () => { setSettingsSpaceId(urlState.spaceId!); setUrlState(prev => ({ ...prev, page: 'spaces', spaceTaskId: undefined })); } : undefined} />
} + {page === 'spaces' &&
setSettingsSpaceId(null)} onOpenAppSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', spaceTaskId: undefined }))} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} />
} {page === 'calendar' &&
setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} />
} {page === 'pieces' &&
} {page === 'schedules' &&
} @@ -558,4 +583,3 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable ); } - diff --git a/ui/src/api.ts b/ui/src/api.ts index 96ff8ae..f2c0b20 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -24,3 +24,4 @@ export * from './api/skills'; export * from './api/notifications'; export * from './api/usage'; export * from './api/delegate'; +export * from './api/chat-connectors'; diff --git a/ui/src/api/calendar.ts b/ui/src/api/calendar.ts index fe0135d..1a0c632 100644 --- a/ui/src/api/calendar.ts +++ b/ui/src/api/calendar.ts @@ -12,6 +12,8 @@ export interface CalendarEvent { endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日 time: string | null; // 開始 HH:MM。null = 終日 endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) + reminderMinutes: number | null; + reminderDeliveredAt: string | null; title: string; description: string | null; createdBy: 'user' | 'agent'; @@ -102,13 +104,13 @@ export async function fetchSpaceCalendarDay( export async function createCalendarEvent( spaceId: string, - input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null }, + input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title: string; description?: string | null }, ): Promise { - const { endDate, endTime, ...rest } = input; + const { endDate, endTime, reminderMinutes, ...rest } = input; const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }), + body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null, reminder_minutes: reminderMinutes ?? null }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to create event'); @@ -118,12 +120,13 @@ export async function createCalendarEvent( export async function updateCalendarEvent( spaceId: string, eventId: number, - patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }, + patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }, ): Promise { - const { endDate, endTime, ...rest } = patch; + const { endDate, endTime, reminderMinutes, ...rest } = patch; const body: Record = { ...rest }; if (endDate !== undefined) body.end_date = endDate; if (endTime !== undefined) body.end_time = endTime; + if (reminderMinutes !== undefined) body.reminder_minutes = reminderMinutes; const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -143,3 +146,14 @@ export async function deleteCalendarEvent(spaceId: string, eventId: number): Pro throw new Error(err.error || res.statusText); } } + +export async function claimSpaceCalendarReminders(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/reminders/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to claim calendar reminders'); + return data.events as CalendarEvent[]; +} diff --git a/ui/src/api/chat-connectors.ts b/ui/src/api/chat-connectors.ts new file mode 100644 index 0000000..f422089 --- /dev/null +++ b/ui/src/api/chat-connectors.ts @@ -0,0 +1,117 @@ +// ui/src/api/chat-connectors.ts — admin CRUD for chat connector bindings +// (issue #801, Phase 2a). Talks to /api/admin/chat/bindings (requireAdmin) +// and, for the "which delegation backs this binding?" picker, the existing +// admin A2A delegations list at /api/admin/a2a/delegations. +// +// Mirrors the shape of ui/src/api/gateway.ts: plain fetch helpers, no +// react-query baked in (the form component owns useQuery/useMutation). + +// --- Chat connector bindings ------------------------------------------------ + +export type ChatConnectorPlatform = 'slack'; +export type ChatConnectorBindingStatus = 'active' | 'disabled'; + +/** + * Mirrors `toPublicBinding()` in src/bridge/chat/chat-connector-bindings-api.ts. + * Never includes bot credentials — those are write-only from the client's + * point of view. + */ +export interface ChatConnectorBinding { + id: string; + platform: ChatConnectorPlatform; + externalWorkspaceId: string; + externalChannelId: string; + spaceId: string; + a2aClientId: string; + a2aDelegationId: string; + status: ChatConnectorBindingStatus; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateChatConnectorBindingInput { + platform: ChatConnectorPlatform; + externalWorkspaceId: string; + externalChannelId: string; + a2aDelegationId: string; + botCredentials: { signingSecret: string; botToken: string }; +} + +export interface UpdateChatConnectorBindingInput { + status?: ChatConnectorBindingStatus; + botCredentials?: { signingSecret: string; botToken: string }; +} + +async function readErrorMessage(res: Response): Promise { + const body = await res.json().catch(() => ({} as { error?: string })); + return (body as { error?: string }).error ?? res.statusText ?? String(res.status); +} + +export async function fetchChatConnectorBindings(): Promise { + const res = await fetch('/api/admin/chat/bindings'); + if (!res.ok) throw new Error(await readErrorMessage(res)); + const data = (await res.json()) as { bindings: ChatConnectorBinding[] }; + return data.bindings; +} + +export async function createChatConnectorBinding( + input: CreateChatConnectorBindingInput, +): Promise { + const res = await fetch('/api/admin/chat/bindings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(await readErrorMessage(res)); + return res.json(); +} + +export async function updateChatConnectorBinding( + id: string, + patch: UpdateChatConnectorBindingInput, +): Promise { + const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) throw new Error(await readErrorMessage(res)); + return res.json(); +} + +export async function deleteChatConnectorBinding(id: string): Promise { + const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await readErrorMessage(res)); +} + +// --- A2A delegations (admin, read-only) — used to populate the "which +// delegation backs this binding?" picker on the create form. A binding can +// only be backed by a delegation that is live AND grants exactly one space +// (chat-connector-bindings-api.ts enforces the same rule server-side; the +// client-side filter below just keeps invalid choices out of the picker). --- + +export interface AdminA2aDelegation { + id: string; + userId: string; + clientId: string; + clientName: string; + grantedSpaceIds: string[]; + grantedSkills: string[]; + expiresAt: string | null; + revokedAt: string | null; + createdAt: string; + live: boolean; +} + +export async function fetchAdminA2aDelegations(): Promise { + const res = await fetch('/api/admin/a2a/delegations'); + if (!res.ok) throw new Error(await readErrorMessage(res)); + const data = (await res.json()) as { delegations: AdminA2aDelegation[] }; + return data.delegations; +} + +/** Delegations eligible to back a new chat binding: live + exactly one granted space. */ +export function eligibleDelegationsForChatBinding(delegations: AdminA2aDelegation[]): AdminA2aDelegation[] { + return delegations.filter(d => d.live && d.grantedSpaceIds.length === 1); +} diff --git a/ui/src/api/tasks.ts b/ui/src/api/tasks.ts index cf7879a..72b4aa3 100644 --- a/ui/src/api/tasks.ts +++ b/ui/src/api/tasks.ts @@ -52,6 +52,14 @@ export interface LocalTask { visibilityScopeOrgName?: string | null; /** Spaces foundation: which space this task belongs to (null = legacy/個人). */ spaceId?: string | null; + /** + * LLM 選択 Phase 1: sticky なワーカー直接指定。未設定 (null/undefined) は + * プロファイルベースの自動ルーティング。次にディスパッチされるジョブから + * 有効になる(実行中のジョブには影響しない)。 + */ + llmWorkerId?: string | null; + /** llmWorkerId とセットの場合のみ有効な reasoning effort。 */ + llmEffort?: string | null; createdAt: string; updatedAt: string; latestJob?: { @@ -211,6 +219,26 @@ export interface LocalTaskComment { injectedAt: string | null; } +export interface MovementHistoryEvent { + eventId: string; + ts: string; + seq: number; + line: number; + runId: string; + kind: 'movement_start' | 'movement_complete' | 'llm_call_retry'; + movement: string | null; + iteration: number | null; + payload: { + next?: string | null; + waitReason?: string | null; + attempt?: number | null; + maxAttempts?: number | null; + errorClass?: string | null; + httpStatus?: number | null; + delayMs?: number | null; + }; +} + export interface CreateLocalTaskInput { title?: string; body: string; @@ -230,6 +258,13 @@ export interface CreateLocalTaskInput { workspaceMode?: 'persistent' | 'ephemeral'; /** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */ spaceId?: string; + /** + * LLM 選択 Phase 1: タスクをピン留めする実行ワーカー。未指定/null は + * プロファイルルーティングに委ねる(従来どおり)。 + */ + llmWorkerId?: string | null; + /** llmWorkerId とセットの場合のみ有効な reasoning effort。 */ + llmEffort?: string | null; options?: { mcpDisabled?: boolean; skillsDisabled?: boolean; @@ -297,6 +332,13 @@ export async function fetchLocalTaskComments(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/movement-history`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch movement history'); + return data.events ?? []; +} + export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise { const payload: Record = { body, author }; if (attachments && attachments.length > 0) payload.attachments = attachments; @@ -311,7 +353,20 @@ export async function postLocalTaskComment(taskId: number, body: string, author: export async function updateLocalTask( taskId: number, - updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null }, + updates: { + title?: string; + visibility?: Visibility; + visibilityScopeOrgId?: string | null; + /** + * LLM 選択 Phase 1: sticky worker 直接指定。null で解除。effort を送るときは + * llmWorkerId も必ず一緒に送ること(サーバーは PATCH 済みフィールドだけ検証する + * ため、effort だけ null にすると「ワーカーは残っているのに effort だけ消える」 + * のは OK だが、逆に worker を null にするときは effort も null で送らないと + * 「effort はワーカー指定とセットのみ」400 になり得る)。 + */ + llmWorkerId?: string | null; + llmEffort?: string | null; + }, ): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'PATCH', diff --git a/ui/src/api/workers.ts b/ui/src/api/workers.ts index 5920059..08b4735 100644 --- a/ui/src/api/workers.ts +++ b/ui/src/api/workers.ts @@ -44,6 +44,32 @@ export async function fetchWorkerBackends(workerId: string): Promise { + const res = await fetch('/api/llm/workers', { credentials: 'include' }); + // Throw (not []) on a non-OK response so react-query surfaces an error + // state instead of a false "empty worker list". The composer's stalled + // badge is gated on `workersQuery.isSuccess`, so swallowing a transient + // 500/404 into [] made a healthy pinned worker look stalled. + if (!res.ok) throw new Error(`Failed to list LLM workers: ${res.status}`); + const data = await res.json() as { workers?: LlmWorkerListItem[] }; + return data.workers ?? []; +} + // ── Side Info Panel ──────────────────────────────────────────────────────── export interface NodeStatus { diff --git a/ui/src/components/chat/ChatAttachmentList.tsx b/ui/src/components/chat/ChatAttachmentList.tsx new file mode 100644 index 0000000..d016847 --- /dev/null +++ b/ui/src/components/chat/ChatAttachmentList.tsx @@ -0,0 +1,18 @@ +export interface ChatAttachment { + name: string; + contentBase64: string; +} + +export function ChatAttachmentList({ attachments, onRemove }: { attachments: ChatAttachment[]; onRemove: (name: string) => void }) { + if (attachments.length === 0) return null; + return ( +
+ {attachments.map(attachment => ( + + {attachment.name} + + + ))} +
+ ); +} diff --git a/ui/src/components/chat/ChatComposer.tsx b/ui/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000..eb91afd --- /dev/null +++ b/ui/src/components/chat/ChatComposer.tsx @@ -0,0 +1,172 @@ +import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LocalTask } from '../../api'; +import { useDraft } from '../../hooks/useDraft'; +import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; +import { toBase64 } from '../../lib/fileAttachments'; +import { ChatComposerPanel } from './ChatComposerPanel'; + +interface ChatComposerProps { + task: LocalTask; + commentsLength: number; + onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise; + onCancel?: () => Promise; +} + +export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) { + const { t } = useTranslation('chat'); + const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`); + const [draft, setDraft] = useState(restoredDraft ?? ''); + const [attachments, setAttachments] = useState>([]); + const [submitting, setSubmitting] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [sendError, setSendError] = useState(null); + const [showPromptCoach, setShowPromptCoach] = useState(false); + const composerRef = useRef(null); + const fileInputRef = useRef(null); + const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []); + + useEffect(() => { + if (!needsAutosizeFallback) return; + const el = composerRef.current; + if (el) autosizeTextarea(el, 192); + }, [draft, needsAutosizeFallback]); + + const submitBaselineRef = useRef(null); + const submitTimeoutRef = useRef | null>(null); + const jobStatus = task.latestJob?.status; + const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks'; + const isPending = jobStatus === 'queued' || jobStatus === 'retry'; + const hasActiveJob = isBusy || isPending; + const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks'; + const inputLocked = jobStatus === 'dispatching'; + const awaitingToolApproval = + jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request'; + const composerLocked = inputLocked || awaitingToolApproval; + + const releaseSubmitting = () => { + if (submitTimeoutRef.current) { + clearTimeout(submitTimeoutRef.current); + submitTimeoutRef.current = null; + } + submitBaselineRef.current = null; + setSubmitting(false); + }; + + useEffect(() => { + if (!submitting) return; + const baseline = submitBaselineRef.current; + if (baseline === null) return; + if (commentsLength > baseline && hasActiveJob) { + releaseSubmitting(); + } + }, [submitting, commentsLength, hasActiveJob]); + + useEffect(() => { + return () => { + if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current); + }; + }, []); + + const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const converted = await Promise.all( + Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) })), + ); + setAttachments(prev => [...prev, ...converted]); + }; + + const removeAttachment = (name: string) => { + setAttachments(prev => prev.filter(a => a.name !== name)); + }; + + const handleSubmit = async () => { + if ((!draft.trim() && attachments.length === 0) || submitting) return; + setSendError(null); + setSubmitting(true); + submitBaselineRef.current = commentsLength; + try { + await onSubmit(draft, attachments.length > 0 ? attachments : undefined); + setDraft(''); + setAttachments([]); + clearDraft(); + if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current); + submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000); + } catch (e) { + setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed')); + releaseSubmitting(); + } + }; + + const handlePaste = async (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + if (files.length === 0) return; + e.preventDefault(); + const converted = await Promise.all( + files.map(async f => { + const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name; + return { name, contentBase64: await toBase64(f) }; + }), + ); + setAttachments(prev => [...prev, ...converted]); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleCancel = async () => { + if (!onCancel || cancelling) return; + setCancelling(true); + try { + await onCancel(); + } finally { + setCancelling(false); + } + }; + + return ( + fileInputRef.current?.click()} + onRemoveAttachment={removeAttachment} + onFileChange={handleFiles} + onPaste={handlePaste} + onKeyDown={handleKeyDown} + onDraftChange={value => { setDraft(value); saveDraft(value); }} + showPromptCoach={showPromptCoach} + onTogglePromptCoach={() => setShowPromptCoach(value => !value)} + onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }} + onResend={handleSubmit} + composerRef={composerRef} + fileInputRef={fileInputRef} + /> + ); +} diff --git a/ui/src/components/chat/ChatComposerPanel.tsx b/ui/src/components/chat/ChatComposerPanel.tsx new file mode 100644 index 0000000..712f5f4 --- /dev/null +++ b/ui/src/components/chat/ChatComposerPanel.tsx @@ -0,0 +1,226 @@ +import { useTranslation } from 'react-i18next'; +import type { ClipboardEvent, KeyboardEvent, RefObject } from 'react'; +import type { LocalTask } from '../../api'; +import { PromptCoachPanel } from '../create/PromptCoachPanel'; +import { ToolRequestApproval } from './ToolRequestApproval'; +import { PackageRequestApproval } from './PackageRequestApproval'; +import { LlmSelectionControl } from './LlmSelectionControl'; +import { ChatAttachmentList } from './ChatAttachmentList'; +import { WaterContextGauge } from './WaterContextGauge'; + +interface ChatComposerPanelProps { + task: LocalTask; + draft: string; + attachments: Array<{ name: string; contentBase64: string }>; + submitting: boolean; + cancelling: boolean; + sendError: string | null; + isBusy: boolean; + isPending: boolean; + canInterject: boolean; + inputLocked: boolean; + awaitingToolApproval: boolean; + composerLocked: boolean; + hasActiveJob: boolean; + jobStatus: string | null | undefined; + onSubmit: () => Promise; + onCancel?: () => Promise; + onAttachClick: () => void; + onRemoveAttachment: (name: string) => void; + onFileChange: (files: FileList | null) => Promise; + onPaste: (e: ClipboardEvent) => Promise; + onKeyDown: (e: KeyboardEvent) => void; + onDraftChange: (value: string) => void; + showPromptCoach: boolean; + onTogglePromptCoach: () => void; + onApplyPromptRewrite: (value: string) => void; + onResend: () => Promise; + composerRef: RefObject; + fileInputRef: RefObject; +} + +export function ChatComposerPanel({ + task, + draft, + attachments, + submitting, + cancelling, + sendError, + isBusy, + isPending, + canInterject, + inputLocked, + awaitingToolApproval, + composerLocked, + hasActiveJob, + jobStatus, + onSubmit, + onCancel, + onAttachClick, + onRemoveAttachment, + onFileChange, + onPaste, + onKeyDown, + onDraftChange, + showPromptCoach, + onTogglePromptCoach, + onApplyPromptRewrite, + onResend, + composerRef, + fileInputRef, +}: ChatComposerPanelProps) { + const { t } = useTranslation('chat'); + return ( +
+ {hasActiveJob && ( +
+ + {canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')} +
+ )} + + + {sendError && !isBusy && ( +
+ ⚠ {sendError} + +
+ )} +
+ +