From ea8fbb47bcedee32a084e5f1dd0644a7d2b6f255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 16:20:32 +0200 Subject: [PATCH 01/60] refactor: Share file input parsing between source write tools Move the files input rules, the manifest builder, and the missing Dockerfile warning from create-actor to source_helpers.ts, so create-actor-version can use the same rules. create-actor behaves as before. --- src/tools/source/create_actor.ts | 70 +++----------------------- src/tools/source/source_helpers.ts | 80 +++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 65 deletions(-) diff --git a/src/tools/source/create_actor.ts b/src/tools/source/create_actor.ts index bf1d09261..2c5f4550c 100644 --- a/src/tools/source/create_actor.ts +++ b/src/tools/source/create_actor.ts @@ -13,27 +13,20 @@ import { getConsoleLinkContext } from '../../utils/console_link.js'; import { respondAborted } from '../../utils/mcp.js'; import { buildNextStepForBuild, respondWithBuild, toBuildResult } from '../builds/build_helpers.js'; import { createActorToolOutputSchema } from '../structured_output_schemas.js'; -import type { SourceFile } from './source_files.js'; -import { - buildFilesRevision, - buildInlineSourceFile, - buildUrlRevision, - compareSourcePaths, - MAX_SOURCE_PATH_LENGTH, - parseStoredPath, -} from './source_files.js'; +import { buildFilesRevision, buildUrlRevision, parseStoredPath } from './source_files.js'; import type { BuildAfterWriteResult } from './source_helpers.js'; import { ACTOR_CONFIG_PATH, - buildSourceFileEntry, + buildFilesManifest, formatBuildLaterHint, formatBuildStartFailure, formatEmptyFilesWarning, + formatMissingDockerfileWarning, formatUrlWithoutSecrets, getSourceFileEntryBytes, isFolderEntry, MAX_WRITE_FILES, - parseInputPath, + parseInputFiles, respondToSourceToolError, sourceFileArgs, startBuildAfterWrite, @@ -54,9 +47,6 @@ const DEFAULT_BUILD_TAG = 'latest'; */ const ACTOR_NAME_TAKEN_ERROR_TYPES: ReadonlySet = new Set(['actor-name-not-unique', 'name-not-unique']); -/** Where the build looks for a Dockerfile when `.actor/actor.json` names none; it matches them regardless of case. */ -const DOCKERFILE_PATHS = ['dockerfile', '.actor/dockerfile']; - const createActorArgs = z.object({ name: z .string() @@ -119,49 +109,13 @@ function parseCreateRequest(args: CreateActorArgs, loadedToolNames: readonly str recoveryText: `Create the Actor with fewer files, then add the rest in later calls${addLaterText}.`, }, ); - const seenPaths = new Set(); - const entries = files.map((file, index) => { - const label = `files[${index}]`; - const path = parseInputPath(file.path, `${label} path`); - if (path.length > MAX_SOURCE_PATH_LENGTH) { - throw new UserInputError(`${label} path is over ${MAX_SOURCE_PATH_LENGTH} characters.`); - } - if (seenPaths.has(path)) throw new UserInputError(`files has ${path} more than once.`); - seenPaths.add(path); - return buildSourceFileEntry({ - path, - content: file.content, - encoding: file.encoding, - label: `${label} content for`, - }); - }); - if (!seenPaths.has(ACTOR_CONFIG_PATH)) { - throw new UserInputError( - `files must include ${ACTOR_CONFIG_PATH}: this tool requires it, since the build reads the Actor's ` + - 'configuration from it.', - ); - } - return { kind: 'files', entries }; -} - -/** Whether `.actor/actor.json` names a Dockerfile; a file that is not valid JSON names none. */ -function hasDockerfileField(entries: readonly ActorVersionSourceFile[]): boolean { - const config = entries.find(({ name }) => name === ACTOR_CONFIG_PATH); - if (!config) return false; - try { - const parsed = JSON.parse(buildInlineSourceFile(config).readContent()) as { dockerfile?: unknown }; - return typeof parsed?.dockerfile === 'string' && parsed.dockerfile !== ''; - } catch { - return false; - } + return { kind: 'files', entries: parseInputFiles(files, 'files') }; } function buildFilesWarnings( sentEntries: readonly ActorVersionSourceFile[], storedEntries: readonly ActorVersionSourceFile[] | undefined, ): string[] { - const paths = new Set(sentEntries.map(({ name }) => name.toLowerCase())); - const hasDockerfile = DOCKERFILE_PATHS.some((path) => paths.has(path)) || hasDockerfileField(sentEntries); const emptyPaths = sentEntries.filter(({ content }) => content === '').map(({ name }) => name); const sentConfig = sentEntries.find(({ name }) => name === ACTOR_CONFIG_PATH); const storedConfig = storedEntries?.findLast(({ name }) => parseStoredPath(name) === ACTOR_CONFIG_PATH); @@ -170,9 +124,7 @@ function buildFilesWarnings( storedConfig !== undefined && !getSourceFileEntryBytes(sentConfig).equals(getSourceFileEntryBytes(storedConfig)); return [ - !hasDockerfile && - `No Dockerfile found: there is no Dockerfile or .actor/Dockerfile, and ${ACTOR_CONFIG_PATH} names none, so ` + - "the build uses the platform's default Node.js Dockerfile; an Actor in another language needs its own.", + formatMissingDockerfileWarning(sentEntries), isConfigRewritten && `The platform set the name field of ${ACTOR_CONFIG_PATH} to the Actor name, so the stored file differs ` + 'from the one sent; its hash in files, and the revision, are those of the stored file.', @@ -192,16 +144,6 @@ function extractStoredEntries(created: Actor, versionNumber: string): ActorVersi return Array.isArray(sourceFiles) ? sourceFiles.filter((entry) => !isFolderEntry(entry)) : undefined; } -/** One file per path, the last stored entry winning as in get-actor-version, sorted by path. */ -function buildFilesManifest(entries: readonly ActorVersionSourceFile[]): SourceFile[] { - const filesByPath = new Map(); - for (const entry of entries) { - const file = buildInlineSourceFile(entry); - filesByPath.set(file.path, file); - } - return [...filesByPath.values()].sort((a, b) => compareSourcePaths(a.path, b.path)); -} - function buildVersion(args: CreateActorArgs, source: PreparedSource): ActorVersion { const common = { versionNumber: args.versionNumber, buildTag: args.buildTag }; if (source.kind === 'git') return { ...common, sourceType: ActorSourceType.GitRepo, gitRepoUrl: source.gitRepoUrl }; diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index c07f9e1a0..503456ced 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -11,7 +11,16 @@ import { getUserInfoCached } from '../../utils/userid_cache.js'; import { ABORT } from '../actors/actor_run_response.js'; import { listVersionNumbers, startBuild } from '../builds/build_helpers.js'; import { ABSOLUTE_NAME_REGEX } from './source_archive.js'; -import { BYTES_PER_MIB, formatMib, hasBinaryExtension, parseSourcePath } from './source_files.js'; +import type { SourceFile } from './source_files.js'; +import { + buildInlineSourceFile, + BYTES_PER_MIB, + compareSourcePaths, + formatMib, + hasBinaryExtension, + MAX_SOURCE_PATH_LENGTH, + parseSourcePath, +} from './source_files.js'; /** * The platform refuses a version whose inline files measure more than this (`MAX_MULTIFILE_BYTES` in `@apify/consts`); @@ -48,6 +57,11 @@ export const sourceFileArgs = z.object({ ), }); +type SourceFileArgs = z.infer; + +/** Where the build looks for a Dockerfile when `.actor/actor.json` names none; it matches them regardless of case. */ +const DOCKERFILE_PATHS = ['dockerfile', '.actor/dockerfile']; + /** A refusal about the caller's account or token, recorded as AUTH rather than as invalid input. */ export class AccountAuthError extends UserInputError {} @@ -216,6 +230,70 @@ export function buildSourceFileEntry(params: { return { name: path, format: 'BASE64', content }; } +/** + * The entries to store for a whole file set sent by the caller, in the order sent. Throws `UserInputError` for a path + * that is not valid, is over 255 characters, or is given twice, and when `.actor/actor.json` is missing. `field` names + * the input field in the messages. + */ +export function parseInputFiles(files: readonly SourceFileArgs[], field: string): ActorVersionSourceFile[] { + const seenPaths = new Set(); + const entries = files.map((file, index) => { + const label = `${field}[${index}]`; + const path = parseInputPath(file.path, `${label} path`); + if (path.length > MAX_SOURCE_PATH_LENGTH) { + throw new UserInputError(`${label} path is over ${MAX_SOURCE_PATH_LENGTH} characters.`); + } + if (seenPaths.has(path)) throw new UserInputError(`${field} has ${path} more than once.`); + seenPaths.add(path); + return buildSourceFileEntry({ + path, + content: file.content, + encoding: file.encoding, + label: `${label} content for`, + }); + }); + if (!seenPaths.has(ACTOR_CONFIG_PATH)) { + throw new UserInputError( + `${field} must include ${ACTOR_CONFIG_PATH}: this tool requires it, since the build reads the Actor's ` + + 'configuration from it.', + ); + } + return entries; +} + +/** One file per path, the last stored entry winning as in get-actor-version, sorted by path; folder entries are left out. */ +export function buildFilesManifest(entries: readonly ActorVersionSourceFile[]): SourceFile[] { + const filesByPath = new Map(); + for (const entry of entries) { + if (isFolderEntry(entry)) continue; + const file = buildInlineSourceFile(entry); + filesByPath.set(file.path, file); + } + return [...filesByPath.values()].sort((a, b) => compareSourcePaths(a.path, b.path)); +} + +/** Whether `.actor/actor.json` names a Dockerfile; a file that is not valid JSON names none. */ +function hasDockerfileField(entries: readonly ActorVersionSourceFile[]): boolean { + const config = entries.find(({ name }) => name === ACTOR_CONFIG_PATH); + if (!config) return false; + try { + const parsed = JSON.parse(buildInlineSourceFile(config).readContent()) as { dockerfile?: unknown }; + return typeof parsed?.dockerfile === 'string' && parsed.dockerfile !== ''; + } catch { + return false; + } +} + +/** The warning for files sent without a Dockerfile, which the build then replaces with the platform's default one. */ +export function formatMissingDockerfileWarning(entries: readonly ActorVersionSourceFile[]): string | undefined { + const paths = new Set(entries.map(({ name }) => name.toLowerCase())); + if (DOCKERFILE_PATHS.some((path) => paths.has(path)) || hasDockerfileField(entries)) return undefined; + return ( + `No Dockerfile found: there is no Dockerfile or .actor/Dockerfile, and ${ACTOR_CONFIG_PATH} names none, so ` + + "the build uses the platform's default Node.js Dockerfile; an Actor in another language needs its own." + ); +} + /** The decoded bytes of a stored entry; a missing format or content reads as TEXT and empty, as the build worker reads it. */ export function getSourceFileEntryBytes(entry: ActorVersionSourceFile): Buffer { const { format, content = '' }: Partial = entry; From f35326c3663d897e1d38ffdeab58b20649386843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 16:28:43 +0200 Subject: [PATCH 02/60] feat: Add create-actor-version and delete-actor-version tools create-actor-version adds a version to one of your own Actors with one POST: a copy of another version, new files, a Git repository, or an empty version. A copy reads the other version and sends its source as stored, so file formats, folder entries, and credentials stored with a Git or gist URL are kept and no content passes through the model. Non-secret env vars are copied with their values; secret ones cannot be read, so their names are returned. Zip-stored versions cannot be copied yet. buildTag has no default, so a working copy never takes over latest. delete-actor-version reads the Actor first, refuses a version it does not have, and refuses its last version, also when the platform does. The version's builds and their tags stay. Both tools are in the source category. --- README.md | 4 +- src/const.ts | 2 + src/tools/AGENTS.md | 20 +- src/tools/registry.ts | 4 +- src/tools/source/create_actor_version.ts | 438 ++++++++++++ src/tools/source/delete_actor_version.ts | 104 +++ src/tools/structured_output_schemas.ts | 45 ++ tests/unit/tools.create_actor_version.test.ts | 658 ++++++++++++++++++ tests/unit/tools.delete_actor_version.test.ts | 212 ++++++ tests/unit/tools.get_actor_version.test.ts | 2 + tests/unit/tools.mode_contract.test.ts | 2 + 11 files changed, 1481 insertions(+), 10 deletions(-) create mode 100644 src/tools/source/create_actor_version.ts create mode 100644 src/tools/source/delete_actor_version.ts create mode 100644 tests/unit/tools.create_actor_version.test.ts create mode 100644 tests/unit/tools.delete_actor_version.test.ts diff --git a/README.md b/README.md index b007990ba..59769ade3 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them: - **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages. - **Schedules**: Create, inspect, update, and delete schedules that run your Actors and tasks automatically. - **Builds**: Build an Actor version, check the status of a build, and retrieve its build log. -- **Actor source**: Read an Actor version's files, with a hash for each file and a revision for the whole set, create an Actor from files or a Git repository, and change a version's files with checks that catch changes made since your last read. +- **Actor source**: Read an Actor version's files, with a hash for each file and a revision for the whole set, create an Actor from files or a Git repository, change a version's files with checks that catch changes made since your last read, and add or delete versions, including copies of a version made on the server. ### Overview of available tools @@ -297,6 +297,8 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's metadata, its file listing with hashes, and the content of the files you ask for. | | | `create-actor` | source | Create a private Actor in your account from files or a Git repository, optionally starting a build. | | | `update-actor-version` | source | Write, edit, delete, or move files of your Actor's version in one all-or-nothing write, or point it at a Git repository. | | +| `create-actor-version` | source | Add a version to your Actor: a copy of another version, new files, a Git repository, or an empty version. | | +| `delete-actor-version` | source | Delete a version of your Actor; its builds and their tags stay. | | > **Note:** > diff --git a/src/const.ts b/src/const.ts index 9769d7bf5..bf8e7d801 100644 --- a/src/const.ts +++ b/src/const.ts @@ -58,6 +58,8 @@ export const HELPER_TOOLS = { ACTOR_TASK_UPDATE: 'update-actor-task', ACTOR_TASK_PUBLISH: 'publish-actor-task', ACTOR_TASK_UNPUBLISH: 'unpublish-actor-task', + ACTOR_VERSION_CREATE: 'create-actor-version', + ACTOR_VERSION_DELETE: 'delete-actor-version', ACTOR_VERSION_GET: 'get-actor-version', ACTOR_VERSION_UPDATE: 'update-actor-version', DATASET_GET: 'get-dataset', diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 10cf86ae6..3487b5d48 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -33,15 +33,19 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - `source/`: `get-actor-version` (an Actor version's metadata, file manifest with hashes, revision, - and requested content), `create-actor` (a new Actor with one version in a single POST), and + and requested content), `create-actor` (a new Actor with one version in a single POST), `update-actor-version` (file operations applied all-or-nothing to a fresh read, stored with one - version PUT that carries only the source keys and buildTag). `source_files.ts` holds the file shape - shared by inline and zip storage, the hash and revision rules, and the text-or-base64 detection; - `source_archive.ts` reads zip-stored sources from the central directory with size, name, and CRC-32 - checks; `source_helpers.ts` holds the own-account check (`resolveOwnActor`), path and base64 input - rules, the platform's 3 MiB size measure, and the build start after a write; `source_edits.ts` - applies text edits and builds the excerpts of changed lines. The write tools store files inline - only: they refuse zip-stored versions and results over 3 MiB. + version PUT that carries only the source keys and buildTag), `create-actor-version` (a new version + in one POST: a copy of another version made on the server, files, Git, or empty, with no default + buildTag), and `delete-actor-version` (reads first, refuses the last version). `source_files.ts` + holds the file shape shared by inline and zip storage, the hash and revision rules, and the + text-or-base64 detection; `source_archive.ts` reads zip-stored sources from the central directory + with size, name, and CRC-32 checks; `source_helpers.ts` holds the own-account check + (`resolveOwnActor`), path and base64 input rules, the files input rules and manifest shared by the + create tools, the platform's 3 MiB size measure, and the build start after a write; + `source_edits.ts` applies text edits and builds the excerpts of changed lines. The write tools store + files inline only: they refuse zip-stored versions and results over 3 MiB, and + `create-actor-version` refuses to copy a zip-stored version. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8a80777d0..eba728f8c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -35,6 +35,8 @@ import { deleteSchedule } from './schedules/delete_schedule.js'; import { getSchedule } from './schedules/get_schedule.js'; import { updateSchedule } from './schedules/update_schedule.js'; import { createActor } from './source/create_actor.js'; +import { createActorVersion } from './source/create_actor_version.js'; +import { deleteActorVersion } from './source/delete_actor_version.js'; import { getActorVersion } from './source/get_actor_version.js'; import { updateActorVersion } from './source/update_actor_version.js'; import { getDataset } from './storage/get_dataset.js'; @@ -73,7 +75,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, buildActor], - source: [getActorVersion, createActor, updateActorVersion], + source: [getActorVersion, createActor, updateActorVersion, createActorVersion, deleteActorVersion], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts new file mode 100644 index 000000000..d64a9d3db --- /dev/null +++ b/src/tools/source/create_actor_version.ts @@ -0,0 +1,438 @@ +import type { ActorEnvironmentVariable, ActorVersion, ActorVersionSourceFile } from 'apify-client'; +import { ActorSourceType, ApifyApiError } from 'apify-client'; +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS } from '../../const.js'; +import { UserInputError } from '../../errors.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; +import { getConsoleLinkContext } from '../../utils/console_link.js'; +import { respondAborted } from '../../utils/mcp.js'; +import { buildNextStepForBuild, listVersionNumbers, respondWithBuild, toBuildResult } from '../builds/build_helpers.js'; +import { createActorVersionToolOutputSchema } from '../structured_output_schemas.js'; +import { buildFilesRevision, buildUrlRevision } from './source_files.js'; +import type { BuildAfterWriteResult } from './source_helpers.js'; +import { + ACTOR_CONFIG_PATH, + buildFilesManifest, + formatBuildLaterHint, + formatBuildStartFailure, + formatEmptyFilesWarning, + formatMissingDockerfileWarning, + formatUrlWithoutSecrets, + hasUrlSecrets, + MAX_WRITE_FILES, + parseInputFiles, + resolveOwnActor, + resolveVersionNumber, + respondToSourceToolError, + sourceFileArgs, + startBuildAfterWrite, + validateCallContentSize, +} from './source_helpers.js'; + +/** The error type the platform returns when the Actor already has the version, also when two creates race. */ +const VERSION_EXISTS_ERROR_TYPE = 'version-already-exists'; + +const createActorVersionArgs = z.object({ + actor: z + .string() + .min(1) + .describe( + 'The Actor to add the version to: its ID, or its full name as username/name or username~name. ' + + 'A name without the username is not enough. It must be in your own account.', + ), + versionNumber: z + .string() + .min(1) + .describe( + 'Number of the new version in MAJOR.MINOR form, for example 0.2, each part 0 to 99. The Actor must not have it yet.', + ), + copyFromVersion: z + .string() + .min(1) + .optional() + .describe( + 'Copy the source and the non-secret environment variables of this existing version, for example 0.1. ' + + 'The source is copied on the server and does not pass through this call.', + ), + files: z + .array(sourceFileArgs) + .min(1) + .max(MAX_WRITE_FILES) + .optional() + .describe(`The version's files, 1 to ${MAX_WRITE_FILES}; they must include ${ACTOR_CONFIG_PATH}.`), + gitRepoUrl: z + .string() + .min(1) + .optional() + .describe( + 'Build the version from this Git repository, as repository#branch:directory (branch and directory are optional).', + ), + buildTag: z + .string() + .min(1) + .optional() + .describe( + 'Tag that builds of the new version get, for example beta. No default: without it, its builds get no tag.', + ), + autoBuild: z + .boolean() + .default(false) + .describe('Start a build of the new version after creating it, and return without waiting. Default: false.'), +}); + +type CreateActorVersionArgs = z.infer; + +type RequestedSource = + | { kind: 'empty' } + | { kind: 'files'; entries: ActorVersionSourceFile[] } + | { kind: 'git'; gitRepoUrl: string } + | { kind: 'copy'; versionNumber: string }; + +/** The version to create, as the POST body and as the result describes it. */ +type NewVersion = { + /** The source keys of the POST body: sourceType and the one field that holds the source. */ + sourceBody: Record; + sourceType: string; + /** The stored entries, for the manifest; undefined for a version built from a URL. */ + entries?: readonly ActorVersionSourceFile[]; + /** The URL without its credentials, for the revision and the summary; set for a version built from a URL. */ + shownUrl?: string; + envVars: ActorEnvironmentVariable[]; + applyEnvVarsToBuild: boolean; + secretEnvVarNames: string[]; + warnings: string[]; +}; + +/** Checks the input without any API call; throws `UserInputError` for the first problem. */ +function parseCreateVersionRequest(args: CreateActorVersionArgs, loadedToolNames: readonly string[]): RequestedSource { + const { copyFromVersion, files, gitRepoUrl } = args; + const sourceCount = [copyFromVersion, files, gitRepoUrl].filter((source) => source !== undefined).length; + if (sourceCount > 1) throw new UserInputError('Give at most one of copyFromVersion, files, or gitRepoUrl.'); + if (copyFromVersion !== undefined) return { kind: 'copy', versionNumber: copyFromVersion }; + if (gitRepoUrl !== undefined) return { kind: 'git', gitRepoUrl }; + if (files === undefined) { + if (args.autoBuild) { + throw new UserInputError( + 'An empty version has nothing to build. Give copyFromVersion, files, or gitRepoUrl, or leave out autoBuild.', + ); + } + return { kind: 'empty' }; + } + // Files within this cap always fit the platform's 3 MiB measure, which counts at most 1.25 bytes per UTF-8 byte. + const addLaterText = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` + : ''; + validateCallContentSize( + files.map(({ content }) => content), + { + fieldsText: 'content', + recoveryText: `Create the version with fewer files, then add the rest in later calls${addLaterText}.`, + }, + ); + return { kind: 'files', entries: parseInputFiles(files, 'files') }; +} + +function formatVersionExistsText(params: { + fullName: string; + versionNumber: string; + versionNumbers: readonly string[]; + loadedToolNames: readonly string[]; +}): string { + const { fullName, versionNumber, versionNumbers, loadedToolNames } = params; + const hint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` To change it, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` + : ' Pick another versionNumber, or change the existing version instead.'; + const versionsText = versionNumbers.length > 0 ? ` Its versions: ${versionNumbers.join(', ')}.` : ''; + return ( + `${fullName} already has version ${versionNumber}, and this tool never changes an existing version.` + + `${versionsText}${hint}` + ); +} + +/** + * Non-secret env vars go to the copy with their values. The API returns a secret with its value removed, so it cannot + * be copied; its name is returned for the caller to set it again. + */ +function splitEnvVars(envVars: readonly ActorEnvironmentVariable[] | undefined): { + envVars: ActorEnvironmentVariable[]; + secretEnvVarNames: string[]; +} { + const copied: ActorEnvironmentVariable[] = []; + const secretEnvVarNames: string[] = []; + for (const { name, value, isSecret } of envVars ?? []) { + if (name === undefined) continue; + if (isSecret === true) secretEnvVarNames.push(name); + else copied.push({ name, value, isSecret: false }); + } + return { envVars: copied, secretEnvVarNames }; +} + +/** + * The copy of a version as read: its source exactly as stored, so formats, folder entries, and the credentials stored + * with a URL are kept. Throws `UserInputError` for a zip-stored version, which this tool cannot copy yet, and for a + * source the API hid or a type it does not know. + */ +function buildCopiedVersion( + version: ActorVersion, + { + fullName, + sourceVersionNumber, + versionNumber, + }: { fullName: string; sourceVersionNumber: string; versionNumber: string }, +): NewVersion { + const { sourceType }: { sourceType: string } = version; + const subject = `Version ${sourceVersionNumber} of ${fullName}`; + const hiddenText = `${subject} came back without its source, so it cannot be copied.`; + const env = { ...splitEnvVars(version.envVars), applyEnvVarsToBuild: version.applyEnvVarsToBuild === true }; + if (version.sourceType === ActorSourceType.SourceFiles) { + if (!Array.isArray(version.sourceFiles)) throw new UserInputError(hiddenText); + const entries = version.sourceFiles; + return { ...env, sourceType, sourceBody: { sourceType, sourceFiles: entries }, entries, warnings: [] }; + } + if (version.sourceType === ActorSourceType.GitRepo || version.sourceType === ActorSourceType.GitHubGist) { + const isGit = version.sourceType === ActorSourceType.GitRepo; + const url = isGit ? version.gitRepoUrl : version.gitHubGistUrl; + if (!url) throw new UserInputError(hiddenText); + const shownUrl = formatUrlWithoutSecrets(url); + const warnings = hasUrlSecrets(url) + ? [ + `The credentials stored with the URL of version ${sourceVersionNumber} were copied too; they are not shown.`, + ] + : []; + const sourceBody = isGit ? { sourceType, gitRepoUrl: url } : { sourceType, gitHubGistUrl: url }; + return { ...env, sourceType, sourceBody, shownUrl, warnings }; + } + if (version.sourceType === ActorSourceType.Tarball) { + throw new UserInputError( + `${subject} is stored as a zip (TARBALL), and this tool cannot copy zip-stored versions yet. Push the ` + + `source to the new version with the Apify CLI instead (apify push --version ${versionNumber}).`, + ); + } + throw new UserInputError(`${subject} has source type ${sourceType}, which this tool cannot copy.`); +} + +function buildNewVersion(source: Exclude): NewVersion { + const common = { envVars: [], applyEnvVarsToBuild: false, secretEnvVarNames: [] }; + if (source.kind === 'git') { + return { + ...common, + sourceType: ActorSourceType.GitRepo, + sourceBody: { sourceType: ActorSourceType.GitRepo, gitRepoUrl: source.gitRepoUrl }, + shownUrl: formatUrlWithoutSecrets(source.gitRepoUrl), + warnings: [], + }; + } + // The platform takes an empty list of files, which gives a version to fill later. + const entries = source.kind === 'files' ? source.entries : []; + const emptyPaths = entries.filter(({ content }) => content === '').map(({ name }) => name); + return { + ...common, + sourceType: ActorSourceType.SourceFiles, + sourceBody: { sourceType: ActorSourceType.SourceFiles, sourceFiles: entries }, + entries, + warnings: + source.kind === 'files' + ? [formatMissingDockerfileWarning(entries), formatEmptyFilesWarning(emptyPaths)].filter( + (warning): warning is string => warning !== undefined, + ) + : [], + }; +} + +function formatSourceText(source: RequestedSource, newVersion: NewVersion, fileCount: number): string { + const filesText = `${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; + const urlText = + newVersion.sourceType === ActorSourceType.GitHubGist + ? `the GitHub gist ${newVersion.shownUrl}` + : `the Git repository ${newVersion.shownUrl}`; + if (source.kind === 'copy') { + const copiedText = newVersion.shownUrl === undefined ? filesText : `building from ${urlText}`; + return `as a copy of version ${source.versionNumber} (${copiedText})`; + } + if (source.kind === 'files') return `from ${filesText}`; + if (source.kind === 'git') return `from ${urlText}`; + return 'with no files'; +} + +function formatEnvVarsNote(newVersion: NewVersion, versionNumber: string): string { + const { envVars, secretEnvVarNames } = newVersion; + const copiedNote = + envVars.length > 0 + ? ` Copied ${envVars.length} environment ${envVars.length === 1 ? 'variable with its value' : 'variables with their values'}.` + : ''; + const secretNote = + secretEnvVarNames.length > 0 + ? ` These secret environment variables were not copied, because their values cannot be read: ` + + `${secretEnvVarNames.join(', ')}. Set them on version ${versionNumber} yourself, for example in Apify Console.` + : ''; + return `${copiedNote}${secretNote}`; +} + +function formatNextStep(params: { + source: RequestedSource; + buildResult: BuildAfterWriteResult | undefined; + loadedToolNames: readonly string[]; +}): string { + const { source, buildResult, loadedToolNames } = params; + if (buildResult?.build) return buildNextStepForBuild(buildResult.build, { loadedToolNames }); + if (buildResult?.buildErrMessage !== undefined) { + return formatBuildStartFailure('The version was created', buildResult.buildErrMessage, loadedToolNames); + } + if (source.kind === 'empty') { + const addText = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` + : ''; + return `The version has no files yet: add them${addText}, then build it.`; + } + return `The version has no build yet, so it cannot run until it is built.${formatBuildLaterHint(loadedToolNames)}`; +} + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const updateNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` To change an existing version, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` + : ''; + const fillNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_UPDATE) ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` : ''; + const waitNote = hasTool(HELPER_TOOLS.ACTOR_BUILD_GET) + ? ` Follow the build with ${HELPER_TOOLS.ACTOR_BUILD_GET}.` + : ''; + return dedent` + Add a new version to one of your own Actors: a copy of another version, new files, a Git repository to build from, or an empty version. + It never changes an existing version: a versionNumber the Actor already has is refused.${updateNote} + - copyFromVersion: copies that version's source on the server, so no file content passes through the call: stored files exactly as they are, or the Git or gist URL with any credentials stored with it. Non-secret environment variables are copied with their values; secret values cannot be read, so their names come back in secretEnvVarsNotCopied. Versions stored as a zip cannot be copied yet. + - files: every file with its path and content; they must include ${ACTOR_CONFIG_PATH}. Binary files take base64 content with encoding base64; files with a binary extension such as .png default to it. + - gitRepoUrl: repository#branch:directory, with the branch and directory optional. + - None of them: an empty version with no files, to fill later${fillNote}. + Give at most one of them. Limits: ${MAX_WRITE_FILES} files and 2 MiB of content. The result lists each file's hash and the version's revision. + buildTag has no default: without it, the version's builds get no tag, so a working copy never takes over a tag such as latest; run its builds by build number. autoBuild starts a build after creating the version and returns without waiting.${waitNote} + + USAGE: + - Use to make a working copy of a version to change and test while the tagged build keeps running, or to add a version with new source. + + USAGE EXAMPLES: + - user_input: Copy version 0.1 of my Actor john/my-scraper to a new version 0.2 + - user_input: Add version 1.0 to my Actor, built from https://github.com/john/my-actor.git`; +} + +/** + * https://docs.apify.com/api/v2/actor-get + * /v2/actors/{actorId} + * https://docs.apify.com/api/v2/actor-version-get + * /v2/actors/{actorId}/versions/{versionNumber} + * https://docs.apify.com/api/v2/actor-versions-post + * /v2/actors/{actorId}/versions + * + * One POST creates the version with its source, so a failed call leaves nothing behind. A copy reads the other version + * in this call and sends its source as stored, so the content never passes through the model. + */ +export const createActorVersion: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.ACTOR_VERSION_CREATE, + title: 'Create Actor version', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + // `fixZodSchemaRequired` strips `autoBuild` from `required` because it has a default. + inputSchema: fixZodSchemaRequired(z.toJSONSchema(createActorVersionArgs)) as ToolInputSchema, + outputSchema: createActorVersionToolOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(createActorVersionArgs)), + annotations: { + title: 'Create Actor version', + readOnlyHint: false, + destructiveHint: false, + // A second call with the same versionNumber is refused rather than repeated. + idempotentHint: false, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyClient: client, apifyToken, loadedToolNames, signal } = toolArgs; + const parsed = createActorVersionArgs.parse(args); + try { + const source = parseCreateVersionRequest(parsed, loadedToolNames); + const { actor, fullName } = await resolveOwnActor({ client, apifyToken, actorSelector: parsed.actor }); + const { versionNumber } = parsed; + const versionNumbers = listVersionNumbers(actor); + const versionExistsText = formatVersionExistsText({ + fullName, + versionNumber, + versionNumbers, + loadedToolNames, + }); + if (versionNumbers.includes(versionNumber)) throw new UserInputError(versionExistsText); + let newVersion: NewVersion; + if (source.kind === 'copy') { + const sourceVersionNumber = resolveVersionNumber(actor, source.versionNumber, parsed.actor); + const sourceVersion = await client.actor(actor.id).version(sourceVersionNumber).get(); + if (!sourceVersion) { + throw new UserInputError(`Actor ${fullName} has no version ${sourceVersionNumber}.`); + } + newVersion = buildCopiedVersion(sourceVersion, { fullName, sourceVersionNumber, versionNumber }); + } else { + newVersion = buildNewVersion(source); + } + // A cancel before the POST creates nothing; per the MCP spec the cancelled request gets no response. + if (signal?.aborted) return respondAborted(); + try { + // apify-client types the body as one of its version shapes; the source keys are built per type above. + await client + .actor(actor.id) + .versions() + .create({ + versionNumber, + ...(parsed.buildTag !== undefined && { buildTag: parsed.buildTag }), + ...newVersion.sourceBody, + ...(newVersion.envVars.length > 0 && { envVars: newVersion.envVars }), + ...(newVersion.applyEnvVarsToBuild && { applyEnvVarsToBuild: true }), + } as unknown as ActorVersion); + } catch (error) { + if (error instanceof ApifyApiError && error.type === VERSION_EXISTS_ERROR_TYPE) { + throw new UserInputError(versionExistsText); + } + throw error; + } + // Per the MCP spec a cancelled request gets no response; the version stands, and no build is started. + if (signal?.aborted) return respondAborted(); + const buildResult = parsed.autoBuild + ? await startBuildAfterWrite(client, actor.id, versionNumber) + : undefined; + const linkContext = buildResult?.build ? await getConsoleLinkContext(apifyToken, client) : undefined; + const files = newVersion.entries === undefined ? [] : buildFilesManifest(newVersion.entries); + const revision = + newVersion.shownUrl === undefined + ? buildFilesRevision(files) + : buildUrlRevision(newVersion.sourceType, newVersion.shownUrl); + const { secretEnvVarNames, warnings } = newVersion; + const structuredContent = { + actorId: actor.id, + fullName, + versionNumber, + sourceType: newVersion.sourceType, + ...(parsed.buildTag !== undefined && { buildTag: parsed.buildTag }), + revision, + files: files.map(({ path, sizeBytes, hash, format }) => ({ path, sizeBytes, hash, format })), + ...(source.kind === 'copy' && { copiedFromVersion: source.versionNumber }), + ...(secretEnvVarNames.length > 0 && { secretEnvVarsNotCopied: secretEnvVarNames }), + warnings, + ...(buildResult?.build && { build: toBuildResult(buildResult.build, linkContext) }), + ...(buildResult?.buildErrMessage !== undefined && { buildError: buildResult.buildErrMessage }), + }; + const tagText = + parsed.buildTag === undefined + ? 'no build tag, so its builds do not move tags such as latest' + : `build tag ${parsed.buildTag}`; + const warningNote = warnings.length > 0 ? ` ${warnings.join(' ')}` : ''; + const summary = + `Created version ${versionNumber} of ${fullName} ${formatSourceText(source, newVersion, files.length)}, ` + + `${tagText}, revision ${revision}.${formatEnvVarsNote(newVersion, versionNumber)}${warningNote}`; + return respondWithBuild({ + structuredContent, + summary, + nextStep: formatNextStep({ source, buildResult, loadedToolNames }), + }); + } catch (error) { + return respondToSourceToolError(error); + } + }, +} as const); diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts new file mode 100644 index 000000000..bd9bff45c --- /dev/null +++ b/src/tools/source/delete_actor_version.ts @@ -0,0 +1,104 @@ +import { ApifyApiError } from 'apify-client'; +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS } from '../../const.js'; +import { UserInputError } from '../../errors.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { respondAborted, respondOk } from '../../utils/mcp.js'; +import { deleteActorVersionToolOutputSchema } from '../structured_output_schemas.js'; +import { resolveOwnActor, resolveVersionNumber, respondToSourceToolError } from './source_helpers.js'; + +/** The error type the platform returns for a delete that would leave the Actor with no version. */ +const TOO_FEW_VERSIONS_ERROR_TYPE = 'too-few-versions'; + +const deleteActorVersionArgs = z.object({ + actor: z + .string() + .min(1) + .describe( + 'The Actor to delete the version from: its ID, or its full name as username/name or username~name. ' + + 'A name without the username is not enough. It must be in your own account.', + ), + versionNumber: z.string().min(1).describe('The version to delete, in MAJOR.MINOR form, for example 0.2.'), +}); + +function formatLastVersionText(fullName: string, versionNumber: string): string { + return ( + `Nothing was deleted: version ${versionNumber} is the only version of ${fullName}, and an Actor must keep ` + + 'at least one version. To remove it, delete the Actor instead.' + ); +} + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const copyNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_CREATE) + ? ` such as a working copy made with ${HELPER_TOOLS.ACTOR_VERSION_CREATE}` + : ''; + return dedent` + Delete one version of one of your own Actors, with its source and environment variables. + An Actor must keep at least one version, so its last version cannot be deleted; delete the Actor instead. + The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. + + USAGE: + - Use to remove a version that is no longer needed${copyNote}. + + USAGE EXAMPLES: + - user_input: Delete version 0.2 of my Actor john/my-scraper + - user_input: Remove the working copies of my Actor now that the fix is in 0.1`; +} + +/** + * https://docs.apify.com/api/v2/actor-get + * /v2/actors/{actorId} + * https://docs.apify.com/api/v2/actor-version-delete + * /v2/actors/{actorId}/versions/{versionNumber} + * + * The Actor is read first: apify-client swallows a 404 on delete, so a missing version would otherwise look deleted. + * The platform refuses to delete the last version, and leaves the version's builds and tags in place. + */ +export const deleteActorVersion: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.ACTOR_VERSION_DELETE, + title: 'Delete Actor version', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + inputSchema: z.toJSONSchema(deleteActorVersionArgs) as ToolInputSchema, + outputSchema: deleteActorVersionToolOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(deleteActorVersionArgs)), + annotations: { + title: 'Delete Actor version', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyClient: client, apifyToken, signal } = toolArgs; + const parsed = deleteActorVersionArgs.parse(args); + try { + const { actor, fullName } = await resolveOwnActor({ client, apifyToken, actorSelector: parsed.actor }); + const versionNumber = resolveVersionNumber(actor, parsed.versionNumber, parsed.actor); + if (actor.versions.length <= 1) throw new UserInputError(formatLastVersionText(fullName, versionNumber)); + // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. + if (signal?.aborted) return respondAborted(); + try { + await client.actor(actor.id).version(versionNumber).delete(); + } catch (error) { + // Another writer deleted the other versions since the read. + if (error instanceof ApifyApiError && error.type === TOO_FEW_VERSIONS_ERROR_TYPE) { + throw new UserInputError(formatLastVersionText(fullName, versionNumber)); + } + throw error; + } + const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true }; + const summary = + `Deleted version ${versionNumber} of ${fullName}. Its builds stay, and so do the tags that point to ` + + 'them: a run with such a tag still uses that build.'; + return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); + } catch (error) { + return respondToSourceToolError(error); + } + }, +} as const); diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index c0ae6b2f2..d95509769 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -643,6 +643,51 @@ export const createActorToolOutputSchema = { required: ['actorId', 'fullName', 'versionNumber', 'sourceType', 'buildTag', 'revision', 'files', 'warnings'], }; +/** Schema for create-actor-version: the new version, its files, and what a copy could not take over. */ +export const createActorVersionToolOutputSchema = { + type: 'object' as const, + properties: { + actorId: { type: 'string', description: 'Actor ID' }, + fullName: { type: 'string', description: 'Actor full name, username/name' }, + versionNumber: { type: 'string', description: 'Number of the new version, e.g. 0.2' }, + sourceType: { + type: 'string', + description: 'Where the source lives: SOURCE_FILES, GIT_REPO, or GITHUB_GIST', + }, + buildTag: { type: 'string', description: 'Tag that builds of the version get; absent when none was given' }, + revision: { + type: 'string', + description: 'Identifies the file set, or the URL; the same value a later read of the version returns', + }, + files: { + type: 'array', + description: 'The files as stored, sorted by path, folders excluded; empty for an empty or URL version', + items: getActorVersionToolOutputSchema.properties.files.items, + }, + copiedFromVersion: { type: 'string', description: 'The version the source was copied from' }, + secretEnvVarsNotCopied: { + type: 'array', + items: { type: 'string' }, + description: + 'Names of secret environment variables a copy could not take over, since their values cannot be read', + }, + ...sourceWriteBuildProperties, + }, + required: ['actorId', 'fullName', 'versionNumber', 'sourceType', 'revision', 'files', 'warnings'], +}; + +/** Schema for delete-actor-version: the version that was deleted. */ +export const deleteActorVersionToolOutputSchema = { + type: 'object' as const, + properties: { + actorId: { type: 'string', description: 'Actor ID' }, + fullName: { type: 'string', description: 'Actor full name, username/name' }, + versionNumber: { type: 'string', description: 'Number of the deleted version' }, + deleted: { type: 'boolean', description: 'Always true' }, + }, + required: ['actorId', 'fullName', 'versionNumber', 'deleted'], +}; + /** Schema for update-actor-version: the revisions before and after, what changed, and excerpts of edited regions. */ export const updateActorVersionToolOutputSchema = { type: 'object' as const, diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts new file mode 100644 index 000000000..6e2633b59 --- /dev/null +++ b/tests/unit/tools.create_actor_version.test.ts @@ -0,0 +1,658 @@ +import { createHash } from 'node:crypto'; + +import { ApifyApiError } from 'apify-client'; +import type { AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FAILURE_CATEGORY, HELPER_TOOLS } from '../../src/const.js'; +import { createActorVersion } from '../../src/tools/source/create_actor_version.js'; +import { getActorVersion } from '../../src/tools/source/get_actor_version.js'; +import { buildFilesRevision, buildUrlRevision } from '../../src/tools/source/source_files.js'; +import { createActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; +import type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { getUserInfoCached } from '../../src/utils/userid_cache.js'; +import { + expectSchemaConformingStructuredContent, + expectSoftFailInvalidInput, + mockUserInfo, + only, + stubToolCallContext, + type TextToolResult, + type ToolTelemetrySnapshot, +} from './helpers/tool_context.js'; + +vi.mock('../../src/utils/userid_cache.js', () => ({ + getUserInfoCached: vi.fn(), +})); + +const actorGetMock = vi.fn(); +const versionGetMock = vi.fn(); +const versionsCreateMock = vi.fn(); +const buildMock = vi.fn(); +const versionMock = vi.fn(() => ({ get: versionGetMock })); +const actorMock = vi.fn(() => ({ + get: actorGetMock, + version: versionMock, + versions: () => ({ create: versionsCreateMock }), + build: buildMock, +})); + +const stubClient = { + actor: actorMock, + baseUrl: 'https://api.example.test/v2', +} as unknown as InternalToolArgs['apifyClient']; + +const ACTOR_JSON = { path: '.actor/actor.json', content: '{"actorSpecification": 1}' }; +const DOCKERFILE = { path: 'Dockerfile', content: 'FROM apify/actor-node:20\n' }; +const MAIN_JS = { path: 'src/main.js', content: 'console.log(1);\n' }; + +const LOGO_BYTES = Buffer.from([137, 80, 78, 71, 0, 255]); +/** Stored the way Console and `apify push` store them: a BASE64 text file, a folder entry, and a path stored twice. */ +const STORED_FILES = [ + { name: '.actor/actor.json', format: 'TEXT', content: ACTOR_JSON.content }, + { name: 'src/main.js', format: 'BASE64', content: Buffer.from('old\n').toString('base64') }, + { name: 'assets', folder: true }, + { name: 'assets/logo.png', format: 'BASE64', content: LOGO_BYTES.toString('base64') }, + { name: './src/main.js', format: 'TEXT', content: MAIN_JS.content }, +]; + +const TOOL_NAMES = Object.values(HELPER_TOOLS); + +type CreateVersionOutput = { + actorId: string; + fullName: string; + versionNumber: string; + sourceType: string; + buildTag?: string; + revision: string; + files: { path: string; sizeBytes: number; hash: string; format: string }[]; + copiedFromVersion?: string; + secretEnvVarsNotCopied?: string[]; + warnings: string[]; + build?: Record; + buildError?: string; +}; + +type CreateVersionResult = TextToolResult & { + structuredContent: CreateVersionOutput; + toolTelemetry?: ToolTelemetrySnapshot; +}; + +function mockActor(overrides: Record = {}) { + return { + id: 'actor-1', + userId: 'user-1', + name: 'my-actor', + username: 'john', + versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES', buildTag: 'latest' }], + ...overrides, + }; +} + +/** The version to copy, as the API returns it to the owner: a secret comes back with its value removed. */ +function mockSourceVersion(overrides: Record = {}) { + return { + versionNumber: '0.1', + buildTag: 'latest', + sourceType: 'SOURCE_FILES', + envVars: [ + { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, + { name: 'API_KEY', isSecret: true, valueHash: 'abc123' }, + { name: 'REGION', value: 'eu' }, + ], + sourceFiles: STORED_FILES, + ...overrides, + }; +} + +function sha256Prefix(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex').slice(0, 16); +} + +function apiError(status: number, message: string, type = 'some-error'): ApifyApiError { + return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); +} + +async function callTool( + args: Record, + loadedToolNames?: string[], + signal?: AbortSignal, +): Promise { + const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); + const withTools = loadedToolNames === undefined ? context : { ...context, loadedToolNames }; + const withSignal = signal === undefined ? withTools : { ...withTools, signal }; + return (await (createActorVersion as HelperTool).call(withSignal)) as CreateVersionResult; +} + +async function callToolExpectingUserError(args: Record, loadedToolNames?: string[]) { + const result = await callTool(args, loadedToolNames); + expectSoftFailInvalidInput(result); + return result.content[0].text; +} + +/** The body of the one version POST the call sent. */ +function getPostBody(): Record { + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + return versionsCreateMock.mock.calls[0][0] as Record; +} + +function expectNoToolNamed(text: string) { + for (const name of TOOL_NAMES) expect(text).not.toContain(name); +} + +describe('create-actor-version', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getUserInfoCached).mockResolvedValue(mockUserInfo({ userId: 'user-1' })); + actorGetMock.mockResolvedValue(mockActor()); + versionGetMock.mockResolvedValue(mockSourceVersion()); + versionsCreateMock.mockImplementation(async (body: Record) => body); + buildMock.mockResolvedValue({ + id: 'build-1', + actId: 'actor-1', + buildNumber: '0.2.1', + status: 'READY', + startedAt: new Date('2026-09-01T10:00:00.000Z'), + }); + }); + + it('is a non-destructive, non-idempotent, closed-world tool without payment', () => { + expect(createActorVersion.name).toBe(HELPER_TOOLS.ACTOR_VERSION_CREATE); + expect(createActorVersion.annotations).toEqual({ + title: 'Create Actor version', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }); + expect((createActorVersion as HelperTool).paymentRequired).toBeUndefined(); + }); + + describe('description', () => { + const { buildDescription } = createActorVersion as HelperTool; + + it('names update-actor-version and get-actor-build only when the session has them', () => { + const full = + buildDescription?.(only(HELPER_TOOLS.ACTOR_VERSION_UPDATE, HELPER_TOOLS.ACTOR_BUILD_GET)) ?? ''; + expect(full).toBe(createActorVersion.description); + expect(full).toContain(`To change an existing version, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`); + expect(full).toContain(`to fill later with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`); + expect(full).toContain(`Follow the build with ${HELPER_TOOLS.ACTOR_BUILD_GET}.`); + const bare = buildDescription?.(only()) ?? ''; + for (const name of TOOL_NAMES.filter((tool) => tool !== HELPER_TOOLS.ACTOR_VERSION_CREATE)) { + expect(bare).not.toContain(name); + } + expect(bare).toContain('an empty version with no files, to fill later.'); + }); + + it('uses plain punctuation and names no tool in the input schema', () => { + expect(createActorVersion.description).not.toMatch(/[–—]/); + const schemaText = JSON.stringify(createActorVersion.inputSchema); + for (const name of TOOL_NAMES) expect(schemaText).not.toContain(`${name} `); + }); + }); + + describe('copyFromVersion', () => { + it('sends the stored files exactly as read, formats and folder entries included', async () => { + const result = await callTool({ copyFromVersion: '0.1' }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(versionMock).toHaveBeenCalledWith('0.1'); + const body = getPostBody(); + expect(body.sourceType).toBe('SOURCE_FILES'); + expect(body.sourceFiles).toEqual(STORED_FILES); + expect(body.versionNumber).toBe('0.2'); + }); + + it('returns the manifest and revision get-actor-version gives, with no content', async () => { + const result = await callTool({ copyFromVersion: '0.1' }); + const files = [ + { + path: '.actor/actor.json', + sizeBytes: ACTOR_JSON.content.length, + hash: sha256Prefix(ACTOR_JSON.content), + format: 'TEXT', + }, + { + path: 'assets/logo.png', + sizeBytes: LOGO_BYTES.length, + hash: sha256Prefix(LOGO_BYTES), + format: 'BASE64', + }, + // The last entry for a path wins, as the build writes them in order. + { + path: 'src/main.js', + sizeBytes: MAIN_JS.content.length, + hash: sha256Prefix(MAIN_JS.content), + format: 'TEXT', + }, + ]; + expect(result.structuredContent.files).toEqual(files); + expect(result.structuredContent.revision).toBe(buildFilesRevision(files)); + expect(result.structuredContent.copiedFromVersion).toBe('0.1'); + + const read = (await (getActorVersion as HelperTool).call( + stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1', paths: [] }, stubClient), + )) as TextToolResult & { structuredContent: { revision: string; files: unknown[] } }; + expect(result.structuredContent.revision).toBe(read.structuredContent.revision); + expect(result.structuredContent.files).toEqual(read.structuredContent.files); + + const output = result.content.map(({ text }) => text).join('\n'); + expect(output).not.toContain(MAIN_JS.content); + expect(output).not.toContain(LOGO_BYTES.toString('base64')); + expect(output).not.toContain('b2xkCg=='); + }); + + it('copies the non-secret env vars with their values and lists the secret ones', async () => { + const result = await callTool({ copyFromVersion: '0.1' }); + expect(getPostBody().envVars).toEqual([ + { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, + { name: 'REGION', value: 'eu', isSecret: false }, + ]); + expect(result.structuredContent.secretEnvVarsNotCopied).toEqual(['API_KEY']); + expect(result.content[1].text).toContain('Copied 2 environment variables with their values.'); + expect(result.content[1].text).toContain( + 'These secret environment variables were not copied, because their values cannot be read: API_KEY. ' + + 'Set them on version 0.2 yourself, for example in Apify Console.', + ); + expect(result.content.map(({ text }) => text).join('\n')).not.toContain('abc123'); + }); + + it('sends no env vars and lists no secrets for a version without them', async () => { + versionGetMock.mockResolvedValue(mockSourceVersion({ envVars: undefined })); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(getPostBody()).not.toHaveProperty('envVars'); + expect(result.structuredContent).not.toHaveProperty('secretEnvVarsNotCopied'); + expect(result.content[1].text).not.toContain('environment variable'); + }); + + it('keeps applyEnvVarsToBuild', async () => { + versionGetMock.mockResolvedValue(mockSourceVersion({ applyEnvVarsToBuild: true })); + await callTool({ copyFromVersion: '0.1' }); + expect(getPostBody().applyEnvVarsToBuild).toBe(true); + }); + + it('does not copy the build tag of the source version', async () => { + const result = await callTool({ copyFromVersion: '0.1' }); + expect(getPostBody()).not.toHaveProperty('buildTag'); + expect(result.structuredContent).not.toHaveProperty('buildTag'); + expect(result.content[1].text).toContain('no build tag, so its builds do not move tags such as latest'); + }); + + it('copies a Git URL with its stored credentials, which are never shown', async () => { + const gitRepoUrl = 'https://user:s3cret@github.com/john/repo.git#main:actor'; + versionGetMock.mockResolvedValue( + mockSourceVersion({ sourceType: 'GIT_REPO', gitRepoUrl, sourceFiles: undefined }), + ); + const result = await callTool({ copyFromVersion: '0.1' }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(getPostBody()).toEqual( + expect.objectContaining({ versionNumber: '0.2', sourceType: 'GIT_REPO', gitRepoUrl }), + ); + expect(getPostBody()).not.toHaveProperty('sourceFiles'); + expect(result.structuredContent.sourceType).toBe('GIT_REPO'); + expect(result.structuredContent.files).toEqual([]); + expect(result.structuredContent.revision).toBe( + buildUrlRevision('GIT_REPO', 'https://github.com/john/repo.git#main:actor'), + ); + expect(result.structuredContent.warnings).toEqual([ + 'The credentials stored with the URL of version 0.1 were copied too; they are not shown.', + ]); + const output = result.content.map(({ text }) => text).join('\n'); + expect(output).toContain('building from the Git repository https://github.com/john/repo.git#main:actor'); + expect(output).not.toContain('s3cret'); + }); + + it('copies a GitHub gist URL', async () => { + const gitHubGistUrl = 'https://gist.github.com/john/abc123'; + versionGetMock.mockResolvedValue( + mockSourceVersion({ sourceType: 'GITHUB_GIST', gitHubGistUrl, sourceFiles: undefined }), + ); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(getPostBody()).toEqual(expect.objectContaining({ sourceType: 'GITHUB_GIST', gitHubGistUrl })); + expect(result.structuredContent.revision).toBe(buildUrlRevision('GITHUB_GIST', gitHubGistUrl)); + expect(result.structuredContent.warnings).toEqual([]); + expect(result.content[1].text).toContain(`building from the GitHub gist ${gitHubGistUrl}`); + }); + + it('refuses a zip-stored (TARBALL) version before any write', async () => { + versionGetMock.mockResolvedValue( + mockSourceVersion({ + sourceType: 'TARBALL', + tarballUrl: 'https://x.test/a.zip', + sourceFiles: undefined, + }), + ); + const text = await callToolExpectingUserError({ copyFromVersion: '0.1' }); + expect(text).toBe( + 'Version 0.1 of john/my-actor is stored as a zip (TARBALL), and this tool cannot copy zip-stored ' + + 'versions yet. Push the source to the new version with the Apify CLI instead (apify push --version 0.2).', + ); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it('refuses a version whose source the API hid, or an unknown source type', async () => { + versionGetMock.mockResolvedValue(mockSourceVersion({ sourceFiles: undefined })); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + 'Version 0.1 of john/my-actor came back without its source, so it cannot be copied.', + ); + versionGetMock.mockResolvedValue(mockSourceVersion({ sourceType: 'SOURCE_CODE' })); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + 'Version 0.1 of john/my-actor has source type SOURCE_CODE, which this tool cannot copy.', + ); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it('refuses a copyFromVersion the Actor does not have, listing its versions', async () => { + const text = await callToolExpectingUserError({ copyFromVersion: '0.9' }); + expect(text).toBe("Actor 'john/my-actor' has no version 0.9; available versions: 0.1."); + expect(versionGetMock).not.toHaveBeenCalled(); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it('refuses a source version that is gone by the time it is read', async () => { + versionGetMock.mockResolvedValue(undefined); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + 'Actor john/my-actor has no version 0.1.', + ); + }); + }); + + describe('files and gitRepoUrl', () => { + it('creates a version from files in one POST', async () => { + const result = await callTool({ files: [MAIN_JS, ACTOR_JSON, DOCKERFILE], buildTag: 'beta' }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(actorMock).toHaveBeenCalledWith('actor-1'); + expect(getPostBody()).toEqual({ + versionNumber: '0.2', + buildTag: 'beta', + sourceType: 'SOURCE_FILES', + sourceFiles: [ + { name: 'src/main.js', format: 'TEXT', content: MAIN_JS.content }, + { name: '.actor/actor.json', format: 'TEXT', content: ACTOR_JSON.content }, + { name: 'Dockerfile', format: 'TEXT', content: DOCKERFILE.content }, + ], + }); + const files = [ACTOR_JSON, DOCKERFILE, MAIN_JS].map(({ path, content }) => ({ + path, + sizeBytes: content.length, + hash: sha256Prefix(content), + format: 'TEXT', + })); + expect(result.structuredContent).toEqual({ + actorId: 'actor-1', + fullName: 'john/my-actor', + versionNumber: '0.2', + sourceType: 'SOURCE_FILES', + buildTag: 'beta', + revision: buildFilesRevision(files), + files, + warnings: [], + }); + expect(result.content[1].text).toBe( + `Created version 0.2 of john/my-actor from 3 files, build tag beta, revision ${buildFilesRevision(files)}.\n` + + `The version has no build yet, so it cannot run until it is built. Build it with ${HELPER_TOOLS.ACTOR_BUILD}.`, + ); + }); + + it('applies the create-actor file rules', async () => { + expect(await callToolExpectingUserError({ files: [MAIN_JS, DOCKERFILE] })).toContain( + 'files must include .actor/actor.json', + ); + expect(await callToolExpectingUserError({ files: [ACTOR_JSON, { path: '../x.js', content: 'x' }] })).toBe( + "files[1] path ../x.js has a '..' segment.", + ); + expect( + await callToolExpectingUserError({ + files: [ACTOR_JSON, MAIN_JS, { path: './src/main.js', content: '' }], + }), + ).toBe('files has src/main.js more than once.'); + expect( + await callToolExpectingUserError({ files: [ACTOR_JSON, { path: 'a.png', content: 'not base64!' }] }), + ).toContain('files[1] content for a.png has encoding base64, but its content is not valid base64'); + expect( + await callToolExpectingUserError({ files: [ACTOR_JSON, { path: 'a'.repeat(256), content: 'x' }] }), + ).toBe('files[1] path is over 255 characters.'); + expect(actorMock).not.toHaveBeenCalled(); + }); + + it('refuses more than 2 MiB of content before any request, naming update-actor-version only when loaded', async () => { + const files = [ACTOR_JSON, { path: 'big.txt', content: 'x'.repeat(2 * 1024 * 1024) }]; + expect(await callToolExpectingUserError({ files })).toContain( + `Create the version with fewer files, then add the rest in later calls with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, + ); + const bare = await callToolExpectingUserError({ files }, [HELPER_TOOLS.ACTOR_VERSION_CREATE]); + expect(bare).toContain('Create the version with fewer files, then add the rest in later calls.'); + expectNoToolNamed(bare); + expect(actorMock).not.toHaveBeenCalled(); + }); + + it('warns about a missing Dockerfile and empty files', async () => { + const result = await callTool({ files: [ACTOR_JSON, { path: 'src/empty.js', content: '' }] }); + expect(result.structuredContent.warnings).toEqual([ + expect.stringContaining('No Dockerfile found'), + 'These files are empty, and the build skips empty files, so they will not exist in the build: src/empty.js.', + ]); + }); + + it('creates a version built from Git, never showing the credentials', async () => { + const gitRepoUrl = 'https://user:token@github.com/john/repo.git#main'; + const result = await callTool({ gitRepoUrl }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(getPostBody()).toEqual({ versionNumber: '0.2', sourceType: 'GIT_REPO', gitRepoUrl }); + expect(result.structuredContent.revision).toBe( + buildUrlRevision('GIT_REPO', 'https://github.com/john/repo.git#main'), + ); + expect(result.content[1].text).toContain('from the Git repository https://github.com/john/repo.git#main'); + expect(result.content.map(({ text }) => text).join('\n')).not.toContain('token@'); + }); + }); + + describe('empty version', () => { + it('creates a version with an empty file list', async () => { + const result = await callTool({}); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(getPostBody()).toEqual({ versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: [] }); + expect(result.structuredContent.files).toEqual([]); + expect(result.structuredContent.revision).toBe(buildFilesRevision([])); + expect(result.content[1].text).toBe( + `Created version 0.2 of john/my-actor with no files, no build tag, so its builds do not move tags such as latest, revision ${buildFilesRevision([])}.\n` + + `The version has no files yet: add them with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}, then build it.`, + ); + const bare = await callTool({}, [HELPER_TOOLS.ACTOR_VERSION_CREATE]); + expect(bare.content[1].text).toContain('The version has no files yet: add them, then build it.'); + expectNoToolNamed(bare.content[1].text); + }); + + it('refuses autoBuild for an empty version before any request', async () => { + expect(await callToolExpectingUserError({ autoBuild: true })).toBe( + 'An empty version has nothing to build. Give copyFromVersion, files, or gitRepoUrl, or leave out autoBuild.', + ); + expect(actorMock).not.toHaveBeenCalled(); + }); + }); + + describe('input rules', () => { + it('refuses more than one source', async () => { + const expected = 'Give at most one of copyFromVersion, files, or gitRepoUrl.'; + expect(await callToolExpectingUserError({ copyFromVersion: '0.1', files: [ACTOR_JSON] })).toBe(expected); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1', gitRepoUrl: 'https://x' })).toBe( + expected, + ); + expect(await callToolExpectingUserError({ files: [ACTOR_JSON], gitRepoUrl: 'https://x' })).toBe(expected); + expect(actorMock).not.toHaveBeenCalled(); + }); + + it('leaves the versionNumber format to the platform and returns its message', async () => { + versionsCreateMock.mockRejectedValue(apiError(400, 'Version number must be in MAJOR.MINOR format')); + const result = await callTool({ versionNumber: '0.1.2' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Version number must be in MAJOR.MINOR format'); + }); + + it('caps files at 500 in the input schema', () => { + const files = Array.from({ length: 501 }, (_, index) => ({ path: `f${index}.txt`, content: 'x' })); + expect(createActorVersion.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', files })).toBe(false); + }); + + it('refuses a versionNumber the Actor already has, naming update-actor-version only when loaded', async () => { + const text = await callToolExpectingUserError({ versionNumber: '0.1', files: [ACTOR_JSON] }); + expect(text).toBe( + 'john/my-actor already has version 0.1, and this tool never changes an existing version. ' + + `Its versions: 0.1. To change it, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, + ); + const bare = await callToolExpectingUserError({ versionNumber: '0.1' }, [ + HELPER_TOOLS.ACTOR_VERSION_CREATE, + ]); + expect(bare).toContain('Pick another versionNumber, or change the existing version instead.'); + expectNoToolNamed(bare); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it('refuses a versionNumber that a racing call created first', async () => { + versionsCreateMock.mockRejectedValue( + apiError(403, 'Version with this number already exists', 'version-already-exists'), + ); + const text = await callToolExpectingUserError({ files: [ACTOR_JSON] }); + expect(text).toContain( + 'john/my-actor already has version 0.2, and this tool never changes an existing version.', + ); + }); + }); + + describe('buildTag and autoBuild', () => { + it('sends buildTag only when given', async () => { + await callTool({ files: [ACTOR_JSON, DOCKERFILE] }); + expect(getPostBody()).not.toHaveProperty('buildTag'); + vi.clearAllMocks(); + await callTool({ copyFromVersion: '0.1', buildTag: 'latest' }); + expect(getPostBody().buildTag).toBe('latest'); + }); + + it('starts a build without waiting and points at get-actor-build', async () => { + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(buildMock).toHaveBeenCalledWith('0.2', { useCache: true }); + expect(result.structuredContent.build).toEqual({ + id: 'build-1', + actorId: 'actor-1', + buildNumber: '0.2.1', + status: 'READY', + startedAt: '2026-09-01T10:00:00.000Z', + finishedAt: null, + }); + expect(result.content[1].text).toContain( + `Check progress with ${HELPER_TOOLS.ACTOR_BUILD_GET} using buildId build-1`, + ); + }); + + it('starts no build without autoBuild', async () => { + await callTool({ copyFromVersion: '0.1' }); + expect(buildMock).not.toHaveBeenCalled(); + }); + + it('reports a build that failed to start with the version still created', async () => { + buildMock.mockRejectedValue(apiError(402, 'Not enough credit')); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent.buildError).toBe('Not enough credit'); + expect(result.content[1].text).toContain( + `The version was created, but the build could not be started: Not enough credit. Start it again with ${HELPER_TOOLS.ACTOR_BUILD}.`, + ); + const bare = await callTool({ copyFromVersion: '0.1', autoBuild: true }, [ + HELPER_TOOLS.ACTOR_VERSION_CREATE, + ]); + expect(bare.content[1].text).toContain('Start the build again to make this version runnable.'); + expectNoToolNamed(bare.content[1].text); + }); + + it('names build-actor after a create without autoBuild only when loaded', async () => { + const bare = await callTool({ files: [ACTOR_JSON, DOCKERFILE] }, [HELPER_TOOLS.ACTOR_VERSION_CREATE]); + expect(bare.content[1].text).toContain('The version has no build yet, so it cannot run until it is built.'); + expectNoToolNamed(bare.content[1].text); + }); + }); + + describe('cancellation', () => { + it('sends nothing when the request is cancelled during the reads', async () => { + const controller = new AbortController(); + versionGetMock.mockImplementation(async () => { + controller.abort(); + return mockSourceVersion(); + }); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, undefined, controller.signal); + expect(result).toEqual({}); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expect(buildMock).not.toHaveBeenCalled(); + }); + + it('starts no build when the request is cancelled during the POST', async () => { + const controller = new AbortController(); + versionsCreateMock.mockImplementation(async (body: Record) => { + controller.abort(); + return body; + }); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, undefined, controller.signal); + expect(result).toEqual({}); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expect(buildMock).not.toHaveBeenCalled(); + }); + }); + + describe('ownership', () => { + it('refuses a session without a token before any request', async () => { + const context = { + ...stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2' }, stubClient), + apifyToken: '', + }; + const result = (await (createActorVersion as HelperTool).call(context)) as CreateVersionResult; + expect(result.isError).toBe(true); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureCategory: FAILURE_CATEGORY.AUTH })); + expect(result.content[0].text).toBe( + "Changing an Actor's source needs an Apify API token, and this session has none.", + ); + expect(actorMock).not.toHaveBeenCalled(); + }); + + it('refuses an Actor that does not exist or a bare name', async () => { + actorGetMock.mockResolvedValue(undefined); + expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( + 'Actor my-actor not found. Give its ID or its full name, username/name; a name without the username is not enough.', + ); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it('refuses when the account cannot be confirmed', async () => { + vi.mocked(getUserInfoCached).mockResolvedValue(mockUserInfo({ userId: null })); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureCategory: FAILURE_CATEGORY.AUTH })); + expect(versionGetMock).not.toHaveBeenCalled(); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + + it("refuses someone else's Actor", async () => { + actorGetMock.mockResolvedValue(mockActor({ userId: 'someone-else' })); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + 'john/my-actor is not in your account; this tool changes only your own Actors.', + ); + expect(versionGetMock).not.toHaveBeenCalled(); + expect(versionsCreateMock).not.toHaveBeenCalled(); + }); + }); + + describe('API errors', () => { + it('returns a 4xx from the POST as the API message', async () => { + versionsCreateMock.mockRejectedValue( + apiError(403, 'The Actor can have at most 10 versions.', 'too-many-versions'), + ); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('The Actor can have at most 10 versions.'); + }); + + it('rethrows a 5xx', async () => { + versionsCreateMock.mockRejectedValue(apiError(500, 'Internal')); + await expect(callTool({ copyFromVersion: '0.1' })).rejects.toThrow('Internal'); + }); + }); +}); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts new file mode 100644 index 000000000..d36254e98 --- /dev/null +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -0,0 +1,212 @@ +import { ApifyApiError } from 'apify-client'; +import type { AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FAILURE_CATEGORY, HELPER_TOOLS } from '../../src/const.js'; +import { deleteActorVersion } from '../../src/tools/source/delete_actor_version.js'; +import { deleteActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; +import type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { getUserInfoCached } from '../../src/utils/userid_cache.js'; +import { + expectSchemaConformingStructuredContent, + expectSoftFailInvalidInput, + mockUserInfo, + only, + stubToolCallContext, + type TextToolResult, + type ToolTelemetrySnapshot, +} from './helpers/tool_context.js'; + +vi.mock('../../src/utils/userid_cache.js', () => ({ + getUserInfoCached: vi.fn(), +})); + +const actorGetMock = vi.fn(); +const versionDeleteMock = vi.fn(); +const versionMock = vi.fn(() => ({ delete: versionDeleteMock })); +const actorMock = vi.fn(() => ({ get: actorGetMock, version: versionMock })); + +const stubClient = { + actor: actorMock, + baseUrl: 'https://api.example.test/v2', +} as unknown as InternalToolArgs['apifyClient']; + +const TOOL_NAMES = Object.values(HELPER_TOOLS); + +type DeleteResult = TextToolResult & { + structuredContent: { actorId: string; fullName: string; versionNumber: string; deleted: boolean }; + toolTelemetry?: ToolTelemetrySnapshot; +}; + +function mockActor(overrides: Record = {}) { + return { + id: 'actor-1', + userId: 'user-1', + name: 'my-actor', + username: 'john', + versions: [ + { versionNumber: '0.1', sourceType: 'SOURCE_FILES', buildTag: 'latest' }, + { versionNumber: '0.2', sourceType: 'SOURCE_FILES' }, + ], + ...overrides, + }; +} + +function apiError(status: number, message: string, type = 'some-error'): ApifyApiError { + return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); +} + +async function callTool( + args: Record, + loadedToolNames?: string[], + signal?: AbortSignal, +): Promise { + const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); + const withTools = loadedToolNames === undefined ? context : { ...context, loadedToolNames }; + const withSignal = signal === undefined ? withTools : { ...withTools, signal }; + return (await (deleteActorVersion as HelperTool).call(withSignal)) as DeleteResult; +} + +async function callToolExpectingUserError(args: Record) { + const result = await callTool(args); + expectSoftFailInvalidInput(result); + return result.content[0].text; +} + +const LAST_VERSION_TEXT = + 'Nothing was deleted: version 0.1 is the only version of john/my-actor, and an Actor must keep at least one ' + + 'version. To remove it, delete the Actor instead.'; + +describe('delete-actor-version', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getUserInfoCached).mockResolvedValue(mockUserInfo({ userId: 'user-1' })); + actorGetMock.mockResolvedValue(mockActor()); + versionDeleteMock.mockResolvedValue(undefined); + }); + + it('is a destructive, idempotent, closed-world tool without payment', () => { + expect(deleteActorVersion.name).toBe(HELPER_TOOLS.ACTOR_VERSION_DELETE); + expect(deleteActorVersion.annotations).toEqual({ + title: 'Delete Actor version', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }); + expect((deleteActorVersion as HelperTool).paymentRequired).toBeUndefined(); + }); + + it('names create-actor-version in its description only when the session has it', () => { + const { buildDescription } = deleteActorVersion as HelperTool; + const full = buildDescription?.(only(HELPER_TOOLS.ACTOR_VERSION_CREATE)) ?? ''; + expect(full).toBe(deleteActorVersion.description); + expect(full).toContain(`such as a working copy made with ${HELPER_TOOLS.ACTOR_VERSION_CREATE}.`); + const bare = buildDescription?.(only()) ?? ''; + for (const name of TOOL_NAMES.filter((tool) => tool !== HELPER_TOOLS.ACTOR_VERSION_DELETE)) { + expect(bare).not.toContain(name); + } + expect(full).not.toMatch(/[–—]/); + }); + + it('deletes the version with one DELETE and says its builds and tags stay', async () => { + const result = await callTool({}); + expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); + expect(actorMock).toHaveBeenCalledWith('actor-1'); + expect(versionMock).toHaveBeenCalledWith('0.2'); + expect(versionDeleteMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toEqual({ + actorId: 'actor-1', + fullName: 'john/my-actor', + versionNumber: '0.2', + deleted: true, + }); + expect(result.content[1].text).toBe( + 'Deleted version 0.2 of john/my-actor. Its builds stay, and so do the tags that point to them: a run ' + + 'with such a tag still uses that build.', + ); + }); + + it('refuses a version the Actor does not have, before any DELETE', async () => { + expect(await callToolExpectingUserError({ versionNumber: '0.9' })).toBe( + "Actor 'john/my-actor' has no version 0.9; available versions: 0.1, 0.2.", + ); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + + it('refuses the last version before any DELETE, naming no tool', async () => { + actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES' }] })); + const text = await callToolExpectingUserError({ versionNumber: '0.1' }); + expect(text).toBe(LAST_VERSION_TEXT); + for (const name of TOOL_NAMES) expect(text).not.toContain(name); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + + it("maps the platform's too-few-versions refusal to the same user error", async () => { + versionDeleteMock.mockRejectedValue( + apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'), + ); + expect(await callToolExpectingUserError({ versionNumber: '0.1' })).toBe(LAST_VERSION_TEXT); + }); + + it('sends nothing when the request is cancelled before the DELETE', async () => { + const controller = new AbortController(); + actorGetMock.mockImplementation(async () => { + controller.abort(); + return mockActor(); + }); + const result = await callTool({}, undefined, controller.signal); + expect(result).toEqual({}); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + + describe('ownership', () => { + it('refuses a session without a token before any request', async () => { + const context = { + ...stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2' }, stubClient), + apifyToken: '', + }; + const result = (await (deleteActorVersion as HelperTool).call(context)) as DeleteResult; + expect(result.isError).toBe(true); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureCategory: FAILURE_CATEGORY.AUTH })); + expect(actorMock).not.toHaveBeenCalled(); + }); + + it('refuses an Actor that does not exist or a bare name', async () => { + actorGetMock.mockResolvedValue(undefined); + expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( + 'Actor my-actor not found. Give its ID or its full name, username/name; a name without the username is not enough.', + ); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + + it('refuses when the account cannot be confirmed', async () => { + vi.mocked(getUserInfoCached).mockResolvedValue(mockUserInfo({ userId: null })); + const result = await callTool({}); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureCategory: FAILURE_CATEGORY.AUTH })); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + + it("refuses someone else's Actor", async () => { + actorGetMock.mockResolvedValue(mockActor({ userId: 'someone-else' })); + expect(await callToolExpectingUserError({})).toBe( + 'john/my-actor is not in your account; this tool changes only your own Actors.', + ); + expect(versionDeleteMock).not.toHaveBeenCalled(); + }); + }); + + describe('API errors', () => { + it('returns a 4xx from the DELETE as the API message', async () => { + versionDeleteMock.mockRejectedValue(apiError(403, 'Insufficient permissions')); + const result = await callTool({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Insufficient permissions'); + }); + + it('rethrows a 5xx', async () => { + versionDeleteMock.mockRejectedValue(apiError(500, 'Internal')); + await expect(callTool({})).rejects.toThrow('Internal'); + }); + }); +}); diff --git a/tests/unit/tools.get_actor_version.test.ts b/tests/unit/tools.get_actor_version.test.ts index 5ea1b930e..45e3718aa 100644 --- a/tests/unit/tools.get_actor_version.test.ts +++ b/tests/unit/tools.get_actor_version.test.ts @@ -167,6 +167,8 @@ describe('get-actor-version', () => { HELPER_TOOLS.ACTOR_VERSION_GET, HELPER_TOOLS.ACTOR_CREATE, HELPER_TOOLS.ACTOR_VERSION_UPDATE, + HELPER_TOOLS.ACTOR_VERSION_CREATE, + HELPER_TOOLS.ACTOR_VERSION_DELETE, ]); expect(toolCategoriesEnabledByDefault).not.toContain('source'); }); diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 164bc2d55..02af50cd4 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -129,6 +129,8 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => { HELPER_TOOLS.ACTOR_VERSION_GET, HELPER_TOOLS.ACTOR_CREATE, HELPER_TOOLS.ACTOR_VERSION_UPDATE, + HELPER_TOOLS.ACTOR_VERSION_CREATE, + HELPER_TOOLS.ACTOR_VERSION_DELETE, ]; expect(toolNames(defaultCategories.source)).toEqual(expected); expect(toolNames(appsCategories.source)).toEqual(expected); From ca8cb3bedc4f7905fcfdb1f65cc25a0e6b8090dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 16:50:27 +0200 Subject: [PATCH 03/60] fix: Harden create-actor-version refusals and warnings - The refusal for a zip-stored source version no longer suggests a bare apify push --version, which takes the tag from .actor/actor.json (often latest) and builds right away. It points to files or gitRepoUrl, and gives the CLI command with a build tag of its own. - The refusal for an existing versionNumber leads with a free number after the highest version. update-actor-version is offered only for a caller who meant that version. After a race, the list includes the requested number. - The result warns when the new version turns Standby on for the whole Actor: the platform does that when .actor/actor.json sets usesStandbyMode and Standby is off. - update-actor-version's refusal of a missing version names create-actor-version when the session has it. - Share the Git or gist source text, the call size check for new files, the sent files warnings, and the path, duplicate, and entry rules for files and replaceFiles through source_helpers.ts. - Type the version POST body instead of casting it. - Mark the delete-actor pointer for the last version as TODO(#1439). - Tests: the copy test compares against its own copy of the stored files, and the input schema checks match tool names with any punctuation, in delete-actor-version too. --- src/tools/source/create_actor.ts | 31 +--- src/tools/source/create_actor_version.ts | 150 ++++++++++++------ src/tools/source/delete_actor_version.ts | 2 + src/tools/source/source_helpers.ts | 77 +++++++-- src/tools/source/update_actor_version.ts | 28 +--- tests/unit/tools.create_actor_version.test.ts | 94 +++++++++-- tests/unit/tools.delete_actor_version.test.ts | 5 + tests/unit/tools.update_actor_version.test.ts | 11 ++ 8 files changed, 284 insertions(+), 114 deletions(-) diff --git a/src/tools/source/create_actor.ts b/src/tools/source/create_actor.ts index 2c5f4550c..6f5e7d5b8 100644 --- a/src/tools/source/create_actor.ts +++ b/src/tools/source/create_actor.ts @@ -20,8 +20,7 @@ import { buildFilesManifest, formatBuildLaterHint, formatBuildStartFailure, - formatEmptyFilesWarning, - formatMissingDockerfileWarning, + buildSentFilesWarnings, formatUrlWithoutSecrets, getSourceFileEntryBytes, isFolderEntry, @@ -31,7 +30,7 @@ import { sourceFileArgs, startBuildAfterWrite, validateActorName, - validateCallContentSize, + validateNewFilesCallSize, validateSessionToken, } from './source_helpers.js'; @@ -98,38 +97,24 @@ function parseCreateRequest(args: CreateActorArgs, loadedToolNames: readonly str } if (args.gitRepoUrl !== undefined) return { kind: 'git', gitRepoUrl: args.gitRepoUrl }; const files = args.files ?? []; - // Files within this cap always fit the platform's 3 MiB measure, which counts at most 1.25 bytes per UTF-8 byte. - const addLaterText = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` - : ''; - validateCallContentSize( - files.map(({ content }) => content), - { - fieldsText: 'content', - recoveryText: `Create the Actor with fewer files, then add the rest in later calls${addLaterText}.`, - }, - ); - return { kind: 'files', entries: parseInputFiles(files, 'files') }; + validateNewFilesCallSize(files, { subject: 'Actor', loadedToolNames }); + return { kind: 'files', entries: parseInputFiles(files) }; } function buildFilesWarnings( sentEntries: readonly ActorVersionSourceFile[], storedEntries: readonly ActorVersionSourceFile[] | undefined, ): string[] { - const emptyPaths = sentEntries.filter(({ content }) => content === '').map(({ name }) => name); const sentConfig = sentEntries.find(({ name }) => name === ACTOR_CONFIG_PATH); const storedConfig = storedEntries?.findLast(({ name }) => parseStoredPath(name) === ACTOR_CONFIG_PATH); const isConfigRewritten = sentConfig !== undefined && storedConfig !== undefined && !getSourceFileEntryBytes(sentConfig).equals(getSourceFileEntryBytes(storedConfig)); - return [ - formatMissingDockerfileWarning(sentEntries), - isConfigRewritten && - `The platform set the name field of ${ACTOR_CONFIG_PATH} to the Actor name, so the stored file differs ` + - 'from the one sent; its hash in files, and the revision, are those of the stored file.', - formatEmptyFilesWarning(emptyPaths), - ].filter((warning): warning is string => typeof warning === 'string'); + const rewrittenWarning = + `The platform set the name field of ${ACTOR_CONFIG_PATH} to the Actor name, so the stored file differs ` + + 'from the one sent; its hash in files, and the revision, are those of the stored file.'; + return [...buildSentFilesWarnings(sentEntries), ...(isConfigRewritten ? [rewrittenWarning] : [])]; } /** diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index d64a9d3db..9dbc34d2c 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -1,4 +1,12 @@ -import type { ActorEnvironmentVariable, ActorVersion, ActorVersionSourceFile } from 'apify-client'; +import type { + Actor, + ActorEnvironmentVariable, + ActorVersion, + ActorVersionGitHubGist, + ActorVersionGitRepo, + ActorVersionSourceFile, + ActorVersionSourceFiles, +} from 'apify-client'; import { ActorSourceType, ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; @@ -17,11 +25,12 @@ import type { BuildAfterWriteResult } from './source_helpers.js'; import { ACTOR_CONFIG_PATH, buildFilesManifest, + buildSentFilesWarnings, formatBuildLaterHint, formatBuildStartFailure, - formatEmptyFilesWarning, - formatMissingDockerfileWarning, + formatUrlSourceText, formatUrlWithoutSecrets, + formatWithUpdateToolText, hasUrlSecrets, MAX_WRITE_FILES, parseInputFiles, @@ -30,7 +39,7 @@ import { respondToSourceToolError, sourceFileArgs, startBuildAfterWrite, - validateCallContentSize, + validateNewFilesCallSize, } from './source_helpers.js'; /** The error type the platform returns when the Actor already has the version, also when two creates race. */ @@ -92,10 +101,15 @@ type RequestedSource = | { kind: 'git'; gitRepoUrl: string } | { kind: 'copy'; versionNumber: string }; +/** The source keys of the POST body: sourceType and the one field that holds the source. */ +type SourceBody = + | Pick + | Pick + | Pick; + /** The version to create, as the POST body and as the result describes it. */ type NewVersion = { - /** The source keys of the POST body: sourceType and the one field that holds the source. */ - sourceBody: Record; + sourceBody: SourceBody; sourceType: string; /** The stored entries, for the manifest; undefined for a version built from a URL. */ entries?: readonly ActorVersionSourceFile[]; @@ -122,34 +136,77 @@ function parseCreateVersionRequest(args: CreateActorVersionArgs, loadedToolNames } return { kind: 'empty' }; } - // Files within this cap always fit the platform's 3 MiB measure, which counts at most 1.25 bytes per UTF-8 byte. - const addLaterText = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` - : ''; - validateCallContentSize( - files.map(({ content }) => content), - { - fieldsText: 'content', - recoveryText: `Create the version with fewer files, then add the rest in later calls${addLaterText}.`, - }, + validateNewFilesCallSize(files, { subject: 'version', loadedToolNames }); + return { kind: 'files', entries: parseInputFiles(files) }; +} + +/** The platform's limit for each part of MAJOR.MINOR. */ +const MAX_VERSION_PART = 99; + +/** A number after the highest MAJOR.MINOR version, for the caller to pick instead; undefined when none is left. */ +function suggestFreeVersionNumber(versionNumbers: readonly string[]): string | undefined { + const parsed = versionNumbers.flatMap((versionNumber) => { + const match = /^(\d+)\.(\d+)$/.exec(versionNumber); + return match ? [{ major: Number(match[1]), minor: Number(match[2]) }] : []; + }); + if (parsed.length === 0) return undefined; + const { major, minor } = parsed.reduce((highest, part) => + part.major > highest.major || (part.major === highest.major && part.minor > highest.minor) ? part : highest, ); - return { kind: 'files', entries: parseInputFiles(files, 'files') }; + if (minor < MAX_VERSION_PART) return `${major}.${minor + 1}`; + return major < MAX_VERSION_PART ? `${major + 1}.0` : undefined; } +/** + * Leads with a free number, since a parallel caller that finds the number taken needs a version of its own; changing + * the existing version is offered only to a caller who meant that version. + */ function formatVersionExistsText(params: { fullName: string; versionNumber: string; versionNumbers: readonly string[]; loadedToolNames: readonly string[]; }): string { - const { fullName, versionNumber, versionNumbers, loadedToolNames } = params; - const hint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` To change it, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` - : ' Pick another versionNumber, or change the existing version instead.'; - const versionsText = versionNumbers.length > 0 ? ` Its versions: ${versionNumbers.join(', ')}.` : ''; + const { fullName, versionNumber, loadedToolNames } = params; + // After a race the versions were read before the other call created this one. + const versionNumbers = params.versionNumbers.includes(versionNumber) + ? params.versionNumbers + : [...params.versionNumbers, versionNumber]; + const freeVersionNumber = suggestFreeVersionNumber(versionNumbers); + const pickText = + freeVersionNumber === undefined + ? ' Pick another versionNumber.' + : ` Pick another versionNumber, such as ${freeVersionNumber}.`; + const updateHint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` If you meant to change version ${versionNumber} itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` + : ''; return ( - `${fullName} already has version ${versionNumber}, and this tool never changes an existing version.` + - `${versionsText}${hint}` + `${fullName} already has version ${versionNumber}, and this tool never changes an existing version. ` + + `Its versions: ${versionNumbers.join(', ')}.${pickText}${updateHint}` + ); +} + +/** + * The warning for a version that turns Standby on for the whole Actor. The platform's version POST does that when the + * first entry named `.actor/actor.json` sets usesStandbyMode and Standby is off. It parses the content as stored, so a + * BASE64 entry never turns it on; it reads JSON5, and a file JSON.parse cannot read only loses this warning. + */ +function formatStandbyWarning( + actor: Pick, + entries: readonly ActorVersionSourceFile[] | undefined, +): string | undefined { + if (actor.actorStandby?.isEnabled === true) return undefined; + const config = entries?.find(({ name }) => name === ACTOR_CONFIG_PATH); + if (!config || config.format === 'BASE64' || typeof config.content !== 'string') return undefined; + try { + const parsed = JSON.parse(config.content) as { usesStandbyMode?: unknown }; + if (!parsed?.usesStandbyMode) return undefined; + } catch { + return undefined; + } + return ( + `Creating this version turned on Standby for the whole Actor, because its ${ACTOR_CONFIG_PATH} sets ` + + 'usesStandbyMode and Standby was off. Turn it off again in Apify Console if it should stay off.' ); } @@ -191,11 +248,11 @@ function buildCopiedVersion( if (version.sourceType === ActorSourceType.SourceFiles) { if (!Array.isArray(version.sourceFiles)) throw new UserInputError(hiddenText); const entries = version.sourceFiles; - return { ...env, sourceType, sourceBody: { sourceType, sourceFiles: entries }, entries, warnings: [] }; + const sourceBody = { sourceType: ActorSourceType.SourceFiles, sourceFiles: entries } as const; + return { ...env, sourceType, sourceBody, entries, warnings: [] }; } if (version.sourceType === ActorSourceType.GitRepo || version.sourceType === ActorSourceType.GitHubGist) { - const isGit = version.sourceType === ActorSourceType.GitRepo; - const url = isGit ? version.gitRepoUrl : version.gitHubGistUrl; + const url = version.sourceType === ActorSourceType.GitRepo ? version.gitRepoUrl : version.gitHubGistUrl; if (!url) throw new UserInputError(hiddenText); const shownUrl = formatUrlWithoutSecrets(url); const warnings = hasUrlSecrets(url) @@ -203,13 +260,20 @@ function buildCopiedVersion( `The credentials stored with the URL of version ${sourceVersionNumber} were copied too; they are not shown.`, ] : []; - const sourceBody = isGit ? { sourceType, gitRepoUrl: url } : { sourceType, gitHubGistUrl: url }; + const sourceBody: SourceBody = + version.sourceType === ActorSourceType.GitRepo + ? { sourceType: ActorSourceType.GitRepo, gitRepoUrl: url } + : { sourceType: ActorSourceType.GitHubGist, gitHubGistUrl: url }; return { ...env, sourceType, sourceBody, shownUrl, warnings }; } if (version.sourceType === ActorSourceType.Tarball) { + // apify push takes the tag from .actor/actor.json without --build-tag and always builds, so the hint sets one. throw new UserInputError( - `${subject} is stored as a zip (TARBALL), and this tool cannot copy zip-stored versions yet. Push the ` + - `source to the new version with the Apify CLI instead (apify push --version ${versionNumber}).`, + `${subject} is stored as a zip (TARBALL), and this tool cannot copy zip-stored versions yet. Give the ` + + 'source as files or gitRepoUrl instead. Or upload your local project folder (not the stored zip) ' + + `with the Apify CLI and a build tag no other version uses: apify push --version ${versionNumber} ` + + `--build-tag wip-${versionNumber.replace('.', '-')}. Without --build-tag, apify push takes the tag ` + + 'from .actor/actor.json, often latest, and it builds right away.', ); } throw new UserInputError(`${subject} has source type ${sourceType}, which this tool cannot copy.`); @@ -228,29 +292,21 @@ function buildNewVersion(source: Exclude): Ne } // The platform takes an empty list of files, which gives a version to fill later. const entries = source.kind === 'files' ? source.entries : []; - const emptyPaths = entries.filter(({ content }) => content === '').map(({ name }) => name); return { ...common, sourceType: ActorSourceType.SourceFiles, sourceBody: { sourceType: ActorSourceType.SourceFiles, sourceFiles: entries }, entries, - warnings: - source.kind === 'files' - ? [formatMissingDockerfileWarning(entries), formatEmptyFilesWarning(emptyPaths)].filter( - (warning): warning is string => warning !== undefined, - ) - : [], + warnings: source.kind === 'files' ? buildSentFilesWarnings(entries) : [], }; } function formatSourceText(source: RequestedSource, newVersion: NewVersion, fileCount: number): string { const filesText = `${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; - const urlText = - newVersion.sourceType === ActorSourceType.GitHubGist - ? `the GitHub gist ${newVersion.shownUrl}` - : `the Git repository ${newVersion.shownUrl}`; + const { sourceType, shownUrl } = newVersion; + const urlText = shownUrl === undefined ? '' : formatUrlSourceText({ sourceType, url: shownUrl }); if (source.kind === 'copy') { - const copiedText = newVersion.shownUrl === undefined ? filesText : `building from ${urlText}`; + const copiedText = shownUrl === undefined ? filesText : `building from ${urlText}`; return `as a copy of version ${source.versionNumber} (${copiedText})`; } if (source.kind === 'files') return `from ${filesText}`; @@ -283,10 +339,7 @@ function formatNextStep(params: { return formatBuildStartFailure('The version was created', buildResult.buildErrMessage, loadedToolNames); } if (source.kind === 'empty') { - const addText = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` - : ''; - return `The version has no files yet: add them${addText}, then build it.`; + return `The version has no files yet: add them${formatWithUpdateToolText(loadedToolNames)}, then build it.`; } return `The version has no build yet, so it cannot run until it is built.${formatBuildLaterHint(loadedToolNames)}`; } @@ -372,10 +425,11 @@ export const createActorVersion: ToolEntry = Object.freeze({ } else { newVersion = buildNewVersion(source); } + const standbyWarning = formatStandbyWarning(actor, newVersion.entries); + if (standbyWarning !== undefined) newVersion.warnings.push(standbyWarning); // A cancel before the POST creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); try { - // apify-client types the body as one of its version shapes; the source keys are built per type above. await client .actor(actor.id) .versions() @@ -385,7 +439,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ ...newVersion.sourceBody, ...(newVersion.envVars.length > 0 && { envVars: newVersion.envVars }), ...(newVersion.applyEnvVarsToBuild && { applyEnvVarsToBuild: true }), - } as unknown as ActorVersion); + } satisfies ActorVersion); } catch (error) { if (error instanceof ApifyApiError && error.type === VERSION_EXISTS_ERROR_TYPE) { throw new UserInputError(versionExistsText); diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index bd9bff45c..8c187ecfc 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -25,6 +25,7 @@ const deleteActorVersionArgs = z.object({ versionNumber: z.string().min(1).describe('The version to delete, in MAJOR.MINOR form, for example 0.2.'), }); +// TODO(#1439): name HELPER_TOOLS.ACTOR_DELETE here, gated on loadedToolNames, once delete-actor is registered. function formatLastVersionText(fullName: string, versionNumber: string): string { return ( `Nothing was deleted: version ${versionNumber} is the only version of ${fullName}, and an Actor must keep ` + @@ -32,6 +33,7 @@ function formatLastVersionText(fullName: string, versionNumber: string): string ); } +// TODO(#1439): name HELPER_TOOLS.ACTOR_DELETE in the last-version sentence, gated on hasTool, once delete-actor is registered. function buildDescription({ hasTool }: ToolDescriptionContext): string { const copyNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_CREATE) ? ` such as a working copy made with ${HELPER_TOOLS.ACTOR_VERSION_CREATE}` diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index 503456ced..fd1152d51 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -1,5 +1,5 @@ import type { Actor, ActorVersionSourceFile, Build } from 'apify-client'; -import { ApifyApiError } from 'apify-client'; +import { ActorSourceType, ApifyApiError } from 'apify-client'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; @@ -31,7 +31,7 @@ export const MAX_INLINE_SOURCE_BYTES = 3 * BYTES_PER_MIB; /** Content, oldText, and newText together per call, so one call stays well within the transports' body limits. */ export const MAX_CALL_CONTENT_BYTES = 2 * BYTES_PER_MIB; -/** Files one call sends: create-actor's files and update-actor-version's replaceFiles. */ +/** Files one call sends: the files input of create-actor and create-actor-version, and update-actor-version's replaceFiles. */ export const MAX_WRITE_FILES = 500; /** The build reads the Actor's configuration, such as its Dockerfile and input schema, from this file. */ @@ -45,7 +45,10 @@ const ACTOR_NAME_REGEX = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/; /** Groups of four base64 characters, the last one padded; no whitespace, no URL-safe alphabet. */ const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; -/** The input shape of one file, shared by create-actor's files and update-actor-version's replaceFiles. */ +/** + * The input shape of one file, shared by the files input of create-actor and create-actor-version and by + * update-actor-version's replaceFiles. + */ export const sourceFileArgs = z.object({ path: z.string().min(1).describe('Path relative to the Actor root, for example src/main.js.'), content: z.string().describe('The whole file content: text as is, or base64 when encoding is base64.'), @@ -136,12 +139,14 @@ export function resolveVersionNumber( actor: Pick, requestedVersionNumber: string | undefined, actorSelector: string, + /** Appended to the refusal of a version the Actor does not have, for example how to add it. */ + missingVersionHint = '', ): string { const versionNumbers = listVersionNumbers(actor); if (versionNumbers.length === 0) throw new UserInputError(`Actor '${actorSelector}' has no versions.`); if (requestedVersionNumber !== undefined && !versionNumbers.includes(requestedVersionNumber)) { throw new UserInputError( - `Actor '${actorSelector}' has no version ${requestedVersionNumber}; available versions: ${versionNumbers.join(', ')}.`, + `Actor '${actorSelector}' has no version ${requestedVersionNumber}; available versions: ${versionNumbers.join(', ')}.${missingVersionHint}`, ); } if (requestedVersionNumber === undefined && versionNumbers.length !== 1) { @@ -183,6 +188,13 @@ export function formatUrlWithoutSecrets(url: string): string { return url.slice(0, queryIndex) + (hashIndex === -1 ? '' : url.slice(hashIndex)); } +/** The source a version builds from a URL, for result text: the Git repository or the GitHub gist, and its URL. */ +export function formatUrlSourceText(source: { sourceType: string; url: string }): string { + return source.sourceType === ActorSourceType.GitHubGist + ? `the GitHub gist ${source.url}` + : `the Git repository ${source.url}`; +} + /** Whether the URL holds something `formatUrlWithoutSecrets` removes: a query string, or an http(s) user or password. */ export function hasUrlSecrets(url: string): boolean { if (!URL.canParse(url)) return url.includes('?'); @@ -231,13 +243,12 @@ export function buildSourceFileEntry(params: { } /** - * The entries to store for a whole file set sent by the caller, in the order sent. Throws `UserInputError` for a path - * that is not valid, is over 255 characters, or is given twice, and when `.actor/actor.json` is missing. `field` names - * the input field in the messages. + * The entries to store for files sent by the caller, in the order sent. Throws `UserInputError` for a path that is not + * valid, is over 255 characters, or is given twice. `field` names the input field in the messages. */ -export function parseInputFiles(files: readonly SourceFileArgs[], field: string): ActorVersionSourceFile[] { +export function parseInputFileEntries(files: readonly SourceFileArgs[], field: string): ActorVersionSourceFile[] { const seenPaths = new Set(); - const entries = files.map((file, index) => { + return files.map((file, index) => { const label = `${field}[${index}]`; const path = parseInputPath(file.path, `${label} path`); if (path.length > MAX_SOURCE_PATH_LENGTH) { @@ -252,15 +263,57 @@ export function parseInputFiles(files: readonly SourceFileArgs[], field: string) label: `${label} content for`, }); }); - if (!seenPaths.has(ACTOR_CONFIG_PATH)) { +} + +/** + * The entries for a whole new file set sent as files, as `parseInputFileEntries` gives them; also throws + * `UserInputError` when `.actor/actor.json` is missing. + */ +export function parseInputFiles(files: readonly SourceFileArgs[]): ActorVersionSourceFile[] { + const entries = parseInputFileEntries(files, 'files'); + if (!entries.some(({ name }) => name === ACTOR_CONFIG_PATH)) { throw new UserInputError( - `${field} must include ${ACTOR_CONFIG_PATH}: this tool requires it, since the build reads the Actor's ` + + `files must include ${ACTOR_CONFIG_PATH}: this tool requires it, since the build reads the Actor's ` + 'configuration from it.', ); } return entries; } +/** " with update-actor-version" when the session has it, for text that says how to add files later. */ +export function formatWithUpdateToolText(loadedToolNames: readonly string[]): string { + return loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) + ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` + : ''; +} + +/** + * Throws `UserInputError` when the files of a new Actor or version send more content than one call takes. Files + * within this cap always fit the platform's 3 MiB measure, which counts at most 1.25 bytes per UTF-8 byte. + */ +export function validateNewFilesCallSize( + files: readonly SourceFileArgs[], + { subject, loadedToolNames }: { subject: 'Actor' | 'version'; loadedToolNames: readonly string[] }, +): void { + validateCallContentSize( + files.map(({ content }) => content), + { + fieldsText: 'content', + recoveryText: + `Create the ${subject} with fewer files, then add the rest in later calls` + + `${formatWithUpdateToolText(loadedToolNames)}.`, + }, + ); +} + +/** The warnings for a file set the caller sent: no Dockerfile, and empty files the build skips. */ +export function buildSentFilesWarnings(entries: readonly ActorVersionSourceFile[]): string[] { + const emptyPaths = entries.filter(({ content }) => content === '').map(({ name }) => name); + return [formatMissingDockerfileWarning(entries), formatEmptyFilesWarning(emptyPaths)].filter( + (warning): warning is string => warning !== undefined, + ); +} + /** One file per path, the last stored entry winning as in get-actor-version, sorted by path; folder entries are left out. */ export function buildFilesManifest(entries: readonly ActorVersionSourceFile[]): SourceFile[] { const filesByPath = new Map(); @@ -285,7 +338,7 @@ function hasDockerfileField(entries: readonly ActorVersionSourceFile[]): boolean } /** The warning for files sent without a Dockerfile, which the build then replaces with the platform's default one. */ -export function formatMissingDockerfileWarning(entries: readonly ActorVersionSourceFile[]): string | undefined { +function formatMissingDockerfileWarning(entries: readonly ActorVersionSourceFile[]): string | undefined { const paths = new Set(entries.map(({ name }) => name.toLowerCase())); if (DOCKERFILE_PATHS.some((path) => paths.has(path)) || hasDockerfileField(entries)) return undefined; return ( diff --git a/src/tools/source/update_actor_version.ts b/src/tools/source/update_actor_version.ts index 53037d528..0e585cba1 100644 --- a/src/tools/source/update_actor_version.ts +++ b/src/tools/source/update_actor_version.ts @@ -31,6 +31,7 @@ import { formatBuildLaterHint, formatBuildStartFailure, formatEmptyFilesWarning, + formatUrlSourceText, formatUrlWithoutSecrets, getInlineSourceBytes, getSourceFileEntryBytes, @@ -38,6 +39,7 @@ import { isFolderEntry, MAX_INLINE_SOURCE_BYTES, MAX_WRITE_FILES, + parseInputFileEntries, parseInputPath, resolveOwnActor, resolveVersionNumber, @@ -313,20 +315,7 @@ function parseUpdateRequest(args: UpdateActorVersionArgs, loadedToolNames: reado ); const prepared = operations.map(parseOperation); if (replaceFiles === undefined) return { operations: prepared, gitRepoUrl }; - const seenPaths = new Set(); - const entries = replaceFiles.map((file, index) => { - const label = `replaceFiles[${index}]`; - const path = parseInputPath(file.path, `${label} path`); - if (seenPaths.has(path)) throw new UserInputError(`replaceFiles has ${path} more than once.`); - seenPaths.add(path); - return buildSourceFileEntry({ - path, - content: file.content, - encoding: file.encoding, - label: `${label} content for`, - }); - }); - return { operations: prepared, replaceFiles: entries }; + return { operations: prepared, replaceFiles: parseInputFileEntries(replaceFiles, 'replaceFiles') }; } type VersionTarget = { actorId: string; fullName: string; versionNumber: string }; @@ -371,12 +360,6 @@ function readCurrentSource(version: ActorVersion, { fullName, versionNumber }: V ); } -function formatUrlSourceText(source: { sourceType: string; url: string }): string { - return source.sourceType === ActorSourceType.GitHubGist - ? `the GitHub gist ${source.url}` - : `the Git repository ${source.url}`; -} - type ApplyState = { files: Map; /** Paths an operation targeted, so a write or edit that changed nothing is reported as unchanged. */ @@ -943,7 +926,10 @@ export const updateActorVersion: ToolEntry = Object.freeze({ try { const request = parseUpdateRequest(parsed, loadedToolNames); const { actor, fullName } = await resolveOwnActor({ client, apifyToken, actorSelector: parsed.actor }); - const versionNumber = resolveVersionNumber(actor, parsed.versionNumber, parsed.actor); + const addVersionHint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_CREATE) + ? ` To add it, use ${HELPER_TOOLS.ACTOR_VERSION_CREATE}.` + : ''; + const versionNumber = resolveVersionNumber(actor, parsed.versionNumber, parsed.actor, addVersionHint); const target = { actorId: actor.id, fullName, versionNumber }; const versionClient = client.actor(actor.id).version(versionNumber); const version = await versionClient.get(); diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 6e2633b59..c2dc06f6f 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -100,7 +100,8 @@ function mockSourceVersion(overrides: Record = {}) { { name: 'API_KEY', isSecret: true, valueHash: 'abc123' }, { name: 'REGION', value: 'eu' }, ], - sourceFiles: STORED_FILES, + // A copy of its own, so a POST body that equals STORED_FILES was not changed in place. + sourceFiles: structuredClone(STORED_FILES), ...overrides, }; } @@ -188,7 +189,7 @@ describe('create-actor-version', () => { it('uses plain punctuation and names no tool in the input schema', () => { expect(createActorVersion.description).not.toMatch(/[–—]/); const schemaText = JSON.stringify(createActorVersion.inputSchema); - for (const name of TOOL_NAMES) expect(schemaText).not.toContain(`${name} `); + for (const name of TOOL_NAMES) expect(schemaText).not.toContain(name); }); }); @@ -199,7 +200,10 @@ describe('create-actor-version', () => { expect(versionMock).toHaveBeenCalledWith('0.1'); const body = getPostBody(); expect(body.sourceType).toBe('SOURCE_FILES'); - expect(body.sourceFiles).toEqual(STORED_FILES); + expect(body.sourceFiles).toStrictEqual(STORED_FILES); + const sent = body.sourceFiles as { name: string; format?: string; folder?: boolean }[]; + expect(sent[1].format).toBe('BASE64'); + expect(sent).toContainEqual({ name: 'assets', folder: true }); expect(body.versionNumber).toBe('0.2'); }); @@ -325,7 +329,10 @@ describe('create-actor-version', () => { const text = await callToolExpectingUserError({ copyFromVersion: '0.1' }); expect(text).toBe( 'Version 0.1 of john/my-actor is stored as a zip (TARBALL), and this tool cannot copy zip-stored ' + - 'versions yet. Push the source to the new version with the Apify CLI instead (apify push --version 0.2).', + 'versions yet. Give the source as files or gitRepoUrl instead. Or upload your local project ' + + 'folder (not the stored zip) with the Apify CLI and a build tag no other version uses: apify ' + + 'push --version 0.2 --build-tag wip-0-2. Without --build-tag, apify push takes the tag from ' + + '.actor/actor.json, often latest, and it builds right away.', ); expect(versionsCreateMock).not.toHaveBeenCalled(); }); @@ -494,28 +501,95 @@ describe('create-actor-version', () => { expect(createActorVersion.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', files })).toBe(false); }); - it('refuses a versionNumber the Actor already has, naming update-actor-version only when loaded', async () => { + it('refuses a versionNumber the Actor already has, suggesting a free one first', async () => { const text = await callToolExpectingUserError({ versionNumber: '0.1', files: [ACTOR_JSON] }); expect(text).toBe( 'john/my-actor already has version 0.1, and this tool never changes an existing version. ' + - `Its versions: 0.1. To change it, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, + 'Its versions: 0.1. Pick another versionNumber, such as 0.2. ' + + `If you meant to change version 0.1 itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, ); const bare = await callToolExpectingUserError({ versionNumber: '0.1' }, [ HELPER_TOOLS.ACTOR_VERSION_CREATE, ]); - expect(bare).toContain('Pick another versionNumber, or change the existing version instead.'); + expect(bare).toBe( + 'john/my-actor already has version 0.1, and this tool never changes an existing version. ' + + 'Its versions: 0.1. Pick another versionNumber, such as 0.2.', + ); expectNoToolNamed(bare); expect(versionsCreateMock).not.toHaveBeenCalled(); }); - it('refuses a versionNumber that a racing call created first', async () => { + it('suggests a number after the highest version', async () => { + actorGetMock.mockResolvedValue( + mockActor({ + versions: [ + { versionNumber: '1.99', sourceType: 'SOURCE_FILES' }, + { versionNumber: '0.1', sourceType: 'SOURCE_FILES' }, + { versionNumber: '1.2', sourceType: 'SOURCE_FILES' }, + ], + }), + ); + const text = await callToolExpectingUserError({ versionNumber: '0.1' }); + expect(text).toContain('Its versions: 1.99, 0.1, 1.2. Pick another versionNumber, such as 2.0.'); + }); + + it('refuses a versionNumber that a racing call created first, listing it among the versions', async () => { versionsCreateMock.mockRejectedValue( apiError(403, 'Version with this number already exists', 'version-already-exists'), ); const text = await callToolExpectingUserError({ files: [ACTOR_JSON] }); - expect(text).toContain( - 'john/my-actor already has version 0.2, and this tool never changes an existing version.', + expect(text).toBe( + 'john/my-actor already has version 0.2, and this tool never changes an existing version. ' + + 'Its versions: 0.1, 0.2. Pick another versionNumber, such as 0.3. ' + + `If you meant to change version 0.2 itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, + ); + }); + }); + + describe('Standby', () => { + const STANDBY_WARNING = + 'Creating this version turned on Standby for the whole Actor, because its .actor/actor.json sets ' + + 'usesStandbyMode and Standby was off. Turn it off again in Apify Console if it should stay off.'; + const standbyConfig = { + path: '.actor/actor.json', + content: '{"actorSpecification": 1, "usesStandbyMode": true}', + }; + + it('warns when a copied actor.json turns Standby on for the Actor', async () => { + versionGetMock.mockResolvedValue( + mockSourceVersion({ + sourceFiles: [{ name: '.actor/actor.json', format: 'TEXT', content: standbyConfig.content }], + }), + ); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(result.structuredContent.warnings).toEqual([STANDBY_WARNING]); + expect(result.content[1].text).toContain(STANDBY_WARNING); + }); + + it('warns when sent files turn Standby on, and not when Standby is already on', async () => { + const result = await callTool({ files: [standbyConfig, DOCKERFILE] }); + expect(result.structuredContent.warnings).toEqual([STANDBY_WARNING]); + actorGetMock.mockResolvedValue(mockActor({ actorStandby: { isEnabled: true } })); + const enabled = await callTool({ files: [standbyConfig, DOCKERFILE] }); + expect(enabled.structuredContent.warnings).toEqual([]); + }); + + it('does not warn for an actor.json without usesStandbyMode, or one stored as BASE64', async () => { + const result = await callTool({ files: [ACTOR_JSON, DOCKERFILE] }); + expect(result.structuredContent.warnings).toEqual([]); + versionGetMock.mockResolvedValue( + mockSourceVersion({ + sourceFiles: [ + { + name: '.actor/actor.json', + format: 'BASE64', + content: Buffer.from(standbyConfig.content).toString('base64'), + }, + ], + }), ); + const copied = await callTool({ copyFromVersion: '0.1' }); + expect(copied.structuredContent.warnings).toEqual([]); }); }); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index d36254e98..f3ec28294 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -109,6 +109,11 @@ describe('delete-actor-version', () => { expect(full).not.toMatch(/[–—]/); }); + it('names no tool in the input schema', () => { + const schemaText = JSON.stringify(deleteActorVersion.inputSchema); + for (const name of TOOL_NAMES) expect(schemaText).not.toContain(name); + }); + it('deletes the version with one DELETE and says its builds and tags stay', async () => { const result = await callTool({}); expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); diff --git a/tests/unit/tools.update_actor_version.test.ts b/tests/unit/tools.update_actor_version.test.ts index debd661f1..a8dd008f9 100644 --- a/tests/unit/tools.update_actor_version.test.ts +++ b/tests/unit/tools.update_actor_version.test.ts @@ -1251,6 +1251,17 @@ describe('update-actor-version', () => { }); describe('input rules', () => { + it('refuses a version the Actor does not have, naming create-actor-version only when loaded', async () => { + const args = { versionNumber: '0.3', operations: [{ type: 'write', path: 'src/new.js', content: 'x' }] }; + expect(await callToolExpectingUserError(args)).toBe( + "Actor 'john/my-actor' has no version 0.3; available versions: 0.1. " + + `To add it, use ${HELPER_TOOLS.ACTOR_VERSION_CREATE}.`, + ); + const bare = await callToolExpectingUserError(args, [HELPER_TOOLS.ACTOR_VERSION_UPDATE]); + expect(bare).toBe("Actor 'john/my-actor' has no version 0.3; available versions: 0.1."); + expect(versionUpdateMock).not.toHaveBeenCalled(); + }); + it.each([ ['/etc/passwd', 'is absolute'], ['C:\\x.js', 'is absolute'], From 31025894f155b6eeb5d871070ffaba024203db29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 23:38:48 +0200 Subject: [PATCH 04/60] docs: Mark where create-actor-version copies a whole source A copy reads every file of the source version and sends them all back, because the Apify API cannot copy a version or apply atomic changes to an Actor's source. A TODO marks the place for when it can. --- src/tools/source/create_actor_version.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 9dbc34d2c..671194658 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -417,6 +417,9 @@ export const createActorVersion: ToolEntry = Object.freeze({ let newVersion: NewVersion; if (source.kind === 'copy') { const sourceVersionNumber = resolveVersionNumber(actor, source.versionNumber, parsed.actor); + // TODO: A copy reads every file of the source version here and sends them all back in the POST below: + // the Apify API cannot copy a version or apply atomic changes to an Actor's source. Once it can, let + // the platform make the copy. const sourceVersion = await client.actor(actor.id).version(sourceVersionNumber).get(); if (!sourceVersion) { throw new UserInputError(`Actor ${fullName} has no version ${sourceVersionNumber}.`); From 9dc2c5d5538c6335d8964dfdd51dce2e449d61b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 15:55:17 +0200 Subject: [PATCH 05/60] refactor: Simplify create-actor-version and delete-actor-version create-actor-version takes files or copyFromVersion and sends one POST. A copy takes the other version's stored files, its non-secret env vars, and applyEnvVarsToBuild from the Actor GET, never its build tag, and is refused for a version not stored as files. The version-exists check and its rewording, the Standby warning, the secret env var list, empty-version handling, and the summary variants are gone; the platform's own errors reach the caller. delete-actor-version keeps the Actor read before the DELETE, since apify-client reports a delete of a missing version as done, and leaves the last-version refusal to the platform. --- src/tools/source/create_actor_version.ts | 340 +++---------- src/tools/source/delete_actor_version.ts | 64 +-- src/tools/structured_output_schemas.ts | 23 +- tests/unit/tools.create_actor_version.test.ts | 471 +++++------------- tests/unit/tools.delete_actor_version.test.ts | 93 +--- 5 files changed, 227 insertions(+), 764 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 730879b75..e75a76cae 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -1,15 +1,13 @@ -import type { Actor, ActorEnvironmentVariable, ActorVersion, ActorVersionSourceFile } from 'apify-client'; -import { ActorSourceType, ApifyApiError } from 'apify-client'; +import type { ActorVersion } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; import { HELPER_TOOLS } from '../../const.js'; -import { UserInputError } from '../../errors.js'; -import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; -import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { TOOL_TYPE } from '../../types.js'; import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; -import { respondAborted } from '../../utils/mcp.js'; -import { listVersionNumbers } from '../builds/build_helpers.js'; +import { respondAborted, respondUserError } from '../../utils/mcp.js'; import { createActorVersionToolOutputSchema } from '../structured_output_schemas.js'; import { buildFilesManifest, buildFilesRevision } from './source_files.js'; import { @@ -23,249 +21,56 @@ import { sourceFileArgs, } from './source_helpers.js'; -/** The error type the platform returns when the Actor already has the version, also when two creates race. */ -const VERSION_EXISTS_ERROR_TYPE = 'version-already-exists'; - -/** The build reads the Actor's configuration, such as its Dockerfile and input schema, from this file. */ -const ACTOR_CONFIG_PATH = '.actor/actor.json'; - const createActorVersionArgs = z.object({ actor: z .string() .min(1) .describe( - 'The Actor to add the version to: its ID, or its full name as username/name or username~name. ' + - 'A name without the username is not enough.', - ), - versionNumber: z - .string() - .min(1) - .describe( - 'Number of the new version in MAJOR.MINOR form, for example 0.2, each part 0 to 99. The Actor must not have it yet.', + 'The Actor to add the version to: its ID, or its full name as username/name or username~name. A name without the username is not enough.', ), + versionNumber: z.string().min(1).describe('Number of the new version in MAJOR.MINOR form, for example 0.2.'), copyFromVersion: z .string() .min(1) .optional() - .describe( - 'Copy the source and the non-secret environment variables of this existing version, for example 0.1. ' + - 'The source is copied on the server and does not pass through this call.', - ), + .describe('The version to copy instead of sending files, for example 0.1.'), files: z.array(sourceFileArgs).min(1).optional().describe("The version's files, each with its whole content."), buildTag: z .string() .min(1) .optional() - .describe( - 'Tag that builds of the new version get, for example beta. No default: without it, its builds get no tag.', - ), + .describe('Tag that builds of the new version get, for example beta. No default: without it, they get no tag.'), autoBuild: z .boolean() .default(false) .describe('Start a build of the new version after creating it, and return without waiting. Default: false.'), }); -type CreateActorVersionArgs = z.infer; - -type RequestedSource = - | { kind: 'empty' } - | { kind: 'files'; entries: ActorVersionSourceFile[] } - | { kind: 'copy'; versionNumber: string }; - -/** The version to create, as the POST body and as the result describes it. */ -type NewVersion = { - entries: ActorVersionSourceFile[]; - envVars: ActorEnvironmentVariable[]; - applyEnvVarsToBuild: boolean; - secretEnvVarNames: string[]; -}; - -/** Checks the input without any API call; throws `UserInputError` for the first problem. */ -function parseCreateVersionRequest(args: CreateActorVersionArgs): RequestedSource { - const { copyFromVersion, files } = args; - if (copyFromVersion !== undefined && files !== undefined) { - throw new UserInputError('Give at most one of copyFromVersion or files.'); - } - if (copyFromVersion !== undefined) return { kind: 'copy', versionNumber: copyFromVersion }; - if (files === undefined) { - if (args.autoBuild) { - throw new UserInputError( - 'An empty version has nothing to build. Give copyFromVersion or files, or leave out autoBuild.', - ); - } - return { kind: 'empty' }; - } - return { kind: 'files', entries: files.map(buildSourceFileEntry) }; -} - -/** The platform's limit for each part of MAJOR.MINOR. */ -const MAX_VERSION_PART = 99; - -/** A number after the highest MAJOR.MINOR version, for the caller to pick instead; undefined when none is left. */ -function suggestFreeVersionNumber(versionNumbers: readonly string[]): string | undefined { - const parsed = versionNumbers.flatMap((versionNumber) => { - const match = /^(\d+)\.(\d+)$/.exec(versionNumber); - return match ? [{ major: Number(match[1]), minor: Number(match[2]) }] : []; - }); - if (parsed.length === 0) return undefined; - const { major, minor } = parsed.reduce((highest, part) => - part.major > highest.major || (part.major === highest.major && part.minor > highest.minor) ? part : highest, - ); - if (minor < MAX_VERSION_PART) return `${major}.${minor + 1}`; - return major < MAX_VERSION_PART ? `${major + 1}.0` : undefined; -} - -/** - * Leads with a free number, since a parallel caller that finds the number taken needs a version of its own; changing - * the existing version is offered only to a caller who meant that version. - */ -function formatVersionExistsText(params: { - fullName: string; - versionNumber: string; - versionNumbers: readonly string[]; - loadedToolNames: readonly string[]; -}): string { - const { fullName, versionNumber, loadedToolNames } = params; - // After a race the versions were read before the other call created this one. - const versionNumbers = params.versionNumbers.includes(versionNumber) - ? params.versionNumbers - : [...params.versionNumbers, versionNumber]; - const freeVersionNumber = suggestFreeVersionNumber(versionNumbers); - const pickText = - freeVersionNumber === undefined - ? ' Pick another versionNumber.' - : ` Pick another versionNumber, such as ${freeVersionNumber}.`; - const updateHint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` If you meant to change version ${versionNumber} itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` - : ''; - return ( - `${fullName} already has version ${versionNumber}, and this tool never changes an existing version. ` + - `Its versions: ${versionNumbers.join(', ')}.${pickText}${updateHint}` - ); -} - -/** - * The warning for a version that turns Standby on for the whole Actor. The platform's version POST does that when the - * first entry named `.actor/actor.json` sets usesStandbyMode and Standby is off. It parses the content as stored, so a - * BASE64 entry never turns it on; it reads JSON5, and a file JSON.parse cannot read only loses this warning. - */ -function formatStandbyWarning( - actor: Pick, - entries: readonly ActorVersionSourceFile[], -): string | undefined { - if (actor.actorStandby?.isEnabled === true) return undefined; - const config = entries.find(({ name }) => name === ACTOR_CONFIG_PATH); - if (!config || config.format === 'BASE64' || typeof config.content !== 'string') return undefined; - try { - const parsed = JSON.parse(config.content) as { usesStandbyMode?: unknown }; - if (!parsed?.usesStandbyMode) return undefined; - } catch { - return undefined; - } - return ( - `Creating this version turned on Standby for the whole Actor, because its ${ACTOR_CONFIG_PATH} sets ` + - 'usesStandbyMode and Standby was off. Turn it off again in Apify Console if it should stay off.' - ); -} - -/** - * Non-secret env vars go to the copy with their values. The API returns a secret with its value removed, so it cannot - * be copied; its name is returned for the caller to set it again. - */ -function splitEnvVars(envVars: readonly ActorEnvironmentVariable[] | undefined): { - envVars: ActorEnvironmentVariable[]; - secretEnvVarNames: string[]; -} { - const copied: ActorEnvironmentVariable[] = []; - const secretEnvVarNames: string[] = []; - for (const { name, value, isSecret } of envVars ?? []) { - if (name === undefined) continue; - if (isSecret === true) secretEnvVarNames.push(name); - else copied.push({ name, value, isSecret: false }); - } - return { envVars: copied, secretEnvVarNames }; -} - -/** - * The copy of a version as read: its files exactly as stored, so formats and folder entries are kept. Throws - * `UserInputError` for a version not stored as files and for one whose source the API hid. - */ -function buildCopiedVersion(version: ActorVersion, versionLabel: string): NewVersion { - return { - entries: extractSourceFiles(version, versionLabel), - ...splitEnvVars(version.envVars), - applyEnvVarsToBuild: version.applyEnvVarsToBuild === true, - }; -} - -function buildNewVersion(source: Exclude): NewVersion { - // The platform takes an empty list of files, which gives a version to fill later. - const entries = source.kind === 'files' ? source.entries : []; - return { entries, envVars: [], applyEnvVarsToBuild: false, secretEnvVarNames: [] }; -} - -function formatSourceText(source: RequestedSource, fileCount: number): string { - const filesText = `${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; - if (source.kind === 'copy') return `as a copy of version ${source.versionNumber} (${filesText})`; - if (source.kind === 'files') return `from ${filesText}`; - return 'with no files'; -} - -function formatEnvVarsNote(newVersion: NewVersion, versionNumber: string): string { - const { envVars, secretEnvVarNames } = newVersion; - const copiedNote = - envVars.length > 0 - ? ` Copied ${envVars.length} environment ${envVars.length === 1 ? 'variable with its value' : 'variables with their values'}.` - : ''; - const secretNote = - secretEnvVarNames.length > 0 - ? ` These secret environment variables were not copied, because their values cannot be read: ` + - `${secretEnvVarNames.join(', ')}. Set them on version ${versionNumber} yourself, for example in Apify Console.` - : ''; - return `${copiedNote}${secretNote}`; -} - -function buildDescription({ hasTool }: ToolDescriptionContext): string { - const updateNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_UPDATE) - ? ` To change an existing version, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.` - : ''; - const fillNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_UPDATE) ? ` with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}` : ''; - const waitNote = hasTool(HELPER_TOOLS.ACTOR_BUILD_GET) - ? ` Follow the build with ${HELPER_TOOLS.ACTOR_BUILD_GET}.` - : ''; - return dedent` - Add a new version to an Actor: a copy of another version, new files, or an empty version. - It never changes an existing version: a versionNumber the Actor already has is refused.${updateNote} - - copyFromVersion: copies that version's files on the server exactly as they are stored, so no file content passes through the call. Non-secret environment variables are copied with their values; secret values cannot be read, so their names come back in secretEnvVarsNotCopied. Only versions stored as files can be copied. - - files: every file with its path and content. Binary files take base64 content with encoding base64; files with a binary extension such as .png default to it. - - None of them: an empty version with no files, to fill later${fillNote}. - Give at most one of them. The result lists each file's hash and the version's revision. - buildTag has no default: without it, the version's builds get no tag, so a working copy never takes over a tag such as latest; run its builds by build number. autoBuild starts a build after creating the version and returns without waiting.${waitNote} - - USAGE: - - Use to make a working copy of a version to change and test while the tagged build keeps running, or to add a version with new source. - - USAGE EXAMPLES: - - user_input: Copy version 0.1 of my Actor john/my-scraper to a new version 0.2`; -} - /** * https://docs.apify.com/api/v2/actor-get * /v2/actors/{actorId} - * https://docs.apify.com/api/v2/actor-version-get - * /v2/actors/{actorId}/versions/{versionNumber} * https://docs.apify.com/api/v2/actor-versions-post * /v2/actors/{actorId}/versions * - * One POST creates the version with its source, so a failed call leaves nothing behind. A copy reads the other version - * in this call and sends its source as stored, so the content never passes through the model. + * One POST creates the version with its files, so a failed call leaves nothing behind. A copy sends the other + * version's files as the Actor GET returns them, so their content never passes through the model. */ export const createActorVersion: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, name: HELPER_TOOLS.ACTOR_VERSION_CREATE, title: 'Create Actor version', - description: buildDescription(ALL_TOOLS_PRESENT), - buildDescription, + description: dedent` + Add a new version to an existing Actor, with the files you give or as a copy of another version. It never changes an existing version: the platform refuses a versionNumber the Actor already has. + - files: every file with its path and content. Binary files take base64 content with encoding base64; files with a binary extension such as .png default to it. + - copyFromVersion: copies that version's files and non-secret environment variables on the server, so no file content passes through the call. Only versions stored as files can be copied. + buildTag has no default, and a copy does not take the tag of the version it copies: without buildTag, builds of the new version get no tag, so they never move a tag such as latest. + autoBuild starts a build after creating the version and returns without waiting. + + USAGE: + - Use to make a working copy of a version to change and test while the tagged build keeps running. + + USAGE EXAMPLES: + - user_input: Copy version 0.1 of my Actor john/my-scraper to a new version 0.2`, // `fixZodSchemaRequired` strips `autoBuild` from `required` because it has a default. inputSchema: fixZodSchemaRequired(z.toJSONSchema(createActorVersionArgs)) as ToolInputSchema, outputSchema: createActorVersionToolOutputSchema, @@ -279,70 +84,42 @@ export const createActorVersion: ToolEntry = Object.freeze({ openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const { args, apifyClient: client, loadedToolNames, signal } = toolArgs; + const { args, apifyClient: client, signal } = toolArgs; const parsed = createActorVersionArgs.parse(args); + const { versionNumber, copyFromVersion } = parsed; + if (copyFromVersion !== undefined && parsed.files !== undefined) { + return respondUserError('Give files or copyFromVersion, not both.'); + } try { - const source = parseCreateVersionRequest(parsed); const { actor, fullName } = await fetchActor(client, parsed.actor); - const { versionNumber } = parsed; - const versionNumbers = listVersionNumbers(actor); - const versionExistsText = formatVersionExistsText({ - fullName, - versionNumber, - versionNumbers, - loadedToolNames, - }); - if (versionNumbers.includes(versionNumber)) throw new UserInputError(versionExistsText); - let newVersion: NewVersion; - if (source.kind === 'copy') { - const sourceVersionNumber = resolveVersion(actor, source.versionNumber, parsed.actor).versionNumber; - // TODO: A copy reads every file of the source version here and sends them all back in the POST below: - // the Apify API cannot copy a version or apply atomic changes to an Actor's source. Once it can, let - // the platform make the copy. - const sourceVersion = await client.actor(actor.id).version(sourceVersionNumber).get(); - if (!sourceVersion) { - throw new UserInputError(`Actor ${fullName} has no version ${sourceVersionNumber}.`); - } - newVersion = buildCopiedVersion(sourceVersion, `Version ${sourceVersionNumber} of ${fullName}`); - } else { - newVersion = buildNewVersion(source); - } - const files = buildFilesManifest(newVersion.entries); - const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); - const standbyWarning = formatStandbyWarning(actor, newVersion.entries); - const warnings = [ - ...(buildEmptyFilesWarnings(emptyPaths).warnings ?? []), - ...(standbyWarning === undefined ? [] : [standbyWarning]), - ]; - // A cancel before the POST creates nothing; per the MCP spec the cancelled request gets no response. + // TODO: A copy takes every file of the other version from the Actor GET and sends them all back in the POST + // below: the Apify API cannot copy a version or apply atomic changes to an Actor's source. Once it can, let + // the platform make the copy. + const copied = + copyFromVersion === undefined ? undefined : resolveVersion(actor, copyFromVersion, parsed.actor); + const sourceFiles = copied + ? extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`) + : (parsed.files ?? []).map(buildSourceFileEntry); + // A cancel during the read creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); - try { - await client - .actor(actor.id) - .versions() - .create({ - versionNumber, - ...(parsed.buildTag !== undefined && { buildTag: parsed.buildTag }), - sourceType: ActorSourceType.SourceFiles, - sourceFiles: newVersion.entries, - ...(newVersion.envVars.length > 0 && { envVars: newVersion.envVars }), - ...(newVersion.applyEnvVarsToBuild && { applyEnvVarsToBuild: true }), - } satisfies ActorVersion); - } catch (error) { - if (error instanceof ApifyApiError && error.type === VERSION_EXISTS_ERROR_TYPE) { - throw new UserInputError(versionExistsText); - } - throw error; - } + await client + .actor(actor.id) + .versions() + .create({ + versionNumber, + ...(parsed.buildTag !== undefined && { buildTag: parsed.buildTag }), + sourceType: ActorSourceType.SourceFiles, + sourceFiles, + // The API returns a secret env var without its value, so a copy takes only the others. + ...(copied && { + envVars: copied.envVars?.filter(({ isSecret }) => !isSecret), + applyEnvVarsToBuild: copied.applyEnvVarsToBuild, + }), + } satisfies ActorVersion); // Per the MCP spec a cancelled request gets no response; the version stands, and no build is started. if (signal?.aborted) return respondAborted(); - const revision = buildFilesRevision(files); - const { secretEnvVarNames } = newVersion; - const tagText = - parsed.buildTag === undefined - ? 'no build tag, so its builds do not move tags such as latest' - : `build tag ${parsed.buildTag}`; - const warningNote = warnings.length > 0 ? ` ${warnings.join(' ')}` : ''; + const files = buildFilesManifest(sourceFiles); + const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); return await respondAfterWrite({ toolArgs, autoBuild: parsed.autoBuild, @@ -351,16 +128,11 @@ export const createActorVersion: ToolEntry = Object.freeze({ actorId: actor.id, fullName, versionNumber, - ...(parsed.buildTag !== undefined && { buildTag: parsed.buildTag }), - revision, + revision: buildFilesRevision(files), files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), - ...(source.kind === 'copy' && { copiedFromVersion: source.versionNumber }), - ...(secretEnvVarNames.length > 0 && { secretEnvVarsNotCopied: secretEnvVarNames }), - warnings, + ...buildEmptyFilesWarnings(emptyPaths), }, - summary: - `Created version ${versionNumber} of ${fullName} ${formatSourceText(source, files.length)}, ` + - `${tagText}, revision ${revision}.${formatEnvVarsNote(newVersion, versionNumber)}${warningNote}`, + summary: `Created version ${versionNumber} of ${fullName}.`, }); } catch (error) { return respondToSourceToolError(error); diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 66dfd4b03..858b8e349 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -1,56 +1,24 @@ -import { ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; import { HELPER_TOOLS } from '../../const.js'; -import { UserInputError } from '../../errors.js'; -import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; -import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondAborted, respondOk } from '../../utils/mcp.js'; import { deleteActorVersionToolOutputSchema } from '../structured_output_schemas.js'; import { fetchActor, resolveVersion, respondToSourceToolError } from './source_helpers.js'; -/** The error type the platform returns for a delete that would leave the Actor with no version. */ -const TOO_FEW_VERSIONS_ERROR_TYPE = 'too-few-versions'; - const deleteActorVersionArgs = z.object({ actor: z .string() .min(1) .describe( - 'The Actor to delete the version from: its ID, or its full name as username/name or username~name. ' + - 'A name without the username is not enough.', + 'The Actor to delete the version from: its ID, or its full name as username/name or username~name. A name without the username is not enough.', ), versionNumber: z.string().min(1).describe('The version to delete, in MAJOR.MINOR form, for example 0.2.'), }); -// TODO(#1439): name HELPER_TOOLS.ACTOR_DELETE here, gated on loadedToolNames, once delete-actor is registered. -function formatLastVersionText(fullName: string, versionNumber: string): string { - return ( - `Nothing was deleted: version ${versionNumber} is the only version of ${fullName}, and an Actor must keep ` + - 'at least one version. To remove it, delete the Actor instead.' - ); -} - -// TODO(#1439): name HELPER_TOOLS.ACTOR_DELETE in the last-version sentence, gated on hasTool, once delete-actor is registered. -function buildDescription({ hasTool }: ToolDescriptionContext): string { - const copyNote = hasTool(HELPER_TOOLS.ACTOR_VERSION_CREATE) - ? ` such as a working copy made with ${HELPER_TOOLS.ACTOR_VERSION_CREATE}` - : ''; - return dedent` - Delete one version of an Actor, with its source and environment variables. - An Actor must keep at least one version, so its last version cannot be deleted; delete the Actor instead. - The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. - - USAGE: - - Use to remove a version that is no longer needed${copyNote}. - - USAGE EXAMPLES: - - user_input: Delete version 0.2 of my Actor john/my-scraper - - user_input: Remove the working copies of my Actor now that the fix is in 0.1`; -} - /** * https://docs.apify.com/api/v2/actor-get * /v2/actors/{actorId} @@ -64,8 +32,15 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, name: HELPER_TOOLS.ACTOR_VERSION_DELETE, title: 'Delete Actor version', - description: buildDescription(ALL_TOOLS_PRESENT), - buildDescription, + description: dedent` + Delete one version of an Actor, with its source and environment variables. The platform refuses to delete an Actor's last version. + The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. + + USAGE: + - Use to remove a version that is no longer needed, such as a working copy. + + USAGE EXAMPLES: + - user_input: Delete version 0.2 of my Actor john/my-scraper`, inputSchema: z.toJSONSchema(deleteActorVersionArgs) as ToolInputSchema, outputSchema: deleteActorVersionToolOutputSchema, ajvValidate: compileSchema(z.toJSONSchema(deleteActorVersionArgs)), @@ -82,22 +57,11 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ try { const { actor, fullName } = await fetchActor(client, parsed.actor); const { versionNumber } = resolveVersion(actor, parsed.versionNumber, parsed.actor); - if (actor.versions.length <= 1) throw new UserInputError(formatLastVersionText(fullName, versionNumber)); // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); - try { - await client.actor(actor.id).version(versionNumber).delete(); - } catch (error) { - // Another writer deleted the other versions since the read. - if (error instanceof ApifyApiError && error.type === TOO_FEW_VERSIONS_ERROR_TYPE) { - throw new UserInputError(formatLastVersionText(fullName, versionNumber)); - } - throw error; - } + await client.actor(actor.id).version(versionNumber).delete(); const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true }; - const summary = - `Deleted version ${versionNumber} of ${fullName}. Its builds stay, and so do the tags that point to ` + - 'them: a run with such a tag still uses that build.'; + const summary = `Deleted version ${versionNumber} of ${fullName}.`; return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); } catch (error) { return respondToSourceToolError(error); diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 879355df4..b0424dc84 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -612,33 +612,18 @@ export const createActorToolOutputSchema = { required: ['actorId', 'fullName', 'versionNumber', 'revision', 'files'], }; -/** Schema for create-actor-version: the new version, its files, and what a copy could not take over. */ +/** Schema for create-actor-version: the new version and its files. */ export const createActorVersionToolOutputSchema = { type: 'object' as const, properties: { actorId: { type: 'string', description: 'Actor ID' }, fullName: { type: 'string', description: 'Actor full name, username/name' }, versionNumber: { type: 'string', description: 'Number of the new version, e.g. 0.2' }, - buildTag: { type: 'string', description: 'Tag that builds of the version get; absent when none was given' }, - revision: { - type: 'string', - description: 'Identifies the file set; the same value a later read of the version returns', - }, - files: { - type: 'array', - description: 'The files as stored, sorted by path; empty for an empty version', - items: getActorVersionToolOutputSchema.properties.files.items, - }, - copiedFromVersion: { type: 'string', description: 'The version the source was copied from' }, - secretEnvVarsNotCopied: { - type: 'array', - items: { type: 'string' }, - description: - 'Names of secret environment variables a copy could not take over, since their values cannot be read', - }, + revision: createActorToolOutputSchema.properties.revision, + files: createActorToolOutputSchema.properties.files, ...sourceWriteBuildProperties, }, - required: ['actorId', 'fullName', 'versionNumber', 'revision', 'files', 'warnings'], + required: ['actorId', 'fullName', 'versionNumber', 'revision', 'files'], }; /** Schema for delete-actor-version: the version that was deleted. */ diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 9b4e6a8a9..a74a0a206 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -13,31 +13,23 @@ import type { HelperTool, InternalToolArgs } from '../../src/types.js'; import { expectSchemaConformingStructuredContent, expectSoftFailInvalidInput, - only, stubToolCallContext, type TextToolResult, type ToolTelemetrySnapshot, } from './helpers/tool_context.js'; const actorGetMock = vi.fn(); -const versionGetMock = vi.fn(); const versionsCreateMock = vi.fn(); const buildMock = vi.fn(); -const versionMock = vi.fn(() => ({ get: versionGetMock })); const actorMock = vi.fn(() => ({ get: actorGetMock, - version: versionMock, versions: () => ({ create: versionsCreateMock }), build: buildMock, })); -const stubClient = { - actor: actorMock, - baseUrl: 'https://api.example.test/v2', -} as unknown as InternalToolArgs['apifyClient']; +const stubClient = { actor: actorMock } as unknown as InternalToolArgs['apifyClient']; const ACTOR_JSON = { path: '.actor/actor.json', content: '{"actorSpecification": 1}' }; -const DOCKERFILE = { path: 'Dockerfile', content: 'FROM apify/actor-node:20\n' }; const MAIN_JS = { path: 'src/main.js', content: 'console.log(1);\n' }; const LOGO_BYTES = Buffer.from([137, 80, 78, 71, 0, 255]); @@ -50,18 +42,13 @@ const STORED_FILES = [ { name: './src/main.js', format: 'TEXT', content: MAIN_JS.content }, ]; -const TOOL_NAMES = Object.values(HELPER_TOOLS); - type CreateVersionOutput = { actorId: string; fullName: string; versionNumber: string; - buildTag?: string; revision: string; files: { path: string; sizeBytes: number; hash: string }[]; - copiedFromVersion?: string; - secretEnvVarsNotCopied?: string[]; - warnings: string[]; + warnings?: string[]; build?: Record; buildError?: string; }; @@ -71,17 +58,7 @@ type CreateVersionResult = TextToolResult & { toolTelemetry?: ToolTelemetrySnapshot; }; -function mockActor(overrides: Record = {}) { - return { - id: 'actor-1', - name: 'my-actor', - username: 'john', - versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES', buildTag: 'latest' }], - ...overrides, - }; -} - -/** The version to copy, as the API returns it to the owner: a secret comes back with its value removed. */ +/** The version to copy, as the Actor GET returns it to the owner: a secret comes back with its value removed. */ function mockSourceVersion(overrides: Record = {}) { return { versionNumber: '0.1', @@ -98,6 +75,16 @@ function mockSourceVersion(overrides: Record = {}) { }; } +/** The Actor GET, which carries the versions with their files and env vars. */ +function mockActorRead(versionOverrides: Record = {}) { + actorGetMock.mockResolvedValue({ + id: 'actor-1', + name: 'my-actor', + username: 'john', + versions: [mockSourceVersion(versionOverrides)], + }); +} + function sha256Prefix(data: Buffer | string): string { return createHash('sha256').update(data).digest('hex').slice(0, 16); } @@ -106,20 +93,16 @@ function apiError(status: number, message: string, type = 'some-error'): ApifyAp return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); } -async function callTool( - args: Record, - loadedToolNames?: string[], - signal?: AbortSignal, -): Promise { +async function callTool(args: Record, signal?: AbortSignal): Promise { const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); - const withTools = loadedToolNames === undefined ? context : { ...context, loadedToolNames }; - const withSignal = signal === undefined ? withTools : { ...withTools, signal }; + const withSignal = signal === undefined ? context : { ...context, signal }; return (await (createActorVersion as HelperTool).call(withSignal)) as CreateVersionResult; } -async function callToolExpectingUserError(args: Record, loadedToolNames?: string[]) { - const result = await callTool(args, loadedToolNames); +async function callToolExpectingUserError(args: Record) { + const result = await callTool(args); expectSoftFailInvalidInput(result); + expect(versionsCreateMock).not.toHaveBeenCalled(); return result.content[0].text; } @@ -129,15 +112,10 @@ function getPostBody(): Record { return versionsCreateMock.mock.calls[0][0] as Record; } -function expectNoToolNamed(text: string) { - for (const name of TOOL_NAMES) expect(text).not.toContain(name); -} - describe('create-actor-version', () => { beforeEach(() => { vi.clearAllMocks(); - actorGetMock.mockResolvedValue(mockActor()); - versionGetMock.mockResolvedValue(mockSourceVersion()); + mockActorRead(); versionsCreateMock.mockImplementation(async (body: Record) => body); buildMock.mockResolvedValue({ id: 'build-1', @@ -160,338 +138,195 @@ describe('create-actor-version', () => { expect((createActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); - describe('description', () => { - const { buildDescription } = createActorVersion as HelperTool; - - it('names update-actor-version and get-actor-build only when the session has them', () => { - const full = - buildDescription?.(only(HELPER_TOOLS.ACTOR_VERSION_UPDATE, HELPER_TOOLS.ACTOR_BUILD_GET)) ?? ''; - expect(full).toBe(createActorVersion.description); - expect(full).toContain(`To change an existing version, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`); - expect(full).toContain(`to fill later with ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`); - expect(full).toContain(`Follow the build with ${HELPER_TOOLS.ACTOR_BUILD_GET}.`); - const bare = buildDescription?.(only()) ?? ''; - for (const name of TOOL_NAMES.filter((tool) => tool !== HELPER_TOOLS.ACTOR_VERSION_CREATE)) { - expect(bare).not.toContain(name); - } - expect(bare).toContain('an empty version with no files, to fill later.'); - }); - - it('uses plain punctuation and names no tool in the input schema', () => { - expect(createActorVersion.description).not.toMatch(/[–—]/); - const schemaText = JSON.stringify(createActorVersion.inputSchema); - for (const name of TOOL_NAMES) expect(schemaText).not.toContain(name); - }); - }); - describe('copyFromVersion', () => { it('sends the stored files exactly as read, formats and folder entries included', async () => { const result = await callTool({ copyFromVersion: '0.1' }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); - expect(versionMock).toHaveBeenCalledWith('0.1'); + expect(actorMock).toHaveBeenCalledWith('actor-1'); const body = getPostBody(); + expect(body.versionNumber).toBe('0.2'); expect(body.sourceType).toBe('SOURCE_FILES'); expect(body.sourceFiles).toStrictEqual(STORED_FILES); - const sent = body.sourceFiles as { name: string; format?: string; folder?: boolean }[]; - expect(sent[1].format).toBe('BASE64'); - expect(sent).toContainEqual({ name: 'assets', folder: true }); - expect(body.versionNumber).toBe('0.2'); }); it('returns the manifest and revision get-actor-version gives, with no content', async () => { const result = await callTool({ copyFromVersion: '0.1' }); + const read = (await (getActorVersion as HelperTool).call( + stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1' }, stubClient), + )) as { structuredContent: { revision: string; files: unknown[] } }; + + // The last entry for a path wins, as the build writes them in order. const files = [ { path: '.actor/actor.json', sizeBytes: ACTOR_JSON.content.length, hash: sha256Prefix(ACTOR_JSON.content), }, - { - path: 'assets/logo.png', - sizeBytes: LOGO_BYTES.length, - hash: sha256Prefix(LOGO_BYTES), - }, - // The last entry for a path wins, as the build writes them in order. - { - path: 'src/main.js', - sizeBytes: MAIN_JS.content.length, - hash: sha256Prefix(MAIN_JS.content), - }, + { path: 'assets/logo.png', sizeBytes: LOGO_BYTES.length, hash: sha256Prefix(LOGO_BYTES) }, + { path: 'src/main.js', sizeBytes: MAIN_JS.content.length, hash: sha256Prefix(MAIN_JS.content) }, ]; - expect(result.structuredContent.files).toEqual(files); - expect(result.structuredContent.revision).toBe(buildFilesRevision(files)); - expect(result.structuredContent.copiedFromVersion).toBe('0.1'); - - // get-actor-version reads the versions from the Actor GET. - actorGetMock.mockResolvedValue(mockActor({ versions: [mockSourceVersion()] })); - const read = (await (getActorVersion as HelperTool).call( - stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1', paths: [] }, stubClient), - )) as TextToolResult & { structuredContent: { revision: string; files: unknown[] } }; + expect(result.structuredContent).toEqual({ + actorId: 'actor-1', + fullName: 'john/my-actor', + versionNumber: '0.2', + revision: buildFilesRevision(files), + files, + }); expect(result.structuredContent.revision).toBe(read.structuredContent.revision); expect(result.structuredContent.files).toEqual(read.structuredContent.files); - - const output = result.content.map(({ text }) => text).join('\n'); - expect(output).not.toContain(MAIN_JS.content); - expect(output).not.toContain(LOGO_BYTES.toString('base64')); - expect(output).not.toContain('b2xkCg=='); - }); - - it('copies the non-secret env vars with their values and lists the secret ones', async () => { - const result = await callTool({ copyFromVersion: '0.1' }); - expect(getPostBody().envVars).toEqual([ - { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, - { name: 'REGION', value: 'eu', isSecret: false }, - ]); - expect(result.structuredContent.secretEnvVarsNotCopied).toEqual(['API_KEY']); - expect(result.content[1].text).toContain('Copied 2 environment variables with their values.'); - expect(result.content[1].text).toContain( - 'These secret environment variables were not copied, because their values cannot be read: API_KEY. ' + - 'Set them on version 0.2 yourself, for example in Apify Console.', + expect(result.content[1].text).toBe( + 'Created version 0.2 of john/my-actor.\nRuns use these files once the version is built.', ); - expect(result.content.map(({ text }) => text).join('\n')).not.toContain('abc123'); + expect(result.content.map(({ text }) => text).join('\n')).not.toContain(LOGO_BYTES.toString('base64')); }); - it('sends no env vars and lists no secrets for a version without them', async () => { - versionGetMock.mockResolvedValue(mockSourceVersion({ envVars: undefined })); - const result = await callTool({ copyFromVersion: '0.1' }); - expect(getPostBody()).not.toHaveProperty('envVars'); - expect(result.structuredContent).not.toHaveProperty('secretEnvVarsNotCopied'); - expect(result.content[1].text).not.toContain('environment variable'); - }); + it('copies the non-secret env vars and applyEnvVarsToBuild, never the build tag', async () => { + mockActorRead({ applyEnvVarsToBuild: true }); - it('keeps applyEnvVarsToBuild', async () => { - versionGetMock.mockResolvedValue(mockSourceVersion({ applyEnvVarsToBuild: true })); await callTool({ copyFromVersion: '0.1' }); - expect(getPostBody().applyEnvVarsToBuild).toBe(true); - }); - it('does not copy the build tag of the source version', async () => { - const result = await callTool({ copyFromVersion: '0.1' }); - expect(getPostBody()).not.toHaveProperty('buildTag'); - expect(result.structuredContent).not.toHaveProperty('buildTag'); - expect(result.content[1].text).toContain('no build tag, so its builds do not move tags such as latest'); + const body = getPostBody(); + expect(body.envVars).toEqual([ + { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, + { name: 'REGION', value: 'eu' }, + ]); + expect(body.applyEnvVarsToBuild).toBe(true); + expect(body).not.toHaveProperty('buildTag'); }); it('refuses a version not stored as files, naming its URL without credentials', async () => { - versionGetMock.mockResolvedValue( - mockSourceVersion({ - sourceType: 'TARBALL', - tarballUrl: 'https://x.test/a.zip?signature=secret', - sourceFiles: undefined, - }), - ); - const text = await callToolExpectingUserError({ copyFromVersion: '0.1' }); - expect(text).toBe( - 'Version 0.1 of john/my-actor is not stored as files (source type TARBALL, https://x.test/a.zip), ' + - 'and this tool works only on versions stored as files.', + mockActorRead({ + sourceType: 'TARBALL', + tarballUrl: 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip?signature=secret', + sourceFiles: undefined, + }); + + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + 'Version 0.1 of john/my-actor is not stored as files (source type TARBALL, ' + + 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip), and this tool works ' + + 'only on versions stored as files.', ); - expect(versionsCreateMock).not.toHaveBeenCalled(); }); - it('refuses a version whose source the API hid', async () => { - versionGetMock.mockResolvedValue(mockSourceVersion({ sourceFiles: undefined })); + it('refuses a version whose source the API hides', async () => { + mockActorRead({ sourceFiles: undefined }); + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toContain( 'Version 0.1 of john/my-actor came back without its source', ); - expect(versionsCreateMock).not.toHaveBeenCalled(); }); - it('refuses a copyFromVersion the Actor does not have, listing its versions', async () => { - const text = await callToolExpectingUserError({ copyFromVersion: '0.9' }); - expect(text).toBe("Actor 'john/my-actor' has no version 0.9; available versions: 0.1."); - expect(versionGetMock).not.toHaveBeenCalled(); - expect(versionsCreateMock).not.toHaveBeenCalled(); - }); - - it('refuses a source version that is gone by the time it is read', async () => { - versionGetMock.mockResolvedValue(undefined); - expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( - 'Actor john/my-actor has no version 0.1.', + it('refuses a copyFromVersion the Actor does not have', async () => { + expect(await callToolExpectingUserError({ copyFromVersion: '0.9' })).toBe( + "Actor 'john/my-actor' has no version 0.9; available versions: 0.1.", ); }); }); describe('files', () => { - it('creates a version from files in one POST', async () => { - const result = await callTool({ files: [MAIN_JS, ACTOR_JSON, DOCKERFILE], buildTag: 'beta' }); + it('creates a version from files in one POST, with no env vars', async () => { + const result = await callTool({ + files: [MAIN_JS, ACTOR_JSON, { path: 'assets/logo.png', content: 'AA==' }], + }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); - expect(actorMock).toHaveBeenCalledWith('actor-1'); expect(getPostBody()).toEqual({ versionNumber: '0.2', - buildTag: 'beta', sourceType: 'SOURCE_FILES', sourceFiles: [ { name: 'src/main.js', format: 'TEXT', content: MAIN_JS.content }, { name: '.actor/actor.json', format: 'TEXT', content: ACTOR_JSON.content }, - { name: 'Dockerfile', format: 'TEXT', content: DOCKERFILE.content }, + { name: 'assets/logo.png', format: 'BASE64', content: 'AA==' }, ], }); - const files = [ACTOR_JSON, DOCKERFILE, MAIN_JS].map(({ path, content }) => ({ - path, - sizeBytes: content.length, - hash: sha256Prefix(content), - })); - expect(result.structuredContent).toEqual({ - actorId: 'actor-1', - fullName: 'john/my-actor', - versionNumber: '0.2', - buildTag: 'beta', - revision: buildFilesRevision(files), - files, - warnings: [], - }); - expect(result.content[1].text).toBe( - `Created version 0.2 of john/my-actor from 3 files, build tag beta, revision ${buildFilesRevision(files)}.\n` + - 'Runs use these files once the version is built.', - ); + expect(result.structuredContent.files.map(({ path }) => path)).toEqual([ + '.actor/actor.json', + 'assets/logo.png', + 'src/main.js', + ]); }); it('warns about empty files, which the build skips', async () => { - const result = await callTool({ files: [ACTOR_JSON, { path: 'src/empty.js', content: '' }] }); + const result = await callTool({ files: [ACTOR_JSON, { path: 'src/__init__.py', content: '' }] }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(result.structuredContent.warnings).toEqual([ - 'These files are empty, and the build skips empty files, so they will not exist in the build: src/empty.js.', + 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', ]); }); - }); - describe('empty version', () => { - it('creates a version with an empty file list', async () => { - const result = await callTool({}); - expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + it('leaves a call with neither files nor copyFromVersion to the platform', async () => { + await callTool({}); + expect(getPostBody()).toEqual({ versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: [] }); - expect(result.structuredContent.files).toEqual([]); - expect(result.structuredContent.revision).toBe(buildFilesRevision([])); - expect(result.content[1].text).toBe( - `Created version 0.2 of john/my-actor with no files, no build tag, so its builds do not move tags such as latest, revision ${buildFilesRevision([])}.\n` + - 'Runs use these files once the version is built.', - ); }); - it('refuses autoBuild for an empty version before any request', async () => { - expect(await callToolExpectingUserError({ autoBuild: true })).toBe( - 'An empty version has nothing to build. Give copyFromVersion or files, or leave out autoBuild.', + it('refuses both files and copyFromVersion before any request', async () => { + expect(await callToolExpectingUserError({ copyFromVersion: '0.1', files: [ACTOR_JSON] })).toBe( + 'Give files or copyFromVersion, not both.', ); expect(actorMock).not.toHaveBeenCalled(); }); }); - describe('input rules', () => { - it('refuses both files and copyFromVersion', async () => { - expect(await callToolExpectingUserError({ copyFromVersion: '0.1', files: [ACTOR_JSON] })).toBe( - 'Give at most one of copyFromVersion or files.', - ); - expect(actorMock).not.toHaveBeenCalled(); - }); + it('sends buildTag only when given', async () => { + await callTool({ copyFromVersion: '0.1', buildTag: 'beta' }); + expect(getPostBody().buildTag).toBe('beta'); + vi.clearAllMocks(); - it('lets the platform refuse a versionNumber format, with its error unchanged', async () => { - const error = apiError(400, 'Version number must be in MAJOR.MINOR format'); - versionsCreateMock.mockRejectedValue(error); - await expect(callTool({ versionNumber: '0.1.2' })).rejects.toBe(error); - }); + await callTool({ files: [ACTOR_JSON] }); + expect(getPostBody()).not.toHaveProperty('buildTag'); + }); - it('refuses a versionNumber the Actor already has, suggesting a free one first', async () => { - const text = await callToolExpectingUserError({ versionNumber: '0.1', files: [ACTOR_JSON] }); - expect(text).toBe( - 'john/my-actor already has version 0.1, and this tool never changes an existing version. ' + - 'Its versions: 0.1. Pick another versionNumber, such as 0.2. ' + - `If you meant to change version 0.1 itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, - ); - const bare = await callToolExpectingUserError({ versionNumber: '0.1' }, [ - HELPER_TOOLS.ACTOR_VERSION_CREATE, - ]); - expect(bare).toBe( - 'john/my-actor already has version 0.1, and this tool never changes an existing version. ' + - 'Its versions: 0.1. Pick another versionNumber, such as 0.2.', - ); - expectNoToolNamed(bare); - expect(versionsCreateMock).not.toHaveBeenCalled(); - }); + describe('Actor and API errors', () => { + it('reports a missing Actor', async () => { + actorGetMock.mockResolvedValue(undefined); - it('suggests a number after the highest version', async () => { - actorGetMock.mockResolvedValue( - mockActor({ - versions: [ - { versionNumber: '1.99', sourceType: 'SOURCE_FILES' }, - { versionNumber: '0.1', sourceType: 'SOURCE_FILES' }, - { versionNumber: '1.2', sourceType: 'SOURCE_FILES' }, - ], - }), + expect(await callToolExpectingUserError({ actor: 'my-actor', files: [ACTOR_JSON] })).toBe( + "Actor 'my-actor' not found. Give its ID or its full name, username/name; a name without the username is not enough.", ); - const text = await callToolExpectingUserError({ versionNumber: '0.1' }); - expect(text).toContain('Its versions: 1.99, 0.1, 1.2. Pick another versionNumber, such as 2.0.'); }); - it('refuses a versionNumber that a racing call created first, listing it among the versions', async () => { - versionsCreateMock.mockRejectedValue( - apiError(403, 'Version with this number already exists', 'version-already-exists'), - ); - const text = await callToolExpectingUserError({ files: [ACTOR_JSON] }); - expect(text).toBe( - 'john/my-actor already has version 0.2, and this tool never changes an existing version. ' + - 'Its versions: 0.1, 0.2. Pick another versionNumber, such as 0.3. ' + - `If you meant to change version 0.2 itself, use ${HELPER_TOOLS.ACTOR_VERSION_UPDATE}.`, - ); + it('lets an API error from the POST through unchanged, such as a version number already taken', async () => { + const error = apiError(403, 'Version with this number already exists', 'version-already-exists'); + versionsCreateMock.mockRejectedValue(error); + + await expect(callTool({ versionNumber: '0.1', files: [ACTOR_JSON] })).rejects.toBe(error); }); }); - describe('Standby', () => { - const STANDBY_WARNING = - 'Creating this version turned on Standby for the whole Actor, because its .actor/actor.json sets ' + - 'usesStandbyMode and Standby was off. Turn it off again in Apify Console if it should stay off.'; - const standbyConfig = { - path: '.actor/actor.json', - content: '{"actorSpecification": 1, "usesStandbyMode": true}', - }; - - it('warns when a copied actor.json turns Standby on for the Actor', async () => { - versionGetMock.mockResolvedValue( - mockSourceVersion({ - sourceFiles: [{ name: '.actor/actor.json', format: 'TEXT', content: standbyConfig.content }], - }), - ); - const result = await callTool({ copyFromVersion: '0.1' }); - expect(result.structuredContent.warnings).toEqual([STANDBY_WARNING]); - expect(result.content[1].text).toContain(STANDBY_WARNING); - }); + describe('cancellation', () => { + it('creates nothing when the request is cancelled during the read', async () => { + const controller = new AbortController(); + actorGetMock.mockImplementation(async () => { + controller.abort(); + return { id: 'actor-1', name: 'my-actor', username: 'john', versions: [mockSourceVersion()] }; + }); - it('warns when sent files turn Standby on, and not when Standby is already on', async () => { - const result = await callTool({ files: [standbyConfig, DOCKERFILE] }); - expect(result.structuredContent.warnings).toEqual([STANDBY_WARNING]); - actorGetMock.mockResolvedValue(mockActor({ actorStandby: { isEnabled: true } })); - const enabled = await callTool({ files: [standbyConfig, DOCKERFILE] }); - expect(enabled.structuredContent.warnings).toEqual([]); - }); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); - it('does not warn for an actor.json without usesStandbyMode, or one stored as BASE64', async () => { - const result = await callTool({ files: [ACTOR_JSON, DOCKERFILE] }); - expect(result.structuredContent.warnings).toEqual([]); - versionGetMock.mockResolvedValue( - mockSourceVersion({ - sourceFiles: [ - { - name: '.actor/actor.json', - format: 'BASE64', - content: Buffer.from(standbyConfig.content).toString('base64'), - }, - ], - }), - ); - const copied = await callTool({ copyFromVersion: '0.1' }); - expect(copied.structuredContent.warnings).toEqual([]); + expect(result).toEqual({}); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expect(buildMock).not.toHaveBeenCalled(); }); - }); - describe('buildTag and autoBuild', () => { - it('sends buildTag only when given', async () => { - await callTool({ files: [ACTOR_JSON, DOCKERFILE] }); - expect(getPostBody()).not.toHaveProperty('buildTag'); - vi.clearAllMocks(); - await callTool({ copyFromVersion: '0.1', buildTag: 'latest' }); - expect(getPostBody().buildTag).toBe('latest'); + it('starts no build when the request is cancelled during the POST', async () => { + const controller = new AbortController(); + versionsCreateMock.mockImplementation(async () => controller.abort()); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); + + expect(result).toEqual({}); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expect(buildMock).not.toHaveBeenCalled(); }); + }); - it('starts a build without waiting and points at get-actor-build', async () => { + describe('autoBuild', () => { + it('starts a build without waiting', async () => { const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(buildMock).toHaveBeenCalledWith('0.2', { useCache: true }); expect(result.structuredContent.build).toEqual({ @@ -502,69 +337,23 @@ describe('create-actor-version', () => { startedAt: '2026-09-01T10:00:00.000Z', finishedAt: null, }); - expect(result.content[1].text).toContain( - `Check progress with ${HELPER_TOOLS.ACTOR_BUILD_GET} using buildId build-1`, - ); }); it('starts no build without autoBuild', async () => { await callTool({ copyFromVersion: '0.1' }); + expect(buildMock).not.toHaveBeenCalled(); }); it('reports a build that failed to start with the version still created', async () => { buildMock.mockRejectedValue(apiError(402, 'Not enough credit')); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(versionsCreateMock).toHaveBeenCalledTimes(1); expect(result.structuredContent.buildError).toBe('Not enough credit'); - expect(result.content[1].text).toContain( - 'The build could not be started; start it again to run these files.', - ); - }); - }); - - describe('cancellation', () => { - it('sends nothing when the request is cancelled during the reads', async () => { - const controller = new AbortController(); - versionGetMock.mockImplementation(async () => { - controller.abort(); - return mockSourceVersion(); - }); - const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, undefined, controller.signal); - expect(result).toEqual({}); - expect(versionsCreateMock).not.toHaveBeenCalled(); - expect(buildMock).not.toHaveBeenCalled(); - }); - - it('starts no build when the request is cancelled during the POST', async () => { - const controller = new AbortController(); - versionsCreateMock.mockImplementation(async (body: Record) => { - controller.abort(); - return body; - }); - const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, undefined, controller.signal); - expect(result).toEqual({}); - expect(versionsCreateMock).toHaveBeenCalledTimes(1); - expect(buildMock).not.toHaveBeenCalled(); - }); - }); - - describe('Actor', () => { - it('refuses an Actor that does not exist or a bare name', async () => { - actorGetMock.mockResolvedValue(undefined); - expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( - "Actor 'my-actor' not found. Give its ID or its full name, username/name; a name without the username is not enough.", - ); - expect(versionsCreateMock).not.toHaveBeenCalled(); - }); - }); - - describe('API errors', () => { - it('lets an API error from the POST through unchanged', async () => { - const error = apiError(403, 'The Actor can have at most 10 versions.', 'too-many-versions'); - versionsCreateMock.mockRejectedValue(error); - await expect(callTool({ copyFromVersion: '0.1' })).rejects.toBe(error); + expect(result.structuredContent).not.toHaveProperty('build'); }); }); }); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 889fd5efa..84535febf 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -9,7 +9,6 @@ import type { HelperTool, InternalToolArgs } from '../../src/types.js'; import { expectSchemaConformingStructuredContent, expectSoftFailInvalidInput, - only, stubToolCallContext, type TextToolResult, type ToolTelemetrySnapshot, @@ -20,12 +19,7 @@ const versionDeleteMock = vi.fn(); const versionMock = vi.fn(() => ({ delete: versionDeleteMock })); const actorMock = vi.fn(() => ({ get: actorGetMock, version: versionMock })); -const stubClient = { - actor: actorMock, - baseUrl: 'https://api.example.test/v2', -} as unknown as InternalToolArgs['apifyClient']; - -const TOOL_NAMES = Object.values(HELPER_TOOLS); +const stubClient = { actor: actorMock } as unknown as InternalToolArgs['apifyClient']; type DeleteResult = TextToolResult & { structuredContent: { actorId: string; fullName: string; versionNumber: string; deleted: boolean }; @@ -49,27 +43,19 @@ function apiError(status: number, message: string, type = 'some-error'): ApifyAp return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); } -async function callTool( - args: Record, - loadedToolNames?: string[], - signal?: AbortSignal, -): Promise { +async function callTool(args: Record, signal?: AbortSignal): Promise { const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); - const withTools = loadedToolNames === undefined ? context : { ...context, loadedToolNames }; - const withSignal = signal === undefined ? withTools : { ...withTools, signal }; + const withSignal = signal === undefined ? context : { ...context, signal }; return (await (deleteActorVersion as HelperTool).call(withSignal)) as DeleteResult; } async function callToolExpectingUserError(args: Record) { const result = await callTool(args); expectSoftFailInvalidInput(result); + expect(versionDeleteMock).not.toHaveBeenCalled(); return result.content[0].text; } -const LAST_VERSION_TEXT = - 'Nothing was deleted: version 0.1 is the only version of john/my-actor, and an Actor must keep at least one ' + - 'version. To remove it, delete the Actor instead.'; - describe('delete-actor-version', () => { beforeEach(() => { vi.clearAllMocks(); @@ -89,25 +75,9 @@ describe('delete-actor-version', () => { expect((deleteActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); - it('names create-actor-version in its description only when the session has it', () => { - const { buildDescription } = deleteActorVersion as HelperTool; - const full = buildDescription?.(only(HELPER_TOOLS.ACTOR_VERSION_CREATE)) ?? ''; - expect(full).toBe(deleteActorVersion.description); - expect(full).toContain(`such as a working copy made with ${HELPER_TOOLS.ACTOR_VERSION_CREATE}.`); - const bare = buildDescription?.(only()) ?? ''; - for (const name of TOOL_NAMES.filter((tool) => tool !== HELPER_TOOLS.ACTOR_VERSION_DELETE)) { - expect(bare).not.toContain(name); - } - expect(full).not.toMatch(/[–—]/); - }); - - it('names no tool in the input schema', () => { - const schemaText = JSON.stringify(deleteActorVersion.inputSchema); - for (const name of TOOL_NAMES) expect(schemaText).not.toContain(name); - }); - - it('deletes the version with one DELETE and says its builds and tags stay', async () => { + it('deletes the version with one DELETE', async () => { const result = await callTool({}); + expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); expect(actorMock).toHaveBeenCalledWith('actor-1'); expect(versionMock).toHaveBeenCalledWith('0.2'); @@ -118,32 +88,29 @@ describe('delete-actor-version', () => { versionNumber: '0.2', deleted: true, }); - expect(result.content[1].text).toBe( - 'Deleted version 0.2 of john/my-actor. Its builds stay, and so do the tags that point to them: a run ' + - 'with such a tag still uses that build.', - ); + expect(result.content[1].text).toBe('Deleted version 0.2 of john/my-actor.'); }); - it('refuses a version the Actor does not have, before any DELETE', async () => { + it('refuses a version the Actor does not have, which apify-client would report as deleted', async () => { expect(await callToolExpectingUserError({ versionNumber: '0.9' })).toBe( "Actor 'john/my-actor' has no version 0.9; available versions: 0.1, 0.2.", ); - expect(versionDeleteMock).not.toHaveBeenCalled(); }); - it('refuses the last version before any DELETE, naming no tool', async () => { - actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES' }] })); - const text = await callToolExpectingUserError({ versionNumber: '0.1' }); - expect(text).toBe(LAST_VERSION_TEXT); - for (const name of TOOL_NAMES) expect(text).not.toContain(name); - expect(versionDeleteMock).not.toHaveBeenCalled(); - }); + it('reports a missing Actor', async () => { + actorGetMock.mockResolvedValue(undefined); - it("maps the platform's too-few-versions refusal to the same user error", async () => { - versionDeleteMock.mockRejectedValue( - apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'), + expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( + "Actor 'my-actor' not found. Give its ID or its full name, username/name; a name without the username is not enough.", ); - expect(await callToolExpectingUserError({ versionNumber: '0.1' })).toBe(LAST_VERSION_TEXT); + }); + + it("lets the platform's refusal to delete the last version through unchanged", async () => { + actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES' }] })); + const error = apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'); + versionDeleteMock.mockRejectedValue(error); + + await expect(callTool({ versionNumber: '0.1' })).rejects.toBe(error); }); it('sends nothing when the request is cancelled before the DELETE', async () => { @@ -152,24 +119,10 @@ describe('delete-actor-version', () => { controller.abort(); return mockActor(); }); - const result = await callTool({}, undefined, controller.signal); - expect(result).toEqual({}); - expect(versionDeleteMock).not.toHaveBeenCalled(); - }); - it('refuses an Actor that does not exist or a bare name', async () => { - actorGetMock.mockResolvedValue(undefined); - expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( - "Actor 'my-actor' not found. Give its ID or its full name, username/name; a name without the username is not enough.", - ); - expect(versionDeleteMock).not.toHaveBeenCalled(); - }); + const result = await callTool({}, controller.signal); - describe('API errors', () => { - it('lets an API error from the DELETE through unchanged', async () => { - const error = apiError(403, 'Insufficient permissions'); - versionDeleteMock.mockRejectedValue(error); - await expect(callTool({})).rejects.toBe(error); - }); + expect(result).toEqual({}); + expect(versionDeleteMock).not.toHaveBeenCalled(); }); }); From 371f73255801aeda159dd19c0b2a73a8db59bb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 16:06:03 +0200 Subject: [PATCH 06/60] refactor: Address review of the create-actor-version and delete-actor-version simplification create-actor-version now refuses a call with neither files nor copyFromVersion before any request, as it already did for a call with both. The platform accepts an empty file list and creates an empty version, and with autoBuild it would also build that version, which the tool does not offer. --- src/tools/source/create_actor_version.ts | 6 +++--- tests/unit/tools.create_actor_version.test.ts | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index e75a76cae..d6322f6ae 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -87,8 +87,8 @@ export const createActorVersion: ToolEntry = Object.freeze({ const { args, apifyClient: client, signal } = toolArgs; const parsed = createActorVersionArgs.parse(args); const { versionNumber, copyFromVersion } = parsed; - if (copyFromVersion !== undefined && parsed.files !== undefined) { - return respondUserError('Give files or copyFromVersion, not both.'); + if ((copyFromVersion === undefined) === (parsed.files === undefined)) { + return respondUserError('Give either files or copyFromVersion.'); } try { const { actor, fullName } = await fetchActor(client, parsed.actor); @@ -99,7 +99,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ copyFromVersion === undefined ? undefined : resolveVersion(actor, copyFromVersion, parsed.actor); const sourceFiles = copied ? extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`) - : (parsed.files ?? []).map(buildSourceFileEntry); + : parsed.files!.map(buildSourceFileEntry); // A cancel during the read creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); await client diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index a74a0a206..3b9fcbd20 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -256,15 +256,14 @@ describe('create-actor-version', () => { ]); }); - it('leaves a call with neither files nor copyFromVersion to the platform', async () => { - await callTool({}); - - expect(getPostBody()).toEqual({ versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: [] }); + it('refuses a call with neither files nor copyFromVersion before any request', async () => { + expect(await callToolExpectingUserError({})).toBe('Give either files or copyFromVersion.'); + expect(actorMock).not.toHaveBeenCalled(); }); it('refuses both files and copyFromVersion before any request', async () => { expect(await callToolExpectingUserError({ copyFromVersion: '0.1', files: [ACTOR_JSON] })).toBe( - 'Give files or copyFromVersion, not both.', + 'Give either files or copyFromVersion.', ); expect(actorMock).not.toHaveBeenCalled(); }); From 19a04c5f995fd1905364fe422e5f61aa9db19b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 30 Sep 2026 10:30:55 +0200 Subject: [PATCH 07/60] test: Cover the copy body, secret env vars, Git and gist refusals, and the files manifest in create-actor-version --- tests/unit/tools.create_actor_version.test.ts | 215 +++++++++++++++--- 1 file changed, 182 insertions(+), 33 deletions(-) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 3b9fcbd20..674139355 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -21,13 +21,18 @@ import { const actorGetMock = vi.fn(); const versionsCreateMock = vi.fn(); const buildMock = vi.fn(); +// The Actor client's methods that change the Actor or one of its versions, which this tool never calls. +const otherWriteMocks = { update: vi.fn(), delete: vi.fn(), version: vi.fn() }; const actorMock = vi.fn(() => ({ get: actorGetMock, versions: () => ({ create: versionsCreateMock }), build: buildMock, + ...otherWriteMocks, })); +// The build client, which only waiting for a build uses. +const buildClientMock = vi.fn(); -const stubClient = { actor: actorMock } as unknown as InternalToolArgs['apifyClient']; +const stubClient = { actor: actorMock, build: buildClientMock } as unknown as InternalToolArgs['apifyClient']; const ACTOR_JSON = { path: '.actor/actor.json', content: '{"actorSpecification": 1}' }; const MAIN_JS = { path: 'src/main.js', content: 'console.log(1);\n' }; @@ -112,6 +117,12 @@ function getPostBody(): Record { return versionsCreateMock.mock.calls[0][0] as Record; } +function expectNoOtherWrite() { + for (const [name, mock] of Object.entries(otherWriteMocks)) { + expect(mock, name).not.toHaveBeenCalled(); + } +} + describe('create-actor-version', () => { beforeEach(() => { vi.clearAllMocks(); @@ -138,6 +149,29 @@ describe('create-actor-version', () => { expect((createActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); + it('requires the actor and versionNumber, and refuses an empty files list or copyFromVersion', () => { + const tool = createActorVersion as HelperTool; + + expect(tool.inputSchema.required).toEqual(['actor', 'versionNumber']); + expect(tool.ajvValidate({ actor: 'john/my-actor', copyFromVersion: '0.1' })).toBe(false); + expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', files: [] })).toBe(false); + expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', copyFromVersion: '' })).toBe(false); + expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', copyFromVersion: '0.1' })).toBe(true); + }); + + it.each([ + ['files', { files: [ACTOR_JSON] }], + ['a copy', { copyFromVersion: '0.1' }], + ])('only POSTs a new version to the Actor it resolved, for %s', async (_, args) => { + await callTool(args); + + expect(actorMock.mock.calls).toEqual([['john/my-actor'], ['actor-1']]); + expect(actorGetMock).toHaveBeenCalledTimes(1); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); + describe('copyFromVersion', () => { it('sends the stored files exactly as read, formats and folder entries included', async () => { const result = await callTool({ copyFromVersion: '0.1' }); @@ -184,36 +218,90 @@ describe('create-actor-version', () => { it('copies the non-secret env vars and applyEnvVarsToBuild, never the build tag', async () => { mockActorRead({ applyEnvVarsToBuild: true }); - await callTool({ copyFromVersion: '0.1' }); + const result = await callTool({ copyFromVersion: '0.1' }); - const body = getPostBody(); - expect(body.envVars).toEqual([ - { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, - { name: 'REGION', value: 'eu' }, - ]); - expect(body.applyEnvVarsToBuild).toBe(true); - expect(body).not.toHaveProperty('buildTag'); + expect(getPostBody()).toStrictEqual({ + versionNumber: '0.2', + sourceType: 'SOURCE_FILES', + sourceFiles: STORED_FILES, + envVars: [ + { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, + { name: 'REGION', value: 'eu' }, + ], + applyEnvVarsToBuild: true, + }); + // No env var reaches the output, and neither does the secret's name or hash. + expect(JSON.stringify(result)).not.toMatch(/API_KEY|abc123|LOG_LEVEL|REGION/); }); - it('refuses a version not stored as files, naming its URL without credentials', async () => { + it('never copies a secret env var, even one that comes back with its value', async () => { mockActorRead({ - sourceType: 'TARBALL', - tarballUrl: 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip?signature=secret', - sourceFiles: undefined, + envVars: [ + { name: 'API_KEY', value: 'secret-value', isSecret: true }, + { name: 'REGION', value: 'eu' }, + ], }); - expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( - 'Version 0.1 of john/my-actor is not stored as files (source type TARBALL, ' + - 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip), and this tool works ' + - 'only on versions stored as files.', - ); + const result = await callTool({ copyFromVersion: '0.1' }); + + expect(getPostBody().envVars).toStrictEqual([{ name: 'REGION', value: 'eu' }]); + expect(JSON.stringify(result)).not.toMatch(/API_KEY|secret-value/); }); + it.each([false, undefined])( + 'copies applyEnvVarsToBuild %s as the version has it', + async (applyEnvVarsToBuild) => { + mockActorRead({ applyEnvVarsToBuild }); + + await callTool({ copyFromVersion: '0.1' }); + + expect(getPostBody().applyEnvVarsToBuild).toBe(applyEnvVarsToBuild); + }, + ); + + it.each([ + [ + 'GIT_REPO', + { gitRepoUrl: 'https://oauth2:secret-token@gitlab.com/john/repo.git#main' }, + 'https://gitlab.com/john/repo.git#main', + ], + [ + 'GITHUB_GIST', + { gitHubGistUrl: 'https://gist.github.com/john/abc123?secret=secret-query' }, + 'https://gist.github.com/john/abc123', + ], + [ + 'TARBALL', + { + tarballUrl: + 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip?signature=secret', + }, + 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip', + ], + ])( + 'refuses a %s version with no POST, naming its URL without credentials', + async (sourceType, urlFields, url) => { + // The version keeps the files it had as SOURCE_FILES, which must not be copied. + mockActorRead({ sourceType, ...urlFields }); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + `Version 0.1 of john/my-actor is not stored as files (source type ${sourceType}, ${url}), and this tool works only on versions stored as files.`, + ); + expect(JSON.stringify(result)).not.toMatch(/secret|oauth2/); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }, + ); + it('refuses a version whose source the API hides', async () => { mockActorRead({ sourceFiles: undefined }); - expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toContain( - 'Version 0.1 of john/my-actor came back without its source', + expect(await callToolExpectingUserError({ copyFromVersion: '0.1' })).toBe( + "Version 0.1 of john/my-actor came back without its source: the API hides it from accounts that cannot modify the Actor. Ask the Actor's owner for the source.", ); }); @@ -247,6 +335,44 @@ describe('create-actor-version', () => { ]); }); + it('returns the manifest and revision get-actor-version gives for the files it sent', async () => { + const readme = '# My Actor\n'; + const result = await callTool({ + files: [ + { path: './src//main.js', content: MAIN_JS.content }, + ACTOR_JSON, + { path: 'assets/logo.png', content: LOGO_BYTES.toString('base64') }, + { path: 'README.md', content: Buffer.from(readme).toString('base64'), encoding: 'base64' }, + ], + }); + actorGetMock.mockResolvedValue({ + id: 'actor-1', + name: 'my-actor', + username: 'john', + versions: [ + { versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: getPostBody().sourceFiles }, + ], + }); + const read = (await (getActorVersion as HelperTool).call( + stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2' }, stubClient), + )) as { structuredContent: { revision: string; files: unknown[] } }; + + const files = [ + { + path: '.actor/actor.json', + sizeBytes: ACTOR_JSON.content.length, + hash: sha256Prefix(ACTOR_JSON.content), + }, + { path: 'README.md', sizeBytes: readme.length, hash: sha256Prefix(readme) }, + { path: 'assets/logo.png', sizeBytes: LOGO_BYTES.length, hash: sha256Prefix(LOGO_BYTES) }, + { path: 'src/main.js', sizeBytes: MAIN_JS.content.length, hash: sha256Prefix(MAIN_JS.content) }, + ]; + expect(result.structuredContent.files).toEqual(files); + expect(result.structuredContent.revision).toBe(buildFilesRevision(files)); + expect(result.structuredContent.revision).toBe(read.structuredContent.revision); + expect(result.structuredContent.files).toEqual(read.structuredContent.files); + }); + it('warns about empty files, which the build skips', async () => { const result = await callTool({ files: [ACTOR_JSON, { path: 'src/__init__.py', content: '' }] }); @@ -269,12 +395,16 @@ describe('create-actor-version', () => { }); }); - it('sends buildTag only when given', async () => { - await callTool({ copyFromVersion: '0.1', buildTag: 'beta' }); + it.each([ + ['files', { files: [ACTOR_JSON] }], + // The copied version has the build tag latest. + ['a copy', { copyFromVersion: '0.1' }], + ])('sends buildTag only when given, for %s', async (_, args) => { + await callTool({ ...args, buildTag: 'beta' }); expect(getPostBody().buildTag).toBe('beta'); vi.clearAllMocks(); - await callTool({ files: [ACTOR_JSON] }); + await callTool(args); expect(getPostBody()).not.toHaveProperty('buildTag'); }); @@ -287,12 +417,21 @@ describe('create-actor-version', () => { ); }); - it('lets an API error from the POST through unchanged, such as a version number already taken', async () => { - const error = apiError(403, 'Version with this number already exists', 'version-already-exists'); - versionsCreateMock.mockRejectedValue(error); - - await expect(callTool({ versionNumber: '0.1', files: [ACTOR_JSON] })).rejects.toBe(error); - }); + it.each([ + ['files', { files: [ACTOR_JSON] }], + ['a copy of that version', { copyFromVersion: '0.1' }], + ])( + 'POSTs a version number the Actor already has, from %s, and lets the API error through unchanged', + async (_, args) => { + const error = apiError(403, 'Version with this number already exists', 'version-already-exists'); + versionsCreateMock.mockRejectedValue(error); + + await expect(callTool({ versionNumber: '0.1', autoBuild: true, ...args })).rejects.toBe(error); + expect(getPostBody().versionNumber).toBe('0.1'); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }, + ); }); describe('cancellation', () => { @@ -307,10 +446,11 @@ describe('create-actor-version', () => { expect(result).toEqual({}); expect(versionsCreateMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); expect(buildMock).not.toHaveBeenCalled(); }); - it('starts no build when the request is cancelled during the POST', async () => { + it('keeps the version and starts no build when the request is cancelled during the POST', async () => { const controller = new AbortController(); versionsCreateMock.mockImplementation(async () => controller.abort()); @@ -318,16 +458,20 @@ describe('create-actor-version', () => { expect(result).toEqual({}); expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); expect(buildMock).not.toHaveBeenCalled(); }); }); describe('autoBuild', () => { - it('starts a build without waiting', async () => { + it('starts a build of the new version without waiting', async () => { const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); - expect(buildMock).toHaveBeenCalledWith('0.2', { useCache: true }); + // Actor GET, version POST, build start: no tag, so the new version's own buildTag applies. + expect(actorMock.mock.calls).toEqual([['john/my-actor'], ['actor-1'], ['actor-1']]); + expect(buildMock.mock.calls).toEqual([['0.2', { useCache: true }]]); + expect(buildClientMock).not.toHaveBeenCalled(); expect(result.structuredContent.build).toEqual({ id: 'build-1', actorId: 'actor-1', @@ -339,9 +483,14 @@ describe('create-actor-version', () => { }); it('starts no build without autoBuild', async () => { - await callTool({ copyFromVersion: '0.1' }); + const result = await callTool({ copyFromVersion: '0.1' }); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); expect(buildMock).not.toHaveBeenCalled(); + expect(result.structuredContent).not.toHaveProperty('build'); + expect(result.content[1].text).toBe( + 'Created version 0.2 of john/my-actor.\nRuns use these files once the version is built.', + ); }); it('reports a build that failed to start with the version still created', async () => { From e2d0c4bbd7a81a2f3cf24e6ee4ca39cfa4967fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 30 Sep 2026 10:30:55 +0200 Subject: [PATCH 08/60] test: Cover the exact version deleted, the required versionNumber, and no other write in delete-actor-version --- tests/unit/tools.delete_actor_version.test.ts | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 84535febf..b6715237c 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -16,8 +16,12 @@ import { const actorGetMock = vi.fn(); const versionDeleteMock = vi.fn(); -const versionMock = vi.fn(() => ({ delete: versionDeleteMock })); -const actorMock = vi.fn(() => ({ get: actorGetMock, version: versionMock })); +// The version client's other writes, which this tool never calls. +const versionUpdateMock = vi.fn(); +const versionMock = vi.fn(() => ({ delete: versionDeleteMock, update: versionUpdateMock })); +// The Actor client's methods that change or delete the Actor, create a version, or build, which this tool never calls. +const actorWriteMocks = { update: vi.fn(), delete: vi.fn(), versions: vi.fn(), build: vi.fn() }; +const actorMock = vi.fn(() => ({ get: actorGetMock, version: versionMock, ...actorWriteMocks })); const stubClient = { actor: actorMock } as unknown as InternalToolArgs['apifyClient']; @@ -56,6 +60,13 @@ async function callToolExpectingUserError(args: Record) { return result.content[0].text; } +function expectNoOtherWrite() { + expect(versionUpdateMock).not.toHaveBeenCalled(); + for (const [name, mock] of Object.entries(actorWriteMocks)) { + expect(mock, name).not.toHaveBeenCalled(); + } +} + describe('delete-actor-version', () => { beforeEach(() => { vi.clearAllMocks(); @@ -75,26 +86,43 @@ describe('delete-actor-version', () => { expect((deleteActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); - it('deletes the version with one DELETE', async () => { - const result = await callTool({}); + it('requires the actor and versionNumber, so it never picks a version itself', () => { + const tool = deleteActorVersion as HelperTool; - expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); - expect(actorMock).toHaveBeenCalledWith('actor-1'); - expect(versionMock).toHaveBeenCalledWith('0.2'); - expect(versionDeleteMock).toHaveBeenCalledTimes(1); - expect(result.structuredContent).toEqual({ - actorId: 'actor-1', - fullName: 'john/my-actor', - versionNumber: '0.2', - deleted: true, - }); - expect(result.content[1].text).toBe('Deleted version 0.2 of john/my-actor.'); + expect(tool.inputSchema.required).toEqual(['actor', 'versionNumber']); + expect(tool.ajvValidate({ actor: 'john/my-actor' })).toBe(false); + expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '' })).toBe(false); + expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2' })).toBe(true); }); + it.each(['0.1', '0.2'])( + 'deletes only version %s of the Actor it resolved, with one DELETE', + async (versionNumber) => { + const result = await callTool({ versionNumber }); + + expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); + // The Actor GET by the name given, then the DELETE by the Actor ID it returned. + expect(actorMock.mock.calls).toEqual([['john/my-actor'], ['actor-1']]); + expect(versionMock.mock.calls).toEqual([[versionNumber]]); + expect(versionDeleteMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); + expect(result.structuredContent).toEqual({ + actorId: 'actor-1', + fullName: 'john/my-actor', + versionNumber, + deleted: true, + }); + expect(result.content[1].text).toBe(`Deleted version ${versionNumber} of john/my-actor.`); + }, + ); + it('refuses a version the Actor does not have, which apify-client would report as deleted', async () => { expect(await callToolExpectingUserError({ versionNumber: '0.9' })).toBe( "Actor 'john/my-actor' has no version 0.9; available versions: 0.1, 0.2.", ); + expect(actorMock.mock.calls).toEqual([['john/my-actor']]); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); }); it('reports a missing Actor', async () => { @@ -103,6 +131,9 @@ describe('delete-actor-version', () => { expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( "Actor 'my-actor' not found. Give its ID or its full name, username/name; a name without the username is not enough.", ); + expect(actorMock.mock.calls).toEqual([['my-actor']]); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); }); it("lets the platform's refusal to delete the last version through unchanged", async () => { @@ -111,6 +142,10 @@ describe('delete-actor-version', () => { versionDeleteMock.mockRejectedValue(error); await expect(callTool({ versionNumber: '0.1' })).rejects.toBe(error); + // The tool leaves the check to the platform, and does nothing else after the refusal. + expect(versionMock.mock.calls).toEqual([['0.1']]); + expect(versionDeleteMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); }); it('sends nothing when the request is cancelled before the DELETE', async () => { @@ -124,5 +159,6 @@ describe('delete-actor-version', () => { expect(result).toEqual({}); expect(versionDeleteMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); }); }); From 2930937becae11ce4a74c5e10ccefd3dc9e1cc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 30 Sep 2026 10:42:41 +0200 Subject: [PATCH 09/60] test: Cover the copied version, the POSTed paths, and a cancel during the build start in create-actor-version --- tests/unit/tools.create_actor_version.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 674139355..e0abaef2e 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -248,6 +248,45 @@ describe('create-actor-version', () => { expect(JSON.stringify(result)).not.toMatch(/API_KEY|secret-value/); }); + it('copies the files and env vars of the version it names, not of another version', async () => { + const copiedFiles = [ + { name: 'main.py', format: 'TEXT', content: 'print(3)\n' }, + { name: 'data', folder: true }, + { name: 'data/seed.bin', format: 'BASE64', content: 'AAE=' }, + ]; + // The copied version sits between two others, so a copy of the first or the last sends another body. + actorGetMock.mockResolvedValue({ + id: 'actor-1', + name: 'my-actor', + username: 'john', + versions: [ + mockSourceVersion({ applyEnvVarsToBuild: false }), + mockSourceVersion({ + versionNumber: '0.3', + buildTag: 'beta', + envVars: [ + { name: 'MODE', value: 'test' }, + { name: 'TOKEN', isSecret: true, valueHash: 'def456' }, + ], + applyEnvVarsToBuild: true, + sourceFiles: structuredClone(copiedFiles), + }), + mockSourceVersion({ versionNumber: '0.5', applyEnvVarsToBuild: false }), + ], + }); + + const result = await callTool({ copyFromVersion: '0.3' }); + + expect(getPostBody()).toStrictEqual({ + versionNumber: '0.2', + sourceType: 'SOURCE_FILES', + sourceFiles: copiedFiles, + envVars: [{ name: 'MODE', value: 'test' }], + applyEnvVarsToBuild: true, + }); + expect(result.structuredContent.files.map(({ path }) => path)).toEqual(['data/seed.bin', 'main.py']); + }); + it.each([false, undefined])( 'copies applyEnvVarsToBuild %s as the version has it', async (applyEnvVarsToBuild) => { @@ -345,6 +384,13 @@ describe('create-actor-version', () => { { path: 'README.md', content: Buffer.from(readme).toString('base64'), encoding: 'base64' }, ], }); + // The path is stored normalized, the way get-actor-version lists it. + expect(getPostBody().sourceFiles).toStrictEqual([ + { name: 'src/main.js', format: 'TEXT', content: MAIN_JS.content }, + { name: '.actor/actor.json', format: 'TEXT', content: ACTOR_JSON.content }, + { name: 'assets/logo.png', format: 'BASE64', content: LOGO_BYTES.toString('base64') }, + { name: 'README.md', format: 'BASE64', content: Buffer.from(readme).toString('base64') }, + ]); actorGetMock.mockResolvedValue({ id: 'actor-1', name: 'my-actor', @@ -493,6 +539,37 @@ describe('create-actor-version', () => { ); }); + it('keeps the build it started when the request is cancelled during the build start', async () => { + const controller = new AbortController(); + buildMock.mockImplementation(async () => { + controller.abort(); + return { + id: 'build-1', + actId: 'actor-1', + buildNumber: '0.2.1', + status: 'RUNNING', + startedAt: new Date('2026-09-01T10:00:00.000Z'), + }; + }); + // The build client is what aborts a build. + const abortMock = vi.fn(async () => ({})); + buildClientMock.mockReturnValueOnce({ abort: abortMock }); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); + + expect(buildMock).toHaveBeenCalledTimes(1); + expect(buildClientMock).not.toHaveBeenCalled(); + expect(abortMock).not.toHaveBeenCalled(); + expect(result.structuredContent.build).toEqual({ + id: 'build-1', + actorId: 'actor-1', + buildNumber: '0.2.1', + status: 'RUNNING', + startedAt: '2026-09-01T10:00:00.000Z', + finishedAt: null, + }); + }); + it('reports a build that failed to start with the version still created', async () => { buildMock.mockRejectedValue(apiError(402, 'Not enough credit')); From b7369c1e1deb692307ff1c84a5a296348d618c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 1 Oct 2026 12:52:34 +0200 Subject: [PATCH 10/60] test: Expect the new refusal wording in create-actor-version --- tests/unit/tools.create_actor_version.test.ts | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index e0abaef2e..1d849b4c0 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -298,43 +298,45 @@ describe('create-actor-version', () => { }, ); - it.each([ - [ - 'GIT_REPO', - { gitRepoUrl: 'https://oauth2:secret-token@gitlab.com/john/repo.git#main' }, - 'https://gitlab.com/john/repo.git#main', - ], - [ - 'GITHUB_GIST', - { gitHubGistUrl: 'https://gist.github.com/john/abc123?secret=secret-query' }, - 'https://gist.github.com/john/abc123', - ], - [ - 'TARBALL', - { + it.each<{ outcome: string; version: Record; text: string }>([ + { + outcome: 'a GIT_REPO version, naming its URL without credentials', + version: { + sourceType: 'GIT_REPO', + gitRepoUrl: 'https://oauth2:secret-token@gitlab.com/john/repo.git#main', + }, + text: 'Version 0.1 of john/my-actor has its files in the Git repository https://gitlab.com/john/repo.git#main, not stored on Apify, so this tool cannot work on them; use the repository.', + }, + { + outcome: 'a GITHUB_GIST version, naming its URL without credentials', + version: { + sourceType: 'GITHUB_GIST', + gitHubGistUrl: 'https://gist.github.com/john/abc123?secret=secret-query', + }, + text: 'Version 0.1 of john/my-actor has its files in the GitHub gist https://gist.github.com/john/abc123, not stored on Apify, so this tool cannot work on them; use the gist.', + }, + { + outcome: 'a TARBALL version without naming its URL', + version: { + sourceType: 'TARBALL', tarballUrl: 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip?signature=secret', }, - 'https://api.example.test/v2/key-value-stores/s/records/version-0.1.zip', - ], - ])( - 'refuses a %s version with no POST, naming its URL without credentials', - async (sourceType, urlFields, url) => { - // The version keeps the files it had as SOURCE_FILES, which must not be copied. - mockActorRead({ sourceType, ...urlFields }); - - const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe( - `Version 0.1 of john/my-actor is not stored as files (source type ${sourceType}, ${url}), and this tool works only on versions stored as files.`, - ); - expect(JSON.stringify(result)).not.toMatch(/secret|oauth2/); - expect(versionsCreateMock).not.toHaveBeenCalled(); - expectNoOtherWrite(); - expect(buildMock).not.toHaveBeenCalled(); + text: 'Version 0.1 of john/my-actor is stored as a zip archive (apify push does this for sources over 3 MiB), and this tool cannot work on zip-stored versions yet.', }, - ); + ])('refuses $outcome, with no POST', async ({ version, text }) => { + // The version keeps the files it had as SOURCE_FILES, which must not be copied. + mockActorRead(version); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(text); + expect(JSON.stringify(result)).not.toMatch(/secret|oauth2|example|key-value-stores/); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); it('refuses a version whose source the API hides', async () => { mockActorRead({ sourceFiles: undefined }); From a9b7ca47711696fc72b29a552be654cce3fb1362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:27:49 +0200 Subject: [PATCH 11/60] fix: Refuse in create-actor-version the files create-actor refuses create-actor-version built the entries of a version from files with no check, so a path outside the Actor root, a folder path, the same path twice, a file under another file, text sent as base64, and text with a lone UTF-16 surrogate were POSTed and reported as created. The platform stores them, and the build then fails or writes a corrupt file. create-actor-version now builds the entries with the checks create-actor runs, moved to source_helpers.ts, and refuses before the POST. A copy still sends the other version's entries as stored. --- src/tools/source/create_actor.ts | 28 +------------ src/tools/source/create_actor_version.ts | 4 +- src/tools/source/source_helpers.ts | 21 ++++++++++ tests/unit/tools.create_actor_version.test.ts | 41 +++++++++++++++++++ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/src/tools/source/create_actor.ts b/src/tools/source/create_actor.ts index 4c3326e7e..68f99c7bb 100644 --- a/src/tools/source/create_actor.ts +++ b/src/tools/source/create_actor.ts @@ -1,10 +1,9 @@ -import type { ActorCollectionCreateOptions, ActorVersionSourceFile } from 'apify-client'; +import type { ActorCollectionCreateOptions } from 'apify-client'; import { ActorSourceType, ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; import { APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE, HELPER_TOOLS } from '../../const.js'; -import { UserInputError } from '../../errors.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { TOOL_TYPE } from '../../types.js'; import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; @@ -13,15 +12,12 @@ import { createActorToolOutputSchema } from '../structured_output_schemas.js'; import { buildFilesManifest, buildFilesRevision } from './source_files.js'; import { buildEmptyFilesWarnings, - buildSourceFileEntry, + buildSourceFileEntries, extractSourceFiles, resolveVersion, respondAfterWrite, respondToSourceToolError, sourceFileArgs, - validateFileContent, - validateFilePath, - validateNewFilePath, } from './source_helpers.js'; const createActorArgs = z.object({ @@ -42,26 +38,6 @@ const createActorArgs = z.object({ .describe('Start a build of the version after creating the Actor, and return without waiting. Default: false.'), }); -/** - * The entries to send, one per file; throws `UserInputError` for a path no file can be written at, and for a path - * given twice, since the platform puts the Actor name in the first .actor/actor.json and the build uses the last. - */ -function buildSourceFileEntries(files: readonly z.infer[]): ActorVersionSourceFile[] { - const entries: ActorVersionSourceFile[] = []; - for (const [index, file] of files.entries()) { - const entry = buildSourceFileEntry(file); - const label = `files[${index}] (${file.path})`; - validateFilePath(entry.name, label); - validateFileContent(entry, label); - if (entries.some(({ name }) => name === entry.name)) { - throw new UserInputError(`${label} repeats the path ${entry.name}; send each file once.`); - } - validateNewFilePath(entry.name, { filePaths: entries.map(({ name }) => name), folderPaths: [] }, label); - entries.push(entry); - } - return entries; -} - /** * https://docs.apify.com/api/v2/actors-post * /v2/actors diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index d6322f6ae..ce47c10d0 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -12,7 +12,7 @@ import { createActorVersionToolOutputSchema } from '../structured_output_schemas import { buildFilesManifest, buildFilesRevision } from './source_files.js'; import { buildEmptyFilesWarnings, - buildSourceFileEntry, + buildSourceFileEntries, extractSourceFiles, fetchActor, resolveVersion, @@ -99,7 +99,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ copyFromVersion === undefined ? undefined : resolveVersion(actor, copyFromVersion, parsed.actor); const sourceFiles = copied ? extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`) - : parsed.files!.map(buildSourceFileEntry); + : buildSourceFileEntries(parsed.files!); // A cancel during the read creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); await client diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index e715f23e6..dbf4ebf79 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -210,6 +210,27 @@ export function validateNewFilePath( throw new UserInputError(`${label} collides with ${collision}; one path cannot be both a file and a folder.`); } +/** + * The entries to send, one per file; throws `UserInputError` for a path no file can be written at, for content the + * stored file would not hold as sent, and for a path given twice, which leaves the version with two entries where the + * build uses the last. + */ +export function buildSourceFileEntries(files: readonly z.infer[]): ActorVersionSourceFile[] { + const entries: ActorVersionSourceFile[] = []; + for (const [index, file] of files.entries()) { + const entry = buildSourceFileEntry(file); + const label = `files[${index}] (${file.path})`; + validateFilePath(entry.name, label); + validateFileContent(entry, label); + if (entries.some(({ name }) => name === entry.name)) { + throw new UserInputError(`${label} repeats the path ${entry.name}; send each file once.`); + } + validateNewFilePath(entry.name, { filePaths: entries.map(({ name }) => name), folderPaths: [] }, label); + entries.push(entry); + } + return entries; +} + /** The build worker skips a file whose content is empty, so the caller hears of such files. */ export function buildEmptyFilesWarnings(paths: readonly string[]): { warnings?: string[] } { if (paths.length === 0) return {}; diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 1d849b4c0..96a887b24 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -430,6 +430,47 @@ describe('create-actor-version', () => { ]); }); + it.each<[string, Record[], string]>([ + [ + 'a path outside the Actor root', + [MAIN_JS, { path: '../x.js', content: 'x' }], + 'files[1] (../x.js) has a path outside the Actor root; give one relative to it, such as src/main.js.', + ], + [ + 'an absolute path', + [{ path: '/src/main.js', content: 'x' }], + 'files[0] (/src/main.js) has a path outside the Actor root; give one relative to it, such as src/main.js.', + ], + [ + 'a folder path', + [{ path: 'src/', content: 'x' }], + 'files[0] (src/) has a path that names a folder, not a file.', + ], + [ + 'the same path twice', + [MAIN_JS, { path: './src/main.js', content: '2' }], + 'files[1] (./src/main.js) repeats the path src/main.js; send each file once.', + ], + [ + 'a file under another file', + [{ path: 'src', content: 'x' }, MAIN_JS], + 'files[1] (src/main.js) collides with src; one path cannot be both a file and a folder.', + ], + [ + 'text sent to a binary extension', + [{ path: 'assets/a.png', content: 'hello world' }], + 'files[0] (assets/a.png) has content that is not valid base64; send binary content as base64, or text with encoding utf8.', + ], + [ + 'text with a lone UTF-16 surrogate', + [{ path: 'src/a.js', content: 'smile \uD83D' }], + 'files[0] (src/a.js) has text with a lone UTF-16 surrogate, which UTF-8 cannot store.', + ], + ])('refuses %s, and creates nothing', async (_, files, text) => { + expect(await callToolExpectingUserError({ files, autoBuild: true })).toBe(text); + expect(buildMock).not.toHaveBeenCalled(); + }); + it('refuses a call with neither files nor copyFromVersion before any request', async () => { expect(await callToolExpectingUserError({})).toBe('Give either files or copyFromVersion.'); expect(actorMock).not.toHaveBeenCalled(); From 128e2cd5b9335bf3b94f0e2a4b1bd886b24b1aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:28:41 +0200 Subject: [PATCH 12/60] fix: Say that a retried create-actor-version POST may have created the version apify-client retries the version POST after a network error, a timeout, a 429, or a 5xx. When the platform saved an attempt it did not answer, the retry fails with version-already-exists, and create-actor-version reported the version it had just created as taken. A refusal of the number on a retry now says that an earlier attempt may have created the version and that it should be read before calling create-actor-version again, as create-actor does for a name. --- src/const.ts | 1 + src/tools/source/create_actor_version.ts | 23 +++++++-- tests/unit/tools.create_actor_version.test.ts | 48 ++++++++++++++++++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/const.ts b/src/const.ts index 20ffe2a12..fe0d7abc2 100644 --- a/src/const.ts +++ b/src/const.ts @@ -305,6 +305,7 @@ export const APIFY_ERROR_TYPE_CANNOT_START_ACTOR_RUNS = 'cannot-start-actor-runs export const APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK = 'cannot-publish-actor-task'; export const APIFY_ERROR_TYPE_INVALID_INPUT = 'invalid-input'; export const APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE = 'actor-name-not-unique'; +export const APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS = 'version-already-exists'; // HTTP status codes export const HTTP_UNAUTHORIZED = 401; diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index ce47c10d0..e27df618b 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -1,9 +1,9 @@ import type { ActorVersion } from 'apify-client'; -import { ActorSourceType } from 'apify-client'; +import { ActorSourceType, ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; -import { HELPER_TOOLS } from '../../const.js'; +import { APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, HELPER_TOOLS } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { TOOL_TYPE } from '../../types.js'; import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; @@ -52,8 +52,10 @@ const createActorVersionArgs = z.object({ * https://docs.apify.com/api/v2/actor-versions-post * /v2/actors/{actorId}/versions * - * One POST creates the version with its files, so a failed call leaves nothing behind. A copy sends the other - * version's files as the Actor GET returns them, so their content never passes through the model. + * One POST creates the version with its files. apify-client retries the POST after a network error, a timeout, a 429, + * or a 5xx, so when the platform saved an attempt it did not answer, the retry fails on the number that attempt took, + * and the call says the version may exist. A copy sends the other version's files as the Actor GET returns them, so + * their content never passes through the model. */ export const createActorVersion: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -135,6 +137,19 @@ export const createActorVersion: ToolEntry = Object.freeze({ summary: `Created version ${versionNumber} of ${fullName}.`, }); } catch (error) { + if ( + error instanceof ApifyApiError && + error.type === APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS && + error.attempt > 1 + ) { + return respondUserError( + `The platform refused version ${versionNumber} as taken when the request was retried, so an ` + + 'earlier attempt of this call may have created it. Read it with ' + + `${HELPER_TOOLS.ACTOR_VERSION_GET} before calling ${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; ` + + 'if it holds the files this call sent, this call created it.', + { httpStatus: error.statusCode }, + ); + } return respondToSourceToolError(error); } }, diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 96a887b24..7a481486a 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -94,8 +94,9 @@ function sha256Prefix(data: Buffer | string): string { return createHash('sha256').update(data).digest('hex').slice(0, 16); } -function apiError(status: number, message: string, type = 'some-error'): ApifyApiError { - return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); +/** `attempt` counts apify-client's tries of the request, retries included. */ +function apiError(status: number, message: string, type = 'some-error', attempt = 1): ApifyApiError { + return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, attempt); } async function callTool(args: Record, signal?: AbortSignal): Promise { @@ -521,6 +522,49 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }, ); + + it.each([ + ['files', { files: [ACTOR_JSON] }], + ['a copy of that version', { copyFromVersion: '0.1' }], + ])( + 'says an earlier attempt may have created the version when the platform refuses the number on a retried POST, from %s', + async (_, args) => { + // apify-client retries a POST that timed out or got a 5xx, and the platform may have saved the attempt. + versionsCreateMock.mockRejectedValue( + apiError(403, 'Version with this number already exists', 'version-already-exists', 2), + ); + + const result = await callTool({ autoBuild: true, ...args }); + + expectSoftFailInvalidInput(result); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); + expect(result.structuredContent).toBeUndefined(); + expect(result.content).toStrictEqual([ + { + type: 'text', + text: + 'The platform refused version 0.2 as taken when the request was retried, so an earlier ' + + 'attempt of this call may have created it. Read it with get-actor-version before calling ' + + 'create-actor-version again; if it holds the files this call sent, this call created it.', + }, + ]); + expect(getPostBody().versionNumber).toBe('0.2'); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }, + ); + + it('lets any other API error of a retried POST through unchanged', async () => { + const error = apiError( + 403, + 'The limit on the combined size of all versions has been exceeded.', + 'versions-size-exceeded', + 2, + ); + versionsCreateMock.mockRejectedValue(error); + + await expect(callTool({ files: [ACTOR_JSON] })).rejects.toBe(error); + }); }); describe('cancellation', () => { From 3b03724333fdc624bb42b0d0ac27cc50a67cb55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:29:23 +0200 Subject: [PATCH 13/60] fix: Report a taken version number and the last-version refusal without the token hint The platform refuses a version number the Actor already has (version-already-exists) and the deletion of an Actor's last version (too-few-versions) with a 403. create-actor-version and delete-actor-version rethrew both, so the tool-call engine added "The resource may be private or your token may lack access." and recorded an AUTH failure. Both refusals now come back as a soft failure with the platform's message and error type, and no hint. Other API errors still go to the engine. --- src/const.ts | 1 + src/tools/source/source_helpers.ts | 13 ++++++- tests/unit/tools.create_actor_version.test.ts | 34 +++++++++++++------ tests/unit/tools.delete_actor_version.test.ts | 23 ++++++++++--- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/const.ts b/src/const.ts index fe0d7abc2..68015d33f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -306,6 +306,7 @@ export const APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK = 'cannot-publish-actor- export const APIFY_ERROR_TYPE_INVALID_INPUT = 'invalid-input'; export const APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE = 'actor-name-not-unique'; export const APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS = 'version-already-exists'; +export const APIFY_ERROR_TYPE_TOO_FEW_VERSIONS = 'too-few-versions'; // HTTP status codes export const HTTP_UNAUTHORIZED = 401; diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index dbf4ebf79..486986b2b 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -5,6 +5,7 @@ import { ActorSourceType, ApifyApiError } from 'apify-client'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; +import { APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS } from '../../const.js'; import { UserInputError } from '../../errors.js'; import type { ConsoleLinkContext, InternalToolArgs } from '../../types.js'; import { getConsoleLinkContext } from '../../utils/console_link.js'; @@ -288,8 +289,18 @@ export async function respondAfterWrite(params: { }); } -/** A `UserInputError` as a soft failure; anything else is rethrown for the tool-call engine to report. */ +/** + * A `UserInputError`, or the platform's refusal of a taken version number or of deleting the last version, as a soft + * failure with the platform's own message; anything else is rethrown for the tool-call engine to report. The engine + * would add a token-access hint to these two 403s, which contradicts their reason. + */ export function respondToSourceToolError(error: unknown): ToolResponse { if (error instanceof UserInputError) return respondUserError(error.message); + if ( + error instanceof ApifyApiError && + (error.type === APIFY_ERROR_TYPE_TOO_FEW_VERSIONS || error.type === APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS) + ) { + return respondUserError(`${error.message} (API error type: ${error.type})`, { httpStatus: error.statusCode }); + } throw error; } diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 7a481486a..8e36136e0 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -510,18 +510,30 @@ describe('create-actor-version', () => { it.each([ ['files', { files: [ACTOR_JSON] }], ['a copy of that version', { copyFromVersion: '0.1' }], - ])( - 'POSTs a version number the Actor already has, from %s, and lets the API error through unchanged', - async (_, args) => { - const error = apiError(403, 'Version with this number already exists', 'version-already-exists'); - versionsCreateMock.mockRejectedValue(error); + ])('reports a version number the Actor already has, from %s', async (_, args) => { + versionsCreateMock.mockRejectedValue( + apiError(403, 'Version with this number already exists', 'version-already-exists'), + ); - await expect(callTool({ versionNumber: '0.1', autoBuild: true, ...args })).rejects.toBe(error); - expect(getPostBody().versionNumber).toBe('0.1'); - expectNoOtherWrite(); - expect(buildMock).not.toHaveBeenCalled(); - }, - ); + const result = await callTool({ versionNumber: '0.1', autoBuild: true, ...args }); + + // The platform's reason, without the token-access hint the engine adds to a 403. + expectSoftFailInvalidInput(result); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); + expect(result.content[0].text).toBe( + 'Version with this number already exists (API error type: version-already-exists)', + ); + expect(getPostBody().versionNumber).toBe('0.1'); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); + + it('lets any other API error of the POST through to the engine', async () => { + const error = apiError(403, 'You do not have permission to modify this Actor.', 'insufficient-permissions'); + versionsCreateMock.mockRejectedValue(error); + + await expect(callTool({ files: [ACTOR_JSON] })).rejects.toBe(error); + }); it.each([ ['files', { files: [ACTOR_JSON] }], diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index b6715237c..037834b6f 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -136,18 +136,33 @@ describe('delete-actor-version', () => { expectNoOtherWrite(); }); - it("lets the platform's refusal to delete the last version through unchanged", async () => { + it("reports the platform's refusal to delete the last version", async () => { actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES' }] })); - const error = apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'); - versionDeleteMock.mockRejectedValue(error); + versionDeleteMock.mockRejectedValue( + apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'), + ); - await expect(callTool({ versionNumber: '0.1' })).rejects.toBe(error); + const result = await callTool({ versionNumber: '0.1' }); + + // The platform's reason, without the token-access hint the engine adds to a 403. + expectSoftFailInvalidInput(result); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); + expect(result.content[0].text).toBe( + 'The Actor must have at least 1 versions (API error type: too-few-versions)', + ); // The tool leaves the check to the platform, and does nothing else after the refusal. expect(versionMock.mock.calls).toEqual([['0.1']]); expect(versionDeleteMock).toHaveBeenCalledTimes(1); expectNoOtherWrite(); }); + it('lets any other API error of the DELETE through to the engine', async () => { + const error = apiError(403, 'You do not have permission to modify this Actor.', 'insufficient-permissions'); + versionDeleteMock.mockRejectedValue(error); + + await expect(callTool({})).rejects.toBe(error); + }); + it('sends nothing when the request is cancelled before the DELETE', async () => { const controller = new AbortController(); actorGetMock.mockImplementation(async () => { From d8941fa0b1ff9b22fe63c2ada1d2e3cbff1e6709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:29:46 +0200 Subject: [PATCH 14/60] fix: Tell the agent to build the version instead of saying runs use its files Without autoBuild, a source write ended with "Runs use these files once the version is built." A run without a build option uses the Actor's default build tag, usually latest, and a build gets the version's buildTag. A version create-actor-version makes has no buildTag unless one is given, so its builds are untagged and default runs never use its files. The next step now says "Build the version to run these files.", which holds for every source write tool, tagged or untagged. --- src/tools/source/source_helpers.ts | 2 +- tests/test_kit/cases/source.cases.ts | 4 ++-- tests/unit/tools.create_actor.test.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 4 ++-- tests/unit/tools.update_actor_version.test.ts | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index 486986b2b..5f069a97b 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -257,7 +257,7 @@ export async function respondAfterWrite(params: { return respondWithBuild({ structuredContent, summary, - nextStep: 'Runs use these files once the version is built.', + nextStep: 'Build the version to run these files.', }); } let linkContext: ConsoleLinkContext | undefined; diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 622075986..33ed87d2c 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -210,7 +210,7 @@ export const sourceCases: Case[] = [ files: expectedFiles, }); expect(createResult.content?.[1]?.text).toBe( - `Created the private Actor ${fullName}.\nRuns use these files once the version is built.`, + `Created the private Actor ${fullName}.\nBuild the version to run these files.`, ); const read = expectToolSuccess( @@ -353,7 +353,7 @@ export const sourceCases: Case[] = [ }); expect(updated.revision).not.toBe(before.revision); expect(updateResult.content?.[1]?.text).toBe( - `Updated version 0.0 of ${fullName}.\nRuns use these files once the version is built.`, + `Updated version 0.0 of ${fullName}.\nBuild the version to run these files.`, ); expect(after.files).toEqual([ buildFileListing('README.md', newReadme), diff --git a/tests/unit/tools.create_actor.test.ts b/tests/unit/tools.create_actor.test.ts index e0766a197..45fb96520 100644 --- a/tests/unit/tools.create_actor.test.ts +++ b/tests/unit/tools.create_actor.test.ts @@ -237,7 +237,7 @@ describe('create-actor', () => { expect(result.content).toHaveLength(2); expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); expect(result.content[1].text).toBe( - 'Created the private Actor john/my-actor.\nRuns use these files once the version is built.', + 'Created the private Actor john/my-actor.\nBuild the version to run these files.', ); // No other call: nothing reads or updates an existing Actor, and no build starts. expect(actorMock).not.toHaveBeenCalled(); diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 8e36136e0..fc89f0f77 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -211,7 +211,7 @@ describe('create-actor-version', () => { expect(result.structuredContent.revision).toBe(read.structuredContent.revision); expect(result.structuredContent.files).toEqual(read.structuredContent.files); expect(result.content[1].text).toBe( - 'Created version 0.2 of john/my-actor.\nRuns use these files once the version is built.', + 'Created version 0.2 of john/my-actor.\nBuild the version to run these files.', ); expect(result.content.map(({ text }) => text).join('\n')).not.toContain(LOGO_BYTES.toString('base64')); }); @@ -634,7 +634,7 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); expect(result.structuredContent).not.toHaveProperty('build'); expect(result.content[1].text).toBe( - 'Created version 0.2 of john/my-actor.\nRuns use these files once the version is built.', + 'Created version 0.2 of john/my-actor.\nBuild the version to run these files.', ); }); diff --git a/tests/unit/tools.update_actor_version.test.ts b/tests/unit/tools.update_actor_version.test.ts index 482966c9a..bb4b48322 100644 --- a/tests/unit/tools.update_actor_version.test.ts +++ b/tests/unit/tools.update_actor_version.test.ts @@ -201,7 +201,7 @@ describe('update-actor-version', () => { expect(result.content).toHaveLength(2); expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); expect(result.content[1].text).toBe( - 'Updated version 0.1 of john/my-actor.\nRuns use these files once the version is built.', + 'Updated version 0.1 of john/my-actor.\nBuild the version to run these files.', ); expect(buildMock).not.toHaveBeenCalled(); }); @@ -1684,7 +1684,7 @@ describe('update-actor-version', () => { expect(versionUpdateMock).not.toHaveBeenCalled(); expect(result.structuredContent).toEqual({ revision: await readRevision(), changed: false, changes: [] }); expect(result.content[1].text).toBe( - 'Nothing changed in version 0.1 of john/my-actor, so nothing was written.\nRuns use these files once the version is built.', + 'Nothing changed in version 0.1 of john/my-actor, so nothing was written.\nBuild the version to run these files.', ); }); @@ -1835,7 +1835,7 @@ describe('update-actor-version', () => { expect(actorMock.mock.calls).toEqual([[actor], ['actor-1']]); expect(result.content[1].text).toBe( - 'Updated version 0.1 of john/my-actor.\nRuns use these files once the version is built.', + 'Updated version 0.1 of john/my-actor.\nBuild the version to run these files.', ); }, ); From 5fe2c29b34d648d77fcb67fdecfa177b28cddd90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:31:24 +0200 Subject: [PATCH 15/60] test: Check that a version from files and a delete work beside a version stored in Git Only a copy in create-actor-version needs a version stored as files. The files-mode test now runs on an Actor whose other version is in a Git repository, and delete-actor-version deletes a Git version in the existing per-version test, so a refusal by source type added to either path fails them. --- tests/unit/tools.create_actor_version.test.ts | 6 ++++++ tests/unit/tools.delete_actor_version.test.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index fc89f0f77..227eee801 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -356,6 +356,12 @@ describe('create-actor-version', () => { describe('files', () => { it('creates a version from files in one POST, with no env vars', async () => { + // The Actor's other version is stored in Git, and a version from files does not depend on it. + mockActorRead({ + sourceType: 'GIT_REPO', + gitRepoUrl: 'https://github.com/john/repo', + sourceFiles: undefined, + }); const result = await callTool({ files: [MAIN_JS, ACTOR_JSON, { path: 'assets/logo.png', content: 'AA==' }], }); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 037834b6f..ef10c9002 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -35,8 +35,14 @@ function mockActor(overrides: Record = {}) { id: 'actor-1', name: 'my-actor', username: 'john', + // The tool deletes a version whatever it is stored as, so one is in Git. versions: [ - { versionNumber: '0.1', sourceType: 'SOURCE_FILES', buildTag: 'latest' }, + { + versionNumber: '0.1', + sourceType: 'GIT_REPO', + gitRepoUrl: 'https://github.com/john/repo', + buildTag: 'latest', + }, { versionNumber: '0.2', sourceType: 'SOURCE_FILES' }, ], ...overrides, From 6d7544b39742ad95e9b242ba8bf7c26fbbf5d9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:31:35 +0200 Subject: [PATCH 16/60] docs: Say which source tools refuse a version not stored as files src/tools/AGENTS.md said the source tools refuse versions stored in Git, a gist, or a zip. Only get-actor-version, update-actor-version, and a copy in create-actor-version do; create-actor-version from files and delete-actor-version work on any Actor. --- src/tools/AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 21cde6245..b42c21a16 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -45,8 +45,10 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. reports a delete of a missing version as done). `source_files.ts` holds the file shape, the manifest builder, the hash and revision rules, and the text-or-base64 detection; `source_helpers.ts` holds the Actor and version lookup, the file input shape, and the response - after a write, which starts the build for autoBuild. Versions not stored as files (Git repository, - gist, or zip) are refused. + after a write, which starts the build for autoBuild. get-actor-version, update-actor-version, and a + copy in create-actor-version refuse a version not stored as files (Git repository, gist, or zip); + create-actor-version from files and delete-actor-version work whatever the Actor's versions are + stored as. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). From 692396a340b25d98152a26d77708e8492657414c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:33:16 +0200 Subject: [PATCH 17/60] test: Add live cases for create-actor-version and delete-actor-version The unit tests mock the version POST to store what it is sent, so what the platform does with a copy was checked nowhere. The copy case seeds a version with a folder entry, entries without format or content, a secret and a non-secret env var, applyEnvVarsToBuild, and the latest tag, and checks that the copy stores the same entries, only the non-secret env var, applyEnvVarsToBuild, and no build tag, that its result matches get-actor-version, and that a second call is refused as taken with no token hint. The delete case deletes one version, refuses a version the Actor no longer has, and checks the platform's refusal of the last one. --- tests/test_kit/cases/source.cases.ts | 167 ++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 33ed87d2c..2b7507f78 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -10,8 +10,8 @@ import { validateStructuredOutputForTool, withClient } from '../helpers.js'; import type { Case, SuiteClient } from '../types.js'; /** - * create-actor and update-actor-version against the live API. Each case reads what the platform stored with the raw - * client, so it checks the stored source, not only what the tool reported. + * create-actor, create-actor-version, update-actor-version, and delete-actor-version against the live API. Each case + * reads what the platform stored with the raw client, so it checks the stored source, not only what the tool reported. * * Names are `test-source--`, never `eval-`, so the eval harness's `eval-*` sweep on the same account * never deletes an Actor mid-case. No case sets autoBuild, since builds cost time and money. Every case deletes its @@ -127,6 +127,13 @@ function extractWrittenState({ modifiedAt, versions }: StoredActorInfo) { return { modifiedAt, versions }; } +/** The stored version with this number; fails the case when there is none. */ +function findStoredVersion(actor: StoredActorInfo, versionNumber: string): StoredVersionInfo { + const version = actor.versions.find((candidate) => candidate.versionNumber === versionNumber); + expect(version, `version ${versionNumber}`).toBeDefined(); + return version as StoredVersionInfo; +} + /** The stored entries sorted by name; the order the platform keeps them in is not part of the contract. */ function extractSortedSourceFiles(version: StoredVersionInfo): StoredSourceFile[] { return [...(version.sourceFiles ?? [])].sort((a, b) => (a.name < b.name ? -1 : 1)); @@ -589,4 +596,160 @@ export const sourceCases: Case[] = [ } }), }, + { + name: "create-actor-version copies a version's stored entries, non-secret env vars, and applyEnvVarsToBuild, never its build tag, and returns the platform error for a version number the Actor already has", + isDeploymentTest: false, + run: withClient({ tools: ['source'] }, async (client, ctx) => { + const api = ctx.createApifyClient(); + const name = buildUniqueActorName('copy'); + const selector = await fetchOwnActorSelector(api, name); + const mainJs = 'console.log("copy");\n'; + const zJs = 'z();\n'; + try { + const seeded = await api.actors().create({ + name, + versions: [ + { + versionNumber: '0.0', + buildTag: 'latest', + applyEnvVarsToBuild: true, + sourceType: ActorSourceType.SourceFiles, + envVars: [ + { name: 'MODE', value: 'test' }, + { name: 'SECRET_MODE', value: 'hidden', isSecret: true }, + ], + sourceFiles: [ + { name: 'src/main.js', format: 'TEXT', content: mainJs }, + { name: 'assets/logo.png', format: 'BASE64', content: PNG_BASE64 }, + { + name: 'assets/raw-bytes', + format: 'BASE64', + content: NON_UTF8_BYTES.toString('base64'), + }, + // Entries with no format, with no content, and an empty folder, as the update case + // seeds them; a copy sends them back as the Actor GET returns them. + { name: 'src/z.js', content: zJs } as ActorVersionSourceFile, + { name: 'src/blank.js', format: 'TEXT' } as ActorVersionSourceFile, + { name: 'storage', folder: true } as unknown as ActorVersionSourceFile, + ], + }, + ], + }); + const storedBefore = await fetchStoredActor(api, seeded.id); + const copyArgs = { actor: selector, versionNumber: '0.1', copyFromVersion: '0.0' }; + + const created = expectToolSuccess( + await callTool(client, 'create-actor-version', copyArgs), + 'create-actor-version', + ); + const stored = await fetchStoredActor(api, seeded.id); + const original = findStoredVersion(stored, '0.0'); + const copy = findStoredVersion(stored, '0.1'); + + expect(stored.versions).toHaveLength(2); + expect(original).toEqual(findStoredVersion(storedBefore, '0.0')); + // Every entry, the folder and the ones without format or content included, is stored as it was read. + expect(extractSortedSourceFiles(copy)).toEqual(extractSortedSourceFiles(original)); + expect(copy.envVars).toEqual([expect.objectContaining({ name: 'MODE', value: 'test' })]); + expect(copy.applyEnvVarsToBuild).toBe(true); + expect(copy.buildTag).toBeUndefined(); + + const read = expectToolSuccess( + await callTool(client, 'get-actor-version', { actor: selector, versionNumber: '0.1' }), + 'get-actor-version', + ); + expect(read.files).toEqual([ + buildFileListing('assets/logo.png', Buffer.from(PNG_BASE64, 'base64')), + buildFileListing('assets/raw-bytes', NON_UTF8_BYTES), + buildFileListing('src/blank.js', ''), + buildFileListing('src/main.js', mainJs), + buildFileListing('src/z.js', zJs), + ]); + expect(created).toEqual({ + actorId: seeded.id, + fullName: `${seeded.username}/${name}`, + versionNumber: '0.1', + revision: read.revision, + files: read.files, + warnings: [ + 'These files are empty, and the build skips empty files, so they will not exist in the build: src/blank.js.', + ], + }); + expect(await fetchBuildCount(api, seeded.id)).toBe(0); + + // The sentence is the platform's, so only the API error type is exact, and no hint follows it. + // In apify-core it reads `Version with this number already exists`, with status 403. + const retryText = expectToolFailure(await callTool(client, 'create-actor-version', copyArgs)); + expect(retryText).toMatch(/^.+ \(API error type: version-already-exists\)$/); + expect(extractWrittenState(await fetchStoredActor(api, seeded.id))).toEqual( + extractWrittenState(stored), + ); + } finally { + await api.actor(selector).delete(); + } + }), + }, + { + name: 'delete-actor-version deletes one version, refuses one the Actor does not have, and returns the platform refusal for the last one', + isDeploymentTest: false, + run: withClient({ tools: ['source'] }, async (client, ctx) => { + const api = ctx.createApifyClient(); + const name = buildUniqueActorName('delete'); + const selector = await fetchOwnActorSelector(api, name); + try { + const seeded = await api.actors().create({ + name, + versions: [ + { + versionNumber: '0.0', + buildTag: 'latest', + sourceType: ActorSourceType.SourceFiles, + sourceFiles: [{ name: 'src/main.js', format: 'TEXT', content: 'console.log("0.0");\n' }], + }, + { + versionNumber: '0.1', + sourceType: ActorSourceType.SourceFiles, + sourceFiles: [{ name: 'src/main.js', format: 'TEXT', content: 'console.log("0.1");\n' }], + }, + ], + }); + const storedBefore = await fetchStoredActor(api, seeded.id); + expect(storedBefore.versions).toHaveLength(2); + + const deleted = expectToolSuccess( + await callTool(client, 'delete-actor-version', { actor: selector, versionNumber: '0.1' }), + 'delete-actor-version', + ); + expect(deleted).toEqual({ + actorId: seeded.id, + fullName: `${seeded.username}/${name}`, + versionNumber: '0.1', + deleted: true, + }); + const storedAfter = await fetchStoredActor(api, seeded.id); + expect(storedAfter.versions).toEqual([findStoredVersion(storedBefore, '0.0')]); + + // apify-client reports a DELETE of a missing version as done, so the tool refuses it from its read. + const missingText = expectToolFailure( + await callTool(client, 'delete-actor-version', { actor: selector, versionNumber: '0.1' }), + ); + expect(missingText).toBe(`Actor '${selector}' has no version 0.1; available versions: 0.0.`); + expect(extractWrittenState(await fetchStoredActor(api, seeded.id))).toEqual( + extractWrittenState(storedAfter), + ); + + // The sentence is the platform's, so only the API error type is exact, and no hint follows it. + // In apify-core it reads `The Actor must have at least 1 versions`, with status 403. + const lastText = expectToolFailure( + await callTool(client, 'delete-actor-version', { actor: selector, versionNumber: '0.0' }), + ); + expect(lastText).toMatch(/^.+ \(API error type: too-few-versions\)$/); + expect(extractWrittenState(await fetchStoredActor(api, seeded.id))).toEqual( + extractWrittenState(storedAfter), + ); + } finally { + await api.actor(selector).delete(); + } + }), + }, ]; From 06466d7c809170638d789f0c44c8d8abdea9389a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:37:30 +0200 Subject: [PATCH 18/60] test: Add live cases for a version from files and a copy of a version stored in Git No live case created a version from files, so the hashes and revision create-actor-version computes from the files it sent were never compared with what the platform stored. The new case adds 0.1 from files, with an actor.json that has no name, beside 0.0 tagged latest, and checks that get-actor-version reads back the same hashes and revision, that the stored entries are the ones sent, that 0.1 has no build tag, and that 0.0 is unchanged. The Git case now also asks for a copy of the Git-stored version and expects the same refusal, with no version created. --- tests/test_kit/cases/source.cases.ts | 87 +++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 2b7507f78..279e3a2c8 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -516,7 +516,7 @@ export const sourceCases: Case[] = [ }), }, { - name: 'update-actor-version refuses a version stored in a Git repository, names the repository without its credentials, and changes nothing', + name: 'update-actor-version and a copy in create-actor-version refuse a version stored in a Git repository, name the repository without its credentials, and change nothing', isDeploymentTest: false, run: withClient({ tools: ['source'] }, async (client, ctx) => { const api = ctx.createApifyClient(); @@ -543,9 +543,15 @@ export const sourceCases: Case[] = [ operations: [{ type: 'write', path: 'src/main.js', content: 'console.log("one");\n' }], }); const readResult = await callTool(client, 'get-actor-version', { actor: seeded.id }); + const copyResult = await callTool(client, 'create-actor-version', { + actor: seeded.id, + versionNumber: '0.1', + copyFromVersion: '0.0', + }); expect(expectToolFailure(updateResult)).toBe(refusal); expect(expectToolFailure(readResult)).toBe(refusal); + expect(expectToolFailure(copyResult)).toBe(refusal); expect(extractWrittenState(await fetchStoredActor(api, seeded.id))).toEqual( extractWrittenState(storedBefore), ); @@ -596,6 +602,85 @@ export const sourceCases: Case[] = [ } }), }, + { + name: 'create-actor-version adds a version from files that get-actor-version reads back with the same hashes and revision, with no build tag, and leaves the other version as it was', + isDeploymentTest: false, + run: withClient({ tools: ['source'] }, async (client, ctx) => { + const api = ctx.createApifyClient(); + const name = buildUniqueActorName('add'); + const selector = await fetchOwnActorSelector(api, name); + // No name: unlike the Actor create, the version POST stores actor.json as sent, so the hashes the tool + // computes from the files it sent are the stored ones. + const actorJson = '{\n "actorSpecification": 1,\n "version": "0.1"\n}\n'; + const mainJs = 'console.log("Grüße 🌍");\n'; + try { + const seeded = await api.actors().create({ + name, + versions: [ + { + versionNumber: '0.0', + buildTag: 'latest', + sourceType: ActorSourceType.SourceFiles, + sourceFiles: [{ name: 'src/main.js', format: 'TEXT', content: 'console.log("0.0");\n' }], + }, + ], + }); + const fullName = `${seeded.username}/${name}`; + const storedBefore = await fetchStoredActor(api, seeded.id); + + const createResult = await callTool(client, 'create-actor-version', { + actor: selector, + versionNumber: '0.1', + files: [ + { path: '.actor/actor.json', content: actorJson }, + { path: 'src/main.js', content: mainJs }, + // Binary by its extension, so base64 without an encoding. + { path: 'assets/logo.png', content: PNG_BASE64 }, + { path: 'assets/raw-bytes', content: NON_UTF8_BYTES.toString('base64'), encoding: 'base64' }, + ], + }); + const created = expectToolSuccess(createResult, 'create-actor-version'); + expect(created).toEqual({ + actorId: seeded.id, + fullName, + versionNumber: '0.1', + revision: expect.stringMatching(/^[0-9a-f]{16}$/), + files: [ + buildFileListing('.actor/actor.json', actorJson), + buildFileListing('assets/logo.png', Buffer.from(PNG_BASE64, 'base64')), + buildFileListing('assets/raw-bytes', NON_UTF8_BYTES), + buildFileListing('src/main.js', mainJs), + ], + }); + expect(createResult.content?.[1]?.text).toBe( + `Created version 0.1 of ${fullName}.\nBuild the version to run these files.`, + ); + + const read = expectToolSuccess( + await callTool(client, 'get-actor-version', { actor: selector, versionNumber: '0.1' }), + 'get-actor-version', + ); + expect(read).toEqual({ ...created, contents: [] }); + + const stored = await fetchStoredActor(api, seeded.id); + const added = findStoredVersion(stored, '0.1'); + expect(stored.versions).toHaveLength(2); + expect(findStoredVersion(stored, '0.0')).toEqual(findStoredVersion(storedBefore, '0.0')); + expect(findStoredVersion(stored, '0.0').buildTag).toBe('latest'); + expect(added.buildTag).toBeUndefined(); + expect(extractSortedSourceFiles(added)).toEqual([ + { name: '.actor/actor.json', format: 'TEXT', content: actorJson }, + { name: 'assets/logo.png', format: 'BASE64', content: PNG_BASE64 }, + { name: 'assets/raw-bytes', format: 'BASE64', content: NON_UTF8_BYTES.toString('base64') }, + { name: 'src/main.js', format: 'TEXT', content: mainJs }, + ]); + // autoBuild defaults to false. + expect(await fetchBuildCount(api, seeded.id)).toBe(0); + } finally { + await api.actor(selector).delete(); + } + }), + }, { name: "create-actor-version copies a version's stored entries, non-secret env vars, and applyEnvVarsToBuild, never its build tag, and returns the platform error for a version number the Actor already has", isDeploymentTest: false, From ccb1b1dd4a3ae30ee0569e954b7764735abcf126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 10:45:28 +0200 Subject: [PATCH 19/60] fix: Name get-actor-version in the retried-POST message only when the session has it The message create-actor-version returns when a retried POST is refused as taken named get-actor-version whatever tools the session was served. src/tools/AGENTS.md allows a tool name in result text only through loadedToolNames. The name is now gated in place, so a session with every tool gets the same text, and one without get-actor-version is told to read the version. --- src/tools/source/create_actor_version.ts | 11 +++++---- tests/unit/tools.create_actor_version.test.ts | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index e27df618b..3fbb735f6 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -86,7 +86,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const { args, apifyClient: client, signal } = toolArgs; + const { args, apifyClient: client, signal, loadedToolNames } = toolArgs; const parsed = createActorVersionArgs.parse(args); const { versionNumber, copyFromVersion } = parsed; if ((copyFromVersion === undefined) === (parsed.files === undefined)) { @@ -142,11 +142,14 @@ export const createActorVersion: ToolEntry = Object.freeze({ error.type === APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS && error.attempt > 1 ) { + const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) + ? ` with ${HELPER_TOOLS.ACTOR_VERSION_GET}` + : ''; return respondUserError( `The platform refused version ${versionNumber} as taken when the request was retried, so an ` + - 'earlier attempt of this call may have created it. Read it with ' + - `${HELPER_TOOLS.ACTOR_VERSION_GET} before calling ${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; ` + - 'if it holds the files this call sent, this call created it.', + `earlier attempt of this call may have created it. Read it${readTool} before calling ` + + `${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; if it holds the files this call sent, this call ` + + 'created it.', { httpStatus: error.statusCode }, ); } diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 227eee801..74035eb29 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -572,6 +572,29 @@ describe('create-actor-version', () => { }, ); + it('names get-actor-version on a retried POST only when the session has it', async () => { + versionsCreateMock.mockRejectedValue( + apiError(403, 'Version with this number already exists', 'version-already-exists', 2), + ); + const loadedToolNames = Object.values(HELPER_TOOLS).filter( + (name) => name !== HELPER_TOOLS.ACTOR_VERSION_GET, + ); + const context = stubToolCallContext( + { actor: 'john/my-actor', versionNumber: '0.2', files: [ACTOR_JSON] }, + stubClient, + { loadedToolNames }, + ); + + const result = (await (createActorVersion as HelperTool).call(context)) as CreateVersionResult; + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + 'The platform refused version 0.2 as taken when the request was retried, so an earlier attempt of ' + + 'this call may have created it. Read it before calling create-actor-version again; if it holds ' + + 'the files this call sent, this call created it.', + ); + }); + it('lets any other API error of a retried POST through unchanged', async () => { const error = apiError( 403, From b249f63b32137035c4f741351889bc8bc629eb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:11:08 +0200 Subject: [PATCH 20/60] test: Expect the create-actor-version reply to say to build the version before running it --- tests/test_kit/cases/source.cases.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 6a04fbc25..569848fab 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -653,7 +653,7 @@ export const sourceCases: Case[] = [ ], }); expect(createResult.content?.[1]?.text).toBe( - `Created version 0.1 of ${fullName}.\nBuild the version to run these files.`, + `Created version 0.1 of ${fullName}.\nBuild the version before running it.`, ); const read = expectToolSuccess( diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 74035eb29..46c91ca31 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -211,7 +211,7 @@ describe('create-actor-version', () => { expect(result.structuredContent.revision).toBe(read.structuredContent.revision); expect(result.structuredContent.files).toEqual(read.structuredContent.files); expect(result.content[1].text).toBe( - 'Created version 0.2 of john/my-actor.\nBuild the version to run these files.', + 'Created version 0.2 of john/my-actor.\nBuild the version before running it.', ); expect(result.content.map(({ text }) => text).join('\n')).not.toContain(LOGO_BYTES.toString('base64')); }); @@ -663,7 +663,7 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); expect(result.structuredContent).not.toHaveProperty('build'); expect(result.content[1].text).toBe( - 'Created version 0.2 of john/my-actor.\nBuild the version to run these files.', + 'Created version 0.2 of john/my-actor.\nBuild the version before running it.', ); }); From 247396eb71511c07c6512b8f4ef942b4392cf510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:14:40 +0200 Subject: [PATCH 21/60] fix: Name the secret env vars a copy of a version leaves out --- src/tools/source/create_actor_version.ts | 10 ++++- src/tools/structured_output_schemas.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 44 +++++++++++++++++-- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 3fbb735f6..2dd6c9143 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -122,6 +122,14 @@ export const createActorVersion: ToolEntry = Object.freeze({ if (signal?.aborted) return respondAborted(); const files = buildFilesManifest(sourceFiles); const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); + const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); + const secretNames = copied?.envVars?.filter(({ isSecret }) => isSecret).map(({ name }) => name) ?? []; + if (secretNames.length > 0) { + warnings.push( + `These secret environment variables were not copied, so set them on version ${versionNumber} ` + + `in Apify Console before building or running it: ${secretNames.join(', ')}.`, + ); + } return await respondAfterWrite({ toolArgs, autoBuild: parsed.autoBuild, @@ -132,7 +140,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ versionNumber, revision: buildFilesRevision(files), files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), - ...buildEmptyFilesWarnings(emptyPaths), + ...(warnings.length > 0 && { warnings }), }, summary: `Created version ${versionNumber} of ${fullName}.`, }); diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index c58fb1498..83732b1f4 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -611,7 +611,7 @@ const sourceWriteBuildProperties = { warnings: { type: 'array', items: { type: 'string' }, - description: 'Things to know about the written files, such as empty files the build skips', + description: 'Things to know about the written version, such as empty files the build skips', }, }; diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 46c91ca31..5027e1b9f 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -207,6 +207,9 @@ describe('create-actor-version', () => { versionNumber: '0.2', revision: buildFilesRevision(files), files, + warnings: [ + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console before building or running it: API_KEY.', + ], }); expect(result.structuredContent.revision).toBe(read.structuredContent.revision); expect(result.structuredContent.files).toEqual(read.structuredContent.files); @@ -231,8 +234,8 @@ describe('create-actor-version', () => { ], applyEnvVarsToBuild: true, }); - // No env var reaches the output, and neither does the secret's name or hash. - expect(JSON.stringify(result)).not.toMatch(/API_KEY|abc123|LOG_LEVEL|REGION/); + // No copied env var reaches the output, and neither does the secret's hash. + expect(JSON.stringify(result)).not.toMatch(/abc123|LOG_LEVEL|REGION/); }); it('never copies a secret env var, even one that comes back with its value', async () => { @@ -246,7 +249,42 @@ describe('create-actor-version', () => { const result = await callTool({ copyFromVersion: '0.1' }); expect(getPostBody().envVars).toStrictEqual([{ name: 'REGION', value: 'eu' }]); - expect(JSON.stringify(result)).not.toMatch(/API_KEY|secret-value/); + expect(JSON.stringify(result)).not.toContain('secret-value'); + }); + + it('warns about the secret env vars it left out, naming them after the empty files', async () => { + mockActorRead({ + envVars: [ + { name: 'API_KEY', isSecret: true, valueHash: 'abc123' }, + { name: 'REGION', value: 'eu' }, + { name: 'TOKEN', value: 'secret-value', isSecret: true }, + ], + sourceFiles: [ + ...structuredClone(STORED_FILES), + { name: 'src/__init__.py', format: 'TEXT', content: '' }, + ], + }); + + const result = await callTool({ copyFromVersion: '0.1' }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(result.structuredContent.warnings).toEqual([ + 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console before building or running it: API_KEY, TOKEN.', + ]); + expect(JSON.stringify(result)).not.toMatch(/abc123|secret-value/); + }); + + it.each([ + ['only non-secret env vars', [{ name: 'REGION', value: 'eu', isSecret: false }]], + ['no env vars', []], + ['no envVars field', undefined], + ])('adds no warning for a version with %s', async (_, envVars) => { + mockActorRead({ envVars }); + + const result = await callTool({ copyFromVersion: '0.1' }); + + expect(result.structuredContent).not.toHaveProperty('warnings'); }); it('copies the files and env vars of the version it names, not of another version', async () => { From a59d0fefef53917c7ef587a259af027181ae1bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:15:16 +0200 Subject: [PATCH 22/60] fix: Say a deleted version cannot be restored, and delete one only when the user asks --- src/tools/source/delete_actor_version.ts | 4 ++-- tests/unit/tools.delete_actor_version.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 858b8e349..4581d4adf 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -33,11 +33,11 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ name: HELPER_TOOLS.ACTOR_VERSION_DELETE, title: 'Delete Actor version', description: dedent` - Delete one version of an Actor, with its source and environment variables. The platform refuses to delete an Actor's last version. + Delete one version of an Actor, with its source and environment variables; a deleted version cannot be restored. The platform refuses to delete an Actor's last version. The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. USAGE: - - Use to remove a version that is no longer needed, such as a working copy. + - Use only when the user explicitly wants the version removed. USAGE EXAMPLES: - user_input: Delete version 0.2 of my Actor john/my-scraper`, diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index ef10c9002..9685144ca 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -92,6 +92,13 @@ describe('delete-actor-version', () => { expect((deleteActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); + it('says in its description that a deleted version cannot be restored, and to delete only on request', () => { + expect(deleteActorVersion.description).toContain('a deleted version cannot be restored'); + expect(deleteActorVersion.description).toContain( + '- Use only when the user explicitly wants the version removed.', + ); + }); + it('requires the actor and versionNumber, so it never picks a version itself', () => { const tool = deleteActorVersion as HelperTool; From deeff150ee6a4d86d736f74521a4af2ed7e05a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:16:33 +0200 Subject: [PATCH 23/60] fix: Say that runs with a deleted version's build tag still use its build --- src/tools/source/delete_actor_version.ts | 13 ++++++-- tests/unit/tools.delete_actor_version.test.ts | 33 ++++++++++++++++--- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 4581d4adf..5f7185aed 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -52,17 +52,24 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const { args, apifyClient: client, signal } = toolArgs; + const { args, apifyClient: client, signal, loadedToolNames } = toolArgs; const parsed = deleteActorVersionArgs.parse(args); try { const { actor, fullName } = await fetchActor(client, parsed.actor); - const { versionNumber } = resolveVersion(actor, parsed.versionNumber, parsed.actor); + const { versionNumber, buildTag } = resolveVersion(actor, parsed.versionNumber, parsed.actor); // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); await client.actor(actor.id).version(versionNumber).delete(); const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true }; const summary = `Deleted version ${versionNumber} of ${fullName}.`; - return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); + const buildTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_BUILD) + ? `${HELPER_TOOLS.ACTOR_BUILD} and ` + : ''; + const text = buildTag + ? `${summary}\nRuns with tag ${buildTag} still use the deleted version's build; to have them run ` + + `another version, build that version with ${buildTool}tag ${buildTag}.` + : summary; + return respondOk([JSON.stringify(structuredContent), text], { structuredContent }); } catch (error) { return respondToSourceToolError(error); } diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 9685144ca..c8489d9b0 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -108,9 +108,19 @@ describe('delete-actor-version', () => { expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2' })).toBe(true); }); - it.each(['0.1', '0.2'])( - 'deletes only version %s of the Actor it resolved, with one DELETE', - async (versionNumber) => { + it.each([ + // Version 0.1 has the build tag latest, which still points to its build after the delete. + { + versionNumber: '0.1', + text: + 'Deleted version 0.1 of john/my-actor.\n' + + "Runs with tag latest still use the deleted version's build; to have them run another version, " + + 'build that version with build-actor and tag latest.', + }, + { versionNumber: '0.2', text: 'Deleted version 0.2 of john/my-actor.' }, + ])( + 'deletes only version $versionNumber of the Actor it resolved, with one DELETE', + async ({ versionNumber, text }) => { const result = await callTool({ versionNumber }); expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); @@ -125,10 +135,25 @@ describe('delete-actor-version', () => { versionNumber, deleted: true, }); - expect(result.content[1].text).toBe(`Deleted version ${versionNumber} of john/my-actor.`); + expect(result.content[1].text).toBe(text); }, ); + it('names build-actor in the next step about the build tag only when the session has it', async () => { + const loadedToolNames = Object.values(HELPER_TOOLS).filter((name) => name !== HELPER_TOOLS.ACTOR_BUILD); + const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1' }, stubClient, { + loadedToolNames, + }); + + const result = (await (deleteActorVersion as HelperTool).call(context)) as DeleteResult; + + expect(result.content[1].text).toBe( + 'Deleted version 0.1 of john/my-actor.\n' + + "Runs with tag latest still use the deleted version's build; to have them run another version, " + + 'build that version with tag latest.', + ); + }); + it('refuses a version the Actor does not have, which apify-client would report as deleted', async () => { expect(await callToolExpectingUserError({ versionNumber: '0.9' })).toBe( "Actor 'john/my-actor' has no version 0.9; available versions: 0.1, 0.2.", From f5c66f6992aa9f4677b1486067d2774e3855eb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:17:10 +0200 Subject: [PATCH 24/60] test: Expect the secret env var warning on a copy and no next step after deleting an untagged version in the live cases --- tests/test_kit/cases/source.cases.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 569848fab..ac42473e8 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -682,7 +682,7 @@ export const sourceCases: Case[] = [ }), }, { - name: "create-actor-version copies a version's stored entries, non-secret env vars, and applyEnvVarsToBuild, never its build tag, and returns the platform error for a version number the Actor already has", + name: "create-actor-version copies a version's stored entries, non-secret env vars, and applyEnvVarsToBuild, never its build tag, names the secret env vars it left out, and returns the platform error for a version number the Actor already has", isDeploymentTest: false, run: withClient({ tools: ['source'] }, async (client, ctx) => { const api = ctx.createApifyClient(); @@ -723,8 +723,9 @@ export const sourceCases: Case[] = [ const storedBefore = await fetchStoredActor(api, seeded.id); const copyArgs = { actor: selector, versionNumber: '0.1', copyFromVersion: '0.0' }; + const createResult = await callTool(client, 'create-actor-version', copyArgs); const created = expectToolSuccess( - await callTool(client, 'create-actor-version', copyArgs), + createResult, 'create-actor-version', ); const stored = await fetchStoredActor(api, seeded.id); @@ -758,8 +759,11 @@ export const sourceCases: Case[] = [ files: read.files, warnings: [ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/blank.js.', + 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console before building or running it: SECRET_MODE.', ], }); + // The warning names the secret, never its value. + expect(JSON.stringify(createResult)).not.toContain('hidden'); expect(await fetchBuildCount(api, seeded.id)).toBe(0); // The sentence is the platform's, so only the API error type is exact, and no hint follows it. @@ -801,16 +805,19 @@ export const sourceCases: Case[] = [ const storedBefore = await fetchStoredActor(api, seeded.id); expect(storedBefore.versions).toHaveLength(2); - const deleted = expectToolSuccess( - await callTool(client, 'delete-actor-version', { actor: selector, versionNumber: '0.1' }), - 'delete-actor-version', - ); + const deleteResult = await callTool(client, 'delete-actor-version', { + actor: selector, + versionNumber: '0.1', + }); + const deleted = expectToolSuccess(deleteResult, 'delete-actor-version'); expect(deleted).toEqual({ actorId: seeded.id, fullName: `${seeded.username}/${name}`, versionNumber: '0.1', deleted: true, }); + // Version 0.1 has no build tag, so no next step about one follows. + expect(deleteResult.content?.[1]?.text).toBe(`Deleted version 0.1 of ${seeded.username}/${name}.`); const storedAfter = await fetchStoredActor(api, seeded.id); expect(storedAfter.versions).toEqual([findStoredVersion(storedBefore, '0.0')]); From 1226be350fb0b98b863cf153d421bd1374d4d2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:28:10 +0200 Subject: [PATCH 25/60] fix: Name the tags that point to a deleted version's builds, not its build tag --- src/tools/source/delete_actor_version.ts | 16 +++++++--- tests/test_kit/cases/source.cases.ts | 2 +- tests/unit/tools.delete_actor_version.test.ts | 32 ++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 5f7185aed..b72ec4e6e 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -56,7 +56,7 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const parsed = deleteActorVersionArgs.parse(args); try { const { actor, fullName } = await fetchActor(client, parsed.actor); - const { versionNumber, buildTag } = resolveVersion(actor, parsed.versionNumber, parsed.actor); + const { versionNumber } = resolveVersion(actor, parsed.versionNumber, parsed.actor); // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); await client.actor(actor.id).version(versionNumber).delete(); @@ -65,10 +65,16 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const buildTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_BUILD) ? `${HELPER_TOOLS.ACTOR_BUILD} and ` : ''; - const text = buildTag - ? `${summary}\nRuns with tag ${buildTag} still use the deleted version's build; to have them run ` + - `another version, build that version with ${buildTool}tag ${buildTag}.` - : summary; + // A tag points to the build it was last given to, which may be a build of another version than the one with + // that buildTag. + const tags = Object.entries(actor.taggedBuilds ?? {}) + .filter(([, { buildNumber }]) => buildNumber?.startsWith(`${versionNumber}.`)) + .map(([tag]) => tag); + const text = + tags.length > 0 + ? `${summary}\nRuns with tag ${tags.join(', ')} still use the deleted version's build; to have them ` + + `run another version, build that version with ${buildTool}tag ${tags.join(', ')}.` + : summary; return respondOk([JSON.stringify(structuredContent), text], { structuredContent }); } catch (error) { return respondToSourceToolError(error); diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index ac42473e8..9828327f9 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -816,7 +816,7 @@ export const sourceCases: Case[] = [ versionNumber: '0.1', deleted: true, }); - // Version 0.1 has no build tag, so no next step about one follows. + // No tag points to a build of version 0.1, so no next step about one follows. expect(deleteResult.content?.[1]?.text).toBe(`Deleted version 0.1 of ${seeded.username}/${name}.`); const storedAfter = await fetchStoredActor(api, seeded.id); expect(storedAfter.versions).toEqual([findStoredVersion(storedBefore, '0.0')]); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index c8489d9b0..a2516a9bb 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -45,6 +45,7 @@ function mockActor(overrides: Record = {}) { }, { versionNumber: '0.2', sourceType: 'SOURCE_FILES' }, ], + taggedBuilds: { latest: { buildId: 'build-1', buildNumber: '0.1.3' } }, ...overrides, }; } @@ -109,7 +110,7 @@ describe('delete-actor-version', () => { }); it.each([ - // Version 0.1 has the build tag latest, which still points to its build after the delete. + // Tag latest points to build 0.1.3, which stays after the delete. { versionNumber: '0.1', text: @@ -139,6 +140,35 @@ describe('delete-actor-version', () => { }, ); + it.each([ + // Version 0.1 has the build tag latest, but a later build of version 0.2 took the tag. + { + versionNumber: '0.1', + taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.2.1' } }, + text: 'Deleted version 0.1 of john/my-actor.', + }, + // Version 0.1 has the build tag latest, but was never built. + { versionNumber: '0.1', taggedBuilds: undefined, text: 'Deleted version 0.1 of john/my-actor.' }, + // Version 0.2 has no build tag, but build-actor gave one of its builds the tag latest. + { + versionNumber: '0.2', + taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.2.1' } }, + text: + 'Deleted version 0.2 of john/my-actor.\n' + + "Runs with tag latest still use the deleted version's build; to have them run another version, " + + 'build that version with build-actor and tag latest.', + }, + ])( + 'takes the next step about a tag from the build it points to, not from the build tag of version $versionNumber', + async ({ versionNumber, taggedBuilds, text }) => { + actorGetMock.mockResolvedValue(mockActor({ taggedBuilds })); + + const result = await callTool({ versionNumber }); + + expect(result.content[1].text).toBe(text); + }, + ); + it('names build-actor in the next step about the build tag only when the session has it', async () => { const loadedToolNames = Object.values(HELPER_TOOLS).filter((name) => name !== HELPER_TOOLS.ACTOR_BUILD); const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1' }, stubClient, { From c1b1ab322af1dde0838b299ecb87f425ca4bc4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:28:36 +0200 Subject: [PATCH 26/60] fix: Say to build a copy after setting its secret env vars, even when autoBuild started a build --- src/tools/source/create_actor_version.ts | 2 +- tests/test_kit/cases/source.cases.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 2dd6c9143..d2d13d853 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -127,7 +127,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ if (secretNames.length > 0) { warnings.push( `These secret environment variables were not copied, so set them on version ${versionNumber} ` + - `in Apify Console before building or running it: ${secretNames.join(', ')}.`, + `in Apify Console, then build the version before running it: ${secretNames.join(', ')}.`, ); } return await respondAfterWrite({ diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 9828327f9..9fe15ec2a 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -759,7 +759,7 @@ export const sourceCases: Case[] = [ files: read.files, warnings: [ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/blank.js.', - 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console before building or running it: SECRET_MODE.', + 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console, then build the version before running it: SECRET_MODE.', ], }); // The warning names the secret, never its value. diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 5027e1b9f..54c412315 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -208,7 +208,7 @@ describe('create-actor-version', () => { revision: buildFilesRevision(files), files, warnings: [ - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console before building or running it: API_KEY.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY.', ], }); expect(result.structuredContent.revision).toBe(read.structuredContent.revision); @@ -270,7 +270,7 @@ describe('create-actor-version', () => { expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console before building or running it: API_KEY, TOKEN.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY, TOKEN.', ]); expect(JSON.stringify(result)).not.toMatch(/abc123|secret-value/); }); @@ -692,6 +692,10 @@ describe('create-actor-version', () => { startedAt: '2026-09-01T10:00:00.000Z', finishedAt: null, }); + // The build started without the secret, so the warning says to build again once it is set. + expect(result.structuredContent.warnings).toEqual([ + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY.', + ]); }); it('starts no build without autoBuild', async () => { From bfc300223b18f14a55a16805054e6b98e42d17ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:28:42 +0200 Subject: [PATCH 27/60] fix: Say runs keep using the tagged build while a working copy changes --- src/tools/source/create_actor_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index d2d13d853..132339cf2 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -69,7 +69,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ autoBuild starts a build after creating the version and returns without waiting. USAGE: - - Use to make a working copy of a version to change and test while the tagged build keeps running. + - Use to make a working copy of a version to change and test while runs keep using the tagged build. USAGE EXAMPLES: - user_input: Copy version 0.1 of my Actor john/my-scraper to a new version 0.2`, From 49a45b7d10a4b1cfeaa43b6b5045a541038e9131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 7 Oct 2026 11:28:42 +0200 Subject: [PATCH 28/60] docs: Make the Actor source feature line one plain list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a84db923f..d579cb156 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them: - **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages. - **Schedules**: Create, inspect, update, and delete schedules that run your Actors and tasks automatically. - **Builds**: Build an Actor version, list builds, check the status of a build, and retrieve its build log. -- **Actor source**: Read an Actor version's files, with a hash for each file and a revision for the whole set, create an Actor from files, change a version's files with checks that catch changes made since your last read, and add or delete versions, including copies of a version made on the server. +- **Actor source**: Read an Actor version's files with a hash for each file and a revision for the whole set, create an Actor from files, change a version's files with checks that catch changes made since your last read, add a version from files or as a copy of another version, and delete a version. ### Overview of available tools From 4104f749a9a10c9d54f570028de262957c568b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 12:38:16 +0200 Subject: [PATCH 29/60] fix: Keep file content out of the logged arguments of create-actor-version --- src/tools/source/create_actor_version.ts | 7 +++ tests/unit/tools.create_actor_version.test.ts | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 132339cf2..d7fede70c 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -15,6 +15,7 @@ import { buildSourceFileEntries, extractSourceFiles, fetchActor, + redactSourceToolArgs, resolveVersion, respondAfterWrite, respondToSourceToolError, @@ -85,6 +86,12 @@ export const createActorVersion: ToolEntry = Object.freeze({ idempotentHint: false, openWorldHint: false, }, + redactArgs: (args: Record) => + redactSourceToolArgs(args, { + keys: ['actor', 'versionNumber', 'copyFromVersion', 'buildTag', 'autoBuild'], + listKey: 'files', + entryKeys: ['path', 'encoding'], + }), call: async (toolArgs: InternalToolArgs) => { const { args, apifyClient: client, signal, loadedToolNames } = toolArgs; const parsed = createActorVersionArgs.parse(args); diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 54c412315..a486608e2 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -5,6 +5,7 @@ import type { AxiosResponse } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; +import { prepareToolCallContext } from '../../src/payments/helpers.js'; import { createActorVersion } from '../../src/tools/source/create_actor_version.js'; import { getActorVersion } from '../../src/tools/source/get_actor_version.js'; import { buildFilesRevision } from '../../src/tools/source/source_files.js'; @@ -160,6 +161,64 @@ describe('create-actor-version', () => { expect(tool.ajvValidate({ actor: 'john/my-actor', versionNumber: '0.2', copyFromVersion: '0.1' })).toBe(true); }); + describe('logged arguments', () => { + const SECRET = 'synthetic-secret-4f2a'; + const getLoggedArgs = (args: Record) => + prepareToolCallContext({ provider: undefined, tool: createActorVersion, args, apifyToken: 'test-token' }) + .toolArgsRedacted; + + it('keep the paths and other metadata, and replace file content with a marker', () => { + const args = { + actor: 'john/my-actor', + versionNumber: '0.2', + buildTag: 'beta', + autoBuild: true, + files: [ + { path: 'src/config.js', content: `export const key = '${SECRET}';\n`, encoding: 'utf8' }, + { path: 'assets/key.bin', content: Buffer.from(SECRET).toString('base64'), extra: SECRET }, + ], + undeclared: SECRET, + }; + const argsBefore = structuredClone(args); + + const logged = getLoggedArgs(args); + + expect(JSON.stringify(logged)).not.toContain(SECRET); + expect(JSON.stringify(logged)).not.toContain(Buffer.from(SECRET).toString('base64')); + expect(logged).toStrictEqual({ + actor: 'john/my-actor', + versionNumber: '0.2', + buildTag: 'beta', + autoBuild: true, + files: [ + { path: 'src/config.js', encoding: 'utf8', content: '[REDACTED]' }, + { path: 'assets/key.bin', content: '[REDACTED]' }, + ], + }); + expect(args).toStrictEqual(argsBefore); + }); + + it('keep the arguments of a copy as they are', () => { + const args = { actor: 'john/my-actor', versionNumber: '0.2', copyFromVersion: '0.1', autoBuild: false }; + + expect(getLoggedArgs(args)).toStrictEqual(args); + }); + + it.each<[string, Record]>([ + ['no arguments', {}], + ['files that are not a list', { files: SECRET }], + ['files that are not objects', { files: [SECRET, null, 1, [SECRET]] }], + ['a path that is not a string', { files: [{ path: { text: SECRET }, content: 'x' }] }], + ['content that is not a string', { files: [{ path: 'a.js', content: { text: SECRET } }] }], + ['an actor that is not a string', { actor: { name: SECRET }, files: [] }], + ])('accept %s without throwing or logging their values', (_, args) => { + const argsBefore = structuredClone(args); + + expect(JSON.stringify(getLoggedArgs(args))).not.toContain(SECRET); + expect(args).toStrictEqual(argsBefore); + }); + }); + it.each([ ['files', { files: [ACTOR_JSON] }], ['a copy', { copyFromVersion: '0.1' }], From 7babd1ef001410ae0512e3bace1aeecb11067432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 12:38:36 +0200 Subject: [PATCH 30/60] test: Expect create-actor-version to refuse control characters in file paths --- tests/unit/tools.create_actor_version.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index a486608e2..a2bf81c88 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -575,6 +575,23 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); + it.each([ + ['U+0000', 'src/a\0.js'], + ['U+000A', 'src/\nmain.js'], + ['U+0009', 'src/a\tb.js'], + ['U+001F', 'src/a\u001f.js'], + ['U+007F', 'src/a\u007f.js'], + ])('refuses a path with the control character %s, and creates nothing', async (character, path) => { + expect( + await callToolExpectingUserError({ autoBuild: true, files: [MAIN_JS, { path, content: 'x' }] }), + ).toBe( + `files[1] has the control character ${character} in its path; ` + + 'a path cannot hold characters U+0000 to U+001F or U+007F.', + ); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); + it('refuses a call with neither files nor copyFromVersion before any request', async () => { expect(await callToolExpectingUserError({})).toBe('Give either files or copyFromVersion.'); expect(actorMock).not.toHaveBeenCalled(); From ee7edace5ee9febdab8c58a4b66a2fa06e068585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 12:38:59 +0200 Subject: [PATCH 31/60] test: Expect create-actor-version to start no build when cancelled during the Console link lookup --- tests/unit/tools.create_actor_version.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index a2bf81c88..401b6b3e7 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -11,14 +11,20 @@ import { getActorVersion } from '../../src/tools/source/get_actor_version.js'; import { buildFilesRevision } from '../../src/tools/source/source_files.js'; import { createActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { getUserInfoCached } from '../../src/utils/userid_cache.js'; import { expectSchemaConformingStructuredContent, expectSoftFailInvalidInput, + mockUserInfo, stubToolCallContext, type TextToolResult, type ToolTelemetrySnapshot, } from './helpers/tool_context.js'; +vi.mock('../../src/utils/userid_cache.js', () => ({ + getUserInfoCached: vi.fn(), +})); + const actorGetMock = vi.fn(); const versionsCreateMock = vi.fn(); const buildMock = vi.fn(); @@ -749,6 +755,30 @@ describe('create-actor-version', () => { expectNoOtherWrite(); expect(buildMock).not.toHaveBeenCalled(); }); + + it('keeps the version and starts no build when the request is cancelled during the Console link lookup', async () => { + const controller = new AbortController(); + vi.mocked(getUserInfoCached).mockImplementationOnce(async () => { + controller.abort(); + return mockUserInfo(); + }); + + const result = await (createActorVersion as HelperTool).call({ + ...stubToolCallContext( + { actor: 'john/my-actor', versionNumber: '0.2', copyFromVersion: '0.1', autoBuild: true }, + stubClient, + ), + // A Console session's UI token is what makes the call look up the Console link. + apifyToken: 'apify_ui_test', + signal: controller.signal, + }); + + expect(result).toEqual({}); + expect(getUserInfoCached).toHaveBeenCalledTimes(1); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); }); describe('autoBuild', () => { From e4467e84eb046e815bd5c54b0c2e6095db4da198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 12:59:29 +0200 Subject: [PATCH 32/60] test: Expect create-actor-version to check 20,000 file paths within a second --- tests/unit/tools.create_actor_version.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 401b6b3e7..26791d1db 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -598,6 +598,16 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); + it('checks 20,000 files for repeated and colliding paths within a second', async () => { + const files = Array.from({ length: 20_000 }, (_, i) => ({ path: `src/d${i % 50}/f${i}.js`, content: 'x' })); + + const startedAt = performance.now(); + await callTool({ files }); + + expect(performance.now() - startedAt).toBeLessThan(1000); + expect(getPostBody().sourceFiles).toHaveLength(20_000); + }); + it('refuses a call with neither files nor copyFromVersion before any request', async () => { expect(await callToolExpectingUserError({})).toBe('Give either files or copyFromVersion.'); expect(actorMock).not.toHaveBeenCalled(); From 7805844ad25abc1a9ac9d1133d98972b5f72903e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:21:50 +0200 Subject: [PATCH 33/60] fix: Report the version count and size limits as the platform's refusal The version POST is refused with 403 too-many-versions when the Actor has 10 versions, and with 403 versions-size-exceeded when its versions would pass the combined size limit. Both went to the tool-call engine, which reported them as AUTH with the hint that the token may lack access. They are now soft failures with the platform's message and error type, like version-already-exists and too-few-versions. --- src/const.ts | 2 ++ src/tools/source/source_helpers.ts | 27 +++++++++++++------ tests/unit/tools.create_actor_version.test.ts | 24 +++++++++++++++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/const.ts b/src/const.ts index 97046ec49..910009cbf 100644 --- a/src/const.ts +++ b/src/const.ts @@ -321,6 +321,8 @@ export const APIFY_ERROR_TYPE_INVALID_INPUT = 'invalid-input'; export const APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE = 'actor-name-not-unique'; export const APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS = 'version-already-exists'; export const APIFY_ERROR_TYPE_TOO_FEW_VERSIONS = 'too-few-versions'; +export const APIFY_ERROR_TYPE_TOO_MANY_VERSIONS = 'too-many-versions'; +export const APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED = 'versions-size-exceeded'; export const APIFY_ERROR_TYPE_PAGE_NOT_FOUND = 'page-not-found'; // HTTP status codes diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index 0abdebadd..d52d773e3 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -5,7 +5,12 @@ import { ActorSourceType, ApifyApiError } from 'apify-client'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; -import { APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS } from '../../const.js'; +import { + APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, + APIFY_ERROR_TYPE_TOO_MANY_VERSIONS, + APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, + APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED, +} from '../../const.js'; import { UserInputError } from '../../errors.js'; import { REDACTED } from '../../resources/api_resources.js'; import type { ConsoleLinkContext, InternalToolArgs } from '../../types.js'; @@ -376,17 +381,23 @@ export async function respondAfterWrite(params: { }); } +/** The platform's 403 refusals of a version write that are not about access. */ +const VERSION_REFUSAL_ERROR_TYPE_SET = new Set([ + APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, + APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, + APIFY_ERROR_TYPE_TOO_MANY_VERSIONS, + APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED, +]); + /** - * A `UserInputError`, or the platform's refusal of a taken version number or of deleting the last version, as a soft - * failure with the platform's own message; anything else is rethrown for the tool-call engine to report. The engine - * would add a token-access hint to these two 403s, which contradicts their reason. + * A `UserInputError`, or the platform's refusal of a taken version number, of deleting the last version, or of a + * version over the limit on the number of versions or on their combined size, as a soft failure with the platform's + * own message; anything else is rethrown for the tool-call engine to report. The engine would add a token-access hint + * to these 403s, which contradicts their reason. */ export function respondToSourceToolError(error: unknown): ToolResponse { if (error instanceof UserInputError) return respondUserError(error.message); - if ( - error instanceof ApifyApiError && - (error.type === APIFY_ERROR_TYPE_TOO_FEW_VERSIONS || error.type === APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS) - ) { + if (error instanceof ApifyApiError && VERSION_REFUSAL_ERROR_TYPE_SET.has(error.type)) { return respondUserError(`${error.message} (API error type: ${error.type})`, { httpStatus: error.statusCode }); } throw error; diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 26791d1db..93f834441 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -664,6 +664,26 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); + it.each([ + ['too-many-versions', 'The Actor can have at most 10 versions.'], + [ + 'versions-size-exceeded', + 'The limit on the combined size of all versions has been exceeded. Delete unused versions to free up ' + + 'space or use another source type. See https://docs.apify.com/platform/actors/development/deployment/source-types.', + ], + ])('reports the platform refusing the POST with %s', async (type, message) => { + versionsCreateMock.mockRejectedValue(apiError(403, message, type)); + + const result = await callTool({ files: [ACTOR_JSON], autoBuild: true }); + + // The platform's reason, without the token-access hint the engine adds to a 403. + expectSoftFailInvalidInput(result); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); + expect(result.content).toStrictEqual([{ type: 'text', text: `${message} (API error type: ${type})` }]); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); + it('lets any other API error of the POST through to the engine', async () => { const error = apiError(403, 'You do not have permission to modify this Actor.', 'insufficient-permissions'); versionsCreateMock.mockRejectedValue(error); @@ -728,8 +748,8 @@ describe('create-actor-version', () => { it('lets any other API error of a retried POST through unchanged', async () => { const error = apiError( 403, - 'The limit on the combined size of all versions has been exceeded.', - 'versions-size-exceeded', + 'You do not have permission to modify this Actor.', + 'insufficient-permissions', 2, ); versionsCreateMock.mockRejectedValue(error); From 838867002753933ab07202f2b51715211833cd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:25:42 +0200 Subject: [PATCH 34/60] fix: Say that the build create-actor-version started has no secret env vars A copy leaves out the secret env vars, and autoBuild still starts a build, which then runs without them and, with buildTag, takes that tag. The warning told the caller to set the secrets and build before running, as if no build had started. When autoBuild starts a build, the warning now says that build runs without them, names the tag it takes when buildTag was given, and says to set them in Apify Console and build again. Without autoBuild, or when the build fails to start, the warning is unchanged. --- src/tools/source/create_actor_version.ts | 32 +++++++++-- src/tools/source/source_helpers.ts | 12 +++-- tests/unit/tools.create_actor_version.test.ts | 53 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index d7fede70c..b788a8db9 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -47,6 +47,30 @@ const createActorVersionArgs = z.object({ .describe('Start a build of the new version after creating it, and return without waiting. Default: false.'), }); +/** + * The warning about the secret env vars a copy leaves out. With `startedBuild`, it says that the build this call started + * runs without them and takes the buildTag, if one was given. + */ +function formatSecretEnvVarsWarning( + secretNames: readonly (string | undefined)[], + versionNumber: string, + startedBuild?: { buildTag: string | undefined }, +): string { + const names = secretNames.join(', '); + if (!startedBuild) { + return ( + `These secret environment variables were not copied, so set them on version ${versionNumber} ` + + `in Apify Console, then build the version before running it: ${names}.` + ); + } + const tag = startedBuild.buildTag === undefined ? '' : ` and takes tag ${startedBuild.buildTag}`; + return ( + `These secret environment variables were not copied, so the build this call started runs without them${tag}; ` + + `set them on version ${versionNumber} in Apify Console, then build the version again before running it: ` + + `${names}.` + ); +} + /** * https://docs.apify.com/api/v2/actor-get * /v2/actors/{actorId} @@ -130,11 +154,12 @@ export const createActorVersion: ToolEntry = Object.freeze({ const files = buildFilesManifest(sourceFiles); const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); + const buildStartedWarnings = [...warnings]; const secretNames = copied?.envVars?.filter(({ isSecret }) => isSecret).map(({ name }) => name) ?? []; if (secretNames.length > 0) { - warnings.push( - `These secret environment variables were not copied, so set them on version ${versionNumber} ` + - `in Apify Console, then build the version before running it: ${secretNames.join(', ')}.`, + warnings.push(formatSecretEnvVarsWarning(secretNames, versionNumber)); + buildStartedWarnings.push( + formatSecretEnvVarsWarning(secretNames, versionNumber, { buildTag: parsed.buildTag }), ); } return await respondAfterWrite({ @@ -149,6 +174,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), ...(warnings.length > 0 && { warnings }), }, + ...(secretNames.length > 0 && { buildStartedWarnings }), summary: `Created version ${versionNumber} of ${fullName}.`, }); } catch (error) { diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index d52d773e3..a05da46e6 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -331,16 +331,18 @@ export function buildEmptyFilesWarnings(paths: readonly string[]): { warnings?: * The response to a committed write. With autoBuild, a build of the version starts first, with no tag so the version's * buildTag applies, and is not waited for. The write stands either way, so a failed start goes to `buildError` rather * than being thrown: a caller told the call failed would retry writes that were saved. A cancel that arrives before - * the start gets the cancelled-call response and starts no build. + * the start gets the cancelled-call response and starts no build. When the build starts, `buildStartedWarnings`, if + * given, replaces the warnings in `structuredContent`. */ export async function respondAfterWrite(params: { toolArgs: Pick; autoBuild: boolean; target: { actorId: string; versionNumber: string }; structuredContent: Record; + buildStartedWarnings?: string[]; summary: string; }): Promise { - const { toolArgs, autoBuild, target, structuredContent, summary } = params; + const { toolArgs, autoBuild, target, structuredContent, buildStartedWarnings, summary } = params; const { apifyClient: client, apifyToken, loadedToolNames, signal } = toolArgs; if (!autoBuild) { return respondWithBuild({ @@ -375,7 +377,11 @@ export async function respondAfterWrite(params: { // startBuild returns ABORT only for a passed signal; the check narrows the type. if (build === ABORT) return respondAborted(); return respondWithBuild({ - structuredContent: { ...structuredContent, build: toBuildResult(build, linkContext) }, + structuredContent: { + ...structuredContent, + ...(buildStartedWarnings && { warnings: buildStartedWarnings }), + build: toBuildResult(build, linkContext), + }, summary, nextStep: buildNextStepForBuild(build, { loadedToolNames }), }); diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 93f834441..421609646 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -829,9 +829,62 @@ describe('create-actor-version', () => { finishedAt: null, }); // The build started without the secret, so the warning says to build again once it is set. + expect(result.structuredContent.warnings).toEqual([ + 'These secret environment variables were not copied, so the build this call started runs without ' + + 'them; set them on version 0.2 in Apify Console, then build the version again before running it: ' + + 'API_KEY.', + ]); + }); + + it('warns that the build it started takes the buildTag without the secret env vars, after the empty files', async () => { + mockActorRead({ + sourceFiles: [ + ...structuredClone(STORED_FILES), + { name: 'src/__init__.py', format: 'TEXT', content: '' }, + ], + }); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true, buildTag: 'beta' }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(buildMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent.warnings).toEqual([ + 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', + 'These secret environment variables were not copied, so the build this call started runs without ' + + 'them and takes tag beta; set them on version 0.2 in Apify Console, then build the version again ' + + 'before running it: API_KEY.', + ]); + }); + + it.each([ + ['without autoBuild', { autoBuild: false }, 'Build the version before running it.'], + [ + 'when the build failed to start', + { autoBuild: true }, + 'The build could not be started; start it again before running the version.', + ], + ])('keeps the warning to build the version once the secret env vars are set, %s', async (_, args, nextStep) => { + buildMock.mockRejectedValue(apiError(402, 'Not enough credit')); + + const result = await callTool({ copyFromVersion: '0.1', buildTag: 'beta', ...args }); + + expect(result.structuredContent).not.toHaveProperty('build'); expect(result.structuredContent.warnings).toEqual([ 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY.', ]); + expect(result.content[1].text).toBe(`Created version 0.2 of john/my-actor.\n${nextStep}`); + }); + + it.each([ + ['only non-secret env vars', [{ name: 'REGION', value: 'eu', isSecret: false }]], + ['no envVars field', undefined], + ])('adds no warning to the build it started for a version with %s', async (_, envVars) => { + mockActorRead({ envVars }); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true, buildTag: 'beta' }); + + expect(result.structuredContent).toHaveProperty('build'); + expect(result.structuredContent).not.toHaveProperty('warnings'); }); it('starts no build without autoBuild', async () => { From bbeec7befc1629665804e7c3dae44b624d830ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:26:47 +0200 Subject: [PATCH 35/60] fix: Tell delete-actor-version callers to build once per tag When several tags pointed to builds of the deleted version, the next step said to build with "tag latest, beta", but build-actor takes one tag. It now says to build with tag latest, then again with tag beta. The text for a single tag is unchanged. A new test also checks that deleting version 0.1 does not name a tag on build 0.10.1. --- src/tools/source/delete_actor_version.ts | 4 +- tests/unit/tools.delete_actor_version.test.ts | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index b72ec4e6e..8c5606c17 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -70,10 +70,12 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const tags = Object.entries(actor.taggedBuilds ?? {}) .filter(([, { buildNumber }]) => buildNumber?.startsWith(`${versionNumber}.`)) .map(([tag]) => tag); + // build-actor takes one tag, so the next step builds once per tag. const text = tags.length > 0 ? `${summary}\nRuns with tag ${tags.join(', ')} still use the deleted version's build; to have them ` + - `run another version, build that version with ${buildTool}tag ${tags.join(', ')}.` + `run another version, build that version with ${buildTool}` + + `tag ${tags.join(', then again with tag ')}.` : summary; return respondOk([JSON.stringify(structuredContent), text], { structuredContent }); } catch (error) { diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index a2516a9bb..24f7d62eb 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -169,6 +169,43 @@ describe('delete-actor-version', () => { }, ); + it('says to build once per tag when several tags point to builds of the deleted version', async () => { + actorGetMock.mockResolvedValue( + mockActor({ + taggedBuilds: { + latest: { buildId: 'build-1', buildNumber: '0.1.3' }, + stable: { buildId: 'build-2', buildNumber: '0.2.1' }, + beta: { buildId: 'build-3', buildNumber: '0.1.4' }, + }, + }), + ); + + const result = await callTool({ versionNumber: '0.1' }); + + // build-actor takes one tag, so each tag needs its own build. + expect(result.content[1].text).toBe( + 'Deleted version 0.1 of john/my-actor.\n' + + "Runs with tag latest, beta still use the deleted version's build; to have them run another version, " + + 'build that version with build-actor and tag latest, then again with tag beta.', + ); + }); + + it('takes no tag of version 0.10 for one of version 0.1', async () => { + actorGetMock.mockResolvedValue( + mockActor({ + versions: [ + { versionNumber: '0.1', sourceType: 'SOURCE_FILES' }, + { versionNumber: '0.10', sourceType: 'SOURCE_FILES' }, + ], + taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.10.1' } }, + }), + ); + + const result = await callTool({ versionNumber: '0.1' }); + + expect(result.content[1].text).toBe('Deleted version 0.1 of john/my-actor.'); + }); + it('names build-actor in the next step about the build tag only when the session has it', async () => { const loadedToolNames = Object.values(HELPER_TOOLS).filter((name) => name !== HELPER_TOOLS.ACTOR_BUILD); const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1' }, stubClient, { From 69e701e7ca4223bef9e9d8788f2dceddb0049a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:27:53 +0200 Subject: [PATCH 36/60] fix: Name the copied version and left-out secrets after a retried create-actor-version POST When a retried POST was refused as taken, the reply said to check whether the version holds the files this call sent, which for a copy are files the caller never saw, and it left out the warning about secret env vars. For a copy it now says the files of the copied version, and it adds the secret env var warning when the copy left secrets out. The secret names are read before the POST, so the reply can use them. --- src/tools/source/create_actor_version.ts | 14 ++++++-- tests/unit/tools.create_actor_version.test.ts | 32 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index b788a8db9..49d0fbba9 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -123,6 +123,8 @@ export const createActorVersion: ToolEntry = Object.freeze({ if ((copyFromVersion === undefined) === (parsed.files === undefined)) { return respondUserError('Give either files or copyFromVersion.'); } + // Set before the POST, so the reply to a retried POST can name them. + let secretNames: (string | undefined)[] = []; try { const { actor, fullName } = await fetchActor(client, parsed.actor); // TODO: A copy takes every file of the other version from the Actor GET and sends them all back in the POST @@ -133,6 +135,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ const sourceFiles = copied ? extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`) : buildSourceFileEntries(parsed.files!); + secretNames = copied?.envVars?.filter(({ isSecret }) => isSecret).map(({ name }) => name) ?? []; // A cancel during the read creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); await client @@ -155,7 +158,6 @@ export const createActorVersion: ToolEntry = Object.freeze({ const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); const buildStartedWarnings = [...warnings]; - const secretNames = copied?.envVars?.filter(({ isSecret }) => isSecret).map(({ name }) => name) ?? []; if (secretNames.length > 0) { warnings.push(formatSecretEnvVarsWarning(secretNames, versionNumber)); buildStartedWarnings.push( @@ -186,11 +188,17 @@ export const createActorVersion: ToolEntry = Object.freeze({ const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) ? ` with ${HELPER_TOOLS.ACTOR_VERSION_GET}` : ''; + const sentFiles = + copyFromVersion === undefined + ? 'the files this call sent' + : `the files of version ${copyFromVersion}`; + const secretsWarning = + secretNames.length > 0 ? ` ${formatSecretEnvVarsWarning(secretNames, versionNumber)}` : ''; return respondUserError( `The platform refused version ${versionNumber} as taken when the request was retried, so an ` + `earlier attempt of this call may have created it. Read it${readTool} before calling ` + - `${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; if it holds the files this call sent, this call ` + - 'created it.', + `${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; if it holds ${sentFiles}, this call created ` + + `it.${secretsWarning}`, { httpStatus: error.statusCode }, ); } diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 421609646..82185a451 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -691,12 +691,32 @@ describe('create-actor-version', () => { await expect(callTool({ files: [ACTOR_JSON] })).rejects.toBe(error); }); - it.each([ - ['files', { files: [ACTOR_JSON] }], - ['a copy of that version', { copyFromVersion: '0.1' }], + it.each<{ source: string; args: Record; version: Record; ending: string }>([ + { + source: 'files', + args: { files: [ACTOR_JSON] }, + version: {}, + ending: 'if it holds the files this call sent, this call created it.', + }, + { + source: 'a copy', + args: { copyFromVersion: '0.1' }, + version: {}, + ending: + 'if it holds the files of version 0.1, this call created it. These secret environment variables ' + + 'were not copied, so set them on version 0.2 in Apify Console, then build the version before ' + + 'running it: API_KEY.', + }, + { + source: 'a copy of a version without secret env vars', + args: { copyFromVersion: '0.1' }, + version: { envVars: [{ name: 'REGION', value: 'eu' }] }, + ending: 'if it holds the files of version 0.1, this call created it.', + }, ])( - 'says an earlier attempt may have created the version when the platform refuses the number on a retried POST, from %s', - async (_, args) => { + 'says an earlier attempt may have created the version when the platform refuses the number on a retried POST, from $source', + async ({ args, version, ending }) => { + mockActorRead(version); // apify-client retries a POST that timed out or got a 5xx, and the platform may have saved the attempt. versionsCreateMock.mockRejectedValue( apiError(403, 'Version with this number already exists', 'version-already-exists', 2), @@ -713,7 +733,7 @@ describe('create-actor-version', () => { text: 'The platform refused version 0.2 as taken when the request was retried, so an earlier ' + 'attempt of this call may have created it. Read it with get-actor-version before calling ' + - 'create-actor-version again; if it holds the files this call sent, this call created it.', + `create-actor-version again; ${ending}`, }, ]); expect(getPostBody().versionNumber).toBe('0.2'); From 35f7f6e581798ac9a0c3a41664eb23a7669113b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:29:17 +0200 Subject: [PATCH 37/60] fix: Say a running build of a deleted version still takes its tag Deleting a version stops none of its builds, and a build that succeeds takes its tag whether or not its version still exists. The delete-actor-version description now says so. --- src/tools/source/delete_actor_version.ts | 2 +- tests/unit/tools.delete_actor_version.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 8c5606c17..8256508b6 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -34,7 +34,7 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ title: 'Delete Actor version', description: dedent` Delete one version of an Actor, with its source and environment variables; a deleted version cannot be restored. The platform refuses to delete an Actor's last version. - The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. + The version's builds stay, and so do the tags that point to them: a run with such a tag still uses that build. A build of the version that is still running keeps running, and takes its tag if it succeeds. USAGE: - Use only when the user explicitly wants the version removed. diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 24f7d62eb..db099a23d 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -100,6 +100,13 @@ describe('delete-actor-version', () => { ); }); + it('says in its description that a running build of the version still takes its tag', () => { + // The delete stops no build, and a build that succeeds takes its tag whether or not its version still exists. + expect(deleteActorVersion.description).toContain( + 'A build of the version that is still running keeps running, and takes its tag if it succeeds.', + ); + }); + it('requires the actor and versionNumber, so it never picks a version itself', () => { const tool = deleteActorVersion as HelperTool; From 6c9fd7e8bb46f8c43062d9778624e670b860263f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:29:49 +0200 Subject: [PATCH 38/60] test: Expect create-actor-version cancelled during the POST to respond without autoBuild too The test ran only with autoBuild, where the response helper's own cancel check also returns the cancelled-call response, so removing the check after the POST passed every test. It now runs with autoBuild true and false, and the false case fails without that check. --- tests/unit/tools.create_actor_version.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 82185a451..0cccd2f3a 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -794,17 +794,20 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); - it('keeps the version and starts no build when the request is cancelled during the POST', async () => { - const controller = new AbortController(); - versionsCreateMock.mockImplementation(async () => controller.abort()); + it.each([true, false])( + 'keeps the version and starts no build when the request is cancelled during the POST, with autoBuild %s', + async (autoBuild) => { + const controller = new AbortController(); + versionsCreateMock.mockImplementation(async () => controller.abort()); - const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); + const result = await callTool({ copyFromVersion: '0.1', autoBuild }, controller.signal); - expect(result).toEqual({}); - expect(versionsCreateMock).toHaveBeenCalledTimes(1); - expectNoOtherWrite(); - expect(buildMock).not.toHaveBeenCalled(); - }); + expect(result).toEqual({}); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }, + ); it('keeps the version and starts no build when the request is cancelled during the Console link lookup', async () => { const controller = new AbortController(); From dceec340cbadedf511d6797cbd4d144dd28ed556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:30:03 +0200 Subject: [PATCH 39/60] docs: Give the missing copy endpoint as the reason create-actor-version sends every file The TODO said the API cannot apply atomic changes to an Actor's source, which is not what a copy needs. It now says the API has no endpoint that copies a version, so a copy reads every file and sends them all back. --- src/tools/source/create_actor_version.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 49d0fbba9..099946837 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -127,9 +127,9 @@ export const createActorVersion: ToolEntry = Object.freeze({ let secretNames: (string | undefined)[] = []; try { const { actor, fullName } = await fetchActor(client, parsed.actor); - // TODO: A copy takes every file of the other version from the Actor GET and sends them all back in the POST - // below: the Apify API cannot copy a version or apply atomic changes to an Actor's source. Once it can, let - // the platform make the copy. + // TODO: The Apify API has no endpoint that copies a version, so a copy reads every file of the other version + // from the Actor GET and sends them all back in the POST below. Once the API has one, let the platform make + // the copy. const copied = copyFromVersion === undefined ? undefined : resolveVersion(actor, copyFromVersion, parsed.actor); const sourceFiles = copied From 852d85148c7d8f8d474ab7b427403cfe6c86d9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:36:33 +0200 Subject: [PATCH 40/60] fix: Say the build create-actor-version started takes its tag only if it succeeds --- src/tools/source/create_actor_version.ts | 4 ++-- tests/unit/tools.create_actor_version.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 099946837..ba9530064 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -49,7 +49,7 @@ const createActorVersionArgs = z.object({ /** * The warning about the secret env vars a copy leaves out. With `startedBuild`, it says that the build this call started - * runs without them and takes the buildTag, if one was given. + * runs without them and takes the buildTag if it succeeds, when one was given. */ function formatSecretEnvVarsWarning( secretNames: readonly (string | undefined)[], @@ -63,7 +63,7 @@ function formatSecretEnvVarsWarning( `in Apify Console, then build the version before running it: ${names}.` ); } - const tag = startedBuild.buildTag === undefined ? '' : ` and takes tag ${startedBuild.buildTag}`; + const tag = startedBuild.buildTag === undefined ? '' : ` and, if it succeeds, takes tag ${startedBuild.buildTag}`; return ( `These secret environment variables were not copied, so the build this call started runs without them${tag}; ` + `set them on version ${versionNumber} in Apify Console, then build the version again before running it: ` + diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 0cccd2f3a..2d7666d0b 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -874,8 +874,8 @@ describe('create-actor-version', () => { expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', 'These secret environment variables were not copied, so the build this call started runs without ' + - 'them and takes tag beta; set them on version 0.2 in Apify Console, then build the version again ' + - 'before running it: API_KEY.', + 'them and, if it succeeds, takes tag beta; set them on version 0.2 in Apify Console, then build ' + + 'the version again before running it: API_KEY.', ]); }); From ffd8d30d330dc59913952779b927f70327c7fc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 16:36:41 +0200 Subject: [PATCH 41/60] test: Name the delete-actor-version test for build 0.10.1 in plain words --- tests/unit/tools.delete_actor_version.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index db099a23d..d9be1f606 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -197,7 +197,7 @@ describe('delete-actor-version', () => { ); }); - it('takes no tag of version 0.10 for one of version 0.1', async () => { + it('names no tag of build 0.10.1 when deleting version 0.1', async () => { actorGetMock.mockResolvedValue( mockActor({ versions: [ From 235da6a0a5283c04e5e155567b4e3468c27e06e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:42:30 +0200 Subject: [PATCH 42/60] fix: State facts instead of next calls in create- and delete-actor-version texts The secret env var warnings no longer ask for a build, the files-or-copy refusal states the rule, the retried-POST refusal says which tool reads the version instead of asking for a read, and delete-actor-version says how long tagged runs keep the deleted version's build. --- src/tools/source/create_actor_version.ts | 16 ++++---- src/tools/source/delete_actor_version.ts | 8 ++-- tests/test_kit/cases/source.cases.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 38 +++++++++---------- tests/unit/tools.delete_actor_version.test.ts | 18 ++++----- 5 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index ba9530064..94e69a3d9 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -60,14 +60,13 @@ function formatSecretEnvVarsWarning( if (!startedBuild) { return ( `These secret environment variables were not copied, so set them on version ${versionNumber} ` + - `in Apify Console, then build the version before running it: ${names}.` + `in Apify Console; only a build made after that includes them: ${names}.` ); } const tag = startedBuild.buildTag === undefined ? '' : ` and, if it succeeds, takes tag ${startedBuild.buildTag}`; return ( - `These secret environment variables were not copied, so the build this call started runs without them${tag}; ` + - `set them on version ${versionNumber} in Apify Console, then build the version again before running it: ` + - `${names}.` + `These secret environment variables were not copied, so the build this call started runs without them${tag}. ` + + `Set them on version ${versionNumber} in Apify Console; only a build made after that includes them: ${names}.` ); } @@ -121,7 +120,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ const parsed = createActorVersionArgs.parse(args); const { versionNumber, copyFromVersion } = parsed; if ((copyFromVersion === undefined) === (parsed.files === undefined)) { - return respondUserError('Give either files or copyFromVersion.'); + return respondUserError('The call takes exactly one of files and copyFromVersion.'); } // Set before the POST, so the reply to a retried POST can name them. let secretNames: (string | undefined)[] = []; @@ -186,7 +185,7 @@ export const createActorVersion: ToolEntry = Object.freeze({ error.attempt > 1 ) { const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) - ? ` with ${HELPER_TOOLS.ACTOR_VERSION_GET}` + ? `; ${HELPER_TOOLS.ACTOR_VERSION_GET} reads its files` : ''; const sentFiles = copyFromVersion === undefined @@ -196,9 +195,8 @@ export const createActorVersion: ToolEntry = Object.freeze({ secretNames.length > 0 ? ` ${formatSecretEnvVarsWarning(secretNames, versionNumber)}` : ''; return respondUserError( `The platform refused version ${versionNumber} as taken when the request was retried, so an ` + - `earlier attempt of this call may have created it. Read it${readTool} before calling ` + - `${HELPER_TOOLS.ACTOR_VERSION_CREATE} again; if it holds ${sentFiles}, this call created ` + - `it.${secretsWarning}`, + `earlier attempt of this call may have created it. ` + + `If it holds ${sentFiles}, this call created it${readTool}.${secretsWarning}`, { httpStatus: error.statusCode }, ); } diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 8256508b6..98680cbf2 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -70,12 +70,12 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const tags = Object.entries(actor.taggedBuilds ?? {}) .filter(([, { buildNumber }]) => buildNumber?.startsWith(`${versionNumber}.`)) .map(([tag]) => tag); - // build-actor takes one tag, so the next step builds once per tag. + // build-actor takes one tag, so the text names one build per tag. const text = tags.length > 0 - ? `${summary}\nRuns with tag ${tags.join(', ')} still use the deleted version's build; to have them ` + - `run another version, build that version with ${buildTool}` + - `tag ${tags.join(', then again with tag ')}.` + ? `${summary}\nRuns with tag ${tags.join(', ')} still use the deleted version's build until each tag ` + + `points to another build, such as one of another version made with ${buildTool}` + + `tag ${tags.join(', then one with tag ')}.` : summary; return respondOk([JSON.stringify(structuredContent), text], { structuredContent }); } catch (error) { diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 0ecfe7ca5..726fa984a 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -765,7 +765,7 @@ export const sourceCases: Case[] = [ files: read.files, warnings: [ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/blank.js.', - 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console, then build the version before running it: SECRET_MODE.', + 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console; only a build made after that includes them: SECRET_MODE.', ], }); // The warning names the secret, never its value. diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index a8995d6e7..b3af3162d 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -273,7 +273,7 @@ describe('create-actor-version', () => { revision: buildFilesRevision(files), files, warnings: [ - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY.', ], }); expect(result.structuredContent.revision).toBe(read.structuredContent.revision); @@ -335,7 +335,7 @@ describe('create-actor-version', () => { expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY, TOKEN.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY, TOKEN.', ]); expect(JSON.stringify(result)).not.toMatch(/abc123|secret-value/); }); @@ -609,13 +609,15 @@ describe('create-actor-version', () => { }); it('refuses a call with neither files nor copyFromVersion before any request', async () => { - expect(await callToolExpectingUserError({})).toBe('Give either files or copyFromVersion.'); + expect(await callToolExpectingUserError({})).toBe( + 'The call takes exactly one of files and copyFromVersion.', + ); expect(actorMock).not.toHaveBeenCalled(); }); it('refuses both files and copyFromVersion before any request', async () => { expect(await callToolExpectingUserError({ copyFromVersion: '0.1', files: [ACTOR_JSON] })).toBe( - 'Give either files or copyFromVersion.', + 'The call takes exactly one of files and copyFromVersion.', ); expect(actorMock).not.toHaveBeenCalled(); }); @@ -696,22 +698,22 @@ describe('create-actor-version', () => { source: 'files', args: { files: [ACTOR_JSON] }, version: {}, - ending: 'if it holds the files this call sent, this call created it.', + ending: 'If it holds the files this call sent, this call created it; get-actor-version reads its files.', }, { source: 'a copy', args: { copyFromVersion: '0.1' }, version: {}, ending: - 'if it holds the files of version 0.1, this call created it. These secret environment variables ' + - 'were not copied, so set them on version 0.2 in Apify Console, then build the version before ' + - 'running it: API_KEY.', + 'If it holds the files of version 0.1, this call created it; get-actor-version reads its files. ' + + 'These secret environment variables were not copied, so set them on version 0.2 in Apify ' + + 'Console; only a build made after that includes them: API_KEY.', }, { source: 'a copy of a version without secret env vars', args: { copyFromVersion: '0.1' }, version: { envVars: [{ name: 'REGION', value: 'eu' }] }, - ending: 'if it holds the files of version 0.1, this call created it.', + ending: 'If it holds the files of version 0.1, this call created it; get-actor-version reads its files.', }, ])( 'says an earlier attempt may have created the version when the platform refuses the number on a retried POST, from $source', @@ -732,8 +734,7 @@ describe('create-actor-version', () => { type: 'text', text: 'The platform refused version 0.2 as taken when the request was retried, so an earlier ' + - 'attempt of this call may have created it. Read it with get-actor-version before calling ' + - `create-actor-version again; ${ending}`, + `attempt of this call may have created it. ${ending}`, }, ]); expect(getPostBody().versionNumber).toBe('0.2'); @@ -760,8 +761,7 @@ describe('create-actor-version', () => { expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe( 'The platform refused version 0.2 as taken when the request was retried, so an earlier attempt of ' + - 'this call may have created it. Read it before calling create-actor-version again; if it holds ' + - 'the files this call sent, this call created it.', + 'this call may have created it. If it holds the files this call sent, this call created it.', ); }); @@ -851,10 +851,10 @@ describe('create-actor-version', () => { startedAt: '2026-09-01T10:00:00.000Z', finishedAt: null, }); - // The build started without the secret, so the warning says to build again once it is set. + // The build started without the secret, so the warning says only a later build includes it. expect(result.structuredContent.warnings).toEqual([ 'These secret environment variables were not copied, so the build this call started runs without ' + - 'them; set them on version 0.2 in Apify Console, then build the version again before running it: ' + + 'them. Set them on version 0.2 in Apify Console; only a build made after that includes them: ' + 'API_KEY.', ]); }); @@ -874,8 +874,8 @@ describe('create-actor-version', () => { expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', 'These secret environment variables were not copied, so the build this call started runs without ' + - 'them and, if it succeeds, takes tag beta; set them on version 0.2 in Apify Console, then build ' + - 'the version again before running it: API_KEY.', + 'them and, if it succeeds, takes tag beta. Set them on version 0.2 in Apify Console; only a build ' + + 'made after that includes them: API_KEY.', ]); }); @@ -890,14 +890,14 @@ describe('create-actor-version', () => { { autoBuild: true }, 'The build could not be started; runs use these files only after the version is built.', ], - ])('keeps the warning to build the version once the secret env vars are set, %s', async (_, args, nextStep) => { + ])('keeps the secret env vars warning, %s', async (_, args, nextStep) => { buildMock.mockRejectedValue(apiError(402, 'Not enough credit')); const result = await callTool({ copyFromVersion: '0.1', buildTag: 'beta', ...args }); expect(result.structuredContent).not.toHaveProperty('build'); expect(result.structuredContent.warnings).toEqual([ - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console, then build the version before running it: API_KEY.', + 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY.', ]); expect(result.content[1].text).toBe(`Created version 0.2 of john/my-actor.\n${nextStep}`); }); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 3d5519af6..2c4967256 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -122,8 +122,8 @@ describe('delete-actor-version', () => { versionNumber: '0.1', text: 'Deleted version 0.1 of john/my-actor.\n' + - "Runs with tag latest still use the deleted version's build; to have them run another version, " + - 'build that version with build-actor and tag latest.', + "Runs with tag latest still use the deleted version's build until each tag points to another build, " + + 'such as one of another version made with build-actor and tag latest.', }, { versionNumber: '0.2', text: 'Deleted version 0.2 of john/my-actor.' }, ])( @@ -162,8 +162,8 @@ describe('delete-actor-version', () => { taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.2.1' } }, text: 'Deleted version 0.2 of john/my-actor.\n' + - "Runs with tag latest still use the deleted version's build; to have them run another version, " + - 'build that version with build-actor and tag latest.', + "Runs with tag latest still use the deleted version's build until each tag points to another build, " + + 'such as one of another version made with build-actor and tag latest.', }, ])( 'takes the next step about a tag from the build it points to, not from the build tag of version $versionNumber', @@ -176,7 +176,7 @@ describe('delete-actor-version', () => { }, ); - it('says to build once per tag when several tags point to builds of the deleted version', async () => { + it('names one build per tag when several tags point to builds of the deleted version', async () => { actorGetMock.mockResolvedValue( mockActor({ taggedBuilds: { @@ -192,8 +192,8 @@ describe('delete-actor-version', () => { // build-actor takes one tag, so each tag needs its own build. expect(result.content[1].text).toBe( 'Deleted version 0.1 of john/my-actor.\n' + - "Runs with tag latest, beta still use the deleted version's build; to have them run another version, " + - 'build that version with build-actor and tag latest, then again with tag beta.', + "Runs with tag latest, beta still use the deleted version's build until each tag points to another " + + 'build, such as one of another version made with build-actor and tag latest, then one with tag beta.', ); }); @@ -223,8 +223,8 @@ describe('delete-actor-version', () => { expect(result.content[1].text).toBe( 'Deleted version 0.1 of john/my-actor.\n' + - "Runs with tag latest still use the deleted version's build; to have them run another version, " + - 'build that version with tag latest.', + "Runs with tag latest still use the deleted version's build until each tag points to another build, " + + 'such as one of another version made with tag latest.', ); }); From 954775352716b24a42a66e15f828ef50d8d88fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:42:52 +0200 Subject: [PATCH 43/60] fix: Mark create-actor-version as open-world autoBuild starts a build, like build-actor. delete-actor-version starts nothing and stays closed-world. --- src/tools/source/create_actor_version.ts | 3 ++- tests/unit/tools.create_actor_version.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 94e69a3d9..0fb7fa1c9 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -107,7 +107,8 @@ export const createActorVersion: ToolEntry = Object.freeze({ destructiveHint: false, // A second call with the same versionNumber is refused rather than repeated. idempotentHint: false, - openWorldHint: false, + // autoBuild starts a build, like build-actor. + openWorldHint: true, }, redactArgs: (args: Record) => redactSourceToolArgs(args, { diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index b3af3162d..92ca4dd92 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -145,14 +145,14 @@ describe('create-actor-version', () => { }); }); - it('is a non-destructive, non-idempotent, closed-world tool without payment', () => { + it('is a non-destructive, non-idempotent, open-world tool without payment, since autoBuild starts a build', () => { expect(createActorVersion.name).toBe(HELPER_TOOLS.ACTOR_VERSION_CREATE); expect(createActorVersion.annotations).toEqual({ title: 'Create Actor version', readOnlyHint: false, destructiveHint: false, idempotentHint: false, - openWorldHint: false, + openWorldHint: true, }); expect((createActorVersion as HelperTool).paymentRequired).toBeUndefined(); }); From 6e0e0c4ce877749371ad6101f26aca1435224a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:43:10 +0200 Subject: [PATCH 44/60] refactor: Drop the post-POST cancel check in create-actor-version respondAfterWrite checks the signal before it starts a build, and the SDK drops the response to a cancelled request, so the check changed nothing a client could see. The same check went from create-actor and update-actor-version. --- src/tools/source/create_actor_version.ts | 2 -- tests/unit/tools.create_actor_version.test.ts | 21 ++++++++----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 0fb7fa1c9..c2155f545 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -152,8 +152,6 @@ export const createActorVersion: ToolEntry = Object.freeze({ applyEnvVarsToBuild: copied.applyEnvVarsToBuild, }), } satisfies ActorVersion); - // Per the MCP spec a cancelled request gets no response; the version stands, and no build is started. - if (signal?.aborted) return respondAborted(); const files = buildFilesManifest(sourceFiles); const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 92ca4dd92..45861c12e 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -794,20 +794,17 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); - it.each([true, false])( - 'keeps the version and starts no build when the request is cancelled during the POST, with autoBuild %s', - async (autoBuild) => { - const controller = new AbortController(); - versionsCreateMock.mockImplementation(async () => controller.abort()); + it('keeps the version and starts no build when the request is cancelled during the POST', async () => { + const controller = new AbortController(); + versionsCreateMock.mockImplementation(async () => controller.abort()); - const result = await callTool({ copyFromVersion: '0.1', autoBuild }, controller.signal); + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); - expect(result).toEqual({}); - expect(versionsCreateMock).toHaveBeenCalledTimes(1); - expectNoOtherWrite(); - expect(buildMock).not.toHaveBeenCalled(); - }, - ); + expect(result).toEqual({}); + expect(versionsCreateMock).toHaveBeenCalledTimes(1); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); it('keeps the version and starts no build when the request is cancelled during the Console link lookup', async () => { const controller = new AbortController(); From 8437d507a9f6f4af17b96819f3bd5acdb7ac08f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:43:33 +0200 Subject: [PATCH 45/60] fix: Say that create- and delete-actor-version take a bare Actor name fetchActor resolves a name without the username against the token's own Actors, so the actor parameter text says so, as in get-actor-version and update-actor-version. --- src/tools/source/create_actor_version.ts | 2 +- src/tools/source/delete_actor_version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index c2155f545..05742bdcd 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -27,7 +27,7 @@ const createActorVersionArgs = z.object({ .string() .min(1) .describe( - 'The Actor to add the version to: its ID, or its full name as username/name or username~name. A name without the username is not enough.', + 'The Actor to add the version to: its ID, its name (resolved against your own Actors), or its full name as username/name or username~name.', ), versionNumber: z.string().min(1).describe('Number of the new version in MAJOR.MINOR form, for example 0.2.'), copyFromVersion: z diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 98680cbf2..80624e95d 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -14,7 +14,7 @@ const deleteActorVersionArgs = z.object({ .string() .min(1) .describe( - 'The Actor to delete the version from: its ID, or its full name as username/name or username~name. A name without the username is not enough.', + 'The Actor to delete the version from: its ID, its name (resolved against your own Actors), or its full name as username/name or username~name.', ), versionNumber: z.string().min(1).describe('The version to delete, in MAJOR.MINOR form, for example 0.2.'), }); From f7152104ed5c5218773c3260100ba433e941daf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:44:04 +0200 Subject: [PATCH 46/60] test: Expect create-actor-version to refuse a lone UTF-16 surrogate in a file path --- tests/unit/tools.create_actor_version.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 45861c12e..62d544cd9 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -598,6 +598,18 @@ describe('create-actor-version', () => { expect(buildMock).not.toHaveBeenCalled(); }); + it('refuses a path with a lone UTF-16 surrogate, and creates nothing', async () => { + expect( + await callToolExpectingUserError({ + autoBuild: true, + files: [MAIN_JS, { path: 'src/a\uD83D.js', content: 'x' }], + }), + ).toBe('files[1] has a lone UTF-16 surrogate in its path, which UTF-8 cannot store.'); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }); + it('checks 20,000 files for repeated and colliding paths within a second', async () => { const files = Array.from({ length: 20_000 }, (_, i) => ({ path: `src/d${i % 50}/f${i}.js`, content: 'x' })); From e6fac335e01036bd63a353b92841b753d9b0cd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:54:30 +0200 Subject: [PATCH 47/60] fix: State where the secret env vars can be set as a fact in create-actor-version --- src/tools/source/create_actor_version.ts | 6 +++--- tests/test_kit/cases/source.cases.ts | 2 +- tests/unit/tools.create_actor_version.test.ts | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 05742bdcd..d3a95479e 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -59,14 +59,14 @@ function formatSecretEnvVarsWarning( const names = secretNames.join(', '); if (!startedBuild) { return ( - `These secret environment variables were not copied, so set them on version ${versionNumber} ` + - `in Apify Console; only a build made after that includes them: ${names}.` + `These secret environment variables were not copied; they can be set on version ${versionNumber} ` + + `in Apify Console, and only a build made after that includes them: ${names}.` ); } const tag = startedBuild.buildTag === undefined ? '' : ` and, if it succeeds, takes tag ${startedBuild.buildTag}`; return ( `These secret environment variables were not copied, so the build this call started runs without them${tag}. ` + - `Set them on version ${versionNumber} in Apify Console; only a build made after that includes them: ${names}.` + `They can be set on version ${versionNumber} in Apify Console, and only a build made after that includes them: ${names}.` ); } diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 726fa984a..41fe46fb0 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -765,7 +765,7 @@ export const sourceCases: Case[] = [ files: read.files, warnings: [ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/blank.js.', - 'These secret environment variables were not copied, so set them on version 0.1 in Apify Console; only a build made after that includes them: SECRET_MODE.', + 'These secret environment variables were not copied; they can be set on version 0.1 in Apify Console, and only a build made after that includes them: SECRET_MODE.', ], }); // The warning names the secret, never its value. diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 62d544cd9..e33799095 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -273,7 +273,7 @@ describe('create-actor-version', () => { revision: buildFilesRevision(files), files, warnings: [ - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY.', + 'These secret environment variables were not copied; they can be set on version 0.2 in Apify Console, and only a build made after that includes them: API_KEY.', ], }); expect(result.structuredContent.revision).toBe(read.structuredContent.revision); @@ -335,7 +335,7 @@ describe('create-actor-version', () => { expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY, TOKEN.', + 'These secret environment variables were not copied; they can be set on version 0.2 in Apify Console, and only a build made after that includes them: API_KEY, TOKEN.', ]); expect(JSON.stringify(result)).not.toMatch(/abc123|secret-value/); }); @@ -718,8 +718,8 @@ describe('create-actor-version', () => { version: {}, ending: 'If it holds the files of version 0.1, this call created it; get-actor-version reads its files. ' + - 'These secret environment variables were not copied, so set them on version 0.2 in Apify ' + - 'Console; only a build made after that includes them: API_KEY.', + 'These secret environment variables were not copied; they can be set on version 0.2 in Apify ' + + 'Console, and only a build made after that includes them: API_KEY.', }, { source: 'a copy of a version without secret env vars', @@ -863,7 +863,7 @@ describe('create-actor-version', () => { // The build started without the secret, so the warning says only a later build includes it. expect(result.structuredContent.warnings).toEqual([ 'These secret environment variables were not copied, so the build this call started runs without ' + - 'them. Set them on version 0.2 in Apify Console; only a build made after that includes them: ' + + 'them. They can be set on version 0.2 in Apify Console, and only a build made after that includes them: ' + 'API_KEY.', ]); }); @@ -883,8 +883,8 @@ describe('create-actor-version', () => { expect(result.structuredContent.warnings).toEqual([ 'These files are empty, and the build skips empty files, so they will not exist in the build: src/__init__.py.', 'These secret environment variables were not copied, so the build this call started runs without ' + - 'them and, if it succeeds, takes tag beta. Set them on version 0.2 in Apify Console; only a build ' + - 'made after that includes them: API_KEY.', + 'them and, if it succeeds, takes tag beta. They can be set on version 0.2 in Apify Console, and ' + + 'only a build made after that includes them: API_KEY.', ]); }); @@ -906,7 +906,7 @@ describe('create-actor-version', () => { expect(result.structuredContent).not.toHaveProperty('build'); expect(result.structuredContent.warnings).toEqual([ - 'These secret environment variables were not copied, so set them on version 0.2 in Apify Console; only a build made after that includes them: API_KEY.', + 'These secret environment variables were not copied; they can be set on version 0.2 in Apify Console, and only a build made after that includes them: API_KEY.', ]); expect(result.content[1].text).toBe(`Created version 0.2 of john/my-actor.\n${nextStep}`); }); From edad6af5184fe6f367cd7b226fcd472a8f2a5abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:54:30 +0200 Subject: [PATCH 48/60] test: Call the delete-actor-version tag text a note in test names --- tests/unit/tools.delete_actor_version.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 2c4967256..23d64466b 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -166,7 +166,7 @@ describe('delete-actor-version', () => { 'such as one of another version made with build-actor and tag latest.', }, ])( - 'takes the next step about a tag from the build it points to, not from the build tag of version $versionNumber', + 'takes the note about a tag from the build it points to, not from the build tag of version $versionNumber', async ({ versionNumber, taggedBuilds, text }) => { actorGetMock.mockResolvedValue(mockActor({ taggedBuilds })); @@ -213,7 +213,7 @@ describe('delete-actor-version', () => { expect(result.content[1].text).toBe('Deleted version 0.1 of john/my-actor.'); }); - it('names build-actor in the next step about the build tag only when the session has it', async () => { + it('names build-actor in the note about the build tag only when the session has it', async () => { const loadedToolNames = Object.values(HELPER_TOOLS).filter((name) => name !== HELPER_TOOLS.ACTOR_BUILD); const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.1' }, stubClient, { loadedToolNames, From 034423d44e38fd141b988806cd6f2696e90f72d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:02:53 +0200 Subject: [PATCH 49/60] fix: Mark create-actor-version as destructive With buildTag and autoBuild, the build it starts moves an existing tag, as build-actor does. --- src/tools/source/create_actor_version.ts | 3 ++- tests/unit/tools.create_actor_version.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index d3a95479e..f2a0fbea7 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -104,7 +104,8 @@ export const createActorVersion: ToolEntry = Object.freeze({ annotations: { title: 'Create Actor version', readOnlyHint: false, - destructiveHint: false, + // With buildTag and autoBuild, the build it starts moves an existing tag, as build-actor does. + destructiveHint: true, // A second call with the same versionNumber is refused rather than repeated. idempotentHint: false, // autoBuild starts a build, like build-actor. diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index e33799095..9d977e0f3 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -145,12 +145,12 @@ describe('create-actor-version', () => { }); }); - it('is a non-destructive, non-idempotent, open-world tool without payment, since autoBuild starts a build', () => { + it('is a destructive, non-idempotent, open-world tool without payment, since autoBuild starts a build that can move an existing tag', () => { expect(createActorVersion.name).toBe(HELPER_TOOLS.ACTOR_VERSION_CREATE); expect(createActorVersion.annotations).toEqual({ title: 'Create Actor version', readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }); From 6aa9ff127a524777b378406629ba4afa2db19d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:03:27 +0200 Subject: [PATCH 50/60] test: Report versions-size-exceeded on the update-actor-version PUT The 403 mapping is shared by the source tools; the PUT is the write most likely to hit the size limit. --- tests/unit/tools.update_actor_version.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/unit/tools.update_actor_version.test.ts b/tests/unit/tools.update_actor_version.test.ts index 32c060447..2d8827f80 100644 --- a/tests/unit/tools.update_actor_version.test.ts +++ b/tests/unit/tools.update_actor_version.test.ts @@ -88,8 +88,8 @@ function mockFiles(...sourceFiles: Record[]) { mockVersionRead({ sourceFiles }); } -function apiError(status: number, message: string): ApifyApiError { - return new ApifyApiError({ data: { error: { type: 'some-error', message } }, status } as AxiosResponse, 1); +function apiError(status: number, message: string, type = 'some-error'): ApifyApiError { + return new ApifyApiError({ data: { error: { type, message } }, status } as AxiosResponse, 1); } async function callTool(args: Record, signal?: AbortSignal): Promise { @@ -2264,6 +2264,25 @@ describe('update-actor-version', () => { expect(JSON.stringify(result)).not.toMatch(/API_KEY|secret-value/); }); + it('reports the platform refusing the PUT with versions-size-exceeded', async () => { + const message = + 'The limit on the combined size of all versions has been exceeded. Delete unused versions to free up ' + + 'space or use another source type. See https://docs.apify.com/platform/actors/development/deployment/source-types.'; + versionUpdateMock.mockRejectedValue(apiError(403, message, 'versions-size-exceeded')); + + const result = await callTool({ autoBuild: true, operations: [write('b.js', 'b')] }); + + // The platform's reason, without the token-access hint the engine adds to a 403. + expectSoftFailInvalidInput(result); + expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); + expect(result.content).toStrictEqual([ + { type: 'text', text: `${message} (API error type: versions-size-exceeded)` }, + ]); + expect(result.structuredContent).toBeUndefined(); + expect(versionUpdateMock).toHaveBeenCalledTimes(1); + expect(buildMock).not.toHaveBeenCalled(); + }); + it('lets an API error from the PUT through unchanged', async () => { const error = apiError(400, 'Source files are too large.'); versionUpdateMock.mockRejectedValue(error); From 74a901ca6c4544a1a6901822effacbd31ae69278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:05:01 +0200 Subject: [PATCH 51/60] fix: Name the Actor's only version when delete-actor-version is refused The platform's too-few-versions refusal now reads as the Actor's only version. Only delete-actor-version can get it, so the shared source tool mapping no longer lists it. --- src/tools/source/delete_actor_version.ts | 17 ++++++++++++++--- src/tools/source/source_helpers.ts | 10 ++++------ tests/test_kit/cases/source.cases.ts | 7 ++++--- tests/unit/tools.delete_actor_version.test.ts | 13 ++++++++----- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 80624e95d..4f75a440e 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -1,11 +1,12 @@ +import { ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; -import { HELPER_TOOLS } from '../../const.js'; +import { APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, HELPER_TOOLS } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; -import { respondAborted, respondOk } from '../../utils/mcp.js'; +import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { deleteActorVersionToolOutputSchema } from '../structured_output_schemas.js'; import { fetchActor, resolveVersion, respondToSourceToolError } from './source_helpers.js'; @@ -59,7 +60,17 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const { versionNumber } = resolveVersion(actor, parsed.versionNumber, parsed.actor); // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); - await client.actor(actor.id).version(versionNumber).delete(); + try { + await client.actor(actor.id).version(versionNumber).delete(); + } catch (error) { + if (error instanceof ApifyApiError && error.type === APIFY_ERROR_TYPE_TOO_FEW_VERSIONS) { + return respondUserError( + `Version ${versionNumber} is the only version of ${fullName}, and an Actor keeps at least one version.`, + { httpStatus: error.statusCode }, + ); + } + throw error; + } const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true }; const summary = `Deleted version ${versionNumber} of ${fullName}.`; const buildTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_BUILD) diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index 67584b8ad..bf530df8f 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -6,7 +6,6 @@ import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; import { - APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, APIFY_ERROR_TYPE_TOO_MANY_VERSIONS, APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED, @@ -388,16 +387,15 @@ export async function respondAfterWrite(params: { /** The platform's 403 refusals of a version write that are not about access. */ const VERSION_REFUSAL_ERROR_TYPE_SET = new Set([ APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, - APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, APIFY_ERROR_TYPE_TOO_MANY_VERSIONS, APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED, ]); /** - * A `UserInputError`, or the platform's refusal of a taken version number, of deleting the last version, or of a - * version over the limit on the number of versions or on their combined size, as a soft failure with the platform's - * own message; anything else is rethrown for the tool-call engine to report. The engine would add a token-access hint - * to these 403s, which contradicts their reason. + * A `UserInputError`, or the platform's refusal of a taken version number or of a version over the limit on the + * number of versions or on their combined size, as a soft failure with the platform's own message; anything else is + * rethrown for the tool-call engine to report. The engine would add a token-access hint to these 403s, which + * contradicts their reason. */ export function respondToSourceToolError(error: unknown): ToolResponse { if (error instanceof UserInputError) return respondUserError(error.message); diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 41fe46fb0..8dc676e2a 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -836,12 +836,13 @@ export const sourceCases: Case[] = [ extractWrittenState(storedAfter), ); - // The sentence is the platform's, so only the API error type is exact, and no hint follows it. - // In apify-core it reads `The Actor must have at least 1 versions`, with status 403. + // The platform refuses it with too-few-versions, and no token-access hint follows the text. const lastText = expectToolFailure( await callTool(client, 'delete-actor-version', { actor: selector, versionNumber: '0.0' }), ); - expect(lastText).toMatch(/^.+ \(API error type: too-few-versions\)$/); + expect(lastText).toBe( + `Version 0.0 is the only version of ${seeded.username}/${name}, and an Actor keeps at least one version.`, + ); expect(extractWrittenState(await fetchStoredActor(api, seeded.id))).toEqual( extractWrittenState(storedAfter), ); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 23d64466b..bc6081fff 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -248,7 +248,7 @@ describe('delete-actor-version', () => { expectNoOtherWrite(); }); - it("reports the platform's refusal to delete the last version", async () => { + it("reports the platform's refusal to delete the last version as the Actor's only version", async () => { actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.1', sourceType: 'SOURCE_FILES' }] })); versionDeleteMock.mockRejectedValue( apiError(403, 'The Actor must have at least 1 versions', 'too-few-versions'), @@ -256,12 +256,15 @@ describe('delete-actor-version', () => { const result = await callTool({ versionNumber: '0.1' }); - // The platform's reason, without the token-access hint the engine adds to a 403. + // Without the token-access hint the engine adds to a 403. expectSoftFailInvalidInput(result); expect(result.toolTelemetry).toEqual(expect.objectContaining({ failureHttpStatus: 403 })); - expect(result.content[0].text).toBe( - 'The Actor must have at least 1 versions (API error type: too-few-versions)', - ); + expect(result.content).toStrictEqual([ + { + type: 'text', + text: 'Version 0.1 is the only version of john/my-actor, and an Actor keeps at least one version.', + }, + ]); // The tool leaves the check to the platform, and does nothing else after the refusal. expect(versionMock.mock.calls).toEqual([['0.1']]); expect(versionDeleteMock).toHaveBeenCalledTimes(1); From c456e010dc8b0c3bf068aa8c7d65846f46c6e310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:08:44 +0200 Subject: [PATCH 52/60] feat: Take an optional expectedRevision in delete-actor-version When given, the delete is refused before the DELETE unless the version's files have that revision, computed as get-actor-version and the write tools compute it and compared ignoring case. A version not stored as files has no revision, so expectedRevision is refused for it. A null or empty expectedRevision counts as not given. --- src/tools/AGENTS.md | 2 +- src/tools/source/delete_actor_version.ts | 44 +++++++++- tests/unit/tools.delete_actor_version.test.ts | 80 ++++++++++++++++++- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index ef5e4039a..ba524fb18 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -51,7 +51,7 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. after a write, which starts the build for autoBuild. get-actor-version, update-actor-version, and a copy in create-actor-version refuse a version not stored as files (Git repository, gist, or zip); create-actor-version from files and delete-actor-version work whatever the Actor's versions are - stored as. + stored as, but delete-actor-version takes expectedRevision only for a version stored as files. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The write tool is in no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 4f75a440e..94d70c418 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -1,14 +1,17 @@ -import { ApifyApiError } from 'apify-client'; +import type { ActorVersion } from 'apify-client'; +import { ActorSourceType, ApifyApiError } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; import { APIFY_ERROR_TYPE_TOO_FEW_VERSIONS, HELPER_TOOLS } from '../../const.js'; +import { UserInputError } from '../../errors.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { deleteActorVersionToolOutputSchema } from '../structured_output_schemas.js'; -import { fetchActor, resolveVersion, respondToSourceToolError } from './source_helpers.js'; +import { buildFilesManifest, buildFilesRevision } from './source_files.js'; +import { extractSourceFiles, fetchActor, resolveVersion, respondToSourceToolError } from './source_helpers.js'; const deleteActorVersionArgs = z.object({ actor: z @@ -18,8 +21,40 @@ const deleteActorVersionArgs = z.object({ 'The Actor to delete the version from: its ID, its name (resolved against your own Actors), or its full name as username/name or username~name.', ), versionNumber: z.string().min(1).describe('The version to delete, in MAJOR.MINOR form, for example 0.2.'), + expectedRevision: z + .string() + .optional() + .describe( + 'The revision of the version when you read it. When given, the delete is refused if any file of the ' + + 'version changed since. Only a version stored as files has a revision.', + ), }); +/** + * Throws `UserInputError` unless the version's revision, computed as get-actor-version and the write tools compute it, + * is `expectedRevision`, ignoring case. A version not stored as files has no revision. + */ +function validateRevision( + version: ActorVersion & { versionNumber: string }, + fullName: string, + expectedRevision: string, +) { + if (version.sourceType !== ActorSourceType.SourceFiles) { + throw new UserInputError( + `Nothing was deleted: version ${version.versionNumber} of ${fullName} has source type ${version.sourceType}, ` + + 'and only a version stored as files has a revision to compare expectedRevision with.', + ); + } + const revision = buildFilesRevision( + buildFilesManifest(extractSourceFiles(version, `Version ${version.versionNumber} of ${fullName}`)), + ); + if (expectedRevision.toLowerCase() === revision) return; + throw new UserInputError( + `Nothing was deleted: expectedRevision failed with REVISION_MISMATCH. The version's revision is ${revision}, ` + + `not ${expectedRevision}.`, + ); +} + /** * https://docs.apify.com/api/v2/actor-get * /v2/actors/{actorId} @@ -57,7 +92,10 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const parsed = deleteActorVersionArgs.parse(args); try { const { actor, fullName } = await fetchActor(client, parsed.actor); - const { versionNumber } = resolveVersion(actor, parsed.versionNumber, parsed.actor); + const version = resolveVersion(actor, parsed.versionNumber, parsed.actor); + const { versionNumber } = version; + // AJV turns a null expectedRevision into an empty string, which no version has, so it counts as not given. + if (parsed.expectedRevision) validateRevision(version, fullName, parsed.expectedRevision); // A cancel before the DELETE deletes nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); try { diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index bc6081fff..e756ae777 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import { deleteActorVersion } from '../../src/tools/source/delete_actor_version.js'; +import { getActorVersion } from '../../src/tools/source/get_actor_version.js'; import { deleteActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; import { @@ -30,6 +31,8 @@ type DeleteResult = TextToolResult & { toolTelemetry?: ToolTelemetrySnapshot; }; +const MAIN_JS = { name: 'src/main.js', format: 'TEXT', content: 'console.log(1);\n' }; + function mockActor(overrides: Record = {}) { return { id: 'actor-1', @@ -43,7 +46,7 @@ function mockActor(overrides: Record = {}) { gitRepoUrl: 'https://github.com/john/repo', buildTag: 'latest', }, - { versionNumber: '0.2', sourceType: 'SOURCE_FILES' }, + { versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: [MAIN_JS] }, ], taggedBuilds: { latest: { buildId: 'build-1', buildNumber: '0.1.3' } }, ...overrides, @@ -67,6 +70,14 @@ async function callToolExpectingUserError(args: Record) { return result.content[0].text; } +/** The revision get-actor-version returns for version 0.2. */ +async function readRevision(): Promise { + const result = (await (getActorVersion as HelperTool).call( + stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2' }, stubClient), + )) as { structuredContent: { revision: string } }; + return result.structuredContent.revision; +} + function expectNoOtherWrite() { expect(versionUpdateMock).not.toHaveBeenCalled(); for (const [name, mock] of Object.entries(actorWriteMocks)) { @@ -228,6 +239,73 @@ describe('delete-actor-version', () => { ); }); + it('describes expectedRevision as a check of the files against the revision read earlier', () => { + const { properties } = (deleteActorVersion as HelperTool).inputSchema as { + properties: Record; + }; + + expect(properties.expectedRevision.description).toBe( + 'The revision of the version when you read it. When given, the delete is refused if any file of the ' + + 'version changed since. Only a version stored as files has a revision.', + ); + }); + + it.each([ + ['as returned', (revision: string) => revision], + ['in upper case', (revision: string) => revision.toUpperCase()], + ])('deletes the version when expectedRevision is the revision get-actor-version returns, %s', async (_, toCase) => { + const expectedRevision = toCase(await readRevision()); + + const result = await callTool({ expectedRevision }); + + expect(versionMock.mock.calls).toEqual([['0.2']]); + expect(versionDeleteMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent.deleted).toBe(true); + }); + + it('refuses a stale expectedRevision (REVISION_MISMATCH) before the DELETE', async () => { + const revision = await readRevision(); + + expect(await callToolExpectingUserError({ expectedRevision: 'aaaaaaaaaaaaaaaa' })).toBe( + 'Nothing was deleted: expectedRevision failed with REVISION_MISMATCH. ' + + `The version's revision is ${revision}, not aaaaaaaaaaaaaaaa.`, + ); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); + + it('refuses expectedRevision for a version not stored as files, which has no revision, before the DELETE', async () => { + expect(await callToolExpectingUserError({ versionNumber: '0.1', expectedRevision: 'aaaaaaaaaaaaaaaa' })).toBe( + 'Nothing was deleted: version 0.1 of john/my-actor has source type GIT_REPO, and only a version stored ' + + 'as files has a revision to compare expectedRevision with.', + ); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); + + it('refuses expectedRevision for a version whose source the API hides, before the DELETE', async () => { + actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.2', sourceType: 'SOURCE_FILES' }] })); + + expect(await callToolExpectingUserError({ expectedRevision: 'aaaaaaaaaaaaaaaa' })).toBe( + 'Version 0.2 of john/my-actor came back without its source: the API hides it from accounts that cannot ' + + "modify the Actor. Ask the Actor's owner for the source.", + ); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); + + it.each(['0.1', '0.2'])('takes a null expectedRevision as not given for version %s', async (versionNumber) => { + const args = { actor: 'john/my-actor', versionNumber, expectedRevision: null }; + + expect((deleteActorVersion as HelperTool).ajvValidate(args)).toBe(true); + expect(args.expectedRevision).toBe(''); + const result = await callTool(args); + + expect(versionMock.mock.calls).toEqual([[versionNumber]]); + expect(versionDeleteMock).toHaveBeenCalledTimes(1); + expect(result.structuredContent.deleted).toBe(true); + }); + it('refuses a version the Actor does not have, which apify-client would report as deleted', async () => { expect(await callToolExpectingUserError({ versionNumber: '0.9' })).toBe( "Actor 'john/my-actor' has no version 0.9; available versions: 0.1, 0.2.", From 5552111e185538d9dbace2c5d62da5480116c9c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:12:02 +0200 Subject: [PATCH 53/60] feat: Return the version's build tag in get-actor-version structuredContent gets buildTag, left out when the version has none, and the summary names it, so a caller can tell which version builds as latest and which as beta. --- src/tools/source/get_actor_version.ts | 5 ++- src/tools/structured_output_schemas.ts | 4 ++ tests/test_kit/cases/source.cases.ts | 1 + tests/unit/tools.get_actor_version.test.ts | 47 +++++++++++++++++----- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/tools/source/get_actor_version.ts b/src/tools/source/get_actor_version.ts index f4c14c67d..1bdf026ca 100644 --- a/src/tools/source/get_actor_version.ts +++ b/src/tools/source/get_actor_version.ts @@ -182,10 +182,12 @@ export const getActorVersion: ToolEntry = Object.freeze({ const files = buildFilesManifest( extractSourceFiles(version, `Version ${version.versionNumber} of ${fullName}`), ); + const { buildTag } = version; const structuredContent = { actorId: actor.id, fullName, versionNumber: version.versionNumber, + ...(buildTag && { buildTag }), revision: buildFilesRevision(files), files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), ...selectContents(files, paths, lineRange), @@ -194,9 +196,10 @@ export const getActorVersion: ToolEntry = Object.freeze({ const omittedHint = lineRange ? 'ask for fewer lines with lineCount' : 'ask for a file alone, or for part of it with startLine and lineCount'; + const tag = buildTag ? ` (build tag ${buildTag})` : ''; // Names the requested files that did not come back, for a caller that reads only the text. const summary = [ - `Read version ${version.versionNumber} of ${fullName}.`, + `Read version ${version.versionNumber}${tag} of ${fullName}.`, omittedPaths && `Left out over the ${INLINE_LIMIT_KIB} KiB limit: ${omittedPaths.join(', ')}; ${omittedHint}.`, notFoundPaths && diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index d4d81ffcb..52c129344 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -587,6 +587,10 @@ export const getActorVersionToolOutputSchema = { actorId: { type: 'string', description: 'Actor ID' }, fullName: { type: 'string', description: 'Actor full name, username/name' }, versionNumber: { type: 'string', description: 'Version number, e.g. 0.1' }, + buildTag: { + type: 'string', + description: 'Tag that new builds of this version get, e.g. latest; absent when the version has none', + }, revision: { type: 'string', description: 'Identifies the whole file set; changes when any file changes' }, files: { type: 'array', diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 8dc676e2a..6d230c16f 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -232,6 +232,7 @@ export const sourceCases: Case[] = [ actorId: created.actorId, fullName, versionNumber: '0.0', + buildTag: 'latest', revision: created.revision, files: created.files, contents: [ diff --git a/tests/unit/tools.get_actor_version.test.ts b/tests/unit/tools.get_actor_version.test.ts index 1c3a5ac64..c409eabe2 100644 --- a/tests/unit/tools.get_actor_version.test.ts +++ b/tests/unit/tools.get_actor_version.test.ts @@ -204,6 +204,7 @@ describe('get-actor-version', () => { actorId: 'actor-1', fullName: 'john/my-actor', versionNumber: '0.1', + buildTag: 'latest', revision: expect.stringMatching(/^[0-9a-f]{16}$/), files: [ { path: '.actor/actor.json', sizeBytes: 25, hash: sha256Prefix(ACTOR_JSON_SOURCE.content) }, @@ -214,7 +215,7 @@ describe('get-actor-version', () => { }); expect(JSON.parse(result.content[0].text)).toEqual(structuredContent); expect(result.content).toHaveLength(2); - expect(result.content[1].text).toBe('Read version 0.1 of john/my-actor.'); + expect(result.content[1].text).toBe('Read version 0.1 (build tag latest) of john/my-actor.'); expectSchemaConformingStructuredContent(result, getActorVersionToolOutputSchema); const serialized = JSON.stringify(result); for (const secret of LEAKABLE_VALUES) { @@ -465,7 +466,7 @@ describe('get-actor-version', () => { ]); expect(structuredContent.notFoundPaths).toEqual(['./missing.js', 'src/']); expect(result.content[1].text).toBe( - 'Read version 0.1 of john/my-actor. Not found: ./missing.js, src/; check the paths against files (folders are not files).', + 'Read version 0.1 (build tag latest) of john/my-actor. Not found: ./missing.js, src/; check the paths against files (folders are not files).', ); }); @@ -621,6 +622,7 @@ describe('get-actor-version', () => { actorId: 'actor-1', fullName: 'john/my-actor', versionNumber: '0.1', + buildTag: 'latest', revision: sha256Prefix('[]'), files: [], contents: [], @@ -778,37 +780,37 @@ describe('get-actor-version', () => { { outcome: 'only the read sentence when every file comes back', args: { paths: ['file-00.txt'] }, - summary: 'Read version 0.1 of john/my-actor.', + summary: 'Read version 0.1 (build tag latest) of john/my-actor.', }, { outcome: 'the files left out over the limit', args: { paths: ['file-00.txt', 'file-01.txt', 'file-02.txt', 'file-03.txt'] }, summary: - 'Read version 0.1 of john/my-actor. Left out over the 256 KiB limit: file-02.txt, file-03.txt; ask for a file alone, or for part of it with startLine and lineCount.', + 'Read version 0.1 (build tag latest) of john/my-actor. Left out over the 256 KiB limit: file-02.txt, file-03.txt; ask for a file alone, or for part of it with startLine and lineCount.', }, { outcome: 'the paths with no file, a folder among them', args: { paths: ['file-00.txt', 'missing.js', 'src'] }, summary: - 'Read version 0.1 of john/my-actor. Not found: missing.js, src; check the paths against files (folders are not files).', + 'Read version 0.1 (build tag latest) of john/my-actor. Not found: missing.js, src; check the paths against files (folders are not files).', }, { outcome: 'both the files left out and the paths with no file', args: { paths: ['file-00.txt', 'file-01.txt', 'file-02.txt', 'missing.js'] }, summary: - 'Read version 0.1 of john/my-actor. Left out over the 256 KiB limit: file-02.txt; ask for a file alone, or for part of it with startLine and lineCount. Not found: missing.js; check the paths against files (folders are not files).', + 'Read version 0.1 (build tag latest) of john/my-actor. Left out over the 256 KiB limit: file-02.txt; ask for a file alone, or for part of it with startLine and lineCount. Not found: missing.js; check the paths against files (folders are not files).', }, { outcome: 'a line range left out over the limit, with a hint to ask for fewer lines', args: { paths: ['big.js'], lineCount: 257 }, summary: - 'Read version 0.1 of john/my-actor. Left out over the 256 KiB limit: big.js; ask for fewer lines with lineCount.', + 'Read version 0.1 (build tag latest) of john/my-actor. Left out over the 256 KiB limit: big.js; ask for fewer lines with lineCount.', }, { outcome: 'a line range of a path with no file', args: { paths: ['nope.js'], startLine: 1 }, summary: - 'Read version 0.1 of john/my-actor. Not found: nope.js; check the paths against files (folders are not files).', + 'Read version 0.1 (build tag latest) of john/my-actor. Not found: nope.js; check the paths against files (folders are not files).', }, ])('says $outcome', async ({ args, summary }) => { const result = await callTool(args); @@ -847,6 +849,33 @@ describe('get-actor-version', () => { expect(structuredContent.files.map(({ path }) => path)).toEqual(['src/main.js']); }); + it('returns the build tag of the requested version and names it in the summary', async () => { + actorGetMock.mockResolvedValue( + mockActor([mockVersion(), mockVersion({ versionNumber: '0.2', buildTag: 'beta' })]), + ); + + const result = await callTool({ versionNumber: '0.2' }); + + expect(getActorVersionToolOutputSchema.properties.buildTag).toMatchObject({ type: 'string' }); + expect(getActorVersionToolOutputSchema.required).not.toContain('buildTag'); + expectSchemaConformingStructuredContent(result, getActorVersionToolOutputSchema); + expect(result.structuredContent.buildTag).toBe('beta'); + expect(result.content[1].text).toBe('Read version 0.2 (build tag beta) of john/my-actor.'); + }); + + it.each([ + ['without the field', { buildTag: undefined }], + ['with null', { buildTag: null }], + ])('leaves the build tag out for a version %s', async (_, overrides) => { + mockVersionRead(overrides); + + const result = await callTool({}); + + expectSchemaConformingStructuredContent(result, getActorVersionToolOutputSchema); + expect(result.structuredContent).not.toHaveProperty('buildTag'); + expect(result.content[1].text).toBe('Read version 0.1 of john/my-actor.'); + }); + it('reads the only version for a null versionNumber, which the input check turns into an empty string', async () => { const args = { actor: 'john/my-actor', versionNumber: null }; @@ -863,7 +892,7 @@ describe('get-actor-version', () => { expect(actorMock.mock.calls).toEqual([[actor]]); expect(result.structuredContent.fullName).toBe('john/my-actor'); - expect(result.content[1].text).toBe('Read version 0.1 of john/my-actor.'); + expect(result.content[1].text).toBe('Read version 0.1 (build tag latest) of john/my-actor.'); }, ); From d5222145570a22c5b287f52cf043f488c53337d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:13:16 +0200 Subject: [PATCH 54/60] feat: Return the tags of the deleted version's builds in delete-actor-version structuredContent gets tags, the tags its text already names, as an array that is empty when no tag points to a build of the version. --- src/tools/source/delete_actor_version.ts | 2 +- src/tools/structured_output_schemas.ts | 8 +++++++- tests/test_kit/cases/source.cases.ts | 1 + tests/unit/tools.delete_actor_version.test.ts | 20 +++++++++++++------ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index 94d70c418..febc33c37 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -109,7 +109,6 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ } throw error; } - const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true }; const summary = `Deleted version ${versionNumber} of ${fullName}.`; const buildTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_BUILD) ? `${HELPER_TOOLS.ACTOR_BUILD} and ` @@ -119,6 +118,7 @@ export const deleteActorVersion: ToolEntry = Object.freeze({ const tags = Object.entries(actor.taggedBuilds ?? {}) .filter(([, { buildNumber }]) => buildNumber?.startsWith(`${versionNumber}.`)) .map(([tag]) => tag); + const structuredContent = { actorId: actor.id, fullName, versionNumber, deleted: true, tags }; // build-actor takes one tag, so the text names one build per tag. const text = tags.length > 0 diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 52c129344..9e7f44ab9 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -695,8 +695,14 @@ export const deleteActorVersionToolOutputSchema = { fullName: { type: 'string', description: 'Actor full name, username/name' }, versionNumber: { type: 'string', description: 'Number of the deleted version' }, deleted: { type: 'boolean', description: 'Always true' }, + tags: { + type: 'array', + items: { type: 'string' }, + description: + 'Tags that point to a build of the deleted version, empty when none; runs with such a tag still use that build', + }, }, - required: ['actorId', 'fullName', 'versionNumber', 'deleted'], + required: ['actorId', 'fullName', 'versionNumber', 'deleted', 'tags'], }; /** Schema for update-actor-version: the new revision and what changed. */ diff --git a/tests/test_kit/cases/source.cases.ts b/tests/test_kit/cases/source.cases.ts index 6d230c16f..5d7cf4232 100644 --- a/tests/test_kit/cases/source.cases.ts +++ b/tests/test_kit/cases/source.cases.ts @@ -822,6 +822,7 @@ export const sourceCases: Case[] = [ fullName: `${seeded.username}/${name}`, versionNumber: '0.1', deleted: true, + tags: [], }); // No tag points to a build of version 0.1, so no next step about one follows. expect(deleteResult.content?.[1]?.text).toBe(`Deleted version 0.1 of ${seeded.username}/${name}.`); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index e756ae777..cd0ec3fbc 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -27,7 +27,7 @@ const actorMock = vi.fn(() => ({ get: actorGetMock, version: versionMock, ...act const stubClient = { actor: actorMock } as unknown as InternalToolArgs['apifyClient']; type DeleteResult = TextToolResult & { - structuredContent: { actorId: string; fullName: string; versionNumber: string; deleted: boolean }; + structuredContent: { actorId: string; fullName: string; versionNumber: string; deleted: boolean; tags: string[] }; toolTelemetry?: ToolTelemetrySnapshot; }; @@ -131,17 +131,19 @@ describe('delete-actor-version', () => { // Tag latest points to build 0.1.3, which stays after the delete. { versionNumber: '0.1', + tags: ['latest'], text: 'Deleted version 0.1 of john/my-actor.\n' + "Runs with tag latest still use the deleted version's build until each tag points to another build, " + 'such as one of another version made with build-actor and tag latest.', }, - { versionNumber: '0.2', text: 'Deleted version 0.2 of john/my-actor.' }, + { versionNumber: '0.2', tags: [], text: 'Deleted version 0.2 of john/my-actor.' }, ])( 'deletes only version $versionNumber of the Actor it resolved, with one DELETE', - async ({ versionNumber, text }) => { + async ({ versionNumber, tags, text }) => { const result = await callTool({ versionNumber }); + expect(deleteActorVersionToolOutputSchema.required).toContain('tags'); expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); // The Actor GET by the name given, then the DELETE by the Actor ID it returned. expect(actorMock.mock.calls).toEqual([['john/my-actor'], ['actor-1']]); @@ -153,6 +155,7 @@ describe('delete-actor-version', () => { fullName: 'john/my-actor', versionNumber, deleted: true, + tags, }); expect(result.content[1].text).toBe(text); }, @@ -163,26 +166,29 @@ describe('delete-actor-version', () => { { versionNumber: '0.1', taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.2.1' } }, + tags: [], text: 'Deleted version 0.1 of john/my-actor.', }, // Version 0.1 has the build tag latest, but was never built. - { versionNumber: '0.1', taggedBuilds: undefined, text: 'Deleted version 0.1 of john/my-actor.' }, + { versionNumber: '0.1', taggedBuilds: undefined, tags: [], text: 'Deleted version 0.1 of john/my-actor.' }, // Version 0.2 has no build tag, but build-actor gave one of its builds the tag latest. { versionNumber: '0.2', taggedBuilds: { latest: { buildId: 'build-2', buildNumber: '0.2.1' } }, + tags: ['latest'], text: 'Deleted version 0.2 of john/my-actor.\n' + "Runs with tag latest still use the deleted version's build until each tag points to another build, " + 'such as one of another version made with build-actor and tag latest.', }, ])( - 'takes the note about a tag from the build it points to, not from the build tag of version $versionNumber', - async ({ versionNumber, taggedBuilds, text }) => { + 'takes the tags and the note about them from the build each tag points to, not from the build tag of version $versionNumber', + async ({ versionNumber, taggedBuilds, tags, text }) => { actorGetMock.mockResolvedValue(mockActor({ taggedBuilds })); const result = await callTool({ versionNumber }); + expect(result.structuredContent.tags).toEqual(tags); expect(result.content[1].text).toBe(text); }, ); @@ -200,6 +206,7 @@ describe('delete-actor-version', () => { const result = await callTool({ versionNumber: '0.1' }); + expect(result.structuredContent.tags).toEqual(['latest', 'beta']); // build-actor takes one tag, so each tag needs its own build. expect(result.content[1].text).toBe( 'Deleted version 0.1 of john/my-actor.\n' + @@ -221,6 +228,7 @@ describe('delete-actor-version', () => { const result = await callTool({ versionNumber: '0.1' }); + expect(result.structuredContent.tags).toEqual([]); expect(result.content[1].text).toBe('Deleted version 0.1 of john/my-actor.'); }); From bee212cc51dba1bfd0fe62346d46ad03a34f400d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:19:20 +0200 Subject: [PATCH 55/60] refactor: Share the create result and retried-POST refusal of the create tools create-actor and create-actor-version built the same result after the POST and the same refusal of a taken name or number on a retried POST. Both now come from source_helpers.ts, with the served text unchanged. --- src/tools/source/create_actor.ts | 50 +++++------------ src/tools/source/create_actor_version.ts | 69 ++++++++--------------- src/tools/source/source_helpers.ts | 71 +++++++++++++++++++++++- 3 files changed, 107 insertions(+), 83 deletions(-) diff --git a/src/tools/source/create_actor.ts b/src/tools/source/create_actor.ts index 997c9208a..853561bfd 100644 --- a/src/tools/source/create_actor.ts +++ b/src/tools/source/create_actor.ts @@ -1,5 +1,5 @@ import type { ActorCollectionCreateOptions, ActorVersionSourceFile } from 'apify-client'; -import { ActorSourceType, ApifyApiError } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; @@ -7,17 +7,15 @@ import { APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE, HELPER_TOOLS } from '../../cons import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { TOOL_TYPE } from '../../types.js'; import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; -import { respondAborted, respondUserError } from '../../utils/mcp.js'; +import { respondAborted } from '../../utils/mcp.js'; import { createActorToolOutputSchema } from '../structured_output_schemas.js'; -import { buildFilesManifest, buildFilesRevision } from './source_files.js'; import { - buildEmptyFilesWarnings, buildSourceFileEntries, extractSourceFiles, redactSourceToolArgs, resolveVersion, - respondAfterWrite, - respondToSourceToolError, + respondAfterCreate, + respondToCreateError, sourceFileArgs, } from './source_helpers.js'; @@ -124,41 +122,21 @@ export const createActor: ToolEntry = Object.freeze({ // The platform sets the name in .actor/actor.json on create, so the stored files, not the sent ones, match // a later read. const version = resolveVersion(created, undefined, fullName); - const files = buildFilesManifest( - extractSourceFiles(version, `Version ${version.versionNumber} of ${fullName}`), - ); - const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); - return await respondAfterWrite({ + return await respondAfterCreate({ toolArgs, autoBuild: parsed.autoBuild, - target: { actorId: created.id, versionNumber: version.versionNumber }, - structuredContent: { - actorId: created.id, - fullName, - versionNumber: version.versionNumber, - revision: buildFilesRevision(files), - files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), - ...buildEmptyFilesWarnings(emptyPaths), - }, + created: { actorId: created.id, fullName, versionNumber: version.versionNumber }, + sourceFiles: extractSourceFiles(version, `Version ${version.versionNumber} of ${fullName}`), summary: `Created the private Actor ${fullName}.`, }); } catch (error) { - if ( - error instanceof ApifyApiError && - error.type === APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE && - error.attempt > 1 - ) { - const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) - ? `; ${HELPER_TOOLS.ACTOR_VERSION_GET} reads its files` - : ''; - return respondUserError( - `The platform refused the name ${parsed.name} as taken when the request was retried, so an earlier ` + - `attempt of this call may have created the Actor ${parsed.name}. ` + - `If it holds the files you sent, this call created it${readTool}.`, - { httpStatus: error.statusCode }, - ); - } - return respondToSourceToolError(error); + return respondToCreateError(error, { + takenErrorType: APIFY_ERROR_TYPE_ACTOR_NAME_NOT_UNIQUE, + loadedToolNames, + refused: `the name ${parsed.name}`, + created: `the Actor ${parsed.name}`, + sentFiles: 'the files you sent', + }); } }, } as const); diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index f2a0fbea7..0f5637555 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -1,5 +1,5 @@ import type { ActorVersion } from 'apify-client'; -import { ActorSourceType, ApifyApiError } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; @@ -9,16 +9,14 @@ import { TOOL_TYPE } from '../../types.js'; import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; import { respondAborted, respondUserError } from '../../utils/mcp.js'; import { createActorVersionToolOutputSchema } from '../structured_output_schemas.js'; -import { buildFilesManifest, buildFilesRevision } from './source_files.js'; import { - buildEmptyFilesWarnings, buildSourceFileEntries, extractSourceFiles, fetchActor, redactSourceToolArgs, resolveVersion, - respondAfterWrite, - respondToSourceToolError, + respondAfterCreate, + respondToCreateError, sourceFileArgs, } from './source_helpers.js'; @@ -153,54 +151,33 @@ export const createActorVersion: ToolEntry = Object.freeze({ applyEnvVarsToBuild: copied.applyEnvVarsToBuild, }), } satisfies ActorVersion); - const files = buildFilesManifest(sourceFiles); - const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); - const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); - const buildStartedWarnings = [...warnings]; - if (secretNames.length > 0) { - warnings.push(formatSecretEnvVarsWarning(secretNames, versionNumber)); - buildStartedWarnings.push( - formatSecretEnvVarsWarning(secretNames, versionNumber, { buildTag: parsed.buildTag }), - ); - } - return await respondAfterWrite({ + return await respondAfterCreate({ toolArgs, autoBuild: parsed.autoBuild, - target: { actorId: actor.id, versionNumber }, - structuredContent: { - actorId: actor.id, - fullName, - versionNumber, - revision: buildFilesRevision(files), - files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), - ...(warnings.length > 0 && { warnings }), - }, - ...(secretNames.length > 0 && { buildStartedWarnings }), + created: { actorId: actor.id, fullName, versionNumber }, + sourceFiles, + ...(secretNames.length > 0 && { + extraWarning: { + withoutBuild: formatSecretEnvVarsWarning(secretNames, versionNumber), + withBuild: formatSecretEnvVarsWarning(secretNames, versionNumber, { + buildTag: parsed.buildTag, + }), + }, + }), summary: `Created version ${versionNumber} of ${fullName}.`, }); } catch (error) { - if ( - error instanceof ApifyApiError && - error.type === APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS && - error.attempt > 1 - ) { - const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) - ? `; ${HELPER_TOOLS.ACTOR_VERSION_GET} reads its files` - : ''; - const sentFiles = + return respondToCreateError(error, { + takenErrorType: APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, + loadedToolNames, + refused: `version ${versionNumber}`, + created: 'it', + sentFiles: copyFromVersion === undefined ? 'the files this call sent' - : `the files of version ${copyFromVersion}`; - const secretsWarning = - secretNames.length > 0 ? ` ${formatSecretEnvVarsWarning(secretNames, versionNumber)}` : ''; - return respondUserError( - `The platform refused version ${versionNumber} as taken when the request was retried, so an ` + - `earlier attempt of this call may have created it. ` + - `If it holds ${sentFiles}, this call created it${readTool}.${secretsWarning}`, - { httpStatus: error.statusCode }, - ); - } - return respondToSourceToolError(error); + : `the files of version ${copyFromVersion}`, + ...(secretNames.length > 0 && { note: formatSecretEnvVarsWarning(secretNames, versionNumber) }), + }); } }, } as const); diff --git a/src/tools/source/source_helpers.ts b/src/tools/source/source_helpers.ts index bf530df8f..915c7f90f 100644 --- a/src/tools/source/source_helpers.ts +++ b/src/tools/source/source_helpers.ts @@ -9,6 +9,7 @@ import { APIFY_ERROR_TYPE_TOO_MANY_VERSIONS, APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, APIFY_ERROR_TYPE_VERSIONS_SIZE_EXCEEDED, + HELPER_TOOLS, } from '../../const.js'; import { UserInputError } from '../../errors.js'; import { REDACTED } from '../../resources/api_resources.js'; @@ -20,7 +21,7 @@ import { respondAborted, respondUserError } from '../../utils/mcp.js'; import { isRecord } from '../api/apify_api_spec.js'; import { buildNextStepForBuild, listVersionNumbers, respondWithBuild, toBuildResult } from '../builds/build_helpers.js'; import { getResourceByIdOrName } from '../tasks/task_helpers.js'; -import { hasBinaryExtension } from './source_files.js'; +import { buildFilesManifest, buildFilesRevision, hasBinaryExtension } from './source_files.js'; /** The input shape of one file sent whole. */ export const sourceFileArgs = z.object({ @@ -384,6 +385,42 @@ export async function respondAfterWrite(params: { }); } +/** + * The response to a POST that created a version from `sourceFiles`, as `respondAfterWrite` gives it: their manifest + * and revision as get-actor-version gives them, and the warnings about empty files followed by + * `extraWarning.withoutBuild`, or by `extraWarning.withBuild` when autoBuild starts a build. + */ +export async function respondAfterCreate(params: { + toolArgs: Pick; + autoBuild: boolean; + created: { actorId: string; fullName: string; versionNumber: string }; + sourceFiles: readonly ActorVersionSourceFile[]; + extraWarning?: { withoutBuild: string; withBuild: string }; + summary: string; +}): Promise { + const { toolArgs, autoBuild, created, sourceFiles, extraWarning, summary } = params; + const { actorId, fullName, versionNumber } = created; + const files = buildFilesManifest(sourceFiles); + const emptyPaths = files.filter(({ sizeBytes }) => sizeBytes === 0).map(({ path }) => path); + const { warnings = [] } = buildEmptyFilesWarnings(emptyPaths); + const allWarnings = extraWarning ? [...warnings, extraWarning.withoutBuild] : warnings; + return await respondAfterWrite({ + toolArgs, + autoBuild, + target: { actorId, versionNumber }, + structuredContent: { + actorId, + fullName, + versionNumber, + revision: buildFilesRevision(files), + files: files.map(({ path, sizeBytes, hash }) => ({ path, sizeBytes, hash })), + ...(allWarnings.length > 0 && { warnings: allWarnings }), + }, + ...(extraWarning && { buildStartedWarnings: [...warnings, extraWarning.withBuild] }), + summary, + }); +} + /** The platform's 403 refusals of a version write that are not about access. */ const VERSION_REFUSAL_ERROR_TYPE_SET = new Set([ APIFY_ERROR_TYPE_VERSION_ALREADY_EXISTS, @@ -404,3 +441,35 @@ export function respondToSourceToolError(error: unknown): ToolResponse { } throw error; } + +/** + * The response to an error of a create POST. apify-client retries the POST after a network error, a timeout, a 429, + * or a 5xx, so when the platform refuses a retry as taken (`takenErrorType`), an attempt it saved but did not answer + * may have created what it refused (`refused`, such as `version 0.2`): the response says so, then adds `note` when + * given. Any other error goes to `respondToSourceToolError`. + */ +export function respondToCreateError( + error: unknown, + params: { + takenErrorType: string; + loadedToolNames: readonly string[]; + refused: string; + created: string; + sentFiles: string; + note?: string; + }, +): ToolResponse { + const { takenErrorType, loadedToolNames, refused, created, sentFiles, note } = params; + if (!(error instanceof ApifyApiError && error.type === takenErrorType && error.attempt > 1)) { + return respondToSourceToolError(error); + } + const readTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_VERSION_GET) + ? `; ${HELPER_TOOLS.ACTOR_VERSION_GET} reads its files` + : ''; + const noteText = note === undefined ? '' : ` ${note}`; + return respondUserError( + `The platform refused ${refused} as taken when the request was retried, so an earlier attempt of this call ` + + `may have created ${created}. If it holds ${sentFiles}, this call created it${readTool}.${noteText}`, + { httpStatus: error.statusCode }, + ); +} From 1e8d40ea2cda3c1ecafc3135ab71e10c4b21f0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:20:47 +0200 Subject: [PATCH 56/60] refactor: Narrow the create-actor-version files without a non-null assertion A type guard on the check that the call gives exactly one of files and copyFromVersion types files as defined when copyFromVersion is not given. --- src/tools/source/create_actor_version.ts | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/tools/source/create_actor_version.ts b/src/tools/source/create_actor_version.ts index 0f5637555..5b422a056 100644 --- a/src/tools/source/create_actor_version.ts +++ b/src/tools/source/create_actor_version.ts @@ -1,4 +1,4 @@ -import type { ActorVersion } from 'apify-client'; +import type { ActorVersion, ActorVersionSourceFile } from 'apify-client'; import { ActorSourceType } from 'apify-client'; import dedent from 'dedent'; import { z } from 'zod'; @@ -45,6 +45,19 @@ const createActorVersionArgs = z.object({ .describe('Start a build of the new version after creating it, and return without waiting. Default: false.'), }); +type CreateActorVersionArgs = z.infer; + +/** Whether the call gives exactly one of files and copyFromVersion, so that copyFromVersion tells which. */ +function hasOneSource( + args: CreateActorVersionArgs, +): args is CreateActorVersionArgs & + ( + | { copyFromVersion: string; files?: undefined } + | { copyFromVersion?: undefined; files: NonNullable } + ) { + return (args.copyFromVersion === undefined) !== (args.files === undefined); +} + /** * The warning about the secret env vars a copy leaves out. With `startedBuild`, it says that the build this call started * runs without them and takes the buildTag if it succeeds, when one was given. @@ -118,10 +131,10 @@ export const createActorVersion: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const { args, apifyClient: client, signal, loadedToolNames } = toolArgs; const parsed = createActorVersionArgs.parse(args); - const { versionNumber, copyFromVersion } = parsed; - if ((copyFromVersion === undefined) === (parsed.files === undefined)) { + if (!hasOneSource(parsed)) { return respondUserError('The call takes exactly one of files and copyFromVersion.'); } + const { versionNumber, copyFromVersion, files } = parsed; // Set before the POST, so the reply to a retried POST can name them. let secretNames: (string | undefined)[] = []; try { @@ -129,11 +142,14 @@ export const createActorVersion: ToolEntry = Object.freeze({ // TODO: The Apify API has no endpoint that copies a version, so a copy reads every file of the other version // from the Actor GET and sends them all back in the POST below. Once the API has one, let the platform make // the copy. - const copied = - copyFromVersion === undefined ? undefined : resolveVersion(actor, copyFromVersion, parsed.actor); - const sourceFiles = copied - ? extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`) - : buildSourceFileEntries(parsed.files!); + let copied: ReturnType | undefined; + let sourceFiles: ActorVersionSourceFile[]; + if (copyFromVersion === undefined) { + sourceFiles = buildSourceFileEntries(files); + } else { + copied = resolveVersion(actor, copyFromVersion, parsed.actor); + sourceFiles = extractSourceFiles(copied, `Version ${copied.versionNumber} of ${fullName}`); + } secretNames = copied?.envVars?.filter(({ isSecret }) => isSecret).map(({ name }) => name) ?? []; // A cancel during the read creates nothing; per the MCP spec the cancelled request gets no response. if (signal?.aborted) return respondAborted(); From 4d8d435b2b6308fa2da1a690e3fb9a1822e9931d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:29:29 +0200 Subject: [PATCH 57/60] fix: Say nothing was deleted when a hidden source has no revision to compare --- src/tools/source/delete_actor_version.ts | 6 ++++++ tests/unit/tools.delete_actor_version.test.ts | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/source/delete_actor_version.ts b/src/tools/source/delete_actor_version.ts index febc33c37..ccd8038f5 100644 --- a/src/tools/source/delete_actor_version.ts +++ b/src/tools/source/delete_actor_version.ts @@ -45,6 +45,12 @@ function validateRevision( 'and only a version stored as files has a revision to compare expectedRevision with.', ); } + if (!version.sourceFiles) { + throw new UserInputError( + `Nothing was deleted: version ${version.versionNumber} of ${fullName} came back without its source, so it ` + + 'has no revision to compare expectedRevision with.', + ); + } const revision = buildFilesRevision( buildFilesManifest(extractSourceFiles(version, `Version ${version.versionNumber} of ${fullName}`)), ); diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index cd0ec3fbc..a7755397b 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -143,7 +143,6 @@ describe('delete-actor-version', () => { async ({ versionNumber, tags, text }) => { const result = await callTool({ versionNumber }); - expect(deleteActorVersionToolOutputSchema.required).toContain('tags'); expectSchemaConformingStructuredContent(result, deleteActorVersionToolOutputSchema); // The Actor GET by the name given, then the DELETE by the Actor ID it returned. expect(actorMock.mock.calls).toEqual([['john/my-actor'], ['actor-1']]); @@ -295,8 +294,8 @@ describe('delete-actor-version', () => { actorGetMock.mockResolvedValue(mockActor({ versions: [{ versionNumber: '0.2', sourceType: 'SOURCE_FILES' }] })); expect(await callToolExpectingUserError({ expectedRevision: 'aaaaaaaaaaaaaaaa' })).toBe( - 'Version 0.2 of john/my-actor came back without its source: the API hides it from accounts that cannot ' + - "modify the Actor. Ask the Actor's owner for the source.", + 'Nothing was deleted: version 0.2 of john/my-actor came back without its source, so it has no revision to ' + + 'compare expectedRevision with.', ); expect(versionMock).not.toHaveBeenCalled(); expectNoOtherWrite(); From 0e0d8bde4fc816d0e8ef9bdcc1e36c088eb999cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 11:29:29 +0200 Subject: [PATCH 58/60] docs: Name the build tag and the deleted version's tags in the source tools entry --- src/tools/AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index ba524fb18..c36fa05ad 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -39,13 +39,13 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. first, pointing at the newest failed one) and `build-actor` (start a build of one version and wait for it); `build_helpers.ts` holds the allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - - `source/`: `get-actor-version` (an Actor version's file manifest with hashes, revision, and - requested content), `create-actor` (a new Actor with one version in a single POST), + - `source/`: `get-actor-version` (an Actor version's build tag, file manifest with hashes, revision, + and requested content), `create-actor` (a new Actor with one version in a single POST), `update-actor-version` (write, edit, and delete operations applied all-or-nothing to a fresh read, stored with one version PUT that carries only the source keys), `create-actor-version` (a new version in one POST, from files or as a copy of another version's files and non-secret env vars, with no default buildTag), and `delete-actor-version` (reads the Actor first, since apify-client - reports a delete of a missing version as done). `source_files.ts` holds the file shape, the + reports a delete of a missing version as done, and returns the tags still on its builds). `source_files.ts` holds the file shape, the manifest builder, the hash and revision rules, and the text-or-base64 detection; `source_helpers.ts` holds the Actor and version lookup, the file input shape, and the response after a write, which starts the build for autoBuild. get-actor-version, update-actor-version, and a From 1b51cb96903255678458acbdab6105cb911d7716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 13:48:54 +0200 Subject: [PATCH 59/60] test: Load the versions category in the version create and delete cases The source category is retired and loads no tools, so these cases would get none of the tools they call. --- tests/test_kit/cases/versions.cases.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_kit/cases/versions.cases.ts b/tests/test_kit/cases/versions.cases.ts index 1a7e9ab56..cdfe0e6f8 100644 --- a/tests/test_kit/cases/versions.cases.ts +++ b/tests/test_kit/cases/versions.cases.ts @@ -611,7 +611,7 @@ export const versionsCases: Case[] = [ { name: 'create-actor-version adds a version from files that get-actor-version reads back with the same hashes and revision, with no build tag, and leaves the other version as it was', isDeploymentTest: false, - run: withClient({ tools: ['source'] }, async (client, ctx) => { + run: withClient({ tools: ['versions'] }, async (client, ctx) => { const api = ctx.createApifyClient(); const name = buildUniqueActorName('add'); const selector = await fetchOwnActorSelector(api, name); @@ -690,7 +690,7 @@ export const versionsCases: Case[] = [ { name: "create-actor-version copies a version's stored entries, non-secret env vars, and applyEnvVarsToBuild, never its build tag, names the secret env vars it left out, and returns the platform error for a version number the Actor already has", isDeploymentTest: false, - run: withClient({ tools: ['source'] }, async (client, ctx) => { + run: withClient({ tools: ['versions'] }, async (client, ctx) => { const api = ctx.createApifyClient(); const name = buildUniqueActorName('copy'); const selector = await fetchOwnActorSelector(api, name); @@ -787,7 +787,7 @@ export const versionsCases: Case[] = [ { name: 'delete-actor-version deletes one version, refuses one the Actor does not have, and returns the platform refusal for the last one', isDeploymentTest: false, - run: withClient({ tools: ['source'] }, async (client, ctx) => { + run: withClient({ tools: ['versions'] }, async (client, ctx) => { const api = ctx.createApifyClient(); const name = buildUniqueActorName('delete'); const selector = await fetchOwnActorSelector(api, name); From d6b4823affd225ab73feb5ef5540a5a8047dd3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 13:49:17 +0200 Subject: [PATCH 60/60] style: Sort the imports in the version create and delete tests The merge moved the tools to the versions folder, so the output schema import now sorts before them. --- tests/unit/tools.create_actor_version.test.ts | 2 +- tests/unit/tools.delete_actor_version.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/tools.create_actor_version.test.ts b/tests/unit/tools.create_actor_version.test.ts index 092d71e4a..6af98de36 100644 --- a/tests/unit/tools.create_actor_version.test.ts +++ b/tests/unit/tools.create_actor_version.test.ts @@ -6,10 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import { prepareToolCallContext } from '../../src/payments/helpers.js'; +import { createActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import { createActorVersion } from '../../src/tools/versions/create_actor_version.js'; import { getActorVersion } from '../../src/tools/versions/get_actor_version.js'; import { buildFilesRevision } from '../../src/tools/versions/source_files.js'; -import { createActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; import { getUserInfoCached } from '../../src/utils/userid_cache.js'; import { diff --git a/tests/unit/tools.delete_actor_version.test.ts b/tests/unit/tools.delete_actor_version.test.ts index 165edff8c..965e227de 100644 --- a/tests/unit/tools.delete_actor_version.test.ts +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -3,9 +3,9 @@ import type { AxiosResponse } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; +import { deleteActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import { deleteActorVersion } from '../../src/tools/versions/delete_actor_version.js'; import { getActorVersion } from '../../src/tools/versions/get_actor_version.js'; -import { deleteActorVersionToolOutputSchema } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; import { expectSchemaConformingStructuredContent,