Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/maestro-workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 참조)
Original file line number Diff line number Diff line change
@@ -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`가 있으면 "체인 이전 요청: <id>" 라인 표시.
체인 시각화(레인 묶음)는 후속.

## 4. 테스트

- presets 단위: 2종 하이라이트 + 필수 필드 누락 시 null.
- 서버: 체인 생성(A→B→C) 후 chain 조회가 3건 오름차순, 미존재 부모 404,
엄격 모드에서 chain 401.
- UI: DecisionSheet 부모 라인 렌더.

## 5. 비범위

커넥터/발송(에이전트 몫), 체인 레인 시각화, actor의 chain 조회(운영자 전용 시작).
6 changes: 4 additions & 2 deletions workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 참조.
22 changes: 21 additions & 1 deletion workflow/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getDecisionByRequestId,
getRequest,
initDecisionStore,
listRequestChain,
listRequests,
} from './server/decisions.js';
import { appendHistory, initHistoryStore, listHistory } from './server/history.js';
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist parentRequestId in history entries

When a chained request is created, this call appears to add parentRequestId to the audit trail, but appendHistory() rebuilds entries from a fixed whitelist and does not copy input.parentRequestId, so /api/history and persisted history still lose the chain link. In any email/request-chain flow that relies on history for auditability, the created event cannot be tied back to its parent unless the history entry schema is updated to store this field.

Useful? React with 👍 / 👎.

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' });
Expand Down
45 changes: 44 additions & 1 deletion workflow/server/decisions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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: {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions workflow/src/components/DecisionSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export default function DecisionSheet({ request, onDecide, onClose }) {
{JSON.stringify(request.subject.payload, null, 2)}
</pre>
<div className="mt-2 text-xs text-slate-500">요청자: {request.actorId}</div>
{request.parentRequestId ? (
<div className="mt-1 text-xs text-indigo-300">체인 이전 요청: {request.parentRequestId}</div>
) : null}

{rejecting ? (
<div className="mt-4">
Expand Down
17 changes: 17 additions & 0 deletions workflow/src/components/DecisionSheet.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,21 @@ describe('DecisionSheet', () => {
await userEvent.click(screen.getByRole('button', { name: '보완 요청' }));
expect(onDecide).toHaveBeenCalledWith('revise', '');
});

it('parentRequestId가 있으면 체인 이전 요청을 표시한다', () => {
render(
<DecisionSheet
request={{
requestId: 'dcr_2',
parentRequestId: 'dcr_1',
subjectType: 'email-reply',
actorId: 'agent_mail',
subject: { title: '답장 초안 v2', summary: '', payload: { to: 'client@corp.com' } },
}}
onDecide={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText(/체인 이전 요청: dcr_1/)).toBeInTheDocument();
});
});
20 changes: 20 additions & 0 deletions workflow/src/lib/presets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
23 changes: 23 additions & 0 deletions workflow/src/lib/presets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
91 changes: 91 additions & 0 deletions workflow/tests/request-chain.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading