diff --git a/CLAUDE.MD b/CLAUDE.MD index 70fe125..79310c6 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,25 +1,36 @@ # Using local Actor runtime + This file provides guidance to programming agents when using the local Actor runtime. Local Actor runtime is an Actor development tool for developing, running, and debugging Actors. It emulates subset of the real Apify API and Apify console to allow actor development locally without the need to do costly rebuilds on Apify platform. # Set up + - Build the docker image `docker build -t actor-runtime .` - Run the container `docker run --rm -p 3333:3333 -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock -v "$(pwd)/data:/data" actor-runtime` - - `-v "$(pwd)/data:/data"` shared volumes `data` is used to store internal actor runtime data. When exposed it can be directly inspected to determine internal state and storage backend (It is not recommended to manually edit those files. Any edit should be done through http API call). + - `-v "$(pwd)/data:/data"` shared volumes `data` is used to store internal actor runtime data. When exposed it can be directly inspected to determine internal state and storage backend (It is not recommended to manually edit those files. Any edit should be done through http API call). ## Work through CLI + - The actor runtime is best used through `apify cli`: https://docs.apify.com/cli/docs - Use the cli according to the skill: https://docs.apify.com/cli/docs/agent-skill#install-the-skill - To use local Actor runtime set environment variable for the Apify CLI: - - `APIFY_CLIENT_BASE_URL=http://localhost:3333` - - `APIFY_CONSOLE_URL=http://localhost:3000` - - `APIFY_PROXY_PASSWORD` (optional if Apify proxy is desired) + - `APIFY_CLIENT_BASE_URL=http://localhost:3333` + - `APIFY_CONSOLE_URL=http://localhost:3000` + - `APIFY_PROXY_PASSWORD` (optional if Apify proxy is desired) - To redirect Apify CLI back to the original Apify services unset the environment variables: - - `APIFY_CLIENT_BASE_URL` - - `APIFY_CONSOLE_URL` + - `APIFY_CLIENT_BASE_URL` + - `APIFY_CONSOLE_URL` - Use `apify cli api ...` to send API calls and inspect the Actors, builds, runs, storages and other objects. - To simulate multiple users use custom token and in additional authorization header. For example: `apify cli api v2/datasets -H '{"authorization": "Bearer TOKEN"}'` - You can use already authenticated CLI or call `apify login --token TOKEN` +- To iterate on an Actor's source without a rebuild for every change: push and build the Actor once, then + register its local source folder with + `apify api POST /actor-runtime/dev-folder/ --body '"/abs/path/to/src"'` (this runtime's own + `/actor-runtime/*` endpoint - also reachable at `/v2/actor-runtime/*`, purely because `apify api` + hardcodes a `/v2`-suffixed base URL). From then on, edit locally, recompile locally (`tsc` or the + language-appropriate equivalent), and `apify call` again - no `apify push`/build in between. Submitting + `--body '""'` clears the registration. Dependency changes still need a real rebuild. ## Through direct API calls + - You can also send direct API calls. For example: http://localhost:3333/v2/datasets?token=TOKEN diff --git a/README.md b/README.md index c90b664..06b8cb4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,39 @@ the real platform is reachable, the runtime also adopts that account's real user the first time it sees the token; fully offline (or with any other non-empty token) it just keeps using the single local user, with no error either way - see `requirements/cli.md`'s User bootstrap section. +## Rapid dev loop: bind-mounting your local source (no rebuild per edit) + +After the one push+build above, register your Actor's local source folder so every future run picks up +local edits without a rebuild: + +```bash +apify api POST /actor-runtime/dev-folder/ --body '"/abs/path/to/sample_actor_ts"' +``` + +`` is the id `apify push --json` printed (`.actor.id`); the path must be absolute and must +already exist on the **host** - the runtime verifies this by actually trying to mount it, and rejects +the call with a clear error if the Actor has no build tagged `latest` yet (a stock `apify push` always +tags its build `latest`, so this is normally just "build at least once first") or the path can't be +confirmed. +The same thing is also a single-field form on the Actor's page in the console (`http://localhost:3000`). + +From then on: + +```bash +# edit src/main.ts, then: +npm run build # recompile locally - tsc, no apify push +apify call --input '{"maxPages":3}' # picks up the new dist/, no rebuild +``` + +Node doesn't hot-reload a running process, so a local recompile is picked up by the **next** run's +container start, not by any run already in progress. `node_modules` inside the container still comes +from the built image - an anonymous volume preserves it underneath the bind mount - so a new dependency +in `package.json` still needs a real `apify push`/build; only source edits skip it. Clear the +registration with an empty body (`--body '""'`) to go back to running purely from the built image. Full +mechanics: `requirements/actor-driver.md`'s "Bind mount volumes with Actor source code"; +endpoint/console details: `requirements/api.md`'s `/actor-runtime/*` section and +`requirements/console.md`. + ## Development ```bash diff --git a/package.json b/package.json index ac6851b..57f575b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test": "vitest run test/unit test/integration", "test:unit": "vitest run test/unit", "test:integration": "vitest run test/integration", - "test:e2e": "vitest run test/e2e", + "test:e2e": "vitest run test/e2e --no-file-parallelism", "test:watch": "vitest" }, "dependencies": { diff --git a/requirements/actor-driver.md b/requirements/actor-driver.md index b2d4c23..d261fd8 100644 --- a/requirements/actor-driver.md +++ b/requirements/actor-driver.md @@ -39,10 +39,45 @@ - Actor details are saved in `__ACTORS__` internal storage # Bind mount volumes with Actor source code -- To enable rapid development of Actors, it is desired to avoid the need to rebuild the Actors. This can be achieved by bind mounting the actor local development folder when starting the container. -- When building an Actor, get the location where the Actor source code is locally located and save it in `__ACTORS__` under `localDevFolder` -- Detect the working directory of docker image `docker inspect -f '{{.Config.WorkingDir}}' {DOCKER_IMAGE}` (replace {DOCKER_IMAGE} by the docker image identifier) and save it to `__ACTORS__` under `imageWorkingDirectory` -- Each Actor stored by the local Actor runtime is started with bind mounted development folder using these additional arguments `-v {localDevFolder}:{imageWorkingDirectory} -v {imageWorkingDirectory}/node_modules` + +- To enable rapid development of Actors, it is desired to avoid the need to rebuild the Actors for + every source change. This is achieved by bind mounting the Actor's local development folder over the + built image's working directory when starting the container - edit locally, recompile locally + (`tsc`, or the language-appropriate equivalent), `apify call` again, with no `apify push`/build in + between. A running container never picks up a recompile; only the next run's container start does. + Dependency or environment changes still require a real rebuild. +- `localDevFolder` is **registered explicitly** on a new local-only endpoint, + `POST /actor-runtime/dev-folder/:actorId` (see `api.md`), also exposed as a single-field form on the + console's Actor detail view (`console.md`), sets or clears it. Both surfaces funnel through one + shared validate-and-persist path, so they can never disagree. +- **Registration validates the path in two layers**, not shape alone: + 1. A cheap shape check: the submitted value must be an absolute POSIX path. + 2. A **host-side existence-and-directory check**. The runtime's own filesystem cannot be trusted to + judge a host path - it is not necessarily the host's filesystem at all - so this must be verified + some other way. + - Submitting the **empty string clears the registration** and never runs either validation layer. + - Every non-success outcome is classified rather than guessed: being unable to verify the path at + all (e.g. Docker is unreachable) is reported as "could not verify", never as "does not exist"; a + path confirmed missing is reported as "path does not exist"; a path that exists but is a file is + reported as "path is not a directory"; anything else unverifiable is a generic "could not verify". +- **Registration has no build-first precondition.** It requires no build of the Actor's own to exist, + succeeded or otherwise - the host-side check needs only something host-present to validate against, + never a build a run would actually use. +- **`imageWorkingDirectory` is captured by the driver itself, right after a successful build, and is + build-specific, not Actor-specific** - it is persisted on that build's own record (see `storage.md`), + never on the Actor. The mount a run applies always reads it off _that run's own resolved build_, never + off any other build the Actor happens to have. +- **The mount is applied only when both a registered dev folder and a known working directory exist** + for the run's resolved build; either missing means the run starts exactly as if the feature did not + exist. +- The registration status the console and API report is the registered folder alone - it never claims a + mount "will apply", since that depends on which build a given run resolves, which an Actor-level status + has no way to know in advance. +- If the registered folder has since been deleted, moved, or made unreadable, the run must **fail + visibly** - never silently mount an empty directory in its place. +- The Actor image's own installed dependencies (e.g. `node_modules`) must remain available to the Actor + despite the mount covering the whole working directory. +- **Registering or clearing a dev folder never bumps the Actor's `modifiedAt`.** # Networking diff --git a/requirements/api.md b/requirements/api.md index c3c5acf..624f82e 100644 --- a/requirements/api.md +++ b/requirements/api.md @@ -124,11 +124,31 @@ implement. Not built here; both paths are in the vendored spec table (`api/spec-table.ts`) as `implemented: false` so they answer `501`, not `404`. - All endpoints from the specification that do not have implementation must return response `501 Not Implemented` -- All endpoints not present in specification must return `404 Not Found` +- All endpoints not present in specification must return `404 Not Found` - **except** the `/actor-runtime/*` -# Private API +# Actor runtime API -- Not implemented +- `/actor-runtime/*` is API that control specifics function of the local Actor runtime +- **`POST /actor-runtime/dev-folder/:actorId`** - registers (or clears) the Actor's local dev folder for + the bind-mount feature (`actor-driver.md`). `:actorId` accepts the same forms as the rest of the API + (id, plain name, `username~name`). + - **Authenticated** the same way as every `/v2` route, and scoped to the caller's own Actors. + - **No build-first precondition** - registration works for an Actor that has never been built at all. + - **Request body**: a JSON string - the absolute path to set, or `""` to clear. + - **Response**: on success, `{ data: { localDevFolder } }` - the same value the console detail page + shows (`console.md`), doubling as the read-back this design has no separate `GET` for. + - **Error responses**, by rejection reason: + - `400` `invalid-request` - the body isn't a JSON string, or the string isn't a valid absolute + path. + - `400` `dev-folder-path-not-found` - the path does not exist on the host. + - `400` `dev-folder-not-a-directory` - the path exists but is not a directory. + - `400` `dev-folder-check-failed` - the path could not be verified, for any other reason. + - `503` `dev-folder-check-unavailable` - Docker itself is unreachable. + - `500` `internal-error` - an operational fault unrelated to the submitted path. +- The console's own dev-folder form (`console.md`) does **not** go through this endpoint - it posts to a + console-local, unauthenticated route on the console's own port. Both routes funnel into the same + underlying validate-and-persist path, so the two surfaces can never drift apart in behavior, only in + how they are reached. ## Upstream fallback (opt-in, off by default, all HTTP methods) diff --git a/requirements/console.md b/requirements/console.md index b36c056..12ac932 100644 --- a/requirements/console.md +++ b/requirements/console.md @@ -1,14 +1,16 @@ # Frontend -- Console frontend is simple view-only page that allows to inspect each user object. +- Console frontend is a page that allows inspecting each user's objects across the whole runtime. - Server-rendered HTML from the same process that serves the API, on its own fixed port (3000). No SPA, no bundler, no build step - plain Express routes returning HTML strings. It reads through the same service layer as the API handlers, so storage/build/run access logic is shared rather than reimplemented. - Frontend shows for each object the owner (`userId`). -- The console has no login of its own (it is unauthenticated and view-only), so with multiple users it - lists and shows every user's objects rather than scoping to one - the API's own endpoints stay - strictly scoped to the calling token's user (`storage.md`'s "Users" section). +- The console has no login of its own, so with multiple users it lists and shows every user's objects + rather than scoping to one - the API's own endpoints stay strictly scoped to the calling token's user + (`storage.md`'s "Users" section). +- The console is unauthenticated. Every route is a read except the dev-folder form below, which is the + console's one write - it is no longer strictly view-only. - There are three types of objects: key-value store, dataset, request queue. - For each object type there must be exactly one widget for inspection. - The request-queue widget leads with the authoritative counts from `RequestQueue.getInfo()` @@ -34,3 +36,16 @@ list's dataset column), not as plain text. - Log views render ANSI colors from actor output as HTML, while the `/v2/logs/:id` API keeps serving logs raw (unconverted) for the CLI to render itself. - The console accepts the real Apify Console's URL shapes (as printed by stock apify-cli, e.g. `/actors/:actorId/runs/:runId`, `/storage/datasets/:id`) via redirects to its own pages. + +## Local dev-folder registration form (Actor detail view) + +- The Actor detail view shows the Actor's registered local dev folder, or that none is registered - the + same status the API endpoint reports (`api.md`). It never claims a mount "will apply": that depends on + which build a given run resolves, which this Actor-level view has no way to know in advance. +- A single-field form exposes the same registration capability as the API endpoint, with no build-first + precondition either: submitting it sets or clears the dev folder, funnelling into the same + validate-and-persist path, so the two surfaces can never observe or produce different outcomes for the + same input. Submitting an empty value clears the registration, matching the API; a whitespace-only + value is rejected as a malformed path, also matching the API. +- A submission that fails validation redirects back to the same detail page with the classified error + message shown inline, never swallowed by the redirect. diff --git a/requirements/storage.md b/requirements/storage.md index 0459a4b..c0a8ea5 100644 --- a/requirements/storage.md +++ b/requirements/storage.md @@ -71,8 +71,21 @@ - `value` is the metadata of the Actor - owner (`userId`) - metadata - - localDevFolder - - imageWorkingDirectory + - `localDevFolder` - **optional**. Absent means no dev folder has ever been registered for this + Actor. Set or cleared only through `POST /actor-runtime/dev-folder/:actorId` or the console's + equivalent form (`api.md`, `console.md`), never as a side effect of any other Actor write, and + never bumping `modifiedAt` (`actor-driver.md`). Submitting the empty string clears it - the + field is removed entirely, not stored as an empty string. When present, it is always an + absolute host path that has passed registration validation (`actor-driver.md`). + - There is **no `imageWorkingDirectory` field on the Actor record.** It lives on the `BuildRecord` + instead (`__BUILDS__` below) - build-specific, not Actor-specific: the workdir a run mounts + against must be the one for the build that run itself resolved, never whichever tag happened to + build most recently. + - `localDevFolder`, together with the resolved build's `imageWorkingDirectory`, are **absent (or + empty) meaning no mount**: a run only adds the dev-folder bind mount when both are present and + non-empty (`actor-driver.md`). + - Neither `localDevFolder` nor any build's `imageWorkingDirectory` is ever exposed on the public + `/v2` API. - The system stores Actor runs in dedicated key-value store called `__RUNS__`: - `key` is the id of the Actor run `runId` - `value` is the metadata of the Actor @@ -85,6 +98,10 @@ - owner (`userId`) - Actor (`actorId`) - metadata + - `imageWorkingDirectory` - **optional**, and specific to this one build. Absent unless this + particular build succeeded and its own image's working directory could be captured (also + absent when the captured value was empty or `/`) - never on any other build, and never derived + from, or copied onto, the Actor record (see `localDevFolder`'s entry above). - The system stores logs in dedicated key-value store called `__LOGS__`: - `key` is the id of the Actor build (`logId`) - `value` is the metadata of the Actor diff --git a/src/api/dto/actors.ts b/src/api/dto/actors.ts index 42a9f68..3d1c580 100644 --- a/src/api/dto/actors.ts +++ b/src/api/dto/actors.ts @@ -1,7 +1,7 @@ import type { ActorRecord, BuildRecord, RunRecord } from '../../storage/entities.js'; -/** Matches `services/runs.ts`'s `DEFAULT_BUILD_TAG` - backfilled here only for run records that predate - * `options.build` (directly-seeded test fixtures); every real run always has it set already. */ +/** Matches `services/actors.ts`'s `DEFAULT_BUILD_TAG` - backfilled here only for run records that + * predate `options.build` (directly-seeded test fixtures); every real run always has it set already. */ const DEFAULT_RUN_BUILD_TAG = 'latest'; /** Matches `services/runs.ts`'s `DISK_MBYTES_PER_MEMORY_MBYTE` - backfilled here only for run records * that predate `options.diskMbytes`; every real run always has it set already. */ diff --git a/src/api/routes/actors.ts b/src/api/routes/actors.ts index d2083b3..5084839 100644 --- a/src/api/routes/actors.ts +++ b/src/api/routes/actors.ts @@ -8,13 +8,20 @@ import { h, jsonBody, paginationParams, queryBoolean, queryNumber, queryString, import { addOrReplaceVersion, createActor, + DEFAULT_BUILD_TAG as DEFAULT_TAG, deleteActor, findVersion, listOwnedActors, resolveOwnedActor, updateActor, } from '../../services/actors.js'; -import { listOwnedBuilds, startBuild, waitForBuildFinish, type StartBuildOptions } from '../../services/builds.js'; +import { + listOwnedBuilds, + resolveTaggedBuild, + startBuild, + waitForBuildFinish, + type StartBuildOptions, +} from '../../services/builds.js'; import { listOwnedRuns, startRun, waitForRunFinish } from '../../services/runs.js'; import { getRegistries } from '../../storage/registries.js'; import { actorDto, buildDto, runDto } from '../dto/actors.js'; @@ -23,8 +30,6 @@ import type { ApiServerDeps } from '../server.js'; import { CONTAINER_API_BASE_URL } from '../../config.js'; import { resolveProxyPassword } from '../../services/users.js'; -const DEFAULT_TAG = 'latest'; - export function mountActors(router: Router, deps: ApiServerDeps): void { router.get( '/actors', @@ -274,11 +279,19 @@ export function mountActors(router: Router, deps: ApiServerDeps): void { if (!actor) throw recordNotFound(); const tag = queryString(req, 'build') ?? DEFAULT_TAG; - const tagged = actor.taggedBuilds[tag]; - if (!tagged) throw recordNotFound(`Actor has no build tagged "${tag}"`); - const { builds } = getRegistries(); - const build = await builds.get(tagged.buildId); - if (!build) throw recordNotFound(); + const lookup = await resolveTaggedBuild(actor, tag); + if (!lookup.found) { + // `no-such-tag` names the tag, matching base behavior exactly. `build-deleted` (the tag + // exists, but its BuildRecord was removed via `DELETE /actor-builds/:buildId`, which does + // not clear the tag pointing at it) throws the same bare `recordNotFound()` base did for + // this case too - not a custom message - so run-start stays byte-for-byte base-identical + // for every input class; `resolveTaggedBuild` (services/builds.ts) only exists so this + // route and the dev-folder probe can each still branch on *which* reason it was, without + // duplicating the tag/build lookup itself. + if (lookup.reason === 'build-deleted') throw recordNotFound(); + throw recordNotFound(`Actor has no build tagged "${tag}"`); + } + const build = lookup.build; const body = rawBody(req); const input = diff --git a/src/api/routes/dev-folder.ts b/src/api/routes/dev-folder.ts new file mode 100644 index 0000000..be77fd1 --- /dev/null +++ b/src/api/routes/dev-folder.ts @@ -0,0 +1,82 @@ +/** + * `POST /actor-runtime/dev-folder/:actorId` - deliberately outside the emulated `/v2` surface + * (`api.md`'s `/actor-runtime/*` namespace). `server.ts` creates its own sub-router, calls + * `mountDevFolder` on it once, and mounts that same router instance at both `/actor-runtime` (canonical) + * and `/v2/actor-runtime` (an alias existing solely because `apify api` hardcodes a `/v2`-suffixed base + * URL - see `server.ts`'s doc comment). Neither mount is nested under the `v2` router, so this route + * needs its own `auth()` rather than inheriting `v2`'s - and since only one of the two mounts ever + * matches a given request, that `auth()` still runs exactly once per request either way. + * + * Canonical body is a JSON string: `'"/abs/path"'` to set, `'""'` to clear (`api.md`). A JSON value that + * parses but isn't a string is rejected the same way a malformed body is. + * + * Ownership-scoped like every other Actor write on this API port: `resolveOwnedActor`, so a caller can + * only ever register a dev folder for their own Actor. + */ +import type { Router } from 'express'; + +import { auth, requireUser } from '../auth.js'; +import { sendData } from '../envelope.js'; +import { ApiError, invalidRequest, recordNotFound } from '../errors.js'; +import { h, jsonBody } from '../handler.js'; +import { + describeDevFolderFailure, + devFolderStatus, + setDevFolder, + type SetDevFolderResult, +} from '../../services/dev-folder.js'; +import { resolveOwnedActor } from '../../services/actors.js'; +import type { ApiServerDeps } from '../server.js'; + +/** Maps a non-`ok` `SetDevFolderResult` to the `ApiError` this route throws - the HTTP status and API + * error `type` are this route's own concern, not the service layer's (`describeDevFolderFailure` only + * supplies the message text, shared with the console). */ +function toApiError(result: Exclude): ApiError { + const message = describeDevFolderFailure(result); + switch (result.kind) { + case 'invalid-path': + return invalidRequest(message); + case 'not-found': + return new ApiError(400, 'dev-folder-path-not-found', message); + case 'not-a-directory': + return new ApiError(400, 'dev-folder-not-a-directory', message); + case 'unreachable': + return new ApiError(503, 'dev-folder-check-unavailable', message); + case 'image-missing': + return new ApiError(500, 'internal-error', message); + case 'unknown': + return new ApiError(400, 'dev-folder-check-failed', message); + } +} + +/** Mounts the `/dev-folder/:actorId` route onto `router`, matching every other route module's + * `mount*(router, deps): void` convention - `server.ts` creates the sub-router, calls this on it, and + * mounts the result at `/actor-runtime` itself (owning the path prefix the same way it owns `/v2`). + * Registers its own `auth()` on `router` rather than inheriting `v2`'s, since this route lives outside + * the `v2` router entirely (`api.md`'s `/actor-runtime/*` namespace). */ +export function mountDevFolder(router: Router, deps: ApiServerDeps): void { + router.use(auth()); + + router.post( + '/dev-folder/:actorId', + h(async (req, res) => { + const user = requireUser(req); + const actor = await resolveOwnedActor(user.id, req.params.actorId as string, user.username); + if (!actor) throw recordNotFound(); + + const raw = jsonBody(req); + if (typeof raw !== 'string') { + throw invalidRequest( + 'Request body must be a JSON string - e.g. "/abs/path/to/src" to set, or "" to clear', + ); + } + + const result = await setDevFolder(deps.driver, actor, raw); + if (result.kind !== 'ok') throw toApiError(result); + + // The response body doubles as the read-back - there is deliberately no separate `GET` for + // this yet - with the same field the console detail page shows. + sendData(res, devFolderStatus(result.actor)); + }), + ); +} diff --git a/src/api/server.ts b/src/api/server.ts index 03b6e60..f981037 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -13,6 +13,7 @@ import { mountBuilds } from './routes/builds.js'; import { mountRuns } from './routes/runs.js'; import { mountLogs } from './routes/logs.js'; import { mountRunStorageAliases } from './routes/run-storage-aliases.js'; +import { mountDevFolder } from './routes/dev-folder.js'; import type { Driver } from '../driver/types.js'; export interface ApiServerDeps { @@ -27,6 +28,26 @@ export function createApiServer(deps: ApiServerDeps): Express { // parse ourselves (see `api/handler.ts`). app.use(express.raw({ type: () => true, limit: '256mb' })); + // `/actor-runtime/*` - a deliberately non-Apify, local-runtime-only namespace (`api.md`), registered + // before the `v2` router (and its own `auth()`) below entirely, so it gets its own sub-router with its + // own `auth()` (see `mountDevFolder`'s doc comment) rather than inheriting `v2.use(auth())`. + const devFolder = express.Router(); + mountDevFolder(devFolder, deps); + app.use('/actor-runtime', devFolder); + // Also served at `/v2/actor-runtime/*` - the *same* router instance, no duplicated route logic - solely + // because `apify api`'s own URL-building hardcodes a `/v2`-suffixed base (`${baseUrl}/${endpoint}`, + // `baseUrl` already ending in `/v2`) and its `normalizePath` only strips a leading `/` and a leading + // `v2/`, never `..`: `apify api POST /actor-runtime/dev-folder/` (the clean, documented form, no + // `../`) therefore resolves to exactly this path, never the canonical `/actor-runtime/*` one above. + // This mount is registered here, before `app.use('/v2', v2)` below - NOT nested under `v2` - so this + // request is only ever authenticated once, by this router's own `auth()`; nesting it under `v2` would + // mean `v2.use(auth())` runs first and this router's `auth()` runs again right after, on every request. + // `/v2/actor-runtime/*` is not part of the emulated Apify API - it is the same deliberately non-Apify + // namespace as `/actor-runtime/*`, reachable a second way purely for CLI ergonomics (`api.md`). The + // dev-folder fields are still never exposed on any real `/v2` Actor response either way - `actorDto` + // is explicit field-by-field regardless of which path reached this router. + app.use('/v2/actor-runtime', devFolder); + const v2 = express.Router(); v2.use(auth()); diff --git a/src/console/server.ts b/src/console/server.ts index 45db364..af8c2f9 100644 --- a/src/console/server.ts +++ b/src/console/server.ts @@ -4,16 +4,25 @@ * through the same service layer as the API handlers, so ownership filtering (over on the API side) is * shared rather than reimplemented. * - * The console itself has no login of its own - it is a view-only, unauthenticated local dev tool - * (`console.md`) - so with multiple users it does not scope to any one of them: every list/detail route - * below reads through the `listAll*`/`get*ById` cross-user service functions (see e.g. - * `services/actors.ts: listAllActors`), never the API's own per-user `listOwned*`/`getOwned*`, and every - * list row and detail view shows the object's owner `userId` (`console.md`: "Frontend shows for each - * object the owner (userId)"). + * The console itself has no login of its own - it is unauthenticated, and every route is a read except + * exactly one mutation (`console.md`'s "Every route is a read except the dev-folder form below, which is + * the console's one write"): the dev-folder form on the Actor detail view. With multiple users it does + * not scope reads to any one of them: every list/detail route below reads through the + * `listAll*`/`get*ById` cross-user service functions (see e.g. `services/actors.ts: listAllActors`), + * never the API's own per-user `listOwned*`/`getOwned*`, and every list row and detail view shows the + * object's owner `userId` (`console.md`: "Frontend shows for each object the owner (userId)"). The + * dev-folder form writes cross-user the same way - a deliberate deviation from the API's own + * strictly-owner-scoped write, not an accident. */ import express, { type Express } from 'express'; import { getActorById, listAllActors } from '../services/actors.js'; +import { + describeDevFolderFailure, + devFolderStatus, + setDevFolder, + type DevFolderStatus, +} from '../services/dev-folder.js'; import { getBuildById, listAllBuilds } from '../services/builds.js'; import { getRunById, listAllRuns } from '../services/runs.js'; import { getFullLog } from '../services/logs.js'; @@ -23,16 +32,40 @@ import { openDataset, openKeyValueStore, openRequestQueue } from '../storage/ope import { pageKeys } from '../services/kv-key-listing.js'; import { applyDatasetProjection, type DatasetItem } from '../services/dataset-projection.js'; import { ansiToHtml } from './ansi.js'; -import { definitionList, escapeHtml, layout, table, type LinkedCell } from './templates.js'; +import { definitionList, devFolderForm, escapeHtml, layout, table, type LinkedCell } from './templates.js'; +import type { Driver } from '../driver/types.js'; /** A run's default-storage id rendered as a link to that storage's detail view instead of plain text. */ function storageLink(prefix: '/datasets' | '/key-value-stores' | '/request-queues', id: string): LinkedCell { return { text: id, href: `${prefix}/${encodeURIComponent(id)}` }; } -export function createConsoleServer(): Express { +export interface ConsoleServerDeps { + driver: Driver; +} + +/** The dev-folder registration form + its one read-only status row, rendered on the Actor detail view + * (`console.md`'s "Local dev-folder registration form" section). Deliberately shows only the registered + * folder, never a build's working directory or a "mount will apply" claim - whether a mount actually + * applies depends on which build a given run resolves, which this Actor-level view has no way to know in + * advance (`services/dev-folder.ts: devFolderStatus`'s doc comment). `errorMessage` is threaded through + * from the POST handler's redirect query param below, since a redirect itself carries no state of its + * own. */ +function devFolderSection(actorId: string, status: DevFolderStatus, errorMessage?: string): string { + return ( + '

Local dev folder

' + + definitionList([['localDevFolder', status.localDevFolder ?? '(none registered)']]) + + devFolderForm(actorId, status.localDevFolder ?? '', errorMessage) + ); +} + +export function createConsoleServer(deps: ConsoleServerDeps): Express { const app = express(); app.disable('x-powered-by'); + // Only the dev-folder form below posts anything - every other console route is a plain `GET` + // (`console.md`'s "Every route is a read except the dev-folder form below, which is the console's + // one write"). + app.use(express.urlencoded({ extended: false })); app.get('/', async (_req, res) => { res.send(layout('actor-runtime', '

Pick an object type from the navigation above.

')); @@ -59,6 +92,7 @@ export function createConsoleServer(): Express { res.status(404).send(layout('Not found', '

Actor not found.

')); return; } + const devFolderError = typeof req.query.devFolderError === 'string' ? req.query.devFolderError : undefined; const body = definitionList([ ['id', actor.id], @@ -79,10 +113,36 @@ export function createConsoleServer(): Express { Object.entries(actor.taggedBuilds).map(([tag, b]) => [tag, b.buildId, b.buildNumber]), 1, '/builds', - ); + ) + + devFolderSection(actor.id, devFolderStatus(actor), devFolderError); res.send(layout(`Actor ${actor.name}`, body)); }); + /** The console's one mutation - funnels through the same `setDevFolder` the API endpoint uses, + * resolving the Actor cross-user by the id already in the page URL (no token) rather than through + * `resolveOwnedActor`. A failure redirects back with `describeDevFolderFailure`'s message in a query + * param, so it's surfaced inline rather than swallowed by the redirect. */ + app.post('/actors/:id/dev-folder', async (req, res) => { + const actor = await getActorById(req.params.id); + if (!actor) { + res.status(404).send(layout('Not found', '

Actor not found.

')); + return; + } + const body = req.body as Record | undefined; + // Not trimmed here - `setDevFolder` itself distinguishes an explicit clear (the literal empty + // string) from a whitespace-only submission (rejected, not treated as a clear); trimming here + // first would collapse that distinction before it ever reaches the service. + const submitted = typeof body?.localDevFolder === 'string' ? body.localDevFolder : ''; + + const result = await setDevFolder(deps.driver, actor, submitted); + if (result.kind !== 'ok') { + const message = describeDevFolderFailure(result); + res.redirect(`/actors/${encodeURIComponent(actor.id)}?devFolderError=${encodeURIComponent(message)}`); + return; + } + res.redirect(`/actors/${encodeURIComponent(actor.id)}`); + }); + // --- Compatibility redirects: stock apify-cli only knows one Console, so it prints links using // the real Apify Console's URL shapes (`/actors/:actorId/runs/:runId`, // `/actors/:actorId/builds/:buildNumber`, `/storage/datasets/:id`, ...). This console serves its own diff --git a/src/console/templates.ts b/src/console/templates.ts index 0dd1c44..6be72dc 100644 --- a/src/console/templates.ts +++ b/src/console/templates.ts @@ -36,6 +36,8 @@ export function layout(title: string, body: string): string { dt { font-weight: 600; } pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; white-space: pre-wrap; } .empty { color: #777; font-style: italic; } + .error { color: #b00020; } + .wide-input { width: 28rem; } h1 { margin-top: 0; } @@ -84,6 +86,22 @@ export function table( return `${head}${body}
`; } +/** The dev-folder registration form on the Actor detail view - a single text field plus a submit + * button, styled via this file's shared `