diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml new file mode 100644 index 00000000..291e8caf --- /dev/null +++ b/.github/workflows/catalog.yml @@ -0,0 +1,59 @@ +name: Model Catalog + +# Publishes the model catalog to https://catalog.codework.sh/v1/models.json, the R2 bucket that +# `infra/catalog` deploys. `vp run catalog` builds it from models.dev and OpenRouter, refuses one +# that fails its checks, and reports whether it differs from the published one; only a changed +# catalog is uploaded. +on: + schedule: + - cron: "23 * * * *" + workflow_dispatch: + inputs: + publish: + description: "Upload the catalog to R2 when it changed" + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: catalog + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + # A bucket-scoped R2 API token (Object Read & Write on catalog-bucket-live only), stored as + # CATALOG_R2_ACCESS_KEY_ID / CATALOG_R2_SECRET_ACCESS_KEY, and the account's S3 endpoint + # (https://.r2.cloudflarestorage.com) as CATALOG_R2_ENDPOINT. + environment: catalog + steps: + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - run: vp install --frozen-lockfile --filter @codeworksh/aikit + - id: build + run: vp run --filter @codeworksh/aikit catalog "$GITHUB_WORKSPACE/.artifacts/catalog/v1/models.json" + - uses: actions/upload-artifact@v4 + with: + name: catalog + path: .artifacts/catalog + retention-days: 14 + - if: steps.build.outputs.changed == 'true' && (github.event_name == 'schedule' || inputs.publish) + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CATALOG_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CATALOG_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: ${{ secrets.CATALOG_R2_ENDPOINT }} + # One PUT, never multipart: R2's ETag is then the file's MD5, which clients send back to get a 304. + run: >- + aws s3api put-object --bucket catalog-bucket-live --key v1/models.json + --body .artifacts/catalog/v1/models.json + --endpoint-url "$R2_ENDPOINT" + --content-type application/json + --cache-control "public, max-age=300" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index edb40224..d39b4772 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,10 @@ name: Publish # One workflow publishes every channel, since npm trusted publishing names a single workflow file. # - canary / dev: every push to that branch # - latest: run by hand on `main`; publishes each package whose version is not on npm yet -# dev and latest also build the standalone binary and attach it to a GitHub Release: `devel`, one -# prerelease replaced on every dev push, or `cli-v`, marked latest. canary stays npm-only. +# dev and latest also make GitHub Releases (scripts/github.mjs): `devel`, one prerelease with the +# standalone binary replaced on every dev push, or `@v` per package released on +# latest, the CLI's with the binary and marked latest. canary makes `nightly`, one prerelease +# with the desktop app replaced on every canary push. on: push: branches: [canary, dev] @@ -192,16 +194,78 @@ jobs: path: release merge-multiple: true # codework.sh/install verifies each download against SHA256SUMS. - - run: | - (cd release && sha256sum *.tar.gz > SHA256SUMS) - version="$(node scripts/version.mjs "$CHANNEL" packages/codework)" - if [ "$CHANNEL" = dev ]; then - gh release delete devel --cleanup-tag --yes || true - gh release create devel release/* --prerelease --target "$GITHUB_SHA" \ - --title "CLI $version" --notes "Development build $version, from $GITHUB_SHA." - elif gh release view "cli-v$version" > /dev/null 2>&1; then - echo "cli-v$version is already released" - else - gh release create "cli-v$version" release/* --latest --target "$GITHUB_SHA" \ - --title "CLI $version" --generate-notes - fi + - run: (cd release && sha256sum *.tar.gz > SHA256SUMS) + - run: node scripts/github.mjs "$CHANNEL" release + + desktop: + needs: guard + if: github.ref_name == 'canary' + name: desktop (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + # Each on its own platform: electron-builder packages for the host, and macOS signs. + include: + - target: mac-arm64 + runner: macos-14 + args: --mac --arm64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + args: --linux --arm64 + - target: linux-x64 + runner: ubuntu-24.04 + args: --linux --x64 + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + steps: + - uses: actions/checkout@v6 + # The pacman target packs with bsdtar. + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libarchive-tools + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + # The app packages the web app and the app server it builds, so those install too. + - run: vp install --frozen-lockfile --filter '@codeworksh/desktop...' --filter '@codeworksh/server...' + - working-directory: apps/desktop + run: vp run package "$CHANNEL" ${{ matrix.args }} + # Installers only; electron-builder's update blockmaps and unpacked app stay behind. + - uses: actions/upload-artifact@v7 + with: + name: desktop-${{ matrix.target }} + path: | + apps/desktop/dist/codework-desktop-*.dmg + apps/desktop/dist/codework-desktop-*.zip + apps/desktop/dist/codework-desktop-*.AppImage + apps/desktop/dist/codework-desktop-*.deb + apps/desktop/dist/codework-desktop-*.pacman + if-no-files-found: error + retention-days: 1 + + # After npm, so `nightly` never names a commit whose packages did not ship. + nightly: + needs: [guard, publish, desktop] + if: github.ref_name == 'canary' + runs-on: ubuntu-latest + permissions: + contents: write + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - uses: actions/download-artifact@v8 + with: + pattern: desktop-* + path: release + merge-multiple: true + - run: (cd release && sha256sum codework-desktop-* > SHA256SUMS) + - run: node scripts/github.mjs canary release diff --git a/.gitignore b/.gitignore index 0a0c8259..117e18fe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .pnpm-store out +.electron-runtime dist build .next @@ -56,5 +57,14 @@ models.gen.json .alchemy +.freebuff +opencode.json + # Rust build output (packages/tui binary; the launcher loads the built artifact). target + +# testing +videos + +# Catalog CI output (.github/workflows/catalog.yml) +.artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f65733..1d7f4484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ This file is the canonical source for unreleased changes and published release n - Add in-progress work under `## [Unreleased] +## [Unreleased] + +### Added + +- Added Agent Skills (`codework.prompt.skill`). A skill is a directory with a `SKILL.md` whose frontmatter has a `name` and a `description`; `.codework/skills` may also hold standalone `.md` skills. Skills are found in the session's sandbox, local or remote, and never on the host or in ``: `.codework/skills` in the working directory, then `.agents/skills` in the working directory and each parent up to the git root (or up to the sandbox root outside a repository). `.gitignore`, `.ignore` and `.fdignore` are honoured. The system prompt lists only each skill's name, description and location under ``; the model reads the full file when a task matches. A skill with `disable-model-invocation: true` is not listed. Invalid fields, unreadable files and name collisions log a warning instead of failing; the first skill found keeps the name. The hint names the `read` tool, or `bash` when `read` is off, and names no tool when the reader is hidden from the prompt. Turn it off with `{ "plugin": "codework.prompt.skill", "enabled": false }` in `plugins`. +- Added `project: { name }` to `session.create`. It names the session's project when the session creates it, ahead of the repository's and the folder's name; a project that already exists keeps its name. The name must be trimmed and not empty. +- Added the `project.rename` RPC (`{ projectId, name }`), which renames a project and fails with `ProjectNotFoundError` for an unknown id. The harness exposes it as `Project.rename`. +- The desktop start page creates a session in the folder you pick, or in a memory sandbox, sends your first prompt and opens the session. A memory session's project is named "In Memory". +- Added the `model.list` RPC (`{ hostDir? }`). It returns the providers the server holds credentials for (an API key in its environment, a stored OAuth login, or a keyless provider from settings), each model's supported thinking levels, and the `default` model and thinking level that settings pick for a session anchored at `hostDir`, with its display names. Without `hostDir` only the user's settings apply. Credentials are checked for presence, not validity. A broken settings file fails with `SettingsError`. +- The desktop start page's composer has a model picker and a Reasoning picker. The model picker lists providers down a rail with their models beside it and searches across all of them. With no model picked, settings decide; a default whose provider has no credentials shows muted and cannot be picked. Reasoning offers only the levels the model supports and marks the settings level as Default. The picks are remembered across restarts. +- Added a model catalog bundled with `@codeworksh/aikit` for OpenAI, Anthropic and OpenRouter (`vp run baseline` in `packages/aikit` regenerates it). It is read when no catalog has been generated yet, so a first run works offline. A generated catalog replaces each provider it lists. +- The model catalog is published to `https://catalog.codework.sh/v1/models.json` (an R2 bucket, `infra/catalog`) by a scheduled GitHub Action, which refuses a catalog that fails its schema check, misses a required model or drops more than a fifth of a provider's models. A refresh now downloads that catalog instead of building one from models.dev on every machine, and asks with the MD5 of its last download so an unchanged catalog costs a 304. A model whose protocol or API method this version does not know is left out. `CODEWORK_CATALOG_URL` points a client at another catalog, a `file:` URL at a local copy. `aikit modelgen` still builds one locally. +- With `OPENROUTER_API_KEY` set, a catalog refresh keeps only the OpenRouter models that key may use, with the key's context limits and prices (`GET /models/user`). +- Added `gpt-6.1-sol` (500k context), `gpt-6-sol` and `gpt-6-astra` to OpenAI Codex. + +### Changed + +- The default thinking level, for settings that name none, is now `medium` instead of `high`. +- The model catalog generator follows Pi's current rules. OpenAI Codex entries are built from models.dev's OpenAI entries, so their prices and long-context tiers come from there. `gpt-5.4` and `gpt-5.4-mini` are no longer listed for Codex, because the Codex backend rejects them for ChatGPT accounts. +- OpenAI reasoning levels come from models.dev's `reasoning_options`, and OpenRouter models are listed from OpenRouter's own `/models`, which gives the reasoning efforts each one accepts. `:batch` variants are left out. +- Prices take models.dev's long-context tiers for every provider, and OpenAI service-tier multipliers come from models.dev's modes (for example ultrafast 6x on GPT-6.1 Sol). Direct OpenAI requests to the GPT-5.4+ and GPT-6 models default to the 272k short-context window; settings can raise `contextWindow`. +- The generator drops the models.dev alias `gpt-5.6`, which OpenAI's API rejects. Claude Opus 4.7+, Opus 5.x, Sonnet 5.5 and Haiku 5.5 no longer get a `temperature` field, which their API rejects. +- Renamed `OPENCODE_MODELS_URL` and `OPENCODE_MODELS_DEV_FILE` to `CODEWORK_MODELS_DEV_URL` and `CODEWORK_MODELS_DEV_FILE`. +- Upgraded the AI SDK packages to their latest releases, including `@ai-sdk/xai` 5, which drops the Chat Completions API: xAI models run on Responses only, as the catalog already set, and `XaiOptions` takes only Responses options. All providers now share one `@ai-sdk/provider` version. +- A missing model catalog is no longer an error (`model-catalog-missing` is gone); the bundled catalog is read instead. + +### Fixed + +- Fixed `codework` failing to start after a fresh npm install with `The requested module 'effect/Path' does not provide an export named 'make'`. `@effect/platform-node` pulled in a newer `@effect/platform-node-shared` than the pinned `effect` supports. Every Effect package is now pinned to 4.0.5, and `@effect/platform-node-shared` is pinned with them. + ## [@codeworksh/cli@0.3.0] - 2026-10-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 3aef3cc8..c6b1d36a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,11 +80,13 @@ Conventional Commit style with optional package scopes, e.g `feat(aikit): ...`, Every package is published by CI from `.github/workflows/publish.yml`, which runs `vp run release ` (`scripts/release.mjs`). It builds the TUI binaries, then publishes the packages in dependency order, derived from their `package.json` workspace dependencies (`scripts/workspace.mjs`), pinning each package to the exact versions uploaded in the same run. `scripts/publish.mjs` builds a single package, rewrites its manifest and publishes it from a temp dir. -- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `-canary...g`. +- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `-canary...g`. It also packages the desktop app (`apps/desktop`, `vp run package `) as "CodeWork Canary" for macOS arm64 and Linux x64/arm64, and replaces the `nightly` prerelease with it. - **dev**: every push to `dev`. Versions are `-dev.` (UTC). - **latest**: run the workflow by hand on `main`. It publishes each package whose manifest version is not on npm yet and skips the rest. -Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@codeworksh/@` without pushing. `vp run bump tui` moves the launcher and its three platform packages together. +GitHub Releases (`scripts/github.mjs`): dev replaces the `devel` prerelease with the CLI binaries; latest makes a `@v` release per package not released yet, the CLI's carrying the binaries. canary replaces the `nightly` prerelease with the desktop app. + +Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@v` without pushing. `vp run bump tui` moves the launcher and its three platform packages together. Flow: diff --git a/apps/contract/package.json b/apps/contract/package.json new file mode 100644 index 00000000..7bf09ddc --- /dev/null +++ b/apps/contract/package.json @@ -0,0 +1,20 @@ +{ + "name": "@codeworksh/contract", + "version": "0.0.0", + "private": true, + "description": "The desktop app server's RPC contract, shared by the app server and the web app.", + "type": "module", + "exports": { + ".": "./src/contract.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codeworksh/widget": "workspace:*", + "effect": "4.0.5" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts new file mode 100644 index 00000000..8cf728ed --- /dev/null +++ b/apps/contract/src/contract.ts @@ -0,0 +1,311 @@ +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/rpc"; +import { ModelList } from "@codeworksh/widget/codework"; +import { Event, EventTimeout, Publish, Request, Synced } from "@codeworksh/widget/event"; + +/** A conversation with the agent, as listed under its project. */ +export const SessionSummary = Schema.Struct({ + id: Schema.String, + title: Schema.String, + /** Last activity, epoch milliseconds. */ + updated: Schema.Number, + /** The model chosen for the session; absent when settings decide. */ + model: Schema.optionalKey(Schema.Struct({ provider: Schema.String, id: Schema.String })), +}); +export type SessionSummary = typeof SessionSummary.Type; + +export const Project = Schema.Struct({ + id: Schema.String, + name: Schema.String, + path: Schema.String, + sessions: Schema.Array(SessionSummary), +}); +export type Project = typeof Project.Type; + +/** The CodeWork server that owns projects and sessions is not running, or did not answer. */ +export class CodeworkUnavailable extends Schema.TaggedError()("Server.CodeworkUnavailable", { + message: Schema.String, +}) {} + +/** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */ +export class ModelsError extends Schema.TaggedError()("Server.ModelsError", { + source: Schema.Literals(["settings", "catalog"]), + path: Schema.String, + message: Schema.String, +}) {} + +export const Layout = Schema.Literals(["tile", "monocle"]); +export type Layout = typeof Layout.Type; + +/** + * A workspace. `bit` is its webwm tag bit, fixed for life so reordering never + * rewrites widget masks; `pos` is its place in the switcher and its Cmd+N key. + */ +export const Workspace = Schema.Struct({ + id: Schema.String, + bit: Schema.Number, + pos: Schema.Number, + name: Schema.String, + /** A name from the app's icon set, e.g. "code". */ + icon: Schema.String, + layout: Layout, + /** Master column share of the width, 0–1. */ + mfact: Schema.Number, + /** Created to show something the user opened (a session); removed once it shows nothing. */ + auto: Schema.Boolean, +}); +export type Workspace = typeof Workspace.Type; + +export const WorkspaceCreate = Schema.Struct({ + name: Schema.String, + icon: Schema.String, + auto: Schema.optionalKey(Schema.Boolean), +}); +export const WorkspaceUpdate = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + icon: Schema.optional(Schema.String), + layout: Schema.optional(Layout), + mfact: Schema.optional(Schema.Number), +}); +export const WorkspaceOrder = Schema.Struct({ ids: Schema.Array(Schema.String) }); +export const WorkspaceId = Schema.Struct({ id: Schema.String }); + +export class WorkspaceError extends Schema.TaggedError()("Server.WorkspaceError", { + message: Schema.String, +}) {} + +/** + * One placed widget on the desk. `tags` is its webwm mask: the workspaces it shows on, by their + * bits. `props` and `params` are the widget's own JSON; the server keeps them as given. + */ +export const Instance = Schema.Struct({ + id: Schema.String, + kind: Schema.String, + tags: Schema.Number, + props: Schema.Json, + pinned: Schema.Boolean, + title: Schema.optionalKey(Schema.String), + address: Schema.optionalKey(Schema.String), + params: Schema.optionalKey(Schema.Json), + openedBy: Schema.optionalKey(Schema.String), +}); +export type Instance = typeof Instance.Type; + +/** Every widget on every workspace, with the desk-wide tile and focus order over their ids. */ +export const Desk = Schema.Struct({ + instances: Schema.Array(Instance), + tileOrder: Schema.Array(Schema.String), + focusOrder: Schema.Array(Schema.String), +}); +export type Desk = typeof Desk.Type; + +export class DeskError extends Schema.TaggedError()("Server.DeskError", { + message: Schema.String, +}) {} + +/** + * The `codework` executable the app uses, and its `--version`, or why that failed. `custom` when it + * is the path chosen in Settings rather than the one found. + */ +export const CliInstall = Schema.Union([ + Schema.Struct({ path: Schema.String, custom: Schema.Boolean, version: Schema.String }), + Schema.Struct({ path: Schema.String, custom: Schema.Boolean, failure: Schema.String }), +]); +export type CliInstall = typeof CliInstall.Type; + +/** + * The CodeWork server registered in the home. `Incompatible` answers but its replies do not decode + * here: another build, even one with the same version. + */ +export const CliServer = Schema.Union([ + Schema.TaggedStruct("Running", { url: Schema.String, pid: Schema.Int, version: Schema.String }), + Schema.TaggedStruct("Incompatible", { url: Schema.String, pid: Schema.Int, version: Schema.String }), + Schema.TaggedStruct("Stopped", {}), + Schema.TaggedStruct("Unreachable", { url: Schema.String, pid: Schema.Int }), +]); +export type CliServer = typeof CliServer.Type; + +/** + * A variable the server is started with. A sensitive one's value never leaves the app server, so + * it comes without `value`; sent back without one, the saved value stays. + */ +export const CliEnvVar = Schema.Struct({ + name: Schema.String, + sensitive: Schema.Boolean, + value: Schema.optionalKey(Schema.String), +}); +export type CliEnvVar = typeof CliEnvVar.Type; + +/** Only servers on this machine for now; remote ones come later. */ +export const ServerKind = Schema.Literals(["local"]); + +/** The server the app always has, which cannot be removed. */ +export const LOCAL_SERVER = "local"; + +/** A server and how it is set up, with its executable and process as found when listed. */ +export const ServerStatus = Schema.Struct({ + id: Schema.String, + kind: ServerKind, + name: Schema.String, + /** The one the app connects to. */ + isDefault: Schema.Boolean, + /** + * The CodeWork home whose `service.json` names the server; `custom` when chosen in Settings, and + * `fallback` the one used otherwise (only the built-in server has one). + */ + home: Schema.Struct({ path: Schema.String, custom: Schema.Boolean, fallback: Schema.NullOr(Schema.String) }), + /** None when no `codework` was found. */ + cli: Schema.NullOr(CliInstall), + server: CliServer, + /** Extra arguments for `codework service start` and `restart`, as typed. */ + args: Schema.String, + env: Schema.Array(CliEnvVar), +}); +export type ServerStatus = typeof ServerStatus.Type; + +export const CliAction = Schema.Literals(["start", "restart", "stop"]); +export type CliAction = typeof CliAction.Type; + +/** A new local server: it needs a home of its own, since a home names one server. */ +export const ServerAdd = Schema.Struct({ + name: Schema.String, + executable: Schema.NullOr(Schema.String), + home: Schema.String, + args: Schema.String, +}); +export type ServerAdd = typeof ServerAdd.Type; + +/** + * Changes to a server; an absent field is left as it is. A `null` path goes back to the one found; + * `env` replaces every variable. + */ +export const ServerUpdate = Schema.Struct({ + id: Schema.String, + name: Schema.optionalKey(Schema.String), + executable: Schema.optionalKey(Schema.NullOr(Schema.String)), + home: Schema.optionalKey(Schema.NullOr(Schema.String)), + args: Schema.optionalKey(Schema.String), + env: Schema.optionalKey(Schema.Array(CliEnvVar)), +}); +export type ServerUpdate = typeof ServerUpdate.Type; + +export const ServerId = Schema.Struct({ id: Schema.String }); + +export class ServersError extends Schema.TaggedError()("Server.ServersError", { + message: Schema.String, +}) {} + +/** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */ +const KvKey = { package: Schema.String, key: Schema.String }; + +export const Api = RpcGroup.make( + Rpc.make("projects.list", { + success: Schema.Array(Project), + error: CodeworkUnavailable, + }), + /** The models a new session can use; `hostDir` is its folder, whose project settings apply. */ + Rpc.make("models.list", { + payload: { hostDir: Schema.optionalKey(Schema.String) }, + success: ModelList, + error: Schema.Union([CodeworkUnavailable, ModelsError]), + }), + Rpc.make("workspaces.list", { + success: Schema.Array(Workspace), + }), + Rpc.make("workspaces.create", { + payload: WorkspaceCreate, + success: Workspace, + error: WorkspaceError, + }), + Rpc.make("workspaces.update", { + payload: WorkspaceUpdate, + success: Schema.Void, + error: WorkspaceError, + }), + /** Puts the workspaces in this order; `ids` must name every workspace once. */ + Rpc.make("workspaces.reorder", { + payload: WorkspaceOrder, + success: Schema.Void, + error: WorkspaceError, + }), + Rpc.make("workspaces.delete", { + payload: WorkspaceId, + success: Schema.Void, + error: WorkspaceError, + }), + Rpc.make("desk.get", { + success: Desk, + }), + /** Replaces the whole desk; both orders must name every instance exactly once. */ + Rpc.make("desk.save", { + payload: Desk, + success: Schema.Void, + error: DeskError, + }), + /** Every server, the built-in one first, then in the order added. */ + Rpc.make("servers.list", { + success: Schema.Array(ServerStatus), + }), + Rpc.make("servers.add", { + payload: ServerAdd, + success: Schema.String, + error: ServersError, + }), + /** A new home for the default server reconnects the app at once. */ + Rpc.make("servers.update", { + payload: ServerUpdate, + success: Schema.Void, + error: ServersError, + }), + /** The default server and the built-in one stay. */ + Rpc.make("servers.remove", { + payload: ServerId, + success: Schema.Void, + error: ServersError, + }), + /** The app reconnects through the new default's home at once. */ + Rpc.make("servers.setDefault", { + payload: ServerId, + success: Schema.Void, + error: ServersError, + }), + /** Runs `codework service ` with the server's executable, home, arguments and variables. */ + Rpc.make("servers.service", { + payload: { id: Schema.String, action: CliAction }, + success: Schema.Void, + error: ServersError, + }), + Rpc.make("kv.get", { + payload: KvKey, + success: Schema.OptionFromNullOr(Schema.Json), + }), + Rpc.make("kv.set", { + payload: { ...KvKey, value: Schema.Json }, + success: Schema.Void, + }), + Rpc.make("event.publish", { + payload: Publish, + success: Event, + }), + /** + * Publishes a request and returns its reply: the first event on the same topic whose `cause` is + * the request's id. A request republished with its id returns the reply already stored. + */ + Rpc.make("event.request", { + payload: Request, + success: Event, + error: EventTimeout, + }), + /** + * Events on `topics`: those after `after` from the log, then `Synced`, then live ones. Without + * `after` nothing is replayed, so `Synced` comes first and carries the current head. + */ + Rpc.make("event.subscribe", { + payload: { topics: Schema.Array(Schema.String), after: Schema.optional(Schema.Int) }, + success: Schema.Union([Event, Synced]), + stream: true, + }), +); + +export * as Contract from "./contract.ts"; diff --git a/apps/contract/tsconfig.json b/apps/contract/tsconfig.json new file mode 100644 index 00000000..db29c6c1 --- /dev/null +++ b/apps/contract/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "../../.cache/typescript/contract.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/apps/desktop/bump.config.ts b/apps/desktop/bump.config.ts new file mode 100644 index 00000000..1e796b86 --- /dev/null +++ b/apps/desktop/bump.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "bumpp"; + +// Release config for the desktop app, loaded automatically by `bumpp`. Its version is its own; +// canary and dev builds add their run id to it as the npm packages do. +export default defineConfig({ + tag: "desktop@v%s", + commit: "release: desktop@v%s", + push: false, +}); diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts new file mode 100644 index 00000000..05d469cc --- /dev/null +++ b/apps/desktop/electron-builder.config.ts @@ -0,0 +1,65 @@ +import type { Configuration } from "electron-builder"; + +// One identity per channel, so channels install side by side with their own data. The version +// is the run's, passed by scripts/package.mjs. +const CHANNELS = { + latest: { productName: "CodeWork", appId: "sh.codework.desktop", packageName: "codework-desktop" }, + dev: { productName: "CodeWork Dev", appId: "sh.codework.desktop.dev", packageName: "codework-desktop-dev" }, + canary: { + productName: "CodeWork Canary", + appId: "sh.codework.desktop.canary", + packageName: "codework-desktop-canary", + }, +} as const; + +const channel = process.env.CODEWORK_CHANNEL; +if (channel !== "latest" && channel !== "dev" && channel !== "canary") { + throw new Error(`CODEWORK_CHANNEL must be latest, dev or canary, not ${channel}`); +} +const { productName, appId, packageName } = CHANNELS[channel]; + +export default { + appId, + productName, + // The same name on every channel; the release a file sits in says which channel it is. + artifactName: "codework-desktop-${os}-${arch}.${ext}", + // Nothing native to rebuild: the app server uses node:sqlite. + npmRebuild: false, + copyright: "Copyright © CodeWork", + directories: { output: "dist", buildResources: "resources" }, + extraMetadata: { name: packageName, productName, homepage: "https://www.codework.sh" }, + // The app is its two bundles and its icon; Electron is the only dependency, and it is the runtime. + files: ["out/*.cjs", "resources/icon.png", "package.json"], + // The app server runs as its own process from Resources, so it sits outside the asar archive. + extraResources: [ + { from: "../server/dist/index.mjs", to: "server/index.mjs" }, + { from: "../web/dist", to: "web" }, + ], + mac: { + target: [ + { target: "dmg", arch: "arm64" }, + { target: "zip", arch: "arm64" }, + ], + category: "public.app-category.developer-tools", + icon: "resources/icon.png", + // Ad-hoc: Apple silicon runs nothing unsigned. A downloaded build still needs its quarantine + // flag removed until builds are signed and notarized. + identity: "-", + hardenedRuntime: false, + }, + linux: { + // Each format names x64 and arm64 its own way (x86_64, amd64, aarch64); one name for all, + // from the arch scripts/package.mjs builds for. + artifactName: "codework-desktop-linux-${env.CODEWORK_ARCH}.${ext}", + target: ["AppImage", "deb", "pacman"], + category: "Development", + icon: "resources/icon.png", + maintainer: "CodeWork", + executableName: packageName, + }, + deb: { packageName }, + pacman: { packageName }, + // The static AppImage runtime, so it runs without libfuse2. + toolsets: { appimage: "1.0.3" }, + publish: null, +} satisfies Configuration; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f33c4f5e..df930c6d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,20 +1,22 @@ { "name": "@codeworksh/desktop", - "version": "0.0.0", + "version": "0.3.0", "private": true, + "description": "The CodeWork desktop app.", "type": "module", "main": "out/main.cjs", "scripts": { "build": "vp pack", "dev": "node scripts/dev.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "electron": "44.4.2" + "typecheck": "tsc --noEmit", + "package": "node scripts/package.mjs" }, "devDependencies": { "@types/node": "catalog:", + "bumpp": "^12.3.0", + "electron": "44.4.2", + "electron-builder": "^26.15.3", "typescript": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 00000000..cab5e895 Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/package.mjs b/apps/desktop/scripts/package.mjs new file mode 100644 index 00000000..0c1a8a3b --- /dev/null +++ b/apps/desktop/scripts/package.mjs @@ -0,0 +1,47 @@ +// `vp run package [electron-builder args]`: builds the web app, the app server +// and this app, then packages them for the channel under the run's version, e.g. `--mac --arm64 --dir`. +// In CI the version carries the run id; locally a dry-run id stands in. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { releaseId, releaseVersion } from "../../../scripts/version.mjs"; + +const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const [channel, ...builderArgs] = process.argv.slice(2); +if (!["canary", "dev", "latest"].includes(channel)) { + console.error("Usage: vp run package [electron-builder args]"); + process.exit(1); +} + +const run = (command, args, cwd, env = process.env) => { + const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status ?? 1); +}; + +// One arch per run: the one asked for, else this machine's (Node names them as the file names do). +const arch = builderArgs.includes("--arm64") ? "arm64" : builderArgs.includes("--x64") ? "x64" : process.arch; + +const { version: base } = JSON.parse(readFileSync(path.join(desktopDir, "package.json"), "utf8")); +const version = releaseVersion(base, channel, releaseId(channel, { dryRun: !process.env.CI })); + +run("vp", ["build"], path.resolve(desktopDir, "../web")); +run("vp", ["pack"], path.resolve(desktopDir, "../server")); +run("vp", ["pack"], desktopDir); +run( + "pnpm", + [ + "exec", + "electron-builder", + "--config", + "electron-builder.config.ts", + `-c.extraMetadata.version=${version}`, + "--publish", + "never", + ...builderArgs, + ], + desktopDir, + { ...process.env, CODEWORK_CHANNEL: channel, CODEWORK_ARCH: arch }, +); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 3eeb31a6..65928a80 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,10 +1,41 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const electronPath = createRequire(import.meta.url)("electron"); +const electronBinary = createRequire(import.meta.url)("electron"); +const APP_NAME = "CodeWork"; +// Its own identity, so a local run never shares data or Dock state with an installed channel. +const APP_ID = "sh.codework.desktop.local"; + +// macOS names the Dock icon after the running bundle's Info.plist, so on macOS +// Electron runs from a renamed copy of Electron.app, rebuilt when Electron changes. +function brandedElectron() { + const source = path.resolve(electronBinary, "../../.."); + const target = path.join(desktopDir, ".electron-runtime", `${APP_NAME}.app`); + const marker = path.join(desktopDir, ".electron-runtime", "source"); + const binary = path.join(target, "Contents", "MacOS", "Electron"); + const stamp = `${source}\n${APP_ID}`; + if (existsSync(marker) && readFileSync(marker, "utf8") === stamp) return binary; + + rmSync(target, { recursive: true, force: true }); + // Keeps the frameworks' relative symlinks; absolute ones crash the helper processes. + cpSync(source, target, { recursive: true, verbatimSymlinks: true }); + const plist = path.join(target, "Contents", "Info.plist"); + for (const [key, value] of [ + ["CFBundleName", APP_NAME], + ["CFBundleDisplayName", APP_NAME], + ["CFBundleIdentifier", APP_ID], + ]) + execFileSync("plutil", ["-replace", key, "-string", value, plist]); + execFileSync("codesign", ["--force", "--deep", "--sign", "-", target]); + writeFileSync(marker, stamp); + return binary; +} + +const electronPath = process.platform === "darwin" ? brandedElectron() : electronBinary; const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 1b0f60de..74e04584 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,2 +1,8 @@ /** Synchronous IPC: the renderer reads the app server's RPC URL once, at load. */ export const SERVER_CHANNEL = "codework:server"; +/** Synchronous IPC: the user's home directory, the folder a new project starts from. */ +export const HOME_CHANNEL = "codework:home"; +/** Invoke: opens the native folder dialog (Finder on macOS); resolves to the chosen path or null. */ +export const PICK_FOLDER_CHANNEL = "codework:pick-folder"; +/** Invoke: shows a file in Finder (Explorer on Windows), selected. */ +export const REVEAL_CHANNEL = "codework:reveal"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 390d174d..5b5ca17d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,6 +1,7 @@ -import { app, BrowserWindow, net, protocol } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from "electron"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL } from "./ipc.ts"; import { startServer } from "./server.ts"; const SCHEME = app.isPackaged ? "codework" : "codework-dev"; @@ -8,9 +9,15 @@ const HOST = "app"; const APP_URL = `${SCHEME}://${HOST}/`; // When set (web dev server), the scheme proxies to it; otherwise the bundled -// `@codeworksh/web` build is served from disk. +// `@codeworksh/web` build is served from disk: the app's Resources when packaged, +// else the workspace beside this app. const DEV_SERVER_URL = process.env.CODEWORK_WEB_URL; -const CLIENT_ASSETS_DIR = process.env.CODEWORK_WEB_DIR ?? path.resolve(app.getAppPath(), "../web/dist"); +const CLIENT_ASSETS_DIR = + process.env.CODEWORK_WEB_DIR ?? + (app.isPackaged ? path.join(process.resourcesPath, "web") : path.resolve(app.getAppPath(), "../web/dist")); +// The 3D brand mark on Apple's icon grid. A packaged build will carry it as an .icns; until then +// it is set at runtime, or the Dock shows Electron's own icon. +const ICON = path.join(app.getAppPath(), "resources/icon.png"); const MIME_TYPES: Record = { ".html": "text/html", @@ -106,12 +113,17 @@ function createMainWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1440, height: 900, + // Wider than the web app's 768px narrow breakpoint, so the sidebar always sits beside the content. + minWidth: 840, + minHeight: 620, // The web shell draws its own title bar (workspace switcher) beside the - // inset traffic lights, which sit centered in its 44px height. + // inset traffic lights, which sit centered in its 48px height. ...(process.platform === "darwin" && { titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 14, y: 16 }, + trafficLightPosition: { x: 14, y: 18 }, }), + // Windows and Linux take the window's icon; macOS ignores it for the Dock's. + icon: ICON, webPreferences: { preload: path.join(app.getAppPath(), "out/preload.cjs"), sandbox: true, @@ -122,7 +134,22 @@ function createMainWindow(): BrowserWindow { } void app.whenReady().then(async () => { + app.dock?.setIcon(ICON); protocol.handle(SCHEME, handleRequest); + ipcMain.on(HOME_CHANNEL, (event) => { + event.returnValue = app.getPath("home"); + }); + ipcMain.handle(PICK_FOLDER_CHANNEL, async (event, from: string) => { + const options: Electron.OpenDialogOptions = { + defaultPath: from, + properties: ["openDirectory", "createDirectory"], + }; + // Attached to the window, so on macOS it drops down as a sheet. + const window = BrowserWindow.fromWebContents(event.sender); + const result = await (window === null ? dialog.showOpenDialog(options) : dialog.showOpenDialog(window, options)); + return result.canceled ? null : (result.filePaths[0] ?? null); + }); + ipcMain.handle(REVEAL_CHANNEL, (_event, file: string) => shell.showItemInFolder(file)); await startServer(); createMainWindow(); @@ -134,3 +161,7 @@ void app.whenReady().then(async () => { app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); + +// A signal (the dev runner restarting, a terminal's Ctrl+C) quits as the app does, so the app server +// is stopped too; left to the default, Electron would exit at once and leave it running. +for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => app.quit()); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f829c408..8fec9cb6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,8 +1,11 @@ import { contextBridge, ipcRenderer } from "electron"; -import { SERVER_CHANNEL } from "./ipc.ts"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL, SERVER_CHANNEL } from "./ipc.ts"; contextBridge.exposeInMainWorld("desktopBridge", { platform: process.platform, // Main creates the window only after the server is ready, so this is set. server: ipcRenderer.sendSync(SERVER_CHANNEL) as string, + home: ipcRenderer.sendSync(HOME_CHANNEL) as string, + pickFolder: (from: string) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, from) as Promise, + reveal: (path: string) => ipcRenderer.invoke(REVEAL_CHANNEL, path) as Promise, }); diff --git a/apps/desktop/src/server.ts b/apps/desktop/src/server.ts index c0b0aa33..406729c6 100644 --- a/apps/desktop/src/server.ts +++ b/apps/desktop/src/server.ts @@ -4,8 +4,13 @@ import path from "node:path"; import { createInterface } from "node:readline"; import { SERVER_CHANNEL } from "./ipc.ts"; -// Dev layout: the server bundle sits beside this app in the workspace. -const ENTRY = path.resolve(app.getAppPath(), "../server/dist/index.mjs"); +// The server bundle: in the app's Resources when packaged, else beside this app in the workspace. +const ENTRY = app.isPackaged + ? path.join(process.resourcesPath, "server", "index.mjs") + : path.resolve(app.getAppPath(), "../server/dist/index.mjs"); + +// How long the server gets to stop by itself on quit before it is killed. +const STOP_GRACE_MS = 5000; /** Starts the app server and resolves with its RPC URL once it listens. */ export function startServer(): Promise { @@ -20,11 +25,27 @@ export function startServer(): Promise { stdio: ["ignore", "pipe", "inherit"], }); - app.on("will-quit", () => child.kill()); + // Quitting waits for the server to stop, asked with SIGTERM and killed if it outlasts the grace, so + // the app never leaves it running. + let stopping = false; + const running = () => child.exitCode === null && child.signalCode === null; + app.on("before-quit", (event) => { + if (!running()) return; + event.preventDefault(); + if (stopping) return; + stopping = true; + const force = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); + child.once("exit", () => { + clearTimeout(force); + app.quit(); + }); + child.kill("SIGTERM"); + }); return new Promise((resolve, reject) => { let ready = false; child.once("exit", (code, signal) => { + if (stopping) return; if (!ready) return reject(new Error(`server exited before ready (code ${code}, signal ${signal})`)); // No restart yet: the app is unusable without its server, so it ends // with it and the dev runner (or the user) starts it again. diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 64f99a91..fe14fcdd 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["ES2023", "DOM"], "tsBuildInfoFile": "../../.cache/typescript/desktop.tsbuildinfo" }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "electron-builder.config.ts", "bump.config.ts"] } diff --git a/apps/server/package.json b/apps/server/package.json index b02e9b62..323f8d91 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,18 +3,19 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { - "./contract": "./src/contract.ts" - }, "scripts": { "build": "vp pack", "dev": "vp pack --watch", + "test": "vp test", "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/platform-node": "4.0.1", - "@effect/sql-sqlite-node": "4.0.1", - "effect": "4.0.1" + "@codeworksh/contract": "workspace:*", + "@codeworksh/web": "workspace:*", + "@codeworksh/widget": "workspace:*", + "@effect/platform-node": "4.0.5", + "@effect/sql-sqlite-node": "4.0.5", + "effect": "4.0.5" }, "devDependencies": { "@types/node": "catalog:", diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts new file mode 100644 index 00000000..a3f1ac26 --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,247 @@ +import { Config, Effect, FileSystem, Option, Schedule, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +/* @effect-diagnostics nodeBuiltinImport:off -- the install folder sits under the OS home directory. */ +import os from "node:os"; +import path from "node:path"; +import { Codework } from "./codework.ts"; +import { + type CliAction, + type CliInstall, + type CliServer, + type ServerAdd, + ServersError, + type ServerStatus, + type ServerUpdate, +} from "@codeworksh/contract"; +import { Servers } from "./servers.ts"; + +// Where the install script puts the binary unless told otherwise. +const installDir = Config.String("CODEWORK_INSTALL_DIR").pipe( + Config.withDefault(path.join(os.homedir(), ".codework", "bin")), + Effect.orDie, +); + +/** + * Runs `file` to completion, with stdout and stderr together; a spawn failure or timeout is a failure. + * `env` is added to this process's own. + */ +const run = ( + file: string, + args: ReadonlyArray, + timeout: `${number} seconds`, + env: Record = {}, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn(ChildProcess.make(file, args, { stdin: "ignore", env, extendEnv: true })); + const [output, code] = yield* Effect.all( + [handle.all.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { + concurrency: "unbounded", + }, + ); + return { output: output.trim(), code: Number(code) }; + }).pipe(Effect.scoped, Effect.timeout(timeout)); + +// A Node crash prints the source line first; the error line says what went wrong. +const reason = (output: string, code: number) => { + const lines = output.split("\n").map((line) => line.trim()); + const line = lines.findLast((candidate) => /error/i.test(candidate)) ?? lines.findLast(Boolean); + return line === undefined ? `exited with code ${code}` : `exited with code ${code}: ${line.slice(0, 240)}`; +}; + +// An app opened from the Dock does not inherit the terminal's PATH, so the login shell is asked. +const onPath = Effect.gen(function* () { + const shell = process.env.SHELL ?? "/bin/sh"; + const { output } = yield* run(shell, ["-ilc", "command -v codework"], "5 seconds"); + return output + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line.startsWith("/")); +}).pipe(Effect.orElseSucceed(() => undefined)); + +interface Command { + readonly file: string; + /** Arguments that go before the CLI's own, as for a source entry run by `node`. */ + readonly prefix: ReadonlyArray; +} + +/** A chosen executable, split as typed; a found binary's path is never split, as it may hold spaces. */ +const split = (executable: string): Command => { + const [file = executable, ...prefix] = Servers.splitArgs(executable); + return { file, prefix }; +}; + +// `executable` is shown as `path`: a binary's path, or the whole command as stored. +const probe = ({ file, prefix }: Command, executable: string, custom: boolean) => + run(file, [...prefix, "--version"], "10 seconds").pipe( + Effect.map(({ output, code }): CliInstall => { + const version = /\bv?(\d+\.\d+\.\d+\S*)/.exec(output)?.[1]; + if (code === 0 && version !== undefined) return { path: executable, custom, version }; + return { path: executable, custom, failure: reason(output, code) }; + }), + Effect.catch((error) => Effect.succeed({ path: executable, custom, failure: error.message })), + ); + +const exists = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + }); + +/** + * The `codework` found without a choice: the install script's, else the login shell's, whichever + * runs first; when neither runs, the first found, with why. + */ +const found = Effect.gen(function* () { + const candidates = [ + path.join(yield* installDir, "codework"), + ...Option.toArray(Option.fromUndefinedOr(yield* onPath)), + ]; + let broken: CliInstall | null = null; + for (const file of candidates) { + if (!(yield* exists(file))) continue; + const install = yield* probe({ file, prefix: [] }, file, false); + if ("version" in install) return install; + broken ??= install; + } + return broken; +}); + +/** The `codework` a server runs: the one chosen for it, else the one found. */ +const executableOf = (row: Servers.Row, foundOnce: typeof found) => { + const { executable } = row; + if (executable === null) return foundOnce; + const command = split(executable); + return Effect.flatMap(exists(command.file), (present) => + present + ? probe(command, executable, true) + : Effect.succeed({ path: executable, custom: true, failure: "No file at this path." }), + ); +}; + +const alive = (pid: number) => + Effect.sync(() => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it exists, under another user. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } + }); + +/** + * The server a home names. The default one is asked through the app's own connection, since a build + * whose replies differ still answers `server.info`: a call the app depends on decides whether it + * works with this app. Any other is taken from its registration and process. + */ +const serverAt = (home: string, isDefault: boolean) => + Effect.gen(function* () { + const registered = yield* Codework.registrationAt(home); + if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const; + const { url, pid, version: registeredVersion } = registered.value; + if (!isDefault) return { _tag: "Running", url, pid, version: registeredVersion } satisfies CliServer; + const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option); + if (Option.isNone(info) || info.value.pid !== pid) return { _tag: "Unreachable", url, pid } satisfies CliServer; + const { version } = info.value; + const usable = yield* Codework.call((client) => client("session.list", {})).pipe(Effect.isSuccess); + return { _tag: usable ? "Running" : "Incompatible", url, pid, version } satisfies CliServer; + }); + +// The app's connection reconnects with a backoff; waiting until it reaches the server keeps the +// status read right after from calling a healthy server unreachable. Only when there is one to reach. +const awaitConnection = Effect.gen(function* () { + if (Option.isNone(yield* Codework.registration)) return; + yield* Codework.call((client) => client("server.info", {})).pipe( + Effect.retry(Schedule.spaced("250 millis")), + Effect.timeout("15 seconds"), + Effect.ignore, + ); +}); + +export const handlers = { + "servers.list": Effect.fn("servers.list")(function* () { + const servers = yield* Servers.Service; + const foundOnce = yield* Effect.cached(found); + return yield* Effect.forEach( + yield* servers.list, + (row) => + Effect.gen(function* () { + const home = servers.homeOf(row); + const [cli, server] = yield* Effect.all( + [executableOf(row, foundOnce), serverAt(home.path, row.isDefault)], + { + concurrency: "unbounded", + }, + ); + return { + id: row.id, + kind: row.kind, + name: row.name, + isDefault: row.isDefault, + home, + cli, + server, + args: row.args, + env: row.env.map(({ name, sensitive, value }) => + sensitive ? { name, sensitive } : { name, sensitive, value }, + ), + } satisfies ServerStatus; + }), + { concurrency: "unbounded" }, + ); + }), + + "servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) { + return yield* (yield* Servers.Service).add(input); + }), + + "servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) { + const servers = yield* Servers.Service; + const before = yield* servers.defaultHome; + yield* servers.update(input); + if ((yield* servers.defaultHome) !== before) yield* awaitConnection; + }), + + "servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) { + yield* (yield* Servers.Service).remove(id); + }), + + "servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) { + const servers = yield* Servers.Service; + const before = yield* servers.defaultHome; + yield* servers.setDefault(id); + if ((yield* servers.defaultHome) !== before) yield* awaitConnection; + }), + + // The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its + // home, which a variable never overrides. + "servers.service": Effect.fn("servers.service")(function* ({ + id, + action, + }: { + readonly id: string; + readonly action: CliAction; + }) { + const servers = yield* Servers.Service; + const row = yield* servers.get(id); + const cli = yield* executableOf(row, found); + if (cli === null || !("version" in cli)) + return yield* new ServersError({ message: "No working codework CLI was found." }); + const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value])); + // `stop` takes no server options, so the arguments go only to what starts a server. + const args = action === "stop" ? [] : Servers.splitArgs(row.args); + const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable); + const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", { + ...env, + CODEWORK_HOME_DIR: servers.homeOf(row).path, + }).pipe( + Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })), + ); + if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` }); + if (row.isDefault && action !== "stop") yield* awaitConnection; + }), +}; + +export * as Cli from "./cli.ts"; diff --git a/apps/server/src/codework.ts b/apps/server/src/codework.ts new file mode 100644 index 00000000..ac05bd31 --- /dev/null +++ b/apps/server/src/codework.ts @@ -0,0 +1,100 @@ +import { Codework as Mirror } from "@codeworksh/widget/codework"; +import { Codework as Client } from "@codeworksh/widget/backend"; +import { NodeSocket } from "@effect/platform-node"; +import { Deferred, Effect, FileSystem, Layer, Schedule, Schema, Stream } from "effect"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { Socket } from "effect/socket"; +/* @effect-diagnostics nodeBuiltinImport:off -- a home's registration is a file path. */ +import path from "node:path"; +import { CodeworkUnavailable } from "@codeworksh/contract"; +import { Servers } from "./servers.ts"; + +export type SessionInfo = Mirror.SessionInfo; + +/** How a running server advertises itself: `/service.json`, written by `codework serve`. */ +const Registration = Schema.fromJsonString( + Schema.Struct({ pid: Schema.Int, version: Schema.String, url: Schema.String, port: Schema.Int }), +); + +/** The server `/service.json` names, read fresh; a missing or unreadable file is none. */ +export const registrationAt = (home: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs + .readFileString(path.join(home, "service.json")) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Registration)), Effect.option); + }); + +/** The server the default server's home names: the one the app connects to. */ +export const registration = Effect.gen(function* () { + return yield* registrationAt(yield* (yield* Servers.Service).defaultHome); +}); + +/** + * The connection ends when the home changes, as if the server went away: the client reconnects, and + * so reads the new home's registration, and its callers resubscribe as after any drop. + */ +const endOnHomeChange = (socket: Socket.Socket, changes: Stream.Stream): Socket.Socket => ({ + ...socket, + // Watched from when the connection opens, so a change between two reads still ends it. + reader: Effect.gen(function* () { + const reader = yield* socket.reader; + const changed = yield* Deferred.make(); + yield* Stream.runHead(changes).pipe(Effect.andThen(Deferred.succeed(changed, undefined)), Effect.forkScoped); + const closed = Deferred.await(changed).pipe( + Effect.andThen(Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) }))), + ); + return { ...reader, pull: reader.pull.pipe(Effect.raceFirst(closed)) }; + }), +}); + +/** + * The app's one connection to a CodeWork server: the default server's. The registration is read on + * every connect, so a server restarted on a new port, a new home or a new default is picked up + * without restarting this app; until one is registered, connecting waits. + */ +export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: true })).pipe( + Layer.provide(RpcClient.layerProtocolSocket({ retryTransientErrors: true })), + Layer.provide( + Layer.effect( + Socket.Socket, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const servers = yield* Servers.Service; + const url = registration.pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((registered) => registered.url), + Effect.retry(Schedule.spaced("1 second")), + Effect.orDie, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Servers.Service, servers), + // The socket reads its URL while opening, which Effect runs uninterruptibly; waiting there + // for a server to register would otherwise keep the app server from ever shutting down. + Effect.interruptible, + ); + return endOnHomeChange(yield* Socket.makeWebSocket(url), servers.homeChanges); + }), + ).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)), + ), + Layer.provide(RpcSerialization.layerNdjson), + Layer.orDie, +); + +/** + * One call on behalf of the app's own RPC handlers. A server that is down, or of another build (a + * reply this mirror cannot decode), surfaces as `CodeworkUnavailable`. + */ +export const call = (use: (client: typeof Client.Service) => Effect.Effect) => + Client.use(use).pipe( + Effect.timeout("5 seconds"), + Effect.catchCause(() => + Effect.fail( + new CodeworkUnavailable({ + message: + "No usable reply from the CodeWork server. Start one with `codework service start`; it may also be running a build older than this app.", + }), + ), + ), + ); + +export * as Codework from "./codework.ts"; diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts deleted file mode 100644 index 3ff7bdb9..00000000 --- a/apps/server/src/contract.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Schema } from "effect"; -import { Rpc, RpcGroup } from "effect/rpc"; - -export const EntryKind = Schema.Literals(["file", "directory"]); -export type EntryKind = typeof EntryKind.Type; - -/** One direct child of a listed directory. */ -export const Entry = Schema.Struct({ name: Schema.String, kind: EntryKind }); -export type Entry = typeof Entry.Type; - -export class FsError extends Schema.TaggedError()("Server.FsError", { - path: Schema.String, - message: Schema.String, -}) {} - -/** A conversation with the agent, as listed under its project. */ -export const SessionSummary = Schema.Struct({ - id: Schema.String, - title: Schema.String, - /** Last activity, epoch milliseconds. */ - updated: Schema.Number, -}); -export type SessionSummary = typeof SessionSummary.Type; - -export const Project = Schema.Struct({ - id: Schema.String, - name: Schema.String, - path: Schema.String, - sessions: Schema.Array(SessionSummary), -}); -export type Project = typeof Project.Type; - -export const Message = Schema.Struct({ - role: Schema.Literals(["user", "assistant"]), - text: Schema.String, -}); -export type Message = typeof Message.Type; - -export const Session = Schema.Struct({ - ...SessionSummary.fields, - project: Schema.String, - messages: Schema.Array(Message), -}); -export type Session = typeof Session.Type; - -export class SessionNotFound extends Schema.TaggedError()("Server.SessionNotFound", { - id: Schema.String, -}) {} - -export const Layout = Schema.Literals(["tile", "monocle"]); -export type Layout = typeof Layout.Type; - -/** - * A workspace. `bit` is its webwm tag bit, fixed for life so reordering never - * rewrites widget masks; `pos` is its place in the switcher and its Cmd+N key. - */ -export const Workspace = Schema.Struct({ - id: Schema.String, - bit: Schema.Number, - pos: Schema.Number, - name: Schema.String, - /** A name from the app's icon set, e.g. "code". */ - icon: Schema.String, - layout: Layout, - /** Master column share of the width, 0–1. */ - mfact: Schema.Number, -}); -export type Workspace = typeof Workspace.Type; - -export const WorkspaceCreate = Schema.Struct({ name: Schema.String, icon: Schema.String }); -export const WorkspaceUpdate = Schema.Struct({ - id: Schema.String, - name: Schema.optional(Schema.String), - icon: Schema.optional(Schema.String), - layout: Schema.optional(Layout), - mfact: Schema.optional(Schema.Number), -}); -export const WorkspaceOrder = Schema.Struct({ ids: Schema.Array(Schema.String) }); -export const WorkspaceId = Schema.Struct({ id: Schema.String }); - -export class WorkspaceError extends Schema.TaggedError()("Server.WorkspaceError", { - message: Schema.String, -}) {} - -/** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */ -const KvKey = { package: Schema.String, key: Schema.String }; - -export const Api = RpcGroup.make( - Rpc.make("fs.list", { - payload: { path: Schema.String }, - success: Schema.Array(Entry), - error: FsError, - }), - Rpc.make("projects.list", { - success: Schema.Array(Project), - }), - Rpc.make("sessions.get", { - payload: { id: Schema.String }, - success: Session, - error: SessionNotFound, - }), - Rpc.make("workspaces.list", { - success: Schema.Array(Workspace), - }), - Rpc.make("workspaces.create", { - payload: WorkspaceCreate, - success: Workspace, - error: WorkspaceError, - }), - Rpc.make("workspaces.update", { - payload: WorkspaceUpdate, - success: Schema.Void, - error: WorkspaceError, - }), - /** Puts the workspaces in this order; `ids` must name every workspace once. */ - Rpc.make("workspaces.reorder", { - payload: WorkspaceOrder, - success: Schema.Void, - error: WorkspaceError, - }), - Rpc.make("workspaces.delete", { - payload: WorkspaceId, - success: Schema.Void, - error: WorkspaceError, - }), - Rpc.make("kv.get", { - payload: KvKey, - success: Schema.OptionFromNullOr(Schema.Json), - }), - Rpc.make("kv.set", { - payload: { ...KvKey, value: Schema.Json }, - success: Schema.Void, - }), -); - -export * as Contract from "./contract.ts"; diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index a7c60cf7..0c8721d9 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -1,12 +1,12 @@ import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"; -import { Clock, Effect, Layer, String as Str } from "effect"; +import { Clock, Effect, Layer, Schema, String as Str } from "effect"; import { Migrator, SqlClient } from "effect/sql"; // Migrations ship as code so they survive bundling. Keys are // "YYYYMMDD_