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
660 changes: 584 additions & 76 deletions .github/workflows/opencode-review.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ jobs:
STRIX_LLM_MAX_RETRIES: 1
STRIX_TRANSIENT_RETRY_PER_MODEL: 2
STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 3
STRIX_FALLBACK_MODELS: ${{ steps.gate.outputs.provider_mode == 'github_models' && 'openai/gpt-4.1' || '' }}
STRIX_FALLBACK_MODELS: ${{ steps.gate.outputs.provider_mode == 'github_models' && 'deepseek/deepseek-r1-0528 deepseek/deepseek-v3-0324' || '' }}
STRIX_FAIL_ON_PROVIDER_SIGNAL: "1"
STRIX_VERTEX_FALLBACK_MODELS: ""
NPM_CONFIG_IGNORE_SCRIPTS: "true"
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,8 @@ frontend/playwright-report/
frontend/playwright/.cache/
frontend/trace_output/

# Local CodeGraph artifacts
.codegraph/
.cursor/rules/codegraph.mdc

.worktrees/
23 changes: 17 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
`LLM_API_BASE_FILE` pointing at a trusted file containing
`https://models.github.ai/inference`; GitHub Models scans must try the
configured GPT-5-or-newer model first and may fall back to the explicit
workflow fallback list, currently `openai/gpt-4.1`, when GitHub Models
provider capacity or model availability blocks the primary run. Keep the
workflow fallback list, currently `deepseek/deepseek-r1-0528` and
`deepseek/deepseek-v3-0324`, when GitHub Models provider capacity or model
availability blocks the primary run. Do not use GPT-4.1 or weaker GitHub
Models fallbacks for Strix or OpenCode PR review evidence. Keep the
GitHub Models endpoint in a trusted input file and pass the token only through
the provider-scoped Strix child-process key path. Legacy `STRIX_LLM` secrets
must not override PR, push, or scheduled Strix defaults. Vertex remains
Expand Down Expand Up @@ -66,6 +68,10 @@
- Missing current-head CodeRabbit evidence is a wait state until bounded polling
or authoritative skip/review evidence resolves it; do not post a hard blocker
only because the current head has not been reviewed yet.
- OpenCode Agent approvals must be gated on current-head GitHub Checks. If a
completed check run or status context failed, or the check rollup cannot be
verified, the OpenCode review must request changes or explain the verification
failure instead of approving.
- Keep CodeRabbit `request_changes_workflow` enabled for robot approval, but
keep CodeRabbit GitHub Checks integration disabled. GitHub Actions are already
evaluated by required checks and PR Governance; letting CodeRabbit also gate
Expand Down Expand Up @@ -355,10 +361,15 @@

## Development environment and tooling defaults

- If CodeGraph is not initialized for this repository, agents may ask the user
to approve initialization and then run `codegraph init -i`; keep the generated
`.codegraph/` index local unless a future repository policy explicitly says to
commit it.
- If CodeGraph is not initialized for this repository, agents may run
`codegraph init -i` autonomously without asking first; keep generated
`.codegraph/` and `.cursor/rules/codegraph.mdc` artifacts local unless a
future repository policy explicitly says to commit them. OpenCode PR review
uses the project `opencode.jsonc` MCP servers for CodeGraph, DeepWiki,
Context7, and web search. It must initialize CodeGraph before review so
structural findings cite graph-backed evidence instead of relying only on grep
or raw file reads; use Context7 for current library docs, DeepWiki for
repository documentation, and web search only for bounded external lookups.
- StepSecurity `harden-runner` will trigger false-positive `suspicious_file_access` lockouts on Next.js build and dev server executions (e.g., `router_init.js` checksum matches). Configure `disable-file-monitoring: true` in the `harden-runner` step rather than disabling the workflow or using `continue-on-error`.
- Next.js 15+ Turbopack resolves workspace roots by scanning upward for `package-lock.json`. Do not create or leave a `package-lock.json` in the user's home directory (`~/`), as it will cause Turbopack to spawn infinite background worker node processes attempting to compile the entire home directory.
- `pydantic-settings` strictly rejects unexpected environment variables by default. When sharing a common `.env` file between frontend and backend services, you must explicitly set `extra="ignore"` in the `SettingsConfigDict` to prevent fatal startup crashes.
Expand Down
259 changes: 259 additions & 0 deletions frontend/src/components/WorkspaceHome.dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ async function waitForCondition(condition: () => boolean) {
throw new Error("waitForCondition timed out after 20 attempts");
}

function emptySourceEvidenceResponse(url: string) {
if (
url.endsWith("/api/calendar/writeback-sources") ||
url.endsWith("/api/webdav/folders")
) {
return Promise.resolve({
ok: true,
json: async () => ([]),
});
}
return null;
}

function emptyCalendarCandidateSearchResponse(url: string) {
if (url.endsWith("/api/search")) {
return Promise.resolve({
ok: true,
json: async () => ({ results: [] }),
});
}
return null;
}

describe("WorkspaceHome Today dashboard", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
Expand Down Expand Up @@ -122,6 +145,10 @@ describe("WorkspaceHome Today dashboard", () => {
json: async () => ([]),
});
}
const sourceEvidenceResponse = emptySourceEvidenceResponse(url);
if (sourceEvidenceResponse) return sourceEvidenceResponse;
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
Expand Down Expand Up @@ -204,6 +231,10 @@ describe("WorkspaceHome Today dashboard", () => {
json: async () => ([]),
});
}
const sourceEvidenceResponse = emptySourceEvidenceResponse(url);
if (sourceEvidenceResponse) return sourceEvidenceResponse;
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
Expand Down Expand Up @@ -283,6 +314,10 @@ describe("WorkspaceHome Today dashboard", () => {
]),
});
}
const sourceEvidenceResponse = emptySourceEvidenceResponse(url);
if (sourceEvidenceResponse) return sourceEvidenceResponse;
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
Expand Down Expand Up @@ -315,4 +350,228 @@ describe("WorkspaceHome Today dashboard", () => {
expect(linkHrefByText("데이터 품질 점검")).toBe("/data");
expect(linkHrefByText("보안 감사 로그")).toBe("/security");
});

it("backs Today dashboard operating metrics with source evidence instead of fixed fixture numbers", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
localStorage.setItem("naruon_session_token", "signed-source-backed-home");
const publicIdentityHeaders = [
"x-user-id",
"x-organization-id",
"x-group-id",
"x-group-ids",
"x-user-role",
"x-dev-auth-token",
];
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
fetchCalls.push({ url, init });
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({
ok: true,
json: async () => ({ emails: [] }),
});
}
if (url.endsWith("/api/emails")) {
return Promise.resolve({
ok: true,
json: async () => ({
emails: [
{
id: 101,
subject: "고객 계약 승인 대기",
sender: "legal@example.com",
date: "2026-05-17T09:00:00Z",
snippet: "오늘 승인해야 하는 계약 검토 요청",
unread: true,
},
],
}),
});
}
if (url.endsWith("/api/tasks")) {
return Promise.resolve({
ok: true,
json: async () => ([
{
id: "task-source-open",
title: "계약 승인 확인",
status: "open",
priority: "high",
created_at: "2026-05-17T09:00:00Z",
updated_at: "2026-05-17T09:00:00Z",
},
{
id: "task-source-done",
title: "첨부 근거 정리",
status: "done",
priority: "low",
created_at: "2026-05-17T09:00:00Z",
updated_at: "2026-05-17T09:00:00Z",
},
]),
});
}
if (url.endsWith("/api/calendar/writeback-sources")) {
return Promise.resolve({
ok: true,
json: async () => ([
{
source_id: "caldav-primary",
provider: "Primary CalDAV",
protocol: "caldav",
capabilities: ["read", "write"],
writeback_enabled: true,
},
{
source_id: "calendar-readonly",
provider: "Read-only Calendar",
protocol: "local",
capabilities: ["read"],
writeback_enabled: false,
},
]),
});
}
if (url.endsWith("/api/webdav/folders")) {
return Promise.resolve({
ok: true,
json: async () => ([
{
folder_uid: "folder-roadmap",
project_name: "Naruon Roadmap",
webdav_path: "/Projects/Naruon_Roadmap",
},
]),
});
}
if (url.endsWith("/api/search")) {
return Promise.resolve({
ok: true,
json: async () => ({
results: [
{
id: 601,
subject: "엔터프라이즈 데모 일정 조율",
sender: "sales@example.com",
date: "2026-05-18T11:00:00Z",
snippet: "고객 데모 후보 시간을 확정해야 합니다.",
},
],
}),
});
}
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.textContent?.includes("고객 계약 승인 대기") ?? false);

expect(container.textContent).toContain("일정 원본");
expect(container.textContent).toContain("2");
expect(container.textContent).toContain("1개 writeback 가능");
expect(container.textContent).toContain("프로젝트 원본");
expect(container.textContent).toContain("1개 WebDAV 폴더");
expect(container.textContent).toContain("작업 완료율");
expect(container.textContent).toContain("50%");
expect(container.textContent).toContain("1/2 완료");
expect(container.textContent).toContain("caldav-primary");
expect(container.textContent).toContain("일정 조율 후보 1건");
expect(container.textContent).toContain("엔터프라이즈 데모 일정 조율");
expect(container.textContent).toContain("고객 데모 후보 시간을 확정해야 합니다.");
expect(container.textContent).not.toContain("오늘 일정");
expect(container.textContent).not.toContain("진행 중 프로젝트");
expect(container.textContent).not.toContain("이번 주 목표 진행률");
expect(container.textContent).not.toContain("회의 2건 예정");
expect(container.textContent).not.toContain("일정 충돌 알림");
expect(container.textContent).not.toContain("68%");
expect(container.textContent).not.toContain("어제 대비");

const calendarSourceCall = fetchCalls.find((call) => call.url.endsWith("/api/calendar/writeback-sources"));
const projectFolderCall = fetchCalls.find((call) => call.url.endsWith("/api/webdav/folders"));
const calendarCandidateCall = fetchCalls.find((call) => call.url.endsWith("/api/search"));
expect(calendarCandidateCall?.init?.method).toBe("POST");
expect(JSON.parse(String(calendarCandidateCall?.init?.body))).toEqual({
query: "일정 충돌 일정 조율 회의 후보",
limit: 3,
});
for (const sourceCall of [calendarSourceCall, projectFolderCall, calendarCandidateCall]) {
expect(sourceCall).toBeDefined();
const headers = sourceCall?.init?.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer signed-source-backed-home");
for (const headerName of publicIdentityHeaders) {
expect(Object.keys(headers).some((key) => key.toLowerCase() === headerName)).toBe(false);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("shows an explicit source evidence error instead of a false empty calendar state", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({
ok: true,
json: async () => ({ emails: [] }),
});
}
if (url.endsWith("/api/emails")) {
return Promise.resolve({
ok: true,
json: async () => ({
emails: [
{
id: 101,
subject: "고객 계약 승인 대기",
sender: "legal@example.com",
date: "2026-05-17T09:00:00Z",
snippet: "오늘 승인해야 하는 계약 검토 요청",
unread: true,
},
],
}),
});
}
if (url.endsWith("/api/tasks")) {
return Promise.resolve({
ok: true,
json: async () => ([]),
});
}
if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) {
return Promise.reject(new Error("source registry unavailable"));
}
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.textContent?.includes("일정 source registry 확인에 실패했습니다.") ?? false);

expect(container.textContent).toContain("일정 원본 확인 필요");
expect(container.textContent).toContain("오류");
expect(container.textContent).toContain("source registry 응답을 확인할 수 없습니다.");
expect(container.textContent).not.toContain("연결된 일정 원본이 없습니다.");
});
});
Loading
Loading