Skip to content

Commit bd57cd7

Browse files
thymikeeclaude
andcommitted
feat(plugins): plugin contract v2 and one provider registry
Built-in providers, the plugin store and an embedder now register through one registry in one shape. A plugin that fails to load, validate or set up is recorded in a daemon inventory and no longer stops the daemon. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 4c1c786 commit bd57cd7

65 files changed

Lines changed: 2885 additions & 751 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/contracts/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@
300300
"types": "./src/lease-scope.ts",
301301
"default": "./src/lease-scope.ts"
302302
},
303+
"./plugin-inventory": {
304+
"types": "./src/plugin-inventory.ts",
305+
"default": "./src/plugin-inventory.ts"
306+
},
303307
"./local-interactor-operation-set": {
304308
"types": "./src/local-interactor-operation-set.ts",
305309
"default": "./src/local-interactor-operation-set.ts"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect, test } from 'vitest';
2+
import { pluginCredentialVariables, readPluginInventory } from './plugin-inventory.ts';
3+
4+
const credentials = { variables: ['A'], byBackend: { ios: ['A', 'B'] } };
5+
6+
test('the reader keeps well-formed entries and drops entries that lose plugin state', () => {
7+
const active = { provider: 'p', source: 'store', state: 'active', credentials };
8+
const failed = {
9+
source: 'builtin',
10+
state: 'failed',
11+
error: { code: 'INVALID_ARGS', message: 'bad', reason: 'invalid_plugin' },
12+
credentials,
13+
};
14+
expect(
15+
readPluginInventory([
16+
active,
17+
failed,
18+
{ ...active, credentials: undefined },
19+
{ ...active, credentials: { variables: 'A' } },
20+
{ ...failed, error: undefined },
21+
{ ...active, state: 'unknown' },
22+
'not an entry',
23+
]),
24+
).toEqual([active, failed]);
25+
});
26+
27+
test('credential variables narrow to the lease backend of an active plugin only', () => {
28+
const inventory = readPluginInventory([
29+
{ provider: 'p', source: 'store', state: 'active', credentials },
30+
{
31+
provider: 'q',
32+
source: 'store',
33+
state: 'failed',
34+
error: { code: 'X', message: 'm' },
35+
credentials,
36+
},
37+
]);
38+
expect(pluginCredentialVariables(inventory, 'p', 'ios')).toEqual(['A', 'B']);
39+
expect(pluginCredentialVariables(inventory, 'p', 'android')).toEqual(['A']);
40+
expect(pluginCredentialVariables(inventory, 'q')).toBeUndefined();
41+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { isRecord } from '@agent-device/kernel/record';
2+
3+
export type PluginSource = 'builtin' | 'store' | 'embedder';
4+
5+
export type PluginInventoryError = Readonly<{
6+
code: string;
7+
message: string;
8+
reason?: string;
9+
hint?: string;
10+
}>;
11+
12+
export type PluginInventoryCredentials = Readonly<{
13+
variables: readonly string[];
14+
byBackend: Readonly<Record<string, readonly string[]>>;
15+
}>;
16+
17+
type PluginInventoryIdentity = Readonly<{
18+
provider?: string;
19+
source: PluginSource;
20+
package?: string;
21+
version?: string;
22+
credentials: PluginInventoryCredentials;
23+
}>;
24+
25+
/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */
26+
export type PluginInventoryEntry =
27+
| (PluginInventoryIdentity & Readonly<{ state: 'active'; error?: undefined }>)
28+
| (PluginInventoryIdentity & Readonly<{ state: 'failed'; error: PluginInventoryError }>);
29+
30+
export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({
31+
variables: Object.freeze([]),
32+
byBackend: Object.freeze({}),
33+
});
34+
35+
/**
36+
* The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own
37+
* list when the plugin declares one, otherwise its flat list. Undefined when no active plugin
38+
* claims the provider.
39+
*/
40+
export function pluginCredentialVariables(
41+
inventory: readonly PluginInventoryEntry[],
42+
provider: string,
43+
leaseBackend?: string,
44+
): readonly string[] | undefined {
45+
const entry = inventory.find((candidate) => candidate.provider === provider);
46+
if (entry?.state !== 'active') return undefined;
47+
const { variables, byBackend } = entry.credentials;
48+
return (
49+
(leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend)
50+
? byBackend[leaseBackend]
51+
: undefined) ?? variables
52+
);
53+
}
54+
55+
/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */
56+
export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] {
57+
return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : [];
58+
}
59+
60+
function readEntry(value: unknown): PluginInventoryEntry | undefined {
61+
if (!isRecord(value) || !isSource(value.source)) return undefined;
62+
const credentials = readCredentials(value.credentials);
63+
if (!credentials) return undefined;
64+
const identity = {
65+
...(typeof value.provider === 'string' ? { provider: value.provider } : {}),
66+
source: value.source,
67+
...(typeof value.package === 'string' ? { package: value.package } : {}),
68+
...(typeof value.version === 'string' ? { version: value.version } : {}),
69+
credentials,
70+
};
71+
if (value.state === 'active') return { ...identity, state: 'active' };
72+
const error = readError(value.error);
73+
return value.state === 'failed' && error ? { ...identity, state: 'failed', error } : undefined;
74+
}
75+
76+
function readCredentials(value: unknown): PluginInventoryCredentials | undefined {
77+
if (!isRecord(value)) return undefined;
78+
const variables = readNames(value.variables);
79+
const byBackend = value.byBackend === undefined ? {} : value.byBackend;
80+
if (!variables || !isRecord(byBackend)) return undefined;
81+
const entries = Object.entries(byBackend).map(([backend, names]) => [backend, readNames(names)]);
82+
if (entries.some(([, names]) => names === undefined)) return undefined;
83+
return {
84+
variables,
85+
byBackend: Object.fromEntries(entries) as Record<string, readonly string[]>,
86+
};
87+
}
88+
89+
function isSource(value: unknown): value is PluginSource {
90+
return value === 'builtin' || value === 'store' || value === 'embedder';
91+
}
92+
93+
function readNames(value: unknown): readonly string[] | undefined {
94+
return Array.isArray(value) && value.every((name) => typeof name === 'string')
95+
? value
96+
: undefined;
97+
}
98+
99+
function readError(value: unknown): PluginInventoryError | undefined {
100+
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') {
101+
return undefined;
102+
}
103+
return {
104+
code: value.code,
105+
message: value.message,
106+
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
107+
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
108+
};
109+
}

‎packages/daemon-contracts/src/daemon-registration-owner.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type ProcessLockAcquisition,
2323
} from '@agent-device/host-kit/file';
2424
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
25+
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
2526
import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts';
2627
import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature';
2728
import {
@@ -51,6 +52,8 @@ export type DaemonRegistrationFields = Readonly<{
5152
codeOrigin: DaemonCodeOrigin;
5253
codeSignature: string;
5354
policyDigest?: string;
55+
/** The provider plugin inventory, so a client can read what this daemon registered. */
56+
plugins?: readonly PluginInventoryEntry[];
5457
}>;
5558
type DaemonRegistrationRemoval =
5659
| Readonly<{ state: 'removed' }>

‎packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ export function daemonRuntimeDepsFixture(
6666
}),
6767
hostDiagnostics: hostDiagnosticsFixture(),
6868
ownerLifecycle: platformOwnerLifecycleFixture(),
69-
providerDeviceRuntimes: [],
70-
skippedProviderRuntimes: [],
69+
shutdownProviderRuntimes: async () => {},
70+
pluginInventory: [],
7171
providerRuntimeIds: [],
7272
recoverableProviderIds: [],
7373
deviceRuntimeGateway: Object.freeze({

‎packages/daemon/src/server/http/daemon-runtime-composition.test.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { expect, test, vi } from 'vitest';
5-
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
65
import {
76
DAEMON_STARTUP_EXIT_CODES,
87
tryAcquireDaemonRegistration,
@@ -69,10 +68,9 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a
6968
const stateDir = mkdtempForTestSync('daemon-held-lock-');
7069
const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir));
7170
if (held.status !== 'acquired') throw new Error('registration fixture refused');
72-
const shutdown = vi.fn(() => {
71+
const shutdownProviderRuntimes = vi.fn(async () => {
7372
throw new Error('cleanup failed');
7473
});
75-
const otherShutdown = vi.fn(async () => {});
7674
const exit = vi.fn();
7775
const runtime = await startDaemonRuntime({
7876
env: { AGENT_DEVICE_STATE_DIR: stateDir },
@@ -81,19 +79,11 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a
8179
stderr: { write: () => {} },
8280
stdout: { write: () => {} },
8381
composeDeps: daemonRuntimeDepsFixture({
84-
providerDeviceRuntimes: [shutdown, otherShutdown].map(
85-
(stop, index) =>
86-
({
87-
provider: `fixture-${index}`,
88-
shutdown: stop,
89-
leaseLifecycle: {},
90-
}) as unknown as ProviderDeviceRuntime,
91-
),
82+
shutdownProviderRuntimes,
9283
}),
9384
});
9485
expect(runtime).toBeNull();
95-
expect(shutdown).toHaveBeenCalledOnce();
96-
expect(otherShutdown).toHaveBeenCalledOnce();
86+
expect(shutdownProviderRuntimes).toHaveBeenCalledOnce();
9787
expect(exit).toHaveBeenCalledWith(DAEMON_STARTUP_EXIT_CODES.busy);
9888
await held.owner.finish();
9989
});

‎packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
12
import fs from 'node:fs';
23
import path from 'node:path';
34
import { afterEach, expect, test, vi } from 'vitest';
@@ -6,7 +7,7 @@ import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/tes
67
const lifecycleEvents = vi.hoisted(() => [] as string[]);
78
const startupFailure = vi.hoisted(() => ({ active: false }));
89
const providerComposition = vi.hoisted(() => ({
9-
skipped: [] as Array<{ provider: string; error: Error & { code: string } }>,
10+
inventory: [] as PluginInventoryEntry[],
1011
}));
1112

1213
import {
@@ -42,7 +43,7 @@ function startRuntime(stateDir: string, exit: (code: number) => void) {
4243
lifecycleEvents.push('gateway-shutdown');
4344
},
4445
}),
45-
skippedProviderRuntimes: providerComposition.skipped,
46+
pluginInventory: providerComposition.inventory,
4647
})),
4748
env: {
4849
...process.env,
@@ -75,7 +76,7 @@ function publishSuccessor(paths: DaemonPaths): void {
7576
afterEach(() => {
7677
startupFailure.active = false;
7778
lifecycleEvents.length = 0;
78-
providerComposition.skipped = [];
79+
providerComposition.inventory = [];
7980
vi.restoreAllMocks();
8081
});
8182

@@ -147,16 +148,26 @@ test('a shutdown removes its own metadata and reports an unverified release', as
147148
}
148149
});
149150

150-
test('a skipped provider runtime is logged after this daemon publishes its record', async () => {
151+
test('a failed provider plugin is logged and published after this daemon publishes its record', async () => {
151152
const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-');
152-
const error = Object.assign(new Error('Limrun instance access is missing X.'), {
153-
code: 'INVALID_ARGS',
154-
});
155-
providerComposition.skipped = [{ provider: 'limrun', error }];
153+
const failed = {
154+
provider: 'limrun',
155+
source: 'builtin',
156+
state: 'failed',
157+
error: { code: 'INVALID_ARGS', message: 'Limrun instance access is missing X.' },
158+
credentials: { variables: [], byBackend: {} },
159+
} as const;
160+
providerComposition.inventory = [failed];
156161
const runtime = await startRuntime(stateDir, () => {});
157162
try {
158-
const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped');
159-
expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' });
163+
const event = logEvents(stateDir).find(({ phase }) => phase === 'provider_plugin_failed');
164+
expect(event?.data).toMatchObject({
165+
provider: 'limrun',
166+
source: 'builtin',
167+
code: 'INVALID_ARGS',
168+
});
169+
const published = JSON.parse(fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'));
170+
expect(published.plugins).toEqual([failed]);
160171
} finally {
161172
await runtime?.shutdown();
162173
}

0 commit comments

Comments
 (0)