Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-dagster-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Move beta workspace view state to `.openspec-workspace/view.yaml` and ignore foreign root `workspace.yaml` files during workspace discovery so `openspec update` keeps working in Dagster projects.
9 changes: 5 additions & 4 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ A workspace has a different shape from a repo-local project:

```text
getGlobalDataDir()/workspaces/<workspace-name>/
├── workspace.yaml # Private local view record
├── .openspec-workspace/
│ └── view.yaml # Private local view record
├── AGENTS.md # Generated runtime guidance
└── <workspace-name>.code-workspace # Generated editor workspace file
```
Expand All @@ -90,7 +91,7 @@ That distinction matters. The workspace folder is a local coordination surface f
Stable link names are how a workspace refers to repos and folders. The private workspace record keeps names such as `api`, `web`, or `checkout` and maps them to this runtime's local paths.

```yaml
# workspace.yaml
# .openspec-workspace/view.yaml
version: 1
name: platform
context: null
Expand All @@ -99,7 +100,7 @@ links:
web: /repos/web
```

When a workspace opens an initiative, `context` records the selected context-store binding and initiative id. Registry-selected stores stay portable by id; path-selected stores intentionally preserve the runtime-local path because `workspace.yaml` is private local state.
When a workspace opens an initiative, `context` records the selected context-store binding and initiative id. Registry-selected stores stay portable by id; path-selected stores intentionally preserve the runtime-local path because `.openspec-workspace/view.yaml` is private local state.

```yaml
context:
Expand Down Expand Up @@ -133,7 +134,7 @@ getGlobalDataDir()/workspaces

That means `$XDG_DATA_HOME/openspec/workspaces` when `XDG_DATA_HOME` is set, `~/.local/share/openspec/workspaces` on Unix-style fallback, and `%LOCALAPPDATA%\openspec\workspaces` on native Windows fallback. Native Windows shells, PowerShell, and WSL2 each keep the path strings for the runtime running OpenSpec. This foundation does not translate between `D:\repo`, `/mnt/d/repo`, and UNC WSL paths.

OpenSpec can still read older beta workspace roots as compatibility inputs, but managed workspaces now use the root `workspace.yaml` record above. The workspace folder remains authoritative for its own private local view.
Managed workspaces use the namespaced private view record above. The workspace folder remains authoritative for its own private local view.

Workspace visibility is not change commitment. Set up a workspace when OpenSpec should know which repos or folders are relevant; create a change later when you are ready to plan a feature, fix, project, or other piece of work.

Expand Down
2 changes: 1 addition & 1 deletion docs/workspaces-beta/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ which local repos or folders to include.

The opened editor view shows linked repos and folders first, initiative context
when attached, and a small `OpenSpec workspace` folder last with `AGENTS.md`,
`workspace.yaml`, and the generated `.code-workspace` file.
`.openspec-workspace/view.yaml`, and the generated `.code-workspace` file.

Use `openspec workspace open --initiative team-context/billing-launch --editor`
when you want to skip the picker. Use `--agent codex-cli`, `--agent claude`, or
Expand Down
8 changes: 4 additions & 4 deletions openspec/specs/workspace-foundation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ OpenSpec SHALL use one kebab-case workspace name across workspace identity, mana

#### Scenario: Using one workspace name
- **WHEN** OpenSpec creates or records a managed workspace
- **THEN** the workspace name SHALL be stored in `.openspec-workspace/workspace.yaml`
- **THEN** the workspace name SHALL be stored in `.openspec-workspace/view.yaml`
- **AND** the same name SHALL be used as the default managed workspace folder name
- **AND** the same name SHALL be used as the local registry name

Expand Down Expand Up @@ -78,7 +78,7 @@ OpenSpec SHALL keep shared workspace information separate from local machine pat

#### Scenario: Keeping managed workspace view state local
- **WHEN** OpenSpec creates a managed workspace
- **THEN** it SHALL write `workspace.yaml` in the workspace root as private local view state
- **THEN** it SHALL write `.openspec-workspace/view.yaml` as private local view state
- **AND** the file SHALL preserve stable link names and local path values for the current machine

### Requirement: Standard Workspace Location
Expand Down Expand Up @@ -130,7 +130,7 @@ OpenSpec SHALL keep a lightweight local registry of known workspaces on the curr

#### Scenario: Keeping workspace folders authoritative
- **WHEN** OpenSpec reads workspace details
- **THEN** each workspace folder's `.openspec-workspace/workspace.yaml` SHALL remain the source of truth for that workspace
- **THEN** each workspace folder's `.openspec-workspace/view.yaml` SHALL remain the source of truth for that workspace
- **AND** the local registry SHALL act only as an index of known workspace locations

#### Scenario: Finding workspaces from anywhere
Expand Down Expand Up @@ -210,7 +210,7 @@ OpenSpec SHALL store a workspace's preferred opener in machine-local workspace s

#### Scenario: Recording an interactive setup opener choice
- **WHEN** an interactive user chooses a preferred opener during `openspec workspace setup`
- **THEN** OpenSpec SHALL record the opener in `.openspec-workspace/local.yaml`
- **THEN** OpenSpec SHALL record the opener in `.openspec-workspace/view.yaml`
- **AND** the stored value SHALL use a structured `preferred_opener` object with `kind` and `id`

#### Scenario: Recording a non-interactive setup opener choice
Expand Down
2 changes: 1 addition & 1 deletion src/commands/workspace/context-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function collectWorkspaceContextStatuses(
target: 'workspace.context.store',
fix: context.store.selector.kind === 'registry'
? 'openspec context-store doctor'
: `Check the path in workspace.yaml or run openspec initiative show ${initiativeId} ${selector}`,
: `Check the path in .openspec-workspace/view.yaml or run openspec initiative show ${initiativeId} ${selector}`,
}
),
];
Expand Down
2 changes: 1 addition & 1 deletion src/commands/workspace/open-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ async function resolveStoredWorkspaceInitiative(
target: 'workspace.context.store',
fix: context.store.selector.kind === 'registry'
? 'openspec context-store doctor'
: 'Check the path in workspace.yaml.',
: 'Check the path in .openspec-workspace/view.yaml.',
}
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/commands/workspace/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function localStateInvalidStatus(error: unknown): WorkspaceStatus {
`Machine-local paths could not be read: ${asErrorMessage(error)}`,
{
target: 'workspace.local_state',
fix: 'Repair workspace.yaml, then run openspec workspace relink <name> <path> for affected links.',
fix: 'Repair .openspec-workspace/view.yaml, then run openspec workspace relink <name> <path> for affected links.',
}
);
}
Expand Down Expand Up @@ -433,7 +433,7 @@ export async function loadWorkspaceForDoctor(
`Workspace state could not be read: ${asErrorMessage(error)}`,
{
target: 'workspace.root',
fix: 'Repair .openspec-workspace/workspace.yaml before using this workspace.',
fix: 'Repair .openspec-workspace/view.yaml before using this workspace.',
}
),
],
Expand Down Expand Up @@ -523,7 +523,7 @@ async function readWorkspaceViewForMutation(selected: SelectedWorkspace): Promis
'workspace_state_invalid',
{
target: 'workspace.state',
fix: 'Repair workspace.yaml before using this workspace.',
fix: 'Repair .openspec-workspace/view.yaml before using this workspace.',
}
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/workspace/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { FileSystemUtils } from '../../utils/file-system.js';

export const WORKSPACE_METADATA_DIR_NAME = '.openspec-workspace';
export const WORKSPACE_VIEW_STATE_FILE_NAME = 'workspace.yaml';
export const WORKSPACE_VIEW_STATE_FILE_NAME = 'view.yaml';
export const WORKSPACE_CHANGES_DIR_NAME = 'changes';
export const WORKSPACE_CODE_WORKSPACE_EXTENSION = '.code-workspace';

Expand Down Expand Up @@ -77,7 +77,7 @@ export function getWorkspaceMetadataDir(workspaceRoot: string): string {
}

export function getWorkspaceViewStatePath(workspaceRoot: string): string {
return joinWorkspacePath(workspaceRoot, WORKSPACE_VIEW_STATE_FILE_NAME);
return joinWorkspacePath(getWorkspaceMetadataDir(workspaceRoot), WORKSPACE_VIEW_STATE_FILE_NAME);
}
Comment on lines 79 to 81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Any code still reading a root-level workspace.yaml (outside .openspec-workspace/)?
rg -nP -C3 "workspace\.yaml" --type=ts -g '!**/*.test.ts'

# 2) Any migration/backfill for relocating legacy root state?
rg -nP -C4 '(migrat|backfill|legacyRoot|rootViewState|relocat)' --type=ts -g 'src/**'

# 3) How getWorkspaceViewStatePath / legacy shared path are consumed across src.
rg -nP -C3 '(getWorkspaceViewStatePath|getWorkspaceLegacySharedStatePath)' --type=ts -g 'src/**'

Repository: Fission-AI/OpenSpec

Length of output: 22057


🏁 Script executed:

#!/bin/bash
# Find any root-level filesystem probing for "workspace.yaml" (not under .openspec-workspace)
rg -nP --type=ts '(workspaceRoot\s*,\s*["'\'']workspace\.yaml["'\'']|joinWorkspacePath\([^)]*workspaceRoot[^)]*["'\'']workspace\.yaml["'\'']|joinPath\([^)]*workspaceRoot[^)]*["'\'']workspace\.yaml["'\'']|path.*workspace\.yaml)' src test

# Inspect workspace root detection/read path logic around view + legacy workspace.yaml
rg -nP --type=ts 'function readWorkspaceViewState|function isWorkspaceRoot|getWorkspaceLegacySharedStatePath|getWorkspaceViewStatePath' src/core/workspace/state-io.ts src/core/workspace/foundation.ts src/core/workspace/legacy-state.ts

# Locate the test mentioned in the original comment
rg -nP 'ignores unmarked root view state' test --type=ts
rg -nP 'unmarked root view state' test --type=ts
rg -nP 'root view state' test --type=ts

Repository: Fission-AI/OpenSpec

Length of output: 3112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search in src for any reads/writes/migrations involving root-level workspace.yaml and view.yaml
rg -nP --type=ts 'workspace\.yaml' src/core src/commands | head -n 200

rg -nP --type=ts 'view\.yaml|WORKSPACE_VIEW_STATE_FILE_NAME|WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME|getWorkspaceViewStatePath|getWorkspaceLegacySharedStatePath' src/core src/commands | head -n 200

# Look specifically for "migration" or "backfill" that mentions workspace state.
rg -nP --type=ts '(migrat|backfill|legacyRoot|root view state|relocat|orphan)' src/core/workspace src/core src/commands | head -n 200

# Inspect the relevant test file around the "ignores unmarked root view state" case.
sed -n '200,340p' test/core/workspace/foundation.test.ts

Repository: Fission-AI/OpenSpec

Length of output: 10675


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant constants + functions for workspace metadata dir + view state path
sed -n '1,140p' src/core/workspace/foundation.ts
echo '---'
sed -n '1,120p' src/core/workspace/legacy-state.ts
echo '---'
sed -n '1,230p' src/core/workspace/state-io.ts

echo '---'
# Search for any code that might copy/migrate root-level workspace.yaml into .openspec-workspace
rg -nP --type=ts "workspaceRoot.*workspace\.yaml|path\.join\([^)]*workspaceRoot[^)]*workspace\.yaml|joinWorkspacePath\([^)]*workspaceRoot[^)]*workspace\.yaml|\.openspec-workspace'.workspace\.yaml|copyFile.*workspace\.yaml|rename.*workspace\.yaml|move.*workspace\.yaml|relocat|migrat" src/core src/commands
echo '---'
# Search for init/update referencing workspace state migration
rg -nP --type=ts "writeWorkspaceViewState|readWorkspaceViewState|readWorkspaceViewStateSync|workspace\.yaml|view\.yaml|relink|repair .*view\.yaml|orphan" src/core src/commands

Repository: Fission-AI/OpenSpec

Length of output: 20247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Any non-constant usage of root-level workspace.yaml inside src (not tests)
rg -nP --type=ts "workspace\.yaml" src/core/workspace src/commands/workspace src/core/planning-home.ts src/core/registry* 2>/dev/null || true

# 2) Any direct mentions of root-level state backfill/migration in workspace area
rg -nP --type=ts "(migrat|backfill|relocat|legacy).*(workspace|view|state)" src/core/workspace src/commands/workspace || true

# 3) Any code that reads/writes legacy shared state file or workspace view state beyond state-io
rg -nP --type=ts "(getWorkspaceLegacySharedStatePath|readWorkspaceViewState|writeWorkspaceViewState|workspaceStatePartsToViewState|WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME|getWorkspaceViewStatePath)" src/core/workspace src/commands/workspace || true

# 4) Inspect registry + managed workspace root detection
sed -n '1,260p' src/core/workspace/registry.ts || true
sed -n '260,520p' src/core/workspace/registry.ts || true

Repository: Fission-AI/OpenSpec

Length of output: 12045


Add/confirm migration/backfill for legacy root-level workspace.yaml so managed/registry workspaces aren’t orphaned

  • isWorkspaceRoot / readWorkspaceViewState* only probe .openspec-workspace/view.yaml and .openspec-workspace/workspace.yaml (not <root>/workspace.yaml), and managed workspace enumeration (registry.ts) relies on that same detection.
  • The test suite enforces the behavior that root-level workspace.yaml is ignored even when its contents look OpenSpec-shaped.
  • Please ensure there’s an explicit backfill/migration for known managed/registry workspaces (or document the behavior) so existing beta users don’t silently lose their workspace state.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/workspace/foundation.ts` around lines 79 - 81, Tests and registry
currently ignore legacy root-level workspace.yaml, so implement an explicit
migration that detects a root-level "workspace.yaml" and backfills it into the
managed metadata directory used by
getWorkspaceViewStatePath/getWorkspaceMetadataDir (or otherwise imports it into
the same shape read by isWorkspaceRoot and readWorkspaceViewState*), and ensure
registry enumeration in registry.ts picks up those migrated workspaces; add a
startup migration function (e.g., migrateLegacyRootWorkspaceYaml) that runs
before workspace enumeration and moves/parses root-level workspace.yaml into
.openspec-workspace/workspace.yaml (or writes the normalized view.yaml) for
known managed/registry workspaces, and update
isWorkspaceRoot/readWorkspaceViewState*/registry.ts to treat the migrated file
as authoritative.


export function getWorkspaceChangesDir(workspaceRoot: string): string {
Expand Down
3 changes: 1 addition & 2 deletions src/core/workspace/legacy-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { z } from 'zod';

import {
WORKSPACE_METADATA_DIR_NAME,
WORKSPACE_VIEW_STATE_FILE_NAME,
getWorkspaceMetadataDir,
parseWorkspaceViewState,
validateWorkspaceLinkName,
Expand All @@ -16,7 +15,7 @@ import {
} from './foundation.js';
import { FileSystemUtils } from '../../utils/file-system.js';

export const WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME = WORKSPACE_VIEW_STATE_FILE_NAME;
export const WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME = 'workspace.yaml';
export const WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME = 'local.yaml';
export const WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN =
`${WORKSPACE_METADATA_DIR_NAME}/${WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME}`;
Expand Down
2 changes: 1 addition & 1 deletion src/core/workspace/open-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ This directory is an OpenSpec workspace: a local working view over context store
- Use repo-local OpenSpec changes for implementation plans owned by a repo or team.
- Use linked repos and folders to inspect context, understand ownership, and make edits in the place that owns the work.
- Keep workspace-local files focused on local paths, opener state, agent setup, and other machine-specific view state.
- Use OpenSpec workspace commands instead of hand-editing \`workspace.yaml\`.
- Use OpenSpec workspace commands instead of hand-editing \`.openspec-workspace/view.yaml\`.
- If this workspace contains legacy or beta workspace-level planning files, treat them as compatibility context unless the user explicitly asks to use that beta flow.`;

export interface WorkspaceOpenResolvedContext {
Expand Down
9 changes: 5 additions & 4 deletions src/core/workspace/state-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'node:path';
import { FileSystemUtils } from '../../utils/file-system.js';
import {
getWorkspaceChangesDir,
getWorkspaceMetadataDir,
getWorkspaceViewStatePath,
parseWorkspaceViewState,
serializeWorkspaceViewState,
Expand Down Expand Up @@ -162,10 +163,10 @@ export async function writeWorkspaceViewState(
workspaceRoot: string,
state: WorkspaceViewState
): Promise<void> {
await FileSystemUtils.writeFile(
getWorkspaceViewStatePath(workspaceRoot),
serializeWorkspaceViewState(state)
);
const content = serializeWorkspaceViewState(state);

await FileSystemUtils.createDirectory(getWorkspaceMetadataDir(workspaceRoot));
await FileSystemUtils.writeFile(getWorkspaceViewStatePath(workspaceRoot), content);
}

export async function workspaceChangesDirExists(workspaceRoot: string): Promise<boolean> {
Expand Down
2 changes: 1 addition & 1 deletion test/commands/workspace-initiative-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ describe('workspace open initiative views', () => {
},
})
);
expect(fs.existsSync(path.join(workspaceRoot, '.openspec-workspace'))).toBe(false);
expect(fs.existsSync(path.join(workspaceRoot, '.openspec-workspace'))).toBe(true);
expect(fs.existsSync(path.join(globalDataDir, 'workspaces', 'registry.yaml'))).toBe(false);
expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain(
'Initiative title: Billing Launch'
Expand Down
26 changes: 26 additions & 0 deletions test/commands/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,32 @@ describe('workspace command', () => {
expect(fs.existsSync(path.join(api, '.codex'))).toBe(false);
});

it('does not redirect openspec update for repo-local projects with foreign workspace.yaml', async () => {
const repoRoot = mkdir('repos/dagster');
fs.mkdirSync(path.join(repoRoot, 'openspec'), { recursive: true });
const foreignWorkspaceYaml = `load_from:
- grpc_server:
host: dagster-code
port: 4000
location_name: example
`;
fs.writeFileSync(path.join(repoRoot, 'workspace.yaml'), foreignWorkspaceYaml);

const update = await runCLI(['update'], {
cwd: repoRoot,
env,
});

expect(update.exitCode).toBe(0);
expect(update.stdout).not.toContain('Workspace update complete');
expect(update.stderr).not.toContain('Invalid workspace state');
expect(update.stdout).toContain('No configured tools found');
expect(fs.readFileSync(path.join(repoRoot, 'workspace.yaml'), 'utf-8')).toBe(
foreignWorkspaceYaml
);
expect(fs.existsSync(path.join(repoRoot, WORKSPACE_METADATA_DIR_NAME))).toBe(false);
});

it('updates the workspace passed to openspec update even when another workspace is known', async () => {
const firstApi = mkdir('repos/first-api');
const secondApi = mkdir('repos/second-api');
Expand Down
32 changes: 29 additions & 3 deletions test/core/planning-home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ describe('planning home paths', () => {

fs.mkdirSync(path.join(realWorkspaceRoot, '.openspec-workspace'), { recursive: true });
fs.writeFileSync(
path.join(realWorkspaceRoot, '.openspec-workspace', 'workspace.yaml'),
'version: 1\nname: platform\nlinks: {}\n',
path.join(realWorkspaceRoot, '.openspec-workspace', 'view.yaml'),
'version: 1\nname: platform\ncontext: null\nlinks: {}\n',
'utf-8'
);
fs.symlinkSync(
Expand All @@ -74,7 +74,7 @@ describe('planning home paths', () => {

fs.mkdirSync(path.join(workspaceRoot, '.openspec-workspace'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, 'workspace.yaml'),
path.join(workspaceRoot, '.openspec-workspace', 'view.yaml'),
'version: 1\nname: bad/name\ncontext: null\nlinks: {}\n',
'utf-8'
);
Expand All @@ -91,4 +91,30 @@ describe('planning home paths', () => {
})
).toThrow(/Workspace name/u);
});

it('resolves repo-local projects with foreign workspace.yaml as repo planning homes', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-planning-home-'));
tempDirs.push(tempDir);
const repoRoot = path.join(tempDir, 'dagster-repo');
const changesDir = path.join(repoRoot, 'openspec', 'changes');

fs.mkdirSync(changesDir, { recursive: true });
fs.writeFileSync(
path.join(repoRoot, 'workspace.yaml'),
`load_from:
- python_file:
relative_path: repository.py
location_name: dagster_repo
`,
'utf-8'
);

const planningHome = resolveCurrentPlanningHomeSync({
startPath: changesDir,
allowImplicitRepoRoot: false,
});

expect(planningHome.kind).toBe('repo');
expect(planningHome.root).toBe(fs.realpathSync.native(repoRoot));
});
});
59 changes: 55 additions & 4 deletions test/core/workspace/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('workspace foundation', () => {

function createWorkspaceRoot(name = 'platform'): string {
const workspaceRoot = path.join(tempDir, name);
fs.mkdirSync(workspaceRoot, { recursive: true });
fs.mkdirSync(getWorkspaceMetadataDir(workspaceRoot), { recursive: true });
fs.writeFileSync(
getWorkspaceViewStatePath(workspaceRoot),
`version: 1
Expand All @@ -83,7 +83,7 @@ links: {}
describe('path helpers', () => {
it('exposes the workspace constants', () => {
expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace');
expect(WORKSPACE_VIEW_STATE_FILE_NAME).toBe('workspace.yaml');
expect(WORKSPACE_VIEW_STATE_FILE_NAME).toBe('view.yaml');
expect(WORKSPACE_CHANGES_DIR_NAME).toBe('changes');
expect(MANAGED_WORKSPACES_DIR_NAME).toBe('workspaces');
expect(WORKSPACE_REGISTRY_FILE_NAME).toBe('registry.yaml');
Expand All @@ -96,7 +96,7 @@ links: {}
path.join(workspaceRoot, '.openspec-workspace')
);
expect(getWorkspaceViewStatePath(workspaceRoot)).toBe(
path.join(workspaceRoot, 'workspace.yaml')
path.join(workspaceRoot, '.openspec-workspace', 'view.yaml')
);
expect(getWorkspaceChangesDir(workspaceRoot)).toBe(path.join(workspaceRoot, 'changes'));
expect(getWorkspaceCodeWorkspaceFileName('platform')).toBe('platform.code-workspace');
Expand All @@ -109,7 +109,7 @@ links: {}
const workspaceRoot = 'D:\\repos\\platform-workspace';

expect(getWorkspaceViewStatePath(workspaceRoot)).toBe(
'D:\\repos\\platform-workspace\\workspace.yaml'
'D:\\repos\\platform-workspace\\.openspec-workspace\\view.yaml'
);
});

Expand Down Expand Up @@ -220,6 +220,57 @@ links: {}
);
});

it('ignores foreign root workspace.yaml files in repo-local projects', async () => {
const repoRoot = path.join(tempDir, 'dagster-repo');
const nestedDir = path.join(repoRoot, 'openspec', 'changes', 'add-feature');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(
path.join(repoRoot, 'workspace.yaml'),
`load_from:
- grpc_server:
host: dagster-code
port: 4000
location_name: example
`
);

await expect(isWorkspaceRoot(repoRoot)).resolves.toBe(false);
await expect(findWorkspaceRoot(nestedDir)).resolves.toBe(null);
});

it('ignores unmarked root view state even when it is OpenSpec-shaped', async () => {
const workspaceRoot = path.join(tempDir, 'unmarked-beta-workspace');
fs.mkdirSync(workspaceRoot, { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, 'workspace.yaml'),
`version: 1
name: unmarked-beta-workspace
context: null
links: {}
`
);

await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(false);
await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe(null);
});

it('writes canonical view state inside the OpenSpec metadata directory', async () => {
const workspaceRoot = path.join(tempDir, 'written-workspace');

await writeWorkspaceViewState(workspaceRoot, {
version: 1,
name: 'written-workspace',
context: null,
links: {},
});

expect(fs.existsSync(getWorkspaceMetadataDir(workspaceRoot))).toBe(true);
expect(fs.existsSync(getWorkspaceViewStatePath(workspaceRoot))).toBe(true);
expect(fs.existsSync(path.join(workspaceRoot, 'workspace.yaml'))).toBe(false);
await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(true);
expectSameExistingPath(await findWorkspaceRoot(workspaceRoot), workspaceRoot);
});

it('detects a workspace even when a linked path has no repo-local openspec state', async () => {
const workspaceRoot = createWorkspaceRoot();
const linkedPath = path.join(workspaceRoot, 'external-folder');
Expand Down
Loading
Loading