diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02e2f39..0376520 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,22 @@ jobs: - name: Verify the built artifact runs run: python relay.pyz version + + gui-smoke: + name: GUI smoke / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install GUI extra + run: python -m pip install ".[gui]" + - name: Run GUI smoke tests + run: python -m unittest tests.test_g1_gui -v diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e121583..6c8579b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,34 @@ -# Relay 0.5.1 release notes +# Relay 0.6.0 release notes -**This is the first published release.** No GitHub release existed before, so +Relay 0.6.0 is the G0 foundation release for the desktop GUI roadmap. + +## Added + +- Versioned SQLite migration with pre-migration backups. +- Committed Relay 0.5.0 empty and populated database fixtures. +- Job history metadata for titles, previews, source labels, replayability, and future Schedule links. +- `/health` compatibility fields for daemon version, API versions, schema revision, minimum GUI version, and Relay Home identity. +- Read-only `/v1/jobs` and `/v1/jobs/{job_id}` APIs with filtering, search, and cursor pagination. +- Compatibility decision helper and built-in Agent registry interface. + +## Compatibility + +- Existing CLI and daemon endpoints remain available. +- Existing Relay 0.5.x databases are upgraded without deleting Job history. +- Schedule tables and the desktop GUI remain planned for later releases. + +## Verification + +The full test suite and Ruff checks pass locally. GitHub Actions must verify +the same result on Windows, Ubuntu, and macOS across Python 3.11–3.13 before +the v0.6.0 tag is published. + +--- + +## Historical v0.5.1 notes + +The following notes are retained from the v0.5.1 release for historical +reference. No GitHub release existed before v0.5.1, so everything documented for 0.5.0 — automatic cleanup, Linux/macOS installers, `relay add-agent`, the English `--help` overhaul — ships here as well. diff --git a/docs/DATABASE_MIGRATION.md b/docs/DATABASE_MIGRATION.md new file mode 100644 index 0000000..ada4a19 --- /dev/null +++ b/docs/DATABASE_MIGRATION.md @@ -0,0 +1,27 @@ +# Relay database migration + +Relay stores its job history in the SQLite database under `RELAY_HOME`. +Database schema changes use SQLite `PRAGMA user_version`. + +## Upgrade behavior + +- A new database is created at the current schema version. +- A legacy database is backed up before migration. +- Migration runs in a transaction. +- A migration failure rolls back and stops startup. +- A database newer than the installed Relay is rejected. +- Downgrade is not supported; restore the migration backup if needed. + +The G0 migration upgrades the Relay 0.5.0 schema to schema version 1 and adds +Job history metadata used by the read API. Schedule tables are added in the +Schedule core release, not in G0. + +Backups are written beside the database with a name like: + +```text +relay.db.backup-20260723T120000Z.db +``` + +The committed fixtures used by the migration tests were generated from the +Relay 0.5.0 schema at commit `4b7d710` using +`tests/fixture_builders/build_relay_0_5_0_fixtures.py`. diff --git a/docs/Relay_GUI_Development_Plan_v1.0.md b/docs/Relay_GUI_Development_Plan_v1.0.md new file mode 100644 index 0000000..c553ccb --- /dev/null +++ b/docs/Relay_GUI_Development_Plan_v1.0.md @@ -0,0 +1,2788 @@ +# Relay-agent GUI Development Plan v1.0 + +- **Repository:** `miter37/Relay-agent` +- **Document date:** 2026-07-23 +- **Target command:** `relay --gui` +- **Primary platforms:** Windows 11, Linux, macOS +- **UI language:** Simple English +- **Document language:** Korean, with proposed UI labels and messages written in English +- **Status:** First detailed implementation plan + +--- + +## 1. Purpose + +Relay-agent의 기존 CLI 기능을 유지하면서, 사람이 작업을 등록하고 진행 상태를 확인하며 결과·로그·산출물·반복 일정을 관리할 수 있는 로컬 데스크톱 GUI를 추가한다. + +GUI는 별도의 실행 시스템이 아니다. 기존 Relay의 다음 요소를 그대로 공유하는 **visual client**여야 한다. + +- `RelayEngine` +- local daemon +- SQLite job history +- worker adapters +- validation and atomic delivery +- cleanup policy +- configuration +- capability audit / deep doctor + +따라서 CLI, Hermes, GUI, Schedule이 등록한 모든 작업은 하나의 작업 이력과 하나의 실행 큐에서 관리된다. + +--- + +## 2. Final product definition + +`relay --gui`를 실행하면 Relay 데스크톱 앱이 열린다. + +GUI의 중심은 대시보드가 아니라 다음 두 영역이다. + +1. **Sidebar** + - Waiting + - Running + - Completed + - Schedules + - Settings + +2. **Main panel** + - 사이드바에서 선택한 작업 또는 Schedule의 상세 내용 표시 + - 새 작업 등록 + - 작업 결과·산출물·로그 확인 + - 설정 변경 + - 신규 Agent App 등록 + +GUI가 닫혀도 daemon이 살아 있다면 작업과 Schedule은 계속 실행된다. + +--- + +## 3. Fixed design decisions + +### 3.1 CLI jobs must appear in the GUI + +당연히 기존 CLI에서 실행한 작업도 GUI에 보여야 한다. + +```text +relay "Research today's semiconductor news" +relay run --task-file task.md +relay submit --task-file task.md +Hermes → relay submit +GUI → Create Task +Schedule → queue JobRequest +``` + +위 모든 경로는 동일한 Relay home과 동일한 SQLite database를 사용한다. + +```text + ┌──────────────┐ +CLI ────────────────────▶│ │ +Hermes ─────────────────▶│ Relay daemon │ +GUI ────────────────────▶│ + Engine │ +Schedule engine ─────────▶│ │ + └──────┬───────┘ + │ + ▼ + SQLite job history + │ + ▼ + GUI sidebar and detail +``` + +GUI는 “GUI에서 만든 작업만” 표시하는 별도 DB를 만들면 안 된다. + +#### Visibility condition + +CLI와 GUI가 같은 기록을 보려면 다음 조건이 같아야 한다. + +- 같은 OS user +- 같은 `RELAY_HOME` +- 같은 `database_path` + +GUI 하단 상태 영역에 현재 Relay home을 표시한다. + +```text +Relay Home: C:\Users\name\AppData\Local\Relay +``` + +CLI가 별도의 `RELAY_HOME` 환경변수로 실행되었다면 해당 작업은 다른 DB에 있으므로 현재 GUI에 나타나지 않는다. 향후 “Open another Relay Home” 기능을 추가할 수 있지만 MVP 범위에서는 제외한다. + +--- + +### 3.2 A Schedule is created from a successful job + +초기 버전에서는 새 작업을 곧바로 반복 일정으로 등록하지 않는다. + +```text +Create Task + ↓ +Run once + ↓ +Completed successfully + ↓ +Schedule this task + ↓ +Choose frequency and time + ↓ +Schedule created +``` + +이 정책의 목적은 다음과 같다. + +- 실제로 성공한 요청만 반복 실행 +- 잘못된 Agent, model, path, attachment 설정이 계속 실패하는 상황 방지 +- 첫 실행 결과를 확인한 후 반복 여부 결정 +- Schedule 등록 UI를 단순화 + +`Schedule this task` 버튼은 기본적으로 `COMPLETED` 작업에만 표시한다. + +`PARTIAL`, `FAILED`, `CANCELLED` 작업에는 표시하지 않는다. 먼저 재실행하여 완전한 성공을 확인하도록 한다. + +--- + +### 3.3 Scheduled runs are normal jobs + +Schedule은 작업을 직접 실행하지 않는다. 정해진 시각이 되면 기존 `JobRequest`를 복제하여 일반 작업 큐에 넣는다. + +```text +Schedule reached + │ + ▼ +Create a normal JobRequest + │ + ▼ +Waiting + │ + ▼ +Running + │ + ▼ +Completed / Partial / Failed / Cancelled +``` + +따라서 Schedule 실행분도 사이드바의 다음 위치에 동일하게 표시된다. + +- 실행 전: **Waiting** +- 실행 중: **Running** +- 종료 후: **Completed** + +Schedule 상세 화면에는 해당 Schedule이 만든 개별 실행 기록도 따로 연결한다. + +--- + +### 3.4 Simple English UI + +모든 메뉴명, 버튼, 도움말, 오류 안내는 가능한 한 쉬운 영어를 사용한다. + +예: + +| Avoid | Use | +|---|---| +| Queue | Waiting | +| Trigger cron | Run now | +| Execution history | Run history | +| Configure recurrence | Choose a schedule | +| Enable fallback | Use another agent if this fails | +| Artifact directory | Files folder | +| Submit request | Create task | +| Terminate process | Stop task | +| Capability audit | Test agent | +| Invocation parameters | Command options | + +기술 용어가 꼭 필요한 경우 짧은 설명을 함께 표시한다. + +```text +Request ID +Used to prevent the same task from being created twice. +``` + +--- + +## 4. Recommended GUI technology + +### 4.1 Recommendation: PySide6 + +GUI는 `PySide6` 기반 데스크톱 앱으로 구현하는 것을 권장한다. + +이유: + +- Windows, Linux, macOS 지원 +- modern desktop UI 구성 용이 +- split panel, tree/list, tabs, dialogs 지원 +- file picker와 native menu 지원 +- system tray와 desktop notification 확장 가능 +- 대용량 로그 표시와 background polling 구현이 비교적 안정적 +- Tkinter보다 화면 품질과 복잡한 레이아웃 구현성이 높음 + +### 4.2 Keep headless CLI lightweight + +현재 CLI의 기본 의존성은 가볍게 유지한다. + +```toml +[project.optional-dependencies] +gui = [ + "PySide6>=6.8,<7", + "tzdata>=2025.2" +] +``` + +권장 설치 정책: + +- 일반 Windows/macOS/Linux 설치 스크립트: GUI extra 포함 +- headless server 설치: base package만 설치 가능 +- GUI 의존성이 없을 때 `relay --gui` 실행: + +```text +GUI support is not installed. +Run: pip install "relay-ai-cli-broker[gui]" +``` + +--- + +## 5. Main information architecture + +```text +Relay-agent +│ +├─ New Task +│ +├─ Waiting +│ └─ jobs waiting for an agent +│ +├─ Running +│ └─ active jobs +│ +├─ Completed +│ ├─ Search +│ ├─ Filters +│ ├─ Today +│ ├─ Yesterday +│ └─ older dates +│ +├─ Schedules +│ ├─ Active +│ ├─ Paused +│ └─ Schedule errors +│ +└─ Settings + ├─ General + ├─ Agents + ├─ Paths + ├─ Task rules + ├─ Cleanup + ├─ Security + └─ Agent Apps +``` + +사이드바에는 기술 용어인 `Cron Jobs`보다 쉬운 `Schedules`를 사용한다. 내부 코드와 개발 문서에서는 cron 또는 schedule이라는 용어를 사용할 수 있다. + +--- + +## 6. Main window layout + +```text +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Relay-agent Daemon: Running [ + New Task ] │ +├──────────────────────────┬────────────────────────────────────────────────────┤ +│ │ │ +│ ▾ Waiting 3 │ │ +│ Market news research │ │ +│ Review report.pdf │ │ +│ Check project code │ │ +│ │ │ +│ ▾ Running 1 │ │ +│ ● Semiconductor news │ Main panel │ +│ Codex · 04:31 │ │ +│ │ Selected job, schedule, or settings page │ +│ ▾ Completed │ │ +│ [ Search jobs... ] │ │ +│ [ All ▼ ] [ Filter ] │ │ +│ │ │ +│ ▾ Today 8 │ │ +│ ✓ TSMC analysis │ │ +│ ◐ Market research │ │ +│ × PDF extraction │ │ +│ ▸ Yesterday 12 │ │ +│ ▸ Jul 21 9 │ │ +│ │ │ +│ ▾ Schedules 4 │ │ +│ ● Daily market news │ │ +│ ● Weekly stock review │ │ +│ ○ Monthly report │ │ +│ │ │ +│ ⚙ Settings │ │ +├──────────────────────────┴────────────────────────────────────────────────────┤ +│ Claude: Ready | Codex: Ready | Antigravity: Off | Running: 1 of 2 │ +│ Relay Home: C:\Users\name\AppData\Local\Relay │ +└───────────────────────────────────────────────────────────────────────────────┘ +``` + +### Layout rules + +- 기본 sidebar 폭: 320px +- 사용자가 drag하여 폭 조절 가능 +- 최소 sidebar 폭: 260px +- 최대 sidebar 폭: 480px +- main panel은 남은 공간 사용 +- 창 크기와 sidebar 폭을 로컬 설정에 저장 +- 최소 권장 해상도: 1280×720 +- 4K 화면에서도 글자가 지나치게 작아지지 않도록 OS scale 사용 + +--- + +## 7. Sidebar behavior + +### 7.1 Waiting + +상태가 다음 중 하나인 작업: + +- `CREATED` +- `QUEUED` +- 필요 시 `PREPARING`을 짧게 Waiting에 포함하거나 Running으로 즉시 이동 + +표시 예: + +```text +Market news research +Codex first · Added 08:32 +``` + +클릭하면 main panel에서 다음을 보여준다. + +- task title +- full task text +- attachments +- requested agent +- model +- profile +- fallback rule +- result format +- timeout +- output paths +- created time +- source: CLI / GUI / Hermes / Schedule +- schedule name, when applicable + +지원 동작: + +- `Edit` — 아직 실제 실행이 시작되지 않은 경우 +- `Stop` +- `Run first` — optional, queue priority 기능이 구현된 경우 +- `Copy job ID` + +MVP에서는 queue priority 변경은 제외할 수 있다. 이 경우 `Run first` 버튼도 제외한다. + +--- + +### 7.2 Running + +상태가 다음 중 하나인 작업: + +- `PREPARING` +- `RUNNING` +- `VALIDATING` +- `DELIVERING` +- `CANCEL_REQUESTED` + +표시 예: + +```text +● Semiconductor news +Codex · Running · 04:31 +``` + +실제 percentage는 표시하지 않는다. Worker가 전체 작업량을 알 수 없기 때문이다. + +대신 단계형 진행 표시를 사용한다. + +```text +Prepare → Run → Check result → Save files → Done + ━━━ +``` + +UI mapping: + +| Internal status | Easy English | +|---|---| +| PREPARING | Preparing | +| RUNNING | Running | +| VALIDATING | Checking result | +| DELIVERING | Saving files | +| CANCEL_REQUESTED | Stopping | + +--- + +### 7.3 Completed + +이 섹션은 실제로 모든 종료 작업을 포함한다. + +- `COMPLETED` +- `PARTIAL` +- `FAILED` +- `CANCELLED` + +사용자에게는 쉬운 메뉴명인 **Completed**를 사용한다. + +상태 아이콘: + +| Status | Icon | Label | +|---|---:|---| +| COMPLETED | ✓ | Completed | +| PARTIAL | ◐ | Partial | +| FAILED | × | Failed | +| CANCELLED | — | Cancelled | + +#### Date grouping + +최근 날짜가 위로 온다. + +```text +▾ Today · 8 + ✓ TSMC analysis 08:20 + ◐ Data center research 07:43 + × PDF chart extraction 06:15 + +▸ Yesterday · 12 +▸ Jul 21, 2026 · 9 +▸ Jul 20, 2026 · 4 +``` + +기본 규칙: + +- `Today`: open +- `Yesterday`: closed +- older dates: closed +- 날짜 안에서는 최근 종료 순 +- timezone은 GUI의 local timezone 사용 +- 7일 이후는 필요할 때 추가 로드 +- 무한 스크롤 또는 `Load more` 지원 +- 날짜 header를 누르면 open/close + +--- + +### 7.4 Completed search and filters + +Completed header 아래에 검색창과 필터 버튼을 둔다. + +```text +Completed +[ Search completed jobs... ] +[ All results ▼ ] [ Agent ▼ ] [ Date ▼ ] +``` + +검색 대상: + +- task title +- task text or saved task preview +- job ID +- agent name +- model +- profile +- error code +- Schedule name +- attachment file name, when indexed + +필터: + +```text +Result +- All results +- Completed +- Partial +- Failed +- Cancelled + +Agent +- All agents +- Claude +- Codex +- Antigravity +- custom agents + +Source +- All sources +- CLI +- GUI +- Hermes +- Schedule + +Date +- Any time +- Today +- Last 7 days +- Last 30 days +- Custom range +``` + +검색 결과도 날짜별 grouping을 유지한다. 매칭 항목이 없는 날짜 group은 숨긴다. + +검색은 debounce 250~400ms를 적용하여 입력할 때마다 DB를 과도하게 조회하지 않도록 한다. + +#### Recommended API + +```http +GET /jobs + ?terminal=true + &query=semiconductor + &status=COMPLETED,PARTIAL + &agent=codex + &source=schedule + &date_from=2026-07-01 + &date_to=2026-07-23 + &limit=100 + &cursor=... +``` + +--- + +### 7.5 Schedules + +표시 예: + +```text +▾ Schedules · 4 + ● Daily market news + Next: Today 13:00 + + ● Weekly stock review + Next: Fri 07:00 + + ○ Monthly report + Paused + + × Data collection + Last run failed +``` + +상태: + +| Icon | Meaning | +|---:|---| +| ● | Active | +| ○ | Paused | +| × | Needs attention | + +Schedule 항목을 클릭하면 main panel에 등록 내용과 다음 실행 시각을 표시한다. + +--- + +## 8. Main panel: job details + +작업을 선택하면 하나의 detail view에서 상태에 따라 기본 tab만 다르게 선택한다. + +```text +Market news research Completed + +Codex · gpt-5.x · web-research +Created 08:31 · Started 08:32 · Finished 08:40 +Source: CLI + +[ Overview ] [ Task ] [ Progress ] [ Result ] [ Files ] [ Logs ] [ Events ] +``` + +기본 tab: + +| Job state | Default tab | +|---|---| +| Waiting | Task | +| Running | Progress | +| Completed | Result | +| Partial | Result | +| Failed | Logs | +| Cancelled | Overview | + +--- + +### 8.1 Overview tab + +```text +Status Completed +Requested agent Claude +Actual agent Codex +Model gpt-5.x +Profile web-research +Result type JSON +Fallback On +Created Jul 23, 2026 08:31 +Started Jul 23, 2026 08:32 +Finished Jul 23, 2026 08:40 +Result file C:\...\result.json +Files folder C:\...\artifacts +Source CLI +``` + +Fallback flow: + +```text +Claude + └─ Sign-in required + ↓ +Codex + └─ Completed +``` + +Actions: + +```text +[ Run again ] [ Copy settings ] [ Open result ] [ Open folder ] +``` + +성공 작업에는 다음 버튼을 추가한다. + +```text +[ Schedule this task ] +``` + +--- + +### 8.2 Task tab + +표시 내용: + +- task title +- original task text +- task file path, if applicable +- attachments +- requested agent +- model +- profile +- fallback +- timeout +- caller +- request ID +- workspace +- output configuration + +기존 historical job에 별도 title이 없으면 다음 순서로 생성한다. + +1. task의 첫 번째 non-empty line +2. 최대 60자 +3. 줄바꿈 제거 +4. task를 읽을 수 없으면 `Job ` + +--- + +### 8.3 Progress tab + +```text +Current step + +Prepare Done +Run In progress +Check result Waiting +Save files Waiting +Done Waiting +``` + +실행 attempt 정보: + +```text +Attempt 1 +Agent Claude +Result Failed +Reason Sign-in required + +Attempt 2 +Agent Codex +Result Running +Elapsed 04:31 +``` + +Actions: + +```text +[ Stop task ] [ Show live log ] +``` + +--- + +### 8.4 Result tab + +JSON 결과: + +```text +Answer +──────────────────────────────────────── +The main findings are... + +Sources · 8 +Uncertainties · 2 +Missing items · 0 + +[ Show sources ] [ Show raw JSON ] [ Open result ] +``` + +TXT 결과: + +- readable monospaced or document text view +- word wrap toggle +- copy all +- open external file + +결과 검증 안내는 쉬운 영어로 표시한다. + +```text +Relay checked the file format and delivery. +It did not check whether the answer is factually correct. +``` + +--- + +### 8.5 Files tab + +```text +Name Type Size Actions +market_report.html HTML 420 KB Open · Show in folder +chart.png Image 860 KB Preview · Open +source_data.csv CSV 72 KB Open · Show in folder +``` + +MVP preview: + +- PNG/JPEG/WebP: internal preview +- TXT/JSON/CSV/MD: internal text preview with size limit +- other formats: open with default OS app + +--- + +### 8.6 Logs tab + +```text +[ Attempt 1: Claude ▼ ] [ stdout ] [ stderr ] [ Errors only ] + +08:32:01 ... +08:32:03 ... +``` + +기능: + +- 1~2초 단위 tail +- auto-scroll on/off +- search in log +- errors only +- copy selected text +- open full log file +- 대용량 파일 전체를 한 번에 메모리에 올리지 않음 + +--- + +### 8.7 Events tab + +```text +08:31:58 Job created +08:32:00 Preparing +08:32:02 Claude started +08:32:14 Claude failed: Sign-in required +08:32:15 Codex started +08:39:52 Checking result +08:40:01 Saving files +08:40:03 Completed +``` + +내부 event code는 유지하되 화면에는 쉬운 영어로 변환한다. + +--- + +## 9. New Task screen + +`+ New Task` 버튼을 누르면 main panel 전체가 등록 화면으로 바뀐다. + +새 작업은 기본적으로 1회 작업이다. Schedule은 성공 후 등록한다. + +```text +New Task + +[ Task ] [ Agent ] [ Run options ] [ Output ] [ Advanced ] +``` + +--- + +### 9.1 Task section + +```text +Task name +[ Research today's AI semiconductor news ] + +What should the agent do? +┌────────────────────────────────────────────────────────────┐ +│ Research the last 24 hours of AI semiconductor news... │ +│ │ +└────────────────────────────────────────────────────────────┘ + +Task input +(●) Write here +( ) Use a task file + +Files +[ + Add files ] report.pdf data.xlsx + +Profile +[ web-research ▼ ] +``` + +`Task name`은 optional이다. + +비어 있으면 task 첫 줄에서 자동 생성한다. + +--- + +### 9.2 Agent section + +```text +Agent +[ Codex ▼ ] + +Model +[ Default model ▼ ] [ Refresh ] + +Use another agent if this fails +[✓] + +Try in this order +1. Claude +2. Antigravity +[ Change order ] +``` + +현재 전역 fallback order만 지원하지만 GUI 개발과 함께 작업별 fallback list를 지원하는 것이 좋다. + +New request field: + +```python +fallback_workers: list[str] | None +``` + +- `None`: global fallback order +- list: task-specific order + +--- + +### 9.3 Run options + +```text +Run mode +(●) Run in the background +( ) Run and keep this page open + +Time limit +[ 1200 ] seconds + +Workspace +[ Use the default workspace ▼ ] + +Create a new job even if the same task exists +[ ] + +Replace an existing result file +[ ] +``` + +GUI 기본값은 background submit이다. + +UI에 노출하지 않아도 되는 내부 옵션: + +- `--machine` +- daemon token +- internal staging paths + +--- + +### 9.4 Output section + +```text +Result type +[ JSON ▼ ] + +Result file +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] + +Files folder +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] +``` + +자동 경로의 예시를 미리 보여준다. + +```text +Example result path +C:\Users\name\AppData\Local\Relay\results\2026-07-23\\result.json +``` + +--- + +### 9.5 Advanced section + +```text +Caller +[ human ▼ ] + +Request ID +[ Create automatically __________________ ] + +Model name +[ _______________________________________ ] + +Timeout +[ 1200 ] + +[ ] Force a new job +[ ] Replace existing output +``` + +`caller`는 일반 사용자에게 혼동을 줄 수 있으므로 Advanced에 둔다. + +--- + +### 9.6 Bottom action bar + +```text +[ Show CLI command ] [ Save as template ] [ Clear ] [ Create task ] +``` + +CLI preview example: + +```powershell +relay submit ` + --task-file "C:\...\request.md" ` + --worker codex ` + --model "gpt-5.x" ` + --format json ` + --timeout 1200 ` + --attach "C:\...\report.pdf" +``` + +CLI preview는 실제 shell escaping 규칙을 OS별로 생성한다. + +- Windows: PowerShell +- Linux/macOS: shell + +--- + +## 10. Schedule creation from a successful job + +### 10.1 Entry point + +Completed job의 main panel 상단 또는 action menu: + +```text +[ Run again ] [ Schedule this task ] [ Open result ] [ Open folder ] +``` + +`Schedule this task` 클릭 시 modal dialog를 연다. + +--- + +### 10.2 Schedule dialog layout + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Schedule this task │ +├──────────────────────────────────────────────────────────────┤ +│ Task │ +│ Daily market news │ +│ Codex · Default model · web-research │ +│ │ +│ Schedule name │ +│ [ Daily market news______________________________________ ] │ +│ │ +│ Repeat │ +│ [ Daily ▼ ] │ +│ │ +│ Time │ +│ [ 09:00 ] [ × ] │ +│ [ 13:00 ] [ × ] │ +│ [ + Add another time ] │ +│ │ +│ Time zone │ +│ [ Asia/Seoul ▼ ] │ +│ │ +│ Next runs │ +│ • Jul 24, 2026 09:00 │ +│ • Jul 24, 2026 13:00 │ +│ • Jul 25, 2026 09:00 │ +│ • Jul 25, 2026 13:00 │ +│ • Jul 26, 2026 09:00 │ +│ │ +│ [ More options ] [ Cancel ] [ Create schedule ]│ +└──────────────────────────────────────────────────────────────┘ +``` + +Popup의 주된 목적은 실행 설정을 다시 작성하는 것이 아니라 **주기와 시각을 선택하는 것**이다. + +원본 성공 작업에서 다음 값을 상속한다. + +- task text +- task title +- agent +- model +- profile +- result format +- fallback settings +- timeout +- other safe request options + +다음 값은 새 Schedule용으로 변경한다. + +- `request_id`: clear +- `force_new`: true for each scheduled run +- `output_path`: automatic unique path +- `artifact_path`: automatic unique path +- `task_file`: clear after task text is materialized +- `caller`: `schedule` +- `submitted_via`: `schedule` +- `schedule_id`: current schedule ID +- `scheduled_for`: expected run time + +--- + +## 11. Supported schedule types + +### 11.1 Daily + +필수: + +- one or more times +- timezone + +```text +Repeat +Daily + +Time +09:00 +13:00 +18:30 +``` + +Rule example: + +```json +{ + "type": "daily", + "times": ["09:00", "13:00", "18:30"], + "timezone": "Asia/Seoul" +} +``` + +--- + +### 11.2 Weekly + +필수: + +- one or more weekdays +- one or more times +- timezone + +```text +Repeat +Weekly + +Days +[✓ Mon] [ ] Tue [✓ Wed] [ ] Thu [✓ Fri] [ ] Sat [ ] Sun + +Time +07:00 +18:00 +``` + +Rule example: + +```json +{ + "type": "weekly", + "weekdays": [1, 3, 5], + "times": ["07:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +내부 weekday numbering은 ISO Monday=1을 권장한다. + +--- + +### 11.3 Monthly + +필수: + +- one or more dates +- one or more times +- timezone + +```text +Repeat +Monthly + +Dates +[ 1 ] [ 15 ] [ 28 ] [ + Add date ] + +Time +09:00 +``` + +Rule example: + +```json +{ + "type": "monthly", + "month_days": [1, 15, 28], + "times": ["09:00"], + "missing_day_policy": "skip", + "timezone": "Asia/Seoul" +} +``` + +29, 30, 31일이 없는 달의 기본 정책: + +```text +Skip months that do not have this date. +``` + +Advanced option: + +```text +Use the last day of the month instead. +``` + +--- + +### 11.4 Every N days + +필수: + +- N +- start date / anchor date +- one or more times +- timezone + +```text +Repeat +Every [ 3 ] days + +Start date +[ Jul 23, 2026 ] + +Time +09:00 +18:00 +``` + +Rule example: + +```json +{ + "type": "n_days", + "interval_days": 3, + "anchor_date": "2026-07-23", + "times": ["09:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +`Every N days`는 단순 cron expression으로 정확히 표현하기 어려우므로 내부 canonical rule JSON을 사용한다. + +--- + +### 11.5 One time + +필수: + +- date +- time +- timezone + +```text +Repeat +One time + +Date +[ Aug 3, 2026 ] + +Time +[ 10:30 ] +``` + +실행 후 자동으로 inactive 상태가 된다. + +Rule example: + +```json +{ + "type": "once", + "run_at": "2026-08-03T10:30:00", + "timezone": "Asia/Seoul" +} +``` + +--- + +## 12. Schedule advanced options + +기본 화면에서는 숨기고 `More options`에서 연다. + +### 12.1 Previous run is still active + +```text +If the previous run is still active + +(●) Skip this run +( ) Add this run to Waiting +``` + +MVP 기본값: + +```text +Skip this run +``` + +초기 버전에서는 `Stop the old run and start a new one`은 제외하는 것을 권장한다. 작업 중단과 결과 파일 처리에서 혼란이 커질 수 있다. + +--- + +### 12.2 Relay was not running + +Schedule은 daemon이 실행 중일 때만 시간을 감지할 수 있다. + +```text +If Relay was not running + +(●) Skip missed runs +( ) Run once when Relay starts +``` + +기본값: + +```text +Skip missed runs +``` + +향후 필요하면 grace period를 추가한다. + +```text +Run once if the missed time was less than [ 12 ] hours ago. +``` + +--- + +### 12.3 Start and end + +```text +Start +[ Now ▼ ] + +End +(●) No end date +( ) End on [ date ] +``` + +MVP에서는 optional이다. + +--- + +### 12.4 Next-run preview + +Schedule 저장 전에 다음 5개 실행 시각을 반드시 표시한다. + +```text +Next runs +• Jul 24, 2026 09:00 +• Jul 24, 2026 13:00 +• Jul 25, 2026 09:00 +• Jul 25, 2026 13:00 +• Jul 26, 2026 09:00 +``` + +잘못된 date/time 조합은 저장 전에 차단한다. + +--- + +## 13. Schedule details screen + +사이드바에서 Schedule을 클릭하면 다음 화면을 표시한다. + +```text +Daily market news Active + +Daily at 09:00 and 13:00 +Time zone: Asia/Seoul + +Next run +Jul 24, 2026 09:00 + +Last run +Jul 23, 2026 13:00 · Completed + +[ Overview ] [ Task settings ] [ Run history ] +``` + +### 13.1 Overview + +```text +Schedule Daily +Times 09:00, 13:00 +Time zone Asia/Seoul +Next run Jul 24, 2026 09:00 +Last run Jul 23, 2026 13:00 +Previous run active Skip this run +Missed runs Skip +Created from Job 20260723-... +``` + +Actions: + +```text +[ Run now ] [ Pause ] [ Edit schedule ] [ Copy ] [ Delete ] +``` + +`Run now` 역시 일반 JobRequest를 queue에 추가한다. + +--- + +### 13.2 Task settings + +```text +Task name Daily market news +Agent Codex +Model Default model +Profile web-research +Fallback Claude +Time limit 20 minutes +Result type JSON +``` + +MVP에서는 task setting을 보여주기만 하고, 수정은 `Edit task settings` 버튼으로 별도 dialog에서 처리한다. + +--- + +### 13.3 Run history + +```text +Scheduled time Started Result +Jul 23, 13:00 Jul 23, 13:00 Completed +Jul 23, 09:00 Jul 23, 09:00 Completed +Jul 22, 13:00 Jul 22, 13:01 Failed +Jul 22, 09:00 — Skipped +``` + +각 row를 클릭하면 해당 일반 job 상세 화면으로 이동한다. + +--- + +## 14. Scheduled run lifecycle + +```mermaid +flowchart TD + A[Schedule reaches next_run_at] --> B{Schedule enabled?} + B -- No --> Z[Do nothing] + B -- Yes --> C{Previous run active?} + C -- No --> E[Clone request template] + C -- Yes --> D{Overlap policy} + D -- Skip --> S[Record skipped schedule run] + D -- Add to Waiting --> E + E --> F[Clear request_id and fixed output paths] + F --> G[Create normal queued job] + G --> H[Waiting] + H --> I[Running] + I --> J[Checking result] + J --> K[Saving files] + K --> L[Completed / Partial / Failed / Cancelled] + L --> M[Update schedule last_run and next_run] +``` + +모든 실행 상태는 기존 job lifecycle을 사용한다. + +--- + +## 15. Attachment handling for Schedules + +성공 작업에서 Schedule을 만들 때 원본 attachment 경로만 그대로 참조하면 안 된다. + +이유: + +- 원본 파일이 이동 또는 삭제될 수 있음 +- 임시 폴더일 수 있음 +- Schedule은 수개월 뒤에도 실행될 수 있음 +- CLI 작업의 task file 경로도 사라질 수 있음 + +### Recommended behavior + +Schedule 생성 시 다음을 수행한다. + +```text +source job task text + → materialize into schedule request template + +source attachments + → copy into persistent schedule input folder + +/schedule-inputs// +``` + +Schedule template에는 복사된 persistent path를 저장한다. + +```text +schedule-inputs/ +└─ sch_abc123/ + ├─ request.md + ├─ report.pdf + └─ data.xlsx +``` + +Schedule 삭제 시: + +- 연결된 과거 job 결과는 삭제하지 않음 +- schedule input snapshot만 retention rule에 따라 삭제 +- 삭제 전에 확인 dialog 표시 + +```text +Delete this schedule? +Past job results will not be deleted. +``` + +--- + +## 16. Daemon and auto-start requirement + +Schedule이 정확히 실행되려면 daemon이 계속 살아 있어야 한다. + +GUI를 닫아도 daemon은 종료하지 않는 것을 기본값으로 한다. + +### First Schedule prompt + +첫 Schedule 생성 시 OS auto-start가 설정되지 않았다면: + +```text +Relay must keep running to start scheduled tasks. + +Start Relay automatically when you sign in? + +[ Not now ] [ Turn on auto-start ] +``` + +### Platform integration + +- Windows: Task Scheduler, current user logon +- macOS: LaunchAgent +- Linux: systemd user service +- fallback: user starts daemon manually + +GUI Settings: + +```text +Start Relay when I sign in +[✓] +``` + +daemon stop confirmation: + +```text +Stopping Relay will also stop scheduled tasks. + +[ Cancel ] [ Stop Relay ] +``` + +--- + +## 17. GUI and daemon communication + +GUI는 SQLite를 직접 수정하지 않는다. + +권장 구조: + +```text +GUI + │ + │ token-authenticated local RPC + ▼ +Relay daemon + │ + ├─ job API + ├─ schedule API + ├─ agent API + ├─ settings API + └─ cleanup API + │ + ▼ +SQLite + RelayEngine +``` + +이유: + +- GUI와 CLI가 동시에 DB를 수정하는 충돌 방지 +- validation logic 중복 방지 +- queue와 Schedule 동시성 한곳에서 관리 +- 보안 규칙과 path validation 재사용 +- 향후 remote mode 확장 가능 + +GUI는 1~2초 polling으로 시작한다. 추후 Server-Sent Events 또는 local event stream을 고려할 수 있지만 MVP에는 필요하지 않다. + +--- + +## 18. Required RPC endpoints + +### 18.1 Jobs + +```http +GET /jobs +GET /jobs/{job_id} +POST /jobs +PATCH /jobs/{job_id} # waiting job only +POST /jobs/{job_id}/cancel +POST /jobs/{job_id}/rerun +GET /jobs/{job_id}/logs +GET /jobs/{job_id}/events +GET /jobs/{job_id}/artifacts +``` + +현재 `/submit`, `/status/{id}`, `/result/{id}`, `/show/{id}`, `/cancel/{id}`와 호환성을 유지한다. + +--- + +### 18.2 Schedules + +```http +GET /schedules +POST /schedules +GET /schedules/{schedule_id} +PATCH /schedules/{schedule_id} +DELETE /schedules/{schedule_id} + +POST /schedules/from-job/{job_id} +POST /schedules/{schedule_id}/run-now +POST /schedules/{schedule_id}/pause +POST /schedules/{schedule_id}/resume + +GET /schedules/{schedule_id}/runs +GET /schedules/{schedule_id}/next-runs?count=5 +``` + +--- + +### 18.3 Agents + +```http +GET /agents +GET /agents/{agent_id} +POST /agents +PATCH /agents/{agent_id} +DELETE /agents/{agent_id} + +POST /agents/{agent_id}/test +GET /agents/{agent_id}/models +POST /agents/{agent_id}/models/refresh +``` + +--- + +### 18.4 Settings and maintenance + +```http +GET /config +PATCH /config + +GET /cleanup/status +POST /cleanup + +GET /health +POST /shutdown +``` + +--- + +## 19. Database changes + +현재 jobs, attempts, artifacts, events, capability_audits를 유지하고 migration으로 확장한다. + +### 19.1 Jobs table additions + +```sql +ALTER TABLE jobs ADD COLUMN title TEXT; +ALTER TABLE jobs ADD COLUMN submitted_via TEXT; +ALTER TABLE jobs ADD COLUMN schedule_id TEXT; +ALTER TABLE jobs ADD COLUMN scheduled_for TEXT; +ALTER TABLE jobs ADD COLUMN task_preview TEXT; +``` + +Recommended values for `submitted_via`: + +- `cli` +- `gui` +- `hermes` +- `schedule` +- `legacy` + +기존 row는 migration 후 `legacy` 또는 caller 기반 추론값을 사용한다. + +#### Search indexes + +```sql +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at +ON jobs(completed_at); + +CREATE INDEX IF NOT EXISTS idx_jobs_submitted_via +ON jobs(submitted_via); + +CREATE INDEX IF NOT EXISTS idx_jobs_schedule +ON jobs(schedule_id, created_at); +``` + +SQLite FTS5 지원 여부가 환경별로 다를 수 있으므로 MVP는 indexed metadata와 `LIKE` 기반 검색으로 시작한다. + +검색 성능이 부족하면 별도 FTS migration을 추가한다. + +--- + +### 19.2 Schedules table + +```sql +CREATE TABLE IF NOT EXISTS schedules ( + schedule_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + + schedule_type TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL, + + source_job_id TEXT, + request_template_json TEXT NOT NULL, + input_snapshot_path TEXT, + + overlap_policy TEXT NOT NULL DEFAULT 'skip', + missed_run_policy TEXT NOT NULL DEFAULT 'skip', + + next_run_at TEXT, + last_scheduled_at TEXT, + last_started_at TEXT, + last_completed_at TEXT, + last_job_id TEXT, + last_status TEXT, + last_error TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +--- + +### 19.3 Schedule runs table + +```sql +CREATE TABLE IF NOT EXISTS schedule_runs ( + schedule_run_id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id TEXT NOT NULL + REFERENCES schedules(schedule_id) ON DELETE CASCADE, + + scheduled_for TEXT NOT NULL, + triggered_at TEXT, + job_id TEXT, + state TEXT NOT NULL, + reason TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_run_unique +ON schedule_runs(schedule_id, scheduled_for); + +CREATE INDEX IF NOT EXISTS idx_schedule_runs_schedule +ON schedule_runs(schedule_id, scheduled_for DESC); +``` + +`state` 예: + +- `planned` +- `queued` +- `running` +- `completed` +- `partial` +- `failed` +- `cancelled` +- `skipped` +- `missed` + +unique index로 daemon restart나 scheduler loop 중복 실행을 방지한다. + +--- + +## 20. Schedule calculation engine + +### 20.1 Canonical JSON, not raw cron + +Daily, weekly, monthly는 cron expression으로 표현할 수 있지만, 다음 요구는 raw cron만으로 일관되게 처리하기 어렵다. + +- multiple times +- every N days with an anchor +- timezone +- invalid monthly dates +- missed-run policy +- next five runs preview + +따라서 내부 source of truth는 normalized JSON rule로 둔다. + +필요하면 Advanced 화면에서 read-only cron-like summary를 보여줄 수 있다. + +--- + +### 20.2 Core functions + +```python +def validate_schedule_rule(rule: dict) -> None: ... + +def next_occurrence( + rule: dict, + after: datetime, +) -> datetime | None: ... + +def next_occurrences( + rule: dict, + after: datetime, + count: int, +) -> list[datetime]: ... + +def is_due( + next_run_at: datetime, + now: datetime, +) -> bool: ... +``` + +모든 계산은 timezone-aware datetime을 사용한다. + +--- + +### 20.3 Scheduler loop + +기존 daemon scheduler에 별도 Schedule loop를 추가한다. + +```text +Job queue loop every 0.5 seconds +Schedule due check every 15–30 seconds +Maintenance loop hourly +``` + +Schedule check는 transaction과 unique key를 사용해 중복 queue를 방지한다. + +--- + +## 21. Agent App registration + +Settings 안에 신규 Agent App 등록 기능을 제공한다. + +### 21.1 Settings layout + +```text +Settings +├─ General +├─ Agents +├─ Paths +├─ Task rules +├─ Cleanup +├─ Security +└─ Agent Apps +``` + +Agent Apps page: + +```text +Agent Apps + +Built in +Claude Code Ready +Codex CLI Ready +Antigravity Off + +Added by you +OpenCode Ready +Gemini CLI Needs a test + +[ + Add agent app ] +``` + +--- + +### 21.2 Registration wizard + +#### Step 1 — Basic details + +```text +Add agent app + +Agent name +[ OpenCode____________________________ ] + +Agent ID +[ opencode____________________________ ] + +Command +[ /usr/local/bin/opencode____________ ] [ Browse ] + +Description +[ ___________________________________ ] +``` + +--- + +#### Step 2 — How to run + +```text +How should Relay send the task? + +(●) Use a request file +( ) Send through standard input +( ) Add the task to the command + +Command options + +run +--input +{request_file} +--output +{result_file} +--workspace +{workspace} +``` + +Supported placeholders: + +```text +{request_file} +{result_file} +{artifact_dir} +{workspace} +{schema_file} +{model} +{profile} +{job_id} +``` + +실행 파일과 arguments를 분리 저장한다. 사용자가 임의의 shell command string을 저장한 뒤 `shell=True`로 실행하면 안 된다. + +--- + +#### Step 3 — Read the result + +```text +Where does this agent save its answer? + +(●) Result file +( ) Standard output + +Supported result types +[✓] JSON +[✓] Text + +Can this agent create files? +[✓] +``` + +--- + +#### Step 4 — Models + +```text +Default model +[ ______________________________ ] + +Can Relay list models? +( ) No +(●) Run a command + +Model list command +[ models --json_________________ ] + +Model option +[ --model {model}_______________ ] +``` + +--- + +#### Step 5 — Safety + +```text +Needs network access +[✓] + +Can write inside the task workspace +[✓] + +May skip permission checks +[ ] + +Environment variable names +[ API_KEY_NAME__________________ ] +[ + Add ] +``` + +MVP에서는 secret value를 일반 TOML에 저장하지 않는다. + +- environment variable name reference +- OS environment +- 향후 OS keyring 지원 + +--- + +#### Step 6 — Test + +```text +Test agent + +Find command Passed +Read version Passed +Run without questions Passed +Create result file Passed +Check JSON or text Passed +Create files Passed + +[ Run test again ] [ Save agent ] +``` + +Deep test를 통과하지 않은 custom agent는 활성화하지 않는다. + +--- + +## 22. Dynamic Agent Registry refactor + +현재 built-in Agent가 코드에 고정되어 있으므로 GUI에서 신규 Agent App을 등록하려면 core refactor가 선행되어야 한다. + +목표 구조: + +```text +AgentRegistry +│ +├─ Built-in adapters +│ ├─ ClaudeAdapter +│ ├─ CodexAdapter +│ └─ AntigravityAdapter +│ +└─ Custom agent definitions + ├─ GenericCLIAdapter: opencode + ├─ GenericCLIAdapter: gemini + └─ GenericCLIAdapter: other +``` + +API concept: + +```python +registry.list_agents() +registry.get_agent("codex") +registry.get_agent("opencode") +registry.get_adapter("opencode") +registry.list_enabled_agents() +``` + +CLI fixed choices를 제거한다. + +```text +relay run --worker opencode +relay doctor --worker opencode --deep +relay models --worker opencode +``` + +GUI에서만 사용할 수 있는 Agent를 만들지 않는다. CLI와 GUI는 동일한 registry를 사용한다. + +--- + +## 23. Proposed module structure + +```text +relay/ +├─ cli.py +├─ daemon.py +├─ engine.py +├─ db.py +├─ config.py +│ +├─ agent_registry.py +├─ schedule.py +├─ schedule_engine.py +├─ migrations.py +│ +├─ adapters/ +│ ├─ base.py +│ ├─ claude.py +│ ├─ codex.py +│ ├─ antigravity.py +│ └─ generic_cli.py +│ +├─ gui/ +│ ├─ __init__.py +│ ├─ app.py +│ ├─ main_window.py +│ ├─ theme.py +│ ├─ state.py +│ ├─ rpc_service.py +│ ├─ formatters.py +│ │ +│ ├─ views/ +│ │ ├─ empty_view.py +│ │ ├─ new_task_view.py +│ │ ├─ job_detail_view.py +│ │ ├─ schedule_detail_view.py +│ │ └─ settings_view.py +│ │ +│ ├─ widgets/ +│ │ ├─ sidebar.py +│ │ ├─ job_item.py +│ │ ├─ date_group.py +│ │ ├─ status_stepper.py +│ │ ├─ log_viewer.py +│ │ ├─ file_list.py +│ │ └─ search_filter.py +│ │ +│ └─ dialogs/ +│ ├─ schedule_dialog.py +│ ├─ schedule_edit_dialog.py +│ ├─ agent_app_wizard.py +│ ├─ confirm_dialog.py +│ └─ error_dialog.py +│ +└─ rpc/ + ├─ routes_jobs.py + ├─ routes_schedules.py + ├─ routes_agents.py + └─ routes_config.py +``` + +현재 단일 `daemon.py` handler가 커지지 않도록 route logic을 모듈로 분리한다. + +--- + +## 24. `relay --gui` command behavior + +### 24.1 Parser + +global option 추가: + +```text +relay --gui +``` + +선택적으로 다음 alias도 지원할 수 있다. + +```text +relay gui +``` + +권장 동작: + +```text +relay --gui + ↓ +Load Config + ↓ +Check GUI dependency + ↓ +Ensure daemon is running + ↓ +Open desktop app +``` + +GUI process와 daemon process는 분리한다. + +GUI가 닫혀도 daemon은 계속 살아 있다. + +--- + +### 24.2 Single-instance behavior + +같은 Relay home에서 GUI를 두 번 실행하면 새 창을 여러 개 만들기보다 기존 창을 앞으로 가져오는 것이 좋다. + +MVP 대안: + +```text +Relay-agent is already open. +``` + +daemon은 기존처럼 단일 port/token을 사용한다. + +--- + +## 25. Easy English copy guide + +### 25.1 Main menu labels + +```text +New Task +Waiting +Running +Completed +Schedules +Settings +``` + +### 25.2 Job actions + +```text +Create task +Edit +Stop task +Run again +Copy settings +Schedule this task +Open result +Open folder +Copy job ID +``` + +### 25.3 Schedule actions + +```text +Create schedule +Run now +Pause +Resume +Edit schedule +Copy +Delete +Next run +Last run +Run history +``` + +### 25.4 Agent actions + +```text +Add agent app +Test agent +Refresh models +Turn on +Turn off +Save agent +Remove agent +``` + +### 25.5 Short help text examples + +```text +Use another agent if this fails. + +Relay will try the next agent only for a technical failure. + +The next run will start at this time. + +Past job results will not be deleted. + +Relay checked the file format, not the factual accuracy. + +This task came from the command line. + +This task was started by a schedule. +``` + +### 25.6 Error message pattern + +항상 다음 순서로 쓴다. + +1. What happened +2. Why, if known +3. What the user can do + +Example: + +```text +Codex could not start. + +The Codex command was not found. + +Open Settings > Agent Apps and check the command path. +``` + +기술 error code는 접힌 상세 영역에 표시한다. + +```text +Technical details +WORKER_NOT_INSTALLED +``` + +--- + +## 26. Security rules + +### 26.1 Keep existing path validation + +GUI file picker가 경로를 선택해도 기존 Relay의 path validation과 caller rules를 우회하지 않는다. + +### 26.2 Do not use shell command strings + +custom Agent는: + +- executable path +- argument list +- environment variable references + +형태로 저장한다. + +`subprocess(..., shell=True)` 금지. + +### 26.3 Antigravity gating + +GUI의 toggle만으로 Antigravity security verification을 우회할 수 없다. + +```text +This agent needs a security check before it can be turned on. +``` + +### 26.4 Secrets + +MVP: + +- secret values 저장하지 않음 +- environment variable name만 등록 +- 화면에 실제 secret 표시하지 않음 + +Later: + +- Windows Credential Manager +- macOS Keychain +- Linux Secret Service +- Python keyring integration + +### 26.5 Schedule inputs + +Schedule input snapshot은 current user만 읽을 수 있도록 파일 권한을 설정한다. + +--- + +## 27. Cleanup and retention + +기존 job retention 정책은 Schedule 실행 작업에도 동일하게 적용한다. + +Schedule definition은 사용자가 삭제하거나 명시적으로 정리하기 전까지 유지한다. + +별도 정책: + +```text +schedule-inputs/ +``` + +- active Schedule: 삭제 금지 +- deleted Schedule: 7일 후 정리 가능 +- past job results: 기존 job retention에 따름 + +Settings > Cleanup: + +```text +Job files +Schedule input files +Unused Agent test files +``` + +Dry run 지원: + +```text +Show what will be removed +``` + +--- + +## 28. Cross-platform behavior + +### Windows + +- default focus +- PowerShell CLI preview +- Task Scheduler auto-start +- Explorer file reveal +- native file dialog + +### macOS + +- shell CLI preview +- LaunchAgent auto-start +- Finder reveal +- app menu conventions + +### Linux + +- shell CLI preview +- systemd user service +- file manager reveal through desktop environment +- Wayland/X11 testing + +UI 기능은 OS별로 다르더라도 core daemon과 DB schema는 동일해야 한다. + +--- + +## 29. Development phases + +### Phase G0 — Core preparation + +목표: GUI와 Schedule이 기존 core를 안전하게 사용할 수 있도록 기반 정리. + +구현: + +- DB migration framework +- jobs title / submitted_via / schedule columns +- server-side job listing, pagination, search, filter +- daemon route 분리 +- AgentRegistry 도입 +- fixed worker choices 제거 +- GenericCLIAdapter 기본 골격 +- API response schema versioning +- regression tests for existing CLI + +완료 기준: + +- 기존 CLI 명령 모두 동일하게 작동 +- 기존 DB 자동 migration +- CLI 작업을 `/jobs` API로 조회 가능 +- built-in 세 Agent가 registry를 통해 동작 + +--- + +### Phase G1 — GUI shell and shared history + +목표: `relay --gui` 실행과 sidebar/main panel의 기본 완성. + +구현: + +- PySide6 optional dependency +- `relay --gui` +- daemon auto-connect/start +- resizable sidebar +- Waiting / Running / Completed sections +- date grouping +- Completed search and filters +- main panel job overview +- GUI state persistence +- current Relay Home 표시 +- CLI/Hermes job visibility + +완료 기준: + +- CLI에서 실행한 작업이 2초 안에 GUI에 나타남 +- 상태 변화가 sidebar 사이에서 자동 이동 +- Completed가 날짜별로 접히고 펼쳐짐 +- 검색과 필터가 동시에 동작 + +--- + +### Phase G2 — Job details and task creation + +목표: GUI만으로 일회성 작업의 전체 lifecycle 처리. + +구현: + +- New Task screen +- all supported request parameters +- file attachments +- agent/model/profile selection +- fallback configuration +- output path picker +- CLI command preview +- create / cancel / rerun +- Result / Files / Logs / Events tabs +- live log tail +- open result/folder + +완료 기준: + +- CLI에서 가능한 주요 JobRequest 파라미터를 GUI에서 지정 가능 +- GUI 작업도 CLI history에 동일하게 표시 +- result와 artifact 확인 가능 +- failed job의 원인과 log 확인 가능 + +--- + +### Phase G3 — Schedule engine + +목표: 성공 작업을 반복 일정으로 등록하고 daemon에서 실행. + +구현: + +- schedules / schedule_runs tables +- canonical schedule JSON +- daily multiple times +- weekly days + multiple times +- monthly dates + multiple times +- every N days +- one-time date/time +- timezone support +- next five runs preview +- due scheduler loop +- overlap policy +- missed-run policy +- unique scheduled-run dedup +- attachment snapshot +- source job link +- auto-start guidance + +완료 기준: + +- 성공 job에서 `Schedule this task` 가능 +- 다음 실행 시각이 정확히 표시 +- 실행 시 일반 queue에 Job이 생성 +- 해당 Job이 Waiting → Running → Completed로 이동 +- Schedule run history에서 일반 Job으로 이동 가능 +- daemon restart 후 중복 실행 없음 + +--- + +### Phase G4 — Schedule GUI + +목표: Schedule의 생성, 조회, 수정, 중지, 즉시 실행. + +구현: + +- schedule dialog +- Schedule sidebar section +- Schedule detail screen +- Run now +- Pause / Resume +- Edit schedule +- Copy schedule +- Delete schedule +- run history +- error state and next run +- auto-start setting + +완료 기준: + +- 모든 supported schedule type을 GUI에서 만들 수 있음 +- Schedule 상세에 next run과 last run 표시 +- paused Schedule은 실행되지 않음 +- easy English만으로 설정 가능 + +--- + +### Phase G5 — Custom Agent Apps + +목표: built-in Agent 외의 CLI app 등록. + +구현: + +- Agent App wizard +- custom agent config storage +- GenericCLIAdapter +- placeholder validation +- result normalization rules +- deep test +- model list command +- enable/disable +- custom Agent를 CLI와 GUI에서 모두 사용 + +완료 기준: + +- 사용자가 command와 args를 등록 가능 +- test를 통과한 Agent만 활성화 +- `relay run --worker ` 작동 +- New Task agent list에 표시 +- Schedule에서도 custom Agent 사용 가능 + +--- + +### Phase G6 — Packaging and operations + +목표: 일반 사용자 배포 품질 확보. + +구현: + +- Windows installer update +- Unix installer update +- GUI dependencies +- desktop shortcut +- icon +- single-instance handling +- system tray, optional +- desktop notifications, optional +- auto-start registration +- crash report file +- accessibility and keyboard navigation +- full cross-platform test matrix + +완료 기준: + +- Windows, Linux, macOS에서 `relay --gui` 실행 +- GUI가 없어도 headless CLI는 계속 작동 +- uninstall/upgrade에서 Relay home data 보존 +- Schedule auto-start가 platform별로 검증됨 + +--- + +## 30. Test plan + +### 30.1 Shared history + +- CLI sync job appears in GUI +- CLI submit job appears in GUI +- Hermes job appears in GUI +- GUI job appears in CLI `history` +- Schedule job appears in both +- different `RELAY_HOME` does not leak jobs + +### 30.2 Sidebar state movement + +```text +QUEUED → Waiting +PREPARING → Running +RUNNING → Running +VALIDATING → Running +DELIVERING → Running +COMPLETED → Completed / correct date +PARTIAL → Completed / Partial icon +FAILED → Completed / Failed icon +CANCELLED → Completed / Cancelled icon +``` + +### 30.3 Completed search + +- title search +- task text search +- job ID search +- agent filter +- status filter +- date range +- Schedule source filter +- combined filter +- pagination +- Korean task text search +- English task text search + +### 30.4 Schedule calculation + +- daily one time +- daily multiple times +- weekly multiple weekdays +- weekly multiple times +- monthly 1, 15, 31 +- February invalid day +- leap year +- every 2/3/10 days +- one-time schedule +- timezone conversion +- daylight saving transition +- next five run preview +- pause/resume +- duplicate due-loop protection + +### 30.5 Schedule execution + +- due job enters Waiting +- progresses to Running +- ends in Completed +- source Schedule link exists +- unique output path +- request ID is not reused +- attachments remain available +- previous run active: skip +- previous run active: queue +- daemon restart +- missed run policy + +### 30.6 Agent App + +- valid executable +- missing executable +- invalid placeholder +- interactive prompt +- no result file +- invalid JSON +- artifact path violation +- model list failure +- custom Agent CLI execution +- custom Agent GUI execution +- custom Agent Schedule execution + +### 30.7 UI and copy + +- no unexplained technical menu labels +- keyboard-only navigation +- screen scaling +- long task titles +- long file paths +- empty states +- error details collapsed +- light and dark OS appearance, if supported + +--- + +## 31. Acceptance criteria + +GUI v1.0은 다음을 모두 만족해야 완료로 본다. + +### Launch and compatibility + +- [ ] `relay --gui`로 실행된다. +- [ ] 기존 CLI 명령은 변경 없이 작동한다. +- [ ] GUI 미설치 환경에서도 CLI는 작동한다. +- [ ] 같은 Relay home의 CLI/Hermes/GUI/Schedule 작업이 모두 보인다. + +### Sidebar + +- [ ] Waiting, Running, Completed, Schedules, Settings가 있다. +- [ ] Completed는 최근 날짜가 위로 온다. +- [ ] 날짜 header를 눌러 open/close할 수 있다. +- [ ] Completed 검색과 필터가 있다. +- [ ] 작업 상태가 바뀌면 올바른 section으로 이동한다. + +### Main panel + +- [ ] 선택한 작업의 요청, 상태, 결과, 파일, 로그, 이벤트를 볼 수 있다. +- [ ] 선택한 Schedule의 rule, next run, last run, run history를 볼 수 있다. +- [ ] 결과 파일과 files folder를 열 수 있다. + +### New Task + +- [ ] 기존 주요 JobRequest 파라미터를 편리하게 선택할 수 있다. +- [ ] attachments를 추가할 수 있다. +- [ ] agent/model/profile/fallback/result type/path/timeout을 지정할 수 있다. +- [ ] GUI 작업이 일반 job DB에 기록된다. + +### Schedules + +- [ ] Completed job에서 Schedule을 만들 수 있다. +- [ ] Daily에 multiple times를 지정할 수 있다. +- [ ] Weekly에 weekday와 time을 지정할 수 있다. +- [ ] Monthly에 date와 time을 지정할 수 있다. +- [ ] Every N days와 one-time을 지정할 수 있다. +- [ ] next run preview가 보인다. +- [ ] Schedule 실행분이 Waiting → Running → Completed에 들어간다. +- [ ] Schedule detail에 next run이 표시된다. +- [ ] pause/resume/run now/edit/delete가 가능하다. + +### Agent Apps + +- [ ] Settings에서 custom Agent App을 등록할 수 있다. +- [ ] command, args, input, output, model, environment 설정을 할 수 있다. +- [ ] deep test를 통과해야 활성화된다. +- [ ] custom Agent가 CLI와 GUI에서 모두 동작한다. + +### Language + +- [ ] 주요 메뉴와 안내가 simple English로 작성된다. +- [ ] 기술 error code는 상세 영역에만 표시된다. +- [ ] 오류 메시지는 문제와 해결 행동을 함께 알려준다. + +--- + +## 32. Recommended implementation order + +기능 의존성을 고려한 실제 구현 순서: + +```text +1. Database migration framework +2. Job list/search/filter RPC +3. Dynamic AgentRegistry +4. GUI shell +5. Sidebar and shared CLI history +6. Job detail views +7. New Task screen +8. Result / files / logs / events +9. Schedule data model +10. Schedule calculation engine +11. Schedule daemon loop +12. Schedule dialog and detail view +13. Attachment snapshot and auto-start +14. Custom Agent App wizard +15. GenericCLIAdapter +16. Packaging and cross-platform validation +``` + +Schedule보다 GUI shell을 먼저 만들되, Schedule schema와 AgentRegistry의 방향은 초기에 고정해야 이후 화면과 API를 다시 뜯어고치지 않는다. + +--- + +## 33. Final user flow examples + +### 33.1 CLI task appears in GUI + +```text +User runs: +relay "Research AI semiconductor news" --worker codex + +GUI: +Waiting + → Research AI semiconductor news + +Running + → Research AI semiconductor news + +Completed + → Today + ✓ Research AI semiconductor news +``` + +--- + +### 33.2 Create a Schedule + +```text +Completed + → Today + ✓ Research AI semiconductor news + ↓ click + +Main panel + → [ Schedule this task ] + ↓ + +Schedule dialog + → Daily + → 09:00, 13:00 + → Asia/Seoul + → Create schedule + ↓ + +Schedules + → Daily AI semiconductor news + Next: Tomorrow 09:00 +``` + +--- + +### 33.3 Scheduled run + +```text +09:00 reached + +Schedules + Daily AI semiconductor news + Last run: Starting + +Waiting + Daily AI semiconductor news · Scheduled + +Running + Daily AI semiconductor news · Codex + +Completed + Today + ✓ Daily AI semiconductor news +``` + +Schedule 항목은 계속 Schedules에 남아 있고, 매 실행분은 일반 작업 이력에 별도 Job으로 기록된다. + +--- + +## 34. Summary architecture + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ GUI │ +│ Sidebar · Main panel · New Task · Schedules · Settings · Agents │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ local authenticated RPC + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Relay daemon │ +│ │ +│ Job Scheduler Schedule Engine Agent Registry │ +│ │ │ │ │ +│ └───────────────┬───────┴─────────────────────┘ │ +│ ▼ │ +│ RelayEngine │ +│ │ │ +│ validation · fallback · delivery · cleanup │ +└──────────────────────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SQLite │ +│ jobs · attempts · events · artifacts · schedules · schedule_runs │ +└──────────────────────┬──────────────────────────────────────────────┘ + │ + ┌────────────┼──────────────┬────────────────┐ + ▼ ▼ ▼ ▼ + Claude Codex Antigravity Custom Agent Apps +``` + +이 구조의 핵심은 GUI, CLI, Hermes, Schedule이 서로 다른 실행 체계를 갖지 않는다는 점이다. 모두 동일한 Relay queue와 job history를 사용하고, GUI는 이를 사람이 다루기 쉽게 보여주는 역할을 담당한다. diff --git a/docs/Relay_GUI_Development_Plan_v1.2.md b/docs/Relay_GUI_Development_Plan_v1.2.md new file mode 100644 index 0000000..20b3572 --- /dev/null +++ b/docs/Relay_GUI_Development_Plan_v1.2.md @@ -0,0 +1,3100 @@ +# Relay-agent GUI Development Plan v1.2 + +- **Repository:** `miter37/Relay-agent` +- **Document date:** 2026-07-23 +- **Target command:** `relay --gui` +- **Primary platforms:** Windows 11 first, Linux and macOS supported after platform validation +- **UI language:** Simple English +- **Document language:** Korean, with proposed UI labels and messages written in English +- **Status:** Detailed implementation plan +- **Supersedes:** `Relay_GUI_Development_Plan_v1.0.md` + +--- + +## 0. v1.2 revision summary + +v1.2는 v1.0의 기본 방향을 유지하면서 다음을 명확히 고쳤다. + +### 0.1 Product and UI changes + +1. 사이드바의 종료 작업 메뉴명을 `Completed`에서 **`Finished`**로 변경했다. + - 종료 목록에는 `Completed`, `Partial`, `Failed`, `Cancelled`가 모두 포함된다. + - 실패 작업까지 포함하는 메뉴를 `Completed`라고 부르는 것은 의미가 맞지 않는다. + - 한국어 요구사항의 “완료된 작업”은 UI에서 더 정확하고 쉬운 영어인 `Finished`로 표현한다. + +2. Schedule은 **성공한 기존 작업에서만 생성**한다. + - `Schedule this task` 버튼을 누른 뒤 반복 주기와 시각만 선택한다. + - 새 작업 등록 화면에서 Schedule을 동시에 만드는 흐름은 MVP에서 제외한다. + +3. Schedule 실행분도 일반 Job으로 생성되어 반드시 다음 흐름을 탄다. + +```text +Waiting → Running → Finished +``` + +4. CLI, Hermes, GUI, Schedule이 만든 작업을 모두 같은 화면에서 보여준다. + +5. Finished 검색은 서버 측 검색·필터·페이지네이션을 사용한다. + +### 0.2 Core architecture changes + +1. GUI는 SQLite를 직접 수정하지 않는다. +2. 새 API는 `/v1/...` 아래에 추가하고 기존 daemon endpoint는 유지한다. +3. DB migration은 `PRAGMA user_version`과 transaction을 사용한다. +4. Schedule due 처리에는 atomic claim과 unique occurrence key를 사용한다. +5. Schedule 시각은 rule의 timezone과 UTC `next_run_at`을 함께 저장한다. +6. `caller`와 `submitted_via`를 분리한다. + - `caller`: 보안 정책을 위한 실행 주체 + - `submitted_via`: CLI, GUI, Hermes, Schedule과 같은 표시 출처 +7. Schedule은 무인 실행이므로 기존 service isolation 보안 규칙을 적용한다. +8. Schedule이 원본 작업의 외부 output path, workspace, request ID를 그대로 상속하지 않도록 한다. + +### 0.3 Privacy and replay changes + +현재 Relay의 재실행 기능은 원본 요청을 다시 만들 수 있어야 한다. 따라서 단순히 `history_mode=metadata`라고 부르면서 실제 요청 내용을 저장하는 구조는 의미가 불명확하다. + +v1.2에서는 다음을 분리한다. + +- `history_display_mode`: GUI에서 작업 내용을 보여줄지 +- `store_replayable_requests`: 재실행·Schedule 생성을 위해 요청 snapshot을 저장할지 + +`store_replayable_requests=false`인 작업은: + +- 결과와 metadata는 볼 수 있음 +- `Run again` 비활성 +- `Schedule this task` 비활성 +- 원본 task text는 저장하지 않음 + +### 0.4 Development order changes + +Dynamic Agent Registry를 GUI 시작 전에 한 번에 전면 교체하지 않는다. + +1. 먼저 기존 built-in Agent를 감싸는 registry interface만 추가한다. +2. GUI와 Schedule을 안정화한다. +3. 마지막에 `GenericCLIAdapter`와 사용자 Agent App 등록을 활성화한다. + +이 순서는 기존 CLI 회귀 위험을 줄인다. + +--- + +## 1. Purpose + +Relay-agent의 기존 CLI 기능을 유지하면서, 사람이 다음 작업을 편리하게 수행할 수 있는 로컬 데스크톱 GUI를 추가한다. + +- 작업 등록 +- 대기·진행·종료 상태 확인 +- 결과·로그·산출물 확인 +- 기존 성공 작업을 반복 일정으로 등록 +- Schedule의 다음 실행 시각과 실행 기록 확인 +- Agent 상태와 model 확인 +- 신규 Agent App 등록 +- 설정과 cleanup 관리 + +GUI는 별도 실행 엔진이 아니다. 기존 Relay의 다음 요소를 공유하는 **visual control client**다. + +- `RelayEngine` +- local daemon +- SQLite job history +- worker adapters +- validation +- atomic delivery +- cleanup policy +- configuration +- capability audit / deep doctor + +--- + +## 2. Product definition + +`relay --gui`를 실행하면 Relay 데스크톱 앱이 열린다. + +앱의 중심 구조는 다음 두 영역이다. + +1. **Sidebar** + - Waiting + - Running + - Finished + - Schedules + - Settings + +2. **Main panel** + - 선택한 Job 상세 + - 선택한 Schedule 상세 + - New Task 화면 + - Settings 화면 + - Agent App 등록 wizard + +GUI를 닫아도 daemon이 살아 있다면 다음은 계속 동작한다. + +- 이미 실행 중인 작업 +- Waiting 작업 +- Schedule due check +- cleanup maintenance + +--- + +## 3. Scope and non-goals + +### 3.1 In scope + +- `relay --gui` +- shared CLI/GUI/Hermes/Schedule history +- job creation and monitoring +- result, logs, files, events +- Schedule creation from a successful Job +- Daily, Weekly, Monthly, Every N days, One time +- multiple times per day +- timezone-aware next-run calculation +- Schedule pause, resume, edit, run now, delete +- custom Agent App registration +- cross-platform desktop packaging +- Simple English UI + +### 3.2 Out of scope for GUI v1.0 + +- multi-user accounts +- remote team collaboration +- cloud synchronization +- browser-hosted public web UI +- mobile UI +- DAG or dependent task workflow +- automatic factual evaluation of AI output +- arbitrary shell script execution +- webhook-triggered jobs +- second-level scheduling +- more frequent than one minute Schedule execution +- automatically cancelling the previous run when a new Schedule occurrence arrives + +--- + +## 4. Fixed product principles + +### 4.1 One Relay home, one shared history + +다음 모든 실행 경로는 동일한 Relay home과 SQLite를 사용한다. + +```text +CLI +Hermes +GUI +Schedule engine +``` + +```text + ┌──────────────────┐ +CLI ────────────────────▶│ │ +Hermes ─────────────────▶│ Relay daemon │ +GUI ────────────────────▶│ + RelayEngine │ +Schedule engine ─────────▶│ │ + └────────┬─────────┘ + │ + ▼ + SQLite job history + │ + ▼ + GUI sidebar and details +``` + +GUI용 별도 Job DB를 만들지 않는다. + +### 4.2 CLI jobs must appear in the GUI + +CLI에서 다음과 같이 실행한 작업도 GUI에 나타난다. + +```text +relay "Research today's semiconductor news" +relay run --task-file task.md +relay submit --task-file task.md +``` + +같은 `RELAY_HOME`을 사용하는 한 다음 상태 변화가 GUI에 반영된다. + +```text +CLI creates job + ↓ +Waiting or Running + ↓ +Finished +``` + +### 4.3 GUI jobs must remain CLI-visible + +GUI에서 만든 Job은 일반 `jobs` row로 기록된다. + +다음 CLI 명령에서도 확인할 수 있어야 한다. + +```text +relay history +relay show +relay logs +relay result +relay cancel +``` + +### 4.4 Schedules create normal Jobs + +Schedule은 Agent를 직접 실행하지 않는다. + +```text +Schedule Engine + ↓ +normal JobRequest + ↓ +Relay queue + ↓ +RelayEngine + ↓ +validation and delivery +``` + +Schedule 실행분도 일반 Job과 동일하게: + +- 동시 실행 제한 +- Agent 검증 +- fallback +- timeout +- cancellation +- result validation +- atomic delivery +- logs +- artifacts +- cleanup + +을 적용받는다. + +### 4.5 Simple English + +메뉴, 버튼, 설명, 오류 메시지는 가능한 한 쉬운 영어를 사용한다. + +| Avoid | Use | +|---|---| +| Queue | Waiting | +| Terminal history | Finished | +| Cron Jobs | Schedules | +| Trigger | Run now | +| Recurrence | Repeat | +| Execute | Run | +| Terminate | Stop | +| Artifact directory | Files folder | +| Capability audit | Test agent | +| Invocation parameters | Command options | +| Submit request | Create task | + +--- + +## 5. Recommended GUI technology + +### 5.1 PySide6 + +GUI는 `PySide6` 기반으로 구현하는 것을 권장한다. + +이유: + +- Windows, Linux, macOS 지원 +- native file dialog +- split panel과 resizable sidebar +- tree/list grouping +- tabs와 modal dialog +- system tray와 notification 확장 가능 +- 대용량 로그 viewer 구현 가능 +- high-DPI 지원 +- Tkinter보다 복잡한 데스크톱 UI 구현에 적합 + +### 5.2 Optional dependency + +headless CLI 설치를 무겁게 만들지 않는다. + +```toml +[project.optional-dependencies] +gui = [ + "PySide6>=6.8,<7", + "tzdata>=2025.2" +] +``` + +GUI dependency가 없을 때: + +```text +GUI support is not installed. + +Run: +pip install "relay-ai-cli-broker[gui]" +``` + +### 5.3 Packaging direction + +개발 단계: + +```text +python package + optional GUI dependency +``` + +배포 단계: + +- Windows installer 또는 self-contained app +- macOS app bundle +- Linux package 또는 launcher +- headless installation은 계속 지원 + +PyInstaller, Nuitka 등 실제 packaging 방식은 Phase G6에서 작은 proof of concept 후 결정한다. + +--- + +## 6. Main information architecture + +```text +Relay-agent +│ +├─ + New Task +│ +├─ Waiting +│ └─ queued jobs +│ +├─ Running +│ └─ active jobs +│ +├─ Finished +│ ├─ Search +│ ├─ Filters +│ ├─ Today +│ ├─ Yesterday +│ └─ older dates +│ +├─ Schedules +│ ├─ Active +│ ├─ Paused +│ └─ Needs attention +│ +└─ Settings + ├─ General + ├─ Agents + ├─ Agent Apps + ├─ Paths + ├─ Task rules + ├─ Schedules + ├─ Cleanup + └─ Security +``` + +--- + +## 7. Main window layout + +```text +┌────────────────────────────────────────────────────────────────────────────────┐ +│ Relay-agent Daemon: Running [ + New Task ] │ +├───────────────────────────┬────────────────────────────────────────────────────┤ +│ │ │ +│ ▾ Waiting 3 │ │ +│ Market news research │ │ +│ Review report.pdf │ │ +│ Check project code │ │ +│ │ │ +│ ▾ Running 1 │ │ +│ ● Semiconductor news │ Main panel │ +│ Codex · 04:31 │ │ +│ │ Selected job, schedule, or settings page │ +│ ▾ Finished │ │ +│ [ Search jobs... ] │ │ +│ [ Result ▼ ] [ More ] │ │ +│ │ │ +│ ▾ Today 8 │ │ +│ ✓ TSMC analysis │ │ +│ ◐ Market research │ │ +│ × PDF extraction │ │ +│ ▸ Yesterday 12 │ │ +│ ▸ Jul 21 9 │ │ +│ │ │ +│ ▾ Schedules 4 │ │ +│ ● Daily market news │ │ +│ Next: Today 13:00 │ │ +│ ● Weekly stock review │ │ +│ Next: Fri 07:00 │ │ +│ ○ Monthly report │ │ +│ Paused │ │ +│ │ │ +│ ⚙ Settings │ │ +├───────────────────────────┴────────────────────────────────────────────────────┤ +│ Claude: Ready | Codex: Ready | Antigravity: Off | Running: 1 of 2 │ +│ Relay Home: C:\Users\name\AppData\Local\Relay │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.1 Layout rules + +- default sidebar width: 320px +- minimum: 260px +- maximum: 480px +- drag resize +- window size and sidebar width persisted +- recommended minimum window: 1280×720 +- native OS scaling +- one main scroll area per view +- sidebar and main panel scroll independently + +### 7.2 Refresh rules + +MVP: + +- active job list: poll every 1 second +- finished list count: poll every 3 seconds +- Schedule next-run status: poll every 15 seconds +- logs: tail every 1 second only while Logs tab is visible +- pause polling when app is minimized, except low-frequency health check + +추후 local event stream을 추가할 수 있지만 MVP에서는 polling을 사용한다. + +--- + +## 8. Sidebar behavior + +## 8.1 Waiting + +포함 상태: + +- `CREATED` +- `QUEUED` + +`PREPARING`부터는 Running으로 이동한다. + +표시: + +```text +Market news research +Codex first · Added 08:32 +``` + +Schedule 실행분: + +```text +Daily market news +Scheduled · Added 13:00 +``` + +선택 시 main panel: + +- Task +- Files +- Agent +- Model +- Profile +- Fallback +- Result type +- Time limit +- Output destination +- Created time +- Source +- Schedule name, if applicable + +지원 동작: + +```text +[ Edit ] [ Stop ] [ Copy job ID ] +``` + +`Edit`는 아직 `QUEUED`이고 실행 lease가 없는 작업에만 허용한다. + +### Waiting edit safety + +Job 수정은 단순 DB update가 아니다. + +서버는 다음을 atomically 확인한다. + +1. status is `QUEUED` +2. scheduler가 아직 claim하지 않음 +3. request hash와 paths를 다시 계산 +4. validation 재실행 +5. event 기록 + +MVP에서 안전하게 구현하기 어렵다면 `Edit`을 제외하고 다음만 지원해도 된다. + +```text +[ Stop ] [ Copy settings to a new task ] +``` + +v1.2 권장 기본은 **queued Job 직접 수정 제외**다. + +--- + +## 8.2 Running + +포함 상태: + +- `PREPARING` +- `RUNNING` +- `VALIDATING` +- `DELIVERING` +- `CANCEL_REQUESTED` + +표시: + +```text +● Semiconductor news +Codex · Running · 04:31 +``` + +percentage는 표시하지 않는다. + +```text +Prepare → Run → Check result → Save files → Done + ━━━ +``` + +| Internal | UI | +|---|---| +| PREPARING | Preparing | +| RUNNING | Running | +| VALIDATING | Checking result | +| DELIVERING | Saving files | +| CANCEL_REQUESTED | Stopping | + +Actions: + +```text +[ Stop task ] [ Show live log ] +``` + +--- + +## 8.3 Finished + +포함 상태: + +- `COMPLETED` +- `PARTIAL` +- `FAILED` +- `CANCELLED` + +상태 표시: + +| Status | Icon | UI label | +|---|---:|---| +| COMPLETED | ✓ | Completed | +| PARTIAL | ◐ | Partial | +| FAILED | × | Failed | +| CANCELLED | — | Cancelled | + +날짜 grouping은 `completed_at`을 기준으로 한다. + +```text +▾ Today · 8 + ✓ TSMC analysis 08:20 + ◐ Data center research 07:43 + × PDF chart extraction 06:15 + +▸ Yesterday · 12 +▸ Jul 21, 2026 · 9 +▸ Jul 20, 2026 · 4 +``` + +기본 규칙: + +- Today: open +- Yesterday and older: closed +- recent dates first +- newest item first inside each date +- GUI local timezone 기준 +- initial load: 50 terminal Jobs +- `Load more` or cursor pagination +- empty date groups hidden +- group open/closed state persisted for current app session + +--- + +## 8.4 Finished search and filters + +```text +Finished +[ Search finished jobs... ] +[ Result ▼ ] [ Agent ▼ ] [ Source ▼ ] [ Date ▼ ] +``` + +### Search fields + +- display title +- stored task preview +- job ID +- Agent ID and display name +- model +- profile +- error code +- Schedule name +- attachment filename, if snapshot metadata exists + +### Filters + +```text +Result +- All +- Completed +- Partial +- Failed +- Cancelled +``` + +```text +Agent +- All agents +- Claude +- Codex +- Antigravity +- custom agents +``` + +```text +Source +- All sources +- Command line +- GUI +- Hermes +- Schedule +``` + +```text +Date +- Any time +- Today +- Last 7 days +- Last 30 days +- Custom range +``` + +### Search implementation + +- server-side +- debounce: 300ms +- cursor pagination +- result limit enforced +- date grouping retained +- matching date groups only +- case-insensitive where SQLite permits +- Korean and English text supported +- wildcard input escaped +- raw SQL fragment input prohibited + +--- + +## 8.5 Schedules + +표시: + +```text +▾ Schedules · 4 + + ● Daily market news + Next: Today 13:00 + + ● Weekly stock review + Next: Fri 07:00 + + ○ Monthly report + Paused + + × Data collection + Needs attention +``` + +| Icon | Meaning | +|---:|---| +| ● | Active | +| ○ | Paused | +| × | Needs attention | + +`Last run failed`만으로 Schedule 자체를 error 상태로 만들지는 않는다. + +`Needs attention` 조건: + +- request snapshot missing +- attachment snapshot missing +- Agent removed +- Schedule rule invalid +- timezone invalid +- repeated queue creation failure +- service isolation not acknowledged +- auto-start unavailable and daemon stopped frequently, optional warning + +--- + +## 9. Main panel: Job details + +```text +Market news research Completed + +Codex · gpt-5.x · web-research +Created 08:31 · Started 08:32 · Finished 08:40 +Source: Command line + +[ Overview ] [ Task ] [ Progress ] [ Result ] [ Files ] [ Logs ] [ Events ] +``` + +기본 tab: + +| State | Default | +|---|---| +| Waiting | Task | +| Running | Progress | +| Completed | Result | +| Partial | Result | +| Failed | Logs | +| Cancelled | Overview | + +--- + +## 9.1 Overview + +```text +Status Completed +Requested agent Claude +Actual agent Codex +Model gpt-5.x +Profile web-research +Result type JSON +Fallback On +Created Jul 23, 2026 08:31 +Started Jul 23, 2026 08:32 +Finished Jul 23, 2026 08:40 +Result file C:\...\result.json +Files folder C:\...\artifacts +Source Command line +``` + +Fallback: + +```text +Claude + └─ Sign-in required + ↓ +Codex + └─ Completed +``` + +Actions: + +```text +[ Run again ] [ Copy settings ] [ Open result ] [ Open folder ] +``` + +완전 성공이며 replay snapshot이 존재할 때: + +```text +[ Schedule this task ] +``` + +### Action availability + +| Action | Condition | +|---|---| +| Run again | replay snapshot exists | +| Schedule this task | status COMPLETED + replay snapshot exists | +| Open result | file exists | +| Open folder | folder exists | +| Stop task | active state | +| Copy settings | replay snapshot or visible sanitized request exists | + +--- + +## 9.2 Task + +표시 가능한 경우: + +- display title +- original task text +- task file source, informational +- attachments +- requested Agent +- model +- profile +- fallback +- time limit +- caller +- request ID +- workspace policy +- output policy + +### Historical title generation + +1. explicit title +2. first non-empty task line +3. maximum 60 visible characters +4. otherwise `Job ` + +### History privacy display + +`history_display_mode=metadata`이면: + +```text +Task details are hidden by your history settings. +``` + +단, replay snapshot이 내부에 저장되어 있을 수 있다는 점은 Settings에서 정확히 설명해야 한다. + +--- + +## 9.3 Progress + +```text +Current step + +Prepare Done +Run In progress +Check result Waiting +Save files Waiting +Done Waiting +``` + +Attempts: + +```text +Attempt 1 +Agent Claude +Result Failed +Reason Sign-in required + +Attempt 2 +Agent Codex +Result Running +Elapsed 04:31 +``` + +--- + +## 9.4 Result + +JSON: + +```text +Answer +──────────────────────────────────────── +The main findings are... + +Sources · 8 +Uncertainties · 2 +Missing items · 0 + +[ Show sources ] [ Show raw JSON ] [ Open result ] +``` + +TXT: + +- readable text view +- word wrap +- copy all +- open external + +Notice: + +```text +Relay checked the file format and delivery. +It did not check whether the answer is factually correct. +``` + +--- + +## 9.5 Files + +```text +Name Type Size Actions +market_report.html HTML 420 KB Open · Show in folder +chart.png Image 860 KB Preview · Open +source_data.csv CSV 72 KB Open · Show in folder +``` + +Internal preview: + +- PNG/JPEG/WebP +- TXT/JSON/CSV/MD with strict size limit + +External open: + +- HTML +- PDF +- spreadsheet +- unknown types + +Path display is read-only and selectable. + +--- + +## 9.6 Logs + +```text +[ Attempt 1: Claude ▼ ] [ stdout ] [ stderr ] [ Errors only ] + +08:32:01 ... +08:32:03 ... +``` + +Rules: + +- tail only +- initial tail: last 8,000–20,000 characters +- incremental read by file offset +- no full-file reload +- auto-scroll toggle +- search current loaded portion +- copy selection +- open full log file +- log path access validation before opening + +--- + +## 9.7 Events + +```text +08:31:58 Job created +08:32:00 Preparing +08:32:02 Claude started +08:32:14 Claude failed: Sign-in required +08:32:15 Codex started +08:39:52 Checking result +08:40:01 Saving files +08:40:03 Completed +``` + +internal event code는 유지하고 UI formatter에서 쉬운 영어로 변환한다. + +--- + +## 10. New Task screen + +```text +New Task + +[ Task ] [ Agent ] [ Run options ] [ Output ] [ Advanced ] +``` + +Schedule은 이 화면에서 직접 만들지 않는다. + +--- + +## 10.1 Task + +```text +Task name +[ Research today's AI semiconductor news ] + +What should the agent do? +┌────────────────────────────────────────────────────────────┐ +│ Research the last 24 hours of AI semiconductor news... │ +│ │ +└────────────────────────────────────────────────────────────┘ + +Task input +(●) Write here +( ) Use a task file + +Files +[ + Add files ] report.pdf data.xlsx + +Profile +[ web-research ▼ ] +``` + +Rules: + +- task name optional +- blank name auto-generated +- drag-and-drop attachments +- duplicate filename warning +- missing attachment blocked before create +- task file is read and materialized before Job creation + +--- + +## 10.2 Agent + +```text +Agent +[ Codex ▼ ] + +Model +[ Default model ▼ ] [ Refresh ] + +Use another agent if this fails +[✓] + +Try in this order +1. Claude +2. Antigravity +[ Change order ] +``` + +작업별 fallback: + +```python +fallback_agents: list[str] | None +``` + +- `None`: global order +- explicit list: Job-specific order + +field 이름은 기존 `worker` 호환성을 위해 내부 migration 동안 `worker`를 유지할 수 있다. UI에서는 `Agent`를 사용한다. + +--- + +## 10.3 Run options + +```text +Run in the background +[✓] + +Time limit +[ 1200 ] seconds + +Workspace +[ Use the default workspace ▼ ] + +Create a new job even if the same task exists +[ ] + +Replace an existing result file +[ ] +``` + +GUI에서는 daemon background submit만 실제 실행 경로로 사용한다. + +`Run in the background`는 항상 on으로 두고 설명만 제공하거나, UI에서 제거해도 된다. + +GUI process가 synchronous `RelayEngine.run()`을 직접 호출하지 않는다. + +--- + +## 10.4 Output + +```text +Result type +[ JSON ▼ ] + +Result file +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] + +Files folder +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] +``` + +자동 path 예시: + +```text +C:\Users\name\AppData\Local\Relay\results\2026-07-23\\result.json +``` + +--- + +## 10.5 Advanced + +```text +Request ID +[ Create automatically __________________ ] + +Caller +[ Human ▼ ] + +Model name +[ _______________________________________ ] + +[ ] Force a new job +[ ] Replace existing output +``` + +`submitted_via`는 사용자가 고르는 값이 아니다. + +GUI server가 자동으로: + +```text +submitted_via = gui +``` + +를 설정한다. + +`caller`는 security principal이고 기본값은 `human`이다. + +--- + +## 10.6 Bottom action bar + +```text +[ Show CLI command ] [ Save as template ] [ Clear ] [ Create task ] +``` + +CLI preview는 실제 서버 payload와 가능한 한 동일하게 만든다. + +Windows: + +```powershell +relay submit ` + --task-file "C:\...\request.md" ` + --worker codex ` + --model "gpt-5.x" ` + --format json ` + --timeout 1200 ` + --attach "C:\...\report.pdf" +``` + +Linux/macOS: + +```sh +relay submit \ + --task-file "/home/.../request.md" \ + --worker codex \ + --model "gpt-5.x" \ + --format json \ + --timeout 1200 \ + --attach "/home/.../report.pdf" +``` + +--- + +## 11. Schedule creation + +## 11.1 Eligibility + +`Schedule this task` 조건: + +1. Job status is `COMPLETED` +2. internal result status is `complete` +3. replay snapshot exists +4. task text can be materialized +5. all attachments can be copied +6. Agent definition still exists +7. service isolation is acknowledged +8. source Job is not already being deleted + +조건이 충족되지 않으면 이유를 알려준다. + +```text +This task cannot be scheduled. + +The original request was not saved. +Run the task again with “Save task settings” turned on. +``` + +--- + +## 11.2 Dialog + +```text +┌───────────────────────────────────────────────────────────────┐ +│ Schedule this task │ +├───────────────────────────────────────────────────────────────┤ +│ Task │ +│ Daily market news │ +│ Codex · Default model · web-research │ +│ │ +│ Schedule name │ +│ [ Daily market news_______________________________________ ] │ +│ │ +│ Repeat │ +│ [ Daily ▼ ] │ +│ │ +│ Time │ +│ [ 09:00 ] [ × ] │ +│ [ 13:00 ] [ × ] │ +│ [ + Add another time ] │ +│ │ +│ Time zone │ +│ [ Asia/Seoul ▼ ] │ +│ │ +│ Next runs │ +│ • Jul 24, 2026 09:00 │ +│ • Jul 24, 2026 13:00 │ +│ • Jul 25, 2026 09:00 │ +│ • Jul 25, 2026 13:00 │ +│ • Jul 26, 2026 09:00 │ +│ │ +│ [ More options ] [ Cancel ] [ Create schedule ] │ +└───────────────────────────────────────────────────────────────┘ +``` + +Popup은 schedule rule 선택에 집중한다. + +원본 Job에서 안전하게 상속: + +- task +- title +- Agent +- model +- profile +- result format +- fallback settings +- timeout +- safe Agent options + +강제로 변경: + +```text +request_id → clear +force_new → true +output_path → automatic +artifact_path → automatic +workspace → default managed workspace +task_file → materialized task snapshot +caller → schedule +submitted_via → schedule +``` + +외부 output path와 외부 workspace는 Schedule에 상속하지 않는다. + +--- + +## 12. Supported Schedule types + +## 12.1 Daily + +```text +Repeat +Daily + +Times +09:00 +13:00 +18:30 +``` + +```json +{ + "type": "daily", + "times": ["09:00", "13:00", "18:30"], + "timezone": "Asia/Seoul" +} +``` + +--- + +## 12.2 Weekly + +```text +Repeat +Weekly + +Days +[✓ Mon] [ ] Tue [✓ Wed] [ ] Thu [✓ Fri] [ ] Sat [ ] Sun + +Times +07:00 +18:00 +``` + +```json +{ + "type": "weekly", + "weekdays": [1, 3, 5], + "times": ["07:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +ISO Monday=1. + +--- + +## 12.3 Monthly + +```text +Repeat +Monthly + +Dates +[ 1 ] [ 15 ] [ 28 ] [ + Add date ] + +Times +09:00 +``` + +```json +{ + "type": "monthly", + "month_days": [1, 15, 28], + "times": ["09:00"], + "missing_day_policy": "skip", + "timezone": "Asia/Seoul" +} +``` + +기본: + +```text +Skip months that do not have this date. +``` + +Optional: + +```text +Use the last day of the month instead. +``` + +--- + +## 12.4 Every N days + +```text +Repeat +Every [ 3 ] days + +Start date +[ Jul 23, 2026 ] + +Times +09:00 +18:00 +``` + +```json +{ + "type": "n_days", + "interval_days": 3, + "anchor_date": "2026-07-23", + "times": ["09:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +--- + +## 12.5 One time + +```text +Repeat +One time + +Date +[ Aug 3, 2026 ] + +Time +[ 10:30 ] +``` + +```json +{ + "type": "once", + "run_at_local": "2026-08-03T10:30:00", + "timezone": "Asia/Seoul" +} +``` + +실행 occurrence를 성공적으로 queue한 뒤 Schedule을 inactive로 전환한다. + +--- + +## 13. Schedule advanced options + +## 13.1 Previous run is active + +```text +If the previous run is still active + +(●) Skip this run +( ) Add this run to Waiting +``` + +기본값: + +```text +Skip this run +``` + +active 판단에는 같은 Schedule의 다음 상태를 포함한다. + +- QUEUED +- PREPARING +- RUNNING +- VALIDATING +- DELIVERING +- CANCEL_REQUESTED + +--- + +## 13.2 Relay was not running + +```text +If Relay was not running + +(●) Skip missed runs +( ) Run once when Relay starts +``` + +`Run once when Relay starts`는 backlog 전체를 실행하지 않는다. + +- 가장 최근 missed occurrence 하나만 생성 +- grace period 내 occurrence만 허용 +- default grace period: 12 hours +- manual `Run now`와 구분하여 `trigger_type=catch_up` + +--- + +## 13.3 Start and end + +```text +Start +[ Now ▼ ] + +End +(●) No end date +( ) End on [ date ] +``` + +--- + +## 13.4 Next-run preview + +저장 전 다음 5회를 표시한다. + +```text +Next runs +• Jul 24, 2026 09:00 +• Jul 24, 2026 13:00 +• Jul 25, 2026 09:00 +• Jul 25, 2026 13:00 +• Jul 26, 2026 09:00 +``` + +preview 계산과 daemon 실행 계산은 같은 함수와 같은 rule parser를 사용한다. + +--- + +## 14. Timezone and clock rules + +### 14.1 Storage + +저장: + +- canonical local rule +- IANA timezone +- `next_run_at_utc` +- `last_occurrence_key` + +표시: + +- Schedule timezone 기준 +- GUI local time을 보조로 표시 가능 + +```text +Next run +Jul 24, 2026 09:00 Asia/Seoul +``` + +### 14.2 DST nonexistent time + +예: DST 시작으로 02:30이 존재하지 않을 때. + +기본 정책: + +```text +Skip this occurrence. +``` + +### 14.3 DST ambiguous time + +같은 local time이 두 번 나타나는 경우. + +기본 정책: + +```text +Run once at the first occurrence. +``` + +occurrence key에는 UTC instant를 포함해 중복 실행을 방지한다. + +### 14.4 Clock changes + +- wall-clock time은 timezone-aware datetime으로 계산 +- scheduler loop interval은 monotonic waiting 사용 +- 시스템 시간이 크게 바뀌면 next run 재계산 +- daemon start 시 모든 active Schedule의 `next_run_at_utc` 검증 + +--- + +## 15. Schedule detail screen + +```text +Daily market news Active + +Daily at 09:00 and 13:00 +Time zone: Asia/Seoul + +Next run +Jul 24, 2026 09:00 + +Last run +Jul 23, 2026 13:00 · Completed + +[ Overview ] [ Task settings ] [ Run history ] +``` + +### 15.1 Overview + +```text +Repeat Daily +Times 09:00, 13:00 +Time zone Asia/Seoul +Next run Jul 24, 2026 09:00 +Last run Jul 23, 2026 13:00 +Previous run active Skip +Missed runs Skip +Created from Job abc123 +``` + +Actions: + +```text +[ Run now ] [ Pause ] [ Edit schedule ] [ Copy ] [ Delete ] +``` + +### 15.2 Task settings + +```text +Task name Daily market news +Agent Codex +Model Default model +Profile web-research +Fallback Claude +Time limit 20 minutes +Result type JSON +Input snapshot Ready +``` + +### 15.3 Run history + +```text +Planned Started Result +Jul 23, 13:00 Jul 23, 13:00 Completed +Jul 23, 09:00 Jul 23, 09:00 Completed +Jul 22, 13:00 Jul 22, 13:01 Failed +Jul 22, 09:00 — Skipped +``` + +row 클릭 시 일반 Job detail로 이동한다. + +--- + +## 16. Schedule runtime lifecycle + +```mermaid +flowchart TD + A[Schedule reaches next_run_at] --> B{Enabled and valid?} + B -- No --> Z[Do nothing or mark needs attention] + B -- Yes --> C[Atomically claim occurrence] + C --> D{Claim succeeded?} + D -- No --> Z2[Another loop already claimed it] + D -- Yes --> E{Previous run active?} + E -- No --> G[Build safe JobRequest] + E -- Yes --> F{Overlap policy} + F -- Skip --> S[Record skipped occurrence] + F -- Add to Waiting --> G + G --> H[Create normal queued Job] + H --> I[Waiting] + I --> J[Running] + J --> K[Checking result] + K --> L[Saving files] + L --> M[Completed / Partial / Failed / Cancelled] + M --> N[Update Schedule run link and next_run] +``` + +### 16.1 Atomic claim + +단순히 `next_run_at <= now`를 읽고 Job을 생성하면 scheduler thread 중복, daemon restart, 두 daemon instance에서 중복 실행될 수 있다. + +필수 조건: + +- `occurrence_key` unique +- transaction 안에서 planned row insert +- insert 성공한 process만 Job 생성 +- Job 생성 실패 시 schedule run state와 retryable error 기록 +- Job 생성 후 `job_id` 연결 + +--- + +## 17. Attachment and request snapshot + +Schedule은 원본 path를 장기간 참조하지 않는다. + +```text +source Job replay snapshot + ↓ +materialize request text + ↓ +copy attachments + ↓ +/schedule-inputs// +``` + +```text +schedule-inputs/ +└─ sch_abc123/ + ├─ request.md + ├─ attachments.json + ├─ report.pdf + └─ data.xlsx +``` + +### 17.1 Creation checks + +Schedule 생성 전: + +- task snapshot exists +- attachment source exists +- total size within configured limit +- destination under schedule input root +- no symlink escape +- filenames normalized +- copies hashed +- manifest written atomically + +### 17.2 Deletion + +```text +Delete this schedule? + +Past job results will not be deleted. +The saved task files for this schedule will be removed later. +``` + +- past Jobs remain +- Schedule definition soft-deleted or removed +- input snapshot marked for cleanup +- active occurrence가 있으면 Schedule 삭제는 future runs만 막음 +- 이미 생성된 normal Job은 자동 취소하지 않음 + +--- + +## 18. Replayable request and privacy model + +### 18.1 Problem + +`Run again`과 `Schedule this task`는 원본 task와 options가 필요하다. + +따라서 task를 저장하지 않으면서 완전한 재실행을 제공할 수는 없다. + +### 18.2 Settings + +```text +Save task settings for Run again and Schedules +[✓] + +Show task text in history +[✓] +``` + +internal: + +```toml +store_replayable_requests = true +history_display_mode = "full" +``` + +### 18.3 Behavior matrix + +| Save replay request | Show text | Run again | Schedule | Task tab | +|---|---|---|---|---| +| true | full | Yes | Yes after success | Full | +| true | metadata | Yes | Yes after success | Hidden | +| false | full | No after process ends | No | only transient | +| false | metadata | No | No | metadata only | + +### 18.4 Sensitive data notice + +Settings에 쉬운 영어로 표시한다. + +```text +Relay can save the task text and file paths so you can run the task again. + +Turn this off if task requests may contain sensitive information. +``` + +민감정보 저장은 local-only라고 하더라도 명확히 설명해야 한다. + +--- + +## 19. Source and security identity + +`caller`와 `submitted_via`를 혼용하지 않는다. + +### 19.1 caller + +보안 정책: + +- `human` +- `hermes` +- `service` +- `schedule` + +### 19.2 submitted_via + +UI 표시와 통계: + +- `cli` +- `gui` +- `hermes` +- `schedule` +- `legacy` + +### 19.3 Server assignment + +- GUI endpoint: server sets `submitted_via=gui` +- CLI: CLI sets `submitted_via=cli` +- Hermes caller: server normalizes to `hermes` +- Schedule engine: server sets `caller=schedule`, `submitted_via=schedule` +- clients cannot forge `schedule_id` link through public Job creation API + +Schedule은 무인 실행이므로 `service_isolation_acknowledged=true`가 필요하다. + +--- + +## 20. GUI and daemon communication + +```text +GUI + │ + │ token-authenticated loopback RPC + ▼ +Relay daemon + │ + ├─ Job API + ├─ Schedule API + ├─ Agent API + ├─ Config API + └─ Cleanup API + │ + ▼ +SQLite + RelayEngine +``` + +GUI는 SQLite를 직접 수정하지 않는다. + +read-only direct DB access도 MVP에서는 사용하지 않는다. 모든 데이터는 daemon schema를 통해 받는다. + +--- + +## 21. API versioning + +기존 endpoint 호환성을 유지한다. + +현재 endpoint 예: + +```text +/submit +/status/ +/result/ +/show/ +/cancel/ +``` + +새 GUI API: + +```text +/v1/jobs +/v1/schedules +/v1/agents +/v1/config +/v1/cleanup +``` + +### 21.1 Jobs + +```http +GET /v1/jobs +GET /v1/jobs/{job_id} +POST /v1/jobs +POST /v1/jobs/{job_id}/cancel +POST /v1/jobs/{job_id}/rerun +GET /v1/jobs/{job_id}/logs +GET /v1/jobs/{job_id}/events +GET /v1/jobs/{job_id}/artifacts +``` + +queued Job edit은 MVP에서 제외한다. + +### 21.2 Schedules + +```http +GET /v1/schedules +POST /v1/schedules/from-job/{job_id} +GET /v1/schedules/{schedule_id} +PATCH /v1/schedules/{schedule_id} +DELETE /v1/schedules/{schedule_id} + +POST /v1/schedules/{schedule_id}/run-now +POST /v1/schedules/{schedule_id}/pause +POST /v1/schedules/{schedule_id}/resume + +GET /v1/schedules/{schedule_id}/runs +POST /v1/schedules/preview +``` + +### 21.3 Agents + +```http +GET /v1/agents +GET /v1/agents/{agent_id} +POST /v1/agents +PATCH /v1/agents/{agent_id} +DELETE /v1/agents/{agent_id} + +POST /v1/agents/{agent_id}/test +GET /v1/agents/{agent_id}/models +POST /v1/agents/{agent_id}/models/refresh +``` + +### 21.4 Config + +```http +GET /v1/config +PATCH /v1/config +GET /v1/cleanup/status +POST /v1/cleanup +GET /health +POST /shutdown +``` + +### 21.5 Error response + +```json +{ + "ok": false, + "error_code": "SCHEDULE_INPUT_MISSING", + "message": "A saved input file is missing.", + "action": "Open the schedule and add the file again.", + "details": {} +} +``` + +UI에는 `message`와 `action`을 보여주고 code는 Technical details에 둔다. + +--- + +## 22. Database migration + +### 22.1 Migration framework + +현재 단순 `CREATE TABLE IF NOT EXISTS`를 유지하되 versioned migration을 추가한다. + +```text +PRAGMA user_version +``` + +규칙: + +- migration transaction +- migration 전 DB backup +- migration failure 시 startup 중단 +- 기존 DB를 삭제하거나 새 DB로 silently 교체하지 않음 +- migration unit test +- downgrade는 지원하지 않되 backup 복구 안내 + +### 22.2 Jobs additions + +```sql +ALTER TABLE jobs ADD COLUMN title TEXT; +ALTER TABLE jobs ADD COLUMN submitted_via TEXT; +ALTER TABLE jobs ADD COLUMN schedule_id TEXT; +ALTER TABLE jobs ADD COLUMN scheduled_for TEXT; +ALTER TABLE jobs ADD COLUMN task_preview TEXT; +ALTER TABLE jobs ADD COLUMN replayable INTEGER NOT NULL DEFAULT 1; +``` + +indexes: + +```sql +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at +ON jobs(completed_at); + +CREATE INDEX IF NOT EXISTS idx_jobs_submitted_via +ON jobs(submitted_via); + +CREATE INDEX IF NOT EXISTS idx_jobs_schedule +ON jobs(schedule_id, created_at); +``` + +### 22.3 Schedules + +```sql +CREATE TABLE IF NOT EXISTS schedules ( + schedule_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + needs_attention INTEGER NOT NULL DEFAULT 0, + + schedule_type TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL, + + source_job_id TEXT, + request_template_json TEXT NOT NULL, + input_snapshot_path TEXT, + + overlap_policy TEXT NOT NULL DEFAULT 'skip', + missed_run_policy TEXT NOT NULL DEFAULT 'skip', + catch_up_grace_minutes INTEGER NOT NULL DEFAULT 720, + + next_run_at_utc TEXT, + last_occurrence_key TEXT, + + last_scheduled_at TEXT, + last_started_at TEXT, + last_completed_at TEXT, + last_job_id TEXT, + last_status TEXT, + last_error_code TEXT, + last_error_message TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +### 22.4 Schedule runs + +```sql +CREATE TABLE IF NOT EXISTS schedule_runs ( + schedule_run_id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id TEXT NOT NULL + REFERENCES schedules(schedule_id) ON DELETE CASCADE, + + occurrence_key TEXT NOT NULL, + scheduled_for_utc TEXT NOT NULL, + scheduled_for_local TEXT NOT NULL, + trigger_type TEXT NOT NULL, + + claimed_at TEXT, + triggered_at TEXT, + job_id TEXT, + state TEXT NOT NULL, + reason TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_occurrence_unique +ON schedule_runs(schedule_id, occurrence_key); + +CREATE INDEX IF NOT EXISTS idx_schedule_runs_schedule +ON schedule_runs(schedule_id, scheduled_for_utc DESC); +``` + +`trigger_type`: + +- `scheduled` +- `manual` +- `catch_up` + +`state`: + +- `claimed` +- `queued` +- `running` +- `completed` +- `partial` +- `failed` +- `cancelled` +- `skipped` +- `missed` + +--- + +## 23. Schedule calculation engine + +canonical JSON이 source of truth다. raw cron string은 사용하지 않는다. + +Core: + +```python +def validate_rule(rule: dict) -> None: ... + +def next_occurrence( + rule: dict, + after_utc: datetime, +) -> ScheduledOccurrence | None: ... + +def next_occurrences( + rule: dict, + after_utc: datetime, + count: int, +) -> list[ScheduledOccurrence]: ... + +def occurrence_key( + schedule_id: str, + scheduled_for_utc: datetime, +) -> str: ... +``` + +`ScheduledOccurrence`: + +```python +@dataclass(frozen=True) +class ScheduledOccurrence: + local_datetime: datetime + utc_datetime: datetime + occurrence_key: str +``` + +Schedule preview API와 daemon loop는 같은 code path를 사용한다. + +--- + +## 24. Daemon scheduling loop + +```text +Job queue loop every 0.5 seconds +Schedule due check every 15 seconds +Maintenance loop hourly +``` + +Due process: + +1. read due active schedules +2. calculate due occurrence +3. atomically insert unique schedule_run +4. evaluate overlap +5. build safe JobRequest +6. queue normal Job +7. link Job ID +8. calculate and persist next occurrence + +### 24.1 Single daemon protection + +daemon pid file만 신뢰하지 않는다. + +- port bind remains primary single-instance guard +- token-authenticated health check +- stale pid cleanup +- unique schedule occurrence remains final duplicate protection + +--- + +## 25. Auto-start + +Schedule의 신뢰성을 위해 daemon auto-start가 필요하다. + +첫 Schedule 생성 시: + +```text +Relay must keep running to start scheduled tasks. + +Start Relay automatically when you sign in? + +[ Not now ] [ Turn on auto-start ] +``` + +Platforms: + +- Windows: Task Scheduler, current user logon +- macOS: LaunchAgent +- Linux: systemd user service +- fallback: manual daemon start + +Settings: + +```text +Start Relay when I sign in +[✓] +``` + +daemon stop: + +```text +Stopping Relay will also stop scheduled tasks. + +[ Cancel ] [ Stop Relay ] +``` + +### Platform support status + +- Windows: v1.0 release requirement +- Linux: release requirement after target-machine validation +- macOS: release requirement after target-machine validation + +“implemented”와 “field validated”를 문서와 UI에서 구분한다. + +--- + +## 26. Agent App registration + +Settings: + +```text +Settings +├─ General +├─ Agents +├─ Agent Apps +├─ Paths +├─ Task rules +├─ Schedules +├─ Cleanup +└─ Security +``` + +Agent Apps: + +```text +Agent Apps + +Built in +Claude Code Ready +Codex CLI Ready +Antigravity Off + +Added by you +OpenCode Ready +Gemini CLI Needs a test + +[ + Add agent app ] +``` + +--- + +## 26.1 Registration wizard + +### Step 1 — Basic details + +```text +Agent name +[ OpenCode____________________________ ] + +Agent ID +[ opencode____________________________ ] + +Command +[ /usr/local/bin/opencode____________ ] [ Browse ] + +Description +[ ___________________________________ ] +``` + +### Step 2 — Command options + +```text +How should Relay send the task? + +(●) Use a request file +( ) Send through standard input +( ) Add the task to the command +``` + +argv list: + +```text +run +--input +{request_file} +--output +{result_file} +--workspace +{workspace} +``` + +placeholders: + +```text +{request_file} +{result_file} +{artifact_dir} +{workspace} +{schema_file} +{model} +{profile} +{job_id} +``` + +금지: + +- `shell=True` +- pipe +- redirect +- command substitution +- chained shell operators +- arbitrary startup scripts + +### Step 3 — Result + +```text +Where does this agent save its answer? + +(●) Result file +( ) Standard output + +Supported result types +[✓] JSON +[✓] Text + +Can this agent create files? +[✓] +``` + +### Step 4 — Models + +```text +Default model +[ ______________________________ ] + +Can Relay list models? +( ) No +(●) Run a command + +Model list options +[ models ] [ --json ] + +Model option +[ --model ] [ {model} ] +``` + +### Step 5 — Safety + +```text +Needs network access +[✓] + +Can write inside the task workspace +[✓] + +May skip permission checks +[ ] + +Environment variable names +[ API_KEY_NAME__________________ ] +``` + +secret value는 저장하지 않는다. + +### Step 6 — Test + +```text +Find command Passed +Read version Passed +Run without questions Passed +Create result file Passed +Check JSON or text Passed +Create files Passed + +[ Run test again ] [ Save agent ] +``` + +deep test를 통과하기 전에는 disabled. + +--- + +## 27. Agent Registry refactor strategy + +전면 교체를 Phase G0에서 바로 하지 않는다. + +### Stage A: compatibility registry + +```text +AgentRegistry +└─ built-in registrations + ├─ ClaudeAdapter + ├─ CodexAdapter + └─ AntigravityAdapter +``` + +기존 behavior를 그대로 감싼다. + +### Stage B: dynamic manifests + +```text +AgentRegistry +├─ built-in adapters +└─ custom manifests + └─ GenericCLIAdapter +``` + +API: + +```python +registry.list_agents() +registry.get_definition(agent_id) +registry.get_adapter(agent_id) +registry.list_enabled_agents() +``` + +CLI 고정 choices 제거는 custom Agent phase에서 수행한다. + +```text +relay run --worker opencode +relay doctor --worker opencode --deep +relay models --worker opencode +``` + +GUI와 CLI는 같은 registry를 사용한다. + +--- + +## 28. CLI parity for Schedules + +GUI 기능을 테스트하고 자동화할 수 있도록 최소 Schedule CLI를 추가한다. + +```text +relay schedule list +relay schedule show +relay schedule create --from-job --rule-file schedule.json +relay schedule pause +relay schedule resume +relay schedule run-now +relay schedule delete +``` + +일반 사용자는 GUI를 사용하지만 core 기능이 GUI에만 묶이지 않도록 한다. + +machine output 지원: + +```text +--machine +``` + +--- + +## 29. Proposed module structure + +```text +relay/ +├─ cli.py +├─ daemon.py +├─ engine.py +├─ db.py +├─ config.py +├─ migrations.py +│ +├─ agents/ +│ ├─ registry.py +│ ├─ definitions.py +│ └─ manifest.py +│ +├─ schedules/ +│ ├─ models.py +│ ├─ rules.py +│ ├─ calculator.py +│ ├─ service.py +│ ├─ scheduler.py +│ └─ snapshot.py +│ +├─ adapters/ +│ ├─ base.py +│ ├─ claude.py +│ ├─ codex.py +│ ├─ antigravity.py +│ └─ generic_cli.py +│ +├─ rpc/ +│ ├─ router.py +│ ├─ responses.py +│ ├─ jobs.py +│ ├─ schedules.py +│ ├─ agents.py +│ └─ config.py +│ +└─ gui/ + ├─ app.py + ├─ main_window.py + ├─ state.py + ├─ rpc_client.py + ├─ copy.py + ├─ theme.py + │ + ├─ views/ + │ ├─ empty.py + │ ├─ new_task.py + │ ├─ job_detail.py + │ ├─ schedule_detail.py + │ └─ settings.py + │ + ├─ widgets/ + │ ├─ sidebar.py + │ ├─ job_item.py + │ ├─ date_group.py + │ ├─ search_filters.py + │ ├─ status_steps.py + │ ├─ log_viewer.py + │ └─ file_list.py + │ + └─ dialogs/ + ├─ schedule_create.py + ├─ schedule_edit.py + ├─ agent_app_wizard.py + ├─ confirmation.py + └─ error.py +``` + +--- + +## 30. `relay --gui` behavior + +### Parser behavior + +현재 argv preprocess와 충돌하지 않도록 top-level `--gui`를 먼저 감지한다. + +```text +relay --gui + ↓ +Resolve Relay Home + ↓ +Check GUI dependency + ↓ +Ensure daemon is running + ↓ +Start GUI process +``` + +`relay gui` alias는 optional이다. + +### Separation + +- GUI process does not execute Jobs +- daemon process executes Jobs +- GUI close does not stop daemon +- daemon stop requires explicit action + +### Single GUI instance + +MVP: + +- lock per Relay home +- second launch shows existing-instance notice +- if practical, bring existing window to front +- stale lock recovery + +--- + +## 31. Easy English copy guide + +### Main + +```text +New Task +Waiting +Running +Finished +Schedules +Settings +``` + +### Job actions + +```text +Create task +Stop task +Run again +Copy settings +Schedule this task +Open result +Open folder +Copy job ID +``` + +### Schedule actions + +```text +Create schedule +Run now +Pause +Resume +Edit schedule +Copy schedule +Delete schedule +Next run +Last run +Run history +``` + +### Agent actions + +```text +Add agent app +Test agent +Refresh models +Turn on +Turn off +Save agent +Remove agent +``` + +### Help examples + +```text +Use another agent if this fails. + +Relay will try the next agent only for a technical failure. + +This task came from the command line. + +This task was started by a schedule. + +Past job results will not be deleted. + +Relay checked the file format, not the factual accuracy. +``` + +### Error pattern + +1. What happened +2. Why +3. What to do + +```text +Codex could not start. + +The Codex command was not found. + +Open Settings > Agent Apps and check the command path. +``` + +--- + +## 32. Security rules + +1. GUI path picker does not bypass Relay path validation. +2. Schedule is treated as unattended service execution. +3. Schedule output and workspace are Relay-managed. +4. custom Agent uses executable + argv, never shell string. +5. custom Agent cannot enable dangerous flags without explicit security warning. +6. deep test required after executable version changes. +7. daemon token remains user-only. +8. Schedule input snapshot permissions are user-only. +9. API does not allow arbitrary `schedule_id` injection. +10. logs and file-open actions validate stored paths. + +--- + +## 33. Cleanup and retention + +Schedule-created Jobs use normal retention. + +Schedule definitions: + +- persist until deleted +- paused Schedule is not cleaned automatically + +Schedule input snapshots: + +- active: never remove +- deleted: mark for cleanup +- default cleanup delay: 7 days +- hash manifest retained until deletion + +Past Job results are independent of Schedule deletion. + +Settings: + +```text +Cleanup + +Job files +Schedule input files +Agent test files + +[ Show what will be removed ] [ Clean now ] +``` + +--- + +## 34. Development phases + +## Phase G0 — Contract and migration foundation + +### Goal + +기존 CLI를 깨뜨리지 않고 GUI용 read model과 migration 기반을 준비한다. + +### Work + +- DB backup and versioned migration +- `/v1/jobs` read API +- search, filter, cursor pagination +- `title`, `task_preview`, `submitted_via` +- `caller` and source separation +- replay/privacy settings design +- compatibility AgentRegistry interface +- old endpoint regression tests +- API schema and error response standard + +### Stop gate + +- all existing CLI tests pass +- existing DB upgrades without data loss +- CLI sync and submit Jobs appear in `/v1/jobs` +- rollback from backup documented + +--- + +## Phase G1 — Read-only GUI shell + +### Goal + +CLI/Hermes history를 GUI에서 안정적으로 보여준다. + +### Work + +- PySide6 app shell +- `relay --gui` +- daemon auto-connect/start +- sidebar +- Waiting, Running, Finished +- date grouping +- Finished search and filters +- main panel Overview +- current Relay Home +- app state persistence + +### Stop gate + +- CLI Job appears within 2 seconds +- state movement is correct +- no GUI DB writes +- 10,000 historical rows do not freeze UI +- long titles and paths render safely + +--- + +## Phase G2 — Job control and New Task + +### Goal + +GUI에서 일반 Job을 만들고 전체 결과를 관리한다. + +### Work + +- New Task +- attachments +- Agent/model/profile +- fallback +- timeout +- result/output +- create/cancel/rerun +- Task/Progress/Result/Files/Logs/Events +- live log tail +- open files/folders +- replay/privacy action gating + +### Stop gate + +- GUI-created Job appears in CLI history +- CLI-equivalent parameters are preserved +- cancellation works +- large logs do not freeze +- replay-disabled Jobs cannot be scheduled + +--- + +## Phase G3 — Schedule core + +### Goal + +GUI 없이도 검증 가능한 Schedule engine을 만든다. + +### Work + +- schedules and schedule_runs migrations +- rule validation +- Daily/Weekly/Monthly/N-days/Once +- timezone and DST policy +- next-run preview +- atomic occurrence claim +- overlap and missed-run policy +- safe request cloning +- input snapshot +- Schedule CLI +- daemon schedule loop + +### Stop gate + +- deterministic next-run tests pass +- daemon restart creates no duplicate occurrence +- Schedule Job enters normal queue +- scheduled unattended security enforced +- missing inputs produce needs-attention, not silent failure + +--- + +## Phase G4 — Schedule GUI and auto-start + +### Goal + +성공 Job에서 Schedule을 만들고 관리한다. + +### Work + +- `Schedule this task` +- schedule dialog +- multiple times +- next five preview +- sidebar Schedules +- Schedule detail +- Run now +- Pause/Resume/Edit/Copy/Delete +- run history +- auto-start prompts and settings + +### Stop gate + +- all supported rules can be created +- next run matches engine calculation +- scheduled run appears Waiting → Running → Finished +- Schedule deletion does not delete past Jobs +- Windows auto-start field validation complete + +--- + +## Phase G5 — Custom Agent Apps + +### Goal + +사용자 CLI Agent를 안전하게 등록한다. + +### Work + +- dynamic manifest schema +- GenericCLIAdapter +- Agent App wizard +- argv placeholder validation +- result normalization +- model list support +- deep test +- version-change invalidation +- CLI dynamic Agent ID +- GUI and Schedule use custom Agent + +### Stop gate + +- built-in Agent regression passes +- custom Agent works from CLI and GUI +- invalid shell syntax cannot be injected +- untested Agent cannot be enabled +- version change requires retest + +--- + +## Phase G6 — Packaging and platform operations + +### Goal + +일반 사용자가 설치하고 지속적으로 사용할 수 있는 상태로 만든다. + +### Work + +- installers +- desktop shortcut +- icon +- single GUI instance +- auto-start implementations +- optional tray +- optional notifications +- crash log +- keyboard navigation +- high-DPI +- Windows/Linux/macOS test matrix + +### Stop gate + +- `relay --gui` works on all target platforms +- headless CLI remains available +- upgrade preserves Relay home +- platform-specific auto-start is field validated + +--- + +## 35. Test plan + +### 35.1 Migration + +- empty DB +- current DB +- partially migrated DB rejected safely +- backup generated +- migration rollback documentation +- indexes created +- legacy rows visible + +### 35.2 Shared history + +- CLI sync +- CLI submit +- Hermes +- GUI +- Schedule +- source labels +- separate Relay Home isolation + +### 35.3 State movement + +```text +QUEUED → Waiting +PREPARING → Running +RUNNING → Running +VALIDATING → Running +DELIVERING → Running +COMPLETED → Finished / Completed +PARTIAL → Finished / Partial +FAILED → Finished / Failed +CANCELLED → Finished / Cancelled +``` + +### 35.4 Search + +- title +- preview +- job ID +- Agent +- model +- error code +- Schedule +- Korean +- English +- date range +- combined filters +- pagination +- escaped wildcards + +### 35.5 Schedule calculation + +- Daily one time +- Daily multiple times +- Weekly multiple days +- Weekly multiple times +- Monthly 1, 15, 31 +- missing month day +- leap year +- Every 2/3/10 days +- One time +- timezone conversion +- DST nonexistent +- DST ambiguous +- next five preview +- pause/resume +- end date + +### 35.6 Schedule reliability + +- duplicate scheduler loops +- daemon restart +- process crash after claim +- queue creation failure +- missed run +- catch-up +- previous run active skip +- previous run active queue +- unique output path +- request ID cleared +- attachment snapshot +- missing snapshot +- deleted Agent +- service isolation disabled + +### 35.7 GUI + +- slow daemon +- daemon unavailable +- daemon restarts while GUI open +- 10,000+ history rows +- 100MB log file +- long result +- missing result file +- missing artifact +- file-open failure +- 125%, 150%, 200% scaling +- keyboard navigation + +### 35.8 Agent Apps + +- valid command +- missing command +- changed version +- interactive prompt +- missing output +- invalid JSON +- artifact escape +- model list failure +- custom Agent in Schedule + +--- + +## 36. Acceptance criteria + +### Compatibility + +- [ ] Existing CLI commands work without behavior regression. +- [ ] Existing daemon endpoints remain compatible. +- [ ] Existing DB migrates without data loss. +- [ ] CLI, Hermes, GUI, and Schedule Jobs share one history. + +### GUI + +- [ ] `relay --gui` launches. +- [ ] Sidebar contains Waiting, Running, Finished, Schedules, Settings. +- [ ] Finished is grouped by date. +- [ ] Finished search and filters work. +- [ ] Job details show status, task, result, files, logs, and events. +- [ ] UI copy uses Simple English. + +### New Task + +- [ ] Main supported JobRequest options are available. +- [ ] Attachments work. +- [ ] GUI Jobs are visible from CLI. +- [ ] Output paths are validated. +- [ ] Replay/privacy settings control actions correctly. + +### Schedules + +- [ ] Created only from a successful replayable Job. +- [ ] Daily supports multiple times. +- [ ] Weekly supports multiple weekdays and times. +- [ ] Monthly supports multiple dates and times. +- [ ] Every N days and One time work. +- [ ] Next five runs are shown. +- [ ] Schedule Jobs move Waiting → Running → Finished. +- [ ] Schedule detail shows next run and run history. +- [ ] duplicate occurrences are prevented. +- [ ] timezone and missed-run policies are deterministic. + +### Agent Apps + +- [ ] Settings can register a custom Agent App. +- [ ] executable and argv are stored separately. +- [ ] shell execution is not used. +- [ ] deep test is required. +- [ ] custom Agent works from CLI, GUI, and Schedule. + +--- + +## 37. Final implementation order + +```text +1. Freeze API and data contracts +2. Add versioned DB migrations +3. Build Job list/search/filter API +4. Add source and replay/privacy model +5. Add compatibility AgentRegistry +6. Build read-only GUI shell +7. Add Job details +8. Add New Task and Job actions +9. Build Schedule rule calculator +10. Build Schedule persistence and atomic claim +11. Add Schedule CLI and daemon loop +12. Add input snapshot and unattended security +13. Build Schedule GUI +14. Implement OS auto-start +15. Add custom Agent manifest and GenericCLIAdapter +16. Build Agent App wizard +17. Package and validate each platform +``` + +--- + +## 38. Final user flows + +### 38.1 CLI Job + +```text +relay "Research AI semiconductor news" --worker codex + ↓ +GUI Waiting + ↓ +GUI Running + ↓ +GUI Finished + Today + ✓ Research AI semiconductor news +``` + +### 38.2 Create Schedule + +```text +Finished + Today + ✓ Research AI semiconductor news + ↓ +Schedule this task + ↓ +Daily +09:00 and 13:00 +Asia/Seoul + ↓ +Create schedule + ↓ +Schedules + Daily AI semiconductor news + Next: Tomorrow 09:00 +``` + +### 38.3 Scheduled run + +```text +09:00 occurrence claimed + ↓ +Waiting + Daily AI semiconductor news · Scheduled + ↓ +Running + Daily AI semiconductor news · Codex + ↓ +Finished + Today + ✓ Daily AI semiconductor news +``` + +Schedule definition은 Schedules에 계속 남고 각 실행은 독립 Job으로 저장된다. + +--- + +## 39. Final architecture + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ GUI │ +│ New Task · Sidebar · Job Detail · Schedule Detail · Settings · Agents │ +└─────────────────────────────────┬────────────────────────────────────────┘ + │ token-authenticated loopback RPC + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ Relay daemon │ +│ │ +│ Job Queue Schedule Engine Agent Registry API v1 │ +│ │ │ │ │ │ +│ └────────────────────┴──────────────┬──────┴──────────────────┘ │ +│ ▼ │ +│ RelayEngine │ +│ │ │ +│ validation · fallback · delivery · cleanup │ +└───────────────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ SQLite │ +│ jobs · attempts · events · artifacts · schedules · schedule_runs │ +└───────────────────────────────────────┬──────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + Built-in Agents Custom Agent Apps Schedule snapshots +``` + +--- + +## 40. Final recommendation + +개발은 GUI 화면부터 빠르게 만들기보다 다음 순서를 지켜야 한다. + +```text +Shared read API + ↓ +Read-only GUI + ↓ +Job control + ↓ +Schedule core + ↓ +Schedule GUI + ↓ +Custom Agent Apps +``` + +가장 중요한 원칙은 다음과 같다. + +1. GUI는 별도 실행 엔진이 아니다. +2. CLI 작업도 GUI에 반드시 보인다. +3. Schedule 실행은 항상 일반 Job이다. +4. Schedule은 성공한 replayable Job에서 만든다. +5. 반복 실행은 external path와 request ID를 그대로 상속하지 않는다. +6. Schedule occurrence는 DB unique claim으로 중복을 막는다. +7. custom Agent 확장은 기존 built-in Agent 안정화 이후에 한다. +8. UI는 쉬운 영어를 사용하되 내부의 보안·검증 규칙을 약화하지 않는다. diff --git a/docs/Relay_GUI_Development_Plan_v1.3.md b/docs/Relay_GUI_Development_Plan_v1.3.md new file mode 100644 index 0000000..1afa363 --- /dev/null +++ b/docs/Relay_GUI_Development_Plan_v1.3.md @@ -0,0 +1,3682 @@ +# Relay-agent GUI Development Plan v1.3 + +- **Repository:** `miter37/Relay-agent` +- **Document date:** 2026-07-23 +- **Target command:** `relay --gui` +- **Primary platforms:** Windows 11 first, Linux and macOS supported after platform validation +- **UI language:** Simple English +- **Document language:** Korean, with proposed UI labels and messages written in English +- **Status:** Detailed implementation plan +- **Supersedes:** `Relay_GUI_Development_Plan_v1.2.md` + +--- + +## 0. v1.3 revision summary + +v1.3은 v1.2의 범위 관리 원칙을 유지하면서, 실제 업데이트·장기 Schedule 운영에서 남아 있던 세 가지 핵심 공백과 세 가지 배포 조건을 보완한다. + +### 0.1 GUI and daemon compatibility contract + +GUI와 daemon은 독립 process이며 Relay update 과정에서 서로 다른 version이 잠시 공존할 수 있다. + +v1.3은 `/health` 응답에 다음 정보를 포함한다. + +```json +{ + "ok": true, + "daemon_version": "0.9.0", + "api_versions": ["v1"], + "api_schema_revision": 3, + "min_gui_version": "0.8.0" +} +``` + +GUI는 최초 연결, 재연결, health polling 때마다 호환성을 확인한다. + +호환되지 않으면 GUI가 종료되거나 parsing error를 내는 대신 **read-only compatibility mode**로 전환한다. + +```text +Relay was updated. + +Please restart the app to use the new version. +``` + +### 0.2 Migration baseline fixtures + +“기존 DB가 안전하게 upgrade된다”는 조건을 검증 가능하게 만들기 위해 실제 Relay 0.5.0 코드로 만든 SQLite fixture를 저장소에 커밋한다. + +```text +tests/fixtures/relay-0.5.0-empty.db +tests/fixtures/relay-0.5.0-populated.db +tests/test_migrations.py +``` + +migration test는 실행 성공뿐 아니라 기존 row 수, 주요 값, 관계, artifact/event ownership이 보존되는지 확인한다. + +### 0.3 Schedule output storage and retention + +Schedule 결과는 일반 Job workspace cleanup과 분리된 전용 output 영역에 저장한다. + +```text +/schedule-outputs//_/ +``` + +Schedule별 사용자 지정 root를 허용할 수 있지만 Relay가 항상 unique timestamp/run 하위 폴더를 붙인다. 따라서 반복 실행이 같은 파일을 덮어쓰지 않는다. + +Schedule output은 일반 Job retention이 아니라 별도의 정책을 사용한다. + +- keep for N days +- keep latest N runs +- keep all outputs + +### 0.4 Packaging license constraint + +PySide6는 LGPL 조건을 고려해야 한다. + +정적 또는 compiled bundling이 Qt library 교체·재연결 가능성을 막지 않는지 G6 packaging proof of concept의 필수 판단 기준으로 둔다. + +### 0.5 Three-OS CI prerequisite + +GUI 개발을 시작하기 전에 GitHub Actions에 Windows, Ubuntu, macOS CI matrix를 둔다. + +GUI packaging은 후반 release에서 검증하지만 기존 CLI, daemon, migrations, Schedule calculation의 platform regression은 첫 release부터 매 commit 확인한다. + +### 0.6 Release-based delivery + +G0~G6을 하나의 긴 개발 구간이 아니라 실제 배포 release로 운영한다. + +```text +0.6.0 = G0 +0.7.0 = G1 +0.8.0 = G2 +0.9.0 = G3 +1.0.0 = G4 +1.1.0 = G5 +1.2.0 = G6 +``` + +각 release는 implementation, stop gate, tests, changelog, version bump, tagged release를 완료해야 종료된다. + +--- + +## 1. Purpose + +Relay-agent의 기존 CLI 기능을 유지하면서, 사람이 다음 작업을 편리하게 수행할 수 있는 로컬 데스크톱 GUI를 추가한다. + +- 작업 등록 +- 대기·진행·종료 상태 확인 +- 결과·로그·산출물 확인 +- 기존 성공 작업을 반복 일정으로 등록 +- Schedule의 다음 실행 시각과 실행 기록 확인 +- Agent 상태와 model 확인 +- 신규 Agent App 등록 +- 설정과 cleanup 관리 + +GUI는 별도 실행 엔진이 아니다. 기존 Relay의 다음 요소를 공유하는 **visual control client**다. + +- `RelayEngine` +- local daemon +- SQLite job history +- worker adapters +- validation +- atomic delivery +- cleanup policy +- configuration +- capability audit / deep doctor + +--- + +## 2. Product definition + +`relay --gui`를 실행하면 Relay 데스크톱 앱이 열린다. + +앱의 중심 구조는 다음 두 영역이다. + +1. **Sidebar** + - Waiting + - Running + - Finished + - Schedules + - Settings + +2. **Main panel** + - 선택한 Job 상세 + - 선택한 Schedule 상세 + - New Task 화면 + - Settings 화면 + - Agent App 등록 wizard + +GUI를 닫아도 daemon이 살아 있다면 다음은 계속 동작한다. + +- 이미 실행 중인 작업 +- Waiting 작업 +- Schedule due check +- cleanup maintenance + +--- + +## 3. Scope and non-goals + +### 3.1 In scope + +- `relay --gui` +- shared CLI/GUI/Hermes/Schedule history +- job creation and monitoring +- result, logs, files, events +- Schedule creation from a successful Job +- Daily, Weekly, Monthly, Every N days, One time +- multiple times per day +- timezone-aware next-run calculation +- Schedule pause, resume, edit, run now, delete +- custom Agent App registration +- cross-platform desktop packaging +- Simple English UI + +### 3.2 Out of scope for GUI v1.0 + +- multi-user accounts +- remote team collaboration +- cloud synchronization +- browser-hosted public web UI +- mobile UI +- DAG or dependent task workflow +- automatic factual evaluation of AI output +- arbitrary shell script execution +- webhook-triggered jobs +- second-level scheduling +- more frequent than one minute Schedule execution +- automatically cancelling the previous run when a new Schedule occurrence arrives + + +### 3.3 Development prerequisites + +GUI implementation을 시작하기 전에 다음 기반이 있어야 한다. + +1. **Three-OS CI** + - `windows-latest` + - `ubuntu-latest` + - `macos-latest` + - Python 3.11과 현재 지원 대상 Python version + +2. **Current behavior baseline** + - 현재 CLI command regression test + - daemon endpoint behavior test + - built-in Agent adapter unit test + - current config and DB schema inventory + +3. **Relay 0.5.0 migration fixtures** + - actual 0.5.0 empty DB + - actual 0.5.0 populated DB + - fixture generation procedure documented + +4. **Repository audit blockers** + - 현재 repository audit에서 확인된 correctness, security, platform issue 중 GUI와 shared daemon에 영향을 주는 항목은 G0 시작 전 issue 또는 checklist로 고정한다. + +CI가 없는 상태에서 “all existing CLI tests pass”를 stop gate로 사용하지 않는다. stop gate는 재현 가능한 CI 결과로 검증해야 한다. + +--- + +## 4. Fixed product principles + +### 4.1 One Relay home, one shared history + +다음 모든 실행 경로는 동일한 Relay home과 SQLite를 사용한다. + +```text +CLI +Hermes +GUI +Schedule engine +``` + +```text + ┌──────────────────┐ +CLI ────────────────────▶│ │ +Hermes ─────────────────▶│ Relay daemon │ +GUI ────────────────────▶│ + RelayEngine │ +Schedule engine ─────────▶│ │ + └────────┬─────────┘ + │ + ▼ + SQLite job history + │ + ▼ + GUI sidebar and details +``` + +GUI용 별도 Job DB를 만들지 않는다. + +### 4.2 CLI jobs must appear in the GUI + +CLI에서 다음과 같이 실행한 작업도 GUI에 나타난다. + +```text +relay "Research today's semiconductor news" +relay run --task-file task.md +relay submit --task-file task.md +``` + +같은 `RELAY_HOME`을 사용하는 한 다음 상태 변화가 GUI에 반영된다. + +```text +CLI creates job + ↓ +Waiting or Running + ↓ +Finished +``` + +### 4.3 GUI jobs must remain CLI-visible + +GUI에서 만든 Job은 일반 `jobs` row로 기록된다. + +다음 CLI 명령에서도 확인할 수 있어야 한다. + +```text +relay history +relay show +relay logs +relay result +relay cancel +``` + +### 4.4 Schedules create normal Jobs + +Schedule은 Agent를 직접 실행하지 않는다. + +```text +Schedule Engine + ↓ +normal JobRequest + ↓ +Relay queue + ↓ +RelayEngine + ↓ +validation and delivery +``` + +Schedule 실행분도 일반 Job과 동일하게: + +- 동시 실행 제한 +- Agent 검증 +- fallback +- timeout +- cancellation +- result validation +- atomic delivery +- logs +- artifacts +- cleanup + +을 적용받는다. + +### 4.5 Simple English + +메뉴, 버튼, 설명, 오류 메시지는 가능한 한 쉬운 영어를 사용한다. + +| Avoid | Use | +|---|---| +| Queue | Waiting | +| Terminal history | Finished | +| Cron Jobs | Schedules | +| Trigger | Run now | +| Recurrence | Repeat | +| Execute | Run | +| Terminate | Stop | +| Artifact directory | Files folder | +| Capability audit | Test agent | +| Invocation parameters | Command options | +| Submit request | Create task | + +--- + +## 5. Recommended GUI technology + +### 5.1 PySide6 + +GUI는 `PySide6` 기반으로 구현하는 것을 권장한다. + +이유: + +- Windows, Linux, macOS 지원 +- native file dialog +- split panel과 resizable sidebar +- tree/list grouping +- tabs와 modal dialog +- system tray와 notification 확장 가능 +- 대용량 로그 viewer 구현 가능 +- high-DPI 지원 +- Tkinter보다 복잡한 데스크톱 UI 구현에 적합 + +### 5.2 Optional dependency + +headless CLI 설치를 무겁게 만들지 않는다. + +```toml +[project.optional-dependencies] +gui = [ + "PySide6>=6.8,<7", + "tzdata>=2025.2" +] +``` + +GUI dependency가 없을 때: + +```text +GUI support is not installed. + +Run: +pip install "relay-ai-cli-broker[gui]" +``` + +### 5.3 Packaging direction + +개발 단계: + +```text +python package + optional GUI dependency +``` + +배포 단계: + +- Windows installer 또는 self-contained app +- macOS app bundle +- Linux package 또는 launcher +- headless installation은 계속 지원 + +PyInstaller, Nuitka 등 실제 packaging 방식은 Release 1.2.0 / G6에서 작은 proof of concept 후 결정한다. + +### 5.4 PySide6 license and bundling constraint + +PySide6 사용 시 LGPL 조건을 packaging 판단 기준에 포함한다. + +```text +PySide6 is available under LGPLv3/GPLv3 or a commercial license. + +Packaging must preserve the user's applicable LGPL rights, including +the ability to replace or relink Qt libraries where required. +``` + +특히 static 또는 compiled bundling은 Qt library 교체·재연결 가능성, license notice, source offer 또는 해당 의무를 법률·배포 관점에서 검토한다. + +G6 proof of concept는 단순 실행 성공만 비교하지 않고 다음을 함께 평가한다. + +- dynamic library separation +- Qt library replacement feasibility +- license notice inclusion +- installer size and startup time +- antivirus false-positive rate +- Windows, macOS, Linux signing/notarization compatibility + +이 문서는 법률 자문을 대신하지 않는다. + +--- + +## 6. Main information architecture + +```text +Relay-agent +│ +├─ + New Task +│ +├─ Waiting +│ └─ queued jobs +│ +├─ Running +│ └─ active jobs +│ +├─ Finished +│ ├─ Search +│ ├─ Filters +│ ├─ Today +│ ├─ Yesterday +│ └─ older dates +│ +├─ Schedules +│ ├─ Active +│ ├─ Paused +│ └─ Needs attention +│ +└─ Settings + ├─ General + ├─ Agents + ├─ Agent Apps + ├─ Paths + ├─ Task rules + ├─ Schedules + ├─ Cleanup + └─ Security +``` + +--- + +## 7. Main window layout + +```text +┌────────────────────────────────────────────────────────────────────────────────┐ +│ Relay-agent Daemon: Running [ + New Task ] │ +├───────────────────────────┬────────────────────────────────────────────────────┤ +│ │ │ +│ ▾ Waiting 3 │ │ +│ Market news research │ │ +│ Review report.pdf │ │ +│ Check project code │ │ +│ │ │ +│ ▾ Running 1 │ │ +│ ● Semiconductor news │ Main panel │ +│ Codex · 04:31 │ │ +│ │ Selected job, schedule, or settings page │ +│ ▾ Finished │ │ +│ [ Search jobs... ] │ │ +│ [ Result ▼ ] [ More ] │ │ +│ │ │ +│ ▾ Today 8 │ │ +│ ✓ TSMC analysis │ │ +│ ◐ Market research │ │ +│ × PDF extraction │ │ +│ ▸ Yesterday 12 │ │ +│ ▸ Jul 21 9 │ │ +│ │ │ +│ ▾ Schedules 4 │ │ +│ ● Daily market news │ │ +│ Next: Today 13:00 │ │ +│ ● Weekly stock review │ │ +│ Next: Fri 07:00 │ │ +│ ○ Monthly report │ │ +│ Paused │ │ +│ │ │ +│ ⚙ Settings │ │ +├───────────────────────────┴────────────────────────────────────────────────────┤ +│ Claude: Ready | Codex: Ready | Antigravity: Off | Running: 1 of 2 │ +│ Relay Home: C:\Users\name\AppData\Local\Relay │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.1 Layout rules + +- default sidebar width: 320px +- minimum: 260px +- maximum: 480px +- drag resize +- window size and sidebar width persisted +- recommended minimum window: 1280×720 +- native OS scaling +- one main scroll area per view +- sidebar and main panel scroll independently + +### 7.2 Refresh rules + +MVP: + +- active job list: poll every 1 second +- finished list count: poll every 3 seconds +- Schedule next-run status: poll every 15 seconds +- logs: tail every 1 second only while Logs tab is visible +- pause polling when app is minimized, except low-frequency health check + +추후 local event stream을 추가할 수 있지만 MVP에서는 polling을 사용한다. + +--- + +## 8. Sidebar behavior + +## 8.1 Waiting + +포함 상태: + +- `CREATED` +- `QUEUED` + +`PREPARING`부터는 Running으로 이동한다. + +표시: + +```text +Market news research +Codex first · Added 08:32 +``` + +Schedule 실행분: + +```text +Daily market news +Scheduled · Added 13:00 +``` + +선택 시 main panel: + +- Task +- Files +- Agent +- Model +- Profile +- Fallback +- Result type +- Time limit +- Output destination +- Created time +- Source +- Schedule name, if applicable + +지원 동작: + +```text +[ Edit ] [ Stop ] [ Copy job ID ] +``` + +`Edit`는 아직 `QUEUED`이고 실행 lease가 없는 작업에만 허용한다. + +### Waiting edit safety + +Job 수정은 단순 DB update가 아니다. + +서버는 다음을 atomically 확인한다. + +1. status is `QUEUED` +2. scheduler가 아직 claim하지 않음 +3. request hash와 paths를 다시 계산 +4. validation 재실행 +5. event 기록 + +MVP에서 안전하게 구현하기 어렵다면 `Edit`을 제외하고 다음만 지원해도 된다. + +```text +[ Stop ] [ Copy settings to a new task ] +``` + +v1.2 권장 기본은 **queued Job 직접 수정 제외**다. + +--- + +## 8.2 Running + +포함 상태: + +- `PREPARING` +- `RUNNING` +- `VALIDATING` +- `DELIVERING` +- `CANCEL_REQUESTED` + +표시: + +```text +● Semiconductor news +Codex · Running · 04:31 +``` + +percentage는 표시하지 않는다. + +```text +Prepare → Run → Check result → Save files → Done + ━━━ +``` + +| Internal | UI | +|---|---| +| PREPARING | Preparing | +| RUNNING | Running | +| VALIDATING | Checking result | +| DELIVERING | Saving files | +| CANCEL_REQUESTED | Stopping | + +Actions: + +```text +[ Stop task ] [ Show live log ] +``` + +--- + +## 8.3 Finished + +포함 상태: + +- `COMPLETED` +- `PARTIAL` +- `FAILED` +- `CANCELLED` + +상태 표시: + +| Status | Icon | UI label | +|---|---:|---| +| COMPLETED | ✓ | Completed | +| PARTIAL | ◐ | Partial | +| FAILED | × | Failed | +| CANCELLED | — | Cancelled | + +날짜 grouping은 `completed_at`을 기준으로 한다. + +```text +▾ Today · 8 + ✓ TSMC analysis 08:20 + ◐ Data center research 07:43 + × PDF chart extraction 06:15 + +▸ Yesterday · 12 +▸ Jul 21, 2026 · 9 +▸ Jul 20, 2026 · 4 +``` + +기본 규칙: + +- Today: open +- Yesterday and older: closed +- recent dates first +- newest item first inside each date +- GUI local timezone 기준 +- initial load: 50 terminal Jobs +- `Load more` or cursor pagination +- empty date groups hidden +- group open/closed state persisted for current app session + +--- + +## 8.4 Finished search and filters + +```text +Finished +[ Search finished jobs... ] +[ Result ▼ ] [ Agent ▼ ] [ Source ▼ ] [ Date ▼ ] +``` + +### Search fields + +- display title +- stored task preview +- job ID +- Agent ID and display name +- model +- profile +- error code +- Schedule name +- attachment filename, if snapshot metadata exists + +### Filters + +```text +Result +- All +- Completed +- Partial +- Failed +- Cancelled +``` + +```text +Agent +- All agents +- Claude +- Codex +- Antigravity +- custom agents +``` + +```text +Source +- All sources +- Command line +- GUI +- Hermes +- Schedule +``` + +```text +Date +- Any time +- Today +- Last 7 days +- Last 30 days +- Custom range +``` + +### Search implementation + +- server-side +- debounce: 300ms +- cursor pagination +- result limit enforced +- date grouping retained +- matching date groups only +- case-insensitive where SQLite permits +- Korean and English text supported +- wildcard input escaped +- raw SQL fragment input prohibited + +--- + +## 8.5 Schedules + +표시: + +```text +▾ Schedules · 4 + + ● Daily market news + Next: Today 13:00 + + ● Weekly stock review + Next: Fri 07:00 + + ○ Monthly report + Paused + + × Data collection + Needs attention +``` + +| Icon | Meaning | +|---:|---| +| ● | Active | +| ○ | Paused | +| × | Needs attention | + +`Last run failed`만으로 Schedule 자체를 error 상태로 만들지는 않는다. + +`Needs attention` 조건: + +- request snapshot missing +- attachment snapshot missing +- Agent removed +- Schedule rule invalid +- timezone invalid +- repeated queue creation failure +- service isolation not acknowledged +- auto-start unavailable and daemon stopped frequently, optional warning + +--- + +## 9. Main panel: Job details + +```text +Market news research Completed + +Codex · gpt-5.x · web-research +Created 08:31 · Started 08:32 · Finished 08:40 +Source: Command line + +[ Overview ] [ Task ] [ Progress ] [ Result ] [ Files ] [ Logs ] [ Events ] +``` + +기본 tab: + +| State | Default | +|---|---| +| Waiting | Task | +| Running | Progress | +| Completed | Result | +| Partial | Result | +| Failed | Logs | +| Cancelled | Overview | + +--- + +## 9.1 Overview + +```text +Status Completed +Requested agent Claude +Actual agent Codex +Model gpt-5.x +Profile web-research +Result type JSON +Fallback On +Created Jul 23, 2026 08:31 +Started Jul 23, 2026 08:32 +Finished Jul 23, 2026 08:40 +Result file C:\...\result.json +Files folder C:\...\artifacts +Source Command line +``` + +Fallback: + +```text +Claude + └─ Sign-in required + ↓ +Codex + └─ Completed +``` + +Actions: + +```text +[ Run again ] [ Copy settings ] [ Open result ] [ Open folder ] +``` + +완전 성공이며 replay snapshot이 존재할 때: + +```text +[ Schedule this task ] +``` + +### Action availability + +| Action | Condition | +|---|---| +| Run again | replay snapshot exists | +| Schedule this task | status COMPLETED + replay snapshot exists | +| Open result | file exists | +| Open folder | folder exists | +| Stop task | active state | +| Copy settings | replay snapshot or visible sanitized request exists | + +--- + +## 9.2 Task + +표시 가능한 경우: + +- display title +- original task text +- task file source, informational +- attachments +- requested Agent +- model +- profile +- fallback +- time limit +- caller +- request ID +- workspace policy +- output policy + +### Historical title generation + +1. explicit title +2. first non-empty task line +3. maximum 60 visible characters +4. otherwise `Job ` + +### History privacy display + +`history_display_mode=metadata`이면: + +```text +Task details are hidden by your history settings. +``` + +단, replay snapshot이 내부에 저장되어 있을 수 있다는 점은 Settings에서 정확히 설명해야 한다. + +--- + +## 9.3 Progress + +```text +Current step + +Prepare Done +Run In progress +Check result Waiting +Save files Waiting +Done Waiting +``` + +Attempts: + +```text +Attempt 1 +Agent Claude +Result Failed +Reason Sign-in required + +Attempt 2 +Agent Codex +Result Running +Elapsed 04:31 +``` + +--- + +## 9.4 Result + +JSON: + +```text +Answer +──────────────────────────────────────── +The main findings are... + +Sources · 8 +Uncertainties · 2 +Missing items · 0 + +[ Show sources ] [ Show raw JSON ] [ Open result ] +``` + +TXT: + +- readable text view +- word wrap +- copy all +- open external + +Notice: + +```text +Relay checked the file format and delivery. +It did not check whether the answer is factually correct. +``` + +--- + +## 9.5 Files + +```text +Name Type Size Actions +market_report.html HTML 420 KB Open · Show in folder +chart.png Image 860 KB Preview · Open +source_data.csv CSV 72 KB Open · Show in folder +``` + +Internal preview: + +- PNG/JPEG/WebP +- TXT/JSON/CSV/MD with strict size limit + +External open: + +- HTML +- PDF +- spreadsheet +- unknown types + +Path display is read-only and selectable. + +--- + +## 9.6 Logs + +```text +[ Attempt 1: Claude ▼ ] [ stdout ] [ stderr ] [ Errors only ] + +08:32:01 ... +08:32:03 ... +``` + +Rules: + +- tail only +- initial tail: last 8,000–20,000 characters +- incremental read by file offset +- no full-file reload +- auto-scroll toggle +- search current loaded portion +- copy selection +- open full log file +- log path access validation before opening + +--- + +## 9.7 Events + +```text +08:31:58 Job created +08:32:00 Preparing +08:32:02 Claude started +08:32:14 Claude failed: Sign-in required +08:32:15 Codex started +08:39:52 Checking result +08:40:01 Saving files +08:40:03 Completed +``` + +internal event code는 유지하고 UI formatter에서 쉬운 영어로 변환한다. + +--- + +## 10. New Task screen + +```text +New Task + +[ Task ] [ Agent ] [ Run options ] [ Output ] [ Advanced ] +``` + +Schedule은 이 화면에서 직접 만들지 않는다. + +--- + +## 10.1 Task + +```text +Task name +[ Research today's AI semiconductor news ] + +What should the agent do? +┌────────────────────────────────────────────────────────────┐ +│ Research the last 24 hours of AI semiconductor news... │ +│ │ +└────────────────────────────────────────────────────────────┘ + +Task input +(●) Write here +( ) Use a task file + +Files +[ + Add files ] report.pdf data.xlsx + +Profile +[ web-research ▼ ] +``` + +Rules: + +- task name optional +- blank name auto-generated +- drag-and-drop attachments +- duplicate filename warning +- missing attachment blocked before create +- task file is read and materialized before Job creation + +--- + +## 10.2 Agent + +```text +Agent +[ Codex ▼ ] + +Model +[ Default model ▼ ] [ Refresh ] + +Use another agent if this fails +[✓] + +Try in this order +1. Claude +2. Antigravity +[ Change order ] +``` + +작업별 fallback: + +```python +fallback_agents: list[str] | None +``` + +- `None`: global order +- explicit list: Job-specific order + +field 이름은 기존 `worker` 호환성을 위해 내부 migration 동안 `worker`를 유지할 수 있다. UI에서는 `Agent`를 사용한다. + +--- + +## 10.3 Run options + +```text +Run in the background +[✓] + +Time limit +[ 1200 ] seconds + +Workspace +[ Use the default workspace ▼ ] + +Create a new job even if the same task exists +[ ] + +Replace an existing result file +[ ] +``` + +GUI에서는 daemon background submit만 실제 실행 경로로 사용한다. + +`Run in the background`는 항상 on으로 두고 설명만 제공하거나, UI에서 제거해도 된다. + +GUI process가 synchronous `RelayEngine.run()`을 직접 호출하지 않는다. + +--- + +## 10.4 Output + +```text +Result type +[ JSON ▼ ] + +Result file +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] + +Files folder +(●) Choose automatically +( ) Use this path [____________________] [ Browse ] +``` + +자동 path 예시: + +```text +C:\Users\name\AppData\Local\Relay\results\2026-07-23\\result.json +``` + +--- + +## 10.5 Advanced + +```text +Request ID +[ Create automatically __________________ ] + +Caller +[ Human ▼ ] + +Model name +[ _______________________________________ ] + +[ ] Force a new job +[ ] Replace existing output +``` + +`submitted_via`는 사용자가 고르는 값이 아니다. + +GUI server가 자동으로: + +```text +submitted_via = gui +``` + +를 설정한다. + +`caller`는 security principal이고 기본값은 `human`이다. + +--- + +## 10.6 Bottom action bar + +```text +[ Show CLI command ] [ Save as template ] [ Clear ] [ Create task ] +``` + +CLI preview는 실제 서버 payload와 가능한 한 동일하게 만든다. + +Windows: + +```powershell +relay submit ` + --task-file "C:\...\request.md" ` + --worker codex ` + --model "gpt-5.x" ` + --format json ` + --timeout 1200 ` + --attach "C:\...\report.pdf" +``` + +Linux/macOS: + +```sh +relay submit \ + --task-file "/home/.../request.md" \ + --worker codex \ + --model "gpt-5.x" \ + --format json \ + --timeout 1200 \ + --attach "/home/.../report.pdf" +``` + +--- + +## 11. Schedule creation + +## 11.1 Eligibility + +`Schedule this task` 조건: + +1. Job status is `COMPLETED` +2. internal result status is `complete` +3. replay snapshot exists +4. task text can be materialized +5. all attachments can be copied +6. Agent definition still exists +7. service isolation is acknowledged +8. source Job is not already being deleted + +조건이 충족되지 않으면 이유를 알려준다. + +```text +This task cannot be scheduled. + +The original request was not saved. +Run the task again with “Save task settings” turned on. +``` + +--- + +## 11.2 Dialog + +```text +┌───────────────────────────────────────────────────────────────┐ +│ Schedule this task │ +├───────────────────────────────────────────────────────────────┤ +│ Task │ +│ Daily market news │ +│ Codex · Default model · web-research │ +│ │ +│ Schedule name │ +│ [ Daily market news_______________________________________ ] │ +│ │ +│ Repeat │ +│ [ Daily ▼ ] │ +│ │ +│ Time │ +│ [ 09:00 ] [ × ] │ +│ [ 13:00 ] [ × ] │ +│ [ + Add another time ] │ +│ │ +│ Time zone │ +│ [ Asia/Seoul ▼ ] │ +│ │ +│ Next runs │ +│ • Jul 24, 2026 09:00 │ +│ • Jul 24, 2026 13:00 │ +│ • Jul 25, 2026 09:00 │ +│ • Jul 25, 2026 13:00 │ +│ • Jul 26, 2026 09:00 │ +│ │ +│ [ More options ] [ Cancel ] [ Create schedule ] │ +└───────────────────────────────────────────────────────────────┘ +``` + +Popup은 schedule rule 선택에 집중한다. + +원본 Job에서 안전하게 상속: + +- task +- title +- Agent +- model +- profile +- result format +- fallback settings +- timeout +- safe Agent options + +강제로 변경: + +```text +request_id → clear +force_new → true +output_path → automatic +artifact_path → automatic +workspace → default managed workspace +task_file → materialized task snapshot +caller → schedule +submitted_via → schedule +``` + +외부 output path와 외부 workspace는 Schedule에 상속하지 않는다. + +--- + +## 12. Supported Schedule types + +## 12.1 Daily + +```text +Repeat +Daily + +Times +09:00 +13:00 +18:30 +``` + +```json +{ + "type": "daily", + "times": ["09:00", "13:00", "18:30"], + "timezone": "Asia/Seoul" +} +``` + +--- + +## 12.2 Weekly + +```text +Repeat +Weekly + +Days +[✓ Mon] [ ] Tue [✓ Wed] [ ] Thu [✓ Fri] [ ] Sat [ ] Sun + +Times +07:00 +18:00 +``` + +```json +{ + "type": "weekly", + "weekdays": [1, 3, 5], + "times": ["07:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +ISO Monday=1. + +--- + +## 12.3 Monthly + +```text +Repeat +Monthly + +Dates +[ 1 ] [ 15 ] [ 28 ] [ + Add date ] + +Times +09:00 +``` + +```json +{ + "type": "monthly", + "month_days": [1, 15, 28], + "times": ["09:00"], + "missing_day_policy": "skip", + "timezone": "Asia/Seoul" +} +``` + +기본: + +```text +Skip months that do not have this date. +``` + +Optional: + +```text +Use the last day of the month instead. +``` + +--- + +## 12.4 Every N days + +```text +Repeat +Every [ 3 ] days + +Start date +[ Jul 23, 2026 ] + +Times +09:00 +18:00 +``` + +```json +{ + "type": "n_days", + "interval_days": 3, + "anchor_date": "2026-07-23", + "times": ["09:00", "18:00"], + "timezone": "Asia/Seoul" +} +``` + +--- + +## 12.5 One time + +```text +Repeat +One time + +Date +[ Aug 3, 2026 ] + +Time +[ 10:30 ] +``` + +```json +{ + "type": "once", + "run_at_local": "2026-08-03T10:30:00", + "timezone": "Asia/Seoul" +} +``` + +실행 occurrence를 성공적으로 queue한 뒤 Schedule을 inactive로 전환한다. + +--- + +## 13. Schedule advanced options + +## 13.1 Previous run is active + +```text +If the previous run is still active + +(●) Skip this run +( ) Add this run to Waiting +``` + +기본값: + +```text +Skip this run +``` + +active 판단에는 같은 Schedule의 다음 상태를 포함한다. + +- QUEUED +- PREPARING +- RUNNING +- VALIDATING +- DELIVERING +- CANCEL_REQUESTED + +--- + +## 13.2 Relay was not running + +```text +If Relay was not running + +(●) Skip missed runs +( ) Run once when Relay starts +``` + +`Run once when Relay starts`는 backlog 전체를 실행하지 않는다. + +- 가장 최근 missed occurrence 하나만 생성 +- grace period 내 occurrence만 허용 +- default grace period: 12 hours +- manual `Run now`와 구분하여 `trigger_type=catch_up` + +--- + +## 13.3 Start and end + +```text +Start +[ Now ▼ ] + +End +(●) No end date +( ) End on [ date ] +``` + +--- + +## 13.4 Next-run preview + +저장 전 다음 5회를 표시한다. + +```text +Next runs +• Jul 24, 2026 09:00 +• Jul 24, 2026 13:00 +• Jul 25, 2026 09:00 +• Jul 25, 2026 13:00 +• Jul 26, 2026 09:00 +``` + +preview 계산과 daemon 실행 계산은 같은 함수와 같은 rule parser를 사용한다. + +--- + +## 14. Timezone and clock rules + +### 14.1 Storage + +저장: + +- canonical local rule +- IANA timezone +- `next_run_at_utc` +- `last_occurrence_key` + +표시: + +- Schedule timezone 기준 +- GUI local time을 보조로 표시 가능 + +```text +Next run +Jul 24, 2026 09:00 Asia/Seoul +``` + +### 14.2 DST nonexistent time + +예: DST 시작으로 02:30이 존재하지 않을 때. + +기본 정책: + +```text +Skip this occurrence. +``` + +### 14.3 DST ambiguous time + +같은 local time이 두 번 나타나는 경우. + +기본 정책: + +```text +Run once at the first occurrence. +``` + +occurrence key에는 UTC instant를 포함해 중복 실행을 방지한다. + +### 14.4 Clock changes + +- wall-clock time은 timezone-aware datetime으로 계산 +- scheduler loop interval은 monotonic waiting 사용 +- 시스템 시간이 크게 바뀌면 next run 재계산 +- daemon start 시 모든 active Schedule의 `next_run_at_utc` 검증 + +--- + +## 15. Schedule detail screen + +```text +Daily market news Active + +Daily at 09:00 and 13:00 +Time zone: Asia/Seoul + +Next run +Jul 24, 2026 09:00 + +Last run +Jul 23, 2026 13:00 · Completed + +[ Overview ] [ Task settings ] [ Run history ] +``` + +### 15.1 Overview + +```text +Repeat Daily +Times 09:00, 13:00 +Time zone Asia/Seoul +Next run Jul 24, 2026 09:00 +Last run Jul 23, 2026 13:00 +Previous run active Skip +Missed runs Skip +Created from Job abc123 +``` + +Actions: + +```text +[ Run now ] [ Pause ] [ Edit schedule ] [ Copy ] [ Delete ] +``` + +### 15.2 Task settings + +```text +Task name Daily market news +Agent Codex +Model Default model +Profile web-research +Fallback Claude +Time limit 20 minutes +Result type JSON +Input snapshot Ready +``` + +### 15.3 Run history + +```text +Planned Started Result +Jul 23, 13:00 Jul 23, 13:00 Completed +Jul 23, 09:00 Jul 23, 09:00 Completed +Jul 22, 13:00 Jul 22, 13:01 Failed +Jul 22, 09:00 — Skipped +``` + +row 클릭 시 일반 Job detail로 이동한다. + +--- + +## 16. Schedule runtime lifecycle + +```mermaid +flowchart TD + A[Schedule reaches next_run_at] --> B{Enabled and valid?} + B -- No --> Z[Do nothing or mark needs attention] + B -- Yes --> C[Atomically claim occurrence] + C --> D{Claim succeeded?} + D -- No --> Z2[Another loop already claimed it] + D -- Yes --> E{Previous run active?} + E -- No --> G[Build safe JobRequest] + E -- Yes --> F{Overlap policy} + F -- Skip --> S[Record skipped occurrence] + F -- Add to Waiting --> G + G --> H[Create normal queued Job] + H --> I[Waiting] + I --> J[Running] + J --> K[Checking result] + K --> L[Saving files] + L --> M[Completed / Partial / Failed / Cancelled] + M --> N[Update Schedule run link and next_run] +``` + +### 16.1 Atomic claim + +단순히 `next_run_at <= now`를 읽고 Job을 생성하면 scheduler thread 중복, daemon restart, 두 daemon instance에서 중복 실행될 수 있다. + +필수 조건: + +- `occurrence_key` unique +- transaction 안에서 planned row insert +- insert 성공한 process만 Job 생성 +- Job 생성 실패 시 schedule run state와 retryable error 기록 +- Job 생성 후 `job_id` 연결 + +--- + +## 17. Attachment and request snapshot + +Schedule은 원본 path를 장기간 참조하지 않는다. + +```text +source Job replay snapshot + ↓ +materialize request text + ↓ +copy attachments + ↓ +/schedule-inputs// +``` + +```text +schedule-inputs/ +└─ sch_abc123/ + ├─ request.md + ├─ attachments.json + ├─ report.pdf + └─ data.xlsx +``` + +### 17.1 Creation checks + +Schedule 생성 전: + +- task snapshot exists +- attachment source exists +- total size within configured limit +- destination under schedule input root +- no symlink escape +- filenames normalized +- copies hashed +- manifest written atomically + +### 17.2 Deletion + +```text +Delete this schedule? + +Past job results will not be deleted. +The saved task files for this schedule will be removed later. +``` + +- past Jobs remain +- Schedule definition soft-deleted or removed +- input snapshot marked for cleanup +- active occurrence가 있으면 Schedule 삭제는 future runs만 막음 +- 이미 생성된 normal Job은 자동 취소하지 않음 + +--- + +## 18. Replayable request and privacy model + +### 18.1 Problem + +`Run again`과 `Schedule this task`는 원본 task와 options가 필요하다. + +따라서 task를 저장하지 않으면서 완전한 재실행을 제공할 수는 없다. + +### 18.2 Settings + +```text +Save task settings for Run again and Schedules +[✓] + +Show task text in history +[✓] +``` + +internal: + +```toml +store_replayable_requests = true +history_display_mode = "full" +``` + +### 18.3 Behavior matrix + +| Save replay request | Show text | Run again | Schedule | Task tab | +|---|---|---|---|---| +| true | full | Yes | Yes after success | Full | +| true | metadata | Yes | Yes after success | Hidden | +| false | full | No after process ends | No | only transient | +| false | metadata | No | No | metadata only | + +### 18.4 Sensitive data notice + +Settings에 쉬운 영어로 표시한다. + +```text +Relay can save the task text and file paths so you can run the task again. + +Turn this off if task requests may contain sensitive information. +``` + +민감정보 저장은 local-only라고 하더라도 명확히 설명해야 한다. + +--- + +## 19. Source and security identity + +`caller`와 `submitted_via`를 혼용하지 않는다. + +### 19.1 caller + +보안 정책: + +- `human` +- `hermes` +- `service` +- `schedule` + +### 19.2 submitted_via + +UI 표시와 통계: + +- `cli` +- `gui` +- `hermes` +- `schedule` +- `legacy` + +### 19.3 Server assignment + +- GUI endpoint: server sets `submitted_via=gui` +- CLI: CLI sets `submitted_via=cli` +- Hermes caller: server normalizes to `hermes` +- Schedule engine: server sets `caller=schedule`, `submitted_via=schedule` +- clients cannot forge `schedule_id` link through public Job creation API + +Schedule은 무인 실행이므로 `service_isolation_acknowledged=true`가 필요하다. + +--- + +## 20. GUI and daemon communication + +```text +GUI + │ + │ token-authenticated loopback RPC + ▼ +Relay daemon + │ + ├─ Job API + ├─ Schedule API + ├─ Agent API + ├─ Config API + └─ Cleanup API + │ + ▼ +SQLite + RelayEngine +``` + +GUI는 SQLite를 직접 수정하지 않는다. + +read-only direct DB access도 MVP에서는 사용하지 않는다. 모든 데이터는 daemon schema를 통해 받는다. + +--- + +## 21. API versioning and GUI compatibility + +기존 endpoint 호환성을 유지한다. + +현재 endpoint 예: + +```text +/submit +/status/ +/result/ +/show/ +/cancel/ +``` + +새 GUI API: + +```text +/v1/jobs +/v1/schedules +/v1/agents +/v1/config +/v1/cleanup +``` + +### 21.1 Health and compatibility contract + +`GET /health`는 단순 process 생존 여부뿐 아니라 GUI가 API 호환성을 판단할 수 있는 정보를 반환한다. + +```json +{ + "ok": true, + "daemon_version": "0.9.0", + "api_versions": ["v1"], + "api_schema_revision": 3, + "min_gui_version": "0.8.0", + "relay_home_id": "sha256-short-id" +} +``` + +Field meaning: + +- `daemon_version`: running daemon package version +- `api_versions`: supported major API namespaces +- `api_schema_revision`: additive response/schema revision +- `min_gui_version`: minimum GUI version permitted to perform write actions +- `relay_home_id`: GUI가 다른 Relay home daemon에 연결되지 않았는지 확인하는 non-secret identifier + +GUI는 다음 시점마다 `/health`를 확인한다. + +- initial connection +- daemon auto-start 후 +- reconnect 후 +- normal polling, default every 15 seconds +- any unexpected schema or HTTP error 후 + +### 21.2 Compatibility decision + +```text +Does daemon support API v1? + ├─ No → compatibility read-only mode + └─ Yes + ↓ +Is GUI version >= min_gui_version? + ├─ No → compatibility read-only mode + └─ Yes + ↓ +Is relay_home_id the expected Relay Home? + ├─ No → disconnect and show error + └─ Yes → normal mode +``` + +`daemon_version != gui_version` 자체는 항상 오류가 아니다. `v1` 지원과 `min_gui_version` 기준이 충족되면 minor version이 달라도 동작할 수 있다. + +### 21.3 Compatibility read-only mode + +호환되지 않을 때 GUI는 crash하거나 unknown response를 계속 parsing하지 않는다. + +허용: + +- health 확인 +- 이미 받아온 Job 목록 보기 +- daemon이 지원하는 안전한 read endpoint 호출 +- result, logs, files 열기 + +차단: + +- New Task +- Stop task +- Run again +- Schedule create/edit/run-now/pause/delete +- Settings write +- Agent App create/edit/delete/test +- cleanup write + +Banner: + +```text +Relay was updated. + +Please restart the app to use the new version. +``` + +기술 상세: + +```text +GUI version: 0.8.0 +Daemon version: 0.9.0 +Required GUI version: 0.9.0 +API versions: v1 +``` + +### 21.4 Jobs + +```http +GET /v1/jobs +GET /v1/jobs/{job_id} +POST /v1/jobs +POST /v1/jobs/{job_id}/cancel +POST /v1/jobs/{job_id}/rerun +GET /v1/jobs/{job_id}/logs +GET /v1/jobs/{job_id}/events +GET /v1/jobs/{job_id}/artifacts +``` + +queued Job edit은 MVP에서 제외한다. + +### 21.5 Schedules + +```http +GET /v1/schedules +POST /v1/schedules/from-job/{job_id} +GET /v1/schedules/{schedule_id} +PATCH /v1/schedules/{schedule_id} +DELETE /v1/schedules/{schedule_id} + +POST /v1/schedules/{schedule_id}/run-now +POST /v1/schedules/{schedule_id}/pause +POST /v1/schedules/{schedule_id}/resume + +GET /v1/schedules/{schedule_id}/runs +POST /v1/schedules/preview +``` + +### 21.6 Agents + +```http +GET /v1/agents +GET /v1/agents/{agent_id} +POST /v1/agents +PATCH /v1/agents/{agent_id} +DELETE /v1/agents/{agent_id} + +POST /v1/agents/{agent_id}/test +GET /v1/agents/{agent_id}/models +POST /v1/agents/{agent_id}/models/refresh +``` + +### 21.7 Config and maintenance + +```http +GET /v1/config +PATCH /v1/config +GET /v1/cleanup/status +POST /v1/cleanup +GET /health +POST /shutdown +``` + +### 21.8 Error response + +```json +{ + "ok": false, + "error_code": "SCHEDULE_INPUT_MISSING", + "message": "A saved input file is missing.", + "action": "Open the schedule and add the file again.", + "details": {} +} +``` + +UI에는 `message`와 `action`을 보여주고 code는 Technical details에 둔다. + +--- + +## 22. Database migration + +### 22.1 Migration framework + +현재 단순 `CREATE TABLE IF NOT EXISTS`를 유지하되 versioned migration을 추가한다. GUI 추가 자체가 migration을 요구하는 것은 아니며, migration 대상은 Schedule 저장과 향후 schema 확장이다. 기존 `jobs` 구조 변경은 필요한 최소 범위로 제한한다. + +```text +PRAGMA user_version +``` + +규칙: + +- migration transaction +- migration 전 DB backup +- migration failure 시 startup 중단 +- 기존 DB를 삭제하거나 새 DB로 silently 교체하지 않음 +- migration unit test +- downgrade는 지원하지 않되 backup 복구 안내 + + +### 22.2 Migration baseline fixtures + +migration 검증의 기준은 “현재 test가 임시로 만든 DB”가 아니라 실제 과거 release가 만든 DB다. + +저장소에 다음 binary fixture를 커밋한다. + +```text +tests/ +├─ fixtures/ +│ ├─ relay-0.5.0-empty.db +│ └─ relay-0.5.0-populated.db +├─ fixture_builders/ +│ └─ build_relay_0_5_0_fixtures.py +└─ test_migrations.py +``` + +`relay-0.5.0-populated.db`는 실제 0.5.0 code 또는 고정된 0.5.0 fixture builder로 생성한다. + +포함 데이터: + +- completed Job +- failed Job +- cancelled Job +- fallback attempt가 있는 Job +- events가 여러 개인 Job +- artifact가 있는 Job +- capability audit +- request ID +- task hash +- JSON result +- TXT result + +fixture에는 synthetic data만 사용하고 실제 사용자 prompt, path, token, credential을 포함하지 않는다. + +migration test: + +```text +Before migration +- schema version +- table names +- row counts +- selected values +- jobs ↔ attempts +- jobs ↔ events +- jobs ↔ artifacts +- capability audit values + +After migration +- every original row remains +- selected original values are unchanged +- relationships remain valid +- new tables and indexes exist +- new columns use safe defaults +- migration can be run only once +- reopening the migrated DB is safe +``` + +`tests/test_migrations.py`는 fixture를 temporary copy한 뒤 migration한다. repository에 커밋된 fixture 자체를 수정하지 않는다. + +G0 stop gate 문구: + +```text +The committed Relay 0.5.0 database fixtures migrate without losing +rows, values, relationships, or file references. +``` + +### 22.3 Jobs additions + +```sql +ALTER TABLE jobs ADD COLUMN title TEXT; +ALTER TABLE jobs ADD COLUMN submitted_via TEXT; +ALTER TABLE jobs ADD COLUMN schedule_id TEXT; +ALTER TABLE jobs ADD COLUMN scheduled_for TEXT; +ALTER TABLE jobs ADD COLUMN task_preview TEXT; +ALTER TABLE jobs ADD COLUMN replayable INTEGER NOT NULL DEFAULT 1; +``` + +indexes: + +```sql +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at +ON jobs(completed_at); + +CREATE INDEX IF NOT EXISTS idx_jobs_submitted_via +ON jobs(submitted_via); + +CREATE INDEX IF NOT EXISTS idx_jobs_schedule +ON jobs(schedule_id, created_at); +``` + +### 22.4 Schedules + +```sql +CREATE TABLE IF NOT EXISTS schedules ( + schedule_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + needs_attention INTEGER NOT NULL DEFAULT 0, + + schedule_type TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL, + + source_job_id TEXT, + request_template_json TEXT NOT NULL, + input_snapshot_path TEXT, + + output_root TEXT, + output_root_kind TEXT NOT NULL DEFAULT 'relay_managed', + output_retention_mode TEXT NOT NULL DEFAULT 'days', + output_retention_value INTEGER NOT NULL DEFAULT 90, + + overlap_policy TEXT NOT NULL DEFAULT 'skip', + missed_run_policy TEXT NOT NULL DEFAULT 'skip', + catch_up_grace_minutes INTEGER NOT NULL DEFAULT 720, + + next_run_at_utc TEXT, + last_occurrence_key TEXT, + + last_scheduled_at TEXT, + last_started_at TEXT, + last_completed_at TEXT, + last_job_id TEXT, + last_status TEXT, + last_error_code TEXT, + last_error_message TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +### 22.5 Schedule runs + +```sql +CREATE TABLE IF NOT EXISTS schedule_runs ( + schedule_run_id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id TEXT NOT NULL + REFERENCES schedules(schedule_id) ON DELETE CASCADE, + + occurrence_key TEXT NOT NULL, + scheduled_for_utc TEXT NOT NULL, + scheduled_for_local TEXT NOT NULL, + trigger_type TEXT NOT NULL, + + claimed_at TEXT, + triggered_at TEXT, + job_id TEXT, + state TEXT NOT NULL, + reason TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_occurrence_unique +ON schedule_runs(schedule_id, occurrence_key); + +CREATE INDEX IF NOT EXISTS idx_schedule_runs_schedule +ON schedule_runs(schedule_id, scheduled_for_utc DESC); +``` + +`trigger_type`: + +- `scheduled` +- `manual` +- `catch_up` + +`state`: + +- `claimed` +- `queued` +- `running` +- `completed` +- `partial` +- `failed` +- `cancelled` +- `skipped` +- `missed` + +--- + +## 23. Schedule calculation engine + +canonical JSON이 source of truth다. raw cron string은 사용하지 않는다. + +Core: + +```python +def validate_rule(rule: dict) -> None: ... + +def next_occurrence( + rule: dict, + after_utc: datetime, +) -> ScheduledOccurrence | None: ... + +def next_occurrences( + rule: dict, + after_utc: datetime, + count: int, +) -> list[ScheduledOccurrence]: ... + +def occurrence_key( + schedule_id: str, + scheduled_for_utc: datetime, +) -> str: ... +``` + +`ScheduledOccurrence`: + +```python +@dataclass(frozen=True) +class ScheduledOccurrence: + local_datetime: datetime + utc_datetime: datetime + occurrence_key: str +``` + +Schedule preview API와 daemon loop는 같은 code path를 사용한다. + +--- + +## 24. Daemon scheduling loop + +```text +Job queue loop every 0.5 seconds +Schedule due check every 15 seconds +Maintenance loop hourly +``` + +Due process: + +1. read due active schedules +2. calculate due occurrence +3. atomically insert unique schedule_run +4. evaluate overlap +5. build safe JobRequest +6. queue normal Job +7. link Job ID +8. calculate and persist next occurrence + +### 24.1 Single daemon protection + +daemon pid file만 신뢰하지 않는다. + +- port bind remains primary single-instance guard +- token-authenticated health check +- stale pid cleanup +- unique schedule occurrence remains final duplicate protection + +--- + +## 25. Auto-start + +Schedule의 신뢰성을 위해 daemon auto-start가 필요하다. + +첫 Schedule 생성 시: + +```text +Relay must keep running to start scheduled tasks. + +Start Relay automatically when you sign in? + +[ Not now ] [ Turn on auto-start ] +``` + +Platforms: + +- Windows: Task Scheduler, current user logon +- macOS: LaunchAgent +- Linux: systemd user service +- fallback: manual daemon start + +Settings: + +```text +Start Relay when I sign in +[✓] +``` + +daemon stop: + +```text +Stopping Relay will also stop scheduled tasks. + +[ Cancel ] [ Stop Relay ] +``` + +### Platform support status + +- Windows: v1.0 release requirement +- Linux: release requirement after target-machine validation +- macOS: release requirement after target-machine validation + +“implemented”와 “field validated”를 문서와 UI에서 구분한다. + +--- + +## 26. Agent App registration + +Settings: + +```text +Settings +├─ General +├─ Agents +├─ Agent Apps +├─ Paths +├─ Task rules +├─ Schedules +├─ Cleanup +└─ Security +``` + +Agent Apps: + +```text +Agent Apps + +Built in +Claude Code Ready +Codex CLI Ready +Antigravity Off + +Added by you +OpenCode Ready +Gemini CLI Needs a test + +[ + Add agent app ] +``` + +--- + +## 26.1 Registration wizard + +### Step 1 — Basic details + +```text +Agent name +[ OpenCode____________________________ ] + +Agent ID +[ opencode____________________________ ] + +Command +[ /usr/local/bin/opencode____________ ] [ Browse ] + +Description +[ ___________________________________ ] +``` + +### Step 2 — Command options + +```text +How should Relay send the task? + +(●) Use a request file +( ) Send through standard input +( ) Add the task to the command +``` + +argv list: + +```text +run +--input +{request_file} +--output +{result_file} +--workspace +{workspace} +``` + +placeholders: + +```text +{request_file} +{result_file} +{artifact_dir} +{workspace} +{schema_file} +{model} +{profile} +{job_id} +``` + +금지: + +- `shell=True` +- pipe +- redirect +- command substitution +- chained shell operators +- arbitrary startup scripts + +### Step 3 — Result + +```text +Where does this agent save its answer? + +(●) Result file +( ) Standard output + +Supported result types +[✓] JSON +[✓] Text + +Can this agent create files? +[✓] +``` + +### Step 4 — Models + +```text +Default model +[ ______________________________ ] + +Can Relay list models? +( ) No +(●) Run a command + +Model list options +[ models ] [ --json ] + +Model option +[ --model ] [ {model} ] +``` + +### Step 5 — Safety + +```text +Needs network access +[✓] + +Can write inside the task workspace +[✓] + +May skip permission checks +[ ] + +Environment variable names +[ API_KEY_NAME__________________ ] +``` + +secret value는 저장하지 않는다. + +### Step 6 — Test + +```text +Find command Passed +Read version Passed +Run without questions Passed +Create result file Passed +Check JSON or text Passed +Create files Passed + +[ Run test again ] [ Save agent ] +``` + +deep test를 통과하기 전에는 disabled. + +--- + +## 27. Agent Registry refactor strategy + +전면 교체를 Phase G0에서 바로 하지 않는다. + +### Stage A: compatibility registry + +```text +AgentRegistry +└─ built-in registrations + ├─ ClaudeAdapter + ├─ CodexAdapter + └─ AntigravityAdapter +``` + +기존 behavior를 그대로 감싼다. + +### Stage B: dynamic manifests + +```text +AgentRegistry +├─ built-in adapters +└─ custom manifests + └─ GenericCLIAdapter +``` + +API: + +```python +registry.list_agents() +registry.get_definition(agent_id) +registry.get_adapter(agent_id) +registry.list_enabled_agents() +``` + +CLI 고정 choices 제거는 custom Agent phase에서 수행한다. + +```text +relay run --worker opencode +relay doctor --worker opencode --deep +relay models --worker opencode +``` + +GUI와 CLI는 같은 registry를 사용한다. + +--- + +## 28. CLI parity for Schedules + +GUI 기능을 테스트하고 자동화할 수 있도록 최소 Schedule CLI를 추가한다. + +```text +relay schedule list +relay schedule show +relay schedule create --from-job --rule-file schedule.json +relay schedule pause +relay schedule resume +relay schedule run-now +relay schedule delete +``` + +일반 사용자는 GUI를 사용하지만 core 기능이 GUI에만 묶이지 않도록 한다. + +machine output 지원: + +```text +--machine +``` + +--- + +## 29. Proposed module structure + +```text +relay/ +├─ cli.py +├─ daemon.py +├─ engine.py +├─ db.py +├─ config.py +├─ migrations.py +│ +├─ agents/ +│ ├─ registry.py +│ ├─ definitions.py +│ └─ manifest.py +│ +├─ schedules/ +│ ├─ models.py +│ ├─ rules.py +│ ├─ calculator.py +│ ├─ service.py +│ ├─ scheduler.py +│ └─ snapshot.py +│ +├─ adapters/ +│ ├─ base.py +│ ├─ claude.py +│ ├─ codex.py +│ ├─ antigravity.py +│ └─ generic_cli.py +│ +├─ rpc/ +│ ├─ router.py +│ ├─ responses.py +│ ├─ jobs.py +│ ├─ schedules.py +│ ├─ agents.py +│ └─ config.py +│ +└─ gui/ + ├─ app.py + ├─ main_window.py + ├─ state.py + ├─ rpc_client.py + ├─ copy.py + ├─ theme.py + │ + ├─ views/ + │ ├─ empty.py + │ ├─ new_task.py + │ ├─ job_detail.py + │ ├─ schedule_detail.py + │ └─ settings.py + │ + ├─ widgets/ + │ ├─ sidebar.py + │ ├─ job_item.py + │ ├─ date_group.py + │ ├─ search_filters.py + │ ├─ status_steps.py + │ ├─ log_viewer.py + │ └─ file_list.py + │ + └─ dialogs/ + ├─ schedule_create.py + ├─ schedule_edit.py + ├─ agent_app_wizard.py + ├─ confirmation.py + └─ error.py +``` + +--- + +## 30. `relay --gui` behavior + +### Parser behavior + +현재 argv preprocess와 충돌하지 않도록 top-level `--gui`를 먼저 감지한다. + +```text +relay --gui + ↓ +Resolve Relay Home + ↓ +Check GUI dependency + ↓ +Ensure daemon is running + ↓ +Start GUI process +``` + +`relay gui` alias는 optional이다. + +### Separation + +- GUI process does not execute Jobs +- daemon process executes Jobs +- GUI close does not stop daemon +- daemon stop requires explicit action + +### Single GUI instance + +MVP: + +- lock per Relay home +- second launch shows existing-instance notice +- if practical, bring existing window to front +- stale lock recovery + +--- + +## 31. Easy English copy guide + +### Main + +```text +New Task +Waiting +Running +Finished +Schedules +Settings +``` + +### Job actions + +```text +Create task +Stop task +Run again +Copy settings +Schedule this task +Open result +Open folder +Copy job ID +``` + +### Schedule actions + +```text +Create schedule +Run now +Pause +Resume +Edit schedule +Copy schedule +Delete schedule +Next run +Last run +Run history +``` + +### Agent actions + +```text +Add agent app +Test agent +Refresh models +Turn on +Turn off +Save agent +Remove agent +``` + +### Help examples + +```text +Use another agent if this fails. + +Relay will try the next agent only for a technical failure. + +This task came from the command line. + +This task was started by a schedule. + +Past job results will not be deleted. + +Relay checked the file format, not the factual accuracy. +``` + +### Error pattern + +1. What happened +2. Why +3. What to do + +```text +Codex could not start. + +The Codex command was not found. + +Open Settings > Agent Apps and check the command path. +``` + +--- + +## 32. Security rules + +1. GUI path picker does not bypass Relay path validation. +2. Schedule is treated as unattended service execution. +3. Schedule output and workspace are Relay-managed. +4. custom Agent uses executable + argv, never shell string. +5. custom Agent cannot enable dangerous flags without explicit security warning. +6. deep test required after executable version changes. +7. daemon token remains user-only. +8. Schedule input snapshot permissions are user-only. +9. API does not allow arbitrary `schedule_id` injection. +10. logs and file-open actions validate stored paths. + +--- + +## 33. Schedule output storage and retention + +Schedule output은 일반 Job workspace와 분리한다. + +일반 Job workspace는 실행 staging, validation, logs, temporary files를 위한 운영 공간이다. Schedule 결과 보관소는 사용자가 반복 결과를 다시 열고 비교하기 위한 지속적 output 영역이다. + +### 33.1 Default output structure + +기본 root: + +```text +/schedule-outputs// +``` + +각 실행은 unique 하위 폴더에 저장한다. + +```text +schedule-outputs/ +└─ sch_abc123/ + ├─ 2026-07-24_0900+0900_run-a1b2c3/ + │ ├─ result.json + │ ├─ receipt.json + │ ├─ manifest.json + │ └─ artifacts/ + │ ├─ report.html + │ └─ chart.png + │ + └─ 2026-07-25_0900+0900_run-d4e5f6/ + ├─ result.json + ├─ receipt.json + ├─ manifest.json + └─ artifacts/ +``` + +folder name은 다음으로 구성한다. + +- scheduled local date +- scheduled local time +- UTC offset +- short immutable schedule run ID + +수동 `Run now`와 catch-up run도 같은 local time에 충돌하지 않는다. + +### 33.2 User-selected output root + +Schedule 생성·편집 화면: + +```text +Output folder + +(●) Use the Relay folder +( ) Choose a folder + [ D:\Reports\Daily Market ] [ Browse ] +``` + +사용자 root를 선택하더라도 최종 result file을 root에 직접 쓰지 않는다. + +Relay가 항상 unique 하위 폴더를 강제로 붙인다. + +```text +D:\Reports\Daily Market\ +└─ 2026-07-24_0900+0900_run-a1b2c3\ + ├─ result.json + └─ artifacts\ +``` + +따라서 원본 Job의 다음 외부 path를 상속하지 않는다. + +```text +D:\Reports\today.json +``` + +Schedule별 root는 directory만 허용한다. fixed result filename은 허용하지 않는다. + +### 33.3 Delivery flow + +```text +normal scheduled Job workspace + ↓ +Agent output + ↓ +Relay validation + ↓ +atomic delivery to unique Schedule output folder + ↓ +Job result record links to delivered Schedule output +``` + +Job workspace cleanup 전에 Schedule output delivery와 hash verification이 완료되어야 한다. + +Schedule output delivery에 실패하면 Job을 정상 `COMPLETED`로 표시하지 않는다. 원래 Relay result 규칙에 따라 `FAILED` 또는 `PARTIAL`로 처리한다. + +### 33.4 Retention policy + +Schedule output은 일반 Job retention과 분리한다. + +Schedule별 설정: + +```text +Keep outputs + +(●) For [ 90 ] days +( ) Keep the latest [ 100 ] runs +( ) Keep all outputs +``` + +internal: + +```json +{ + "output_retention": { + "mode": "days", + "value": 90 + } +} +``` + +지원 mode: + +- `days` +- `latest_runs` +- `forever` + +기본값: + +```text +90 days +``` + +안전 규칙: + +- active run output은 삭제하지 않음 +- 가장 최근 successful output 최소 1개 보호 +- DB run link와 manifest를 확인한 뒤 삭제 +- partial directory는 별도 recovery cleanup 대상 +- 사용자 지정 root 바깥으로 path traversal 금지 +- symlink escape 금지 +- cleanup event 기록 +- output deletion failure는 다음 cleanup에서 재시도 + +### 33.5 Schedule detail UI + +Schedule detail: + +```text +[ Run now ] [ Open output folder ] [ Pause ] [ Edit ] [ Delete ] +``` + +Overview: + +```text +Output folder D:\Reports\Daily Market +Keep outputs 90 days +Stored runs 37 +Oldest output May 18, 2026 +``` + +Run history: + +```text +Jul 24, 09:00 Completed Output available +Jul 23, 09:00 Completed Output available +Apr 10, 09:00 Completed Removed by retention +``` + +Job detail의 `Open result`와 `Open folder`는 Schedule output 경로를 우선 사용한다. + +### 33.6 Schedule deletion + +Schedule 삭제 dialog: + +```text +Delete this schedule? + +Past job history will not be deleted. + +Keep saved outputs +[✓] + +[ Cancel ] [ Delete schedule ] +``` + +기본값은 saved outputs 보존이다. + +사용자가 output 삭제도 선택하면: + +- active Job은 취소하지 않음 +- future occurrence만 중단 +- output을 immediate delete하지 않고 cleanup queue에 등록 +- 사용자 root 자체는 삭제하지 않음 +- Relay가 만든 run 하위 폴더만 삭제 + +### 33.7 Acceptance conditions + +- 12일 전 Schedule result가 일반 7일 workspace cleanup 때문에 사라지지 않는다. +- 반복 실행이 이전 result를 덮어쓰지 않는다. +- user-selected root에서도 unique 하위 폴더가 생성된다. +- retained output은 Run history에서 열 수 있다. +- retention으로 제거된 output은 기록은 남고 `Removed by retention`으로 표시된다. +- Schedule 삭제가 past Job history를 삭제하지 않는다. + +--- + +## 34. Cleanup and retention + +Schedule-created Jobs use normal retention. + +Schedule definitions: + +- persist until deleted +- paused Schedule is not cleaned automatically + +Schedule input snapshots: + +- active: never remove +- deleted: mark for cleanup +- default cleanup delay: 7 days +- hash manifest retained until deletion + +Past Job results are independent of Schedule deletion. + +Settings: + +```text +Cleanup + +Job files +Schedule input files +Agent test files + +[ Show what will be removed ] [ Clean now ] +``` + +--- + +## 35. Release plan + +G0~G6은 내부 milestone이 아니라 실제 release다. 각 release는 독립적으로 설치·업데이트 가능하고 사용자에게 유용해야 한다. + +### 35.1 Release completion rule + +모든 release는 다음 순서로 종료한다. + +```text +Implementation + ↓ +Stop gate + ↓ +Three-OS CI + ↓ +Changelog + ↓ +Version bump + ↓ +Tagged release + ↓ +Upgrade test from previous release +``` + +다음 release 작업을 main branch에 장기간 쌓지 않는다. 필요한 경우 feature branch 또는 draft PR을 사용한다. + +--- + +### Release 0.6.0 — G0: API, compatibility, migration foundation + +### User value + +- Hermes와 외부 client가 `/v1/jobs`를 통해 안정적으로 history를 조회할 수 있음 +- update 전후 API version 확인 가능 +- 과거 DB upgrade 안전성 검증 + +### Work + +- Three-OS CI prerequisite +- `/health` compatibility fields +- GUI/daemon version decision helper +- DB backup and versioned migration +- committed 0.5.0 DB fixtures +- `/v1/jobs` read API +- search, filter, cursor pagination +- `title`, `task_preview`, `submitted_via` +- `caller` and source separation +- replay/privacy data contract +- compatibility AgentRegistry interface +- existing endpoint regression tests +- standard API error response + +### Stop gate + +- Windows, Ubuntu, macOS CI green +- actual 0.5.0 fixture DB migrates with no row/value/relation loss +- existing CLI commands pass regression +- `/health` reports version and API capability +- existing daemon endpoints remain compatible +- upgrade from 0.5.0 package/data is documented and tested + +--- + +### Release 0.7.0 — G1: Read-only GUI + +### User value + +- CLI/Hermes Job을 desktop GUI에서 확인 +- Waiting, Running, Finished monitoring +- Finished 검색·필터 + +### Work + +- PySide6 optional dependency +- `relay --gui` +- daemon connect/start +- health compatibility check +- compatibility read-only mode +- sidebar +- Waiting, Running, Finished +- date grouping +- Finished search and filters +- Job overview +- current Relay Home +- GUI state persistence + +### Stop gate + +- CLI Job appears within 2 seconds +- GUI 0.7 with compatible daemon works +- incompatible daemon switches GUI to read-only without crash +- daemon restart causes reconnect and compatibility recheck +- no GUI DB writes +- 10,000 historical rows do not freeze UI + +--- + +### Release 0.8.0 — G2: Job control and New Task + +### User value + +- GUI에서 Job 등록 +- 결과, 파일, 로그, 이벤트 확인 +- cancel, rerun + +### Work + +- New Task +- attachments +- Agent/model/profile +- fallback +- timeout +- result/output +- create/cancel/rerun +- Task/Progress/Result/Files/Logs/Events +- live log tail +- open files/folders +- replay/privacy action gating + +### Stop gate + +- GUI-created Job appears in CLI history +- CLI-equivalent parameters preserved +- cancellation works +- large logs do not freeze +- replay-disabled Job cannot rerun or schedule +- GUI/daemon version mismatch blocks all write actions + +--- + +### Release 0.9.0 — G3: Schedule core + +### User value + +- CLI에서 성공 Job을 Schedule로 등록 가능 +- daemon이 정해진 시각에 일반 Job 생성 +- 반복 결과가 unique output folder에 축적 + +### Work + +- schedules and schedule_runs migration +- Daily/Weekly/Monthly/N-days/Once +- timezone and DST +- next-run preview +- atomic occurrence claim +- overlap and missed-run policy +- safe request cloning +- input snapshot +- Schedule output structure +- Schedule-specific retention +- Schedule CLI +- daemon schedule loop + +### Stop gate + +- deterministic next-run tests pass +- daemon restart creates no duplicate occurrence +- Schedule Job enters normal queue +- Schedule output survives normal Job workspace cleanup +- repeated runs never overwrite prior output +- user-selected root still forces unique run folder +- missing inputs produce needs-attention + +--- + +### Release 1.0.0 — G4: Schedule GUI and auto-start + +### User value + +- 성공 Job에서 `Schedule this task` +- Schedule 조회·수정·중지·즉시 실행 +- output folder와 retention 관리 + +### Work + +- Schedule creation dialog +- multiple times +- next five preview +- sidebar Schedules +- Schedule detail +- Run now +- Pause/Resume/Edit/Copy/Delete +- Run history +- Open output folder +- output root selection +- retention settings +- auto-start prompts and settings + +### Stop gate + +- every supported rule can be created +- preview matches daemon calculation +- scheduled run moves Waiting → Running → Finished +- output available from Run history +- Schedule deletion preserves past Job history +- Windows auto-start field validation complete + +--- + +### Release 1.1.0 — G5: Custom Agent Apps + +### User value + +- Settings에서 새로운 CLI Agent 등록 +- CLI, GUI, Schedule에서 동일 Agent 사용 + +### Work + +- dynamic manifest schema +- GenericCLIAdapter +- Agent App wizard +- argv placeholder validation +- result normalization +- model list +- deep test +- version-change invalidation +- dynamic Agent ID in CLI +- GUI and Schedule integration + +### Stop gate + +- built-in Agent regression passes +- custom Agent works from CLI, GUI, Schedule +- shell injection forms rejected +- untested Agent cannot be enabled +- executable version change requires retest + +--- + +### Release 1.2.0 — G6: Packaging and platform operations + +### User value + +- 일반 desktop app처럼 설치·실행 +- platform auto-start +- optional tray and notifications + +### Work + +- PyInstaller/Nuitka packaging PoC +- LGPL compliance evaluation +- Windows installer +- macOS app bundle and notarization path +- Linux package/launcher +- desktop shortcut +- icon +- single GUI instance +- auto-start +- optional tray +- optional notifications +- crash log +- keyboard navigation +- high-DPI + +### Stop gate + +- `relay --gui` works on all target platforms +- headless CLI remains available +- upgrade preserves Relay Home +- platform auto-start field validated +- packaging approach passes LGPL/relinkability review +- license notices included + +--- + +## 36. Test plan + +### 36.1 Migration + +Fixture baseline: + +```text +tests/fixtures/relay-0.5.0-empty.db +tests/fixtures/relay-0.5.0-populated.db +``` + +Tests: + +- fixture checksum is stable +- fixture is copied before migration +- empty 0.5.0 DB migration +- populated 0.5.0 DB migration +- original row counts preserved +- selected original values preserved +- jobs/attempts/events/artifacts relationships preserved +- capability audit preserved +- backup generated +- new tables/indexes created +- safe defaults applied +- migration is idempotent +- partially migrated DB rejected safely +- migrated DB reopens successfully +- rollback documentation exists + +### 36.2 Shared history + +- CLI sync +- CLI submit +- Hermes +- GUI +- Schedule +- source labels +- separate Relay Home isolation + +### 36.3 State movement + +```text +QUEUED → Waiting +PREPARING → Running +RUNNING → Running +VALIDATING → Running +DELIVERING → Running +COMPLETED → Finished / Completed +PARTIAL → Finished / Partial +FAILED → Finished / Failed +CANCELLED → Finished / Cancelled +``` + +### 36.4 Search + +- title +- preview +- job ID +- Agent +- model +- error code +- Schedule +- Korean +- English +- date range +- combined filters +- pagination +- escaped wildcards + +### 36.5 Schedule calculation + +- Daily one time +- Daily multiple times +- Weekly multiple days +- Weekly multiple times +- Monthly 1, 15, 31 +- missing month day +- leap year +- Every 2/3/10 days +- One time +- timezone conversion +- DST nonexistent +- DST ambiguous +- next five preview +- pause/resume +- end date + +### 36.6 Schedule reliability + +- duplicate scheduler loops +- daemon restart +- process crash after claim +- queue creation failure +- missed run +- catch-up +- previous run active skip +- previous run active queue +- unique output path +- request ID cleared +- attachment snapshot +- missing snapshot +- deleted Agent +- service isolation disabled + +### 36.7 GUI and daemon compatibility + +- compatible same version +- compatible different daemon version +- daemon does not support API v1 +- GUI below `min_gui_version` +- unexpected `api_schema_revision` +- Relay Home ID mismatch +- read-only banner shown +- write actions disabled in compatibility mode +- safe read actions remain available +- daemon restarts while GUI open +- reconnect rechecks compatibility +- malformed health response +- health endpoint timeout +- daemon downgrade while GUI open +- daemon upgrade while GUI open + +### 36.8 Schedule output and retention + +- default Relay-managed output root +- user-selected output root +- unique timestamp/run folder +- two manual runs in same minute do not collide +- DST repeated local time does not collide +- result and artifacts delivered atomically +- normal workspace cleanup does not remove Schedule output +- retention by days +- retention by latest N runs +- keep forever +- newest successful output protected +- removed output shown as `Removed by retention` +- Schedule deletion keeps outputs by default +- optional output cleanup removes only Relay-created run folders +- user root itself is never deleted +- symlink/path traversal blocked +- Open output folder action + +### 36.9 Agent Apps + +- valid command +- missing command +- changed version +- interactive prompt +- missing output +- invalid JSON +- artifact escape +- model list failure +- custom Agent in Schedule + +--- + +## 37. Acceptance criteria + +### Compatibility + +- [ ] Existing CLI commands work without behavior regression. +- [ ] `/health` reports daemon version, supported API versions, schema revision, and minimum GUI version. +- [ ] GUI checks compatibility on connect, reconnect, and polling. +- [ ] Incompatible GUI/daemon combinations enter read-only mode instead of crashing. +- [ ] Existing daemon endpoints remain compatible. +- [ ] Existing DB migrates without data loss. +- [ ] Committed Relay 0.5.0 fixture DBs migrate with row, value, and relationship preservation. +- [ ] CLI, Hermes, GUI, and Schedule Jobs share one history. + +### GUI + +- [ ] `relay --gui` launches. +- [ ] Sidebar contains Waiting, Running, Finished, Schedules, Settings. +- [ ] Finished is grouped by date. +- [ ] Finished search and filters work. +- [ ] Job details show status, task, result, files, logs, and events. +- [ ] UI copy uses Simple English. + +### New Task + +- [ ] Main supported JobRequest options are available. +- [ ] Attachments work. +- [ ] GUI Jobs are visible from CLI. +- [ ] Output paths are validated. +- [ ] Replay/privacy settings control actions correctly. + +### Schedules + +- [ ] Created only from a successful replayable Job. +- [ ] Daily supports multiple times. +- [ ] Weekly supports multiple weekdays and times. +- [ ] Monthly supports multiple dates and times. +- [ ] Every N days and One time work. +- [ ] Next five runs are shown. +- [ ] Schedule Jobs move Waiting → Running → Finished. +- [ ] Schedule detail shows next run and run history. +- [ ] duplicate occurrences are prevented. +- [ ] timezone and missed-run policies are deterministic. +- [ ] Schedule outputs use unique run folders. +- [ ] Schedule outputs are not removed by normal Job workspace cleanup. +- [ ] Schedule-specific retention works. +- [ ] Schedule detail can open the output folder. + +### Agent Apps + +- [ ] Settings can register a custom Agent App. +- [ ] executable and argv are stored separately. +- [ ] shell execution is not used. +- [ ] deep test is required. +- [ ] custom Agent works from CLI, GUI, and Schedule. + +--- + +## 38. Final implementation and release order + +```text +0.6.0 +API compatibility contract +DB fixtures and migration +/v1 read API +Three-OS CI + ↓ +0.7.0 +Read-only GUI +shared history +version-skew handling + ↓ +0.8.0 +New Task +Job control +result/files/logs/events + ↓ +0.9.0 +Schedule core +Schedule CLI +unique persistent outputs +retention + ↓ +1.0.0 +Schedule GUI +output folder UI +auto-start + ↓ +1.1.0 +Custom Agent Apps + ↓ +1.2.0 +Packaging +LGPL evaluation +platform hardening +``` + +각 release는 previous release에서 실제 upgrade test를 수행한다. + +## 39. Final user flows + +### 38.1 CLI Job + +```text +relay "Research AI semiconductor news" --worker codex + ↓ +GUI Waiting + ↓ +GUI Running + ↓ +GUI Finished + Today + ✓ Research AI semiconductor news +``` + +### 38.2 Create Schedule + +```text +Finished + Today + ✓ Research AI semiconductor news + ↓ +Schedule this task + ↓ +Daily +09:00 and 13:00 +Asia/Seoul + ↓ +Create schedule + ↓ +Schedules + Daily AI semiconductor news + Next: Tomorrow 09:00 +``` + +### 38.3 Scheduled run + +```text +09:00 occurrence claimed + ↓ +Waiting + Daily AI semiconductor news · Scheduled + ↓ +Running + Daily AI semiconductor news · Codex + ↓ +Finished + Today + ✓ Daily AI semiconductor news +``` + +Schedule definition은 Schedules에 계속 남고 각 실행은 독립 Job으로 저장된다. + +### 39.4 Schedule output accumulation + +```text +Daily schedule runs at 09:00 + ↓ +D:\Reports\Daily Market\ +├─ 2026-07-24_0900+0900_run-a1b2c3\ +│ ├─ result.json +│ └─ artifacts\ +└─ 2026-07-25_0900+0900_run-d4e5f6\ + ├─ result.json + └─ artifacts\ +``` + +과거 결과는 Schedule retention 정책에 따라 유지되며 일반 Job workspace cleanup과 분리된다. + + +--- + +## 40. Final architecture + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ GUI │ +│ New Task · Sidebar · Job Detail · Schedule Detail · Settings · Agents │ +└─────────────────────────────────┬────────────────────────────────────────┘ + │ token-authenticated loopback RPC + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ Relay daemon │ +│ │ +│ Job Queue Schedule Engine Agent Registry API v1 + Health contract│ +│ │ │ │ │ │ +│ └────────────────────┴──────────────┬──────┴──────────────────┘ │ +│ ▼ │ +│ RelayEngine │ +│ │ │ +│ validation · fallback · delivery · cleanup │ +└───────────────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ SQLite │ +│ jobs · attempts · events · artifacts · schedules · schedule_runs │ +└───────────────────────────────────────┬──────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + Built-in Agents Custom Agent Apps Schedule inputs/outputs +``` + +--- + +## 41. Final recommendation + +개발은 GUI 화면부터 빠르게 만들기보다 다음 순서를 지켜야 한다. + +```text +Shared read API + ↓ +Read-only GUI + ↓ +Job control + ↓ +Schedule core + ↓ +Schedule GUI + ↓ +Custom Agent Apps +``` + +가장 중요한 원칙은 다음과 같다. + +1. GUI는 별도 실행 엔진이 아니다. +2. CLI 작업도 GUI에 반드시 보인다. +3. Schedule 실행은 항상 일반 Job이다. +4. Schedule은 성공한 replayable Job에서 만든다. +5. 반복 실행은 external path와 request ID를 그대로 상속하지 않는다. +6. Schedule occurrence는 DB unique claim으로 중복을 막는다. +7. custom Agent 확장은 기존 built-in Agent 안정화 이후에 한다. +8. UI는 쉬운 영어를 사용하되 내부의 보안·검증 규칙을 약화하지 않는다. +9. GUI와 daemon은 version/API compatibility를 매 연결마다 확인한다. +10. migration은 실제 0.5.0 fixture DB를 기준으로 검증한다. +11. Schedule output은 일반 workspace cleanup과 분리한다. +12. 각 G phase는 실제 release로 출하한다. diff --git a/pyproject.toml b/pyproject.toml index eb29fec..cd2b1c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ description = "Reliable delegation broker for Antigravity CLI, Claude Code, and requires-python = ">=3.11" dependencies = [] +[project.optional-dependencies] +gui = [ + "PySide6>=6.8,<7", + "tzdata>=2025.2", +] + [project.scripts] relay = "relay.cli:main" diff --git a/relay/__init__.py b/relay/__init__.py index 037ef80..03600ce 100644 --- a/relay/__init__.py +++ b/relay/__init__.py @@ -1,3 +1,3 @@ """Relay: reliable delegation broker for AI CLIs.""" -__version__ = "0.5.1" +__version__ = "0.6.0" diff --git a/relay/agent_registry.py b/relay/agent_registry.py new file mode 100644 index 0000000..7d7c138 --- /dev/null +++ b/relay/agent_registry.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .adapters import get_adapter +from .config import Config + +BUILTIN_AGENT_IDS = ("claude", "codex", "antigravity") + + +class AgentRegistry: + """Compatibility registry for the existing built-in and configured agents.""" + + def __init__(self, config: Config, spec_root: Path): + self.config = config + self.spec_root = spec_root + + def _agent_ids(self) -> list[str]: + configured = list(self.config.get("workers", {}).keys()) + return list(dict.fromkeys([*BUILTIN_AGENT_IDS, *configured])) + + def get_definition(self, agent_id: str) -> dict[str, Any]: + config = self.config.worker(agent_id) + if not config: + raise KeyError(agent_id) + return { + "agent_id": agent_id, + "display_name": str(config.get("display_name") or agent_id.capitalize()), + "description": str(config.get("description") or ""), + "enabled": bool(config.get("enabled", False)), + "builtin": agent_id in BUILTIN_AGENT_IDS, + "command": config.get("command"), + "default_model": config.get("default_model"), + } + + def list_agents(self) -> list[dict[str, Any]]: + return [self.get_definition(agent_id) for agent_id in self._agent_ids()] + + def list_enabled_agents(self) -> list[dict[str, Any]]: + return [agent for agent in self.list_agents() if agent["enabled"]] + + def get_adapter(self, agent_id: str): + self.get_definition(agent_id) + return get_adapter(agent_id, self.config.worker(agent_id), self.spec_root) diff --git a/relay/api.py b/relay/api.py new file mode 100644 index 0000000..493b658 --- /dev/null +++ b/relay/api.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import binascii +import json +from typing import Any + +from .db import Database +from .errors import RelayError + +RESULT_STATUS = { + "completed": "COMPLETED", + "partial": "PARTIAL", + "failed": "FAILED", + "cancelled": "CANCELLED", +} + + +def _encode_cursor(value: tuple[str, str]) -> str: + raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode_cursor(value: str | None) -> tuple[str, str] | None: + if not value: + return None + try: + padded = value + "=" * (-len(value) % 4) + decoded = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) + if not isinstance(decoded, list) or len(decoded) != 2 or not all(isinstance(item, str) for item in decoded): + raise ValueError + return decoded[0], decoded[1] + except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error): + raise RelayError("INVALID_REQUEST", "The cursor is invalid.") from None + + +def _summary(job: dict[str, Any], *, hide_task: bool) -> dict[str, Any]: + request: dict[str, Any] = {} + try: + if job.get("request_json"): + value = json.loads(job["request_json"]) + if isinstance(value, dict): + request = value + except json.JSONDecodeError: + pass + job.pop("request_json", None) + if hide_task: + job.pop("task_text", None) + job.pop("task_preview", None) + job["model"] = request.get("model") + return job + + +def list_jobs( + db: Database, + *, + bucket: str = "all", + status: str | None = None, + agent: str | None = None, + submitted_via: str | None = None, + query: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + limit: int = 50, + cursor: str | None = None, + hide_task: bool = True, +) -> dict[str, Any]: + if status: + status = RESULT_STATUS.get(status.lower(), status.upper()) + rows = db.list_jobs_page( + bucket=bucket, + status=status, + agent=agent, + submitted_via=submitted_via, + query=query, + date_from=date_from, + date_to=date_to, + limit=limit, + cursor=_decode_cursor(cursor), + ) + has_more = len(rows) > limit + rows = rows[:limit] + next_cursor = None + if has_more and rows: + sort_value = { + "waiting": rows[-1]["created_at"], + "running": rows[-1].get("started_at") or rows[-1]["created_at"], + "finished": rows[-1].get("completed_at") or rows[-1]["created_at"], + "all": rows[-1]["created_at"], + }[bucket] + next_cursor = _encode_cursor((sort_value, rows[-1]["job_id"])) + return { + "ok": True, + "jobs": [_summary(row, hide_task=hide_task) for row in rows], + "next_cursor": next_cursor, + "has_more": has_more, + } diff --git a/relay/cli.py b/relay/cli.py index 57303ce..983997f 100644 --- a/relay/cli.py +++ b/relay/cli.py @@ -60,6 +60,7 @@ def _preprocess(argv: list[str]) -> list[str]: def _add_request_args(parser: argparse.ArgumentParser, task_required: bool = False) -> None: parser.add_argument("task", nargs=None if task_required else "?", default="") + parser.add_argument("--title", help="Optional short title shown in job history") parser.add_argument("--task-file") parser.add_argument("--worker", choices=["auto", "claude", "codex", "antigravity"], default="auto") parser.add_argument("--fallback", action="store_true", default=None) @@ -103,6 +104,7 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--version", action="version", version=f"Relay {__version__}") + parser.add_argument("--gui", action="store_true", help="Open the optional read-only desktop GUI") sub = parser.add_subparsers(dest="command") run = sub.add_parser( @@ -419,6 +421,7 @@ def build_parser() -> argparse.ArgumentParser: def _request_from_args(args, config: Config) -> JobRequest: return JobRequest( task=args.task or "", + title=args.title, task_file=args.task_file, worker=args.worker, fallback=args.fallback, @@ -800,9 +803,33 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) config = Config() config.init() - db = Database(config.path_value("database_path")) - engine = RelayEngine(config, db) + if args.gui: + try: + from .gui.app import run_gui + + return run_gui(config) + except ImportError: + print( + 'GUI support is not installed. Run: pip install "relay-ai-cli-broker[gui]"', + file=sys.stderr, + ) + return 2 machine = bool(getattr(args, "machine", False)) + try: + db = Database(config.path_value("database_path")) + engine = RelayEngine(config, db) + except RelayError as err: + _emit( + { + "ok": False, + "status": "failed", + "error_code": err.code, + "error_message": err.message, + "details": err.details, + }, + machine, + ) + return 2 try: # Sync-only users still receive automatic retention cleanup when new work arrives. if args.command in {"run", "submit"}: @@ -814,7 +841,7 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "init": _emit({"ok": True, "config": str(config.init(force=args.force)), "home": str(config.home)}, machine) elif args.command == "run": - result = engine.run(_request_from_args(args, config)) + result = engine.run(_request_from_args(args, config), submitted_via="cli") _emit(result, machine) return _receipt_exit_code(result) elif args.command == "submit": diff --git a/relay/compatibility.py b/relay/compatibility.py new file mode 100644 index 0000000..fc685d6 --- /dev/null +++ b/relay/compatibility.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import hashlib +import os +from dataclasses import dataclass +from pathlib import Path + + +def relay_home_id(home: Path) -> str: + value = str(home.resolve()) + if os.name == "nt": + value = value.casefold() + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _version_tuple(value: str) -> tuple[int, ...]: + parts = value.strip().lstrip("v").split(".") + if not parts or any(not part.isdigit() for part in parts): + raise ValueError(f"Invalid Relay version: {value}") + return tuple(int(part) for part in parts) + + +@dataclass(frozen=True, slots=True) +class CompatibilityDecision: + mode: str + reason: str | None = None + + +def evaluate_compatibility( + health: dict, + *, + gui_version: str, + expected_relay_home_id: str | None = None, + required_api_version: str = "v1", + supported_schema_revision: int = 1, +) -> CompatibilityDecision: + if not health.get("ok"): + return CompatibilityDecision("read-only", "daemon health check failed") + if required_api_version not in health.get("api_versions", []): + return CompatibilityDecision("read-only", "daemon does not support the required API") + if "api_schema_revision" in health and health.get("api_schema_revision") != supported_schema_revision: + return CompatibilityDecision("read-only", "daemon API schema revision is not supported") + try: + if _version_tuple(gui_version) < _version_tuple(str(health.get("min_gui_version", "9999"))): + return CompatibilityDecision("read-only", "GUI version is below the daemon minimum") + except ValueError: + return CompatibilityDecision("read-only", "invalid version in health response") + if expected_relay_home_id and health.get("relay_home_id") != expected_relay_home_id: + return CompatibilityDecision("disconnected", "Relay Home does not match") + return CompatibilityDecision("normal") diff --git a/relay/config.py b/relay/config.py index 4eac888..36d2082 100644 --- a/relay/config.py +++ b/relay/config.py @@ -22,6 +22,7 @@ "max_concurrent_per_worker": 1, "fallback_enabled": True, "history_mode": "metadata", + "store_replayable_requests": True, "soft_dedup_window_minutes": 30, "soft_dedup_hermes_action": "reuse", "soft_dedup_human_action": "warn", diff --git a/relay/daemon.py b/relay/daemon.py index 27aef6f..37678bd 100644 --- a/relay/daemon.py +++ b/relay/daemon.py @@ -7,8 +7,12 @@ from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any +from urllib.parse import parse_qs, urlsplit +from . import __version__ +from .api import list_jobs from .cleanup import CleanupManager +from .compatibility import relay_home_id from .config import Config from .db import Database from .engine import RelayEngine @@ -109,6 +113,19 @@ def _json(self, status: int, value: Any) -> None: def _authorized(self) -> bool: return self.headers.get("X-Relay-Token", "") == self.daemon.token + def _api_error(self, status: int, code: str, message: str, *, details: dict | None = None) -> None: + self._json( + status, + { + "ok": False, + "error_code": code, + "message": message, + "action": None, + "details": details or {}, + "retryable": status >= 500, + }, + ) + def _body(self) -> dict: length = int(self.headers.get("Content-Length", "0")) return json.loads(self.rfile.read(length).decode("utf-8")) if length else {} @@ -117,7 +134,10 @@ def do_GET(self) -> None: if not self._authorized(): self._json(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"}) return - if self.path == "/health": + parsed = urlsplit(self.path) + path = parsed.path + params = parse_qs(parsed.query, keep_blank_values=True) + if path == "/health": self._json( 200, { @@ -125,12 +145,56 @@ def do_GET(self) -> None: "status": "running", "started_at": self.daemon.started_at, "cleanup": self.daemon.maintenance.manager.status(), + "daemon_version": __version__, + "api_versions": ["v1"], + "api_schema_revision": 1, + "min_gui_version": "0.7.0", + "relay_home_id": relay_home_id(self.daemon.config.home), }, ) return + if path == "/v1/jobs": + try: + + def value(name: str) -> str | None: + values = params.get(name, []) + return values[0] if values else None + + limit_value = value("limit") or "50" + limit = int(limit_value) + payload = list_jobs( + self.daemon.db, + bucket=value("bucket") or "all", + status=value("status") or value("result"), + agent=value("agent"), + submitted_via=value("source"), + query=value("q"), + date_from=value("from"), + date_to=value("to"), + limit=limit, + cursor=value("cursor"), + hide_task=self.daemon.engine._history_display_mode() != "full", + ) + self._json(HTTPStatus.OK, payload) + except (ValueError, RelayError) as err: + if isinstance(err, RelayError): + code, message = err.code, err.message + else: + code, message = "INVALID_REQUEST", str(err) + self._api_error(HTTPStatus.BAD_REQUEST, code, message) + except Exception as exc: + self._api_error(HTTPStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", str(exc)) + return + if path.startswith("/v1/jobs/"): + job_id = path[len("/v1/jobs/") :] + try: + self._json(HTTPStatus.OK, self.daemon.engine.show(job_id)) + except RelayError as err: + self._api_error(HTTPStatus.NOT_FOUND, err.code, err.message, details=err.details) + return for prefix, action in (("/status/", "status"), ("/result/", "result"), ("/show/", "show")): - if self.path.startswith(prefix): - job_id = self.path[len(prefix) :] + if path.startswith(prefix): + job_id = path[len(prefix) :] try: if action == "show": value = self.daemon.engine.show(job_id) @@ -149,7 +213,9 @@ def do_POST(self) -> None: try: if self.path == "/submit": request = JobRequest.from_dict(self._body()) - self._json(200, self.daemon.engine.queue(request)) + caller = request.caller.strip().lower() + submitted_via = "hermes" if caller == "hermes" else "schedule" if caller == "schedule" else "cli" + self._json(200, self.daemon.engine.queue(request, submitted_via=submitted_via)) return if self.path.startswith("/cancel/"): job_id = self.path.split("/")[-1] diff --git a/relay/db.py b/relay/db.py index 09c7b0a..7666009 100644 --- a/relay/db.py +++ b/relay/db.py @@ -4,11 +4,15 @@ import sqlite3 from collections.abc import Iterator from contextlib import contextmanager +from datetime import UTC, datetime from pathlib import Path from typing import Any +from .errors import RelayError from .util import utc_now +CURRENT_SCHEMA_VERSION = 1 + SCHEMA = """ PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; @@ -16,8 +20,11 @@ job_id TEXT PRIMARY KEY, request_id TEXT, caller TEXT NOT NULL, + submitted_via TEXT NOT NULL DEFAULT 'legacy', task_hash TEXT NOT NULL, task_text TEXT, + task_preview TEXT, + title TEXT, requested_worker TEXT NOT NULL, actual_worker TEXT, format TEXT NOT NULL, @@ -34,12 +41,18 @@ created_at TEXT NOT NULL, started_at TEXT, completed_at TEXT, + schedule_id TEXT, + scheduled_for TEXT, + replayable INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_request_id ON jobs(request_id) WHERE request_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_jobs_task_hash_created ON jobs(task_hash, created_at); CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at ON jobs(completed_at); +CREATE INDEX IF NOT EXISTS idx_jobs_submitted_via ON jobs(submitted_via); +CREATE INDEX IF NOT EXISTS idx_jobs_schedule ON jobs(schedule_id, created_at); CREATE TABLE IF NOT EXISTS attempts ( attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -96,10 +109,48 @@ CREATE INDEX IF NOT EXISTS idx_audits_worker ON capability_audits(worker, audit_time); """ +MIGRATION_0_TO_1 = """ +ALTER TABLE jobs ADD COLUMN submitted_via TEXT NOT NULL DEFAULT 'legacy'; +ALTER TABLE jobs ADD COLUMN task_preview TEXT; +ALTER TABLE jobs ADD COLUMN title TEXT; +ALTER TABLE jobs ADD COLUMN schedule_id TEXT; +ALTER TABLE jobs ADD COLUMN scheduled_for TEXT; +ALTER TABLE jobs ADD COLUMN replayable INTEGER NOT NULL DEFAULT 1; +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at ON jobs(completed_at); +CREATE INDEX IF NOT EXISTS idx_jobs_submitted_via ON jobs(submitted_via); +CREATE INDEX IF NOT EXISTS idx_jobs_schedule ON jobs(schedule_id, created_at); +""" + +LEGACY_JOB_COLUMNS = { + "job_id", + "request_id", + "caller", + "task_hash", + "task_text", + "requested_worker", + "actual_worker", + "format", + "profile", + "output_path", + "artifact_path", + "status", + "result_status", + "error_code", + "error_message", + "fallback_enabled", + "request_json", + "receipt_json", + "created_at", + "started_at", + "completed_at", + "updated_at", +} + class Database: def __init__(self, path: Path): self.path = path + self.last_backup_path: Path | None = None self.path.parent.mkdir(parents=True, exist_ok=True) self.migrate() @@ -116,7 +167,100 @@ def connect(self) -> Iterator[sqlite3.Connection]: def migrate(self) -> None: with self.connect() as conn: - conn.executescript(SCHEMA) + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + } + if version > CURRENT_SCHEMA_VERSION: + raise RelayError( + "DATABASE_TOO_NEW", + f"Database schema version {version} is newer than supported version {CURRENT_SCHEMA_VERSION}", + ) + if not tables: + conn.executescript(SCHEMA) + conn.execute(f"PRAGMA user_version={CURRENT_SCHEMA_VERSION}") + return + if version == 0: + self._validate_legacy_schema(conn, tables) + self.last_backup_path = self._create_backup() + try: + conn.execute("BEGIN") + for statement in MIGRATION_0_TO_1.split(";"): + if statement.strip(): + conn.execute(statement) + conn.execute(f"PRAGMA user_version={CURRENT_SCHEMA_VERSION}") + self._backfill_job_metadata(conn) + conn.execute("COMMIT") + except Exception as exc: + conn.rollback() + backup = f" Backup: {self.last_backup_path}" if self.last_backup_path else "" + raise RelayError("DATABASE_MIGRATION_FAILED", f"Database migration failed.{backup}") from exc + if version == CURRENT_SCHEMA_VERSION: + self._backfill_job_metadata(conn) + return + + def _validate_legacy_schema(self, conn: sqlite3.Connection, tables: set[str]) -> None: + required_tables = {"jobs", "attempts", "artifacts", "events", "capability_audits"} + missing_tables = required_tables - tables + if missing_tables: + missing = ", ".join(sorted(missing_tables)) + raise RelayError( + "DATABASE_MIGRATION_FAILED", + f"Database is not a supported Relay legacy schema; missing tables: {missing}", + ) + columns = {row[1] for row in conn.execute("PRAGMA table_info(jobs)").fetchall()} + missing_columns = LEGACY_JOB_COLUMNS - columns + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise RelayError( + "DATABASE_MIGRATION_FAILED", + f"Database is not a supported Relay legacy schema; missing columns: {missing}", + ) + + def _create_backup(self) -> Path: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + backup_path = self.path.with_name(f"{self.path.name}.backup-{stamp}.db") + suffix = 1 + while backup_path.exists(): + backup_path = self.path.with_name(f"{self.path.name}.backup-{stamp}-{suffix}.db") + suffix += 1 + source = sqlite3.connect(self.path) + target = sqlite3.connect(backup_path) + try: + source.backup(target) + finally: + target.close() + source.close() + return backup_path + + @staticmethod + def _title_from_task(task: str, job_id: str) -> str: + first_line = next((line.strip() for line in task.splitlines() if line.strip()), "") + value = " ".join((first_line or f"Job {job_id[:8]}").split()) + return value if len(value) <= 60 else value[:59].rstrip() + "…" + + def _backfill_job_metadata(self, conn: sqlite3.Connection) -> None: + rows = conn.execute( + "SELECT job_id,task_text,title,task_preview FROM jobs " + "WHERE title IS NULL OR (task_preview IS NULL AND task_text IS NOT NULL)" + ).fetchall() + for row in rows: + changes: list[str] = [] + values: list[Any] = [] + task = row[1] + if row[2] is None: + changes.append("title=?") + values.append(self._title_from_task(task or "", row[0])) + if row[3] is None and task is not None: + changes.append("task_preview=?") + normalized = " ".join(task.split()) + values.append(normalized if len(normalized) <= 240 else normalized[:239].rstrip() + "…") + if changes: + values.append(row[0]) + conn.execute(f"UPDATE jobs SET {','.join(changes)} WHERE job_id=?", values) def create_job(self, row: dict[str, Any]) -> None: now = utc_now() @@ -180,6 +324,91 @@ def list_jobs(self, status: str | None = None, limit: int = 50) -> list[dict[str rows = conn.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() return [dict(r) for r in rows] + def list_jobs_page( + self, + *, + bucket: str = "all", + status: str | None = None, + agent: str | None = None, + submitted_via: str | None = None, + query: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + limit: int = 50, + cursor: tuple[str, str] | None = None, + ) -> list[dict[str, Any]]: + bucket_statuses = { + "waiting": ("CREATED", "QUEUED"), + "running": ("PREPARING", "RUNNING", "VALIDATING", "DELIVERING", "CANCEL_REQUESTED"), + "finished": ("COMPLETED", "PARTIAL", "FAILED", "CANCELLED"), + "all": (), + } + if bucket not in bucket_statuses: + raise ValueError(f"Unsupported job bucket: {bucket}") + if limit < 1 or limit > 200: + raise ValueError("Job limit must be between 1 and 200") + + where: list[str] = [] + params: list[Any] = [] + statuses = bucket_statuses[bucket] + if statuses: + placeholders = ",".join("?" for _ in statuses) + where.append(f"status IN ({placeholders})") + params.extend(statuses) + if status: + where.append("status=?") + params.append(status) + if agent: + where.append("(requested_worker=? OR actual_worker=?)") + params.extend([agent, agent]) + if submitted_via: + where.append("submitted_via=?") + params.append(submitted_via) + if query: + escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped}%" + where.append( + "(job_id LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR title LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR task_preview LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR requested_worker LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR actual_worker LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR profile LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR error_code LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR COALESCE(json_extract(CASE WHEN json_valid(request_json) THEN request_json ELSE '{}' END, '$.model'), '') " + "LIKE ? ESCAPE '\\' COLLATE NOCASE " + "OR EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(request_json) THEN request_json ELSE '{}' END, '$.attachments') " + "WHERE CAST(value AS TEXT) LIKE ? ESCAPE '\\' COLLATE NOCASE))" + ) + params.extend([pattern] * 9) + if date_from: + where.append("COALESCE(completed_at, created_at)>=?") + params.append(date_from) + if date_to: + where.append("COALESCE(completed_at, created_at)<=?") + params.append(date_to) + + sort_expression = { + "waiting": "created_at", + "running": "COALESCE(started_at, created_at)", + "finished": "COALESCE(completed_at, created_at)", + "all": "created_at", + }[bucket] + ascending = bucket == "waiting" + if cursor: + operator = ">" if ascending else "<" + where.append(f"({sort_expression} {operator} ? OR ({sort_expression}=? AND job_id {operator} ?))") + params.extend([cursor[0], cursor[0], cursor[1]]) + + sql = "SELECT * FROM jobs" + if where: + sql += " WHERE " + " AND ".join(where) + sql += f" ORDER BY {sort_expression} {'ASC' if ascending else 'DESC'}, job_id {'ASC' if ascending else 'DESC'} LIMIT ?" + params.append(limit + 1) + with self.connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [dict(row) for row in rows] + def create_attempt(self, job_id: str, worker: str, **values: Any) -> int: row = { "job_id": job_id, @@ -270,18 +499,32 @@ def recover_interrupted(self) -> int: with self.connect() as conn: cursor = conn.execute( "UPDATE jobs SET status='FAILED',error_code='DAEMON_RESTARTED'," - "error_message='Daemon restarted while job was active',completed_at=?,updated_at=? " + "error_message='Daemon restarted while job was active',completed_at=?,updated_at=?," + "request_json=CASE WHEN replayable=0 THEN '{}' ELSE request_json END," + "task_text=CASE WHEN replayable=0 THEN NULL ELSE task_text END," + "task_preview=CASE WHEN replayable=0 THEN NULL ELSE task_preview END " "WHERE status IN ('PREPARING','RUNNING','VALIDATING','DELIVERING','CANCEL_REQUESTED')", (utc_now(), utc_now()), ) return cursor.rowcount + def scrub_non_replayable(self, job_id: str) -> None: + with self.connect() as conn: + conn.execute( + "UPDATE jobs SET request_json='{}',task_text=NULL,task_preview=NULL,updated_at=? " + "WHERE job_id=? AND replayable=0", + (utc_now(), job_id), + ) + def request_cancel(self, job_id: str) -> bool: with self.connect() as conn: cursor = conn.execute( "UPDATE jobs SET status=CASE WHEN status='QUEUED' THEN 'CANCELLED' " "ELSE 'CANCEL_REQUESTED' END, completed_at=CASE WHEN status='QUEUED' " - "THEN ? ELSE completed_at END, updated_at=? " + "THEN ? ELSE completed_at END, updated_at=?," + "request_json=CASE WHEN status='QUEUED' AND replayable=0 THEN '{}' ELSE request_json END," + "task_text=CASE WHEN status='QUEUED' AND replayable=0 THEN NULL ELSE task_text END," + "task_preview=CASE WHEN status='QUEUED' AND replayable=0 THEN NULL ELSE task_preview END " "WHERE job_id=? AND status IN ('QUEUED','PREPARING','RUNNING')", (utc_now(), utc_now(), job_id), ) diff --git a/relay/engine.py b/relay/engine.py index 1631d11..beed7e2 100644 --- a/relay/engine.py +++ b/relay/engine.py @@ -8,8 +8,8 @@ from pathlib import Path from typing import Any -from .adapters import get_adapter from .adapters.base import AdapterContext +from .agent_registry import AgentRegistry from .config import Config from .db import Database from .delivery import atomic_deliver_pair @@ -57,6 +57,9 @@ "CAPABILITY_AUDIT_FAILED", } +VALID_CALLERS = {"human", "hermes", "service", "schedule"} +VALID_SUBMITTED_VIA = {"cli", "gui", "hermes", "schedule", "legacy"} + class RelayEngine: def __init__(self, config: Config | None = None, db: Database | None = None): @@ -64,12 +67,18 @@ def __init__(self, config: Config | None = None, db: Database | None = None): self.config.init() self.db = db or Database(self.config.path_value("database_path")) self.spec_root = self.config.path_value("adapter_spec_root") + self.agent_registry = AgentRegistry(self.config, self.spec_root) self._running_processes: dict[str, threading.Event] = {} self._lock = threading.Lock() per_worker = int(self.config.get("max_concurrent_per_worker", 1)) self._worker_slots = {name: threading.Semaphore(per_worker) for name in ("claude", "codex", "antigravity")} def _resolve_request_task(self, request: JobRequest) -> None: + request.caller = request.caller.strip().lower() + if request.caller == "daemon": + request.caller = "service" + if request.caller not in VALID_CALLERS: + raise RelayError("INVALID_REQUEST", f"Unsupported caller: {request.caller}") if request.task_file: path = safe_resolve(Path(request.task_file)) if not path.is_file(): @@ -83,6 +92,35 @@ def _resolve_request_task(self, request: JobRequest) -> None: if request.worker not in {"auto", "claude", "codex", "antigravity"}: raise RelayError("INVALID_REQUEST", f"Unsupported worker: {request.worker}") + def _history_display_mode(self) -> str: + mode = str(self.config.get("history_display_mode") or self.config.get("history_mode", "metadata")) + if mode not in {"full", "metadata"}: + return "metadata" + return mode + + @staticmethod + def _short_text(value: str, limit: int) -> str: + normalized = " ".join(value.split()) + if len(normalized) <= limit: + return normalized + return normalized[: max(0, limit - 1)].rstrip() + "…" + + def _job_title_and_preview(self, request: JobRequest, job_id: str) -> tuple[str, str | None]: + explicit = (request.title or "").strip() + first_line = next((line.strip() for line in request.task.splitlines() if line.strip()), "") + title = self._short_text(explicit or first_line or f"Job {job_id[:8]}", 60) + preview = self._short_text(request.task, 240) if self._history_display_mode() == "full" else None + return title, preview + + @staticmethod + def _submitted_via(request: JobRequest, submitted_via: str | None) -> str: + if submitted_via is None: + return "hermes" if request.caller == "hermes" else "legacy" + value = submitted_via.strip().lower() + if value not in VALID_SUBMITTED_VIA: + raise RelayError("INVALID_REQUEST", f"Unsupported submitted_via: {submitted_via}") + return value + def _default_paths(self, job_id: str, request: JobRequest) -> tuple[Path, Path]: ext = ".json" if request.result_format == "json" else ".txt" output = ( @@ -97,7 +135,9 @@ def _default_paths(self, job_id: str, request: JobRequest) -> tuple[Path, Path]: ) return output, artifacts - def create_job(self, request: JobRequest, queued: bool = False) -> tuple[dict[str, Any], bool]: + def create_job( + self, request: JobRequest, queued: bool = False, submitted_via: str | None = None + ) -> tuple[dict[str, Any], bool]: self._resolve_request_task(request) self.config.reload() if request.caller.lower() in {"hermes", "service", "daemon"} and not self.config.get( @@ -142,13 +182,18 @@ def create_job(self, request: JobRequest, queued: bool = False) -> tuple[dict[st output, artifacts = self._default_paths(job_id, request) validate_requested_paths(self.config, request.caller, output, artifacts) fallback = self.config.get("fallback_enabled", True) if request.fallback is None else request.fallback - task_text = request.task if self.config.get("history_mode") == "full" else None + title, task_preview = self._job_title_and_preview(request, job_id) + replayable = bool(self.config.get("store_replayable_requests", True)) + task_text = request.task if self._history_display_mode() == "full" else None row = { "job_id": job_id, "request_id": request.request_id, "caller": request.caller, + "submitted_via": self._submitted_via(request, submitted_via), "task_hash": computed_hash, "task_text": task_text, + "task_preview": task_preview, + "title": title, "requested_worker": request.worker, "format": request.result_format, "profile": request.profile, @@ -157,6 +202,7 @@ def create_job(self, request: JobRequest, queued: bool = False) -> tuple[dict[st "status": "QUEUED" if queued else "CREATED", "fallback_enabled": 1 if fallback else 0, "request_json": json.dumps(request.to_dict(), ensure_ascii=False), + "replayable": 1 if replayable else 0, } try: self.db.create_job(row) @@ -246,7 +292,7 @@ def execute_job(self, job_id: str) -> dict[str, Any]: err = RelayError("WORKER_DISABLED", f"Worker is disabled: {worker}") errors.append({"worker": worker, "code": err.code, "message": err.message}) continue - adapter = get_adapter(worker, worker_cfg, self.spec_root) + adapter = self.agent_registry.get_adapter(worker) try: spec = adapter.require_verified() except RelayError as err: @@ -328,6 +374,7 @@ def execute_job(self, job_id: str) -> dict[str, Any]: self.db.update_job( job_id, status="CANCELLED", error_code=code, error_message=message, completed_at=utc_now() ) + self.db.scrub_non_replayable(job_id) return self.receipt(job_id) errors.append({"worker": worker, "code": code, "message": message}) if job["fallback_enabled"] and code in TECHNICAL_FALLBACK_CODES: @@ -428,6 +475,7 @@ def execute_job(self, job_id: str) -> dict[str, Any]: self.db.add_event(job_id, "JOB_COMPLETED", receipt) json_dump(output_path.parent / "relay-receipt.json", receipt) json_dump(artifact_path / "manifest.json", {"job_id": job_id, "artifacts": artifact_records}) + self.db.scrub_non_replayable(job_id) return receipt except RelayError as err: self.db.update_attempt( @@ -471,18 +519,19 @@ def _fail_job(self, job_id: str, code: str, message: str, errors: list[dict[str, completed_at=utc_now(), ) self.db.add_event(job_id, "JOB_FAILED", receipt) + self.db.scrub_non_replayable(job_id) return receipt - def run(self, request: JobRequest) -> dict[str, Any]: - job, reused = self.create_job(request, queued=False) + def run(self, request: JobRequest, submitted_via: str | None = None) -> dict[str, Any]: + job, reused = self.create_job(request, queued=False, submitted_via=submitted_via) if reused: receipt = self.receipt(job["job_id"]) receipt["deduplicated"] = True return receipt return self.execute_job(job["job_id"]) - def queue(self, request: JobRequest) -> dict[str, Any]: - job, reused = self.create_job(request, queued=True) + def queue(self, request: JobRequest, submitted_via: str | None = None) -> dict[str, Any]: + job, reused = self.create_job(request, queued=True, submitted_via=submitted_via) return { "ok": True, "status": "reused" if reused else "queued", @@ -518,14 +567,17 @@ def show(self, job_id: str) -> dict[str, Any]: job["events"] = self.db.events_for_job(job_id) job["artifacts"] = self.db.artifacts_for_job(job_id) job.pop("request_json", None) - if self.config.get("history_mode") != "full": + if self._history_display_mode() != "full": job.pop("task_text", None) + job.pop("task_preview", None) return job def rerun(self, job_id: str, force_new: bool = True) -> dict[str, Any]: job = self.db.get_job(job_id) if not job: raise RelayError("JOB_NOT_FOUND", f"Job not found: {job_id}") + if not bool(job.get("replayable", 1)) or job.get("request_json") in (None, "", "{}"): + raise RelayError("JOB_NOT_REPLAYABLE", "This job did not save a replayable request.") request = JobRequest.from_dict(json.loads(job["request_json"])) request.request_id = None request.force_new = force_new diff --git a/relay/errors.py b/relay/errors.py index f7b5d95..aab50d3 100644 --- a/relay/errors.py +++ b/relay/errors.py @@ -44,6 +44,9 @@ def __str__(self) -> str: "ALL_WORKERS_FAILED", "DUPLICATE_REQUEST", "JOB_NOT_FOUND", + "JOB_NOT_REPLAYABLE", + "DATABASE_MIGRATION_FAILED", + "DATABASE_TOO_NEW", "DAEMON_UNAVAILABLE", "DAEMON_RESTARTED", "DELIVERY_FAILED", diff --git a/relay/gui/__init__.py b/relay/gui/__init__.py new file mode 100644 index 0000000..ce02326 --- /dev/null +++ b/relay/gui/__init__.py @@ -0,0 +1 @@ +"""Optional PySide6 desktop client for Relay.""" diff --git a/relay/gui/app.py b/relay/gui/app.py new file mode 100644 index 0000000..d1f6ba9 --- /dev/null +++ b/relay/gui/app.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QApplication, QMessageBox + +from .. import __version__ +from ..cli import _ensure_daemon +from ..compatibility import relay_home_id +from ..errors import RelayError +from .main_window import MainWindow + + +def run_gui(config) -> int: + app = QApplication.instance() or QApplication([]) + try: + _ensure_daemon(config) + except RelayError as exc: + if not config.get("daemon_auto_start", True): + pass + elif exc.code != "DAEMON_UNAVAILABLE": + QMessageBox.critical(None, "Relay-agent", exc.message) + window = MainWindow(config, gui_version=__version__, expected_home_id=relay_home_id(config.home)) + window.show() + return app.exec() diff --git a/relay/gui/main_window.py b/relay/gui/main_window.py new file mode 100644 index 0000000..cd388ed --- /dev/null +++ b/relay/gui/main_window.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from html import escape +from urllib.parse import urlencode + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QPushButton, + QSplitter, + QStackedWidget, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from ..compatibility import evaluate_compatibility +from .rpc_client import GuiRpcClient +from .state import GuiState + + +class MainWindow(QMainWindow): + def __init__(self, config, *, gui_version: str, expected_home_id: str): + super().__init__() + self.config = config + self.gui_version = gui_version + self.expected_home_id = expected_home_id + self.state = GuiState(config) + self.client = GuiRpcClient(config) + self.client.response.connect(self._handle_response) + self.pending: dict[int, str] = {} + self.jobs: dict[str, dict] = {} + self.current_mode = "disconnected" + self.current_filter = "" + self.finished_cursor: str | None = None + + self.setWindowTitle("Relay-agent") + self.resize(1280, 720) + self._build_ui() + self._restore_state() + + self.health_timer = QTimer(self) + self.health_timer.timeout.connect(self._refresh_health) + self.health_timer.start(5000) + self.active_timer = QTimer(self) + self.active_timer.timeout.connect(self._refresh_active) + self.active_timer.start(1000) + self.finished_timer = QTimer(self) + self.finished_timer.timeout.connect(self._refresh_finished) + self.finished_timer.start(3000) + self._refresh_health() + + def _build_ui(self) -> None: + root = QWidget() + outer = QVBoxLayout(root) + header = QFrame() + header_layout = QVBoxLayout(header) + title_row = QFrame() + title_layout = QVBoxLayout(title_row) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.addWidget(QLabel("Relay-agent")) + self.daemon_label = QLabel("Daemon: Connecting") + title_layout.addWidget(self.daemon_label) + header_layout.addWidget(title_row) + self.banner = QLabel() + self.banner.setWordWrap(True) + self.banner.hide() + header_layout.addWidget(self.banner) + outer.addWidget(header) + + self.splitter = QSplitter(Qt.Horizontal) + self.splitter.setObjectName("mainSplitter") + self.sidebar = QWidget() + sidebar_layout = QVBoxLayout(self.sidebar) + sidebar_layout.setContentsMargins(4, 4, 4, 4) + self.search = QLineEdit() + self.search.setPlaceholderText("Search finished jobs...") + self.search.textChanged.connect(self._on_filter_changed) + sidebar_layout.addWidget(self.search) + self.result_filter = self._combo("Result", ["All", "Completed", "Partial", "Failed", "Cancelled"]) + self.agent_filter = self._combo("Agent", ["All", "Claude", "Codex", "Antigravity"]) + self.source_filter = self._combo("Source", ["All", "Command line", "GUI", "Hermes", "Schedule"]) + self.date_filter = self._combo("Date", ["Any time", "Today", "Last 7 days", "Last 30 days"]) + sidebar_layout.addWidget(self.result_filter) + sidebar_layout.addWidget(self.agent_filter) + sidebar_layout.addWidget(self.source_filter) + sidebar_layout.addWidget(self.date_filter) + for combo in (self.result_filter, self.agent_filter, self.source_filter, self.date_filter): + combo.currentIndexChanged.connect(self._on_filter_changed) + self.job_list = QListWidget() + self.job_list.itemClicked.connect(self._select_item) + sidebar_layout.addWidget(self.job_list, 1) + self.load_more = QPushButton("Load more") + self.load_more.clicked.connect(self._load_more_finished) + self.load_more.setEnabled(False) + sidebar_layout.addWidget(self.load_more) + self.splitter.addWidget(self.sidebar) + + self.detail_stack = QStackedWidget() + self.empty_detail = QLabel("Select a job to view its overview.") + self.empty_detail.setAlignment(Qt.AlignCenter) + self.detail_stack.addWidget(self.empty_detail) + self.detail = QTextBrowser() + self.detail.setOpenExternalLinks(False) + self.detail_stack.addWidget(self.detail) + self.splitter.addWidget(self.detail_stack) + self.splitter.setSizes([320, 960]) + outer.addWidget(self.splitter, 1) + self.setCentralWidget(root) + self.statusBar().showMessage(f"Relay Home: {self.config.home}") + + @staticmethod + def _combo(prefix: str, values: list[str]) -> QComboBox: + combo = QComboBox() + combo.setObjectName(prefix.lower().replace(" ", "_")) + combo.addItems(values) + combo.setToolTip(prefix) + return combo + + def _restore_state(self) -> None: + geometry = self.state.value("window/geometry") + if geometry: + self.restoreGeometry(geometry) + splitter_state = self.state.value("window/splitter_state") + if splitter_state: + self.splitter.restoreState(splitter_state) + self.search.setText(str(self.state.value("filters/search", ""))) + + def closeEvent(self, event) -> None: + self.state.set_value("window/geometry", self.saveGeometry()) + self.state.set_value("window/splitter_state", self.splitter.saveState()) + self.state.set_value("filters/search", self.search.text()) + super().closeEvent(event) + + def _request(self, kind: str, path: str) -> None: + self.pending[self.client.get(path)] = kind + + def _refresh_health(self) -> None: + self._request("health", "/health") + + def _refresh_active(self) -> None: + if self.current_mode == "normal": + self._request("waiting", "/v1/jobs?bucket=waiting&limit=200") + self._request("running", "/v1/jobs?bucket=running&limit=200") + + def _refresh_finished(self) -> None: + if self.current_mode == "normal": + self.finished_cursor = None + self._request("finished", self._finished_path()) + + def _load_more_finished(self) -> None: + if self.current_mode == "normal" and self.finished_cursor: + self._request("finished_more", self._finished_path(cursor=self.finished_cursor)) + + def _finished_path(self, *, cursor: str | None = None) -> str: + query: dict[str, str] = {"bucket": "finished", "limit": "50"} + if self.search.text().strip(): + query["q"] = self.search.text().strip() + if self.result_filter.currentIndex(): + query["result"] = self.result_filter.currentText().lower() + if self.agent_filter.currentIndex(): + query["agent"] = self.agent_filter.currentText().lower() + if self.source_filter.currentIndex(): + query["source"] = {"Command line": "cli", "GUI": "gui", "Hermes": "hermes", "Schedule": "schedule"}[ + self.source_filter.currentText() + ] + now = datetime.now().astimezone() + date_choice = self.date_filter.currentText() + if date_choice != "Any time": + days = {"Today": 0, "Last 7 days": 7, "Last 30 days": 30}[date_choice] + start = now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days) + query["from"] = start.astimezone().isoformat() + query["to"] = now.astimezone().isoformat() + if cursor: + query["cursor"] = cursor + return "/v1/jobs?" + urlencode(query) + + def _on_filter_changed(self) -> None: + if self.current_mode == "normal": + self.finished_cursor = None + self._refresh_finished() + + def _handle_response(self, request_id: int, payload, error) -> None: + kind = self.pending.pop(request_id, None) + if kind is None: + return + if error or not isinstance(payload, dict): + if kind == "health": + self._set_connection("disconnected", "Relay daemon is unavailable. Retrying...") + return + if kind == "health": + decision = evaluate_compatibility( + payload, + gui_version=self.gui_version, + expected_relay_home_id=self.expected_home_id, + ) + self._set_connection(decision.mode, decision.reason) + if decision.mode == "normal": + self._refresh_active() + self._refresh_finished() + return + if kind == "detail": + self._show_detail(payload) + return + if kind == "finished": + self._remove_statuses({"COMPLETED", "PARTIAL", "FAILED", "CANCELLED"}) + elif kind == "finished_more": + pass + else: + self._remove_statuses( + { + "CREATED", + "QUEUED", + "PREPARING", + "RUNNING", + "VALIDATING", + "DELIVERING", + "CANCEL_REQUESTED", + } + if kind in {"waiting", "running"} + else set() + ) + for job in payload.get("jobs", []): + if job.get("job_id"): + self.jobs[job["job_id"]] = job + if kind in {"finished", "finished_more"}: + self.finished_cursor = payload.get("next_cursor") + self.load_more.setEnabled(bool(payload.get("has_more"))) + self._render_jobs() + + def _remove_statuses(self, statuses: set[str]) -> None: + for job_id in [job_id for job_id, job in self.jobs.items() if job.get("status") in statuses]: + del self.jobs[job_id] + + def _set_connection(self, mode: str, reason: str | None = None) -> None: + self.current_mode = mode + self.daemon_label.setText("Daemon: Running" if mode == "normal" else "Daemon: Disconnected") + if mode == "normal": + self.banner.hide() + else: + self.banner.setText(f"Read-only compatibility mode: {reason or 'daemon compatibility is unavailable'}") + self.banner.show() + + def _render_jobs(self) -> None: + selected = self.job_list.currentItem().data(Qt.UserRole) if self.job_list.currentItem() else None + self.job_list.clear() + groups = ( + ("Waiting", {"CREATED", "QUEUED"}, "created_at"), + ("Running", {"PREPARING", "RUNNING", "VALIDATING", "DELIVERING", "CANCEL_REQUESTED"}, "started_at"), + ("Finished", {"COMPLETED", "PARTIAL", "FAILED", "CANCELLED"}, "completed_at"), + ) + for group_name, statuses, date_key in groups: + rows = [job for job in self.jobs.values() if job.get("status") in statuses] + if group_name == "Finished": + rows = [job for job in rows if self._matches_finished_filters(job)] + rows.sort(key=lambda job: job.get(date_key) or job.get("created_at") or "", reverse=True) + if not rows: + continue + header = QListWidgetItem(f"▾ {group_name} · {len(rows)}") + header.setFlags(Qt.ItemIsEnabled) + self.job_list.addItem(header) + date_groups = {"All": rows} + if group_name == "Finished": + date_groups = {} + for job in rows: + date_groups.setdefault(self._local_date(job.get(date_key) or job.get("created_at")), []).append(job) + for date_name, date_rows in date_groups.items(): + if group_name == "Finished": + date_item = QListWidgetItem(f"▾ {date_name} · {len(date_rows)}") + date_item.setFlags(Qt.ItemIsEnabled) + self.job_list.addItem(date_item) + for job in date_rows: + title = job.get("title") or job.get("job_id", "Job")[:8] + item = QListWidgetItem(f"{self._status_icon(job.get('status'))} {title}") + item.setData(Qt.UserRole, job.get("job_id")) + item.setToolTip(job.get("task_preview") or job.get("job_id", "")) + self.job_list.addItem(item) + if job.get("job_id") == selected: + self.job_list.setCurrentItem(item) + + @staticmethod + def _local_date(value: str | None) -> str: + if not value: + return "Unknown date" + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%b %d, %Y") + except ValueError: + return value[:10] + + def _matches_finished_filters(self, job: dict) -> bool: + query = self.search.text().strip().casefold() + haystack = " ".join( + str(job.get(key) or "") for key in ("title", "task_preview", "job_id", "requested_worker", "actual_worker") + ) + if query and query not in haystack.casefold(): + return False + result = self.result_filter.currentText() + if result != "All" and job.get("status", "").casefold() != result.casefold(): + return False + agent = self.agent_filter.currentText() + if agent != "All" and agent.casefold() not in { + str(job.get("requested_worker") or "").casefold(), + str(job.get("actual_worker") or "").casefold(), + }: + return False + source = self.source_filter.currentText() + source_value = {"Command line": "cli", "GUI": "gui", "Hermes": "hermes", "Schedule": "schedule"}.get( + source, source.casefold() + ) + if source != "All" and job.get("submitted_via", "").casefold() != source_value: + return False + return True + + @staticmethod + def _status_icon(status: str | None) -> str: + return {"COMPLETED": "✓", "PARTIAL": "◐", "FAILED": "×", "CANCELLED": "—"}.get(status or "", "●") + + def _select_item(self, item: QListWidgetItem) -> None: + job_id = item.data(Qt.UserRole) + if not job_id: + return + self._show_detail(self.jobs.get(job_id, {})) + if self.current_mode == "normal": + self._request("detail", f"/v1/jobs/{job_id}") + + def _show_detail(self, job: dict) -> None: + if not job or not job.get("job_id"): + self.detail_stack.setCurrentWidget(self.empty_detail) + return + fields = ( + ("Status", job.get("status")), + ("Requested agent", job.get("requested_worker")), + ("Actual agent", job.get("actual_worker") or job.get("requested_worker")), + ("Model", job.get("model")), + ("Profile", job.get("profile")), + ("Created", job.get("created_at")), + ("Started", job.get("started_at")), + ("Finished", job.get("completed_at")), + ("Source", job.get("submitted_via")), + ("Result file", job.get("output_path")), + ("Files folder", job.get("artifact_path")), + ("Job ID", job.get("job_id")), + ) + body = "

{}

{}
".format( + escape(str(job.get("title") or job.get("job_id"))), + "".join( + f"{escape(str(key))}{escape(str(value or '—'))}" + for key, value in fields + ), + ) + self.detail.setHtml(body) + self.detail_stack.setCurrentWidget(self.detail) diff --git a/relay/gui/rpc_client.py b/relay/gui/rpc_client.py new file mode 100644 index 0000000..3de3f48 --- /dev/null +++ b/relay/gui/rpc_client.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from typing import Any + +from PySide6.QtCore import QObject, QUrl, Signal +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest + + +class GuiRpcClient(QObject): + """Small asynchronous client used by the GUI main thread.""" + + response = Signal(int, object, object) + + def __init__(self, config): + super().__init__() + self.config = config + self.manager = QNetworkAccessManager(self) + self._sequence = 0 + + def get(self, path: str, *, timeout_ms: int = 5000) -> int: + self._sequence += 1 + request_id = self._sequence + request = QNetworkRequest( + QUrl(f"http://{self.config.get('daemon_host')}:{self.config.get('daemon_port')}{path}") + ) + token_path = self.config.path_value("runtime_root") / "daemon.token" + if token_path.exists(): + request.setRawHeader(b"X-Relay-Token", token_path.read_text(encoding="utf-8").strip().encode("utf-8")) + reply = self.manager.get(request) + reply.setProperty("relay_request_id", request_id) + reply.setProperty("relay_timeout_ms", timeout_ms) + reply.finished.connect(lambda: self._finished(reply)) + return request_id + + def _finished(self, reply) -> None: + request_id = int(reply.property("relay_request_id")) + payload: Any = None + error: str | None = None + try: + payload = json.loads(bytes(reply.readAll()).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + error = str(exc) + if reply.error() and error is None: + error = reply.errorString() + self.response.emit(request_id, payload, error) + reply.deleteLater() diff --git a/relay/gui/state.py b/relay/gui/state.py new file mode 100644 index 0000000..bd4262f --- /dev/null +++ b/relay/gui/state.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from PySide6.QtCore import QSettings + + +class GuiState: + def __init__(self, config): + self.settings = QSettings(str(config.home / "config" / "gui.ini"), QSettings.IniFormat) + + def value(self, key: str, default=None): + return self.settings.value(key, default) + + def set_value(self, key: str, value) -> None: + self.settings.setValue(key, value) + self.settings.sync() diff --git a/relay/models.py b/relay/models.py index dba3626..d4e2afd 100644 --- a/relay/models.py +++ b/relay/models.py @@ -8,6 +8,7 @@ @dataclass(slots=True) class JobRequest: task: str + title: str | None = None task_file: str | None = None worker: str = "auto" fallback: bool | None = None diff --git a/relay/rpc.py b/relay/rpc.py index aee952b..9009abf 100644 --- a/relay/rpc.py +++ b/relay/rpc.py @@ -38,7 +38,11 @@ def request(self, method: str, path: str, payload: Any = None, timeout: int = 15 if isinstance(payload, dict) and payload.get("error_code"): raise RelayError( str(payload["error_code"]), - str(payload.get("error_message") or f"Relay daemon returned HTTP {exc.code}"), + str( + payload.get("error_message") + or payload.get("message") + or f"Relay daemon returned HTTP {exc.code}" + ), bool(payload.get("retryable", False)), payload.get("details"), ) from exc diff --git a/tests/fixture_builders/__init__.py b/tests/fixture_builders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixture_builders/build_relay_0_5_0_fixtures.py b/tests/fixture_builders/build_relay_0_5_0_fixtures.py new file mode 100644 index 0000000..81d4f0c --- /dev/null +++ b/tests/fixture_builders/build_relay_0_5_0_fixtures.py @@ -0,0 +1,306 @@ +"""Build deterministic SQLite fixtures from the Relay 0.5.0 schema.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from pathlib import Path + +LEGACY_SCHEMA = """ +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; +CREATE TABLE jobs ( + job_id TEXT PRIMARY KEY, + request_id TEXT, + caller TEXT NOT NULL, + task_hash TEXT NOT NULL, + task_text TEXT, + requested_worker TEXT NOT NULL, + actual_worker TEXT, + format TEXT NOT NULL, + profile TEXT NOT NULL, + output_path TEXT NOT NULL, + artifact_path TEXT NOT NULL, + status TEXT NOT NULL, + result_status TEXT, + error_code TEXT, + error_message TEXT, + fallback_enabled INTEGER NOT NULL DEFAULT 0, + request_json TEXT NOT NULL, + receipt_json TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX idx_jobs_request_id ON jobs(request_id) WHERE request_id IS NOT NULL; +CREATE INDEX idx_jobs_task_hash_created ON jobs(task_hash, created_at); +CREATE INDEX idx_jobs_status ON jobs(status); + +CREATE TABLE attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE, + worker TEXT NOT NULL, + worker_version TEXT, + adapter_spec_hash TEXT, + permission_mode TEXT, + sandbox_mode TEXT, + unattended_verified INTEGER NOT NULL DEFAULT 0, + started_at TEXT NOT NULL, + completed_at TEXT, + exit_code INTEGER, + status TEXT NOT NULL, + failure_code TEXT, + failure_message TEXT, + stdout_path TEXT, + stderr_path TEXT, + command_json TEXT, + fallback_reason TEXT +); +CREATE INDEX idx_attempts_job ON attempts(job_id); + +CREATE TABLE artifacts ( + artifact_id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE, + relative_path TEXT NOT NULL, + final_path TEXT NOT NULL, + mime_type TEXT, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT +); +CREATE INDEX idx_events_job ON events(job_id, event_id); + +CREATE TABLE capability_audits ( + audit_id INTEGER PRIMARY KEY AUTOINCREMENT, + worker TEXT NOT NULL, + version TEXT, + audit_time TEXT NOT NULL, + test_name TEXT NOT NULL, + result TEXT NOT NULL, + details_json TEXT, + spec_hash TEXT +); +CREATE INDEX idx_audits_worker ON capability_audits(worker, audit_time); +""" + + +def _insert_populated_data(conn: sqlite3.Connection) -> None: + jobs = [ + ( + "fixture-completed", + "fixture-request-1", + "human", + "hash-completed", + "Research the semiconductor market", + "codex", + "codex", + "json", + "web-research", + "D:/RelayFixture/results/completed.json", + "D:/RelayFixture/artifacts/fixture-completed", + "COMPLETED", + "complete", + None, + None, + 0, + json.dumps({"task": "Research the semiconductor market", "worker": "codex"}), + json.dumps({"ok": True, "status": "completed", "job_id": "fixture-completed"}), + "2026-07-22T09:00:00+00:00", + "2026-07-22T09:00:01+00:00", + "2026-07-22T09:01:00+00:00", + "2026-07-22T09:01:00+00:00", + ), + ( + "fixture-failed", + None, + "hermes", + "hash-failed", + None, + "claude", + None, + "txt", + "web-research", + "D:/RelayFixture/results/failed.txt", + "D:/RelayFixture/artifacts/fixture-failed", + "FAILED", + "failed", + "AUTH_REQUIRED", + "Authentication is required", + 1, + json.dumps({"task": "Check the protected report", "worker": "claude"}), + json.dumps({"ok": False, "status": "failed", "job_id": "fixture-failed"}), + "2026-07-22T10:00:00+00:00", + "2026-07-22T10:00:01+00:00", + "2026-07-22T10:00:05+00:00", + "2026-07-22T10:00:05+00:00", + ), + ( + "fixture-cancelled", + "fixture-request-3", + "human", + "hash-cancelled", + "Review the cancelled task", + "codex", + None, + "json", + "web-research", + "D:/RelayFixture/results/cancelled.json", + "D:/RelayFixture/artifacts/fixture-cancelled", + "CANCELLED", + "cancelled", + "CANCELLED", + None, + 0, + json.dumps({"task": "Review the cancelled task", "worker": "codex"}), + json.dumps({"ok": False, "status": "cancelled", "job_id": "fixture-cancelled"}), + "2026-07-22T11:00:00+00:00", + "2026-07-22T11:00:01+00:00", + "2026-07-22T11:00:02+00:00", + "2026-07-22T11:00:02+00:00", + ), + ] + conn.executemany( + "INSERT INTO jobs(job_id,request_id,caller,task_hash,task_text,requested_worker,actual_worker,format,profile," + "output_path,artifact_path,status,result_status,error_code,error_message,fallback_enabled,request_json," + "receipt_json,created_at,started_at,completed_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + jobs, + ) + conn.executemany( + "INSERT INTO attempts(job_id,worker,worker_version,adapter_spec_hash,permission_mode,sandbox_mode," + "unattended_verified,started_at,completed_at,exit_code,status,failure_code,failure_message," + "stdout_path,stderr_path,command_json,fallback_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + [ + ( + "fixture-completed", + "codex", + "0.144.1", + "fixture-spec-hash", + "never", + "workspace-write", + 1, + "2026-07-22T09:00:01+00:00", + "2026-07-22T09:01:00+00:00", + 0, + "COMPLETED", + None, + None, + "D:/RelayFixture/logs/completed.stdout", + "D:/RelayFixture/logs/completed.stderr", + json.dumps(["codex", "--model", "o4-mini"]), + None, + ), + ( + "fixture-failed", + "claude", + "2.1.217", + "fixture-claude-hash", + "bypassPermissions", + None, + 0, + "2026-07-22T10:00:01+00:00", + "2026-07-22T10:00:05+00:00", + 1, + "FAILED", + "AUTH_REQUIRED", + "Authentication is required", + "D:/RelayFixture/logs/failed.stdout", + "D:/RelayFixture/logs/failed.stderr", + json.dumps(["claude", "--output-format", "json"]), + None, + ), + ( + "fixture-failed", + "codex", + "0.144.1", + "fixture-spec-hash", + "never", + "workspace-write", + 1, + "2026-07-22T10:00:06+00:00", + "2026-07-22T10:00:07+00:00", + 1, + "FAILED", + "TIMEOUT", + "Timed out", + "D:/RelayFixture/logs/fallback.stdout", + "D:/RelayFixture/logs/fallback.stderr", + json.dumps(["codex"]), + "AUTH_REQUIRED", + ), + ], + ) + conn.execute( + "INSERT INTO artifacts(job_id,relative_path,final_path,mime_type,size,sha256,created_at) VALUES(?,?,?,?,?,?,?)", + ( + "fixture-completed", + "summary.txt", + "D:/RelayFixture/artifacts/fixture-completed/summary.txt", + "text/plain", + 14, + "fixture-artifact-sha256", + "2026-07-22T09:01:00+00:00", + ), + ) + conn.executemany( + "INSERT INTO events(job_id,timestamp,event_type,payload_json) VALUES(?,?,?,?)", + [ + ("fixture-completed", "2026-07-22T09:00:00+00:00", "JOB_CREATED", '{"queued":false}'), + ("fixture-completed", "2026-07-22T09:00:01+00:00", "ATTEMPT_STARTED", '{"worker":"codex"}'), + ("fixture-failed", "2026-07-22T10:00:00+00:00", "JOB_CREATED", '{"queued":false}'), + ("fixture-cancelled", "2026-07-22T11:00:02+00:00", "JOB_CANCELLED", None), + ], + ) + conn.execute( + "INSERT INTO capability_audits(worker,version,audit_time,test_name,result,details_json,spec_hash) " + "VALUES(?,?,?,?,?,?,?)", + ( + "codex", + "0.144.1", + "2026-07-22T08:00:00+00:00", + "deep", + "passed", + '{"output":true,"artifacts":true}', + "fixture-spec-hash", + ), + ) + + +def build(path: Path, populated: bool, force: bool = False) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + if not force: + raise FileExistsError(f"Refusing to overwrite existing fixture: {path}") + path.unlink() + conn = sqlite3.connect(path) + try: + conn.executescript(LEGACY_SCHEMA) + if populated: + _insert_populated_data(conn) + conn.execute("PRAGMA user_version=0") + conn.commit() + finally: + conn.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] / "fixtures" + build(root / "relay-0.5.0-empty.db", populated=False, force=args.force) + build(root / "relay-0.5.0-populated.db", populated=True, force=args.force) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/relay-0.5.0-empty.db b/tests/fixtures/relay-0.5.0-empty.db new file mode 100644 index 0000000..247ceb6 Binary files /dev/null and b/tests/fixtures/relay-0.5.0-empty.db differ diff --git a/tests/fixtures/relay-0.5.0-populated.db b/tests/fixtures/relay-0.5.0-populated.db new file mode 100644 index 0000000..578cc96 Binary files /dev/null and b/tests/fixtures/relay-0.5.0-populated.db differ diff --git a/tests/test_g0_api.py b/tests/test_g0_api.py new file mode 100644 index 0000000..a7176fc --- /dev/null +++ b/tests/test_g0_api.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import socket +import tempfile +import threading +import unittest +from pathlib import Path +from urllib.parse import urlencode + +from relay.agent_registry import AgentRegistry +from relay.compatibility import evaluate_compatibility, relay_home_id +from relay.config import Config +from relay.daemon import RelayDaemon +from relay.db import Database +from relay.engine import RelayEngine +from relay.errors import RelayError +from relay.models import JobRequest +from relay.rpc import RPCClient + + +class G0ApiTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.home = Path(self.temp.name) / "relay-home" + self.config = Config(self.home) + self.config.init() + self.db = Database(self.config.path_value("database_path")) + self.engine = RelayEngine(self.config, self.db) + self.daemon_clients: list[tuple[RPCClient, threading.Thread]] = [] + + def tearDown(self): + for client, thread in self.daemon_clients: + self._stop_daemon(client, thread) + self.temp.cleanup() + + def _free_port(self) -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + def _start_daemon(self) -> tuple[RelayDaemon, RPCClient, threading.Thread]: + self.config.set("daemon_port", self._free_port()) + daemon = RelayDaemon(self.config) + thread = threading.Thread(target=daemon.serve, daemon=True) + thread.start() + client = RPCClient(self.config) + self.assertTrue(client.wait_until_healthy(3)) + self.daemon_clients.append((client, thread)) + return daemon, client, thread + + @staticmethod + def _stop_daemon(client: RPCClient, thread: threading.Thread) -> None: + if thread.is_alive(): + client.request("POST", "/shutdown") + thread.join(timeout=10) + + def _create_job(self, task: str, *, status: str, submitted_via: str = "cli") -> str: + job, _ = self.engine.create_job( + JobRequest(task=task, worker="codex"), queued=status == "QUEUED", submitted_via=submitted_via + ) + if status != "QUEUED": + self.db.update_job(job["job_id"], status=status, completed_at="2026-07-23T10:00:00+00:00") + return job["job_id"] + + def test_health_reports_compatibility_contract(self): + _, client, _ = self._start_daemon() + + health = client.request("GET", "/health") + + self.assertTrue(health["ok"]) + self.assertEqual(health["api_versions"], ["v1"]) + self.assertEqual(health["api_schema_revision"], 1) + self.assertEqual(health["min_gui_version"], "0.7.0") + self.assertEqual(health["relay_home_id"], relay_home_id(self.home)) + + def test_jobs_api_filters_and_cursor_pagination(self): + _, client, _ = self._start_daemon() + self._create_job("Market research one", status="COMPLETED") + self._create_job("Market research two", status="COMPLETED") + self._create_job("Market research three", status="COMPLETED") + self._create_job("Waiting task", status="QUEUED") + + query = urlencode({"bucket": "finished", "q": "Market", "limit": 2}) + first = client.request("GET", f"/v1/jobs?{query}") + self.assertTrue(first["ok"]) + self.assertEqual(len(first["jobs"]), 2) + self.assertTrue(first["has_more"]) + self.assertNotIn("request_json", first["jobs"][0]) + self.assertEqual(first["jobs"][0]["submitted_via"], "cli") + + second_query = urlencode({"bucket": "finished", "q": "Market", "limit": 2, "cursor": first["next_cursor"]}) + second = client.request("GET", f"/v1/jobs?{second_query}") + self.assertEqual(len(second["jobs"]), 1) + self.assertFalse(second["has_more"]) + + waiting = client.request("GET", "/v1/jobs?bucket=waiting") + self.assertEqual(len(waiting["jobs"]), 1) + self.assertEqual(waiting["jobs"][0]["status"], "QUEUED") + + def test_jobs_api_rejects_invalid_cursor(self): + _, client, _ = self._start_daemon() + + with self.assertRaises(RelayError) as context: + client.request("GET", "/v1/jobs?cursor=not-a-valid-cursor") + self.assertEqual(context.exception.code, "INVALID_REQUEST") + + def test_job_metadata_and_privacy_contract(self): + self.config.set("history_display_mode", "full") + self.config.set("store_replayable_requests", False) + job, _ = self.engine.create_job( + JobRequest(task="First line\nSecond line", worker="codex"), queued=True, submitted_via="cli" + ) + stored = self.db.get_job(job["job_id"]) + self.assertEqual(stored["title"], "First line") + self.assertEqual(stored["task_preview"], "First line Second line") + self.assertEqual(stored["submitted_via"], "cli") + self.assertEqual(stored["replayable"], 0) + + self.assertTrue(self.db.request_cancel(job["job_id"])) + stored = self.db.get_job(job["job_id"]) + self.assertEqual(stored["request_json"], "{}") + self.assertIsNone(stored["task_text"]) + with self.assertRaises(RelayError) as context: + self.engine.rerun(job["job_id"]) + self.assertEqual(context.exception.code, "JOB_NOT_REPLAYABLE") + + +class CompatibilityTests(unittest.TestCase): + def test_compatible_health_is_normal(self): + health = { + "ok": True, + "api_versions": ["v1"], + "min_gui_version": "0.7.0", + "relay_home_id": "home-1", + } + self.assertEqual( + evaluate_compatibility(health, gui_version="0.7.0", expected_relay_home_id="home-1").mode, + "normal", + ) + + def test_incompatible_health_is_read_only(self): + health = {"ok": True, "api_versions": [], "min_gui_version": "0.7.0"} + decision = evaluate_compatibility(health, gui_version="0.7.0") + self.assertEqual(decision.mode, "read-only") + + def test_wrong_relay_home_is_disconnected(self): + health = { + "ok": True, + "api_versions": ["v1"], + "min_gui_version": "0.7.0", + "relay_home_id": "other-home", + } + decision = evaluate_compatibility(health, gui_version="0.7.0", expected_relay_home_id="home-1") + self.assertEqual(decision.mode, "disconnected") + + def test_agent_registry_keeps_builtin_definitions(self): + with tempfile.TemporaryDirectory() as directory: + config = Config(Path(directory) / "relay-home") + config.init() + registry = AgentRegistry(config, config.path_value("adapter_spec_root")) + agents = {agent["agent_id"]: agent for agent in registry.list_agents()} + self.assertEqual(set(("claude", "codex", "antigravity")), set(agents)) + self.assertTrue(agents["claude"]["builtin"]) + self.assertEqual(registry.get_adapter("codex").name, "codex") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_g1_gui.py b/tests/test_g1_gui.py new file mode 100644 index 0000000..c07a7f7 --- /dev/null +++ b/tests/test_g1_gui.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from relay.compatibility import evaluate_compatibility +from relay.config import Config +from relay.gui.main_window import MainWindow + + +class G1GuiTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.config = Config(Path(self.temp.name) / "relay-home") + self.config.init() + self.window = MainWindow(self.config, gui_version="0.7.0", expected_home_id="home") + + def tearDown(self): + self.window.close() + self.temp.cleanup() + + def test_gui_has_read_only_sections_and_home_state(self): + self.assertEqual(self.window.windowTitle(), "Relay-agent") + self.assertEqual(self.window.sidebar.minimumWidth(), 0) + self.assertIn("Relay Home:", self.window.statusBar().currentMessage()) + + def test_finished_filter_and_status_rendering(self): + self.window.current_mode = "normal" + self.window.jobs = { + "done": {"job_id": "done", "status": "COMPLETED", "title": "Done", "submitted_via": "cli"}, + "failed": {"job_id": "failed", "status": "FAILED", "title": "Failed", "submitted_via": "gui"}, + } + self.window._render_jobs() + self.assertEqual(self.window.job_list.count(), 4) + self.window.result_filter.setCurrentText("Failed") + self.window._render_jobs() + self.assertEqual(self.window.job_list.count(), 3) + self.assertIn("× Failed", self.window.job_list.item(2).text()) + + def test_unsupported_schema_is_read_only(self): + health = {"ok": True, "api_versions": ["v1"], "api_schema_revision": 99, "min_gui_version": "0.7.0"} + self.assertEqual(evaluate_compatibility(health, gui_version="0.7.0").mode, "read-only") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..c58d223 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import hashlib +import shutil +import sqlite3 +import tempfile +import unittest +from contextlib import closing +from pathlib import Path + +from relay.db import CURRENT_SCHEMA_VERSION, Database +from relay.errors import RelayError + +ROOT = Path(__file__).resolve().parent +FIXTURES = ROOT / "fixtures" + + +class MigrationTests(unittest.TestCase): + def copy_fixture(self, name: str) -> tuple[tempfile.TemporaryDirectory[str], Path]: + temp = tempfile.TemporaryDirectory() + target = Path(temp.name) / name + shutil.copy2(FIXTURES / name, target) + return temp, target + + def test_empty_0_5_fixture_migrates(self): + temp, path = self.copy_fixture("relay-0.5.0-empty.db") + self.addCleanup(temp.cleanup) + + db = Database(path) + + with closing(sqlite3.connect(path)) as conn, conn: + self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], CURRENT_SCHEMA_VERSION) + columns = {row[1] for row in conn.execute("PRAGMA table_info(jobs)")} + self.assertTrue( + {"title", "submitted_via", "task_preview", "schedule_id", "scheduled_for", "replayable"} <= columns + ) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0], 0) + self.assertIsNotNone(db.last_backup_path) + self.assertTrue(db.last_backup_path and db.last_backup_path.exists()) + + def test_populated_fixture_preserves_rows_values_and_relationships(self): + temp, path = self.copy_fixture("relay-0.5.0-populated.db") + self.addCleanup(temp.cleanup) + + Database(path) + + with closing(sqlite3.connect(path)) as conn, conn: + self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], CURRENT_SCHEMA_VERSION) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0], 3) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM attempts").fetchone()[0], 3) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM events").fetchone()[0], 4) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM artifacts").fetchone()[0], 1) + self.assertEqual(conn.execute("SELECT COUNT(*) FROM capability_audits").fetchone()[0], 1) + row = conn.execute( + "SELECT job_id,status,request_id,output_path,submitted_via,replayable FROM jobs " + "WHERE job_id='fixture-completed'" + ).fetchone() + self.assertEqual( + row[:4], + ( + "fixture-completed", + "COMPLETED", + "fixture-request-1", + "D:/RelayFixture/results/completed.json", + ), + ) + self.assertEqual(row[4:], ("legacy", 1)) + self.assertEqual( + conn.execute("SELECT COUNT(*) FROM attempts WHERE job_id='fixture-failed'").fetchone()[0], 2 + ) + + def test_migration_is_idempotent_and_reopens(self): + temp, path = self.copy_fixture("relay-0.5.0-populated.db") + self.addCleanup(temp.cleanup) + + first = Database(path) + backup = first.last_backup_path + second = Database(path) + + self.assertIsNone(second.last_backup_path) + self.assertTrue(backup and backup.exists()) + with closing(sqlite3.connect(path)) as conn, conn: + self.assertEqual(conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0], 3) + + def test_fixture_checksum_is_stable(self): + expected = { + "relay-0.5.0-empty.db": "14f3c53b32dd93eea382214783ef8a961d1d3c797a278a503d5e9451cc574f09", + "relay-0.5.0-populated.db": "b08e3b2dc00bd3fde5b34b16b2e59ec2c11af7fd2f5166454af48aeda4a5ade2", + } + for name, checksum in expected.items(): + with self.subTest(name=name): + digest = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() + self.assertEqual(digest, checksum) + + def test_new_database_starts_at_current_schema(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "relay.db" + db = Database(path) + self.assertIsNone(db.last_backup_path) + with closing(sqlite3.connect(path)) as conn, conn: + self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], CURRENT_SCHEMA_VERSION) + + def test_newer_schema_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "relay.db" + with closing(sqlite3.connect(path)) as conn, conn: + conn.execute("CREATE TABLE marker(value TEXT)") + conn.execute("PRAGMA user_version=99") + conn.commit() + with self.assertRaisesRegex(RelayError, "newer than supported"): + Database(path) + + +if __name__ == "__main__": + unittest.main()