Files
tutti/docs/conventions/workspace-app-catalog.md
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

15 KiB

Workspace App Catalog

Workspace App Center can show built-in apps from two sources:

  • embedded app packages committed in services/tuttid/builtin-apps
  • remote built-in app packages listed by a JSON catalog

Remote built-in apps are optional. App Center can show their name, description, and icon before the package is downloaded. The package zip is downloaded only when the user installs the app.

Runtime Overrides

TUTTI_APP_CATALOG_FILE points tuttid at a local catalog JSON file and has priority over TUTTI_APP_CATALOG_URL.

When TUTTI_APP_CATALOG_URL is unset, tuttid loads the default published catalog:

https://d1x7gb6wqsqmnm.cloudfront.net/tutti-app-releases/catalog.json

Staging releases use a separate catalog and must not write production latest.json objects:

https://d1x7gb6wqsqmnm.cloudfront.net/tutti-app-releases-staging/catalog.json

TUTTI_APP_CATALOG_URL points tuttid at a public HTTP(S) catalog JSON file, usually served through CloudFront or S3. Set it to an empty string to disable the default remote catalog.

Example local mock:

TUTTI_APP_CATALOG_FILE=/tmp/tutti-app-catalog/catalog.json pnpm dev:desktop

Refresh Behavior

tuttid is the source of truth for remote catalog retrieval, manifest validation, artifact URLs, and artifact SHA-256 values. Renderer code should ask tuttid to refresh the catalog instead of fetching the CDN catalog directly.

App Center opening should call POST /v1/workspaces/{workspaceID}/apps/catalog/refresh. The refresh request is in-flight deduplicated by tuttid, keeps local and previously loaded apps visible while loading, and retries retryable network or 5xx failures for a total of three attempts.

App Center Grouping

App Center uses the same daemon catalog snapshot for recommended and community apps. tuttid should not add a separate community source enum for this presentation split. The reusable App Center UI keeps "My apps" for local and local-dev apps, and splits non-local catalog apps between "Official apps" and "Community apps" by app id in packages/workspace/app-center/src/core/appCenterAppOrdering.ts.

When moving an app between recommended and community presentation, update the central app id list and the App Center ordering tests in the same change. Community app cards show a developer entry first in the card action menu when the app metadata has an author URL. Prefer publishing author metadata through the app catalog; desktop-local app id overrides are acceptable only as a temporary bridge for community apps whose catalog metadata has not caught up.

Catalog Shape

{
  "schemaVersion": "tutti.app.catalog.v1",
  "apps": [
    {
      "localizations": [
        {
          "locale": "zh-CN",
          "name": "产品原型设计",
          "description": "创建并迭代产品原型设计",
          "tags": ["设计", "原型"]
        }
      ],
      "manifest": {
        "schemaVersion": "tutti.app.manifest.v1",
        "appId": "vibe-design",
        "version": "0.1.0+abc123",
        "name": "Vibe Design",
        "description": "Design workspace",
        "icon": {
          "type": "asset",
          "src": "icon.svg"
        },
        "runtime": {
          "bootstrap": "bootstrap.sh",
          "healthcheckPath": "/"
        }
      },
      "distribution": {
        "kind": "remote",
        "artifactUrl": "https://cdn.example.test/apps/vibe-design/0.1.0%2Babc123/vibe-design-0.1.0%2Babc123.zip",
        "artifactSha256": "64-char-sha256",
        "iconUrl": "https://cdn.example.test/apps/vibe-design/0.1.0%2Babc123/icon.svg"
      }
    }
  ]
}

Remote catalog entries must include distribution.iconUrl, distribution.artifactUrl, distribution.artifactSha256, and a manifest icon asset. The zip package must contain a complete app package with tutti.app.json, bootstrap.sh, AGENTS.md, and the manifest icon asset.

catalog.json keeps schemaVersion: "tutti.app.catalog.v1" so old daemons continue to accept it. Its apps[] array is the legacy-safe projection: for each app it contains the highest active release whose minTuttiVersion is 0.0.0. New daemons also read the additive compatibility frontier:

{
  "schemaVersion": "tutti.app.catalog.v1",
  "apps": [{ "manifest": { "appId": "vibe-design", "version": "0.1.0" } }],
  "compatibility": {
    "apps": {
      "vibe-design": [
        {
          "minTuttiVersion": "0.0.0",
          "app": { "manifest": { "appId": "vibe-design", "version": "0.1.0" } }
        },
        {
          "minTuttiVersion": "0.12.0",
          "app": { "manifest": { "appId": "vibe-design", "version": "0.2.0" } }
        }
      ]
    }
  }
}

The full compatibility entries contain the same manifest, localization, and remote distribution fields as apps[]; they are abbreviated above only for clarity. For a valid host version, the daemon selects the eligible entry with the greatest minTuttiVersion; releases from lower compatibility tiers do not compete by app version. The frontier keeps only versions that can be selected for some Tutti version. The builder rejects catalog output above 1 MiB because legacy daemons enforce that response limit.

Each app also owns apps/<appId>/versions.json. It retains the full active and withdrawn release history plus the explicit minTuttiVersion for each immutable release. latest.json remains the absolute latest published release and is not a compatibility input.

Managed Desktop passes its version to tuttid through TUTTI_APP_VERSION. Packaged Desktop uses the Electron package version; make dev-gui resolves the same Git-derived version used by desktop packaging before launching Electron. The daemon applies compatibility selection consistently to list, refresh, install, and update workflows. Missing or invalid host versions use apps[].

Desktop preferences choose the active app catalog channel (production or staging). That channel selects which remote catalog URL to load. Once a catalog entry is selected for the host, it is authoritative for App Center update and materialize decisions: when the installed builtin package version differs from the selected catalog version, Tutti offers or applies that catalog package even if the catalog version is lower than a package previously installed from the other channel. Equal version strings leave the installed package in place.

When a package manifest declares localizationInfo, release tooling reads the referenced package-local manifest locale files and writes their name, description, and tags into the catalog entry localizations field. App Center uses this catalog metadata for uninstalled remote apps, because it must not download or unzip the app package just to render localized catalog cards.

Workspace app packages do not declare arbitrary runtime kinds or bundle Python/Node. Packages that only need the managed Node/static runtime may declare runtime.profile: "connector-node-static"; all other managed runtime release and download rules belong to Workspace App Runtime.

Release Flow

External app repositories should call .github/workflows/publish-tutti-app-release.yml from this repository. The reusable workflow:

  1. Checks out the app repository.
  2. Serializes releases per app and branch.
  3. Resolves the release version from release_bump and existing release tags, or from the package manifest for staging-style runs.
  4. Runs the app repository package command.
  5. Runs @tutti-os/app-release-tools.
  6. Generates a zip, immutable release.json, and mutable latest.json.
  7. Refuses to overwrite an existing immutable release version.
  8. Uploads the immutable release directory, then conditionally updates versions.json and latest.json.
  9. Creates the release tag when create_release_tag is enabled.
  10. Optionally rebuilds that app from versions.json and conditionally merges it into catalog.json when publish_catalog is enabled.

Production app releases are manually dispatched from GitHub Actions with a release_bump value of patch, minor, or major. The reusable workflow fetches existing tags with the configured release_tag_prefix (default <appId>-v), reads the packaged manifest version, calculates the next stable semver version from the greater of those sources, publishes the S3 release, verifies the artifact, then creates an annotated release tag such as vibe-design-v1.2.4. The workflow never edits or commits the source manifest.

Every normal release must pass an explicit min_tutti_version. Use 0.0.0 only when the release is safe for every legacy Tutti client. Missing compatibility metadata fails before package upload; there is no permissive default.

Staging app releases do not create release tags. When release_bump is empty, the reusable workflow resolves a stable seed from the greater of the packaged manifest version and existing <appId>-v* release tags, then publishes <seed>+<short git sha>. Production tags therefore keep staging builds aligned with the current product line (for example 0.1.45+<sha> after ai-doc-v0.1.45), instead of freezing on a stale source-manifest version that production never rewrites.

Staging caller workflows should run automatically on push to main, which is the event produced after a pull request is merged. Push-triggered staging runs should publish the staging catalog by default so App Center sees the merged app without a second manual dispatch. Manual staging dispatch remains useful for catalog repair, reruns, and targeted validation.

GitHub Actions concurrency groups are repository-scoped and cannot serialize catalog writes from different app repositories. Mutable versions.json, latest.json, and catalog.json writes therefore use S3 ETag preconditions and retry after 412 Precondition Failed. The release workflow reads the existing catalog from the same S3 prefix, rebuilds the selected app from versions.json, and optionally invalidates the catalog path when catalog_cloudfront_distribution_id is set. The release upload role must be allowed to read and write the app's versions.json, latest.json, and the shared catalog.json object, and must have CloudFront invalidation permissions when invalidation is enabled.

Caller workflows usually pass catalog_cloudfront_distribution_id from TUTTI_APP_RELEASES_PRODUCTION_CLOUDFRONT_DISTRIBUTION_ID or the shared TUTTI_APP_RELEASES_CLOUDFRONT_DISTRIBUTION_ID variable. Prefer configuring shared CloudFront distribution ids as organization variables with selected repository access, and use repository variables only for repository-specific overrides or temporary setup. When neither variable is configured, the input is empty, invalidation is skipped, and catalog readers rely on the catalog.json cache TTL.

The release workflow also supports catalog_only: true for catalog repair and refresh operations. In catalog-only mode it skips package build, version bump, release metadata generation, and immutable artifact upload, then rebuilds the app from apps/<appId>/versions.json and merges it into catalog.json. App repositories may expose this as a manual input; the Tutti catalog workflows provide the same repair path when a caller workflow does not expose catalog-only dispatch.

Retrying a release version is allowed only when the existing immutable release.json matches the newly generated release metadata. Release archives normalize entry order, timestamps, and optional zip metadata so rebuilding the same package produces the same artifact SHA-256. In that case the workflow repairs mutable state such as latest.json, catalog metadata, and CloudFront invalidation without re-uploading immutable artifacts.

Each app uploads under:

apps/<appId>/<version>/
apps/<appId>/latest.json
apps/<appId>/versions.json

The Tutti repository owns .github/workflows/publish-tutti-app-catalog.yml. That workflow reads selected apps/<appId>/versions.json files from S3 and publishes one shared catalog.json. It defaults to merge mode, which preserves existing catalog apps and updates only selected app ids. Replace mode publishes only the selected app ids and should be used only for deliberate full catalog replacement.

For a controlled withdrawal, dispatch the production workflow in merge mode with withdraw_app_versions set to comma- or newline-separated appId@version entries. The workflow marks those records withdrawn in their mutable versions.json indexes and then rebuilds the catalog. It never deletes the immutable release directory or latest.json, so restoring a withdrawn release remains a metadata-only operation. Withdrawal is intentionally rejected in replace mode to prevent an accidental partial catalog publication.

There are three normal publishing modes:

  1. Release only: publish_catalog: false and catalog_only: false. This uploads a new app version and updates apps/<appId>/latest.json. App Center will see it after a later catalog.json publish.
  2. Release and catalog: publish_catalog: true. This uploads the app release and immediately merges that release into catalog.json.
  3. Catalog only: catalog_only: true, or the Tutti catalog workflow in merge mode with app_ids: <appId>. Use this after a release already succeeded when catalog was skipped, or when validation should happen before catalog publication. It must not bump or upload a new app version.

Catalog merges are explicit. App repository release workflows are scoped to the current app_id: publish_catalog: true and catalog_only: true merge only that app's apps/<appId>/versions.json into catalog.json. If app A published a release without catalog publication, app B's later release with publish_catalog: true preserves the existing catalog and updates B only; it does not discover and add A. To add or refresh multiple apps at once, use the Tutti catalog workflow with every desired app listed in app_ids, or run each app's catalog-only dispatch separately. No catalog path scans S3 for all published app indexes.

Production and staging release metadata must stay on separate S3 prefixes:

tutti-app-releases/apps/<appId>/latest.json
tutti-app-releases/apps/<appId>/versions.json
tutti-app-releases/catalog.json

tutti-app-releases-staging/apps/<appId>/latest.json
tutti-app-releases-staging/apps/<appId>/versions.json
tutti-app-releases-staging/catalog.json

The staging asset base URL must also point at the staging prefix, for example https://d1x7gb6wqsqmnm.cloudfront.net/tutti-app-releases-staging. Do not reuse the production TUTTI_APP_RELEASES_BASE_URL for staging, because the published latest.json and catalog.json would then reference production artifact URLs. For private app repositories that cannot read organization variables, set the Tutti release variables as repository variables on each app repository.

Use .github/workflows/publish-tutti-app-catalog-staging.yml to publish a staging catalog. Use .github/workflows/publish-tutti-app-catalog.yml to publish the production catalog. Production catalog publishing must read only production release metadata; staging catalog publishing must read only staging release metadata. In merge mode, an empty app id input refreshes the existing catalog and invalidates CloudFront without changing the app set, as long as an existing catalog.json is already present.