From d7fe0234c6206297bb392a4f9113239dc7f6e0f8 Mon Sep 17 00:00:00 2001 From: RichardGeorgeDavis Date: Sun, 10 May 2026 18:30:54 +0200 Subject: [PATCH] Harden Workspace Hub audit and stale-info cleanup --- README.md | 6 + docs/02-local-runtime-handover.md | 14 +- docs/03-workspace-hub-build-spec.md | 14 +- docs/05-examples-and-templates.md | 4 +- docs/10-release-readiness.md | 13 +- docs/12-maintainer-runbook.md | 6 +- docs/CHANGELOG.md | 22 +- docs/HANDOVER.md | 41 +++- docs/README.md | 7 +- docs/plans/readme-docs-closeout.md | 4 +- project-manifest.template.json | 4 +- repo-index.sample.json | 4 +- repos/workspace-hub/README.md | 13 +- repos/workspace-hub/docs/manifest.md | 12 +- .../server/core-service-runtime.ts | 31 ++- repos/workspace-hub/server/core-services.ts | 7 + repos/workspace-hub/server/index.ts | 225 ++++++++++++++---- repos/workspace-hub/server/repo-intake.ts | 4 +- repos/workspace-hub/server/repo-manifest.ts | 14 +- .../server/workspace-metadata.ts | 2 +- repos/workspace-hub/server/workspace.ts | 30 +-- repos/workspace-hub/src/app/App.tsx | 8 +- .../src/features/repos/RepoDetails.tsx | 54 ++--- .../features/services/CoreServiceDetails.tsx | 21 +- .../features/services/CoreServicesPanel.tsx | 21 +- .../services/MempalaceWorkspacePage.tsx | 32 ++- repos/workspace-hub/src/lib/api.ts | 189 ++++----------- .../src/lib/workspaceHubIntent.ts | 6 + .../src/lib/workspaceMemoryPause.ts | 7 + repos/workspace-hub/src/types/workspace.ts | 14 +- .../test/core-services-panel.test.tsx | 4 + .../test/mempalace-memory.test.ts | 14 ++ .../test/repo-details-context-cache.test.ts | 6 +- .../test/workspace-cache-search.test.ts | 12 +- .../test/workspace-healthcheck.test.ts | 100 +++++++- tools/scripts/cleanup-sync-noise.sh | 75 +++++- tools/scripts/doctor-workspace.sh | 7 +- tools/scripts/mempalace-env.sh | 1 - tools/scripts/setup-workspace-profile.sh | 1 - tools/scripts/trim-git-repos.sh | 4 +- 40 files changed, 697 insertions(+), 356 deletions(-) create mode 100644 repos/workspace-hub/src/lib/workspaceHubIntent.ts create mode 100644 repos/workspace-hub/src/lib/workspaceMemoryPause.ts diff --git a/README.md b/README.md index 3a481a5..f8167b0 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ explicitly needs them. See [docs/21-agent-token-budget.md](docs/21-agent-token-b Tracked repo knowledge belongs in public docs, manifests, and portable skills. Local operator memory belongs in ignored local files until it becomes stable enough to promote into tracked project guidance. +To keep stale information out of the workspace, dated local verification results should replace vague "latest" claims. Generated context under `cache/` is useful evidence, but tracked docs and manifests stay canonical. External service status, including TomeVault profile or scan counts, should be checked live before it is recorded as current. + Workspace memory is temporarily disabled. `tools/bin/workspace-memory` now exits without running MemPalace closeout, ingest, search, wake-up, export, or graph commands while the write-lock and corpus-size behavior is reviewed. @@ -265,6 +267,10 @@ Current closeout guidance: - record workspace closeout in `docs/HANDOVER.md` and `docs/CHANGELOG.md` - use generated context-cache summaries under `cache/context/` only as optional local side-load material, not as canonical memory +For local cleanup, use `tools/scripts/cleanup-sync-noise.sh` first in its +default dry-run mode. Pass `--run` only for targeted macOS/sync-noise removal; +do not use broad `git clean` flows for workspace cleanup. + When a capability becomes part of how the whole workspace operates, prefer a core-service shape: - tracked runtime code in `tools/` diff --git a/docs/02-local-runtime-handover.md b/docs/02-local-runtime-handover.md index 569a0bf..b73219d 100644 --- a/docs/02-local-runtime-handover.md +++ b/docs/02-local-runtime-handover.md @@ -51,7 +51,7 @@ The Workspace Hub application should: - open preview URLs - open repos in Finder, terminal, editor, or Codex where useful - store known ports and preferred launch methods -- optionally provide **mapped-host-aware** preview links when `servbayPath` / `servbaySubdomain` (stable manifest keys) are set +- optionally provide **mapped-host-aware** preview links when `mappedHostPath` / `mappedHostSubdomain` (stable manifest keys) are set ## Recommended Hub app approach @@ -169,7 +169,7 @@ Suggested fields: - `name` - `type` -- `preferredMode` → `direct` | `servbay` | `external` (the value `servbay` means mapped-host/proxy preview; see Hub manifest docs for stable JSON keys) +- `preferredMode` → `direct` | `mapped-host` | `external` (the value `mapped-host` means mapped-host/proxy preview; see Hub manifest docs for stable JSON keys) - `packageManager` - `devCommand` - `buildCommand` @@ -180,8 +180,8 @@ Suggested fields: - `notes` - `tags` - `isWordPress` -- `servbayPath` -- `servbaySubdomain` +- `mappedHostPath` +- `mappedHostSubdomain` - `healthcheckUrl` Not every field is required. @@ -218,8 +218,8 @@ Use this when: - proxying adds unnecessary complexity - the project expects to run at root -### `servbay` (mapped host / proxy) -The manifest uses the stable enum value `servbay` for previews served through a configured path or subdomain on your local domain (see `servbayPath` and `servbaySubdomain`). +### `mapped-host` (mapped host / proxy) +The manifest uses the stable enum value `mapped-host` for previews served through a configured path or subdomain on your local domain (see `mappedHostPath` and `mappedHostSubdomain`). Use this when: - clean URLs are useful @@ -242,7 +242,7 @@ Use this when: Default to: - `external` for WordPress projects already managed in Local - `direct` for Vite, Three.js, WebGL, and most static repos -- `servbay` only where a mapped path or subdomain is clearly useful and stable +- `mapped-host` only where a mapped path or subdomain is clearly useful and stable This avoids fragile path/proxy assumptions. diff --git a/docs/03-workspace-hub-build-spec.md b/docs/03-workspace-hub-build-spec.md index 6e8a94b..def0e06 100644 --- a/docs/03-workspace-hub-build-spec.md +++ b/docs/03-workspace-hub-build-spec.md @@ -314,7 +314,7 @@ Suggested example: "buildCommand": "pnpm build", "previewCommand": "pnpm preview", "previewUrl": "http://localhost:5173", - "servbayPath": "/repo/three-lab", + "mappedHostPath": "/repo/three-lab", "tags": ["threejs", "art", "experiment"], "notes": "Uses heavy assets and WebGL" } @@ -332,8 +332,8 @@ Suggested example: - `previewCommand` - `previewUrl` - `externalUrl` -- `servbayPath` -- `servbaySubdomain` +- `mappedHostPath` +- `mappedHostSubdomain` - `tags` - `notes` - `healthcheckUrl` @@ -398,13 +398,13 @@ Preferred for: - WordPress sites already managed in Local - any repo controlled by another app -### Mapped host preview (`servbay` mode in manifests) +### Mapped host preview (`mapped-host` mode in manifests) Use when: - proxying is useful - a stable mapped path is known - the dashboard is being used as the front door -The manifest enum value remains `servbay` for compatibility; do not force every repo into this mode. +Use the manifest enum value `mapped-host` only for repos with a tested mapped path or subdomain; do not force every repo into this mode. ## Optional mapped-host integration requirements @@ -464,7 +464,7 @@ Build: Build: - settings for optional proxy base domain - mapped-host preview-link generation -- optional path and subdomain awareness (manifest keys `servbayPath`, `servbaySubdomain`) +- optional path and subdomain awareness (manifest keys `mappedHostPath`, `mappedHostSubdomain`) - dashboard mode through a configured local hostname when used ## Definition of done @@ -476,7 +476,7 @@ This build spec is complete when Codex can implement a Workspace Hub that: - classifies repo types conservatively - reads optional repo manifests - starts and stops supported repos -- opens previews in direct, external, or mapped-host (`servbay`) mode +- opens previews in direct, external, or mapped-host (`mapped-host`) mode - stores useful non-sensitive metadata - remains useful even if no proxy front door is configured - feels lightweight, practical, and scalable diff --git a/docs/05-examples-and-templates.md b/docs/05-examples-and-templates.md index 8b52efe..dcb1e84 100644 --- a/docs/05-examples-and-templates.md +++ b/docs/05-examples-and-templates.md @@ -77,7 +77,7 @@ The manifest template demonstrates: - a normal slug - package manager and commands - direct preview URL -- optional mapped-host path (`servbayPath` in manifests) +- optional mapped-host path (`mappedHostPath` in manifests) - optional healthcheck URL - lightweight notes and tags @@ -140,7 +140,7 @@ Use for: - Local-managed WordPress sites - repos opened through another tool or service -### `servbay` (mapped host / proxy) +### `mapped-host` (mapped host / proxy) Use when: - a clean mapped path or local-domain route is stable - proxying adds real convenience diff --git a/docs/10-release-readiness.md b/docs/10-release-readiness.md index edba116..2e122ad 100644 --- a/docs/10-release-readiness.md +++ b/docs/10-release-readiness.md @@ -13,11 +13,12 @@ The target is a practical release gate: - a clear support matrix - a migration note for the `.codex/` contract -Current status: +Current status, checked on 2026-05-10: -- stable baseline verified on 2026-04-03 -- automated checks passed via `bootstrap-workspace.sh`, both doctor scripts, and `release-readiness.sh` -- live Workspace Hub smoke checks passed for a direct-preview repo, an external WordPress repo, and a mixed-stack SwiftPM repo +- workspace release tag remains `v1.2.2` +- latest local `tools/scripts/release-readiness.sh` run passed with both doctor scripts, Workspace Hub tests, lint, build, skill-sync dry run, and placeholder checks +- Workspace Hub tests covered 51 passing cases in the latest local gate +- live Workspace Hub smoke checks are still required before publishing a new release or changing runtime behavior ## Stable contract @@ -50,6 +51,10 @@ Run these before calling a release stable or after changes that could affect the Do not skip the manual repo check just because automated verification passed. +When documenting release state, prefer dated local verification results over +open-ended claims such as "latest" or "current". External service status must +be checked live before recording it as fact. + ## Support matrix ### Supported baseline diff --git a/docs/12-maintainer-runbook.md b/docs/12-maintainer-runbook.md index 8ae6828..0c09650 100644 --- a/docs/12-maintainer-runbook.md +++ b/docs/12-maintainer-runbook.md @@ -103,7 +103,8 @@ Do not mix the two flows. write-lock and corpus-size behavior is reviewed. Do not run memory closeout, ingest, search, wake-up, export, or graph commands during this pause. -Historical checks, currently paused: +Historical direct-service checks, currently paused and not part of the active +closeout path: ```bash tools/bin/workspace-memory status @@ -111,7 +112,8 @@ tools/bin/mempalace-start tools/bin/mempalace-sync ``` -Workspace Hub should surface MemPalace as a core service, but these commands remain the direct shell fallback. +Workspace Hub should surface MemPalace as a core service, but these commands +must stay disabled until the workspace-memory pause is lifted in tracked docs. ## Codex MCP profile management diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5bd71e5..72cdc58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,22 @@ - Added a claim-only TomeVault distribution path with root `AGENTS.md` as the canonical public source, standalone Copilot guidance, and no Relay install or generated multi-format files by default. - Added `tools/manifests/tomevault-skills.json` plus `tools/scripts/sync-tomevault-skills.sh` to maintain a deterministic root `.agents/skills/` mirror for all tracked publishable skills. - Removed machine-specific absolute links from public skill sources so mirrored TomeVault skill content stays portable. +- Hardened Workspace Hub mutating API routes with a local intent header and + pre-body origin guard, and updated the Hub client POST helpers so UI actions + send that intent consistently. +- Normalized Workspace Hub repo activity, metadata, and event writes to the + canonical discovered `repo.relativePath` value instead of raw request payloads. +- Added core-service `maintenancePaused` metadata so Workspace Hub UI controls + and service API routes use the same pause contract. +- Updated Workspace Hub memory surfaces and smoke docs so MemPalace maintenance, + search, and graph command actions stay visibly paused and API-rejected while + `tools/bin/workspace-memory` is disabled. +- Updated release-readiness and handover docs so stale external status is not + recorded as current without live verification, and tracked docs/manifests stay + canonical over generated cache. +- Changed `tools/scripts/cleanup-sync-noise.sh` to dry-run by default with an + explicit `--run` mode, and updated Git trimming to opt into that cleanup + intentionally. ## 2026-04-27 @@ -46,9 +62,9 @@ ## 2026-04-11 - Bumped the workspace baseline release to `v1.2.2` and updated `repos/workspace-hub` to `1.2.2` to capture the side-load `entry.md` packet flow, manifest `entryDocs` support, and thin-versus-deep indexed search as the new published baseline. -- Refreshed the root [README](../README.md) with tool-agnostic positioning (Codex, Cursor, Claude), a **Workspace Hub** subsection with the cover image, and a **What's included (and why)** table; shifted public docs to neutral language for optional reverse-proxy and mapped-host previews while keeping compatibility manifest keys documented where needed. +- Refreshed the root [README](../README.md) with tool-agnostic positioning (Codex, Cursor, Claude), a **Workspace Hub** subsection with the cover image, and a **What's included (and why)** table; shifted public docs to neutral language for optional reverse-proxy and mapped-host previews. - Updated [HANDOVER](HANDOVER.md) completion review, the project review addendum pointer to [CHANGELOG](CHANGELOG.md) for resolved Hub items, and aligned [docs/README](README.md), wiki stubs, contributor templates, and [AGENTS.md](../AGENTS.md) with the same stance. -- Rewrote [00-overview](00-overview.md) and [02-local-runtime-handover](02-local-runtime-handover.md) so runtime guidance uses generic mapped-host terminology while keeping stable manifest enum and field names (`servbay`, `servbayPath`, `servbaySubdomain`) documented in [repos/workspace-hub/docs/manifest.md](../repos/workspace-hub/docs/manifest.md). +- Rewrote [00-overview](00-overview.md) and [02-local-runtime-handover](02-local-runtime-handover.md) so runtime guidance uses generic mapped-host terminology while keeping stable manifest enum and field names (`mapped-host`, `mappedHostPath`, `mappedHostSubdomain`) documented in [repos/workspace-hub/docs/manifest.md](../repos/workspace-hub/docs/manifest.md). - Added a tracked [docs/plans/readme-docs-closeout.md](plans/readme-docs-closeout.md) plan for future reference, and aligned small Workspace Hub/operator copy surfaces with the same mapped-host wording in `repos/workspace-hub`, `tools/scripts/doctor-workspace.sh`, and `tools/scripts/setup-workspace-profile.sh`. - Extended `repos/workspace-hub` backlog follow-through with better repo-list prioritization for pinned and recent work, clearer Python-aware dependency readiness, intake-result surfacing in repo details, capability search and inspection, explicit mapped-host routing status, and richer runtime troubleshooting guidance. - Advanced Workspace memory graph support from file-open-only toward Phase 2 by surfacing derived-edge counts, node-type breakdown, and an in-app graph report preview for the selected target, while clarifying intentional MCP profile usage in the Workspace Hub settings panel. @@ -107,7 +123,7 @@ - Promoted repo-group updates to a tracked default manifest at `tools/manifests/repo-groups.json`, while keeping `repo-groups.example.json` as a sample shape. - Reclassified `VoltAgent/awesome-design-md` as an optional ability under `repos/abilities/voltagent-awesome-design-md` and updated the local `DESIGN.md` tooling/docs accordingly. - Updated Workspace Hub to read capability registry data, expose capability lifecycle actions in the UI, and persist a repo layout preference with `split` and `discovery-first` modes. -- Synced the public-facing docs set so the root README, docs index, and older handover notes now all describe Workspace memory, the reviewed-source taxonomy, optional abilities, and the optional `gh auth login` and ServBay stance consistently. +- Synced the public-facing docs set so the root README, docs index, and older handover notes now all describe Workspace memory, the reviewed-source taxonomy, optional abilities, and the optional `gh auth login` stance consistently. - Added a reusable live Hub acceptance block to `docs/HANDOVER.md` and `repos/workspace-hub/README.md` covering base summary, capability-aware search, repo-details hydration, Workspace memory, Workspace Capabilities, Repo Discovery, and `discovery-first` rendering checks. - Corrected the legacy compatibility manifest note for `VoltAgent/awesome-design-md` so it no longer points at the old `repos/core/` placement. - Added `GET /api/capabilities` to `workspace-hub` as a read-only capability snapshot endpoint and surfaced its installed or enabled or reference-only counts directly in the Workspace Capabilities panel. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index e5140fb..d4bb3ac 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -24,7 +24,7 @@ or use Hub memory command actions until the pause is explicitly lifted. - workspace release tag: `v1.2.2` - `repos/workspace-hub` version: `1.2.2` -- stable release gate passed on `2026-04-10` +- latest local release-readiness gate passed on `2026-05-10` - current release URL: `https://github.com/RichardGeorgeDavis/Codex-Workspace/releases/tag/v1.2.2` The workspace foundation is in place: @@ -38,6 +38,33 @@ The workspace foundation is in place: - `tools/ref/` is reference-only and can remain empty unless a reviewed snapshot is explicitly refreshed - launcher commands coordinate ports through `cache/runtime/ports/` +## Latest Local Work + +On `2026-05-10`, Workspace Hub was hardened after a comprehensive audit: + +- unsafe API methods now require the local Hub intent header and reject foreign + browser origins before body parsing or any write, install, open, or runtime + action runs; mapped-host Hub origins must be explicitly allowlisted +- Hub client POST helpers now send the intent header consistently +- repo activity, metadata, and event writes use canonical discovered + `repo.relativePath` values instead of raw request payload paths +- core service payloads now include `maintenancePaused` and + `maintenancePausedReason` so UI disablement and server rejection share the + same service-level contract +- MemPalace install/start/restart/sync controls are disabled or relabeled while + workspace memory remains paused, and the API rejects paused MemPalace + maintenance and command calls directly +- Workspace Hub README smoke commands now use the intent header and avoid paused + MemPalace command POSTs +- stale-information handling now keeps tracked docs and manifests canonical, + treats generated cache as optional evidence, and requires live verification + before recording external service status as current +- `tools/scripts/cleanup-sync-noise.sh` now defaults to dry-run and requires + `--run` before removing macOS or sync-client noise files +- verification covered `pnpm typecheck`, `pnpm test`, `pnpm lint`, + `pnpm build`, live API smoke, in-app browser smoke, and + `tools/scripts/release-readiness.sh` + ## Token Budget Rules Keep agent context small by default: @@ -66,6 +93,10 @@ Implemented: - capability and core-service surfacing from the tracked manifest - base-summary refresh with selected-repo detail hydration - side-load freshness visibility for generated context packets +- guarded local-only mutating API actions for writes, opens, installs, and + runtime controls +- canonical repo-relative path persistence for repo activity, metadata, and + workspace events Workspace memory UI exists, but command actions are paused because `tools/bin/workspace-memory` is disabled. @@ -75,10 +106,10 @@ Workspace memory UI exists, but command actions are paused because Practical next work: - TomeVault distribution mirror is implemented and pushed in commit `5186120`. - Public TomeVault still reports `1 config, 0 skill, 6 formats`; claim the - profile and ask Oli to rescan root `AGENTS.md` plus - `.agents/skills/*/SKILL.md`. Do not install Relay unless explicitly - requested. + Verify the public TomeVault profile state live before documenting scan counts + or skill totals as current. Claim the profile and ask Oli to rescan root + `AGENTS.md` plus `.agents/skills/*/SKILL.md`. Do not install Relay unless + explicitly requested. - keep future changes end-to-end and update this file plus `docs/CHANGELOG.md` - keep public surfaces aligned when workspace-wide behavior changes: `README.md`, `docs/README.md`, `docs/CHANGELOG.md`, and relevant repo-local docs diff --git a/docs/README.md b/docs/README.md index 487a4a1..c616326 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,11 +81,10 @@ Useful maintenance scripts: - `tools/scripts/bootstrap-workspace.sh` prepares safe cache/context folders and can install `workspace-hub` dependencies without touching sibling repos. - `tools/bin/workspace-memory` is temporarily disabled while the MemPalace write-lock and corpus-size behavior is reviewed. -- `tools/bin/mempalace-start` runs the MemPalace MCP server with the workspace-scoped home. -- `tools/bin/mempalace-sync` fast-forwards the MemPalace repo when its working tree is clean. +- `tools/bin/mempalace-start` and `tools/bin/mempalace-sync` are tracked MemPalace service wrappers, but do not run them during the current workspace-memory pause unless tracked docs explicitly lift the pause. - `tools/scripts/bootstrap-repo.sh` previews or runs repo-native install/setup using manifest `installCommand` first, then package-manager precedence such as env override, manifest `packageManager`, `package.json`, and lockfiles. - `tools/scripts/doctor-workspace.sh` runs a non-destructive environment and readiness check for the workspace, Workspace Hub, mixed-stack tooling, and Codex-related setup. -- `tools/scripts/cleanup-sync-noise.sh` removes macOS and sync-client noise files such as `Icon\r` and `._*`, including the broken-ref cases when they leak into `.git/`. +- `tools/scripts/cleanup-sync-noise.sh` previews macOS and sync-client noise cleanup by default; pass `--run` to remove `.DS_Store`, `Icon\r`, and `._*`, including broken-ref cases when they leak into `.git/`. - `tools/scripts/install-shared-playwright-browser.sh` installs Playwright browsers such as Chromium into the shared workspace cache so multiple repos can reuse them. - `tools/scripts/print-workspace-env.sh` prints the shared workspace environment exports, including the shared Playwright browser cache path. - `tools/scripts/install-mcp-profile.sh` generates and optionally applies the managed Codex Workspace MCP block for a named profile. @@ -104,7 +103,7 @@ Useful maintenance scripts: - `tools/scripts/sync-reference-snapshots.sh` previews or refreshes ignored upstream reference snapshots under `tools/ref/`, with dry-run mode by default. - `tools/scripts/sync-codex-skills.sh` previews or syncs tracked workspace skill sources into repo `.codex/skills/` folders plus optional `.agents/skills/` compatibility mirrors, with dry-run mode by default. - `tools/scripts/sync-tomevault-skills.sh` previews or syncs the manifest-managed public `.agents/skills/` mirror used for TomeVault distribution, with dry-run mode by default. -- `tools/scripts/trim-git-repos.sh` performs safe Git maintenance across `repos/` by cleaning `.git` sync noise, expiring older reflog entries, and running `git gc` with a conservative prune window. +- `tools/scripts/trim-git-repos.sh` performs intentional Git maintenance across `repos/` by explicitly running `.git` sync-noise cleanup, expiring older reflog entries, and running `git gc` with a conservative prune window. - `tools/scripts/update-all.sh` can now fast-forward all repos or only a named repo group from a JSON manifest via `--group`. Local launch examples: diff --git a/docs/plans/readme-docs-closeout.md b/docs/plans/readme-docs-closeout.md index 942fa6e..0ab0835 100644 --- a/docs/plans/readme-docs-closeout.md +++ b/docs/plans/readme-docs-closeout.md @@ -6,12 +6,12 @@ Tracked copy of the implementation plan (for Codex or future sessions). Executed 1. **Handover:** Batches 1–4 complete; v1.2.2 baseline. Optional follow-ons: MCP profiles, Hub memory-graph Phase 2, capability drill-down, repo-intake polish. Completion review open themes: diagnostics, favourites, dependency feedback, mapped-host preview polish, runtime troubleshooting docs. 2. **Docs/wiki review:** Align `docs/` and `docs/wiki/` with HANDOVER/CHANGELOG; reduce duplicate prose; keep wiki navigational. -3. **Remove ServBay product naming** from docs and user-facing surfaces; use neutral language (optional reverse proxy, mapped host). Stable JSON keys `servbay`, `servbayPath`, `servbaySubdomain` remain documented as stable manifest values. +3. **Use neutral mapped-host naming** in docs and user-facing surfaces. Stable JSON keys are `mapped-host`, `mappedHostPath`, and `mappedHostSubdomain`. 4. **README:** Multi-agent intro, Workspace Hub section + cover placement, **What's included (and why)** table. 5. **Public surfaces:** `README.md`, `docs/README.md`, `docs/CHANGELOG.md`, `docs/HANDOVER.md`, `repos/workspace-hub/README.md` / manifest docs when relevant. ## Codex prompt (reuse) ```text -Execute the docs closeout plan in docs/plans/readme-docs-closeout.md. Goals: (0) Review docs/ and docs/wiki/ for accuracy vs HANDOVER/CHANGELOG, de-duplicate index vs wiki, and trim bloat/stale narrative. (1) Remove ServBay naming from workspace Markdown and contributor-facing templates; use neutral language for optional proxy/mapped-host tooling and WordPress via Local. (2) Update root README: add multi-agent positioning (Codex/Cursor/Claude), move the Workspace Hub cover block to sit with the Workspace Hub section, add a clear “What’s included and why” section, remove ServBay lines. (3) Update docs/HANDOVER.md completion review to drop ServBay polish and stay accurate; tighten addendum if it duplicates resolved CHANGELOG items. (4) Add a CHANGELOG entry and align docs/README.md. (5) For workspace-hub, update README/docs only unless we explicitly rename manifest API keys—if code still exposes servbay fields, document generically. Finish with git status; while workspace memory is paused, do not run `workspace-memory` closeout. +Execute the docs closeout plan in docs/plans/readme-docs-closeout.md. Goals: (0) Review docs/ and docs/wiki/ for accuracy vs HANDOVER/CHANGELOG, de-duplicate index vs wiki, and trim bloat/stale narrative. (1) Use neutral language for optional proxy/mapped-host tooling and WordPress via Local. (2) Update root README: add multi-agent positioning (Codex/Cursor/Claude), move the Workspace Hub cover block to sit with the Workspace Hub section, and add a clear “What’s included and why” section. (3) Update docs/HANDOVER.md completion review to stay accurate; tighten addendum if it duplicates resolved CHANGELOG items. (4) Add a CHANGELOG entry and align docs/README.md. (5) For workspace-hub, update README/docs and manifest wording with generic mapped-host fields. Finish with git status; while workspace memory is paused, do not run `workspace-memory` closeout. ``` diff --git a/project-manifest.template.json b/project-manifest.template.json index 40a5dce..048352f 100644 --- a/project-manifest.template.json +++ b/project-manifest.template.json @@ -10,8 +10,8 @@ "previewCommand": "pnpm preview", "previewUrl": "http://localhost:5173", "externalUrl": "", - "servbayPath": "/repo/example-project", - "servbaySubdomain": "", + "mappedHostPath": "/repo/example-project", + "mappedHostSubdomain": "", "healthcheckUrl": "http://localhost:5173", "tags": [ "frontend", diff --git a/repo-index.sample.json b/repo-index.sample.json index 18ce794..b9fa03b 100644 --- a/repo-index.sample.json +++ b/repo-index.sample.json @@ -44,7 +44,7 @@ "packageManager": "pnpm", "devCommand": "pnpm dev", "previewUrl": "http://localhost:5173", - "servbayPath": "/repo/threejs-scene", + "mappedHostPath": "/repo/threejs-scene", "tags": [ "threejs", "webgl", @@ -61,7 +61,7 @@ "packageManager": "npm", "devCommand": "npx serve .", "previewUrl": "http://localhost:3000", - "servbayPath": "/repo/static-portfolio", + "mappedHostPath": "/repo/static-portfolio", "tags": [ "static", "portfolio" diff --git a/repos/workspace-hub/README.md b/repos/workspace-hub/README.md index f10d384..ad3b1a8 100644 --- a/repos/workspace-hub/README.md +++ b/repos/workspace-hub/README.md @@ -206,17 +206,22 @@ curl -s "http://127.0.0.1:4101/api/workspace/observability" Manual smoke (live Hub acceptance): +Mutating local API smoke calls must send +`X-Workspace-Hub-Intent: same-origin`. The API rejects unsafe methods before +body parsing when the header is missing, and rejects browser origins outside +loopback/localhost unless an origin is explicitly listed in +`WORKSPACE_HUB_ALLOWED_ORIGINS` for a mapped-host Hub session. + ```bash pnpm dev:api pnpm dev:web --host 127.0.0.1 --port 4174 +HUB_INTENT_HEADER='X-Workspace-Hub-Intent: same-origin' curl -s http://127.0.0.1:4101/api/workspace/summary/base | jq '{repoCount: (.repos | length), capabilityCount: (.capabilities | length), firstRepo: (.repos[0] | {relativePath, detailLevel})}' curl -s "http://127.0.0.1:4101/api/workspace/summary/base?includeArchives=true" | jq '{archiveCount: (.archives | length)}' curl -s http://127.0.0.1:4101/api/capabilities | jq '{generatedAt, stats}' curl -s "http://127.0.0.1:4101/api/search?q=memory&mode=thin" | jq '{mode, total: (.results | length), categories: (.results | map(.category))}' curl -s "http://127.0.0.1:4101/api/search?q=memory&mode=deep" | jq '{mode, total: (.results | length), categories: (.results | map(.category))}' -curl -s -X POST http://127.0.0.1:4101/api/services/context -H 'Content-Type: application/json' -d '{"serviceId":"mempalace","targetKind":"workspace-docs"}' | jq '{targetLabel, graph: .graph | {lastBuiltAt, nodeCount, edgeCount}}' -curl -s -X POST http://127.0.0.1:4101/api/services/command -H 'Content-Type: application/json' -d '{"serviceId":"mempalace","commandId":"search","searchQuery":"workspace memory"}' | jq '{command, ok}' -curl -s -X POST http://127.0.0.1:4101/api/services/command -H 'Content-Type: application/json' -d '{"serviceId":"mempalace","commandId":"build-graph"}' | jq '{command, ok}' +curl -s -X POST http://127.0.0.1:4101/api/services/context -H 'Content-Type: application/json' -H "$HUB_INTENT_HEADER" -d '{"serviceId":"mempalace","targetKind":"workspace-docs"}' | jq '{targetLabel, searchEnabled: (.commands[] | select(.id == "search") | .enabled), buildGraphEnabled: (.commands[] | select(.id == "build-graph") | .enabled), graph: .graph | {lastBuiltAt, nodeCount, edgeCount}}' curl -s --get http://127.0.0.1:4101/api/repos/details --data-urlencode "relativePath=repos/workspace-hub" | jq '{relativePath, detailLevel, diagnosticsFreshness}' curl -s http://127.0.0.1:4101/api/health | jq '.workspaceHub.repoDetails' npx playwright screenshot --wait-for-selector 'text=Workspace memory' http://127.0.0.1:4174 /tmp/workspace-hub-workspace-memory.png @@ -241,7 +246,7 @@ EOF npx playwright screenshot --load-storage /tmp/workspace-hub-discovery-storage.json --wait-for-selector 'text=Select a repo to open details.' --full-page http://127.0.0.1:4174 /tmp/workspace-hub-discovery-mode.png ``` -That smoke pass validates the current base-summary list projection, indexed capability-aware search, selected repo-detail hydration, `Workspace memory`, the in-app MemPalace search flow, target-scoped graph builds, the capability panel, the discovery-first inline empty-state prompt, and the inline selected-repo details rendering path. +That smoke pass validates the current base-summary list projection, indexed capability-aware search, selected repo-detail hydration, `Workspace memory`, the paused MemPalace command surface, target-scoped graph metadata, the capability panel, the discovery-first inline empty-state prompt, and the inline selected-repo details rendering path. Default local endpoints: diff --git a/repos/workspace-hub/docs/manifest.md b/repos/workspace-hub/docs/manifest.md index 068f2e2..05e21ff 100644 --- a/repos/workspace-hub/docs/manifest.md +++ b/repos/workspace-hub/docs/manifest.md @@ -41,7 +41,7 @@ Accepted `preferredMode` values: - `direct` - `external` -- `servbay` (mapped host / reverse-proxy preview; the JSON value is stable for compatibility even though the label is generic in docs) +- `mapped-host` (mapped host / reverse-proxy preview) ## Optional fields @@ -55,8 +55,8 @@ Workspace Hub currently supports these optional fields: - `previewUrl` - `externalUrl` - `healthcheckUrl` -- `servbayPath` -- `servbaySubdomain` +- `mappedHostPath` +- `mappedHostSubdomain` - `entryDocs` - `tags` - `notes` @@ -87,8 +87,8 @@ When Workspace Hub writes a manifest, it uses this order for known fields: 10. `previewUrl` 11. `externalUrl` 12. `healthcheckUrl` -13. `servbayPath` -14. `servbaySubdomain` +13. `mappedHostPath` +14. `mappedHostSubdomain` 15. `entryDocs` 16. `tags` 17. `notes` @@ -132,7 +132,7 @@ Unknown preserved keys are appended after the known keys. - Prefer `direct` for Vite, Three.js, and similar frontend repos unless a repo explicitly needs something else. - Prefer `external` for WordPress repos already managed by Local or another app. -- Use `servbayPath` or `servbaySubdomain` only when mapped-host routing is stable and tested (field names are stable JSON keys). +- Use `mappedHostPath` or `mappedHostSubdomain` only when mapped-host routing is stable and tested (field names are stable JSON keys). - Use `entryDocs` for the small set of canonical files the operator should open before scanning the repo broadly. - Keep manifests explicit and readable; do not turn them into a dump of every inferred value unless the repo benefits from that clarity. - Keep local-only values in `project.local.json` when they should not ship with the repo. diff --git a/repos/workspace-hub/server/core-service-runtime.ts b/repos/workspace-hub/server/core-service-runtime.ts index 4a56fc6..b11bfa2 100644 --- a/repos/workspace-hub/server/core-service-runtime.ts +++ b/repos/workspace-hub/server/core-service-runtime.ts @@ -10,6 +10,7 @@ import type { WorkspaceCoreService, WorkspaceCoreServiceCommandId, } from '../src/types/workspace.ts' +import { workspaceMemoryMaintenancePausedReason } from '../src/lib/workspaceMemoryPause.ts' import { publishWorkspaceEvent } from './live-events.ts' import { invalidateWorkspaceSearchIndex } from './workspace-search.ts' @@ -114,6 +115,20 @@ function buildIdleInstall(command: string): RepoInstall { } } +function serviceMaintenancePausedReason(service: WorkspaceCoreService) { + return service.maintenancePaused + ? service.maintenancePausedReason ?? workspaceMemoryMaintenancePausedReason + : null +} + +function assertServiceMaintenanceAvailable(service: WorkspaceCoreService) { + const pausedReason = serviceMaintenancePausedReason(service) + + if (pausedReason) { + throw new Error(pausedReason) + } +} + export function getCoreServiceRuntimeSnapshots() { return new Map( [...managedRuntimes.entries()].map(([serviceId, record]) => [serviceId, record.snapshot]), @@ -135,6 +150,8 @@ export function canInstallCoreService(service: WorkspaceCoreService) { } export async function startCoreService(service: WorkspaceCoreService) { + assertServiceMaintenanceAvailable(service) + const existing = managedRuntimes.get(service.id) if (existing?.snapshot.status === 'running') { throw new Error(`${service.name} is already running.`) @@ -201,6 +218,8 @@ export async function startCoreService(service: WorkspaceCoreService) { } export async function stopCoreService(service: WorkspaceCoreService) { + assertServiceMaintenanceAvailable(service) + const record = managedRuntimes.get(service.id) if (!record || record.snapshot.status !== 'running') { throw new Error(`${service.name} is not running.`) @@ -222,6 +241,8 @@ export async function stopCoreService(service: WorkspaceCoreService) { } export async function restartCoreService(service: WorkspaceCoreService) { + assertServiceMaintenanceAvailable(service) + const existing = managedRuntimes.get(service.id) if (existing?.snapshot.status === 'running') { existing.child.kill('SIGTERM') @@ -232,6 +253,8 @@ export async function restartCoreService(service: WorkspaceCoreService) { } export async function runCoreServiceInstall(service: WorkspaceCoreService) { + assertServiceMaintenanceAvailable(service) + const existing = managedInstalls.get(service.id) if (existing?.snapshot.status === 'running') { throw new Error(`${service.name} install is already running.`) @@ -297,6 +320,8 @@ export async function runCoreServiceInstall(service: WorkspaceCoreService) { } export async function runCoreServiceSync(service: WorkspaceCoreService) { + assertServiceMaintenanceAvailable(service) + await execFileAsync(service.syncCommandArgs[0], service.syncCommandArgs.slice(1), { cwd: service.repoPresent ? service.repoPath : undefined, env: process.env, @@ -318,8 +343,6 @@ type CoreServiceCommandOptions = { searchQuery?: string | null } -const workspaceMemoryPausedMessage = 'Workspace memory is temporarily paused.' - function buildCoreServiceCommandInvocation( service: WorkspaceCoreService, commandId: WorkspaceCoreServiceCommandId, @@ -399,9 +422,7 @@ export async function runCoreServiceCommand( commandId: WorkspaceCoreServiceCommandId, options: CoreServiceCommandOptions = {}, ) { - if (service.id === 'mempalace') { - throw new Error(workspaceMemoryPausedMessage) - } + assertServiceMaintenanceAvailable(service) if (commandId === 'runtime-start') { await startCoreService(service) diff --git a/repos/workspace-hub/server/core-services.ts b/repos/workspace-hub/server/core-services.ts index 4073cd3..50e72e5 100644 --- a/repos/workspace-hub/server/core-services.ts +++ b/repos/workspace-hub/server/core-services.ts @@ -11,6 +11,10 @@ import type { WorkspaceCoreService, WorkspaceCoreServiceManifestIssue, } from '../src/types/workspace.ts' +import { + isWorkspaceMemoryService, + workspaceMemoryMaintenancePausedReason, +} from '../src/lib/workspaceMemoryPause.ts' import { resolveWorkspaceCommand, resolveWorkspacePath, @@ -368,6 +372,7 @@ export async function readCoreServices( const upstreamUrl = repoPresent ? await readGitValue(repoPath, ['remote', 'get-url', 'upstream']) : null const version = repoPresent ? await readVersion(repoPath) : null const state = await readState(statePath) + const maintenancePaused = isWorkspaceMemoryService(id) services.push({ branch, @@ -398,6 +403,8 @@ export async function readCoreServices( lastSearchQuery: state?.lastSearchQuery ?? null, lastSyncAt: state?.lastSyncAt ?? null, lastWakeUpAt: state?.lastWakeUpAt ?? null, + maintenancePaused, + maintenancePausedReason: maintenancePaused ? workspaceMemoryMaintenancePausedReason : null, name, notes: serviceConfig.notes?.trim() ?? '', originUrl, diff --git a/repos/workspace-hub/server/index.ts b/repos/workspace-hub/server/index.ts index e0f99ce..245bd09 100644 --- a/repos/workspace-hub/server/index.ts +++ b/repos/workspace-hub/server/index.ts @@ -2,7 +2,14 @@ import express, { type NextFunction, type Request, type Response } from 'express import { access } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import type { WorkspaceCoreServiceCommandId } from '../src/types/workspace.ts' +import type { + WorkspaceCoreService, + WorkspaceCoreServiceCommandId, +} from '../src/types/workspace.ts' +import { + workspaceHubIntentHeaderName, + workspaceHubIntentHeaderValue, +} from '../src/lib/workspaceHubIntent.ts' import { applyRepoAgentPreset, isRepoAgentPresetId } from './agent-tooling.ts' import { handleWorkspaceEvents, publishWorkspaceEvent } from './live-events.ts' @@ -78,7 +85,19 @@ const runtimeTroubleshootingPath = fileURLToPath( const app = express() -app.use(express.json()) +const unsafeHttpMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']) +const trustedRequestOrigins = parseTrustedRequestOrigins( + process.env.WORKSPACE_HUB_ALLOWED_ORIGINS, +) + +function parseTrustedRequestOrigins(value: string | undefined) { + return new Set( + (value ?? '') + .split(',') + .map((entry) => normalizeOrigin(entry)) + .filter((entry): entry is string => Boolean(entry)), + ) +} function isLocalPreviewTarget(target: string | null) { if (!target) { @@ -102,6 +121,73 @@ function isLocalPreviewTarget(target: string | null) { } } +function normalizeOrigin(value: string | undefined) { + if (!value?.trim()) { + return null + } + + try { + return new URL(value.trim()).origin + } catch { + return null + } +} + +function isLoopbackOrigin(origin: string) { + try { + const url = new URL(origin) + const hostname = url.hostname.toLowerCase() + + return ( + hostname === 'localhost' || + hostname === '::1' || + hostname === '[::1]' || + hostname === '127.0.0.1' || + hostname.startsWith('127.') + ) + } catch { + return false + } +} + +function isTrustedRequestOrigin(origin: string | undefined) { + if (!origin) { + return true + } + + const normalizedOrigin = normalizeOrigin(origin) + if (!normalizedOrigin) { + return false + } + + return isLoopbackOrigin(normalizedOrigin) || trustedRequestOrigins.has(normalizedOrigin) +} + +app.use((request: Request, response: Response, next: NextFunction) => { + if (!unsafeHttpMethods.has(request.method.toUpperCase())) { + next() + return + } + + if (request.get(workspaceHubIntentHeaderName) !== workspaceHubIntentHeaderValue) { + response.status(403).json({ + message: 'Workspace Hub rejected this local action request.', + }) + return + } + + if (!isTrustedRequestOrigin(request.get('origin'))) { + response.status(403).json({ + message: 'Workspace Hub rejected this request origin.', + }) + return + } + + next() +}) + +app.use(express.json()) + function requireRelativePath(body: unknown) { if (typeof body !== 'object' || body === null) { throw new Error('A repo relativePath is required.') @@ -113,7 +199,7 @@ function requireRelativePath(body: unknown) { throw new Error('A repo relativePath is required.') } - return candidate.trim() + return candidate.trim().replace(/^\/+/, '') } function requireObjectPayload(body: unknown, fieldName: string, message: string) { @@ -247,13 +333,37 @@ function targetMatchesPath(servicePath: string | null, targetPath: string | null ) } +function serviceMaintenancePausedReason(service: WorkspaceCoreService) { + return service.maintenancePaused + ? service.maintenancePausedReason ?? 'This service maintenance is temporarily paused.' + : null +} + +function rejectPausedServiceMaintenance(service: WorkspaceCoreService, response: Response) { + const pausedReason = serviceMaintenancePausedReason(service) + + if (!pausedReason) { + return false + } + + response.status(400).json({ message: pausedReason }) + return true +} + function buildMempalaceContextCommands( - _serviceId: string, + service: WorkspaceCoreService, targetKind: 'current-repo' | 'repo' | 'workspace-docs', repoRelativePath: string | null, targetAvailable: boolean, ) { - const pausedReason = 'Workspace memory is temporarily paused.' + const pausedReason = serviceMaintenancePausedReason(service) + const serviceCommandsEnabled = !pausedReason + const targetCommandsEnabled = serviceCommandsEnabled && targetAvailable + const repoCommandEnabled = serviceCommandsEnabled && Boolean(repoRelativePath) + const targetUnavailableReason = 'Select an available memory target before running this command.' + const repoUnavailableReason = 'Select an available repo target before running this command.' + const commandDisabledReason = (enabled: boolean, fallbackReason: string | null = null) => + enabled ? null : pausedReason ?? fallbackReason const buildGraphCommand = targetAvailable && repoRelativePath ? `tools/bin/workspace-memory build-graph repo ${repoRelativePath}` @@ -267,82 +377,82 @@ function buildMempalaceContextCommands( return [ { description: 'Build a target-scoped graph export from MemPalace sidecars and nearby docs.', - enabled: false, + enabled: targetCommandsEnabled, id: 'build-graph', label: 'Build graph', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(targetCommandsEnabled, targetUnavailableReason), shellCommand: buildGraphCommand, }, { description: 'Check local service readiness and key workspace paths.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'status', label: 'Status', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory status', }, { description: 'Run a retrieval search against the workspace memory corpus.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'search', label: 'Search memory', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory search ', }, { description: 'Save workspace docs and the current Codex thread into MemPalace.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'save-workspace', label: 'Save workspace', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory save-workspace', }, { description: 'Save the selected repo target plus the current Codex thread.', - enabled: false, + enabled: repoCommandEnabled, id: 'save-repo', label: 'Save repo', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(repoCommandEnabled, repoUnavailableReason), shellCommand: saveRepoCommand, }, { description: 'Export the active Codex thread into a readable transcript bundle.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'export-codex-current', label: 'Export current Codex thread', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory export-codex current', }, { description: 'Mine the active Codex thread directly from the local session log.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'mine-codex-current', label: 'Mine current Codex thread', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory mine-codex-current', }, { description: 'Refresh the MemPalace wake-up summary from the current corpus.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'wake-up', label: 'Wake-up', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/workspace-memory wake-up', }, { description: 'Start the MemPalace MCP server for the workspace user.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'runtime-start', label: 'Start MCP server', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/mempalace-start', }, { description: 'Fast-forward the MemPalace fork from upstream when the tree is clean.', - enabled: false, + enabled: serviceCommandsEnabled, id: 'sync', label: 'Sync fork', - reasonDisabled: pausedReason, + reasonDisabled: commandDisabledReason(serviceCommandsEnabled), shellCommand: 'tools/bin/mempalace-sync', }, ] @@ -722,7 +832,7 @@ app.post( response.json({ commands: buildMempalaceContextCommands( - service.id, + service, targetKind, resolvedRepo?.relativePath ?? resolvedRepoRelativePath, targetKind === 'workspace-docs' || Boolean(resolvedRepo), @@ -778,6 +888,10 @@ app.post( return } + if (rejectPausedServiceMaintenance(service, response)) { + return + } + const result = await runCoreServiceCommand(service, commandId as WorkspaceCoreServiceCommandId, { repoRelativePath, searchQuery, @@ -890,6 +1004,10 @@ app.post( return } + if (rejectPausedServiceMaintenance(service, response)) { + return + } + if (!canRunCoreService(service)) { response.status(400).json({ message: 'This service does not have a runtime command.' }) return @@ -937,6 +1055,10 @@ app.post( return } + if (rejectPausedServiceMaintenance(service, response)) { + return + } + if (!canInstallCoreService(service)) { response.status(400).json({ message: 'This service does not have an install command.' }) return @@ -974,6 +1096,10 @@ app.post( return } + if (rejectPausedServiceMaintenance(service, response)) { + return + } + await runCoreServiceSync(service) invalidateWorkspaceCaches({ search: true }) response.json({ ok: true }) @@ -1072,11 +1198,11 @@ app.post( return } - await saveRepoActivity(relativePath, 'open') + await saveRepoActivity(repo.relativePath, 'open') invalidateWorkspaceCaches() publishWorkspaceEvent({ message: target, - relativePath, + relativePath: repo.relativePath, status: 'open', type: 'activity', }) @@ -1114,11 +1240,11 @@ app.post( } const runtime = await startRepoRuntime(repo) - await saveRepoActivity(relativePath, 'runtime') + await saveRepoActivity(repo.relativePath, 'runtime') invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: action, - relativePath, + relativePath: repo.relativePath, status: runtime.status, type: 'activity', }) @@ -1128,11 +1254,11 @@ app.post( if (action === 'stop') { const runtime = await stopRepoRuntime(repo.path) - await saveRepoActivity(relativePath, 'runtime') + await saveRepoActivity(repo.relativePath, 'runtime') invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: action, - relativePath, + relativePath: repo.relativePath, status: runtime?.status ?? 'stopped', type: 'activity', }) @@ -1149,11 +1275,11 @@ app.post( } const runtime = await restartRepoRuntime(repo) - await saveRepoActivity(relativePath, 'runtime') + await saveRepoActivity(repo.relativePath, 'runtime') invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: action, - relativePath, + relativePath: repo.relativePath, status: runtime.status, type: 'activity', }) @@ -1211,11 +1337,11 @@ app.post( } const install = await runRepoInstall(repo) - await saveRepoActivity(relativePath, 'install') + await saveRepoActivity(repo.relativePath, 'install') invalidateWorkspaceCaches() publishWorkspaceEvent({ message: 'install', - relativePath, + relativePath: repo.relativePath, status: install.status, type: 'activity', }) @@ -1243,11 +1369,11 @@ app.post( } const cover = await generateRepoCover(repo) - await saveRepoActivity(relativePath, 'open') + await saveRepoActivity(repo.relativePath, 'open') invalidateWorkspaceCaches() publishWorkspaceEvent({ message: cover.coverImagePath, - relativePath, + relativePath: repo.relativePath, status: 'cover', type: 'cover', }) @@ -1275,12 +1401,12 @@ app.post( } const result = await runRepoIntake(repo, workspaceRoot) - await saveRepoActivity(relativePath, 'open') + await saveRepoActivity(repo.relativePath, 'open') invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: result.manifestCreated ? 'intake + manifest' : 'intake', - relativePath, + relativePath: repo.relativePath, status: 'intake', type: 'activity', }) @@ -1314,11 +1440,14 @@ app.post( return } - response.json({ activity: await saveRepoActivity(relativePath, 'select'), ok: true }) + response.json({ + activity: await saveRepoActivity(repo.relativePath, 'select'), + ok: true, + }) invalidateWorkspaceCaches() publishWorkspaceEvent({ message: 'select', - relativePath, + relativePath: repo.relativePath, status: 'select', type: 'activity', }) @@ -1361,7 +1490,7 @@ app.post( invalidateWorkspaceCaches() publishWorkspaceEvent({ message: preset, - relativePath, + relativePath: repo.relativePath, status: result.appliedFiles.length ? 'applied' : 'unchanged', type: 'agent', }) @@ -1405,7 +1534,7 @@ app.post( invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: result.manifestPath, - relativePath, + relativePath: repo.relativePath, status: 'manifest', type: 'manifest', }) @@ -1432,7 +1561,7 @@ app.post( } const savedMetadata = await saveRepoMetadata( - relativePath, + repo.relativePath, requireObjectPayload( request.body, 'metadata', @@ -1443,7 +1572,7 @@ app.post( invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: 'saved', - relativePath, + relativePath: repo.relativePath, status: 'metadata', type: 'metadata', }) @@ -1470,11 +1599,11 @@ app.post( return } - await resetRepoMetadata(relativePath) + await resetRepoMetadata(repo.relativePath) invalidateWorkspaceCaches({ search: true }) publishWorkspaceEvent({ message: 'reset', - relativePath, + relativePath: repo.relativePath, status: 'metadata', type: 'metadata', }) diff --git a/repos/workspace-hub/server/repo-intake.ts b/repos/workspace-hub/server/repo-intake.ts index ee43483..aad5f93 100644 --- a/repos/workspace-hub/server/repo-intake.ts +++ b/repos/workspace-hub/server/repo-intake.ts @@ -226,8 +226,8 @@ function buildManifestForIntake(repo: WorkspaceRepo) { repo.previewUrl && repo.previewUrlSource !== 'runtime' ? repo.previewUrl : repo.suggestedManifest.previewUrl, - servbayPath: repo.servbayPath ?? repo.suggestedManifest.servbayPath, - servbaySubdomain: repo.servbaySubdomain ?? repo.suggestedManifest.servbaySubdomain, + mappedHostPath: repo.mappedHostPath ?? repo.suggestedManifest.mappedHostPath, + mappedHostSubdomain: repo.mappedHostSubdomain ?? repo.suggestedManifest.mappedHostSubdomain, } } diff --git a/repos/workspace-hub/server/repo-manifest.ts b/repos/workspace-hub/server/repo-manifest.ts index a2824a6..7f30d87 100644 --- a/repos/workspace-hub/server/repo-manifest.ts +++ b/repos/workspace-hub/server/repo-manifest.ts @@ -20,14 +20,14 @@ export type RepoManifestInput = { preferredMode?: unknown previewCommand?: unknown previewUrl?: unknown - servbayPath?: unknown - servbaySubdomain?: unknown + mappedHostPath?: unknown + mappedHostSubdomain?: unknown slug?: unknown tags?: unknown type?: unknown } -const previewModes: PreviewMode[] = ['direct', 'external', 'servbay'] +const previewModes: PreviewMode[] = ['direct', 'external', 'mapped-host'] const repoTypes: RepoType[] = [ 'node-app', 'other', @@ -50,8 +50,8 @@ const orderedManifestKeys = [ 'previewUrl', 'externalUrl', 'healthcheckUrl', - 'servbayPath', - 'servbaySubdomain', + 'mappedHostPath', + 'mappedHostSubdomain', 'entryDocs', 'tags', 'notes', @@ -158,8 +158,8 @@ export function normalizeManifestInput(input: RepoManifestInput) { preferredMode: input.preferredMode, previewCommand: normalizeOptionalString(input.previewCommand), previewUrl: normalizeOptionalString(input.previewUrl), - servbayPath: normalizeOptionalString(input.servbayPath), - servbaySubdomain: normalizeOptionalString(input.servbaySubdomain), + mappedHostPath: normalizeOptionalString(input.mappedHostPath), + mappedHostSubdomain: normalizeOptionalString(input.mappedHostSubdomain), slug, tags: normalizedTags.length ? normalizedTags : undefined, type: input.type, diff --git a/repos/workspace-hub/server/workspace-metadata.ts b/repos/workspace-hub/server/workspace-metadata.ts index 9f9f98c..67a6d2e 100644 --- a/repos/workspace-hub/server/workspace-metadata.ts +++ b/repos/workspace-hub/server/workspace-metadata.ts @@ -40,7 +40,7 @@ type WorkspaceMetadataFile = { version: 1 } -const previewModes: PreviewMode[] = ['direct', 'external', 'servbay'] +const previewModes: PreviewMode[] = ['direct', 'external', 'mapped-host'] const serverFile = fileURLToPath(import.meta.url) const serverDir = path.dirname(serverFile) const dataRoot = path.resolve(serverDir, '..', 'data') diff --git a/repos/workspace-hub/server/workspace.ts b/repos/workspace-hub/server/workspace.ts index 8c2c4ce..536d953 100644 --- a/repos/workspace-hub/server/workspace.ts +++ b/repos/workspace-hub/server/workspace.ts @@ -56,8 +56,8 @@ type RepoManifest = { preferredMode?: unknown previewCommand?: unknown previewUrl?: unknown - servbayPath?: unknown - servbaySubdomain?: unknown + mappedHostPath?: unknown + mappedHostSubdomain?: unknown slug?: unknown tags?: unknown type?: unknown @@ -121,7 +121,7 @@ const archiveFileExtensions = [ const ignoredArchiveDisplayRoots = ['repos/Check-[Sort+add]/'] -const previewModes: PreviewMode[] = ['direct', 'external', 'servbay'] +const previewModes: PreviewMode[] = ['direct', 'external', 'mapped-host'] const publicManifestFileName = 'project.json' const localManifestFileName = 'project.local.json' const openAgentConfigCandidates = [ @@ -1794,8 +1794,8 @@ function buildSuggestedManifest(options: { preferredMode: PreviewMode previewCommand: string | null previewUrl: string | null - servbayPath: string | null - servbaySubdomain: string | null + mappedHostPath: string | null + mappedHostSubdomain: string | null slug: string tags: string[] type: RepoType @@ -1813,8 +1813,8 @@ function buildSuggestedManifest(options: { preferredMode: options.preferredMode, previewCommand: options.previewCommand ?? undefined, previewUrl: options.previewUrl ?? undefined, - servbayPath: options.servbayPath ?? undefined, - servbaySubdomain: options.servbaySubdomain ?? undefined, + mappedHostPath: options.mappedHostPath ?? undefined, + mappedHostSubdomain: options.mappedHostSubdomain ?? undefined, slug: options.slug, tags: options.tags.length ? options.tags : undefined, type: options.type, @@ -1997,9 +1997,9 @@ async function buildRepoRecord( const manifestEntryDocs = deriveEntryDocs(relativePath, names, manifest) const manifestTags = normalizeTags(manifest?.tags) const recommendedPreset = buildRecommendedPreset(type, packageManager) - const servbayPath = isNonEmptyString(manifest?.servbayPath) ? manifest.servbayPath : null - const servbaySubdomain = isNonEmptyString(manifest?.servbaySubdomain) - ? manifest.servbaySubdomain + const mappedHostPath = isNonEmptyString(manifest?.mappedHostPath) ? manifest.mappedHostPath : null + const mappedHostSubdomain = isNonEmptyString(manifest?.mappedHostSubdomain) + ? manifest.mappedHostSubdomain : null const suggestedManifest = buildSuggestedManifest({ buildCommand: manifestBuildCommand, @@ -2014,8 +2014,8 @@ async function buildRepoRecord( preferredMode: manifestPreferredMode, previewCommand, previewUrl: manifestPreviewUrl ?? fallbackPreviewUrl, - servbayPath, - servbaySubdomain, + mappedHostPath, + mappedHostSubdomain, slug, tags: manifestTags, type, @@ -2111,8 +2111,8 @@ async function buildRepoRecord( recommendedPreset, relativePath, savedMetadata: savedOverrides, - servbayPath, - servbaySubdomain, + mappedHostPath, + mappedHostSubdomain, slug, suggestedManifest, tags: savedOverrides?.tags ?? manifestTags, @@ -2456,7 +2456,7 @@ export async function buildWorkspaceSummary( externalWordPressMode: 'external', manifestPath: '.workspace/project.json', previewMode: 'direct', - servbayOptional: true, + mappedHostOptional: true, }, sharedRoot, stats: { diff --git a/repos/workspace-hub/src/app/App.tsx b/repos/workspace-hub/src/app/App.tsx index 7e21edb..aeed066 100644 --- a/repos/workspace-hub/src/app/App.tsx +++ b/repos/workspace-hub/src/app/App.tsx @@ -813,7 +813,7 @@ export function App({ initialThemePreference }: AppProps) { healthcheckUrl?: string notes: string pinned: boolean - preferredMode: 'direct' | 'external' | 'servbay' + preferredMode: 'direct' | 'external' | 'mapped-host' previewUrl?: string tags: string[] }, @@ -868,11 +868,11 @@ export function App({ initialThemePreference }: AppProps) { name: string notes?: string packageManager?: string - preferredMode: 'direct' | 'external' | 'servbay' + preferredMode: 'direct' | 'external' | 'mapped-host' previewCommand?: string previewUrl?: string - servbayPath?: string - servbaySubdomain?: string + mappedHostPath?: string + mappedHostSubdomain?: string slug: string tags?: string[] type: RepoType diff --git a/repos/workspace-hub/src/features/repos/RepoDetails.tsx b/repos/workspace-hub/src/features/repos/RepoDetails.tsx index 12d3c02..fefd72c 100644 --- a/repos/workspace-hub/src/features/repos/RepoDetails.tsx +++ b/repos/workspace-hub/src/features/repos/RepoDetails.tsx @@ -75,8 +75,8 @@ type RepoDetailsProps = { preferredMode: PreviewMode previewCommand?: string previewUrl?: string - servbayPath?: string - servbaySubdomain?: string + mappedHostPath?: string + mappedHostSubdomain?: string slug: string tags?: string[] type: RepoType @@ -110,8 +110,8 @@ type ManifestDraft = { preferredMode: PreviewMode previewCommand: string previewUrl: string - servbayPath: string - servbaySubdomain: string + mappedHostPath: string + mappedHostSubdomain: string slug: string tags: string type: RepoType @@ -209,8 +209,8 @@ function buildManifestDraft(repo: WorkspaceRepo): ManifestDraft { preferredMode: repo.preferredMode, previewCommand: repo.previewCommand ?? '', previewUrl: repo.previewUrl ?? '', - servbayPath: repo.servbayPath ?? '', - servbaySubdomain: repo.servbaySubdomain ?? '', + mappedHostPath: repo.mappedHostPath ?? '', + mappedHostSubdomain: repo.mappedHostSubdomain ?? '', slug: repo.slug, tags: repo.tags.join(', '), type: repo.type, @@ -231,8 +231,8 @@ function buildManifestDraftFromRecord(manifest: WorkspaceManifestRecord): Manife preferredMode: manifest.preferredMode, previewCommand: manifest.previewCommand ?? '', previewUrl: manifest.previewUrl ?? '', - servbayPath: manifest.servbayPath ?? '', - servbaySubdomain: manifest.servbaySubdomain ?? '', + mappedHostPath: manifest.mappedHostPath ?? '', + mappedHostSubdomain: manifest.mappedHostSubdomain ?? '', slug: manifest.slug, tags: (manifest.tags ?? []).join(', '), type: manifest.type, @@ -265,8 +265,8 @@ function buildManifestPayload(draft: ManifestDraft) { packageManager: draft.packageManager.trim() || undefined, previewCommand: draft.previewCommand.trim() || undefined, previewUrl: draft.previewUrl.trim() || undefined, - servbayPath: draft.servbayPath.trim() || undefined, - servbaySubdomain: draft.servbaySubdomain.trim() || undefined, + mappedHostPath: draft.mappedHostPath.trim() || undefined, + mappedHostSubdomain: draft.mappedHostSubdomain.trim() || undefined, tags: tags.length ? tags : undefined, } } @@ -383,11 +383,11 @@ function buildTroubleshootingTips(repo: WorkspaceRepo) { ) } - if (repo.preferredMode === 'servbay' && !repo.servbayPath && !repo.servbaySubdomain) { + if (repo.preferredMode === 'mapped-host' && !repo.mappedHostPath && !repo.mappedHostSubdomain) { tips.push('Mapped-host mode is selected, but no path or subdomain is configured yet. Add one before treating routed preview links as stable.') } - if (repo.preferredMode === 'servbay' && repo.previewUrlSource === 'runtime') { + if (repo.preferredMode === 'mapped-host' && repo.previewUrlSource === 'runtime') { tips.push('The current preview URL came from runtime logs. Save an explicit preview URL once the mapped-host route is stable so the Hub does not fall back to transient ports.') } @@ -525,9 +525,9 @@ function RepoDetailsContent({ repo.runtime.status === 'error' const sideLoad = repo.sideLoad const sideLoadStatus = sideLoad ? formatSideLoadStatus(sideLoad.status) : null - const hasMappedHostRouting = Boolean(repo.servbayPath || repo.servbaySubdomain) + const hasMappedHostRouting = Boolean(repo.mappedHostPath || repo.mappedHostSubdomain) const mappedHostStatus = - repo.preferredMode !== 'servbay' + repo.preferredMode !== 'mapped-host' ? 'not using mapped-host mode' : hasMappedHostRouting ? 'configured' @@ -539,7 +539,7 @@ function RepoDetailsContent({ ? 'unknown' : repo.dependencies.state const mappedHostBadgeTone = - repo.preferredMode !== 'servbay' + repo.preferredMode !== 'mapped-host' ? 'unknown' : hasMappedHostRouting ? 'ready' @@ -772,9 +772,9 @@ function RepoDetailsContent({
Mapped-host routing
{mappedHostStatus} - {repo.preferredMode === 'servbay' + {repo.preferredMode === 'mapped-host' ? hasMappedHostRouting - ? ` ${repo.servbaySubdomain ? `${repo.servbaySubdomain} subdomain` : repo.servbayPath}` + ? ` ${repo.mappedHostSubdomain ? `${repo.mappedHostSubdomain} subdomain` : repo.mappedHostPath}` : ' Add a mapped-host path or subdomain before relying on this mode.' : ' Direct or external preview is currently preferred.'}
@@ -1173,7 +1173,7 @@ function RepoDetailsContent({ > - + @@ -1445,7 +1445,7 @@ function RepoDetailsContent({ > - + @@ -1605,12 +1605,12 @@ function RepoDetailsContent({ onChange={(event) => { setManifestDraft((currentDraft) => ({ ...currentDraft, - servbayPath: event.target.value, + mappedHostPath: event.target.value, })) }} placeholder="/repo/example" type="text" - value={manifestDraft.servbayPath} + value={manifestDraft.mappedHostPath} /> @@ -1620,12 +1620,12 @@ function RepoDetailsContent({ onChange={(event) => { setManifestDraft((currentDraft) => ({ ...currentDraft, - servbaySubdomain: event.target.value, + mappedHostSubdomain: event.target.value, })) }} placeholder="workspace-hub" type="text" - value={manifestDraft.servbaySubdomain} + value={manifestDraft.mappedHostSubdomain} /> @@ -1670,7 +1670,7 @@ function RepoDetailsContent({ ))} - {repo.preferredMode === 'servbay' ? ( + {repo.preferredMode === 'mapped-host' ? (

Mapped-host previews are only reliable when the repo has a tested path or subdomain and the saved preview URL matches the operator's local routing setup.

@@ -1881,9 +1881,9 @@ function RepoDetailsContent({
Mapped host
- {repo.servbaySubdomain - ? `${repo.servbaySubdomain} subdomain` - : repo.servbayPath ?? 'No mapped-host routing set'} + {repo.mappedHostSubdomain + ? `${repo.mappedHostSubdomain} subdomain` + : repo.mappedHostPath ?? 'No mapped-host routing set'}
diff --git a/repos/workspace-hub/src/features/services/CoreServiceDetails.tsx b/repos/workspace-hub/src/features/services/CoreServiceDetails.tsx index 698ebc7..cca4a34 100644 --- a/repos/workspace-hub/src/features/services/CoreServiceDetails.tsx +++ b/repos/workspace-hub/src/features/services/CoreServiceDetails.tsx @@ -56,6 +56,7 @@ export function CoreServiceDetails({ } const pendingPrefix = `service:${service.id}:` + const serviceMaintenancePaused = service.maintenancePaused return (
diff --git a/repos/workspace-hub/src/features/services/CoreServicesPanel.tsx b/repos/workspace-hub/src/features/services/CoreServicesPanel.tsx index d61f4d3..c0213eb 100644 --- a/repos/workspace-hub/src/features/services/CoreServicesPanel.tsx +++ b/repos/workspace-hub/src/features/services/CoreServicesPanel.tsx @@ -52,6 +52,7 @@ function ServiceStatusCard({ service: WorkspaceCoreService }) { const pendingPrefix = `service:${service.id}:` + const serviceMaintenancePaused = service.maintenancePaused return (
@@ -119,53 +120,53 @@ function ServiceStatusCard({ @@ -495,25 +505,29 @@ export function MempalaceWorkspacePage({
  • Workspace Hub surfaces the installed MemPalace version from the local package metadata.
  • -
  • Wake-up now prefers project guidance over dependency metadata by skipping low-signal drawers.
  • -
  • Repo mining supports explicit `--exclude` patterns for lockfiles and generated output.
  • +
  • Wake-up and repo-mining improvements remain available only after the wrapper pause is lifted.
  • +
  • Repo mining should keep explicit excludes for lockfiles and generated output.
  • Tracked docs remain canonical; MemPalace is supporting retrieval and long-term operator memory.
  • Future phases can add automation for recurring saves and more conversation exporters.
{context ? (
diff --git a/repos/workspace-hub/src/lib/api.ts b/repos/workspace-hub/src/lib/api.ts index 05c8766..ad53868 100644 --- a/repos/workspace-hub/src/lib/api.ts +++ b/repos/workspace-hub/src/lib/api.ts @@ -11,6 +11,7 @@ import type { WorkspaceEvent, WorkspaceSummary, } from '../types/workspace.ts' +import { workspaceHubIntentHeaders } from './workspaceHubIntent.ts' async function readErrorMessage(response: Response) { try { @@ -21,6 +22,26 @@ async function readErrorMessage(response: Response) { } } +const workspaceHubJsonHeaders = { + 'Content-Type': 'application/json', + ...workspaceHubIntentHeaders, +} as const + +function postJson(pathname: string, payload: unknown) { + return fetch(pathname, { + body: JSON.stringify(payload), + headers: workspaceHubJsonHeaders, + method: 'POST', + }) +} + +function postAction(pathname: string) { + return fetch(pathname, { + headers: workspaceHubIntentHeaders, + method: 'POST', + }) +} + function withSummaryReason( pathname: string, reason: SummaryRequestReason, @@ -98,13 +119,7 @@ export async function openRepoTarget( | 'terminal' | 'troubleshooting', ) { - const response = await fetch('/api/repos/open', { - body: JSON.stringify({ relativePath, target }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/open', { relativePath, target }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -112,13 +127,7 @@ export async function openRepoTarget( } export async function openWorkspacePath(targetPath: string) { - const response = await fetch('/api/open/path', { - body: JSON.stringify({ path: targetPath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/open/path', { path: targetPath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -139,12 +148,10 @@ export async function openCoreServiceTarget( | 'terminal', targetPath?: string | null, ) { - const response = await fetch('/api/services/open', { - body: JSON.stringify({ serviceId, target, targetPath: targetPath ?? null }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', + const response = await postJson('/api/services/open', { + serviceId, + target, + targetPath: targetPath ?? null, }) if (!response.ok) { @@ -156,13 +163,7 @@ export async function openWorkspaceCapabilityTarget( capabilityId: string, target: 'docs' | 'readme' | 'repo', ) { - const response = await fetch('/api/capabilities/open', { - body: JSON.stringify({ capabilityId, target }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/capabilities/open', { capabilityId, target }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -173,12 +174,9 @@ export async function runWorkspaceCapabilityAction( capabilityId: string, action: WorkspaceCapabilityActionId, ) { - const response = await fetch('/api/capabilities/action', { - body: JSON.stringify({ action, capabilityId }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', + const response = await postJson('/api/capabilities/action', { + action, + capabilityId, }) if (!response.ok) { @@ -204,13 +202,7 @@ export async function fetchCoreServiceTargetContext( targetKind: 'current-repo' | 'repo' | 'workspace-docs' }, ) { - const response = await fetch('/api/services/context', { - body: JSON.stringify({ serviceId, ...payload }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/services/context', { serviceId, ...payload }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -237,13 +229,7 @@ export async function runCoreServiceCommand( searchQuery?: string | null }, ) { - const response = await fetch('/api/services/command', { - body: JSON.stringify({ serviceId, ...payload }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/services/command', { serviceId, ...payload }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -301,13 +287,7 @@ export function subscribeWorkspaceEvents( } export async function runRepoInstall(relativePath: string) { - const response = await fetch('/api/repos/install', { - body: JSON.stringify({ relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/install', { relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -315,13 +295,7 @@ export async function runRepoInstall(relativePath: string) { } export async function runCoreServiceInstall(serviceId: string) { - const response = await fetch('/api/services/install', { - body: JSON.stringify({ serviceId }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/services/install', { serviceId }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -329,13 +303,7 @@ export async function runCoreServiceInstall(serviceId: string) { } export async function runRepoIntake(relativePath: string) { - const response = await fetch('/api/repos/intake', { - body: JSON.stringify({ relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/intake', { relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -353,13 +321,7 @@ export async function runRepoIntake(relativePath: string) { } export async function generateRepoCover(relativePath: string) { - const response = await fetch('/api/repos/cover', { - body: JSON.stringify({ relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/cover', { relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -370,13 +332,7 @@ export async function recordRepoActivity( relativePath: string, kind: 'select', ) { - const response = await fetch('/api/repos/activity', { - body: JSON.stringify({ kind, relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/activity', { kind, relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -395,23 +351,17 @@ export async function writeRepoManifest( name: string notes?: string packageManager?: string - preferredMode: 'direct' | 'external' | 'servbay' + preferredMode: 'direct' | 'external' | 'mapped-host' previewCommand?: string previewUrl?: string - servbayPath?: string - servbaySubdomain?: string + mappedHostPath?: string + mappedHostSubdomain?: string slug: string tags?: string[] type: 'node-app' | 'other' | 'php' | 'static' | 'threejs' | 'vite' | 'wordpress' }, ) { - const response = await fetch('/api/repos/manifest', { - body: JSON.stringify({ manifest, relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/manifest', { manifest, relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -422,12 +372,9 @@ export async function applyRepoAgentPreset( relativePath: string, preset: RepoAgentPresetId, ) { - const response = await fetch('/api/repos/agent-preset', { - body: JSON.stringify({ preset, relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', + const response = await postJson('/api/repos/agent-preset', { + preset, + relativePath, }) if (!response.ok) { @@ -449,13 +396,7 @@ export async function runRepoRuntimeAction( relativePath: string, action: 'restart' | 'start' | 'stop', ) { - const response = await fetch('/api/repos/runtime', { - body: JSON.stringify({ action, relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/runtime', { action, relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -466,13 +407,7 @@ export async function runCoreServiceRuntimeAction( serviceId: string, action: 'restart' | 'start' | 'stop', ) { - const response = await fetch('/api/services/runtime', { - body: JSON.stringify({ action, serviceId }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/services/runtime', { action, serviceId }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -480,13 +415,7 @@ export async function runCoreServiceRuntimeAction( } export async function syncCoreService(serviceId: string) { - const response = await fetch('/api/services/sync', { - body: JSON.stringify({ serviceId }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/services/sync', { serviceId }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -494,9 +423,7 @@ export async function syncCoreService(serviceId: string) { } export async function stopAllRuntimes() { - const response = await fetch('/api/runtime/stop-all', { - method: 'POST', - }) + const response = await postAction('/api/runtime/stop-all') if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -512,18 +439,12 @@ export async function saveRepoMetadata( healthcheckUrl?: string notes: string pinned: boolean - preferredMode: 'direct' | 'external' | 'servbay' + preferredMode: 'direct' | 'external' | 'mapped-host' previewUrl?: string tags: string[] }, ) { - const response = await fetch('/api/repos/metadata', { - body: JSON.stringify({ metadata, relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/metadata', { metadata, relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) @@ -531,13 +452,7 @@ export async function saveRepoMetadata( } export async function resetRepoMetadata(relativePath: string) { - const response = await fetch('/api/repos/metadata/reset', { - body: JSON.stringify({ relativePath }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }) + const response = await postJson('/api/repos/metadata/reset', { relativePath }) if (!response.ok) { throw new Error(await readErrorMessage(response)) diff --git a/repos/workspace-hub/src/lib/workspaceHubIntent.ts b/repos/workspace-hub/src/lib/workspaceHubIntent.ts new file mode 100644 index 0000000..baa689c --- /dev/null +++ b/repos/workspace-hub/src/lib/workspaceHubIntent.ts @@ -0,0 +1,6 @@ +export const workspaceHubIntentHeaderName = 'X-Workspace-Hub-Intent' +export const workspaceHubIntentHeaderValue = 'same-origin' + +export const workspaceHubIntentHeaders = { + [workspaceHubIntentHeaderName]: workspaceHubIntentHeaderValue, +} as const diff --git a/repos/workspace-hub/src/lib/workspaceMemoryPause.ts b/repos/workspace-hub/src/lib/workspaceMemoryPause.ts new file mode 100644 index 0000000..3e646cf --- /dev/null +++ b/repos/workspace-hub/src/lib/workspaceMemoryPause.ts @@ -0,0 +1,7 @@ +export const workspaceMemoryServiceId = 'mempalace' +export const workspaceMemoryMaintenancePausedReason = + 'Workspace memory is temporarily paused.' + +export function isWorkspaceMemoryService(serviceId: string) { + return serviceId === workspaceMemoryServiceId +} diff --git a/repos/workspace-hub/src/types/workspace.ts b/repos/workspace-hub/src/types/workspace.ts index f17f62a..16bac91 100644 --- a/repos/workspace-hub/src/types/workspace.ts +++ b/repos/workspace-hub/src/types/workspace.ts @@ -1,4 +1,4 @@ -export type PreviewMode = 'direct' | 'external' | 'servbay' +export type PreviewMode = 'direct' | 'external' | 'mapped-host' export type SummaryRequestReason = | 'action' | 'event' @@ -189,8 +189,8 @@ export type WorkspaceManifestRecord = { preferredMode: PreviewMode previewCommand?: string previewUrl?: string - servbayPath?: string - servbaySubdomain?: string + mappedHostPath?: string + mappedHostSubdomain?: string slug: string tags?: string[] type: RepoType @@ -364,6 +364,8 @@ export type WorkspaceCoreService = { lastSearchQuery: string | null lastSyncAt: string | null lastWakeUpAt: string | null + maintenancePaused: boolean + maintenancePausedReason: string | null name: string notes: string originUrl: string | null @@ -478,8 +480,8 @@ export type WorkspaceRepo = { recommendedPreset: RepoPreset relativePath: string savedMetadata: RepoSavedMetadata | null - servbayPath: string | null - servbaySubdomain: string | null + mappedHostPath: string | null + mappedHostSubdomain: string | null slug: string suggestedManifest: WorkspaceManifestRecord sideLoad?: RepoSideLoad | null @@ -503,7 +505,7 @@ export type WorkspaceSummary = { externalWordPressMode: string manifestPath: string previewMode: PreviewMode - servbayOptional: boolean + mappedHostOptional: boolean } sharedRoot: string stats: { diff --git a/repos/workspace-hub/test/core-services-panel.test.tsx b/repos/workspace-hub/test/core-services-panel.test.tsx index 031855f..8a518c0 100644 --- a/repos/workspace-hub/test/core-services-panel.test.tsx +++ b/repos/workspace-hub/test/core-services-panel.test.tsx @@ -49,6 +49,8 @@ function buildService(): WorkspaceCoreService { lastSearchQuery: null, lastSyncAt: null, lastWakeUpAt: null, + maintenancePaused: true, + maintenancePausedReason: 'Workspace memory is temporarily paused.', name: 'MemPalace', notes: '', originUrl: 'https://github.com/milla-jovovich/mempalace.git', @@ -123,6 +125,8 @@ test('CoreServicesPanel renders skipped manifest warnings with remediation', () assert.match(markup, /tools\/manifests\/workspace-capabilities\.json/) assert.match(markup, /Docs path resolves outside the workspace root and was rejected\./) assert.match(markup, /Use a workspace-relative `docsPath` that stays inside the workspace\./) + assert.match(markup, /Maintenance paused/) + assert.match(markup, /Stop paused/) }) test('CoreServicesPanel empty state points at workspace capabilities manifest', () => { diff --git a/repos/workspace-hub/test/mempalace-memory.test.ts b/repos/workspace-hub/test/mempalace-memory.test.ts index 9cd1949..adba684 100644 --- a/repos/workspace-hub/test/mempalace-memory.test.ts +++ b/repos/workspace-hub/test/mempalace-memory.test.ts @@ -194,6 +194,8 @@ test('core service memory commands are disabled while workspace memory is paused const service = await coreServices.findCoreService('mempalace', new Map(), new Map()) assert.ok(service) + assert.equal(service.maintenancePaused, true) + assert.equal(service.maintenancePausedReason, 'Workspace memory is temporarily paused.') await assert.rejects( coreServiceRuntime.runCoreServiceCommand(service, 'search', { @@ -201,6 +203,18 @@ test('core service memory commands are disabled while workspace memory is paused }), /Workspace memory is temporarily paused/, ) + await assert.rejects( + coreServiceRuntime.runCoreServiceInstall(service), + /Workspace memory is temporarily paused/, + ) + await assert.rejects( + coreServiceRuntime.startCoreService(service), + /Workspace memory is temporarily paused/, + ) + await assert.rejects( + coreServiceRuntime.runCoreServiceSync(service), + /Workspace memory is temporarily paused/, + ) }) test('core service reader skips services whose paths escape the workspace root', async () => { diff --git a/repos/workspace-hub/test/repo-details-context-cache.test.ts b/repos/workspace-hub/test/repo-details-context-cache.test.ts index d2c50ed..7cc05d4 100644 --- a/repos/workspace-hub/test/repo-details-context-cache.test.ts +++ b/repos/workspace-hub/test/repo-details-context-cache.test.ts @@ -105,8 +105,8 @@ function buildRepo(sideLoad: WorkspaceRepo['sideLoad']): WorkspaceRepo { updatedAt: null, }, savedMetadata: null, - servbayPath: null, - servbaySubdomain: null, + mappedHostPath: null, + mappedHostSubdomain: null, sideLoad, slug: 'workspace-hub', suggestedManifest: { @@ -188,7 +188,7 @@ test('RepoDetails omits the context cache block when side-load metadata has not test('RepoDetails renders the latest repo intake notes and mapped-host warning when relevant', () => { const repo = buildRepo(undefined) - repo.preferredMode = 'servbay' + repo.preferredMode = 'mapped-host' const markup = renderRepoDetails(repo, { coverCreated: true, diff --git a/repos/workspace-hub/test/workspace-cache-search.test.ts b/repos/workspace-hub/test/workspace-cache-search.test.ts index f5dd399..723a22e 100644 --- a/repos/workspace-hub/test/workspace-cache-search.test.ts +++ b/repos/workspace-hub/test/workspace-cache-search.test.ts @@ -317,8 +317,8 @@ test('thin search uses side-load summaries while deep search can include debug-o updatedAt: null, }, savedMetadata: null, - servbayPath: null, - servbaySubdomain: null, + mappedHostPath: null, + mappedHostSubdomain: null, slug: 'repo-search-modes', suggestedManifest: { entryDocs: ['repos/repo-search-modes/README.md'], @@ -455,8 +455,8 @@ test('deep search only indexes the configured file prefix for large files', asyn updatedAt: null, }, savedMetadata: null, - servbayPath: null, - servbaySubdomain: null, + mappedHostPath: null, + mappedHostSubdomain: null, slug: 'repo-large-readme', suggestedManifest: { entryDocs: ['repos/repo-large-readme/README.md'], @@ -942,8 +942,8 @@ test('search observability tracks requests and document cache reuse', async () = updatedAt: null, }, savedMetadata: null, - servbayPath: null, - servbaySubdomain: null, + mappedHostPath: null, + mappedHostSubdomain: null, slug: 'repo-search-observability', suggestedManifest: { entryDocs: [], diff --git a/repos/workspace-hub/test/workspace-healthcheck.test.ts b/repos/workspace-hub/test/workspace-healthcheck.test.ts index 3f50e36..4b7b98e 100644 --- a/repos/workspace-hub/test/workspace-healthcheck.test.ts +++ b/repos/workspace-hub/test/workspace-healthcheck.test.ts @@ -5,6 +5,11 @@ import os from 'node:os' import path from 'node:path' import { after, test } from 'node:test' +import { + workspaceHubIntentHeaderName, + workspaceHubIntentHeaderValue, +} from '../src/lib/workspaceHubIntent.ts' + const repoRoot = path.resolve(import.meta.dirname, '..') const tempRoots: string[] = [] @@ -47,7 +52,7 @@ after(async () => { test('workspace healthcheck reports manifest validation warnings and observability', async () => { const workspaceRoot = await createTempWorkspaceRoot('codex-workspace-healthcheck-') - for (const directory of ['cache', 'docs', 'repos', 'shared', 'tools/manifests']) { + for (const directory of ['cache', 'docs', 'repos', 'shared', 'tools/bin', 'tools/manifests', 'tools/mempalace']) { await mkdir(path.join(workspaceRoot, directory), { recursive: true }) } @@ -59,6 +64,23 @@ test('workspace healthcheck reports manifest validation warnings and observabili { version: 1, capabilities: [ + { + cacheRoot: 'cache/mempalace', + category: 'memory', + classification: 'core-service', + description: 'Workspace memory fixture', + docsPath: 'docs', + id: 'mempalace', + installCommand: ['tools/bin/workspace-memory', 'install'], + installMethod: 'git', + installTarget: 'tools/mempalace', + name: 'MemPalace', + runtimeCommand: ['tools/bin/mempalace-start'], + sharedRoot: 'shared/mempalace', + sourceUrl: 'https://example.com/mempalace.git', + syncCommand: ['tools/bin/mempalace-sync'], + updateStrategy: 'git-sync-command', + }, { category: 'memory', classification: 'core-service', @@ -136,6 +158,82 @@ test('workspace healthcheck reports manifest validation warnings and observabili archiveSummary.archives.some((archive) => archive.relativePath === 'repos/fixture-archive.zip'), ) assert.ok(archiveSummary.stats.archiveFiles >= 1) + + const invalidJsonWithoutIntentResponse = await fetch(`${baseUrl}/api/runtime/stop-all`, { + body: '{', + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + assert.equal(invalidJsonWithoutIntentResponse.status, 403) + + const unguardedPostResponse = await fetch(`${baseUrl}/api/runtime/stop-all`, { + method: 'POST', + }) + assert.equal(unguardedPostResponse.status, 403) + + const foreignOriginPostResponse = await fetch(`${baseUrl}/api/runtime/stop-all`, { + headers: { + Origin: 'https://example.com', + [workspaceHubIntentHeaderName]: workspaceHubIntentHeaderValue, + }, + method: 'POST', + }) + assert.equal(foreignOriginPostResponse.status, 403) + + const trustedLoopbackOriginPostResponse = await fetch(`${baseUrl}/api/runtime/stop-all`, { + headers: { + Origin: 'http://localhost:4100', + [workspaceHubIntentHeaderName]: workspaceHubIntentHeaderValue, + }, + method: 'POST', + }) + assert.equal(trustedLoopbackOriginPostResponse.status, 200) + + const guardedPostResponse = await fetch(`${baseUrl}/api/runtime/stop-all`, { + headers: { + [workspaceHubIntentHeaderName]: workspaceHubIntentHeaderValue, + }, + method: 'POST', + }) + assert.equal(guardedPostResponse.status, 200) + + const serviceHeaders = { + 'Content-Type': 'application/json', + [workspaceHubIntentHeaderName]: workspaceHubIntentHeaderValue, + } + const pausedMessage = 'Workspace memory is temporarily paused.' + + for (const [pathname, body] of [ + ['/api/services/install', { serviceId: 'mempalace' }], + ['/api/services/runtime', { action: 'start', serviceId: 'mempalace' }], + ['/api/services/sync', { serviceId: 'mempalace' }], + ['/api/services/command', { commandId: 'search', searchQuery: 'workspace memory', serviceId: 'mempalace' }], + ] as const) { + const serviceResponse = await fetch(`${baseUrl}${pathname}`, { + body: JSON.stringify(body), + headers: serviceHeaders, + method: 'POST', + }) + assert.equal(serviceResponse.status, 400) + assert.equal((await serviceResponse.json() as { message: string }).message, pausedMessage) + } + + const contextResponse = await fetch(`${baseUrl}/api/services/context`, { + body: JSON.stringify({ serviceId: 'mempalace', targetKind: 'workspace-docs' }), + headers: serviceHeaders, + method: 'POST', + }) + assert.equal(contextResponse.status, 200) + const contextPayload = await contextResponse.json() as { + commands: Array<{ enabled: boolean; id: string; reasonDisabled: string | null }> + } + assert.ok( + contextPayload.commands.every( + (command) => command.enabled === false && command.reasonDisabled === pausedMessage, + ), + ) } finally { child.kill('SIGTERM') await new Promise((resolve) => { diff --git a/tools/scripts/cleanup-sync-noise.sh b/tools/scripts/cleanup-sync-noise.sh index 5fc71c2..409a066 100755 --- a/tools/scripts/cleanup-sync-noise.sh +++ b/tools/scripts/cleanup-sync-noise.sh @@ -1,24 +1,75 @@ #!/usr/bin/env sh set -eu -target_path=${1:-.} -cleanup_mode=${2:---all} +target_path=. +target_path_set=0 +cleanup_mode=--all +run_mode=--dry-run + +usage() { + printf 'Usage: %s [path] [--all|--git-only] [--dry-run|--run]\n' "$0" >&2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --all|--git-only) + cleanup_mode=$1 + ;; + --dry-run|--run) + run_mode=$1 + ;; + --help|-h) + usage + exit 0 + ;; + --*) + usage + exit 1 + ;; + *) + if [ "$target_path_set" -eq 1 ]; then + usage + exit 1 + fi + target_path=$1 + target_path_set=1 + ;; + esac + shift +done case "$cleanup_mode" in --all|--git-only) ;; *) - printf 'Usage: %s [path] [--all|--git-only]\n' "$0" >&2 + usage + exit 1 + ;; +esac + +case "$run_mode" in + --dry-run|--run) ;; + *) + usage exit 1 ;; esac -python3 - "$target_path" "$cleanup_mode" <<'PY' +if [ ! -e "$target_path" ]; then + printf 'Target path not found: %s\n' "$target_path" >&2 + exit 1 +fi + +python3 - "$target_path" "$cleanup_mode" "$run_mode" <<'PY' import os import sys target_path = os.path.abspath(sys.argv[1]) cleanup_mode = sys.argv[2] +run_mode = sys.argv[3] +matched = 0 +matched_git = 0 +matched_plain = 0 removed = 0 removed_git = 0 removed_plain = 0 @@ -26,7 +77,7 @@ removed_plain = 0 for dirpath, dirnames, filenames in os.walk(target_path): for name in filenames: name_bytes = os.fsencode(name) - is_noise = name_bytes == b'Icon\r' or name.startswith('._') + is_noise = name == '.DS_Store' or name_bytes == b'Icon\r' or name.startswith('._') if not is_noise: continue @@ -36,6 +87,15 @@ for dirpath, dirnames, filenames in os.walk(target_path): if cleanup_mode == '--git-only' and not is_git_path: continue + matched += 1 + if is_git_path: + matched_git += 1 + else: + matched_plain += 1 + + if run_mode != '--run': + continue + try: os.remove(path) except FileNotFoundError: @@ -49,6 +109,11 @@ for dirpath, dirnames, filenames in os.walk(target_path): print(f'target={target_path}') print(f'mode={cleanup_mode}') +print(f'run={run_mode}') +print(f'matched={matched}') +print(f'matched_git={matched_git}') +print(f'matched_plain={matched_plain}') +print(f'would_remove={0 if run_mode == "--run" else matched}') print(f'removed={removed}') print(f'removed_git={removed_git}') print(f'removed_plain={removed_plain}') diff --git a/tools/scripts/doctor-workspace.sh b/tools/scripts/doctor-workspace.sh index 693fe8c..76dc67e 100755 --- a/tools/scripts/doctor-workspace.sh +++ b/tools/scripts/doctor-workspace.sh @@ -332,17 +332,12 @@ if [ -x "$mempalace_wrapper" ]; then fi printf '\nOptional local runtimes\n' -servbay_present=0 local_present=0 -if [ -e "/Applications/ServBay.app" ] || [ -e "$HOME/Applications/ServBay.app" ]; then - servbay_present=1 -fi if [ -e "/Applications/Local.app" ] || [ -e "$HOME/Applications/Local.app" ]; then local_present=1 fi -check_app_paths "Optional proxy (ServBay.app)" recommended wp_missing "/Applications/ServBay.app" "$HOME/Applications/ServBay.app" check_app_paths "Local" recommended wp_missing "/Applications/Local.app" "$HOME/Applications/Local.app" printf '\nAgent environment\n' @@ -415,7 +410,7 @@ core_status=$(profile_status "$core_missing") hub_status=$(profile_status "$hub_missing") mixed_status=$(profile_status "$mixed_missing") -if [ "$servbay_present" -eq 1 ] || [ "$local_present" -eq 1 ]; then +if [ "$local_present" -eq 1 ]; then wordpress_status="ready if you need WordPress support" else wordpress_status="optional, install Local or another WordPress stack only if you manage WordPress here" diff --git a/tools/scripts/mempalace-env.sh b/tools/scripts/mempalace-env.sh index 0e897ae..8ffe9ba 100755 --- a/tools/scripts/mempalace-env.sh +++ b/tools/scripts/mempalace-env.sh @@ -42,7 +42,6 @@ detect_mempalace_python() { "python3.11" \ "python3.10" \ "/usr/bin/python3" \ - "/Applications/ServBay/package/python/current/Python.framework/Versions/Current/bin/python3" \ "python3" do [ -n "$candidate" ] || continue diff --git a/tools/scripts/setup-workspace-profile.sh b/tools/scripts/setup-workspace-profile.sh index 819a4b1..e1314bc 100755 --- a/tools/scripts/setup-workspace-profile.sh +++ b/tools/scripts/setup-workspace-profile.sh @@ -238,7 +238,6 @@ case "$profile" in wordpress) printf 'Purpose: support WordPress repos without making WordPress tooling mandatory for the whole workspace.\n\n' printf 'Checks\n' - check_app_path "Optional proxy (ServBay.app)" recommended "/Applications/ServBay.app" "$HOME/Applications/ServBay.app" check_app_path "Local" recommended "/Applications/Local.app" "$HOME/Applications/Local.app" check_cmd "composer" composer recommended check_cmd "wp" wp recommended diff --git a/tools/scripts/trim-git-repos.sh b/tools/scripts/trim-git-repos.sh index 3e7803e..3263609 100755 --- a/tools/scripts/trim-git-repos.sh +++ b/tools/scripts/trim-git-repos.sh @@ -35,14 +35,14 @@ while IFS= read -r git_dir; do before_kb=$(du -sk "$git_dir" | awk '{print $1}') if [ -x "$cleanup_script" ]; then - "$cleanup_script" "$repo_dir" --git-only >/dev/null + "$cleanup_script" "$repo_dir" --git-only --run >/dev/null fi git -C "$repo_dir" reflog expire --expire=90.days.ago --expire-unreachable=30.days.ago --all >/dev/null 2>&1 || true git -C "$repo_dir" gc --prune=30.days.ago >/dev/null 2>&1 || true if [ -x "$cleanup_script" ]; then - "$cleanup_script" "$repo_dir" --git-only >/dev/null + "$cleanup_script" "$repo_dir" --git-only --run >/dev/null fi after_kb=$(du -sk "$git_dir" | awk '{print $1}')