Skip to content
Open
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
10 changes: 8 additions & 2 deletions middleware/migrations/0024_dev_platform_w3.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ ALTER TABLE dev_jobs
ADD COLUMN IF NOT EXISTS conductor_await_id text;

-- --- operator grant: which plugin may drive dev jobs on which repo (W3 §2) --
-- The ctx.devJobs accessor resolves ONLY operator-granted repos; everything
-- else fails closed. Mirrors the MCP-server grant pattern.
--
-- ORPHANED — KNOWINGLY RETAINED. This table backed the plugin-facing accessor
-- for this subsystem. That accessor never had a provider and never had a
-- consumer (no manifest anywhere declared the permission), so it was deleted,
-- and its store class went with it. NO CODE reads or writes this table any
-- more, and it never held a row in production. It survives only because the
-- migrations here are forward-only — do NOT read its existence as evidence of
-- a live feature. Rationale: dormant-capabilities.md §2 (epic #470).
CREATE TABLE IF NOT EXISTS dev_repo_plugin_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES dev_repos(id) ON DELETE CASCADE,
Expand Down
9 changes: 9 additions & 0 deletions middleware/migrations/0025_dev_jobs_source_plugin.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
-- src/devplatform/types.ts (DEV_JOB_SOURCES) — the DB CHECK only guards the
-- known set.
--
-- ORPHANED — KNOWINGLY RETAINED. The plugin-facing accessor was deleted, and
-- with it the host `createJob` that was the ONLY writer of `source='plugin'`.
-- That accessor never had a provider and never had a consumer, so no row ever
-- carried this value in production and no code path can produce it now. The
-- widened CHECK stays because the migrations here are forward-only, and
-- 'plugin' stays in the runtime source union so a hypothetical pre-existing
-- row still validates. Do NOT read this value as evidence of a live feature.
-- Rationale: dormant-capabilities.md §2 (epic #470).
--
-- Forward-only, idempotent: drop the auto-named inline CHECK from 0022 and
-- re-add it with 'plugin' included. Safe to re-run (DROP ... IF EXISTS).
ALTER TABLE dev_jobs DROP CONSTRAINT IF EXISTS dev_jobs_source_check;
Expand Down
110 changes: 17 additions & 93 deletions middleware/packages/plugin-api/src/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,6 @@ export interface PluginContext {
* `if (ctx.mcp)` — a Hub plugin may land on an older core that lacks it. */
readonly mcp?: McpAccessor;

/** Epic #470 W3 — dev-platform access. Present iff the manifest declares
* `permissions.devJobs` AND the host wires the 'devJobs' service. Scoped to
* the repos the operator has EXPLICITLY granted to this plugin
* (`dev_repo_plugin_grants`) — never ambient access to every registered
* repo. Fail-closed like `ctx.mcp`: an ungranted repo (or a job on one)
* throws, and the error never reveals whether an out-of-scope repo/job
* exists. Deliberately CANNOT resolve gates — a human gate must stay
* attributable to a human session, never a model/plugin turn. Guard with
* `if (ctx.devJobs)` — a Hub plugin may land on an older core that lacks it. */
readonly devJobs?: DevJobsAccessor;

/** Spec 004 — redirect/callback flow toolkit. Present iff the manifest
* declares `permissions.flows: true`. Supplies the three things a plugin
* needs to run a credential-acquisition round-trip on its OWN route:
Expand Down Expand Up @@ -1398,90 +1387,25 @@ export interface McpAccessor {
}

// ---------------------------------------------------------------------------
// Dev-platform access (epic #470 W3, issue #470) — `ctx.devJobs`.
// Dev-platform access (`ctx.devJobs`) — REMOVED.
//
// `ctx.devJobs` and its six types (DevJobKind, DevJobStatus, DevJobDescriptor,
// DevJobCreateRequest, DevJobEventRecord, DevJobsAccessor) used to live here.
// Nothing ever provided the backing `'devJobs'` host service, so the accessor
// threw on every invocation, and no manifest in this repo, in the private byte5
// plugin set, or in any sibling repo ever declared `permissions.devJobs`.
// Deleted per `specs/470-dev-platform-plugin/dormant-capabilities.md` §2.
//
// The descriptor/event view types survive CORE-LOCALLY in
// `middleware/src/devplatform/devJobTypes.ts` for the chat dev-job surface,
// which is a host-internal consumer and never crossed this package boundary.
//
// Back-compat: a stale manifest that still declares `permissions.devJobs`
// installs and activates unchanged — unknown permission keys are ignored by
// `adaptManifestV1` and `ctx.devJobs` is simply absent (it was already
// unusable). Regression-tested in `test/manifestDevJobsLegacyKey.test.ts`.
// ---------------------------------------------------------------------------

/** The three job intents a plugin may start (spec §2). */
export type DevJobKind = 'analyze' | 'fix_issue' | 'implement';

/** Job lifecycle status, mirrored from `dev_jobs.status` (spec §2). */
export type DevJobStatus =
| 'queued'
| 'provisioning'
| 'running'
| 'waiting'
| 'applying'
| 'done'
| 'failed'
| 'cancelled'
| 'stalled'
| 'budget_exceeded';

/** Read-only projection of a dev job handed to plugins. Deliberately omits the
* creator, runner token, cost, and raw diff artifacts — the forge PR page is
* the review surface (epic non-goal). */
export interface DevJobDescriptor {
readonly id: string;
readonly repoId: string;
readonly kind: DevJobKind;
readonly status: DevJobStatus;
readonly phase: string;
readonly branch?: string;
readonly prUrl?: string;
readonly createdAt: string;
}

export interface DevJobCreateRequest {
readonly repoId: string;
readonly kind: DevJobKind;
readonly brief: string;
/** Ticket key, e.g. "PROJ-123" — becomes the job's `sourceRef`. */
readonly sourceRef?: string;
}

export interface DevJobEventRecord {
readonly id: number;
/** Server-assigned ordering key (event timestamp, ISO 8601). */
readonly at: string;
readonly type: string;
readonly payload: Record<string, unknown>;
}

/**
* Dev-platform access for plugins (epic #470 W3). Repo ids are host
* `dev_repos` ids; only operator-granted repos (`dev_repo_plugin_grants`)
* resolve — everything else throws a plain `Error` (plugin-api stays
* dependency-free), with an opaque message that does NOT reveal whether an
* out-of-scope repo or job exists. Mirrors `McpAccessor`'s fail-closed
* contract. Gate resolution is deliberately absent from this surface — see the
* `devJobs?` doc on {@link PluginContext}.
*/
export interface DevJobsAccessor {
/** Repo ids the operator has granted to THIS plugin. Everything else is
* invisible. */
listRepos(): Promise<readonly string[]>;
/** Start a dev job on a granted repo. Throws on an ungranted repo. */
create(req: DevJobCreateRequest): Promise<DevJobDescriptor>;
/** Fetch one job. Throws — indistinguishably from not-found — when the job is
* missing OR lives on a repo not granted to this plugin (no existence
* oracle). */
get(jobId: string): Promise<DevJobDescriptor>;
/** List jobs, scoped to granted repos. A `repoId` filter naming an ungranted
* repo throws. */
list(filter?: {
repoId?: string;
status?: DevJobStatus;
}): Promise<readonly DevJobDescriptor[]>;
/** Cursor-poll over the append-only event log — no push subscription in v1
* (SSE stays a host/UI concern). Pass the last-seen `afterId` to page.
* Same repo-grant scoping as `get`. */
listEvents(jobId: string, afterId?: number): Promise<readonly DevJobEventRecord[]>;
/** Cancel a job — only jobs THIS plugin created. Throws on a job created by
* another plugin, and (indistinguishably from not-found) on an
* ungranted/missing job. */
cancel(jobId: string): Promise<void>;
}

export interface LlmCompleteResult {
/** Concatenated text content of the assistant turn. Tool-use finish reasons
* produce empty `text` — plugins should branch on `finishReason` if they
Expand Down
8 changes: 0 additions & 8 deletions middleware/src/api/admin-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,6 @@ export interface PluginPermissionsSummary {
* descriptions of the servers the plugin expects, shown in the grant UI.
* Granting is ALWAYS an explicit operator action. */
mcp_servers_hint?: string[];
/** Epic #470 W3: plugin declares `permissions.devJobs` (true or a block) and
* receives `ctx.devJobs`, scoped to operator-granted repos
* (`dev_repo_plugin_grants`). Loader defaults to `false`. */
dev_jobs?: boolean;
/** Optional author hint (`permissions.devJobs.repos_hint`): repos the plugin
* expects to drive, shown in the operator grant UI. Documentation only —
* granting a repo is ALWAYS an explicit operator action. */
dev_jobs_repos_hint?: string[];
/** Spec 005: true when the manifest declares >=1 `oauth_providers`
* descriptor — the plugin acquires standard authorization-code credentials
* through the kernel OAuth broker (tokens stored + refreshed kernel-side;
Expand Down
40 changes: 14 additions & 26 deletions middleware/src/devplatform/chatDevJobService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,25 @@
* - `isPermittedLauncher` — injected so this module does not import the routes
* layer (avoids a `routes → devplatform` import cycle). Boot passes the
* canonical `devPlatformShared.isPermittedLauncher`.
* - `resolveJobPlacement` — the SAME placement seam boot feeds
* `createDevJobsHostService`, so W0/W2 launcher-admissibility stays a single
* source of truth.
* - `resolveJobPlacement` — the W0/W2 launcher-admissibility seam, so backend
* and authMode selection stays a single source of truth.
*
* Authorization model (documented for the report): a chat session is driven by
* a HUMAN operator, so the correct gate is the **W0 launch-authorization**
* (`isPermittedLauncher`: repo creator OR holder of an `allowed_launchers`
* role) keyed on the session identity — NOT the `ctx.devJobs` per-plugin grant
* model, which keys on a plugin id a chat turn does not have. That launch check
* is further narrowed by the agent-config `allowedRepoIds` envelope. Fail-closed
* throughout: empty grant ⇒ nothing resolves; every read intersects
* `allowedRepoIds ∩ isPermittedLauncher` before returning anything.
* role) keyed on the session identity. That launch check is further narrowed by
* the agent-config `allowedRepoIds` envelope. Fail-closed throughout: empty
* grant ⇒ nothing resolves; every read intersects `allowedRepoIds ∩
* isPermittedLauncher` before returning anything.
*
* Reuse: the READ paths (getJob / listJobs / listJobEvents) delegate to the
* ctx.devJobs host service (`createDevJobsHostService`) — same descriptor
* mapping + in-memory repo scoping. Only `startJob` is bespoke: the host
* service hardcodes `source:'plugin'` + a plugin `createdBy`, so a chat job
* (`source:'chat'`, `createdBy = caller.sub`) is created against the store
* directly, mirroring the admin `POST /jobs` launch path.
* Reuse: the READ paths (getJob / listJobs / listJobEvents) delegate to
* `createDevJobsHostService` — descriptor mapping + repo scoping. Only
* `startJob` is bespoke: it creates a `source:'chat'`, `createdBy = caller.sub`
* job against the store directly, mirroring the admin `POST /jobs` launch path.
*/

import type { DevJobDescriptor, DevJobStatus } from '@omadia/plugin-api';

import type { DevJobsHostService } from '../platform/pluginContext.js';
import type { DevJobDescriptor } from './devJobTypes.js';
import type { DevJobsHostService } from './devJobsHostService.js';

import { createDevJobsHostService } from './devJobsHostService.js';
import type {
Expand All @@ -46,6 +41,7 @@ import type {
DevJob,
DevJobAuthMode,
DevJobEvent,
DevJobStatus,
DevRepo,
NewDevJob,
RunnerBackendKind,
Expand Down Expand Up @@ -113,17 +109,9 @@ export function createChatDevJobService(
deps.mintRunnerToken ?? (() => ({ hash: defaultMintRunnerToken().hash }));
const allowed = new Set(deps.allowedRepoIds);

// Reuse the ctx.devJobs host service for the READ paths. Its plugin-shaped
// create/cancel are never called here, so `grants`/`finalize` are inert stubs.
// Descriptor/event projection for the READ paths.
const host: DevJobsHostService = createDevJobsHostService({
jobStore: deps.jobStore,
repoStore: deps.repoStore,
grants: { listRepoIdsForPlugin: async (): Promise<string[]> => [] },
finalize: async (): Promise<void> => {
throw new Error('chatDevJobService: finalize is not used by the chat surface');
},
resolveJobPlacement: deps.resolveJobPlacement,
...(deps.mintRunnerToken ? { mintRunnerToken: deps.mintRunnerToken } : {}),
});

/** Ids of repos the caller is authorized to launch on: granted ∩ launchable. */
Expand Down
19 changes: 6 additions & 13 deletions middleware/src/devplatform/devJobOrchestratorTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
* Epic #470 W3 §3 — built-in orchestrator tools for dev jobs.
*
* The chat-agent surface that lets a conversational orchestrator START and
* OBSERVE dev jobs. This is the parallel of the plugin `ctx.devJobs` accessor
* (§2) — same underlying stores, different caller identity: a chat turn is
* driven by a HUMAN operator session, not a plugin.
* OBSERVE dev jobs. A chat turn is driven by a HUMAN operator session, so the
* authorization gate is the W0 launch check keyed on that session.
*
* Three tools ship here:
* - `dev_job_start` — create a job on an authorized repo (source `'chat'`).
Expand All @@ -14,8 +13,7 @@
* There is deliberately NO `dev_job_resolve_gate` tool. Per spec §4, gate
* resolution must be attributable to a HUMAN session, never a model turn — the
* live job card calls the W2 gate API (`POST …/gates/:gateId/resolve`)
* directly. Withholding it here is the same reason it is withheld from
* `ctx.devJobs`.
* directly.
*
* Registration mirrors `requestSelfExtensionTool.ts` EXACTLY: this module is a
* factory returning `KernelToolRegistration[]` (`{ name, spec, promptDoc,
Expand All @@ -35,16 +33,11 @@

import { z } from 'zod';

import type {
DevJobDescriptor,
DevJobEventRecord,
DevJobKind,
DevJobStatus,
NativeToolHandler,
NativeToolSpec,
} from '@omadia/plugin-api';
import type { NativeToolHandler, NativeToolSpec } from '@omadia/plugin-api';

import type { DevJobDescriptor, DevJobEventRecord } from './devJobTypes.js';
import { DEV_JOB_STATUSES, isDevJobStatus } from './types.js';
import type { DevJobKind, DevJobStatus } from './types.js';

// ---------------------------------------------------------------------------
// Tool names + schemas (spec §3). `dev_job_start` / `dev_job_status` names and
Expand Down
46 changes: 46 additions & 0 deletions middleware/src/devplatform/devJobTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Dev-platform view types shared by the core dev-job surfaces.
*
* These used to live on the published `@omadia/plugin-api` surface because they
* were the wire shape of `ctx.devJobs`. That accessor is gone (epic #470 —
* `specs/470-dev-platform-plugin/dormant-capabilities.md` §2: zero providers,
* zero consumers, threw on every call), so the types are no longer part of any
* plugin contract. They stay CORE-LOCAL and travel with the dev-platform tree
* when it moves to its own repo — deliberately NOT a new published package,
* which would be the same speculative generality the accessor was.
*
* Consumers today: `devJobsHostService.ts`, `chatDevJobService.ts`,
* `devJobOrchestratorTool.ts`.
*
* `DevJobKind` / `DevJobStatus` are NOT redefined here — `./types.ts` already
* owns them as the single source of truth (derived from the `as const` arrays
* that also back the runtime validators). They are re-exported so a consumer
* needs one import for the whole descriptor vocabulary.
*/

export type { DevJobKind, DevJobStatus } from './types.js';

import type { DevJobKind, DevJobStatus } from './types.js';

/** Read-only projection of a dev job. Deliberately omits the creator, runner
* token, cost, and raw diff artifacts — the forge PR page is the review
* surface (epic non-goal). */
export interface DevJobDescriptor {
readonly id: string;
readonly repoId: string;
readonly kind: DevJobKind;
readonly status: DevJobStatus;
readonly phase: string;
readonly branch?: string;
readonly prUrl?: string;
readonly createdAt: string;
}

/** One entry of the append-only job event log, projected for a reader. */
export interface DevJobEventRecord {
readonly id: number;
/** Server-assigned ordering key (event timestamp, ISO 8601). */
readonly at: string;
readonly type: string;
readonly payload: Record<string, unknown>;
}
Loading
Loading