Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ services:
DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects}
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-}
DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git}
DOCKER_GIT_EXCHANGE_TARGETS: ${DOCKER_GIT_EXCHANGE_TARGETS:-}
DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL: ${DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL:-}
DOCKER_GIT_EXCHANGE_AGENT_PROVIDER: ${DOCKER_GIT_EXCHANGE_AGENT_PROVIDER:-codex}
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
ports:
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
dns:
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ services:
DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects}
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-}
DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git}
DOCKER_GIT_EXCHANGE_TARGETS: ${DOCKER_GIT_EXCHANGE_TARGETS:-}
DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL: ${DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL:-}
DOCKER_GIT_EXCHANGE_AGENT_PROVIDER: ${DOCKER_GIT_EXCHANGE_AGENT_PROVIDER:-codex}
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
ports:
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
dns:
Expand Down
26 changes: 26 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ Optional env:
- `DOCKER_GIT_PROJECTS_ROOT_VOLUME` (Docker volume name for controller state, default: `docker-git-projects`)
- `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub origin)
- `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`)
- `DOCKER_GIT_EXCHANGE_TARGETS` (optional comma-separated exchange targets, e.g. `https://exchange.lefine.pro` or `code@exchange.lefine.pro`)
- `DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL` (fallback repo for exchange Tickets without a GitHub URL)
- `DOCKER_GIT_EXCHANGE_AGENT_PROVIDER` (default: `codex`; also supports `claude`, `opencode`, `custom`)
- `DOCKER_GIT_EXCHANGE_AGENT_COMMAND` (optional command template; `{{prompt}}` is replaced with the task prompt)
- `DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS` (default: `3600000`)
- `DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS` (default: `5000`)

## Endpoints

Expand All @@ -56,6 +62,9 @@ Optional env:
- `GET /federation/followers`
- `GET /federation/following`
- `GET /federation/liked`
- `POST /federation/exchange/subscriptions` (discover remote actor, persist metadata, send signed `Follow`)
- `GET /federation/exchange/subscriptions`
- `POST /federation/exchange/poll` (manual remote outbox poll)
- `POST /federation/follows` (create ActivityPub `Follow` subscription)
- `GET /federation/follows`
- `GET /projects`
Expand All @@ -77,6 +86,23 @@ Optional env:

## Subscription workflow (ActivityPub Follow + ForgeFed issues)

Exchange targets must be explicit. Use `https://exchange.lefine.pro`, an actor URL, or a handle like `code@exchange.lefine.pro`; the API resolves the code actor document, stores its `inbox/outbox/followers/publicKey`, sends `Follow`, and polls the stored `outbox`.

```bash
./ctl request POST /federation/exchange/subscriptions '{
"domain":"https://social.provercoder.ai",
"target":"https://exchange.lefine.pro",
"projectRepoUrl":"https://github.com/ProverCoderAI/docker-git",
"agentProvider":"codex"
}'

./ctl request POST /federation/exchange/poll '{}'
./ctl request GET /federation/exchange/subscriptions
./ctl request GET /federation/issues
```

When a polled `Create(Ticket)` has no GitHub URL in the Ticket payload, `projectRepoUrl` or `DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL` is required for the automatic docker-git project/agent run.

1. Read actor profile (contains `inbox/outbox/followers/following/liked`):

```bash
Expand Down
81 changes: 78 additions & 3 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,21 +399,46 @@ export type ForgeFedTicket = {
readonly summary: string
readonly content: string
readonly mediaType?: string | undefined
readonly source?: string | undefined
readonly source?: string | ForgeFedTicketSource | undefined
readonly published?: string | undefined
readonly updated?: string | undefined
readonly url?: string | undefined
readonly context?: string | undefined
readonly workType?: string | undefined
readonly attachment?: ReadonlyArray<unknown> | undefined
readonly raw?: unknown | undefined
}

export type FederationIssueStatus = "offered" | "accepted" | "rejected"
export type ForgeFedTicketSource = {
readonly content?: string | undefined
readonly mediaType?: string | undefined
}

export type FederationIssueStatus =
| "offered"
| "accepted"
| "rejected"
| "queued"
| "running"
| "completed"
| "failed"

export type FederationIssueRecord = {
readonly issueId: string
readonly offerId?: string | undefined
readonly activityId?: string | undefined
readonly actor?: string | undefined
readonly tracker?: string | undefined
readonly status: FederationIssueStatus
readonly receivedAt: string
readonly updatedAt?: string | undefined
readonly ticket: ForgeFedTicket
readonly projectId?: string | undefined
readonly agentId?: string | undefined
readonly remoteInbox?: string | undefined
readonly remoteOutbox?: string | undefined
readonly result?: string | undefined
readonly error?: string | undefined
}

export type CreateFollowRequest = {
Expand All @@ -437,6 +462,12 @@ export type ActivityPubFollowActivity = {
readonly capability?: string | undefined
}

export type ActivityPubPublicKey = {
readonly id: string
readonly owner: string
readonly publicKeyPem: string
}

export type ActivityPubPerson = {
readonly "@context": "https://www.w3.org/ns/activitystreams"
readonly type: "Person"
Expand All @@ -449,10 +480,14 @@ export type ActivityPubPerson = {
readonly followers: string
readonly following: string
readonly liked: string
readonly publicKey?: ActivityPubPublicKey | undefined
readonly endpoints?: {
readonly sharedInbox?: string | undefined
} | undefined
}

export type ActivityPubOrderedCollection = {
readonly "@context": "https://www.w3.org/ns/activitystreams"
readonly "@context": "https://www.w3.org/ns/activitystreams" | ReadonlyArray<string>
readonly type: "OrderedCollection"
readonly id: string
readonly totalItems: number
Expand All @@ -465,6 +500,18 @@ export type FollowSubscription = {
readonly actor: string
readonly object: string
readonly inbox?: string | undefined
readonly remoteActor?: string | undefined
readonly remoteInbox?: string | undefined
readonly remoteOutbox?: string | undefined
readonly remoteFollowers?: string | undefined
readonly remoteSharedInbox?: string | undefined
readonly remotePublicKeyId?: string | undefined
readonly remotePublicKeyPem?: string | undefined
readonly subscriptionName?: string | undefined
readonly queue?: string | undefined
readonly projectRepoUrl?: string | undefined
readonly agentProvider?: AgentProvider | undefined
readonly agentCommand?: string | undefined
readonly to: ReadonlyArray<string>
readonly capability?: string | undefined
status: FollowStatus
Expand All @@ -487,6 +534,10 @@ export type FederationInboxResult =
readonly kind: "issue.ticket"
readonly issue: FederationIssueRecord
}
| {
readonly kind: "issue.create"
readonly issue: FederationIssueRecord
}
| {
readonly kind: "follow.accept"
readonly subscription: FollowSubscription
Expand All @@ -496,6 +547,30 @@ export type FederationInboxResult =
readonly subscription: FollowSubscription
}

export type ExchangeSubscribeRequest = {
readonly target: string
readonly domain?: string | undefined
readonly actor?: string | undefined
readonly inbox?: string | undefined
readonly projectRepoUrl?: string | undefined
readonly agentProvider?: AgentProvider | undefined
readonly agentCommand?: string | undefined
}

export type ExchangePollRequest = {
readonly target?: string | undefined
readonly runTasks?: boolean | undefined
}

export type ExchangePollResult = {
readonly polledAt: string
readonly subscriptions: number
readonly totalItems: number
readonly newItems: number
readonly processedItems: number
readonly failedItems: number
}

export type ApiEventType =
| "snapshot"
| "project.created"
Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ export const CreateFollowRequestSchema = Schema.Struct({
capability: OptionalString
})

export const ExchangeSubscribeRequestSchema = Schema.Struct({
target: Schema.String,
domain: OptionalString,
actor: OptionalString,
inbox: OptionalString,
projectRepoUrl: OptionalString,
agentProvider: Schema.optional(AgentProviderSchema),
agentCommand: OptionalString
})

export const ExchangePollRequestSchema = Schema.Struct({
target: OptionalString,
runTasks: OptionalBoolean
})

export const AgentSessionSchema = Schema.Struct({
id: Schema.String,
projectId: Schema.String,
Expand Down
53 changes: 47 additions & 6 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
CreateAgentRequestSchema,
CreateFollowRequestSchema,
CreateProjectRequestSchema,
ExchangePollRequestSchema,
ExchangeSubscribeRequestSchema,
GithubAuthLoginRequestSchema,
GithubAuthLogoutRequestSchema,
ProjectDatabaseProfileRequestSchema,
Expand Down Expand Up @@ -52,15 +54,18 @@ import { readContainerTaskLogs, readContainerTaskSnapshot, stopContainerTask } f
import { latestProjectCursor, listProjectEventsSince } from "./services/events.js"
import {
createFollowSubscription,
ensureExchangeSubscription,
ingestFederationInbox,
listExchangeSubscriptions,
listFederationIssues,
listFollowSubscriptions,
makeFederationActorDocument,
makeFederationContext,
makeFederationFollowersCollection,
makeFederationFollowingCollection,
makeFederationLikedCollection,
makeFederationOutboxCollection
makeFederationOutboxCollection,
pollExchangeOutboxes
} from "./services/federation.js"
import {
applyAllProjects,
Expand Down Expand Up @@ -201,6 +206,9 @@ const textResponse = (data: string, contentType: string, status = 200) =>
)
)

const activityJsonResponse = (data: unknown, status: number) =>
textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", 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) {
Expand Down Expand Up @@ -326,6 +334,12 @@ const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitReq
const readStateCommitRequest = () => HttpServerRequest.schemaBodyJson(StateCommitRequestSchema)
const readStateSyncRequest = () => HttpServerRequest.schemaBodyJson(StateSyncRequestSchema)
const readApplyAllRequest = () => HttpServerRequest.schemaBodyJson(ApplyAllRequestSchema)
const readExchangeSubscribeRequest = () => HttpServerRequest.schemaBodyJson(ExchangeSubscribeRequestSchema)
const emptyExchangePollRequest = {}
const readExchangePollRequest = () =>
HttpServerRequest.schemaBodyJson(ExchangePollRequestSchema).pipe(
Effect.catchAll(() => Effect.succeed(emptyExchangePollRequest))
)
const emptyUpProjectRequest: UpProjectRequestInput = {}
const readUpProjectRequest = () =>
HttpServerRequest.schemaBodyJson(UpProjectRequestSchema).pipe(
Expand Down Expand Up @@ -577,39 +591,66 @@ export const makeRouter = () => {
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
return yield* _(jsonResponse(makeFederationActorDocument(context), 200))
return yield* _(activityJsonResponse(makeFederationActorDocument(context), 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/federation/outbox",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
return yield* _(jsonResponse(makeFederationOutboxCollection(context), 200))
return yield* _(activityJsonResponse(makeFederationOutboxCollection(context), 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/federation/followers",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
return yield* _(jsonResponse(makeFederationFollowersCollection(context), 200))
return yield* _(activityJsonResponse(makeFederationFollowersCollection(context), 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/federation/following",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
return yield* _(jsonResponse(makeFederationFollowingCollection(context), 200))
return yield* _(activityJsonResponse(makeFederationFollowingCollection(context), 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/federation/liked",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
return yield* _(jsonResponse(makeFederationLikedCollection(context), 200))
return yield* _(activityJsonResponse(makeFederationLikedCollection(context), 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/federation/exchange/subscriptions",
Effect.gen(function*(_) {
const requestBody = yield* _(readExchangeSubscribeRequest())
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request, requestBody.domain))
const created = yield* _(ensureExchangeSubscription(requestBody, context))
return yield* _(jsonResponse(created, 201))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/federation/exchange/subscriptions",
Effect.sync(() => ({ subscriptions: listExchangeSubscriptions() })).pipe(
Effect.flatMap((payload) => jsonResponse(payload, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/federation/exchange/poll",
Effect.gen(function*(_) {
const requestBody = yield* _(readExchangePollRequest())
const request = yield* _(HttpServerRequest.HttpServerRequest)
const context = yield* _(resolveFederationContext(request))
const result = yield* _(pollExchangeOutboxes(requestBody, context))
return yield* _(jsonResponse({ result }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createServer } from "node:http"
import { makeRouter } from "./http.js"
import { initializeAgentState } from "./services/agents.js"
import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js"
import { startOutboxPolling } from "./services/federation.js"
import { initializeFederationState, startOutboxPolling } from "./services/federation.js"
import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js"
import { attachProjectDatabaseWebSocketServer } from "./services/project-databases.js"
import { attachTerminalWebSocketServer } from "./services/terminal-sessions.js"
Expand Down Expand Up @@ -65,6 +65,7 @@ export const program = (() => {
return Effect.scoped(
Console.log(`docker-git api boot port=${port}`).pipe(
Effect.zipRight(initializeAgentState()),
Effect.zipRight(initializeFederationState()),
Effect.zipRight(
Console.log(`docker-git outbox polling interval=${pollingInterval}ms`)
),
Expand Down
Loading