From 6c1d2dcf25b460c52680113e1ccfeedca3c2f9ce Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:39:45 +0000 Subject: [PATCH 1/8] feat(api): add clean-slate v1 docker-git backend --- package.json | 5 + packages/api/.gitignore | 4 + packages/api/README.md | 44 +++ packages/api/eslint.config.mjs | 29 ++ packages/api/package.json | 38 ++ packages/api/src/api/contracts.ts | 123 +++++++ packages/api/src/api/errors.ts | 28 ++ packages/api/src/api/schema.ts | 75 ++++ packages/api/src/http.ts | 302 ++++++++++++++++ packages/api/src/main.ts | 6 + packages/api/src/program.ts | 52 +++ packages/api/src/services/agents.ts | 486 ++++++++++++++++++++++++++ packages/api/src/services/events.ts | 75 ++++ packages/api/src/services/projects.ts | 338 ++++++++++++++++++ packages/api/tests/events.test.ts | 23 ++ packages/api/tests/schema.test.ts | 43 +++ packages/api/tsconfig.json | 10 + packages/api/vitest.config.ts | 9 + pnpm-lock.yaml | 103 ++++++ 19 files changed, 1793 insertions(+) create mode 100644 packages/api/.gitignore create mode 100644 packages/api/README.md create mode 100644 packages/api/eslint.config.mjs create mode 100644 packages/api/package.json create mode 100644 packages/api/src/api/contracts.ts create mode 100644 packages/api/src/api/errors.ts create mode 100644 packages/api/src/api/schema.ts create mode 100644 packages/api/src/http.ts create mode 100644 packages/api/src/main.ts create mode 100644 packages/api/src/program.ts create mode 100644 packages/api/src/services/agents.ts create mode 100644 packages/api/src/services/events.ts create mode 100644 packages/api/src/services/projects.ts create mode 100644 packages/api/tests/events.test.ts create mode 100644 packages/api/tests/schema.test.ts create mode 100644 packages/api/tsconfig.json create mode 100644 packages/api/vitest.config.ts diff --git a/package.json b/package.json index d8a59aa4..ec2af1e1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "scripts": { "setup:pre-commit-hook": "node scripts/setup-pre-commit-hook.js", "build": "pnpm --filter ./packages/app build", + "api:build": "pnpm --filter ./packages/api build", + "api:start": "pnpm --filter ./packages/api start", + "api:dev": "pnpm --filter ./packages/api dev", + "api:test": "pnpm --filter ./packages/api test", + "api:typecheck": "pnpm --filter ./packages/api typecheck", "check": "pnpm --filter ./packages/app check && pnpm --filter ./packages/lib typecheck", "changeset": "changeset", "changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", diff --git a/packages/api/.gitignore b/packages/api/.gitignore new file mode 100644 index 00000000..8fd037b9 --- /dev/null +++ b/packages/api/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +.vitest/ diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 00000000..2a28cdec --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,44 @@ +# @effect-template/api + +Clean-slate v1 HTTP API for docker-git orchestration. + +## Run + +```bash +pnpm --filter ./packages/api build +pnpm --filter ./packages/api start +``` + +Env: + +- `DOCKER_GIT_API_PORT` (default: `3334`) +- `DOCKER_GIT_PROJECTS_ROOT` (default: `~/.docker-git`) +- `DOCKER_GIT_API_LOG_LEVEL` (default: `info`) + +## Endpoints (v1) + +- `GET /v1/health` +- `GET /v1/projects` +- `GET /v1/projects/:projectId` +- `POST /v1/projects` +- `DELETE /v1/projects/:projectId` +- `POST /v1/projects/:projectId/up` +- `POST /v1/projects/:projectId/down` +- `POST /v1/projects/:projectId/recreate` +- `GET /v1/projects/:projectId/ps` +- `GET /v1/projects/:projectId/logs` +- `GET /v1/projects/:projectId/events` (SSE) +- `POST /v1/projects/:projectId/agents` +- `GET /v1/projects/:projectId/agents` +- `GET /v1/projects/:projectId/agents/:agentId` +- `GET /v1/projects/:projectId/agents/:agentId/attach` +- `POST /v1/projects/:projectId/agents/:agentId/stop` +- `GET /v1/projects/:projectId/agents/:agentId/logs` + +## Example + +```bash +curl -s http://localhost:3334/v1/projects +curl -s -X POST http://localhost:3334/v1/projects//up +curl -s -N http://localhost:3334/v1/projects//events +``` diff --git a/packages/api/eslint.config.mjs b/packages/api/eslint.config.mjs new file mode 100644 index 00000000..0d55b554 --- /dev/null +++ b/packages/api/eslint.config.mjs @@ -0,0 +1,29 @@ +import js from "@eslint/js" +import globals from "globals" +import tsPlugin from "@typescript-eslint/eslint-plugin" +import tsParser from "@typescript-eslint/parser" + +export default [ + { + ignores: ["dist/**"] + }, + js.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + sourceType: "module" + }, + globals: { + ...globals.node + } + }, + plugins: { + "@typescript-eslint": tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules + } + } +] diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 00000000..eada1be2 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,38 @@ +{ + "name": "@effect-template/api", + "version": "0.1.0", + "private": true, + "description": "docker-git clean-slate v1 API", + "main": "dist/src/main.js", + "type": "module", + "scripts": { + "prebuild": "pnpm -C ../lib build", + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "prestart": "pnpm run build", + "start": "node dist/src/main.js", + "pretypecheck": "pnpm -C ../lib build", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "eslint .", + "pretest": "pnpm -C ../lib build", + "test": "vitest run" + }, + "dependencies": { + "@effect-template/lib": "workspace:*", + "@effect/platform": "^0.94.1", + "@effect/platform-node": "^0.104.0", + "@effect/schema": "^0.75.5", + "effect": "^3.19.14" + }, + "devDependencies": { + "@effect/vitest": "^0.27.0", + "@eslint/js": "9.39.1", + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.48.1", + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts new file mode 100644 index 00000000..1b571472 --- /dev/null +++ b/packages/api/src/api/contracts.ts @@ -0,0 +1,123 @@ +export type ProjectStatus = "running" | "stopped" | "unknown" + +export type AgentProvider = "codex" | "opencode" | "claude" | "custom" + +export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exited" | "failed" + +export type ProjectSummary = { + readonly id: string + readonly displayName: string + readonly repoUrl: string + readonly repoRef: string + readonly status: ProjectStatus + readonly statusLabel: string +} + +export type ProjectDetails = ProjectSummary & { + readonly containerName: string + readonly serviceName: string + readonly sshUser: string + readonly sshPort: number + readonly targetDir: string + readonly projectDir: string + readonly sshCommand: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly codexHome: string +} + +export type CreateProjectRequest = { + readonly repoUrl?: string | undefined + readonly repoRef?: string | undefined + readonly targetDir?: string | undefined + readonly sshPort?: string | undefined + readonly sshUser?: string | undefined + readonly containerName?: string | undefined + readonly serviceName?: string | undefined + readonly volumeName?: string | undefined + readonly secretsRoot?: string | undefined + readonly authorizedKeysPath?: string | undefined + readonly envGlobalPath?: string | undefined + readonly envProjectPath?: string | undefined + readonly codexAuthPath?: string | undefined + readonly codexHome?: string | undefined + readonly dockerNetworkMode?: string | undefined + readonly dockerSharedNetworkName?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined + readonly outDir?: string | undefined + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly up?: boolean | undefined + readonly openSsh?: boolean | undefined + readonly force?: boolean | undefined + readonly forceEnv?: boolean | undefined +} + +export type AgentEnvVar = { + readonly key: string + readonly value: string +} + +export type CreateAgentRequest = { + readonly provider: AgentProvider + readonly command?: string | undefined + readonly args?: ReadonlyArray | undefined + readonly cwd?: string | undefined + readonly env?: ReadonlyArray | undefined + readonly label?: string | undefined +} + +export type AgentSession = { + readonly id: string + readonly projectId: string + readonly provider: AgentProvider + readonly label: string + readonly command: string + readonly containerName: string + readonly status: AgentStatus + readonly source: string + readonly pidFile: string + readonly hostPid: number | null + readonly startedAt: string + readonly updatedAt: string + readonly stoppedAt?: string | undefined + readonly exitCode?: number | undefined + readonly signal?: string | undefined +} + +export type AgentLogLine = { + readonly at: string + readonly stream: "stdout" | "stderr" + readonly line: string +} + +export type AgentAttachInfo = { + readonly projectId: string + readonly agentId: string + readonly containerName: string + readonly pidFile: string + readonly inspectCommand: string + readonly shellCommand: string +} + +export type ApiEventType = + | "snapshot" + | "project.created" + | "project.deleted" + | "project.deployment.status" + | "project.deployment.log" + | "agent.started" + | "agent.output" + | "agent.exited" + | "agent.stopped" + | "agent.error" + +export type ApiEvent = { + readonly seq: number + readonly projectId: string + readonly type: ApiEventType + readonly at: string + readonly payload: unknown +} diff --git a/packages/api/src/api/errors.ts b/packages/api/src/api/errors.ts new file mode 100644 index 00000000..9d59a550 --- /dev/null +++ b/packages/api/src/api/errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect" + +export class ApiBadRequestError extends Data.TaggedError("ApiBadRequestError")<{ + readonly message: string + readonly details?: unknown +}> {} + +export class ApiNotFoundError extends Data.TaggedError("ApiNotFoundError")<{ + readonly message: string +}> {} + +export class ApiConflictError extends Data.TaggedError("ApiConflictError")<{ + readonly message: string +}> {} + +export class ApiInternalError extends Data.TaggedError("ApiInternalError")<{ + readonly message: string + readonly cause?: unknown +}> {} + +export type ApiKnownError = + | ApiBadRequestError + | ApiNotFoundError + | ApiConflictError + | ApiInternalError + +export const describeUnknown = (error: unknown): string => + error instanceof Error ? (error.stack ?? error.message) : String(error) diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts new file mode 100644 index 00000000..a8838099 --- /dev/null +++ b/packages/api/src/api/schema.ts @@ -0,0 +1,75 @@ +import * as Schema from "effect/Schema" + +const OptionalString = Schema.optional(Schema.String) +const OptionalBoolean = Schema.optional(Schema.Boolean) + +export const CreateProjectRequestSchema = Schema.Struct({ + repoUrl: OptionalString, + repoRef: OptionalString, + targetDir: OptionalString, + sshPort: OptionalString, + sshUser: OptionalString, + containerName: OptionalString, + serviceName: OptionalString, + volumeName: OptionalString, + secretsRoot: OptionalString, + authorizedKeysPath: OptionalString, + envGlobalPath: OptionalString, + envProjectPath: OptionalString, + codexAuthPath: OptionalString, + codexHome: OptionalString, + dockerNetworkMode: OptionalString, + dockerSharedNetworkName: OptionalString, + enableMcpPlaywright: OptionalBoolean, + outDir: OptionalString, + gitTokenLabel: OptionalString, + codexTokenLabel: OptionalString, + claudeTokenLabel: OptionalString, + up: OptionalBoolean, + openSsh: OptionalBoolean, + force: OptionalBoolean, + forceEnv: OptionalBoolean +}) + +export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") + +export const AgentEnvVarSchema = Schema.Struct({ + key: Schema.String, + value: Schema.String +}) + +export const CreateAgentRequestSchema = Schema.Struct({ + provider: AgentProviderSchema, + command: OptionalString, + args: Schema.optional(Schema.Array(Schema.String)), + cwd: OptionalString, + env: Schema.optional(Schema.Array(AgentEnvVarSchema)), + label: OptionalString +}) + +export const AgentSessionSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + provider: AgentProviderSchema, + label: Schema.String, + command: Schema.String, + containerName: Schema.String, + status: Schema.Literal("starting", "running", "stopping", "stopped", "exited", "failed"), + source: Schema.String, + pidFile: Schema.String, + hostPid: Schema.NullOr(Schema.Number), + startedAt: Schema.String, + updatedAt: Schema.String, + stoppedAt: OptionalString, + exitCode: Schema.optional(Schema.Number), + signal: OptionalString +}) + +export const AgentLogLineSchema = Schema.Struct({ + at: Schema.String, + stream: Schema.Literal("stdout", "stderr"), + line: Schema.String +}) + +export type CreateProjectRequestInput = Schema.Schema.Type +export type CreateAgentRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts new file mode 100644 index 00000000..9a80815e --- /dev/null +++ b/packages/api/src/http.ts @@ -0,0 +1,302 @@ +import { Chunk, Duration, Effect, Ref } from "effect" +import * as Stream from "effect/Stream" +import type { PlatformError } from "@effect/platform/Error" +import type * as HttpBody from "@effect/platform/HttpBody" +import * as HttpRouter from "@effect/platform/HttpRouter" +import * as HttpServerRequest from "@effect/platform/HttpServerRequest" +import * as HttpServerResponse from "@effect/platform/HttpServerResponse" +import * as HttpServerError from "@effect/platform/HttpServerError" +import * as ParseResult from "effect/ParseResult" +import * as Schema from "effect/Schema" + +import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" +import { CreateAgentRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" +import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" +import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" +import { + createProjectFromRequest, + deleteProjectById, + downProject, + getProject, + listProjects, + readProjectLogs, + readProjectPs, + recreateProject, + upProject +} from "./services/projects.js" + +const ProjectParamsSchema = Schema.Struct({ + projectId: Schema.String +}) + +const AgentParamsSchema = Schema.Struct({ + projectId: Schema.String, + agentId: Schema.String +}) + +type ApiError = + | ApiBadRequestError + | ApiNotFoundError + | ApiConflictError + | ApiInternalError + | ParseResult.ParseError + | HttpBody.HttpBodyError + | HttpServerError.RequestError + | PlatformError + +const jsonResponse = (data: unknown, status: number) => + Effect.map(HttpServerResponse.json(data), (response) => HttpServerResponse.setStatus(response, status)) + +const parseQueryInt = (url: string, key: string, fallback: number): number => { + const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "") + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback + } + return Math.floor(parsed) +} + +const errorResponse = (error: ApiError | unknown) => { + if (ParseResult.isParseError(error)) { + return jsonResponse( + { + error: { + type: "ParseError", + message: ParseResult.TreeFormatter.formatIssueSync(error.issue) + } + }, + 400 + ) + } + + if (error instanceof ApiBadRequestError) { + return jsonResponse({ error: { type: error._tag, message: error.message, details: error.details } }, 400) + } + + if (error instanceof ApiNotFoundError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 404) + } + + if (error instanceof ApiConflictError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 409) + } + + if (error instanceof ApiInternalError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 500) + } + + return jsonResponse( + { + error: { + type: "InternalError", + message: describeUnknown(error) + } + }, + 500 + ) +} + +const projectParams = HttpRouter.schemaParams(ProjectParamsSchema) +const agentParams = HttpRouter.schemaParams(AgentParamsSchema) + +const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema) + +export const makeRouter = () => { + const base = HttpRouter.empty.pipe( + HttpRouter.get("/v1/health", jsonResponse({ ok: true }, 200)), + HttpRouter.get( + "/v1/projects", + listProjects().pipe( + Effect.flatMap((projects) => jsonResponse({ projects }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/projects", + Effect.gen(function*(_) { + const request = yield* _(readCreateProjectRequest()) + const project = yield* _(createProjectFromRequest(request)) + return yield* _(jsonResponse({ project }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/projects/:projectId", + projectParams.pipe( + Effect.flatMap(({ projectId }) => getProject(projectId)), + Effect.flatMap((project) => jsonResponse({ project }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.del( + "/v1/projects/:projectId", + projectParams.pipe( + Effect.flatMap(({ projectId }) => deleteProjectById(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/projects/:projectId/up", + projectParams.pipe( + Effect.flatMap(({ projectId }) => upProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/projects/:projectId/down", + projectParams.pipe( + Effect.flatMap(({ projectId }) => downProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/projects/:projectId/recreate", + projectParams.pipe( + Effect.flatMap(({ projectId }) => recreateProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/v1/projects/:projectId/ps", + projectParams.pipe( + Effect.flatMap(({ projectId }) => readProjectPs(projectId)), + Effect.flatMap((output) => jsonResponse({ output }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/v1/projects/:projectId/logs", + projectParams.pipe( + Effect.flatMap(({ projectId }) => readProjectLogs(projectId)), + Effect.flatMap((output) => jsonResponse({ output }, 200)), + Effect.catchAll(errorResponse) + ) + ) + ) + + const withAgents = base.pipe( + HttpRouter.post( + "/v1/projects/:projectId/agents", + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const project = yield* _(getProject(projectId)) + const request = yield* _(HttpServerRequest.schemaBodyJson(CreateAgentRequestSchema)) + const session = yield* _(startAgent(project, request)) + return yield* _(jsonResponse({ session }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/projects/:projectId/agents", + projectParams.pipe( + Effect.flatMap(({ projectId }) => jsonResponse({ sessions: listAgents(projectId) }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/v1/projects/:projectId/agents/:agentId", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => getAgent(projectId, agentId)), + Effect.flatMap((session) => jsonResponse({ session }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/v1/projects/:projectId/agents/:agentId/attach", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => getAgentAttachInfo(projectId, agentId)), + Effect.flatMap((attach) => jsonResponse({ attach }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/projects/:projectId/agents/:agentId/stop", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId)) + return yield* _(stopAgent(projectId, project.projectDir, project.containerName, agentId)) + }) + ), + Effect.flatMap((session) => jsonResponse({ session }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/v1/projects/:projectId/agents/:agentId/logs", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const lines = parseQueryInt(request.url, "lines", 200) + const entries = yield* _(readAgentLogs(projectId, agentId, lines)) + return { entries, lines } + }) + ), + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ) + ) + + return withAgents.pipe( + HttpRouter.get( + "/v1/projects/:projectId/events", + projectParams.pipe( + Effect.flatMap(({ projectId }) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const startCursor = parseQueryInt(request.url, "cursor", 0) + const cursorRef = yield* _(Ref.make(startCursor)) + const snapshotRef = yield* _(Ref.make(false)) + const encoder = new TextEncoder() + + const encodeSse = (event: string, data: unknown, id?: number): Uint8Array => { + const idLine = id === undefined ? "" : `id: ${id}\n` + return encoder.encode(`${idLine}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + + const poll = Effect.gen(function* (_) { + const snapshotSent = yield* _(Ref.get(snapshotRef)) + + if (!snapshotSent) { + yield* _(Ref.set(snapshotRef, true)) + const cursor = latestProjectCursor(projectId) + yield* _(Ref.set(cursorRef, cursor)) + return Chunk.of( + encodeSse("snapshot", { + projectId, + cursor, + agents: listAgents(projectId) + }, cursor) + ) + } + + const currentCursor = yield* _(Ref.get(cursorRef)) + const events = listProjectEventsSince(projectId, currentCursor) + if (events.length === 0) { + yield* _(Effect.sleep(Duration.millis(500))) + return Chunk.empty() + } + + const nextCursor = events[events.length - 1]?.seq ?? currentCursor + yield* _(Ref.set(cursorRef, nextCursor)) + const encoded = events.map((event) => encodeSse(event.type, event, event.seq)) + return Chunk.fromIterable(encoded) + }) + + return HttpServerResponse.stream(Stream.repeatEffectChunk(poll), { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "connection": "keep-alive" + } + }) + }) + ), + Effect.catchAll(errorResponse) + ) + ) + ) +} diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts new file mode 100644 index 00000000..17fc7466 --- /dev/null +++ b/packages/api/src/main.ts @@ -0,0 +1,6 @@ +import { NodeContext, NodeRuntime } from "@effect/platform-node" +import { Effect } from "effect" + +import { program } from "./program.js" + +NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts new file mode 100644 index 00000000..273ba0b9 --- /dev/null +++ b/packages/api/src/program.ts @@ -0,0 +1,52 @@ +import { HttpMiddleware, HttpServer, HttpServerRequest } from "@effect/platform" +import { NodeHttpServer } from "@effect/platform-node" +import { Console, Effect, Layer, Option } from "effect" +import { createServer } from "node:http" + +import { makeRouter } from "./http.js" +import { initializeAgentState } from "./services/agents.js" + +const resolvePort = (env: Record): number => { + const raw = env["DOCKER_GIT_API_PORT"] ?? env["PORT"] + const parsed = raw === undefined ? Number.NaN : Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 3334 +} + +const requestLogger = HttpMiddleware.make((httpApp) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const startedAt = Date.now() + const id = `${startedAt}-${Math.floor(Math.random() * 1e6)}` + const remote = Option.getOrElse(request.remoteAddress, () => "unknown") + + yield* _(Console.log(`[api req ${id}] ${request.method} ${request.url} remote=${remote}`)) + + return yield* _( + httpApp.pipe( + Effect.tap((response) => + Console.log( + `[api res ${id}] ${request.method} ${request.url} status=${response.status} ms=${Date.now() - startedAt}` + ) + ), + Effect.tapError((error) => + Console.error(`[api err ${id}] ${request.method} ${request.url} ${String(error)}`) + ) + ) + ) + }) +) + +export const program = (() => { + const port = resolvePort(process.env) + const router = makeRouter() + const app = router.pipe(HttpServer.serve(requestLogger), HttpServer.withLogAddress) + const server = createServer() + const serverLayer = NodeHttpServer.layer(() => server, { port }) + + return Effect.scoped( + Console.log(`docker-git api boot port=${port}`).pipe( + Effect.zipRight(initializeAgentState()), + Effect.zipRight(Layer.launch(Layer.provide(app, serverLayer))) + ) + ) +})() diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts new file mode 100644 index 00000000..f1b8f0c5 --- /dev/null +++ b/packages/api/src/services/agents.ts @@ -0,0 +1,486 @@ +import { runCommandWithExitCodes } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { defaultProjectsRoot } from "@effect-template/lib/usecases/path-helpers" +import { Effect } from "effect" +import { randomUUID } from "node:crypto" +import { promises as fs } from "node:fs" +import { join } from "node:path" +import { spawn, type ChildProcess } from "node:child_process" + +import type { + AgentLogLine, + AgentSession, + CreateAgentRequest, + ProjectDetails +} from "../api/contracts.js" +import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" +import { emitProjectEvent } from "./events.js" + +type AgentRecord = { + session: AgentSession + projectDir: string + logs: Array + process: ChildProcess | null + stdoutRemainder: string + stderrRemainder: string +} + +type SnapshotFile = { + readonly sessions: ReadonlyArray +} + +const records: Map = new Map() +const projectIndex: Map> = new Map() +const maxLogLines = 5000 +let initialized = false + +const nowIso = (): string => new Date().toISOString() + +const stateFilePath = (): string => + join(defaultProjectsRoot(process.cwd()), ".orch", "state", "api-agents.json") + +const upsertProjectIndex = (projectId: string, agentId: string): void => { + const current = projectIndex.get(projectId) + if (current) { + current.add(agentId) + return + } + projectIndex.set(projectId, new Set([agentId])) +} + +const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +const sourceLabel = (request: CreateAgentRequest): string => + request.label?.trim().length ? request.label.trim() : request.provider + +const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string => { + if (provider === "codex") { + return "codex" + } + if (provider === "opencode") { + return "opencode" + } + if (provider === "claude") { + return "claude" + } + return "" +} + +const buildCommand = (request: CreateAgentRequest): string => { + const direct = request.command?.trim() ?? "" + if (direct.length > 0) { + return direct + } + + const base = pickDefaultCommand(request.provider) + if (base.length === 0) { + throw new ApiBadRequestError({ message: "Custom provider requires a non-empty 'command'." }) + } + + const args = (request.args ?? []).map((arg) => shellEscape(arg)) + return args.length === 0 ? base : `${base} ${args.join(" ")}` +} + +const buildAgentScript = ( + sessionId: string, + cwd: string, + envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>, + command: string +): string => { + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + const exports = envEntries + .map(({ key, value }) => `export ${key}=${shellEscape(value)}`) + .join("\n") + + return [ + "set -euo pipefail", + `PID_FILE=${shellEscape(pidFile)}`, + "cleanup() { rm -f \"$PID_FILE\"; }", + "trap cleanup EXIT", + "echo $$ > \"$PID_FILE\"", + `cd ${shellEscape(cwd)}`, + exports, + `exec ${command}` + ] + .filter((line) => line.trim().length > 0) + .join("\n") +} + +const trimLogs = (logs: Array): Array => + logs.length <= maxLogLines ? logs : logs.slice(logs.length - maxLogLines) + +const persistSnapshot = async (): Promise => { + const filePath = stateFilePath() + await fs.mkdir(join(filePath, ".."), { recursive: true }) + const payload: SnapshotFile = { + sessions: [...records.values()].map((record) => record.session) + } + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8") +} + +const persistSnapshotBestEffort = (): void => { + void persistSnapshot().catch(() => { + // best effort snapshot persistence + }) +} + +const updateSession = ( + record: AgentRecord, + patch: Partial +): void => { + record.session = { + ...record.session, + ...patch, + updatedAt: nowIso() + } + records.set(record.session.id, record) + persistSnapshotBestEffort() +} + +const appendLog = ( + record: AgentRecord, + stream: AgentLogLine["stream"], + line: string +): void => { + const entry: AgentLogLine = { + at: nowIso(), + stream, + line + } + record.logs = trimLogs([...record.logs, entry]) + emitProjectEvent(record.session.projectId, "agent.output", { + agentId: record.session.id, + stream, + line, + at: entry.at + }) +} + +const flushRemainder = (record: AgentRecord, stream: AgentLogLine["stream"]): void => { + const remainder = stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder + if (remainder.length === 0) { + return + } + appendLog(record, stream, remainder) + if (stream === "stdout") { + record.stdoutRemainder = "" + } else { + record.stderrRemainder = "" + } +} + +const consumeChunk = ( + record: AgentRecord, + stream: AgentLogLine["stream"], + chunk: Buffer +): void => { + const incoming = chunk.toString("utf8") + const withRemainder = (stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder) + incoming + const lines = withRemainder.split(/\r?\n/u) + const tail = lines.pop() ?? "" + + for (const line of lines) { + if (line.length > 0) { + appendLog(record, stream, line) + } + } + + if (stream === "stdout") { + record.stdoutRemainder = tail + } else { + record.stderrRemainder = tail + } +} + +const getProjectAgentIds = (projectId: string): ReadonlyArray => { + const ids = projectIndex.get(projectId) + return ids ? [...ids] : [] +} + +const getRecordOrFail = ( + projectId: string, + agentId: string +): Effect.Effect => + Effect.gen(function*(_) { + const record = records.get(agentId) + if (!record || record.session.projectId !== projectId) { + return yield* _( + Effect.fail( + new ApiNotFoundError({ message: `Agent not found: ${agentId} in project ${projectId}` }) + ) + ) + } + return record + }) + +const endedStatuses: ReadonlySet = new Set(["stopped", "exited", "failed"]) + +const killAgentScript = (sessionId: string): string => { + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + return [ + "set -eu", + `PID_FILE=${shellEscape(pidFile)}`, + "if [ -f \"$PID_FILE\" ]; then", + " PID=$(cat \"$PID_FILE\" 2>/dev/null || true)", + " if [ -n \"$PID\" ]; then", + " kill -TERM \"$PID\" 2>/dev/null || true", + " sleep 2", + " if kill -0 \"$PID\" 2>/dev/null; then kill -KILL \"$PID\" 2>/dev/null || true; fi", + " fi", + "fi" + ].join("\n") +} + +const hydrateFromSnapshot = async (): Promise => { + const filePath = stateFilePath() + const exists = await fs.stat(filePath).then(() => true).catch(() => false) + if (!exists) { + return + } + + const raw = await fs.readFile(filePath, "utf8") + const parsed = JSON.parse(raw) as SnapshotFile + for (const session of parsed.sessions ?? []) { + const restored: AgentSession = { + ...session, + status: endedStatuses.has(session.status) ? session.status : "exited", + hostPid: null, + stoppedAt: session.stoppedAt ?? nowIso(), + updatedAt: nowIso() + } + + const record: AgentRecord = { + session: restored, + projectDir: "", + logs: [], + process: null, + stdoutRemainder: "", + stderrRemainder: "" + } + + records.set(restored.id, record) + upsertProjectIndex(restored.projectId, restored.id) + } +} + +export const initializeAgentState = () => + Effect.tryPromise({ + try: async () => { + if (initialized) { + return + } + await hydrateFromSnapshot() + initialized = true + }, + catch: (error) => new Error(String(error)) + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.asVoid + ) + +// CHANGE: start an agent process inside a project container and register it for API control. +// WHY: issue #84 requires non-CLI lifecycle control for Codex/OpenCode/Claude runs. +// QUOTE(ТЗ): "Запускать агентов" +// REF: issue-84-agent-start +// SOURCE: n/a +// FORMAT THEOREM: forall req: valid(req) -> exists(session(req)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: agent ids are unique UUIDs and state snapshots are persisted best-effort +// COMPLEXITY: O(1) +export const startAgent = ( + project: ProjectDetails, + request: CreateAgentRequest +)=> + Effect.try({ + try: () => { + const command = buildCommand(request) + const sessionId = randomUUID() + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + const label = sourceLabel(request) + const startedAt = nowIso() + const workingDir = request.cwd?.trim() || project.targetDir + + const session: AgentSession = { + id: sessionId, + projectId: project.id, + provider: request.provider, + label, + command, + containerName: project.containerName, + status: "starting", + source: `provider:${request.provider}`, + pidFile, + hostPid: null, + startedAt, + updatedAt: startedAt + } + + const record: AgentRecord = { + session, + projectDir: project.projectDir, + logs: [], + process: null, + stdoutRemainder: "", + stderrRemainder: "" + } + + records.set(sessionId, record) + upsertProjectIndex(project.id, sessionId) + + const script = buildAgentScript(sessionId, workingDir, request.env ?? [], command) + const child = spawn( + "docker", + ["exec", "-i", project.containerName, "bash", "-lc", script], + { + cwd: project.projectDir, + env: process.env, + stdio: ["ignore", "pipe", "pipe"] + } + ) + + record.process = child + updateSession(record, { + status: "running", + hostPid: child.pid ?? null + }) + + emitProjectEvent(project.id, "agent.started", { + agentId: sessionId, + provider: request.provider, + label, + command + }) + + child.stdout.on("data", (chunk: Buffer) => { + consumeChunk(record, "stdout", chunk) + }) + + child.stderr.on("data", (chunk: Buffer) => { + consumeChunk(record, "stderr", chunk) + }) + + child.on("error", (error) => { + updateSession(record, { + status: "failed", + stoppedAt: nowIso() + }) + emitProjectEvent(project.id, "agent.error", { + agentId: sessionId, + message: error.message + }) + }) + + child.on("close", (exitCode, signal) => { + flushRemainder(record, "stdout") + flushRemainder(record, "stderr") + + const expectedStop = record.session.status === "stopping" || record.session.status === "stopped" + const nextStatus: AgentSession["status"] = expectedStop + ? "stopped" + : (exitCode === 0 ? "exited" : "failed") + + updateSession(record, { + status: nextStatus, + hostPid: null, + stoppedAt: nowIso(), + ...(exitCode === null ? {} : { exitCode }), + ...(signal === null ? {} : { signal }) + }) + + emitProjectEvent(project.id, expectedStop ? "agent.stopped" : "agent.exited", { + agentId: sessionId, + exitCode, + signal, + status: nextStatus + }) + }) + + persistSnapshotBestEffort() + return record.session + }, + catch: (error) => { + if (error instanceof ApiBadRequestError) { + return error + } + if (error instanceof ApiConflictError) { + return error + } + return new ApiConflictError({ message: `Failed to start agent: ${String(error)}` }) + } + }) + +export const listAgents = (projectId: string): ReadonlyArray => + getProjectAgentIds(projectId) + .map((id) => records.get(id)) + .filter((record): record is AgentRecord => Boolean(record)) + .map((record) => record.session) + +export const getAgent = ( + projectId: string, + agentId: string +) => + getRecordOrFail(projectId, agentId).pipe(Effect.map((record) => record.session)) + +export const stopAgent = ( + projectId: string, + projectDir: string, + containerName: string, + agentId: string +)=> + Effect.gen(function*(_) { + const record = yield* _(getRecordOrFail(projectId, agentId)) + + if (endedStatuses.has(record.session.status)) { + return record.session + } + + updateSession(record, { status: "stopping" }) + + const command = killAgentScript(agentId) + yield* _( + runCommandWithExitCodes( + { + cwd: projectDir, + command: "docker", + args: ["exec", containerName, "bash", "-lc", command] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker exec kill-agent", exitCode }) + ).pipe(Effect.catchAll(() => Effect.void)) + ) + + if (record.process && !record.process.killed) { + record.process.kill("SIGTERM") + } + + emitProjectEvent(projectId, "agent.stopped", { agentId, message: "Stop signal sent" }) + return record.session + }) + +export const readAgentLogs = ( + projectId: string, + agentId: string, + lines: number +)=> + getRecordOrFail(projectId, agentId).pipe( + Effect.map((record) => { + const safe = Number.isFinite(lines) && lines > 0 ? Math.min(Math.floor(lines), maxLogLines) : 200 + return record.logs.slice(record.logs.length - safe) + }) + ) + +export const getAgentAttachInfo = ( + projectId: string, + agentId: string +)=> + getRecordOrFail(projectId, agentId).pipe( + Effect.map((record) => ({ + projectId, + agentId, + containerName: record.session.containerName, + pidFile: record.session.pidFile, + inspectCommand: `docker exec ${record.session.containerName} bash -lc 'cat ${record.session.pidFile}'`, + shellCommand: `docker exec -it ${record.session.containerName} bash` + })) + ) diff --git a/packages/api/src/services/events.ts b/packages/api/src/services/events.ts new file mode 100644 index 00000000..2c7cdfdf --- /dev/null +++ b/packages/api/src/services/events.ts @@ -0,0 +1,75 @@ +import type { ApiEvent, ApiEventType } from "../api/contracts.js" + +type ProjectEventsState = { + nextSeq: number + events: Array +} + +const maxEventsPerProject = 4000 +const state: Map = new Map() + +const nowIso = (): string => new Date().toISOString() + +const getProjectState = (projectId: string): ProjectEventsState => { + const existing = state.get(projectId) + if (existing) { + return existing + } + const created: ProjectEventsState = { + nextSeq: 1, + events: [] + } + state.set(projectId, created) + return created +} + +const trimEvents = (events: Array): Array => + events.length <= maxEventsPerProject + ? events + : events.slice(events.length - maxEventsPerProject) + +// CHANGE: append a project-scoped API event for SSE consumers. +// WHY: keep realtime streams deterministic across deployment and agent lifecycles. +// QUOTE(ТЗ): "Мне надо иметь возможность управлять полностью проектом с помощью API" +// REF: issue-84-events +// SOURCE: n/a +// FORMAT THEOREM: forall p,e: emit(p,e) -> exists(event in stream(p), event.type=e) +// PURITY: SHELL +// EFFECT: n/a +// INVARIANT: sequence numbers are strictly monotonic per project +// COMPLEXITY: O(1) +export const emitProjectEvent = ( + projectId: string, + type: ApiEventType, + payload: unknown +): ApiEvent => { + const project = getProjectState(projectId) + const event: ApiEvent = { + seq: project.nextSeq, + projectId, + type, + at: nowIso(), + payload + } + project.nextSeq += 1 + project.events = trimEvents([...project.events, event]) + return event +} + +export const listProjectEventsSince = ( + projectId: string, + cursor: number +): ReadonlyArray => { + const project = getProjectState(projectId) + return project.events.filter((event) => event.seq > cursor) +} + +export const latestProjectCursor = (projectId: string): number => { + const project = getProjectState(projectId) + const last = project.events[project.events.length - 1] + return last ? last.seq : 0 +} + +export const clearProjectEvents = (projectId: string): void => { + state.delete(projectId) +} diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts new file mode 100644 index 00000000..11568e73 --- /dev/null +++ b/packages/api/src/services/projects.ts @@ -0,0 +1,338 @@ +import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" +import type { RawOptions } from "@effect-template/lib/core/command-options" +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { Effect, Either } from "effect" + +import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js" +import { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" +import { emitProjectEvent } from "./events.js" + +const readComposePsFormatted = (cwd: string) => + runCommandCapture( + { + cwd, + command: "docker", + args: [ + "compose", + "--ansi", + "never", + "ps", + "--format", + "{{.Name}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}" + ] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker compose ps", exitCode }) + ) + +const runComposeCapture = ( + projectId: string, + cwd: string, + args: ReadonlyArray, + okExitCodes: ReadonlyArray = [0] +) => + runCommandCapture( + { + cwd, + command: "docker", + args: ["compose", "--ansi", "never", ...args] + }, + okExitCodes, + (exitCode) => new CommandFailedError({ command: `docker compose ${args.join(" ")}`, exitCode }) + ).pipe( + Effect.tap((output) => + Effect.sync(() => { + for (const line of output.split(/\r?\n/u)) { + const trimmed = line.trimEnd() + if (trimmed.length > 0) { + emitProjectEvent(projectId, "project.deployment.log", { + line: trimmed, + command: `docker compose ${args.join(" ")}` + }) + } + } + }) + ) + ) + +const toProjectStatus = (raw: string): ProjectStatus => { + const normalized = raw.toLowerCase() + if (normalized.includes("up") || normalized.includes("running")) { + return "running" + } + if (normalized.includes("exited") || normalized.includes("stopped") || raw.trim().length === 0) { + return "stopped" + } + return "unknown" +} + +const statusLabelFromPs = (raw: string): string => { + const lines = raw + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + if (lines.length === 0) { + return "stopped" + } + const statuses = lines + .map((line) => { + const parts = line.split("\t") + return parts[1]?.trim() ?? "unknown" + }) + .filter((value) => value.length > 0) + return statuses.length > 0 ? statuses.join(", ") : "unknown" +} + +const withProjectRuntime = (project: ProjectItem) => + readComposePsFormatted(project.projectDir).pipe( + Effect.catchAll(() => Effect.succeed("")), + Effect.map((rawStatus) => ({ + id: project.projectDir, + displayName: project.displayName, + repoUrl: project.repoUrl, + repoRef: project.repoRef, + status: toProjectStatus(rawStatus), + statusLabel: statusLabelFromPs(rawStatus) + })) + ) + +const toProjectDetails = ( + project: ProjectItem, + summary: ProjectSummary +): ProjectDetails => ({ + ...summary, + containerName: project.containerName, + serviceName: project.serviceName, + sshUser: project.sshUser, + sshPort: project.sshPort, + targetDir: project.targetDir, + projectDir: project.projectDir, + sshCommand: project.sshCommand, + envGlobalPath: project.envGlobalPath, + envProjectPath: project.envProjectPath, + codexAuthPath: project.codexAuthPath, + codexHome: project.codexHome +}) + +const findProjectById = (projectId: string) => + listProjectItems.pipe( + Effect.flatMap((projects) => { + const project = projects.find((item) => item.projectDir === projectId) + return project + ? Effect.succeed(project) + : Effect.fail(new ApiNotFoundError({ message: `Project not found: ${projectId}` })) + }) + ) + +const resolveCreatedProject = ( + containerName: string, + repoUrl: string, + repoRef: string +) => + listProjectItems.pipe( + Effect.flatMap((items) => { + const exact = items.find((item) => + item.containerName === containerName && item.repoUrl === repoUrl && item.repoRef === repoRef) + if (exact) { + return Effect.succeed(exact) + } + const fallback = items.find((item) => item.containerName === containerName) + return fallback + ? Effect.succeed(fallback) + : Effect.fail( + new ApiInternalError({ message: "Project was created but could not be reloaded from index." }) + ) + }) + ) + +export const listProjects = () => + listProjectItems.pipe( + Effect.flatMap((projects) => Effect.forEach(projects, withProjectRuntime, { concurrency: "unbounded" })), + Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) + ) + +export const getProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + const summary = yield* _(withProjectRuntime(project)) + return toProjectDetails(project, summary) + }) + +// CHANGE: create a docker-git project exclusively through typed API input. +// WHY: issue #84 requires end-to-end project lifecycle without CLI interaction. +// QUOTE(ТЗ): "Мне надо иметь возможность управлять полностью проектом с помощью API" +// REF: issue-84-project-create +// SOURCE: n/a +// FORMAT THEOREM: forall req: valid(req) -> exists(project(req)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: openSsh is always disabled in API mode +// COMPLEXITY: O(n) where n = number of projects in index scan +export const createProjectFromRequest = ( + request: CreateProjectRequest +) => + Effect.gen(function*(_) { + const raw: RawOptions = { + ...(request.repoUrl === undefined ? {} : { repoUrl: request.repoUrl }), + ...(request.repoRef === undefined ? {} : { repoRef: request.repoRef }), + ...(request.targetDir === undefined ? {} : { targetDir: request.targetDir }), + ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), + ...(request.sshUser === undefined ? {} : { sshUser: request.sshUser }), + ...(request.containerName === undefined ? {} : { containerName: request.containerName }), + ...(request.serviceName === undefined ? {} : { serviceName: request.serviceName }), + ...(request.volumeName === undefined ? {} : { volumeName: request.volumeName }), + ...(request.secretsRoot === undefined ? {} : { secretsRoot: request.secretsRoot }), + ...(request.authorizedKeysPath === undefined ? {} : { authorizedKeysPath: request.authorizedKeysPath }), + ...(request.envGlobalPath === undefined ? {} : { envGlobalPath: request.envGlobalPath }), + ...(request.envProjectPath === undefined ? {} : { envProjectPath: request.envProjectPath }), + ...(request.codexAuthPath === undefined ? {} : { codexAuthPath: request.codexAuthPath }), + ...(request.codexHome === undefined ? {} : { codexHome: request.codexHome }), + ...(request.dockerNetworkMode === undefined ? {} : { dockerNetworkMode: request.dockerNetworkMode }), + ...(request.dockerSharedNetworkName === undefined ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), + ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), + ...(request.outDir === undefined ? {} : { outDir: request.outDir }), + ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), + ...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }), + ...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }), + ...(request.up === undefined ? {} : { up: request.up }), + ...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }), + ...(request.force === undefined ? {} : { force: request.force }), + ...(request.forceEnv === undefined ? {} : { forceEnv: request.forceEnv }) + } + + const parsed = buildCreateCommand(raw) + if (Either.isLeft(parsed)) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Invalid create payload.", + details: formatParseError(parsed.left) + }) + ) + ) + } + + const command = { + ...parsed.right, + openSsh: false + } + + yield* _( + Effect.sync(() => { + emitProjectEvent(command.outDir, "project.deployment.status", { + phase: "create", + message: "Project creation started" + }) + }) + ) + + yield* _(createProject(command)) + + const project = yield* _( + resolveCreatedProject( + command.config.containerName, + command.config.repoUrl, + command.config.repoRef + ) + ) + const summary = yield* _(withProjectRuntime(project)) + + yield* _( + Effect.sync(() => { + emitProjectEvent(project.projectDir, "project.created", { + projectId: project.projectDir, + containerName: project.containerName + }) + }) + ) + + return toProjectDetails(project, summary) + }) + +export const deleteProjectById = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(deleteDockerGitProject(project)) + yield* _( + Effect.sync(() => { + emitProjectEvent(projectId, "project.deleted", { projectId }) + }) + ) + }) + +const markDeployment = (projectId: string, phase: string, message: string) => + Effect.sync(() => { + emitProjectEvent(projectId, "project.deployment.status", { phase, message }) + }) + +export const upProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(markDeployment(projectId, "build", "docker compose up -d --build")) + yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(markDeployment(projectId, "running", "Container running")) + }) + +export const downProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(markDeployment(projectId, "down", "docker compose down")) + yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) + yield* _(markDeployment(projectId, "idle", "Container stopped")) + }) + +export const recreateProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + const config = yield* _(readProjectConfig(project.projectDir)) + + yield* _(markDeployment(projectId, "recreate", "Recreate started")) + + yield* _( + createProject({ + _tag: "Create", + config: config.template, + outDir: project.projectDir, + runUp: false, + openSsh: false, + force: true, + forceEnv: false, + waitForClone: false + }) + ) + + yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) + yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(markDeployment(projectId, "running", "Recreate completed")) + }) + +export const readProjectPs = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + return yield* _(runComposeCapture(projectId, project.projectDir, ["ps"], [0])) + }) + +export const readProjectLogs = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + return yield* _(runComposeCapture(projectId, project.projectDir, ["logs", "--tail", "200"], [0, 1])) + }) + +export const resolveProjectById = findProjectById diff --git a/packages/api/tests/events.test.ts b/packages/api/tests/events.test.ts new file mode 100644 index 00000000..de8eedd6 --- /dev/null +++ b/packages/api/tests/events.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { clearProjectEvents, emitProjectEvent, latestProjectCursor, listProjectEventsSince } from "../src/services/events.js" + +describe("events service", () => { + it.effect("keeps monotonic cursor per project", () => + Effect.sync(() => { + const projectId = "project-a" + clearProjectEvents(projectId) + + const first = emitProjectEvent(projectId, "project.deployment.status", { phase: "build" }) + const second = emitProjectEvent(projectId, "project.deployment.log", { line: "ok" }) + + expect(first.seq).toBe(1) + expect(second.seq).toBe(2) + expect(latestProjectCursor(projectId)).toBe(2) + + const next = listProjectEventsSince(projectId, 1) + expect(next).toHaveLength(1) + expect(next[0]?.seq).toBe(2) + })) +}) diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts new file mode 100644 index 00000000..4371b07c --- /dev/null +++ b/packages/api/tests/schema.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Either, ParseResult, Schema } from "effect" + +import { CreateAgentRequestSchema, CreateProjectRequestSchema } from "../src/api/schema.js" + +describe("api schemas", () => { + it.effect("decodes create project payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateProjectRequestSchema)({ + repoUrl: "https://github.com/ProverCoderAI/docker-git", + repoRef: "main", + up: true, + force: false + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.repoRef).toBe("main") + expect(value.up).toBe(true) + } + }) + })) + + it.effect("rejects invalid agent provider", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateAgentRequestSchema)({ + provider: "wrong", + command: "codex" + }) + + Either.match(result, { + onLeft: (error) => { + expect(ParseResult.TreeFormatter.formatIssueSync(error.issue)).toContain("Expected \"codex\"") + }, + onRight: () => { + throw new Error("Expected schema decode failure") + } + }) + })) +}) diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 00000000..1b337c15 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*", "tests/**/*", "vitest.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 00000000..60aea824 --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.{test,spec}.ts"], + exclude: ["node_modules", "dist"] + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a42e78c..c43ea0bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,52 @@ importers: specifier: ^2.29.8 version: 2.29.8(@types/node@24.10.9) + packages/api: + dependencies: + '@effect-template/lib': + specifier: workspace:* + version: link:../lib + '@effect/platform': + specifier: ^0.94.1 + version: 0.94.1(effect@3.19.14) + '@effect/platform-node': + specifier: ^0.104.0 + version: 0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/schema': + specifier: ^0.75.5 + version: 0.75.5(effect@3.19.14) + effect: + specifier: ^3.19.14 + version: 3.19.14 + devDependencies: + '@effect/vitest': + specifier: ^0.27.0 + version: 0.27.0(effect@3.19.14)(vitest@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + '@eslint/js': + specifier: 9.39.1 + version: 9.39.1 + '@types/node': + specifier: ^24.10.1 + version: 24.10.9 + '@typescript-eslint/eslint-plugin': + specifier: ^8.48.1 + version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.48.1 + version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.39.1 + version: 9.39.2(jiti@2.6.1) + globals: + specifier: ^16.5.0 + version: 16.5.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + packages/app: dependencies: '@effect/cli': @@ -542,24 +588,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.11': resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.11': resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.11': resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.11': resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} @@ -1040,89 +1090,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1273,24 +1339,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1349,36 +1419,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1447,56 +1523,67 @@ packages: resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.3': resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.3': resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.3': resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.3': resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.3': resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.3': resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.3': resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.3': resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.3': resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} @@ -1573,24 +1660,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1834,41 +1925,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3382,24 +3481,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} From 2267993328653dd67ba6f61666d5c0d5706dc050 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:05:52 +0000 Subject: [PATCH 2/8] feat(api): add built-in web console for v1 endpoints --- packages/api/README.md | 8 + packages/api/src/http.ts | 12 + packages/api/src/ui.ts | 931 ++++++++++++++++++++++++++++++++++ packages/api/tests/ui.test.ts | 14 + 4 files changed, 965 insertions(+) create mode 100644 packages/api/src/ui.ts create mode 100644 packages/api/tests/ui.test.ts diff --git a/packages/api/README.md b/packages/api/README.md index 2a28cdec..f0e813ee 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -2,6 +2,14 @@ Clean-slate v1 HTTP API for docker-git orchestration. +## UI wrapper + +После запуска API открой: + +- `http://localhost:3334/` + +Это встроенная фронт-обвязка для ручного тестирования endpoint-ов (проекты, агенты, логи, SSE). + ## Run ```bash diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 9a80815e..5edbc33b 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema" import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" import { CreateAgentRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" +import { uiHtml, uiScript, uiStyles } from "./ui.js" import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" import { @@ -47,6 +48,14 @@ type ApiError = const jsonResponse = (data: unknown, status: number) => Effect.map(HttpServerResponse.json(data), (response) => HttpServerResponse.setStatus(response, status)) +const textResponse = (data: string, contentType: string, status = 200) => + Effect.succeed( + HttpServerResponse.setStatus( + HttpServerResponse.text(data, { contentType }), + status + ) + ) + const parseQueryInt = (url: string, key: string, fallback: number): number => { const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "") if (!Number.isFinite(parsed) || parsed <= 0) { @@ -102,6 +111,9 @@ const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreatePr export const makeRouter = () => { const base = HttpRouter.empty.pipe( + HttpRouter.get("/", textResponse(uiHtml, "text/html; charset=utf-8", 200)), + HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), + HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), HttpRouter.get("/v1/health", jsonResponse({ ok: true }, 200)), HttpRouter.get( "/v1/projects", diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts new file mode 100644 index 00000000..ca3d57d9 --- /dev/null +++ b/packages/api/src/ui.ts @@ -0,0 +1,931 @@ +export const uiStyles = `@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); + +:root { + --bg: #f6f7f9; + --surface: rgba(255, 255, 255, 0.88); + --surface-strong: #ffffff; + --text: #12222f; + --muted: #516574; + --line: rgba(18, 34, 47, 0.14); + --accent: #0466c8; + --accent-2: #0096c7; + --danger: #b42318; + --ok: #117a65; + --shadow: 0 16px 42px rgba(7, 31, 51, 0.12); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 10% -10%, rgba(4, 102, 200, 0.18), transparent 45%), + radial-gradient(circle at 90% 0%, rgba(0, 150, 199, 0.16), transparent 40%), + linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%); +} + +body { + min-height: 100vh; +} + +.app-shell { + width: min(1260px, 100% - 2rem); + margin: 1rem auto 2rem; +} + +.hero { + padding: 1rem 0; +} + +.hero h1 { + margin: 0; + font-size: clamp(1.4rem, 2vw + 0.6rem, 2.2rem); + letter-spacing: 0.01em; +} + +.hero p { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.toolbar { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + align-items: center; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.8rem; +} + +.toolbar input { + flex: 1 1 260px; +} + +.grid { + display: grid; + grid-template-columns: minmax(300px, 0.95fr) minmax(340px, 1.2fr); + gap: 0.9rem; + margin-top: 0.9rem; +} + +.stack { + display: grid; + gap: 0.9rem; +} + +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + padding: 0.72rem 0.85rem; + border-bottom: 1px solid var(--line); + background: linear-gradient(135deg, rgba(4, 102, 200, 0.06), rgba(0, 150, 199, 0.04)); +} + +.panel-head h2, +.panel-head h3 { + margin: 0; + font-size: 1rem; +} + +.panel-body { + padding: 0.82rem; +} + +label { + font-size: 0.8rem; + color: var(--muted); + display: block; + margin-bottom: 0.2rem; +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-strong); + padding: 0.52rem 0.6rem; + color: var(--text); +} + +textarea, +pre, +code, +.output, +.events { + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +textarea { + min-height: 88px; + resize: vertical; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.55rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button { + border: 0; + border-radius: 999px; + padding: 0.45rem 0.86rem; + font-weight: 600; + cursor: pointer; + color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); +} + +button[data-variant="ghost"] { + color: var(--text); + border: 1px solid var(--line); + background: #fff; +} + +button[data-variant="danger"] { + background: linear-gradient(135deg, #b42318, #da3f34); +} + +button[data-variant="ok"] { + background: linear-gradient(135deg, #0c8a5c, #0d9d68); +} + +button:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.project-list { + display: grid; + gap: 0.45rem; + max-height: 350px; + overflow: auto; +} + +.project-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.55rem; + background: #fff; +} + +.project-card.active { + border-color: rgba(4, 102, 200, 0.5); + box-shadow: 0 0 0 2px rgba(4, 102, 200, 0.12); +} + +.project-card .top { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: center; +} + +.badge { + border-radius: 999px; + padding: 0.1rem 0.5rem; + font-size: 0.75rem; + border: 1px solid var(--line); + background: #fff; +} + +.badge.running { + color: var(--ok); + border-color: rgba(17, 122, 101, 0.36); + background: rgba(17, 122, 101, 0.1); +} + +.badge.stopped { + color: #9a4d04; + border-color: rgba(154, 77, 4, 0.3); + background: rgba(154, 77, 4, 0.11); +} + +.kv { + display: grid; + grid-template-columns: 170px 1fr; + gap: 0.3rem 0.6rem; + font-size: 0.85rem; +} + +.kv .k { + color: var(--muted); +} + +pre { + margin: 0; + white-space: pre-wrap; + border: 1px solid var(--line); + border-radius: 10px; + background: #f9fcff; + padding: 0.68rem; + max-height: 240px; + overflow: auto; +} + +.events, +.output { + border: 1px solid var(--line); + border-radius: 10px; + background: #0f1a23; + color: #d8e7f5; + padding: 0.68rem; + min-height: 130px; + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + font-size: 0.8rem; +} + +.agents { + display: grid; + gap: 0.6rem; +} + +.agent-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.56rem; + background: #fff; + display: grid; + gap: 0.45rem; +} + +.agent-item .top { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: center; +} + +.small { + font-size: 0.8rem; + color: var(--muted); +} + +.mono { + font-family: "IBM Plex Mono", ui-monospace, monospace; + word-break: break-word; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.checkbox-row input { + width: auto; +} + +@media (max-width: 980px) { + .grid { + grid-template-columns: 1fr; + } + + .row { + grid-template-columns: 1fr; + } + + .kv { + grid-template-columns: 1fr; + } +} +` + +export const uiHtml = ` + + + + + docker-git API Console + + + +
+
+

docker-git API Console

+

UI-обвязка для тестирования v1 API без CLI

+
+ +
+
+ + +
+ + +
+ +
+
+
+
+

Создать проект

+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+ +
+
+
+ +
+
+

Проекты

+ 0 +
+
+
+
+
+
+ +
+
+
+

Проект

+ not selected +
+
+
+ +
+ + + + + + +
+ + +

+
+              
+ + +
+ +
+
+
+ +
+
+

Агенты

+ +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ +
+
+

Debug output

+ +
+
+
+
+
+
+ + + + +` + +export const uiScript = ` +(() => { + const state = { + baseUrl: '', + projectId: '', + project: null, + projects: [], + agents: [], + eventSource: null, + eventCursor: 0 + }; + + const byId = (id) => document.getElementById(id); + + const views = { + baseUrl: byId('base-url'), + projectsCount: byId('projects-count'), + projectsList: byId('projects-list'), + activeProjectId: byId('active-project-id'), + projectDetails: byId('project-details'), + projectOutput: byId('project-output'), + eventsLog: byId('events-log'), + debugOutput: byId('debug-output'), + agentProvider: byId('agent-provider'), + agentLabel: byId('agent-label'), + agentCommand: byId('agent-command'), + agentCwd: byId('agent-cwd'), + agentEnv: byId('agent-env'), + agentsList: byId('agents-list'), + createRepoUrl: byId('create-repo-url'), + createRepoRef: byId('create-repo-ref'), + createSshPort: byId('create-ssh-port'), + createNetworkMode: byId('create-network-mode'), + createUp: byId('create-up'), + createForce: byId('create-force'), + createForceEnv: byId('create-force-env') + }; + + const appendDebug = (label, payload) => { + const stamp = new Date().toISOString(); + const line = '[' + stamp + '] ' + label + '\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); + views.debugOutput.textContent = (line + '\n\n' + views.debugOutput.textContent).slice(0, 24000); + }; + + const normalizeBase = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return window.location.origin; + } + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; + }; + + const projectPath = (projectId, suffix) => '/v1/projects/' + encodeURIComponent(projectId) + suffix; + + const request = async (path, init) => { + const base = normalizeBase(views.baseUrl.value); + state.baseUrl = base; + const url = base + path; + const response = await fetch(url, init || {}); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_error) { + json = { raw: text }; + } + + if (!response.ok) { + appendDebug('HTTP ' + response.status + ' ' + path, json); + throw new Error((json && json.error && json.error.message) || ('HTTP ' + response.status)); + } + + appendDebug('HTTP ' + response.status + ' ' + path, json || text); + return json; + }; + + const setProjectOutput = (value) => { + views.projectOutput.textContent = value || ''; + }; + + const renderProjectDetails = () => { + views.activeProjectId.textContent = state.projectId || 'not selected'; + if (!state.project) { + views.projectDetails.innerHTML = '
Выберите проект слева
'; + return; + } + + const details = [ + ['displayName', state.project.displayName], + ['repo', state.project.repoUrl + ' @ ' + state.project.repoRef], + ['status', state.project.status + ' (' + state.project.statusLabel + ')'], + ['container', state.project.containerName], + ['service', state.project.serviceName], + ['ssh', state.project.sshCommand], + ['targetDir', state.project.targetDir] + ]; + + views.projectDetails.innerHTML = details.map(([k, v]) => '
' + k + '
' + String(v) + '
').join(''); + }; + + const renderProjects = () => { + views.projectsCount.textContent = String(state.projects.length); + if (state.projects.length === 0) { + views.projectsList.innerHTML = '
Проекты не найдены
'; + return; + } + + views.projectsList.innerHTML = state.projects.map((item) => { + const activeClass = item.id === state.projectId ? ' active' : ''; + const badgeClass = item.status === 'running' ? 'running' : (item.status === 'stopped' ? 'stopped' : ''); + return [ + '
', + '
', + '' + item.displayName + '', + '' + item.status + '', + '
', + '
' + item.repoRef + '
', + '
', + '
' + ].join(''); + }).join(''); + + views.projectsList.querySelectorAll('button[data-project-id]').forEach((button) => { + button.addEventListener('click', () => { + selectProject(button.getAttribute('data-project-id') || ''); + }); + }); + }; + + const loadProjects = async () => { + const payload = await request('/v1/projects'); + state.projects = (payload && payload.projects) || []; + renderProjects(); + + if (!state.projectId && state.projects.length > 0) { + await selectProject(state.projects[0].id); + } + }; + + const loadProject = async () => { + if (!state.projectId) { + return; + } + const payload = await request(projectPath(state.projectId, '')); + state.project = payload.project; + renderProjectDetails(); + }; + + const selectProject = async (projectId) => { + if (!projectId) { + return; + } + state.projectId = projectId; + renderProjects(); + await loadProject(); + await loadAgents(); + }; + + const loadAgents = async () => { + if (!state.projectId) { + views.agentsList.innerHTML = '
Сначала выберите проект
'; + return; + } + + const payload = await request(projectPath(state.projectId, '/agents')); + state.agents = (payload && payload.sessions) || []; + renderAgents(); + }; + + const renderAgents = () => { + if (!state.projectId) { + views.agentsList.innerHTML = '
Сначала выберите проект
'; + return; + } + + if (state.agents.length === 0) { + views.agentsList.innerHTML = '
Агенты не запущены
'; + return; + } + + views.agentsList.innerHTML = state.agents.map((agent) => { + return [ + '
', + '
', + '' + agent.label + '', + '' + agent.status + '', + '
', + '
' + agent.id + '
', + '
' + agent.command + '
', + '
', + '', + '', + '', + '
', + '
' + ].join(''); + }).join(''); + + views.agentsList.querySelectorAll('button[data-action]').forEach((button) => { + button.addEventListener('click', async () => { + const action = button.getAttribute('data-action') || ''; + const agentId = button.getAttribute('data-agent-id') || ''; + if (!agentId || !state.projectId) { + return; + } + + if (action === 'stop') { + await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/stop'), { method: 'POST' }); + await loadAgents(); + return; + } + + if (action === 'logs') { + const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/logs?lines=250')); + const lines = (payload.entries || []).map((entry) => entry.at + ' [' + entry.stream + '] ' + entry.line); + setProjectOutput(lines.join('\n')); + return; + } + + if (action === 'attach') { + const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/attach')); + setProjectOutput(JSON.stringify(payload.attach, null, 2)); + } + }); + }); + }; + + const clearEvents = () => { + views.eventsLog.textContent = ''; + }; + + const appendEvent = (event, payload) => { + const line = event + ' ' + JSON.stringify(payload); + views.eventsLog.textContent = (line + '\n' + views.eventsLog.textContent).slice(0, 24000); + }; + + const stopEventStream = () => { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + appendEvent('system', { message: 'events stopped' }); + } + }; + + const startEventStream = () => { + if (!state.projectId) { + throw new Error('Выберите проект перед запуском SSE'); + } + + stopEventStream(); + const base = normalizeBase(views.baseUrl.value); + const url = base + projectPath(state.projectId, '/events?cursor=' + state.eventCursor); + const source = new EventSource(url); + state.eventSource = source; + + source.onmessage = (event) => { + if (!event.data) { + return; + } + try { + const payload = JSON.parse(event.data); + if (payload && payload.seq) { + state.eventCursor = payload.seq; + } + appendEvent(event.type || 'message', payload); + } catch (_error) { + appendEvent(event.type || 'message', event.data); + } + }; + + source.addEventListener('snapshot', (event) => { + try { + const payload = JSON.parse(event.data || '{}'); + state.eventCursor = payload.cursor || state.eventCursor; + appendEvent('snapshot', payload); + } catch (_error) { + appendEvent('snapshot', event.data || ''); + } + }); + + source.onerror = () => { + appendEvent('system', { message: 'events connection error' }); + }; + + appendEvent('system', { message: 'events started', url }); + }; + + const actionProject = async (suffix, method) => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + await request(projectPath(state.projectId, suffix), { method: method || 'POST' }); + await loadProject(); + await loadProjects(); + }; + + const runProjectRead = async (suffix) => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + const payload = await request(projectPath(state.projectId, suffix)); + setProjectOutput(payload.output || ''); + }; + + const parseEnvLines = (raw) => { + return String(raw || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line.includes('=')) + .map((line) => { + const idx = line.indexOf('='); + return { key: line.slice(0, idx).trim(), value: line.slice(idx + 1) }; + }) + .filter((entry) => entry.key.length > 0); + }; + + const createProject = async () => { + const body = { + repoUrl: views.createRepoUrl.value.trim() || undefined, + repoRef: views.createRepoRef.value.trim() || undefined, + sshPort: views.createSshPort.value.trim() || undefined, + dockerNetworkMode: views.createNetworkMode.value.trim() || undefined, + up: views.createUp.checked, + force: views.createForce.checked, + forceEnv: views.createForceEnv.checked, + openSsh: false + }; + + await request('/v1/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + + await loadProjects(); + }; + + const createAgent = async () => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + + const body = { + provider: views.agentProvider.value, + label: views.agentLabel.value.trim() || undefined, + command: views.agentCommand.value.trim() || undefined, + cwd: views.agentCwd.value.trim() || undefined, + env: parseEnvLines(views.agentEnv.value) + }; + + await request(projectPath(state.projectId, '/agents'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + + await loadAgents(); + }; + + const withUiError = (fn) => async () => { + try { + await fn(); + } catch (error) { + appendDebug('UI error', String(error)); + window.alert(String(error)); + } + }; + + const wireActions = () => { + byId('btn-clear-output').addEventListener('click', () => { + views.debugOutput.textContent = ''; + views.eventsLog.textContent = ''; + views.projectOutput.textContent = ''; + }); + + byId('btn-health').addEventListener('click', withUiError(async () => { + const payload = await request('/v1/health'); + window.alert('Health: ' + JSON.stringify(payload)); + })); + + byId('btn-projects-refresh').addEventListener('click', withUiError(loadProjects)); + byId('btn-create-project').addEventListener('click', withUiError(createProject)); + + byId('btn-up').addEventListener('click', withUiError(() => actionProject('/up', 'POST'))); + byId('btn-down').addEventListener('click', withUiError(() => actionProject('/down', 'POST'))); + byId('btn-recreate').addEventListener('click', withUiError(() => actionProject('/recreate', 'POST'))); + byId('btn-delete').addEventListener('click', withUiError(async () => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + const ok = window.confirm('Удалить проект ' + state.projectId + '?'); + if (!ok) { + return; + } + await request(projectPath(state.projectId, ''), { method: 'DELETE' }); + stopEventStream(); + state.projectId = ''; + state.project = null; + state.agents = []; + renderProjectDetails(); + renderAgents(); + await loadProjects(); + })); + + byId('btn-ps').addEventListener('click', withUiError(() => runProjectRead('/ps'))); + byId('btn-logs').addEventListener('click', withUiError(() => runProjectRead('/logs'))); + + byId('btn-events-start').addEventListener('click', withUiError(async () => { + clearEvents(); + startEventStream(); + })); + byId('btn-events-stop').addEventListener('click', () => stopEventStream()); + + byId('btn-agent-start').addEventListener('click', withUiError(createAgent)); + byId('btn-agents-refresh').addEventListener('click', withUiError(loadAgents)); + }; + + const bootstrap = async () => { + views.baseUrl.value = window.location.origin; + wireActions(); + renderProjectDetails(); + renderAgents(); + await loadProjects(); + }; + + window.addEventListener('beforeunload', () => stopEventStream()); + + bootstrap().catch((error) => { + appendDebug('bootstrap failure', String(error)); + }); +})(); +` diff --git a/packages/api/tests/ui.test.ts b/packages/api/tests/ui.test.ts new file mode 100644 index 00000000..e4861d89 --- /dev/null +++ b/packages/api/tests/ui.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { uiHtml, uiScript, uiStyles } from "../src/ui.js" + +describe("api ui wrapper", () => { + it.effect("contains basic shell and API hooks", () => + Effect.sync(() => { + expect(uiHtml).toContain("docker-git API Console") + expect(uiHtml).toContain("/ui/app.js") + expect(uiScript).toContain("/v1/projects") + expect(uiStyles).toContain(".panel") + })) +}) From 5d66a742b49744873c0868e96f04d7a9ce78984e Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:30:20 +0000 Subject: [PATCH 3/8] fix(api-ui): escape runtime newlines in generated script --- packages/api/src/ui.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts index ca3d57d9..b0de8488 100644 --- a/packages/api/src/ui.ts +++ b/packages/api/src/ui.ts @@ -537,8 +537,8 @@ export const uiScript = ` const appendDebug = (label, payload) => { const stamp = new Date().toISOString(); - const line = '[' + stamp + '] ' + label + '\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); - views.debugOutput.textContent = (line + '\n\n' + views.debugOutput.textContent).slice(0, 24000); + const line = '[' + stamp + '] ' + label + '\\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); + views.debugOutput.textContent = (line + '\\n\\n' + views.debugOutput.textContent).slice(0, 24000); }; const normalizeBase = (value) => { @@ -712,7 +712,7 @@ export const uiScript = ` if (action === 'logs') { const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/logs?lines=250')); const lines = (payload.entries || []).map((entry) => entry.at + ' [' + entry.stream + '] ' + entry.line); - setProjectOutput(lines.join('\n')); + setProjectOutput(lines.join('\\n')); return; } @@ -730,7 +730,7 @@ export const uiScript = ` const appendEvent = (event, payload) => { const line = event + ' ' + JSON.stringify(payload); - views.eventsLog.textContent = (line + '\n' + views.eventsLog.textContent).slice(0, 24000); + views.eventsLog.textContent = (line + '\\n' + views.eventsLog.textContent).slice(0, 24000); }; const stopEventStream = () => { @@ -803,7 +803,7 @@ export const uiScript = ` const parseEnvLines = (raw) => { return String(raw || '') - .split(/\r?\n/) + .split(/\\r?\\n/) .map((line) => line.trim()) .filter((line) => line.length > 0 && line.includes('=')) .map((line) => { From 3e4e956ae7e6c47a931779a47786f4fd3d23ce7b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:50:55 +0000 Subject: [PATCH 4/8] fix(lib): install oh-my-opencode platform binary in Dockerfile --- packages/lib/src/core/templates/dockerfile.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 5ad32b3a..a81c3aea 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -31,7 +31,7 @@ RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /u > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh` const renderDockerfileBunPrelude = (config: TemplateConfig): string => - `# Tooling: pnpm + Codex CLI + oh-my-opencode (bun) + Claude Code CLI (npm) + `# Tooling: pnpm + Codex CLI (bun) + oh-my-opencode (npm + platform binary) + Claude Code CLI (npm) RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate ENV TERM=xterm-256color RUN set -eu; \ @@ -42,15 +42,23 @@ RUN set -eu; \ exit 0; \ fi; \ echo "bun install attempt \${attempt} failed; retrying..." >&2; \ - rm -f /tmp/bun-install.sh; \ + rm -f /tmp/bun-install.sh; \ sleep $((attempt * 2)); \ done; \ echo "bun install failed after retries" >&2; \ exit 1 RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun -RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest oh-my-opencode@latest" /dev/null +RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -RUN ln -sf /usr/local/bun/bin/oh-my-opencode /usr/local/bin/oh-my-opencode +RUN set -eu; \ + ARCH="$(uname -m)"; \ + case "$ARCH" in \ + x86_64|amd64) OH_MY_OPENCODE_ARCH="x64" ;; \ + aarch64|arm64) OH_MY_OPENCODE_ARCH="arm64" ;; \ + *) echo "Unsupported arch for oh-my-opencode: $ARCH" >&2; exit 1 ;; \ + esac; \ + npm install -g oh-my-opencode@latest "oh-my-opencode-linux-\${OH_MY_OPENCODE_ARCH}@latest" +RUN oh-my-opencode --version RUN npm install -g @anthropic-ai/claude-code@latest RUN claude --version` From 984ce9cd53f6da3eb2e9ca3d14890a33c422971d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:37:54 +0000 Subject: [PATCH 5/8] feat(api): add forgefed issue inbox and activitypub follow support --- packages/api/README.md | 12 + packages/api/src/api/contracts.ts | 80 +++++ packages/api/src/api/schema.ts | 9 + packages/api/src/http.ts | 40 ++- packages/api/src/services/federation.ts | 400 ++++++++++++++++++++++++ packages/api/tests/federation.test.ts | 98 ++++++ packages/api/tests/schema.test.ts | 22 +- 7 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/services/federation.ts create mode 100644 packages/api/tests/federation.test.ts diff --git a/packages/api/README.md b/packages/api/README.md index f0e813ee..c29639e4 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -26,6 +26,10 @@ Env: ## Endpoints (v1) - `GET /v1/health` +- `POST /v1/federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) +- `GET /v1/federation/issues` +- `POST /v1/federation/follows` (create ActivityPub `Follow` activity for task-feed subscription) +- `GET /v1/federation/follows` - `GET /v1/projects` - `GET /v1/projects/:projectId` - `POST /v1/projects` @@ -49,4 +53,12 @@ Env: curl -s http://localhost:3334/v1/projects curl -s -X POST http://localhost:3334/v1/projects//up curl -s -N http://localhost:3334/v1/projects//events + +curl -s -X POST http://localhost:3334/v1/federation/follows \ + -H 'content-type: application/json' \ + -d '{"actor":"https://dev.example/users/bot","object":"https://tracker.example/issues/followers"}' + +curl -s -X POST http://localhost:3334/v1/federation/inbox \ + -H 'content-type: application/json' \ + -d '{"type":"Offer","target":"https://tracker.example/issues","object":{"type":"Ticket","id":"https://tracker.example/issues/42","attributedTo":"https://origin.example/users/alice","summary":"Title","content":"Body"}}' ``` diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 1b571472..3e1f1a6c 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -102,6 +102,86 @@ export type AgentAttachInfo = { readonly shellCommand: string } +export type ForgeFedTicket = { + readonly id: string + readonly attributedTo: string + readonly summary: string + readonly content: string + readonly mediaType?: string | undefined + readonly source?: string | undefined + readonly published?: string | undefined + readonly updated?: string | undefined + readonly url?: string | undefined +} + +export type FederationIssueStatus = "offered" | "accepted" | "rejected" + +export type FederationIssueRecord = { + readonly issueId: string + readonly offerId?: string | undefined + readonly tracker?: string | undefined + readonly status: FederationIssueStatus + readonly receivedAt: string + readonly ticket: ForgeFedTicket +} + +export type CreateFollowRequest = { + readonly actor: string + readonly object: string + readonly inbox?: string | undefined + readonly to?: ReadonlyArray | undefined + readonly capability?: string | undefined +} + +export type FollowStatus = "pending" | "accepted" | "rejected" + +export type ActivityPubFollowActivity = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly id: string + readonly type: "Follow" + readonly actor: string + readonly object: string + readonly to?: ReadonlyArray | undefined + readonly capability?: string | undefined +} + +export type FollowSubscription = { + readonly id: string + readonly activityId: string + readonly actor: string + readonly object: string + readonly inbox?: string | undefined + readonly to: ReadonlyArray + readonly capability?: string | undefined + readonly status: FollowStatus + readonly createdAt: string + readonly updatedAt: string + readonly activity: ActivityPubFollowActivity +} + +export type FollowSubscriptionCreated = { + readonly subscription: FollowSubscription + readonly activity: ActivityPubFollowActivity +} + +export type FederationInboxResult = + | { + readonly kind: "issue.offer" + readonly issue: FederationIssueRecord + } + | { + readonly kind: "issue.ticket" + readonly issue: FederationIssueRecord + } + | { + readonly kind: "follow.accept" + readonly subscription: FollowSubscription + } + | { + readonly kind: "follow.reject" + readonly subscription: FollowSubscription + } + export type ApiEventType = | "snapshot" | "project.created" diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index a8838099..fc0a279b 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -47,6 +47,14 @@ export const CreateAgentRequestSchema = Schema.Struct({ label: OptionalString }) +export const CreateFollowRequestSchema = Schema.Struct({ + actor: Schema.String, + object: Schema.String, + inbox: OptionalString, + to: Schema.optional(Schema.Array(Schema.String)), + capability: OptionalString +}) + export const AgentSessionSchema = Schema.Struct({ id: Schema.String, projectId: Schema.String, @@ -73,3 +81,4 @@ export const AgentLogLineSchema = Schema.Struct({ export type CreateProjectRequestInput = Schema.Schema.Type export type CreateAgentRequestInput = Schema.Schema.Type +export type CreateFollowRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 5edbc33b..a2dde24e 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -10,10 +10,16 @@ import * as ParseResult from "effect/ParseResult" import * as Schema from "effect/Schema" import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" -import { CreateAgentRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" +import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" import { uiHtml, uiScript, uiStyles } from "./ui.js" import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" +import { + createFollowSubscription, + ingestFederationInbox, + listFederationIssues, + listFollowSubscriptions +} from "./services/federation.js" import { createProjectFromRequest, deleteProjectById, @@ -108,6 +114,8 @@ const projectParams = HttpRouter.schemaParams(ProjectParamsSchema) const agentParams = HttpRouter.schemaParams(AgentParamsSchema) const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema) +const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema) +const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown) export const makeRouter = () => { const base = HttpRouter.empty.pipe( @@ -115,6 +123,36 @@ export const makeRouter = () => { HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), HttpRouter.get("/v1/health", jsonResponse({ ok: true }, 200)), + HttpRouter.get( + "/v1/federation/issues", + Effect.sync(() => ({ issues: listFederationIssues() })).pipe( + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/federation/follows", + Effect.gen(function*(_) { + const request = yield* _(readCreateFollowRequest()) + const created = yield* _(createFollowSubscription(request)) + return yield* _(jsonResponse(created, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/federation/follows", + Effect.sync(() => ({ follows: listFollowSubscriptions() })).pipe( + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/v1/federation/inbox", + Effect.gen(function*(_) { + const payload = yield* _(readInboxPayload()) + const result = yield* _(ingestFederationInbox(payload)) + return yield* _(jsonResponse({ result }, 202)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/v1/projects", listProjects().pipe( diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts new file mode 100644 index 00000000..56d182ff --- /dev/null +++ b/packages/api/src/services/federation.ts @@ -0,0 +1,400 @@ +import { Effect } from "effect" +import { randomUUID } from "node:crypto" + +import type { + ActivityPubFollowActivity, + CreateFollowRequest, + FederationInboxResult, + FederationIssueRecord, + FollowStatus, + FollowSubscription, + FollowSubscriptionCreated, + ForgeFedTicket +} from "../api/contracts.js" +import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" + +type JsonRecord = { readonly [key: string]: unknown } + +const issueStore: Map = new Map() +const followStore: Map = new Map() +const followByActivityId: Map = new Map() +const followByActorObject: Map = new Map() + +const nowIso = (): string => new Date().toISOString() + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asRecord = (value: unknown): JsonRecord | null => + isRecord(value) ? value : null + +const asNonEmptyString = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null + +const readOptionalString = (record: JsonRecord, key: string): string | undefined => + asNonEmptyString(record[key]) ?? undefined + +const readRequiredString = ( + record: JsonRecord, + key: string, + label: string +): Effect.Effect => { + const value = asNonEmptyString(record[key]) + return value !== null + ? Effect.succeed(value) + : Effect.fail( + new ApiBadRequestError({ + message: `${label} must include a non-empty "${key}" field.` + }) + ) +} + +const readTypeTags = (record: JsonRecord): ReadonlyArray => { + const raw = record["type"] + if (typeof raw === "string") { + const value = raw.trim() + return value.length > 0 ? [value] : [] + } + if (Array.isArray(raw)) { + return raw + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + } + return [] +} + +const hasType = (record: JsonRecord, expected: string): boolean => + readTypeTags(record).includes(expected) + +const readObjectRecord = ( + payload: JsonRecord, + key: string, + label: string +): Effect.Effect => { + const objectRecord = asRecord(payload[key]) + return objectRecord !== null + ? Effect.succeed(objectRecord) + : Effect.fail( + new ApiBadRequestError({ + message: `${label} must include an object "${key}" payload.` + }) + ) +} + +const parseTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + if (!hasType(payload, "Ticket")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "ForgeFed ticket payload must include type=\"Ticket\"." + }) + ) + ) + } + + const attributedTo = yield* _(readRequiredString(payload, "attributedTo", "ForgeFed ticket")) + const summary = yield* _(readRequiredString(payload, "summary", "ForgeFed ticket")) + const content = yield* _(readRequiredString(payload, "content", "ForgeFed ticket")) + const id = readOptionalString(payload, "id") ?? `urn:docker-git:forgefed:ticket:${randomUUID()}` + + return { + id, + attributedTo, + summary, + content, + mediaType: readOptionalString(payload, "mediaType"), + source: readOptionalString(payload, "source"), + published: readOptionalString(payload, "published"), + updated: readOptionalString(payload, "updated"), + url: readOptionalString(payload, "url") + } + }) + +const upsertIssue = (issue: FederationIssueRecord): FederationIssueRecord => { + issueStore.set(issue.issueId, issue) + return issue +} + +const followKey = (actor: string, object: string): string => `${actor}\u0000${object}` + +const cleanToRecipients = ( + raw: ReadonlyArray | undefined +): ReadonlyArray => + (raw ?? []) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + +const lookupFollowByReference = ( + reference: string +): Effect.Effect => { + const byActivity = followByActivityId.get(reference) + if (byActivity) { + const stored = followStore.get(byActivity) + if (stored) { + return Effect.succeed(stored) + } + } + + const direct = followStore.get(reference) + if (direct) { + return Effect.succeed(direct) + } + + return Effect.fail( + new ApiNotFoundError({ + message: `Follow subscription not found for reference: ${reference}` + }) + ) +} + +const updateFollowStatus = ( + subscription: FollowSubscription, + status: FollowStatus +): FollowSubscription => { + const updated: FollowSubscription = { + ...subscription, + status, + updatedAt: nowIso() + } + followStore.set(updated.id, updated) + followByActivityId.set(updated.activityId, updated.id) + followByActorObject.set(followKey(updated.actor, updated.object), updated.id) + return updated +} + +const resolveFollowFromInbox = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + const objectValue = payload["object"] + + if (typeof objectValue === "string" && objectValue.trim().length > 0) { + return yield* _(lookupFollowByReference(objectValue.trim())) + } + + const objectRecord = asRecord(objectValue) + if (objectRecord === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Accept/Reject payload must include object reference as string or Follow object." + }) + ) + ) + } + + const explicitId = readOptionalString(objectRecord, "id") + if (explicitId !== undefined) { + return yield* _(lookupFollowByReference(explicitId)) + } + + if (!hasType(objectRecord, "Follow")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Accept/Reject payload object must include type=\"Follow\" when no id is provided." + }) + ) + ) + } + + const actor = yield* _(readRequiredString(objectRecord, "actor", "Follow object reference")) + const object = yield* _(readRequiredString(objectRecord, "object", "Follow object reference")) + const indexed = followByActorObject.get(followKey(actor, object)) + if (!indexed) { + return yield* _( + Effect.fail( + new ApiNotFoundError({ + message: `Follow subscription not found for actor=${actor} object=${object}` + }) + ) + ) + } + return yield* _(lookupFollowByReference(indexed)) + }) + +const ingestOfferTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + const objectPayload = yield* _(readObjectRecord(payload, "object", "ForgeFed offer")) + if (!hasType(objectPayload, "Ticket")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "ForgeFed offer currently supports object.type=\"Ticket\" only." + }) + ) + ) + } + + const ticket = yield* _(parseTicket(objectPayload)) + const issueId = ticket.id + const issue = upsertIssue({ + issueId, + offerId: readOptionalString(payload, "id"), + tracker: readOptionalString(payload, "target"), + status: "offered", + receivedAt: nowIso(), + ticket + }) + return issue + }) + +const ingestDirectTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.map(parseTicket(payload), (ticket) => + upsertIssue({ + issueId: ticket.id, + status: "accepted", + receivedAt: nowIso(), + ticket + })) + +// CHANGE: support ForgeFed issue inputs and ActivityPub inbox transitions in API mode. +// WHY: Konrad requested ForgeFed Issue intake and Follow workflow support in PR discussion. +// QUOTE(ТЗ): "А сможешь на вход поддержать ... #issues" + "добавить поддержку follow" +// REF: pr-88-konrad-request +// SOURCE: n/a +// FORMAT THEOREM: ∀m: validInbox(m) → handled(m) ∈ {issue.offer, issue.ticket, follow.accept, follow.reject} +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: state transitions are deterministic for identical references +// COMPLEXITY: O(1) +export const ingestFederationInbox = ( + payload: unknown +): Effect.Effect => + Effect.gen(function*(_) { + const record = asRecord(payload) + if (record === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Inbox payload must be a JSON object." + }) + ) + ) + } + + if (hasType(record, "Offer")) { + const issue = yield* _(ingestOfferTicket(record)) + return { kind: "issue.offer", issue } + } + + if (hasType(record, "Ticket")) { + const issue = yield* _(ingestDirectTicket(record)) + return { kind: "issue.ticket", issue } + } + + if (hasType(record, "Accept") || hasType(record, "Reject")) { + const subscription = yield* _(resolveFollowFromInbox(record)) + const status: FollowStatus = hasType(record, "Accept") ? "accepted" : "rejected" + const updated = updateFollowStatus(subscription, status) + return status === "accepted" + ? { kind: "follow.accept", subscription: updated } + : { kind: "follow.reject", subscription: updated } + } + + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Unsupported inbox payload type. Expected Offer(Ticket), Ticket, Accept, or Reject." + }) + ) + ) + }) + +// CHANGE: build outgoing ActivityPub Follow subscriptions for task feeds. +// WHY: requested to subscribe to issue/task distribution via ActivityPub Follow. +// QUOTE(ТЗ): "добавить поддержку follow, чтобы можно было подписатся на отдачу задач" +// REF: pr-88-konrad-request +// SOURCE: n/a +// FORMAT THEOREM: ∀r: valid(r) → ∃s: s.status = pending ∧ s.actor = r.actor ∧ s.object = r.object +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: non-rejected actor/object pairs are unique +// COMPLEXITY: O(1) +export const createFollowSubscription = ( + request: CreateFollowRequest +): Effect.Effect => + Effect.gen(function*(_) { + const actor = request.actor.trim() + if (actor.length === 0) { + return yield* _(Effect.fail(new ApiBadRequestError({ message: "Follow actor must be non-empty." }))) + } + + const object = request.object.trim() + if (object.length === 0) { + return yield* _(Effect.fail(new ApiBadRequestError({ message: "Follow object must be non-empty." }))) + } + + const key = followKey(actor, object) + const existingId = followByActorObject.get(key) + if (existingId) { + const existing = followStore.get(existingId) + if (existing && existing.status !== "rejected") { + return yield* _( + Effect.fail( + new ApiConflictError({ + message: `Follow subscription already exists for actor=${actor} object=${object}.` + }) + ) + ) + } + } + + const to = cleanToRecipients(request.to) + const capability = request.capability?.trim() + const inbox = request.inbox?.trim() + const id = randomUUID() + const activityId = `urn:docker-git:activity:follow:${id}` + const createdAt = nowIso() + + const activity: ActivityPubFollowActivity = { + "@context": "https://www.w3.org/ns/activitystreams", + id: activityId, + type: "Follow", + actor, + object, + ...(to.length === 0 ? {} : { to }), + ...(capability && capability.length > 0 ? { capability } : {}) + } + + const subscription: FollowSubscription = { + id, + activityId, + actor, + object, + inbox: inbox && inbox.length > 0 ? inbox : undefined, + to, + capability: capability && capability.length > 0 ? capability : undefined, + status: "pending", + createdAt, + updatedAt: createdAt, + activity + } + + followStore.set(id, subscription) + followByActivityId.set(activityId, id) + followByActorObject.set(key, id) + + return { subscription, activity } + }) + +export const listFederationIssues = (): ReadonlyArray => + [...issueStore.values()].sort((left, right) => right.receivedAt.localeCompare(left.receivedAt)) + +export const listFollowSubscriptions = (): ReadonlyArray => + [...followStore.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + +export const clearFederationState = (): void => { + issueStore.clear() + followStore.clear() + followByActivityId.clear() + followByActorObject.clear() +} diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts new file mode 100644 index 00000000..4675edaa --- /dev/null +++ b/packages/api/tests/federation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + clearFederationState, + createFollowSubscription, + ingestFederationInbox, + listFederationIssues, + listFollowSubscriptions +} from "../src/services/federation.js" + +describe("federation service", () => { + it.effect("ingests ForgeFed Offer with Ticket payload", () => + Effect.gen(function*(_) { + clearFederationState() + + const result = yield* _( + ingestFederationInbox({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://forgefed.org/ns" + ], + id: "https://tracker.example/offers/42", + type: "Offer", + target: "https://tracker.example/issues", + object: { + type: "Ticket", + id: "https://tracker.example/issues/42", + attributedTo: "https://origin.example/users/alice", + summary: "Need reproducible CI parity", + content: "Implement API behavior matching CLI." + } + }) + ) + + expect(result.kind).toBe("issue.offer") + if (result.kind === "issue.offer") { + expect(result.issue.issueId).toBe("https://tracker.example/issues/42") + expect(result.issue.status).toBe("offered") + } + + const issues = listFederationIssues() + expect(issues).toHaveLength(1) + expect(issues[0]?.tracker).toBe("https://tracker.example/issues") + })) + + it.effect("creates follow subscription and resolves it via Accept activity", () => + Effect.gen(function*(_) { + clearFederationState() + + const created = yield* _( + createFollowSubscription({ + actor: "https://dev.example/users/bot", + object: "https://tracker.example/issues/followers", + capability: "https://tracker.example/caps/follow", + to: ["https://www.w3.org/ns/activitystreams#Public"] + }) + ) + + expect(created.subscription.status).toBe("pending") + expect(created.activity.type).toBe("Follow") + + const accepted = yield* _( + ingestFederationInbox({ + type: "Accept", + actor: "https://tracker.example/system", + object: created.activity.id + }) + ) + + expect(accepted.kind).toBe("follow.accept") + if (accepted.kind === "follow.accept") { + expect(accepted.subscription.status).toBe("accepted") + } + + const follows = listFollowSubscriptions() + expect(follows).toHaveLength(1) + expect(follows[0]?.status).toBe("accepted") + })) + + it.effect("rejects duplicate pending follow subscription", () => + Effect.gen(function*(_) { + clearFederationState() + + const request = { + actor: "https://dev.example/users/bot", + object: "https://tracker.example/issues/followers" + } as const + + yield* _(createFollowSubscription(request)) + + const duplicateError = yield* _( + createFollowSubscription(request).pipe(Effect.flip) + ) + + expect(duplicateError._tag).toBe("ApiConflictError") + })) +}) diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index 4371b07c..2eb260e1 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Either, ParseResult, Schema } from "effect" -import { CreateAgentRequestSchema, CreateProjectRequestSchema } from "../src/api/schema.js" +import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "../src/api/schema.js" describe("api schemas", () => { it.effect("decodes create project payload", () => @@ -40,4 +40,24 @@ describe("api schemas", () => { } }) })) + + it.effect("decodes follow payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateFollowRequestSchema)({ + actor: "https://example.com/users/alice", + object: "https://example.net/tracker/inbox", + to: ["https://www.w3.org/ns/activitystreams#Public"] + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.actor).toBe("https://example.com/users/alice") + expect(value.object).toBe("https://example.net/tracker/inbox") + expect(value.to).toHaveLength(1) + } + }) + })) }) From 81e36e1cae2b6aef8afd274b98e3297d066d6c58 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:51:16 +0000 Subject: [PATCH 6/8] fix(lint): resolve CI lint failures after main merge --- packages/app/src/docker-git/menu-actions.ts | 2 +- packages/app/src/docker-git/menu-shared.ts | 12 +-- .../lib/src/shell/docker-published-ports.ts | 80 +++++++++++++++++++ packages/lib/src/shell/docker.ts | 74 +---------------- .../lib/src/usecases/docker-network-gc.ts | 45 ++++++----- packages/lib/src/usecases/terminal-cursor.ts | 3 +- 6 files changed, 113 insertions(+), 103 deletions(-) create mode 100644 packages/lib/src/shell/docker-published-ports.ts diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index 73e2192d..87ce1c9a 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -1,6 +1,7 @@ import { type MenuAction, type ProjectConfig } from "@effect-template/lib/core/domain" import { readProjectConfig } from "@effect-template/lib/shell/config" import { runDockerComposeDown, runDockerComposeLogs, runDockerComposePs } from "@effect-template/lib/shell/docker" +import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc" import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" import { @@ -9,7 +10,6 @@ import { listProjectStatus, listRunningProjectItems } from "@effect-template/lib/usecases/projects" -import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc" import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up" import { Effect, Match, pipe } from "effect" diff --git a/packages/app/src/docker-git/menu-shared.ts b/packages/app/src/docker-git/menu-shared.ts index 66e9faa6..06ae5187 100644 --- a/packages/app/src/docker-git/menu-shared.ts +++ b/packages/app/src/docker-git/menu-shared.ts @@ -45,12 +45,12 @@ const disableTerminalInputModes = (): void => { // Disable mouse/input modes that can leak across TUI <-> SSH transitions. process.stdout.write( "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[" + + "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + + "\u001B[?1004l\u001B[?2004l" + + "\u001B[>4;0m\u001B[>4m\u001B[/g + +const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => { + const parsed: Array = [] + for (const match of line.matchAll(publishedHostPortPattern)) { + const rawPort = match[1] + if (rawPort === undefined) { + continue + } + const value = Number.parseInt(rawPort, 10) + if (Number.isInteger(value) && value > 0 && value <= 65_535) { + parsed.push(value) + } + } + return parsed +} + +// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output +// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible +// QUOTE(ТЗ): "должен просто новый порт брать под себя" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in parse(output): published_by_docker(p) +// PURITY: CORE +// EFFECT: Effect, never, never> +// INVARIANT: returns unique ports in encounter order +// COMPLEXITY: O(|output|) +export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray => { + const unique = new Set() + const parsed: Array = [] + + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (trimmed.length === 0) { + continue + } + for (const port of parsePublishedHostPortsFromLine(trimmed)) { + if (!unique.has(port)) { + unique.add(port) + parsed.push(port) + } + } + } + + return parsed +} + +// CHANGE: read currently published Docker host ports from running containers +// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets +// QUOTE(ТЗ): "а не сражаться за старый" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in result: published_by_running_container(p) +// PURITY: SHELL +// EFFECT: Effect, CommandFailedError | PlatformError, CommandExecutor> +// INVARIANT: output ports are unique +// COMPLEXITY: O(command + |stdout|) +export const runDockerPsPublishedHostPorts = ( + cwd: string +): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => + pipe( + runCommandCapture( + { + cwd, + command: "docker", + args: ["ps", "--format", "{{.Ports}}"] + }, + [Number(ExitCode(0))], + (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) + ), + Effect.map((output) => parseDockerPublishedHostPorts(output)) + ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 81bfea4b..cb4898fe 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -8,6 +8,7 @@ import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from " import { CommandFailedError, DockerCommandError } from "./errors.js" export { classifyDockerAccessIssue, ensureDockerDaemonAccess } from "./docker-daemon-access.js" +export { parseDockerPublishedHostPorts, runDockerPsPublishedHostPorts } from "./docker-published-ports.js" const composeSpec = (cwd: string, args: ReadonlyArray) => ({ cwd, @@ -489,76 +490,3 @@ export const runDockerPsNames = ( .filter((line) => line.length > 0) ) ) - -const publishedHostPortPattern = /:(\d+)->/g - -const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => { - const parsed: Array = [] - for (const match of line.matchAll(publishedHostPortPattern)) { - const rawPort = match[1] - if (rawPort === undefined) { - continue - } - const value = Number.parseInt(rawPort, 10) - if (Number.isInteger(value) && value > 0 && value <= 65_535) { - parsed.push(value) - } - } - return parsed -} - -// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output -// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible -// QUOTE(ТЗ): "должен просто новый порт брать под себя" -// REF: user-request-2026-02-19-port-allocation -// SOURCE: n/a -// FORMAT THEOREM: forall p in parse(output): published_by_docker(p) -// PURITY: CORE -// EFFECT: Effect, never, never> -// INVARIANT: returns unique ports in encounter order -// COMPLEXITY: O(|output|) -export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray => { - const unique = new Set() - const parsed: Array = [] - - for (const line of output.split(/\r?\n/)) { - const trimmed = line.trim() - if (trimmed.length === 0) { - continue - } - for (const port of parsePublishedHostPortsFromLine(trimmed)) { - if (!unique.has(port)) { - unique.add(port) - parsed.push(port) - } - } - } - - return parsed -} - -// CHANGE: read currently published Docker host ports from running containers -// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets -// QUOTE(ТЗ): "а не сражаться за старый" -// REF: user-request-2026-02-19-port-allocation -// SOURCE: n/a -// FORMAT THEOREM: forall p in result: published_by_running_container(p) -// PURITY: SHELL -// EFFECT: Effect, CommandFailedError | PlatformError, CommandExecutor> -// INVARIANT: output ports are unique -// COMPLEXITY: O(command + |stdout|) -export const runDockerPsPublishedHostPorts = ( - cwd: string -): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => - pipe( - runCommandCapture( - { - cwd, - command: "docker", - args: ["ps", "--format", "{{.Ports}}"] - }, - [Number(ExitCode(0))], - (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) - ), - Effect.map((output) => parseDockerPublishedHostPorts(output)) - ) diff --git a/packages/lib/src/usecases/docker-network-gc.ts b/packages/lib/src/usecases/docker-network-gc.ts index e9f850ed..a7295dc0 100644 --- a/packages/lib/src/usecases/docker-network-gc.ts +++ b/packages/lib/src/usecases/docker-network-gc.ts @@ -2,11 +2,7 @@ import type { CommandExecutor } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Effect } from "effect" -import { - defaultTemplateConfig, - resolveComposeNetworkName, - type TemplateConfig -} from "../core/domain.js" +import { defaultTemplateConfig, resolveComposeNetworkName, type TemplateConfig } from "../core/domain.js" import { runDockerNetworkContainerCount, runDockerNetworkCreateBridge, @@ -21,19 +17,27 @@ const protectedNetworkNames = new Set(["bridge", "host", "none"]) const isProtectedNetwork = (networkName: string, sharedNetworkName: string): boolean => protectedNetworkNames.has(networkName) || networkName === sharedNetworkName -const sharedNetworkFallbackSubnets: ReadonlyArray = [ - "10.250.0.0/24", - "10.251.0.0/24", - "10.252.0.0/24", - "10.253.0.0/24", - "172.31.250.0/24", - "172.31.251.0/24", - "172.31.252.0/24", - "172.31.253.0/24", - "192.168.250.0/24", - "192.168.251.0/24" +type Subnet24Seed = readonly [number, number, number] + +const sharedNetworkFallbackSubnetSeeds: ReadonlyArray = [ + [10, 250, 0], + [10, 251, 0], + [10, 252, 0], + [10, 253, 0], + [172, 31, 250], + [172, 31, 251], + [172, 31, 252], + [172, 31, 253], + [192, 168, 250], + [192, 168, 251] ] +const formatSubnet24 = ([a, b, c]: Subnet24Seed): string => `${[a, b, c, 0].join(".")}/24` + +const sharedNetworkFallbackSubnets: ReadonlyArray = sharedNetworkFallbackSubnetSeeds.map((seed) => + formatSubnet24(seed) +) + const createSharedNetworkWithSubnetFallback = ( cwd: string, networkName: string @@ -46,8 +50,7 @@ const createSharedNetworkWithSubnetFallback = ( Effect.catchTag("DockerCommandError", (error) => Effect.logWarning( `Shared network create fallback failed (${networkName}, subnet ${subnet}, exit ${error.exitCode}); trying next subnet.` - ).pipe(Effect.as(false)) - ) + ).pipe(Effect.as(false))) ) ) if (created) { @@ -94,7 +97,8 @@ export const ensureComposeNetworkReady = ( ? Effect.void : Effect.log(`Creating shared Docker network: ${networkName}`).pipe( Effect.zipRight(ensureSharedNetworkExists(cwd, networkName)) - )) + ) + ) ) } @@ -169,5 +173,4 @@ export const gcProjectNetworkByServiceName = ( cwd: string, serviceName: string, sharedNetworkName: string = defaultTemplateConfig.dockerSharedNetworkName -): Effect.Effect => - gcNetworkByName(cwd, `${serviceName}-net`, sharedNetworkName) +): Effect.Effect => gcNetworkByName(cwd, `${serviceName}-net`, sharedNetworkName) diff --git a/packages/lib/src/usecases/terminal-cursor.ts b/packages/lib/src/usecases/terminal-cursor.ts index 9f5f6e16..a38f23e6 100644 --- a/packages/lib/src/usecases/terminal-cursor.ts +++ b/packages/lib/src/usecases/terminal-cursor.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" -const terminalSaneEscape = - "\u001B[0m" + // reset rendition +const terminalSaneEscape = "\u001B[0m" + // reset rendition "\u001B[?25h" + // show cursor "\u001B[?1l" + // normal cursor keys mode "\u001B>" + // normal keypad mode From f1bba4dbf1bbe737cf164255df441043589aea41 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:31:49 +0000 Subject: [PATCH 7/8] feat(api): align federation objects with konrad notes --- packages/api/README.md | 13 +- packages/api/src/api/contracts.ts | 25 ++- packages/api/src/api/schema.ts | 3 +- packages/api/src/http.ts | 110 ++++++++++- packages/api/src/services/federation.ts | 235 ++++++++++++++++++++++-- packages/api/tests/federation.test.ts | 106 +++++++++-- packages/api/tests/schema.test.ts | 9 +- 7 files changed, 465 insertions(+), 36 deletions(-) diff --git a/packages/api/README.md b/packages/api/README.md index c29639e4..cbb02872 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -22,12 +22,19 @@ Env: - `DOCKER_GIT_API_PORT` (default: `3334`) - `DOCKER_GIT_PROJECTS_ROOT` (default: `~/.docker-git`) - `DOCKER_GIT_API_LOG_LEVEL` (default: `info`) +- `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub domain, e.g. `https://social.my-domain.tld`) +- `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`) ## Endpoints (v1) - `GET /v1/health` - `POST /v1/federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) - `GET /v1/federation/issues` +- `GET /v1/federation/actor` (ActivityPub `Person`) +- `GET /v1/federation/outbox` +- `GET /v1/federation/followers` +- `GET /v1/federation/following` +- `GET /v1/federation/liked` - `POST /v1/federation/follows` (create ActivityPub `Follow` activity for task-feed subscription) - `GET /v1/federation/follows` - `GET /v1/projects` @@ -54,11 +61,13 @@ curl -s http://localhost:3334/v1/projects curl -s -X POST http://localhost:3334/v1/projects//up curl -s -N http://localhost:3334/v1/projects//events +curl -s http://localhost:3334/v1/federation/actor + curl -s -X POST http://localhost:3334/v1/federation/follows \ -H 'content-type: application/json' \ - -d '{"actor":"https://dev.example/users/bot","object":"https://tracker.example/issues/followers"}' + -d '{"domain":"social.my-domain.tld","object":"https://social.my-domain.tld/issues/followers"}' curl -s -X POST http://localhost:3334/v1/federation/inbox \ -H 'content-type: application/json' \ - -d '{"type":"Offer","target":"https://tracker.example/issues","object":{"type":"Ticket","id":"https://tracker.example/issues/42","attributedTo":"https://origin.example/users/alice","summary":"Title","content":"Body"}}' + -d '{"@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"],"id":"https://social.my-domain.tld/offers/42","type":"Offer","target":"https://social.my-domain.tld/issues","object":{"type":"Ticket","id":"https://social.my-domain.tld/issues/42","attributedTo":"https://origin.my-domain.tld/users/alice","summary":"Title","content":"Body"}}' ``` diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 3e1f1a6c..9b76b7b0 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -126,8 +126,9 @@ export type FederationIssueRecord = { } export type CreateFollowRequest = { - readonly actor: string + readonly actor?: string | undefined readonly object: string + readonly domain?: string | undefined readonly inbox?: string | undefined readonly to?: ReadonlyArray | undefined readonly capability?: string | undefined @@ -145,6 +146,28 @@ export type ActivityPubFollowActivity = { readonly capability?: string | undefined } +export type ActivityPubPerson = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly type: "Person" + readonly id: string + readonly name: string + readonly preferredUsername: string + readonly summary: string + readonly inbox: string + readonly outbox: string + readonly followers: string + readonly following: string + readonly liked: string +} + +export type ActivityPubOrderedCollection = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly type: "OrderedCollection" + readonly id: string + readonly totalItems: number + readonly orderedItems: ReadonlyArray +} + export type FollowSubscription = { readonly id: string readonly activityId: string diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index fc0a279b..5a518621 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -48,8 +48,9 @@ export const CreateAgentRequestSchema = Schema.Struct({ }) export const CreateFollowRequestSchema = Schema.Struct({ - actor: Schema.String, + actor: OptionalString, object: Schema.String, + domain: OptionalString, inbox: OptionalString, to: Schema.optional(Schema.Array(Schema.String)), capability: OptionalString diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index a2dde24e..7b345ffd 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -18,7 +18,13 @@ import { createFollowSubscription, ingestFederationInbox, listFederationIssues, - listFollowSubscriptions + listFollowSubscriptions, + makeFederationActorDocument, + makeFederationContext, + makeFederationFollowersCollection, + makeFederationFollowingCollection, + makeFederationLikedCollection, + makeFederationOutboxCollection } from "./services/federation.js" import { createProjectFromRequest, @@ -117,6 +123,55 @@ const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreatePr const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema) const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown) +const configuredFederationPublicOrigin = + process.env["DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN"] ?? + process.env["DOCKER_GIT_API_PUBLIC_URL"] + +const configuredFederationActorUsername = + process.env["DOCKER_GIT_FEDERATION_ACTOR"] ?? "docker-git" + +const readHeader = ( + request: HttpServerRequest.HttpServerRequest, + key: string +): string | undefined => { + const value = request.headers[key.toLowerCase()] + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + +const firstCommaValue = (value: string | undefined): string | undefined => { + if (value === undefined) { + return undefined + } + const first = value.split(",")[0]?.trim() + return first && first.length > 0 ? first : undefined +} + +const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): string => { + const forwardedHost = firstCommaValue(readHeader(request, "x-forwarded-host")) + const host = forwardedHost ?? readHeader(request, "host") + const proto = firstCommaValue(readHeader(request, "x-forwarded-proto")) ?? "http" + if (host === undefined || host.length === 0) { + return "http://localhost:3334" + } + return `${proto}://${host}` +} + +const resolveFederationContext = ( + request: HttpServerRequest.HttpServerRequest, + requestedDomain?: string | undefined +) => { + const fromBody = requestedDomain?.trim() + const publicOrigin = + fromBody && fromBody.length > 0 + ? fromBody + : configuredFederationPublicOrigin ?? resolveRequestOrigin(request) + + return makeFederationContext({ + publicOrigin, + actorUsername: configuredFederationActorUsername + }) +} + export const makeRouter = () => { const base = HttpRouter.empty.pipe( HttpRouter.get("/", textResponse(uiHtml, "text/html; charset=utf-8", 200)), @@ -130,11 +185,53 @@ export const makeRouter = () => { Effect.catchAll(errorResponse) ) ), + HttpRouter.get( + "/v1/federation/actor", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationActorDocument(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/federation/outbox", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationOutboxCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/federation/followers", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationFollowersCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/federation/following", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationFollowingCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/v1/federation/liked", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationLikedCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.post( "/v1/federation/follows", Effect.gen(function*(_) { - const request = yield* _(readCreateFollowRequest()) - const created = yield* _(createFollowSubscription(request)) + const requestBody = yield* _(readCreateFollowRequest()) + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request, requestBody.domain)) + const created = yield* _(createFollowSubscription(requestBody, context)) return yield* _(jsonResponse(created, 201)) }).pipe(Effect.catchAll(errorResponse)) ), @@ -152,7 +249,10 @@ export const makeRouter = () => { const result = yield* _(ingestFederationInbox(payload)) return yield* _(jsonResponse({ result }, 202)) }).pipe(Effect.catchAll(errorResponse)) - ), + ) + ) + + const withProjects = base.pipe( HttpRouter.get( "/v1/projects", listProjects().pipe( @@ -226,7 +326,7 @@ export const makeRouter = () => { ) ) - const withAgents = base.pipe( + const withAgents = withProjects.pipe( HttpRouter.post( "/v1/projects/:projectId/agents", Effect.gen(function*(_) { diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 56d182ff..8a441dbc 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto" import type { ActivityPubFollowActivity, + ActivityPubOrderedCollection, + ActivityPubPerson, CreateFollowRequest, FederationInboxResult, FederationIssueRecord, @@ -15,6 +17,25 @@ import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/e type JsonRecord = { readonly [key: string]: unknown } +export type FederationContextInput = { + readonly publicOrigin: string + readonly actorUsername?: string | undefined +} + +export type FederationContext = { + readonly publicOrigin: string + readonly actorUsername: string + readonly actorId: string + readonly inbox: string + readonly outbox: string + readonly followers: string + readonly following: string + readonly liked: string + readonly followsActivityPrefix: string +} + +const defaultActorUsername = "docker-git" + const issueStore: Map = new Map() const followStore: Map = new Map() const followByActivityId: Map = new Map() @@ -128,6 +149,195 @@ const cleanToRecipients = ( .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) +const looksLikeAbsoluteUrl = (value: string): boolean => + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) + +const normalizeOrigin = ( + raw: string +): Effect.Effect => + Effect.try({ + try: () => { + const trimmed = raw.trim() + if (trimmed.length === 0) { + throw new Error("Public federation domain must be non-empty.") + } + const candidate = looksLikeAbsoluteUrl(trimmed) ? trimmed : `https://${trimmed}` + const parsed = new URL(candidate) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Public federation domain must use http:// or https://.") + } + return `${parsed.protocol}//${parsed.host}` + }, + catch: (cause) => + new ApiBadRequestError({ + message: cause instanceof Error ? cause.message : String(cause) + }) + }) + +const normalizeActorUsername = ( + raw: string | undefined +): Effect.Effect => + Effect.gen(function*(_) { + const value = raw?.trim() ?? defaultActorUsername + const username = value.length === 0 ? defaultActorUsername : value + if (/[\s/]/.test(username)) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Federation actor username must not include spaces or slashes." + }) + ) + ) + } + return username + }) + +const normalizeHttpUrl = ( + raw: string, + context: FederationContext, + label: string +): Effect.Effect => + Effect.gen(function*(_) { + const value = raw.trim() + if (value.length === 0) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: `${label} must be non-empty.` + }) + ) + ) + } + + if (value.startsWith("/")) { + return `${context.publicOrigin}${value}` + } + + const candidate = looksLikeAbsoluteUrl(value) + ? value + : value.includes(".") + ? `https://${value}` + : null + + if (candidate === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: `${label} must be an absolute URL or "/path" relative to the configured domain.` + }) + ) + ) + } + + return yield* _( + Effect.try({ + try: () => { + const parsed = new URL(candidate) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${label} must use http:// or https://.`) + } + + if (parsed.hostname.endsWith(".example")) { + const replacement = new URL(context.publicOrigin) + parsed.protocol = replacement.protocol + parsed.host = replacement.host + } + + return parsed.toString() + }, + catch: (cause) => + new ApiBadRequestError({ + message: cause instanceof Error ? cause.message : String(cause) + }) + }) + ) + }) + +export const makeFederationContext = ( + input: FederationContextInput +): Effect.Effect => + Effect.gen(function*(_) { + const publicOrigin = yield* _(normalizeOrigin(input.publicOrigin)) + const actorUsername = yield* _(normalizeActorUsername(input.actorUsername)) + + return { + publicOrigin, + actorUsername, + actorId: `${publicOrigin}/v1/federation/actor`, + inbox: `${publicOrigin}/v1/federation/inbox`, + outbox: `${publicOrigin}/v1/federation/outbox`, + followers: `${publicOrigin}/v1/federation/followers`, + following: `${publicOrigin}/v1/federation/following`, + liked: `${publicOrigin}/v1/federation/liked`, + followsActivityPrefix: `${publicOrigin}/v1/federation/activities/follows` + } + }) + +export const makeFederationActorDocument = ( + context: FederationContext +): ActivityPubPerson => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Person", + id: context.actorId, + name: "docker-git task feed", + preferredUsername: context.actorUsername, + summary: "docker-git ActivityPub actor for task and issue stream subscriptions.", + inbox: context.inbox, + outbox: context.outbox, + followers: context.followers, + following: context.following, + liked: context.liked +}) + +export const makeFederationOutboxCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => { + const orderedItems = listFollowSubscriptions().map((subscription) => subscription.activity) + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.outbox, + totalItems: orderedItems.length, + orderedItems + } +} + +export const makeFederationFollowersCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.followers, + totalItems: 0, + orderedItems: [] +}) + +export const makeFederationFollowingCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => { + const orderedItems = listFollowSubscriptions() + .filter((subscription) => subscription.status === "accepted") + .map((subscription) => subscription.object) + + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.following, + totalItems: orderedItems.length, + orderedItems + } +} + +export const makeFederationLikedCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.liked, + totalItems: 0, + orderedItems: [] +}) + const lookupFollowByReference = ( reference: string ): Effect.Effect => { @@ -314,24 +524,21 @@ export const ingestFederationInbox = ( // QUOTE(ТЗ): "добавить поддержку follow, чтобы можно было подписатся на отдачу задач" // REF: pr-88-konrad-request // SOURCE: n/a -// FORMAT THEOREM: ∀r: valid(r) → ∃s: s.status = pending ∧ s.actor = r.actor ∧ s.object = r.object +// FORMAT THEOREM: ∀r: valid(r) → ∃s: s.status = pending ∧ s.object = r.object // PURITY: SHELL // EFFECT: Effect // INVARIANT: non-rejected actor/object pairs are unique // COMPLEXITY: O(1) export const createFollowSubscription = ( - request: CreateFollowRequest + request: CreateFollowRequest, + context: FederationContext ): Effect.Effect => Effect.gen(function*(_) { - const actor = request.actor.trim() - if (actor.length === 0) { - return yield* _(Effect.fail(new ApiBadRequestError({ message: "Follow actor must be non-empty." }))) - } + const actor = request.actor?.trim() + ? yield* _(normalizeHttpUrl(request.actor, context, "Follow actor")) + : context.actorId - const object = request.object.trim() - if (object.length === 0) { - return yield* _(Effect.fail(new ApiBadRequestError({ message: "Follow object must be non-empty." }))) - } + const object = yield* _(normalizeHttpUrl(request.object, context, "Follow object")) const key = followKey(actor, object) const existingId = followByActorObject.get(key) @@ -351,8 +558,12 @@ export const createFollowSubscription = ( const to = cleanToRecipients(request.to) const capability = request.capability?.trim() const inbox = request.inbox?.trim() + const normalizedInbox = inbox && inbox.length > 0 + ? yield* _(normalizeHttpUrl(inbox, context, "Follow inbox")) + : undefined + const id = randomUUID() - const activityId = `urn:docker-git:activity:follow:${id}` + const activityId = `${context.followsActivityPrefix}/${id}` const createdAt = nowIso() const activity: ActivityPubFollowActivity = { @@ -370,7 +581,7 @@ export const createFollowSubscription = ( activityId, actor, object, - inbox: inbox && inbox.length > 0 ? inbox : undefined, + inbox: normalizedInbox, to, capability: capability && capability.length > 0 ? capability : undefined, status: "pending", diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index 4675edaa..4b5ed3db 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -6,7 +6,10 @@ import { createFollowSubscription, ingestFederationInbox, listFederationIssues, - listFollowSubscriptions + listFollowSubscriptions, + makeFederationActorDocument, + makeFederationContext, + makeFederationFollowingCollection } from "../src/services/federation.js" describe("federation service", () => { @@ -48,17 +51,28 @@ describe("federation service", () => { Effect.gen(function*(_) { clearFederationState() - const created = yield* _( - createFollowSubscription({ - actor: "https://dev.example/users/bot", - object: "https://tracker.example/issues/followers", - capability: "https://tracker.example/caps/follow", - to: ["https://www.w3.org/ns/activitystreams#Public"] + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "docker-git" }) ) + const created = yield* _( + createFollowSubscription( + { + object: "https://tracker.provercoder.ai/issues/followers", + capability: "https://tracker.provercoder.ai/caps/follow", + to: ["https://www.w3.org/ns/activitystreams#Public"] + }, + context + ) + ) + expect(created.subscription.status).toBe("pending") expect(created.activity.type).toBe("Follow") + expect(created.activity.id).toContain("https://social.provercoder.ai/v1/federation/activities/follows/") + expect(created.activity.actor).toBe("https://social.provercoder.ai/v1/federation/actor") const accepted = yield* _( ingestFederationInbox({ @@ -78,19 +92,89 @@ describe("federation service", () => { expect(follows[0]?.status).toBe("accepted") })) + it.effect("replaces .example host by configured domain", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "social.provercoder.ai" + }) + ) + + const created = yield* _( + createFollowSubscription( + { + actor: "https://dev.example/users/bot", + object: "https://tracker.example/issues/followers", + inbox: "/v1/federation/inbox" + }, + context + ) + ) + + expect(created.activity.actor).toBe("https://social.provercoder.ai/users/bot") + expect(created.activity.object).toBe("https://social.provercoder.ai/issues/followers") + expect(created.subscription.inbox).toBe("https://social.provercoder.ai/v1/federation/inbox") + })) + + it.effect("builds person and following collections in activitypub shape", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" + }) + ) + + const person = makeFederationActorDocument(context) + expect(person.type).toBe("Person") + expect(person.id).toBe("https://social.provercoder.ai/v1/federation/actor") + expect(person.preferredUsername).toBe("tasks") + expect(person.followers).toBe("https://social.provercoder.ai/v1/federation/followers") + + const created = yield* _( + createFollowSubscription( + { + object: "https://tracker.provercoder.ai/issues/followers" + }, + context + ) + ) + + yield* _( + ingestFederationInbox({ + type: "Accept", + object: created.activity.id + }) + ) + + const following = makeFederationFollowingCollection(context) + expect(following.type).toBe("OrderedCollection") + expect(following.totalItems).toBe(1) + expect(following.orderedItems[0]).toBe("https://tracker.provercoder.ai/issues/followers") + })) + it.effect("rejects duplicate pending follow subscription", () => Effect.gen(function*(_) { clearFederationState() + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai" + }) + ) + const request = { - actor: "https://dev.example/users/bot", - object: "https://tracker.example/issues/followers" + object: "https://tracker.provercoder.ai/issues/followers" } as const - yield* _(createFollowSubscription(request)) + yield* _(createFollowSubscription(request, context)) const duplicateError = yield* _( - createFollowSubscription(request).pipe(Effect.flip) + createFollowSubscription(request, context).pipe(Effect.flip) ) expect(duplicateError._tag).toBe("ApiConflictError") diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index 2eb260e1..2e77bc5b 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -44,8 +44,8 @@ describe("api schemas", () => { it.effect("decodes follow payload", () => Effect.sync(() => { const result = Schema.decodeUnknownEither(CreateFollowRequestSchema)({ - actor: "https://example.com/users/alice", - object: "https://example.net/tracker/inbox", + domain: "social.my-domain.tld", + object: "/issues/followers", to: ["https://www.w3.org/ns/activitystreams#Public"] }) @@ -54,8 +54,9 @@ describe("api schemas", () => { throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) }, onRight: (value) => { - expect(value.actor).toBe("https://example.com/users/alice") - expect(value.object).toBe("https://example.net/tracker/inbox") + expect(value.actor).toBeUndefined() + expect(value.domain).toBe("social.my-domain.tld") + expect(value.object).toBe("/issues/followers") expect(value.to).toHaveLength(1) } }) From 124e8604cfd287e08635cb43279aedc6833656ee Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:08:24 +0000 Subject: [PATCH 8/8] refactor(api): drop versioned v1 route prefix --- packages/api/README.md | 68 ++++++++++++------------- packages/api/src/http.ts | 52 +++++++++---------- packages/api/src/services/federation.ts | 14 ++--- packages/api/src/ui.ts | 8 +-- packages/api/tests/federation.test.ts | 12 ++--- packages/api/tests/ui.test.ts | 2 +- 6 files changed, 78 insertions(+), 78 deletions(-) diff --git a/packages/api/README.md b/packages/api/README.md index cbb02872..fdc5413d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -25,49 +25,49 @@ Env: - `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub domain, e.g. `https://social.my-domain.tld`) - `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`) -## Endpoints (v1) - -- `GET /v1/health` -- `POST /v1/federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) -- `GET /v1/federation/issues` -- `GET /v1/federation/actor` (ActivityPub `Person`) -- `GET /v1/federation/outbox` -- `GET /v1/federation/followers` -- `GET /v1/federation/following` -- `GET /v1/federation/liked` -- `POST /v1/federation/follows` (create ActivityPub `Follow` activity for task-feed subscription) -- `GET /v1/federation/follows` -- `GET /v1/projects` -- `GET /v1/projects/:projectId` -- `POST /v1/projects` -- `DELETE /v1/projects/:projectId` -- `POST /v1/projects/:projectId/up` -- `POST /v1/projects/:projectId/down` -- `POST /v1/projects/:projectId/recreate` -- `GET /v1/projects/:projectId/ps` -- `GET /v1/projects/:projectId/logs` -- `GET /v1/projects/:projectId/events` (SSE) -- `POST /v1/projects/:projectId/agents` -- `GET /v1/projects/:projectId/agents` -- `GET /v1/projects/:projectId/agents/:agentId` -- `GET /v1/projects/:projectId/agents/:agentId/attach` -- `POST /v1/projects/:projectId/agents/:agentId/stop` -- `GET /v1/projects/:projectId/agents/:agentId/logs` +## Endpoints + +- `GET /health` +- `POST /federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) +- `GET /federation/issues` +- `GET /federation/actor` (ActivityPub `Person`) +- `GET /federation/outbox` +- `GET /federation/followers` +- `GET /federation/following` +- `GET /federation/liked` +- `POST /federation/follows` (create ActivityPub `Follow` activity for task-feed subscription) +- `GET /federation/follows` +- `GET /projects` +- `GET /projects/:projectId` +- `POST /projects` +- `DELETE /projects/:projectId` +- `POST /projects/:projectId/up` +- `POST /projects/:projectId/down` +- `POST /projects/:projectId/recreate` +- `GET /projects/:projectId/ps` +- `GET /projects/:projectId/logs` +- `GET /projects/:projectId/events` (SSE) +- `POST /projects/:projectId/agents` +- `GET /projects/:projectId/agents` +- `GET /projects/:projectId/agents/:agentId` +- `GET /projects/:projectId/agents/:agentId/attach` +- `POST /projects/:projectId/agents/:agentId/stop` +- `GET /projects/:projectId/agents/:agentId/logs` ## Example ```bash -curl -s http://localhost:3334/v1/projects -curl -s -X POST http://localhost:3334/v1/projects//up -curl -s -N http://localhost:3334/v1/projects//events +curl -s http://localhost:3334/projects +curl -s -X POST http://localhost:3334/projects//up +curl -s -N http://localhost:3334/projects//events -curl -s http://localhost:3334/v1/federation/actor +curl -s http://localhost:3334/federation/actor -curl -s -X POST http://localhost:3334/v1/federation/follows \ +curl -s -X POST http://localhost:3334/federation/follows \ -H 'content-type: application/json' \ -d '{"domain":"social.my-domain.tld","object":"https://social.my-domain.tld/issues/followers"}' -curl -s -X POST http://localhost:3334/v1/federation/inbox \ +curl -s -X POST http://localhost:3334/federation/inbox \ -H 'content-type: application/json' \ -d '{"@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"],"id":"https://social.my-domain.tld/offers/42","type":"Offer","target":"https://social.my-domain.tld/issues","object":{"type":"Ticket","id":"https://social.my-domain.tld/issues/42","attributedTo":"https://origin.my-domain.tld/users/alice","summary":"Title","content":"Body"}}' ``` diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 7b345ffd..420b2fef 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -177,16 +177,16 @@ export const makeRouter = () => { HttpRouter.get("/", textResponse(uiHtml, "text/html; charset=utf-8", 200)), HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), - HttpRouter.get("/v1/health", jsonResponse({ ok: true }, 200)), + HttpRouter.get("/health", jsonResponse({ ok: true }, 200)), HttpRouter.get( - "/v1/federation/issues", + "/federation/issues", Effect.sync(() => ({ issues: listFederationIssues() })).pipe( Effect.flatMap((payload) => jsonResponse(payload, 200)), Effect.catchAll(errorResponse) ) ), HttpRouter.get( - "/v1/federation/actor", + "/federation/actor", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) @@ -194,7 +194,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/federation/outbox", + "/federation/outbox", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) @@ -202,7 +202,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/federation/followers", + "/federation/followers", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) @@ -210,7 +210,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/federation/following", + "/federation/following", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) @@ -218,7 +218,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/federation/liked", + "/federation/liked", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) @@ -226,7 +226,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.post( - "/v1/federation/follows", + "/federation/follows", Effect.gen(function*(_) { const requestBody = yield* _(readCreateFollowRequest()) const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -236,14 +236,14 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/federation/follows", + "/federation/follows", Effect.sync(() => ({ follows: listFollowSubscriptions() })).pipe( Effect.flatMap((payload) => jsonResponse(payload, 200)), Effect.catchAll(errorResponse) ) ), HttpRouter.post( - "/v1/federation/inbox", + "/federation/inbox", Effect.gen(function*(_) { const payload = yield* _(readInboxPayload()) const result = yield* _(ingestFederationInbox(payload)) @@ -254,14 +254,14 @@ export const makeRouter = () => { const withProjects = base.pipe( HttpRouter.get( - "/v1/projects", + "/projects", listProjects().pipe( Effect.flatMap((projects) => jsonResponse({ projects }, 200)), Effect.catchAll(errorResponse) ) ), HttpRouter.post( - "/v1/projects", + "/projects", Effect.gen(function*(_) { const request = yield* _(readCreateProjectRequest()) const project = yield* _(createProjectFromRequest(request)) @@ -269,7 +269,7 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/projects/:projectId", + "/projects/:projectId", projectParams.pipe( Effect.flatMap(({ projectId }) => getProject(projectId)), Effect.flatMap((project) => jsonResponse({ project }, 200)), @@ -277,7 +277,7 @@ export const makeRouter = () => { ) ), HttpRouter.del( - "/v1/projects/:projectId", + "/projects/:projectId", projectParams.pipe( Effect.flatMap(({ projectId }) => deleteProjectById(projectId)), Effect.flatMap(() => jsonResponse({ ok: true }, 200)), @@ -285,7 +285,7 @@ export const makeRouter = () => { ) ), HttpRouter.post( - "/v1/projects/:projectId/up", + "/projects/:projectId/up", projectParams.pipe( Effect.flatMap(({ projectId }) => upProject(projectId)), Effect.flatMap(() => jsonResponse({ ok: true }, 200)), @@ -293,7 +293,7 @@ export const makeRouter = () => { ) ), HttpRouter.post( - "/v1/projects/:projectId/down", + "/projects/:projectId/down", projectParams.pipe( Effect.flatMap(({ projectId }) => downProject(projectId)), Effect.flatMap(() => jsonResponse({ ok: true }, 200)), @@ -301,7 +301,7 @@ export const makeRouter = () => { ) ), HttpRouter.post( - "/v1/projects/:projectId/recreate", + "/projects/:projectId/recreate", projectParams.pipe( Effect.flatMap(({ projectId }) => recreateProject(projectId)), Effect.flatMap(() => jsonResponse({ ok: true }, 200)), @@ -309,7 +309,7 @@ export const makeRouter = () => { ) ), HttpRouter.get( - "/v1/projects/:projectId/ps", + "/projects/:projectId/ps", projectParams.pipe( Effect.flatMap(({ projectId }) => readProjectPs(projectId)), Effect.flatMap((output) => jsonResponse({ output }, 200)), @@ -317,7 +317,7 @@ export const makeRouter = () => { ) ), HttpRouter.get( - "/v1/projects/:projectId/logs", + "/projects/:projectId/logs", projectParams.pipe( Effect.flatMap(({ projectId }) => readProjectLogs(projectId)), Effect.flatMap((output) => jsonResponse({ output }, 200)), @@ -328,7 +328,7 @@ export const makeRouter = () => { const withAgents = withProjects.pipe( HttpRouter.post( - "/v1/projects/:projectId/agents", + "/projects/:projectId/agents", Effect.gen(function*(_) { const { projectId } = yield* _(projectParams) const project = yield* _(getProject(projectId)) @@ -338,14 +338,14 @@ export const makeRouter = () => { }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( - "/v1/projects/:projectId/agents", + "/projects/:projectId/agents", projectParams.pipe( Effect.flatMap(({ projectId }) => jsonResponse({ sessions: listAgents(projectId) }, 200)), Effect.catchAll(errorResponse) ) ), HttpRouter.get( - "/v1/projects/:projectId/agents/:agentId", + "/projects/:projectId/agents/:agentId", agentParams.pipe( Effect.flatMap(({ projectId, agentId }) => getAgent(projectId, agentId)), Effect.flatMap((session) => jsonResponse({ session }, 200)), @@ -353,7 +353,7 @@ export const makeRouter = () => { ) ), HttpRouter.get( - "/v1/projects/:projectId/agents/:agentId/attach", + "/projects/:projectId/agents/:agentId/attach", agentParams.pipe( Effect.flatMap(({ projectId, agentId }) => getAgentAttachInfo(projectId, agentId)), Effect.flatMap((attach) => jsonResponse({ attach }, 200)), @@ -361,7 +361,7 @@ export const makeRouter = () => { ) ), HttpRouter.post( - "/v1/projects/:projectId/agents/:agentId/stop", + "/projects/:projectId/agents/:agentId/stop", agentParams.pipe( Effect.flatMap(({ projectId, agentId }) => Effect.gen(function*(_) { @@ -374,7 +374,7 @@ export const makeRouter = () => { ) ), HttpRouter.get( - "/v1/projects/:projectId/agents/:agentId/logs", + "/projects/:projectId/agents/:agentId/logs", agentParams.pipe( Effect.flatMap(({ projectId, agentId }) => Effect.gen(function*(_) { @@ -392,7 +392,7 @@ export const makeRouter = () => { return withAgents.pipe( HttpRouter.get( - "/v1/projects/:projectId/events", + "/projects/:projectId/events", projectParams.pipe( Effect.flatMap(({ projectId }) => Effect.gen(function*(_) { diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 8a441dbc..b07f6a8d 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -263,13 +263,13 @@ export const makeFederationContext = ( return { publicOrigin, actorUsername, - actorId: `${publicOrigin}/v1/federation/actor`, - inbox: `${publicOrigin}/v1/federation/inbox`, - outbox: `${publicOrigin}/v1/federation/outbox`, - followers: `${publicOrigin}/v1/federation/followers`, - following: `${publicOrigin}/v1/federation/following`, - liked: `${publicOrigin}/v1/federation/liked`, - followsActivityPrefix: `${publicOrigin}/v1/federation/activities/follows` + actorId: `${publicOrigin}/federation/actor`, + inbox: `${publicOrigin}/federation/inbox`, + outbox: `${publicOrigin}/federation/outbox`, + followers: `${publicOrigin}/federation/followers`, + following: `${publicOrigin}/federation/following`, + liked: `${publicOrigin}/federation/liked`, + followsActivityPrefix: `${publicOrigin}/federation/activities/follows` } }) diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts index b0de8488..55e47f32 100644 --- a/packages/api/src/ui.ts +++ b/packages/api/src/ui.ts @@ -549,7 +549,7 @@ export const uiScript = ` return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; }; - const projectPath = (projectId, suffix) => '/v1/projects/' + encodeURIComponent(projectId) + suffix; + const projectPath = (projectId, suffix) => '/projects/' + encodeURIComponent(projectId) + suffix; const request = async (path, init) => { const base = normalizeBase(views.baseUrl.value); @@ -627,7 +627,7 @@ export const uiScript = ` }; const loadProjects = async () => { - const payload = await request('/v1/projects'); + const payload = await request('/projects'); state.projects = (payload && payload.projects) || []; renderProjects(); @@ -825,7 +825,7 @@ export const uiScript = ` openSsh: false }; - await request('/v1/projects', { + await request('/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) @@ -873,7 +873,7 @@ export const uiScript = ` }); byId('btn-health').addEventListener('click', withUiError(async () => { - const payload = await request('/v1/health'); + const payload = await request('/health'); window.alert('Health: ' + JSON.stringify(payload)); })); diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index 4b5ed3db..cc6c2d92 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -71,8 +71,8 @@ describe("federation service", () => { expect(created.subscription.status).toBe("pending") expect(created.activity.type).toBe("Follow") - expect(created.activity.id).toContain("https://social.provercoder.ai/v1/federation/activities/follows/") - expect(created.activity.actor).toBe("https://social.provercoder.ai/v1/federation/actor") + expect(created.activity.id).toContain("https://social.provercoder.ai/federation/activities/follows/") + expect(created.activity.actor).toBe("https://social.provercoder.ai/federation/actor") const accepted = yield* _( ingestFederationInbox({ @@ -107,7 +107,7 @@ describe("federation service", () => { { actor: "https://dev.example/users/bot", object: "https://tracker.example/issues/followers", - inbox: "/v1/federation/inbox" + inbox: "/federation/inbox" }, context ) @@ -115,7 +115,7 @@ describe("federation service", () => { expect(created.activity.actor).toBe("https://social.provercoder.ai/users/bot") expect(created.activity.object).toBe("https://social.provercoder.ai/issues/followers") - expect(created.subscription.inbox).toBe("https://social.provercoder.ai/v1/federation/inbox") + expect(created.subscription.inbox).toBe("https://social.provercoder.ai/federation/inbox") })) it.effect("builds person and following collections in activitypub shape", () => @@ -131,9 +131,9 @@ describe("federation service", () => { const person = makeFederationActorDocument(context) expect(person.type).toBe("Person") - expect(person.id).toBe("https://social.provercoder.ai/v1/federation/actor") + expect(person.id).toBe("https://social.provercoder.ai/federation/actor") expect(person.preferredUsername).toBe("tasks") - expect(person.followers).toBe("https://social.provercoder.ai/v1/federation/followers") + expect(person.followers).toBe("https://social.provercoder.ai/federation/followers") const created = yield* _( createFollowSubscription( diff --git a/packages/api/tests/ui.test.ts b/packages/api/tests/ui.test.ts index e4861d89..c876de38 100644 --- a/packages/api/tests/ui.test.ts +++ b/packages/api/tests/ui.test.ts @@ -8,7 +8,7 @@ describe("api ui wrapper", () => { Effect.sync(() => { expect(uiHtml).toContain("docker-git API Console") expect(uiHtml).toContain("/ui/app.js") - expect(uiScript).toContain("/v1/projects") + expect(uiScript).toContain("/projects") expect(uiStyles).toContain(".panel") })) })