From 12838a8b077c7e41501ee599aeee467e4c6b112f Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:48:33 +0900 Subject: [PATCH 01/48] review: harden readonly git checks and lint verification --- prompts/arbiter-paired-room.md | 2 +- prompts/claude-paired-room.md | 4 +- runners/agent-runner/src/ipc-mcp-stdio.ts | 4 +- runners/agent-runner/src/verification.ts | 7 +- .../test/reviewer-runtime.test.ts | 27 ++++++ .../test/reviewer-runtime.test.ts | 27 ++++++ runners/shared/src/reviewer-git-guard.ts | 15 +++ src/verification.test.ts | 92 ++++++++++++++++++- src/verification.ts | 16 +++- 9 files changed, 185 insertions(+), 9 deletions(-) diff --git a/prompts/arbiter-paired-room.md b/prompts/arbiter-paired-room.md index f620a39..9811ddc 100644 --- a/prompts/arbiter-paired-room.md +++ b/prompts/arbiter-paired-room.md @@ -34,7 +34,7 @@ You may receive reference opinions from external models appended to your prompt. ## Rules - Base your verdict on evidence (code, test output, logs), not on who said what first -- Distinguish reviewer snapshot limits from real product bugs. Reviewer workspaces may intentionally omit heavy artifacts like `node_modules`, `dist`, and `build`; inability to run direct local test/typecheck/build there is not, by itself, a blocker if dedicated verification evidence exists +- Distinguish reviewer snapshot limits from real product bugs. Reviewer workspaces may intentionally omit heavy artifacts like `node_modules`, `dist`, and `build`; inability to run direct local test/typecheck/build/lint there is not, by itself, a blocker if dedicated verification evidence exists - When verification evidence exists from the dedicated verification path, judge that evidence on its merits instead of requiring the reviewer to reproduce the same result from the lightweight reviewer snapshot - Your verdict is final for this deadlock cycle — after it, work resumes normally - You do NOT implement or review code — you only judge the disagreement diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md index 87d9803..1d422fd 100644 --- a/prompts/claude-paired-room.md +++ b/prompts/claude-paired-room.md @@ -32,10 +32,10 @@ If you see a materially better design, debugging path, or scoping choice, propos ## Rules - Judge completion only by verification output. "It should work now" means run it. "I'm confident" means nothing — confidence is not evidence. "I tested earlier" means test again if code changed since. "It's a trivial change" means verify anyway -- Reviewer runs against the owner's current workspace in read-only mode. Do not treat the inability to run direct local test/typecheck/build from the reviewer workspace as a product bug by itself +- Reviewer runs against the owner's current workspace in read-only mode. Do not treat the inability to run direct local test/typecheck/build/lint from the reviewer workspace as a product bug by itself - Treat `EJCLAW_WORK_DIR` as the canonical verification root for this turn. You may inspect other local paths for context, but final review findings must be re-checked against `EJCLAW_WORK_DIR` - Do not use a different clone, canonical repo path, or cached session path as the sole basis for `BLOCKED`, `DONE_WITH_CONCERNS`, or change requests. If another path disagrees with `EJCLAW_WORK_DIR`, prefer `EJCLAW_WORK_DIR` and explicitly call out the mismatch -- When test/typecheck/build evidence is needed, prefer the dedicated verification path (`run_verification`) over assuming the reviewer workspace should execute the full project locally +- When test/typecheck/build/lint evidence is needed, prefer the dedicated verification path (`run_verification`) over assuming the reviewer workspace should execute the full project locally - Separate correctness issues from improvement ideas. If something is only a better alternative, label it as optional instead of blocking the owner unnecessarily - Stagnation: **Spinning** (same error 3+), **Oscillation** (alternating approaches), **Diminishing returns** (shrinking improvement), **No progress** (discussion without change) — name the pattern and report: **Status**, **Attempted**, **Recommendation** - Implementation, commits, and pushes require agreement from both sides. Either can veto diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index b47c67f..0fe7ad3 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -479,12 +479,12 @@ server.tool( server.tool( 'run_verification', - 'Run a fixed verification profile directly on the host against the current repo snapshot using a restricted environment and snapshot checks. Use this instead of broad shell write access for test/typecheck/build verification.', + 'Run a fixed verification profile directly on the host against the current repo snapshot using a restricted environment and snapshot checks. Use this instead of broad shell write access for test/typecheck/build/lint verification.', { profile: z .enum(VERIFICATION_PROFILES) .describe( - 'Fixed verification profile. Runs the workspace test/typecheck/build scripts with the repo-configured package manager.', + 'Fixed verification profile. Runs the workspace test/typecheck/build/lint scripts with the repo-configured package manager.', ), }, async (args) => { diff --git a/runners/agent-runner/src/verification.ts b/runners/agent-runner/src/verification.ts index 01cdc62..4d7c662 100644 --- a/runners/agent-runner/src/verification.ts +++ b/runners/agent-runner/src/verification.ts @@ -3,7 +3,12 @@ import path from 'path'; import { pathToFileURL } from 'url'; export { computeVerificationSnapshotId } from '../../../shared/verification-snapshot.js'; -export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; +export const VERIFICATION_PROFILES = [ + 'test', + 'typecheck', + 'build', + 'lint', +] as const; export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index a8f06b0..0052108 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -169,6 +169,9 @@ describe('claude reviewer runtime guard', () => { const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true); expect(env.PATH).toContain('ejclaw-reviewer-git-'); expect(env.EJCLAW_REAL_GIT).toBeTruthy(); + expect(env.GIT_CONFIG_GLOBAL).toContain('global.gitconfig'); + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); + expect(fs.existsSync(env.GIT_CONFIG_GLOBAL!)).toBe(true); }); it('prefers an executable HOME-scoped wrapper dir before tmp', () => { @@ -245,6 +248,30 @@ describe('claude reviewer runtime guard', () => { } }); + it('overrides problematic git config paths so read-only git queries still work', () => { + const cwd = createTempRepo('ejclaw-reviewer-readonly-git-'); + const env = buildReviewerGitGuardEnv( + { + PATH: process.env.PATH, + HOME: cwd, + EJCLAW_WORK_DIR: cwd, + GIT_CONFIG_GLOBAL: path.join(cwd, '.gitconfig'), + }, + true, + ); + + expect(env.GIT_CONFIG_GLOBAL).not.toBe(path.join(cwd, '.gitconfig')); + expect(() => + execFileSync('git', ['log', '--oneline', '-1'], { + cwd, + env, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + ).not.toThrow(); + expect(fs.existsSync(path.join(cwd, '.gitconfig'))).toBe(false); + }); + it('accepts a mounted local origin path that resolves as a git repo', () => { const originDir = createTempRepo('ejclaw-reviewer-origin-'); const cwd = createTempRepo('ejclaw-reviewer-workspace-'); diff --git a/runners/codex-runner/test/reviewer-runtime.test.ts b/runners/codex-runner/test/reviewer-runtime.test.ts index d4493e3..04df0a0 100644 --- a/runners/codex-runner/test/reviewer-runtime.test.ts +++ b/runners/codex-runner/test/reviewer-runtime.test.ts @@ -75,6 +75,9 @@ describe('codex reviewer runtime guard', () => { const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true); expect(env.PATH).toContain('ejclaw-reviewer-git-'); expect(env.EJCLAW_REAL_GIT).toBeTruthy(); + expect(env.GIT_CONFIG_GLOBAL).toContain('global.gitconfig'); + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); + expect(fs.existsSync(env.GIT_CONFIG_GLOBAL!)).toBe(true); }); it('prefers an executable HOME-scoped wrapper dir before tmp', () => { @@ -151,6 +154,30 @@ describe('codex reviewer runtime guard', () => { } }); + it('overrides problematic git config paths so read-only git queries still work', () => { + const cwd = createTempRepo('ejclaw-reviewer-readonly-git-'); + const env = buildReviewerGitGuardEnv( + { + PATH: process.env.PATH, + HOME: cwd, + EJCLAW_WORK_DIR: cwd, + GIT_CONFIG_GLOBAL: path.join(cwd, '.gitconfig'), + }, + true, + ); + + expect(env.GIT_CONFIG_GLOBAL).not.toBe(path.join(cwd, '.gitconfig')); + expect(() => + execFileSync('git', ['log', '--oneline', '-1'], { + cwd, + env, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + ).not.toThrow(); + expect(fs.existsSync(path.join(cwd, '.gitconfig'))).toBe(false); + }); + it('accepts a mounted local origin path that resolves as a git repo', () => { const originDir = createTempRepo('ejclaw-reviewer-origin-'); const cwd = createTempRepo('ejclaw-reviewer-workspace-'); diff --git a/runners/shared/src/reviewer-git-guard.ts b/runners/shared/src/reviewer-git-guard.ts index 3fedb8d..3d62cf5 100644 --- a/runners/shared/src/reviewer-git-guard.ts +++ b/runners/shared/src/reviewer-git-guard.ts @@ -78,6 +78,18 @@ function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string { ); } +function prepareGitConfigSandbox(wrapperDir: string): { + gitConfigGlobalPath: string; +} { + const configDir = path.join(wrapperDir, 'git-config'); + fs.mkdirSync(configDir, { recursive: true }); + const gitConfigGlobalPath = path.join(configDir, 'global.gitconfig'); + if (!fs.existsSync(gitConfigGlobalPath)) { + fs.writeFileSync(gitConfigGlobalPath, ''); + } + return { gitConfigGlobalPath }; +} + export function buildReviewerGitGuardEnv( baseEnv: NodeJS.ProcessEnv, reviewerRuntime: boolean, @@ -90,6 +102,7 @@ export function buildReviewerGitGuardEnv( const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || ''; const wrapperDir = createExecutableWrapperDir(baseEnv); const wrapperPath = path.join(wrapperDir, 'git'); + const { gitConfigGlobalPath } = prepareGitConfigSandbox(wrapperDir); const blocked = [...BLOCKED_GIT_SUBCOMMANDS] .map((value) => `'${value}'`) .join(' '); @@ -179,6 +192,8 @@ exec "$real_git" "$@" ...baseEnv, EJCLAW_REAL_GIT: realGitPath, EJCLAW_PROTECTED_WORK_DIR: protectedWorkDir, + GIT_CONFIG_GLOBAL: gitConfigGlobalPath, + GIT_CONFIG_NOSYSTEM: '1', PATH: `${wrapperDir}:${baseEnv.PATH || ''}`, }; } diff --git a/src/verification.test.ts b/src/verification.test.ts index 4555424..de8fb61 100644 --- a/src/verification.test.ts +++ b/src/verification.test.ts @@ -20,7 +20,7 @@ describe('verification helpers', () => { expect(isVerificationProfile('test')).toBe(true); expect(isVerificationProfile('typecheck')).toBe(true); expect(isVerificationProfile('build')).toBe(true); - expect(isVerificationProfile('lint')).toBe(false); + expect(isVerificationProfile('lint')).toBe(true); }); it('builds deterministic commands for each profile', () => { @@ -44,6 +44,49 @@ describe('verification helpers', () => { args: ['run', 'build'], requiredScript: 'build', }); + expect(buildVerificationCommand('lint', repoDir)).toMatchObject({ + file: 'npm', + args: ['run', 'lint'], + requiredScript: 'lint', + }); + }); + + it('prefers lint:ci and lint:check before plain lint', () => { + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-lint-'), + ); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + scripts: { + lint: 'eslint .', + 'lint:check': 'eslint --max-warnings 0 .', + 'lint:ci': 'eslint --format compact .', + }, + }), + ); + + expect(buildVerificationCommand('lint', repoDir)).toMatchObject({ + file: 'npm', + args: ['run', 'lint:ci'], + requiredScript: 'lint:ci', + }); + + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + scripts: { + lint: 'eslint .', + 'lint:check': 'eslint --max-warnings 0 .', + }, + }), + ); + + expect(buildVerificationCommand('lint', repoDir)).toMatchObject({ + file: 'npm', + args: ['run', 'lint:check'], + requiredScript: 'lint:check', + }); }); it('selects the workspace package manager for verification commands', () => { @@ -250,6 +293,53 @@ describe('verification helpers', () => { expect(result.snapshotId).toBe(expectedSnapshotId); }); + it('runs lint verification using the preferred lint script', async () => { + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-direct-lint-'), + ); + fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), { + recursive: true, + }); + fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'verification-direct-lint', + packageManager: 'bun@1.3.11', + scripts: { + 'lint:check': + "node -e \"process.stdout.write('lint-check:' + (process.env.CI || 'missing'))\"", + }, + }), + ); + fs.writeFileSync(path.join(repoDir, 'bun.lock'), ''); + fs.writeFileSync( + path.join(repoDir, 'node_modules', '.bin', 'placeholder'), + '', + ); + fs.writeFileSync( + path.join(repoDir, 'src', 'index.ts'), + 'export const value = 1;\n', + ); + ensureWorkspaceDependenciesInstalled(repoDir); + + const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId; + const result = await runVerificationRequest( + { + requestId: 'req-direct-lint', + profile: 'lint', + expectedSnapshotId, + }, + { repoDir }, + ); + + expect(result.ok).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.command).toContain('lint:check'); + expect(result.stdout).toContain('lint-check:1'); + expect(result.snapshotId).toBe(expectedSnapshotId); + }); + it('does not leak host secrets into direct verification commands', async () => { const repoDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-verification-direct-secret-'), diff --git a/src/verification.ts b/src/verification.ts index 787502d..4aacc2a 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -11,7 +11,12 @@ import { } from './workspace-package-manager.js'; import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js'; -export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; +export const VERIFICATION_PROFILES = [ + 'test', + 'typecheck', + 'build', + 'lint', +] as const; export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; @@ -91,12 +96,19 @@ export function buildVerificationCommand( profile: VerificationProfile, repoDir: string = process.cwd(), ): VerificationCommandSpec { + const scripts = readPackageScripts(repoDir); const scriptName = profile === 'test' ? 'test' : profile === 'typecheck' ? 'typecheck' - : 'build'; + : profile === 'build' + ? 'build' + : scripts['lint:ci'] + ? 'lint:ci' + : scripts['lint:check'] + ? 'lint:check' + : 'lint'; const command = buildWorkspaceScriptCommand(repoDir, scriptName); return { file: command.file, From 5599791eae907ff4049e10864479340e1d8b66cc Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:50:39 +0900 Subject: [PATCH 02/48] test: satisfy readonly reviewer sandbox typing --- runners/agent-runner/test/reviewer-runtime.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 0052108..6f968af 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -138,7 +138,8 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox.failIfUnavailable).toBe(false); + expect(sandbox).toBeDefined(); + expect(sandbox?.failIfUnavailable).toBe(false); }); it('keeps non-linux reviewers in best-effort sandbox mode', () => { @@ -152,7 +153,8 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox.failIfUnavailable).toBe(false); + expect(sandbox).toBeDefined(); + expect(sandbox?.failIfUnavailable).toBe(false); }); it('flags mutating shell commands', () => { From 921a254120324b9e3adf8cabfda9365b2880ccfa Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:52:13 +0900 Subject: [PATCH 03/48] test: fix readonly reviewer sandbox assertions --- runners/agent-runner/test/reviewer-runtime.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 6f968af..3bfbde7 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -138,8 +138,10 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox).toBeDefined(); - expect(sandbox?.failIfUnavailable).toBe(false); + if (!sandbox) { + throw new Error('expected sandbox settings'); + } + expect(sandbox.failIfUnavailable).toBe(false); }); it('keeps non-linux reviewers in best-effort sandbox mode', () => { @@ -153,8 +155,10 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox).toBeDefined(); - expect(sandbox?.failIfUnavailable).toBe(false); + if (!sandbox) { + throw new Error('expected sandbox settings'); + } + expect(sandbox.failIfUnavailable).toBe(false); }); it('flags mutating shell commands', () => { From d8a3702b1e1fcd6dd827604fc09c50738d9f8617 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:55:12 +0900 Subject: [PATCH 04/48] test: remove impossible readonly sandbox guards --- runners/agent-runner/test/reviewer-runtime.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 3bfbde7..0052108 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -138,9 +138,6 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - if (!sandbox) { - throw new Error('expected sandbox settings'); - } expect(sandbox.failIfUnavailable).toBe(false); }); @@ -155,9 +152,6 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - if (!sandbox) { - throw new Error('expected sandbox settings'); - } expect(sandbox.failIfUnavailable).toBe(false); }); From 13ac26b80e34e71cca918c28472bea6afc6fe5bf Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:56:27 +0900 Subject: [PATCH 05/48] test: relax readonly sandbox assertions --- runners/agent-runner/test/reviewer-runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 0052108..4d002df 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -138,7 +138,7 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox.failIfUnavailable).toBe(false); + expect(sandbox?.failIfUnavailable).toBe(false); }); it('keeps non-linux reviewers in best-effort sandbox mode', () => { @@ -152,7 +152,7 @@ describe('claude reviewer runtime guard', () => { 'best-effort', ); - expect(sandbox.failIfUnavailable).toBe(false); + expect(sandbox?.failIfUnavailable).toBe(false); }); it('flags mutating shell commands', () => { From bf4cbd03194a030a72281f0474a25b2304ccb703 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:57:59 +0900 Subject: [PATCH 06/48] test: cast readonly sandbox expectation shape --- runners/agent-runner/test/reviewer-runtime.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 4d002df..edb67ce 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -136,9 +136,9 @@ describe('claude reviewer runtime guard', () => { ['/repo/work'], 'linux', 'best-effort', - ); + ) as { failIfUnavailable?: boolean }; - expect(sandbox?.failIfUnavailable).toBe(false); + expect(sandbox.failIfUnavailable).toBe(false); }); it('keeps non-linux reviewers in best-effort sandbox mode', () => { @@ -150,9 +150,9 @@ describe('claude reviewer runtime guard', () => { ['/repo/work'], 'darwin', 'best-effort', - ); + ) as { failIfUnavailable?: boolean }; - expect(sandbox?.failIfUnavailable).toBe(false); + expect(sandbox.failIfUnavailable).toBe(false); }); it('flags mutating shell commands', () => { From 3b0875e80d26a0c39c8a6438f1951b8ec9d48723 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 22 Apr 2026 20:59:10 +0900 Subject: [PATCH 07/48] test: cast readonly sandbox expectation through unknown --- runners/agent-runner/test/reviewer-runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index edb67ce..4ff8a25 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -136,7 +136,7 @@ describe('claude reviewer runtime guard', () => { ['/repo/work'], 'linux', 'best-effort', - ) as { failIfUnavailable?: boolean }; + ) as unknown as { failIfUnavailable?: boolean }; expect(sandbox.failIfUnavailable).toBe(false); }); @@ -150,7 +150,7 @@ describe('claude reviewer runtime guard', () => { ['/repo/work'], 'darwin', 'best-effort', - ) as { failIfUnavailable?: boolean }; + ) as unknown as { failIfUnavailable?: boolean }; expect(sandbox.failIfUnavailable).toBe(false); }); From afd9c7a5384fe013edbfa5c19dc870df23933b56 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 23 Apr 2026 13:06:26 +0900 Subject: [PATCH 08/48] =?UTF-8?q?Phase=200=20=E2=80=94=20STEP=5FDONE/TASK?= =?UTF-8?q?=5FDONE=20split=20+=20owner-follow-up=20integration=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/arbiter-paired-room.md | 1 + prompts/claude-paired-room.md | 6 +- prompts/owner-common-paired-room.md | 9 +- src/message-agent-executor-paired.ts | 6 + src/message-agent-executor-rules.test.ts | 12 + src/message-agent-executor-rules.ts | 6 + src/message-runtime-flow.ts | 4 + src/message-runtime-follow-up.test.ts | 29 +++ src/message-runtime-follow-up.ts | 71 ++++-- src/message-runtime-prompts.ts | 5 +- src/message-runtime-queue.ts | 7 + src/message-runtime-rules.test.ts | 35 +++ src/message-runtime-rules.ts | 13 +- src/message-runtime.test.ts | 238 +++++++++++++++++++- src/paired-execution-context-owner.ts | 40 +++- src/paired-execution-context-shared.test.ts | 42 ++++ src/paired-execution-context-shared.ts | 22 ++ src/paired-execution-context.test.ts | 59 +++++ 18 files changed, 574 insertions(+), 31 deletions(-) diff --git a/prompts/arbiter-paired-room.md b/prompts/arbiter-paired-room.md index 9811ddc..43a2f71 100644 --- a/prompts/arbiter-paired-room.md +++ b/prompts/arbiter-paired-room.md @@ -34,6 +34,7 @@ You may receive reference opinions from external models appended to your prompt. ## Rules - Base your verdict on evidence (code, test output, logs), not on who said what first +- When reading owner/reviewer summaries, treat **TASK_DONE** as full task completion, **STEP_DONE** as intermediate progress that should keep the owner flow alive, and **DONE** as a legacy alias for **TASK_DONE** - Distinguish reviewer snapshot limits from real product bugs. Reviewer workspaces may intentionally omit heavy artifacts like `node_modules`, `dist`, and `build`; inability to run direct local test/typecheck/build/lint there is not, by itself, a blocker if dedicated verification evidence exists - When verification evidence exists from the dedicated verification path, judge that evidence on its merits instead of requiring the reviewer to reproduce the same result from the lightweight reviewer snapshot - Your verdict is final for this deadlock cycle — after it, work resumes normally diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md index 1d422fd..96d2cae 100644 --- a/prompts/claude-paired-room.md +++ b/prompts/claude-paired-room.md @@ -22,9 +22,11 @@ If you see a materially better design, debugging path, or scoping choice, propos ## Completion status -**Start your first line** with one of these four statuses. This is required. +**Start your first line** with one of these six statuses. This is required. -- **DONE** — Approved. The owner's response is correct and complete. Include the evidence +- **STEP_DONE** — The current step is acceptable, but the original requested task still has remaining work. Send the task back to the owner without escalating to the arbiter +- **TASK_DONE** — Approved. The owner's work satisfies the full requested task. Include the evidence +- **DONE** — Legacy alias for **TASK_DONE**. Prefer **TASK_DONE** for new turns - **DONE_WITH_CONCERNS** — Approved with concerns. List specific actions the owner must take. If the same concerns repeat for 2+ turns, escalate to BLOCKED - **BLOCKED** — Cannot proceed without user decision - **NEEDS_CONTEXT** — Missing information from user diff --git a/prompts/owner-common-paired-room.md b/prompts/owner-common-paired-room.md index 0368cb3..50a9365 100644 --- a/prompts/owner-common-paired-room.md +++ b/prompts/owner-common-paired-room.md @@ -18,16 +18,19 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc ## Completion status -**Start your first line** with one of these four statuses. This is required. +**Start your first line** with one of these six statuses. This is required. -- **DONE** — All steps completed. Include the evidence (test output, build log, diff) +- **STEP_DONE** — A meaningful intermediate step is complete, but the original task still has remaining work. This keeps the task active and continues the owner flow without reviewer or arbiter intervention +- **TASK_DONE** — The original requested task is complete. Include the evidence (test output, build log, diff) +- **DONE** — Legacy alias for **TASK_DONE**. Prefer **TASK_DONE** for new turns - **DONE_WITH_CONCERNS** — Completed, but there are issues worth flagging. If the reviewer raises the same concerns again, fix them or escalate to BLOCKED - **BLOCKED** — Cannot proceed. State what is stopping you - **NEEDS_CONTEXT** — Missing information needed to continue ### Finalize semantics -- When the reviewer already approved and you are finalizing, **DONE** closes the paired turn +- When the reviewer already approved and you are finalizing, **TASK_DONE** closes the paired turn +- In that same finalize step, **STEP_DONE** keeps the task active and resumes the owner flow because the original request still has remaining work - In that same finalize step, **DONE_WITH_CONCERNS** does not close the turn — it intentionally reopens review - Use **DONE_WITH_CONCERNS** on finalize only when you are explicitly asking the reviewer loop to resume diff --git a/src/message-agent-executor-paired.ts b/src/message-agent-executor-paired.ts index 349bea2..7cbf0ea 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -14,6 +14,7 @@ import { completePairedExecutionContext, type PreparedPairedExecutionContext, } from './paired-execution-context.js'; +import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js'; import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'; import type { PairedTurnIdentity } from './paired-turn-identity.js'; @@ -438,6 +439,7 @@ export function createPairedExecutionLifecycle(args: { executionStatus: effectiveStatus, sawOutput: sawOutputForFollowUp, taskStatus: finishedTask?.status ?? null, + outputSummary: pairedExecutionSummary, }); if (queueAction !== 'pending' || !finishedTask) { return; @@ -452,6 +454,10 @@ export function createPairedExecutionLifecycle(args: { executionStatus: effectiveStatus, sawOutput: sawOutputForFollowUp, fallbackLastTurnOutputRole: sawOutputForFollowUp ? completedRole : null, + fallbackLastTurnOutputVerdict: + sawOutputForFollowUp && pairedExecutionSummary + ? parseVisibleVerdict(pairedExecutionSummary) + : null, enqueueMessageCheck, }); if (followUpResult.kind !== 'paired-follow-up') { diff --git a/src/message-agent-executor-rules.test.ts b/src/message-agent-executor-rules.test.ts index 74583ad..6fe983f 100644 --- a/src/message-agent-executor-rules.test.ts +++ b/src/message-agent-executor-rules.test.ts @@ -216,6 +216,18 @@ describe('message-agent-executor-rules', () => { ).toBe('none'); }); + it('does not request a duplicate executor-side follow-up after successful owner STEP_DONE output', () => { + expect( + resolvePairedFollowUpQueueAction({ + completedRole: 'owner', + executionStatus: 'succeeded', + sawOutput: true, + taskStatus: 'active', + outputSummary: 'STEP_DONE\nkeep going', + }), + ).toBe('none'); + }); + it('returns none after successful output when no next-turn action is needed', () => { expect( resolvePairedFollowUpQueueAction({ diff --git a/src/message-agent-executor-rules.ts b/src/message-agent-executor-rules.ts index 4fab503..134d6f5 100644 --- a/src/message-agent-executor-rules.ts +++ b/src/message-agent-executor-rules.ts @@ -2,6 +2,7 @@ import { resolveFollowUpDispatch, resolveNextTurnAction, } from './message-runtime-rules.js'; +import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import type { PairedRoomRole, PairedTaskStatus } from './types.js'; export { isRetryableClaudeSessionFailureAttempt, @@ -20,10 +21,15 @@ export function resolvePairedFollowUpQueueAction(args: { executionStatus: 'succeeded' | 'failed'; sawOutput: boolean; taskStatus: PairedTaskStatus | null; + outputSummary?: string | null; }): PairedFollowUpQueueAction { const nextTurnAction = resolveNextTurnAction({ taskStatus: args.taskStatus, lastTurnOutputRole: args.sawOutput ? args.completedRole : null, + lastTurnOutputVerdict: + args.sawOutput && args.outputSummary + ? parseVisibleVerdict(args.outputSummary) + : null, }); const dispatch = resolveFollowUpDispatch({ source: 'executor-recovery', diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index 8058d63..5d4f46d 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -29,6 +29,7 @@ import { type ScheduledPairedFollowUpIntentKind, } from './paired-follow-up-scheduler.js'; import { hasReviewerLease } from './service-routing.js'; +import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import type { Channel, NewMessage, @@ -127,6 +128,9 @@ export function buildPendingPairedTurn(args: { const nextTurnAction = resolveNextTurnAction({ taskStatus, lastTurnOutputRole: lastTurnOutput?.role ?? null, + lastTurnOutputVerdict: lastTurnOutput?.output_text + ? parseVisibleVerdict(lastTurnOutput.output_text) + : null, }); const recentMessages = getRecentChatMessages(chatJid, 20); const lastHumanMessage = getLastHumanMessageContent(chatJid); diff --git a/src/message-runtime-follow-up.test.ts b/src/message-runtime-follow-up.test.ts index 8eae6f7..4bf4dfc 100644 --- a/src/message-runtime-follow-up.test.ts +++ b/src/message-runtime-follow-up.test.ts @@ -83,6 +83,35 @@ describe('message-runtime-follow-up', () => { expect(enqueue).toHaveBeenCalledTimes(1); }); + it('uses the fallback STEP_DONE verdict to schedule owner follow-ups when the owner keeps the task active', () => { + const enqueue = vi.fn(); + const result = dispatchPairedFollowUpForEvent({ + chatJid: 'group@test', + runId: 'run-owner-step-done', + task: { + id: 'task-owner-step-done', + status: 'active', + round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', + }, + source: 'owner-delivery-success', + completedRole: 'owner', + fallbackLastTurnOutputRole: 'owner', + fallbackLastTurnOutputVerdict: 'step_done', + enqueue, + }); + + expect(result).toMatchObject({ + kind: 'paired-follow-up', + intentKind: 'owner-follow-up', + scheduled: true, + taskStatus: 'active', + lastTurnOutputRole: 'owner', + lastTurnOutputVerdict: 'step_done', + }); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => { const enqueue = vi.fn(); vi.mocked(getPairedTurnOutputs).mockReturnValue([ diff --git a/src/message-runtime-follow-up.ts b/src/message-runtime-follow-up.ts index e774021..97725eb 100644 --- a/src/message-runtime-follow-up.ts +++ b/src/message-runtime-follow-up.ts @@ -1,4 +1,8 @@ import { getPairedTurnOutputs } from './db.js'; +import { + parseVisibleVerdict, + type VisibleVerdict, +} from './paired-execution-context-shared.js'; import { matchesExpectedPairedFollowUpIntent, resolveFollowUpDispatch, @@ -20,6 +24,7 @@ export interface PairedFollowUpDecision { taskId: string | null; taskStatus: PairedTaskStatus | null; lastTurnOutputRole: PairedRoomRole | null; + lastTurnOutputVerdict: VisibleVerdict | null; nextTurnAction: NextTurnAction; dispatch: FollowUpDispatch; } @@ -37,15 +42,23 @@ export type PairedFollowUpDispatchResult = scheduled: boolean; }); -export function resolveLatestPairedTurnOutputRole(args: { +export function resolveLatestPairedTurnOutputContext(args: { task: Pick | null | undefined; fallbackLastTurnOutputRole?: PairedRoomRole | null; -}): PairedRoomRole | null { - return ( - (args.task ? getPairedTurnOutputs(args.task.id).at(-1)?.role : null) ?? - args.fallbackLastTurnOutputRole ?? - null - ); + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; +}): { + role: PairedRoomRole | null; + verdict: VisibleVerdict | null; +} { + const latestOutput = args.task + ? getPairedTurnOutputs(args.task.id).at(-1) + : null; + return { + role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null, + verdict: latestOutput?.output_text + ? parseVisibleVerdict(latestOutput.output_text) + : (args.fallbackLastTurnOutputVerdict ?? null), + }; } export function resolvePairedFollowUpDecision(args: { @@ -55,14 +68,18 @@ export function resolvePairedFollowUpDecision(args: { executionStatus?: 'succeeded' | 'failed'; sawOutput?: boolean; fallbackLastTurnOutputRole?: PairedRoomRole | null; + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; }): PairedFollowUpDecision { - const lastTurnOutputRole = resolveLatestPairedTurnOutputRole({ - task: args.task, - fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, - }); + const { role: lastTurnOutputRole, verdict: lastTurnOutputVerdict } = + resolveLatestPairedTurnOutputContext({ + task: args.task, + fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, + fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict, + }); const nextTurnAction = resolveNextTurnAction({ taskStatus: args.task?.status ?? null, lastTurnOutputRole, + lastTurnOutputVerdict, }); const dispatch = resolveFollowUpDispatch({ source: args.source, @@ -76,6 +93,7 @@ export function resolvePairedFollowUpDecision(args: { taskId: args.task?.id ?? null, taskStatus: args.task?.status ?? null, lastTurnOutputRole, + lastTurnOutputVerdict, nextTurnAction, dispatch, }; @@ -91,23 +109,31 @@ export function schedulePairedFollowUpIntent(args: { intentKind: ScheduledPairedFollowUpIntentKind; enqueue: () => void; fallbackLastTurnOutputRole?: PairedRoomRole | null; + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; lastTurnOutputRole?: PairedRoomRole | null; + lastTurnOutputVerdict?: VisibleVerdict | null; }): boolean { if (!args.task) { return false; } - const lastTurnOutputRole = - args.lastTurnOutputRole ?? - resolveLatestPairedTurnOutputRole({ - task: args.task, - fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, - }); + const latestOutputContext = + args.lastTurnOutputRole != null || args.lastTurnOutputVerdict != null + ? { + role: args.lastTurnOutputRole ?? null, + verdict: args.lastTurnOutputVerdict ?? null, + } + : resolveLatestPairedTurnOutputContext({ + task: args.task, + fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, + fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict, + }); if ( !matchesExpectedPairedFollowUpIntent({ taskStatus: args.task.status, - lastTurnOutputRole, + lastTurnOutputRole: latestOutputContext.role, + lastTurnOutputVerdict: latestOutputContext.verdict, intentKind: args.intentKind, }) ) { @@ -133,7 +159,9 @@ export function schedulePairedFollowUpWithMessageCheck(args: { intentKind: ScheduledPairedFollowUpIntentKind; enqueueMessageCheck: () => void; fallbackLastTurnOutputRole?: PairedRoomRole | null; + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; lastTurnOutputRole?: PairedRoomRole | null; + lastTurnOutputVerdict?: VisibleVerdict | null; }): boolean { return schedulePairedFollowUpIntent({ chatJid: args.chatJid, @@ -142,7 +170,9 @@ export function schedulePairedFollowUpWithMessageCheck(args: { intentKind: args.intentKind, enqueue: args.enqueueMessageCheck, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, + fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict, lastTurnOutputRole: args.lastTurnOutputRole, + lastTurnOutputVerdict: args.lastTurnOutputVerdict, }); } @@ -158,6 +188,7 @@ export function dispatchPairedFollowUpForEvent(args: { executionStatus?: 'succeeded' | 'failed'; sawOutput?: boolean; fallbackLastTurnOutputRole?: PairedRoomRole | null; + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; enqueue: () => void; enqueueMessageCheck?: () => void; }): PairedFollowUpDispatchResult { @@ -168,6 +199,7 @@ export function dispatchPairedFollowUpForEvent(args: { executionStatus: args.executionStatus, sawOutput: args.sawOutput, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, + fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict, }); if ( @@ -193,6 +225,7 @@ export function dispatchPairedFollowUpForEvent(args: { intentKind: decision.nextTurnAction.kind, enqueue: args.enqueue, lastTurnOutputRole: decision.lastTurnOutputRole, + lastTurnOutputVerdict: decision.lastTurnOutputVerdict, }); return { @@ -221,6 +254,7 @@ export function enqueuePairedFollowUpAfterEvent(args: { executionStatus?: 'succeeded' | 'failed'; sawOutput?: boolean; fallbackLastTurnOutputRole?: PairedRoomRole | null; + fallbackLastTurnOutputVerdict?: VisibleVerdict | null; enqueueMessageCheck: () => void; }): PairedFollowUpDispatchResult { return dispatchPairedFollowUpForEvent({ @@ -232,6 +266,7 @@ export function enqueuePairedFollowUpAfterEvent(args: { executionStatus: args.executionStatus, sawOutput: args.sawOutput, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, + fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict, enqueue: args.enqueueMessageCheck, enqueueMessageCheck: args.enqueueMessageCheck, }); diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index 99a6380..263e3c0 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -212,7 +212,8 @@ export function buildFinalizePendingPrompt(args: { ? `\n\nReviewer's final assessment:\n${lastReviewerOutput.output_text.slice(0, 2000)}` : ''; - return `The reviewer approved your work (DONE). Finalize and report the result. -If you intend to close this paired turn now, your first line must be DONE. + return `The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result. +If you intend to close this paired turn now, your first line must be TASK_DONE. +If the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE. If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.${reviewerSummary}`; } diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 698b444..1ceaee8 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -11,6 +11,7 @@ import { isBotOnlyPairedRoomTurn, } from './message-runtime-flow.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; +import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import { advanceLastAgentCursor, resolveActiveRole, @@ -183,6 +184,11 @@ export async function runQueuedGroupTurn(args: { const lastTurnOutputRole = currentTask ? (turnOutputs.at(-1)?.role ?? null) : null; + const lastTurnOutputVerdict = currentTask + ? turnOutputs.at(-1)?.output_text + ? parseVisibleVerdict(turnOutputs.at(-1)?.output_text) + : null + : null; const turnRole = currentTask ? hasHumanMsg ? resolveQueuedTurnRole({ @@ -193,6 +199,7 @@ export async function runQueuedGroupTurn(args: { taskStatus, hasHumanMessage: false, lastTurnOutputRole, + lastTurnOutputVerdict, }) : 'owner'; if (!turnRole) { diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index 7c78c6b..db9b4bd 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -113,6 +113,14 @@ describe('message-runtime-rules', () => { intentKind: 'owner-follow-up', }), ).toBe(true); + expect( + matchesExpectedPairedFollowUpIntent({ + taskStatus: 'active', + lastTurnOutputRole: 'owner', + lastTurnOutputVerdict: 'step_done', + intentKind: 'owner-follow-up', + }), + ).toBe(true); }); it('maps active tasks with reviewer output to an owner follow-up', () => { @@ -124,6 +132,16 @@ describe('message-runtime-rules', () => { ).toEqual({ kind: 'owner-follow-up' }); }); + it('maps active tasks with owner STEP_DONE output to an owner follow-up', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'active', + lastTurnOutputRole: 'owner', + lastTurnOutputVerdict: 'step_done', + }), + ).toEqual({ kind: 'owner-follow-up' }); + }); + it('returns none when an active task has no reviewer or arbiter handoff output', () => { expect( resolveNextTurnAction({ @@ -152,6 +170,15 @@ describe('message-runtime-rules', () => { kind: 'enqueue', queueKind: 'paired-follow-up', }); + expect( + resolveFollowUpDispatch({ + source: 'owner-delivery-success', + nextTurnAction: { kind: 'owner-follow-up' }, + }), + ).toEqual({ + kind: 'enqueue', + queueKind: 'paired-follow-up', + }); expect( resolveFollowUpDispatch({ source: 'owner-delivery-success', @@ -295,6 +322,14 @@ describe('message-runtime-rules', () => { lastTurnOutputRole: 'owner', }), ).toBe('reviewer'); + expect( + resolveQueuedPairedTurnRole({ + taskStatus: 'active', + hasHumanMessage: false, + lastTurnOutputRole: 'owner', + lastTurnOutputVerdict: 'step_done', + }), + ).toBe('owner'); }); it('resolves reviewer execution target from review_ready task status', () => { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 7d24a07..033abb1 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -4,6 +4,7 @@ import { normalizeStoredSeqCursor } from './message-cursor.js'; import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { isTaskStatusControlMessage } from './task-watch-status.js'; import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.js'; +import type { VisibleVerdict } from './paired-execution-context-shared.js'; import { hasReviewerLease, resolveLeaseServiceId, @@ -79,6 +80,7 @@ export function resolveQueuedPairedTurnRole(args: { taskStatus?: PairedTaskStatus | null; hasHumanMessage: boolean; lastTurnOutputRole?: PairedRoomRole | null; + lastTurnOutputVerdict?: VisibleVerdict | null; }): 'owner' | 'reviewer' | 'arbiter' | null { if (args.hasHumanMessage) { return resolveQueuedTurnRole({ @@ -90,6 +92,7 @@ export function resolveQueuedPairedTurnRole(args: { const nextTurnAction = resolveNextTurnAction({ taskStatus: args.taskStatus, lastTurnOutputRole: args.lastTurnOutputRole, + lastTurnOutputVerdict: args.lastTurnOutputVerdict, }); switch (nextTurnAction.kind) { @@ -125,6 +128,7 @@ export type FollowUpDispatch = export function resolveNextTurnAction(args: { taskStatus?: PairedTaskStatus | null; lastTurnOutputRole?: PairedRoomRole | null; + lastTurnOutputVerdict?: VisibleVerdict | null; }): NextTurnAction { switch (args.taskStatus) { case 'review_ready': @@ -142,7 +146,8 @@ export function resolveNextTurnAction(args: { ? { kind: 'none' } : { kind: 'finalize-owner-turn' }; case 'active': - return args.lastTurnOutputRole === 'reviewer' || + return args.lastTurnOutputVerdict === 'step_done' || + args.lastTurnOutputRole === 'reviewer' || args.lastTurnOutputRole === 'arbiter' ? { kind: 'owner-follow-up' } : { kind: 'none' }; @@ -154,12 +159,14 @@ export function resolveNextTurnAction(args: { export function matchesExpectedPairedFollowUpIntent(args: { taskStatus?: PairedTaskStatus | null; lastTurnOutputRole?: PairedRoomRole | null; + lastTurnOutputVerdict?: VisibleVerdict | null; intentKind: ScheduledNextTurnActionKind; }): boolean { return ( resolveNextTurnAction({ taskStatus: args.taskStatus, lastTurnOutputRole: args.lastTurnOutputRole, + lastTurnOutputVerdict: args.lastTurnOutputVerdict, }).kind === args.intentKind ); } @@ -184,7 +191,8 @@ export function resolveFollowUpDispatch(args: { args.completedRole === 'owner' ) { return args.nextTurnAction.kind === 'reviewer-turn' || - args.nextTurnAction.kind === 'arbiter-turn' + args.nextTurnAction.kind === 'arbiter-turn' || + args.nextTurnAction.kind === 'owner-follow-up' ? { kind: 'enqueue', queueKind: 'paired-follow-up' } : { kind: 'none' }; } @@ -219,6 +227,7 @@ export function resolveFollowUpDispatch(args: { return { kind: 'none' }; } return args.nextTurnAction.kind === 'reviewer-turn' || + args.nextTurnAction.kind === 'owner-follow-up' || args.nextTurnAction.kind === 'arbiter-turn' || args.nextTurnAction.kind === 'finalize-owner-turn' ? { kind: 'enqueue', queueKind: 'paired-follow-up' } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index e9c6d68..b5d82df 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1632,8 +1632,9 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, input, _onProcess, onOutput) => { expect(input.prompt).toBe( - `The reviewer approved your work (DONE). Finalize and report the result. -If you intend to close this paired turn now, your first line must be DONE. + `The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result. +If you intend to close this paired turn now, your first line must be TASK_DONE. +If the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE. If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n${truncatedReviewerOutput}`, ); expect(input.prompt).not.toContain(longReviewerOutput); @@ -1741,7 +1742,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, input, _onProcess, onOutput) => { expect(input.prompt).toBe( - "The reviewer approved your work (DONE). Finalize and report the result.\nIf you intend to close this paired turn now, your first line must be DONE.\nIf your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n리뷰 승인 요약", + "The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result.\nIf you intend to close this paired turn now, your first line must be TASK_DONE.\nIf the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE.\nIf your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n리뷰 승인 요약", ); expect(input.prompt).not.toContain('DONE\n승인합니다.'); await onOutput?.({ @@ -2004,6 +2005,110 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead expect(pending?.prompt).toContain('이전 reviewer 피드백'); }); + it('builds an owner follow-up pending turn when the latest owner output is STEP_DONE on an active task', () => { + const chatJid = 'group@test'; + const group = makeGroup('claude-code'); + const task = { + id: 'task-owner-step-done-pending', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 1, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:10.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + } as any; + + vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(undefined); + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: task.id, + turn_number: 1, + role: 'owner', + output_text: 'STEP_DONE\n1단계는 끝났고 2단계로 이어가야 함', + created_at: '2026-03-30T00:00:11.000Z', + }, + ] as any); + + const pending = buildPendingPairedTurn({ + chatJid, + timezone: 'UTC', + task, + rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:12.000Z' }], + recentHumanMessages: [], + labeledRecentMessages: [], + resolveChannel: () => makeChannel(chatJid), + }); + + expect(pending).not.toBeNull(); + expect(pending).toMatchObject({ + role: 'owner', + intentKind: 'owner-follow-up', + taskId: task.id, + }); + }); + + it('builds a reviewer pending turn when a review_ready task follows owner TASK_DONE output', () => { + const chatJid = 'group@test'; + const group = makeGroup('claude-code'); + const task = { + id: 'task-owner-task-done-pending', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:10.000Z', + round_trip_count: 1, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:10.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + } as any; + + vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(undefined); + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: task.id, + turn_number: 1, + role: 'owner', + output_text: 'TASK_DONE\n요청 범위 전체 완료', + created_at: '2026-03-30T00:00:11.000Z', + }, + ] as any); + + const pending = buildPendingPairedTurn({ + chatJid, + timezone: 'UTC', + task, + rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:12.000Z' }], + recentHumanMessages: [], + labeledRecentMessages: [], + resolveChannel: () => makeChannel(chatJid, 'discord-review', false), + }); + + expect(pending).not.toBeNull(); + expect(pending).toMatchObject({ + role: 'reviewer', + intentKind: 'reviewer-turn', + taskId: task.id, + }); + }); + it('re-enqueues reviewer after a successful owner delivery moves the task to review_ready', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); @@ -2109,6 +2214,133 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead ); }); + it('re-enqueues owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel: Channel = { + ...makeChannel(chatJid), + isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'), + }; + const reviewerChannel = makeChannel(chatJid, 'discord-review', false); + const enqueueMessageCheck = vi.fn(); + const pairedTask = { + id: 'task-owner-step-done-follow-up', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 1, + owner_failure_count: 2, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + } as any; + let turnOutputs: any[] = []; + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockImplementation( + () => pairedTask, + ); + vi.mocked(db.getPairedTurnOutputs).mockImplementation((taskId: string) => + taskId === pairedTask.id ? turnOutputs : [], + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'human-step-done-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '이 리팩토링 이어서 해줘', + timestamp: '2026-03-30T00:00:00.000Z', + seq: 1, + is_bot_message: false, + } as any, + ]); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + pairedTask.status = 'active'; + pairedTask.owner_failure_count = 0; + turnOutputs = [ + { + id: 1, + task_id: pairedTask.id, + turn_number: 1, + role: 'owner', + output_text: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', + created_at: '2026-03-30T00:00:01.000Z', + }, + ]; + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', + newSessionId: 'session-owner-step-done-follow-up', + }); + return { + status: 'success', + result: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', + newSessionId: 'session-owner-step-done-follow-up', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel, reviewerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRoomBindings: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-owner-step-done-follow-up', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(ownerChannel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', + ); + expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); + expect(pairedTask.status).toBe('active'); + expect(pairedTask.round_trip_count).toBe(1); + expect(pairedTask.owner_failure_count).toBe(0); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid, + runId: 'run-owner-step-done-follow-up', + completedRole: 'owner', + taskId: 'task-owner-step-done-follow-up', + taskStatus: 'active', + intentKind: 'owner-follow-up', + }), + 'Queued paired follow-up after successful owner delivery', + ); + }); + it('defers reviewer enqueue after owner delivery when a CI watcher is still active', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index 9d2b7c4..c59ae8b 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -20,7 +20,7 @@ import { } from './paired-execution-context-shared.js'; import type { PairedTask } from './types.js'; -type OwnerFinalizeOutcome = 'stop' | 're_review'; +type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner'; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; export function handleFailedOwnerExecution(args: { @@ -170,6 +170,28 @@ function handleOwnerFinalizeCompletion(args: { return 're_review'; } + if (signal.kind === 'request_owner_continue') { + transitionPairedTaskStatus({ + taskId, + currentStatus: task.status, + nextStatus: 'active', + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + owner_failure_count: 0, + }, + }); + logger.info( + { + taskId, + ownerVerdict, + summary: summary?.slice(0, 100), + }, + 'Owner marked finalize output as an intermediate step — task returned to active without re-review', + ); + return 'continue_owner'; + } + transitionPairedTaskStatus({ taskId, currentStatus: task.status, @@ -290,6 +312,22 @@ export function handleOwnerCompletion(args: { return; } + if (signal.kind === 'request_owner_continue') { + applyPairedTaskPatch({ + taskId, + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + owner_failure_count: 0, + }, + }); + logger.info( + { taskId, ownerVerdict, summary: summary?.slice(0, 100) }, + 'Owner marked the current output as an intermediate completed step — keeping task active for follow-up', + ); + return; + } + maybeAutoTriggerReviewerAfterOwnerCompletion({ task, taskId, diff --git a/src/paired-execution-context-shared.test.ts b/src/paired-execution-context-shared.test.ts index b19568b..3cb197a 100644 --- a/src/paired-execution-context-shared.test.ts +++ b/src/paired-execution-context-shared.test.ts @@ -9,6 +9,8 @@ import { describe('paired execution context shared verdict helpers', () => { it('parses visible verdicts from the first summary line only', () => { + expect(parseVisibleVerdict('STEP_DONE\nmore to do')).toBe('step_done'); + expect(parseVisibleVerdict('TASK_DONE\nall done')).toBe('task_done'); expect( parseVisibleVerdict( 'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing', @@ -19,6 +21,15 @@ describe('paired execution context shared verdict helpers', () => { }); it('maps normal owner completion verdicts to reviewer or arbiter signals', () => { + expect( + resolveOwnerCompletionSignal({ + phase: 'normal', + visibleVerdict: 'step_done', + }), + ).toEqual({ + kind: 'request_owner_continue', + resetStatusToActive: false, + }); expect( resolveOwnerCompletionSignal({ phase: 'normal', @@ -37,6 +48,18 @@ describe('paired execution context shared verdict helpers', () => { }); it('maps finalize owner outcomes to complete, re-review, or arbiter', () => { + expect( + resolveOwnerCompletionSignal({ + phase: 'finalize', + visibleVerdict: 'step_done', + hasChangesSinceApproval: false, + roundTripCount: 0, + deadlockThreshold: 2, + }), + ).toEqual({ + kind: 'request_owner_continue', + resetStatusToActive: true, + }); expect( resolveOwnerCompletionSignal({ phase: 'finalize', @@ -70,6 +93,20 @@ describe('paired execution context shared verdict helpers', () => { }); it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => { + expect( + resolveReviewerCompletionSignal({ + visibleVerdict: 'task_done', + roundTripCount: 0, + deadlockThreshold: 3, + }), + ).toEqual({ kind: 'request_owner_finalize' }); + expect( + resolveReviewerCompletionSignal({ + visibleVerdict: 'step_done', + roundTripCount: 0, + deadlockThreshold: 3, + }), + ).toEqual({ kind: 'request_owner_changes' }); expect( resolveReviewerCompletionSignal({ visibleVerdict: 'done', @@ -94,6 +131,11 @@ describe('paired execution context shared verdict helpers', () => { }); it('maps reviewer failure verdicts to explicit failure signals', () => { + expect( + resolveReviewerFailureSignal({ + visibleVerdict: 'task_done', + }), + ).toEqual({ kind: 'request_owner_finalize' }); expect( resolveReviewerFailureSignal({ visibleVerdict: 'done', diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 18d3faa..8bf11fc 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -6,6 +6,8 @@ import { logger } from './logger.js'; import type { PairedTaskStatus } from './types.js'; export type VisibleVerdict = + | 'step_done' + | 'task_done' | 'done' | 'done_with_concerns' | 'blocked' @@ -15,6 +17,7 @@ export type VisibleVerdict = export type CompletionSignal = | { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_owner_finalize' } + | { kind: 'request_owner_continue'; resetStatusToActive: boolean } | { kind: 'request_owner_changes' } | { kind: 'request_arbiter' } | { kind: 'complete'; completionReason: 'done' | 'escalated' } @@ -29,6 +32,8 @@ export function parseVisibleVerdict( const firstLine = cleaned.split('\n')[0].trim(); if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; + if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; + if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) return 'done_with_concerns'; if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; @@ -57,12 +62,25 @@ export function resolveOwnerCompletionSignal(args: { } if (phase === 'normal') { + if (visibleVerdict === 'step_done') { + return { + kind: 'request_owner_continue', + resetStatusToActive: false, + }; + } return { kind: 'request_reviewer', resetStatusToActive: false, }; } + if (visibleVerdict === 'step_done') { + return { + kind: 'request_owner_continue', + resetStatusToActive: true, + }; + } + const needsReReview = visibleVerdict === 'done_with_concerns' || hasChangesSinceApproval === true; @@ -90,8 +108,11 @@ export function resolveReviewerCompletionSignal(args: { const { visibleVerdict, roundTripCount, deadlockThreshold } = args; switch (visibleVerdict) { + case 'task_done': case 'done': return { kind: 'request_owner_finalize' }; + case 'step_done': + return { kind: 'request_owner_changes' }; case 'blocked': case 'needs_context': return { kind: 'request_arbiter' }; @@ -111,6 +132,7 @@ export function resolveReviewerFailureSignal(args: { const { visibleVerdict } = args; switch (visibleVerdict) { + case 'task_done': case 'done': return { kind: 'request_owner_finalize' }; case 'blocked': diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index bfee801..e3f29bc 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -742,6 +742,65 @@ describe('paired execution context', () => { ).not.toHaveBeenCalled(); }); + it('keeps an active task in owner follow-up mode when the owner reports STEP_DONE', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'active', + round_trip_count: 1, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + owner_failure_count: 0, + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + expect(db.updatePairedTask).not.toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'active', + }), + ); + }); + + it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'merge_ready', + round_trip_count: 1, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'STEP_DONE\n남은 범위가 있어서 계속 진행', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'active', + owner_failure_count: 0, + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + it('re-triggers review once when owner reports DONE_WITH_CONCERNS during finalize', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ From 9ef79e382862f5a548fd4116fbb63f6d05cb67ab Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 23 Apr 2026 15:27:49 +0900 Subject: [PATCH 09/48] Add STEP_DONE guards, verdict storage, and stale delivery suppression --- src/db.test.ts | 43 ++++++ src/db/base-schema.ts | 5 + src/db/bootstrap.test.ts | 1 + .../012_paired-verdict-and-step-telemetry.ts | 75 ++++++++++ src/db/migrations/index.ts | 2 + src/db/paired-state.ts | 46 +++++- src/db/paired-turn-outputs.ts | 6 +- src/message-runtime-flow.ts | 9 +- src/message-runtime-follow-up.ts | 10 +- src/message-runtime-queue.ts | 9 +- src/message-runtime.test.ts | 121 ++++++++++++++++ src/message-runtime.ts | 91 +++++++++--- src/paired-execution-context-owner.ts | 129 ++++++++++++++++- src/paired-execution-context-shared.ts | 45 +++--- src/paired-execution-context.test.ts | 136 ++++++++++++++++++ src/paired-execution-context.ts | 55 ++++++- src/paired-verdict.ts | 41 ++++++ src/types.ts | 7 + 18 files changed, 767 insertions(+), 64 deletions(-) create mode 100644 src/db/migrations/012_paired-verdict-and-step-telemetry.ts create mode 100644 src/paired-verdict.ts diff --git a/src/db.test.ts b/src/db.test.ts index 0d687f5..0a15fc6 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1242,10 +1242,53 @@ describe('paired task state', () => { expect(outputs.map((output) => output.turn_number)).toEqual([1, 2]); expect(outputs[0].role).toBe('owner'); expect(outputs[0].output_text).toHaveLength(50_000); + expect(outputs[0].verdict).toBe('continue'); expect(outputs[1].output_text).toBe('review turn'); + expect(outputs[1].verdict).toBe('continue'); expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2); }); + it('stores the parsed visible verdict with paired turn outputs', () => { + createPairedTask({ + id: 'paired-task-turn-output-verdict', + chat_jid: 'dc:paired', + group_folder: 'paired-room', + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: null, + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + + insertPairedTurnOutput( + 'paired-task-turn-output-verdict', + 1, + 'owner', + 'STEP_DONE\n1단계 완료', + ); + insertPairedTurnOutput( + 'paired-task-turn-output-verdict', + 2, + 'owner', + 'TASK_DONE\n요청 범위 전체 완료', + ); + + const outputs = getPairedTurnOutputs('paired-task-turn-output-verdict'); + + expect(outputs.map((output) => output.verdict)).toEqual([ + 'step_done', + 'task_done', + ]); + }); + it('preserves explicit created_at when inserting a paired turn output', () => { createPairedTask({ id: 'paired-task-turn-output-created-at', diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 1fd66ba..d44e693 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -119,6 +119,10 @@ export function applyBaseSchema(database: Database): void { review_requested_at TEXT, round_trip_count INTEGER NOT NULL DEFAULT 0, owner_failure_count INTEGER NOT NULL DEFAULT 0, + owner_step_done_streak INTEGER NOT NULL DEFAULT 0, + finalize_step_done_count INTEGER NOT NULL DEFAULT 0, + task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0, + empty_step_done_streak INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', arbiter_verdict TEXT, arbiter_requested_at TEXT, @@ -154,6 +158,7 @@ export function applyBaseSchema(database: Database): void { turn_number INTEGER NOT NULL, role TEXT NOT NULL, output_text TEXT NOT NULL, + verdict TEXT, created_at TEXT NOT NULL, UNIQUE(task_id, turn_number, role) ); diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index c375201..49dc122 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -36,6 +36,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 9, name: 'paired_workspace_project_schema_cleanup' }, { version: 10, name: 'paired_turn_provenance_upgrade' }, { version: 11, name: 'owner_failure_count' }, + { version: 12, name: 'paired_verdict_and_step_telemetry' }, ]; } diff --git a/src/db/migrations/012_paired-verdict-and-step-telemetry.ts b/src/db/migrations/012_paired-verdict-and-step-telemetry.ts new file mode 100644 index 0000000..d4f9e6e --- /dev/null +++ b/src/db/migrations/012_paired-verdict-and-step-telemetry.ts @@ -0,0 +1,75 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION: SchemaMigrationDefinition = + { + version: 12, + name: 'paired_verdict_and_step_telemetry', + apply(database: Database) { + if (!tableHasColumn(database, 'paired_turn_outputs', 'verdict')) { + database.exec(` + ALTER TABLE paired_turn_outputs + ADD COLUMN verdict TEXT + `); + } + + if (!tableHasColumn(database, 'paired_tasks', 'owner_step_done_streak')) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN owner_step_done_streak INTEGER NOT NULL DEFAULT 0 + `); + } + + if ( + !tableHasColumn(database, 'paired_tasks', 'finalize_step_done_count') + ) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN finalize_step_done_count INTEGER NOT NULL DEFAULT 0 + `); + } + + if ( + !tableHasColumn( + database, + 'paired_tasks', + 'task_done_then_user_reopen_count', + ) + ) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0 + `); + } + + if (!tableHasColumn(database, 'paired_tasks', 'empty_step_done_streak')) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN empty_step_done_streak INTEGER NOT NULL DEFAULT 0 + `); + } + + database.exec(` + UPDATE paired_tasks + SET owner_step_done_streak = 0 + WHERE owner_step_done_streak IS NULL + `); + database.exec(` + UPDATE paired_tasks + SET finalize_step_done_count = 0 + WHERE finalize_step_done_count IS NULL + `); + database.exec(` + UPDATE paired_tasks + SET task_done_then_user_reopen_count = 0 + WHERE task_done_then_user_reopen_count IS NULL + `); + database.exec(` + UPDATE paired_tasks + SET empty_step_done_streak = 0 + WHERE empty_step_done_streak IS NULL + `); + }, + }; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 85eaf42..d6b5e83 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -11,6 +11,7 @@ import { PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION } from './008_paired-task-schema-c import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js'; import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js'; import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; +import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -30,6 +31,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION, PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION, OWNER_FAILURE_COUNT_MIGRATION, + PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index cb589bd..0ebbb20 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -51,6 +51,10 @@ export type PairedTaskUpdates = Partial< | 'review_requested_at' | 'round_trip_count' | 'owner_failure_count' + | 'owner_step_done_streak' + | 'finalize_step_done_count' + | 'task_done_then_user_reopen_count' + | 'empty_step_done_streak' | 'status' | 'arbiter_verdict' | 'arbiter_requested_at' @@ -173,6 +177,10 @@ export function createPairedTaskInDatabase( review_requested_at, round_trip_count, owner_failure_count, + owner_step_done_streak, + finalize_step_done_count, + task_done_then_user_reopen_count, + empty_step_done_streak, status, arbiter_verdict, arbiter_requested_at, @@ -180,7 +188,7 @@ export function createPairedTaskInDatabase( created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -198,6 +206,10 @@ export function createPairedTaskInDatabase( task.review_requested_at, task.round_trip_count, task.owner_failure_count ?? 0, + task.owner_step_done_streak ?? 0, + task.finalize_step_done_count ?? 0, + task.task_done_then_user_reopen_count ?? 0, + task.empty_step_done_streak ?? 0, task.status, task.arbiter_verdict, task.arbiter_requested_at, @@ -306,6 +318,22 @@ export function updatePairedTaskInDatabase( fields.push('owner_failure_count = ?'); values.push(updates.owner_failure_count); } + if (updates.owner_step_done_streak !== undefined) { + fields.push('owner_step_done_streak = ?'); + values.push(updates.owner_step_done_streak); + } + if (updates.finalize_step_done_count !== undefined) { + fields.push('finalize_step_done_count = ?'); + values.push(updates.finalize_step_done_count); + } + if (updates.task_done_then_user_reopen_count !== undefined) { + fields.push('task_done_then_user_reopen_count = ?'); + values.push(updates.task_done_then_user_reopen_count); + } + if (updates.empty_step_done_streak !== undefined) { + fields.push('empty_step_done_streak = ?'); + values.push(updates.empty_step_done_streak); + } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); @@ -369,6 +397,22 @@ export function updatePairedTaskIfUnchangedInDatabase( fields.push('owner_failure_count = ?'); values.push(updates.owner_failure_count); } + if (updates.owner_step_done_streak !== undefined) { + fields.push('owner_step_done_streak = ?'); + values.push(updates.owner_step_done_streak); + } + if (updates.finalize_step_done_count !== undefined) { + fields.push('finalize_step_done_count = ?'); + values.push(updates.finalize_step_done_count); + } + if (updates.task_done_then_user_reopen_count !== undefined) { + fields.push('task_done_then_user_reopen_count = ?'); + values.push(updates.task_done_then_user_reopen_count); + } + if (updates.empty_step_done_streak !== undefined) { + fields.push('empty_step_done_streak = ?'); + values.push(updates.empty_step_done_streak); + } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts index 9c89bb3..01eceda 100644 --- a/src/db/paired-turn-outputs.ts +++ b/src/db/paired-turn-outputs.ts @@ -1,6 +1,7 @@ import { Database } from 'bun:sqlite'; import { logger } from '../logger.js'; +import { parseVisibleVerdict } from '../paired-verdict.js'; import { PairedRoomRole, PairedTurnOutput } from '../types.js'; const MAX_TURN_OUTPUT_CHARS = 50_000; @@ -29,14 +30,15 @@ export function insertPairedTurnOutputInDatabase( database .prepare( `INSERT OR REPLACE INTO paired_turn_outputs - (task_id, turn_number, role, output_text, created_at) - VALUES (?, ?, ?, ?, ?)`, + (task_id, turn_number, role, output_text, verdict, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, ) .run( taskId, turnNumber, role, outputText.slice(0, MAX_TURN_OUTPUT_CHARS), + parseVisibleVerdict(outputText), createdAt ?? new Date().toISOString(), ); } diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index 5d4f46d..9fa5aeb 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -24,12 +24,12 @@ import { } from './message-runtime-rules.js'; import type { ExecuteTurnFn } from './message-runtime-types.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; +import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { claimPairedTurnExecution, type ScheduledPairedFollowUpIntentKind, } from './paired-follow-up-scheduler.js'; import { hasReviewerLease } from './service-routing.js'; -import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import type { Channel, NewMessage, @@ -128,9 +128,10 @@ export function buildPendingPairedTurn(args: { const nextTurnAction = resolveNextTurnAction({ taskStatus, lastTurnOutputRole: lastTurnOutput?.role ?? null, - lastTurnOutputVerdict: lastTurnOutput?.output_text - ? parseVisibleVerdict(lastTurnOutput.output_text) - : null, + lastTurnOutputVerdict: resolveStoredVisibleVerdict({ + verdict: lastTurnOutput?.verdict ?? null, + outputText: lastTurnOutput?.output_text ?? null, + }), }); const recentMessages = getRecentChatMessages(chatJid, 20); const lastHumanMessage = getLastHumanMessageContent(chatJid); diff --git a/src/message-runtime-follow-up.ts b/src/message-runtime-follow-up.ts index 97725eb..43fbf0d 100644 --- a/src/message-runtime-follow-up.ts +++ b/src/message-runtime-follow-up.ts @@ -1,8 +1,8 @@ import { getPairedTurnOutputs } from './db.js'; import { - parseVisibleVerdict, type VisibleVerdict, } from './paired-execution-context-shared.js'; +import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { matchesExpectedPairedFollowUpIntent, resolveFollowUpDispatch, @@ -55,9 +55,11 @@ export function resolveLatestPairedTurnOutputContext(args: { : null; return { role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null, - verdict: latestOutput?.output_text - ? parseVisibleVerdict(latestOutput.output_text) - : (args.fallbackLastTurnOutputVerdict ?? null), + verdict: + resolveStoredVisibleVerdict({ + verdict: latestOutput?.verdict ?? null, + outputText: latestOutput?.output_text ?? null, + }) ?? args.fallbackLastTurnOutputVerdict ?? null, }; } diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 1ceaee8..4cacf38 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -11,7 +11,7 @@ import { isBotOnlyPairedRoomTurn, } from './message-runtime-flow.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; -import { parseVisibleVerdict } from './paired-execution-context-shared.js'; +import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { advanceLastAgentCursor, resolveActiveRole, @@ -185,9 +185,10 @@ export async function runQueuedGroupTurn(args: { ? (turnOutputs.at(-1)?.role ?? null) : null; const lastTurnOutputVerdict = currentTask - ? turnOutputs.at(-1)?.output_text - ? parseVisibleVerdict(turnOutputs.at(-1)?.output_text) - : null + ? resolveStoredVisibleVerdict({ + verdict: turnOutputs.at(-1)?.verdict ?? null, + outputText: turnOutputs.at(-1)?.output_text ?? null, + }) : null; const turnRole = currentTask ? hasHumanMsg diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index b5d82df..1af391c 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -842,6 +842,127 @@ describe('createMessageRuntime', () => { ); }); + it('suppresses a stale owner work item when a new human message arrives while the paired task is still active', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const enqueueMessageCheck = vi.fn(); + const activeTask = { + id: 'task-active-owner-follow-up', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:05.000Z', + } as any; + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getOpenWorkItem).mockReturnValue({ + id: 109, + group_folder: group.folder, + chat_jid: chatJid, + agent_type: 'codex', + service_id: 'claude', + delivery_role: 'owner', + status: 'delivery_retry', + start_seq: 1, + end_seq: 1, + result_payload: '이전 step 결과입니다.', + delivery_attempts: 1, + created_at: '2026-03-30T00:00:05.000Z', + updated_at: '2026-03-30T00:00:05.000Z', + delivered_at: null, + delivery_message_id: null, + last_error: 'discord send failed', + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-2', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '이전 답 말고 이 방향으로 진행해줘', + timestamp: '2026-03-30T00:00:10.000Z', + seq: 2, + }, + ]); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(activeTask); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, input, _onProcess, onOutput) => { + expect(input.prompt).toContain('이 방향으로 진행해줘'); + expect(input.prompt).not.toContain('이전 step 결과입니다.'); + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'STEP_DONE\n새 입력 기준으로 계속 진행', + output: { + visibility: 'public', + text: 'STEP_DONE\n새 입력 기준으로 계속 진행', + }, + } as any); + return { + status: 'success', + result: 'STEP_DONE\n새 입력 기준으로 계속 진행', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRoomBindings: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-suppress-stale-active-owner-work-item', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(db.markWorkItemDelivered).toHaveBeenCalledWith(109); + expect(channel.sendMessage).not.toHaveBeenCalledWith( + chatJid, + '이전 step 결과입니다.', + ); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid, + workItemId: 109, + taskId: 'task-active-owner-follow-up', + taskStatus: 'active', + }), + 'Suppressed stale owner delivery retry because a new human message arrived while the paired task was still active', + ); + }); + it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index de06cbe..39dbf06 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -56,6 +56,7 @@ import { } from './types.js'; import { createScopedLogger, logger } from './logger.js'; import { hasReviewerLease } from './service-routing.js'; +import type { WorkItem } from './db/work-items.js'; export { resolveHandoffCursorKey, resolveHandoffRoleOverride, @@ -113,6 +114,54 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deps.queue, ); + const getFreshHumanPreflightMessages = ( + chatJid: string, + channel: Channel, + ): NewMessage[] => { + const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0'; + const preflightRawMessages = getMessagesSinceSeq( + chatJid, + sinceSeqCursor, + deps.assistantName, + ); + const preflightMessages = filterLoopingPairedBotMessages( + chatJid, + getProcessableMessages(chatJid, preflightRawMessages, channel), + FAILURE_FINAL_TEXT, + ); + return preflightMessages.filter( + (message) => message.is_from_me !== true && !message.is_bot_message, + ); + }; + + const hasHumanMessageAfterWorkItem = ( + openWorkItem: WorkItem, + freshHumanMessages: NewMessage[], + ): boolean => { + if (freshHumanMessages.length === 0) { + return false; + } + + const workItemSeq = openWorkItem.end_seq ?? openWorkItem.start_seq ?? null; + const workItemUpdatedAt = Date.parse(openWorkItem.updated_at); + + return freshHumanMessages.some((message) => { + if (message.seq != null && workItemSeq != null) { + return message.seq > workItemSeq; + } + + const messageTimestamp = Date.parse(message.timestamp); + if ( + Number.isFinite(messageTimestamp) && + Number.isFinite(workItemUpdatedAt) + ) { + return messageTimestamp > workItemUpdatedAt; + } + + return true; + }); + }; + const scheduleQueuedPairedFollowUp = (args: { chatJid: string; runId: string; @@ -351,25 +400,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ? getLatestOpenPairedTaskForChat(chatJid) : null; let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE); - if ( - pendingTask?.status === 'merge_ready' && - openWorkItem?.delivery_role === 'owner' - ) { - const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0'; - const preflightRawMessages = getMessagesSinceSeq( - chatJid, - sinceSeqCursor, - deps.assistantName, - ); - const preflightMessages = filterLoopingPairedBotMessages( - chatJid, - getProcessableMessages(chatJid, preflightRawMessages, channel), - FAILURE_FINAL_TEXT, - ); - const hasFreshHumanMessage = preflightMessages.some( - (message) => message.is_from_me !== true && !message.is_bot_message, - ); - if (hasFreshHumanMessage) { + if (openWorkItem?.delivery_role === 'owner' && pendingTask) { + const freshHumanMessages = getFreshHumanPreflightMessages(chatJid, channel); + if ( + pendingTask.status === 'merge_ready' && + freshHumanMessages.length > 0 + ) { const resolvedTask = resolveOwnerTaskForHumanMessage({ group, chatJid, @@ -389,6 +425,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); openWorkItem = undefined; } + } else if ( + pendingTask.status === 'active' && + hasHumanMessageAfterWorkItem(openWorkItem, freshHumanMessages) + ) { + markWorkItemDelivered(openWorkItem.id); + log.info( + { + chatJid, + workItemId: openWorkItem.id, + taskId: pendingTask.id, + taskStatus: pendingTask.status, + workItemEndSeq: openWorkItem.end_seq ?? null, + freshHumanMessageCount: freshHumanMessages.length, + }, + 'Suppressed stale owner delivery retry because a new human message arrived while the paired task was still active', + ); + openWorkItem = undefined; } } const openWorkItemOutcome = await processOpenWorkItemDelivery({ diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index c59ae8b..547b4b9 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -22,6 +22,8 @@ import type { PairedTask } from './types.js'; type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner'; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; +const EMPTY_STEP_DONE_THRESHOLD = 2; +const OWNER_STEP_DONE_LOOP_THRESHOLD = 3; export function handleFailedOwnerExecution(args: { task: PairedTask; @@ -101,6 +103,14 @@ function handleOwnerFinalizeCompletion(args: { const hasNewChanges = workspace?.workspace_dir ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) : null; + const nextFinalizeStepDoneCount = + ownerVerdict === 'step_done' + ? (task.finalize_step_done_count ?? 0) + 1 + : task.finalize_step_done_count ?? 0; + const nextEmptyStepDoneStreak = + ownerVerdict === 'step_done' && hasNewChanges === false + ? (task.empty_step_done_streak ?? 0) + 1 + : 0; const signal = resolveOwnerCompletionSignal({ phase: 'finalize', visibleVerdict: ownerVerdict, @@ -138,6 +148,41 @@ function handleOwnerFinalizeCompletion(args: { }, patch: { owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: nextEmptyStepDoneStreak, + }, + }); + return 'stop'; + } + + if ( + signal.kind === 'request_owner_continue' && + hasNewChanges === false && + nextEmptyStepDoneStreak >= EMPTY_STEP_DONE_THRESHOLD + ) { + requestArbiterOrEscalate({ + taskId, + currentStatus: task.status, + expectedUpdatedAt: task.updated_at, + now, + arbiterLogMessage: + 'Owner repeated STEP_DONE during finalize without code changes — requesting arbiter', + escalateLogMessage: + 'Owner repeated STEP_DONE during finalize without code changes — escalating to user', + logContext: { + taskId, + ownerVerdict, + hasNewChanges, + emptyStepDoneStreak: nextEmptyStepDoneStreak, + finalizeStepDoneCount: nextFinalizeStepDoneCount, + summary: summary?.slice(0, 100), + }, + patch: { + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: nextEmptyStepDoneStreak, }, }); return 'stop'; @@ -153,6 +198,9 @@ function handleOwnerFinalizeCompletion(args: { updatedAt: now, patch: { owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: 0, }, }); } @@ -179,12 +227,17 @@ function handleOwnerFinalizeCompletion(args: { updatedAt: now, patch: { owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: nextEmptyStepDoneStreak, }, }); logger.info( { taskId, ownerVerdict, + emptyStepDoneStreak: nextEmptyStepDoneStreak, + finalizeStepDoneCount: nextFinalizeStepDoneCount, summary: summary?.slice(0, 100), }, 'Owner marked finalize output as an intermediate step — task returned to active without re-review', @@ -201,6 +254,9 @@ function handleOwnerFinalizeCompletion(args: { patch: { completion_reason: 'done', owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: 0, }, }); logger.info( @@ -242,6 +298,8 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: { patch: { round_trip_count: task.round_trip_count + 1, owner_failure_count: 0, + owner_step_done_streak: 0, + empty_step_done_streak: 0, }, }); if (hasActiveCiWatcherForChat(task.chat_jid)) { @@ -287,6 +345,16 @@ export function handleOwnerCompletion(args: { } const ownerVerdict = parseVisibleVerdict(summary); + const workspace = getPairedWorkspace(task.id, 'owner'); + const hasNewChanges = workspace?.workspace_dir + ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) + : null; + const nextOwnerStepDoneStreak = + ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0; + const nextEmptyStepDoneStreak = + ownerVerdict === 'step_done' && hasNewChanges === false + ? (task.empty_step_done_streak ?? 0) + 1 + : 0; const signal = resolveOwnerCompletionSignal({ phase: 'normal', visibleVerdict: ownerVerdict, @@ -303,10 +371,46 @@ export function handleOwnerCompletion(args: { logContext: { taskId, ownerVerdict, + ownerStepDoneStreak: nextOwnerStepDoneStreak, + emptyStepDoneStreak: nextEmptyStepDoneStreak, summary: summary?.slice(0, 100), }, patch: { owner_failure_count: 0, + owner_step_done_streak: nextOwnerStepDoneStreak, + empty_step_done_streak: nextEmptyStepDoneStreak, + }, + }); + return; + } + + if ( + signal.kind === 'request_owner_continue' && + hasNewChanges === false && + nextOwnerStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD && + nextEmptyStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD + ) { + requestArbiterOrEscalate({ + taskId, + currentStatus: task.status, + expectedUpdatedAt: task.updated_at, + now, + arbiterLogMessage: + 'Owner repeated STEP_DONE in active mode without code changes — requesting arbiter', + escalateLogMessage: + 'Owner repeated STEP_DONE in active mode without code changes — escalating to user', + logContext: { + taskId, + ownerVerdict, + hasNewChanges, + ownerStepDoneStreak: nextOwnerStepDoneStreak, + emptyStepDoneStreak: nextEmptyStepDoneStreak, + summary: summary?.slice(0, 100), + }, + patch: { + owner_failure_count: 0, + owner_step_done_streak: nextOwnerStepDoneStreak, + empty_step_done_streak: nextEmptyStepDoneStreak, }, }); return; @@ -319,15 +423,38 @@ export function handleOwnerCompletion(args: { updatedAt: now, patch: { owner_failure_count: 0, + owner_step_done_streak: nextOwnerStepDoneStreak, + empty_step_done_streak: nextEmptyStepDoneStreak, }, }); logger.info( - { taskId, ownerVerdict, summary: summary?.slice(0, 100) }, + { + taskId, + ownerVerdict, + hasNewChanges, + ownerStepDoneStreak: nextOwnerStepDoneStreak, + emptyStepDoneStreak: nextEmptyStepDoneStreak, + summary: summary?.slice(0, 100), + }, 'Owner marked the current output as an intermediate completed step — keeping task active for follow-up', ); return; } + if ( + nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0) || + nextEmptyStepDoneStreak !== (task.empty_step_done_streak ?? 0) + ) { + applyPairedTaskPatch({ + taskId, + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + owner_step_done_streak: nextOwnerStepDoneStreak, + empty_step_done_streak: nextEmptyStepDoneStreak, + }, + }); + } maybeAutoTriggerReviewerAfterOwnerCompletion({ task, taskId, diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 8bf11fc..6e59957 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -3,17 +3,12 @@ import { execFileSync } from 'child_process'; import { isArbiterEnabled } from './config.js'; import { updatePairedTaskIfUnchanged } from './db.js'; import { logger } from './logger.js'; +import { + parseVisibleVerdict, + type VisibleVerdict, +} from './paired-verdict.js'; import type { PairedTaskStatus } from './types.js'; -export type VisibleVerdict = - | 'step_done' - | 'task_done' - | 'done' - | 'done_with_concerns' - | 'blocked' - | 'needs_context' - | 'continue'; - export type CompletionSignal = | { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_owner_finalize' } @@ -23,24 +18,8 @@ export type CompletionSignal = | { kind: 'complete'; completionReason: 'done' | 'escalated' } | { kind: 'preserve_review_ready' }; -export function parseVisibleVerdict( - summary: string | null | undefined, -): VisibleVerdict { - if (!summary) return 'continue'; - const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); - if (!cleaned) return 'continue'; - const firstLine = cleaned.split('\n')[0].trim(); - if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; - if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; - if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; - if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; - if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) - return 'done_with_concerns'; - if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; - if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done'; - if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done'; - return 'continue'; -} +export { parseVisibleVerdict }; +export type { VisibleVerdict }; export function resolveOwnerCompletionSignal(args: { phase: 'normal' | 'finalize'; @@ -270,6 +249,10 @@ export function transitionPairedTaskStatus(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + owner_step_done_streak?: number; + finalize_step_done_count?: number; + task_done_then_user_reopen_count?: number; + empty_step_done_streak?: number; arbiter_verdict?: string | null; arbiter_requested_at?: string | null; completion_reason?: string | null; @@ -314,6 +297,10 @@ export function applyPairedTaskPatch(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + owner_step_done_streak?: number; + finalize_step_done_count?: number; + task_done_then_user_reopen_count?: number; + empty_step_done_streak?: number; status?: PairedTaskStatus; arbiter_verdict?: string | null; arbiter_requested_at?: string | null; @@ -355,6 +342,10 @@ export function requestArbiterOrEscalate(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + owner_step_done_streak?: number; + finalize_step_done_count?: number; + task_done_then_user_reopen_count?: number; + empty_step_done_streak?: number; arbiter_verdict?: string | null; arbiter_requested_at?: string | null; completion_reason?: string | null; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index e3f29bc..c7a6df2 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -143,6 +143,10 @@ function buildPairedTask(overrides: Partial = {}): PairedTask { review_requested_at: null, round_trip_count: 0, owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, status: 'active', arbiter_verdict: null, arbiter_requested_at: null, @@ -293,6 +297,62 @@ describe('paired execution context', () => { expect(db.insertPairedTurnOutput).not.toHaveBeenCalled(); }); + it('records a quick reopen when a new owner task starts shortly after TASK_DONE completion', () => { + const previousTask = buildPairedTask({ + id: 'task-completed', + status: 'completed', + completion_reason: 'done', + updated_at: new Date(Date.now() - 60_000).toISOString(), + task_done_then_user_reopen_count: 0, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined); + vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask); + + const result = resolveOwnerTaskForHumanMessage({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + existingTask: null, + }); + + expect(result.task).not.toBeNull(); + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-completed', + expect.objectContaining({ + task_done_then_user_reopen_count: 1, + }), + ); + }); + + it('resets active STEP_DONE loop counters when a new human message continues an active task', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue( + buildPairedTask({ + status: 'active', + owner_failure_count: 1, + owner_step_done_streak: 2, + empty_step_done_streak: 2, + }), + ); + + preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-human-reset-step-done-loop', + roomRoleContext: ownerContext, + hasHumanMessage: true, + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + empty_step_done_streak: 0, + }), + ); + }); + it('uses room role context agent overrides when creating a paired task', () => { preparePairedExecutionContext({ group, @@ -761,6 +821,7 @@ describe('paired execution context', () => { 'task-1', expect.objectContaining({ owner_failure_count: 0, + owner_step_done_streak: 1, }), ); expect( @@ -774,6 +835,43 @@ describe('paired execution context', () => { ); }); + it('requests arbiter when active STEP_DONE repeats without code changes', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + + const repoDir = createCanonicalRepoWithCommit('active step done loop'); + const sourceRef = resolveTreeRef(repoDir); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'active', + source_ref: sourceRef, + owner_step_done_streak: 2, + empty_step_done_streak: 2, + }), + ); + vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => + role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'STEP_DONE\n요약만 반복되고 코드 변경은 없음', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + owner_step_done_streak: 3, + empty_step_done_streak: 3, + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ @@ -794,6 +892,44 @@ describe('paired execution context', () => { expect.objectContaining({ status: 'active', owner_failure_count: 0, + finalize_step_done_count: 1, + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + + it('requests arbiter when finalize STEP_DONE repeats without code changes', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + + const repoDir = createCanonicalRepoWithCommit('reviewed'); + const approvedSourceRef = resolveTreeRef(repoDir); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'merge_ready', + source_ref: approvedSourceRef, + empty_step_done_streak: 1, + finalize_step_done_count: 1, + }), + ); + vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => + role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'STEP_DONE\n아직 남았지만 코드 변경은 없음', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + empty_step_done_streak: 2, + finalize_step_done_count: 2, }), ); expect( diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 45038e3..3732eee 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -30,6 +30,7 @@ import { hasActiveCiWatcherForChat, insertPairedTurnOutput, releasePairedTaskExecutionLease, + updatePairedTask, upsertPairedProject, } from './db.js'; import { logger } from './logger.js'; @@ -72,6 +73,8 @@ import type { } from './types.js'; import { resolveRoleAgentPlan } from './role-agent-plan.js'; +const TASK_DONE_REOPEN_WINDOW_MS = 10 * 60_000; + function ensurePairedProject( group: RegisteredGroup, chatJid: string, @@ -144,6 +147,10 @@ function createActiveTaskForRoom(args: { review_requested_at: null, round_trip_count: 0, owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, status: 'active', arbiter_verdict: null, arbiter_requested_at: null, @@ -164,6 +171,39 @@ function createActiveTaskForRoom(args: { return task; } +function maybeRecordTaskDoneReopen(previousTask: PairedTask | null): void { + if ( + !previousTask || + previousTask.status !== 'completed' || + previousTask.completion_reason !== 'done' + ) { + return; + } + + const completedAt = Date.parse(previousTask.updated_at); + if (!Number.isFinite(completedAt)) { + return; + } + if (Date.now() - completedAt > TASK_DONE_REOPEN_WINDOW_MS) { + return; + } + + updatePairedTask(previousTask.id, { + task_done_then_user_reopen_count: + (previousTask.task_done_then_user_reopen_count ?? 0) + 1, + updated_at: new Date().toISOString(), + }); + logger.info( + { + taskId: previousTask.id, + chatJid: previousTask.chat_jid, + reopenCount: (previousTask.task_done_then_user_reopen_count ?? 0) + 1, + completionReason: previousTask.completion_reason, + }, + 'Recorded paired task reopen shortly after TASK_DONE completion', + ); +} + function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void { const turnIdentity = buildPairedTurnIdentity({ taskId: task.id, @@ -253,6 +293,7 @@ export function resolveOwnerTaskForHumanMessage(args: { args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null; if (!existing) { + maybeRecordTaskDoneReopen(getLatestPairedTaskForChat(args.chatJid) ?? null); return { task: canonicalWorkDir ? createActiveTaskForRoom({ @@ -432,7 +473,12 @@ export function preparePairedExecutionContext(args: { updatedAt: now, patch: { ...(hasHuman - ? { round_trip_count: 0, owner_failure_count: 0 } + ? { + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + empty_step_done_streak: 0, + } : {}), }, }); @@ -443,7 +489,12 @@ export function preparePairedExecutionContext(args: { updatedAt: now, patch: { ...(hasHuman - ? { round_trip_count: 0, owner_failure_count: 0 } + ? { + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + empty_step_done_streak: 0, + } : {}), }, }); diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts new file mode 100644 index 0000000..57e375d --- /dev/null +++ b/src/paired-verdict.ts @@ -0,0 +1,41 @@ +export type VisibleVerdict = + | 'step_done' + | 'task_done' + | 'done' + | 'done_with_concerns' + | 'blocked' + | 'needs_context' + | 'continue'; + +export function parseVisibleVerdict( + summary: string | null | undefined, +): VisibleVerdict { + if (!summary) return 'continue'; + const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); + if (!cleaned) return 'continue'; + const firstLine = cleaned.split('\n')[0].trim(); + if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; + if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) + return 'needs_context'; + if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; + if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; + if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) + return 'done_with_concerns'; + if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; + if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done'; + if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done'; + return 'continue'; +} + +export function resolveStoredVisibleVerdict(args: { + verdict?: VisibleVerdict | null; + outputText?: string | null; +}): VisibleVerdict | null { + if (args.verdict) { + return args.verdict; + } + if (!args.outputText) { + return null; + } + return parseVisibleVerdict(args.outputText); +} diff --git a/src/types.ts b/src/types.ts index 49842fa..5c00ed3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { VisibleVerdict } from './paired-verdict.js'; + export interface AgentConfig { timeout?: number; // Default: 300000 (5 minutes) // Per-group model/effort overrides (take precedence over global env vars) @@ -83,6 +85,10 @@ export interface PairedTask { review_requested_at: string | null; round_trip_count: number; owner_failure_count?: number | null; + owner_step_done_streak?: number | null; + finalize_step_done_count?: number | null; + task_done_then_user_reopen_count?: number | null; + empty_step_done_streak?: number | null; status: PairedTaskStatus; arbiter_verdict: string | null; arbiter_requested_at: string | null; @@ -97,6 +103,7 @@ export interface PairedTurnOutput { turn_number: number; role: PairedRoomRole; output_text: string; + verdict?: VisibleVerdict | null; created_at: string; } From 6d28a4ae192203a05b852b681020d22c7eaac734 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 23 Apr 2026 18:51:56 +0900 Subject: [PATCH 10/48] style: format paired stepdone telemetry files --- src/message-runtime-follow-up.ts | 8 ++++---- src/message-runtime.ts | 5 ++++- src/paired-execution-context-owner.ts | 2 +- src/paired-execution-context-shared.ts | 5 +---- src/paired-verdict.ts | 3 +-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/message-runtime-follow-up.ts b/src/message-runtime-follow-up.ts index 43fbf0d..0bed522 100644 --- a/src/message-runtime-follow-up.ts +++ b/src/message-runtime-follow-up.ts @@ -1,7 +1,5 @@ import { getPairedTurnOutputs } from './db.js'; -import { - type VisibleVerdict, -} from './paired-execution-context-shared.js'; +import { type VisibleVerdict } from './paired-execution-context-shared.js'; import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { matchesExpectedPairedFollowUpIntent, @@ -59,7 +57,9 @@ export function resolveLatestPairedTurnOutputContext(args: { resolveStoredVisibleVerdict({ verdict: latestOutput?.verdict ?? null, outputText: latestOutput?.output_text ?? null, - }) ?? args.fallbackLastTurnOutputVerdict ?? null, + }) ?? + args.fallbackLastTurnOutputVerdict ?? + null, }; } diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 39dbf06..bea78ab 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -401,7 +401,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { : null; let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE); if (openWorkItem?.delivery_role === 'owner' && pendingTask) { - const freshHumanMessages = getFreshHumanPreflightMessages(chatJid, channel); + const freshHumanMessages = getFreshHumanPreflightMessages( + chatJid, + channel, + ); if ( pendingTask.status === 'merge_ready' && freshHumanMessages.length > 0 diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index 547b4b9..f820007 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -106,7 +106,7 @@ function handleOwnerFinalizeCompletion(args: { const nextFinalizeStepDoneCount = ownerVerdict === 'step_done' ? (task.finalize_step_done_count ?? 0) + 1 - : task.finalize_step_done_count ?? 0; + : (task.finalize_step_done_count ?? 0); const nextEmptyStepDoneStreak = ownerVerdict === 'step_done' && hasNewChanges === false ? (task.empty_step_done_streak ?? 0) + 1 diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 6e59957..998e374 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -3,10 +3,7 @@ import { execFileSync } from 'child_process'; import { isArbiterEnabled } from './config.js'; import { updatePairedTaskIfUnchanged } from './db.js'; import { logger } from './logger.js'; -import { - parseVisibleVerdict, - type VisibleVerdict, -} from './paired-verdict.js'; +import { parseVisibleVerdict, type VisibleVerdict } from './paired-verdict.js'; import type { PairedTaskStatus } from './types.js'; export type CompletionSignal = diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts index 57e375d..a2eef37 100644 --- a/src/paired-verdict.ts +++ b/src/paired-verdict.ts @@ -15,8 +15,7 @@ export function parseVisibleVerdict( if (!cleaned) return 'continue'; const firstLine = cleaned.split('\n')[0].trim(); if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; - if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) - return 'needs_context'; + if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) From f84dccfa10b97dd05b944e2fbef07f489e2d9038 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 12:03:12 +0900 Subject: [PATCH 11/48] Route STEP_DONE through reviewer --- src/message-runtime-follow-up.test.ts | 8 +- src/message-runtime-rules.test.ts | 8 +- src/message-runtime-rules.ts | 3 +- src/message-runtime.test.ts | 20 +-- src/paired-execution-context-owner.ts | 132 +++++--------------- src/paired-execution-context-shared.test.ts | 4 +- src/paired-execution-context-shared.ts | 9 +- src/paired-execution-context.test.ts | 77 +++++++++--- 8 files changed, 105 insertions(+), 156 deletions(-) diff --git a/src/message-runtime-follow-up.test.ts b/src/message-runtime-follow-up.test.ts index 4bf4dfc..f8c3c76 100644 --- a/src/message-runtime-follow-up.test.ts +++ b/src/message-runtime-follow-up.test.ts @@ -83,7 +83,7 @@ describe('message-runtime-follow-up', () => { expect(enqueue).toHaveBeenCalledTimes(1); }); - it('uses the fallback STEP_DONE verdict to schedule owner follow-ups when the owner keeps the task active', () => { + it('does not use fallback owner STEP_DONE verdict to schedule owner follow-ups while active', () => { const enqueue = vi.fn(); const result = dispatchPairedFollowUpForEvent({ chatJid: 'group@test', @@ -102,14 +102,12 @@ describe('message-runtime-follow-up', () => { }); expect(result).toMatchObject({ - kind: 'paired-follow-up', - intentKind: 'owner-follow-up', - scheduled: true, + kind: 'none', taskStatus: 'active', lastTurnOutputRole: 'owner', lastTurnOutputVerdict: 'step_done', }); - expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue).not.toHaveBeenCalled(); }); it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => { diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index db9b4bd..d8eba00 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -120,7 +120,7 @@ describe('message-runtime-rules', () => { lastTurnOutputVerdict: 'step_done', intentKind: 'owner-follow-up', }), - ).toBe(true); + ).toBe(false); }); it('maps active tasks with reviewer output to an owner follow-up', () => { @@ -132,14 +132,14 @@ describe('message-runtime-rules', () => { ).toEqual({ kind: 'owner-follow-up' }); }); - it('maps active tasks with owner STEP_DONE output to an owner follow-up', () => { + it('does not map active owner STEP_DONE output to an owner follow-up', () => { expect( resolveNextTurnAction({ taskStatus: 'active', lastTurnOutputRole: 'owner', lastTurnOutputVerdict: 'step_done', }), - ).toEqual({ kind: 'owner-follow-up' }); + ).toEqual({ kind: 'none' }); }); it('returns none when an active task has no reviewer or arbiter handoff output', () => { @@ -329,7 +329,7 @@ describe('message-runtime-rules', () => { lastTurnOutputRole: 'owner', lastTurnOutputVerdict: 'step_done', }), - ).toBe('owner'); + ).toBeNull(); }); it('resolves reviewer execution target from review_ready task status', () => { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 033abb1..c7582e2 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -146,8 +146,7 @@ export function resolveNextTurnAction(args: { ? { kind: 'none' } : { kind: 'finalize-owner-turn' }; case 'active': - return args.lastTurnOutputVerdict === 'step_done' || - args.lastTurnOutputRole === 'reviewer' || + return args.lastTurnOutputRole === 'reviewer' || args.lastTurnOutputRole === 'arbiter' ? { kind: 'owner-follow-up' } : { kind: 'none' }; diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 1af391c..6bf1b59 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -2126,7 +2126,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead expect(pending?.prompt).toContain('이전 reviewer 피드백'); }); - it('builds an owner follow-up pending turn when the latest owner output is STEP_DONE on an active task', () => { + it('does not build an owner follow-up pending turn from owner STEP_DONE on an active task', () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const task = { @@ -2170,12 +2170,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead resolveChannel: () => makeChannel(chatJid), }); - expect(pending).not.toBeNull(); - expect(pending).toMatchObject({ - role: 'owner', - intentKind: 'owner-follow-up', - taskId: task.id, - }); + expect(pending).toBeNull(); }); it('builds a reviewer pending turn when a review_ready task follows owner TASK_DONE output', () => { @@ -2335,7 +2330,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead ); }); - it('re-enqueues owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => { + it('does not re-enqueue owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); const ownerChannel: Channel = { @@ -2445,17 +2440,12 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead chatJid, 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', ); - expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); + expect(enqueueMessageCheck).not.toHaveBeenCalled(); expect(pairedTask.status).toBe('active'); expect(pairedTask.round_trip_count).toBe(1); expect(pairedTask.owner_failure_count).toBe(0); - expect(logger.info).toHaveBeenCalledWith( + expect(logger.info).not.toHaveBeenCalledWith( expect.objectContaining({ - chatJid, - runId: 'run-owner-step-done-follow-up', - completedRole: 'owner', - taskId: 'task-owner-step-done-follow-up', - taskStatus: 'active', intentKind: 'owner-follow-up', }), 'Queued paired follow-up after successful owner delivery', diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index f820007..beb958a 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -20,10 +20,9 @@ import { } from './paired-execution-context-shared.js'; import type { PairedTask } from './types.js'; -type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner'; +type OwnerFinalizeOutcome = 'stop' | 're_review'; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; const EMPTY_STEP_DONE_THRESHOLD = 2; -const OWNER_STEP_DONE_LOOP_THRESHOLD = 3; export function handleFailedOwnerExecution(args: { task: PairedTask; @@ -157,7 +156,7 @@ function handleOwnerFinalizeCompletion(args: { } if ( - signal.kind === 'request_owner_continue' && + ownerVerdict === 'step_done' && hasNewChanges === false && nextEmptyStepDoneStreak >= EMPTY_STEP_DONE_THRESHOLD ) { @@ -200,7 +199,8 @@ function handleOwnerFinalizeCompletion(args: { owner_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: nextFinalizeStepDoneCount, - empty_step_done_streak: 0, + empty_step_done_streak: + ownerVerdict === 'step_done' ? nextEmptyStepDoneStreak : 0, }, }); } @@ -213,36 +213,27 @@ function handleOwnerFinalizeCompletion(args: { }, ownerVerdict === 'done_with_concerns' ? 'Owner raised concerns during finalize — task set back to active' - : 'Owner made changes after reviewer approval — task set back to active before re-review', + : ownerVerdict === 'step_done' + ? 'Owner reported STEP_DONE during finalize — task set back to active before review' + : 'Owner made changes after reviewer approval — task set back to active before re-review', ); - return 're_review'; - } - - if (signal.kind === 'request_owner_continue') { - transitionPairedTaskStatus({ + maybeAutoTriggerReviewerAfterOwnerCompletion({ + task, taskId, - currentStatus: task.status, - nextStatus: 'active', - expectedUpdatedAt: task.updated_at, - updatedAt: now, - patch: { - owner_failure_count: 0, - owner_step_done_streak: 0, - finalize_step_done_count: nextFinalizeStepDoneCount, - empty_step_done_streak: nextEmptyStepDoneStreak, - }, + now, + logMessage: + ownerVerdict === 'step_done' + ? 'Auto-triggered reviewer after owner finalize STEP_DONE' + : 'Auto-triggered reviewer after owner finalize required re-review', + patch: + ownerVerdict === 'step_done' + ? { + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: nextEmptyStepDoneStreak, + } + : undefined, }); - logger.info( - { - taskId, - ownerVerdict, - emptyStepDoneStreak: nextEmptyStepDoneStreak, - finalizeStepDoneCount: nextFinalizeStepDoneCount, - summary: summary?.slice(0, 100), - }, - 'Owner marked finalize output as an intermediate step — task returned to active without re-review', - ); - return 'continue_owner'; + return 'stop'; } transitionPairedTaskStatus({ @@ -271,6 +262,11 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: { taskId: string; now: string; logMessage: string; + patch?: { + owner_step_done_streak?: number; + finalize_step_done_count?: number; + empty_step_done_streak?: number; + }; }): void { const { task, taskId, now, logMessage } = args; if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) { @@ -300,6 +296,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: { owner_failure_count: 0, owner_step_done_streak: 0, empty_step_done_streak: 0, + ...args.patch, }, }); if (hasActiveCiWatcherForChat(task.chat_jid)) { @@ -345,16 +342,8 @@ export function handleOwnerCompletion(args: { } const ownerVerdict = parseVisibleVerdict(summary); - const workspace = getPairedWorkspace(task.id, 'owner'); - const hasNewChanges = workspace?.workspace_dir - ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) - : null; const nextOwnerStepDoneStreak = ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0; - const nextEmptyStepDoneStreak = - ownerVerdict === 'step_done' && hasNewChanges === false - ? (task.empty_step_done_streak ?? 0) + 1 - : 0; const signal = resolveOwnerCompletionSignal({ phase: 'normal', visibleVerdict: ownerVerdict, @@ -372,86 +361,23 @@ export function handleOwnerCompletion(args: { taskId, ownerVerdict, ownerStepDoneStreak: nextOwnerStepDoneStreak, - emptyStepDoneStreak: nextEmptyStepDoneStreak, summary: summary?.slice(0, 100), }, patch: { owner_failure_count: 0, owner_step_done_streak: nextOwnerStepDoneStreak, - empty_step_done_streak: nextEmptyStepDoneStreak, }, }); return; } - if ( - signal.kind === 'request_owner_continue' && - hasNewChanges === false && - nextOwnerStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD && - nextEmptyStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD - ) { - requestArbiterOrEscalate({ - taskId, - currentStatus: task.status, - expectedUpdatedAt: task.updated_at, - now, - arbiterLogMessage: - 'Owner repeated STEP_DONE in active mode without code changes — requesting arbiter', - escalateLogMessage: - 'Owner repeated STEP_DONE in active mode without code changes — escalating to user', - logContext: { - taskId, - ownerVerdict, - hasNewChanges, - ownerStepDoneStreak: nextOwnerStepDoneStreak, - emptyStepDoneStreak: nextEmptyStepDoneStreak, - summary: summary?.slice(0, 100), - }, - patch: { - owner_failure_count: 0, - owner_step_done_streak: nextOwnerStepDoneStreak, - empty_step_done_streak: nextEmptyStepDoneStreak, - }, - }); - return; - } - - if (signal.kind === 'request_owner_continue') { - applyPairedTaskPatch({ - taskId, - expectedUpdatedAt: task.updated_at, - updatedAt: now, - patch: { - owner_failure_count: 0, - owner_step_done_streak: nextOwnerStepDoneStreak, - empty_step_done_streak: nextEmptyStepDoneStreak, - }, - }); - logger.info( - { - taskId, - ownerVerdict, - hasNewChanges, - ownerStepDoneStreak: nextOwnerStepDoneStreak, - emptyStepDoneStreak: nextEmptyStepDoneStreak, - summary: summary?.slice(0, 100), - }, - 'Owner marked the current output as an intermediate completed step — keeping task active for follow-up', - ); - return; - } - - if ( - nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0) || - nextEmptyStepDoneStreak !== (task.empty_step_done_streak ?? 0) - ) { + if (nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0)) { applyPairedTaskPatch({ taskId, expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { owner_step_done_streak: nextOwnerStepDoneStreak, - empty_step_done_streak: nextEmptyStepDoneStreak, }, }); } diff --git a/src/paired-execution-context-shared.test.ts b/src/paired-execution-context-shared.test.ts index 3cb197a..c6ceddf 100644 --- a/src/paired-execution-context-shared.test.ts +++ b/src/paired-execution-context-shared.test.ts @@ -27,7 +27,7 @@ describe('paired execution context shared verdict helpers', () => { visibleVerdict: 'step_done', }), ).toEqual({ - kind: 'request_owner_continue', + kind: 'request_reviewer', resetStatusToActive: false, }); expect( @@ -57,7 +57,7 @@ describe('paired execution context shared verdict helpers', () => { deadlockThreshold: 2, }), ).toEqual({ - kind: 'request_owner_continue', + kind: 'request_reviewer', resetStatusToActive: true, }); expect( diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 998e374..1ede0b1 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -9,7 +9,6 @@ import type { PairedTaskStatus } from './types.js'; export type CompletionSignal = | { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_owner_finalize' } - | { kind: 'request_owner_continue'; resetStatusToActive: boolean } | { kind: 'request_owner_changes' } | { kind: 'request_arbiter' } | { kind: 'complete'; completionReason: 'done' | 'escalated' } @@ -38,12 +37,6 @@ export function resolveOwnerCompletionSignal(args: { } if (phase === 'normal') { - if (visibleVerdict === 'step_done') { - return { - kind: 'request_owner_continue', - resetStatusToActive: false, - }; - } return { kind: 'request_reviewer', resetStatusToActive: false, @@ -52,7 +45,7 @@ export function resolveOwnerCompletionSignal(args: { if (visibleVerdict === 'step_done') { return { - kind: 'request_owner_continue', + kind: 'request_reviewer', resetStatusToActive: true, }; } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index c7a6df2..1c4ab42 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -802,13 +802,22 @@ describe('paired execution context', () => { ).not.toHaveBeenCalled(); }); - it('keeps an active task in owner follow-up mode when the owner reports STEP_DONE', () => { + it('requests review when the owner reports STEP_DONE in active mode', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ status: 'active', round_trip_count: 1, }), ); + vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue( + { + ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'), + reviewerWorkspace: buildWorkspace( + 'reviewer', + '/tmp/paired/task-1/reviewer', + ), + }, + ); completePairedExecutionContext({ taskId: 'task-1', @@ -820,22 +829,17 @@ describe('paired execution context', () => { expect(db.updatePairedTask).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ - owner_failure_count: 0, - owner_step_done_streak: 1, + round_trip_count: 2, + owner_step_done_streak: 0, + empty_step_done_streak: 0, }), ); expect( pairedWorkspaceManager.markPairedTaskReviewReady, - ).not.toHaveBeenCalled(); - expect(db.updatePairedTask).not.toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - status: 'active', - }), - ); + ).toHaveBeenCalledWith('task-1'); }); - it('requests arbiter when active STEP_DONE repeats without code changes', () => { + it('requests review instead of arbiter when active STEP_DONE repeats without code changes', () => { vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); const repoDir = createCanonicalRepoWithCommit('active step done loop'); @@ -851,6 +855,15 @@ describe('paired execution context', () => { vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, ); + vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue( + { + ownerWorkspace: buildWorkspace('owner', repoDir), + reviewerWorkspace: buildWorkspace( + 'reviewer', + '/tmp/paired/task-1/reviewer', + ), + }, + ); completePairedExecutionContext({ taskId: 'task-1', @@ -862,23 +875,44 @@ describe('paired execution context', () => { expect(db.updatePairedTask).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ - status: 'arbiter_requested', - owner_step_done_streak: 3, - empty_step_done_streak: 3, + round_trip_count: 1, + owner_step_done_streak: 0, + empty_step_done_streak: 0, }), ); expect( pairedWorkspaceManager.markPairedTaskReviewReady, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledWith('task-1'); + expect(db.updatePairedTask).not.toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + }), + ); }); - it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => { + it('re-triggers review when the owner reports STEP_DONE during finalize', () => { + const repoDir = createCanonicalRepoWithCommit('reviewed'); + const approvedSourceRef = resolveTreeRef(repoDir); vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ status: 'merge_ready', + source_ref: approvedSourceRef, round_trip_count: 1, }), ); + vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => + role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, + ); + vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue( + { + ownerWorkspace: buildWorkspace('owner', repoDir), + reviewerWorkspace: buildWorkspace( + 'reviewer', + '/tmp/paired/task-1/reviewer', + ), + }, + ); completePairedExecutionContext({ taskId: 'task-1', @@ -893,11 +927,20 @@ describe('paired execution context', () => { status: 'active', owner_failure_count: 0, finalize_step_done_count: 1, + empty_step_done_streak: 1, + }), + ); + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + round_trip_count: 2, + finalize_step_done_count: 1, + empty_step_done_streak: 1, }), ); expect( pairedWorkspaceManager.markPairedTaskReviewReady, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledWith('task-1'); }); it('requests arbiter when finalize STEP_DONE repeats without code changes', () => { From 3eee842164e79f9ca2d3ccab591ab1a8e287daa9 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 13:13:11 +0900 Subject: [PATCH 12/48] fix: restore bundled agent CLI resolution - Upgrade EJClaw bundled Codex CLI to 0.124.0 for gpt-5.5 access - Resolve Claude Agent SDK native binary packages via package.json - Prefer glibc Claude binary before musl on Linux and align platform binary names - Add regression coverage for optional package resolution --- runners/agent-runner/src/bundled-cli-path.ts | 21 +++++-- .../test/bundled-cli-path.test.ts | 55 +++++++++++++++++-- runners/codex-runner/bun.lock | 16 +++--- runners/codex-runner/package.json | 2 +- 4 files changed, 74 insertions(+), 20 deletions(-) diff --git a/runners/agent-runner/src/bundled-cli-path.ts b/runners/agent-runner/src/bundled-cli-path.ts index 281bf70..f48a3f1 100644 --- a/runners/agent-runner/src/bundled-cli-path.ts +++ b/runners/agent-runner/src/bundled-cli-path.ts @@ -45,7 +45,7 @@ function platformCandidates( return [ { pkg: `@anthropic-ai/claude-agent-sdk-darwin-${arch}`, - file: arch === 'arm64' ? 'cli' : 'cli', + file: 'claude', }, ]; } @@ -53,7 +53,7 @@ function platformCandidates( return [ { pkg: `@anthropic-ai/claude-agent-sdk-win32-${arch}`, - file: 'cli.exe', + file: 'claude.exe', }, ]; } @@ -127,12 +127,22 @@ export function resolveBundledClaudeCodeExecutable(options?: { * Default package directory resolver. Uses `require.resolve` against this * module's location so it works regardless of whether agent-runner is invoked * from its own node_modules layout or via a parent workspace. + * + * Important: the SDK's optional native-binary packages may not expose a bare + * package entrypoint, so `require.resolve(pkg)` can fail even when the package + * and binary are installed. Resolve `package.json` first, then fall back to the + * bare package only for package layouts that do expose an entrypoint. */ function defaultResolvePackageDir(pkg: string): string | null { + const req = createRequire(import.meta.url); + try { + const pkgJson = req.resolve(`${pkg}/package.json`); + return path.dirname(pkgJson); + } catch { + // Fall through to legacy/bare-entrypoint resolution below. + } + try { - // Resolve the package entrypoint instead of package.json because the SDK's - // exports map does not expose `./package.json`. - const req = createRequire(import.meta.url); const entrypoint = req.resolve(pkg); return path.dirname(entrypoint); } catch { @@ -142,5 +152,6 @@ function defaultResolvePackageDir(pkg: string): string | null { export const __test__ = { platformCandidates, + defaultResolvePackageDir, ENV_OVERRIDE, }; diff --git a/runners/agent-runner/test/bundled-cli-path.test.ts b/runners/agent-runner/test/bundled-cli-path.test.ts index 608fbf7..a68c8b3 100644 --- a/runners/agent-runner/test/bundled-cli-path.test.ts +++ b/runners/agent-runner/test/bundled-cli-path.test.ts @@ -1,8 +1,12 @@ import fs from 'fs'; +import { createRequire } from 'module'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; -import { resolveBundledClaudeCodeExecutable } from '../src/bundled-cli-path.js'; +import { + __test__, + resolveBundledClaudeCodeExecutable, +} from '../src/bundled-cli-path.js'; describe('resolveBundledClaudeCodeExecutable', () => { const origEnv = process.env.EJCLAW_CLAUDE_CLI_PATH; @@ -83,10 +87,10 @@ describe('resolveBundledClaudeCodeExecutable', () => { expect(result).toBe(path.join(muslDir, 'claude')); }); - it('resolves Darwin arm64 binary under `cli`', () => { + it('resolves Darwin arm64 binary under `claude`', () => { const dir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; - const existsSync = (p: string): boolean => p === path.join(dir, 'cli'); + const existsSync = (p: string): boolean => p === path.join(dir, 'claude'); const result = resolveBundledClaudeCodeExecutable({ env: {}, existsSync, @@ -95,13 +99,13 @@ describe('resolveBundledClaudeCodeExecutable', () => { resolvePackageDir: (pkg) => pkg === '@anthropic-ai/claude-agent-sdk-darwin-arm64' ? dir : null, }); - expect(result).toBe(path.join(dir, 'cli')); + expect(result).toBe(path.join(dir, 'claude')); }); - it('resolves Windows binary under `cli.exe`', () => { + it('resolves Windows binary under `claude.exe`', () => { const dir = 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; - const binary = path.join(dir, 'cli.exe'); + const binary = path.join(dir, 'claude.exe'); const existsSync = (p: string): boolean => p === binary; const result = resolveBundledClaudeCodeExecutable({ env: {}, @@ -114,6 +118,17 @@ describe('resolveBundledClaudeCodeExecutable', () => { expect(result).toBe(binary); }); + it('default resolver handles SDK optional packages without a bare entrypoint', () => { + if (process.platform !== 'linux' || process.arch !== 'x64') return; + + const dir = __test__.defaultResolvePackageDir( + '@anthropic-ai/claude-agent-sdk-linux-x64', + ); + if (!dir) return; + + expect(fs.existsSync(path.join(dir, 'claude'))).toBe(true); + }); + it('throws a descriptive error when no binary is found', () => { const existsSync = () => false; expect(() => @@ -127,6 +142,34 @@ describe('resolveBundledClaudeCodeExecutable', () => { ).toThrow(/Unable to locate a bundled Claude Code CLI binary/); }); + it('resolves the linux x64 optional package even when it has no JS entrypoint', () => { + if (process.platform !== 'linux' || process.arch !== 'x64') { + return; + } + + const req = createRequire(import.meta.url); + let packageJsonPath: string; + try { + packageJsonPath = req.resolve( + '@anthropic-ai/claude-agent-sdk-linux-x64/package.json', + ); + } catch { + return; + } + + try { + req.resolve('@anthropic-ai/claude-agent-sdk-linux-x64'); + return; + } catch { + // This is the regression case: package.json resolves, bare package does not. + } + + const result = resolveBundledClaudeCodeExecutable({ env: {} }); + + expect(result).toBe(path.join(path.dirname(packageJsonPath), 'claude')); + expect(fs.existsSync(result)).toBe(true); + }); + it('end-to-end: resolves the actual bundled binary on this host when installed', () => { // Real-world smoke test. Some CI/host sandboxes do not install the optional // per-platform SDK package, so treat "package not resolvable" as a host diff --git a/runners/codex-runner/bun.lock b/runners/codex-runner/bun.lock index f4e3765..efcfdf2 100644 --- a/runners/codex-runner/bun.lock +++ b/runners/codex-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ejclaw-codex-runner", "dependencies": { - "@openai/codex": "^0.120.0", + "@openai/codex": "^0.124.0", "ejclaw-runners-shared": "file:../shared", }, "devDependencies": { @@ -15,19 +15,19 @@ }, }, "packages": { - "@openai/codex": ["@openai/codex@0.120.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.120.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.120.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.120.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.120.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.120.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.120.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-e2P1Gya3dwsRe9IPOiswVz5JfR700u+/sWCqDc3jkqv2QViPkNiBmZoGhFnZL5jBpKakSjehC4/Fpspg70nHTw=="], + "@openai/codex": ["@openai/codex@0.124.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.124.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.124.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.124.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.124.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.124.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.124.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-1EVAuPyAQZ8zIVMw3bPJ6a4R8ifLAZ7LGsOyknj5c2he9AFXVRCmWx12WrdZJ25wcBvOEKt1n1Zx+QAj0EVGbQ=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.120.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7CU+I5kBaMuoqfG3xisq0mUWzxoEHvfu34cB8a0KpBiIhAgu12fKpmYgZ4/DvRP6Wm9Fu6LJYKVF5apUHFp8nQ=="], + "@openai/codex-darwin-arm64": ["@openai/codex@0.124.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lnuqeAdl+RjPWsqVZ8rrPskRIOZmzlyr8e5q/wFVEnMPsm9dWgjRW1PKa84UmDSQVOuz9GObF28DEHFyC4A8NA=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.120.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-d7joNYuwrmd5iIdp/xAE5f8bZT1r82MnmU6Hzgxq3G+xClwEyhxU737ZWnstFSpnZNfxJ5zXCuFUJh4CAkHNtQ=="], + "@openai/codex-darwin-x64": ["@openai/codex@0.124.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Br+VlL83IOu96Urw+mkZ1PQXLsQXGAYEH6kXNt4ULuVt7qAdQY2FKYwIgLLVLl0vpMk8qWxdfdVMqhiu2uCHlg=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.120.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-sVYY25/URlpZPtb0Q0ryLh+lcq9UTEtHAkdZKa0a/R7mAdyPuhpU9V6jWmxwiUh7s53XZOEVFoKmLfH8YIDWCQ=="], + "@openai/codex-linux-arm64": ["@openai/codex@0.124.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXafFVQJxPfU1LSCI5afuHsF5evKAcbQpFuKou0Kl241/HG/zYHdwoWj546zH844gJgegIPAxPf36+RSkUsbbA=="], - "@openai/codex-linux-x64": ["@openai/codex@0.120.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-VcP9B/c/O+EFEgqoetCzvHrLfAdo8vrt09Gx1lJ8ikewctqAuJ/ozj/6wuvlz7XaaK64ib5cge01pOAeCyt2Sg=="], + "@openai/codex-linux-x64": ["@openai/codex@0.124.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-cJ4QfQIrz2gkXVWephiGo9JDyD3qLKhvkTTLk7gI8rKd7MZpVXn9/1e7pZCQnJVA7Dk6ocA5G+sxnR78SoIejw=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.120.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-SAaTQU1XHa1qDnmQldmbyROIY5SiaspF+Cw3ziWeeTgyAET3rWusm4ELOElx6QiY1ugYW5ZD+7AFufS2z1xtpQ=="], + "@openai/codex-win32-arm64": ["@openai/codex@0.124.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDSI/CQh0kagBwWVPG9EiUBfHiY27ju3LPrjTVZg4VMpVJVNA31JTK8rHMZ6G/2gfNmOl6DMBA6+0ICxSkkshg=="], - "@openai/codex-win32-x64": ["@openai/codex@0.120.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-zja1GNrbHyOUTvOy5FVMa+rAYIs3m+FOS8rAXftxMEhodMmkMw2O8zcvso657SHhZR0hIEiZ6T70lcyH2YX0mQ=="], + "@openai/codex-win32-x64": ["@openai/codex@0.124.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-t+lAwmCWwIWQKk6dKaYetRhQsmA+uDmQy1NLka1xAAv3PlNOH8iNz9Lsisr3qYkBR8Tf7tbQlt+pl9tw/A5mfA=="], "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], diff --git a/runners/codex-runner/package.json b/runners/codex-runner/package.json index 0dfb377..66788ca 100644 --- a/runners/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "ejclaw-runners-shared": "file:../shared", - "@openai/codex": "^0.120.0" + "@openai/codex": "^0.124.0" }, "devDependencies": { "@types/node": "^22.10.7", From e03652105467a87fede5b514710a37841368ed25 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 15:02:09 +0900 Subject: [PATCH 13/48] fix: salvage EJClaw runtime hardening - add dist freshness guard to deploy flow - preserve message source provenance for IPC injection - harden Codex usage fallback and workspace package manager detection - document owner branch return protocol --- package.json | 3 +- prompts/owner-common-paired-room.md | 31 +++ scripts/check-dist-fresh.sh | 113 +++++++++ src/codex-token-rotation.test.ts | 55 +++++ src/codex-token-rotation.ts | 81 ++++--- src/codex-usage-collector.test.ts | 242 +++++++++++++++++++ src/codex-usage-collector.ts | 36 ++- src/db-message-source.test.ts | 157 ++++++++++++ src/db/base-schema.ts | 1 + src/db/messages.ts | 33 ++- src/db/migrations/013_message-source-kind.ts | 43 ++++ src/db/migrations/index.ts | 2 + src/index.ts | 49 ++++ src/message-source.test.ts | 43 ++++ src/message-source.ts | 39 +++ src/verification.test.ts | 60 +++++ src/verification.ts | 10 +- src/workspace-package-manager.test.ts | 63 +++++ src/workspace-package-manager.ts | 49 ++++ 19 files changed, 1059 insertions(+), 51 deletions(-) create mode 100755 scripts/check-dist-fresh.sh create mode 100644 src/codex-usage-collector.test.ts create mode 100644 src/db-message-source.test.ts create mode 100644 src/db/migrations/013_message-source-kind.ts create mode 100644 src/message-source.test.ts create mode 100644 src/message-source.ts diff --git a/package.json b/package.json index 9bd4002..1458599 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build", "build:all": "bun run build && bun run build:runners", "build:runtime": "bun run build:all", - "deploy": "git pull --ff-only && bun run build:all && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw", + "verify:dist": "bash scripts/check-dist-fresh.sh", + "deploy": "git pull --ff-only && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw", "start": "bun dist/index.js", "dev": "bun --watch src/index.ts", "test": "vitest run", diff --git a/prompts/owner-common-paired-room.md b/prompts/owner-common-paired-room.md index 50a9365..0a1babd 100644 --- a/prompts/owner-common-paired-room.md +++ b/prompts/owner-common-paired-room.md @@ -41,3 +41,34 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc - Implementation, commits, and pushes require agreement from both sides. Either can veto - Implement directly when it makes sense — you have full implementation authority - Never mention or tag the user (@username) during the owner↔reviewer loop — the system handles escalation automatically. User is only notified when all resolution paths (including arbiter) are exhausted + +## 🔴 Workspace Branch Protocol (MANDATORY) + +The owner workspace is managed by EJClaw's paired-room state machine. The workspace branch name MUST be `codex/owner/` at the moment your turn ends. If any other branch is checked out when the next message arrives, the entire room goes BLOCKED with "branch mismatch" and needs manual git recovery. + +### Every turn, in order + +1. **Start**: verify `git branch --show-current`. If it is not `codex/owner/`, check it out before doing anything else. +2. **Work**: feel free to create feature branches (`fix/...`, `feat/...`) while implementing. +3. **Before you finish the turn**: + - If you committed on a feature branch, merge it back into the owner branch (`git checkout codex/owner/ && git merge --ff-only `), then optionally delete the feature branch. + - If the feature branch is behind or diverged, use the appropriate merge/rebase to land the work on the owner branch. + - Confirm a clean end-state: `git branch --show-current` prints `codex/owner/` and `git status --short` is empty (or at least there is no merge conflict and no stray dirty state that the next turn cannot understand). +4. **Only then** emit your DONE / DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT line and exit. + +### Hard rules + +- Never end a turn on a `fix/*` or `feat/*` branch. +- Never end a turn with unresolved merge conflicts. +- Never leave the workspace in detached-HEAD or rebase-in-progress state at turn end. + +### If you discover a mismatch at turn start + +You landed on a feature branch because a previous turn forgot to return. Recover and continue: + +1. `git status` — inspect dirty state. +2. If clean and the feature branch is ahead of the owner branch with a linear history: `git checkout codex/owner/ && git merge --ff-only `. +3. If there is dirty state you actually want to keep: commit it with a meaningful message or `git stash push -u -m ""`, then switch branches and re-apply. +4. If the feature branch has diverged in a way that is not fast-forwardable: branch it off explicitly (`backup/-recover-`), return to the owner branch, then merge/cherry-pick the needed commits. + +The group folder matches the EJClaw paired-room workspace directory name (for example `eyejokerdb-9`, `ejset`, `brain`). Use `basename $(pwd | sed 's|/owner$||')` if in doubt. diff --git a/scripts/check-dist-fresh.sh b/scripts/check-dist-fresh.sh new file mode 100755 index 0000000..b8ab0a9 --- /dev/null +++ b/scripts/check-dist-fresh.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# check-dist-fresh.sh — verify compiled dist/ outputs are at least as new as their +# corresponding src/ .ts sources. Use this as a restart-precondition / CI guard so +# we never ship stale compiled artifacts again (see 2026-04-23 reviewer infinite-loop +# incident caused by a patched src/bundled-cli-path.ts with a stale dist/). +# +# Usage: +# scripts/check-dist-fresh.sh # checks default packages (root + agent-runner) +# scripts/check-dist-fresh.sh DIR [DIR ...] # each DIR must contain src/ and dist/ +# +# Exit codes: +# 0 — all dist/ files up-to-date +# 1 — at least one dist/ file missing or older than its src/ sibling +# 2 — usage / environment error +# +# Rules per package: +# - For every foo.ts under src/ (excluding *.test.ts, *.d.ts, __tests__/*): +# require dist/foo.js to exist AND have mtime >= src/foo.ts +# - Missing dist/foo.js is a hard error (build never ran for that file). +# +set -euo pipefail + +# Resolve repo root from this script's location so the script works from any cwd. +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ "$#" -gt 0 ]; then + PACKAGES=("$@") +else + PACKAGES=( + "${REPO_ROOT}" + "${REPO_ROOT}/runners/agent-runner" + "${REPO_ROOT}/runners/codex-runner" + "${REPO_ROOT}/runners/shared" + ) +fi + +fail=0 +checked=0 + +# Use mtime in nanoseconds (GNU stat) for precision. Fallback to seconds if %Y only. +_mtime() { + # $1: path + local t + t="$(stat -c '%Y' "$1" 2>/dev/null || true)" + if [ -z "$t" ]; then + # BSD stat fallback (macOS) + t="$(stat -f '%m' "$1" 2>/dev/null || true)" + fi + echo "${t:-0}" +} + +check_package() { + local pkg="$1" + local src_dir="${pkg}/src" + local dist_dir="${pkg}/dist" + + if [ ! -d "$src_dir" ]; then + echo "skip: ${pkg} (no src/)" >&2 + return 0 + fi + if [ ! -d "$dist_dir" ]; then + echo "FAIL: ${pkg} — dist/ does not exist (run build)" >&2 + fail=1 + return 0 + fi + + local ts dist_file src_mtime dist_mtime + # Loop over every .ts file under src/, skipping tests & type declarations. + while IFS= read -r -d '' ts; do + local rel="${ts#${src_dir}/}" + dist_file="${dist_dir}/${rel%.ts}.js" + checked=$((checked + 1)) + + if [ ! -f "$dist_file" ]; then + echo "FAIL: missing dist file: ${dist_file} (src: ${ts})" >&2 + fail=1 + continue + fi + + src_mtime="$(_mtime "$ts")" + dist_mtime="$(_mtime "$dist_file")" + + if [ "$dist_mtime" -lt "$src_mtime" ]; then + echo "FAIL: stale dist: ${dist_file}" >&2 + echo " src mtime=${src_mtime} ($(date -d "@${src_mtime}" '+%F %T' 2>/dev/null || true)) ${ts}" >&2 + echo " dist mtime=${dist_mtime} ($(date -d "@${dist_mtime}" '+%F %T' 2>/dev/null || true))" >&2 + fail=1 + fi + done < <(find "$src_dir" -type f -name '*.ts' \ + ! -name '*.test.ts' \ + ! -name '*.d.ts' \ + ! -path '*/__tests__/*' \ + -print0) +} + +for pkg in "${PACKAGES[@]}"; do + if [ ! -d "$pkg" ]; then + echo "skip: ${pkg} (directory not found)" >&2 + continue + fi + check_package "$pkg" +done + +if [ "$fail" -ne 0 ]; then + echo "" >&2 + echo "dist freshness check FAILED (checked ${checked} source files)" >&2 + echo "Run: bun run build:all (or the package's build script)" >&2 + exit 1 +fi + +echo "dist freshness OK (checked ${checked} source files across ${#PACKAGES[@]} package(s))" +exit 0 diff --git a/src/codex-token-rotation.test.ts b/src/codex-token-rotation.test.ts index 8762a33..85dc5f6 100644 --- a/src/codex-token-rotation.test.ts +++ b/src/codex-token-rotation.test.ts @@ -56,6 +56,19 @@ function createFakeAccounts(homeDir: string, count: number): void { } } +function createDefaultCodexAuth(homeDir: string): string { + const authPath = path.join(homeDir, '.codex', 'auth.json'); + fs.mkdirSync(path.dirname(authPath), { recursive: true }); + fs.writeFileSync( + authPath, + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { account_id: 'default-acct', access_token: 'default-token' }, + }), + ); + return authPath; +} + describe('codex-token-rotation d7 ≥ 100% auto-skip', () => { let tempHome: string; @@ -142,4 +155,46 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => { 'Failed to persist Codex rotation state', ); }); + + it('does not append ~/.codex/auth.json fallback when numbered accounts exist', async () => { + const fallbackAuthPath = createDefaultCodexAuth(tempHome); + + const mod = await import('./codex-token-rotation.js'); + mod.initCodexTokenRotation(); + + expect(mod.getCodexAccountCount()).toBe(4); + expect(mod.getActiveCodexAuthPath()).not.toBe(fallbackAuthPath); + }); +}); + +describe('codex-token-rotation single-account fallback', () => { + let tempHome: string; + + beforeEach(() => { + vi.resetModules(); + tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-fallback-')); + process.env.CODEX_ROT_TEST_HOME = tempHome; + }); + + afterEach(() => { + delete process.env.CODEX_ROT_TEST_HOME; + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('uses ~/.codex/auth.json fallback when ~/.codex-accounts is absent', async () => { + const fallbackAuthPath = createDefaultCodexAuth(tempHome); + + const mod = await import('./codex-token-rotation.js'); + mod.initCodexTokenRotation(); + + expect(mod.getCodexAccountCount()).toBe(1); + expect(mod.getActiveCodexAuthPath()).toBe(fallbackAuthPath); + expect(mod.getAllCodexAccounts()).toEqual([ + expect.objectContaining({ + index: 0, + accountId: 'default-acct', + isActive: true, + }), + ]); + }); }); diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index 3c1c039..bb7b03c 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -78,46 +78,63 @@ let currentIndex = 0; let initialized = false; const ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts'); +const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex'); +const DEFAULT_AUTH_PATH = path.join(DEFAULT_CODEX_DIR, 'auth.json'); + +function loadCodexAccount( + authPath: string, + fallbackAccountId: string, +): boolean { + const data = readJsonFile<{ + tokens?: { account_id?: string; id_token?: string }; + }>(authPath); + if (!data) { + logger.warn({ authPath }, 'Failed to parse codex account auth.json'); + return false; + } + + const accountId = data?.tokens?.account_id || fallbackAccountId; + const jwt = parseJwtAuth(data?.tokens?.id_token || ''); + accounts.push({ + index: accounts.length, + authPath, + accountId, + planType: jwt.planType, + subscriptionUntil: jwt.expiresAt, + rateLimitedUntil: null, + }); + return true; +} export function initCodexTokenRotation(): void { if (initialized) return; initialized = true; - if (!fs.existsSync(ACCOUNTS_DIR)) { - logger.info( - { dir: ACCOUNTS_DIR }, - 'Codex accounts dir not found, skipping', - ); - return; - } + const hasAccountsDir = fs.existsSync(ACCOUNTS_DIR); + const dirs = hasAccountsDir + ? fs + .readdirSync(ACCOUNTS_DIR) + .filter((d) => /^\d+$/.test(d)) + .sort((a, b) => parseInt(a) - parseInt(b)) + : []; - const dirs = fs - .readdirSync(ACCOUNTS_DIR) - .filter((d) => /^\d+$/.test(d)) - .sort((a, b) => parseInt(a) - parseInt(b)); + if (!hasAccountsDir) { + logger.info({ dir: ACCOUNTS_DIR }, 'Codex accounts dir not found'); + } for (const dir of dirs) { const authPath = path.join(ACCOUNTS_DIR, dir, 'auth.json'); if (!fs.existsSync(authPath)) continue; + loadCodexAccount(authPath, `account-${dir}`); + } - const data = readJsonFile<{ - tokens?: { account_id?: string; id_token?: string }; - }>(authPath); - if (!data) { - logger.warn({ authPath }, 'Failed to parse codex account auth.json'); - continue; + if (dirs.length === 0 && fs.existsSync(DEFAULT_AUTH_PATH)) { + if (loadCodexAccount(DEFAULT_AUTH_PATH, 'default-account')) { + logger.info( + { authPath: DEFAULT_AUTH_PATH }, + 'Codex accounts dir absent/empty; using ~/.codex/auth.json fallback', + ); } - const accountId = data?.tokens?.account_id || `account-${dir}`; - const jwt = parseJwtAuth(data?.tokens?.id_token || ''); - const planType = jwt.planType; - accounts.push({ - index: accounts.length, - authPath, - accountId, - planType, - subscriptionUntil: jwt.expiresAt, - rateLimitedUntil: null, - }); } if (accounts.length > 1) loadCodexState(); @@ -236,8 +253,14 @@ export function reloadCodexStateFromDisk(): void { /** Get the auth.json path for the current active account. */ export function getActiveCodexAuthPath(): string | null { + return getCodexAuthPath(); +} + +export function getCodexAuthPath( + accountIndex: number = currentIndex, +): string | null { if (accounts.length === 0) return null; - return accounts[currentIndex]?.authPath ?? null; + return accounts[accountIndex]?.authPath ?? null; } export function detectCodexRotationTrigger( diff --git a/src/codex-usage-collector.test.ts b/src/codex-usage-collector.test.ts new file mode 100644 index 0000000..90106dd --- /dev/null +++ b/src/codex-usage-collector.test.ts @@ -0,0 +1,242 @@ +import { EventEmitter } from 'events'; +import fs from 'fs'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/ejclaw-codex-usage-data', +})); + +vi.mock('./utils.js', async () => { + const actual = + await vi.importActual('./utils.js'); + return { + ...actual, + writeJsonFile: vi.fn(), + }; +}); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + default: { + ...actual, + homedir: () => process.env.CODEX_USAGE_TEST_HOME || '/tmp', + }, + homedir: () => process.env.CODEX_USAGE_TEST_HOME || '/tmp', + }; +}); + +function createDefaultCodexAuth(homeDir: string): string { + const authPath = path.join(homeDir, '.codex', 'auth.json'); + fs.mkdirSync(path.dirname(authPath), { recursive: true }); + fs.writeFileSync( + authPath, + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { account_id: 'default-acct', access_token: 'default-token' }, + }), + ); + return authPath; +} + +function createFakeChildProcess(rateLimitsByLimitId: Record) { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stdin: { write: ReturnType }; + kill: ReturnType; + }; + const stdout = new EventEmitter(); + proc.stdout = stdout; + proc.stdin = { + write: vi.fn((payload: string) => { + const message = JSON.parse(payload.trim()) as { + id: number; + method?: string; + }; + if (message.id === 1) { + setImmediate(() => { + stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ id: 1, result: {} })}\n`), + ); + }); + } + if (message.id === 2 && message.method === 'account/rateLimits/read') { + setImmediate(() => { + stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + id: 2, + result: { rateLimitsByLimitId }, + })}\n`, + ), + ); + }); + } + return true; + }), + }; + proc.kill = vi.fn(); + return proc; +} + +describe('codex-usage-collector fallback account usage', () => { + let tempHome: string; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-usage-')); + process.env.CODEX_USAGE_TEST_HOME = tempHome; + }); + + afterEach(() => { + delete process.env.CODEX_USAGE_TEST_HOME; + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('refreshes usage via ~/.codex/auth.json when ~/.codex-accounts is missing', async () => { + createDefaultCodexAuth(tempHome); + const childProcess = await import('child_process'); + const fallbackCodexHome = path.join(tempHome, '.codex'); + let capturedCodexHome: string | undefined; + + vi.mocked(childProcess.spawn).mockImplementation((( + _cmd: string, + _args: readonly string[] | undefined, + opts?: { env?: Record }, + ) => { + capturedCodexHome = opts?.env?.CODEX_HOME; + return createFakeChildProcess({ + codex: { + limitName: 'Codex', + primary: { + usedPercent: 12.4, + resetsAt: new Date(Date.now() + 3_600_000).toISOString(), + }, + secondary: { + usedPercent: 67.6, + resetsAt: new Date(Date.now() + 86_400_000).toISOString(), + }, + }, + }) as never; + }) as unknown as typeof childProcess.spawn); + + const rotation = await import('./codex-token-rotation.js'); + const usage = await import('./codex-usage-collector.js'); + + rotation.initCodexTokenRotation(); + const result = await usage.refreshActiveCodexUsage(); + + expect(rotation.getCodexAccountCount()).toBe(1); + expect(capturedCodexHome).toBe(fallbackCodexHome); + expect(result.fetchedAt).toEqual(expect.any(String)); + expect(result.rows).toEqual([ + expect.objectContaining({ + name: 'Codex', + h5pct: 12, + d7pct: 68, + }), + ]); + }); + + it('finds codex via ~/.hermes/node/bin when running under bun', async () => { + createDefaultCodexAuth(tempHome); + const childProcess = await import('child_process'); + const bunExecPath = path.join(tempHome, '.bun', 'bin', 'bun'); + const hermesBin = path.join(tempHome, '.hermes', 'node', 'bin'); + const originalExecPath = process.execPath; + const originalPath = process.env.PATH; + + fs.mkdirSync(hermesBin, { recursive: true }); + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + Object.defineProperty(process, 'execPath', { + configurable: true, + writable: true, + value: bunExecPath, + }); + + vi.mocked(childProcess.spawn).mockImplementation((( + cmd: string, + _args: readonly string[] | undefined, + opts?: { env?: Record }, + ) => { + const spawnPathEntries = (opts?.env?.PATH || '').split(path.delimiter); + expect(cmd).toBe('codex'); + expect(spawnPathEntries).toContain(path.dirname(bunExecPath)); + expect(spawnPathEntries).toContain(hermesBin); + + if (!spawnPathEntries.includes(hermesBin)) { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stdin: { write: ReturnType }; + kill: ReturnType; + }; + proc.stdout = new EventEmitter(); + proc.stdin = { write: vi.fn() }; + proc.kill = vi.fn(); + setImmediate(() => proc.emit('error', new Error('spawn codex ENOENT'))); + return proc as never; + } + + return createFakeChildProcess({ + codex: { + limitName: 'Codex', + primary: { + usedPercent: 34.2, + resetsAt: new Date(Date.now() + 7_200_000).toISOString(), + }, + secondary: { + usedPercent: 56.1, + resetsAt: new Date(Date.now() + 172_800_000).toISOString(), + }, + }, + }) as never; + }) as unknown as typeof childProcess.spawn); + + try { + const rotation = await import('./codex-token-rotation.js'); + const usage = await import('./codex-usage-collector.js'); + + rotation.initCodexTokenRotation(); + const result = await usage.refreshActiveCodexUsage(); + + expect(result.fetchedAt).toEqual(expect.any(String)); + expect(result.rows).toEqual([ + expect.objectContaining({ + name: 'Codex', + h5pct: 34, + d7pct: 56, + }), + ]); + } finally { + Object.defineProperty(process, 'execPath', { + configurable: true, + writable: true, + value: originalExecPath, + }); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + }); +}); diff --git a/src/codex-usage-collector.ts b/src/codex-usage-collector.ts index 7cd2c45..2a59ecb 100644 --- a/src/codex-usage-collector.ts +++ b/src/codex-usage-collector.ts @@ -5,6 +5,7 @@ import path from 'path'; import { getAllCodexAccounts, + getCodexAuthPath, updateCodexAccountUsage, } from './codex-token-rotation.js'; import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js'; @@ -27,11 +28,26 @@ export interface CodexUsageRefreshResult { fetchedAt: string | null; } -const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts'); - /** Full scan interval — exported so the orchestrator can schedule it. */ export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour +function getPreferredCodexPathEntries(): string[] { + const entries = [ + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + ]; + if (process.versions.bun || path.basename(process.execPath) === 'bun') { + entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin')); + } + return [...new Set(entries)]; +} + +function getCodexHomeForAccount(accountIndex?: number): string | null { + const authPath = getCodexAuthPath(accountIndex); + if (!authPath || !fs.existsSync(authPath)) return null; + return path.dirname(authPath); +} + export async function fetchCodexUsage( codexHomeOverride?: string, ): Promise { @@ -59,11 +75,9 @@ export async function fetchCodexUsage( const spawnEnv: Record = { ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), + PATH: [...getPreferredCodexPathEntries(), process.env.PATH || ''] + .filter(Boolean) + .join(path.delimiter), }; if (codexHomeOverride) { spawnEnv.CODEX_HOME = codexHomeOverride; @@ -243,8 +257,8 @@ export async function refreshAllCodexAccountUsage(): Promise { + let tempDir: string | null = null; + + beforeEach(() => { + _initTestDatabase(); + }); + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('persists explicit message source kind through store and read paths', () => { + storeChatMetadata('room@g.us', '2026-04-24T00:00:00.000Z', 'Room'); + storeMessage({ + id: 'ipc-1', + chat_jid: 'room@g.us', + sender: 'hermes', + sender_name: 'Hermes', + content: 'wake up', + timestamp: '2026-04-24T00:00:01.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + }); + + const [recent] = getRecentChatMessages('room@g.us', 1); + expect(recent).toMatchObject({ + id: 'ipc-1', + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + }); + + storeMessage({ + id: 'ipc-1', + chat_jid: 'room@g.us', + sender: 'hermes', + sender_name: 'Hermes', + content: 'updated', + timestamp: '2026-04-24T00:00:02.000Z', + is_from_me: false, + is_bot_message: true, + message_source_kind: 'ipc_injected_bot', + }); + + const [updated] = getMessagesSinceSeq('room@g.us', 0, 'EJClaw', 10); + expect(updated).toMatchObject({ + id: 'ipc-1', + content: 'updated', + is_bot_message: true, + message_source_kind: 'ipc_injected_bot', + }); + }); + + it('defaults missing message source kind from is_bot_message', () => { + storeChatMetadata('room@g.us', '2026-04-24T00:00:00.000Z', 'Room'); + storeMessage({ + id: 'human-1', + chat_jid: 'room@g.us', + sender: 'user', + sender_name: 'User', + content: 'hello', + timestamp: '2026-04-24T00:00:01.000Z', + is_from_me: false, + is_bot_message: false, + }); + storeMessage({ + id: 'bot-1', + chat_jid: 'room@g.us', + sender: 'bot', + sender_name: 'Bot', + content: 'done', + timestamp: '2026-04-24T00:00:02.000Z', + is_from_me: false, + is_bot_message: true, + }); + + expect(getRecentChatMessages('room@g.us', 2)).toEqual([ + expect.objectContaining({ id: 'human-1', message_source_kind: 'human' }), + expect.objectContaining({ id: 'bot-1', message_source_kind: 'bot' }), + ]); + }); + + it('migrates legacy message tables and backfills source kind', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-msg-source-')); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE chats ( + jid TEXT PRIMARY KEY, + name TEXT, + last_message_time TEXT, + channel TEXT, + is_group INTEGER DEFAULT 0 + ); + CREATE TABLE messages ( + id TEXT, + chat_jid TEXT, + sender TEXT, + sender_name TEXT, + content TEXT, + timestamp TEXT, + seq INTEGER, + is_from_me INTEGER, + is_bot_message INTEGER DEFAULT 0, + PRIMARY KEY (id, chat_jid) + ); + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO chats (jid, name, last_message_time, channel, is_group) + VALUES ('room@g.us', 'Room', '2026-04-24T00:00:02.000Z', 'discord', 1); + INSERT INTO messages ( + id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message + ) VALUES + ('human-legacy', 'room@g.us', 'user', 'User', 'hello', '2026-04-24T00:00:01.000Z', 1, 0, 0), + ('bot-legacy', 'room@g.us', 'bot', 'Bot', 'done', '2026-04-24T00:00:02.000Z', 2, 0, 1); + `); + const insertMigration = legacyDb.prepare( + 'INSERT INTO schema_migrations (version, name) VALUES (?, ?)', + ); + for (let version = 1; version <= 12; version += 1) { + insertMigration.run(version, `legacy-${version}`); + } + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getRecentChatMessages('room@g.us', 2)).toEqual([ + expect.objectContaining({ + id: 'human-legacy', + message_source_kind: 'human', + }), + expect.objectContaining({ id: 'bot-legacy', message_source_kind: 'bot' }), + ]); + }); +}); diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index d44e693..0ded6e5 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -19,6 +19,7 @@ export function applyBaseSchema(database: Database): void { seq INTEGER, is_from_me INTEGER, is_bot_message INTEGER DEFAULT 0, + message_source_kind TEXT NOT NULL DEFAULT 'human', PRIMARY KEY (id, chat_jid), FOREIGN KEY (chat_jid) REFERENCES chats(jid) ); diff --git a/src/db/messages.ts b/src/db/messages.ts index 5bf65c1..6a2df5c 100644 --- a/src/db/messages.ts +++ b/src/db/messages.ts @@ -1,5 +1,9 @@ import { Database } from 'bun:sqlite'; +import { + inferMessageSourceKindFromBotFlag, + normalizeMessageSourceKind, +} from '../message-source.js'; import { NewMessage } from '../types.js'; export interface ChatInfo { @@ -14,12 +18,18 @@ function normalizeMessageRow( row: NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number; + message_source_kind?: unknown; }, ): NewMessage { + const isBotMessage = !!row.is_bot_message; return { ...row, is_from_me: !!row.is_from_me, - is_bot_message: !!row.is_bot_message, + is_bot_message: isBotMessage, + message_source_kind: normalizeMessageSourceKind( + row.message_source_kind, + inferMessageSourceKindFromBotFlag(isBotMessage), + ), }; } @@ -106,15 +116,16 @@ export function storeMessageInDatabase( database .prepare( `INSERT INTO messages ( - id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id, chat_jid) DO UPDATE SET sender = excluded.sender, sender_name = excluded.sender_name, content = excluded.content, timestamp = excluded.timestamp, is_from_me = excluded.is_from_me, - is_bot_message = excluded.is_bot_message`, + is_bot_message = excluded.is_bot_message, + message_source_kind = excluded.message_source_kind`, ) .run( msg.id, @@ -126,6 +137,10 @@ export function storeMessageInDatabase( seq, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0, + normalizeMessageSourceKind( + msg.message_source_kind, + inferMessageSourceKindFromBotFlag(msg.is_bot_message), + ), ); })(); } @@ -142,7 +157,7 @@ export function getNewMessagesFromDatabase( const placeholders = jids.map(() => '?').join(','); const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND content NOT LIKE ? @@ -178,7 +193,7 @@ export function getMessagesSinceFromDatabase( ): NewMessage[] { const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind FROM messages WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ? @@ -238,7 +253,7 @@ export function getNewMessagesBySeqFromDatabase( const placeholders = jids.map(() => '?').join(','); const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message + SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind FROM messages WHERE seq > ? AND chat_jid IN (${placeholders}) AND content NOT LIKE ? @@ -273,7 +288,7 @@ export function getMessagesSinceSeqFromDatabase( ): NewMessage[] { const sinceSeq = normalizeSeqCursor(sinceSeqCursor); const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message + SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind FROM messages WHERE chat_jid = ? AND seq > ? AND content NOT LIKE ? @@ -300,7 +315,7 @@ export function getRecentChatMessagesFromDatabase( ): NewMessage[] { const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind FROM messages WHERE chat_jid = ? AND content != '' AND content IS NOT NULL diff --git a/src/db/migrations/013_message-source-kind.ts b/src/db/migrations/013_message-source-kind.ts new file mode 100644 index 0000000..bdb358e --- /dev/null +++ b/src/db/migrations/013_message-source-kind.ts @@ -0,0 +1,43 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const MESSAGE_SOURCE_KIND_MIGRATION: SchemaMigrationDefinition = { + version: 13, + name: 'message_source_kind', + apply(database: Database) { + const addedColumn = !tableHasColumn( + database, + 'messages', + 'message_source_kind', + ); + if (addedColumn) { + database.exec(` + ALTER TABLE messages + ADD COLUMN message_source_kind TEXT NOT NULL DEFAULT 'human' + `); + } + + const invalidOnlyWhere = ` + WHERE message_source_kind IS NULL + OR message_source_kind = '' + OR message_source_kind NOT IN ( + 'human', + 'bot', + 'trusted_external_bot', + 'ipc_injected_human', + 'ipc_injected_bot' + ) + `; + + database.exec(` + UPDATE messages + SET message_source_kind = CASE + WHEN is_bot_message = 1 THEN 'bot' + ELSE 'human' + END + ${addedColumn ? '' : invalidOnlyWhere} + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d6b5e83..af0f435 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -12,6 +12,7 @@ import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired- import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js'; import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js'; +import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -32,6 +33,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION, OWNER_FAILURE_COUNT_MIGRATION, PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION, + MESSAGE_SOURCE_KIND_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/index.ts b/src/index.ts index e85ab35..16831a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,10 @@ import { hasAvailableClaudeToken, initTokenRotation, } from './token-rotation.js'; +import { + isBotMessageSourceKind, + resolveInjectedMessageSourceKind, +} from './message-source.js'; import { parseVisibleVerdict } from './paired-execution-context-shared.js'; export function isTerminalStatusMessage(text: string): boolean { @@ -412,6 +416,51 @@ async function main(): Promise { queue.noteDirectTerminalDelivery(jid, senderRole, text); } }, + injectInboundMessage: async (payload) => { + const jid = payload.chatJid; + const binding = runtimeState.getRoomBindings()[jid]; + if (!binding) { + logger.warn( + { chatJid: jid, sender: payload.sender ?? null }, + 'inject_inbound_message: no room binding, dropping', + ); + return; + } + const ts = payload.timestamp || new Date().toISOString(); + const msgId = + payload.messageId || + `ipc-inject-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const treatAsHuman = payload.treatAsHuman === true; + const messageSourceKind = resolveInjectedMessageSourceKind({ + treatAsHuman, + sourceKind: payload.sourceKind, + }); + storeChatMetadata(jid, ts, binding.name, 'discord', true); + storeMessage({ + id: msgId, + chat_jid: jid, + sender: payload.sender || 'ipc-inject', + sender_name: payload.senderName || payload.sender || 'IPC Inject', + content: payload.text, + timestamp: ts, + is_from_me: false, + is_bot_message: isBotMessageSourceKind(messageSourceKind), + message_source_kind: messageSourceKind, + }); + queue.enqueueMessageCheck(jid, resolveGroupIpcPath(binding.folder)); + logger.info( + { + chatJid: jid, + sender: payload.sender ?? null, + senderName: payload.senderName ?? null, + treatAsHuman, + messageSourceKind, + messageId: msgId, + groupFolder: binding.folder, + }, + 'Injected inbound message via IPC', + ); + }, nudgeScheduler: nudgeSchedulerLoop, roomBindings: runtimeState.getRoomBindings, assignRoom: runtimeState.assignRoomForIpc, diff --git a/src/message-source.test.ts b/src/message-source.test.ts new file mode 100644 index 0000000..d0aa110 --- /dev/null +++ b/src/message-source.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + isBotMessageSourceKind, + normalizeMessageSourceKind, + resolveInjectedMessageSourceKind, +} from './message-source.js'; + +describe('message source helpers', () => { + it('defaults IPC injected messages to trusted human-equivalent provenance', () => { + expect(resolveInjectedMessageSourceKind({ treatAsHuman: true })).toBe( + 'trusted_external_bot', + ); + expect( + isBotMessageSourceKind( + resolveInjectedMessageSourceKind({ treatAsHuman: true }), + ), + ).toBe(false); + }); + + it('defaults non-human IPC injected messages to bot-equivalent provenance', () => { + expect(resolveInjectedMessageSourceKind({ treatAsHuman: false })).toBe( + 'ipc_injected_bot', + ); + expect( + isBotMessageSourceKind( + resolveInjectedMessageSourceKind({ treatAsHuman: false }), + ), + ).toBe(true); + }); + + it('honors valid explicit source kinds and normalizes invalid values', () => { + expect( + resolveInjectedMessageSourceKind({ + treatAsHuman: true, + sourceKind: 'ipc_injected_human', + }), + ).toBe('ipc_injected_human'); + + expect(normalizeMessageSourceKind('bot', 'human')).toBe('bot'); + expect(normalizeMessageSourceKind('not-real', 'human')).toBe('human'); + }); +}); diff --git a/src/message-source.ts b/src/message-source.ts new file mode 100644 index 0000000..2e36acb --- /dev/null +++ b/src/message-source.ts @@ -0,0 +1,39 @@ +import type { MessageSourceKind } from './types.js'; + +const MESSAGE_SOURCE_KINDS = new Set([ + 'human', + 'bot', + 'trusted_external_bot', + 'ipc_injected_human', + 'ipc_injected_bot', +]); + +export function normalizeMessageSourceKind( + value: unknown, + fallback: MessageSourceKind = 'human', +): MessageSourceKind { + return typeof value === 'string' && + MESSAGE_SOURCE_KINDS.has(value as MessageSourceKind) + ? (value as MessageSourceKind) + : fallback; +} + +export function isBotMessageSourceKind(kind: MessageSourceKind): boolean { + return kind === 'bot' || kind === 'ipc_injected_bot'; +} + +export function inferMessageSourceKindFromBotFlag( + isBotMessage: boolean | number | null | undefined, +): MessageSourceKind { + return isBotMessage ? 'bot' : 'human'; +} + +export function resolveInjectedMessageSourceKind(args: { + treatAsHuman: boolean; + sourceKind?: unknown; +}): MessageSourceKind { + return normalizeMessageSourceKind( + args.sourceKind, + args.treatAsHuman ? 'trusted_external_bot' : 'ipc_injected_bot', + ); +} diff --git a/src/verification.test.ts b/src/verification.test.ts index de8fb61..3dc0466 100644 --- a/src/verification.test.ts +++ b/src/verification.test.ts @@ -106,6 +106,66 @@ describe('verification helpers', () => { }); }); + it('verifies nested pnpm workspaces under a bun parent without tripping corepack project specs', async () => { + const parentDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-corepack-parent-'), + ); + fs.writeFileSync( + path.join(parentDir, 'package.json'), + JSON.stringify({ packageManager: 'bun@1.3.11' }), + ); + + const repoDir = path.join(parentDir, 'owner'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'nested-pnpm-workspace', + scripts: { + typecheck: + 'node -e "process.stdout.write(process.env.COREPACK_ENABLE_PROJECT_SPEC || \'missing\')"', + }, + }), + ); + fs.writeFileSync( + path.join(repoDir, 'pnpm-lock.yaml'), + [ + "lockfileVersion: '6.0'", + 'settings:', + ' autoInstallPeers: true', + ' excludeLinksFromLockfile: false', + 'importers:', + ' .: {}', + '', + ].join('\n'), + ); + fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(repoDir, 'node_modules', '.bin', 'placeholder'), + '', + ); + + expect(hasInstalledNodeModules(repoDir)).toBe(false); + + const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId; + const result = await runVerificationRequest( + { + requestId: 'req-corepack-parent-pnpm', + profile: 'typecheck', + expectedSnapshotId, + }, + { repoDir }, + ); + + expect(result.ok).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.command).toBe('corepack pnpm run typecheck'); + expect(result.stdout).toContain('0'); + expect(result.snapshotId).toBe(expectedSnapshotId); + }); + it('computes a stable snapshot over the readable workspace inputs', () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-')); fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); diff --git a/src/verification.ts b/src/verification.ts index 4aacc2a..e62c663 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -5,9 +5,11 @@ import path from 'path'; import { TIMEZONE } from './config.js'; import { + buildWorkspaceCommandEnvironment, buildWorkspaceScriptCommand, ensureWorkspaceDependenciesInstalled, hasInstalledNodeModules, + type WorkspacePackageManager, } from './workspace-package-manager.js'; import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js'; @@ -44,6 +46,7 @@ export interface VerificationSnapshot { } interface VerificationCommandSpec { + packageManager: WorkspacePackageManager; file: string; args: string[]; commandText: string; @@ -111,6 +114,7 @@ export function buildVerificationCommand( : 'lint'; const command = buildWorkspaceScriptCommand(repoDir, scriptName); return { + packageManager: command.packageManager, file: command.file, args: command.args, commandText: command.commandText, @@ -334,7 +338,11 @@ export async function runVerificationRequest( command.args, { cwd: repoDir, - env: directExecution.env, + env: buildWorkspaceCommandEnvironment( + repoDir, + command.packageManager, + directExecution.env, + ), }, ); const afterSnapshot = computeVerificationSnapshot(repoDir); diff --git a/src/workspace-package-manager.test.ts b/src/workspace-package-manager.test.ts index 70e3dd4..0989542 100644 --- a/src/workspace-package-manager.test.ts +++ b/src/workspace-package-manager.test.ts @@ -13,6 +13,7 @@ vi.mock('child_process', () => ({ })); import { + buildWorkspaceCommandEnvironment, buildWorkspaceScriptCommand, detectWorkspacePackageManager, ensureWorkspaceDependenciesInstalled, @@ -139,6 +140,13 @@ describe('workspace package manager helpers', () => { packageManager: 'pnpm', commandText: 'corepack pnpm install --frozen-lockfile', }); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'corepack', + ['pnpm', 'install', '--frozen-lockfile'], + expect.objectContaining({ + cwd: repoDir, + }), + ); expect(hasInstalledNodeModules(repoDir)).toBe(true); execFileSyncMock.mockClear(); @@ -209,4 +217,59 @@ describe('workspace package manager helpers', () => { expect(hasInstalledNodeModules(repoDir)).toBe(true); expect(execFileSyncMock).not.toHaveBeenCalled(); }); + + it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => { + const parentDir = path.join(tempRoot, 'parent'); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync( + path.join(parentDir, 'package.json'), + JSON.stringify({ packageManager: 'bun@1.3.11' }), + ); + + const repoDir = path.join(parentDir, 'child'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ name: 'child', scripts: { typecheck: 'tsc --noEmit' } }), + ); + fs.writeFileSync( + path.join(repoDir, 'pnpm-lock.yaml'), + 'lockfileVersion: 9.0\n', + ); + + expect( + buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }), + ).toMatchObject({ + PATH: '/tmp/bin', + COREPACK_ENABLE_PROJECT_SPEC: '0', + }); + }); + + it('keeps an explicitly pinned pnpm workspace packageManager under a bun ancestor', () => { + const parentDir = path.join(tempRoot, 'parent'); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync( + path.join(parentDir, 'package.json'), + JSON.stringify({ packageManager: 'bun@1.3.11' }), + ); + + const repoDir = path.join(parentDir, 'child'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'child', + packageManager: 'pnpm@10.11.0', + scripts: { typecheck: 'tsc --noEmit' }, + }), + ); + fs.writeFileSync( + path.join(repoDir, 'pnpm-lock.yaml'), + 'lockfileVersion: 9.0\n', + ); + + expect( + buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }), + ).toEqual({ PATH: '/tmp/bin' }); + }); }); diff --git a/src/workspace-package-manager.ts b/src/workspace-package-manager.ts index 0a55ba7..d8022af 100644 --- a/src/workspace-package-manager.ts +++ b/src/workspace-package-manager.ts @@ -18,6 +18,7 @@ export interface WorkspaceDependencyInstallResult { commandText?: string; } +const COREPACK_PROJECT_SPEC_DISABLED = '0'; const INSTALL_STATE_FILENAME = '.ejclaw-install-state.json'; const NODE_MODULES_NOISE_ENTRIES = new Set([ '.cache', @@ -117,6 +118,52 @@ function buildCorepackCommand( }; } +function findNearestAncestorPackageManager( + repoDir: string, +): WorkspacePackageManager | null { + let currentDir = path.dirname(path.resolve(repoDir)); + + while (true) { + const packageManager = detectPackageManagerFromField( + readPackageJsonMetadata(currentDir)?.packageManager, + ); + if (packageManager) { + return packageManager; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function buildWorkspaceCommandEnvironment( + repoDir: string, + packageManager: WorkspacePackageManager, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + if (packageManager !== 'pnpm') { + return baseEnv; + } + + const localPackageManager = readPackageJsonMetadata(repoDir)?.packageManager; + if (typeof localPackageManager === 'string' && localPackageManager.trim()) { + return baseEnv; + } + + const ancestorPackageManager = findNearestAncestorPackageManager(repoDir); + if (!ancestorPackageManager || ancestorPackageManager === packageManager) { + return baseEnv; + } + + return { + ...baseEnv, + COREPACK_ENABLE_PROJECT_SPEC: COREPACK_PROJECT_SPEC_DISABLED, + }; +} + function computeInstallFingerprint(repoDir: string): string | null { const packageJsonPath = path.join(repoDir, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -359,6 +406,7 @@ export function ensureWorkspaceDependenciesInstalled( try { execFileSync(command.file, command.args, { cwd: repoDir, + env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -420,6 +468,7 @@ export function detectPnpmStorePath(workspaceDir: string): string | null { try { const storePath = execFileSync(file, args, { cwd: workspaceDir, + env: buildWorkspaceCommandEnvironment(workspaceDir, 'pnpm'), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, From deca9dab740c02e353f93c9f51500ee9293311a7 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 15:48:11 +0900 Subject: [PATCH 14/48] test: include message source migration in bootstrap expectations --- src/db/bootstrap.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 49dc122..343fe7a 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -37,6 +37,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 10, name: 'paired_turn_provenance_upgrade' }, { version: 11, name: 'owner_failure_count' }, { version: 12, name: 'paired_verdict_and_step_telemetry' }, + { version: 13, name: 'message_source_kind' }, ]; } From bd56a5d765a417201503c20cd78400ad769da5e7 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 18:40:36 +0900 Subject: [PATCH 15/48] feat: add optional Codex warm-up scheduler --- .env.example | 15 ++ src/codex-warmup.test.ts | 268 ++++++++++++++++++++++++++++++++++ src/codex-warmup.ts | 294 ++++++++++++++++++++++++++++++++++++++ src/config.test.ts | 38 +++++ src/config.ts | 1 + src/config/load-config.ts | 39 +++++ src/config/schema.ts | 13 ++ src/unified-dashboard.ts | 43 +++++- 8 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 src/codex-warmup.test.ts create mode 100644 src/codex-warmup.ts diff --git a/.env.example b/.env.example index 2727de8..d79b377 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,21 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # STATUS_SHOW_ROOMS=true # Show room status in dashboard # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info +# --- Codex warm-up (optional, disabled by default) --- +# Sends a tiny real Codex prompt to near-zero-usage accounts so their reset +# countdown starts after a fresh quota window. Keep disabled if OpenAI changes +# policy or starts blocking automated warm-up prompts. +# CODEX_WARMUP_ENABLED=false +# CODEX_WARMUP_PROMPT=Reply exactly OK. Do not run tools. +# CODEX_WARMUP_MODEL= # Defaults to CODEX_MODEL +# CODEX_WARMUP_INTERVAL_MS=300000 # Check every 5min +# CODEX_WARMUP_MIN_INTERVAL_MS=18300000 # Per-account minimum gap: 5h5m +# CODEX_WARMUP_STAGGER_MS=1800000 # Global gap between accounts: 30min +# CODEX_WARMUP_MAX_USAGE_PCT=0 # Only warm accounts at/below this 5h usage +# CODEX_WARMUP_MAX_D7_USAGE_PCT=99 # Skip weekly-exhausted accounts +# CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES=2 +# CODEX_WARMUP_FAILURE_COOLDOWN_MS=21600000 # Back off 6h after repeated failure + # --- Fallback provider (when Claude/Codex both unavailable) --- # FALLBACK_ENABLED=true # Enable fallback provider # FALLBACK_PROVIDER_NAME=kimi # Provider name for logging diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts new file mode 100644 index 0000000..53d7b9a --- /dev/null +++ b/src/codex-warmup.test.ts @@ -0,0 +1,268 @@ +import { EventEmitter } from 'events'; +import fs from 'fs'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('./codex-token-rotation.js', () => ({ + getAllCodexAccounts: vi.fn(), + getCodexAuthPath: vi.fn(), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +type FakeProcess = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { write: ReturnType; end: ReturnType }; + kill: ReturnType; +}; + +function createFakeCodexProcess(exitCode = 0): FakeProcess { + const proc = new EventEmitter() as FakeProcess; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { write: vi.fn(), end: vi.fn() }; + proc.kill = vi.fn(); + setImmediate(() => { + proc.stdout.emit('data', Buffer.from('OK\n')); + proc.emit('close', exitCode, null); + }); + return proc; +} + +function authPathFor(tempHome: string, accountIndex: number): string { + return path.join( + tempHome, + '.codex-accounts', + String(accountIndex + 1), + 'auth.json', + ); +} + +describe('Codex warm-up scheduler', () => { + let tempHome: string; + let statePath: string; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-warmup-')); + statePath = path.join(tempHome, 'codex-warmup-state.json'); + for (let i = 0; i < 3; i++) { + const p = authPathFor(tempHome, i); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, '{}'); + } + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('warms one eligible zero-usage account with a real codex exec and persists account cooldown state', async () => { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T09:00:00Z').getTime(); + let capturedEnv: Record | undefined; + let capturedArgs: readonly string[] | undefined; + + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'busy-account', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 14, + cachedUsageD7Pct: 30, + }, + { + index: 1, + accountId: 'rate-limited-account', + planType: 'pro', + isActive: false, + isRateLimited: true, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + }, + { + index: 2, + accountId: 'fresh-account', + planType: 'team', + isActive: false, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + }, + ]); + vi.mocked(rotation.getCodexAuthPath).mockImplementation( + (accountIndex = 0) => authPathFor(tempHome, accountIndex), + ); + vi.mocked(childProcess.spawn).mockImplementation((( + _cmd: string, + args?: readonly string[], + opts?: { env?: Record }, + ) => { + capturedArgs = args; + capturedEnv = opts?.env; + return createFakeCodexProcess(0) as never; + }) as unknown as typeof childProcess.spawn); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 1_800_000, + maxUsagePct: 0, + maxD7UsagePct: 99, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath }, + ); + + expect(result).toEqual({ status: 'warmed', accountIndex: 2 }); + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + expect(capturedArgs).toEqual( + expect.arrayContaining([ + 'exec', + '--ephemeral', + '--ignore-rules', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '-m', + 'gpt-5.5', + 'Reply exactly OK. Do not run tools.', + ]), + ); + expect(capturedEnv?.CODEX_HOME).toBe( + path.dirname(authPathFor(tempHome, 2)), + ); + + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + expect(state.lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); + expect(state.accounts['2'].lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); + expect(state.consecutiveFailures).toBe(0); + }); + + it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T09:00:00Z').getTime(); + + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'fresh-account', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + }, + ]); + vi.mocked(rotation.getCodexAuthPath).mockImplementation( + (accountIndex = 0) => authPathFor(tempHome, accountIndex), + ); + vi.mocked(childProcess.spawn).mockImplementation( + () => createFakeCodexProcess(1) as never, + ); + + const config = { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 0, + maxUsagePct: 0, + maxD7UsagePct: 99, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 1, + }; + + const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath }); + expect(failed.status).toBe('failed'); + + const backedOff = await runCodexWarmupCycle(config, { + nowMs: now + 60_000, + statePath, + }); + + expect(backedOff).toEqual({ + status: 'skipped', + reason: 'disabled_cooldown', + }); + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + expect(state.disabledUntil).toBe('2026-04-24T15:00:00.000Z'); + expect(state.consecutiveFailures).toBe(1); + }); + + it('respects global stagger and does not warm another account too soon', async () => { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T09:20:00Z').getTime(); + + fs.writeFileSync( + statePath, + JSON.stringify({ + lastWarmupAt: '2026-04-24T09:00:00.000Z', + consecutiveFailures: 0, + accounts: { '0': { lastWarmupAt: '2026-04-24T09:00:00.000Z' } }, + }), + ); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 1, + accountId: 'another-fresh-account', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + }, + ]); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: '.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 1_800_000, + maxUsagePct: 0, + maxD7UsagePct: 99, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath }, + ); + + expect(result).toEqual({ status: 'skipped', reason: 'stagger_wait' }); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts new file mode 100644 index 0000000..cacc63d --- /dev/null +++ b/src/codex-warmup.ts @@ -0,0 +1,294 @@ +import { ChildProcess, spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import type { AppConfig } from './config/schema.js'; +import { + getAllCodexAccounts, + getCodexAuthPath, +} from './codex-token-rotation.js'; +import { logger } from './logger.js'; + +export type CodexWarmupConfig = AppConfig['codexWarmup']; + +interface CodexWarmupAccountState { + lastWarmupAt?: string; + lastAttemptAt?: string; + lastErrorAt?: string; + failures?: number; +} + +interface CodexWarmupState { + lastWarmupAt?: string; + lastAttemptAt?: string; + disabledUntil?: string; + consecutiveFailures?: number; + accounts?: Record; +} + +interface CodexWarmupRuntimeOptions { + nowMs?: number; + statePath?: string; + shouldSkip?: () => boolean; +} + +export type CodexWarmupCycleResult = + | { status: 'disabled' } + | { status: 'skipped'; reason: string } + | { status: 'warmed'; accountIndex: number } + | { status: 'failed'; accountIndex: number; reason: string }; + +const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json'); + +function parseTimestamp(value?: string): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function readWarmupState(statePath: string): CodexWarmupState { + try { + if (!fs.existsSync(statePath)) return { accounts: {} }; + const parsed = JSON.parse( + fs.readFileSync(statePath, 'utf8'), + ) as CodexWarmupState; + if (!parsed.accounts || typeof parsed.accounts !== 'object') { + parsed.accounts = {}; + } + return parsed; + } catch (err) { + logger.warn( + { err, statePath }, + 'Failed to read Codex warm-up state; starting fresh', + ); + return { accounts: {} }; + } +} + +function writeWarmupState(statePath: string, state: CodexWarmupState): void { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`); + fs.renameSync(`${statePath}.tmp`, statePath); +} + +function getPreferredCodexPathEntries(): string[] { + const entries = [ + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + ]; + if (process.versions.bun || path.basename(process.execPath) === 'bun') { + entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin')); + } + return [...new Set(entries)]; +} + +function resolveCodexBinary(): string { + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); + return fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; +} + +function ensureAccountState( + state: CodexWarmupState, + accountIndex: number, +): CodexWarmupAccountState { + state.accounts ??= {}; + const key = String(accountIndex); + state.accounts[key] ??= {}; + return state.accounts[key]; +} + +function selectWarmupCandidate( + config: CodexWarmupConfig, + state: CodexWarmupState, + nowMs: number, +): { accountIndex: number } | { reason: string } { + const disabledUntilMs = parseTimestamp(state.disabledUntil); + if (disabledUntilMs != null && disabledUntilMs > nowMs) { + return { reason: 'disabled_cooldown' }; + } + + const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt); + if ( + lastGlobalWarmupMs != null && + config.staggerMs > 0 && + nowMs - lastGlobalWarmupMs < config.staggerMs + ) { + return { reason: 'stagger_wait' }; + } + + const accounts = getAllCodexAccounts(); + if (accounts.length === 0) return { reason: 'no_accounts' }; + + for (const account of accounts) { + if (account.isRateLimited) continue; + if (typeof account.cachedUsagePct !== 'number') continue; + if (typeof account.cachedUsageD7Pct !== 'number') continue; + if (account.cachedUsagePct > config.maxUsagePct) continue; + if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue; + + const accountState = state.accounts?.[String(account.index)]; + const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt); + if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) { + continue; + } + + return { accountIndex: account.index }; + } + + return { reason: 'no_eligible_accounts' }; +} + +function runCodexWarmupCommand( + accountDir: string, + config: CodexWarmupConfig, +): Promise<{ ok: boolean; reason: string }> { + const args = [ + 'exec', + '--ephemeral', + '--ignore-rules', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '-C', + os.tmpdir(), + '-m', + config.model, + config.prompt, + ]; + + const spawnEnv: Record = { + ...(process.env as Record), + CODEX_HOME: accountDir, + PATH: [...getPreferredCodexPathEntries(), process.env.PATH || ''] + .filter(Boolean) + .join(path.delimiter), + }; + + return new Promise((resolve) => { + let done = false; + let proc: ChildProcess | null = null; + let stderr = ''; + const finish = (result: { ok: boolean; reason: string }) => { + if (done) return; + done = true; + clearTimeout(timer); + if (proc && result.reason === 'timeout') { + try { + proc.kill(); + } catch { + /* ignore */ + } + } + resolve(result); + }; + + const timer = setTimeout( + () => finish({ ok: false, reason: 'timeout' }), + config.commandTimeoutMs, + ); + + try { + proc = spawn(resolveCodexBinary(), args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: spawnEnv, + }); + } catch (err) { + logger.warn({ err }, 'Failed to spawn Codex warm-up command'); + finish({ ok: false, reason: 'spawn_error' }); + return; + } + + proc.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + if (stderr.length > 2000) stderr = stderr.slice(-2000); + }); + proc.on('error', (err) => { + logger.warn({ err }, 'Codex warm-up process error'); + finish({ ok: false, reason: 'process_error' }); + }); + proc.on('close', (code) => { + if (code === 0) { + finish({ ok: true, reason: 'ok' }); + return; + } + logger.warn( + { exitCode: code, stderr: stderr.trim() || undefined }, + 'Codex warm-up command failed', + ); + finish({ ok: false, reason: `exit_${code ?? 'unknown'}` }); + }); + }); +} + +export async function runCodexWarmupCycle( + config: CodexWarmupConfig, + runtime: CodexWarmupRuntimeOptions = {}, +): Promise { + if (!config.enabled) return { status: 'disabled' }; + if (runtime.shouldSkip?.()) + return { status: 'skipped', reason: 'runtime_busy' }; + + const nowMs = runtime.nowMs ?? Date.now(); + const nowIso = new Date(nowMs).toISOString(); + const statePath = runtime.statePath ?? DEFAULT_STATE_FILE; + const state = readWarmupState(statePath); + const selected = selectWarmupCandidate(config, state, nowMs); + if ('reason' in selected) { + return { status: 'skipped', reason: selected.reason }; + } + + const authPath = getCodexAuthPath(selected.accountIndex); + if (!authPath || !fs.existsSync(authPath)) { + return { status: 'skipped', reason: 'missing_auth' }; + } + const accountDir = path.dirname(authPath); + const accountState = ensureAccountState(state, selected.accountIndex); + state.lastAttemptAt = nowIso; + accountState.lastAttemptAt = nowIso; + + logger.info( + { account: selected.accountIndex + 1, model: config.model }, + 'Starting Codex warm-up prompt', + ); + const result = await runCodexWarmupCommand(accountDir, config); + + if (result.ok) { + state.lastWarmupAt = nowIso; + state.consecutiveFailures = 0; + delete state.disabledUntil; + accountState.lastWarmupAt = nowIso; + accountState.failures = 0; + writeWarmupState(statePath, state); + logger.info( + { account: selected.accountIndex + 1 }, + 'Codex warm-up prompt completed', + ); + return { status: 'warmed', accountIndex: selected.accountIndex }; + } + + state.consecutiveFailures = (state.consecutiveFailures ?? 0) + 1; + accountState.lastErrorAt = nowIso; + accountState.failures = (accountState.failures ?? 0) + 1; + if (state.consecutiveFailures >= config.maxConsecutiveFailures) { + state.disabledUntil = new Date( + nowMs + config.failureCooldownMs, + ).toISOString(); + } + writeWarmupState(statePath, state); + logger.warn( + { + account: selected.accountIndex + 1, + reason: result.reason, + consecutiveFailures: state.consecutiveFailures, + disabledUntil: state.disabledUntil, + }, + 'Codex warm-up prompt failed', + ); + return { + status: 'failed', + accountIndex: selected.accountIndex, + reason: result.reason, + }; +} diff --git a/src/config.test.ts b/src/config.test.ts index 46a0357..258b3d5 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -31,6 +31,17 @@ describe('config/env loading', () => { delete process.env.DISCORD_CODEX_REVIEW_BOT_TOKEN; delete process.env.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL; delete process.env.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION; + delete process.env.CODEX_WARMUP_ENABLED; + delete process.env.CODEX_WARMUP_PROMPT; + delete process.env.CODEX_WARMUP_MODEL; + delete process.env.CODEX_WARMUP_INTERVAL_MS; + delete process.env.CODEX_WARMUP_MIN_INTERVAL_MS; + delete process.env.CODEX_WARMUP_STAGGER_MS; + delete process.env.CODEX_WARMUP_MAX_USAGE_PCT; + delete process.env.CODEX_WARMUP_MAX_D7_USAGE_PCT; + delete process.env.CODEX_WARMUP_COMMAND_TIMEOUT_MS; + delete process.env.CODEX_WARMUP_FAILURE_COOLDOWN_MS; + delete process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES; delete process.env.SESSION_COMMAND_USER_IDS; vi.resetModules(); }); @@ -124,6 +135,33 @@ describe('config/env loading', () => { expect(config.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION).toBe(true); }); + it('keeps Codex warm-up disabled by default and exposes conservative opt-in env config', async () => { + let config = await import('./config.js'); + expect(config.CODEX_WARMUP_CONFIG.enabled).toBe(false); + expect(config.CODEX_WARMUP_CONFIG.maxUsagePct).toBe(0); + expect( + config.CODEX_WARMUP_CONFIG.maxConsecutiveFailures, + ).toBeGreaterThanOrEqual(1); + + vi.resetModules(); + process.env.CODEX_MODEL = 'gpt-5.5'; + process.env.CODEX_WARMUP_ENABLED = 'true'; + process.env.CODEX_WARMUP_PROMPT = '.'; + process.env.CODEX_WARMUP_STAGGER_MS = '600000'; + process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES = '1'; + config = await import('./config.js'); + + expect(config.CODEX_WARMUP_CONFIG).toEqual( + expect.objectContaining({ + enabled: true, + prompt: '.', + model: 'gpt-5.5', + staggerMs: 600000, + maxConsecutiveFailures: 1, + }), + ); + }); + it('fails fast when a legacy Discord token alias is configured', async () => { process.env.DISCORD_BOT_TOKEN = 'legacy-owner-token'; diff --git a/src/config.ts b/src/config.ts index d194b8c..a739df3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -140,6 +140,7 @@ export const USAGE_UPDATE_INTERVAL = CONFIG.status.usageUpdateInterval; export const STATUS_SHOW_ROOMS = CONFIG.status.showRooms; export const STATUS_SHOW_ROOM_DETAILS = CONFIG.status.showRoomDetails; export const USAGE_DASHBOARD_ENABLED = CONFIG.status.usageDashboardEnabled; +export const CODEX_WARMUP_CONFIG = CONFIG.codexWarmup; // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 98e1740..a5242d3 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -53,6 +53,11 @@ function readIntegerAtLeast( return Math.max(minimum, readInteger(key, fallback) || fallback); } +function readPercent(key: string, fallback: number): number { + const value = readInteger(key, fallback); + return Math.min(100, Math.max(0, value)); +} + function readAgentType( key: string, fallback?: AgentType, @@ -255,6 +260,40 @@ export function loadConfig(): AppConfig { timezone: readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone, }, + codexWarmup: { + enabled: readBoolean('CODEX_WARMUP_ENABLED', false), + prompt: + readNonEmptyText('CODEX_WARMUP_PROMPT') ?? + 'Reply exactly OK. Do not run tools.', + model: + readNonEmptyText('CODEX_WARMUP_MODEL') ?? + readNonEmptyText('CODEX_MODEL') ?? + 'codex', + intervalMs: readIntegerAtLeast('CODEX_WARMUP_INTERVAL_MS', 300000, 60000), + minIntervalMs: readIntegerAtLeast( + 'CODEX_WARMUP_MIN_INTERVAL_MS', + 18300000, + 60000, + ), + staggerMs: Math.max(0, readInteger('CODEX_WARMUP_STAGGER_MS', 1800000)), + maxUsagePct: readPercent('CODEX_WARMUP_MAX_USAGE_PCT', 0), + maxD7UsagePct: readPercent('CODEX_WARMUP_MAX_D7_USAGE_PCT', 99), + commandTimeoutMs: readIntegerAtLeast( + 'CODEX_WARMUP_COMMAND_TIMEOUT_MS', + 120000, + 10000, + ), + failureCooldownMs: readIntegerAtLeast( + 'CODEX_WARMUP_FAILURE_COOLDOWN_MS', + 21600000, + 60000, + ), + maxConsecutiveFailures: readIntegerAtLeast( + 'CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES', + 2, + 1, + ), + }, sessionCommands: { allowedSenders: new Set( (readText('SESSION_COMMAND_ALLOWED_SENDERS') ?? '') diff --git a/src/config/schema.ts b/src/config/schema.ts index e1fbd46..d5450f9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -78,6 +78,19 @@ export interface AppConfig { usageDashboardEnabled: boolean; timezone: string; }; + codexWarmup: { + enabled: boolean; + prompt: string; + model: string; + intervalMs: number; + minIntervalMs: number; + staggerMs: number; + maxUsagePct: number; + maxD7UsagePct: number; + commandTimeoutMs: number; + failureCooldownMs: number; + maxConsecutiveFailures: number; + }; sessionCommands: { allowedSenders: Set; }; diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 9ea1c84..44048fd 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -14,6 +14,7 @@ import { STATUS_SHOW_ROOM_DETAILS, STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED, + CODEX_WARMUP_CONFIG, getMoaConfig, } from './config.js'; import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js'; @@ -28,6 +29,7 @@ import { refreshActiveCodexUsage, refreshAllCodexAccountUsage, } from './codex-usage-collector.js'; +import { runCodexWarmupCycle } from './codex-warmup.js'; import { composeDashboardContent, formatElapsed, @@ -735,18 +737,49 @@ export async function startUnifiedDashboard( cachedCodexUsageRows = result.rows; if (result.fetchedAt) codexUsageFetchedAt = result.fetchedAt; }; - void refreshAllCodexAccountUsage().then((r) => { - applyCodexRefresh(r); - return refreshActiveCodexUsage().then(applyCodexRefresh); - }); + const isWarmupRuntimeBusy = () => { + const groups = opts.roomBindings(); + return opts.queue + .getStatuses(Object.keys(groups)) + .some((status) => status.status === 'processing'); + }; + let codexWarmupInFlight = false; + const runCodexWarmup = async () => { + if (!CODEX_WARMUP_CONFIG.enabled || codexWarmupInFlight) return; + codexWarmupInFlight = true; + try { + const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG, { + shouldSkip: isWarmupRuntimeBusy, + }); + if (result.status === 'warmed') { + applyCodexRefresh(await refreshAllCodexAccountUsage()); + } + } catch (err) { + logger.warn({ err }, 'Codex warm-up cycle failed unexpectedly'); + } finally { + codexWarmupInFlight = false; + } + }; + void refreshAllCodexAccountUsage() + .then((r) => { + applyCodexRefresh(r); + return refreshActiveCodexUsage().then(applyCodexRefresh); + }) + .then(() => runCodexWarmup()); setInterval( () => void refreshActiveCodexUsage().then(applyCodexRefresh), opts.usageUpdateInterval, ); setInterval( - () => void refreshAllCodexAccountUsage().then(applyCodexRefresh), + () => + void refreshAllCodexAccountUsage() + .then(applyCodexRefresh) + .then(() => runCodexWarmup()), CODEX_FULL_SCAN_INTERVAL, ); + if (CODEX_WARMUP_CONFIG.enabled) { + setInterval(() => void runCodexWarmup(), CODEX_WARMUP_CONFIG.intervalMs); + } logger.info( { From 884aef5c997174bee6214d1797c9d16a9d908bb5 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 18:53:04 +0900 Subject: [PATCH 16/48] fix: restrict Codex warm-up to fresh quota windows --- .env.example | 9 +++++---- src/codex-warmup.test.ts | 6 +++--- src/config.test.ts | 1 + src/config/load-config.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index d79b377..23e11f6 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,10 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info # --- Codex warm-up (optional, disabled by default) --- -# Sends a tiny real Codex prompt to near-zero-usage accounts so their reset -# countdown starts after a fresh quota window. Keep disabled if OpenAI changes -# policy or starts blocking automated warm-up prompts. +# Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so +# reset countdown starts once after a fresh quota window. Keep disabled if +# OpenAI changes policy or starts blocking automated warm-up prompts. +# Normally only set this one line; the other knobs have conservative defaults. # CODEX_WARMUP_ENABLED=false # CODEX_WARMUP_PROMPT=Reply exactly OK. Do not run tools. # CODEX_WARMUP_MODEL= # Defaults to CODEX_MODEL @@ -47,7 +48,7 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # CODEX_WARMUP_MIN_INTERVAL_MS=18300000 # Per-account minimum gap: 5h5m # CODEX_WARMUP_STAGGER_MS=1800000 # Global gap between accounts: 30min # CODEX_WARMUP_MAX_USAGE_PCT=0 # Only warm accounts at/below this 5h usage -# CODEX_WARMUP_MAX_D7_USAGE_PCT=99 # Skip weekly-exhausted accounts +# CODEX_WARMUP_MAX_D7_USAGE_PCT=0 # Only warm accounts at/below this 7d usage # CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES=2 # CODEX_WARMUP_FAILURE_COOLDOWN_MS=21600000 # Back off 6h after repeated failure diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index 53d7b9a..7c8332b 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -130,7 +130,7 @@ describe('Codex warm-up scheduler', () => { minIntervalMs: 18_300_000, staggerMs: 1_800_000, maxUsagePct: 0, - maxD7UsagePct: 99, + maxD7UsagePct: 0, commandTimeoutMs: 120_000, failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, @@ -195,7 +195,7 @@ describe('Codex warm-up scheduler', () => { minIntervalMs: 18_300_000, staggerMs: 0, maxUsagePct: 0, - maxD7UsagePct: 99, + maxD7UsagePct: 0, commandTimeoutMs: 120_000, failureCooldownMs: 21_600_000, maxConsecutiveFailures: 1, @@ -254,7 +254,7 @@ describe('Codex warm-up scheduler', () => { minIntervalMs: 18_300_000, staggerMs: 1_800_000, maxUsagePct: 0, - maxD7UsagePct: 99, + maxD7UsagePct: 0, commandTimeoutMs: 120_000, failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, diff --git a/src/config.test.ts b/src/config.test.ts index 258b3d5..d9280a0 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -139,6 +139,7 @@ describe('config/env loading', () => { let config = await import('./config.js'); expect(config.CODEX_WARMUP_CONFIG.enabled).toBe(false); expect(config.CODEX_WARMUP_CONFIG.maxUsagePct).toBe(0); + expect(config.CODEX_WARMUP_CONFIG.maxD7UsagePct).toBe(0); expect( config.CODEX_WARMUP_CONFIG.maxConsecutiveFailures, ).toBeGreaterThanOrEqual(1); diff --git a/src/config/load-config.ts b/src/config/load-config.ts index a5242d3..76f910c 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -277,7 +277,7 @@ export function loadConfig(): AppConfig { ), staggerMs: Math.max(0, readInteger('CODEX_WARMUP_STAGGER_MS', 1800000)), maxUsagePct: readPercent('CODEX_WARMUP_MAX_USAGE_PCT', 0), - maxD7UsagePct: readPercent('CODEX_WARMUP_MAX_D7_USAGE_PCT', 99), + maxD7UsagePct: readPercent('CODEX_WARMUP_MAX_D7_USAGE_PCT', 0), commandTimeoutMs: readIntegerAtLeast( 'CODEX_WARMUP_COMMAND_TIMEOUT_MS', 120000, From ee8b18145e4c86465f085acfbcdfb3102c44b707 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 19:04:22 +0900 Subject: [PATCH 17/48] fix: avoid repeated zero-usage Codex warm-ups --- src/codex-warmup.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++ src/codex-warmup.ts | 25 ++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index 7c8332b..b0c4b0c 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -106,6 +106,8 @@ describe('Codex warm-up scheduler', () => { isRateLimited: false, cachedUsagePct: 0, cachedUsageD7Pct: 0, + resetAt: '2026-04-24T14:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', }, ]); vi.mocked(rotation.getCodexAuthPath).mockImplementation( @@ -160,9 +162,69 @@ describe('Codex warm-up scheduler', () => { const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); expect(state.lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); expect(state.accounts['2'].lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); + expect(state.accounts['2'].zeroUsageWarmupUntil).toBe( + '2026-05-01T09:00:00.000Z', + ); expect(state.consecutiveFailures).toBe(0); }); + it('does not repeat warm-up while the same zero-usage quota window is already marked warmed', async () => { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T15:00:00Z').getTime(); + + fs.writeFileSync( + statePath, + JSON.stringify({ + lastWarmupAt: '2026-04-24T09:00:00.000Z', + consecutiveFailures: 0, + accounts: { + '0': { + lastWarmupAt: '2026-04-24T09:00:00.000Z', + zeroUsageWarmupUntil: '2026-05-01T09:00:00.000Z', + }, + }, + }), + ); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'fresh-account-still-rounded-zero', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + resetAt: '2026-04-24T14:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', + }, + ]); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 0, + maxUsagePct: 0, + maxD7UsagePct: 0, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath }, + ); + + expect(result).toEqual({ + status: 'skipped', + reason: 'no_eligible_accounts', + }); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => { const childProcess = await import('child_process'); const rotation = await import('./codex-token-rotation.js'); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index cacc63d..a70b58a 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -17,6 +17,7 @@ interface CodexWarmupAccountState { lastWarmupAt?: string; lastAttemptAt?: string; lastErrorAt?: string; + zeroUsageWarmupUntil?: string; failures?: number; } @@ -41,6 +42,7 @@ export type CodexWarmupCycleResult = | { status: 'failed'; accountIndex: number; reason: string }; const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json'); +const DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; function parseTimestamp(value?: string): number | null { if (!value) return null; @@ -103,7 +105,7 @@ function selectWarmupCandidate( config: CodexWarmupConfig, state: CodexWarmupState, nowMs: number, -): { accountIndex: number } | { reason: string } { +): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } { const disabledUntilMs = parseTimestamp(state.disabledUntil); if (disabledUntilMs != null && disabledUntilMs > nowMs) { return { reason: 'disabled_cooldown' }; @@ -129,12 +131,30 @@ function selectWarmupCandidate( if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue; const accountState = state.accounts?.[String(account.index)]; + const zeroUsageWarmupUntilMs = parseTimestamp( + accountState?.zeroUsageWarmupUntil, + ); + if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) { + continue; + } + const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt); if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) { continue; } - return { accountIndex: account.index }; + const resetD7Ms = parseTimestamp(account.resetD7At); + const zeroUsageWarmupUntilMsForState = + resetD7Ms != null && resetD7Ms > nowMs + ? resetD7Ms + : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + + return { + accountIndex: account.index, + zeroUsageWarmupUntil: new Date( + zeroUsageWarmupUntilMsForState, + ).toISOString(), + }; } return { reason: 'no_eligible_accounts' }; @@ -259,6 +279,7 @@ export async function runCodexWarmupCycle( state.consecutiveFailures = 0; delete state.disabledUntil; accountState.lastWarmupAt = nowIso; + accountState.zeroUsageWarmupUntil = selected.zeroUsageWarmupUntil; accountState.failures = 0; writeWarmupState(statePath, state); logger.info( From 83b7aef494d4fa24bd9887685fbff2d922827f47 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 25 Apr 2026 09:20:28 +0900 Subject: [PATCH 18/48] Add structured Discord attachments --- prompts/claude-platform.md | 22 +++ prompts/codex-platform.md | 27 +++ prompts/owner-common-platform.md | 27 +++ runners/agent-runner/src/output-protocol.ts | 4 +- runners/shared/src/agent-protocol.ts | 46 ++++- runners/shared/src/index.ts | 1 + runners/shared/test/agent-protocol.test.ts | 41 +++++ src/agent-output.ts | 10 + src/agent-protocol.ts | 1 + src/channels/discord.test.ts | 69 +++++++ src/channels/discord.ts | 117 ++++++++---- src/db.test.ts | 32 ++++ src/db/base-schema.ts | 1 + src/db/bootstrap.test.ts | 1 + .../migrations/014_work-item-attachments.ts | 17 ++ src/db/migrations/index.ts | 2 + src/db/work-items.ts | 50 ++++- src/message-runtime-delivery.ts | 29 ++- src/message-runtime-turns.ts | 7 +- src/message-runtime.ts | 4 + src/message-turn-controller.test.ts | 56 ++++++ src/message-turn-controller.ts | 35 +++- src/outbound-attachments.test.ts | 149 +++++++++++++++ src/outbound-attachments.ts | 174 ++++++++++++++++++ src/types.ts | 23 ++- 25 files changed, 892 insertions(+), 53 deletions(-) create mode 100644 src/db/migrations/014_work-item-attachments.ts create mode 100644 src/outbound-attachments.test.ts create mode 100644 src/outbound-attachments.ts diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md index 4373ff9..42a7abf 100644 --- a/prompts/claude-platform.md +++ b/prompts/claude-platform.md @@ -4,3 +4,25 @@ You have a `send_message` tool that sends a message immediately while you are st Use it to acknowledge a request before starting longer work. When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to. + +## Image attachments + +When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose: + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "스크린샷을 첨부했습니다.", + "attachments": [ + { + "path": "/absolute/path/screenshot.png", + "name": "screenshot.png", + "mime": "image/png" + } + ] + } +} +``` + +Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 612cef2..d2e07ca 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -28,9 +28,36 @@ Your output is sent directly to the Discord group. - Do not use generic recurring task registration from Codex - If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it +## Image attachments + +When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`. + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "이미지를 생성했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/absolute/path/image.png", + "name": "image.png", + "mime": "image/png" + } + ] + } +} +``` + +- `attachments.path` must be an absolute local path; URLs and relative paths are ignored +- Do not repeat the same file path in the visible text +- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. +- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file + ## CI 감시 (watch_ci) GitHub Actions run 감시는 structured 필드를 우선 사용: + - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling) - structured 필드 없이 generic 등록 시 매 tick LLM 실행됨 diff --git a/prompts/owner-common-platform.md b/prompts/owner-common-platform.md index b217fc8..67204b8 100644 --- a/prompts/owner-common-platform.md +++ b/prompts/owner-common-platform.md @@ -20,9 +20,36 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context. +## Image attachments + +For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths: + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "스크린샷을 첨부했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/absolute/path/screenshot.png", + "name": "screenshot.png", + "mime": "image/png" + } + ] + } +} +``` + +- Use absolute local paths only +- Do not duplicate the same path in the visible text +- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. +- The channel harness validates and uploads attachments; plain prose paths are not reliable + ## CI monitoring (watch_ci) GitHub Actions run monitoring uses structured fields first: + - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - This combination → host-driven fast path (no LLM token cost, 15s polling) - Without structured fields → generic path, each tick runs LLM diff --git a/runners/agent-runner/src/output-protocol.ts b/runners/agent-runner/src/output-protocol.ts index 8d38f0d..f5ded31 100644 --- a/runners/agent-runner/src/output-protocol.ts +++ b/runners/agent-runner/src/output-protocol.ts @@ -3,7 +3,7 @@ import path from 'path'; import { extractImageTagPaths, - normalizePublicTextOutput, + normalizeEjclawStructuredOutput, type RunnerStructuredOutput, writeProtocolOutput, } from 'ejclaw-runners-shared'; @@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): { result: string | null; output?: RunnerOutput['output']; } { - return normalizePublicTextOutput(result); + return normalizeEjclawStructuredOutput(result); } export function extractAssistantText(message: unknown): string | null { diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 89aa8f0..20a1494 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -1,7 +1,8 @@ export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; -export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; +export const IMAGE_TAG_RE = + /\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g; export const IPC_POLL_MS = 500; export const IPC_INPUT_SUBDIR = 'input'; @@ -21,11 +22,18 @@ export type RunnerOutputVerdict = export type RunnerOutputVisibility = 'public' | 'silent'; +export interface RunnerOutputAttachment { + path: string; + name?: string; + mime?: string; +} + export type RunnerStructuredOutput = | { visibility: 'public'; text: string; verdict?: Exclude; + attachments?: RunnerOutputAttachment[]; } | { visibility: 'silent'; @@ -89,6 +97,33 @@ function isVisibleVerdict( ); } +function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { + if (!Array.isArray(value)) return []; + + const attachments: RunnerOutputAttachment[] = []; + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const candidate = item as { + path?: unknown; + name?: unknown; + mime?: unknown; + }; + if (typeof candidate.path !== 'string' || candidate.path.length === 0) { + continue; + } + attachments.push({ + path: candidate.path, + ...(typeof candidate.name === 'string' && candidate.name.length > 0 + ? { name: candidate.name } + : {}), + ...(typeof candidate.mime === 'string' && candidate.mime.length > 0 + ? { mime: candidate.mime } + : {}), + }); + } + return attachments; +} + export function normalizeEjclawStructuredOutput( result: string | null, ): NormalizedRunnerOutput { @@ -99,7 +134,12 @@ export function normalizeEjclawStructuredOutput( const trimmed = result.trim(); try { const parsed = JSON.parse(trimmed) as { - ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown }; + ejclaw?: { + visibility?: unknown; + text?: unknown; + verdict?: unknown; + attachments?: unknown; + }; }; const envelope = parsed?.ejclaw; if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { @@ -128,6 +168,7 @@ export function normalizeEjclawStructuredOutput( ) { return normalizePublicTextOutput(result); } + const attachments = normalizeAttachments(envelope.attachments); return { result: envelope.text, output: { @@ -136,6 +177,7 @@ export function normalizeEjclawStructuredOutput( verdict: isVisibleVerdict(envelope.verdict) ? envelope.verdict : undefined, + ...(attachments.length > 0 ? { attachments } : {}), }, }; } diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index eb7cd92..8e432d1 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -15,6 +15,7 @@ export { writeProtocolOutput, type NormalizedRunnerOutput, type RunnerOutputPhase, + type RunnerOutputAttachment, type RunnerOutputVerdict, type RunnerOutputVisibility, type RunnerStructuredOutput, diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 37adb62..74e631f 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -13,6 +13,12 @@ describe('shared agent protocol helpers', () => { cleanText: 'hello', imagePaths: ['/tmp/a.png'], }); + expect( + extractImageTagPaths('hello [Image: screenshot.png → /tmp/a.png]'), + ).toEqual({ + cleanText: 'hello', + imagePaths: ['/tmp/a.png'], + }); expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({ cleanText: 'second', imagePaths: ['/tmp/b.png'], @@ -45,6 +51,41 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses public ejclaw attachments', () => { + expect( + normalizeEjclawStructuredOutput( + JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '이미지를 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }, + }), + ), + ).toEqual({ + result: '이미지를 생성했습니다.', + output: { + visibility: 'public', + text: '이미지를 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }, + }); + }); + it('falls back to visible raw text on invalid public verdicts', () => { const raw = JSON.stringify({ ejclaw: { diff --git a/src/agent-output.ts b/src/agent-output.ts index 1d7e8d9..d8fb478 100644 --- a/src/agent-output.ts +++ b/src/agent-output.ts @@ -34,6 +34,16 @@ export function getAgentOutputText(output: { return stringifyLegacyAgentResult(output.result); } +export function getAgentOutputAttachments(output: { + output?: StructuredAgentOutput; +}): NonNullable< + Extract['attachments'] +> { + const structured = getStructuredAgentOutput(output); + if (structured?.visibility !== 'public') return []; + return structured.attachments ?? []; +} + export function hasAgentOutputPayload(output: { output?: StructuredAgentOutput; result?: string | object | null; diff --git a/src/agent-protocol.ts b/src/agent-protocol.ts index 53db438..17daa32 100644 --- a/src/agent-protocol.ts +++ b/src/agent-protocol.ts @@ -10,6 +10,7 @@ export { OUTPUT_START_MARKER, writeProtocolOutput, type NormalizedRunnerOutput, + type RunnerOutputAttachment, type RunnerOutputPhase, type RunnerOutputVerdict, type RunnerOutputVisibility, diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 1171424..277098f 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -1,3 +1,7 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // --- Mocks --- @@ -127,6 +131,18 @@ import { logger } from '../logger.js'; // --- Test helpers --- +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +function createTempPng(name = 'image.png'): { dir: string; filePath: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-')); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, ONE_PIXEL_PNG); + return { dir, filePath }; +} + function createTestOpts( overrides?: Partial, ): DiscordChannelOpts { @@ -839,6 +855,59 @@ describe('DiscordChannel', () => { }); }); + it('sends structured attachments as Discord files', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('structured.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + '이미지를 생성했습니다.', + { + attachments: [ + { path: filePath, name: 'result.png', mime: 'image/png' }, + ], + }, + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '이미지를 생성했습니다.', + files: [{ attachment: filePath, name: 'result.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses legacy image tags as Discord attachment fallback', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('screenshot.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`, + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '스크린샷입니다.', + files: [{ attachment: filePath, name: 'screenshot.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + it('logs channel name and Discord message ids after sending', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 75bcaad..84a64f9 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -19,8 +19,11 @@ import { } from '../config.js'; import { getEnv } from '../env.js'; import { logger } from '../logger.js'; +import { extractImageTagPaths } from '../agent-protocol.js'; +import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; +import type { OutboundAttachment, SendMessageOptions } from '../types.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); @@ -30,6 +33,45 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter'; const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; +const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; + +function extractMarkdownImageAttachments(text: string): { + cleanText: string; + attachments: OutboundAttachment[]; +} { + const attachments: OutboundAttachment[] = []; + const seen = new Set(); + + const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => { + const trimmed = rawPath.trim(); + if (IMAGE_EXTS.test(trimmed)) { + if (!seen.has(trimmed)) { + attachments.push({ + path: trimmed, + name: path.basename(trimmed), + }); + seen.add(trimmed); + } + return ''; + } + + const basename = path.basename(trimmed.replace(/#.*$/, '')); + const lineMatch = trimmed.match(/#L(\d+)/); + return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``; + }); + + return { cleanText, attachments }; +} + +function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] { + return paths + .filter((filePath) => IMAGE_EXTS.test(filePath)) + .map((filePath) => ({ + path: filePath, + name: path.basename(filePath), + })); +} /** * Download a Discord attachment to local disk. @@ -403,7 +445,11 @@ export class DiscordChannel implements Channel { }); } - async sendMessage(jid: string, text: string): Promise { + async sendMessage( + jid: string, + text: string, + options: SendMessageOptions = {}, + ): Promise { if (!this.client) { logger.warn('Discord client not initialized'); return; @@ -420,37 +466,43 @@ export class DiscordChannel implements Channel { const textChannel = channel as TextChannel; - // Extract image attachments from markdown links with image extensions - // e.g. [name.png](/absolute/path/name.png) - const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|bmp)$/i; - const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; - const imageFiles: string[] = []; - const seen = new Set(); - let match; + const structuredAttachments = options.attachments ?? []; + const hasStructuredAttachments = structuredAttachments.length > 0; + const markdownExtracted = extractMarkdownImageAttachments(text); + const imageTagExtracted = extractImageTagPaths( + markdownExtracted.cleanText, + ); + const legacyImageTagAttachments = imageTagPathsToAttachments( + imageTagExtracted.imagePaths, + ); + const outboundAttachments = hasStructuredAttachments + ? structuredAttachments + : [...markdownExtracted.attachments, ...legacyImageTagAttachments]; + const attachmentSource = hasStructuredAttachments + ? 'structured' + : markdownExtracted.attachments.length > 0 + ? 'md-link' + : legacyImageTagAttachments.length > 0 + ? 'image-tag' + : 'none'; + const validation = validateOutboundAttachments(outboundAttachments, { + baseDirs: options.attachmentBaseDirs, + }); + const files = validation.files; - while ((match = MD_LINK_RE.exec(text)) !== null) { - const imgPath = match[1].trim(); - if ( - !seen.has(imgPath) && - IMAGE_EXTS.test(imgPath) && - fs.existsSync(imgPath) - ) { - imageFiles.push(imgPath); - seen.add(imgPath); - } + if (validation.rejected.length > 0) { + logger.warn( + { + jid, + channelName: this.name, + attachmentSource, + rejected: validation.rejected, + }, + 'Rejected outbound Discord attachments', + ); } - let cleaned = text - .replace(MD_LINK_RE, (full, p, _offset, _str, groups) => { - const trimmed = p.trim(); - // Image links: remove entirely (attached as files) - if (IMAGE_EXTS.test(trimmed) && seen.has(trimmed)) return ''; - // Non-image local path links: convert to readable filename - const basename = path.basename(trimmed.replace(/#.*$/, '')); - const lineMatch = trimmed.match(/#L(\d+)/); - return lineMatch - ? `\`${basename}:${lineMatch[1]}\`` - : `\`${basename}\``; - }) + + let cleaned = imageTagExtracted.cleanText .replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines .replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines .trim(); @@ -467,10 +519,6 @@ export class DiscordChannel implements Channel { // Discord has a 2000 character limit per message and 10 attachments per message const MAX_LENGTH = 2000; const MAX_ATTACHMENTS = 10; - const files = imageFiles.map((f) => ({ - attachment: f, - name: path.basename(f), - })); const sentMessageIds: string[] = []; let chunkCount = 0; @@ -544,6 +592,7 @@ export class DiscordChannel implements Channel { deliveryMode: 'send', chunkCount, attachmentCount: files.length, + attachmentSource, messageId: sentMessageIds[0] ?? null, messageIds: sentMessageIds, botUserId: this.client.user?.id ?? null, diff --git a/src/db.test.ts b/src/db.test.ts index 0a15fc6..8a5a3f0 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -9106,6 +9106,38 @@ describe('work items', () => { ).toBeUndefined(); }); + it('stores produced work item attachments for delivery retries', () => { + const item = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:attachments', + agent_type: 'claude-code', + delivery_role: 'owner', + start_seq: 1, + end_seq: 2, + result_payload: 'image ready', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }); + + const stored = getOpenWorkItem( + 'dc:attachments', + 'claude-code', + item.service_id, + ); + expect(stored?.attachments).toEqual([ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ]); + }); + it('finds pending delivery retries even when they were created by a fallback agent type', () => { const fallbackItem = createProducedWorkItem({ group_folder: 'discord_test', diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 0ded6e5..4cae88e 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -39,6 +39,7 @@ export function applyBaseSchema(database: Database): void { start_seq INTEGER, end_seq INTEGER, result_payload TEXT NOT NULL, + attachment_payload TEXT, delivery_attempts INTEGER NOT NULL DEFAULT 0, delivery_message_id TEXT, last_error TEXT, diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 343fe7a..b8d3c3c 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -38,6 +38,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 11, name: 'owner_failure_count' }, { version: 12, name: 'paired_verdict_and_step_telemetry' }, { version: 13, name: 'message_source_kind' }, + { version: 14, name: 'work_item_attachments' }, ]; } diff --git a/src/db/migrations/014_work-item-attachments.ts b/src/db/migrations/014_work-item-attachments.ts new file mode 100644 index 0000000..d5a3522 --- /dev/null +++ b/src/db/migrations/014_work-item-attachments.ts @@ -0,0 +1,17 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const WORK_ITEM_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = { + version: 14, + name: 'work_item_attachments', + apply(database: Database) { + if (!tableHasColumn(database, 'work_items', 'attachment_payload')) { + database.exec(` + ALTER TABLE work_items + ADD COLUMN attachment_payload TEXT + `); + } + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index af0f435..ddd4bb2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -13,6 +13,7 @@ import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-prov import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js'; import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; +import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -34,6 +35,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ OWNER_FAILURE_COUNT_MIGRATION, PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION, MESSAGE_SOURCE_KIND_MIGRATION, + WORK_ITEM_ATTACHMENTS_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/work-items.ts b/src/db/work-items.ts index f5af722..8a9e51c 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -6,7 +6,7 @@ import { inferRoleFromServiceShadow, resolveRoleServiceShadow, } from '../role-service-shadow.js'; -import { AgentType, PairedRoomRole } from '../types.js'; +import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js'; export interface WorkItem { id: number; @@ -19,6 +19,7 @@ export interface WorkItem { start_seq: number | null; end_seq: number | null; result_payload: string; + attachments?: OutboundAttachment[]; delivery_attempts: number; delivery_message_id: string | null; last_error: string | null; @@ -29,10 +30,11 @@ export interface WorkItem { interface StoredWorkItemRow extends Omit< WorkItem, - 'agent_type' | 'service_id' + 'agent_type' | 'service_id' | 'attachments' > { agent_type: string; service_id?: string | null; + attachment_payload?: string | null; } export interface CreateProducedWorkItemInput { @@ -44,6 +46,7 @@ export interface CreateProducedWorkItemInput { start_seq: number | null; end_seq: number | null; result_payload: string; + attachments?: OutboundAttachment[]; } function normalizeStoredAgentType( @@ -96,9 +99,48 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem { ...row, agent_type: agentType, service_id: readStoredWorkItemServiceId(row), + attachments: parseAttachmentPayload(row.attachment_payload), }; } +function parseAttachmentPayload( + payload: string | null | undefined, +): OutboundAttachment[] { + if (!payload) return []; + try { + const parsed = JSON.parse(payload) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (item): item is OutboundAttachment => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + typeof (item as { path?: unknown }).path === 'string', + ) + .map((item) => ({ + path: item.path, + ...(typeof item.name === 'string' ? { name: item.name } : {}), + ...(typeof item.mime === 'string' ? { mime: item.mime } : {}), + })); + } catch { + return []; + } +} + +function serializeAttachmentPayload( + attachments: OutboundAttachment[] | undefined, +): string | null { + if (!attachments?.length) return null; + return JSON.stringify( + attachments.map((attachment) => ({ + path: attachment.path, + ...(attachment.name ? { name: attachment.name } : {}), + ...(attachment.mime ? { mime: attachment.mime } : {}), + })), + ); +} + function resolvePreferredWorkItemRole( serviceId: string | null | undefined, ): PairedRoomRole | null { @@ -220,10 +262,11 @@ export function createProducedWorkItemInDatabase( start_seq, end_seq, result_payload, + attachment_payload, delivery_attempts, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`, ) .run( input.group_folder, @@ -234,6 +277,7 @@ export function createProducedWorkItemInDatabase( input.start_seq, input.end_seq, input.result_payload, + serializeAttachmentPayload(input.attachments), now, now, ); diff --git a/src/message-runtime-delivery.ts b/src/message-runtime-delivery.ts index d64ac34..4d01962 100644 --- a/src/message-runtime-delivery.ts +++ b/src/message-runtime-delivery.ts @@ -28,11 +28,14 @@ export async function deliverOpenWorkItem(args: { channel: Channel; item: WorkItem; log: RuntimeDeliveryLog; + attachmentBaseDirs?: string[]; replaceMessageId?: string | null; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; openContinuation: (chatJid: string) => void; }): Promise { const replaceMessageId = args.replaceMessageId ?? null; + const attachments = args.item.attachments ?? []; + const hasAttachments = attachments.length > 0; const isDuplicate = args.isDuplicateOfLastBotFinal( args.item.chat_jid, @@ -52,7 +55,7 @@ export async function deliverOpenWorkItem(args: { } try { - if (replaceMessageId && args.channel.editMessage) { + if (replaceMessageId && args.channel.editMessage && !hasAttachments) { args.log.info( buildDeliveryLogContext(args.channel, args.item, { deliveryAttempts: args.item.delivery_attempts + 1, @@ -93,19 +96,32 @@ export async function deliverOpenWorkItem(args: { try { args.log.info( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', }), 'Attempting to deliver produced work item as a new message', ); - await args.channel.sendMessage( - args.item.chat_jid, - args.item.result_payload, - ); + if (hasAttachments) { + await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + { + attachmentBaseDirs: args.attachmentBaseDirs, + attachments, + }, + ); + } else { + await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + ); + } markWorkItemDelivered(args.item.id); args.openContinuation(args.item.chat_jid); args.log.info( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', }), @@ -117,6 +133,7 @@ export async function deliverOpenWorkItem(args: { markWorkItemDeliveryRetry(args.item.id, errorMessage); args.log.warn( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', err, @@ -135,6 +152,7 @@ export async function processOpenWorkItemDelivery(args: { channel: Channel; roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>; log: RuntimeDeliveryLog; + attachmentBaseDirs?: string[]; isPairedRoom: boolean; getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; @@ -186,6 +204,7 @@ export async function processOpenWorkItemDelivery(args: { channel: deliveryChannel, item: openWorkItem, log: args.log, + attachmentBaseDirs: args.attachmentBaseDirs, isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal, openContinuation: args.openContinuation, }); diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index 5e2bce3..237de10 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -11,9 +11,10 @@ import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js'; import { normalizeMessageForDedupe } from './router.js'; import type { ExecuteTurnFn } from './message-runtime-types.js'; import type { + AgentType, Channel, NewMessage, - AgentType, + OutboundAttachment, PairedRoomRole, RegisteredGroup, } from './types.js'; @@ -100,6 +101,7 @@ interface CreateExecuteTurnDeps { clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; deliverFinalText: (args: { text: string; + attachments?: OutboundAttachment[]; chatJid: string; runId: string; channel: Channel; @@ -202,6 +204,9 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { try { return await deps.deliverFinalText({ text, + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), chatJid, runId, channel, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index bea78ab..b2c197a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -210,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { clearSession: deps.clearSession, deliverFinalText: async ({ text, + attachments, chatJid, runId, channel, @@ -248,11 +249,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { start_seq: startSeq, end_seq: endSeq, result_payload: text, + attachments, }); return deliverOpenWorkItem({ channel, item: workItem, log: logger, + attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, replaceMessageId, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, openContinuation: (targetChatJid) => @@ -455,6 +458,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel, roleToChannel, log, + attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, isPairedRoom: hasReviewerLease(chatJid), getMissingRoleChannelMessage, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index 7137190..ecc686a 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -317,6 +317,62 @@ describe('MessageTurnController outbound audit logging', () => { ); }); + it('passes structured final attachments to final delivery', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + const attachments = [ + { + path: '/tmp/e2e-screenshot.png', + name: 'e2e-screenshot.png', + mime: 'image/png', + }, + ]; + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-review-attachments', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'reviewer', + deliveryServiceId: 'codex-review', + pairedTurnIdentity: makeTurnIdentity(), + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'final', + result: '스크린샷을 첨부했습니다.', + output: { + visibility: 'public', + text: '스크린샷을 첨부했습니다.', + attachments, + }, + } as any); + await controller.finish('success'); + + expect(deliverFinalText).toHaveBeenCalledWith( + '스크린샷을 첨부했습니다.', + { + replaceMessageId: null, + attachments, + }, + ); + expect(getAuditEntries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + auditEvent: 'final-delivery-attempt', + attachmentCount: 1, + }), + ]), + ); + }); + it('replaces the tracked progress message when an owner final arrives', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 31424a4..ba0b6fd 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -1,5 +1,8 @@ import { type AgentOutput } from './agent-runner.js'; -import { getAgentOutputText } from './agent-output.js'; +import { + getAgentOutputAttachments, + getAgentOutputText, +} from './agent-output.js'; import { createScopedLogger, logger } from './logger.js'; import { formatOutbound } from './router.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; @@ -11,6 +14,7 @@ import { toVisiblePhase, type AgentOutputPhase, type Channel, + type OutboundAttachment, type PairedRoomRole, type RegisteredGroup, type VisiblePhase, @@ -35,7 +39,10 @@ interface MessageTurnControllerOptions { requestClose: (reason: string) => void; deliverFinalText: ( text: string, - options?: { replaceMessageId?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + replaceMessageId?: string | null; + }, ) => Promise; canDeliverFinalText?: () => boolean; allowProgressReplayWithoutFinal?: boolean; @@ -160,6 +167,7 @@ export class MessageTurnController { const raw = getAgentOutputText(result); const text = raw ? formatOutbound(raw) : null; + const attachments = getAgentOutputAttachments(result); if (raw) { this.log.info( @@ -297,6 +305,7 @@ export class MessageTurnController { // then discard the pending buffer so it never shows up. if (text) { await this.publishTerminalText(text, { + attachments, flushPendingText: text, }); } else if (raw) { @@ -648,14 +657,22 @@ export class MessageTurnController { private async publishTerminalText( text: string, - options?: { flushPendingText?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + flushPendingText?: string | null; + }, ): Promise { if (options?.flushPendingText) { await this.flushPendingProgress(options.flushPendingText); } const replaceMessageId = this.consumeProgressForFinalDelivery(); - await this.deliverFinalText(text, { replaceMessageId }); + await this.deliverFinalText(text, { + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), + replaceMessageId, + }); } private consumeProgressForFinalDelivery(): string | null { @@ -675,7 +692,10 @@ export class MessageTurnController { private async deliverFinalText( text: string, - options?: { replaceMessageId?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + replaceMessageId?: string | null; + }, ): Promise { await this.activateTyping('turn:deliver-final'); this.visiblePhase = toVisiblePhase('final'); @@ -697,14 +717,19 @@ export class MessageTurnController { return; } this.logOutboundAudit('final-delivery-attempt', { + attachmentCount: options?.attachments?.length ?? 0, messageId: replaceMessageId, textLength: text.length, deliveryMode: replaceMessageId ? 'edit' : 'send', }); const delivered = await this.options.deliverFinalText(text, { + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), replaceMessageId, }); this.logOutboundAudit('final-delivery-result', { + attachmentCount: options?.attachments?.length ?? 0, messageId: replaceMessageId, textLength: text.length, deliveryMode: replaceMessageId ? 'edit' : 'send', diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts new file mode 100644 index 0000000..85c0fa3 --- /dev/null +++ b/src/outbound-attachments.test.ts @@ -0,0 +1,149 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { validateOutboundAttachments } from './outbound-attachments.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const cleanupDirs: string[] = []; + +function makeTempDir(baseDir: string, prefix: string): string { + const dir = fs.mkdtempSync(path.join(baseDir, prefix)); + cleanupDirs.push(dir); + return dir; +} + +function writeFile( + dir: string, + name: string, + content: Buffer | string, +): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content); + return filePath; +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('validateOutboundAttachments', () => { + it('accepts real image files under default attachment directories', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const imagePath = writeFile(dir, 'screenshot.png', ONE_PIXEL_PNG); + + const result = validateOutboundAttachments([ + { + path: imagePath, + name: '../unsafe-name.png', + mime: 'image/png', + }, + ]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'unsafe-name.png', + }, + ]); + }); + + it('requires workspace paths to be explicitly allowlisted', () => { + const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); + const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); + + expect(validateOutboundAttachments([{ path: imagePath }])).toEqual({ + files: [], + rejected: [{ path: imagePath, reason: 'outside-allowed-dirs' }], + }); + + const allowed = validateOutboundAttachments([{ path: imagePath }], { + baseDirs: [dir], + }); + + expect(allowed.rejected).toEqual([]); + expect(allowed.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'workspace-shot.png', + }, + ]); + }); + + it('rejects symlink attempts that escape the allowed directory', () => { + const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); + const targetPath = writeFile( + workspaceDir, + 'secret-shot.png', + ONE_PIXEL_PNG, + ); + const tmpDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const linkPath = path.join(tmpDir, 'linked-shot.png'); + fs.symlinkSync(targetPath, linkPath); + + const result = validateOutboundAttachments([{ path: linkPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: linkPath, reason: 'outside-allowed-dirs' }, + ]); + }); + + it('rejects SVG attachments before inspecting file content', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const svgPath = writeFile(dir, 'vector.svg', ''); + + const result = validateOutboundAttachments([{ path: svgPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: svgPath, reason: 'unsupported-extension' }, + ]); + }); + + it('rejects files whose extension and image signature do not match policy', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const fakePng = writeFile(dir, 'fake.png', 'not an image'); + const realPng = writeFile(dir, 'real.png', ONE_PIXEL_PNG); + + expect(validateOutboundAttachments([{ path: fakePng }])).toEqual({ + files: [], + rejected: [{ path: fakePng, reason: 'invalid-image-signature' }], + }); + expect( + validateOutboundAttachments([{ path: realPng, mime: 'image/jpeg' }]), + ).toEqual({ + files: [], + rejected: [{ path: realPng, reason: 'mime-mismatch' }], + }); + }); + + it('rejects non-files and files over the size cap', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const nestedDir = path.join(dir, 'nested.png'); + fs.mkdirSync(nestedDir); + const largePng = writeFile( + dir, + 'large.png', + Buffer.concat([ONE_PIXEL_PNG, Buffer.alloc(8 * 1024 * 1024)]), + ); + + expect(validateOutboundAttachments([{ path: nestedDir }])).toEqual({ + files: [], + rejected: [{ path: nestedDir, reason: 'not-file' }], + }); + expect(validateOutboundAttachments([{ path: largePng }])).toEqual({ + files: [], + rejected: [{ path: largePng, reason: 'too-large' }], + }); + }); +}); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts new file mode 100644 index 0000000..fc7feb7 --- /dev/null +++ b/src/outbound-attachments.ts @@ -0,0 +1,174 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import type { OutboundAttachment } from './types.js'; + +export interface ValidatedOutboundAttachment { + attachment: string; + name: string; +} + +export interface ValidateOutboundAttachmentsResult { + files: ValidatedOutboundAttachment[]; + rejected: Array<{ path: string; reason: string }>; +} + +const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; + +function unique(values: Array): string[] { + return [ + ...new Set(values.filter((value): value is string => Boolean(value))), + ]; +} + +function resolveExistingDir(dir: string): string | null { + try { + if (!fs.existsSync(dir)) return null; + return fs.realpathSync(dir); + } catch { + return null; + } +} + +export function getDefaultAttachmentBaseDirs(): string[] { + const home = process.env.HOME; + const codexHome = + process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); + // Keep defaults narrow. Runtime-specific workspaces must be passed via + // attachmentBaseDirs so one room cannot attach another room's files by path. + return unique([ + '/tmp', + path.join(DATA_DIR, 'attachments'), + codexHome ? path.join(codexHome, 'generated_images') : null, + ]) + .map(resolveExistingDir) + .filter((dir): dir is string => Boolean(dir)); +} + +function isWithinBaseDir(realPath: string, baseDir: string): boolean { + const relative = path.relative(baseDir, realPath); + return ( + relative === '' || + (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean { + return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir)); +} + +function detectImageMime(filePath: string): string | null { + const handle = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(512); + const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0); + const header = buffer.subarray(0, bytesRead); + if ( + header + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return 'image/png'; + } + if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) { + return 'image/jpeg'; + } + if ( + header.subarray(0, 6).toString('ascii') === 'GIF87a' || + header.subarray(0, 6).toString('ascii') === 'GIF89a' + ) { + return 'image/gif'; + } + if ( + header.subarray(0, 4).toString('ascii') === 'RIFF' && + header.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + if (header[0] === 0x42 && header[1] === 0x4d) { + return 'image/bmp'; + } + return null; + } finally { + fs.closeSync(handle); + } +} + +function normalizeAttachmentName( + attachment: OutboundAttachment, + realPath: string, +): string { + const candidate = attachment.name + ? path.basename(attachment.name) + : path.basename(realPath); + return candidate || 'attachment'; +} + +export function validateOutboundAttachments( + attachments: OutboundAttachment[] | undefined, + options: { baseDirs?: string[] } = {}, +): ValidateOutboundAttachmentsResult { + const baseDirs = unique([ + ...getDefaultAttachmentBaseDirs(), + ...(options.baseDirs ?? []).map(resolveExistingDir), + ]).filter((dir): dir is string => Boolean(dir)); + const files: ValidatedOutboundAttachment[] = []; + const rejected: Array<{ path: string; reason: string }> = []; + const seen = new Set(); + + for (const attachment of attachments ?? []) { + const requestedPath = attachment.path; + try { + if (!path.isAbsolute(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'not-absolute' }); + continue; + } + if (!IMAGE_EXTS.test(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'unsupported-extension' }); + continue; + } + if (!fs.existsSync(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'not-found' }); + continue; + } + const realPath = fs.realpathSync(requestedPath); + if (seen.has(realPath)) continue; + const stat = fs.statSync(realPath); + if (!stat.isFile()) { + rejected.push({ path: requestedPath, reason: 'not-file' }); + continue; + } + if (stat.size > MAX_ATTACHMENT_BYTES) { + rejected.push({ path: requestedPath, reason: 'too-large' }); + continue; + } + if (!matchesAllowedBaseDir(realPath, baseDirs)) { + rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); + continue; + } + const detectedMime = detectImageMime(realPath); + if (!detectedMime) { + rejected.push({ + path: requestedPath, + reason: 'invalid-image-signature', + }); + continue; + } + if (attachment.mime && attachment.mime !== detectedMime) { + rejected.push({ path: requestedPath, reason: 'mime-mismatch' }); + continue; + } + files.push({ + attachment: realPath, + name: normalizeAttachmentName(attachment, realPath), + }); + seen.add(realPath); + } catch { + rejected.push({ path: requestedPath, reason: 'validation-error' }); + } + } + + return { files, rejected }; +} diff --git a/src/types.ts b/src/types.ts index 5c00ed3..07f72f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,22 @@ export type VisiblePhase = 'silent' | 'progress' | 'final'; export type AgentVisibility = 'public' | 'silent'; +export interface OutboundAttachment { + path: string; + name?: string; + mime?: string; +} + +export interface SendMessageOptions { + attachments?: OutboundAttachment[]; + /** + * Extra realpath roots that are valid for this delivery attempt. Runtime + * callers can pass the active project/workspace directory without widening + * the global Discord attachment allowlist. + */ + attachmentBaseDirs?: string[]; +} + export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = @@ -126,6 +142,7 @@ export type StructuredAgentOutput = | { visibility: 'public'; text: string; + attachments?: OutboundAttachment[]; } | { visibility: 'silent'; @@ -234,7 +251,11 @@ export interface ChannelOutboundAuditMeta { export interface Channel { name: string; connect(): Promise; - sendMessage(jid: string, text: string): Promise; + sendMessage( + jid: string, + text: string, + options?: SendMessageOptions, + ): Promise; isConnected(): boolean; ownsJid(jid: string): boolean; // Optional: whether a stored inbound message was authored by this channel's own bot/user. From 3c13ab6ed8523c2f643539be531de212c27f1d44 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 25 Apr 2026 09:22:01 +0900 Subject: [PATCH 19/48] style: format structured attachment test --- src/message-turn-controller.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index ecc686a..acc4912 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -356,13 +356,10 @@ describe('MessageTurnController outbound audit logging', () => { } as any); await controller.finish('success'); - expect(deliverFinalText).toHaveBeenCalledWith( - '스크린샷을 첨부했습니다.', - { - replaceMessageId: null, - attachments, - }, - ); + expect(deliverFinalText).toHaveBeenCalledWith('스크린샷을 첨부했습니다.', { + replaceMessageId: null, + attachments, + }); expect(getAuditEntries()).toEqual( expect.arrayContaining([ expect.objectContaining({ From bd7e8a2debf1b81c9c129bdac580f1f141a90727 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 25 Apr 2026 09:30:40 +0900 Subject: [PATCH 20/48] Allow Codex warm-up state file --- src/data-state-files.test.ts | 43 ++++++++++++++++++++++++++++++++++++ src/data-state-files.ts | 1 + 2 files changed, 44 insertions(+) create mode 100644 src/data-state-files.test.ts diff --git a/src/data-state-files.test.ts b/src/data-state-files.test.ts new file mode 100644 index 0000000..d40d1c7 --- /dev/null +++ b/src/data-state-files.test.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { listUnexpectedDataStateFiles } from './data-state-files.js'; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-data-state-')); + tempDirs.push(tempDir); + return tempDir; +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } +}); + +describe('listUnexpectedDataStateFiles', () => { + it('allows known runtime state files', () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, 'token-rotation-state.json'), '{}'); + fs.writeFileSync(path.join(dataDir, 'codex-rotation-state.json'), '{}'); + fs.writeFileSync(path.join(dataDir, 'codex-warmup-state.json'), '{}'); + fs.writeFileSync(path.join(dataDir, 'claude-usage-cache.json'), '{}'); + fs.writeFileSync(path.join(dataDir, 'restart-context.abc.json'), '{}'); + + expect(listUnexpectedDataStateFiles(dataDir)).toEqual([]); + }); + + it('reports unsupported legacy state files', () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, 'router_state.json'), '{}'); + + expect(listUnexpectedDataStateFiles(dataDir)).toEqual([ + 'router_state.json', + ]); + }); +}); diff --git a/src/data-state-files.ts b/src/data-state-files.ts index 420e416..db6cff1 100644 --- a/src/data-state-files.ts +++ b/src/data-state-files.ts @@ -4,6 +4,7 @@ import path from 'path'; const ALLOWED_DATA_JSON_PATTERNS = [ /^token-rotation-state\.json$/, /^codex-rotation-state\.json$/, + /^codex-warmup-state\.json$/, /^claude-usage-cache\.json$/, /^restart-context\..+\.json$/, ]; From cd7e35a8b0492184cbe31c9cdda0baa49909fb4b Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 25 Apr 2026 10:14:33 +0900 Subject: [PATCH 21/48] Parse fenced EJClaw output envelopes --- prompts/owner-common-platform.md | 1 + runners/shared/src/agent-protocol.ts | 10 ++- runners/shared/test/agent-protocol.test.ts | 98 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/prompts/owner-common-platform.md b/prompts/owner-common-platform.md index 67204b8..618b41f 100644 --- a/prompts/owner-common-platform.md +++ b/prompts/owner-common-platform.md @@ -41,6 +41,7 @@ For locally generated images or e2e screenshots that should appear in Discord, p } ``` +- When emitting this as your final runner output, emit the JSON envelope directly. Do not wrap it in Markdown fences or add prose outside the JSON. - Use absolute local paths only - Do not duplicate the same path in the visible text - Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 20a1494..de2e6cc 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -124,6 +124,13 @@ function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { return attachments; } +function extractStructuredJsonCandidate(trimmed: string): string { + const fencedJson = trimmed.match( + /^```(?:json|JSON)?[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/, + ); + return fencedJson?.[1]?.trim() ?? trimmed; +} + export function normalizeEjclawStructuredOutput( result: string | null, ): NormalizedRunnerOutput { @@ -132,8 +139,9 @@ export function normalizeEjclawStructuredOutput( } const trimmed = result.trim(); + const jsonCandidate = extractStructuredJsonCandidate(trimmed); try { - const parsed = JSON.parse(trimmed) as { + const parsed = JSON.parse(jsonCandidate) as { ejclaw?: { visibility?: unknown; text?: unknown; diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 74e631f..7b166f6 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -86,6 +86,104 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses fenced public ejclaw attachments', () => { + expect( + normalizeEjclawStructuredOutput(`\`\`\`json +{ + "ejclaw": { + "visibility": "public", + "text": "검 아이콘을 생성했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/tmp/imagegen-sword.png", + "name": "imagegen-sword.png", + "mime": "image/png" + } + ] + } +} +\`\`\``), + ).toEqual({ + result: '검 아이콘을 생성했습니다.', + output: { + visibility: 'public', + text: '검 아이콘을 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/imagegen-sword.png', + name: 'imagegen-sword.png', + mime: 'image/png', + }, + ], + }, + }); + }); + + it('parses unlabeled fenced ejclaw envelopes', () => { + expect( + normalizeEjclawStructuredOutput(`\`\`\` +{"ejclaw":{"visibility":"public","text":"스크린샷입니다.","verdict":"done"}} +\`\`\``), + ).toEqual({ + result: '스크린샷입니다.', + output: { + visibility: 'public', + text: '스크린샷입니다.', + verdict: 'done', + }, + }); + }); + + it('does not parse mixed prose and fenced ejclaw JSON as structured output', () => { + const raw = `설명입니다. + +\`\`\`json +{"ejclaw":{"visibility":"public","text":"첨부입니다.","verdict":"done"}} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + + it('falls back when fenced JSON has no ejclaw envelope', () => { + const raw = `\`\`\`json +{"text":"plain json"} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + + it('falls back when multiple fenced JSON blocks are present', () => { + const raw = `\`\`\`json +{"ejclaw":{"visibility":"public","text":"첫 번째","verdict":"done"}} +\`\`\` + +\`\`\`json +{"ejclaw":{"visibility":"public","text":"두 번째","verdict":"done"}} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + it('falls back to visible raw text on invalid public verdicts', () => { const raw = JSON.stringify({ ejclaw: { From a74ea4bad03d24dbc02cd5b663be7ce8447e1448 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 25 Apr 2026 12:02:02 +0900 Subject: [PATCH 22/48] fix: preserve paired owner workspace branch invariant Safely re-anchor drifted paired owner workspaces, guard owner completion handling, and tighten generated attachment temp-dir allowlisting. --- prompts/owner-common-paired-room.md | 22 ++--- src/outbound-attachments.ts | 32 +++++++- src/paired-execution-context.test.ts | 36 +++++++++ src/paired-execution-context.ts | 21 +++++ src/paired-workspace-manager.test.ts | 94 ++++++++++++++++++++-- src/paired-workspace-manager.ts | 115 ++++++++++++++++++++++----- 6 files changed, 281 insertions(+), 39 deletions(-) diff --git a/prompts/owner-common-paired-room.md b/prompts/owner-common-paired-room.md index 0a1babd..f91f0a5 100644 --- a/prompts/owner-common-paired-room.md +++ b/prompts/owner-common-paired-room.md @@ -44,23 +44,25 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc ## 🔴 Workspace Branch Protocol (MANDATORY) -The owner workspace is managed by EJClaw's paired-room state machine. The workspace branch name MUST be `codex/owner/` at the moment your turn ends. If any other branch is checked out when the next message arrives, the entire room goes BLOCKED with "branch mismatch" and needs manual git recovery. +The owner workspace is managed by EJClaw's paired-room state machine. The persistent owner workspace branch name MUST remain `codex/owner/`. Treat this as an invariant for the whole turn, not just a cleanup step at the end. If any other branch is checked out when the next message arrives, the entire room can go BLOCKED with "branch mismatch" and need manual git recovery. ### Every turn, in order -1. **Start**: verify `git branch --show-current`. If it is not `codex/owner/`, check it out before doing anything else. -2. **Work**: feel free to create feature branches (`fix/...`, `feat/...`) while implementing. -3. **Before you finish the turn**: - - If you committed on a feature branch, merge it back into the owner branch (`git checkout codex/owner/ && git merge --ff-only `), then optionally delete the feature branch. - - If the feature branch is behind or diverged, use the appropriate merge/rebase to land the work on the owner branch. - - Confirm a clean end-state: `git branch --show-current` prints `codex/owner/` and `git status --short` is empty (or at least there is no merge conflict and no stray dirty state that the next turn cannot understand). -4. **Only then** emit your DONE / DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT line and exit. +1. **Start**: verify `git branch --show-current`. It must print `codex/owner/` before you modify files. +2. **Work in place on the owner branch** by default. Do not run `git checkout -b`, `git switch -c`, or switch the persistent owner workspace to `fix/*`, `feat/*`, `codex/main-*`, or any other branch. +3. **If a temporary branch is truly necessary**, create it in a separate worktree outside the persistent owner workspace (for example under `/tmp`) and merge/cherry-pick the finished commits back into `codex/owner/`. Do not leave the persistent `/owner` workspace checked out on that temporary branch. +4. **Before you finish the turn**: + - Confirm the invariant again: `git branch --show-current` prints `codex/owner/`. + - Confirm there is no unresolved git operation: no merge conflict, no detached HEAD, no rebase/cherry-pick in progress. + - `git status --short` should be empty unless the task intentionally requires uncommitted files for the next owner step; if so, explain that state explicitly in your status line. +5. **Only then** emit your DONE / DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT line and exit. ### Hard rules -- Never end a turn on a `fix/*` or `feat/*` branch. +- Never switch the persistent owner workspace itself to a `fix/*`, `feat/*`, `codex/main-*`, `codex/hotfix-*`, or detached-HEAD branch. +- Never end a turn on any branch except `codex/owner/`. - Never end a turn with unresolved merge conflicts. -- Never leave the workspace in detached-HEAD or rebase-in-progress state at turn end. +- Never leave the workspace in detached-HEAD, rebase-in-progress, cherry-pick-in-progress, or bisect state at turn end. ### If you discover a mismatch at turn start diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts index fc7feb7..fffd8e3 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { DATA_DIR } from './config.js'; @@ -16,6 +17,10 @@ export interface ValidateOutboundAttachmentsResult { const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; +const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [ + 'ejclaw-attachment-', + 'ejclaw-discord-image-', +] as const; function unique(values: Array): string[] { return [ @@ -38,8 +43,9 @@ export function getDefaultAttachmentBaseDirs(): string[] { process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); // Keep defaults narrow. Runtime-specific workspaces must be passed via // attachmentBaseDirs so one room cannot attach another room's files by path. + // Ad-hoc generated files under os.tmpdir()/ejclaw-attachment-* are handled + // separately after resolving symlinks, without allowlisting all of /tmp. return unique([ - '/tmp', path.join(DATA_DIR, 'attachments'), codexHome ? path.join(codexHome, 'generated_images') : null, ]) @@ -59,6 +65,24 @@ function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean { return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir)); } +function isWithinDefaultTempAttachmentDir( + realPath: string, + tempDir: string | null, +): boolean { + if (!tempDir) return false; + const relative = path.relative(tempDir, realPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + const [firstSegment, ...rest] = relative.split(path.sep); + return ( + rest.length > 0 && + DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES.some((prefix) => + firstSegment.startsWith(prefix), + ) + ); +} + function detectImageMime(filePath: string): string | null { const handle = fs.openSync(filePath, 'r'); try { @@ -114,6 +138,7 @@ export function validateOutboundAttachments( ...getDefaultAttachmentBaseDirs(), ...(options.baseDirs ?? []).map(resolveExistingDir), ]).filter((dir): dir is string => Boolean(dir)); + const defaultTempDir = resolveExistingDir(os.tmpdir()); const files: ValidatedOutboundAttachment[] = []; const rejected: Array<{ path: string; reason: string }> = []; const seen = new Set(); @@ -144,7 +169,10 @@ export function validateOutboundAttachments( rejected.push({ path: requestedPath, reason: 'too-large' }); continue; } - if (!matchesAllowedBaseDir(realPath, baseDirs)) { + if ( + !matchesAllowedBaseDir(realPath, baseDirs) && + !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) + ) { rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); continue; } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 1c4ab42..ba33ec2 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -802,6 +802,42 @@ describe('paired execution context', () => { ).not.toHaveBeenCalled(); }); + it('re-anchors the owner workspace before handling a successful owner completion', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'active', + round_trip_count: 1, + }), + ); + vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue( + { + ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'), + reviewerWorkspace: buildWorkspace( + 'reviewer', + '/tmp/paired/task-1/reviewer', + ), + }, + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속', + }); + + expect( + pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, + ).toHaveBeenCalledWith('task-1'); + const reanchorCallOrder = vi.mocked( + pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, + ).mock.invocationCallOrder[0]; + const reviewReadyCallOrder = vi.mocked( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).mock.invocationCallOrder[0]; + expect(reanchorCallOrder).toBeLessThan(reviewReadyCallOrder); + }); + it('requests review when the owner reports STEP_DONE in active mode', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 3732eee..e8777ea 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -676,6 +676,27 @@ export function completePairedExecutionContext(args: { } if (role === 'owner') { + try { + provisionOwnerWorkspaceForPairedTask(taskId); + } catch (error) { + if (isOwnerWorkspaceRepairNeededError(error)) { + logger.warn( + { + taskId, + role, + repairMessage: error.blockMessage || error.message, + }, + 'Owner workspace post-run guard blocked completion handling', + ); + handleFailedOwnerExecution({ + task, + taskId, + summary: error.blockMessage || error.message, + }); + return; + } + throw error; + } handleOwnerCompletion({ task, taskId, summary: args.summary }); return; } diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index d1a4e24..b5eb64d 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -1145,7 +1145,7 @@ describe('paired workspace manager', () => { ).toBe(ownerBranchName('named-existing-room')); }); - it('blocks provisioning when a named owner workspace branch mismatch has local changes', async () => { + it('re-anchors a dirty named owner workspace without touching local changes', async () => { const { db, manager } = await loadModules(); db._initTestDatabase(); @@ -1156,6 +1156,10 @@ describe('paired workspace manager', () => { groupFolder: 'dirty-named-room', }); + runGit( + ['branch', ownerBranchName('dirty-named-room'), 'HEAD'], + canonicalDir, + ); const workspaceDir = ownerWorkspacePath('dirty-named-room'); fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); runGit( @@ -1173,14 +1177,90 @@ describe('paired workspace manager', () => { path.join(workspaceDir, 'README.md'), 'dirty named branch\n', ); + fs.writeFileSync(path.join(workspaceDir, 'NOTES.md'), 'untracked keep\n'); + const dirtyBefore = runGit(['status', '--short'], workspaceDir); - expect(() => - manager.provisionOwnerWorkspaceForPairedTask( - 'paired-task-dirty-named-branch', + const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-dirty-named-branch', + ); + + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('dirty-named-room')); + expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toBe( + dirtyBefore, + ); + expect( + fs.readFileSync( + path.join(ownerWorkspace.workspace_dir, 'README.md'), + 'utf-8', ), - ).toThrow(/Owner workspace needs repair/); - expect(runGit(['branch', '--show-current'], workspaceDir)).toBe( - 'codex/owner/dirty-named-room-sync', + ).toBe('dirty named branch\n'); + expect( + fs.readFileSync( + path.join(ownerWorkspace.workspace_dir, 'NOTES.md'), + 'utf-8', + ), + ).toBe('untracked keep\n'); + expect( + runGit(['branch', '--list', 'backup/dirty-named-room-*'], canonicalDir), + ).toContain('backup/dirty-named-room-current-pre-reanchor-'); + }); + + it('re-anchors a clean divergent named owner workspace to the current feature branch head', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-divergent-named-branch', + groupFolder: 'divergent-named-room', + }); + + runGit( + ['branch', ownerBranchName('divergent-named-room'), 'HEAD'], + canonicalDir, + ); + const workspaceDir = ownerWorkspacePath('divergent-named-room'); + fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); + runGit( + [ + 'worktree', + 'add', + '-b', + 'codex/feature-divergent-named-room', + workspaceDir, + 'HEAD', + ], + canonicalDir, + ); + fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'feature head\n'); + runGit(['add', 'README.md'], workspaceDir); + runGit(['commit', '-m', 'feature work'], workspaceDir); + const featureHead = runGit(['rev-parse', 'HEAD'], workspaceDir); + + const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-divergent-named-branch', + ); + + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('divergent-named-room')); + expect( + runGit( + ['rev-parse', ownerBranchName('divergent-named-room')], + canonicalDir, + ), + ).toBe(featureHead); + expect( + fs.readFileSync( + path.join(ownerWorkspace.workspace_dir, 'README.md'), + 'utf-8', + ), + ).toBe('feature head\n'); + expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toBe( + '', ); }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index 2cb88af..966ce47 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -152,6 +152,74 @@ Workspace: \`${args.workspaceDir}\` Repair the owner workspace branch, then retry the task.`; } +function buildOwnerReanchorBackupPrefix(targetBranch: string): string { + const groupFolder = targetBranch.startsWith('codex/owner/') + ? targetBranch.slice('codex/owner/'.length) + : targetBranch.replace(/\//g, '-'); + return `backup/${groupFolder}`; +} + +function buildOwnerReanchorBackupSuffix(): string { + const timestamp = new Date() + .toISOString() + .replace(/[-:.TZ]/g, '') + .slice(0, 14); + return `${timestamp}-${crypto.randomBytes(3).toString('hex')}`; +} + +function reanchorNamedOwnerWorkspaceBranch(args: { + canonicalWorkDir: string; + workspaceDir: string; + currentBranch: string; + targetBranch: string; + targetBranchCommit: string | null; + currentHeadCommit: string; + reason: string; +}): boolean { + const { + canonicalWorkDir, + workspaceDir, + currentBranch, + targetBranch, + targetBranchCommit, + currentHeadCommit, + reason, + } = args; + + ensureBranchNotCheckedOutElsewhere( + canonicalWorkDir, + workspaceDir, + targetBranch, + ); + + const backupPrefix = buildOwnerReanchorBackupPrefix(targetBranch); + const backupSuffix = buildOwnerReanchorBackupSuffix(); + const currentBackupBranch = `${backupPrefix}-current-pre-reanchor-${backupSuffix}`; + const targetBackupBranch = `${backupPrefix}-target-pre-reanchor-${backupSuffix}`; + + runGit(['branch', currentBackupBranch, currentBranch], workspaceDir); + if (targetBranchCommit) { + runGit(['branch', targetBackupBranch, targetBranch], workspaceDir); + } + runGit(['branch', '-f', targetBranch, currentHeadCommit], workspaceDir); + runGit(['symbolic-ref', 'HEAD', branchRefName(targetBranch)], workspaceDir); + + logger.warn( + { + workspaceDir, + previousBranch: currentBranch, + targetBranch, + currentHeadCommit, + targetBranchCommit, + currentBackupBranch, + targetBackupBranch: targetBranchCommit ? targetBackupBranch : null, + reason, + }, + 'Re-anchored owner workspace branch mismatch while preserving worktree state', + ); + return true; +} + function maybeRepairNamedOwnerWorkspaceBranch(args: { canonicalWorkDir: string; workspaceDir: string; @@ -174,32 +242,39 @@ function maybeRepairNamedOwnerWorkspaceBranch(args: { } if (!isGitWorktreeClean(workspaceDir)) { - throw new OwnerWorkspaceRepairNeededError( - buildOwnerWorkspaceRepairBlockMessage({ - workspaceDir, - currentBranch, - targetBranch, - reason: - 'Automatic repair was skipped because the workspace has local changes.', - }), - ); + return reanchorNamedOwnerWorkspaceBranch({ + canonicalWorkDir, + workspaceDir, + currentBranch, + targetBranch, + targetBranchCommit, + currentHeadCommit, + reason: 'workspace has local changes', + }); } if (!targetBranchCommit) { - runGit(['switch', '-c', targetBranch], workspaceDir); - return true; + return reanchorNamedOwnerWorkspaceBranch({ + canonicalWorkDir, + workspaceDir, + currentBranch, + targetBranch, + targetBranchCommit, + currentHeadCommit, + reason: 'expected branch does not exist yet', + }); } if (targetBranchCommit !== currentHeadCommit) { - throw new OwnerWorkspaceRepairNeededError( - buildOwnerWorkspaceRepairBlockMessage({ - workspaceDir, - currentBranch, - targetBranch, - reason: - 'Automatic repair was skipped because the expected branch points at a different commit.', - }), - ); + return reanchorNamedOwnerWorkspaceBranch({ + canonicalWorkDir, + workspaceDir, + currentBranch, + targetBranch, + targetBranchCommit, + currentHeadCommit, + reason: 'expected branch points at a different commit', + }); } ensureBranchNotCheckedOutElsewhere( From d52556fd28904386762fe7663747f261f8b47a2c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 25 Apr 2026 14:41:46 +0900 Subject: [PATCH 23/48] fix: preserve structured attachment outputs (#17) --- runners/agent-runner/src/index.ts | 2 +- runners/shared/src/agent-protocol.ts | 15 ++++- runners/shared/test/agent-protocol.test.ts | 71 ++++++++++++++++++++++ src/message-turn-controller.test.ts | 65 ++++++++++++++++++++ src/message-turn-controller.ts | 19 +++++- 5 files changed, 168 insertions(+), 4 deletions(-) diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 9665bba..295daff 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -356,7 +356,7 @@ async function runQuery( writeOutput({ status: 'success', phase: 'intermediate', - result: pendingProgressText, + ...normalizeStructuredOutput(pendingProgressText), newSessionId, }); pendingProgressText = null; diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index de2e6cc..6268d66 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -18,6 +18,7 @@ export type RunnerOutputVerdict = | 'done' | 'done_with_concerns' | 'blocked' + | 'in_progress' | 'silent'; export type RunnerOutputVisibility = 'public' | 'silent'; @@ -93,10 +94,20 @@ function isVisibleVerdict( value: unknown, ): value is Exclude { return ( - value === 'done' || value === 'done_with_concerns' || value === 'blocked' + value === 'done' || + value === 'done_with_concerns' || + value === 'blocked' || + value === 'in_progress' ); } +const LEADING_STRUCTURED_OUTPUT_CONTROL_RE = + /^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u; + +function stripLeadingStructuredOutputControls(value: string): string { + return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart(); +} + function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { if (!Array.isArray(value)) return []; @@ -138,7 +149,7 @@ export function normalizeEjclawStructuredOutput( return { result }; } - const trimmed = result.trim(); + const trimmed = stripLeadingStructuredOutputControls(result.trim()); const jsonCandidate = extractStructuredJsonCandidate(trimmed); try { const parsed = JSON.parse(jsonCandidate) as { diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 7b166f6..da4a26d 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -121,6 +121,77 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses fenced ejclaw envelopes with leading invisible control characters', () => { + expect( + normalizeEjclawStructuredOutput(`\u2063\u2063\u2063\`\`\`json +{ + "ejclaw": { + "visibility": "public", + "text": "최종 이미지를 첨부했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/tmp/ejclaw-discord-image-final.png", + "name": "final.png", + "mime": "image/png" + } + ] + } +} +\`\`\``), + ).toEqual({ + result: '최종 이미지를 첨부했습니다.', + output: { + visibility: 'public', + text: '최종 이미지를 첨부했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-final.png', + name: 'final.png', + mime: 'image/png', + }, + ], + }, + }); + }); + + it('parses in_progress public envelopes instead of leaking raw structured JSON', () => { + const raw = JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '이미지를 생성하는 중입니다.', + verdict: 'in_progress', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-draft.png', + name: 'draft.png', + mime: 'image/png', + }, + ], + }, + }); + + const normalized = normalizeEjclawStructuredOutput(raw); + + expect(normalized).toEqual({ + result: '이미지를 생성하는 중입니다.', + output: { + visibility: 'public', + text: '이미지를 생성하는 중입니다.', + verdict: 'in_progress', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-draft.png', + name: 'draft.png', + mime: 'image/png', + }, + ], + }, + }); + expect(normalized.result).not.toContain('"ejclaw"'); + }); + it('parses unlabeled fenced ejclaw envelopes', () => { expect( normalizeEjclawStructuredOutput(`\`\`\` diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index acc4912..a48f153 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -370,6 +370,71 @@ describe('MessageTurnController outbound audit logging', () => { ); }); + it('sends final attachments as a fresh final message instead of replacing text-only progress', async () => { + const channel = { + ...makeChannel(), + name: 'discord', + } satisfies Channel; + const deliverFinalText = vi.fn().mockResolvedValue(true); + const attachments = [ + { + path: '/tmp/ejclaw-discord-image-final.png', + name: 'final.png', + mime: 'image/png', + }, + ]; + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-owner-final-attachment-send', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn', + taskId: 'task-1', + taskUpdatedAt: '2026-04-10T14:22:00.000Z', + intentKind: 'finalize-owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '이미지 렌더링 중', + } as any); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '이미지 파일 쓰는 중', + } as any); + await flushAsync(); + await controller.handleOutput({ + status: 'success', + phase: 'final', + result: '이미지 렌더 완료.', + output: { + visibility: 'public', + text: '이미지 렌더 완료.', + attachments, + }, + } as any); + await controller.finish('success'); + + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(deliverFinalText).toHaveBeenCalledWith('이미지 렌더 완료.', { + replaceMessageId: null, + attachments, + }); + }); + it('replaces the tracked progress message when an owner final arrives', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index ba0b6fd..6e48f8d 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -666,7 +666,10 @@ export class MessageTurnController { await this.flushPendingProgress(options.flushPendingText); } - const replaceMessageId = this.consumeProgressForFinalDelivery(); + const hasAttachments = (options?.attachments?.length ?? 0) > 0; + const replaceMessageId = hasAttachments + ? this.discardProgressForAttachmentFinalDelivery() + : this.consumeProgressForFinalDelivery(); await this.deliverFinalText(text, { ...(options?.attachments?.length ? { attachments: options.attachments } @@ -690,6 +693,20 @@ export class MessageTurnController { return replaceMessageId; } + private discardProgressForAttachmentFinalDelivery(): null { + this.log.info( + { + progressMessageId: this.progressMessageId, + latestProgressText: this.latestProgressText, + }, + this.progressMessageId + ? 'Discarding tracked progress replacement for final attachment delivery' + : 'Delivering final attachment output without a tracked progress message to replace', + ); + this.resetProgressState(); + return null; + } + private async deliverFinalText( text: string, options?: { From 5ba607644f5d9e6510c20534aa57e48ff2abc5f6 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 26 Apr 2026 17:05:50 +0900 Subject: [PATCH 24/48] Add read-only web dashboard MVP Adds a disabled-by-default loopback web dashboard MVP with read-only control-plane views, prompt preview redaction, Vite React UI, and validation coverage. --- apps/dashboard/index.html | 12 + apps/dashboard/src/App.tsx | 444 ++++++++++++++++++++++ apps/dashboard/src/api.ts | 100 +++++ apps/dashboard/src/main.tsx | 16 + apps/dashboard/src/styles.css | 506 ++++++++++++++++++++++++++ apps/dashboard/src/vite-env.d.ts | 1 + apps/dashboard/tsconfig.json | 21 ++ apps/dashboard/vite.config.ts | 18 + bun.lock | 108 +++++- package.json | 17 +- setup/room-registration-state.test.ts | 7 +- src/config.test.ts | 30 +- src/config.ts | 1 + src/config/load-config.ts | 9 + src/config/schema.ts | 6 + src/index.ts | 6 + src/web-dashboard-data.test.ts | 125 +++++++ src/web-dashboard-data.ts | 185 ++++++++++ src/web-dashboard-server.test.ts | 73 ++++ src/web-dashboard-server.ts | 182 +++++++++ 20 files changed, 1857 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/index.html create mode 100644 apps/dashboard/src/App.tsx create mode 100644 apps/dashboard/src/api.ts create mode 100644 apps/dashboard/src/main.tsx create mode 100644 apps/dashboard/src/styles.css create mode 100644 apps/dashboard/src/vite-env.d.ts create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/dashboard/vite.config.ts create mode 100644 src/web-dashboard-data.test.ts create mode 100644 src/web-dashboard-data.ts create mode 100644 src/web-dashboard-server.test.ts create mode 100644 src/web-dashboard-server.ts diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..434ca72 --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + EJClaw Dashboard + + +
+ + + diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx new file mode 100644 index 0000000..27d48e5 --- /dev/null +++ b/apps/dashboard/src/App.tsx @@ -0,0 +1,444 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react'; + +import { + type DashboardOverview, + type DashboardTask, + type StatusSnapshot, + fetchDashboardData, +} from './api'; +import './styles.css'; + +interface DashboardState { + overview: DashboardOverview; + snapshots: StatusSnapshot[]; + tasks: DashboardTask[]; +} + +const REFRESH_INTERVAL_MS = 15_000; + +function formatDate(value: string | null | undefined): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(date); +} + +function formatPct(value: number): string { + return `${Math.round(value)}%`; +} + +function statusLabel(status: string): string { + switch (status) { + case 'processing': + return '처리중'; + case 'waiting': + return '대기'; + case 'inactive': + return '휴면'; + case 'active': + return '활성'; + case 'paused': + return '일시정지'; + case 'completed': + return '완료'; + default: + return status; + } +} + +function formatDuration(value: number | null): string { + if (value === null) return '-'; + const seconds = Math.floor(value / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function Card({ + label, + value, + hint, +}: { + label: string; + value: string | number; + hint?: string; +}) { + return ( +
+ {label} + {value} + {hint ? {hint} : null} +
+ ); +} + +function EmptyState({ children }: { children: ReactNode }) { + return
{children}
; +} + +function ControlRail({ data }: { data: DashboardState }) { + const queue = data.snapshots.reduce( + (acc, snapshot) => { + for (const entry of snapshot.entries) { + acc.pendingTasks += entry.pendingTasks; + if (entry.pendingMessages) acc.pendingMessageRooms += 1; + } + return acc; + }, + { pendingTasks: 0, pendingMessageRooms: 0 }, + ); + + return ( +
+
+ agent heartbeat + + {data.overview.rooms.active + data.overview.rooms.waiting}/ + {data.overview.rooms.total} + + processing + waiting rooms +
+
+ work queue + {queue.pendingTasks} + {queue.pendingMessageRooms} rooms with pending messages +
+
+ governance + read-only + no approvals, merges, or worker kills in MVP +
+
+ audit safety + redacted + task prompts are preview-only +
+
+ ); +} + +function ServicePanel({ overview }: { overview: DashboardOverview }) { + if (overview.services.length === 0) { + return ( + + 최근 status snapshot이 없어. 서비스가 아직 heartbeat를 안 쓴 상태일 수 + 있어. + + ); + } + + return ( +
+ {overview.services.map((service) => ( +
+
+ {service.agentType} +

{service.assistantName}

+
+
+ + heartbeat {formatDate(service.updatedAt)} +
+
+
+
service
+
{service.serviceId}
+
+
+
rooms
+
+ {service.activeRooms}/{service.totalRooms} active +
+
+
+
updated
+
{formatDate(service.updatedAt)}
+
+
+
+ ))} +
+ ); +} + +function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) { + const entries = snapshots.flatMap((snapshot) => + snapshot.entries.map((entry) => ({ + ...entry, + serviceId: snapshot.serviceId, + })), + ); + + if (entries.length === 0) { + return 표시할 룸 상태가 없어.; + } + + return ( +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
roomserviceagentstatusqueue
+ {entry.name} + + {entry.folder} · {entry.jid} + + {entry.serviceId}{entry.agentType} + + {statusLabel(entry.status)} + + {formatDuration(entry.elapsedMs)} + + {entry.pendingTasks} task{entry.pendingMessages ? ' · msg' : ''} +
+
+ ); +} + +function UsagePanel({ overview }: { overview: DashboardOverview }) { + if (overview.usage.rows.length === 0) { + return ( + + 사용량 snapshot이 없어. usage dashboard가 꺼져 있거나 아직 수집 전일 수 + 있어. + + ); + } + + return ( +
+ {overview.usage.rows.map((row) => ( +
+
+ {row.name} + fetched {formatDate(overview.usage.fetchedAt)} +
+
+ 5h + + {formatPct(row.h5pct)} + {row.h5reset} +
+
+ 7d + + {formatPct(row.d7pct)} + {row.d7reset} +
+
+ ))} +
+ ); +} + +function TaskPanel({ tasks }: { tasks: DashboardTask[] }) { + const sortedTasks = useMemo( + () => + [...tasks].sort((a, b) => { + const statusRank = { active: 0, paused: 1, completed: 2 } as const; + const rankDelta = statusRank[a.status] - statusRank[b.status]; + if (rankDelta !== 0) return rankDelta; + return (a.nextRun ?? a.createdAt).localeCompare( + b.nextRun ?? b.createdAt, + ); + }), + [tasks], + ); + + if (sortedTasks.length === 0) { + return 등록된 scheduled task가 없어.; + } + + return ( +
+ + + + + + + + + + + + {sortedTasks.map((task) => ( + + + + + + + + ))} + +
taskstatusschedulenextlast
+ {task.isWatcher ? 'CI Watch' : task.id} + {task.promptPreview || '(empty prompt preview)'} + + {task.groupFolder} · {task.promptLength} chars + + + + {statusLabel(task.status)} + + {task.suspendedUntil ? ( + until {formatDate(task.suspendedUntil)} + ) : null} + + {task.scheduleType} · {task.scheduleValue} + {task.contextMode} + {formatDate(task.nextRun)} + {formatDate(task.lastRun)} + {task.lastResult ? {task.lastResult} : null} +
+
+ ); +} + +function App() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + async function refresh(showSpinner = false) { + if (showSpinner) setRefreshing(true); + try { + const nextData = await fetchDashboardData(); + setData(nextData); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => { + void refresh(); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, []); + + if (loading && !data) { + return ( +
EJClaw Dashboard 불러오는 중...
+ ); + } + + return ( +
+
+
+ EJClaw Control Plane · read-only MVP +

Agent factory control room

+

+ Discord는 pager와 승인 호출로 남기고, 여기서는 agent heartbeat, room + queue, scheduled work, quota, audit preview를 한 화면에서 본다. +

+
+ +
+ + {error ? ( +
API 오류: {error}
+ ) : null} + + {data ? ( + <> + + +
+ + + + +
+ +
+
+

Agent Heartbeats

+ 최근 heartbeat 기준 +
+ +
+ +
+
+
+

Cost & Quota

+ 5h / 7d usage snapshot +
+ +
+
+
+

Rooms & Queues

+ processing / waiting / inactive +
+ +
+
+ +
+
+

Scheduled Work

+ watchers, heartbeats, redacted prompt previews +
+ +
+ + ) : null} +
+ ); +} + +export default App; diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts new file mode 100644 index 0000000..031eec8 --- /dev/null +++ b/apps/dashboard/src/api.ts @@ -0,0 +1,100 @@ +export interface DashboardOverview { + generatedAt: string; + services: Array<{ + serviceId: string; + assistantName: string; + agentType: string; + updatedAt: string; + totalRooms: number; + activeRooms: number; + }>; + rooms: { + total: number; + active: number; + waiting: number; + inactive: number; + }; + tasks: { + total: number; + active: number; + paused: number; + completed: number; + watchers: { + active: number; + paused: number; + completed: number; + }; + }; + usage: { + rows: Array<{ + name: string; + h5pct: number; + h5reset: string; + d7pct: number; + d7reset: string; + }>; + fetchedAt: string | null; + }; +} + +export interface StatusSnapshot { + serviceId: string; + agentType: string; + assistantName: string; + updatedAt: string; + entries: Array<{ + jid: string; + name: string; + folder: string; + agentType: string; + status: 'processing' | 'waiting' | 'inactive'; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + }>; +} + +export interface DashboardTask { + id: string; + groupFolder: string; + chatJid: string; + agentType: string | null; + ciProvider: string | null; + ciMetadata: string | null; + scheduleType: string; + scheduleValue: string; + contextMode: string; + nextRun: string | null; + lastRun: string | null; + lastResult: string | null; + status: 'active' | 'paused' | 'completed'; + suspendedUntil: string | null; + createdAt: string; + promptPreview: string; + promptLength: number; + isWatcher: boolean; +} + +async function fetchJson(path: string): Promise { + const response = await fetch(path, { + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`${path} failed: ${response.status}`); + } + return (await response.json()) as T; +} + +export async function fetchDashboardData(): Promise<{ + overview: DashboardOverview; + snapshots: StatusSnapshot[]; + tasks: DashboardTask[]; +}> { + const [overview, snapshots, tasks] = await Promise.all([ + fetchJson('/api/overview'), + fetchJson('/api/status-snapshots'), + fetchJson('/api/tasks'), + ]); + + return { overview, snapshots, tasks }; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 0000000..f0becbb --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './App'; + +const root = document.getElementById('root'); + +if (!root) { + throw new Error('Dashboard root element was not found'); +} + +createRoot(root).render( + + + , +); diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css new file mode 100644 index 0000000..176b1a7 --- /dev/null +++ b/apps/dashboard/src/styles.css @@ -0,0 +1,506 @@ +:root { + color-scheme: light; + --bg: #f3efe4; + --bg-ink: #1c211c; + --panel: rgba(255, 252, 242, 0.9); + --panel-solid: #fffaf0; + --panel-border: rgba(43, 55, 38, 0.16); + --muted: #6d735f; + --accent: #bf5f2c; + --accent-strong: #8f351b; + --green: #3f7f51; + --yellow: #b58a22; + --red: #b74734; + --shadow: 0 24px 70px rgba(44, 39, 25, 0.18); + font-family: + 'Avenir Next', 'Segoe UI Variable', 'Noto Sans KR', ui-sans-serif, + sans-serif; + background: var(--bg); + color: var(--bg-ink); +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + radial-gradient( + circle at 18% 12%, + rgba(214, 127, 58, 0.22), + transparent 28rem + ), + radial-gradient( + circle at 88% 8%, + rgba(74, 123, 87, 0.18), + transparent 24rem + ), + linear-gradient(135deg, #f4ead5 0%, #edf1df 46%, #f5f0e5 100%); +} + +button, +input, +table { + font: inherit; +} + +.shell { + width: min(1480px, calc(100% - 40px)); + margin: 0 auto; + padding: 34px 0 56px; +} + +.shell::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + content: ''; + background-image: + linear-gradient(rgba(28, 33, 28, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(28, 33, 28, 0.045) 1px, transparent 1px); + background-size: 44px 44px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), transparent 82%); +} + +.loading { + display: grid; + min-height: 100vh; + place-items: center; + font-size: clamp(24px, 5vw, 54px); + font-weight: 900; + letter-spacing: -0.05em; +} + +.hero { + display: grid; + grid-template-columns: 1fr auto; + gap: 28px; + align-items: end; + padding: clamp(28px, 4vw, 54px); + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: 34px; + background: + linear-gradient( + 128deg, + rgba(255, 250, 240, 0.96), + rgba(255, 250, 240, 0.76) + ), + linear-gradient(45deg, rgba(191, 95, 44, 0.12), rgba(63, 127, 81, 0.1)); + box-shadow: var(--shadow); +} + +.hero h1 { + max-width: 880px; + margin: 10px 0 12px; + font-family: 'Georgia', 'Noto Serif KR', serif; + font-size: clamp(42px, 8vw, 108px); + line-height: 0.92; + letter-spacing: -0.08em; +} + +.hero p { + max-width: 720px; + margin: 0; + color: var(--muted); + font-size: clamp(16px, 2vw, 22px); + line-height: 1.55; +} + +.eyebrow { + color: var(--accent-strong); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +button { + min-height: 48px; + padding: 0 22px; + border: 0; + border-radius: 999px; + color: #fffaf0; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: 0 14px 30px rgba(143, 53, 27, 0.24); + cursor: pointer; +} + +button:disabled { + cursor: wait; + opacity: 0.62; +} + +.error-card, +.card, +.panel { + border: 1px solid var(--panel-border); + background: var(--panel); + box-shadow: 0 12px 44px rgba(44, 39, 25, 0.09); + backdrop-filter: blur(14px); +} + +.error-card { + margin-top: 18px; + padding: 18px 20px; + border-color: rgba(183, 71, 52, 0.35); + border-radius: 22px; + color: #7c2518; + background: rgba(255, 235, 225, 0.82); + font-weight: 800; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin: 22px 0; +} + +.control-rail { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + margin: 18px 0 22px; + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: 28px; + background: rgba(43, 55, 38, 0.12); + box-shadow: 0 18px 50px rgba(44, 39, 25, 0.08); +} + +.control-rail div { + display: grid; + gap: 8px; + min-height: 116px; + padding: 20px; + background: + linear-gradient( + 145deg, + rgba(255, 250, 240, 0.9), + rgba(255, 250, 240, 0.68) + ), + radial-gradient( + circle at 18% 18%, + rgba(191, 95, 44, 0.1), + transparent 16rem + ); +} + +.control-rail strong { + font-size: clamp(22px, 3vw, 38px); + line-height: 1; + letter-spacing: -0.05em; +} + +.control-rail small { + color: var(--muted); + line-height: 1.35; +} + +.metric-card { + display: grid; + gap: 8px; + min-height: 142px; + padding: 22px; + border-radius: 26px; +} + +.metric-card span, +.metric-card small, +td span, +td small, +dd, +.panel-title span, +.usage-title span, +.bar-line small { + color: var(--muted); +} + +.metric-card span { + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-card strong { + font-size: clamp(36px, 5vw, 66px); + line-height: 0.9; + letter-spacing: -0.07em; +} + +.panel { + margin-top: 18px; + padding: 24px; + border-radius: 30px; +} + +.split-panel { + display: grid; + grid-template-columns: minmax(340px, 0.82fr) minmax(0, 1.18fr); + gap: 22px; +} + +.panel-title { + display: flex; + gap: 16px; + align-items: baseline; + justify-content: space-between; + margin-bottom: 18px; +} + +.panel-title h2 { + margin: 0; + font-family: 'Georgia', 'Noto Serif KR', serif; + font-size: clamp(26px, 3vw, 40px); + letter-spacing: -0.05em; +} + +.service-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +} + +.service-card { + display: grid; + gap: 18px; + padding: 20px; + border-radius: 24px; + background: rgba(255, 250, 240, 0.72); +} + +.service-card h3 { + margin: 6px 0 0; + font-size: 26px; + letter-spacing: -0.04em; +} + +.heartbeat-line { + display: flex; + gap: 10px; + align-items: center; + color: var(--muted); +} + +.heartbeat-line span { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--green); + box-shadow: 0 0 0 7px rgba(63, 127, 81, 0.13); +} + +dl { + display: grid; + gap: 10px; + margin: 0; +} + +dl div { + display: flex; + gap: 12px; + justify-content: space-between; +} + +dt { + color: var(--muted); + font-weight: 750; +} + +dd { + margin: 0; + text-align: right; +} + +.table-wrap { + overflow: auto; + border: 1px solid rgba(43, 55, 38, 0.1); + border-radius: 22px; + background: rgba(255, 250, 240, 0.56); +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +th, +td { + padding: 16px; + border-bottom: 1px solid rgba(43, 55, 38, 0.1); + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + background: rgba(255, 250, 240, 0.7); +} + +tr:last-child td { + border-bottom: 0; +} + +td strong, +td span, +td small { + display: block; +} + +td strong { + margin-bottom: 4px; +} + +td small { + margin-top: 4px; + max-width: 480px; + overflow-wrap: anywhere; +} + +.pill { + display: inline-flex; + width: fit-content; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 850; +} + +.pill-processing, +.pill-active { + color: #173e23; + background: rgba(63, 127, 81, 0.18); +} + +.pill-waiting, +.pill-paused { + color: #6e4a05; + background: rgba(181, 138, 34, 0.2); +} + +.pill-inactive, +.pill-completed { + color: #625d52; + background: rgba(109, 115, 95, 0.16); +} + +.usage-list { + display: grid; + gap: 14px; +} + +.usage-row { + padding: 18px; + border: 1px solid rgba(43, 55, 38, 0.1); + border-radius: 22px; + background: rgba(255, 250, 240, 0.62); +} + +.usage-title { + display: flex; + gap: 10px; + justify-content: space-between; + margin-bottom: 14px; +} + +.bar-line { + display: grid; + grid-template-columns: 34px 1fr 54px minmax(60px, auto); + gap: 10px; + align-items: center; + margin-top: 9px; +} + +progress { + width: 100%; + height: 12px; + overflow: hidden; + border: 0; + border-radius: 999px; + background: rgba(43, 55, 38, 0.1); +} + +progress::-webkit-progress-bar { + background: rgba(43, 55, 38, 0.1); +} + +progress::-webkit-progress-value { + border-radius: 999px; + background: linear-gradient(90deg, var(--green), var(--accent)); +} + +progress::-moz-progress-bar { + border-radius: 999px; + background: linear-gradient(90deg, var(--green), var(--accent)); +} + +.empty-state { + padding: 24px; + border: 1px dashed rgba(43, 55, 38, 0.2); + border-radius: 22px; + color: var(--muted); + background: rgba(255, 250, 240, 0.45); +} + +@media (max-width: 980px) { + .hero, + .split-panel { + grid-template-columns: 1fr; + } + + .hero { + align-items: start; + } + + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .control-rail { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .shell { + width: min(100% - 24px, 1480px); + padding: 18px 0 34px; + } + + .hero, + .panel { + border-radius: 22px; + padding: 20px; + } + + .metrics-grid { + grid-template-columns: 1fr; + } + + .control-rail { + grid-template-columns: 1fr; + } + + .panel-title, + .usage-title { + display: grid; + } + + .bar-line { + grid-template-columns: 28px 1fr 44px; + } + + .bar-line small { + grid-column: 2 / -1; + } +} diff --git a/apps/dashboard/src/vite-env.d.ts b/apps/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..596336d --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [] +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts new file mode 100644 index 0000000..2f02257 --- /dev/null +++ b/apps/dashboard/vite.config.ts @@ -0,0 +1,18 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: __dirname, + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + host: '127.0.0.1', + port: 5174, + proxy: { + '/api': 'http://127.0.0.1:8734', + }, + }, +}); diff --git a/bun.lock b/bun.lock index c559ad9..d9060f2 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,8 @@ "ejclaw-runners-shared": "file:./runners/shared", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", "yaml": "^2.8.2", "zod": "^4.3.6", }, @@ -18,11 +20,15 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.11", "@types/node": "^22.10.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.0.18", "better-sqlite3": "^12.8.0", "husky": "^9.1.7", "prettier": "^3.8.1", "typescript": "^5.7.0", + "vite": "^8.0.10", "vitest": "^4.0.18", }, }, @@ -50,6 +56,12 @@ "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -112,8 +124,44 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], @@ -172,6 +220,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], @@ -184,8 +234,14 @@ "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -256,6 +312,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -392,6 +450,30 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], @@ -454,7 +536,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], @@ -466,7 +548,7 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -488,6 +570,10 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -496,6 +582,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -506,6 +594,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -562,7 +652,7 @@ "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], @@ -590,7 +680,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], @@ -615,5 +705,15 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], + + "vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], } } diff --git a/package.json b/package.json index 1458599..a647dfe 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "dashboard:dev": "vite --config apps/dashboard/vite.config.ts", + "dashboard:build": "vite build --config apps/dashboard/vite.config.ts", + "dashboard:preview": "vite preview --config apps/dashboard/vite.config.ts", "install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner", "build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build", - "build:all": "bun run build && bun run build:runners", + "build:all": "bun run build && bun run dashboard:build && bun run build:runners", "build:runtime": "bun run build:all", "verify:dist": "bash scripts/check-dist-fresh.sh", "deploy": "git pull --ff-only && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw", @@ -17,9 +20,9 @@ "dev": "bun --watch src/index.ts", "test": "vitest run", "typecheck": "tsc --noEmit", - "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json", - "format:fix": "prettier --write \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", - "format:check": "prettier --check \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", + "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p apps/dashboard/tsconfig.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json", + "format:fix": "prettier --write \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", + "format:check": "prettier --check \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", "check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all", "prepare": "husky", "setup": "bun setup/index.ts" @@ -31,6 +34,8 @@ "ejclaw-runners-shared": "file:./runners/shared", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -38,11 +43,15 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.11", "@types/node": "^22.10.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.0.18", "better-sqlite3": "^12.8.0", "husky": "^9.1.7", "prettier": "^3.8.1", "typescript": "^5.7.0", + "vite": "^8.0.10", "vitest": "^4.0.18" }, "engines": { diff --git a/setup/room-registration-state.test.ts b/setup/room-registration-state.test.ts index 4bb5cba..54daedd 100644 --- a/setup/room-registration-state.test.ts +++ b/setup/room-registration-state.test.ts @@ -243,7 +243,12 @@ describe('room registration state', () => { JSON.stringify({ last_timestamp: '1234' }), ); - expect(detectRoomRegistrationState({ projectRoot: tempRoot })).toEqual({ + expect( + detectRoomRegistrationState({ + projectRoot: tempRoot, + dbPath: path.join(tempRoot, 'messages.db'), + }), + ).toEqual({ assignedRooms: 0, roomsByOwnerAgent: {}, legacyRegisteredGroupRows: 0, diff --git a/src/config.test.ts b/src/config.test.ts index d9280a0..6d54501 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -42,6 +42,10 @@ describe('config/env loading', () => { delete process.env.CODEX_WARMUP_COMMAND_TIMEOUT_MS; delete process.env.CODEX_WARMUP_FAILURE_COOLDOWN_MS; delete process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES; + delete process.env.WEB_DASHBOARD_ENABLED; + delete process.env.WEB_DASHBOARD_HOST; + delete process.env.WEB_DASHBOARD_PORT; + delete process.env.WEB_DASHBOARD_STATIC_DIR; delete process.env.SESSION_COMMAND_USER_IDS; vi.resetModules(); }); @@ -163,8 +167,32 @@ describe('config/env loading', () => { ); }); + it('keeps the web dashboard disabled by default and loads explicit bind/static settings', async () => { + let config = await import('./config.js'); + expect(config.WEB_DASHBOARD.enabled).toBe(false); + expect(config.WEB_DASHBOARD.host).toBe('127.0.0.1'); + expect(config.WEB_DASHBOARD.port).toBe(8734); + expect(config.WEB_DASHBOARD.staticDir).toBe( + path.resolve(tempRoot, 'apps', 'dashboard', 'dist'), + ); + + vi.resetModules(); + process.env.WEB_DASHBOARD_ENABLED = 'true'; + process.env.WEB_DASHBOARD_HOST = '0.0.0.0'; + process.env.WEB_DASHBOARD_PORT = '9001'; + process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist'; + config = await import('./config.js'); + + expect(config.WEB_DASHBOARD).toEqual({ + enabled: true, + host: '0.0.0.0', + port: 9001, + staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'), + }); + }); + it('fails fast when a legacy Discord token alias is configured', async () => { - process.env.DISCORD_BOT_TOKEN = 'legacy-owner-token'; + process.env.DISCORD_BOT_TOKEN = 'legacy...oken'; const { loadConfig } = await import('./config/load-config.js'); diff --git a/src/config.ts b/src/config.ts index a739df3..8a113e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -141,6 +141,7 @@ export const STATUS_SHOW_ROOMS = CONFIG.status.showRooms; export const STATUS_SHOW_ROOM_DETAILS = CONFIG.status.showRoomDetails; export const USAGE_DASHBOARD_ENABLED = CONFIG.status.usageDashboardEnabled; export const CODEX_WARMUP_CONFIG = CONFIG.codexWarmup; +export const WEB_DASHBOARD = CONFIG.webDashboard; // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 76f910c..cee46da 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -260,6 +260,15 @@ export function loadConfig(): AppConfig { timezone: readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone, }, + webDashboard: { + enabled: readBoolean('WEB_DASHBOARD_ENABLED', false), + host: readNonEmptyText('WEB_DASHBOARD_HOST') ?? '127.0.0.1', + port: readIntegerAtLeast('WEB_DASHBOARD_PORT', 8734, 1), + staticDir: path.resolve( + readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ?? + path.join(projectRoot, 'apps', 'dashboard', 'dist'), + ), + }, codexWarmup: { enabled: readBoolean('CODEX_WARMUP_ENABLED', false), prompt: diff --git a/src/config/schema.ts b/src/config/schema.ts index d5450f9..15afdf1 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -78,6 +78,12 @@ export interface AppConfig { usageDashboardEnabled: boolean; timezone: string; }; + webDashboard: { + enabled: boolean; + host: string; + port: number; + staticDir: string; + }; codexWarmup: { enabled: boolean; prompt: string; diff --git a/src/index.ts b/src/index.ts index 16831a9..a9dde28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { TIMEZONE, TRIGGER_PATTERN, USAGE_UPDATE_INTERVAL, + WEB_DASHBOARD, } from './config.js'; import './channels/index.js'; import { @@ -54,6 +55,7 @@ import { import { createMessageRuntime } from './message-runtime.js'; import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js'; import { startUnifiedDashboard } from './unified-dashboard.js'; +import { startWebDashboardServer } from './web-dashboard-server.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { initCodexTokenRotation } from './codex-token-rotation.js'; @@ -248,6 +250,7 @@ async function main(): Promise { // Graceful shutdown handlers let leaseRecoveryTimer: ReturnType | null = null; + let webDashboardServer: ReturnType = null; const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); stopTokenRefreshLoop(); @@ -255,6 +258,8 @@ async function main(): Promise { clearInterval(leaseRecoveryTimer); leaseRecoveryTimer = null; } + webDashboardServer?.stop(); + webDashboardServer = null; const roomBindings = runtimeState.getRoomBindings(); const interruptedGroups = queue .getStatuses(Object.keys(roomBindings)) @@ -516,6 +521,7 @@ async function main(): Promise { }, purgeOnStart: true, }); + webDashboardServer = startWebDashboardServer(WEB_DASHBOARD); leaseRecoveryTimer = setInterval(() => { const failover = getGlobalFailoverInfo(); diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts new file mode 100644 index 0000000..99bc4a3 --- /dev/null +++ b/src/web-dashboard-data.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import type { StatusSnapshot } from './status-dashboard.js'; +import type { ScheduledTask } from './types.js'; +import { + buildWebDashboardOverview, + sanitizeScheduledTask, +} from './web-dashboard-data.js'; + +function makeTask(overrides: Partial): ScheduledTask { + return { + id: 'task-1', + group_folder: 'general', + chat_jid: 'dc:general', + agent_type: null, + status_message_id: null, + status_started_at: null, + prompt: 'secret long prompt that should not be exposed in full', + schedule_type: 'cron', + schedule_value: '* * * * *', + context_mode: 'group', + next_run: '2026-04-26T05:00:00.000Z', + last_run: null, + last_result: null, + status: 'active', + created_at: '2026-04-26T04:00:00.000Z', + ...overrides, + }; +} + +describe('web dashboard data', () => { + it('builds overview counts from status snapshots and scheduled tasks', () => { + const snapshots: StatusSnapshot[] = [ + { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt: '2026-04-26T04:59:00.000Z', + entries: [ + { + jid: 'dc:1', + name: '#general', + folder: 'general', + agentType: 'codex', + status: 'processing', + elapsedMs: 1200, + pendingMessages: true, + pendingTasks: 2, + }, + { + jid: 'dc:2', + name: '#brain', + folder: 'brain', + agentType: 'claude-code', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + ], + usageRows: [ + { + name: 'codex-a', + h5pct: 10, + h5reset: '1h', + d7pct: 20, + d7reset: '2d', + }, + ], + }, + ]; + + const overview = buildWebDashboardOverview({ + now: '2026-04-26T05:00:00.000Z', + snapshots, + tasks: [ + makeTask({ + id: 'watch-1', + prompt: '[BACKGROUND CI WATCH] owner/repo#1', + status: 'active', + }), + makeTask({ id: 'cron-1', prompt: 'regular cleanup', status: 'paused' }), + ], + }); + + expect(overview.rooms.total).toBe(2); + expect(overview.rooms.active).toBe(1); + expect(overview.rooms.waiting).toBe(0); + expect(overview.rooms.inactive).toBe(1); + expect(overview.tasks.total).toBe(2); + expect(overview.tasks.active).toBe(1); + expect(overview.tasks.paused).toBe(1); + expect(overview.tasks.watchers.active).toBe(1); + expect(overview.usage.rows).toHaveLength(1); + }); + + it('does not expose full scheduled task prompts through API payloads', () => { + const sanitized = sanitizeScheduledTask( + makeTask({ + prompt: 'x'.repeat(220), + last_result: 'ok', + }), + ); + + expect(sanitized).not.toHaveProperty('prompt'); + expect(sanitized.promptPreview.length).toBeLessThanOrEqual(123); + expect(sanitized.promptLength).toBe(220); + }); + + it('redacts common secret values from scheduled task prompt previews', () => { + const sanitized = sanitizeScheduledTask( + makeTask({ + prompt: + 'deploy with OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456 and BOT_TOKEN=plain-secret-value', + }), + ); + + expect(sanitized.promptPreview).toContain('OPENAI_API_KEY='); + expect(sanitized.promptPreview).toContain('BOT_TOKEN='); + expect(sanitized.promptPreview).not.toContain( + 'sk-abcdefghijklmnopqrstuvwxyz123456', + ); + expect(sanitized.promptPreview).not.toContain('plain-secret-value'); + }); +}); diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts new file mode 100644 index 0000000..87c14ec --- /dev/null +++ b/src/web-dashboard-data.ts @@ -0,0 +1,185 @@ +import { isWatchCiTask } from './task-watch-status.js'; +import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; +import type { ScheduledTask } from './types.js'; + +export interface SanitizedScheduledTask { + id: string; + groupFolder: string; + chatJid: string; + agentType: ScheduledTask['agent_type']; + ciProvider: ScheduledTask['ci_provider']; + ciMetadata: ScheduledTask['ci_metadata']; + scheduleType: ScheduledTask['schedule_type']; + scheduleValue: string; + contextMode: ScheduledTask['context_mode']; + nextRun: string | null; + lastRun: string | null; + lastResult: string | null; + status: ScheduledTask['status']; + suspendedUntil: string | null; + createdAt: string; + promptPreview: string; + promptLength: number; + isWatcher: boolean; +} + +export interface WebDashboardOverview { + generatedAt: string; + services: Array<{ + serviceId: string; + assistantName: string; + agentType: StatusSnapshot['agentType']; + updatedAt: string; + totalRooms: number; + activeRooms: number; + }>; + rooms: { + total: number; + active: number; + waiting: number; + inactive: number; + }; + tasks: { + total: number; + active: number; + paused: number; + completed: number; + watchers: { + active: number; + paused: number; + completed: number; + }; + }; + usage: { + rows: UsageRowSnapshot[]; + fetchedAt: string | null; + }; +} + +const SECRET_ASSIGNMENT_RE = + /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi; +const SECRET_VALUE_RE = + /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g; + +function redactSensitiveText(value: string): string { + return value + .replace(SECRET_ASSIGNMENT_RE, '$1=') + .replace(SECRET_VALUE_RE, ''); +} + +function truncateText(value: string, maxLength = 120): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength)}...`; +} + +function buildPromptPreview(prompt: string): string { + return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim()); +} + +export function sanitizeScheduledTask( + task: ScheduledTask, +): SanitizedScheduledTask { + return { + id: task.id, + groupFolder: task.group_folder, + chatJid: task.chat_jid, + agentType: task.agent_type, + ciProvider: task.ci_provider ?? null, + ciMetadata: task.ci_metadata ?? null, + scheduleType: task.schedule_type, + scheduleValue: task.schedule_value, + contextMode: task.context_mode, + nextRun: task.next_run, + lastRun: task.last_run, + lastResult: task.last_result, + status: task.status, + suspendedUntil: task.suspended_until ?? null, + createdAt: task.created_at, + promptPreview: buildPromptPreview(task.prompt), + promptLength: task.prompt.length, + isWatcher: isWatchCiTask(task), + }; +} + +export function buildWebDashboardOverview(args: { + now?: string; + snapshots: StatusSnapshot[]; + tasks: ScheduledTask[]; +}): WebDashboardOverview { + const rooms = { + total: 0, + active: 0, + waiting: 0, + inactive: 0, + }; + + const services = args.snapshots.map((snapshot) => { + let activeRooms = 0; + for (const entry of snapshot.entries) { + rooms.total += 1; + if (entry.status === 'processing') { + rooms.active += 1; + activeRooms += 1; + } else if (entry.status === 'waiting') { + rooms.waiting += 1; + activeRooms += 1; + } else { + rooms.inactive += 1; + } + } + + return { + serviceId: snapshot.serviceId, + assistantName: snapshot.assistantName, + agentType: snapshot.agentType, + updatedAt: snapshot.updatedAt, + totalRooms: snapshot.entries.length, + activeRooms, + }; + }); + + const tasks = { + total: args.tasks.length, + active: 0, + paused: 0, + completed: 0, + watchers: { + active: 0, + paused: 0, + completed: 0, + }, + }; + + for (const task of args.tasks) { + if (task.status === 'active') tasks.active += 1; + if (task.status === 'paused') tasks.paused += 1; + if (task.status === 'completed') tasks.completed += 1; + + if (isWatchCiTask(task)) { + if (task.status === 'active') tasks.watchers.active += 1; + if (task.status === 'paused') tasks.watchers.paused += 1; + if (task.status === 'completed') tasks.watchers.completed += 1; + } + } + + const usageRows = args.snapshots.flatMap( + (snapshot) => snapshot.usageRows ?? [], + ); + const usageFetchedAt = + args.snapshots + .map((snapshot) => snapshot.usageRowsFetchedAt) + .filter((value): value is string => !!value) + .sort() + .at(-1) ?? null; + + return { + generatedAt: args.now ?? new Date().toISOString(), + services, + rooms, + tasks, + usage: { + rows: usageRows, + fetchedAt: usageFetchedAt, + }, + }; +} diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts new file mode 100644 index 0000000..9d065a6 --- /dev/null +++ b/src/web-dashboard-server.test.ts @@ -0,0 +1,73 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createWebDashboardHandler } from './web-dashboard-server.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('web dashboard server handler', () => { + it('serves health and overview JSON without requiring Discord', async () => { + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + }); + + const health = await handler(new Request('http://localhost/api/health')); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toEqual({ ok: true }); + + const overview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(overview.status).toBe(200); + const body = (await overview.json()) as { + rooms: { total: number }; + tasks: { total: number }; + }; + expect(body.rooms.total).toBe(0); + expect(body.tasks.total).toBe(0); + }); + + it('serves Vite static assets and falls back to index for SPA routes', async () => { + const staticDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-dashboard-'), + ); + tempDirs.push(staticDir); + fs.writeFileSync( + path.join(staticDir, 'index.html'), + '
', + ); + fs.mkdirSync(path.join(staticDir, 'assets')); + fs.writeFileSync( + path.join(staticDir, 'assets', 'app.js'), + 'console.log("ok")', + ); + + const handler = createWebDashboardHandler({ + staticDir, + readStatusSnapshots: () => [], + getTasks: () => [], + }); + + const asset = await handler(new Request('http://localhost/assets/app.js')); + expect(asset.status).toBe(200); + expect(asset.headers.get('content-type')).toContain('text/javascript'); + await expect(asset.text()).resolves.toContain('console.log'); + + const fallback = await handler( + new Request('http://localhost/tasks/swarm_123'), + ); + expect(fallback.status).toBe(200); + expect(fallback.headers.get('content-type')).toContain('text/html'); + await expect(fallback.text()).resolves.toContain('root'); + }); +}); diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts new file mode 100644 index 0000000..ac2242c --- /dev/null +++ b/src/web-dashboard-server.ts @@ -0,0 +1,182 @@ +import fs from 'fs'; +import path from 'path'; + +import { WEB_DASHBOARD } from './config.js'; +import { getAllTasks } from './db.js'; +import { logger } from './logger.js'; +import { + readStatusSnapshots, + type StatusSnapshot, +} from './status-dashboard.js'; +import type { ScheduledTask } from './types.js'; +import { + buildWebDashboardOverview, + sanitizeScheduledTask, +} from './web-dashboard-data.js'; + +const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; + +export interface WebDashboardHandlerOptions { + staticDir?: string; + statusMaxAgeMs?: number; + readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[]; + getTasks?: () => ScheduledTask[]; + now?: () => string; +} + +export interface StartedWebDashboardServer { + url: string; + stop: () => void; +} + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value, null, 2), { + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(init?.headers ?? {}), + }, + }); +} + +function getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + case '.mjs': + return 'text/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.webp': + return 'image/webp'; + case '.ico': + return 'image/x-icon'; + default: + return 'application/octet-stream'; + } +} + +function resolveStaticFile(staticDir: string, pathname: string): string | null { + const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, ''); + const candidate = path.resolve(staticDir, normalizedPath || 'index.html'); + const root = path.resolve(staticDir); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { + return null; + } + + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + + const indexPath = path.join(root, 'index.html'); + if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { + return indexPath; + } + + return null; +} + +function serveStaticFile(staticDir: string, pathname: string): Response { + const filePath = resolveStaticFile(staticDir, pathname); + if (!filePath) { + return new Response('Not found', { status: 404 }); + } + + return new Response(fs.readFileSync(filePath), { + headers: { + 'content-type': getContentType(filePath), + }, + }); +} + +export function createWebDashboardHandler( + opts: WebDashboardHandlerOptions = {}, +): (request: Request) => Response | Promise { + const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots; + const loadTasks = opts.getTasks ?? getAllTasks; + const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; + + return (request: Request): Response => { + const url = new URL(request.url); + + if (request.method !== 'GET' && request.method !== 'HEAD') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + if (url.pathname === '/api/health') { + return jsonResponse({ ok: true }); + } + + if (url.pathname === '/api/overview') { + const snapshots = readSnapshots(statusMaxAgeMs); + const tasks = loadTasks(); + return jsonResponse( + buildWebDashboardOverview({ + now: opts.now?.(), + snapshots, + tasks, + }), + ); + } + + if (url.pathname === '/api/status-snapshots') { + return jsonResponse(readSnapshots(statusMaxAgeMs)); + } + + if (url.pathname === '/api/tasks') { + return jsonResponse(loadTasks().map(sanitizeScheduledTask)); + } + + if (url.pathname.startsWith('/api/')) { + return jsonResponse({ error: 'Not found' }, { status: 404 }); + } + + if (!opts.staticDir) { + return new Response('Dashboard static directory is not configured', { + status: 404, + }); + } + + return serveStaticFile(opts.staticDir, url.pathname); + }; +} + +export function startWebDashboardServer( + opts: { + enabled?: boolean; + host?: string; + port?: number; + staticDir?: string; + } = {}, +): StartedWebDashboardServer | null { + const enabled = opts.enabled ?? WEB_DASHBOARD.enabled; + if (!enabled) return null; + + const host = opts.host ?? WEB_DASHBOARD.host; + const port = opts.port ?? WEB_DASHBOARD.port; + const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir; + const server = Bun.serve({ + hostname: host, + port, + fetch: createWebDashboardHandler({ staticDir }), + }); + const url = `http://${host}:${server.port}`; + + logger.info({ url, staticDir }, 'Web dashboard started'); + + return { + url, + stop: () => server.stop(true), + }; +} From 646bc3437291b947991027d001c4162138dfef24 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 26 Apr 2026 21:29:13 +0900 Subject: [PATCH 25/48] [codex] Improve mobile dashboard UX (#19) * feat(dashboard): improve mobile control plane UX * fix(dashboard): improve mobile nav accessibility * feat(dashboard): add mobile drawer and usage glance cards * feat(dashboard): add compact usage matrix and i18n * fix(dashboard): make usage-first console layout * fix(dashboard): remove chrome and group usage rows --- apps/dashboard/src/App.tsx | 948 +++++++++++++++++++++++-------- apps/dashboard/src/i18n.ts | 615 ++++++++++++++++++++ apps/dashboard/src/styles.css | 877 ++++++++++++++++++++++++---- src/unified-dashboard.test.ts | 92 +++ src/unified-dashboard.ts | 55 +- src/web-dashboard-data.test.ts | 77 +++ src/web-dashboard-data.ts | 24 +- src/web-dashboard-server.test.ts | 53 ++ 8 files changed, 2374 insertions(+), 367 deletions(-) create mode 100644 apps/dashboard/src/i18n.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 27d48e5..7fd51b0 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -6,6 +6,16 @@ import { type StatusSnapshot, fetchDashboardData, } from './api'; +import { + LOCALES, + isLocale, + languageNames, + localeTags, + matchLocale, + messages, + type Locale, + type Messages, +} from './i18n'; import './styles.css'; interface DashboardState { @@ -14,13 +24,37 @@ interface DashboardState { tasks: DashboardTask[]; } -const REFRESH_INTERVAL_MS = 15_000; +type UsageRow = DashboardOverview['usage']['rows'][number]; +type RiskLevel = 'ok' | 'warn' | 'critical'; +type UsageGroup = 'primary' | 'codex'; -function formatDate(value: string | null | undefined): string { +const REFRESH_INTERVAL_MS = 15_000; +const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; + +function readInitialLocale(): Locale { + const stored = + typeof window === 'undefined' + ? null + : window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (isLocale(stored)) return stored; + + const languages = + typeof navigator === 'undefined' + ? [] + : [...(navigator.languages || []), navigator.language]; + for (const language of languages) { + const matched = matchLocale(language); + if (matched) return matched; + } + + return 'ko'; +} + +function formatDate(value: string | null | undefined, locale: Locale): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat('ko-KR', { + return new Intl.DateTimeFormat(localeTags[locale], { month: '2-digit', day: '2-digit', hour: '2-digit', @@ -30,61 +64,261 @@ function formatDate(value: string | null | undefined): string { } function formatPct(value: number): string { + if (value < 0) return '-'; return `${Math.round(value)}%`; } -function statusLabel(status: string): string { - switch (status) { - case 'processing': - return '처리중'; - case 'waiting': - return '대기'; - case 'inactive': - return '휴면'; - case 'active': - return '활성'; - case 'paused': - return '일시정지'; - case 'completed': - return '완료'; - default: - return status; - } +function usagePeak(row: UsageRow): number { + return Math.max(row.h5pct, row.d7pct); } -function formatDuration(value: number | null): string { +function usageRiskLevel(row: UsageRow): RiskLevel { + const peak = usagePeak(row); + if (peak >= 85) return 'critical'; + if (peak >= 65) return 'warn'; + return 'ok'; +} + +function usageGroup(row: UsageRow): UsageGroup { + return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary'; +} + +function statusLabel(status: string, t: Messages): string { + if (status in t.status) return t.status[status as keyof Messages['status']]; + return status; +} + +function formatDuration(value: number | null, t: Messages): string { if (value === null) return '-'; const seconds = Math.floor(value / 1000); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return `${seconds}${t.units.second}`; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return `${minutes}${t.units.minute}`; const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; + return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; } -function Card({ - label, - value, - hint, -}: { - label: string; - value: string | number; - hint?: string; -}) { - return ( -
- {label} - {value} - {hint ? {hint} : null} -
- ); +function queueLabel( + pendingTasks: number, + pendingMessages: boolean, + t: Messages, +) { + const parts = [`${pendingTasks} ${t.units.task}`]; + if (pendingMessages) parts.push(t.units.messageShort); + return parts.join(' · '); +} + +function navItems(t: Messages) { + return [ + { href: '#overview', label: t.nav.health }, + { href: '#usage', label: t.nav.usage }, + { href: '#rooms', label: t.nav.rooms }, + { href: '#work', label: t.nav.scheduled }, + ]; } function EmptyState({ children }: { children: ReactNode }) { return
{children}
; } -function ControlRail({ data }: { data: DashboardState }) { +function LanguageSelector({ + locale, + onLocaleChange, + t, +}: { + locale: Locale; + onLocaleChange: (locale: Locale) => void; + t: Messages; +}) { + return ( + + ); +} + +function LoadingSkeleton({ t }: { t: Messages }) { + return ( +
+
+ + + +
+
+ {Array.from({ length: 4 }, (_, index) => ( +
+ + + +
+ ))} +
+
+ ); +} + +function SideRail({ + lastRefreshed, + locale, + onLocaleChange, + onRefresh, + refreshing, + t, +}: { + lastRefreshed: string | null; + locale: Locale; + onLocaleChange: (locale: Locale) => void; + onRefresh: () => void; + refreshing: boolean; + t: Messages; +}) { + return ( + + ); +} + +function SectionNav({ + drawerOpen, + lastRefreshed, + locale, + onCloseDrawer, + onLocaleChange, + onOpenDrawer, + refreshing, + onRefresh, + t, +}: { + drawerOpen: boolean; + lastRefreshed: string | null; + locale: Locale; + onCloseDrawer: () => void; + onLocaleChange: (locale: Locale) => void; + onOpenDrawer: () => void; + refreshing: boolean; + onRefresh: () => void; + t: Messages; +}) { + return ( + <> + + + {drawerOpen ? ( + <> + + + + +
+ {t.nav.updated} + {formatDate(lastRefreshed, locale)} +
+ + + ) : null} + + ); +} + +function ControlRail({ data, t }: { data: DashboardState; t: Messages }) { const queue = data.snapshots.reduce( (acc, snapshot) => { for (const entry of snapshot.entries) { @@ -97,42 +331,49 @@ function ControlRail({ data }: { data: DashboardState }) { ); return ( -
+
- agent heartbeat + {t.metrics.rooms} {data.overview.rooms.active + data.overview.rooms.waiting}/ {data.overview.rooms.total} - processing + waiting rooms + {t.control.activeRooms}
- work queue + {t.control.queue} {queue.pendingTasks} - {queue.pendingMessageRooms} rooms with pending messages + + {queue.pendingMessageRooms} {t.control.pendingRooms} +
- governance - read-only - no approvals, merges, or worker kills in MVP + {t.metrics.agents} + {data.overview.services.length} + {t.panels.heartbeat}
- audit safety - redacted - task prompts are preview-only + {t.metrics.ciWatchers} + {data.overview.tasks.watchers.active} + + {data.overview.tasks.watchers.paused} {t.status.paused} +
); } -function ServicePanel({ overview }: { overview: DashboardOverview }) { +function ServicePanel({ + overview, + locale, + t, +}: { + overview: DashboardOverview; + locale: Locale; + t: Messages; +}) { if (overview.services.length === 0) { - return ( - - 최근 status snapshot이 없어. 서비스가 아직 heartbeat를 안 쓴 상태일 수 - 있어. - - ); + return {t.service.empty}; } return ( @@ -145,22 +386,24 @@ function ServicePanel({ overview }: { overview: DashboardOverview }) {
- heartbeat {formatDate(service.updatedAt)} + + {t.service.heartbeat} {formatDate(service.updatedAt, locale)} +
-
service
+
{t.service.service}
{service.serviceId}
-
rooms
+
{t.service.rooms}
- {service.activeRooms}/{service.totalRooms} active + {service.activeRooms}/{service.totalRooms} {t.status.active}
-
updated
-
{formatDate(service.updatedAt)}
+
{t.service.updated}
+
{formatDate(service.updatedAt, locale)}
@@ -169,7 +412,13 @@ function ServicePanel({ overview }: { overview: DashboardOverview }) { ); } -function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) { +function RoomPanel({ + snapshots, + t, +}: { + snapshots: StatusSnapshot[]; + t: Messages; +}) { const entries = snapshots.flatMap((snapshot) => snapshot.entries.map((entry) => ({ ...entry, @@ -178,86 +427,225 @@ function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) { ); if (entries.length === 0) { - return 표시할 룸 상태가 없어.; + return {t.rooms.empty}; } return ( -
- - - - - - - - - - - - {entries.map((entry) => ( - - - - - - + <> +
+
roomserviceagentstatusqueue
- {entry.name} - - {entry.folder} · {entry.jid} - - {entry.serviceId}{entry.agentType} - - {statusLabel(entry.status)} - - {formatDuration(entry.elapsedMs)} - - {entry.pendingTasks} task{entry.pendingMessages ? ' · msg' : ''} -
+ + + + + + + - ))} - -
{t.rooms.room}{t.rooms.service}{t.rooms.agent}{t.rooms.status}{t.rooms.queue}
+ + + {entries.map((entry) => ( + + + {entry.name} + + {entry.folder} · {entry.jid} + + + {entry.serviceId} + {entry.agentType} + + + {statusLabel(entry.status, t)} + + {formatDuration(entry.elapsedMs, t)} + + + {queueLabel(entry.pendingTasks, entry.pendingMessages, t)} + + + ))} + + +
+ +
+ {entries.map((entry) => ( +
+
+
+ {entry.name} + {entry.folder} +
+ + {statusLabel(entry.status, t)} + +
+
+ + {t.rooms.queue} + + {queueLabel(entry.pendingTasks, entry.pendingMessages, t)} + + + + {t.rooms.agent} + {entry.agentType} + + + {t.rooms.service} + {entry.serviceId} + + + {t.rooms.elapsed} + {formatDuration(entry.elapsedMs, t)} + +
+

{entry.jid}

+
+ ))} +
+ + ); +} + +function UsageMeter({ + label, + pct, + reset, + rowName, + t, +}: { + label: string; + pct: number; + reset: string; + rowName: string; + t: Messages; +}) { + return ( +
+
+ {label} + {formatPct(pct)} +
+ + + {t.usage.reset} {reset || '-'} +
); } -function UsagePanel({ overview }: { overview: DashboardOverview }) { - if (overview.usage.rows.length === 0) { - return ( - - 사용량 snapshot이 없어. usage dashboard가 꺼져 있거나 아직 수집 전일 수 - 있어. - - ); +function UsagePanel({ + overview, + locale, + t, +}: { + overview: DashboardOverview; + locale: Locale; + t: Messages; +}) { + const rows = useMemo(() => [...overview.usage.rows], [overview.usage.rows]); + const watched = rows.filter((row) => usagePeak(row) >= 65).length; + + if (rows.length === 0) { + return {t.usage.empty}; } + const highest = rows[0]; + const groups = [ + { + key: 'primary' as const, + label: t.usage.groupPrimary, + rows: rows.filter((row) => usageGroup(row) === 'primary'), + }, + { + key: 'codex' as const, + label: t.usage.groupCodex, + rows: rows.filter((row) => usageGroup(row) === 'codex'), + }, + ].filter((group) => group.rows.length > 0); + return ( -
- {overview.usage.rows.map((row) => ( -
-
- {row.name} - fetched {formatDate(overview.usage.fetchedAt)} +
+
+
+ {t.usage.highest} + + {highest.name} · {formatPct(usagePeak(highest))} + +
+
+ {t.usage.watch} + {watched} +
+
+ {t.usage.updated} + {formatDate(overview.usage.fetchedAt, locale)} +
+
+ +
+
+ {t.usage.usage} + {t.usage.window5h} + {t.usage.window7d} +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.rows.map((row) => { + const risk = usageRiskLevel(row); + return ( +
+
+ {row.name} + + {t.usage.risk[risk]} + +
+ + +
+ ); + })}
-
- 5h - - {formatPct(row.h5pct)} - {row.h5reset} -
-
- 7d - - {formatPct(row.d7pct)} - {row.d7reset} -
-
- ))} + ))} +
); } -function TaskPanel({ tasks }: { tasks: DashboardTask[] }) { +function TaskPanel({ + tasks, + locale, + t, +}: { + tasks: DashboardTask[]; + locale: Locale; + t: Messages; +}) { const sortedTasks = useMemo( () => [...tasks].sort((a, b) => { @@ -272,53 +660,97 @@ function TaskPanel({ tasks }: { tasks: DashboardTask[] }) { ); if (sortedTasks.length === 0) { - return 등록된 scheduled task가 없어.; + return {t.tasks.empty}; } return ( -
- - - - - - - - - - - - {sortedTasks.map((task) => ( - - - - - - + <> +
+
taskstatusschedulenextlast
- {task.isWatcher ? 'CI Watch' : task.id} - {task.promptPreview || '(empty prompt preview)'} - - {task.groupFolder} · {task.promptLength} chars - - - - {statusLabel(task.status)} - - {task.suspendedUntil ? ( - until {formatDate(task.suspendedUntil)} - ) : null} - - {task.scheduleType} · {task.scheduleValue} - {task.contextMode} - {formatDate(task.nextRun)} - {formatDate(task.lastRun)} - {task.lastResult ? {task.lastResult} : null} -
+ + + + + + + - ))} - -
{t.tasks.task}{t.tasks.status}{t.tasks.schedule}{t.tasks.next}{t.tasks.last}
-
+ + + {sortedTasks.map((task) => ( + + + {task.isWatcher ? t.tasks.ciWatch : task.id} + {task.promptPreview || t.tasks.emptyPrompt} + + {task.groupFolder} · {task.promptLength} {t.units.chars} + + + + + {statusLabel(task.status, t)} + + {task.suspendedUntil ? ( + + {t.tasks.until} {formatDate(task.suspendedUntil, locale)} + + ) : null} + + + {task.scheduleType} · {task.scheduleValue} + {task.contextMode} + + {formatDate(task.nextRun, locale)} + + {formatDate(task.lastRun, locale)} + {task.lastResult ? {task.lastResult} : null} + + + ))} + + + + +
+ {sortedTasks.map((task) => ( +
+
+
+ {task.isWatcher ? t.tasks.ciWatch : task.id} + {task.groupFolder} +
+ + {statusLabel(task.status, t)} + +
+

{task.promptPreview || t.tasks.emptyPrompt}

+
+ + {t.tasks.schedule} + {task.scheduleType} + + + {t.tasks.next} + {formatDate(task.nextRun, locale)} + + + {t.tasks.last} + {formatDate(task.lastRun, locale)} + + + {t.tasks.context} + {task.contextMode} + +
+ {task.lastResult ? ( +

+ {t.tasks.lastResult}: {task.lastResult} +

+ ) : null} +
+ ))} +
+ ); } @@ -327,12 +759,22 @@ function App() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [lastRefreshed, setLastRefreshed] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [locale, setLocale] = useState(readInitialLocale); + const t = messages[locale]; + + function setDashboardLocale(nextLocale: Locale) { + setLocale(nextLocale); + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); + } async function refresh(showSpinner = false) { if (showSpinner) setRefreshing(true); try { const nextData = await fetchDashboardData(); setData(nextData); + setLastRefreshed(new Date().toISOString()); setError(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -342,6 +784,10 @@ function App() { } } + useEffect(() => { + document.documentElement.lang = localeTags[locale]; + }, [locale]); + useEffect(() => { void refresh(); const id = window.setInterval(() => { @@ -350,94 +796,94 @@ function App() { return () => window.clearInterval(id); }, []); + useEffect(() => { + if (!drawerOpen) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setDrawerOpen(false); + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [drawerOpen]); + if (loading && !data) { - return ( -
EJClaw Dashboard 불러오는 중...
- ); + return ; } return ( -
-
-
- EJClaw Control Plane · read-only MVP -

Agent factory control room

-

- Discord는 pager와 승인 호출로 남기고, 여기서는 agent heartbeat, room - queue, scheduled work, quota, audit preview를 한 화면에서 본다. -

-
- -
+
+ void refresh(true)} + refreshing={refreshing} + t={t} + /> +
+ setDrawerOpen(false)} + onLocaleChange={setDashboardLocale} + onOpenDrawer={() => setDrawerOpen(true)} + onRefresh={() => void refresh(true)} + refreshing={refreshing} + t={t} + /> - {error ? ( -
API 오류: {error}
- ) : null} - - {data ? ( - <> - - -
- - - - + {error ? ( +
+ + {t.error.api}: {error} + +
+ ) : null} -
-
-

Agent Heartbeats

- 최근 heartbeat 기준 -
- -
- -
-
+ {data ? ( + <> +
-

Cost & Quota

- 5h / 7d usage snapshot +

{t.panels.usage}

+ {t.panels.usageWindow}
- -
-
-
-

Rooms & Queues

- processing / waiting / inactive -
- -
-
+ +
-
-
-

Scheduled Work

- watchers, heartbeats, redacted prompt previews -
- -
- - ) : null} -
+ + +
+
+

{t.panels.health}

+ {t.panels.heartbeat} +
+ +
+ +
+
+

{t.panels.rooms}

+ {t.panels.queue} +
+ +
+ +
+
+

{t.panels.scheduled}

+ {t.panels.promptPreviews} +
+ +
+ + ) : null} +
+ ); } diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts new file mode 100644 index 0000000..f249a02 --- /dev/null +++ b/apps/dashboard/src/i18n.ts @@ -0,0 +1,615 @@ +export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const; + +export type Locale = (typeof LOCALES)[number]; + +export interface Messages { + app: { + loading: string; + }; + actions: { + close: string; + refresh: string; + refreshing: string; + retry: string; + }; + nav: { + aria: string; + drawerAria: string; + drawerNavAria: string; + menuOpen: string; + menuClose: string; + operations: string; + updated: string; + health: string; + usage: string; + rooms: string; + scheduled: string; + }; + language: { + label: string; + }; + error: { + api: string; + }; + control: { + aria: string; + queue: string; + activeRooms: string; + pendingRooms: string; + }; + metrics: { + agents: string; + rooms: string; + ciWatchers: string; + }; + panels: { + health: string; + heartbeat: string; + usage: string; + usageWindow: string; + rooms: string; + queue: string; + scheduled: string; + promptPreviews: string; + }; + service: { + empty: string; + heartbeat: string; + service: string; + rooms: string; + updated: string; + }; + rooms: { + empty: string; + cardsAria: string; + room: string; + service: string; + agent: string; + status: string; + queue: string; + elapsed: string; + }; + usage: { + empty: string; + highest: string; + watch: string; + updated: string; + peak: string; + reset: string; + usage: string; + window5h: string; + window7d: string; + groupPrimary: string; + groupCodex: string; + risk: { + ok: string; + warn: string; + critical: string; + }; + }; + tasks: { + empty: string; + cardsAria: string; + task: string; + status: string; + schedule: string; + next: string; + last: string; + context: string; + ciWatch: string; + emptyPrompt: string; + until: string; + lastResult: string; + }; + status: { + processing: string; + waiting: string; + inactive: string; + active: string; + paused: string; + completed: string; + }; + units: { + second: string; + minute: string; + hour: string; + task: string; + messageShort: string; + chars: string; + }; +} + +export const languageNames: Record = { + ko: '한국어', + en: 'English', + zh: '简体中文', + ja: '日本語', +}; + +export const localeTags: Record = { + ko: 'ko-KR', + en: 'en-US', + zh: 'zh-CN', + ja: 'ja-JP', +}; + +export const messages = { + ko: { + app: { + loading: '대시보드 로딩 중', + }, + actions: { + close: '닫기', + refresh: '새로고침', + refreshing: '새로고침 중', + retry: '다시 시도', + }, + nav: { + aria: '대시보드 섹션', + drawerAria: '대시보드 메뉴', + drawerNavAria: '대시보드 메뉴 섹션', + menuOpen: '메뉴 열기', + menuClose: '메뉴 닫기', + operations: '운영', + updated: '갱신', + health: '상태', + usage: '사용량', + rooms: '룸', + scheduled: '예약', + }, + language: { + label: '언어', + }, + error: { + api: 'API 오류', + }, + control: { + aria: '컨트롤 플레인 요약', + queue: '작업 큐', + activeRooms: '처리 + 대기 룸', + pendingRooms: '메시지 대기 룸', + }, + metrics: { + agents: '에이전트', + rooms: '룸', + ciWatchers: 'CI 감시', + }, + panels: { + health: '상태', + heartbeat: '하트비트', + usage: '사용량', + usageWindow: '5시간 / 7일', + rooms: '룸', + queue: '큐', + scheduled: '예약', + promptPreviews: '프롬프트 미리보기', + }, + service: { + empty: '하트비트 없음. 서비스 로그 확인.', + heartbeat: '하트비트', + service: '서비스', + rooms: '룸', + updated: '갱신', + }, + rooms: { + empty: '룸 없음.', + cardsAria: '룸 상태 카드', + room: '룸', + service: '서비스', + agent: '에이전트', + status: '상태', + queue: '큐', + elapsed: '경과', + }, + usage: { + empty: '사용량 스냅샷 없음. 수집기 확인.', + highest: '최고', + watch: '주의', + updated: '갱신', + peak: '피크', + reset: '리셋', + usage: '사용량', + window5h: '5시간', + window7d: '7일', + groupPrimary: 'Claude / Kimi', + groupCodex: 'Codex', + risk: { + ok: '여유', + warn: '주의', + critical: '위험', + }, + }, + tasks: { + empty: '예약 작업 없음.', + cardsAria: '예약 작업 카드', + task: '작업', + status: '상태', + schedule: '스케줄', + next: '다음', + last: '최근', + context: '컨텍스트', + ciWatch: 'CI 감시', + emptyPrompt: '(빈 미리보기)', + until: '까지', + lastResult: '최근 결과', + }, + status: { + processing: '처리중', + waiting: '대기', + inactive: '휴면', + active: '활성', + paused: '일시정지', + completed: '완료', + }, + units: { + second: '초', + minute: '분', + hour: '시간', + task: '작업', + messageShort: '메시지', + chars: '자', + }, + }, + en: { + app: { + loading: 'Loading dashboard', + }, + actions: { + close: 'Close', + refresh: 'Refresh', + refreshing: 'Refreshing', + retry: 'Retry', + }, + nav: { + aria: 'Dashboard sections', + drawerAria: 'Dashboard menu', + drawerNavAria: 'Dashboard menu sections', + menuOpen: 'Open menu', + menuClose: 'Close menu', + operations: 'Ops', + updated: 'Updated', + health: 'Health', + usage: 'Usage', + rooms: 'Rooms', + scheduled: 'Scheduled', + }, + language: { + label: 'Language', + }, + error: { + api: 'API error', + }, + control: { + aria: 'Control plane summary', + queue: 'Work queue', + activeRooms: 'processing + waiting rooms', + pendingRooms: 'rooms with pending messages', + }, + metrics: { + agents: 'agents', + rooms: 'rooms', + ciWatchers: 'CI watchers', + }, + panels: { + health: 'Health', + heartbeat: 'Heartbeat', + usage: 'Usage', + usageWindow: '5h / 7d', + rooms: 'Rooms', + queue: 'Queue', + scheduled: 'Scheduled', + promptPreviews: 'Prompt previews', + }, + service: { + empty: 'No heartbeat yet. Check service logs.', + heartbeat: 'heartbeat', + service: 'service', + rooms: 'rooms', + updated: 'updated', + }, + rooms: { + empty: 'No rooms yet.', + cardsAria: 'Room status cards', + room: 'room', + service: 'service', + agent: 'agent', + status: 'status', + queue: 'queue', + elapsed: 'elapsed', + }, + usage: { + empty: 'No usage snapshot. Check collector.', + highest: 'Highest', + watch: 'Watch', + updated: 'Updated', + peak: 'Peak', + reset: 'reset', + usage: 'usage', + window5h: '5h', + window7d: '7d', + groupPrimary: 'Claude / Kimi', + groupCodex: 'Codex', + risk: { + ok: 'Clear', + warn: 'Watch', + critical: 'Limit risk', + }, + }, + tasks: { + empty: 'No scheduled work.', + cardsAria: 'Scheduled task cards', + task: 'task', + status: 'status', + schedule: 'schedule', + next: 'next', + last: 'last', + context: 'context', + ciWatch: 'CI Watch', + emptyPrompt: '(empty preview)', + until: 'until', + lastResult: 'last result', + }, + status: { + processing: 'processing', + waiting: 'waiting', + inactive: 'inactive', + active: 'active', + paused: 'paused', + completed: 'completed', + }, + units: { + second: 's', + minute: 'm', + hour: 'h', + task: 'task', + messageShort: 'msg', + chars: 'chars', + }, + }, + zh: { + app: { + loading: '正在加载仪表盘', + }, + actions: { + close: '关闭', + refresh: '刷新', + refreshing: '刷新中', + retry: '重试', + }, + nav: { + aria: '仪表盘分区', + drawerAria: '仪表盘菜单', + drawerNavAria: '仪表盘菜单分区', + menuOpen: '打开菜单', + menuClose: '关闭菜单', + operations: '运营', + updated: '更新', + health: '健康', + usage: '用量', + rooms: '房间', + scheduled: '计划', + }, + language: { + label: '语言', + }, + error: { + api: 'API 错误', + }, + control: { + aria: '控制平面摘要', + queue: '任务队列', + activeRooms: '处理中 + 等待房间', + pendingRooms: '有待处理消息的房间', + }, + metrics: { + agents: '代理', + rooms: '房间', + ciWatchers: 'CI 监控', + }, + panels: { + health: '健康', + heartbeat: '心跳', + usage: '用量', + usageWindow: '5小时 / 7天', + rooms: '房间', + queue: '队列', + scheduled: '计划', + promptPreviews: '提示预览', + }, + service: { + empty: '暂无心跳。检查服务日志。', + heartbeat: '心跳', + service: '服务', + rooms: '房间', + updated: '更新', + }, + rooms: { + empty: '暂无房间。', + cardsAria: '房间状态卡片', + room: '房间', + service: '服务', + agent: '代理', + status: '状态', + queue: '队列', + elapsed: '耗时', + }, + usage: { + empty: '暂无用量快照。检查采集器。', + highest: '最高', + watch: '关注', + updated: '更新', + peak: '峰值', + reset: '重置', + usage: '用量', + window5h: '5小时', + window7d: '7天', + groupPrimary: 'Claude / Kimi', + groupCodex: 'Codex', + risk: { + ok: '充足', + warn: '关注', + critical: '接近上限', + }, + }, + tasks: { + empty: '暂无计划任务。', + cardsAria: '计划任务卡片', + task: '任务', + status: '状态', + schedule: '计划', + next: '下次', + last: '最近', + context: '上下文', + ciWatch: 'CI 监控', + emptyPrompt: '(空预览)', + until: '直到', + lastResult: '最近结果', + }, + status: { + processing: '处理中', + waiting: '等待', + inactive: '空闲', + active: '活跃', + paused: '暂停', + completed: '完成', + }, + units: { + second: '秒', + minute: '分', + hour: '小时', + task: '任务', + messageShort: '消息', + chars: '字', + }, + }, + ja: { + app: { + loading: 'ダッシュボードを読み込み中', + }, + actions: { + close: '閉じる', + refresh: '更新', + refreshing: '更新中', + retry: '再試行', + }, + nav: { + aria: 'ダッシュボードセクション', + drawerAria: 'ダッシュボードメニュー', + drawerNavAria: 'ダッシュボードメニューセクション', + menuOpen: 'メニューを開く', + menuClose: 'メニューを閉じる', + operations: '運用', + updated: '更新', + health: '状態', + usage: '使用量', + rooms: 'ルーム', + scheduled: '予定', + }, + language: { + label: '言語', + }, + error: { + api: 'API エラー', + }, + control: { + aria: 'コントロールプレーン概要', + queue: '作業キュー', + activeRooms: '処理中 + 待機ルーム', + pendingRooms: 'メッセージ待ちルーム', + }, + metrics: { + agents: 'エージェント', + rooms: 'ルーム', + ciWatchers: 'CI監視', + }, + panels: { + health: '状態', + heartbeat: 'ハートビート', + usage: '使用量', + usageWindow: '5時間 / 7日', + rooms: 'ルーム', + queue: 'キュー', + scheduled: '予定', + promptPreviews: 'プロンプトプレビュー', + }, + service: { + empty: 'ハートビートなし。サービスログを確認。', + heartbeat: 'ハートビート', + service: 'サービス', + rooms: 'ルーム', + updated: '更新', + }, + rooms: { + empty: 'ルームなし。', + cardsAria: 'ルーム状態カード', + room: 'ルーム', + service: 'サービス', + agent: 'エージェント', + status: '状態', + queue: 'キュー', + elapsed: '経過', + }, + usage: { + empty: '使用量スナップショットなし。収集器を確認。', + highest: '最高', + watch: '注意', + updated: '更新', + peak: 'ピーク', + reset: 'リセット', + usage: '使用量', + window5h: '5時間', + window7d: '7日', + groupPrimary: 'Claude / Kimi', + groupCodex: 'Codex', + risk: { + ok: '余裕', + warn: '注意', + critical: '上限注意', + }, + }, + tasks: { + empty: '予定作業なし。', + cardsAria: '予定作業カード', + task: '作業', + status: '状態', + schedule: '予定', + next: '次回', + last: '直近', + context: 'コンテキスト', + ciWatch: 'CI監視', + emptyPrompt: '(空のプレビュー)', + until: 'まで', + lastResult: '直近結果', + }, + status: { + processing: '処理中', + waiting: '待機', + inactive: '休止', + active: '有効', + paused: '一時停止', + completed: '完了', + }, + units: { + second: '秒', + minute: '分', + hour: '時間', + task: '作業', + messageShort: 'メッセージ', + chars: '字', + }, + }, +} satisfies Record; + +export function isLocale(value: string | null | undefined): value is Locale { + return LOCALES.includes(value as Locale); +} + +export function matchLocale(value: string | null | undefined): Locale | null { + if (!value) return null; + const normalized = value.toLowerCase(); + if (normalized.startsWith('ko')) return 'ko'; + if (normalized.startsWith('en')) return 'en'; + if (normalized.startsWith('zh')) return 'zh'; + if (normalized.startsWith('ja')) return 'ja'; + return null; +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 176b1a7..96895e2 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -11,6 +11,7 @@ --green: #3f7f51; --yellow: #b58a22; --red: #b74734; + --drawer: rgba(26, 31, 26, 0.72); --shadow: 0 24px 70px rgba(44, 39, 25, 0.18); font-family: 'Avenir Next', 'Segoe UI Variable', 'Noto Sans KR', ui-sans-serif, @@ -19,6 +20,11 @@ color: var(--bg-ink); } +html { + scroll-behavior: smooth; + scroll-padding-top: 92px; +} + * { box-sizing: border-box; } @@ -27,6 +33,7 @@ body { min-width: 320px; min-height: 100vh; margin: 0; + overflow-x: hidden; background: radial-gradient( circle at 18% 12%, @@ -48,9 +55,23 @@ table { } .shell { + display: grid; + grid-template-columns: 184px minmax(0, 1fr); + gap: 18px; + align-items: start; width: min(1480px, calc(100% - 40px)); margin: 0 auto; - padding: 34px 0 56px; + padding: 18px 0 calc(36px + env(safe-area-inset-bottom)); +} + +.shell-loading { + display: block; +} + +.dashboard-content { + display: grid; + gap: 12px; + min-width: 0; } .shell::before { @@ -75,42 +96,6 @@ table { letter-spacing: -0.05em; } -.hero { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - padding: clamp(28px, 4vw, 54px); - overflow: hidden; - border: 1px solid var(--panel-border); - border-radius: 34px; - background: - linear-gradient( - 128deg, - rgba(255, 250, 240, 0.96), - rgba(255, 250, 240, 0.76) - ), - linear-gradient(45deg, rgba(191, 95, 44, 0.12), rgba(63, 127, 81, 0.1)); - box-shadow: var(--shadow); -} - -.hero h1 { - max-width: 880px; - margin: 10px 0 12px; - font-family: 'Georgia', 'Noto Serif KR', serif; - font-size: clamp(42px, 8vw, 108px); - line-height: 0.92; - letter-spacing: -0.08em; -} - -.hero p { - max-width: 720px; - margin: 0; - color: var(--muted); - font-size: clamp(16px, 2vw, 22px); - line-height: 1.55; -} - .eyebrow { color: var(--accent-strong); font-size: 12px; @@ -130,11 +115,266 @@ button { cursor: pointer; } +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible { + outline: 3px solid rgba(191, 95, 44, 0.45); + outline-offset: 3px; +} + button:disabled { cursor: wait; opacity: 0.62; } +.section-nav { + position: sticky; + top: 10px; + z-index: 20; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + margin: 0; + padding: 10px; + border: 1px solid rgba(43, 55, 38, 0.14); + border-radius: 22px; + background: rgba(255, 250, 240, 0.82); + box-shadow: 0 18px 48px rgba(44, 39, 25, 0.12); + backdrop-filter: blur(18px); +} + +.section-nav button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 999px; + text-decoration: none; + white-space: nowrap; +} + +.section-nav button { + min-height: 44px; +} + +.section-nav .menu-button { + width: 48px; + min-width: 48px; + padding: 0; + gap: 4px; + flex-direction: column; + background: #20281f; + box-shadow: none; +} + +.menu-button span { + display: block; + width: 18px; + height: 2px; + border-radius: 999px; + background: #fffaf0; +} + +.section-nav .refresh-button { + min-width: 96px; +} + +.topbar-label { + min-width: 0; + overflow: hidden; + font-size: 14px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.section-nav span { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 13px; + font-weight: 750; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-rail { + position: sticky; + top: 18px; + display: grid; + min-height: calc(100vh - 36px); + align-content: start; + gap: 14px; + padding: 16px; + border: 1px solid rgba(43, 55, 38, 0.14); + border-radius: 28px; + background: rgba(255, 250, 240, 0.78); + box-shadow: 0 18px 48px rgba(44, 39, 25, 0.1); + backdrop-filter: blur(18px); +} + +.side-rail-brand strong { + display: block; + margin-top: 6px; + font-size: 24px; + letter-spacing: -0.05em; +} + +.side-rail nav { + display: grid; + gap: 7px; +} + +.side-rail nav a { + display: flex; + min-height: 44px; + align-items: center; + padding: 0 12px; + border-radius: 15px; + color: var(--bg-ink); + background: rgba(43, 55, 38, 0.055); + font-weight: 900; + text-decoration: none; +} + +.side-rail nav a:hover { + background: rgba(191, 95, 44, 0.12); +} + +.side-refresh { + width: 100%; + min-height: 44px; + color: var(--bg-ink); + background: rgba(43, 55, 38, 0.08); + box-shadow: none; + font-weight: 900; +} + +.side-refresh:hover { + background: rgba(191, 95, 44, 0.12); +} + +.language-select { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.language-select select { + min-height: 44px; + width: 100%; + padding: 0 12px; + border: 1px solid rgba(43, 55, 38, 0.14); + border-radius: 15px; + color: var(--bg-ink); + background: rgba(255, 250, 240, 0.78); + font: inherit; + font-size: 14px; + letter-spacing: 0; + text-transform: none; +} + +.drawer-backdrop { + position: fixed; + inset: 0; + z-index: 35; + min-height: 0; + padding: 0; + border-radius: 0; + background: var(--drawer); + box-shadow: none; + cursor: pointer; +} + +.nav-drawer { + position: fixed; + z-index: 40; + top: max(14px, env(safe-area-inset-top)); + bottom: max(14px, env(safe-area-inset-bottom)); + left: 14px; + display: grid; + width: min(360px, calc(100vw - 28px)); + grid-template-rows: auto 1fr auto; + gap: 18px; + padding: 18px; + border: 1px solid rgba(255, 250, 240, 0.3); + border-radius: 28px; + background: rgba(255, 250, 240, 0.94); + box-shadow: 0 28px 90px rgba(20, 18, 10, 0.36); + backdrop-filter: blur(20px); +} + +.drawer-head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.drawer-head strong { + display: block; + margin-top: 4px; + font-size: 24px; + letter-spacing: -0.04em; +} + +.drawer-head button { + min-height: 44px; + padding: 0 14px; + color: var(--bg-ink); + background: rgba(43, 55, 38, 0.08); + box-shadow: none; +} + +.nav-drawer nav { + display: grid; + align-content: start; + gap: 8px; +} + +.nav-drawer nav a { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border: 1px solid rgba(43, 55, 38, 0.08); + border-radius: 18px; + color: var(--bg-ink); + background: rgba(43, 55, 38, 0.045); + font-weight: 900; + text-decoration: none; +} + +.nav-drawer nav a::after { + color: var(--muted); + content: '↘'; +} + +.drawer-meta { + display: grid; + gap: 4px; + padding: 14px; + border-radius: 18px; + color: var(--muted); + background: rgba(43, 55, 38, 0.06); +} + +.drawer-meta strong { + color: var(--bg-ink); +} + .error-card, .card, .panel { @@ -145,6 +385,10 @@ button:disabled { } .error-card { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; margin-top: 18px; padding: 18px 20px; border-color: rgba(183, 71, 52, 0.35); @@ -154,18 +398,23 @@ button:disabled { font-weight: 800; } -.metrics-grid { +.error-card button { + min-width: 112px; + flex: none; +} + +.skeleton-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; - margin: 22px 0; + margin: 0; } -.control-rail { +.ops-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; - margin: 18px 0 22px; + margin: 0; overflow: hidden; border: 1px solid var(--panel-border); border-radius: 28px; @@ -173,11 +422,11 @@ button:disabled { box-shadow: 0 18px 50px rgba(44, 39, 25, 0.08); } -.control-rail div { +.ops-strip div { display: grid; - gap: 8px; - min-height: 116px; - padding: 20px; + gap: 5px; + min-height: 74px; + padding: 12px 14px; background: linear-gradient( 145deg, @@ -191,27 +440,34 @@ button:disabled { ); } -.control-rail strong { - font-size: clamp(22px, 3vw, 38px); +.ops-strip span { + color: var(--muted); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ops-strip strong { + font-size: clamp(18px, 2vw, 28px); line-height: 1; letter-spacing: -0.05em; } -.control-rail small { +.ops-strip small { color: var(--muted); + font-size: 12px; line-height: 1.35; } -.metric-card { +.skeleton-card { display: grid; gap: 8px; - min-height: 142px; + min-height: 112px; padding: 22px; border-radius: 26px; } -.metric-card span, -.metric-card small, td span, td small, dd, @@ -221,28 +477,14 @@ dd, color: var(--muted); } -.metric-card span { - font-weight: 850; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.metric-card strong { - font-size: clamp(36px, 5vw, 66px); - line-height: 0.9; - letter-spacing: -0.07em; -} - .panel { - margin-top: 18px; padding: 24px; border-radius: 30px; + scroll-margin-top: 92px; } -.split-panel { - display: grid; - grid-template-columns: minmax(340px, 0.82fr) minmax(0, 1.18fr); - gap: 22px; +.usage-first { + padding: 18px; } .panel-title { @@ -324,6 +566,10 @@ dd { background: rgba(255, 250, 240, 0.56); } +.mobile-record-list { + display: none; +} + table { width: 100%; border-collapse: collapse; @@ -374,6 +620,7 @@ td small { border-radius: 999px; font-size: 12px; font-weight: 850; + white-space: nowrap; } .pill-processing, @@ -394,36 +641,153 @@ td small { background: rgba(109, 115, 95, 0.16); } -.usage-list { - display: grid; - gap: 14px; +.pill-ok { + color: #173e23; + background: rgba(63, 127, 81, 0.18); } -.usage-row { - padding: 18px; +.pill-warn { + color: #6e4a05; + background: rgba(181, 138, 34, 0.2); +} + +.pill-critical { + color: #7c2518; + background: rgba(183, 71, 52, 0.18); +} + +.usage-dashboard { + display: grid; + gap: 10px; +} + +.usage-summary { + display: grid; + grid-template-columns: 1.1fr 0.46fr 0.9fr; + gap: 8px; +} + +.usage-summary div, +.usage-matrix { border: 1px solid rgba(43, 55, 38, 0.1); - border-radius: 22px; + border-radius: 18px; background: rgba(255, 250, 240, 0.62); } -.usage-title { - display: flex; - gap: 10px; - justify-content: space-between; - margin-bottom: 14px; +.usage-summary div { + display: grid; + gap: 4px; + min-width: 0; + padding: 10px 12px; } -.bar-line { +.usage-summary span, +.usage-window small { + color: var(--muted); +} + +.usage-summary strong { + min-width: 0; + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-matrix { display: grid; - grid-template-columns: 34px 1fr 54px minmax(60px, auto); - gap: 10px; + overflow: hidden; +} + +.usage-matrix-head, +.usage-row { + display: grid; + grid-template-columns: minmax(120px, 0.72fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; align-items: center; - margin-top: 9px; +} + +.usage-matrix-head { + padding: 9px 12px; + color: var(--muted); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; + background: rgba(43, 55, 38, 0.055); +} + +.usage-group { + display: grid; +} + +.usage-group + .usage-group { + border-top: 1px solid rgba(43, 55, 38, 0.12); +} + +.usage-group-label { + padding: 7px 10px; + color: var(--accent-strong); + font-size: 11px; + font-weight: 950; + letter-spacing: 0.1em; + text-transform: uppercase; + background: rgba(191, 95, 44, 0.07); +} + +.usage-row { + --meter: var(--green); + padding: 8px 10px; + border-top: 1px solid rgba(43, 55, 38, 0.08); +} + +.usage-warn { + --meter: var(--yellow); +} + +.usage-critical { + --meter: var(--red); +} + +.usage-account, +.usage-window div { + display: flex; + min-width: 0; + gap: 8px; + align-items: center; + justify-content: space-between; +} + +.usage-account { + justify-content: flex-start; +} + +.usage-account strong { + min-width: 0; + overflow: hidden; + font-size: 14px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-window span { + color: var(--muted); + font-size: 11px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.usage-window { + display: grid; + min-width: 0; + gap: 5px; } progress { width: 100%; - height: 12px; + height: 8px; overflow: hidden; border: 0; border-radius: 999px; @@ -436,12 +800,12 @@ progress::-webkit-progress-bar { progress::-webkit-progress-value { border-radius: 999px; - background: linear-gradient(90deg, var(--green), var(--accent)); + background: linear-gradient(90deg, var(--meter, var(--green)), var(--accent)); } progress::-moz-progress-bar { border-radius: 999px; - background: linear-gradient(90deg, var(--green), var(--accent)); + background: linear-gradient(90deg, var(--meter, var(--green)), var(--accent)); } .empty-state { @@ -452,55 +816,350 @@ progress::-moz-progress-bar { background: rgba(255, 250, 240, 0.45); } +.record-card { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid rgba(43, 55, 38, 0.12); + border-radius: 22px; + background: rgba(255, 250, 240, 0.68); +} + +.record-card-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; +} + +.record-card-head strong, +.task-card > p { + overflow-wrap: anywhere; +} + +.record-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.record-card-grid > span { + min-width: 0; + padding: 11px; + border: 1px solid rgba(43, 55, 38, 0.08); + border-radius: 16px; + background: rgba(43, 55, 38, 0.045); +} + +.record-card-grid small, +.record-id { + color: var(--muted); +} + +.record-card-grid small, +.record-card-grid strong { + display: block; +} + +.record-card-grid strong { + min-width: 0; + overflow: hidden; + margin-top: 3px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mono-chip, +.record-id { + font-family: + 'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace; + font-size: 12px; +} + +.mono-chip { + display: inline-flex; + max-width: 100%; + min-height: 28px; + align-items: center; + margin-top: 8px; + padding: 0 9px; + overflow: hidden; + border-radius: 999px; + color: var(--muted); + background: rgba(43, 55, 38, 0.08); + text-overflow: ellipsis; + white-space: nowrap; +} + +.record-id { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skeleton-line, +.skeleton-button { + display: block; + overflow: hidden; + border-radius: 999px; + background: linear-gradient( + 90deg, + rgba(43, 55, 38, 0.08), + rgba(255, 250, 240, 0.85), + rgba(43, 55, 38, 0.08) + ); + background-size: 220% 100%; + animation: shimmer 1.45s ease-in-out infinite; +} + +.skeleton-short { + width: 180px; + height: 14px; +} + +.skeleton-title { + width: min(640px, 84vw); + height: clamp(48px, 10vw, 94px); + margin-top: 16px; +} + +.skeleton-copy { + width: min(520px, 72vw); + height: 18px; + margin-top: 14px; +} + +.skeleton-number { + width: 90px; + height: 56px; +} + +.skeleton-button { + width: 126px; + height: 48px; +} + +@keyframes shimmer { + from { + background-position: 120% 0; + } + + to { + background-position: -120% 0; + } +} + +@media (min-width: 981px) { + .section-nav { + display: none; + } +} + @media (max-width: 980px) { - .hero, - .split-panel { - grid-template-columns: 1fr; + .shell { + display: block; } - .hero { - align-items: start; + .side-rail { + display: none; } - .metrics-grid { + .skeleton-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .control-rail { + .ops-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 760px) { + .desktop-table { + display: none; + } + + .mobile-record-list { + display: grid; + gap: 12px; + } +} + @media (max-width: 640px) { - .shell { - width: min(100% - 24px, 1480px); - padding: 18px 0 34px; + html { + scroll-padding-top: 18px; + scroll-padding-bottom: 104px; + } + + .shell { + width: min(100% - 20px, 1480px); + padding: 10px 0 calc(24px + env(safe-area-inset-bottom)); } - .hero, .panel { border-radius: 22px; - padding: 20px; + padding: 18px; } - .metrics-grid { - grid-template-columns: 1fr; + .section-nav { + position: sticky; + top: max(8px, env(safe-area-inset-top)); + right: 10px; + left: 10px; + grid-template-columns: 52px minmax(0, 1fr) 54px; + gap: 6px; + margin: 0; + padding: 8px; + border-radius: 24px; } - .control-rail { - grid-template-columns: 1fr; + .section-nav button { + min-height: 48px; + padding: 0 8px; + font-size: 12px; } - .panel-title, - .usage-title { + .section-nav span { + display: none; + } + + .section-nav button { + min-width: 0; + color: transparent; + font-size: 0; + } + + .section-nav .menu-button { + padding: 0; + } + + .section-nav .refresh-button { + min-width: 0; + } + + .section-nav .refresh-button::before { + color: #fffaf0; + content: '↻'; + font-size: 20px; + font-weight: 900; + } + + .section-nav .refresh-button:disabled::before { + content: '…'; + } + + .skeleton-grid, + .ops-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .ops-strip div, + .skeleton-card { + min-height: 84px; + padding: 14px; + } + + .ops-strip strong { + font-size: clamp(22px, 8vw, 34px); + } + + .ops-strip small { + font-size: 12px; + } + + .panel-title { display: grid; } - .bar-line { - grid-template-columns: 28px 1fr 44px; + .usage-summary { + grid-template-columns: minmax(0, 1fr) minmax(78px, 0.55fr); } - .bar-line small { - grid-column: 2 / -1; + .usage-summary div:last-child { + grid-column: 1 / -1; + } + + .usage-matrix-head { + display: none; + } + + .usage-row { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 6px; + padding: 9px; + } + + .usage-account { + display: grid; + gap: 5px; + } + + .usage-window { + gap: 4px; + } + + .usage-window div { + gap: 6px; + } + + .usage-window small { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .record-card-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 380px) { + .shell { + width: min(100% - 16px, 1480px); + } + + .section-nav { + right: 8px; + left: 8px; + } + + .usage-summary { + grid-template-columns: 1fr; + } + + .usage-summary div:last-child { + grid-column: auto; + } + + .usage-row { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .usage-account { + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) auto; + } + + .skeleton-grid, + .ops-strip { + gap: 8px; + } + + .record-card-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index 3a6cd5d..6e77084 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { UsageRow } from './dashboard-usage-rows.js'; import { + buildWebUsageRowsForSnapshot, formatStatusHeader, renderUsageTable, summarizeWatcherTasks, @@ -107,3 +108,94 @@ describe('renderUsageTable', () => { expect(lines).toEqual(['_조회 불가_']); }); }); + +describe('buildWebUsageRowsForSnapshot', () => { + it('keeps real Claude and Kimi rows ahead of Codex rows for web snapshots', () => { + const rows = buildWebUsageRowsForSnapshot({ + serviceAgentType: 'claude-code', + claudeAccounts: [ + { + index: 0, + masked: 'claude-1', + isActive: true, + isRateLimited: false, + usage: { + five_hour: { + utilization: 0.2, + resets_at: '2026-04-26T12:00:00.000Z', + }, + seven_day: { + utilization: 0.4, + resets_at: '2026-04-27T12:00:00.000Z', + }, + }, + }, + { + index: 1, + masked: 'claude-2', + isActive: false, + isRateLimited: false, + usage: { + five_hour: { + utilization: 0.3, + resets_at: '2026-04-26T12:00:00.000Z', + }, + seven_day: { + utilization: 0.5, + resets_at: '2026-04-27T12:00:00.000Z', + }, + }, + }, + ], + kimiUsage: { + fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' }, + weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' }, + }, + codexRows: [ + { + name: 'Codex1', + h5pct: 25, + h5reset: '1h', + d7pct: 35, + d7reset: '2d', + }, + ], + }); + + const names = rows.map((row) => row.name); + expect(names[0]).toMatch(/^Claude1/); + expect(names[1]).toMatch(/^Claude2/); + expect(names[2]).toMatch(/^Kimi/); + expect(names[3]).toBe('Codex1'); + }); + + it('does not invent Claude or Kimi rows for codex-only services', () => { + const rows = buildWebUsageRowsForSnapshot({ + serviceAgentType: 'codex', + claudeAccounts: [ + { + index: 0, + masked: 'claude-1', + isActive: true, + isRateLimited: false, + usage: null, + }, + ], + kimiUsage: { + fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' }, + weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' }, + }, + codexRows: [ + { + name: 'Codex1', + h5pct: 25, + h5reset: '1h', + d7pct: 35, + d7reset: '2d', + }, + ], + }); + + expect(rows.map((row) => row.name)).toEqual(['Codex1']); + }); +}); diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 44048fd..86113ba 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -17,7 +17,11 @@ import { CODEX_WARMUP_CONFIG, getMoaConfig, } from './config.js'; -import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js'; +import { + fetchKimiUsage, + buildKimiUsageRows, + type KimiUsageData, +} from './kimi-usage.js'; import { getGlobalFailoverInfo } from './service-routing.js'; import { fetchAllClaudeUsage, @@ -93,7 +97,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000; let statusMessageId: string | null = null; let cachedUsageContent = ''; let cachedClaudeAccounts: ClaudeAccountUsage[] = []; -let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null; +let cachedKimiUsage: KimiUsageData | null = null; let usageUpdateInProgress = false; let channelMetaCache = new Map(); let channelMetaLastRefresh = 0; @@ -102,6 +106,8 @@ let dashboardUpdateLogged = false; let cachedCodexUsageRows: UsageRow[] = []; /** Codex service only: ISO timestamp of last successful usage fetch. */ let codexUsageFetchedAt: string | null = null; +/** Renderer service only: ISO timestamp of last successful Claude/Kimi usage render. */ +let rendererUsageFetchedAt: string | null = null; export interface WatcherTaskSummary { active: number; @@ -231,9 +237,47 @@ function formatRoomName( return base; } +export function buildWebUsageRowsForSnapshot(args: { + serviceAgentType: AgentType; + claudeAccounts: ClaudeAccountUsage[]; + kimiUsage: KimiUsageData | null; + codexRows: UsageRow[]; +}): UsageRow[] { + const rows: UsageRow[] = []; + + if (args.serviceAgentType === 'claude-code') { + rows.push(...buildClaudeUsageRows(args.claudeAccounts)); + rows.push(...buildKimiUsageRows(args.kimiUsage)); + } + + rows.push(...args.codexRows); + return rows; +} + +function buildUsageSnapshotRows(opts: UnifiedDashboardOptions): { + rows: UsageRow[]; + fetchedAt: string | null; +} { + const rows = buildWebUsageRowsForSnapshot({ + serviceAgentType: opts.serviceAgentType, + claudeAccounts: cachedClaudeAccounts, + kimiUsage: cachedKimiUsage, + codexRows: cachedCodexUsageRows, + }); + + const fetchedAt = + [rendererUsageFetchedAt, codexUsageFetchedAt] + .filter((value): value is string => !!value) + .sort() + .at(-1) ?? null; + + return { rows, fetchedAt }; +} + function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void { const groups = opts.roomBindings(); const statuses = opts.queue.getStatuses(Object.keys(groups)); + const usageSnapshot = buildUsageSnapshotRows(opts); writeStatusSnapshot({ serviceId: opts.serviceId, @@ -267,8 +311,10 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void { pendingMessages: boolean; pendingTasks: number; }>, - ...(cachedCodexUsageRows.length > 0 && { usageRows: cachedCodexUsageRows }), - ...(codexUsageFetchedAt && { usageRowsFetchedAt: codexUsageFetchedAt }), + ...(usageSnapshot.rows.length > 0 && { usageRows: usageSnapshot.rows }), + ...(usageSnapshot.fetchedAt && { + usageRowsFetchedAt: usageSnapshot.fetchedAt, + }), }); } @@ -647,6 +693,7 @@ async function refreshUsageCache(): Promise { usageUpdateInProgress = true; try { cachedUsageContent = await buildUsageContent(); + rendererUsageFetchedAt = new Date().toISOString(); } catch (err) { logger.warn({ err }, 'Failed to build usage content'); } finally { diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 99bc4a3..8d3c098 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -94,6 +94,83 @@ describe('web dashboard data', () => { expect(overview.usage.rows).toHaveLength(1); }); + it('deduplicates full usage rows from renderer and codex snapshots', () => { + const snapshots: StatusSnapshot[] = [ + { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt: '2026-04-26T04:59:00.000Z', + entries: [], + usageRowsFetchedAt: '2026-04-26T04:59:00.000Z', + usageRows: [ + { + name: 'Codex1', + h5pct: 20, + h5reset: '1h', + d7pct: 30, + d7reset: '2d', + }, + { + name: 'Codex2', + h5pct: 15, + h5reset: '1h', + d7pct: 18, + d7reset: '2d', + }, + ], + }, + { + serviceId: 'claude-main', + agentType: 'claude-code', + assistantName: 'Claude', + updatedAt: '2026-04-26T05:00:00.000Z', + entries: [], + usageRowsFetchedAt: '2026-04-26T05:00:00.000Z', + usageRows: [ + { + name: 'Claude1 Max', + h5pct: 66, + h5reset: '2h', + d7pct: 40, + d7reset: '4d', + }, + { + name: 'Kimi', + h5pct: 10, + h5reset: '3h', + d7pct: 12, + d7reset: '5d', + }, + { + name: 'Codex1', + h5pct: 25, + h5reset: '55m', + d7pct: 35, + d7reset: '2d', + }, + ], + }, + ]; + + const overview = buildWebDashboardOverview({ + now: '2026-04-26T05:01:00.000Z', + snapshots, + tasks: [], + }); + + expect(overview.usage.rows.map((row) => row.name)).toEqual([ + 'Claude1 Max', + 'Kimi', + 'Codex1', + 'Codex2', + ]); + expect( + overview.usage.rows.filter((row) => row.name === 'Codex1'), + ).toHaveLength(1); + expect(overview.usage.fetchedAt).toBe('2026-04-26T05:00:00.000Z'); + }); + it('does not expose full scheduled task prompts through API payloads', () => { const sanitized = sanitizeScheduledTask( makeTask({ diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index 87c14ec..3d45814 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -76,6 +76,26 @@ function buildPromptPreview(prompt: string): string { return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim()); } +function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { + const rows: UsageRowSnapshot[] = []; + const seen = new Set(); + const sortedSnapshots = [...snapshots].sort((a, b) => + (b.usageRowsFetchedAt ?? b.updatedAt).localeCompare( + a.usageRowsFetchedAt ?? a.updatedAt, + ), + ); + + for (const snapshot of sortedSnapshots) { + for (const row of snapshot.usageRows ?? []) { + if (seen.has(row.name)) continue; + seen.add(row.name); + rows.push(row); + } + } + + return rows; +} + export function sanitizeScheduledTask( task: ScheduledTask, ): SanitizedScheduledTask { @@ -162,9 +182,7 @@ export function buildWebDashboardOverview(args: { } } - const usageRows = args.snapshots.flatMap( - (snapshot) => snapshot.usageRows ?? [], - ); + const usageRows = collectUsageRows(args.snapshots); const usageFetchedAt = args.snapshots .map((snapshot) => snapshot.usageRowsFetchedAt) diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 9d065a6..5223b13 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -37,6 +37,59 @@ describe('web dashboard server handler', () => { expect(body.tasks.total).toBe(0); }); + it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => { + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [ + { + serviceId: 'renderer', + agentType: 'claude-code', + assistantName: 'Claude', + updatedAt: '2026-04-26T11:59:00.000Z', + entries: [], + usageRowsFetchedAt: '2026-04-26T11:59:00.000Z', + usageRows: [ + { + name: 'Claude1 Max', + h5pct: 66, + h5reset: '2h', + d7pct: 40, + d7reset: '4d', + }, + { + name: 'Kimi', + h5pct: 10, + h5reset: '3h', + d7pct: 12, + d7reset: '5d', + }, + { + name: 'Codex1', + h5pct: 25, + h5reset: '55m', + d7pct: 35, + d7reset: '2d', + }, + ], + }, + ], + getTasks: () => [], + }); + + const overview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(overview.status).toBe(200); + const body = (await overview.json()) as { + usage: { rows: Array<{ name: string }> }; + }; + + expect(body.usage.rows.map((row) => row.name)).toEqual([ + 'Claude1 Max', + 'Kimi', + 'Codex1', + ]); + }); + it('serves Vite static assets and falls back to index for SPA routes', async () => { const staticDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-dashboard-'), From 11ab18280cc1dabc5d09a8f21e38fa0bcd16c0c0 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 26 Apr 2026 22:03:52 +0900 Subject: [PATCH 26/48] [codex] Switch dashboard sections to view routing Switch dashboard navigation from long section scrolling to hash-backed views, preserving Usage as the default operational view and keeping mobile drawer navigation visible. --- apps/dashboard/src/App.tsx | 154 +++++++++++++++++++++++++--------- apps/dashboard/src/styles.css | 25 ++++++ 2 files changed, 140 insertions(+), 39 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 7fd51b0..6d96882 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -27,9 +27,28 @@ interface DashboardState { type UsageRow = DashboardOverview['usage']['rows'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; +type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled'; const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; +const DEFAULT_VIEW: DashboardView = 'usage'; + +function isDashboardView( + value: string | null | undefined, +): value is DashboardView { + return ( + value === 'usage' || + value === 'health' || + value === 'rooms' || + value === 'scheduled' + ); +} + +function readViewFromHash(): DashboardView { + if (typeof window === 'undefined') return DEFAULT_VIEW; + const raw = window.location.hash.replace(/^#\/?/, ''); + return isDashboardView(raw) ? raw : DEFAULT_VIEW; +} function readInitialLocale(): Locale { const stored = @@ -110,10 +129,10 @@ function queueLabel( function navItems(t: Messages) { return [ - { href: '#overview', label: t.nav.health }, - { href: '#usage', label: t.nav.usage }, - { href: '#rooms', label: t.nav.rooms }, - { href: '#work', label: t.nav.scheduled }, + { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, + { href: '#/health', label: t.nav.health, view: 'health' as const }, + { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, + { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, ]; } @@ -170,15 +189,19 @@ function LoadingSkeleton({ t }: { t: Messages }) { } function SideRail({ + activeView, lastRefreshed, locale, + onNavigate, onLocaleChange, onRefresh, refreshing, t, }: { + activeView: DashboardView; lastRefreshed: string | null; locale: Locale; + onNavigate: (view: DashboardView) => void; onLocaleChange: (locale: Locale) => void; onRefresh: () => void; refreshing: boolean; @@ -192,7 +215,13 @@ function SideRail({