Skip to content

RFC: the server unifies session reads across both engines but not writes — add an engine-agnostic write surface? #353

Description

@Vito-168

TL;DRipollowork-server unifies session reads across OpenCode and DeepSeek Harness,
but there is no engine-agnostic way to create a session or send a prompt. I'd like to add
one, mirroring the engine adapter the browser already has. Disclosure up front: I've
already built it
on a fork branch, and I'm posting this first because I'd rather throw it
away than land the wrong shape. "Don't build this" is a real answer. The specific questions
are at the bottom.

The gap

I wanted to drive iPolloWork from CI — submit a goal, stream the run, answer tool
permissions, collect the result — with no desktop UI. More of that already works than I
expected: the server ships standalone, apps/orchestrator runs headless, and the scoped
token model is real.

Where I got stuck: routes/sessions.ts unifies list/get/messages/snapshot across both
engines, but there's no unified create or prompt. The one unified write that exists says
the problem out loud — DELETE /workspace/:id/sessions/:sessionId hard-codes
501 session_delete_unsupported for Harness (sessions.ts:234), because the two engines
couldn't be reconciled at that point.

Both engines can be written to, just not through one contract: OpenCode via the
/opencode/* proxy (its private API, no compatibility promise), Harness via the JSON-RPC
allowlist in routes/deepseek-harness.ts:14. Two dialects, neither a stable public surface.

Two smaller things follow from the same root:

  • The only OpenAPI document in the tree (packages/docs/openapi.json) is the cloud
    Den API, not the local server.
  • GET /w/:id/capabilities exists (core.ts:215) but its handler takes no ctx at all —
    it discards :id and returns server-wide flags, with nothing about the engine. So a
    caller can't ask what a given workspace's engine actually supports.

The direction I'd like to check

I don't think this needs a new abstraction, because two pieces are already here and
half-finished:

1. server-route is a declared contribution type with no runtime consumer.
packages/types/src/plugins.ts:63 lists it in contributionTypeSchema. Its only other
appearance is apps/app/src/app/extensions.ts:195 — a descriptive entry pointing at a route
hardcoded in routes/core.ts:582. Nothing reads it to mount anything.

2. ConversationEngineAdapter exists in the browser but not on the server.
apps/app/src/react-app/domains/session/engine/conversation-engine.ts defines the whole
thing — normalized event union (:129), connection interface (:175), adapter (:217),
registry (:228) — with working OpenCode and Harness implementations beside it. That is
precisely what the server lacks, and why writes can't be unified today.

So: mirror the browser's engine adapter onto the server, and organize the resulting
endpoints as declarative modules — the shape server-route already anticipates. Each module
declares its operations once; a registry turns that single declaration into both the route
table and the OpenAPI document, so the two can't drift. Legacy routes stay exactly where they
are.

The objection I'd raise if I were you

This duplicates the browser's engine layer. opencode-conversation-engine.ts +
deepseek-harness-conversation-engine.ts and their mappers are ~1,500 lines, and a
server-side mirror means two copies of the same wire-format knowledge, drifting
independently. Concretely, my branch already copies the Harness internal-<system>-block
stripper out of deepseek-harness-conversation-mapper.ts.

I went ahead anyway because the alternative — the server proxying an engine's private API
and calling that a public contract — seemed worse. But if you'd rather see the mapping
extracted into packages/ and shared by both, that's a better end state than what I built,
and I'd rather do that than have two copies. It does mean touching browser code, which is
why I'm asking rather than assuming.

The related worry is the one README.md:171 names under "Architecture boundary" — OpenCode
staying independently upgradable. Worth saying plainly: today apps/server contains exactly
one OpenCode event-name string (in toy-ui.ts); my adapter adds about thirty. That's a real
increase in coupling surface, concentrated in one file rather than spread out, but I won't
pretend it's zero.

Also, independent of all this

serve-node.ts never wires request.signal into the Request it builds, and its write loop
releases the response reader rather than cancelling it. This is a live leak on the shipped
path, not a hypothetical: GET /workspace/:id/engine/deepseek-harness/events/:stream already
forwards ctx.request.signal into runtime.events(...)fetch(..., { signal }), so both
ends are wired and only the middle is missing. Every closed Harness event stream leaves its
upstream subscription alive for the life of the process.

I have a fix with three end-to-end tests. I'll file it as its own issue and PR — it's a
bug on main and shouldn't ride along with an API proposal.

Questions

  1. Is a server-side engine adapter the right call, or should the public API stay
    engine-specific and let callers target OpenCode directly?
  2. If yes — shared mapping in packages/, or a server-side copy? I'd prefer shared, but
    it touches browser code and that's your call.
  3. Is a versioned API already on the roadmap? If so I'd rather contribute to that than
    propose a parallel one.
  4. How would you want it split? My branch is 61 files in one commit, which is too much to
    review at once. Note that "OpenAPI generation first" doesn't actually work — the mount
    builds the engine registry unconditionally — so my best guess is: engine adapter +
    sessions, then OpenAPI generation, then tasks/webhooks/policy, then the legacy compat
    layer last, since that one adds the most routes and deserves its own review.

Happy to open the PR, split it, rework it as shared mapping, or drop it if it doesn't fit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions