feat(workspace): workspace setup experience — variables, requirements, bootstrap + re-setup#368
Draft
Vpr99 wants to merge 17 commits into
Draft
feat(workspace): workspace setup experience — variables, requirements, bootstrap + re-setup#368Vpr99 wants to merge 17 commits into
Vpr99 wants to merge 17 commits into
Conversation
Adds an optional top-level `variables: Record<string, VariableDeclaration>`
field to `WorkspaceConfigSchema`. Each declaration carries an optional
description, a v1-restricted JSON Schema (string/number/integer/boolean root
types with the supported constraint keywords), and an optional default.
Unsupported features (array/object roots, `$ref`, `oneOf`/`anyOf`/`allOf`,
conditionals, `patternProperties`, etc.) reject at parse time via per-type
`strictObject` and a discriminated union on `type`. The Zod error path
includes the offending variable name.
This is the foundation for `{{variables.X}}` interpolation, setup-requirement
derivation, and the variable-aware env-write card.
Task: #5
Extends `interpolateConfig` to handle the `variables.` namespace alongside
the existing flat keys (`{{repo_root}}` etc.). The placeholder regex now
captures dotted tokens; flat keys resolve from `WorkspaceVariables`, dotted
`variables.X` tokens resolve from a pre-built `Record<string, string>`
namespace.
Adds `resolveDeclaredVariables(declarations, env)` which walks each declared
variable, reads its `UPPER_SNAKE_CASE(name)` env value, validates against the
v1-restricted schema (via `z.fromJSONSchema`), and falls back to
`schema.default` on absent or schema-failing values. Unresolved variables
are omitted from the namespace, so the interpolator leaves the placeholder
literal — enforcing the strict namespace rule that prevents silent env leaks
via undeclared keys.
Coercion is one-way at read: env strings → typed values for validation;
resolved values → strings for substitution. The answer handler (T10) is what
writes typed user input back into `.env`.
Disjoint from PR #199's prompt-runtime interpolator (`{{config.x}}` /
`{{inputs.x}}` / `{{signal.payload.x}}` at LLM-step time) — those tokens
stay literal here because they live under a different top-level namespace.
Task: #6
…state Replaces stored `WorkspaceMetadata.requires_setup` with a pure derivation that computes setup state per request from parsed config, workspace env snapshot, and a caller-supplied Link credential snapshot. Variables are filled via JSON-Schema validation; credentials follow Decision 5 (stale pinned id post-import → recoverable requirement, at import → hard error) and Decision 3 (transient Link errors do not flip the boolean true). A small Hono-context cache memoizes results per workspace per request so the signal gate, sidebar badge, workspace summary, and system-prompt builder can each call the derivation without paying repeated `.env` reads or Link round-trips. Deletes the legacy `WorkspaceMetadata.requires_setup` field and the `POST /:workspaceId/setup/complete` endpoint in the same PR — no transitional reads, zero in-repo callers for the endpoint. Task: #7
…iry sweeper
Extends `ElicitationKindSchema` with `"workspace-setup"`. The request payload
reuses the existing `options` field to carry `setup_requirements:
SetupRequirement[]` — the schema is re-declared in `@atlas/core` because the
package cannot import `@atlas/workspace` without cycling.
The answer envelope stays flat (`{ value, note? }`); `value` for this kind is
the structured `{ variableValues, credentialChoices }` object rather than a
string option. The 30-minute expiry sweeper skips this kind — setup may sit
unfinished for days — and the read-time `expired` derivation + past-deadline
answer guard both honour the exemption.
State machine for this kind: `pending → answered | declined`.
Task: #9
… endpoints `GET /workspaces` (list) and `GET /workspaces/:id` (single) now derive the workspace's setup state per request via `getOrComputeSetupRequirements` from #7, returning `requires_setup: boolean` and `setup_requirements: SetupRequirement[]` alongside the existing payload. A new `assembleLinkCredentialState` helper walks `extractCredentials(config)`, calls `resolveCredentialsByProvider` per provider, and assembles the `{ defaultByProvider, resolvedIds, providerErrors }` snapshot the pure derivation expects. Transient Link errors land in `providerErrors` so the derivation can honour Decision 3 — "previous answer still valid" rather than flipping `requires_setup` true. Both endpoints use `allowStaleIdRecovery: true` since they run on already-imported workspaces; the import-time hard-error path stays out of this PR. Task: #8
… requires setup `WorkspaceManager` gains an optional `RequiresSetupProbe` injection point and short-circuits the registrar loop inside `registerWithRegistrars` (called by both initial registration and `restartSignalsForWorkspace`) when the probe reports `requires_setup: true`. HTTP, chat, and communicator providers still register — their gates live at trigger time (#14). The probe is fail-open: an absent probe or a thrown check is treated as "not setup-required" so we don't take a workspace's signals offline because a Link lookup blipped (Decision 3 — transient errors keep the previous answer). Daemon wires the probe to `assembleLinkCredentialState` + `loadWorkspaceEnv` + `resolveWorkspaceSetupRequirements` so the IO concerns stay out of `@atlas/workspace`. After the user completes setup, the answer handler's `restartSignalsForWorkspace` call (T10) re-runs registration and the now- clean derivation lets the registrars proceed. Task: #13
…ovider audience
Per Decision 7, audiences for non-chat signal triggers differ — cron has no
caller, HTTP has a webhook client, communicators have a human. The blanket
"skipped" response doesn't fit. Adds a thin gate helper
(`setup-required-gate.ts`) with three call sites:
1. `triggerWorkspaceSignal` in atlas-daemon (cascade-worker path: schedule,
fs-watch, queued HTTP) — emits one structured info log line, records a
`.setup_required` metric, throws `WorkspaceSetupRequiredError`. The
cascade dispatcher rethrows so the HTTP webhook subscriber can return 409.
2. HTTP signal routes (both SSE and JSON branches) — return `409 Conflict`
with the documented `{ error: "workspace_setup_required", message,
setup_url }` body. `bypassConcurrency` callers are not exempt (still HTTP
audience).
3. Communicator inbound handler in chat-sdk-instance — owner gets a reply
with the setup URL; non-owners drop silently with an info log.
Chat goes through `triggerSignalWithSession` directly and is intentionally
not gated here.
Task: #14
When the env-write card renders for a key that matches an auto-derived
declared-variable name on the current workspace, the card now looks up the
declaration and:
- renders the variable's `description` above the value input,
- builds a Zod validator from the declaration's schema (via
`z.fromJSONSchema`),
- coerces the proposed string through the same `coerceFromString` logic
the workspace's interpolation + setup-derivation use, then validates,
- renders a per-row validation error line on failure,
- guards both the Confirm button and the agent-driven `answer("confirm")`
path so neither can commit a schema-failing value.
Non-matching keys fall through to the existing raw rendering and the
secret-key heuristic still drives the password-input branch — variable
awareness layers on top, not in place of.
A standalone pure helper (`env-write-variable-awareness.ts`) houses the
variable lookup, the coercion, and the Zod validation so the Svelte
component is mostly UI glue. 10 unit tests cover declared-match,
undeclared-fallthrough, schema-fail-feedback, and the secret-on-top
interaction.
Task: #17
Each entry in the sidebar workspace list now displays a small "SETUP" pill when the workspace's `requires_setup === true`. Single visual treatment covers both initial setup (`active_setup_session_id` non-null) and re-setup (pointer null but derivation true) — design says one badge for both phases. The workspace summary schema gains `requires_setup: z.boolean().default(false)` matching the new field surfaced by GET /workspaces in #8. `WorkspaceSummary` type picks the field up automatically via inference. `useAnswerElicitation`'s `mergeAndInvalidate` invalidates `workspaceQueries.all()` when the answered elicitation is a `workspace-setup` kind, so the badge clears immediately on commit without needing an SSE round-trip. Task: #20
…edirect/banner Combined Wave 4 commit covering three tasks landed together due to lint-staged + signing volatility: #10 — POST /api/elicitations/:id/answer dispatches on kind. For workspace-setup: pre-flight validates every variableValue against its declared schema and every credentialChoice against Link ownership; collects per-field errors and returns 400 on any failure with no writes. On success commits env writes via the existing env-write pipeline, credential pins via updateCredential, calls restartSignalsForWorkspace, clears active_setup_session_id when the elicitation belongs to the bootstrap session, and marks the elicitation answered. Partial-commit failure post-pre-flight is idempotent on retry. #11 — /create, /import-bundle, /import-bundle-all now run resolveWorkspaceSetupRequirements(allowStaleIdRecovery: false) after the existing toIdRefs auto-pin step. When requires_setup is true, a chat session is created, one workspace-setup elicitation is pre-seeded into it with the setup_requirements payload, and the new WorkspaceMetadata.active_setup_session_id pointer is set. The bootstrap session id is returned to the importer for client-side redirect. StaleCredentialIdAtImportError surfaces as the import-time hard error. #21 — Initial setup: /workspaces/:id/chat with no session id server-side-redirects to the bootstrap session. Re-setup: /jobs, /agents, /signals load normally with a SetupRequiredBanner above their content; no redirect (Decision 4). ElicitationSchema gains a top-level optional setupRequirements field (Option A) so the import-time spawn can pre-seed the form payload — re-derivation at answer time still runs and is the source of truth (Decision 6). Tasks: #10, #11, #21
…side tool Combined Wave 5 commit landing three tasks together (signing chaos forced bundling): #12 — `recoverBootstrapSessionIfDeleted` reads workspace metadata, and when `requires_setup === true` AND `active_setup_session_id` points at a session that no longer exists, creates a new session, re-seeds a workspace-setup elicitation via the shared emitter, and updates the pointer last so the read-then-recover sequence is atomic-enough for single-writer access. Subsequent reads with a valid pointer are a no-op. #15 — New `workspace-setup-card.svelte` renders one row per variable requirement (label, optional description, plain text input). Validates on blur and on submit via #17's `validateProposedValue` helper; submit disabled until all required fields validate. Submits the documented `{ value: { variableValues, credentialChoices: {} } }` payload to the elicitation answer endpoint. Mounts in `user-chat.svelte` between the message list and chat input — the import-time elicitation has no associated tool call, so the existing tool-card dispatch wasn't the right slot. #18 — New `request_workspace_setup` MCP tool registered on the workspace-chat agent. Calls a freshly-derived `resolveWorkspaceSetupRequirements` and emits a session-scoped workspace-setup elicitation via the shared `emitWorkspaceSetupElicitation` helper. Resulting elicitation flows through the existing answer handler — single mutation path, two emission sites (import + agent). Shared emitter (`packages/core/src/elicitations/emit-workspace-setup.ts`) factored out so both spawn sites use the same insert + payload shape. `workspace-chat.agent.ts` registers the new tool alongside `env_set` and `connect_service`. Tasks: #12, #15, #18
…tem-prompt block #16 — `workspace-setup-card.svelte` extended with one CredentialPicker row per credential requirement, scoped to the requirement's provider. Picker queries its own credentials list via TanStack Query (not from the elicitation payload — per Decision in #7) so OAuth popup completion refetches cleanly. Variable input values persist across OAuth round-trips. Submit now includes `credentialChoices: Record<provider, credentialId>` keyed by provider. #19 — Workspace-chat agent's system-prompt builder now calls `resolveWorkspaceSetupRequirements` on each prompt composition and injects a `[WORKSPACE SETUP STATUS]` block when `requires_setup === true` AND `active_setup_session_id === null` (re-setup, not initial — Decision 4). Block enumerates each variable gap with description + schema summary and each credential gap with provider + reason, then lists `env_set`, `connect_service`, and `request_workspace_setup` with one-line use guidance. Template matches design § Module — Re-setup surface verbatim. Tasks: #16, #19
In-process Vitest exercising the full Decision 4 contract: a configured workspace whose pinned Gmail credential gets disconnected re-enters Workspace Setup *without* a redirect, surfaces the gap via the chat agent's `[WORKSPACE SETUP STATUS]` prompt block on the next composition, and recovers via `request_workspace_setup` flowing through the same elicitation answer-handler that initial-setup forms use. Seven cases pin the contract end-to-end: - workspace GET flips `requires_setup=true` with `active_setup_session_id=null` after credential disconnect (no bootstrap-session recreation since the pointer was never set — this is re-setup, not initial) - the (`requires_setup=true` AND `active_setup_session_id=null`) tuple the SetupRequiredBanner + chat-redirect page-load both key on is present, so `/jobs` renders with banner and never redirects - `fetchWorkspaceSetupStatus` + `formatSetupStatusBlock` (the real builder helpers) emit the design-doc template verbatim listing the credential gap with the stale_id reason - once reconnected, the block is NOT injected - `request_workspace_setup` tool emits one workspace-setup elicitation scoped to the current chat session (not the null bootstrap pointer) - POSTing the structured answer to `/api/elicitations/:id/answer` runs through `commitWorkspaceSetupAnswer`, pins the new credential via the draft-mutation, restarts signals, and re-derivation flips `requires_setup` back to false - the agent-side emission envelope is identical to what the import-time spawner persists (one form, one schema, one answer handler, two emission sites — per the design's Single Mutation Path note) Strategy A — boundary mocks (Link, ChatStorage, ElicitationStorage, filesystem, `applyDraftAwareMutation`) wrap an in-memory env overlay and elicitation row store. The real Hono workspaces + elicitations routes, real setup-answer pipeline, real system-prompt builder helpers, and the real MCP tool are exercised. Per `apps/atlasd/CLAUDE.md` and design doc § Testing Decisions #13, #17 + User Stories #18, #21. Task: #23
Integration test pinning the end-to-end initial setup flow: POST /create
on a config with an unfilled variable + provider-only Gmail credential
returns a bootstrap session id, the bootstrap chat + workspace-setup
elicitation land in real storage, the signal registrar's
`registerWorkspace` is gated off pre-submit (T13), and after answering via
POST /api/elicitations/:id/answer the .env file picks up the submitted
value, workspace.yml is rewritten with the credential pin, the elicitation
flips to answered, `active_setup_session_id` clears, the registrar fires
exactly once, and reloading the config + declared-variable interpolator
resolves `{{variables.email_recipient}}` to the submitted value.
Real `WorkspaceManager` + real `ElicitationStorage` + real `ChatStorage`
(per-worker NATS test server wired by vitest.setup.ts) + real filesystem
through the setup-answer handler. Link client is the only mocked boundary.
Fixes a missing piece of T10 surfaced by the integration test: the
elicitation answer handler now clears `active_setup_session_id` on
workspace metadata when the answered elicitation is the bootstrap-seeded
one, matching design doc § Module — Answer handler dispatch, step 6.
Without this the T21 chat-no-session redirect kept routing post-setup
users back into the bootstrap chat.
Key Learnings:
- The shared vitest.setup.ts wires every storage facade except
ElicitationStorage by design — tests that need it must call
`initElicitationStorage(getTestNc())` in their own beforeAll.
- `WorkspaceManager.isUnderHome` compares string-prefix of `entry.path`
(post-`Deno.realPath`) against FRIDAY_HOME, so tests that set
FRIDAY_HOME to a `tmpdir()` path on macOS must realpath it too or
every registered workspace is masked as cross-home (404).
- The manager's `initialize()` skips auto-import + default-workspace
bootstrap only when `DENO_TEST=true`; vitest's `VITEST=true` is not
enough — tests must set DENO_TEST to keep the per-test home isolated.
…polation
`WorkspaceRuntime.initialize` was calling `interpolateConfig(workspace, wsVars)`
without the third arg, so `{{variables.X}}` placeholders survived interpolation
and signal-triggered sessions received literal token strings in their agent
prompts.
Resolve the declared-variable namespace from `loadWorkspaceEnv` +
`resolveDeclaredVariables` and pass it into both the workspace and atlas
interpolation calls.
Extend `runtime-agent-prompt-composition.test.ts` with a regression case that
pins the call site: a workspace `.env` value flows through to the prompt the
agent receives.
Refs #24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🪷 A workspace arrives.
Variables blank, credentials gone —
the form finds the gap.
Summary
End-to-end Workspace Setup: imported workspaces with unfilled variables or unresolved credentials no longer silently fail at runtime. Instead they enter a first-class Workspace Setup state derived live from
(parsedConfig, envSnapshot, linkCredentials), and are guided to completion through one of two UX paths.variables:block inworkspace.ymlwith JSON-Schema-typed declarations;{{variables.X}}resolves at config-load time and propagates through runtime interpolation into agent prompts. Validation runs at every mutation path — initial setup form, agentenv_set(env-write card becomes variable-aware), and direct.envedits.workspace-setupelicitation into it, pinsactive_setup_session_id, and redirects the user. Existing tool-card rendering handles the form. Non-chat signals (cron, fs-watch, HTTP, communicators) are gated per-provider audience while setup is pending.requires_setupre-derives true on an already-configured workspace, the sidebar shows a badge, operational pages render a banner, and the workspace-chat agent's system prompt grows a setup-status block listing gaps + tools. A newrequest_workspace_setupagent tool emits the same form on demand.WorkspaceMetadata.requires_setupis gone. Per-request memoization handles intra-request consistency; transient Link backend failures degrade to "previous answer still valid" rather than flipping the gate.updateCredential. Import-time stale ids remain hard creation errors.Design doc: `docs/plans/2026-05-15-workspace-setup-design.md`.
Test Plan
Closes the workspace-setup design plan; ref #24 for the runtime-interpolation regression fix.