diff --git a/app/api/companies/[ws]/mail/route.js b/app/api/companies/[ws]/mail/route.js
new file mode 100644
index 0000000..c5dae4e
--- /dev/null
+++ b/app/api/companies/[ws]/mail/route.js
@@ -0,0 +1,58 @@
+// 쪽지함 — 크루 우편함(src/crewmail.mjs)의 화면용 API. GET 목록 / POST 사장 발신 / DELETE 대기 취소 / PATCH 실패함 조작.
+import { listMail, sendCrewMail, cancelMail, requeueDead, deleteDead } from '../../../../../src/crewmail.mjs';
+import { loadCompany } from '../../../../../src/workspace.mjs';
+import { guardCompany } from '../../../../auth.mjs';
+
+export async function GET(_req, { params }) {
+ const { ws } = await params;
+ const denied = await guardCompany(ws); if (denied) return denied;
+ try {
+ return Response.json(await listMail(ws));
+ } catch (e) {
+ return Response.json({ error: String(e.message || e) }, { status: 500 });
+ }
+}
+
+/** 사장 발신 — room.mjs의 회의실 cc와 같은 신원(from:'captain', fromRole:'captain'). 이름은 회사 언어에 따른 호칭. */
+export async function POST(req, { params }) {
+ try {
+ const { ws } = await params;
+ const denied = await guardCompany(ws); if (denied) return denied;
+ const { to, cc = [], message } = await req.json();
+ if (!to || !String(message ?? '').trim()) return Response.json({ error: '수신 크루와 내용이 필요합니다' }, { status: 400 });
+ const { lang = 'ko' } = await loadCompany(ws).catch(() => ({}));
+ const id = await sendCrewMail(ws, {
+ from: 'captain', fromName: lang === 'en' ? 'the captain' : '사장', fromRole: 'captain',
+ to: String(to), cc: Array.isArray(cc) ? cc.map(String) : [], message: String(message),
+ });
+ return Response.json({ id });
+ } catch (e) {
+ return Response.json({ error: String(e.message || e) }, { status: 400 });
+ }
+}
+
+export async function DELETE(req, { params }) {
+ try {
+ const { ws } = await params;
+ const denied = await guardCompany(ws); if (denied) return denied;
+ const url = new URL(req.url);
+ const to = url.searchParams.get('to'); const id = url.searchParams.get('id');
+ if (!to || !id) return Response.json({ error: 'to·id가 필요합니다' }, { status: 400 });
+ return Response.json(await cancelMail(ws, to, id));
+ } catch (e) {
+ return Response.json({ error: String(e.message || e) }, { status: 400 });
+ }
+}
+
+export async function PATCH(req, { params }) {
+ try {
+ const { ws } = await params;
+ const denied = await guardCompany(ws); if (denied) return denied;
+ const { op, file } = await req.json();
+ if (op === 'requeue') return Response.json(await requeueDead(ws, file));
+ if (op === 'deleteDead') return Response.json(await deleteDead(ws, file));
+ return Response.json({ error: 'op는 requeue 또는 deleteDead입니다' }, { status: 400 });
+ } catch (e) {
+ return Response.json({ error: String(e.message || e) }, { status: 400 });
+ }
+}
diff --git a/app/c/[ws]/layout.jsx b/app/c/[ws]/layout.jsx
index bc37a25..389e69e 100644
--- a/app/c/[ws]/layout.jsx
+++ b/app/c/[ws]/layout.jsx
@@ -220,6 +220,7 @@ export default function CompanyShell({ children, params }) {
: pathname.endsWith('/routines') ? t('nav.routines')
: pathname.endsWith('/market') ? t('nav.market')
: pathname.endsWith('/activity') ? t('nav.activity')
+ : pathname.endsWith('/mail') ? t('nav.mail')
: pathname.endsWith('/settings') ? t('nav.settings')
: currentCrew ? currentCrew.name : t('nav.deck');
// 사이드바 크루 — 고정(pin) 크루는 최상단 '고정' 그룹으로, 나머지는 팀별 그룹(팀 없는 크루는 마지막).
@@ -270,6 +271,9 @@ export default function CompanyShell({ children, params }) {
{t('nav.activity')}
+
+ {t('nav.mail')}
+
{t('nav.market')}
diff --git a/app/c/[ws]/mail/page.jsx b/app/c/[ws]/mail/page.jsx
new file mode 100644
index 0000000..151701c
--- /dev/null
+++ b/app/c/[ws]/mail/page.jsx
@@ -0,0 +1,250 @@
+'use client';
+// 쪽지함 — 크루 우편함(mail/)의 화면. 사장 발신 + 대기/배달 기록/실패함. 5초 폴링(배달은 스케줄러가 1분 틱으로).
+import { use, useCallback, useEffect, useState } from 'react';
+import Link from 'next/link';
+import { Icon, Avatar, Spinner, Skeleton, ConfirmModal, DropUp, api, timeAgo } from '../../../ui';
+import { useLang } from '../../../i18n';
+
+const CC_MAX = 4; // src/crewmail.mjs CC_MAX와 같은 값 — 서버가 최종 강제, 여기선 안내·토글 상한
+
+export default function Mail({ params }) {
+ const { ws } = use(params);
+ const { t, lang } = useLang();
+ const [data, setData] = useState(null);
+ const [agents, setAgents] = useState([]);
+ const [error, setError] = useState('');
+ const [to, setTo] = useState('');
+ const [cc, setCc] = useState([]);
+ const [message, setMessage] = useState('');
+ const [sending, setSending] = useState(false);
+ const [notice, setNotice] = useState('');
+ const [busy, setBusy] = useState(''); // 진행 중인 행 조작 키
+ const [delTarget, setDelTarget] = useState(null);
+
+ const load = useCallback(() => api(`/api/companies/${ws}/mail`)
+ .then((d) => { setData(d); setError(''); })
+ .catch((e) => { setError(String(e?.message || '') || t('mail.loadFail')); }), [ws, t]);
+
+ useEffect(() => {
+ api(`/api/companies/${ws}`).then((d) => setAgents(d.agents ?? [])).catch(() => {});
+ }, [ws]);
+ useEffect(() => {
+ load();
+ const iv = setInterval(load, 5000);
+ return () => clearInterval(iv);
+ }, [load]);
+ useEffect(() => { if (!to && agents.length) setTo(agents[0].slug); }, [agents, to]);
+
+ const nameOf = (slug) => (slug === 'captain' ? t('mail.fromCaptain') : (agents.find((a) => a.slug === slug)?.name ?? slug));
+
+ async function send(e) {
+ e.preventDefault();
+ if (sending || !to || !message.trim()) return;
+ setSending(true); setError(''); setNotice('');
+ try {
+ await api(`/api/companies/${ws}/mail`, { to, cc: cc.filter((s) => s !== to), message });
+ setMessage(''); setCc([]); setNotice(t('mail.sent'));
+ load();
+ } catch (err) {
+ setError(String(err.message));
+ } finally { setSending(false); }
+ }
+
+ async function cancel(m) {
+ setBusy(`c:${m.to}:${m.id}`); setError('');
+ try {
+ await fetch(`/api/companies/${ws}/mail?to=${encodeURIComponent(m.to)}&id=${encodeURIComponent(m.id)}`, { method: 'DELETE' })
+ .then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); });
+ load();
+ } catch (err) { setError(String(err.message)); } finally { setBusy(''); }
+ }
+
+ async function patch(op, file) {
+ setBusy(`${op}:${file}`); setError('');
+ try {
+ await fetch(`/api/companies/${ws}/mail`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op, file }) })
+ .then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); });
+ load();
+ } catch (err) { setError(String(err.message)); } finally { setBusy(''); }
+ }
+
+ async function doDelete() {
+ const d = delTarget; if (!d) return;
+ setDelTarget(null); // 모달을 await 전에 닫아 더블클릭 이중 요청 차단(루틴 페이지와 동일)
+ await patch('deleteDead', d.file);
+ }
+
+ const toggleCc = (slug) => setCc((cur) => (cur.includes(slug) ? cur.filter((s) => s !== slug) : (cur.length >= CC_MAX ? cur : [...cur, slug])));
+ const pending = data?.pending ?? [];
+ const log = data?.log ?? [];
+ const dead = data?.dead ?? [];
+ const kindChip = (k) => {k === 'cc' ? t('mail.kind.cc') : t('mail.kind.to')};
+ const who = (slug) => (
+
+ {nameOf(slug)}
+
+ );
+
+ return (
+
+ {delTarget && (
+
setDelTarget(null)} />
+ )}
+ {t('mail.intro')}
+
+ {/* 보내기 */}
+
+
+ {/* ① 대기 중 */}
+
+
+ {t('mail.pending')}
+
+ {pending.length}
+
+ {data === null ? (
+ error ?
{error}
+ :
+ ) : pending.length === 0 ? (
+
{t('mail.emptyPending')}
+ ) : (
+
+
+ | {t('mail.to')} | {t('mail.from')} | {t('mail.message')} | | |
+
+
+ {pending.map((m) => (
+
+ | {who(m.to)} |
+ {m.fromRole === 'captain' ? t('mail.fromCaptain') : (m.fromName ?? m.from)} {kindChip(m.kind)} |
+ {m.message} |
+
+ {m.ts ? timeAgo(m.ts, lang) : '—'} · {t('mail.attempts', { n: m.attempts })}
+ {m.claimed && {t('mail.claimed')}}
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+ {/* ② 배달 기록 */}
+
+
+ {t('mail.log')}
+
+
+ {data === null ? (
+
+ ) : log.length === 0 ? (
+
{t('mail.emptyLog')}
+ ) : (
+
+
+ {log.map((l, i) => (
+
+ |
+
+ |
+ {who(l.to)} |
+ {l.from === 'captain' ? t('mail.fromCaptain') : (l.fromName ?? l.from)} {l.kind && kindChip(l.kind)} |
+
+ {l.ok ? t('mail.ok') : (l.error === 'cancelled' ? t('mail.cancelled') : `${t('mail.fail')} — ${l.error ?? ''}`)}
+ |
+ {timeAgo(l.ts, lang)} |
+
+ {t('mail.openChat')}
+ |
+
+ ))}
+
+
+ )}
+
+
+ {/* ③ 실패함 */}
+
+
+ {t('mail.dead')}
+
+ {dead.length > 0 && {dead.length}}
+
+ {data === null ? (
+
+ ) : dead.length === 0 ? (
+
{t('mail.emptyDead')}
+ ) : (
+
+
+ {dead.map((d) => (
+
+ | {d.corrupt ? {d.file} : who(d.to)} |
+ {d.corrupt ? '' : <>{d.from === 'captain' ? t('mail.fromCaptain') : (d.fromName ?? d.from)} {d.kind && kindChip(d.kind)}>} |
+
+ {d.corrupt
+ ? {t('mail.corrupt')}
+ : <>
+ {d.message}
+ {t('mail.attempts', { n: d.attempts })} · {d.lastError}
+ >}
+ |
+
+
+ {!d.corrupt && }
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/i18n.jsx b/app/i18n.jsx
index b5f2a10..529221f 100644
--- a/app/i18n.jsx
+++ b/app/i18n.jsx
@@ -46,6 +46,7 @@ const DICT = {
'nav.memory': ['기억', 'Memory'],
'nav.routines': ['루틴', 'Routines'],
'nav.activity': ['활동', 'Activity'],
+ 'nav.mail': ['쪽지함', 'Mailbox'],
'nav.market': ['스킬·도구', 'Skills & Tools'],
'nav.settings': ['설정', 'Settings'],
'nav.hire': ['크루 영입', 'Hire crew'],
@@ -1003,6 +1004,42 @@ const DICT = {
'activity.todayByCrew': ['오늘 크루별', 'Today by Crew'],
'activity.turnsCount': ['{n}턴', '{n} turns'],
+ // ── 쪽지함
+ 'mail.title': ['쪽지함', 'Mailbox'],
+ 'mail.intro': ['크루끼리, 그리고 사장이 크루에게 보내는 비동기 쪽지. 배달은 스케줄러가 수신 크루의 새 턴으로(약 1분 주기, 쪽지 1건 = 턴 1회).', 'Async notes between crew, and from the captain to crew. The scheduler delivers each as a new turn for the recipient (about every minute; one note = one turn).'],
+ 'mail.compose': ['쪽지 보내기', 'Send a note'],
+ 'mail.to': ['받는 크루', 'To'],
+ 'mail.from': ['보낸 이', 'From'],
+ 'mail.cc': ['참조', 'CC'],
+ 'mail.ccHint': ['참조는 {n}명까지 — 참조도 각자 턴을 씁니다', 'Up to {n} CC — each CC also runs a turn'],
+ 'mail.message': ['내용', 'Message'],
+ 'mail.messagePlaceholder': ['크루에게 전할 내용을 적으세요', 'Write what to tell the crew'],
+ 'mail.send': ['보내기', 'Send'],
+ 'mail.sent': ['쪽지를 우편함에 넣었습니다 — 다음 틱에 배달됩니다', 'Note queued — delivered on the next tick'],
+ 'mail.pending': ['대기 중', 'Pending'],
+ 'mail.log': ['배달 기록', 'Delivery log'],
+ 'mail.dead': ['실패함', 'Failed'],
+ 'mail.cancel': ['취소', 'Cancel'],
+ 'mail.requeue': ['재투입', 'Requeue'],
+ 'mail.delete': ['삭제', 'Delete'],
+ 'mail.claimed': ['배달 중', 'Delivering'],
+ 'mail.attempts': ['시도 {n}회', '{n} attempts'],
+ 'mail.kind.to': ['수신', 'To'],
+ 'mail.kind.cc': ['참조', 'CC'],
+ 'mail.fromCaptain': ['사장', 'Captain'],
+ 'mail.ok': ['배달 성공', 'Delivered'],
+ 'mail.fail': ['배달 실패', 'Failed'],
+ 'mail.cancelled': ['취소됨', 'Cancelled'],
+ 'mail.corrupt': ['손상된 파일 — 재투입 불가', 'Corrupt file — cannot requeue'],
+ 'mail.openChat': ['대화 열기', 'Open chat'],
+ 'mail.emptyPending': ['대기 중인 쪽지가 없습니다.', 'No pending notes.'],
+ 'mail.emptyLog': ['아직 배달 기록이 없습니다.', 'No deliveries yet.'],
+ 'mail.emptyDead': ['실패한 쪽지가 없습니다.', 'No failed notes.'],
+ 'mail.noCrew': ['쪽지를 받을 크루가 아직 없습니다. 먼저 크루를 영입하세요.', 'No crew to receive notes yet. Hire crew first.'],
+ 'mail.deleteTitle': ['실패 기록 삭제', 'Delete failed note'],
+ 'mail.deleteConfirm': ['이 쪽지 기록을 영구히 삭제합니다. 재투입하려면 삭제 대신 재투입을 누르세요.', 'This note record will be deleted permanently. To retry, use Requeue instead.'],
+ 'mail.loadFail': ['쪽지함을 불러오지 못했습니다.', 'Couldn’t load the mailbox.'],
+
// ── 루틴
'routines.loadFail': ['루틴을 불러오지 못했습니다 — 설정 파일이 손상됐을 수 있습니다. 잠시 후 다시 시도해 주세요.', 'Couldn’t load routines — the settings file may be corrupted. Please try again shortly.'],
'routines.dow.sun': ['일', 'Sun'],
diff --git a/app/ui.jsx b/app/ui.jsx
index 5861e48..fe2f797 100644
--- a/app/ui.jsx
+++ b/app/ui.jsx
@@ -42,6 +42,7 @@ const PATHS = {
eye: 'M2.1 12S5.5 5.5 12 5.5 21.9 12 21.9 12 18.5 18.5 12 18.5 2.1 12 2.1 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
folder: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z',
tasks: 'M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01',
+ mail: 'M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 7l9 6 9-6', // 쪽지함
pin: 'M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z',
sort: 'M3 6h18M6 12h12M10 18h4', // 탐색기 정렬(옵시디언 툴바)
collapse: 'M7 11l5-5 5 5M7 19l5-5 5 5', // 모두 접기(옵시디언 툴바)
diff --git a/src/crewmail.mjs b/src/crewmail.mjs
index 4078dff..99dd1eb 100644
--- a/src/crewmail.mjs
+++ b/src/crewmail.mjs
@@ -10,7 +10,7 @@
// 두 기기가 같은 쪽지를 이중 배달한다(.gw-queue 선례와 동일 결함 계급). 세션 간 소통은 배달 결과가
// 스레드(동기화 대상)로 남는 것으로 성립한다 — 큐 자체는 발신 기기 소유이며, 배달도 그 기기의
// 스케줄러가 한다(클라우드 리더 게이트 미적용 — 걸면 비리더 기기 발신분이 무증상 소실, 2026-07-28).
-import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
+import { appendFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { paths } from './workspace.mjs';
import { writeJsonAtomic } from './jsonstore.mjs';
@@ -30,6 +30,13 @@ export const MAIL_MAX_ATTEMPTS = 3;
const CLAIM_STALE_MS = 45 * 60_000;
/** 예약 디렉터리 — dot 접두라 크루 slug와 충돌하지 않는다(slug는 WS_ID류 영숫자, 분리 검수 LOW). */
const DEAD_DIR = '.dead';
+/** 배달 기록(jsonl) — 쪽지함 화면의 "배달 기록" 섹션. mail/ 아래라 동기화 제외(sync.mjs EXCLUDE). */
+const LOG_FILE = '.log.jsonl';
+/** ponytail: 로그는 단순 유지 — 읽을 때 LOG_TRIM_AT 줄을 넘으면 최근 LOG_KEEP 줄로 잘라 다시 쓴다.
+ 회전·락 없음: 잘라 쓰는 사이 append가 끼면 그 한 줄은 유실될 수 있다(기록 전용 — 배달에 영향 없음). */
+const LOG_TRIM_AT = 2000;
+const LOG_KEEP = 1000;
+const LOG_SHOW = 200;
const mailRoot = (wsId) => join(paths(wsId).root, 'mail');
const mailDir = (wsId, slug) => join(mailRoot(wsId), String(slug));
@@ -49,6 +56,9 @@ export async function sendCrewMail(wsId, { from, fromName, fromRole = null, to,
const eqSlug = (a, b) => String(a ?? '').normalize('NFC').toLowerCase().trim() === String(b ?? '').normalize('NFC').toLowerCase().trim();
if (from && eqSlug(to, from)) throw new Error('자기 자신에게는 쪽지를 보낼 수 없습니다');
if (kind !== 'to' && kind !== 'cc') throw new Error('kind는 to 또는 cc입니다');
+ // 수신 slug는 곧 디렉터리명 — 쪽지함 API(사장 발신)가 화면 입력을 그대로 넘기므로 저장 관문에서 검증한다
+ // (격리 재현 2026-08-23: to:'../x'가 /x/에 파일을 만들었다).
+ assertSlug(to); for (const c of cc) assertSlug(c);
const id = `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const base = {
id, from, fromName: fromName || from, ...(fromRole ? { fromRole } : {}), message: String(message).trim(),
@@ -89,6 +99,111 @@ async function pendingBySlug(wsId) {
return out;
}
+/** 배달 기록 한 줄 append — 실패는 배달을 막지 않는다(기록은 부가, 배달이 본업). */
+async function appendLog(wsId, entry) {
+ try {
+ await mkdir(mailRoot(wsId), { recursive: true });
+ await appendFile(join(mailRoot(wsId), LOG_FILE), JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+ } catch (e) {
+ console.warn(`[argo] 크루 우편 배달 기록 실패(${wsId}):`, e.message);
+ }
+}
+
+/** 배달 기록 읽기 — 최신순 LOG_SHOW건. 파일이 LOG_TRIM_AT 줄을 넘으면 최근 LOG_KEEP 줄로 잘라 다시 쓴다. */
+async function readLog(wsId) {
+ const file = join(mailRoot(wsId), LOG_FILE);
+ let lines = [];
+ try { lines = (await readFile(file, 'utf8')).split('\n').filter(Boolean); } catch { return []; }
+ if (lines.length > LOG_TRIM_AT) {
+ lines = lines.slice(-LOG_KEEP);
+ await writeFile(file, lines.join('\n') + '\n').catch(() => {});
+ }
+ const out = [];
+ for (const l of lines.slice(-LOG_SHOW).reverse()) {
+ try { out.push(JSON.parse(l)); } catch { /* 절단 줄 — 건너뜀 */ }
+ }
+ return out;
+}
+
+// 경로 조립 전 검증 — 화면 입력이 그대로 파일 경로가 되므로 slug·id·파일명은 허용 문법만 통과시킨다.
+const SLUG_RE = /^[^/\\.][^/\\]*$/; // 구분자 금지, dot 접두 금지(.dead·.log 예약)
+const ID_RE = /^m[a-z0-9]+$/; // sendCrewMail이 만드는 id 형태
+const DEAD_RE = /^[^/\\.][^/\\]*-m[a-z0-9]+-(to|cc)\.json$/; // .dead/의 기록 파일명 `--.json`
+function assertSlug(slug) { if (!SLUG_RE.test(String(slug ?? '')) || String(slug).includes('..')) throw new Error('잘못된 크루 slug'); }
+function assertId(id) { if (!ID_RE.test(String(id ?? ''))) throw new Error('잘못된 쪽지 id'); }
+function assertDeadFile(file) { if (!DEAD_RE.test(String(file ?? '')) || String(file).includes('..')) throw new Error('잘못된 실패함 파일명'); }
+
+/** 쪽지함 목록 — 화면용. pending(대기·배달 중), dead(실패함), log(배달 기록 최신순). */
+export async function listMail(wsId) {
+ const pending = [];
+ for (const item of await pendingBySlug(wsId)) {
+ let body = {};
+ try { body = JSON.parse(await readFile(item.full, 'utf8')); } catch { /* 손상 — 파일명 정보만 */ }
+ const m = /^(m[a-z0-9]+)-(to|cc)\.json(\.claimed)?$/.exec(item.file);
+ pending.push({
+ id: body.id ?? m?.[1] ?? item.file, to: item.slug, from: body.from ?? null, fromName: body.fromName ?? body.from ?? null,
+ fromRole: body.fromRole ?? null, kind: body.kind ?? m?.[2] ?? null, message: body.message ?? '',
+ ts: body.ts ?? null, attempts: body.attempts ?? 0, claimed: !!item.claimed, claimedAt: body.claimedAt ?? null,
+ lastError: body.lastError ?? null, file: item.file,
+ });
+ }
+ const dead = [];
+ try {
+ for (const f of (await readdir(join(mailRoot(wsId), DEAD_DIR))).sort()) {
+ if (f.endsWith('.corrupt')) { dead.push({ file: f, corrupt: true }); continue; }
+ if (!f.endsWith('.json')) continue;
+ let body = {};
+ try { body = JSON.parse(await readFile(join(mailRoot(wsId), DEAD_DIR, f), 'utf8')); } catch { dead.push({ file: f, corrupt: true }); continue; }
+ const tail = `-${body.id}-${body.kind}.json`;
+ dead.push({
+ file: f, id: body.id ?? null, to: body.id && body.kind && f.endsWith(tail) ? f.slice(0, f.length - tail.length) : null,
+ from: body.from ?? null, fromName: body.fromName ?? body.from ?? null, fromRole: body.fromRole ?? null,
+ kind: body.kind ?? null, message: body.message ?? '', ts: body.ts ?? null, attempts: body.attempts ?? 0, lastError: body.lastError ?? null,
+ });
+ }
+ } catch { /* .dead 없음 */ }
+ return { pending, dead, log: await readLog(wsId) };
+}
+
+/** 대기 쪽지 취소 — .json만. .claimed(배달 진행 중)는 거부: 턴이 이미 도는 중이라 지워도 배달은 끝난다. */
+export async function cancelMail(wsId, slug, id) {
+ assertSlug(slug); assertId(id);
+ const dir = mailDir(wsId, slug);
+ let files = [];
+ try { files = await readdir(dir); } catch { throw new Error('쪽지를 찾을 수 없습니다'); }
+ const claimed = files.find((f) => f.startsWith(`${id}-`) && f.endsWith('.claimed'));
+ if (claimed) throw new Error('배달 중인 쪽지는 취소할 수 없습니다');
+ const target = files.find((f) => f.startsWith(`${id}-`) && f.endsWith('.json'));
+ if (!target) throw new Error('쪽지를 찾을 수 없습니다');
+ let body = {};
+ try { body = JSON.parse(await readFile(join(dir, target), 'utf8')); } catch { /* 기록은 파일명 기준 */ }
+ await rm(join(dir, target), { force: true });
+ await appendLog(wsId, { id, to: slug, from: body.from ?? null, fromName: body.fromName ?? null, kind: body.kind ?? null, ok: false, error: 'cancelled', attempts: body.attempts ?? 0 });
+ return { ok: true };
+}
+
+/** 실패함 → 원래 우편함 복귀. attempts 0·lastError 제거. .corrupt는 불가(재기록할 원문이 없다). */
+export async function requeueDead(wsId, file) {
+ assertDeadFile(file);
+ const src = join(mailRoot(wsId), DEAD_DIR, file);
+ const { lastError: _drop, claimedAt: _drop2, ...body } = JSON.parse(await readFile(src, 'utf8'));
+ if (!body.id || !body.kind) throw new Error('복귀할 수 없는 기록입니다');
+ const slug = file.slice(0, file.length - `-${body.id}-${body.kind}.json`.length);
+ assertSlug(slug);
+ await mkdir(mailDir(wsId, slug), { recursive: true });
+ await writeJsonAtomic(join(mailDir(wsId, slug), `${body.id}-${body.kind}.json`), { ...body, attempts: 0 });
+ await rm(src, { force: true });
+ return { ok: true, to: slug, id: body.id };
+}
+
+/** 실패함 기록 삭제(.corrupt 포함). */
+export async function deleteDead(wsId, file) {
+ if (!DEAD_RE.test(String(file ?? '')) && !/^[^/\\.][^/\\]*\.corrupt$/.test(String(file ?? ''))) throw new Error('잘못된 실패함 파일명');
+ if (String(file).includes('..')) throw new Error('잘못된 실패함 파일명');
+ await rm(join(mailRoot(wsId), DEAD_DIR, file), { force: true });
+ return { ok: true };
+}
+
/** 배달 프롬프트 — 수신 크루 턴의 사용자 메시지. delegate 프리픽스와 같은 문법(스레드에 그대로 보임).
회신 안내는 **회신이 실제로 가능한 턴에만**(kind=to && hop<2 — hop≥2 배달 턴은 도구가 없다,
분리 검수 HIGH-2: 존재하지 않는 도구를 지시하던 프롬프트).
@@ -134,9 +249,21 @@ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, no
}
continue;
}
- // 선점 — rename 원자성만 신뢰(승자 1명). 패자는 ENOENT로 continue.
+ // 선점 — mkdir 뮤텍스 + rename. rename 단독은 **윈도우에서 승자가 둘**일 수 있다(MoveFileEx 내부
+ // 핸들 TOCTOU, CI 실측 2026-08-06·2026-08-23: 같은 원본에 두 rename이 모두 성공해 이중 배달).
+ // mkdir은 모든 OS에서 배타적이라 승자 선출에 쓰고, rename이 끝나면 바로 지운다 — 늦은 패자는
+ // mkdir 성공 뒤 rename에서 ENOENT로 빠진다. 크래시 잔재(.lockd)는 stale 회수가 치운다.
const claimedPath = `${item.full}.claimed`;
- try { await rename(item.full, claimedPath); } catch { continue; }
+ const lockDir = `${item.full}.lockd`;
+ try { await mkdir(lockDir); } catch {
+ // 잔재 락(프로세스 크래시) — 오래됐으면 치우고 다음 틱에 맡긴다
+ try { if (now - (await stat(lockDir)).mtimeMs > CLAIM_STALE_MS) await rm(lockDir, { recursive: true, force: true }); } catch { /* 이미 사라짐 */ }
+ continue;
+ }
+ let won = false;
+ try { await rename(item.full, claimedPath); won = true; } catch { /* 패자 */ }
+ await rm(lockDir, { recursive: true, force: true }).catch(() => {});
+ if (!won) continue;
inFlight.add(claimedPath);
done += 1;
let msg = null;
@@ -153,6 +280,7 @@ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, no
// 태우지 않게, 턴 실행 **전에** 상한을 본다(재검 LOW: "상한이 결국 잡는다"가 이 경로에선 거짓이었다).
if ((msg.attempts ?? 0) >= MAIL_MAX_ATTEMPTS) {
await moveToDead(wsId, item.slug, item.file, claimedPath, { ...msg, lastError: msg.lastError ?? 'attempts exhausted' });
+ await appendLog(wsId, { id: msg.id, to: item.slug, from: msg.from, fromName: msg.fromName, kind: msg.kind, ok: false, error: msg.lastError ?? 'attempts exhausted', attempts: msg.attempts ?? 0 });
inFlight.delete(claimedPath);
continue;
}
@@ -161,11 +289,14 @@ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, no
try {
await runTurn(item.slug, msg, { from: msg.from, hop: msg.hop ?? 0, chain: msg.chain ?? [] });
await rm(claimedPath, { force: true }).catch(() => {});
+ await appendLog(wsId, { id: msg.id, to: item.slug, from: msg.from, fromName: msg.fromName, kind: msg.kind, ok: true, attempts: (msg.attempts ?? 0) + 1 });
} catch (e) {
const attempts = (msg.attempts ?? 0) + 1;
+ const error = String(e.message ?? e).slice(0, 200);
+ await appendLog(wsId, { id: msg.id, to: item.slug, from: msg.from, fromName: msg.fromName, kind: msg.kind, ok: false, error, attempts, exhausted: attempts >= MAIL_MAX_ATTEMPTS });
if (attempts >= MAIL_MAX_ATTEMPTS) {
console.error(`[argo] 크루 우편 배달 소진(${wsId}/${item.slug}/${msg.id}):`, e.message);
- await moveToDead(wsId, item.slug, item.file, claimedPath, { ...msg, attempts, lastError: String(e.message ?? e).slice(0, 200) });
+ await moveToDead(wsId, item.slug, item.file, claimedPath, { ...msg, attempts, lastError: error });
} else {
console.warn(`[argo] 크루 우편 배달 실패(${attempts}/${MAIL_MAX_ATTEMPTS}) ${wsId}/${item.slug}/${msg.id}:`, e.message);
// 재시도 — attempts 올려 .json으로 복귀(다음 틱). 갱신 실패 시에도 복귀는 시도한다
diff --git a/test/crewmail.test.mjs b/test/crewmail.test.mjs
index 98a80b8..b117914 100644
--- a/test/crewmail.test.mjs
+++ b/test/crewmail.test.mjs
@@ -215,3 +215,82 @@ test('interval 루틴 — normalizeSchedule·isDue', async () => {
// 오염 방어 — 하한 미달 값이 파일에 직접 쓰였어도 발화하지 않는다
assert.equal(isDue({ enabled: true, schedule: { type: 'interval', everyMinutes: 1 }, lastRun: null }, new Date()), false);
});
+
+// ── 쪽지함 화면용 조작(listMail·cancelMail·requeueDead·deleteDead) + 배달 기록
+test('listMail — pending(대기·배달 중)·dead·log 구조', async () => {
+ const id = await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'lm', cc: ['lm2'], message: '목록 검증' });
+ const { rename } = await import('node:fs/promises');
+ // lm2의 사본을 배달 중(.claimed)으로 위장 — 화면은 claimed:true로 구분해야 한다
+ await rename(join(paths(WS).root, 'mail', 'lm2', `${id}-cc.json`), join(paths(WS).root, 'mail', 'lm2', `${id}-cc.json.claimed`));
+ const r = await mod.listMail(WS);
+ assert.ok(Array.isArray(r.pending) && Array.isArray(r.dead) && Array.isArray(r.log));
+ const to = r.pending.find((m) => m.id === id && m.to === 'lm');
+ const cc = r.pending.find((m) => m.id === id && m.to === 'lm2');
+ assert.equal(to.kind, 'to'); assert.equal(to.claimed, false); assert.equal(to.fromName, '알파'); assert.equal(to.attempts, 0);
+ assert.equal(cc.kind, 'cc'); assert.equal(cc.claimed, true);
+ assert.ok(r.dead.some((d) => d.corrupt !== true && d.attempts === mod.MAIL_MAX_ATTEMPTS && d.to === 'b'), '앞 테스트의 소진 기록이 dead에 구조화돼야 한다');
+});
+
+test('cancelMail — 대기는 삭제·기록, 배달 중(.claimed)은 거부', async () => {
+ const r = await mod.listMail(WS);
+ const to = r.pending.find((m) => m.to === 'lm');
+ const cc = r.pending.find((m) => m.to === 'lm2');
+ await assert.rejects(() => mod.cancelMail(WS, 'lm2', cc.id), /배달 중/);
+ assert.deepEqual(await mailFiles('lm2'), [`${cc.id}-cc.json.claimed`], '거부됐으면 파일은 그대로');
+ await mod.cancelMail(WS, 'lm', to.id);
+ assert.deepEqual(await mailFiles('lm'), []);
+ const log = (await mod.listMail(WS)).log;
+ assert.ok(log.some((l) => l.id === to.id && l.ok === false && l.error === 'cancelled'), '취소가 배달 기록에 남아야 한다');
+});
+
+test('배달 기록 — 성공·실패가 각각 한 줄(최신순)', async () => {
+ const ok = await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'log1', message: '성공' });
+ await mod.deliverCrewMail(WS, async () => {});
+ const bad = await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'log2', message: '실패' });
+ await mod.deliverCrewMail(WS, async () => { throw new Error('runner down'); });
+ const { log } = await mod.listMail(WS);
+ const okRow = log.find((l) => l.id === ok);
+ const badRow = log.find((l) => l.id === bad);
+ assert.equal(okRow.ok, true); assert.equal(okRow.to, 'log1'); assert.equal(okRow.attempts, 1);
+ assert.equal(badRow.ok, false); assert.match(badRow.error, /runner down/); assert.equal(badRow.attempts, 1);
+ assert.ok(log.indexOf(badRow) < log.indexOf(okRow), '최신이 앞');
+ const raw = await readFile(join(paths(WS).root, 'mail', '.log.jsonl'), 'utf8');
+ assert.ok(raw.trim().split('\n').every((l) => JSON.parse(l).ts), 'jsonl 각 줄에 ts');
+});
+
+test('requeueDead — 실패함 기록이 attempts 0·lastError 없이 원래 우편함으로', async () => {
+ const { dead } = await mod.listMail(WS);
+ const rec = dead.find((d) => d.to === 'b' && !d.corrupt);
+ const r = await mod.requeueDead(WS, rec.file);
+ assert.equal(r.to, 'b');
+ assert.deepEqual(await mailFiles('b'), [`${rec.id}-to.json`]);
+ const body = JSON.parse(await readFile(join(paths(WS).root, 'mail', 'b', `${rec.id}-to.json`), 'utf8'));
+ assert.equal(body.attempts, 0); assert.equal(body.lastError, undefined); assert.equal(body.message, '실패 유도');
+ assert.ok(!(await readdir(join(paths(WS).root, 'mail', '.dead'))).includes(rec.file), '.dead에서 사라져야 한다');
+ await mod.cancelMail(WS, 'b', rec.id); // 정리
+});
+
+test('deleteDead — 기록 삭제, .corrupt도 삭제 가능', async () => {
+ const { writeFile } = await import('node:fs/promises');
+ const deadDir = join(paths(WS).root, 'mail', '.dead');
+ await writeFile(join(deadDir, 'zz-m1-to.json.corrupt'), '{broken');
+ const before = await mod.listMail(WS);
+ assert.ok(before.dead.some((d) => d.file === 'zz-m1-to.json.corrupt' && d.corrupt === true));
+ await assert.rejects(() => mod.requeueDead(WS, 'zz-m1-to.json.corrupt'), /파일명/);
+ await mod.deleteDead(WS, 'zz-m1-to.json.corrupt');
+ assert.ok(!(await readdir(deadDir)).includes('zz-m1-to.json.corrupt'));
+});
+
+test('경로 검증 — slug·id·파일명에 구분자·상위 경로·dot 접두는 거부', async () => {
+ await assert.rejects(() => mod.cancelMail(WS, '../b', 'm1abc'), /slug/);
+ await assert.rejects(() => mod.cancelMail(WS, '.dead', 'm1abc'), /slug/);
+ await assert.rejects(() => mod.cancelMail(WS, 'b', '../x'), /id/);
+ await assert.rejects(() => mod.cancelMail(WS, 'b', 'm1/x'), /id/);
+ await assert.rejects(() => mod.requeueDead(WS, '../company.json'), /파일명/);
+ await assert.rejects(() => mod.requeueDead(WS, 'b-m1-to.json/../x'), /파일명/);
+ await assert.rejects(() => mod.deleteDead(WS, '../company.json'), /파일명/);
+ await assert.rejects(() => mod.deleteDead(WS, '.log.jsonl'), /파일명/);
+ // 저장 관문 — 쪽지함 API가 화면 입력을 그대로 넘기므로 sendCrewMail 자체가 막아야 한다
+ await assert.rejects(() => mod.sendCrewMail(WS, { from: 'captain', to: '../x', message: 'x' }), /slug/);
+ await assert.rejects(() => mod.sendCrewMail(WS, { from: 'captain', to: 'b', cc: ['.dead'], message: 'x' }), /slug/);
+});