@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.
- package/build/index.js +1 -4
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.d.ts +17 -0
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +50 -7
- package/build/prompts/test-maintenance/driftAnalysisShared.js +1 -1
- package/build/tools/workspace/initScanWorkspaceTool.d.ts +11 -1
- package/build/tools/workspace/initScanWorkspaceTool.js +100 -9
- package/build/tools/workspace/initializeWorkspaceTool.js +40 -4
- package/build/workspace/workspace.d.ts +9 -1
- package/build/workspace/workspace.js +24 -3
- package/build/workspace/workspaceVerifier.d.ts +28 -0
- package/build/workspace/workspaceVerifier.js +307 -0
- package/package.json +1 -1
- package/plugin/prompts/plan-tests.md +2 -2
- package/plugin/skills/setup-testbot-target-sut/SKILL.md +54 -0
- package/plugin/skills/setup-testbot-target-sut/reference/ci-workflow.md +26 -0
- package/plugin/skills/setup-testbot-target-sut/reference/existing-tests.md +14 -0
- package/plugin/skills/setup-testbot-target-sut/reference/troubleshooting.md +20 -0
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +0 -2
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +0 -178
- package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +0 -2
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +0 -311
- package/build/prompts/sut-setup/shared.d.ts +0 -62
- package/build/prompts/sut-setup/shared.js +0 -196
- package/build/resources/sutSetupResource.d.ts +0 -2
- 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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
18
|
-
if (!
|
|
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
|
|
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${
|
|
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 `
|
|
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
|
|
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 (
|
|
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).
|
|
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[];
|