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
71 changes: 70 additions & 1 deletion packages/api/src/domains/plugin/PluginRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,18 @@ export class PluginRegistry {
(c) =>
c.pluginId === manifest.id && c.type === r.type && normalizeCapId(c.id) === resourceCapId(manifest.id, r),
);
return {
const base: PluginResourceStatus = {
type: r.type,
path: r.path,
name: r.name,
enabled: capEntry?.enabled ?? false,
...(capEntry?.agentProvider?.state ? { agentProviderState: capEntry.agentProvider.state } : {}),
};
if (r.type !== 'agentProvider') return base;
return {
...base,
...buildAgentProviderResourceProjection(manifest.id, r, capEntry?.agentProvider),
};
});

return {
Expand All @@ -170,6 +175,70 @@ export class PluginRegistry {
}
}

type AgentProviderCapDescriptor = NonNullable<CapabilitiesConfig['capabilities'][number]['agentProvider']>;
type AgentProviderResource = NonNullable<PluginManifest['resources'][number]['agentProvider']>;

/** Project host-owned routeable + binding state from the persisted capability row. */
function projectHostOwnedAgentProviderFields(
ap: AgentProviderCapDescriptor | undefined,
): Partial<PluginResourceStatus> {
if (!ap) return {};
const out: Partial<PluginResourceStatus> = {};
if (typeof ap.routeable === 'boolean') out.agentProviderRouteable = ap.routeable;
if (typeof ap.routeableApproved === 'boolean') out.agentProviderRouteableApproved = ap.routeableApproved;
if (ap.routeableBinding) {
const b = ap.routeableBinding;
out.agentProviderBinding = {
catId: b.catId,
...(b.profileId ? { profileId: b.profileId } : {}),
...(b.mentionPatterns ? { mentionPatterns: [...b.mentionPatterns] } : {}),
};
}
if (ap.descriptorHash) out.agentProviderDescriptorHash = ap.descriptorHash;
if (ap.health?.passed === false && ap.health.failureReason) {
out.agentProviderHealthFailureReason = ap.health.failureReason;
}
// F241 Phase C (PR #42 round-1 review @codex P2): surface persisted sync
// failure separately from health probe failure. Sync failures fire AFTER
// approval + health both pass, during the Step 6 AgentRegistry projection
// (post-approval sync hook). Without this, the Hub renders
// `approved=true / healthy / routeable=false` with no inline explanation.
if (ap.lastSyncError) {
out.agentProviderLastSyncError = {
message: ap.lastSyncError.message,
occurredAt: ap.lastSyncError.occurredAt,
};
}
return out;
}

/** Project manifest-declared identity claims (PR #39) — Hub UI uses these as form defaults. */
function projectAgentProviderClaims(c: AgentProviderResource | undefined): Partial<PluginResourceStatus> {
if (!c) return {};
const claims = {
...(c.providerId ? { providerId: c.providerId } : {}),
...(c.displayName ? { displayName: c.displayName } : {}),
...(c.mentionPatterns ? { mentionPatterns: [...c.mentionPatterns] } : {}),
};
return Object.keys(claims).length > 0 ? { agentProviderClaims: claims } : {};
}

/**
* F241 Phase C — Compose the agentProvider extension fields for `PluginResourceStatus`.
* Pure: never touches `base`, just returns the additional fields to merge.
*/
function buildAgentProviderResourceProjection(
pluginId: string,
resource: PluginManifest['resources'][number],
ap: AgentProviderCapDescriptor | undefined,
): Partial<PluginResourceStatus> {
return {
capId: resourceCapId(pluginId, resource),
...projectHostOwnedAgentProviderFields(ap),
...projectAgentProviderClaims(resource.agentProvider),
};
}

export function resourceCapId(pluginId: string, resource: { type: string; path?: string; name?: string }): string {
if (resource.type === 'skill' && resource.path) {
return resourcePathBasename(resource.path);
Expand Down
226 changes: 226 additions & 0 deletions packages/api/test/plugin-registry-agent-provider-info.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* F241 Phase C — `PluginRegistry.getPluginInfo` agentProvider projection tests.
*
* The Hub UI for owner approval renders entirely off the `PluginResourceStatus`
* fields that `getPluginInfo` projects out of the persisted capabilities row
* and the manifest declaration. These tests lock down that projection:
* - capId is populated for agentProvider resources
* - host-owned routeable / approval / binding state mirrors capabilities
* - manifest-declared claims (PR #39 providerId/displayName/mentionPatterns)
* are surfaced so the form can prefill defaults
* - failureReason surfaces only when health.passed === false
* - non-agentProvider resources are unaffected
*/

import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';

const { PluginRegistry } = await import('../dist/domains/plugin/PluginRegistry.js');

function makeManifest({ withClaims = true } = {}) {
return {
id: 'clowder-code',
name: 'Clowder Code',
version: '0.1.0',
builtin: false,
config: [],
resources: [
{
type: 'agentProvider',
name: 'clowder-code',
agentProvider: {
name: 'clowder-code',
transport: 'cli-jsonl',
command: 'clowder-code',
startupArgs: ['--json'],
sessionPolicy: 'stateless',
outputProfile: 'clowder-code-turn-result-v1',
...(withClaims
? {
providerId: 'clowder-code',
displayName: 'Clowder Code',
mentionPatterns: ['@clowder', '@clowder-code'],
}
: {}),
},
},
],
};
}

function makeCapabilities({ routeable, approved, binding, descriptorHash, failureReason, lastSyncError } = {}) {
return {
version: 1,
capabilities: [
{
id: 'plugin:clowder-code:clowder-code',
type: 'agentProvider',
enabled: true,
source: 'cat-cafe',
pluginId: 'clowder-code',
agentProvider: {
name: 'clowder-code',
transport: 'cli-jsonl',
command: 'clowder-code',
startupArgs: ['--json'],
sessionPolicy: 'stateless',
outputProfile: 'clowder-code-turn-result-v1',
state: routeable ? 'healthy' : 'transportReady',
routeable: !!routeable,
routeableApproved: !!approved,
descriptorHash: descriptorHash ?? 'hash-X',
...(binding ? { routeableBinding: binding } : {}),
...(failureReason
? {
health: {
passed: false,
checkedAt: 1000,
ttlMs: 60_000,
descriptorHash: descriptorHash ?? 'hash-X',
failureReason,
},
}
: {}),
...(lastSyncError ? { lastSyncError } : {}),
},
},
],
};
}

function makeRegistry() {
return new PluginRegistry(mkdtempSync(join(os.tmpdir(), 'plugin-info-test-')));
}

describe('PluginRegistry.getPluginInfo — F241 agentProvider projection', () => {
it('populates capId for the Hub UI POST URL', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(makeManifest(), makeCapabilities({ routeable: false, approved: false }), {});
const r = info.resources[0];
assert.equal(r.capId, 'plugin:clowder-code:clowder-code');
});

it('surfaces routeable + approval flags from capabilities', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({
routeable: true,
approved: true,
binding: { catId: 'clowder-cat', mentionPatterns: ['@clowder'] },
}),
{},
);
const r = info.resources[0];
assert.equal(r.agentProviderRouteable, true);
assert.equal(r.agentProviderRouteableApproved, true);
assert.equal(r.agentProviderState, 'healthy');
assert.deepEqual(r.agentProviderBinding, { catId: 'clowder-cat', mentionPatterns: ['@clowder'] });
});

it('surfaces manifest identity claims (PR #39) for form prefill', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest({ withClaims: true }),
makeCapabilities({ routeable: false, approved: false }),
{},
);
assert.deepEqual(info.resources[0].agentProviderClaims, {
providerId: 'clowder-code',
displayName: 'Clowder Code',
mentionPatterns: ['@clowder', '@clowder-code'],
});
});

it('omits agentProviderClaims when manifest declares none', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest({ withClaims: false }),
makeCapabilities({ routeable: false, approved: false }),
{},
);
assert.equal(info.resources[0].agentProviderClaims, undefined);
});

it('surfaces descriptorHash so the operator can see when re-approval is pending', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({ routeable: false, approved: false, descriptorHash: 'hash-Y' }),
{},
);
assert.equal(info.resources[0].agentProviderDescriptorHash, 'hash-Y');
});

it('surfaces health.failureReason when probe failed', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({ routeable: false, approved: false, failureReason: 'cli-probe-cli-not-found:clowder-code' }),
{},
);
assert.equal(info.resources[0].agentProviderHealthFailureReason, 'cli-probe-cli-not-found:clowder-code');
});

it('omits failureReason when health passed (no operator-visible noise on the happy path)', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }),
{},
);
assert.equal(info.resources[0].agentProviderHealthFailureReason, undefined);
});

// PR #42 round-1 review @codex P2: persisted post-approval sync failures
// must surface to the Hub so operators can diagnose a row that is
// approved + healthy but stuck non-routeable.
it('surfaces lastSyncError (message + occurredAt) so post-approval sync failure is diagnosable', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({
routeable: false,
approved: true,
binding: { catId: 'clowder-cat' },
lastSyncError: { message: 'agent registry sync failed: ENOENT', occurredAt: 1_700_000_000_999 },
}),
{},
);
assert.deepEqual(info.resources[0].agentProviderLastSyncError, {
message: 'agent registry sync failed: ENOENT',
occurredAt: 1_700_000_000_999,
});
});

it('omits lastSyncError on the happy path (no UI noise after sync succeeds)', () => {
const reg = makeRegistry();
const info = reg.getPluginInfo(
makeManifest(),
makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }),
{},
);
assert.equal(info.resources[0].agentProviderLastSyncError, undefined);
});

it('does NOT add F241 fields to non-agentProvider resources', () => {
const reg = makeRegistry();
const manifest = {
id: 'gh',
name: 'GitHub',
version: '1.0.0',
builtin: false,
config: [],
resources: [{ type: 'schedule', name: 'cicd-check', factoryId: 'github.cicd-check' }],
};
const info = reg.getPluginInfo(manifest, { version: 1, capabilities: [] }, {});
const r = info.resources[0];
assert.equal(r.capId, undefined);
assert.equal(r.agentProviderRouteable, undefined);
assert.equal(r.agentProviderBinding, undefined);
assert.equal(r.agentProviderClaims, undefined);
});
});
51 changes: 51 additions & 0 deletions packages/shared/src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,57 @@ export interface PluginResourceStatus {
enabled: boolean;
agentProviderState?: AgentProviderLifecycleState;
error?: string;
/**
* F241 Phase C — Operator-visible state for `agentProvider` resources.
*
* Surfaces the host-owned routeable view + manifest-declared claims to the
* Hub UI so an operator can render the approve-routeable form, see whether
* a binding is already live, and prefill the form from claim defaults.
* All fields are optional and only populated for `type === 'agentProvider'`.
*/

/** Canonical capability id (e.g. `plugin:clowder-code:clowder-code`) — required for approve-routeable POST URL. */
capId?: string;

/** Effective truth — "you can `@` this cat now". Always false until the routeable gate fully passes. */
agentProviderRouteable?: boolean;

/** Operator intent — flips true on explicit approve, resets to false on descriptor delta. */
agentProviderRouteableApproved?: boolean;

/** Live binding the operator set at approve-time. Undefined when not yet approved. */
agentProviderBinding?: {
catId: string;
profileId?: string;
mentionPatterns?: string[];
};

/** Manifest-declared identity claims (PR #39). Hub UI uses these as form defaults. */
agentProviderClaims?: {
providerId?: string;
displayName?: string;
mentionPatterns?: string[];
};

/** Stable hash bound to the descriptor; surfaced so the operator can see when re-approval is needed after a manifest delta. */
agentProviderDescriptorHash?: string;

/** Operator-visible probe failure (e.g. `cli-probe-cli-not-found:foo`, `cli-probe-timeout:10000ms`). */
agentProviderHealthFailureReason?: string;

/**
* Operator-visible AgentRegistry sync failure surfaced from the persisted
* `lastSyncError`. Distinct from `agentProviderHealthFailureReason` —
* sync failures fire AFTER approval / health both pass, during the Step 6
* AgentRegistry projection (post-approval sync hook in
* `agent-provider-approval-service.ts`). Without this field a sync failure
* leaves the row as `approved=true / healthy / routeable=false` with no
* UI explanation of WHY it isn't routeable. (PR #42 round-1 review @codex.)
*/
agentProviderLastSyncError?: {
message: string;
occurredAt: number;
};
}

/** Full plugin info returned by API (manifest + derived state) */
Expand Down
Loading