Skip to content

Commit a668d40

Browse files
authored
Allow shared workspace bootstrap setup (#40)
1 parent aa653cf commit a668d40

15 files changed

Lines changed: 627 additions & 78 deletions

File tree

DICTIONARY.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ Before adding or changing a term, check this file and ask the user for approval.
2929
- `artifact`: Preserved file or directory written by SkillGym.
3030
- `artifact directory`: Directory holding artifacts for one scope such as a suite run, execution, repetition, retry, or session.
3131
- `workspace`: Directory where the agent runs.
32-
- `shared workspace`: Run directly in one real directory.
32+
- `no workspace`: Run directly in an existing working directory with no provisioning.
33+
- `shared workspace`: One provisioned workspace created once per suite run and reused across executions.
3334
- `isolated workspace`: Fresh workspace created per execution.
34-
- `workspace template`: Directory copied into an isolated workspace before execution.
35-
- `workspace bootstrap`: Command run in an isolated workspace before the agent starts.
35+
- `workspace template`: Directory copied into an execution workspace before execution.
36+
- `workspace bootstrap`: Command run in a provisioned workspace before the agent starts.
3637
- `schedule`: Execution ordering and concurrency policy.
3738
- `reporter`: Component rendering suite-run progress and results.
3839
- `skill detection`: Evidence that a skill was used, with confidence and evidence.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ A workspace is the directory where an execution runs.
200200
- `shared`: run directly in one real directory
201201
- `isolated`: create a fresh temporary workspace per case x runner execution
202202

203-
Use `shared` when you want the agent to work against your real project checkout. Use `isolated` when you want clean filesystem state per execution or need to prepare each execution from a template.
203+
Use `shared` when you want the agent to work against your real project checkout. Shared mode can still seed that directory with `templateDir` and `bootstrap` before the agent starts. Use `isolated` when you want clean filesystem state per execution or need a fresh prepared workspace every time.
204204

205205
You can configure workspaces in `skillgym.config.*` with `run.workspace`, or per suite with a named `workspace` export. Suite-level workspace config overrides config-level `run.workspace`.
206206

docs/cases.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,13 @@ export type SuiteWorkspaceConfig =
249249
| {
250250
mode: "shared";
251251
cwd?: string;
252+
templateDir?: string;
253+
bootstrap?: {
254+
command: string;
255+
args?: string[];
256+
timeoutMs?: number;
257+
env?: Record<string, string>;
258+
};
252259
}
253260
| {
254261
mode: "isolated";
@@ -264,8 +271,8 @@ export type SuiteWorkspaceConfig =
264271

265272
Rules:
266273

267-
- `shared` mode supports `cwd` only
268-
- `isolated` mode supports `templateDir` and `bootstrap` only
274+
- `shared` mode supports `cwd`, `templateDir`, and `bootstrap`
275+
- `isolated` mode supports `templateDir` and `bootstrap` but not `cwd`
269276
- relative suite workspace paths resolve from the suite file directory
270277
- isolated workspaces start empty when `templateDir` is omitted
271278
- `templateDir` copies the full directory contents, including dotfiles and `.git`

docs/workspaces.md

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
## Overview
44

5-
`skillgym` supports two workspace modes:
5+
`skillgym` supports three workspace modes:
66

7-
- `shared`: run directly in a real working directory
7+
- `none`: run directly in an existing working directory
8+
- `shared`: create one workspace per suite run and reuse it across executions
89
- `isolated`: create a fresh workspace per case x runner execution
910

10-
Use isolated workspaces when suites need different filesystem setups or when executions should not mutate the original source tree.
11+
Use `none` when the agent should run in an existing directory. Use `shared` or `isolated` when the suite needs SkillGym to provision a workspace first.
1112

1213
## Suite-level workspace config
1314

@@ -44,22 +45,43 @@ Suite workspace config overrides config-level `run.workspace`.
4445

4546
See `../examples/workspace-isolation-suite.ts` for a complete example that copies a template directory and runs a bootstrap command before the agent starts.
4647

47-
## Shared mode
48+
## None mode
4849

4950
```ts
5051
export const workspace = {
51-
mode: "shared",
52+
mode: "none",
5253
cwd: "./fixtures/repo-a",
5354
};
5455
```
5556

5657
Behavior:
5758

58-
- executions run directly in the shared directory
59+
- executions run directly in `cwd`
5960
- `cwd` is optional
60-
- if omitted, shared mode falls back to config `run.cwd`, then `process.cwd()`
61+
- if omitted, none mode falls back to config `run.cwd`, then `process.cwd()`
6162
- `templateDir` and `bootstrap` are not allowed
6263

64+
If a suite file does not export `workspace`, SkillGym falls back to config `run.workspace`, then to implicit `none` mode.
65+
66+
## Shared mode
67+
68+
```ts
69+
export const workspace = {
70+
mode: "shared",
71+
templateDir: "./fixtures/repo-template",
72+
};
73+
```
74+
75+
Behavior:
76+
77+
- one shared workspace is created under `outputDir/workspaces/shared`
78+
- all executions reuse that same workspace for the suite run
79+
- `cwd` is not allowed
80+
- `templateDir` copies into the shared workspace before any execution starts
81+
- `bootstrap` runs once inside the shared workspace before any execution starts
82+
- successful suite runs delete the shared workspace
83+
- failed suite runs preserve the shared workspace
84+
6385
## Isolated mode
6486

6587
```ts
@@ -79,11 +101,11 @@ Behavior:
79101

80102
## Bootstrap commands
81103

82-
Bootstrap commands run inside the isolated workspace before the agent starts.
104+
Bootstrap commands run inside the execution workspace before the agent starts.
83105

84106
```ts
85107
export const workspace = {
86-
mode: "isolated",
108+
mode: "shared",
87109
bootstrap: {
88110
command: "sh",
89111
args: ["./scripts/bootstrap-workspace.sh", "--seed", "demo"],
@@ -93,22 +115,28 @@ export const workspace = {
93115

94116
Bootstrap command behavior:
95117

96-
- `cwd` is the isolated workspace
118+
- `cwd` is the provisioned shared workspace or isolated workspace
97119
- non-zero exit fails that execution before the agent runs
98-
- stdout and stderr are written to the execution artifact directory
120+
- shared-workspace bootstrap stdout and stderr are written to `outputDir/workspaces/shared-setup`
121+
- isolated-workspace bootstrap stdout and stderr are written to the execution artifact directory
99122

100123
Runtime environment variables:
101124

102125
- `SKILLGYM_WORKSPACE`
103-
- `SKILLGYM_CASE_ID`
104-
- `SKILLGYM_RUNNER_ID`
105126
- `SKILLGYM_OUTPUT_DIR`
106127
- `SKILLGYM_ARTIFACT_DIR`
107128

129+
Isolated workspace bootstrap also receives:
130+
131+
- `SKILLGYM_CASE_ID`
132+
- `SKILLGYM_RUNNER_ID`
133+
108134
## Cleanup
109135

110136
Cleanup behavior is fixed:
111137

138+
- successful shared suite runs delete their workspace
139+
- failed shared suite runs preserve their workspace
112140
- successful isolated executions delete their workspace
113141
- failed isolated executions preserve their workspace
114142

@@ -120,7 +148,7 @@ Path rules:
120148

121149
- config `run.workspace` paths resolve from the config file directory
122150
- suite `workspace` paths resolve from the suite file directory
123-
- `bootstrap.command` and path-like `bootstrap.args` are resolved from the config or suite directory before the bootstrap runs inside the isolated workspace
151+
- `bootstrap.command` and path-like `bootstrap.args` are resolved from the config or suite directory before the bootstrap runs inside the execution workspace
124152

125153
## Limitations
126154

skills/workspaces.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ Use isolated workspaces when executions should not mutate the original checkout
2020
export const workspace = {
2121
mode: "shared",
2222
cwd: "./fixtures/repo-a",
23+
templateDir: "./fixtures/base-project",
24+
bootstrap: {
25+
command: "sh",
26+
args: ["./scripts/bootstrap-workspace.sh"],
27+
},
2328
};
2429
```
2530

@@ -28,6 +33,8 @@ Behavior:
2833
- executions run directly in that directory
2934
- `cwd` is optional
3035
- if omitted, Skillgym falls back to config `run.cwd`, then `process.cwd()`
36+
- `templateDir` copies into that directory before the agent starts
37+
- `bootstrap` runs in that directory before the agent starts
3138

3239
## Isolated mode
3340

src/cli/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export async function runCommand(options: {
6363
suiteDir: suite.dirPath,
6464
});
6565

66-
if (options.cwd !== undefined && effectiveWorkspace.mode === "isolated") {
66+
if (options.cwd !== undefined && effectiveWorkspace.mode !== "none") {
6767
throw new Error(
68-
"CLI option --cwd is only supported when the effective workspace mode is shared.",
68+
"CLI option --cwd is only supported when the effective workspace mode is none.",
6969
);
7070
}
7171

src/config.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -421,18 +421,18 @@ function parseWorkspaceConfig(value: unknown, configPath: string): SuiteWorkspac
421421
const templateDir = parseOptionalNonEmptyString(record.templateDir, `${configPath}.templateDir`);
422422
const bootstrap = parseOptionalBootstrapConfig(record.bootstrap, `${configPath}.bootstrap`);
423423

424-
if (mode === "shared") {
424+
if (mode === "none") {
425425
if (templateDir !== undefined) {
426426
throw invalidConfig(
427427
`${configPath}.templateDir`,
428-
'expected this key to be omitted when workspace mode is "shared"',
428+
'expected this key to be omitted when workspace mode is "none"',
429429
);
430430
}
431431

432432
if (bootstrap !== undefined) {
433433
throw invalidConfig(
434434
`${configPath}.bootstrap`,
435-
'expected this key to be omitted when workspace mode is "shared"',
435+
'expected this key to be omitted when workspace mode is "none"',
436436
);
437437
}
438438

@@ -442,6 +442,21 @@ function parseWorkspaceConfig(value: unknown, configPath: string): SuiteWorkspac
442442
};
443443
}
444444

445+
if (mode === "shared") {
446+
if (cwd !== undefined) {
447+
throw invalidConfig(
448+
`${configPath}.cwd`,
449+
'expected this key to be omitted when workspace mode is "shared"',
450+
);
451+
}
452+
453+
return {
454+
mode,
455+
templateDir,
456+
bootstrap,
457+
};
458+
}
459+
445460
if (cwd !== undefined) {
446461
throw invalidConfig(
447462
`${configPath}.cwd`,
@@ -457,8 +472,8 @@ function parseWorkspaceConfig(value: unknown, configPath: string): SuiteWorkspac
457472
}
458473

459474
function parseWorkspaceMode(value: unknown, configPath: string): SuiteWorkspaceConfig["mode"] {
460-
if (value !== "shared" && value !== "isolated") {
461-
throw invalidConfig(configPath, 'expected "shared" or "isolated"');
475+
if (value !== "none" && value !== "shared" && value !== "isolated") {
476+
throw invalidConfig(configPath, 'expected "none", "shared", or "isolated"');
462477
}
463478

464479
return value;
@@ -623,10 +638,22 @@ function resolveWorkspaceConfigPaths(
623638
config: SuiteWorkspaceConfig,
624639
configDir: string,
625640
): SuiteWorkspaceConfig {
641+
if (config.mode === "none") {
642+
return {
643+
mode: "none",
644+
cwd: config.cwd === undefined ? undefined : path.resolve(configDir, config.cwd),
645+
};
646+
}
647+
626648
if (config.mode === "shared") {
627649
return {
628650
mode: "shared",
629-
cwd: config.cwd === undefined ? undefined : path.resolve(configDir, config.cwd),
651+
templateDir:
652+
config.templateDir === undefined ? undefined : path.resolve(configDir, config.templateDir),
653+
bootstrap:
654+
config.bootstrap === undefined
655+
? undefined
656+
: resolveBootstrapConfigPaths(config.bootstrap, configDir),
630657
};
631658
}
632659

src/domain/case.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@ export interface WorkspaceBootstrapConfig {
1010

1111
export type SuiteWorkspaceConfig =
1212
| {
13-
mode: "shared";
13+
mode: "none";
1414
cwd?: string;
1515
templateDir?: never;
1616
bootstrap?: never;
1717
}
18+
| {
19+
mode: "shared";
20+
cwd?: never;
21+
templateDir?: string;
22+
bootstrap?: WorkspaceBootstrapConfig;
23+
}
1824
| {
1925
mode: "isolated";
2026
cwd?: never;

src/reporters/contract.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Case } from "../domain/case.js";
66
export interface ReporterContext {
77
isInteractive: boolean;
88
cwd: string;
9-
workspaceMode: "shared" | "isolated";
9+
workspaceMode: "none" | "shared" | "isolated";
1010
suitePath: string;
1111
suiteRunArtifactDir: string;
1212
selectedCaseCount: number;

src/reporters/standard.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,13 @@ export function createStandardReporter(options: StandardReporterOptions = {}): B
105105
onSuiteStart(event) {
106106
printBanner({ kind: "compact", stdout });
107107
writeLine(`${colors.dim("Suite ")}${accent(event.context.suitePath)}`, stdout);
108-
writeLine(
109-
`${colors.dim("Workspace ")}${colors.bold(event.context.workspaceMode === "shared" ? event.context.cwd : `${event.context.workspaceMode} per execution`)}`,
110-
stdout,
111-
);
108+
const workspaceLabel =
109+
event.context.workspaceMode === "shared"
110+
? event.context.cwd
111+
: event.context.workspaceMode === "isolated"
112+
? "isolated per execution"
113+
: `${event.context.cwd} (none)`;
114+
writeLine(`${colors.dim("Workspace ")}${colors.bold(workspaceLabel)}`, stdout);
112115
writeLine(`${colors.dim("Cases ")}${String(event.context.selectedCaseCount)}`, stdout);
113116
if (event.context.tagFilter !== undefined) {
114117
writeLine(`${colors.dim("Tags ")}${event.context.tagFilter.join(", ")}`, stdout);

0 commit comments

Comments
 (0)