Skip to content

feat(cli): add read-only context for lightweight query commands#100

Merged
dean0x merged 11 commits intomainfrom
feat/read-only-cli-90
Mar 18, 2026
Merged

feat(cli): add read-only context for lightweight query commands#100
dean0x merged 11 commits intomainfrom
feat/read-only-cli-90

Conversation

@dean0x
Copy link
Owner

@dean0x dean0x commented Mar 18, 2026

Summary

Implement ReadOnlyContext module for query-only commands (status, logs, list, schedule list/get). Skips EventBus, handlers, WorkerPool, and recovery, reducing startup overhead from 200-500ms to ~50ms.

Changes

  • ReadOnlyContext interface: Database + 4 repositories (TaskRepository, OutputRepository, ScheduleRepository)
  • createReadOnlyContext() factory: Lightweight context initialization
  • Query commands refactored: logs.ts, status.ts now use withReadOnlyContext()
  • BootstrapOptions.skipRecovery: Added flag to skip recovery for short-lived CLI commands
  • Query command classification in services.ts: Clear guidance on which commands use which context
  • 8 new tests: Context creation, round-trip data, database state, multi-repository queries, error cases

Performance Impact

  • Query commands: ~200-400ms faster (skip Event-driven pipeline)
  • Mutation commands: No change (still use full bootstrap)
  • MCP server: No change (full bootstrap unchanged)

Testing

  • 8 new tests in read-only-context.test.ts covering all scenarios
  • All existing tests passing
  • Manual testing: beat status, beat logs should be noticeably faster

Related

Co-Authored-By: Claude noreply@anthropic.com

Dean Sharon and others added 2 commits March 18, 2026 19:46
- Add error reporting when --history fetch fails in schedule get
  (was silently swallowed)
- Replace inline import() type with proper top-level ScheduleExecution import
Implement ReadOnlyContext module that skips EventBus, handlers, and
WorkerPool for query-only commands (status, logs, list, schedule list/get),
reducing startup time from 200-500ms to ~50ms.

Changes:
- ReadOnlyContext interface with Database + 4 repositories
- createReadOnlyContext() factory that bypasses full bootstrap
- Query commands (logs, status) now use withReadOnlyContext()
- Mutation commands still use full bootstrap via withServices()
- Added skipRecovery flag to BootstrapOptions for short-lived CLI commands
- 8 new tests covering context creation, round-trip data, and error handling

Performance impact:
- Query commands: ~200-400ms faster (no EventBus, handlers, WorkerPool)
- Mutation commands: Unchanged (still use full bootstrap)
- MCP server: Unchanged (full bootstrap with recovery/schedule executor)

Related issue: #90

Co-Authored-By: Claude <noreply@anthropic.com>
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 18, 2026

Confidence Score: 3/5

  • Safe to merge after fixing the stderr/stdout routing bug for execution history output in scheduleGet().
  • The core abstraction (ReadOnlyContext, createReadOnlyContext, withReadOnlyContext) is clean and well-tested. The refactor of logs.ts and status.ts is correct. The one concrete bug — execution history written to process.stderr instead of process.stdout in scheduleGet() — breaks a user-facing feature (--history output cannot be piped or redirected). It is a one-line fix but does affect observable behaviour.
  • src/cli/commands/schedule.ts — execution history output at line 382 uses process.stderr.write and should use process.stdout.write

Important Files Changed

Filename Overview
src/cli/read-only-context.ts New lightweight context module; cleanly wraps Database + 3 repositories with a close() method. No issues found.
src/cli/services.ts Adds withReadOnlyContext() helper alongside existing withServices(); error handling and process.exit on failure are consistent with the rest of the file.
src/cli/commands/logs.ts Refactored to use withReadOnlyContext(); stdout/stderr correctly routed to their respective streams. No issues found.
src/cli/commands/status.ts Refactored to use withReadOnlyContext(); context is declared before try and closed in finally, resource cleanup is correct. No issues found.
src/cli/commands/schedule.ts Split list/get onto ReadOnlyContext and mutation commands onto full bootstrap. Execution history output at line 382 is incorrectly written to process.stderr, breaking piping and redirection.
src/bootstrap.ts Adds skipRecovery flag to BootstrapOptions and honours it before running RecoveryManager; straightforward and consistent with the existing skipScheduleExecutor pattern.
tests/unit/read-only-context.test.ts 8 focused tests covering context creation, round-trip reads, multi-repo queries, output repository data, and error cases. Test isolation via temp directories is correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    CLI[CLI Command] --> Q{Query or\nMutation?}

    Q -- "status / logs /\nschedule list|get" --> ROC[withReadOnlyContext]
    Q -- "run / cancel / retry /\nschedule create|cancel|pause|resume" --> WS[withServices]

    ROC --> CRC[createReadOnlyContext]
    CRC --> CFG[loadConfiguration]
    CRC --> DB[(Database\nSQLite)]
    DB --> TR[SQLiteTaskRepository]
    DB --> OR[SQLiteOutputRepository]
    DB --> SR[SQLiteScheduleRepository]
    CRC --> CTX[ReadOnlyContext\ntaskRepository\noutputRepository\nscheduleRepository\nclose]

    WS --> BS[bootstrap\nskipRecovery: true\nskipScheduleExecutor: true]
    BS --> EB[EventBus]
    BS --> WP[WorkerPool]
    BS --> TM[TaskManager]
    BS --> SS[ScheduleService]
    BS --> HDLRS[Event Handlers]

    CTX --> CMD1[getTaskStatus]
    CTX --> CMD2[getTaskLogs]
    CTX --> CMD3[scheduleList]
    CTX --> CMD4[scheduleGet]
Loading

Last reviewed commit: "fix(cli): write task..."

import { SQLiteTaskRepository } from '../implementations/task-repository.js';

export interface ReadOnlyContext {
readonly database: Database;
Copy link
Owner Author

Choose a reason for hiding this comment

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

[Architecture] DIP Violation: Concrete Database class on interface

The readonly database: Database exposes the concrete Database class instead of an abstraction. This breaks the Dependency Inversion Principle used throughout the codebase where repository interfaces are abstracted in src/core/interfaces.ts.

Impact:

  • Callers are coupled to a specific SQLite implementation
  • OutputRepository is imported from the implementation layer instead of core interfaces

Suggested Fix - Option A (remove database if not needed):

export interface ReadOnlyContext {
  readonly taskRepository: TaskRepository;
  readonly outputRepository: OutputRepository;
  readonly scheduleRepository: ScheduleRepository;
  close(): void;
}

Option B (expose narrow lifecycle interface):

interface Closeable { close(): void; isOpen(): boolean; }
export interface ReadOnlyContext {
  readonly database: Closeable;
  readonly taskRepository: TaskRepository;
  readonly outputRepository: OutputRepository;
  readonly scheduleRepository: ScheduleRepository;
}

*/

import { loadConfiguration } from '../core/configuration.js';
import { ScheduleRepository, TaskRepository } from '../core/interfaces.js';
Copy link
Owner Author

Choose a reason for hiding this comment

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

[TypeScript] Missing import type for interface-only imports

Repository interfaces are only used as type annotations in ReadOnlyContext, but imported as values. Use import type to clarify intent:

import type { ScheduleRepository, TaskRepository } from '../core/interfaces.js';
import type { OutputRepository } from '../implementations/output-repository.js';
import { SQLiteOutputRepository } from '../implementations/output-repository.js';

This follows the TypeScript skill guideline: "Type-only imports for types."

import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createReadOnlyContext, ReadOnlyContext } from '../../src/cli/read-only-context.js';
Copy link
Owner Author

Choose a reason for hiding this comment

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

[Code Quality] Unused type import

ReadOnlyContext is imported but never used as a type annotation. It only appears in the describe('ReadOnlyContext', ...) string.

Fix:

import { createReadOnlyContext } from '../../src/cli/read-only-context.js';

try {
s.start(taskId ? `Fetching status for ${taskId}...` : 'Fetching tasks...');
const { taskManager } = await withServices(s);
const ctx = withReadOnlyContext(s);
Copy link
Owner Author

Choose a reason for hiding this comment

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

[Architecture] Database connection not closed

The ReadOnlyContext opens a database connection but never closes it before process.exit(). While the OS cleanup is fine for short-lived CLI commands, this violates the project's "Resource cleanup - Always use try/finally" principle from CLAUDE.md.

The test suite (read-only-context.test.ts) correctly calls ctx.database.close() in every test, establishing an expectation not met by production code.

Fix - Option 1 (add finally block):

const ctx = withReadOnlyContext(s);
try {
  // ... existing logic ...
} finally {
  ctx.database.close();
}

Option 2 (document intentional reliance on process.exit):
Add a comment explaining that process.exit() handles cleanup for short-lived CLI processes.

if (result.ok) {
const logs = result.value;
// Validate task exists
const taskResult = await ctx.taskRepository.findById(TaskId(taskId));
Copy link
Owner Author

Choose a reason for hiding this comment

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

[Performance] Redundant sequential database queries

The task existence check (line 13) is followed by an output fetch (line 26), but the output fetch already returns null when no data exists. This creates an unnecessary database round-trip.

Impact: Minor overhead (~1-5ms per invocation on SQLite), but the validation is redundant.

Fix - Option 1 (remove task validation):

const outputResult = await ctx.outputRepository.get(TaskId(taskId));
if (!outputResult.ok) {
  s.stop('Failed');
  ui.error(`Failed to get task logs: ${outputResult.error.message}`);
  process.exit(1);
}
if (!outputResult.value) {
  s.stop('Not found');
  ui.error('No task output captured — task may not exist');
  process.exit(1);
}

Option 2 (parallel queries):

const [taskResult, outputResult] = await Promise.all([
  ctx.taskRepository.findById(TaskId(taskId)),
  ctx.outputRepository.get(TaskId(taskId)),
]);

@@ -265,10 +275,9 @@ async function scheduleList(service: ScheduleService, scheduleArgs: string[]) {
const { ScheduleStatus } = await import('../../core/domain.js');
const statusEnum = status ? (status as keyof typeof ScheduleStatus) : undefined;
Copy link
Owner Author

Choose a reason for hiding this comment

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

[Security/TypeScript] Unsafe enum value cast without validation

The status value is cast directly to the enum key without validating that it's actually a valid member. Passing --status constructor or --status __proto__ would lookup inherited Object properties rather than enum values.

Impact: LOW — parameterized SQL queries prevent injection, but passing invalid status returns undefined, causing unexpected behavior.

Fix:

const validStatuses = Object.keys(ScheduleStatus).map(k => k.toLowerCase());
if (status && !validStatuses.includes(status.toLowerCase())) {
  ui.error(`Invalid status: ${status}. Valid: ${validStatuses.join(', ')}`);
  process.exit(1);
}
const statusEnum = status ? (status.toUpperCase() as keyof typeof ScheduleStatus) : undefined;
const result = statusEnum
  ? await repo.findByStatus(ScheduleStatus[statusEnum], limit)
  : await repo.findAll(limit);

Note: This is a pre-existing pattern from the old service.listSchedules() code, but worth fixing in the refactored version.

expect(findResult.ok).toBe(true);
if (!findResult.ok) return;
expect(findResult.value).not.toBeNull();
expect(findResult.value!.prompt).toBe('test read-only context');
Copy link
Owner Author

Choose a reason for hiding this comment

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

[TypeScript] Non-null assertions instead of null guards

The test file uses ! non-null assertions (e.g., findResult.value!.prompt) instead of null guards. While each assertion is preceded by an expect().not.toBeNull() check, TypeScript's type narrowing doesn't recognize Vitest assertions.

Consistency Issue: The same file correctly uses if (!result.ok) return; guards in other tests, but switches to ! assertions in the loop. This is inconsistent.

Fix:

const task = findResult.value;
if (!task) return;
expect(task.prompt).toBe('test read-only context');
expect(task.status).toBe(TaskStatus.QUEUED);

This follows the codebase's own guard pattern used elsewhere in the file.

@dean0x
Copy link
Owner Author

dean0x commented Mar 18, 2026

Code Review Summary

Review Date: 2026-03-18
Branch: feat/read-only-cli-90 → main
Reviewers: 8 specialized reviewers (architecture, complexity, consistency, performance, regression, security, tests, typescript)


Review Results

Reviewer Score Verdict
Architecture 7/10 CHANGES_REQUESTED
Complexity 7/10 APPROVED_WITH_CONDITIONS
Consistency 7/10 APPROVED_WITH_CONDITIONS
Performance 8/10 APPROVED_WITH_CONDITIONS
Regression 9/10 APPROVED
Security 9/10 APPROVED
Tests 5/10 CHANGES_REQUESTED
TypeScript 7/10 APPROVED_WITH_CONDITIONS

Issues Summary

Inline Comments Created (≥80% Confidence)

8 blocking/should-fix issues identified and commented inline:

  1. DIP Violation (src/cli/read-only-context.ts:24) — Concrete Database on interface
  2. Missing type-only imports (src/cli/read-only-context.ts:16) — Repository interfaces
  3. Unused import (tests/unit/read-only-context.test.ts:5) — ReadOnlyContext type
  4. Database not closed (src/cli/commands/status.ts:11) — Violates resource cleanup principle
  5. Redundant sequential queries (src/cli/commands/logs.ts:13) — Unnecessary task existence check
  6. Unsafe enum cast (src/cli/commands/schedule.ts:276) — No validation of status value
  7. Non-null assertions (tests/unit/read-only-context.test.ts:56) — Should use null guards instead

Lower-Confidence Findings (60-79%)

These are suggestions rather than blocking issues — correct but verbose code, or intentional patterns:

  • Error handling boilerplate (logs.ts, schedule.ts) — Consider extracting exitOnError/exitOnNull helpers (stylistic improvement, not blocking)
  • Spinner pattern inconsistency (schedule.ts vs status/logs.ts) — Minor UX inconsistency in initialization feedback
  • scheduleGet graceful degradation — History fetch errors shown to user (arguably an improvement, not a regression)
  • Test coverage gaps — CLI tests now validate stale code paths; new production code (withReadOnlyContext, skipRecovery) lacks integration test coverage

Deduplication Notes

Multiple reviewers flagged the same issues:

  • Database cleanup: 6 reviewers (Performance, Regression, Security, Architecture, TypeScript, Tests)
  • Unused imports: 4 reviewers (Consistency, TypeScript, Tests, Regression)
  • DIP violation: 2 reviewers (Architecture, Consistency)
  • Sequential queries: 2 reviewers (Performance)
  • Unsafe enum cast: 2 reviewers (Security, TypeScript)

All inline comments reference the first instance and highest-confidence finding.


Next Steps

Before Merge:

  1. Fix the 3 HIGH-severity TypeScript issues (DIP violation, missing type-only imports, unused import)
  2. Address database cleanup pattern in CLI commands (violates stated CLAUDE.md principle)
  3. Consider fixing unsafe enum cast and sequential queries (MEDIUM severity, both have low effort fixes)

Post-Merge (Follow-up Issues):

  • Move OutputRepository interface to src/core/interfaces.ts (pre-existing inconsistency)
  • Update CLI tests to exercise actual refactored code paths instead of stale mocks
  • Add integration tests for withReadOnlyContext() error handling and skipRecovery bootstrap option
  • Consider extracting error handling helpers to reduce boilerplate in CLI commands

Review comments generated by Claude Code — See inline discussions for code-specific feedback.

Dean Sharon and others added 8 commits March 18, 2026 20:54
Replace concrete Database exposure in ReadOnlyContext interface with
close() method. Callers no longer depend on Database implementation
details. Also switch to import type for repository interfaces used
only as type annotations.

Co-Authored-By: Claude <noreply@anthropic.com>
…-context tests

Replace `value!.property` non-null assertions with proper null guard
pattern (`if (!value) return`) at two locations, matching the existing
Result-type guard convention used elsewhere in the file.

Co-Authored-By: Claude <noreply@anthropic.com>
…ommand

- Add try/finally to close ReadOnlyContext before process.exit()
- Switch from findAllUnbounded() to findAll() (defaults to 100 tasks)
- Remove unnecessary `as Task[]` cast that stripped readonly modifier

Co-Authored-By: Claude <noreply@anthropic.com>
Add try/finally to ensure ReadOnlyContext.close() is called on all exit
paths, preventing SQLite connection leaks.

Co-Authored-By: Claude <noreply@anthropic.com>
- Add try/finally to close ReadOnlyContext DB connection in schedule
  list/get paths (prevents leaked SQLite handles)
- Validate --status enum value against ScheduleStatus before use
  (prevents prototype property access via user input)
- Exit with code 1 on execution history fetch failure (consistent
  with all other repo error paths in CLI)
- Use contextual spinner messages ('Fetching schedules/schedule...')
  matching status.ts and logs.ts patterns

Co-Authored-By: Claude <noreply@anthropic.com>
… paths

Production code for status, logs, schedule list, and schedule get commands
now uses withReadOnlyContext() with direct repository access instead of
TaskManager/ScheduleService. The existing tests still validated the old
mock-based paths (mockTaskManager.getStatus, mockScheduleService.listSchedules)
which provided false confidence.

- Add MockReadOnlyContext with mock taskRepository, outputRepository,
  scheduleRepository matching the ReadOnlyContext interface
- Rewrite Status Command tests to use ctx.taskRepository.findById/findAll
- Rewrite Logs Command tests to use ctx.taskRepository.findById then
  ctx.outputRepository.get with tail slicing
- Rewrite schedule list tests to use ctx.scheduleRepository.findAll/findByStatus
- Rewrite schedule get tests to use ctx.scheduleRepository.findById
- Update simulate helper functions to mirror production code paths
- Leave mutation command tests (cancel, retry, resume, schedule create/cancel/
  pause/resume) untouched as they still use full services correctly

Co-Authored-By: Claude <noreply@anthropic.com>
@dean0x dean0x merged commit 23ea6d2 into main Mar 18, 2026
2 checks passed
@dean0x dean0x deleted the feat/read-only-cli-90 branch March 18, 2026 19:51
dean0x pushed a commit that referenced this pull request Mar 20, 2026
- Bump version 0.5.0 → 0.6.0
- Update release notes with all 8 PRs (was missing #85, #86, #91, #94, #100, #106, #107)
- Mark v0.6.0 as released in ROADMAP.md
- Update FEATURES.md architecture section for hybrid event model
- Expand "What's New in v0.6.0" with architectural simplification, bug fixes, tech debt
- Fix README roadmap: v0.6.1 → v0.7.0 for loops
- Update bug report template example version to 0.6.0
@dean0x dean0x mentioned this pull request Mar 20, 2026
7 tasks
dean0x added a commit that referenced this pull request Mar 20, 2026
## Summary

- Bump version `0.5.0` → `0.6.0` (package.json + package-lock.json)
- Expand release notes with all 8 PRs (#78, #85, #86, #91, #94, #100,
#106, #107) — was only covering #78
- Mark v0.6.0 as released in ROADMAP.md, update status and version
timeline
- Update FEATURES.md architecture section for hybrid event model (was
describing old fully event-driven architecture with removed services)
- Expand "What's New in v0.6.0" in FEATURES.md with architectural
simplification, additional bug fixes, tech debt, breaking changes,
migration 9
- Fix README roadmap version: `v0.6.1` → `v0.7.0` for task/pipeline
loops
- Update bug report template example version `0.5.0` → `0.6.0`

### GitHub Issues
- Closed #82 (cancelTasks scope — PR #106)
- Closed #95 (totalSize tail-slicing — PR #106)
- Updated #105 release tracker checklist (all items checked)

## Test plan
- [x] `npm run build` — clean compilation
- [x] `npm run test:all` — full suite passes (822 tests, 0 failures)
- [x] `npx biome check src/ tests/` — no lint errors
- [x] `package.json` version is `0.6.0`
- [x] Release notes file exists and covers all PRs
- [ ] After merge: trigger Release workflow from GitHub Actions
- [ ] After release published: close #105

---------

Co-authored-by: Dean Sharon <deanshrn@gmain.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: Lightweight CLI Path — read-only commands skip bootstrap (Phase 3)

1 participant