diff --git a/docs/maestro-workflow/README.md b/docs/maestro-workflow/README.md index b5582eb..93856e6 100644 --- a/docs/maestro-workflow/README.md +++ b/docs/maestro-workflow/README.md @@ -18,4 +18,4 @@ Maestro Harmony 제품군의 범용 승인·결정·이력 앱. 구현은 [`work ## 후속 구상 -- 채널 에이전트 연동(이메일 triage/reply 승인, 요청 체인): 비전 문서 §4(d) 및 설계 스펙 §9 참조 (2026-08-04 현행화, 미착수) +- 채널 에이전트 연동: Workflow측 토대 구현됨(2026-08-04) — email 프리셋 2종 + parentRequestId 체인/조회 API. 커넥터 에이전트는 미착수 (비전 §4(d), 스펙 2026-08-04-workflow-email-presets-chain 참조) diff --git a/docs/superpowers/specs/2026-08-04-workflow-email-presets-chain-design.md b/docs/superpowers/specs/2026-08-04-workflow-email-presets-chain-design.md new file mode 100644 index 0000000..973a3fc --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-workflow-email-presets-chain-design.md @@ -0,0 +1,42 @@ +# Maestro Workflow 이메일 프리셋 + 요청 체인 설계 + +- 날짜: 2026-08-04 +- 상태: 확정 (비전 §4(d) 채널 에이전트 구상의 Workflow측 토대 2건) +- 범위: `workflow/` 하위만 (경계 규칙 준수) + +## 1. 프리셋 subjectType 2종 (표시 전용 — 서버는 유형을 모름) + +`presets.js` `formatPresetHighlight` 확장: + +- `email-reply`: payload `{ to, subject, draft }` — label `↩ {to}`, + detail은 subject(없으면 draft 앞부분). `to` 없으면 null(프리셋 미적용). +- `email-triage`: payload `{ from, subject, proposedAction }` — label + `✉ {from}`, detail은 proposedAction(없으면 subject). `from` 없으면 null. + +## 2. 요청 체인 (`parentRequestId`) + +- `createDecisionRequest`가 선택 필드 `parentRequestId`를 받는다. + 존재하지 않는 부모면 `PARENT_REQUEST_NOT_FOUND` (라우트에선 404). + 저장 필드는 항상 존재(`null` 기본). +- `listRequestChain(requestId)`: 루트까지 조상 추적 후 자손 BFS — + createdAt 오름차순 정렬로 반환. 미존재 요청은 null. +- 라우트 `GET /api/decision-requests/:id/chain` (운영자 토큰): + `{ items }`. 404 = 요청 없음. +- `REQUEST_CREATED` 이력 엔트리에 `parentRequestId` 포함(체인 감사 추적). +- 영속화는 기존 스토어 직렬화로 자동 포함. + +## 3. 대시보드 최소 표시 + +DecisionSheet에 `parentRequestId`가 있으면 "체인 이전 요청: " 라인 표시. +체인 시각화(레인 묶음)는 후속. + +## 4. 테스트 + +- presets 단위: 2종 하이라이트 + 필수 필드 누락 시 null. +- 서버: 체인 생성(A→B→C) 후 chain 조회가 3건 오름차순, 미존재 부모 404, + 엄격 모드에서 chain 401. +- UI: DecisionSheet 부모 라인 렌더. + +## 5. 비범위 + +커넥터/발송(에이전트 몫), 체인 레인 시각화, actor의 chain 조회(운영자 전용 시작). diff --git a/workflow/README.md b/workflow/README.md index 102fe87..b39f3f5 100644 --- a/workflow/README.md +++ b/workflow/README.md @@ -45,5 +45,7 @@ actor도 자신의 actorToken으로 같은 `WORKFLOW_AUTH` 핸드셰이크를 - 토큰은 localStorage에 평문 저장된다 — 로컬 신뢰 기기 전제. TLS 없음, 기본 `HOST=127.0.0.1` 로컬 전용 전제를 유지하라. - 다중 운영자/권한 분리는 후속 스펙으로 예약한다. -- 채널 에이전트 연동(이메일 확인·답장 승인 등)과 `parentRequestId` 요청 체인은 - 비전 문서 §4(d) 구상으로 예약한다 — 프리셋 2종+체인 필드만으로 수용 가능. +- 채널 에이전트 연동의 Workflow측 토대는 구현됨(2026-08-04): 프리셋 + `email-triage`/`email-reply` 표시, `parentRequestId` 요청 체인 + + `GET /api/decision-requests/:id/chain`(운영자 토큰). 커넥터(IMAP/발송) + 에이전트 자체는 비범위 — 비전 문서 §4(d) 참조. diff --git a/workflow/server.js b/workflow/server.js index dacc674..c0903c3 100644 --- a/workflow/server.js +++ b/workflow/server.js @@ -23,6 +23,7 @@ import { getDecisionByRequestId, getRequest, initDecisionStore, + listRequestChain, listRequests, } from './server/decisions.js'; import { appendHistory, initHistoryStore, listHistory } from './server/history.js'; @@ -190,18 +191,37 @@ async function handleRequest(req, res) { const request = createDecisionRequest(data); console.log(`📨 결정 요청 수신: [${request.actorId}] (${request.subjectType}) ${request.subject.title}`); broadcast({ type: 'WORKFLOW_REQUEST_CREATED', item: request }); - recordHistory({ event: 'REQUEST_CREATED', requestId: request.requestId, actorId: request.actorId, subjectType: request.subjectType, title: request.subject.title }); + recordHistory({ event: 'REQUEST_CREATED', requestId: request.requestId, actorId: request.actorId, subjectType: request.subjectType, title: request.subject.title, parentRequestId: request.parentRequestId }); sendJson(res, 200, { success: true, item: request }); } catch (error) { if (error.code === 'SUBJECT_TYPE_REQUIRED' || error.code === 'SUBJECT_TITLE_REQUIRED') { sendJson(res, 400, { error: error.code }); return; } + if (error.code === 'PARENT_REQUEST_NOT_FOUND') { + sendJson(res, 404, { error: error.code }); + return; + } sendJson(res, 500, { error: 'INTERNAL_ERROR' }); } return; } + const chainMatch = pathname.match(/^\/api\/decision-requests\/([^/]+)\/chain$/); + if (req.method === 'GET' && chainMatch) { + if (!isServerAuthorized(req)) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const chain = listRequestChain(decodeURIComponent(chainMatch[1])); + if (!chain) { + sendJson(res, 404, { error: 'DECISION_REQUEST_NOT_FOUND' }); + return; + } + sendJson(res, 200, { items: chain }); + return; + } + if (req.method === 'GET' && pathname === '/api/decision-requests') { if (!isServerAuthorized(req)) { sendJson(res, 401, { error: 'Unauthorized' }); diff --git a/workflow/server/decisions.js b/workflow/server/decisions.js index fe2d655..b77f351 100644 --- a/workflow/server/decisions.js +++ b/workflow/server/decisions.js @@ -32,7 +32,7 @@ export function initDecisionStore(path) { } } -export function createDecisionRequest({ actorId, subjectType, subject = {}, source = 'agent' } = {}) { +export function createDecisionRequest({ actorId, subjectType, subject = {}, source = 'agent', parentRequestId = null } = {}) { const type = sanitizeText(subjectType, 40).toLowerCase(); if (!type) { const error = new Error('SUBJECT_TYPE_REQUIRED'); @@ -45,9 +45,18 @@ export function createDecisionRequest({ actorId, subjectType, subject = {}, sour error.code = 'SUBJECT_TITLE_REQUIRED'; throw error; } + // 요청 체인 (스펙 2026-08-04 §2): 부모는 반드시 실존해야 한다 + const parentId = sanitizeText(parentRequestId, 60) || null; + if (parentId && !requestsById.has(parentId)) { + const error = new Error('PARENT_REQUEST_NOT_FOUND'); + error.code = 'PARENT_REQUEST_NOT_FOUND'; + throw error; + } + const now = new Date().toISOString(); const request = { requestId: `dcr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + parentRequestId: parentId, actorId: sanitizeText(actorId, 80) || 'unknown', subjectType: type, subject: { @@ -72,6 +81,40 @@ export function getRequest(requestId) { return requestsById.get(requestId) || null; } +// 체인 전체 조회: 루트까지 조상 추적 후 자손 포함, createdAt 오름차순 (스펙 2026-08-04 §2). +export function listRequestChain(requestId) { + const start = requestsById.get(requestId); + if (!start) { + return null; + } + + let root = start; + const visited = new Set([root.requestId]); + while (root.parentRequestId && requestsById.has(root.parentRequestId) && !visited.has(root.parentRequestId)) { + root = requestsById.get(root.parentRequestId); + visited.add(root.requestId); + } + + const chain = []; + const queue = [root.requestId]; + const included = new Set(); + while (queue.length) { + const currentId = queue.shift(); + if (included.has(currentId)) continue; + included.add(currentId); + const current = requestsById.get(currentId); + if (!current) continue; + chain.push(current); + for (const candidate of requestsById.values()) { + if (candidate.parentRequestId === currentId && !included.has(candidate.requestId)) { + queue.push(candidate.requestId); + } + } + } + + return chain.sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt)); +} + export function listRequests({ status = null } = {}) { const items = Array.from(requestsById.values()); const filtered = status ? items.filter((item) => item.status === status) : items; diff --git a/workflow/src/components/DecisionSheet.jsx b/workflow/src/components/DecisionSheet.jsx index 8476611..8971367 100644 --- a/workflow/src/components/DecisionSheet.jsx +++ b/workflow/src/components/DecisionSheet.jsx @@ -42,6 +42,9 @@ export default function DecisionSheet({ request, onDecide, onClose }) { {JSON.stringify(request.subject.payload, null, 2)}
요청자: {request.actorId}
+ {request.parentRequestId ? ( +
체인 이전 요청: {request.parentRequestId}
+ ) : null} {rejecting ? (
diff --git a/workflow/src/components/DecisionSheet.test.jsx b/workflow/src/components/DecisionSheet.test.jsx index b1d9110..6a0eb92 100644 --- a/workflow/src/components/DecisionSheet.test.jsx +++ b/workflow/src/components/DecisionSheet.test.jsx @@ -51,4 +51,21 @@ describe('DecisionSheet', () => { await userEvent.click(screen.getByRole('button', { name: '보완 요청' })); expect(onDecide).toHaveBeenCalledWith('revise', ''); }); + + it('parentRequestId가 있으면 체인 이전 요청을 표시한다', () => { + render( + {}} + onClose={() => {}} + />, + ); + expect(screen.getByText(/체인 이전 요청: dcr_1/)).toBeInTheDocument(); + }); }); diff --git a/workflow/src/lib/presets.js b/workflow/src/lib/presets.js index 5d754e9..c3804c3 100644 --- a/workflow/src/lib/presets.js +++ b/workflow/src/lib/presets.js @@ -16,5 +16,25 @@ export function formatPresetHighlight(subjectType, payload = {}) { detail: payload.contentSummary ? String(payload.contentSummary) : '', }; } + // 이메일 채널 프리셋 (스펙 2026-08-04 §1) — 역시 표시 전용 + if (subjectType === 'email-reply') { + if (!payload.to) return null; + const draftSnippet = payload.draft ? String(payload.draft).slice(0, 80) : ''; + return { + label: `↩ ${payload.to}`, + detail: payload.subject ? String(payload.subject) : draftSnippet, + }; + } + if (subjectType === 'email-triage') { + if (!payload.from) return null; + return { + label: `✉ ${payload.from}`, + detail: payload.proposedAction + ? String(payload.proposedAction) + : payload.subject + ? String(payload.subject) + : '', + }; + } return null; } diff --git a/workflow/src/lib/presets.test.js b/workflow/src/lib/presets.test.js index ea54ee0..47ab7ed 100644 --- a/workflow/src/lib/presets.test.js +++ b/workflow/src/lib/presets.test.js @@ -18,3 +18,26 @@ describe('formatPresetHighlight', () => { expect(formatPresetHighlight('publish', {})).toBeNull(); }); }); + +describe('email 프리셋 (채널 에이전트 구상)', () => { + it('email-reply는 수신자 라벨과 제목 디테일을 만든다', () => { + expect(formatPresetHighlight('email-reply', { to: 'client@corp.com', subject: '견적 회신', draft: '안녕하세요...' })) + .toEqual({ label: '↩ client@corp.com', detail: '견적 회신' }); + }); + + it('email-reply는 제목이 없으면 초안 앞부분을 디테일로 쓴다', () => { + const highlight = formatPresetHighlight('email-reply', { to: 'a@b.c', draft: '긴 초안 본문입니다. 뒷부분은 잘립니다.' }); + expect(highlight.label).toBe('↩ a@b.c'); + expect(highlight.detail.startsWith('긴 초안 본문')).toBe(true); + }); + + it('email-triage는 발신자 라벨과 처리방침 디테일을 만든다', () => { + expect(formatPresetHighlight('email-triage', { from: 'boss@corp.com', subject: '계약 검토', proposedAction: '법무 전달 후 회신' })) + .toEqual({ label: '✉ boss@corp.com', detail: '법무 전달 후 회신' }); + }); + + it('필수 필드가 없으면 null (프리셋 미적용)', () => { + expect(formatPresetHighlight('email-reply', { subject: 'x' })).toBeNull(); + expect(formatPresetHighlight('email-triage', { subject: 'x' })).toBeNull(); + }); +}); diff --git a/workflow/tests/request-chain.test.mjs b/workflow/tests/request-chain.test.mjs new file mode 100644 index 0000000..0e3e215 --- /dev/null +++ b/workflow/tests/request-chain.test.mjs @@ -0,0 +1,91 @@ +// 요청 체인 (스펙 2026-08-04 §2): parentRequestId 연결과 chain 조회. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, cleanupDataDir, authHeaders } from './helpers.mjs'; + +const SERVER_TOKEN = 'wf-server-secret'; + +async function setupActor(server, actorId) { + const res = await fetch(`http://127.0.0.1:${server.port}/api/actors/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(SERVER_TOKEN) }, + body: JSON.stringify({ actorId }), + }); + return (await res.json()).actorToken; +} + +async function postRequest(server, token, payload) { + const res = await fetch(`http://127.0.0.1:${server.port}/api/decision-requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(token) }, + body: JSON.stringify(payload), + }); + return res; +} + +test('parentRequestId로 체인을 만들고 chain 조회가 오름차순 전체를 돌려준다', async () => { + const server = await startServer({ serverToken: SERVER_TOKEN }); + try { + const token = await setupActor(server, 'agent_mail'); + const first = await (await postRequest(server, token, { + subjectType: 'email-triage', + subject: { title: '메일 분류: 계약 검토', payload: { from: 'boss@corp.com' } }, + })).json(); + const second = await (await postRequest(server, token, { + subjectType: 'email-reply', + subject: { title: '답장 초안 v1' }, + parentRequestId: first.item.requestId, + })).json(); + const third = await (await postRequest(server, token, { + subjectType: 'email-reply', + subject: { title: '답장 초안 v2 (반려 반영)' }, + parentRequestId: second.item.requestId, + })).json(); + + assert.equal(second.item.parentRequestId, first.item.requestId); + assert.equal(first.item.parentRequestId, null); + + // 체인 조회는 중간 노드 기준으로도 전체를 돌려준다 + const chainRes = await fetch( + `http://127.0.0.1:${server.port}/api/decision-requests/${second.item.requestId}/chain`, + { headers: authHeaders(SERVER_TOKEN) }, + ); + assert.equal(chainRes.status, 200); + const chain = (await chainRes.json()).items; + assert.deepEqual( + chain.map((item) => item.requestId), + [first.item.requestId, second.item.requestId, third.item.requestId], + ); + } finally { + await server.stop(); + cleanupDataDir(server.dataDir); + } +}); + +test('존재하지 않는 부모는 404 PARENT_REQUEST_NOT_FOUND', async () => { + const server = await startServer({ serverToken: SERVER_TOKEN }); + try { + const token = await setupActor(server, 'agent_mail'); + const res = await postRequest(server, token, { + subjectType: 'email-reply', + subject: { title: '고아 요청' }, + parentRequestId: 'dcr_missing', + }); + assert.equal(res.status, 404); + assert.equal((await res.json()).error, 'PARENT_REQUEST_NOT_FOUND'); + } finally { + await server.stop(); + cleanupDataDir(server.dataDir); + } +}); + +test('엄격 모드에서 chain 조회는 운영자 토큰을 요구한다', async () => { + const server = await startServer({ serverToken: SERVER_TOKEN }); + try { + const res = await fetch(`http://127.0.0.1:${server.port}/api/decision-requests/dcr_x/chain`); + assert.equal(res.status, 401); + } finally { + await server.stop(); + cleanupDataDir(server.dataDir); + } +});