diff --git a/middleware/migrations/0024_dev_platform_w3.sql b/middleware/migrations/0024_dev_platform_w3.sql index f90d4f34..67023644 100644 --- a/middleware/migrations/0024_dev_platform_w3.sql +++ b/middleware/migrations/0024_dev_platform_w3.sql @@ -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, diff --git a/middleware/migrations/0025_dev_jobs_source_plugin.sql b/middleware/migrations/0025_dev_jobs_source_plugin.sql index 30e57f66..2313d05b 100644 --- a/middleware/migrations/0025_dev_jobs_source_plugin.sql +++ b/middleware/migrations/0025_dev_jobs_source_plugin.sql @@ -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; diff --git a/middleware/packages/plugin-api/src/pluginContext.ts b/middleware/packages/plugin-api/src/pluginContext.ts index 7db19cd3..45b7c442 100644 --- a/middleware/packages/plugin-api/src/pluginContext.ts +++ b/middleware/packages/plugin-api/src/pluginContext.ts @@ -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: @@ -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; -} - -/** - * 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; - /** Start a dev job on a granted repo. Throws on an ungranted repo. */ - create(req: DevJobCreateRequest): Promise; - /** 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; - /** List jobs, scoped to granted repos. A `repoId` filter naming an ungranted - * repo throws. */ - list(filter?: { - repoId?: string; - status?: DevJobStatus; - }): Promise; - /** 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; - /** 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; -} - export interface LlmCompleteResult { /** Concatenated text content of the assistant turn. Tool-use finish reasons * produce empty `text` — plugins should branch on `finishReason` if they diff --git a/middleware/src/api/admin-v1.ts b/middleware/src/api/admin-v1.ts index 881d0aa5..b15df4f0 100644 --- a/middleware/src/api/admin-v1.ts +++ b/middleware/src/api/admin-v1.ts @@ -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; diff --git a/middleware/src/devplatform/chatDevJobService.ts b/middleware/src/devplatform/chatDevJobService.ts index 601433dc..98ddb9cb 100644 --- a/middleware/src/devplatform/chatDevJobService.ts +++ b/middleware/src/devplatform/chatDevJobService.ts @@ -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 { @@ -46,6 +41,7 @@ import type { DevJob, DevJobAuthMode, DevJobEvent, + DevJobStatus, DevRepo, NewDevJob, RunnerBackendKind, @@ -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 => [] }, - finalize: async (): Promise => { - 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. */ diff --git a/middleware/src/devplatform/devJobOrchestratorTool.ts b/middleware/src/devplatform/devJobOrchestratorTool.ts index 1d7c1bf6..f6ede314 100644 --- a/middleware/src/devplatform/devJobOrchestratorTool.ts +++ b/middleware/src/devplatform/devJobOrchestratorTool.ts @@ -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'`). @@ -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, @@ -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 diff --git a/middleware/src/devplatform/devJobTypes.ts b/middleware/src/devplatform/devJobTypes.ts new file mode 100644 index 00000000..783f957a --- /dev/null +++ b/middleware/src/devplatform/devJobTypes.ts @@ -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; +} diff --git a/middleware/src/devplatform/devJobsHostService.ts b/middleware/src/devplatform/devJobsHostService.ts index 787a0f38..bd6f6636 100644 --- a/middleware/src/devplatform/devJobsHostService.ts +++ b/middleware/src/devplatform/devJobsHostService.ts @@ -1,60 +1,35 @@ /** - * Epic #470 W3 — concrete 'devJobs' host service backing `ctx.devJobs`. + * Read-side dev-job service: descriptor/event projection over `DevJobStore`. * - * Registered in the kernel ServiceRegistry under the name `'devJobs'` by the - * dev-platform boot module; the plugin-side `createPluginDevJobsAccessor` - * resolves it lazily and layers the repo-scoping / no-existence-oracle contract - * on top (see src/platform/pluginContext.ts). + * HISTORY — this used to be the concrete `'devJobs'` host service backing the + * `ctx.devJobs` plugin accessor. That accessor never had a provider or a + * consumer and threw on every call, so it was deleted + * (`specs/470-dev-platform-plugin/dormant-capabilities.md` §2). What survived + * is exactly the part the chat surface already used. * - * This layer owns the two facts the accessor cannot see from a descriptor: - * 1. the granted-repo set (`dev_repo_plugin_grants`, via the grant store), and - * 2. the job creator (`dev_jobs.created_by`), used to enforce "only jobs this - * plugin created" on cancel. + * Shed with the accessor, because every one of them existed only for the + * plugin path: + * - `listGrantedRepoIds` + the `grants` dep (`dev_repo_plugin_grants`) + * - `createJob` (hardcoded `source:'plugin'` / `created_by:'plugin:'`) + * and with it the `repoStore`, `resolveJobPlacement` and `mintRunnerToken` + * deps — the chat surface creates jobs itself with `source:'chat'` + * - `cancelJob(jobId, requestedByPluginId)` and its `finalize` dep — the + * `requestedByPluginId` creator check has no meaning without plugin + * callers, and the chat surface never cancelled through here (it passed an + * always-throwing `finalize` stub) * - * Job-placement policy (backend / authMode admissibility) is injected as - * `resolveJobPlacement` so W0/W2 keep a single source of truth for it — this - * unit does not re-implement launcher policy. + * The sole consumer is `chatDevJobService.ts`, which calls `getJob`, + * `listJobs` and `listJobEvents`. Its own authorization envelope + * (`allowedRepoIds` ∩ `isPermittedLauncher`) sits ON TOP of this layer — this + * unit performs no authorization of its own beyond honouring the `repoIds` + * scope it is handed. */ -import type { - DevJobCreateRequest, - DevJobDescriptor, - DevJobEventRecord, - DevJobStatus, -} from '@omadia/plugin-api'; +import type { DevJobDescriptor, DevJobEventRecord } from './devJobTypes.js'; +import type { DevJob, DevJobEvent, DevJobStatus } from './types.js'; -import type { DevJobsHostService } from '../platform/pluginContext.js'; - -import { mintRunnerToken as defaultMintRunnerToken } from './jobToken.js'; -import type { - DevJob, - DevJobAuthMode, - DevJobEvent, - DevRepo, - NewDevJob, - RunnerBackendKind, -} from './types.js'; - -/** `dev_jobs.created_by` marker for a plugin-created job. The `source='plugin'` - * column records provenance; this marker records WHICH plugin, so cancel can - * enforce single-creator ownership. */ -export const PLUGIN_CREATED_BY_PREFIX = 'plugin:'; - -export function pluginCreatedByMarker(pluginId: string): string { - return `${PLUGIN_CREATED_BY_PREFIX}${pluginId}`; -} - -/** Thrown by `cancelJob` when a plugin tries to cancel a job it did not create. */ -export class DevJobNotCreatedByPluginError extends Error { - constructor(jobId: string, pluginId: string) { - super(`dev job "${jobId}" was not created by plugin "${pluginId}"`); - this.name = 'DevJobNotCreatedByPluginError'; - } -} - -/** Narrow read/write surface of `DevJobStore` this service needs. */ +/** Narrow read surface of `DevJobStore` this service needs. */ export interface DevJobsHostJobStore { - createJob(input: NewDevJob & { runnerTokenHash: string }): Promise; getJob(id: string): Promise; listJobs(filter?: { repoId?: string; @@ -65,32 +40,24 @@ export interface DevJobsHostJobStore { listEvents(jobId: string, afterId?: number, limit?: number): Promise; } -export interface DevJobsHostRepoStore { - getRepo(id: string): Promise; -} - -export interface DevJobsHostGrantStore { - listRepoIdsForPlugin(pluginId: string): Promise; +/** + * Repo-scoped read surface over dev jobs. `listJobs` is scoped by the caller; + * `getJob` / `listJobEvents` are UNSCOPED by design — the caller resolves the + * descriptor's `repoId` against its own authorization envelope (see + * `chatDevJobService.getJob`). + */ +export interface DevJobsHostService { + getJob(jobId: string): Promise; + /** Scope is narrowed to `repoIds` by the caller. */ + listJobs(filter: { + repoIds: readonly string[]; + status?: DevJobStatus; + }): Promise; + listJobEvents(jobId: string, afterId?: number): Promise; } export interface DevJobsHostServiceDeps { jobStore: DevJobsHostJobStore; - repoStore: DevJobsHostRepoStore; - grants: DevJobsHostGrantStore; - /** Bound terminal-transition choke point (finalizeDevJob) used by cancel. */ - finalize: ( - jobId: string, - status: DevJobStatus, - ctx: { reason?: string }, - ) => Promise; - /** Resolve backend + authMode for a plugin-created job on this repo. Injected - * so W0/W2 launcher-admissibility policy stays authoritative in one place. */ - resolveJobPlacement: (repo: DevRepo) => { - backend: RunnerBackendKind; - authMode?: DevJobAuthMode; - }; - /** Override for tests; defaults to the real one-time token minter. */ - mintRunnerToken?: () => { hash: string }; } function toDescriptor(j: DevJob): DevJobDescriptor { @@ -113,36 +80,7 @@ function toEventRecord(e: DevJobEvent): DevJobEventRecord { export function createDevJobsHostService( deps: DevJobsHostServiceDeps, ): DevJobsHostService { - const mint = deps.mintRunnerToken ?? (() => ({ hash: defaultMintRunnerToken().hash })); - return { - async listGrantedRepoIds(pluginId: string): Promise { - return deps.grants.listRepoIdsForPlugin(pluginId); - }, - - async createJob( - input: DevJobCreateRequest & { createdBy: { kind: 'plugin'; id: string } }, - ): Promise { - const repo = await deps.repoStore.getRepo(input.repoId); - if (!repo) { - throw new Error(`dev repo "${input.repoId}" not found`); - } - const placement = deps.resolveJobPlacement(repo); - const minted = mint(); - const job = await deps.jobStore.createJob({ - repoId: input.repoId, - kind: input.kind, - brief: input.brief, - source: 'plugin', - sourceRef: input.sourceRef ?? null, - backend: placement.backend, - authMode: placement.authMode ?? 'api_key', - createdBy: pluginCreatedByMarker(input.createdBy.id), - runnerTokenHash: minted.hash, - }); - return toDescriptor(job); - }, - async getJob(jobId: string): Promise { const job = await deps.jobStore.getJob(jobId); return job ? toDescriptor(job) : undefined; @@ -154,11 +92,11 @@ export function createDevJobsHostService( }): Promise { const scope = new Set(filter.repoIds); if (scope.size === 0) return []; - // Scope to the granted repos IN SQL (before LIMIT), so a caller's own jobs - // can never be silently dropped behind other repos' rows under the store - // limit (Forge W3 — the earlier list-all-then-narrow could omit them). The - // in-memory `scope` filter is kept as defense-in-depth against a store that - // ignores the filter. + // Scope to the caller's repos IN SQL (before LIMIT), so a caller's own + // jobs can never be silently dropped behind other repos' rows under the + // store limit (Forge W3 — the earlier list-all-then-narrow could omit + // them). The in-memory `scope` filter is kept as defense-in-depth against + // a store that ignores the filter. const jobs = await deps.jobStore.listJobs({ repoIds: [...scope], ...(filter.status ? { status: filter.status } : {}), @@ -173,20 +111,5 @@ export function createDevJobsHostService( const events = await deps.jobStore.listEvents(jobId, afterId); return events.map(toEventRecord); }, - - async cancelJob(jobId: string, requestedByPluginId: string): Promise { - const job = await deps.jobStore.getJob(jobId); - if (!job) { - throw new Error(`dev job "${jobId}" not found`); - } - // Single-creator ownership: only the plugin that created the job may - // cancel it. The accessor has already confirmed the repo is granted. - if (job.createdBy !== pluginCreatedByMarker(requestedByPluginId)) { - throw new DevJobNotCreatedByPluginError(jobId, requestedByPluginId); - } - await deps.finalize(jobId, 'cancelled', { - reason: `cancelled by plugin ${requestedByPluginId}`, - }); - }, }; } diff --git a/middleware/src/devplatform/devRepoPluginGrantStore.ts b/middleware/src/devplatform/devRepoPluginGrantStore.ts deleted file mode 100644 index d96d96e0..00000000 --- a/middleware/src/devplatform/devRepoPluginGrantStore.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Epic #470 W3 — operator grants that gate `ctx.devJobs` (spec §2). - * - * A row in `dev_repo_plugin_grants` (migration 0024) means "the operator has - * allowed plugin P to drive dev jobs on repo R". The `ctx.devJobs` accessor - * resolves ONLY the repos a plugin is granted; everything else fails closed. - * Granting/revoking is always an explicit operator action (the admin - * `/admin/dev-platform` surface, wired in a sibling W3 unit) — never something - * a plugin can do to itself. - */ - -import type { Pool } from 'pg'; - -import { iso, str, type Row } from './pgMappers.js'; - -export interface DevRepoPluginGrant { - readonly repoId: string; - readonly pluginId: string; - readonly grantedBy: string; - readonly createdAt: string; -} - -function toGrant(r: Row): DevRepoPluginGrant { - return { - repoId: str(r['repo_id']), - pluginId: str(r['plugin_id']), - grantedBy: str(r['granted_by']), - createdAt: iso(r['created_at']), - }; -} - -export class DevRepoPluginGrantStore { - constructor(private readonly pool: Pool) {} - - /** Repo ids granted to a plugin — the fail-closed set the accessor scopes to. */ - async listRepoIdsForPlugin(pluginId: string): Promise { - const r = await this.pool.query( - `SELECT repo_id FROM dev_repo_plugin_grants WHERE plugin_id = $1 - ORDER BY created_at ASC`, - [pluginId], - ); - return r.rows.map((row) => str(row['repo_id'])); - } - - /** Plugin ids granted on a repo — for the operator grant UI. */ - async listGrantsForRepo(repoId: string): Promise { - const r = await this.pool.query( - `SELECT repo_id, plugin_id, granted_by, created_at - FROM dev_repo_plugin_grants WHERE repo_id = $1 ORDER BY created_at ASC`, - [repoId], - ); - return r.rows.map(toGrant); - } - - async isGranted(repoId: string, pluginId: string): Promise { - const r = await this.pool.query( - `SELECT 1 FROM dev_repo_plugin_grants WHERE repo_id = $1 AND plugin_id = $2`, - [repoId, pluginId], - ); - return (r.rowCount ?? 0) > 0; - } - - /** Idempotent grant (UNIQUE(repo_id, plugin_id) — a repeat is a no-op). */ - async grant(repoId: string, pluginId: string, grantedBy: string): Promise { - await this.pool.query( - `INSERT INTO dev_repo_plugin_grants (repo_id, plugin_id, granted_by) - VALUES ($1, $2, $3) - ON CONFLICT (repo_id, plugin_id) DO NOTHING`, - [repoId, pluginId, grantedBy], - ); - } - - async revoke(repoId: string, pluginId: string): Promise { - await this.pool.query( - `DELETE FROM dev_repo_plugin_grants WHERE repo_id = $1 AND plugin_id = $2`, - [repoId, pluginId], - ); - } -} diff --git a/middleware/src/devplatform/types.ts b/middleware/src/devplatform/types.ts index c9f4f02a..2ed6d9f5 100644 --- a/middleware/src/devplatform/types.ts +++ b/middleware/src/devplatform/types.ts @@ -94,9 +94,10 @@ export const DEV_JOB_SOURCES = [ 'webhook', 'schedule', 'tracker', - // Epic #470 W3 — job started by a plugin via `ctx.devJobs.create`. Paired - // with `created_by = 'plugin:'` so cancel can enforce "only jobs - // this plugin created". CHECK relaxed in migration 0025. + // ORPHANED. Written only by the `ctx.devJobs` accessor's host `createJob`, + // both of which were deleted (dormant-capabilities.md §2) — nothing can + // produce this value any more. Retained so a hypothetical pre-existing row + // still validates; migration 0025's CHECK is likewise forward-only. 'plugin', ] as const; export type DevJobSource = (typeof DEV_JOB_SOURCES)[number]; diff --git a/middleware/src/platform/pluginContext.ts b/middleware/src/platform/pluginContext.ts index a901afca..91b0b7db 100644 --- a/middleware/src/platform/pluginContext.ts +++ b/middleware/src/platform/pluginContext.ts @@ -29,11 +29,6 @@ import { type LlmProvider, type McpAccessor, type McpAccessorToolDescriptor, - type DevJobsAccessor, - type DevJobCreateRequest, - type DevJobDescriptor, - type DevJobEventRecord, - type DevJobStatus, type MemoryAccessor, type MemoryStore, type MigrationContext, @@ -753,17 +748,6 @@ export function createPluginContext( ? createPluginMcpAccessor(agentId, serviceRegistry) : undefined; - // Epic #470 W3 — ctx.devJobs: present iff the manifest declares - // permissions.devJobs. The backing host service ('devJobs') is resolved - // LAZILY per call (mirrors ctx.mcp); grants are read live so an operator - // revoke applies without re-activation. Scoped to operator-granted repos — - // fail-closed on everything else. - const devJobsAllowed = - catalog.get(agentId)?.plugin.permissions_summary.dev_jobs === true; - const devJobs: DevJobsAccessor | undefined = devJobsAllowed - ? createPluginDevJobsAccessor(agentId, serviceRegistry) - : undefined; - const eventsAllowed = catalog.get(agentId)?.plugin.permissions_summary.events_emit === true; const events: EventsAccessor | undefined = eventsAllowed ? { @@ -799,7 +783,6 @@ export function createPluginContext( ...(knowledgeGraph ? { knowledgeGraph } : {}), ...(llm ? { llm } : {}), ...(mcp ? { mcp } : {}), - ...(devJobs ? { devJobs } : {}), ...(flows ? { flows } : {}), ...(oauthTokens ? { oauthTokens } : {}), ...(events ? { events } : {}), @@ -912,126 +895,6 @@ export function createPluginMcpAccessor( }; } -// --------------------------------------------------------------------------- -// Epic #470 W3 — ctx.devJobs host service + plugin accessor. -// --------------------------------------------------------------------------- - -/** - * Host service registered by the W0 dev-platform module under 'devJobs' (epic - * #470 W3). The plugin accessor resolves it lazily per call. Grant resolution - * (`listGrantedRepoIds`) and creator enforcement (`cancelJob`) live here - * because they need DB-level `dev_repo_plugin_grants` / `dev_jobs.created_by` - * access; the accessor layers the repo-scoping / no-oracle contract on top. - */ -export interface DevJobsHostService { - /** Repo ids the operator granted to this plugin (`dev_repo_plugin_grants`). */ - listGrantedRepoIds(pluginId: string): Promise; - createJob( - input: DevJobCreateRequest & { createdBy: { kind: 'plugin'; id: string } }, - ): Promise; - getJob(jobId: string): Promise; - /** Scope is already narrowed to `repoIds` by the accessor. */ - listJobs(filter: { - repoIds: readonly string[]; - status?: DevJobStatus; - }): Promise; - listJobEvents(jobId: string, afterId?: number): Promise; - /** Cancel a job created by `requestedByPluginId`. The host enforces the - * creator match and throws when the job was created by another plugin. */ - cancelJob(jobId: string, requestedByPluginId: string): Promise; -} - -/** - * Exported for direct unit testing (mirrors {@link createPluginMcpAccessor}). - * - * Fail-closed contract: - * - `listRepos` returns only the operator-granted repo ids for this plugin. - * - `create`/`list` on an ungranted repo throw. - * - `get`/`listEvents`/`cancel` resolve the job first; a job that is missing - * OR lives on an ungranted repo raises the SAME opaque error — no - * existence oracle. - * - `cancel` additionally forwards to `host.cancelJob(jobId, pluginId)`, - * which rejects jobs this plugin did not create. - */ -export function createPluginDevJobsAccessor( - pluginId: string, - serviceRegistry: { get(name: string): T | undefined }, -): DevJobsAccessor { - const host = (): DevJobsHostService => { - const service = serviceRegistry.get('devJobs'); - if (!service) { - throw new Error( - 'dev-platform host service unavailable — the core did not wire ctx.devJobs', - ); - } - return service; - }; - const grantedSet = async (): Promise> => - new Set(await host().listGrantedRepoIds(pluginId)); - const requireGrantedRepo = async (repoId: string): Promise => { - if (!(await grantedSet()).has(repoId)) { - // Fail closed; the message never reveals whether the repo exists. - throw new Error(`dev repo "${repoId}" is not granted to plugin "${pluginId}"`); - } - }; - // Resolve a job ONLY when it lives on a granted repo. A missing job and an - // out-of-scope job raise the SAME error, so a plugin cannot probe existence. - const requireAccessibleJob = async (jobId: string): Promise => { - const svc = host(); - const job = await svc.getJob(jobId); - const granted = await grantedSet(); - if (!job || !granted.has(job.repoId)) { - throw new Error(`dev job "${jobId}" is not accessible to plugin "${pluginId}"`); - } - return job; - }; - return { - async listRepos(): Promise { - return host().listGrantedRepoIds(pluginId); - }, - async create(req: DevJobCreateRequest): Promise { - await requireGrantedRepo(req.repoId); - return host().createJob({ ...req, createdBy: { kind: 'plugin', id: pluginId } }); - }, - async get(jobId: string): Promise { - return requireAccessibleJob(jobId); - }, - async list(filter?: { - repoId?: string; - status?: DevJobStatus; - }): Promise { - const granted = await grantedSet(); - let repoIds: readonly string[]; - if (filter?.repoId !== undefined) { - if (!granted.has(filter.repoId)) { - throw new Error( - `dev repo "${filter.repoId}" is not granted to plugin "${pluginId}"`, - ); - } - repoIds = [filter.repoId]; - } else { - repoIds = [...granted]; - } - return host().listJobs({ - repoIds, - ...(filter?.status ? { status: filter.status } : {}), - }); - }, - async listEvents( - jobId: string, - afterId?: number, - ): Promise { - await requireAccessibleJob(jobId); - return host().listJobEvents(jobId, afterId); - }, - async cancel(jobId: string): Promise { - // Repo-grant scoping first (no existence oracle); then the host enforces - // the "only jobs this plugin created" rule via created_by. - await requireAccessibleJob(jobId); - await host().cancelJob(jobId, pluginId); - }, - }; -} interface SubAgentPermissions { /** Whitelisted target agentIds. Wildcards (`'de.byte5.agent.*'`) match diff --git a/middleware/src/plugins/manifestLoader.ts b/middleware/src/plugins/manifestLoader.ts index cf6ef477..97094d04 100644 --- a/middleware/src/plugins/manifestLoader.ts +++ b/middleware/src/plugins/manifestLoader.ts @@ -671,14 +671,12 @@ function extractPermissions( const mcpBlock = permissions?.['mcp']; const mcpDeclared = mcpBlock === true || (typeof mcpBlock === 'object' && mcpBlock !== null); - // Epic #470 W3 — ctx.devJobs gate. `permissions.devJobs: true` or a block - // ({ repos_hint: [...] }) opts in; absent → no accessor. The repos_hint is - // documentation for the operator grant UI, not enforcement (the real grant - // lives in dev_repo_plugin_grants). - const devJobsBlock = permissions?.['devJobs']; - const devJobsDeclared = - devJobsBlock === true || - (typeof devJobsBlock === 'object' && devJobsBlock !== null); + // NOTE: `permissions.devJobs` is no longer parsed — `ctx.devJobs` was deleted + // (see specs/470-dev-platform-plugin/dormant-capabilities.md §2). A stale + // manifest that still declares it stays installable and activatable: unknown + // permission keys are simply ignored here, so the plugin loads unchanged and + // just receives no accessor (it never had a working one). Regression-tested + // in `test/manifestDevJobsLegacyKey.test.ts`. return { memory_reads: extractStringArray(memory?.['reads']), memory_writes: extractStringArray(memory?.['writes']), @@ -704,8 +702,6 @@ function extractPermissions( events_emit: asRecord(permissions?.['events'])?.['emit'] === true, mcp: mcpDeclared, mcp_servers_hint: extractStringArray(asRecord(mcpBlock)?.['servers_hint']), - dev_jobs: devJobsDeclared, - dev_jobs_repos_hint: extractStringArray(asRecord(devJobsBlock)?.['repos_hint']), // Spec 005 — overridden to true in adaptManifestV1 when the manifest // declares >=1 valid oauth_providers descriptor. acquires_oauth: false, diff --git a/middleware/test/devplatform/devJobOrchestratorTool.test.ts b/middleware/test/devplatform/devJobOrchestratorTool.test.ts index 692c0950..0287ea98 100644 --- a/middleware/test/devplatform/devJobOrchestratorTool.test.ts +++ b/middleware/test/devplatform/devJobOrchestratorTool.test.ts @@ -5,7 +5,7 @@ import type { DevJobDescriptor, DevJobEventRecord, DevJobStatus, -} from '@omadia/plugin-api'; +} from '../../src/devplatform/devJobTypes.js'; import { createChatDevJobService, diff --git a/middleware/test/manifestDevJobsLegacyKey.test.ts b/middleware/test/manifestDevJobsLegacyKey.test.ts new file mode 100644 index 00000000..97a23139 --- /dev/null +++ b/middleware/test/manifestDevJobsLegacyKey.test.ts @@ -0,0 +1,144 @@ +/** + * Backward compatibility for the deleted `ctx.devJobs` surface + * (specs/470-dev-platform-plugin/dormant-capabilities.md §2). + * + * A plugin published before the deletion may still declare + * `permissions.devJobs` in its manifest. Such a plugin MUST keep installing and + * activating exactly as before, with `ctx.devJobs` simply absent — it was + * already unusable, because nothing ever provided the backing host service and + * every call threw. + * + * Unknown permission keys are silently ignored by `adaptManifestV1` today. That + * is the entire back-compat guarantee, and it is implicit — nothing in the + * loader states it. These tests assert it EXPLICITLY, so a future move to + * strict manifest validation cannot silently start rejecting stale manifests. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { Plugin } from '../src/api/admin-v1.js'; +import { adaptManifestV1 } from '../src/plugins/manifestLoader.js'; +import type { + PluginCatalog, + PluginCatalogEntry, +} from '../src/plugins/manifestLoader.js'; +import { createPluginContext } from '../src/platform/pluginContext.js'; +import { ServiceRegistry } from '../src/platform/serviceRegistry.js'; + +const LEGACY_ID = 'de.byte5.integration.legacy-devjobs'; + +function manifest(permissions: Record): Record { + return { + schema_version: '1', + identity: { + id: LEGACY_ID, + kind: 'integration', + domain: 'test', + name: 'Legacy devJobs Plugin', + version: '1.0.0', + }, + permissions, + }; +} + +describe('legacy permissions.devJobs manifests stay loadable', () => { + it('adapts a manifest declaring `permissions.devJobs: true` without rejecting it', () => { + const plugin = adaptManifestV1(manifest({ devJobs: true })); + assert.ok(plugin, 'a stale devJobs manifest must still adapt to a Plugin'); + assert.equal(plugin.id, LEGACY_ID); + }); + + it('adapts the block form (`{ repos_hint: [...] }`) too', () => { + const plugin = adaptManifestV1( + manifest({ devJobs: { repos_hint: ['omadia/omadia'] } }), + ); + assert.ok(plugin); + assert.equal(plugin.id, LEGACY_ID); + }); + + it('emits no dev_jobs field on permissions_summary — the key is ignored, not mapped', () => { + const plugin = adaptManifestV1(manifest({ devJobs: true })); + assert.ok(plugin); + const summary = plugin.permissions_summary as Record; + assert.equal( + 'dev_jobs' in summary, + false, + 'permissions_summary must not carry dev_jobs any more', + ); + assert.equal('dev_jobs_repos_hint' in summary, false); + }); + + it('does not disturb the permission keys that ARE still parsed', () => { + const plugin = adaptManifestV1( + manifest({ devJobs: true, flows: true, mcp: true }), + ); + assert.ok(plugin); + assert.equal(plugin.permissions_summary.flows, true); + assert.equal( + (plugin.permissions_summary as Record)['mcp'], + true, + ); + }); + + it('builds an activation context with no devJobs accessor', () => { + const plugin = adaptManifestV1(manifest({ devJobs: true })); + assert.ok(plugin); + const ctx = createPluginContext({ + agentId: LEGACY_ID, + vault: { + get: async () => undefined, + listKeys: async () => [], + } as unknown as Parameters[0]['vault'], + registry: { + has: () => true, + list: () => [], + get: () => undefined, + } as unknown as Parameters[0]['registry'], + catalog: catalogOf(plugin), + serviceRegistry: new ServiceRegistry(), + nativeToolRegistry: { + register: () => () => {}, + registerHandler: () => () => {}, + } as unknown as Parameters< + typeof createPluginContext + >[0]['nativeToolRegistry'], + routeRegistry: { + register: () => () => {}, + list: () => [], + disposeBySource: () => 0, + } as unknown as Parameters[0]['routeRegistry'], + jobScheduler: { + register: () => () => {}, + stopForPlugin: () => {}, + } as unknown as Parameters[0]['jobScheduler'], + logger: () => {}, + }); + assert.equal( + (ctx as Record)['devJobs'], + undefined, + 'ctx.devJobs must be absent for a stale manifest — no throw, no accessor', + ); + // And the rest of the context is intact: the plugin activates normally. + assert.equal(ctx.agentId, LEGACY_ID); + assert.equal(typeof ctx.services.get, 'function'); + assert.equal( + ctx.services.get('devJobs'), + undefined, + 'no provider registers devJobs, so the service route yields nothing either', + ); + }); +}); + +function catalogOf(plugin: Plugin): PluginCatalog { + const entry = { + plugin, + manifest: {}, + source_path: `/abs/${plugin.id}/manifest.yaml`, + source_kind: 'manifest-v1', + } as unknown as PluginCatalogEntry; + return { + list: () => [entry], + get: (q: string) => (q === plugin.id ? entry : undefined), + } as unknown as PluginCatalog; +} diff --git a/middleware/test/pluginDevJobsAccessor.test.ts b/middleware/test/pluginDevJobsAccessor.test.ts deleted file mode 100644 index 959a72e2..00000000 --- a/middleware/test/pluginDevJobsAccessor.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Epic #470 W3 — unit test for the ctx.devJobs plugin accessor (spec §2/§10). - * Mirrors test/pluginMcpAccessor.test.ts: a stubbed 'devJobs' host service in a - * fake service registry, exercising the fail-closed / no-existence-oracle - * contract in `createPluginDevJobsAccessor` directly (no DB). - */ -import { describe, it } from 'node:test'; -import { strict as assert } from 'node:assert'; - -import type { - DevJobCreateRequest, - DevJobDescriptor, - DevJobEventRecord, - DevJobStatus, -} from '@omadia/plugin-api'; - -import { - createPluginDevJobsAccessor, - type DevJobsHostService, -} from '../src/platform/pluginContext.js'; - -const PLUGIN = '@omadia/integration-example'; -const OTHER_PLUGIN = '@omadia/integration-other'; -const REPO_GRANTED = 'repo-granted'; -const REPO_UNGRANTED = 'repo-ungranted'; - -interface StubJob { - descriptor: DevJobDescriptor; - creatorPluginId: string; - events: DevJobEventRecord[]; -} - -interface CreateCall { - input: DevJobCreateRequest & { createdBy: { kind: 'plugin'; id: string } }; -} - -function desc(id: string, repoId: string, over: Partial = {}): DevJobDescriptor { - return { - id, - repoId, - kind: 'fix_issue', - status: 'queued', - phase: 'analyze', - createdAt: '2026-07-11T00:00:00.000Z', - ...over, - }; -} - -/** A stub 'devJobs' host service modelling the real grant + creator semantics - * the accessor relies on. */ -function makeHost(opts: { - grants: Record; - jobs?: StubJob[]; - createCalls?: CreateCall[]; - cancelled?: string[]; -}): DevJobsHostService { - const jobs = new Map((opts.jobs ?? []).map((j) => [j.descriptor.id, j])); - return { - async listGrantedRepoIds(pluginId) { - return opts.grants[pluginId] ?? []; - }, - async createJob(input) { - opts.createCalls?.push({ input }); - const d = desc(`job-${jobs.size + 1}`, input.repoId, { kind: input.kind }); - jobs.set(d.id, { descriptor: d, creatorPluginId: input.createdBy.id, events: [] }); - return d; - }, - async getJob(jobId) { - return jobs.get(jobId)?.descriptor; - }, - async listJobs(filter) { - const scope = new Set(filter.repoIds); - return [...jobs.values()] - .map((j) => j.descriptor) - .filter((d) => scope.has(d.repoId)) - .filter((d) => (filter.status ? d.status === filter.status : true)); - }, - async listJobEvents(jobId, afterId) { - const j = jobs.get(jobId); - if (!j) return []; - return j.events.filter((e) => (afterId === undefined ? true : e.id > afterId)); - }, - async cancelJob(jobId, requestedByPluginId) { - const j = jobs.get(jobId); - if (!j) throw new Error(`dev job "${jobId}" not found`); - if (j.creatorPluginId !== requestedByPluginId) { - throw new Error(`dev job "${jobId}" was not created by plugin "${requestedByPluginId}"`); - } - opts.cancelled?.push(jobId); - }, - }; -} - -function registryOf(host: DevJobsHostService): { get(name: string): T | undefined } { - return { get: (name: string) => (name === 'devJobs' ? (host as T) : undefined) }; -} - -describe('createPluginDevJobsAccessor (#470 W3)', () => { - it('listRepos returns exactly the granted repo ids', async () => { - const host = makeHost({ grants: { [PLUGIN]: [REPO_GRANTED] } }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - assert.deepEqual(await accessor.listRepos(), [REPO_GRANTED]); - }); - - it('create on a granted repo tags the job with plugin creator attribution', async () => { - const createCalls: CreateCall[] = []; - const host = makeHost({ grants: { [PLUGIN]: [REPO_GRANTED] }, createCalls }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - const job = await accessor.create({ repoId: REPO_GRANTED, kind: 'analyze', brief: 'do the thing' }); - assert.equal(job.repoId, REPO_GRANTED); - assert.deepEqual(createCalls[0]?.input.createdBy, { kind: 'plugin', id: PLUGIN }); - }); - - it('create on an ungranted repo fails closed', async () => { - const host = makeHost({ grants: { [PLUGIN]: [REPO_GRANTED] } }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - await assert.rejects( - accessor.create({ repoId: REPO_UNGRANTED, kind: 'fix_issue', brief: 'x'.repeat(20) }), - /not granted/, - ); - }); - - it('get returns a job on a granted repo', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('j1', REPO_GRANTED), creatorPluginId: PLUGIN, events: [] }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - assert.equal((await accessor.get('j1')).id, 'j1'); - }); - - it('no existence oracle: a missing job and an out-of-scope job throw the SAME error', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('on-b', REPO_UNGRANTED), creatorPluginId: PLUGIN, events: [] }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - const missingMsg = await accessor.get('does-not-exist').then( - () => 'NO_THROW', - (e: Error) => e.message, - ); - const outOfScopeMsg = await accessor.get('on-b').then( - () => 'NO_THROW', - (e: Error) => e.message, - ); - assert.match(missingMsg, /not accessible/); - // No existence oracle: the message TEMPLATE must be identical whether the job - // is missing or exists-but-ungranted — only the caller's own id (which they - // already know) differs. Normalise the id out before comparing. - const normalizeId = (m: string): string => m.replace(/dev job "[^"]*"/, 'dev job ""'); - assert.equal(normalizeId(missingMsg), normalizeId(outOfScopeMsg)); - }); - - it('list scopes to granted repos; a filter naming an ungranted repo throws', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [ - { descriptor: desc('g1', REPO_GRANTED), creatorPluginId: PLUGIN, events: [] }, - { descriptor: desc('u1', REPO_UNGRANTED), creatorPluginId: OTHER_PLUGIN, events: [] }, - ], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - const all = await accessor.list(); - assert.deepEqual(all.map((d) => d.id), ['g1']); - await assert.rejects(accessor.list({ repoId: REPO_UNGRANTED }), /not granted/); - }); - - it('list passes a status filter through', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [ - { descriptor: desc('run', REPO_GRANTED, { status: 'running' }), creatorPluginId: PLUGIN, events: [] }, - { descriptor: desc('don', REPO_GRANTED, { status: 'done' }), creatorPluginId: PLUGIN, events: [] }, - ], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - const running = await accessor.list({ status: 'running' as DevJobStatus }); - assert.deepEqual(running.map((d) => d.id), ['run']); - }); - - it('listEvents cursor-polls with afterId over the append-only log', async () => { - const events: DevJobEventRecord[] = [ - { id: 1, at: 't1', type: 'phase', payload: {} }, - { id: 2, at: 't2', type: 'log', payload: { line: 'a' } }, - { id: 3, at: 't3', type: 'log', payload: { line: 'b' } }, - ]; - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('j1', REPO_GRANTED), creatorPluginId: PLUGIN, events }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - assert.deepEqual((await accessor.listEvents('j1')).map((e) => e.id), [1, 2, 3]); - assert.deepEqual((await accessor.listEvents('j1', 2)).map((e) => e.id), [3]); - }); - - it('listEvents on an ungranted-repo job fails closed with the no-oracle error', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('u1', REPO_UNGRANTED), creatorPluginId: PLUGIN, events: [] }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - await assert.rejects(accessor.listEvents('u1'), /not accessible/); - }); - - it('cancel works on a self-created job', async () => { - const cancelled: string[] = []; - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('mine', REPO_GRANTED), creatorPluginId: PLUGIN, events: [] }], - cancelled, - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - await accessor.cancel('mine'); - assert.deepEqual(cancelled, ['mine']); - }); - - it("cancel throws on another plugin's job (on a granted repo)", async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('theirs', REPO_GRANTED), creatorPluginId: OTHER_PLUGIN, events: [] }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - await assert.rejects(accessor.cancel('theirs'), /was not created by plugin/); - }); - - it('cancel on an ungranted-repo job fails closed (no oracle) before the creator check', async () => { - const host = makeHost({ - grants: { [PLUGIN]: [REPO_GRANTED] }, - jobs: [{ descriptor: desc('u1', REPO_UNGRANTED), creatorPluginId: PLUGIN, events: [] }], - }); - const accessor = createPluginDevJobsAccessor(PLUGIN, registryOf(host)); - await assert.rejects(accessor.cancel('u1'), /not accessible/); - }); - - it('throws a clear error when the host service is unregistered', async () => { - const accessor = createPluginDevJobsAccessor(PLUGIN, { get: () => undefined }); - await assert.rejects(accessor.listRepos(), /host service unavailable/); - await assert.rejects(accessor.get('x'), /host service unavailable/); - }); -}); diff --git a/specs/470-dev-platform-plugin/README.md b/specs/470-dev-platform-plugin/README.md index 0711413b..23951f29 100644 --- a/specs/470-dev-platform-plugin/README.md +++ b/specs/470-dev-platform-plugin/README.md @@ -97,7 +97,7 @@ node scripts/check-core-decoupling.mjs --update # lower the baseline ``` The ratchet counts Dev Platform references across 14 disjoint zones and **fails if the count -rises, per zone**. Baseline **3,303**. It only ever falls; raising it needs a hand-edited baseline, so +rises, per zone**. Baseline **3,217**. It only ever falls; raising it needs a hand-edited baseline, so a new coupling shows up in review instead of slipping in. That is what makes the checklist's staleness survivable — a file inventory goes stale on diff --git a/specs/470-dev-platform-plugin/acceptance.md b/specs/470-dev-platform-plugin/acceptance.md index 94cb6b39..83ea88dd 100644 --- a/specs/470-dev-platform-plugin/acceptance.md +++ b/specs/470-dev-platform-plugin/acceptance.md @@ -16,7 +16,7 @@ every row passes *and* the decoupling ratchet reads zero. | Guard | What it proves | Status | |---|---|---| -| `scripts/check-core-decoupling.mjs` + CI job `core decoupling ratchet (#470)` | Core does not re-acquire Dev Platform references while the extraction is in flight | **In place.** Baseline **3,303** across **14** zones, per-zone regression check | +| `scripts/check-core-decoupling.mjs` + CI job `core decoupling ratchet (#470)` | Core does not re-acquire Dev Platform references while the extraction is in flight | **In place.** Baseline **3,217** across **14** zones, per-zone regression check | | `middleware/test/devplatform/**` (54 files) | The behaviour itself, at unit/integration level. These **move with the plugin** and must stay green in the new repo | In place, moves in P4 | | §2 capability matrix below | Nothing is silently dropped in the move | **Written here; not yet automated** | | §3 install/uninstall | The result is genuinely installable | **Not yet built** — needs P3/P4 | diff --git a/specs/470-dev-platform-plugin/decoupling-baseline.json b/specs/470-dev-platform-plugin/decoupling-baseline.json index f89dec4a..7f3d0737 100644 --- a/specs/470-dev-platform-plugin/decoupling-baseline.json +++ b/specs/470-dev-platform-plugin/decoupling-baseline.json @@ -1,12 +1,12 @@ { - "total": 3306, + "total": 3217, "zones": { - "middleware/src": 1636, - "middleware/test": 966, - "middleware/packages": 99, + "middleware/src": 1573, + "middleware/test": 956, + "middleware/packages": 84, "middleware/scripts": 8, "middleware/sidecars": 195, - "middleware/migrations": 70, + "middleware/migrations": 69, "middleware/package.json": 0, "middleware/env-example": 19, "web-ui/app": 227,