diff --git a/AGENTS.md b/AGENTS.md index b3cd2e9..997a9a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ steps, project setup, headless operation, and removal. | [docs/architecture.md](docs/architecture.md) | Component responsibilities, a short issue-to-PR overview, configuration ownership, scheduler ownership, and shared state. | Start here to understand how the system is divided before locating implementation code. | | [docs/bot-workflow.md](docs/bot-workflow.md) | Eight Mermaid diagrams and detailed implementation notes: startup and polling; discovery and routing; task phases; sessions and questions; media helpers; verification and publication; feedback, merging, and tab closure; status, retries, and recovery. Includes links to the source for each area. | Use for exact execution order, state transitions, checkpoint behavior, failure paths, and tracing a bot task from issue to merged PR. | | [docs/configuration.md](docs/configuration.md) | The standard `.opencode/automation.json` format, defaults, setup flags, configuration tracking across Git branches, authors, triggers, checks, base branches, model capabilities, media helpers, custom prompts, signatures, and auto-merge settings. | Use when adding or changing user-facing configuration, defaults, or setup examples. | -| [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, runtime sidebar/status freshness, local task closure, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | +| [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, runtime sidebar/status freshness, host repository inventory and discovery, local task closure, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | | [docs/advanced.md](docs/advanced.md) | Separate scheduler/dispatcher setup, multiple repositories, custom RPC jobs, full options, timeouts, management and retry commands, persistence, reconciliation, locks, and known limits. | Use for low-level configuration, operational troubleshooting, recovery, or ownership/concurrency changes. | | [docs/installation.md](docs/installation.md) | Loader registration, config-directory precedence, prerequisites, source installation, project-local installation, upgrade conflicts, testing on another machine, and migration limits. | Use when working on packaging, installers, registration, upgrades, or deployment troubleshooting. | | [docs/releases.md](docs/releases.md) | Feature-to-devel and devel-to-release PR checks, automatic patch versions, manual npm version/tag releases, exact changelog notes, publication recovery, README commits on release, automatic release-to-devel synchronization, and promotion PRs into protected main. | Use for CI triggers, versioning, packaging, GitHub Release publication, branch permissions, or recovery after a failed release. | @@ -43,6 +43,10 @@ For common investigations: and the configuration reference's automatic-merge rules. - **Why did a session tab open or close?** Read the runtime tab sections and workflow section 7. +- **Which folders are configured, running, paused, or missing?** Read the runtime + repository inventory section and advanced monitoring notes. `list` and `/bot` + → **Repositories** use timestamped host snapshots; do not activate owners to + inspect them. Discovery is an explicit registration operation. - **Why is polling inactive or duplicated?** Read architecture ownership, workflow section 1, and installation registration details. @@ -72,6 +76,9 @@ the installation block without making remote writes. Keep its markers intact. `src/runtime-panel.ts` owns polling, freshness and presentation; `src/monitor.ts` defines read-only monitoring schemas. `src/rpc.ts` defines RPC contracts; `src/manage.ts` exposes management operations. +- `src/repositories.ts` owns host registry registration, explicit discovery and + component snapshot files; `src/repository-report.ts` defines inventory schemas + and shared CLI/TUI formatting. Neither inventory reader starts bot work. - `src/setup.ts`, `src/wizard.ts`, `src/install.ts`, and `scripts/` cover setup and installation. `examples/` contains configuration examples; `test/` contains automated tests. `package.json` defines build and validation commands. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1fbe7..01fcdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ include the full version, for example `## 0.7.0-beta.1`. ### Added +- List configured repositories across the host with `opencode2-automation list` + (`--json` for scripts) and `/bot` → **Repositories**. Show owner/checkout paths, + base branches, timestamped dispatcher/scheduler status and concrete issue + failures without activating other bots. Register projects during init/startup; + import older inactive standard configurations with `list --discover `. + Preserve missing entries and explicitly mark stopped, stale or unavailable data. + - Manage tasks directly from `/bot`: inspect details, open sessions, close idle tabs, restart workflows, or stop sessions and durably end tracking without deleting work. Preserve closed tasks as history and skip rediscovery, feedback, diff --git a/README.md b/README.md index 94d1399..ea5daf8 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,12 @@ installations are not removed by `npm uninstall --global`. - **Merging:** approve the bot's PR or post a configured merge phrase. The author must be allowed and have repository write access. Set `autoMerge.enabled` to `false` to disable this. `signature` controls the signature on new bot messages. +- **Repository inventory:** run `opencode2-automation list` from any directory, + or choose **Repositories** in `/bot`. See registered GitHub repositories, owner + and checkout paths, base branches, timestamped runtime status, scan timing and + issue counts/errors. Use `list --json` for scripts and + `list --discover /absolute/path/to/projects` to import older standard configs + without starting bots. See [repository inventory](docs/runtime.md#repository-inventory). - **Runtime status:** the right sidebar's **BOT RUNTIME** panel shows dispatcher work, GitHub discovery, scheduled scans, queue counts and the selected task. `/botstatus` opens a full text report. Status refreshes every five seconds; diff --git a/docs/advanced.md b/docs/advanced.md index 4c8941b..2d339db 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -122,6 +122,31 @@ Each poll has a four-second bound; failures retain marked stale data. The existi Full task history remains available through `status`; see the [runtime sidebar](runtime.md#runtime-status-sidebar) for display and selection rules. +### Host repository inventory + +`opencode2-automation list [--json]` is independent of the current checkout and +service discovery. `automation.github.repositories` accepts `{}` and returns the +same `{ entries, warnings }` report on the connected server. The method reads +local registry/snapshot files; it does not invoke RPC in other owner locations, +which could activate their plugins. `/bot` → **Repositories** consumes this API. + +`init` and combined-plugin activation register standard configurations. Dispatcher +startup registers all advanced `repositories` entries under its canonical owner. +Dispatcher and scheduler write separate atomic snapshots every five seconds while +they hold their existing ownership locks, with PID, timestamp and shutdown state. +Disposal settles the last snapshot before releasing ownership. Readers verify +process existence and 15-second freshness; stale details remain historical. +Snapshot/registration errors are reported but do not abort execution or alter +queue state. The registry uses private files under `XDG_STATE_HOME` (default +`~/.local/state`), separate from Git-backed state; no GitHub token or model +credentials are stored there. + +Use `list --discover /absolute/path/to/projects` to register older inactive +standard `.opencode/automation.json` configurations without loading owners. +Discovery has explicit filesystem/depth limits and does not import arbitrary +advanced plugin options. For statuses and migration details, see +[repository inventory](runtime.md#repository-inventory). + ## Persistence and reconciliation The queue stores analysis decisions and clarification dialogue, comment ID, session ID, phase, pinned base branch, diff --git a/docs/architecture.md b/docs/architecture.md index e684846..5e5f226 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,12 @@ loads a generic scheduler, a GitHub dispatcher, and a terminal UI component. session history, and exposes `/bot` and `/restartworkflow` task selectors. A read-only runtime sidebar and `/botstatus` combine live dispatcher diagnostics, scheduler state and task snapshots, marking stale or unavailable data. +- **Repository inventory:** `init` and owner activation register configured + checkouts in a per-user host registry. Dispatcher and scheduler publish separate + local status snapshots every five seconds. CLI `list` reads these without + activating owners; `/bot` → **Repositories** reads the connected server's same + registry through `automation.github.repositories`. Missing/stale data is explicit; + discovery can import inactive standard configs without starting automation. ## Workflow diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index 3dccc85..b6e1b49 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -20,8 +20,10 @@ flowchart TD Owner -->|No or outside Git| Inactive[Plugin stays inactive] Owner -->|Yes| Config[Use nonempty plugin options or read .opencode/automation.json] Config -->|No project config| Inactive - Config --> Resolve[Validate settings and resolve GitHub auth, routes and defaults] - Resolve --> GH[Acquire github lock and load queue.json] + Config --> Register[Register primary checkout in local user inventory without activating other owners] + Register --> Resolve[Validate settings and resolve GitHub auth, routes and defaults] + Resolve --> Metadata[Register resolved repositories and base branches] + Metadata --> GH[Acquire github lock and load queue.json] GH --> RPC[Register runtime bridge and dispatcher RPC] RPC --> Worker[Immediate worker tick, then every workerEverySeconds] RPC --> Scheduler[Start scheduler after GitHub setup succeeds] @@ -50,7 +52,7 @@ flowchart TD Keepalive --> PID{Registered service PID matches this process?} PID -->|Yes| Touch[Create or reuse maintenance session, then emit rename event] PID -->|No| Skip[Skip keepalive] - Stop[Owner reload or shutdown] --> Cleanup[Stop timers and local waits, settle writes, dispose RPC, release locks] + Stop[Owner reload or shutdown] --> Cleanup[Stop timers, save stopped inventory snapshots, settle writes, dispose RPC, release locks] Cleanup --> Preserve[Preserve durable queue and healthy worktree execution] Preserve --> Load View[Runtime sidebar or botstatus] -.-> Monitor[Read dispatcher monitor and scheduler status every five seconds] @@ -59,8 +61,25 @@ flowchart TD Monitor --> Fresh{Both readings available and fresh?} Fresh -->|Yes| Display[Show live operations, queue and selected task] Fresh -->|No| Stale[Mark unavailable or retained stale readings] + RPC -.-> DS[Publish dispatcher snapshot every five seconds] + State -.-> SS[Publish scheduler snapshot every five seconds] + DS --> Inventory[Per-user registry and separate atomic component snapshots] + SS --> Inventory + Register --> Inventory + Metadata --> Inventory + Init[CLI init or explicit list --discover] --> Inventory + List[CLI list or bot Repositories via current owner RPC] --> Read[Read local inventory without activating owners] + Inventory -.-> Read + Read --> Check[Check paths, config, PID and 15-second freshness] + Check --> Report[Report repository status, scan timing, task counts and issue errors] ``` +- Registration and component snapshots are observational, best-effort writes. + Registry errors do not stop the bot. `init` registers after configuration succeeds; + explicit discovery imports old standard configs without auth or service startup. + The CLI and TUI report share a per-user host registry, not the task queue. + Missing paths and stale/dead processes are shown explicitly. See + [inventory behavior and limits](runtime.md#repository-inventory). - Easy configuration puts state under the shared Git directory at `opencode2-automation/`. Worker worktrees do not start another scheduler. - Default discovery interval: **60 seconds**. Default worker interval: @@ -88,7 +107,8 @@ flowchart TD Sources: [index.ts](../src/index.ts), [easy.ts](../src/easy.ts), [GitHub plugin](../src/plugins/github.ts), [scheduler plugin](../src/plugins/scheduler.ts), [lifecycle.ts](../src/lifecycle.ts), -[dispatcher.ts — workOnce](../src/dispatcher.ts), [state.ts](../src/state.ts). +[dispatcher.ts — workOnce](../src/dispatcher.ts), [state.ts](../src/state.ts), +[repositories.ts](../src/repositories.ts), [repository-report.ts](../src/repository-report.ts). ## 2. Discovery and routing @@ -490,7 +510,9 @@ flowchart TD Ack --> UI[Activity events and TUI polling every 10 seconds] Refresh --> UI Local[Task closure finishes with status closed] --> UI - Menu[bot menu: select issue] --> Action[Open session, details, close tabs, restart workflow, stop and close task] + Menu[bot menu: select issue or Repositories] --> Action[Open session, details, close tabs, restart workflow, stop and close task] + Menu -->|Repositories| Repos[Read connected server inventory, choose repository, show timestamped details] + Repos --> Observe[No task or scheduler mutation, no activation of other owners] Action -->|Stop and close task| Confirm[Confirm stop and close, queue durable closing request] UI --> Busy{Associated tab busy?} Busy -->|Yes| Defer[Retry closure on a later snapshot] diff --git a/docs/configuration.md b/docs/configuration.md index 2151fc7..4bbe8ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,3 +167,14 @@ acknowledgements. They identify the message in its text; GitHub still attributes posts to the account authenticated by your token. Existing posts are not rewritten. Set `"autoMerge": { "enabled": false }` to disable automatic merging. + +## Repository inventory registration + +`init` and owner activation register the configured checkout for +`opencode2-automation list` and `/bot` → **Repositories**. No new project setting +is required. The registry stores last-resolved repository/base-branch metadata +and timestamped component snapshots under the user's state directory; it does +not replace `.opencode/automation.json` or the shared Git queue. Changes to default +branches are reflected when the owner is activated again. See +[repository inventory](runtime.md#repository-inventory) to import older inactive +configurations and distinguish configured projects from running bots. diff --git a/docs/installation.md b/docs/installation.md index 4ae55c5..c999320 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -123,5 +123,13 @@ commands and action menus such as `/bot` task closure and `/restartworkflow`; me reload its client's command registrations. A service restart preserves queue blocks and pending questions. Use [workflow recovery](runtime.md#interrupted-sessions-and-workflow-recovery) for an execution stop instead of reinstalling or deleting state. +After upgrading, activated owners register themselves for +`opencode2-automation list`. Import older, currently inactive standard projects +with `opencode2-automation list --discover /absolute/path/to/projects`; this does +not activate them. Use the same user and `XDG_STATE_HOME` as the service. The TUI's +**Repositories** option reads the connected server registry. See +[repository inventory](runtime.md#repository-inventory) for discovery limits, +status freshness, and missing-directory behavior. + Do not change an active project's `origin` to switch repositories: clone another project and configure it separately. diff --git a/docs/runtime.md b/docs/runtime.md index 738b918..84a2444 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -197,7 +197,7 @@ before retrying; the bot does not create a replacement or discard existing work. Edits to existing comments and PR review comments are not supported. Closing the issue or closing/merging the PR blocks further rounds. -Management commands run from the primary owner checkout of the target repository, +Except for the host-wide `list` command described below, management commands run from the primary owner checkout of the target repository, not from a bot worktree: For source installations, replace `"$HOME/.local/bin/opencode2-automation"` with @@ -216,9 +216,80 @@ Pausing stops scheduled scans; it does not cancel accepted tasks or active sessi Do not run independent bots on two machines against the same issues: they do not share queue ownership across machines. +## Repository inventory + +Run these commands from **any directory**, including outside Git: + +```bash +opencode2-automation list +opencode2-automation list --json +opencode2-automation list --discover /absolute/path/to/projects +``` + +`list` reads this user's registry on this host. It never starts the service, +activates another owner, scans GitHub, retries tasks, or prompts a model. JSON +output contains `entries` and `warnings`. In the TUI, `/bot` → **Repositories** +shows the same inventory from the **connected server**, not the TUI client's +machine. Select a repository for details. This option remains available when +there are no tasks. It requires an updated, loaded owner plugin for the inventory +RPC; otherwise use the CLI on the server. Reopen the TUI after updating it. + +Entries identify the GitHub repository, full checkout and owner paths, and the +last registered base branch. Before first activation an automatic branch may +say `auto (resolved on activation)`; task-specific base overrides are still shown +in task details. Advanced configurations with multiple repositories list each +configured repository, sharing the owner's scheduler information. + +The report includes dispatcher activity, last scan attempt completion (which can +include failure), scheduler next-run timestamps, task counts and every open +blocked/failed/closing issue key and saved error. Active counts use the actual +active task; scheduled work is separate. Closed local tracking and closed/merged +PR history do not inflate counts. A working dispatcher can have blocked tasks; +inspect the task counts as well as the owner status. + +| Status | Meaning | +| --- | --- | +| `running` | Fresh dispatcher and scheduler snapshots; at least one polling job is unpaused. This does not promise that all tasks succeeded. | +| `paused` | Both snapshots are fresh and all scheduler jobs are paused. Accepted tasks can still execute. | +| `error` | A fresh dispatcher reports stopped/scan failure, a scheduler job has failures, or the standard configuration is invalid/unreadable. | +| `not-running` | No dispatcher snapshot yet, an explicit shutdown snapshot, or its process no longer exists. | +| `unavailable` | Missing, corrupt or stale component status, or an inaccessible directory. Do not infer idleness. | +| `missing` | A registered checkout/owner directory was removed or moved. | +| `unconfigured` | Its registered standard configuration file was removed. A previously loaded runtime may still be active until reloaded. | + +Each component publishes a local snapshot every five seconds. A reading older +than 15 seconds is unavailable, even if its process still exists. Timestamps are +shown in UTC. Stopped/stale entries retain **historical** details; counts and next +run times in those snapshots are not live promises. Open the list again to refresh +it. Inventory errors do not reset queues or prevent bot execution. + +`init` registers new projects. Loading an updated combined plugin imports its +existing standard configuration; the dispatcher also registers advanced +`repositories` options. To include older **inactive** standard configurations, +run `list --discover `. Discovery only reads Git/config files and adds +registry metadata: no credentials, GitHub calls, or service activation are needed. +It examines the root plus six directory levels, at most 10,000 directories, +without following child symlinks or descending into hidden directories, +`node_modules`, `vendor`, `build`, or `dist`. Worktrees and subdirectories of a Git +checkout are excluded. Limits, unreadable folders and invalid configs are +reported. Choose a more specific root (including a hidden folder directly) when +needed. Advanced options require loading their owner once. This is an inventory +of registered/configured projects, not an exhaustive filesystem or other-user +scan. + +Registration is per canonical owner path under +`$XDG_STATE_HOME/opencode2-automation/repositories`, defaulting to +`$HOME/.local/state/opencode2-automation/repositories`. CLI and service must use +the same user and state-home environment. Per-owner atomic files avoid lost +updates when different projects register concurrently. Aliases of one owner are +deduplicated; separate clones remain separate. Missing entries are retained so +the operator can see what disappeared. The registry never replaces the queue or +session database, and `list --discover` does not rewrite project configuration. + ## Manage tasks from /bot -Run `/bot` in the owner project's TUI, choose an issue, then choose an action: +Run `/bot` in the owner project's TUI, choose an issue, then choose an action. +The same picker also offers **Repositories** for the host inventory: - **Open session**: inspect its saved conversation, including a locally closed task. - **Show details**: read the saved status, phase, error, branch, worktree, session, diff --git a/scripts/package-check.mjs b/scripts/package-check.mjs index 8780a95..56ee03b 100644 --- a/scripts/package-check.mjs +++ b/scripts/package-check.mjs @@ -18,11 +18,11 @@ try { const [archive] = JSON.parse(packed.stdout); assert.equal(archive.version, pkg.version, "Packed version must match package.json"); assert.equal(archive.filename, basename(archive.filename), "Archive name must not contain a directory"); - for (const required of ["dist/index.js", "dist/tui.js", "dist/setup.js", "dist/install.js", "scripts/postinstall.mjs", "prompts/bot.md", "CHANGELOG.md"]) { + for (const required of ["dist/index.js", "dist/tui.js", "dist/setup.js", "dist/install.js", "dist/repositories.js", "dist/repository-report.js", "scripts/postinstall.mjs", "prompts/bot.md", "CHANGELOG.md"]) { assert.ok(archive.files.some(file => file.path === required), `Missing packaged file: ${required}`); } const file = join(output, archive.filename), prefix = join(temporary, "prefix"), config = join(temporary, "config"); - const env = { ...process.env, OPENCODE_CONFIG_DIR: config, XDG_CONFIG_HOME: join(temporary, "xdg") }; + const env = { ...process.env, OPENCODE_CONFIG_DIR: config, XDG_CONFIG_HOME: join(temporary, "xdg"), XDG_STATE_HOME: join(temporary, "state") }; // Reuse cached downloads, allowing metadata lookups absent from npm ci's cache. await exec("npm", ["install", "--global", "--prefix", prefix, "--prefer-offline", "--ignore-scripts=false", "--no-audit", "--no-fund", file], { env, timeout: 120_000, maxBuffer: 8 * 1024 * 1024, @@ -35,6 +35,8 @@ try { assert.deepEqual(await readdir(config), ["plugins"], "Installation must not create project configuration"); const help = await exec(join(prefix, "bin", pkg.name), ["--help"], { env, cwd: temporary, timeout: 15_000 }); assert.match(help.stdout, /init/); + const inventory = await exec(join(prefix, "bin", pkg.name), ["list", "--json"], { env, cwd: temporary, timeout: 15_000 }); + assert.deepEqual(JSON.parse(inventory.stdout), { entries: [], warnings: [] }, "Inventory works outside Git without starting a service"); const digest = createHash("sha256").update(await readFile(file)).digest("hex"); await writeFile(`${file}.sha256`, `${digest} ${archive.filename}\n`); if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `filename=${archive.filename}\n`); diff --git a/src/index.ts b/src/index.ts index a678428..17fcf92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { readFile, realpath } from "node:fs/promises"; import { join } from "node:path"; import github from "./plugins/github.js"; import scheduler from "./plugins/scheduler.js"; +import { registerConfigured } from "./repositories.js"; import { checkout, resolveEasy } from "./easy.js"; export default Plugin.define({ @@ -17,6 +18,8 @@ export default Plugin.define({ try { options = JSON.parse(await readFile(join(location.root, ".opencode", "automation.json"), "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } } + await registerConfigured(location.root, Object.keys(ctx.options).length ? options : undefined) + .catch(() => console.error("Repository registration failed. Use list --discover to retry standard configurations.")); const resolved = await resolveEasy(location.root, options); const stopGithub = await github.setup({ ...ctx, options: resolved.github }); try { diff --git a/src/plugins/github.ts b/src/plugins/github.ts index 3b768b5..8f767df 100644 --- a/src/plugins/github.ts +++ b/src/plugins/github.ts @@ -1,3 +1,4 @@ +import { listRepositories, publishRepositoryRuntime, registerRepositories } from "../repositories.js"; import { registerRuntimeBridge } from "../bridge.js"; import { Plugin } from "@opencode/plugin"; import { realpath } from "node:fs/promises"; @@ -17,6 +18,8 @@ export default Plugin.define({ async setup(ctx) { const options = GithubOptions.parse(ctx.options); if (await realpath(ctx.location.directory) !== await realpath(options.ownerDirectory)) return; + await registerRepositories(options.repositories.map(r => ({ ownerDirectory: options.ownerDirectory, directory: r.directory, repo: r.repo, baseBranch: r.baseBranch, stateDirectory: options.stateDirectory, registeredAt: Date.now() })), true) + .catch(error => console.error("Repository registration failed", redact(error))); const token = await githubToken(options.tokenEnv); const controller = new AbortController(); const release = await acquire(options.stateDirectory, "github", error => controller.abort(error), true); @@ -25,10 +28,12 @@ export default Plugin.define({ const dispatcher = new Dispatcher(options, new JsonStore(join(options.stateDirectory, "queue.json"), Queue, () => ({ version: 1, tasks: [] })), new Github(token, controller.signal, fetch, options.signature), executor, controller.signal, [token], Date.now, activity => publish(activity)); let releaseBridge: (() => void) | undefined; let registration: { dispose(): Promise } | undefined; + let stopInventory: (() => Promise) | undefined; let stopHeartbeat: (() => Promise) | undefined; let timer: ReturnType | undefined; const stop = () => cleanup( () => { clearInterval(timer); controller.abort(); }, + () => stopInventory?.(), () => stopHeartbeat?.(), () => dispatcher.settle(), () => abortable(async () => { await registration?.dispose(); }, AbortSignal.timeout(5_000)), @@ -54,6 +59,7 @@ export default Plugin.define({ status: async () => JSON.parse(JSON.stringify(dispatcher.status())), activity: async () => dispatcher.activity(), monitor: async () => dispatcher.monitor(), + repositories: async () => listRepositories(), retry: async ({ key, restartSession }) => { controller.signal.throwIfAborted(); return { accepted: await dispatcher.retry(key, restartSession) }; }, close: async ({ key }) => ({ accepted: await dispatcher.closeTask(key) }), restartworkflow: async ({ key }) => ({ accepted: await dispatcher.restartWorkflow(key) }), @@ -63,6 +69,7 @@ export default Plugin.define({ const tick = () => { if (!controller.signal.aborted) void dispatcher.tick().catch(error => { console.error("Dispatcher stopped", redact(error, [token])); controller.abort(error); }); }; timer = setInterval(tick, options.workerEverySeconds * 1000); stopHeartbeat = heartbeat(signal => touchOwner(options.ownerDirectory, signal), error => console.error("Automation owner heartbeat failed", redact(error, [token]))); + stopInventory = publishRepositoryRuntime(options.ownerDirectory, "dispatcher", () => dispatcher.monitor()); tick(); return stop; } catch (error) { diff --git a/src/plugins/scheduler.ts b/src/plugins/scheduler.ts index 2862116..1d5316f 100644 --- a/src/plugins/scheduler.ts +++ b/src/plugins/scheduler.ts @@ -1,3 +1,4 @@ +import { publishRepositoryRuntime } from "../repositories.js"; import { Plugin } from "@opencode/plugin"; import { realpath } from "node:fs/promises"; import { join } from "node:path"; @@ -21,10 +22,12 @@ export default Plugin.define({ return abortable(() => method(job.input, { signal }), signal); }); let registration: { dispose(): Promise } | undefined; + let stopInventory: (() => Promise) | undefined; let stopHeartbeat: (() => Promise) | undefined; let timer: ReturnType | undefined; const stop = () => cleanup( () => { clearInterval(timer); controller.abort(); }, + () => stopInventory?.(), () => stopHeartbeat?.(), () => scheduler.settle(), () => abortable(async () => { await registration?.dispose(); }, AbortSignal.timeout(5_000)), @@ -40,6 +43,7 @@ export default Plugin.define({ const tick = () => { if (!controller.signal.aborted) void scheduler.tick().catch(error => { console.error("Scheduler stopped", error); controller.abort(error); }); }; timer = setInterval(tick, 1000); stopHeartbeat = heartbeat(signal => touchOwner(options.ownerDirectory, signal), error => console.error("Scheduler owner heartbeat failed", redact(error))); + stopInventory = publishRepositoryRuntime(options.ownerDirectory, "scheduler", () => scheduler.status(), () => controller.signal.aborted); tick(); return stop; } catch (error) { diff --git a/src/repositories.ts b/src/repositories.ts new file mode 100644 index 0000000..e77decb --- /dev/null +++ b/src/repositories.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { z } from "zod"; +import { checkout, EasyOptions, run } from "./easy.js"; +import { JsonStore, redact } from "./state.js"; +import { heartbeat } from "./lifecycle.js"; +import { DispatcherMonitor, SchedulerMonitor } from "./monitor.js"; +import { RepositoryEntry, type RepositoryReport, type RepositoryRow } from "./repository-report.js"; + +const Registration = z.object({ version: z.literal(1), entries: z.array(RepositoryEntry).min(1) }); +const Runtime = z.object({ + pid: z.number().int().positive(), at: z.number(), stopped: z.boolean(), + dispatcher: DispatcherMonitor.optional(), scheduler: SchedulerMonitor.optional(), +}); +type Runtime = z.infer; +export function registryDirectory() { + return resolve(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "opencode2-automation", "repositories"); +} +function prefix(owner: string) { return createHash("sha256").update(owner).digest("hex"); } +async function save(file: string, schema: z.ZodType, value: T) { + await mkdir(registryDirectory(), { recursive: true, mode: 0o700 }); + await new JsonStore(file, schema, () => value).save(value); +} +export async function registerRepositories(entries: RepositoryEntry[], preserveConfig = false) { + if (!entries.length) return; + const owner = await realpath(entries[0]!.ownerDirectory); + entries = await Promise.all(entries.map(async e => ({ ...e, directory: await realpath(e.directory) }))); + if (preserveConfig) { + try { + const old = Registration.parse(JSON.parse(await readFile(join(registryDirectory(), `${prefix(owner)}.json`), "utf8"))); + entries = entries.map(e => ({ ...e, configFile: old.entries.find(previous => previous.repo === e.repo && previous.directory === e.directory)?.configFile })); + } catch { /* Registration replaces invalid metadata, never task state. */ } + } + await save(join(registryDirectory(), `${prefix(owner)}.json`), Registration, { version: 1, entries: entries.map(e => ({ ...e, ownerDirectory: owner })) }); +} + +// Import standard configs without resolving credentials, contacting GitHub, or activating an owner. +export async function registerConfigured(directory: string, raw?: unknown) { + const { root, common, primary } = await checkout(directory); + if (!primary || root !== await realpath(directory)) return false; + const configFile = join(root, ".opencode", "automation.json"); + const options = EasyOptions.parse(raw ?? JSON.parse(await readFile(configFile, "utf8"))); + const remote = await run(root, ["git", "remote", "get-url", "origin"]); + const repo = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(remote)?.[1]; + if (!repo) throw new Error("origin must point to a GitHub.com repository"); + await registerRepositories([{ ownerDirectory: root, directory: root, repo, baseBranch: options.baseBranch ?? "auto (resolved on activation)", stateDirectory: join(common, "opencode2-automation"), ...(raw === undefined ? { configFile } : {}), registeredAt: Date.now() }]); + return true; +} + +export async function discoverRepositories(directory: string) { + const warnings: string[] = []; + const found: string[] = []; + let visited = 0; + const walk = async (folder: string, depth: number): Promise => { + if (++visited > 10000) throw new Error("Discovery reached 10000 directories. Choose a smaller root."); + let children; + try { children = await readdir(folder, { withFileTypes: true }); } + catch { warnings.push(`Cannot read directory: ${folder}`); return; } + try { + await readFile(join(folder, ".opencode", "automation.json")); + if (await registerConfigured(folder)) found.push(folder); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") warnings.push(`${folder}: ${redact(error)}`); } + const dirs = children.filter(d => d.isDirectory() && !d.name.startsWith(".") && !["node_modules", "vendor", "build", "dist"].includes(d.name)); + if (depth === 6) { if (dirs.length) warnings.push(`Discovery depth limit reached: ${folder}`); return; } + for (const child of dirs) await walk(join(folder, child.name), depth + 1); + }; + await walk(await realpath(directory), 0); + return { found, warnings }; +} + +// Snapshot writes never control execution. They use independent files for each component. +export function publishRepositoryRuntime(owner: string, component: "dispatcher" | "scheduler", read: () => DispatcherMonitor | SchedulerMonitor, isStopped = () => false) { + const canonical = realpath(owner); + const write = async (stopped: boolean) => { + const file = join(registryDirectory(), `${prefix(await canonical)}.${component}.json`); + const data = read(); + await save(file, Runtime, { pid: process.pid, at: Date.now(), stopped: stopped || isStopped(), + ...(component === "dispatcher" ? { dispatcher: DispatcherMonitor.parse(data) } : { scheduler: SchedulerMonitor.parse(data) }), + }); + }; + const report = (error: unknown) => console.error("Repository status snapshot failed", redact(error)); + const stop = heartbeat(() => write(false), report, 5000); + return async () => { await stop(); await write(true).catch(report); }; +} +function alive(pid: number) { + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } +} +async function readRuntime(owner: string, component: string) { + try { + const value = Runtime.parse(JSON.parse(await readFile(join(registryDirectory(), `${prefix(owner)}.${component}.json`), "utf8"))); + if (component === "dispatcher" ? !value.dispatcher : !value.scheduler) throw new Error("Missing component snapshot"); + return value; + } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw new Error(`Invalid or unreadable ${component} snapshot`, { cause: error }); } +} +export async function listRepositories(now = Date.now()): Promise { + const report: RepositoryReport = { entries: [], warnings: [] }; + let files: string[]; + try { files = await readdir(registryDirectory()); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return report; throw error; } + for (const file of files.filter(f => /^[a-f0-9]{64}\.json$/.test(f)).sort()) { + try { + const registration = Registration.parse(JSON.parse(await readFile(join(registryDirectory(), file), "utf8"))); + for (const entry of registration.entries) { + const row: RepositoryRow = { ...entry, status: "unavailable" }; + report.entries.push(row); + try { + const [d, s] = await Promise.all([readRuntime(entry.ownerDirectory, "dispatcher"), readRuntime(entry.ownerDirectory, "scheduler")]); + row.dispatcher = d?.dispatcher; row.dispatcherAt = d?.at; row.scheduler = s?.scheduler; row.schedulerAt = s?.at; + const fresh = (v?: Runtime) => Boolean(v && !v.stopped && alive(v.pid) && now >= v.at && now - v.at <= 15000); + if (!d || d.stopped || !alive(d.pid)) { + row.status = "not-running"; row.reason = "Configured; dispatcher is not running or has not reported since registration. Snapshots, if present, are historical."; + } else if (!fresh(d) || !fresh(s) || !s?.scheduler?.length) { + row.reason = "Runtime status unavailable or stale. Retained snapshots are historical, not proof of activity."; + } else if (d.dispatcher?.worker === "stopped" || d.dispatcher?.scanError || s?.scheduler?.some(j => j.failures > 0)) { + row.status = "error"; row.reason = "Dispatcher stopped or the latest scan/job failed. Inspect the details."; + } else row.status = s?.scheduler?.length && s.scheduler.every(j => j.paused) ? "paused" : "running"; + } catch (error) { row.reason = redact(error); } + try { + if (!(await stat(entry.directory)).isDirectory() || !(await stat(entry.ownerDirectory)).isDirectory()) throw new Error("Not a directory"); + } catch (error) { + row.status = (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable"; + row.reason = "Registered directory is missing, moved, or inaccessible. No files have been removed."; continue; + } + if (entry.configFile) { + try { EasyOptions.parse(JSON.parse(await readFile(entry.configFile, "utf8"))); } + catch (error) { + row.status = (error as NodeJS.ErrnoException).code === "ENOENT" ? "unconfigured" : "error"; + row.reason = "Project configuration is missing, invalid, or unreadable. A previously loaded runtime may still be active."; + } + } + } + } catch { report.warnings.push(`Invalid or unreadable registry record: ${file}`); } + } + report.entries.sort((a, b) => a.directory.localeCompare(b.directory) || a.repo.localeCompare(b.repo)); + return report; +} diff --git a/src/repository-report.ts b/src/repository-report.ts new file mode 100644 index 0000000..67b538a --- /dev/null +++ b/src/repository-report.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { DispatcherMonitor, SchedulerMonitor } from "./monitor.js"; + +export const RepositoryEntry = z.object({ + ownerDirectory: z.string(), directory: z.string(), repo: z.string(), baseBranch: z.string(), + stateDirectory: z.string(), configFile: z.string().optional(), registeredAt: z.number(), +}); +export type RepositoryEntry = z.infer; +export const RepositoryReport = z.object({ + entries: z.array(RepositoryEntry.extend({ + status: z.enum(["running", "paused", "error", "not-running", "unavailable", "missing", "unconfigured"]), + reason: z.string().optional(), dispatcherAt: z.number().optional(), schedulerAt: z.number().optional(), + dispatcher: DispatcherMonitor.optional(), scheduler: SchedulerMonitor.optional(), + })), warnings: z.array(z.string()), +}); +export type RepositoryReport = z.infer; +export type RepositoryRow = RepositoryReport["entries"][number]; + +// Labels can originate in paths, issue text and saved error messages. +export function plain(value: string) { + // eslint-disable-next-line no-control-regex -- Never emit terminal control sequences from repository data. + return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " "); +} +const timestamp = (value?: number) => value === undefined ? "unknown" : new Date(value).toISOString(); +export function repositoryDetails(row: RepositoryRow) { + const d = row.dispatcher; + const tasks = d?.tasks.filter(t => t.repo === row.repo && t.status !== "closed" && (t.status === "closing" || t.prState !== "closed" && t.phase !== "merged")); + const counts = tasks ? `Active: ${tasks.filter(t => t.key === d?.activeTask).length} · Scheduled: ${tasks.filter(t => ["ready", "retry_wait"].includes(t.status) && t.key !== d?.activeTask).length} · Waiting: ${tasks.filter(t => t.status === "waiting").length} · Blocked/failed: ${tasks.filter(t => ["blocked", "failed"].includes(t.status)).length} · Closing: ${tasks.filter(t => t.status === "closing").length}` : "Task counts: unavailable"; + return [ + `${row.repo} · ${row.status}`, `Directory: ${row.directory}`, `Owner: ${row.ownerDirectory}`, + `Base branch (last registered): ${row.baseBranch}`, ...(row.reason ? [row.reason] : []), + `Dispatcher snapshot: ${timestamp(row.dispatcherAt)} · Scheduler snapshot: ${timestamp(row.schedulerAt)}`, + `Last scan attempt finished: ${timestamp(d?.lastScanFinished)}`, ...(d?.scanError ? [`Scan error: ${d.scanError}`] : []), + `Dispatcher: ${d?.worker ?? "unavailable"}${d?.scanning ? " · scanning" : ""}`, counts, + ...(row.scheduler?.map(s => `Job ${s.id}: ${s.paused ? "paused" : s.running ? "running" : "scheduled"} · next ${s.paused ? "paused" : timestamp(s.nextAt)} · failures ${s.failures}${s.error ? ` · ${s.error}` : ""}`) ?? ["Scheduler: unavailable"]), + ...(tasks?.filter(t => ["blocked", "failed", "closing"].includes(t.status)).map(t => `${t.key}: ${t.status} · ${t.error ?? "Stopping sessions"}`) ?? []), + ].map(plain).join("\n"); +} +export function formatRepositories(report: RepositoryReport) { + return ["Repositories on this host (current user)", "Snapshots refresh every 5s; readings older than 15s are unavailable. Pausing scans does not stop accepted work.", + ...report.entries.map(repositoryDetails), ...report.warnings.map(w => `Warning: ${plain(w)}`), + ...(!report.entries.length ? ["No registered repositories. Run list --discover /path/to/projects to import existing configurations, or init in a new repository."] : []), + ].join("\n\n"); +} diff --git a/src/rpc.ts b/src/rpc.ts index d05e012..f55c92f 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,6 +1,7 @@ import { Rpc } from "@opencode/plugin/rpc"; import { z } from "zod"; import { DispatcherMonitor } from "./monitor.js"; +import { RepositoryReport } from "./repository-report.js"; import { Activity } from "./activity.js"; export const GithubRpc = Rpc.define({ @@ -13,6 +14,7 @@ export const GithubRpc = Rpc.define({ diagnose: { input: z.object({ sessionID: z.string() }), output: z.object({ exists: z.boolean(), error: z.string().optional() }) }, scan: { input: z.object({}).strict(), output: z.object({ queued: z.number(), ignored: z.number() }) }, status: { input: z.object({}).strict(), output: z.array(z.json()) }, + repositories: { input: z.object({}).strict(), output: RepositoryReport }, monitor: { input: z.object({}).strict(), output: DispatcherMonitor }, activity: { input: z.object({}).strict(), output: z.array(Activity) }, retry: { input: z.object({ key: z.string(), restartSession: z.boolean().default(false) }), output: z.object({ accepted: z.boolean() }) }, diff --git a/src/setup.ts b/src/setup.ts index 206aa70..1b65ea9 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -10,10 +10,21 @@ import { Service } from "@opencode/client/service"; import { configure } from "./wizard.js"; import { installLocalEntrypoints } from "./local.js"; import { installGlobalEntrypoints } from "./install.js"; +import { discoverRepositories, listRepositories, registerRepositories } from "./repositories.js"; +import { formatRepositories } from "./repository-report.js"; import { fileURLToPath } from "node:url"; async function main() { const operation = process.argv[2]; + if (operation === "list") { + const { values, positionals } = parseArgs({ args: process.argv.slice(3), options: { json: { type: "boolean" }, discover: { type: "string" } } }); + if (positionals.length) throw new Error("Usage: opencode2-automation list [--json] [--discover /path/to/projects]"); + const discovered = values.discover ? await discoverRepositories(values.discover) : undefined; + const report = await listRepositories(); + report.warnings.push(...discovered?.warnings ?? []); + console.log(values.json ? JSON.stringify(report, null, 2) : formatRepositories(report)); + return; + } if (operation === "install") { const directory = await installGlobalEntrypoints(fileURLToPath(new URL("..", import.meta.url))); console.log(`Registered OpenCode 2 automation and TUI in ${directory}. Restart the service when its sessions are idle. Run init inside a project when ready.`); @@ -42,7 +53,7 @@ async function main() { local: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, } }); if (values.help || positionals[0] !== "init" || positionals.length !== 1) { - console.log("Usage: opencode2-automation install\n opencode2-automation init [--model provider/model] [--trigger @opencodebot] [--base-branch name] [--capabilities text,vision,audio] [--media-model provider/model] [--media-capabilities text,vision] [--system-prompt path.md] [--signature text] [--authors login (repeatable)] [--check executable --check argument | --skip-tests] [--local] [--yes]\n opencode2-automation \n opencode2-automation retry owner/repo#123 [--restart-session]\n opencode2-automation restartworkflow owner/repo#123\ninstall registers the global plugin. Run other commands inside your repository. --local enables an installation in .opencode/node_modules."); + console.log("Usage: opencode2-automation install\n opencode2-automation init [--model provider/model] [--trigger @opencodebot] [--base-branch name] [--capabilities text,vision,audio] [--media-model provider/model] [--media-capabilities text,vision] [--system-prompt path.md] [--signature text] [--authors login (repeatable)] [--check executable --check argument | --skip-tests] [--local] [--yes]\n opencode2-automation \n opencode2-automation list [--json] [--discover /path/to/projects]\n opencode2-automation retry owner/repo#123 [--restart-session]\n opencode2-automation restartworkflow owner/repo#123\ninstall registers the global plugin. list works from any directory. Run other commands inside your repository. --local enables an installation in .opencode/node_modules."); return; } const { root, primary } = await checkout(process.cwd()); @@ -102,6 +113,7 @@ async function main() { await installLocalEntrypoints(root); } } catch (error) { await rm(file); throw error; } + await registerRepositories(resolved.github.repositories.map(r => ({ ownerDirectory: root, directory: r.directory, repo: r.repo, baseBranch: r.baseBranch, stateDirectory: resolved.github.stateDirectory, configFile: file, registeredAt: Date.now() }))).catch(error => console.error(`Repository registration failed; retry with list --discover "${root}": ${error instanceof Error ? error.message : "unknown error"}`)); console.log(`Ready: ${resolved.repo}. Trigger: ${EasyOptions.parse(settings).trigger}. Account: ${resolved.login}. Tests: ${resolved.check === false ? "skipped — the PR will report this" : resolved.check.join(" ")}.\nLoad the project in OpenCode 2 through the TUI or the API. Automation also considers existing matching issues.`); } main().catch(error => { console.error(error instanceof Error ? error.message : "Configuration failed"); process.exitCode = 1; }); diff --git a/src/ui.ts b/src/ui.ts index 72e13e2..fe79841 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,5 +1,6 @@ import type { Plugin } from "@opencode/plugin/tui"; import { GithubRpc } from "./rpc.js"; +import { plain, repositoryDetails } from "./repository-report.js"; import { Activity } from "./activity.js"; export function setupUI(context: Plugin.Context) { @@ -74,11 +75,28 @@ export function setupUI(context: Plugin.Context) { run: async () => { await sync(true); const rows = [...states.values()].reverse(); - if (!rows.length) { context.ui.toast.show({ message: "No bot tasks in this project.", variant: "info" }); return; } const selected = await context.ui.dialog.select({ - title: "Bot tasks", options: rows.map(a => ({ title: `${a.key} · ${a.status}`, description: a.error ?? `Round ${a.round} · ${a.phase}`, value: a.key })), + title: "Bot tasks", options: [ + ...rows.map(a => ({ title: `${a.key} · ${a.status}`, description: a.error ?? `Round ${a.round} · ${a.phase}`, value: a.key })), + { title: "Repositories", description: "Configured folders and bot status on the connected server", value: "repositories" }, + ], }); if (!selected || stopped) return; + if (selected === "repositories") { + try { + const report = await rpc.repositories({}, { location, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) }); + if (stopped) return; + if (report.warnings.length) await context.ui.dialog.alert({ title: "Repository inventory warnings", message: report.warnings.map(plain).join("\n") }); + if (!report.entries.length) { await context.ui.dialog.alert({ title: "Repositories", message: "No registered repositories. Run opencode2-automation list --discover /path/to/projects on the server to import older configurations." }); return; } + const choice = await context.ui.dialog.select({ title: "Repositories — connected server", options: report.entries.map((r, i) => ({ title: plain(`${r.repo} · ${r.status}`), description: plain(r.directory), value: String(i) })) }); + if (choice === undefined || stopped) return; + const row = report.entries[Number(choice)]; + if (row) await context.ui.dialog.alert({ title: plain(row.repo), message: repositoryDetails(row) }); + } catch { + if (!stopped) await context.ui.dialog.alert({ title: "Repositories unavailable", message: "Update/load the owner plugin, or run opencode2-automation list on the server. No repositories were started." }); + } + return; + } const activity = states.get(selected); if (!activity) return; const terminal = ["closing", "closed"].includes(activity.status); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 39317f2..cb745fb 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -26,6 +26,8 @@ test("cleanup settles work and releases ownership even after disposal failures", for (const kind of ["github", "scheduler"] as const) { test(`${kind} plugin releases its actual lock when RPC disposal rejects`, async () => { const directory = await realpath(await mkdtemp(join(tmpdir(), "oc2-lifecycle-"))); + const oldState = process.env.XDG_STATE_HOME; + process.env.XDG_STATE_HOME = directory; const tokenName = "OC2_LIFECYCLE_TEST_TOKEN"; process.env[tokenName] = "test-token"; const rpc = Object.assign(() => ({ scan: async () => ({}) }), { @@ -46,6 +48,7 @@ for (const kind of ["github", "scheduler"] as const) { await release(); assert.equal(runtimeBridge(directory), undefined); } finally { + if (oldState === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = oldState; delete process.env[tokenName]; await rm(directory, { recursive: true, force: true }); } diff --git a/test/repositories.test.ts b/test/repositories.test.ts new file mode 100644 index 0000000..88711f7 --- /dev/null +++ b/test/repositories.test.ts @@ -0,0 +1,148 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { discoverRepositories, listRepositories, publishRepositoryRuntime, registerConfigured, registerRepositories, registryDirectory } from "../src/repositories.js"; +import { formatRepositories, repositoryDetails, type RepositoryEntry } from "../src/repository-report.js"; +import type { DispatcherMonitor, SchedulerMonitor } from "../src/monitor.js"; +import { run } from "../src/easy.js"; + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), "oc2-repositories-"))); + const previous = process.env.XDG_STATE_HOME; + process.env.XDG_STATE_HOME = join(root, "state"); + const project = join(root, "project"); + await mkdir(project); + const entry: RepositoryEntry = { ownerDirectory: project, directory: project, repo: "owner/repo", baseBranch: "main", stateDirectory: join(project, ".git", "opencode2-automation"), registeredAt: Date.now() }; + const dispatcher: DispatcherMonitor = { ownerDirectory: project, worker: "idle", scanning: false, tasks: [] }; + const scheduler: SchedulerMonitor = [{ id: "github-issues", paused: false, running: false, nextAt: Date.now(), failures: 0 }]; + return { root, project, entry, dispatcher, scheduler, async cleanup() { + if (previous === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = previous; + await rm(root, { recursive: true, force: true }); + } }; +} +async function waitFor(predicate: () => Promise) { + for (let i = 0; i < 100; i++) { if (await predicate()) return; await new Promise(r => setTimeout(r, 10)); } + assert.fail("Snapshot was not published"); +} + +test("inventory is read-only and distinguishes active, paused, stale, stopped and missing owners", async () => { + const f = await fixture(); let stopD: (() => Promise) | undefined, stopS: (() => Promise) | undefined; + try { + await registerRepositories([f.entry]); + assert.equal((await listRepositories()).entries[0]?.status, "not-running"); + stopD = publishRepositoryRuntime(f.project, "dispatcher", () => f.dispatcher); + stopS = publishRepositoryRuntime(f.project, "scheduler", () => f.scheduler); + await waitFor(async () => (await listRepositories()).entries[0]?.status === "running"); + const names = await readdir(registryDirectory()); + const before = await Promise.all(names.map(n => readFile(join(registryDirectory(), n), "utf8"))); + await listRepositories(); await listRepositories(); + assert.deepEqual(await Promise.all(names.map(n => readFile(join(registryDirectory(), n), "utf8"))), before); + assert.equal((await listRepositories(Date.now() + 16000)).entries[0]?.status, "unavailable"); + await stopS(); stopS = undefined; + f.scheduler[0]!.paused = true; + stopS = publishRepositoryRuntime(f.project, "scheduler", () => f.scheduler); + await waitFor(async () => (await listRepositories()).entries[0]?.status === "paused"); + await stopD(); stopD = undefined; + assert.equal((await listRepositories()).entries[0]?.status, "not-running"); + await rm(f.project, { recursive: true }); + assert.equal((await listRepositories()).entries[0]?.status, "missing"); + } finally { await stopD?.(); await stopS?.(); await f.cleanup(); } +}); + +test("dead processes and corrupt snapshots never look healthy; damaged records do not hide other repositories", async () => { + const f = await fixture(); + try { + await registerRepositories([f.entry]); + const meta = (await readdir(registryDirectory()))[0]!; + const prefix = meta.replace(/\.json$/, ""); + await writeFile(join(registryDirectory(), `${prefix}.dispatcher.json`), JSON.stringify({ pid: 2147483647, at: Date.now(), stopped: false, dispatcher: f.dispatcher })); + assert.equal((await listRepositories()).entries[0]?.status, "not-running"); + await writeFile(join(registryDirectory(), `${prefix}.dispatcher.json`), "broken"); + assert.equal((await listRepositories()).entries[0]?.status, "unavailable"); + await writeFile(join(registryDirectory(), `${"a".repeat(64)}.json`), "broken"); + const report = await listRepositories(); + assert.equal(report.entries.length, 1); assert.equal(report.warnings.length, 1); + } finally { await f.cleanup(); } +}); + +test("migration finds old configurations without auth or service, deduplicates aliases and excludes worktrees", async () => { + const f = await fixture(); + try { + await run(f.project, ["git", "init", "-b", "main"]); + await run(f.project, ["git", "remote", "add", "origin", "git@github.com:owner/repo.git"]); + await run(f.project, ["git", "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--allow-empty", "-m", "Initial"]); + await mkdir(join(f.project, ".opencode")); + const config = join(f.project, ".opencode", "automation.json"); + await writeFile(config, JSON.stringify({ model: "provider/model", check: false, baseBranch: "main" })); + const worker = join(f.root, "worker"); + await run(f.project, ["git", "worktree", "add", "-b", "task", worker]); + await mkdir(join(worker, ".opencode")); + await writeFile(join(worker, ".opencode", "automation.json"), await readFile(config)); + const alias = join(f.root, "alias"); await symlink(f.project, alias); + const result = await discoverRepositories(f.root); + assert.deepEqual(result.found, [f.project]); assert.deepEqual(result.warnings, []); + assert.equal(await registerConfigured(worker), false); + await registerConfigured(alias); + const report = await listRepositories(); assert.equal(report.entries.length, 1); + assert.equal(report.entries[0]?.repo, "owner/repo"); assert.equal(report.entries[0]?.directory, f.project); + await rm(config); + assert.equal((await listRepositories()).entries[0]?.status, "unconfigured"); + await writeFile(config, "broken"); + assert.equal((await listRepositories()).entries[0]?.status, "error"); + } finally { await f.cleanup(); } +}); + +test("independent concurrent registrations do not overwrite each other and multi-repository owners remain distinct", async () => { + const f = await fixture(); + try { + const other = join(f.root, "other"); await mkdir(other); + await Promise.all([ + registerRepositories([f.entry, { ...f.entry, repo: "owner/second", directory: other }]), + registerRepositories([{ ...f.entry, ownerDirectory: other, directory: other, repo: "owner/third" }]), + ]); + assert.equal((await listRepositories()).entries.length, 3); + } finally { await f.cleanup(); } +}); + +test("reports show concrete issue failures without counting closed history or implying queued work is executing", async () => { + const f = await fixture(); + try { + const task = { key: "owner/repo#7", repo: "owner/repo", issueNumber: 7, round: 1, status: "blocked", phase: "running", sessionReady: true, error: "Session failed\u001b[2J" }; + const details = repositoryDetails({ ...f.entry, status: "running", dispatcher: { ...f.dispatcher, tasks: [task, { ...task, key: "owner/repo#8", status: "closed" }, { ...task, key: "owner/other#9", repo: "owner/other" }] }, scheduler: f.scheduler }); + assert.match(details, /Active: 0 · Scheduled: 0 · Waiting: 0 · Blocked\/failed: 1/); + assert.match(details, /owner\/repo#7: blocked/); assert.doesNotMatch(details, /#8|#9/); assert.equal(details.includes("\u001b"), false); + assert.match(formatRepositories({ entries: [], warnings: [] }), /list --discover/); + } finally { await f.cleanup(); } +}); + +test("CLI list works outside Git without a service and returns machine-readable inventory", async () => { + const f = await fixture(); + try { + await registerRepositories([f.entry]); + const cli = resolve("src/setup.ts"), tsx = resolve("node_modules/tsx/dist/loader.mjs"); + const { stdout } = await promisify(execFile)(process.execPath, ["--import", tsx, cli, "list", "--json"], { cwd: f.root, env: process.env }); + assert.equal(JSON.parse(stdout).entries[0].repo, "owner/repo"); + } finally { await f.cleanup(); } +}); + + +test("fresh scan failures are errors, whereas a missing scheduler or aborted scheduler is unavailable", async () => { + const f = await fixture(); let stopD: (() => Promise) | undefined, stopS: (() => Promise) | undefined; + try { + await registerRepositories([f.entry]); + f.dispatcher.scanError = "GitHub unavailable"; + stopD = publishRepositoryRuntime(f.project, "dispatcher", () => f.dispatcher); + await waitFor(async () => Boolean((await listRepositories()).entries[0]?.dispatcher)); + assert.equal((await listRepositories()).entries[0]?.status, "unavailable"); + stopS = publishRepositoryRuntime(f.project, "scheduler", () => f.scheduler); + await waitFor(async () => (await listRepositories()).entries[0]?.status === "error"); + await stopS(); stopS = undefined; + stopS = publishRepositoryRuntime(f.project, "scheduler", () => f.scheduler, () => true); + await waitFor(async () => (await listRepositories()).entries[0]?.status === "unavailable"); + assert.match(repositoryDetails((await listRepositories()).entries[0]!), /Scan error: GitHub unavailable/); + } finally { await stopD?.(); await stopS?.(); await f.cleanup(); } +}); diff --git a/test/ui.test.ts b/test/ui.test.ts index 67bceff..36f7280 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -9,6 +9,9 @@ function fixture(initial: Activity[] = [], restored: string[] = []) { const recovered: string[] = [], alerts: unknown[] = [], ended: string[] = []; const choices: (string | undefined)[] = []; let recoveryError: Error | undefined; + let repositoriesError = false; + const repositoryRequests: unknown[] = []; + const repositoryReport = { entries: [{ ownerDirectory: "/remote/owner", directory: "/remote/project", stateDirectory: "/remote/state", repo: "remote/repo", baseBranch: "devel", registeredAt: 0, status: "not-running" }], warnings: [] }; const commands = new Map Promise>(); const toasts: unknown[] = [], opened: string[] = [], navigated: unknown[] = [], closed: string[] = []; const tabs = new Map(restored.map(sessionID => [sessionID, { sessionID, busy: false }])); @@ -17,7 +20,7 @@ function fixture(initial: Activity[] = [], restored: string[] = []) { let command!: () => Promise, unsubscribed = false; const context = { location: { directory: "/repo" }, - client: { rpc: () => ({ activity: async () => initial, close: async ({ key }: { key: string }) => { ended.push(key); return { accepted: true }; }, restartworkflow: async ({ key }: { key: string }) => { if (recoveryError) throw recoveryError; recovered.push(key); return { accepted: true }; }, events: { on: (_name: string, cb: typeof listener) => { listener = cb; return () => { unsubscribed = true; }; } } }) }, + client: { rpc: () => ({ repositories: async (_input: unknown, request: unknown) => { repositoryRequests.push(request); if (repositoriesError) throw new Error("Unavailable"); return repositoryReport; }, activity: async () => initial, close: async ({ key }: { key: string }) => { ended.push(key); return { accepted: true }; }, restartworkflow: async ({ key }: { key: string }) => { if (recoveryError) throw recoveryError; recovered.push(key); return { accepted: true }; }, events: { on: (_name: string, cb: typeof listener) => { listener = cb; return () => { unsubscribed = true; }; } } }) }, data: { session: { sync: async () => {} } }, keymap: { layer: (get: () => { commands: { slash: { name: string }; run: () => Promise }[] }) => { command = get().commands[0]!.run; for (const cmd of get().commands) commands.set(cmd.slash.name, cmd.run); } }, ui: { @@ -33,7 +36,7 @@ function fixture(initial: Activity[] = [], restored: string[] = []) { }, } as unknown as Plugin.Context; const stop = setupUI(context)!; - return { ended, choose: (...values: (string | undefined)[]) => choices.push(...values), recovered, alerts, recoveryError: (error: Error) => { recoveryError = error; }, restart: () => commands.get("restartworkflow")!(), toasts, opened, navigated, closed, tabs, enableTabs: (value: boolean) => { enabled = value; }, stop, unsubscribed: () => unsubscribed, command: () => command(), event: (data: Activity, directory = "/repo") => listener({ data, location: { directory } }) }; + return { repositoryRequests, repositoriesError: () => { repositoriesError = true; }, ended, choose: (...values: (string | undefined)[]) => choices.push(...values), recovered, alerts, recoveryError: (error: Error) => { recoveryError = error; }, restart: () => commands.get("restartworkflow")!(), toasts, opened, navigated, closed, tabs, enableTabs: (value: boolean) => { enabled = value; }, stop, unsubscribed: () => unsubscribed, command: () => command(), event: (data: Activity, directory = "/repo") => listener({ data, location: { directory } }) }; } test("a start event opens a background tab once without navigating the current conversation", async () => { @@ -151,3 +154,17 @@ test("/bot can close tracking before a session exists and tab-only closure never assert.match(JSON.stringify(f.toasts.at(-1)), /Busy tabs/); } finally { f.stop(); } }); + + +test("/bot lists remote repositories even with no tasks and never starts or restarts a task", async () => { + const f = fixture(); + try { + f.choose("repositories", "0"); await f.command(); + assert.match(JSON.stringify(f.alerts.at(-1)), /remote\/repo/); + assert.match(JSON.stringify(f.alerts.at(-1)), /remote\/project/); + assert.equal((f.repositoryRequests[0] as { location: { directory: string } }).location.directory, "/repo"); + assert.deepEqual(f.recovered, []); assert.deepEqual(f.ended, []); assert.deepEqual(f.navigated, []); + f.repositoriesError(); f.choose("repositories"); await f.command(); + assert.match(JSON.stringify(f.alerts.at(-1)), /Repositories unavailable/); + } finally { f.stop(); } +});