Skip to content

Comments

feat: add scheduled tasks feature (#16)#95

Closed
dzianisv wants to merge 457 commits intochriswritescode-dev:mainfrom
dzianisv:feature/issue-16-scheduled-tasks
Closed

feat: add scheduled tasks feature (#16)#95
dzianisv wants to merge 457 commits intochriswritescode-dev:mainfrom
dzianisv:feature/issue-16-scheduled-tasks

Conversation

@dzianisv
Copy link

@dzianisv dzianisv commented Jan 30, 2026

Summary

Implements scheduled tasks feature that allows users to schedule recurring tasks directly from the opencode-manager interface.

  • Add node-cron based cron job scheduling
  • Create scheduled_tasks database table with migrations
  • Implement SchedulerService for task lifecycle management
  • Add REST API endpoints for CRUD operations
  • Create TasksPage frontend with full management UI
  • Support three command types: skill, opencode-run, script

Features

  • Task Management: Create, update, delete, pause/resume scheduled tasks
  • Cron Scheduling: Full cron expression support with preset options
  • Run Now: Manually trigger any task immediately
  • Command Types:
    • skill: Run OpenCode skills (e.g., recruiter-response)
    • opencode-run: Send messages to OpenCode
    • script: Run arbitrary scripts/commands
  • Status Tracking: Last run time, next scheduled run, task status

Documentation

  • Added Scheduled Tasks section to README.md
  • Created docs/scheduled-tasks.md with full API documentation

Testing

  • Backend builds successfully
  • Frontend builds successfully
  • Unit tests pass (103 total)
    • 35 tests for SchedulerService (CRUD, cron validation, command execution)
    • 27 tests for task API routes (HTTP status, validation)
  • API endpoints functional

Screenshots

The Tasks page is accessible from the main dashboard via the "Tasks" button.

Closes #16

chriswritescode-dev and others added 30 commits December 18, 2025 22:48
* feat: Implement /compact command with processing feedback

- Add 'compact' status type to session status store
- Update SessionStatusIndicator to show purple pulsing animation for compaction
- Add summarizeSession() API method to OpenCode client
- Modify command handler for /compact to:
  - Get current model from state
  - Show compaction toast
  - Set session to compacting status
  - Call summarize endpoint with provider/model
- Update SSE handler to clear compact status on session.compacted event

The user sees a purple animated indicator and 'Compacting' text during the operation, matching the TUI experience.

* Fix compact command to use break instead of return for model validation

---------

Co-authored-by: Chris Scott <chris@cstech.dev>
* Add message undo/refresh features and stop audio floating button

* Exclude workspace directory from pnpm workspace detection

---------

Co-authored-by: Chris Scott <chris@cstech.dev>
Co-authored-by: Chris Scott <chris@cstech.dev>
Simplify card layout with consistent button styling, proper event handling for card clicks, and improved visual feedback with hover states. Consolidate branch and status indicators in a cleaner layout structure.
Tests the full voice pipeline:
- Ask OpenCode a math question (2+2, 2*5)
- Synthesize response with Coqui TTS
- Send audio to Whisper STT
- Verify transcription matches expected answer (4, 10)

This ensures both TTS and STT work correctly together.
Deleted:
- backend/test/integration/voice-to-code.test.ts (duplicates scripts/test-voice.ts)
- backend/test/integration/full-stack.test.ts (duplicates scripts/test-startup.ts)

Kept webfetch-large-output.test.ts as it tests unique context overflow scenario.
#14)

Voice E2E test failures are pre-existing infrastructure issues (Whisper health check, Coqui TTS paths) also failing on main. Browser E2E and Unit Tests pass.
- Update tarball paths to include backend/dist and frontend/dist
- Clean stale endpoints.json before reinstall
Read auth credentials from ~/.local/run/opencode-manager/auth.json
if AUTH_USERNAME/AUTH_PASSWORD environment variables are not set.

This enables the tunnel script to configure authentication that
the backend respects, without requiring env vars.

Voice E2E tests: 14/14 pass
- checkServerHealth now returns true for any HTTP response (not just 2xx)
- Added isPortInUse helper to check if port is already bound
- startOpenCodeServer now checks port first, waits for existing server
- Only kills and restarts if server is truly unresponsive

Fixes issue where service failed to start after macOS restart when
opencode server had plugin errors returning HTTP 500 on /doc endpoint.
Adds testEndpointsFile() to test-startup.ts that verifies:
- endpoints.json exists at ~/.local/run/opencode-manager/
- File has valid JSON structure with endpoints array
- Contains both local and tunnel endpoints
- Tunnel timestamp is recent (within 24h)
- Auth file exists with credentials
- Tunnel URL is accessible and returns healthy status
- Tunnel serves HTML webapp at root path

This regression test ensures the bug where endpoints.json wasn't
updated after macOS restart (due to HTTP 500 health check failure)
doesn't recur.
- Combine file existence, structure, and tunnel URL checks into single test
- Combine auth check with tunnel accessibility test
- Add webapp HTML serving verification
- Clearer error messages for debugging
- Document how to use owpenbot (from OpenWork project) for Telegram bridge
- Include quick start, service configuration (macOS/Linux), and troubleshooting
- Also document native integration option (not implemented)
- Tested and verified working with opencode-manager
- Add node-cron dependency for cron job scheduling
- Create scheduled_tasks database table with migrations
- Implement SchedulerService for task management (create/update/delete/toggle/run)
- Add REST API endpoints for tasks (/api/tasks)
- Create TasksPage frontend component with full CRUD UI
- Add Table UI component for task list display
- Wire up scheduler to backend startup/shutdown
- Add navigation to Tasks page from main dashboard

Supports three command types:
- skill: Run OpenCode skills (e.g., recruiter-response)
- opencode-run: Send messages to OpenCode
- script: Run arbitrary scripts

Closes #16
Copilot AI review requested due to automatic review settings January 30, 2026 02:48
@gitguardian
Copy link

gitguardian bot commented Jan 30, 2026

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Vapid Key 620164a backend/src/services/push.ts View secret
- - Username Password 3f9508b .test/config.json View secret
- - Username Password 7804498 .secrets/2026-01-04.json View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements a scheduled tasks feature (recurring jobs) and expands the app’s supporting infrastructure (API clients, SSE events, voice STT/TTS, terminal, push notifications), plus updates build/deploy tooling to support the new runtime behavior.

Changes:

  • Add scheduled tasks persistence + backend CRUD endpoints + frontend API layer (and UI route wiring).
  • Introduce voice and notification infrastructure (STT/TTS APIs, push service worker + VAPID handling, additional SSE event typing).
  • Update Docker/CI/test configuration and backend utilities to support new services and runtime behavior.

Reviewed changes

Copilot reviewed 100 out of 339 changed files in this pull request and generated 18 comments.

Show a summary per file
File Description
frontend/src/components/command/CommandSuggestions.tsx Updates command suggestions UI behavior and positioning.
frontend/src/api/types/settings.ts Aligns frontend settings types/defaults with shared package.
frontend/src/api/types.ts Extends shared API/SSE types (permissions, installation, session status, image parts).
frontend/src/api/tts.ts Adds frontend TTS API client.
frontend/src/api/tasks.ts Adds frontend scheduled tasks API client + helpers.
frontend/src/api/stt.ts Adds frontend STT API client.
frontend/src/api/stt.test.ts Adds frontend unit tests for STT API client.
frontend/src/api/settings.ts Extends settings API (restart, rollback, agents.md, git token validation) and centralizes API base URL import.
frontend/src/api/sessions.ts Adds “recent sessions” API client.
frontend/src/api/repos.ts Extends repo API (local repos, download, branches, summaries) and adjusts fetch options.
frontend/src/api/push.ts Adds push notification client utilities + service worker registration helpers.
frontend/src/api/opencode.ts Extends OpenCode client (agents, permissions, revert, session status, providers, summarize).
frontend/src/api/oauth.ts Adds OAuth API client wrapper.
frontend/src/api/mcp.ts Adds MCP management API wrappers.
frontend/src/api/git.ts Adds git status/diff API + React Query hooks.
frontend/src/api/files.ts Adds file read/range/patch API + React Query hook.
frontend/src/api/client.ts Adds shared axios API client instance.
frontend/src/App.tsx Adds new routes/providers (Tasks, Terminal, permissions/questions, toasts).
frontend/public/sw.js Adds service worker for push notifications.
frontend/public/manifest.json Updates PWA metadata and icon configuration.
frontend/package.json Adds testing scripts/deps and new UI deps used by new features.
frontend/index.html Updates app title/viewport config for better PWA/mobile behavior.
frontend/eslint.config.js Adjusts lint rule for specific UI component paths.
docs/voiceTestEnv.md Documents CI voice testing strategy (fake audio capture).
docs/azureDeploy.md Adds manual Azure deployment follow-up steps.
docs/alternatives.md Adds alternatives research notes (context for product direction).
docker-compose.yml Updates deployment configuration (ports, image, volumes) for new runtime.
docker-compose.dev.yml (deleted) Removes dev compose configuration.
backend/vitest.integration.config.ts Adds separate Vitest integration-test config.
backend/vitest.config.ts Updates Vitest config (timeouts, include/exclude, deps).
backend/test/setup.ts Adds Bun spawn mocking for backend tests.
backend/test/services/terminal.test.ts Adds tests for terminal session service behavior.
backend/test/services/repo-auth-env.test.ts Adds tests for git auth env injection.
backend/test/routes/tts.test.ts Adds tests for TTS routes + cache helpers.
backend/test/db/queries.test.ts Updates DB query tests for schema changes (local repos).
backend/src/utils/process.ts Extends executeCommand to support options (env/silent).
backend/src/utils/logger.ts Switches debug logging toggle to shared ENV config.
backend/src/utils/git-auth.ts Adds helpers for non-interactive git + GitHub token env injection.
backend/src/types/repo.ts Updates repo input typing for local repo support.
backend/src/services/whisper.ts Adds Whisper server manager (start/health/transcribe/models).
backend/src/services/terminal.ts Adds Bun-based terminal session service + PTY worker integration.
backend/src/services/summarization.ts Adds session summarization service (LLM-backed with cache + fallback).
backend/src/services/push.ts Adds web-push subscription storage and send helpers.
backend/src/services/pty-worker.cjs Adds node-pty worker process used by terminal service.
backend/src/services/proxy.ts Refactors OpenCode proxying + adds config patch + directory-aware proxy helper.
backend/src/services/opencode-sdk-client.ts Adds OpenCode SDK client for listing projects/sessions & health/version helpers.
backend/src/services/opencode-discovery.ts Adds discovery/health monitoring of OpenCode instances.
backend/src/services/global-sse.ts Adds global SSE listener to trigger push notifications on events.
backend/src/services/git-operations.ts Adds git status/diff helpers using executeCommand and settings-derived auth env.
backend/src/services/files.ts Extends file service (upload with relative paths, ranged reads, patch apply).
backend/src/services/file-operations.ts Updates repo path sourcing to shared config env.
backend/src/services/auth.ts Tightens auth file permissions behavior.
backend/src/services/archive.ts Adds repo zip archive creation with gitignore filtering.
backend/src/routes/terminal.ts Adds terminal HTTP endpoints + Socket.IO wiring.
backend/src/routes/tasks.ts Adds scheduled tasks CRUD + run/toggle API routes.
backend/src/routes/stt.ts Adds STT API endpoints backed by Whisper server manager.
backend/src/routes/sessions.ts Replaces session route behavior with “recent sessions” aggregation endpoint.
backend/src/routes/push.ts Adds push subscription and test/send endpoints.
backend/src/routes/oauth.ts Adds OAuth proxy endpoints and triggers OpenCode restart post-callback.
backend/src/routes/health.ts Extends health endpoint with OpenCode version/support info.
backend/src/routes/files.ts Extends file routes for ranged reads and patch operations.
backend/src/db/schema.ts Updates schema (local repos, scheduled_tasks table) and DB initialization write.
backend/src/db/queries.ts Updates repo queries for local paths, schema changes, and conflict handling.
backend/src/db/migrations.ts Adds migrations for nullable repo_url, local path uniqueness, and scheduled_tasks table.
backend/src/config/index.ts Re-exports shared config entrypoint.
backend/src/config.ts (deleted) Removes local env config module (migrated to shared env).
backend/package.json Updates dependencies/scripts for new services (cron, socket.io, web-push, archiver).
LICENSE Adds project license file.
Dockerfile Major rebuild: pnpm workspace build, whisper/coqui venvs, kubectl, multi-stage runners.
AGENTS.LEARN.md Adds internal learnings document.
.opencode/skills/verify-readiness/SKILL.md Adds operational skill doc for readiness verification.
.opencode/skills/test-voice-ci/SKILL.md Adds operational skill doc for CI voice tests.
.opencode/skills/deploy-azure/SKILL.md Adds operational skill doc for Azure deployment.
.opencode/command/qa-test.md Adds QA test command definition.
.opencode/command/qa-health.md Adds QA health command definition.
.npmignore Adds npm packaging ignore rules for monorepo content.
.github/workflows/e2e-tests.yml Adds CI workflow for unit + voice API + browser E2E tests via Docker.
.github/workflows/docker-build.yml Adds GHCR Docker build/push workflow.
.env.local.example Adds native local dev env template.
.env.example Updates env template to new defaults and ports/paths.
.env.deploy.example Adds deployment env template.

Comment on lines +34 to +43
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:5001/api/stt/transcribe',
expect.objectContaining({
audio: expect.any(String),
format: 'webm',
model: undefined,
language: undefined
}),
{ params: { userId: 'default' } }
)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The test asserts an exact axios config object, but sttApi.transcribe() passes additional config fields (timeout, signal). This will cause the test to fail once executed. Update the third argument assertion to use expect.objectContaining({ params: { userId: 'default' } }) (and similarly for other calls) so the test matches the implementation.

Copilot uses AI. Check for mistakes.

const result = await sttApi.getModels()

expect(mockedAxios.get).toHaveBeenCalledWith('http://localhost:5001/api/stt/models')
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

sttApi.getModels() calls axios.get(url, { timeout: 10000 }), so this assertion will fail because it expects no options argument. Update the expectation to include the options (or use expect.any(Object) / expect.objectContaining({ timeout: 10000 })).

Suggested change
expect(mockedAxios.get).toHaveBeenCalledWith('http://localhost:5001/api/stt/models')
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://localhost:5001/api/stt/models',
expect.objectContaining({ timeout: 10000 })
)

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +130
beforeEach(() => {
vi.useFakeTimers()
})

Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

There is a duplicated beforeEach (and one is mis-indented), which is very likely unintended and can hide timer-related issues (or just add confusion). Remove the duplicate hook and keep a single vi.useFakeTimers() setup for this describe block.

Suggested change
beforeEach(() => {
vi.useFakeTimers()
})

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +8
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || 'BEsTZT_8wnxMiqK2r8nwZc23zdrUJzoBsMMe51q2oM4y5S42_agpvOIGrCd7lTVh-UanS-D2SvzXLWW8-U6_IVE'
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || 'rq6W9J-4vu4svUui3kBK6dzCF-dMzQXofjDUkDlXFaE'
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@opencode.ai'

Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

A hard-coded default VAPID private key is a serious security issue: any deployment accidentally using defaults is insecure and can be impersonated. Require VAPID_PRIVATE_KEY to be set (fail fast at startup if missing), and avoid shipping a usable private key in source control. If you want a smoother dev experience, generate keys during local bootstrap (or document how to create them) rather than embedding them in code.

Suggested change
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || 'BEsTZT_8wnxMiqK2r8nwZc23zdrUJzoBsMMe51q2oM4y5S42_agpvOIGrCd7lTVh-UanS-D2SvzXLWW8-U6_IVE'
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || 'rq6W9J-4vu4svUui3kBK6dzCF-dMzQXofjDUkDlXFaE'
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@opencode.ai'
const vapidPublicKeyFromEnv = process.env.VAPID_PUBLIC_KEY
const vapidPrivateKeyFromEnv = process.env.VAPID_PRIVATE_KEY
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@opencode.ai'
if (!vapidPublicKeyFromEnv || !vapidPrivateKeyFromEnv) {
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables must be set')
}
const VAPID_PUBLIC_KEY: string = vapidPublicKeyFromEnv
const VAPID_PRIVATE_KEY: string = vapidPrivateKeyFromEnv

Copilot uses AI. Check for mistakes.
- opencode-workspace:/workspace
- opencode-data:/app/backend/data
- opencode-data:/app/data
- ~/.kube/config:/home/node/.kube/config:ro
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Mounting ~/.kube/config into the container by default is a high-risk credential exposure and also makes the compose file non-portable (won’t work on many hosts/CI). Consider removing this from the default compose, and instead document an opt-in override (e.g., docker-compose.override.yml) or gate it behind an explicit profile.

Suggested change
- ~/.kube/config:/home/node/.kube/config:ro

Copilot uses AI. Check for mistakes.
properties: {
sessionID: string
permissionID: string
response: string
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

response is currently typed as string, which loses the safety you introduced via PermissionResponse = 'once' | 'always' | 'reject'. Use PermissionResponse here so the client code can’t accidentally handle impossible values.

Suggested change
response: string
response: PermissionResponse

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +101
for (const repo of repos) {
try {
const sessionsRes = await fetch(
`http://127.0.0.1:${opencodePort}/session?directory=${encodeURIComponent(repo.fullPath)}`
)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This does a sequential per-repo fetch, and then an additional per-session fetch inside getSessionSummary() (N+1 pattern). With multiple repos/sessions, this can become slow and delay the /recent response noticeably. Consider parallelizing repo fetches (and/or summaries) with Promise.all and adding a concurrency limit, or returning summaries lazily via a separate endpoint.

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +118
const status = sessionStatuses[session.id]
const summary = await getSessionSummary(opencodePort, session.id, session.directory)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This does a sequential per-repo fetch, and then an additional per-session fetch inside getSessionSummary() (N+1 pattern). With multiple repos/sessions, this can become slow and delay the /recent response noticeably. Consider parallelizing repo fetches (and/or summaries) with Promise.all and adding a concurrency limit, or returning summaries lazily via a separate endpoint.

Copilot uses AI. Check for mistakes.
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repoUrl, branch, openCodeConfigName, useWorktree }),
body: JSON.stringify({ repoUrl, localPath, branch, openCodeConfigName, useWorktree }),
credentials: 'include', // Ensure credentials (cookies) are sent
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Inline explanatory comments like this are discouraged by the project guidelines (“code must be self-documenting”). Prefer extracting a shared helper (e.g., a wrapper around fetch that always sets credentials: 'include') so the intent is expressed by the abstraction rather than repeated comments.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +18

// Add response interceptor to handle errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// You can handle global errors here
return Promise.reject(error)
}
)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The interceptor currently adds no behavior (it just re-throws), and it also introduces new inline comments (discouraged by guidelines). Either remove the interceptor block entirely until it’s needed, or implement a concrete cross-cutting behavior (e.g., mapping server error shapes into a consistent thrown error type).

Suggested change
// Add response interceptor to handle errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// You can handle global errors here
return Promise.reject(error)
}
)

Copilot uses AI. Check for mistakes.
)

- Add 35 unit tests for SchedulerService (CRUD, cron validation, command execution)
- Add 27 API route tests for /api/tasks endpoints (HTTP status, validation, all endpoints)
- Remove useless telegram tests that only tested inline logic
- Update plan.md with current test coverage (103 tests passing)
- Add Scheduled Tasks feature section to README.md
- Create docs/scheduled-tasks.md with comprehensive documentation
- Document API endpoints, command types, cron examples
- Update plan.md to mark all tasks complete
…thout tunnel

When the service starts without --tunnel flag, any existing tunnel entries
in endpoints.json are now removed. This prevents stale/inaccessible tunnel
URLs from persisting after a tunnel stops or service restarts without tunnel.
Copilot AI review requested due to automatic review settings January 30, 2026 04:41
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 81 out of 343 changed files in this pull request and generated 9 comments.

Comment on lines +29 to +33
# Install kubectl (supports both amd64 and arm64)
RUN ARCH=$(case ${TARGETARCH} in arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl" && \
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \
rm kubectl
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This case construct is syntactically invalid in POSIX shell and will fail the Docker build. Use a properly formed case ... esac statement (or a simple if) to set ARCH based on TARGETARCH.

Copilot uses AI. Check for mistakes.
Comment on lines +395 to +420
private calculateNextRun(cronExpression: string): number {
const interval = cron.schedule(cronExpression, () => {}, { scheduled: false })

const now = new Date()
const parts = cronExpression.split(' ')

const minute = parts[0] === '*' ? now.getMinutes() : parseInt(parts[0]) || 0
const hour = parts[1] === '*' ? now.getHours() : parseInt(parts[1]) || 0
const dayOfMonth = parts[2] === '*' ? now.getDate() : parseInt(parts[2]) || 1
const month = parts[3] === '*' ? now.getMonth() : (parseInt(parts[3]) || 1) - 1

let nextDate = new Date(now.getFullYear(), month, dayOfMonth, hour, minute, 0, 0)

if (nextDate <= now) {
if (parts[0] !== '*') {
nextDate.setHours(nextDate.getHours() + 1)
} else if (parts[1] !== '*') {
nextDate.setDate(nextDate.getDate() + 1)
} else {
nextDate.setMinutes(nextDate.getMinutes() + 1)
}
}

interval.stop()
return nextDate.getTime()
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This next-run calculation is not a correct cron evaluator (e.g., it ignores day-of-week, ranges/steps like */15, and the increment logic is inverted: if minute is fixed, it bumps hours). This will produce inaccurate next_run_at. Use a cron parsing library (or node-cron’s underlying scheduler metadata if available) to compute the next occurrence instead of manually parsing fields.

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +155
export function startGlobalSSEListener(db: Database): void {
if (isRunning) return

database = db
isRunning = true

logger.info('[GlobalSSE] Starting global SSE listener')

syncRepoConnections()

setInterval(() => {
if (isRunning) {
syncRepoConnections()
}
}, 30000)
}

export function stopGlobalSSEListener(): void {
isRunning = false
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The interval created in startGlobalSSEListener is never cleared in stopGlobalSSEListener, so it will keep running indefinitely after stop (leaking timers and waking the event loop). Store the interval handle and clearInterval it on stop.

Copilot uses AI. Check for mistakes.
Comment on lines +504 to 520
deleteFilesystemConfig(): boolean {
const configPath = getOpenCodeConfigFilePath()

if (!existsSync(configPath)) {
logger.warn('Config file does not exist:', configPath)
return false
}

try {
return JSON.stringify(config.content, null, 2)
unlinkSync(configPath)
logger.info('Deleted filesystem config to allow server startup:', configPath)
return true
} catch (error) {
logger.error(`Failed to stringify config '${configName}':`, error)
return null
logger.error('Failed to delete config file:', error)
return false
}
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This uses synchronous filesystem calls (existsSync, unlinkSync) in the request/runtime path, which blocks the event loop. Prefer async equivalents (fs.promises.access/unlink) and propagate errors via typed exceptions so callers can handle failures consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +275 to +285
async function countFileLines(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
let lineCount = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })

rl.on('line', () => { lineCount++ })
rl.on('close', () => resolve(lineCount))
rl.on('error', reject)
})
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

getFileRange reads the file twice (once to count lines, once to collect the requested range). For large files this will be noticeably slower. Consider doing a single streaming pass that both counts total lines and collects the requested slice (with early termination once endLine is reached), returning totalLines if needed via a full pass only when required.

Copilot uses AI. Check for mistakes.
Comment on lines +275 to +327
async function countFileLines(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
let lineCount = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })

rl.on('line', () => { lineCount++ })
rl.on('close', () => resolve(lineCount))
rl.on('error', reject)
})
}

async function readFileLines(filePath: string, startLine: number, endLine: number): Promise<string[]> {
return new Promise((resolve, reject) => {
const lines: string[] = []
let currentLine = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })

rl.on('line', (line) => {
if (currentLine >= startLine && currentLine < endLine) {
lines.push(line)
}
currentLine++
if (currentLine >= endLine) {
rl.close()
stream.destroy()
}
})
rl.on('close', () => resolve(lines))
rl.on('error', reject)
})
}

export async function getFileRange(userPath: string, startLine: number, endLine: number): Promise<ChunkedFileInfo> {
const validatedPath = validatePath(userPath)
logger.info(`Getting file range for path: ${userPath} lines ${startLine}-${endLine}`)

const exists = await fileExists(validatedPath)
if (!exists) {
throw { message: 'File does not exist', statusCode: 404 }
}

const stats = await getFileStats(validatedPath)
if (stats.isDirectory) {
throw { message: 'Path is a directory', statusCode: 400 }
}

const totalLines = await countFileLines(validatedPath)
const clampedEnd = Math.min(endLine, totalLines)
const lines = await readFileLines(validatedPath, startLine, clampedEnd)
const mimeType = getMimeType(validatedPath, new Uint8Array())

Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

getFileRange reads the file twice (once to count lines, once to collect the requested range). For large files this will be noticeably slower. Consider doing a single streaming pass that both counts total lines and collects the requested slice (with early termination once endLine is reached), returning totalLines if needed via a full pass only when required.

Suggested change
async function countFileLines(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
let lineCount = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
rl.on('line', () => { lineCount++ })
rl.on('close', () => resolve(lineCount))
rl.on('error', reject)
})
}
async function readFileLines(filePath: string, startLine: number, endLine: number): Promise<string[]> {
return new Promise((resolve, reject) => {
const lines: string[] = []
let currentLine = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
rl.on('line', (line) => {
if (currentLine >= startLine && currentLine < endLine) {
lines.push(line)
}
currentLine++
if (currentLine >= endLine) {
rl.close()
stream.destroy()
}
})
rl.on('close', () => resolve(lines))
rl.on('error', reject)
})
}
export async function getFileRange(userPath: string, startLine: number, endLine: number): Promise<ChunkedFileInfo> {
const validatedPath = validatePath(userPath)
logger.info(`Getting file range for path: ${userPath} lines ${startLine}-${endLine}`)
const exists = await fileExists(validatedPath)
if (!exists) {
throw { message: 'File does not exist', statusCode: 404 }
}
const stats = await getFileStats(validatedPath)
if (stats.isDirectory) {
throw { message: 'Path is a directory', statusCode: 400 }
}
const totalLines = await countFileLines(validatedPath)
const clampedEnd = Math.min(endLine, totalLines)
const lines = await readFileLines(validatedPath, startLine, clampedEnd)
const mimeType = getMimeType(validatedPath, new Uint8Array())
async function readFileLinesWithCount(
filePath: string,
startLine: number,
endLine: number,
): Promise<{ lines: string[]; totalLines: number }> {
return new Promise((resolve, reject) => {
const lines: string[] = []
let currentLine = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
rl.on('line', (line) => {
if (currentLine >= startLine && currentLine < endLine) {
lines.push(line)
}
currentLine++
})
rl.on('close', () => {
const totalLines = currentLine
resolve({ lines, totalLines })
})
rl.on('error', (error) => {
reject(error)
})
})
}
export async function getFileRange(userPath: string, startLine: number, endLine: number): Promise<ChunkedFileInfo> {
const validatedPath = validatePath(userPath)
logger.info(`Getting file range for path: ${userPath} lines ${startLine}-${endLine}`)
const exists = await fileExists(validatedPath)
if (!exists) {
throw { message: 'File does not exist', statusCode: 404 }
}
const stats = await getFileStats(validatedPath)
if (stats.isDirectory) {
throw { message: 'Path is a directory', statusCode: 400 }
}
const { lines, totalLines } = await readFileLinesWithCount(validatedPath, startLine, endLine)
const clampedEnd = Math.min(endLine, totalLines)
const mimeType = getMimeType(validatedPath, new Uint8Array())

Copilot uses AI. Check for mistakes.
Comment on lines +323 to +325
const totalLines = await countFileLines(validatedPath)
const clampedEnd = Math.min(endLine, totalLines)
const lines = await readFileLines(validatedPath, startLine, clampedEnd)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

getFileRange reads the file twice (once to count lines, once to collect the requested range). For large files this will be noticeably slower. Consider doing a single streaming pass that both counts total lines and collects the requested slice (with early termination once endLine is reached), returning totalLines if needed via a full pass only when required.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +71
beforeAll(async () => {
console.log(`Testing against: ${OPENCODE_MANAGER_URL}`)
if (AUTH_USER) {
console.log(`Using Basic Auth: ${AUTH_USER}:****`)
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The test uses console.log for routine output. If the project standard is structured logging, consider switching these to the shared logger (or gate them behind an explicit verbose flag) to avoid noisy CI logs.

Copilot uses AI. Check for mistakes.
"command_type": "skill",
"command_config": {
"skillName": "recruiter-response",
"args": {}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The args shape in this example is an object ({}), but the backend scheduler’s CommandConfig currently models args as string[]. Update the docs to match the API (or update the API/schema to match the docs) so users don’t create tasks that fail at runtime.

Suggested change
"args": {}
"args": []

Copilot uses AI. Check for mistakes.
Defines mandatory requirements for:
1. Cloudflare Tunnel - MUST always start, endpoint in endpoints.json
2. TTS - Coqui/Chatterbox AND Browser API, switchable in Settings
3. STT - Faster Whisper AND Browser API, switchable in Settings
4. Telegram - MUST work when TELEGRAM_BOT_TOKEN is provided

Updated AGENTS.md to reference requirements.md as first priority.
- TasksPage component with list view
- CreateTaskDialog for creating new scheduled tasks
- Update App.tsx import path
Copilot AI review requested due to automatic review settings January 30, 2026 04:49
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 82 out of 346 changed files in this pull request and generated 18 comments.

Comment on lines +395 to +420
private calculateNextRun(cronExpression: string): number {
const interval = cron.schedule(cronExpression, () => {}, { scheduled: false })

const now = new Date()
const parts = cronExpression.split(' ')

const minute = parts[0] === '*' ? now.getMinutes() : parseInt(parts[0]) || 0
const hour = parts[1] === '*' ? now.getHours() : parseInt(parts[1]) || 0
const dayOfMonth = parts[2] === '*' ? now.getDate() : parseInt(parts[2]) || 1
const month = parts[3] === '*' ? now.getMonth() : (parseInt(parts[3]) || 1) - 1

let nextDate = new Date(now.getFullYear(), month, dayOfMonth, hour, minute, 0, 0)

if (nextDate <= now) {
if (parts[0] !== '*') {
nextDate.setHours(nextDate.getHours() + 1)
} else if (parts[1] !== '*') {
nextDate.setDate(nextDate.getDate() + 1)
} else {
nextDate.setMinutes(nextDate.getMinutes() + 1)
}
}

interval.stop()
return nextDate.getTime()
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This calculateNextRun implementation does not correctly compute the next occurrence for general cron expressions (e.g., day-of-week field, lists, steps like */15, ranges, or month/day interactions). That will cause next_run_at to be wrong for many valid cron schedules. Consider using a dedicated cron parsing library (e.g., cron-parser) to compute the next run time (respecting timezone), or omit next_run_at if you can’t compute it reliably. Also, creating a cron.schedule(...) instance here is unused beyond .stop().

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +9
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || 'BEsTZT_8wnxMiqK2r8nwZc23zdrUJzoBsMMe51q2oM4y5S42_agpvOIGrCd7lTVh-UanS-D2SvzXLWW8-U6_IVE'
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || 'rq6W9J-4vu4svUui3kBK6dzCF-dMzQXofjDUkDlXFaE'
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@opencode.ai'

webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Shipping a hard-coded VAPID private key is a credential leak and makes every deployment share the same signing identity. Remove the fallback defaults for VAPID_PRIVATE_KEY (and ideally VAPID_PUBLIC_KEY), and fail startup with a clear error if they’re missing, or generate/store per-deployment keys in a persistent volume/DB.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +87
const output = execSync(
`lsof -i -P -n | grep -E "opencode.*LISTEN" | awk '{print $2, $9}'`,
{ encoding: 'utf8', timeout: 5000 }
)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

execSync + a shell pipeline (lsof | grep | awk) blocks the event loop and is OS/tooling dependent. This can make the service sluggish under load and can fail in minimal/container environments. Prefer a non-blocking approach (spawn + parse), or avoid port discovery entirely by relying on the configured OpenCode port / SDK (you already added opencode-sdk-client).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +57
// Ensure the worker path exists
if (!fs.existsSync(this.ptyWorkerPath)) {
logger.error(`PTY worker file not found at: ${this.ptyWorkerPath}`)
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

If pty-worker.cjs is missing, the service logs an error but still proceeds to spawn node with a non-existent entrypoint and then registers a session in the map. That can leave a broken session around and surface confusing downstream errors. Prefer throwing an exception (or returning a failure result) before creating/storing the session when the worker file is not present.

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +162
setInterval(() => {
if (isRunning) {
syncRepoConnections()
}
}, 30000)
}

export function stopGlobalSSEListener(): void {
isRunning = false

for (const [dir] of globalEventSources) {
disconnectFromRepo(dir)
}

logger.info('[GlobalSSE] Stopped global SSE listener')
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

startGlobalSSEListener creates an interval but stopGlobalSSEListener never clears it. Even with the isRunning guard, the timer continues to live for the process lifetime, which is avoidable and can complicate restarts/tests. Store the interval handle and clearInterval(...) on stop.

Copilot uses AI. Check for mistakes.
Comment on lines 9 to 18
export interface CreateRepoInput {
repoUrl: string
repoUrl?: string
localPath: string
branch?: string
defaultBranch: string
cloneStatus: 'cloning' | 'ready' | 'error'
clonedAt: number
isWorktree?: boolean
isLocal?: boolean
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Making repoUrl optional while also allowing isLocal optional creates an ambiguous shape (callers can omit both repoUrl and isLocal, or set isLocal=false with no repoUrl). Since other code uses non-null assertions on repoUrl, this can lead to runtime failures if misused. Consider modeling this as a discriminated union (e.g., { isLocal: true; localPath: ...; repoUrl?: never } | { isLocal?: false; repoUrl: string; ... }) so TypeScript can enforce correct combinations.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +57
// Create session if it doesn't exist
terminalService.createSession(sessionId, cwd)

// Handle incoming data from client
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

There are several inline comments explaining control flow. If the project’s convention is to avoid code comments, consider rewriting these as self-documenting code (e.g., extracting helper functions like ensureSession(...), wireSocketHandlers(...), and wirePtyBroadcast(...)) and removing the comments.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +68
// Setup PTY listeners for this socket
// We need to be careful not to duplicate listeners if multiple sockets connect to the same session
// For now, we'll just add new listeners and rely on the service to broadcast to all
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

There are several inline comments explaining control flow. If the project’s convention is to avoid code comments, consider rewriting these as self-documenting code (e.g., extracting helper functions like ensureSession(...), wireSocketHandlers(...), and wirePtyBroadcast(...)) and removing the comments.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +83
// We don't destroy the session on disconnect to allow reconnection
// The session will be destroyed when the PTY exits or manually via API
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

There are several inline comments explaining control flow. If the project’s convention is to avoid code comments, consider rewriting these as self-documenting code (e.g., extracting helper functions like ensureSession(...), wireSocketHandlers(...), and wirePtyBroadcast(...)) and removing the comments.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +11
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/integration/**/*.test.ts'],
testTimeout: 120000,
hookTimeout: 60000
}
})
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

This introduces a new default export. If the codebase convention is 'named exports only', consider aligning config modules the same way (or explicitly documenting/allow-listing config files where tooling requires export default).

Copilot uses AI. Check for mistakes.
@dzianisv dzianisv closed this Jan 30, 2026
@dzianisv dzianisv deleted the feature/issue-16-scheduled-tasks branch January 30, 2026 08:20
@dzianisv dzianisv restored the feature/issue-16-scheduled-tasks branch January 30, 2026 08:22
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.

2 participants