diff --git a/README.md b/README.md index 78acb193b..4b84a8de7 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 versions**: Read an Actor version's files with a hash for each file and a revision for the whole set, create an Actor from files, and change a version's files with checks that catch changes made since your last read. +- **Actor versions**: 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. - **Apify API**: Search the Apify API reference, inspect an operation, and send requests to the API to read or change data. ### Overview of available tools @@ -301,6 +301,8 @@ Legend for the **Enabled by default** column: | `get-actor-version` | versions | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `create-actor` | versions | Create a private Actor in your account from files, optionally starting a build. | | | `update-actor-version` | versions | Write, edit, or delete files of an Actor version in one all-or-nothing write. | | +| `create-actor-version` | versions | Add a version to an Actor, from files or as a copy of another version, optionally starting a build. | | +| `delete-actor-version` | versions | Delete an Actor version; its builds and their tags stay. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | | `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses the `method` query parameter and a GET to the synchronous run endpoints, which starts a paid run. | | diff --git a/src/const.ts b/src/const.ts index dbc473f53..77eda93bc 100644 --- a/src/const.ts +++ b/src/const.ts @@ -70,6 +70,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', @@ -287,6 +289,10 @@ 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'; +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/AGENTS.md b/src/tools/AGENTS.md index 85d0153c8..a71901559 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -39,14 +39,19 @@ 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. - - `versions/`: `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), and + - `versions/`: `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). `source_files.ts` holds the file - shape, the manifest builder, the hash and revision rules, and the text-or-base64 detection; + 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, 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. 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, 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/registry.ts b/src/tools/registry.ts index babcfb09a..42ba57281 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -55,6 +55,8 @@ import { publishActorTask } from './tasks/publish_actor_task.js'; import { unpublishActorTask } from './tasks/unpublish_actor_task.js'; import { updateActorTask } from './tasks/update_actor_task.js'; import { createActor } from './versions/create_actor.js'; +import { createActorVersion } from './versions/create_actor_version.js'; +import { deleteActorVersion } from './versions/delete_actor_version.js'; import { getActorVersion } from './versions/get_actor_version.js'; import { updateActorVersion } from './versions/update_actor_version.js'; import { callActorWidget } from './widgets/call_actor_widget.js'; @@ -80,7 +82,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, getActorBuildList, buildActor], - versions: [getActorVersion, createActor, updateActorVersion], + versions: [getActorVersion, createActor, updateActorVersion, createActorVersion, deleteActorVersion], api: [apifyApiSearch, apifyApiDetails, apifyApiRead], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index cd2658839..9e7f44ab9 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', @@ -644,7 +648,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', }, }; @@ -669,6 +673,38 @@ export const createActorToolOutputSchema = { required: ['actorId', 'fullName', 'versionNumber', 'revision', 'files'], }; +/** 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' }, + revision: createActorToolOutputSchema.properties.revision, + files: createActorToolOutputSchema.properties.files, + ...sourceWriteBuildProperties, + }, + required: ['actorId', 'fullName', 'versionNumber', 'revision', 'files'], +}; + +/** 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' }, + 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', 'tags'], +}; + /** Schema for update-actor-version: the new revision and what changed. */ export const updateActorVersionToolOutputSchema = { type: 'object' as const, diff --git a/src/tools/versions/create_actor.ts b/src/tools/versions/create_actor.ts index b337e2cf9..853561bfd 100644 --- a/src/tools/versions/create_actor.ts +++ b/src/tools/versions/create_actor.ts @@ -1,28 +1,22 @@ 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'; 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'; -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, - buildSourceFileEntry, + buildSourceFileEntries, extractSourceFiles, redactSourceToolArgs, resolveVersion, - respondAfterWrite, - respondToSourceToolError, + respondAfterCreate, + respondToCreateError, sourceFileArgs, - validateFileContent, - validateFilePath, - validatePathCharacters, } from './source_helpers.js'; const createActorArgs = z.object({ @@ -47,46 +41,6 @@ const createActorArgs = z.object({ .describe('Start a build of the version after creating the Actor, and return without waiting. Default: false.'), }); -/** The folders above a file, outermost first: `a` and `a/b` for `a/b/c.js`. */ -function listFolders(path: string): string[] { - const segments = path.split('/'); - return segments.slice(1).map((_, index) => segments.slice(0, index + 1).join('/')); -} - -/** - * 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. - * Paths are checked through a set and a map, since comparing each file with every earlier one took 8 s for 20,000. - */ -function buildSourceFileEntries(files: readonly z.infer[]): ActorVersionSourceFile[] { - const entries: ActorVersionSourceFile[] = []; - const filePaths = new Set(); - const firstFileByFolder = new Map(); - for (const [index, file] of files.entries()) { - validatePathCharacters(file.path, `files[${index}]`); - const entry = buildSourceFileEntry(file); - const label = `files[${index}] (${file.path})`; - validateFilePath(entry.name, label); - validateFileContent(entry, label); - if (filePaths.has(entry.name)) { - throw new UserInputError(`${label} repeats the path ${entry.name}; each path can be sent only once.`); - } - const folders = listFolders(entry.name); - const collision = folders.find((folder) => filePaths.has(folder)) ?? firstFileByFolder.get(entry.name); - if (collision !== undefined) { - throw new UserInputError( - `${label} collides with ${collision}; one path cannot be both a file and a folder.`, - ); - } - filePaths.add(entry.name); - for (const folder of folders) { - if (!firstFileByFolder.has(folder)) firstFileByFolder.set(folder, entry.name); - } - entries.push(entry); - } - return entries; -} - /** The `version` of .actor/actor.json when it is a non-empty string in valid JSON, as `apify push` reads it. */ function readActorJsonVersion(entries: readonly ActorVersionSourceFile[]): string | undefined { const actorJson = entries.find(({ name, format }) => name === '.actor/actor.json' && format === 'TEXT'); @@ -168,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/versions/create_actor_version.ts b/src/tools/versions/create_actor_version.ts new file mode 100644 index 000000000..5b422a056 --- /dev/null +++ b/src/tools/versions/create_actor_version.ts @@ -0,0 +1,199 @@ +import type { ActorVersion, ActorVersionSourceFile } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; +import dedent from 'dedent'; +import { z } from 'zod'; + +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'; +import { respondAborted, respondUserError } from '../../utils/mcp.js'; +import { createActorVersionToolOutputSchema } from '../structured_output_schemas.js'; +import { + buildSourceFileEntries, + extractSourceFiles, + fetchActor, + redactSourceToolArgs, + resolveVersion, + respondAfterCreate, + respondToCreateError, + sourceFileArgs, +} from './source_helpers.js'; + +const createActorVersionArgs = z.object({ + actor: z + .string() + .min(1) + .describe( + '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 + .string() + .min(1) + .optional() + .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, 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; + +/** 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. + */ +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; 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}. ` + + `They can be set on version ${versionNumber} in Apify Console, and only a build made after that includes them: ${names}.` + ); +} + +/** + * https://docs.apify.com/api/v2/actor-get + * /v2/actors/{actorId} + * https://docs.apify.com/api/v2/actor-versions-post + * /v2/actors/{actorId}/versions + * + * 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, + name: HELPER_TOOLS.ACTOR_VERSION_CREATE, + title: 'Create Actor version', + 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 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`, + // `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, + // 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. + openWorldHint: true, + }, + 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); + 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 { + const { actor, fullName } = await fetchActor(client, parsed.actor); + // 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. + 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(); + 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); + return await respondAfterCreate({ + toolArgs, + autoBuild: parsed.autoBuild, + 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) { + 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}`, + ...(secretNames.length > 0 && { note: formatSecretEnvVarsWarning(secretNames, versionNumber) }), + }); + } + }, +} as const); diff --git a/src/tools/versions/delete_actor_version.ts b/src/tools/versions/delete_actor_version.ts new file mode 100644 index 000000000..ccd8038f5 --- /dev/null +++ b/src/tools/versions/delete_actor_version.ts @@ -0,0 +1,140 @@ +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 { buildFilesManifest, buildFilesRevision } from './source_files.js'; +import { extractSourceFiles, fetchActor, resolveVersion, respondToSourceToolError } from './source_helpers.js'; + +const deleteActorVersionArgs = z.object({ + actor: z + .string() + .min(1) + .describe( + '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.', + ); + } + 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}`)), + ); + 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} + * 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: 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. 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. + + 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)), + annotations: { + title: 'Delete Actor version', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyClient: client, signal, loadedToolNames } = toolArgs; + const parsed = deleteActorVersionArgs.parse(args); + try { + const { actor, fullName } = await fetchActor(client, 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 { + 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 summary = `Deleted version ${versionNumber} of ${fullName}.`; + const buildTool = loadedToolNames.includes(HELPER_TOOLS.ACTOR_BUILD) + ? `${HELPER_TOOLS.ACTOR_BUILD} and ` + : ''; + // 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 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 + ? `${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) { + return respondToSourceToolError(error); + } + }, +} as const); diff --git a/src/tools/versions/get_actor_version.ts b/src/tools/versions/get_actor_version.ts index f4c14c67d..1bdf026ca 100644 --- a/src/tools/versions/get_actor_version.ts +++ b/src/tools/versions/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/versions/source_helpers.ts b/src/tools/versions/source_helpers.ts index c4ac93c01..33d525530 100644 --- a/src/tools/versions/source_helpers.ts +++ b/src/tools/versions/source_helpers.ts @@ -5,6 +5,12 @@ import { ActorSourceType, ApifyApiError } from 'apify-client'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; +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'; import type { ConsoleLinkContext, InternalToolArgs } from '../../types.js'; @@ -15,7 +21,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; 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 { 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({ @@ -274,6 +280,47 @@ export function validateNewFilePath( throw new UserInputError(`${label} collides with ${collision}; one path cannot be both a file and a folder.`); } +/** The folders above a file, outermost first: `a` and `a/b` for `a/b/c.js`. */ +function listFolders(path: string): string[] { + const segments = path.split('/'); + return segments.slice(1).map((_, index) => segments.slice(0, index + 1).join('/')); +} + +/** + * 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. Paths are checked through a set and a map, since comparing each file with every earlier one + * took 8 s for 20,000. + */ +export function buildSourceFileEntries(files: readonly z.infer[]): ActorVersionSourceFile[] { + const entries: ActorVersionSourceFile[] = []; + const filePaths = new Set(); + const firstFileByFolder = new Map(); + for (const [index, file] of files.entries()) { + validatePathCharacters(file.path, `files[${index}]`); + const entry = buildSourceFileEntry(file); + const label = `files[${index}] (${file.path})`; + validateFilePath(entry.name, label); + validateFileContent(entry, label); + if (filePaths.has(entry.name)) { + throw new UserInputError(`${label} repeats the path ${entry.name}; each path can be sent only once.`); + } + const folders = listFolders(entry.name); + const collision = folders.find((folder) => filePaths.has(folder)) ?? firstFileByFolder.get(entry.name); + if (collision !== undefined) { + throw new UserInputError( + `${label} collides with ${collision}; one path cannot be both a file and a folder.`, + ); + } + filePaths.add(entry.name); + for (const folder of folders) { + if (!firstFileByFolder.has(folder)) firstFileByFolder.set(folder, entry.name); + } + 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 {}; @@ -285,16 +332,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({ @@ -326,14 +375,101 @@ export async function respondAfterWrite(params: { }); } return respondWithBuild({ - structuredContent: { ...structuredContent, build: toBuildResult(build, linkContext) }, + structuredContent: { + ...structuredContent, + ...(buildStartedWarnings && { warnings: buildStartedWarnings }), + build: toBuildResult(build, linkContext), + }, summary, nextStep: buildNextStepForBuild(build, { loadedToolNames }), }); } -/** A `UserInputError` as a soft failure; anything else is rethrown for the tool-call engine to report. */ +/** + * 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, + 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 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 && VERSION_REFUSAL_ERROR_TYPE_SET.has(error.type)) { + return respondUserError(`${error.message} (API error type: ${error.type})`, { httpStatus: error.statusCode }); + } 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 }, + ); +} diff --git a/tests/test_kit/cases/versions.cases.ts b/tests/test_kit/cases/versions.cases.ts index eb96ca9c2..9f78f8a82 100644 --- a/tests/test_kit/cases/versions.cases.ts +++ b/tests/test_kit/cases/versions.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, and the one build-actor @@ -129,6 +129,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)); @@ -227,6 +234,7 @@ export const versionsCases: Case[] = [ actorId: created.actorId, fullName, versionNumber: '0.0', + buildTag: 'latest', revision: created.revision, files: created.files, contents: [ @@ -524,7 +532,7 @@ export const versionsCases: 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: ['versions'] }, async (client, ctx) => { const api = ctx.createApifyClient(); @@ -551,9 +559,15 @@ export const versionsCases: 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), ); @@ -603,4 +617,248 @@ 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: ['versions'] }, 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}.\nNo build was started; runs use these files only after the version is built.`, + ); + + 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, 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: ['versions'] }, 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 createResult = await callTool(client, 'create-actor-version', copyArgs); + const created = expectToolSuccess( + createResult, + '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.', + '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. + 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. + // 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: ['versions'] }, 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 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, + 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}.`); + 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 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).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), + ); + } finally { + await api.actor(selector).delete(); + } + }), + }, ]; 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..6af98de36 --- /dev/null +++ b/tests/unit/tools.create_actor_version.test.ts @@ -0,0 +1,979 @@ +import { createHash } from 'node:crypto'; + +import { ApifyApiError } from 'apify-client'; +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 { 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 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(); +// 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, 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' }; + +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 }, +]; + +type CreateVersionOutput = { + actorId: string; + fullName: string; + versionNumber: string; + revision: string; + files: { path: string; sizeBytes: number; hash: string }[]; + warnings?: string[]; + build?: Record; + buildError?: string; +}; + +type CreateVersionResult = TextToolResult & { + structuredContent: CreateVersionOutput; + toolTelemetry?: ToolTelemetrySnapshot; +}; + +/** 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', + buildTag: 'latest', + sourceType: 'SOURCE_FILES', + envVars: [ + { name: 'LOG_LEVEL', value: 'debug', isSecret: false }, + { name: 'API_KEY', isSecret: true, valueHash: 'abc123' }, + { name: 'REGION', value: 'eu' }, + ], + // A copy of its own, so a POST body that equals STORED_FILES was not changed in place. + sourceFiles: structuredClone(STORED_FILES), + ...overrides, + }; +} + +/** 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); +} + +/** `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 { + const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); + const withSignal = signal === undefined ? context : { ...context, signal }; + return (await (createActorVersion as HelperTool).call(withSignal)) as CreateVersionResult; +} + +async function callToolExpectingUserError(args: Record) { + const result = await callTool(args); + expectSoftFailInvalidInput(result); + expect(versionsCreateMock).not.toHaveBeenCalled(); + 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 expectNoOtherWrite() { + for (const [name, mock] of Object.entries(otherWriteMocks)) { + expect(mock, name).not.toHaveBeenCalled(); + } +} + +describe('create-actor-version', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockActorRead(); + 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 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: true, + idempotentHint: false, + openWorldHint: true, + }); + 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); + }); + + 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' }], + ])('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' }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + 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); + }); + + 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) }, + { path: 'src/main.js', sizeBytes: MAIN_JS.content.length, hash: sha256Prefix(MAIN_JS.content) }, + ]; + expect(result.structuredContent).toEqual({ + actorId: 'actor-1', + fullName: 'john/my-actor', + versionNumber: '0.2', + revision: buildFilesRevision(files), + files, + warnings: [ + '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); + expect(result.structuredContent.files).toEqual(read.structuredContent.files); + expect(result.content[1].text).toBe( + 'Created version 0.2 of john/my-actor.\nNo build was started; runs use these files only after the version is built.', + ); + expect(result.content.map(({ text }) => text).join('\n')).not.toContain(LOGO_BYTES.toString('base64')); + }); + + it('copies the non-secret env vars and applyEnvVarsToBuild, never the build tag', async () => { + mockActorRead({ applyEnvVarsToBuild: true }); + + const result = await callTool({ copyFromVersion: '0.1' }); + + 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 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 () => { + mockActorRead({ + envVars: [ + { name: 'API_KEY', value: 'secret-value', isSecret: true }, + { name: 'REGION', value: 'eu' }, + ], + }); + + const result = await callTool({ copyFromVersion: '0.1' }); + + expect(getPostBody().envVars).toStrictEqual([{ name: 'REGION', value: 'eu' }]); + 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; 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/); + }); + + 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 () => { + 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) => { + mockActorRead({ applyEnvVarsToBuild }); + + await callTool({ copyFromVersion: '0.1' }); + + expect(getPostBody().applyEnvVarsToBuild).toBe(applyEnvVarsToBuild); + }, + ); + + 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', + }, + 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 }); + + 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.", + ); + }); + + 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, 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==' }], + }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + expect(getPostBody()).toEqual({ + versionNumber: '0.2', + 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: 'assets/logo.png', format: 'BASE64', content: 'AA==' }, + ], + }); + expect(result.structuredContent.files.map(({ path }) => path)).toEqual([ + '.actor/actor.json', + 'assets/logo.png', + 'src/main.js', + ]); + }); + + 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' }, + ], + }); + // 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', + 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: '' }] }); + + 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.', + ]); + }); + + 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; paths are 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; paths are 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; each path can be sent only 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; encoding base64 takes binary content as base64, and text takes 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.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 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' })); + + 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( + '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( + 'The call takes exactly one of files and copyFromVersion.', + ); + expect(actorMock).not.toHaveBeenCalled(); + }); + }); + + 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(args); + expect(getPostBody()).not.toHaveProperty('buildTag'); + }); + + describe('Actor and API errors', () => { + it('reports a missing Actor', async () => { + actorGetMock.mockResolvedValue(undefined); + + expect(await callToolExpectingUserError({ actor: 'my-actor', files: [ACTOR_JSON] })).toBe( + "Actor 'my-actor' not found. The actor argument takes an ID, a name (resolved against your own Actors), or a full name as username/name.", + ); + }); + + it.each([ + ['files', { files: [ACTOR_JSON] }], + ['a copy of that version', { copyFromVersion: '0.1' }], + ])('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'), + ); + + 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.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); + + await expect(callTool({ files: [ACTOR_JSON] })).rejects.toBe(error); + }); + + 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; 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; get-actor-version reads its files. ' + + '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', + args: { copyFromVersion: '0.1' }, + version: { envVars: [{ name: 'REGION', value: 'eu' }] }, + 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', + 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), + ); + + 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. ${ending}`, + }, + ]); + expect(getPostBody().versionNumber).toBe('0.2'); + expectNoOtherWrite(); + expect(buildMock).not.toHaveBeenCalled(); + }, + ); + + 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. 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, + 'You do not have permission to modify this Actor.', + 'insufficient-permissions', + 2, + ); + versionsCreateMock.mockRejectedValue(error); + + await expect(callTool({ files: [ACTOR_JSON] })).rejects.toBe(error); + }); + }); + + 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()] }; + }); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); + + expect(result).toEqual({}); + expect(versionsCreateMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + 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()); + + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }, controller.signal); + + 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(); + 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', () => { + it('starts a build of the new version without waiting', async () => { + const result = await callTool({ copyFromVersion: '0.1', autoBuild: true }); + + expectSchemaConformingStructuredContent(result, createActorVersionToolOutputSchema); + // 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', + buildNumber: '0.2.1', + status: 'READY', + startedAt: '2026-09-01T10:00:00.000Z', + finishedAt: null, + }); + // 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. They can be set on version 0.2 in Apify Console, and only a build made after that includes them: ' + + '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, 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.', + ]); + }); + + it.each([ + [ + 'without autoBuild', + { autoBuild: false }, + 'No build was started; runs use these files only after the version is built.', + ], + [ + 'when the build failed to start', + { autoBuild: true }, + 'The build could not be started; runs use these files only after the version is built.', + ], + ])('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; 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}`); + }); + + 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 () => { + 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.\nNo build was started; runs use these files only after the version is built.', + ); + }); + + 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')); + + 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.structuredContent).not.toHaveProperty('build'); + }); + }); +}); 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..965e227de --- /dev/null +++ b/tests/unit/tools.delete_actor_version.test.ts @@ -0,0 +1,379 @@ +import { ApifyApiError } from 'apify-client'; +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 type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { + expectSchemaConformingStructuredContent, + expectSoftFailInvalidInput, + stubToolCallContext, + type TextToolResult, + type ToolTelemetrySnapshot, +} from './helpers/tool_context.js'; + +const actorGetMock = vi.fn(); +const versionDeleteMock = vi.fn(); +// 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']; + +type DeleteResult = TextToolResult & { + structuredContent: { actorId: string; fullName: string; versionNumber: string; deleted: boolean; tags: string[] }; + toolTelemetry?: ToolTelemetrySnapshot; +}; + +const MAIN_JS = { name: 'src/main.js', format: 'TEXT', content: 'console.log(1);\n' }; + +function mockActor(overrides: Record = {}) { + return { + 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: 'GIT_REPO', + gitRepoUrl: 'https://github.com/john/repo', + buildTag: 'latest', + }, + { versionNumber: '0.2', sourceType: 'SOURCE_FILES', sourceFiles: [MAIN_JS] }, + ], + taggedBuilds: { latest: { buildId: 'build-1', buildNumber: '0.1.3' } }, + ...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, signal?: AbortSignal): Promise { + const context = stubToolCallContext({ actor: 'john/my-actor', versionNumber: '0.2', ...args }, stubClient); + 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; +} + +/** 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)) { + expect(mock, name).not.toHaveBeenCalled(); + } +} + +describe('delete-actor-version', () => { + beforeEach(() => { + vi.clearAllMocks(); + 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('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('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; + + 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([ + // 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', tags: [], text: 'Deleted version 0.2 of john/my-actor.' }, + ])( + 'deletes only version $versionNumber of the Actor it resolved, with one DELETE', + async ({ versionNumber, tags, text }) => { + 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, + tags, + }); + expect(result.content[1].text).toBe(text); + }, + ); + + 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' } }, + 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, 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 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); + }, + ); + + it('names one build 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' }); + + 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' + + "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.', + ); + }); + + it('names no tag of build 0.10.1 when deleting 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.structuredContent.tags).toEqual([]); + expect(result.content[1].text).toBe('Deleted version 0.1 of john/my-actor.'); + }); + + 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, + }); + + 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 until each tag points to another build, " + + 'such as one of another version made with tag latest.', + ); + }); + + 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( + '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(); + }); + + 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.", + ); + expect(actorMock.mock.calls).toEqual([['john/my-actor']]); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); + + it('reports a missing Actor', async () => { + actorGetMock.mockResolvedValue(undefined); + + expect(await callToolExpectingUserError({ actor: 'my-actor' })).toBe( + "Actor 'my-actor' not found. The actor argument takes an ID, a name (resolved against your own Actors), or a full name as username/name.", + ); + expect(actorMock.mock.calls).toEqual([['~my-actor']]); + expect(versionMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); + + 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'), + ); + + const result = await callTool({ versionNumber: '0.1' }); + + // 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: '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); + 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 () => { + controller.abort(); + return mockActor(); + }); + + const result = await callTool({}, controller.signal); + + expect(result).toEqual({}); + expect(versionDeleteMock).not.toHaveBeenCalled(); + expectNoOtherWrite(); + }); +}); diff --git a/tests/unit/tools.get_actor_version.test.ts b/tests/unit/tools.get_actor_version.test.ts index 4a8c5a571..6c0d63e30 100644 --- a/tests/unit/tools.get_actor_version.test.ts +++ b/tests/unit/tools.get_actor_version.test.ts @@ -185,6 +185,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('versions'); }); @@ -202,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) }, @@ -212,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) { @@ -463,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).', ); }); @@ -619,6 +622,7 @@ describe('get-actor-version', () => { actorId: 'actor-1', fullName: 'john/my-actor', versionNumber: '0.1', + buildTag: 'latest', revision: sha256Prefix('[]'), files: [], contents: [], @@ -776,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); @@ -845,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 }; @@ -861,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.'); }, ); diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 9f06c6416..168f4796c 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -144,6 +144,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.versions)).toEqual(expected); expect(toolNames(appsCategories.versions)).toEqual(expected); diff --git a/tests/unit/tools.update_actor_version.test.ts b/tests/unit/tools.update_actor_version.test.ts index 3869ddd2b..fbbe40b37 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 { @@ -2265,6 +2265,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);