@skyramp/mcp 0.4.4-rc.2 → 0.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/build/index.js +1 -4
  2. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.d.ts +17 -0
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +50 -7
  4. package/build/prompts/test-maintenance/driftAnalysisShared.js +1 -1
  5. package/build/tools/workspace/initScanWorkspaceTool.d.ts +11 -1
  6. package/build/tools/workspace/initScanWorkspaceTool.js +100 -9
  7. package/build/tools/workspace/initializeWorkspaceTool.js +40 -4
  8. package/build/workspace/workspace.d.ts +9 -1
  9. package/build/workspace/workspace.js +24 -3
  10. package/build/workspace/workspaceVerifier.d.ts +28 -0
  11. package/build/workspace/workspaceVerifier.js +307 -0
  12. package/package.json +1 -1
  13. package/plugin/prompts/plan-tests.md +2 -2
  14. package/plugin/skills/setup-testbot-target-sut/SKILL.md +54 -0
  15. package/plugin/skills/setup-testbot-target-sut/reference/ci-workflow.md +26 -0
  16. package/plugin/skills/setup-testbot-target-sut/reference/existing-tests.md +14 -0
  17. package/plugin/skills/setup-testbot-target-sut/reference/troubleshooting.md +20 -0
  18. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +0 -2
  19. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +0 -178
  20. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +0 -2
  21. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +0 -311
  22. package/build/prompts/sut-setup/shared.d.ts +0 -62
  23. package/build/prompts/sut-setup/shared.js +0 -196
  24. package/build/resources/sutSetupResource.d.ts +0 -2
  25. package/build/resources/sutSetupResource.js +0 -63
package/build/index.js CHANGED
@@ -33,7 +33,6 @@ import { registerMockTool } from "./tools/generate-tests/generateMockRestTool.js
33
33
  import { registerBatchMockTool } from "./tools/generate-tests/batchMockGenerationTool.js";
34
34
  import { registerAnalyzeChangesTool, registerAnalyzeTestHealthTool, registerActionsTool, registerRegisterTestPlanTool, registerResolveScreenTool, } from "./tools/test-management/index.js";
35
35
  import { registerTestbotPrompt } from "./prompts/testbot/testbot-prompts.js";
36
- import { registerSutSetupResource } from "./resources/sutSetupResource.js";
37
36
  import { registerTestbotResource } from "./resources/testbotResource.js";
38
37
  import { registerLocalDevPrompt } from "./prompts/local-dev/local-dev-prompts.js";
39
38
  import { registerSubmitReportTool } from "./tools/submitReportTool.js";
@@ -74,7 +73,7 @@ else if (localDevEnabled) {
74
73
  oneClickInstructions = localDevInstructions;
75
74
  }
76
75
  // Global workspace-init nudge for bare MCP usage. Suppressed in testbot mode,
77
- // which runs its own INIT_WORKSPACE step (buildWorkspaceInitSection()) instead.
76
+ // which initializes the workspace in its own steps instead.
78
77
  const workspaceInitInstructions = isTestbotEnabled() || oneClickEnabled || localDevEnabled
79
78
  ? ""
80
79
  : `
@@ -167,8 +166,6 @@ async function createServer({ era }) {
167
166
  prompts.push(registerTestbotPrompt);
168
167
  registerTestbotResource(server);
169
168
  logger.info("Testbot prompt enabled via SKYRAMP_FEATURE_TESTBOT");
170
- registerSutSetupResource(server);
171
- logger.info("Testbot SUT setup resource enabled via SKYRAMP_FEATURE_TESTBOT");
172
169
  }
173
170
  if (isLocalDevEnabled()) {
174
171
  prompts.push(registerLocalDevPrompt);
@@ -1 +1,18 @@
1
1
  export declare const INIT_WORKSPACE_INSTRUCTIONS: string;
2
+ /** A repository of the Target other than the workspace's own, scanned into the same workspace.yml. */
3
+ export interface ScanRelatedRepository {
4
+ /** owner/repo, set verbatim as each of its services' `repository`. */
5
+ repository: string;
6
+ repositoryPath: string;
7
+ }
8
+ /**
9
+ * The scan instructions for one workspace. With related repositories, every
10
+ * repository is scanned into the one workspace.yml at `workspacePath`, each
11
+ * related repository's services carrying its `repository`. With a test
12
+ * repository, testDirectory is read from, and relative to, that checkout.
13
+ */
14
+ export declare function buildInitWorkspaceInstructions(target: {
15
+ workspacePath: string;
16
+ relatedRepositories?: ScanRelatedRepository[];
17
+ testRepositoryPath?: string;
18
+ }): string;
@@ -1,9 +1,10 @@
1
1
  import { getPersonaPrefix } from "../personas.js";
2
2
  import { AUTH_TYPES_PROMPT_LIST } from "../../utils/workspaceAuth.js";
3
+ import { SUPPORTED_FRAMEWORKS } from "../../workspace/frameworks.js";
3
4
  export const INIT_WORKSPACE_INSTRUCTIONS = `${getPersonaPrefix()}Your task is to scan this repository, discover ALL services, and call the skyramp_init_workspace tool with the discovered services array and the scanToken.
4
5
 
5
6
  After scanning the workspace, before calling the skyramp_init_workspace tool, you MUST:
6
- 1. Output a <thinking> block to justify the reasoning behind each field mapping for every discovered service. Follow the scanning sections first, then field definitions, then verification.
7
+ 1. For each discovered service, list where each field value came from: the file it was read from, or the documented inference or fallback rule below that produced it (for example the deterministic testDirectory fallback, or the authType fallback). Follow the scanning sections first, then field definitions, then verification.
7
8
  2. Then output a Discovery Summary with the exact services array you will pass to the tool:
8
9
  \`\`\`json
9
10
  [
@@ -13,7 +14,7 @@ After scanning the workspace, before calling the skyramp_init_workspace tool, yo
13
14
  "framework": "<framework>",
14
15
  "testDirectory": "<path>",
15
16
  "api": { "schemaPath": "<path-or-url>", "baseUrl": "<url>", "authType": "<type>", "authHeader": "<header>" },
16
- "runtimeDetails": { "runtime": "<runtime>", "serverStartCommand": "<command>", "serverStopCommand": "<docker stop command>", "dockerNetwork": "<network>" }
17
+ "runtimeDetails": { "runtime": "<runtime>", "serverStartCommand": "<command>", "serverStopCommand": "<docker stop command>", "dockerNetwork": "<network>", "testSuites": [{ "framework": "<framework>", "testRunCommand": "<command>", "pathGlobs": ["<dir>/**"] }] }
17
18
  }
18
19
  ]
19
20
  \`\`\`
@@ -89,7 +90,7 @@ Create one service entry per deployable unit. You MUST include every backend/API
89
90
  - JUnit: Usually src/test/java. Check pom.xml or build.gradle for custom test source directories.
90
91
  If no framework-configured test directory is available, use the Skyramp deterministic fallback:
91
92
  - Single service: set testDirectory to tests/skyramp.
92
- - Multiple services or monorepos: set testDirectory to tests/skyramp/<serviceDirName>, where <serviceDirName> is the service directory name with path separators and whitespace replaced by hyphens.
93
+ - Multiple services or monorepos: set testDirectory to tests/skyramp/<serviceDirName>, where <serviceDirName> is the service's directory relative to its repository root with path separators and whitespace replaced by hyphens, or the repository's name for a service at the root.
93
94
  Framework config takes precedence. Use the Skyramp deterministic fallback only when no framework-configured test directory is available.
94
95
  NEVER set testDirectory to \`.skyramp\`, to any path inside it, or to \`.\` or the repo root, whatever the framework config or existing test files say. \`.skyramp\` is the executor's own working area: it holds the run videos and executor artefacts. skyramp_init_workspace rejects all of these, and the generation tools refuse to write there.
95
96
  </basic_fields>
@@ -123,7 +124,7 @@ Create one service entry per deployable unit. You MUST include every backend/API
123
124
  - Laravel: auth:sanctum on API indicates bearer. middleware("auth") on web indicates cookie.
124
125
  - Rust/Axum: Authorization<Bearer> extraction indicates bearer.
125
126
  d. Query-parameter auth: Some APIs pass credentials as a URL query param rather than a header (such as ?key=<key>, ?api_key=<key>, or ?access_token=<token>). Signals include source code reading credentials from req.query.key, request.query_params["api_key"], or @RequestParam("token"), and API docs or README showing auth examples like /endpoint?key=<your-key>.
126
- e. Fallback: For frontend or UI services, use none. For backend APIs with no header-based auth signals, use bearer.
127
+ e. Fallback: when none of the signals above applies, use none, for any service. An API that answers without credentials is unauthenticated; never assume bearer without a signal.
127
128
  4. api.authHeader: The exact HTTP header name carrying the credential.
128
129
  - For bearer, basic, oauth, or token: always "Authorization" (inferred automatically, so you may omit it).
129
130
  - For cookie or session: always "Cookie" (also inferred automatically).
@@ -146,12 +147,21 @@ Create one service entry per deployable unit. You MUST include every backend/API
146
147
  - For "k8s" runtime: Use a deploy command such as "kubectl apply -f deploy/", "helm install myrelease .", or "skaffold run". This is always derivable from the manifests or charts present in the repo.
147
148
  - For "local" runtime: Use an application command such as "uvicorn main:app", "npm run dev", or "java -jar app.jar". Derive from Makefile, package.json scripts, or README. If no start command is discoverable, omit this field entirely.
148
149
  NEVER mix runtime types with incompatible commands (for example, using "uvicorn" with runtime "docker" will cause errors). For "local" runtime, NEVER fabricate a command. Only use commands found in Makefile, package.json scripts, or README.
149
- 3. runtimeDetails.serverStopCommand: Optional command to stop the service after test execution. ONLY set when runtime is "docker" and serverStartCommand starts persistent Docker infrastructure. For docker compose starts, prefer matching stop/down commands from the same compose file. Omit this field for "local" and "k8s" runtimes, or when teardown is unsafe or not discoverable.
150
+ When the SUT recipe (.skyramp/sut/setup.sh) is what brings a service up and no compose service in the repository runs it, set serverStartCommand to "bash .skyramp/sut/setup.sh" and serverStopCommand to "bash .skyramp/sut/teardown.sh". Never invent a container name or a hand-built docker build or docker run command.
151
+ 3. runtimeDetails.serverStopCommand: Optional command to stop the service after test execution. ONLY set when runtime is "docker" and serverStartCommand starts persistent Docker infrastructure, or to "bash .skyramp/sut/teardown.sh" when serverStartCommand runs the SUT recipe. For docker compose starts, prefer matching stop/down commands from the same compose file. Omit this field for "local" and "k8s" runtimes, or when teardown is unsafe or not discoverable.
150
152
  4. runtimeDetails.dockerNetwork: Docker network name. ONLY set when runtime is "docker". NEVER set for "local" or "k8s".
151
153
  5. runtimeDetails.k8sNamespace: Kubernetes namespace. ONLY set when runtime is "k8s". NEVER set for "local" or "docker".
152
154
  6. runtimeDetails.k8sContext: Kubernetes context. ONLY set when runtime is "k8s". NEVER set for "local" or "docker".
153
155
  </runtime_fields>
154
156
 
157
+ ### Existing test suites
158
+ <test_suites>
159
+ runtimeDetails.testSuites records the service's own existing tests, which later runs execute and maintain. They cannot find a suite missing from this list. Each distinct (runner, test directory) pair is one suite: a Mocha suite under packages/api/test/ and a Playwright suite under e2e/ are two. Find them in two places and record the union: every test-runner config in the test tree (playwright.config.*, cypress.config.*, vitest.config.*, pytest.ini, test projects, and so on), and every CI step that runs tests (npm test, pytest, go test, dotnet test, mvn test or verify, cypress run, and so on), including a separate integration-test step. Record each suite under the service it tests, even when there is only one, and every suite you run yourself. Omit testSuites only when the service has no existing tests. Each entry sets:
160
+ 1. framework: the suite's runner, one of ${SUPPORTED_FRAMEWORKS.join(", ")}. Leave it out when the runner is none of these; record the suite all the same.
161
+ 2. testRunCommand: the command that runs the suite, from the root of the repository the suite lives in, taken from its CI job or package scripts. A machine-readable reporter flag and the test file paths, relative to that root, are appended to the end of it, so it must accept appended arguments and must not cd into a subdirectory: point the runner at the suite instead, with the repository's own config file or a package filter (for example "npx playwright test --config e2e/playwright.config.ts" or "pnpm --filter @acme/api test").
162
+ 3. pathGlobs: the directories the suite owns, as globs relative to that same root (for example ["e2e/tests/**"]). Set it on every suite. Scope by directory, not by filename, and keep the suites' globs disjoint, so each test file belongs to exactly one suite.
163
+ </test_suites>
164
+
155
165
  ### Verification
156
166
  <verification>
157
167
  Before calling skyramp_init_workspace, confirm all of the following:
@@ -168,11 +178,44 @@ Before calling skyramp_init_workspace, confirm all of the following:
168
178
  11. For "local" runtime: if no start command is discoverable from Makefile, package.json scripts, or README, omit serverStartCommand rather than guessing.
169
179
  12. dockerNetwork is set only when runtime is "docker".
170
180
  13. k8sNamespace and k8sContext are set only when runtime is "k8s".
171
- 14. If force-recreating a workspace.yml due to a schema validation error, correct the schema error but keep any valid system-under-test setup (serviceName, baseUrl, auth config, runtime details) as it is.
181
+ 14. Every existing test suite, from the test tree and from CI, is recorded under runtimeDetails.testSuites with its testRunCommand and pathGlobs.
182
+ 15. If force-recreating a workspace.yml due to a schema validation error, correct the schema error but keep any valid system-under-test setup (serviceName, baseUrl, auth config, runtime details) as it is.
172
183
  </verification>
173
184
 
174
185
  Once verified, call skyramp_init_workspace with:
175
186
  - workspacePath: the repository root path
176
187
  - services: the array built above
177
188
  - scanToken: the token returned by the first call to skyramp_init_scan
178
- - force: defaults to false. Set to true only if the user explicitly asks to overwrite an existing .skyramp/workspace.yml, or if the existing file must be regenerated due to a validation failure such as a schema mismatch, unknown fields, or a parse error.`;
189
+ - force: defaults to false. Set to true only if the user explicitly asks to overwrite an existing .skyramp/workspace.yml, or if the existing file must be regenerated due to a validation failure such as a schema mismatch, unknown fields, or a parse error.
190
+
191
+ skyramp_init_workspace checks the services against these rules before writing anything. When it refuses, it lists each problem: fix every one and call it again with the full corrected services array and the same scanToken, which a refusal leaves valid.`;
192
+ /**
193
+ * The scan instructions for one workspace. With related repositories, every
194
+ * repository is scanned into the one workspace.yml at `workspacePath`, each
195
+ * related repository's services carrying its `repository`. With a test
196
+ * repository, testDirectory is read from, and relative to, that checkout.
197
+ */
198
+ export function buildInitWorkspaceInstructions(target) {
199
+ const related = target.relatedRepositories ?? [];
200
+ const deliveryRoot = target.testRepositoryPath ?? target.workspacePath;
201
+ const blocks = [];
202
+ if (related.length > 0) {
203
+ const count = related.length + 1;
204
+ blocks.push(`### Target repositories
205
+ <target_repositories>
206
+ This workspace covers ${count} repositories. Scan EACH one with every section above, as if it were the only repository (its monorepo packages, frontend packages and CI test steps included), and pass the services of all ${count} to ONE skyramp_init_workspace call:
207
+ - ${target.workspacePath}: this workspace's own repository. Its services carry no repository field.
208
+ ${related.map((r) => `- ${r.repositoryPath}: set repository to "${r.repository}" on every service found here.`).join("\n")}
209
+ For a related repository's service, write every path and command as it reads from that repository's root (schemaPath, compose files, testRunCommand, pathGlobs), except testDirectory: every testDirectory is relative to ${deliveryRoot}, where generated tests are delivered. When no framework config sets it, a related repository's service uses tests/skyramp/<repoName>-<serviceDirName>, so services of two repositories never share one by accident. Call skyramp_init_workspace with workspacePath "${target.workspacePath}" only. A related repository gets no workspace.yml of its own.
210
+ </target_repositories>`);
211
+ }
212
+ if (target.testRepositoryPath) {
213
+ blocks.push(`### Test repository
214
+ <test_repository>
215
+ Generated tests are delivered into the test repository at ${target.testRepositoryPath}, not into the repository that holds the service. Read each service's testDirectory from the test repository's own framework config (Playwright testDir, pytest testpaths, the JUnit test source directory), relative to the test repository's root; the deterministic fallback applies there too. The test repository holds tests, not services: register no service for it. Record the suites it already has under the service each one tests, with testRunCommand and pathGlobs relative to the test repository's root.
216
+ </test_repository>`);
217
+ }
218
+ return blocks.length
219
+ ? `${INIT_WORKSPACE_INSTRUCTIONS}\n\n${blocks.join("\n\n")}`
220
+ : INIT_WORKSPACE_INSTRUCTIONS;
221
+ }
@@ -57,7 +57,7 @@ ${opts.domainNotes}
57
57
 
58
58
  **If your rationale concludes the test is unaffected, assign IGNORE, not VERIFY.** Assign VERIFY only for a doubt that the code you read does not settle. A rule that forbids IGNORE still applies.
59
59
 
60
- **A stated requirement wins over the code.** Before you write a new value or refresh a screenshot in an existing test, compare it with what the pull request title, the description and any requirements file they name state. If the new value breaks a stated requirement, do not write it: keep that assertion on the requirement's value and make only the other edits. If no other edit remains, mark the test VERIFY. The test stays red; do not relax it. If the requirements file is unchanged and the description presents the new behaviour as intended, the file is out of date: update the test.
60
+ **A stated requirement wins over the code.** Before you write a new value or refresh a screenshot in an existing test, compare it with what the pull request title, the description and any requirements file they name state. If the new value breaks a stated requirement, do not write it: keep that assertion on the requirement's value and make only the other edits. If no other edit remains, mark the test VERIFY. The test stays red; do not relax it. Authority runs requirements > PR description > code change: an unchanged requirements file that the description contradicts still wins — keep the assertion on the file's value and report the requirement conflict.
61
61
 
62
62
  **Pre-commit verification — confirm before finalizing UPDATE/REGENERATE/DELETE:**
63
63
  1. You can quote a specific diff line${opts.blueprintSuffix} this test observes that triggered the action.
@@ -1,4 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
- export declare function issueScanToken(): string;
2
+ import { type ScanRelatedRepository } from "../../prompts/initialize-workspace/initializeWorkspacePrompt.js";
3
+ /** What a scan token was issued for: when it expires, and the related
4
+ * repositories its scan named, which skyramp_init_workspace verifies the
5
+ * services against. */
6
+ export interface ScanGrant {
7
+ expiry: number;
8
+ relatedRepositories: ScanRelatedRepository[];
9
+ }
10
+ export declare function issueScanToken(relatedRepositories?: ScanRelatedRepository[]): string;
11
+ /** The grant of a valid, unexpired token, without consuming it. */
12
+ export declare function scanTokenGrant(token: string): ScanGrant | undefined;
3
13
  export declare function validateAndConsumeScanToken(token: string): boolean;
4
14
  export declare function registerInitScanWorkspaceTool(server: McpServer): void;
@@ -1,24 +1,33 @@
1
1
  import { z } from "zod";
2
2
  import { randomUUID } from "crypto";
3
+ import fs from "fs";
3
4
  import { WorkspaceConfigManager } from "../../workspace/workspace.js";
5
+ import { resolveRealPath, tryRepositorySlug, } from "../../utils/repositorySlug.js";
4
6
  import { logger } from "../../utils/logger.js";
5
7
  import { AnalyticsService } from "../../services/AnalyticsService.js";
6
- import { INIT_WORKSPACE_INSTRUCTIONS } from "../../prompts/initialize-workspace/initializeWorkspacePrompt.js";
8
+ import { buildInitWorkspaceInstructions, } from "../../prompts/initialize-workspace/initializeWorkspacePrompt.js";
7
9
  const TOOL_NAME = "skyramp_init_scan";
8
- // Token store: token -> expiry timestamp (ms)
9
10
  const scanTokens = new Map();
10
11
  const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
11
- export function issueScanToken() {
12
+ export function issueScanToken(relatedRepositories = []) {
12
13
  const token = randomUUID();
13
- scanTokens.set(token, Date.now() + TOKEN_TTL_MS);
14
+ scanTokens.set(token, {
15
+ expiry: Date.now() + TOKEN_TTL_MS,
16
+ relatedRepositories,
17
+ });
14
18
  return token;
15
19
  }
20
+ /** The grant of a valid, unexpired token, without consuming it. */
21
+ export function scanTokenGrant(token) {
22
+ const grant = scanTokens.get(token);
23
+ return grant && Date.now() < grant.expiry ? grant : undefined;
24
+ }
16
25
  export function validateAndConsumeScanToken(token) {
17
- const expiry = scanTokens.get(token);
18
- if (!expiry)
26
+ const grant = scanTokens.get(token);
27
+ if (!grant)
19
28
  return false;
20
29
  scanTokens.delete(token);
21
- return Date.now() < expiry;
30
+ return Date.now() < grant.expiry;
22
31
  }
23
32
  const initScanWorkspaceSchema = z.object({
24
33
  workspacePath: z.string().describe("Path to the workspace/repository root"),
@@ -26,12 +35,66 @@ const initScanWorkspaceSchema = z.object({
26
35
  .boolean()
27
36
  .default(false)
28
37
  .describe("Set to true to force a re-scan even if .skyramp/workspace.yml already exists. Default is false."),
38
+ relatedRepositories: z
39
+ .array(z.object({
40
+ repositoryPath: z
41
+ .string()
42
+ .describe("Path to the related repository's checkout"),
43
+ repository: z
44
+ .string()
45
+ .optional()
46
+ .describe("The related repository's owner/repo. Defaults to the one its checkout's origin remote names."),
47
+ }))
48
+ .optional()
49
+ .describe("The other repositories of a multi-repository Target. Each is scanned into the one workspace.yml at workspacePath, its services carrying its owner/repo as `repository`; none gets a workspace.yml of its own. Omit for a single repository."),
50
+ testRepositoryPath: z
51
+ .string()
52
+ .optional()
53
+ .describe("Path to the separate test repository generated tests are delivered into, when there is one. Each service's testDirectory is then read from, and relative to, that checkout."),
29
54
  });
55
+ const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
56
+ function isDirectory(p) {
57
+ try {
58
+ return fs.statSync(p).isDirectory();
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Each related repository with its owner/repo, or the first problem with them:
66
+ * a checkout that is not a directory, is the workspace itself, or names no
67
+ * owner/repo, and a repository listed twice. Paths compare with symlinks
68
+ * resolved, and owner/repo case-insensitively, as GitHub does.
69
+ */
70
+ async function resolveRelatedRepositories(workspacePath, entries) {
71
+ const repos = [];
72
+ const workspaceReal = await resolveRealPath(workspacePath);
73
+ for (const entry of entries) {
74
+ const at = entry.repositoryPath;
75
+ if (!isDirectory(at))
76
+ return { error: `relatedRepositories: ${at} is not a directory` };
77
+ if ((await resolveRealPath(at)) === workspaceReal)
78
+ return {
79
+ error: `relatedRepositories: ${at} is workspacePath itself; list only the other repositories`,
80
+ };
81
+ const repository = entry.repository?.trim() || (await tryRepositorySlug(at));
82
+ if (!repository || !OWNER_REPO.test(repository))
83
+ return {
84
+ error: `relatedRepositories: no owner/repo for ${at}${repository ? ` ("${repository}" is not owner/repo)` : " (its checkout has no origin remote)"}; pass its repository`,
85
+ };
86
+ if (repos.some((r) => r.repository.toLowerCase() === repository.toLowerCase()))
87
+ return { error: `relatedRepositories: ${repository} is listed twice` };
88
+ repos.push({ repository, repositoryPath: at });
89
+ }
90
+ return { repos };
91
+ }
30
92
  export function registerInitScanWorkspaceTool(server) {
31
93
  server.registerTool(TOOL_NAME, {
32
94
  description: "Call this FIRST, before any other Skyramp tool (test generation, execution, analysis), whenever workspacePath is a git repository and .skyramp/workspace.yml does not exist at its root. " +
33
95
  "PREREQUISITE for skyramp_init_workspace. " +
34
96
  "Scans a repository and returns instructions for discovering all services, along with a scanToken. " +
97
+ "For a Target spanning several repositories, call it ONCE with the others in relatedRepositories, so all their services go into one workspace.yml. " +
35
98
  "Follow the instructions to build the services array, then pass both to skyramp_init_workspace to create the workspace config.",
36
99
  inputSchema: initScanWorkspaceSchema,
37
100
  }, async (params) => {
@@ -39,6 +102,8 @@ export function registerInitScanWorkspaceTool(server) {
39
102
  const recordParams = {
40
103
  workspacePath: params.workspacePath,
41
104
  force: String(params.force || false),
105
+ relatedRepositories: String(params.relatedRepositories?.length ?? 0),
106
+ testRepository: String(Boolean(params.testRepositoryPath)),
42
107
  };
43
108
  try {
44
109
  const manager = new WorkspaceConfigManager(params.workspacePath);
@@ -53,12 +118,38 @@ export function registerInitScanWorkspaceTool(server) {
53
118
  isError: false,
54
119
  };
55
120
  }
56
- const token = issueScanToken();
121
+ const related = await resolveRelatedRepositories(params.workspacePath, params.relatedRepositories ?? []);
122
+ if ("error" in related) {
123
+ errorResult = {
124
+ content: [{ type: "text", text: related.error }],
125
+ isError: true,
126
+ };
127
+ return errorResult;
128
+ }
129
+ if (params.testRepositoryPath &&
130
+ !isDirectory(params.testRepositoryPath)) {
131
+ errorResult = {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: `testRepositoryPath: ${params.testRepositoryPath} is not a directory`,
136
+ },
137
+ ],
138
+ isError: true,
139
+ };
140
+ return errorResult;
141
+ }
142
+ const token = issueScanToken(related.repos);
143
+ const instructions = buildInitWorkspaceInstructions({
144
+ workspacePath: params.workspacePath,
145
+ relatedRepositories: related.repos,
146
+ testRepositoryPath: params.testRepositoryPath,
147
+ });
57
148
  return {
58
149
  content: [
59
150
  {
60
151
  type: "text",
61
- text: `SCAN TOKEN: ${token}\n\nSave this token — you MUST pass it as scanToken when calling skyramp_init_workspace.\n\n${INIT_WORKSPACE_INSTRUCTIONS}`,
152
+ text: `SCAN TOKEN: ${token}\n\nSave this token — you MUST pass it as scanToken when calling skyramp_init_workspace.\n\n${instructions}`,
62
153
  },
63
154
  ],
64
155
  isError: false,
@@ -7,7 +7,8 @@ import yaml from "js-yaml";
7
7
  import { logger } from "../../utils/logger.js";
8
8
  import { AnalyticsService } from "../../services/AnalyticsService.js";
9
9
  import { SKYRAMP_IMAGE_VERSION } from "../../utils/versions.js";
10
- import { validateAndConsumeScanToken } from "./initScanWorkspaceTool.js";
10
+ import { scanTokenGrant, validateAndConsumeScanToken, } from "./initScanWorkspaceTool.js";
11
+ import { verifyWorkspaceServices } from "../../workspace/workspaceVerifier.js";
11
12
  import { upsertServicesByRepo } from "./serviceUpsert.js";
12
13
  import { isInsideDir } from "../../utils/gitStaging.js";
13
14
  import { EXECUTOR_VIDEOS_DIR, EXECUTOR_WORK_DIR, isInsideExecutorWorkDir, } from "../../utils/executorWorkDir.js";
@@ -110,7 +111,7 @@ export function registerInitializeWorkspaceTool(server) {
110
111
  "1. Fresh init: PREREQUISITE: Call skyramp_init_scan first to get a scanToken and discover services. " +
111
112
  "Then call this tool to write .skyramp/workspace.yml with the discovered services and scanToken. " +
112
113
  "2. Edit existing workspace.yml: omit scanToken, set force: true, and pass the FULL updated services array (overwrite, not merge — fields not in the array are dropped). " +
113
- "The services schema is strict: any field not in the schema (e.g. inventing `serverStopCommand` on `runtimeDetails`) fails validation and the call is rejected.",
114
+ "The services schema is strict: any field not in the schema (e.g. inventing `healthUrl` on `runtimeDetails`) fails validation and the call is rejected.",
114
115
  inputSchema: initializeWorkspaceSchema,
115
116
  _meta: {
116
117
  keywords: [
@@ -182,11 +183,46 @@ export function registerInitializeWorkspaceTool(server) {
182
183
  if (badMerged)
183
184
  return badTestDirectoryResult(badMerged);
184
185
  }
186
+ // The verifier, before the token is spent: a refusal asks for a
187
+ // corrected retry, which must be able to reuse the same token. Only the
188
+ // incoming services: a merge must not be blocked by an older entry.
189
+ const grant = params.scanToken
190
+ ? scanTokenGrant(params.scanToken)
191
+ : undefined;
192
+ const issues = verifyWorkspaceServices(params.services, {
193
+ workspacePath,
194
+ relatedRepositories: grant?.relatedRepositories,
195
+ });
196
+ if (issues.length > 0) {
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: `${manager.getConfigPath()} was not written: ${issues.length} problem(s) with the services.\n${issues.map((i) => `- ${i}`).join("\n")}\nFix each one and call skyramp_init_workspace again with the full corrected services array${grant ? " and the same scanToken, which is still valid" : ""}.`,
202
+ },
203
+ ],
204
+ isError: true,
205
+ };
206
+ }
185
207
  // scanToken is required for fresh init; for edits (force:true on an
186
208
  // existing workspace.yml) the caller supplies the full services array
187
- // directly and we skip the token check.
209
+ // directly and no token is needed. A token passed with an edit is still
210
+ // validated and spent, so a successful write never leaves it reusable.
188
211
  const isEdit = alreadyExists && params.force;
189
- if (!isEdit) {
212
+ if (isEdit && params.scanToken) {
213
+ if (!validateAndConsumeScanToken(params.scanToken)) {
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: "Invalid or expired scanToken. An edit of an existing workspace.yml needs none: call again with force: true and no scanToken.",
219
+ },
220
+ ],
221
+ isError: true,
222
+ };
223
+ }
224
+ }
225
+ else if (!isEdit) {
190
226
  if (!params.scanToken) {
191
227
  return {
192
228
  content: [
@@ -273,9 +273,17 @@ export declare class WorkspaceConfigManager {
273
273
  /**
274
274
  * Initialize workspace — creates .skyramp/workspace.yml with repo info.
275
275
  * Auto-detects repoName and repoUrl from git when not provided.
276
- * Produces an empty services array and default metadata timestamps.
276
+ * Produces an empty services array and default metadata timestamps. Over an
277
+ * existing file it keeps that file's createdAt: the workspace was created
278
+ * then, however often it is rewritten.
277
279
  */
278
280
  initialize(workspaceInfo?: WorkspaceSection): Promise<WorkspaceConfig>;
281
+ /**
282
+ * The existing file's metadata.createdAt, read leniently: a rewrite is often
283
+ * the repair of a file that no longer validates, and its createdAt still
284
+ * stands. Undefined when there is no file or it records none.
285
+ */
286
+ private existingCreatedAt;
279
287
  /**
280
288
  * Update the metadata section of an existing workspace config.
281
289
  * Only the provided fields are merged; metadata.updatedAt is refreshed
@@ -107,8 +107,7 @@ export const serviceSchema = z
107
107
  k8sContext: z.string().optional(),
108
108
  // External-suite test contract (SKYR-4002). A service hosts one or more
109
109
  // runnable suites — record each under testSuites, even a single suite
110
- // (there is no flat single-suite form). Written by the SUT adapt_workflow
111
- // bootstrap (skyramp_testbot_sut). Each suite carries its own framework
110
+ // (there is no flat single-suite form). Each suite carries its own framework
112
111
  // (falling back to the service's), testRunCommand, and pathGlobs
113
112
  // — the repo-relative paths that suite owns, which the agent reads to find
114
113
  // the suite of a test file. One service can host multiple frameworks.
@@ -346,13 +345,18 @@ export class WorkspaceConfigManager {
346
345
  /**
347
346
  * Initialize workspace — creates .skyramp/workspace.yml with repo info.
348
347
  * Auto-detects repoName and repoUrl from git when not provided.
349
- * Produces an empty services array and default metadata timestamps.
348
+ * Produces an empty services array and default metadata timestamps. Over an
349
+ * existing file it keeps that file's createdAt: the workspace was created
350
+ * then, however often it is rewritten.
350
351
  */
351
352
  async initialize(workspaceInfo = {}) {
352
353
  await fs.mkdir(this.skyrampDir, { recursive: true });
353
354
  // Auto-detect repo info from workspace root when not explicitly provided
354
355
  const detected = await detectRepoInfo(this.workspacePath);
355
356
  const config = createDefaultConfig();
357
+ const createdAt = await this.existingCreatedAt();
358
+ if (createdAt && config.metadata)
359
+ config.metadata.createdAt = createdAt;
356
360
  config.workspace = {
357
361
  repoName: workspaceInfo.repoName || detected.repoName || undefined,
358
362
  repoUrl: workspaceInfo.repoUrl || detected.repoUrl || undefined,
@@ -361,6 +365,23 @@ export class WorkspaceConfigManager {
361
365
  await writeWorkspaceFile(this.configPath, validated);
362
366
  return validated;
363
367
  }
368
+ /**
369
+ * The existing file's metadata.createdAt, read leniently: a rewrite is often
370
+ * the repair of a file that no longer validates, and its createdAt still
371
+ * stands. Undefined when there is no file or it records none.
372
+ */
373
+ async existingCreatedAt() {
374
+ try {
375
+ const doc = yaml.load(await fs.readFile(this.configPath, "utf8"));
376
+ const createdAt = doc?.metadata?.createdAt;
377
+ return typeof createdAt === "string" && createdAt.trim()
378
+ ? createdAt
379
+ : undefined;
380
+ }
381
+ catch {
382
+ return undefined;
383
+ }
384
+ }
364
385
  /**
365
386
  * Update the metadata section of an existing workspace config.
366
387
  * Only the provided fields are merged; metadata.updatedAt is refreshed
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Verifies the services written to .skyramp/workspace.yml against the rules
3
+ * the scan instructions state and that a file can show deterministically:
4
+ * service identity, runtime and command consistency, API addressing and auth,
5
+ * the compose files and services a start command names, and each recorded
6
+ * test suite's routing. skyramp_init_workspace refuses a write that breaks one,
7
+ * and the SUT eval runs it on the file a run produced.
8
+ *
9
+ * It reports only what is certainly wrong. A rule it cannot decide from the
10
+ * files (an interpolated compose path, a command that changes directory, a
11
+ * compose file it cannot parse) is skipped, never guessed.
12
+ */
13
+ import type { Service } from "./workspace.js";
14
+ /** A repository of the Target other than the workspace's own. */
15
+ export interface VerifierRepository {
16
+ repository: string;
17
+ repositoryPath: string;
18
+ }
19
+ export interface WorkspaceVerifierOptions {
20
+ /** The workspace's own repository root, where a service with no `repository` lives. */
21
+ workspacePath: string;
22
+ /** The other repositories the scan named. When given, every service's
23
+ * `repository` is one of them, and its compose commands are checked in its
24
+ * checkout. */
25
+ relatedRepositories?: VerifierRepository[];
26
+ }
27
+ /** Every problem with `services`, one line each, naming the service and the fix. */
28
+ export declare function verifyWorkspaceServices(services: readonly Service[], opts: WorkspaceVerifierOptions): string[];