Skip to content

feat(workspace): workspace setup experience — variables, requirements, bootstrap + re-setup#368

Draft
Vpr99 wants to merge 17 commits into
mainfrom
workspace-setup-experience
Draft

feat(workspace): workspace setup experience — variables, requirements, bootstrap + re-setup#368
Vpr99 wants to merge 17 commits into
mainfrom
workspace-setup-experience

Conversation

@Vpr99
Copy link
Copy Markdown
Contributor

@Vpr99 Vpr99 commented May 16, 2026

🪷 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 as a first-class config primitive. New variables: block in workspace.yml with 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, agent env_set (env-write card becomes variable-aware), and direct .env edits.
  • Initial setup = pre-seeded bootstrap chat session. At import, if the workspace needs setup, the daemon eagerly spawns a chat session, inserts a workspace-setup elicitation into it, pins active_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.
  • Re-setup = agent-driven, no redirect. When requires_setup re-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 new request_workspace_setup agent tool emits the same form on demand.
  • Live-derived, never stored. Legacy WorkspaceMetadata.requires_setup is gone. Per-request memoization handles intra-request consistency; transient Link backend failures degrade to "previous answer still valid" rather than flipping the gate.
  • Stale pinned credentials are recoverable. Post-import disconnection of a pinned credential becomes a setup requirement (not a hard error); the form overwrites the stale pin via updateCredential. Import-time stale ids remain hard creation errors.

Design doc: `docs/plans/2026-05-15-workspace-setup-design.md`.

Test Plan

  • `deno task test` across touched packages — new suites:
    • `packages/config/src/variables-schema.test.ts` — variables block parsing + schema constraints
    • `packages/workspace/src/tests/setup-requirements.test.ts` — derivation matrix
    • `packages/workspace/src/tests/setup-gate-registrars.test.ts` — schedule + fs-watch skip
    • `packages/workspace/src/tests/variable-interpolation.test.ts` — `{{variables.X}}` resolution
    • `packages/workspace/src/runtime-agent-prompt-composition.test.ts` — regression for deps(deps-dev): update vitest requirement from ^4.1.4 to ^4.1.5 in /apps/link #24 (declared variables flow into runtime-built agent prompts)
    • `apps/atlasd/routes/workspaces/setup-spawn.test.ts` + `setup-integration.test.ts` — bootstrap session spawn end-to-end
    • `apps/atlasd/src/re-setup-recovery.test.ts` — full re-setup recovery loop
    • `apps/atlasd/src/setup-answer-handler.test.ts` — pre-flight validation + atomic commit
    • `apps/atlasd/src/setup-required-gate.test.ts` + `setup-requirements-cache.test.ts` — gate semantics and per-request memoization
    • `packages/core/src/agents/workspace-chat/setup-status-section.test.ts` + `tools/request-workspace-setup.test.ts` — agent prompt + tool
    • `tools/agent-playground/src/lib/components/chat/workspace-setup-card.test.ts` + `env-write-variable-awareness.test.ts` — UI rendering
  • Manual: imported `rtx-price-monitor` from Discover → redirected into bootstrap session → form rendered with variables + credential pickers → submit → workspace becomes runnable, schedule fires on next tick.
  • Manual: disconnected a pinned Gmail credential post-setup → sidebar badge appeared, `/jobs` showed banner, agent surfaced the gap on next message and offered `connect_service`.

Closes the workspace-setup design plan; ref #24 for the runtime-interpolation regression fix.

Vpr99 added 17 commits May 15, 2026 22:12
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant