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
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@
"types": "./src/lease-scope.ts",
"default": "./src/lease-scope.ts"
},
"./plugin-inventory": {
"types": "./src/plugin-inventory.ts",
"default": "./src/plugin-inventory.ts"
},
"./local-interactor-operation-set": {
"types": "./src/local-interactor-operation-set.ts",
"default": "./src/local-interactor-operation-set.ts"
Expand Down
41 changes: 41 additions & 0 deletions packages/contracts/src/plugin-inventory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from 'vitest';
import { pluginCredentialVariables, readPluginInventory } from './plugin-inventory.ts';

const credentials = { variables: ['A'], byBackend: { ios: ['A', 'B'] } };

test('the reader keeps well-formed entries and drops entries that lose plugin state', () => {
const active = { provider: 'p', source: 'store', state: 'active', credentials };
const failed = {
source: 'builtin',
state: 'failed',
error: { code: 'INVALID_ARGS', message: 'bad', reason: 'invalid_plugin' },
credentials,
};
expect(
readPluginInventory([
active,
failed,
{ ...active, credentials: undefined },
{ ...active, credentials: { variables: 'A' } },
{ ...failed, error: undefined },
{ ...active, state: 'unknown' },
'not an entry',
]),
).toEqual([active, failed]);
});

test('credential variables narrow to the lease backend of an active plugin only', () => {
const inventory = readPluginInventory([
{ provider: 'p', source: 'store', state: 'active', credentials },
{
provider: 'q',
source: 'store',
state: 'failed',
error: { code: 'X', message: 'm' },
credentials,
},
]);
expect(pluginCredentialVariables(inventory, 'p', 'ios')).toEqual(['A', 'B']);
expect(pluginCredentialVariables(inventory, 'p', 'android')).toEqual(['A']);
expect(pluginCredentialVariables(inventory, 'q')).toBeUndefined();
});
109 changes: 109 additions & 0 deletions packages/contracts/src/plugin-inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { isRecord } from '@agent-device/kernel/record';

export type PluginSource = 'builtin' | 'store' | 'embedder';

export type PluginInventoryError = Readonly<{
code: string;
message: string;
reason?: string;
hint?: string;
}>;

export type PluginInventoryCredentials = Readonly<{
variables: readonly string[];
byBackend: Readonly<Record<string, readonly string[]>>;
}>;

type PluginInventoryIdentity = Readonly<{
provider?: string;
source: PluginSource;
package?: string;
version?: string;
credentials: PluginInventoryCredentials;
}>;

/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */
export type PluginInventoryEntry =
| (PluginInventoryIdentity & Readonly<{ state: 'active'; error?: undefined }>)
| (PluginInventoryIdentity & Readonly<{ state: 'failed'; error: PluginInventoryError }>);

export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({
variables: Object.freeze([]),
byBackend: Object.freeze({}),
});

/**
* The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own
* list when the plugin declares one, otherwise its flat list. Undefined when no active plugin
* claims the provider.
*/
export function pluginCredentialVariables(
inventory: readonly PluginInventoryEntry[],
provider: string,
leaseBackend?: string,
): readonly string[] | undefined {
const entry = inventory.find((candidate) => candidate.provider === provider);
if (entry?.state !== 'active') return undefined;
const { variables, byBackend } = entry.credentials;
return (
(leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend)
? byBackend[leaseBackend]
: undefined) ?? variables
);
}

/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */
export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] {
return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : [];
}

function readEntry(value: unknown): PluginInventoryEntry | undefined {
if (!isRecord(value) || !isSource(value.source)) return undefined;
const credentials = readCredentials(value.credentials);
if (!credentials) return undefined;
const identity = {
...(typeof value.provider === 'string' ? { provider: value.provider } : {}),
source: value.source,
...(typeof value.package === 'string' ? { package: value.package } : {}),
...(typeof value.version === 'string' ? { version: value.version } : {}),
credentials,
};
if (value.state === 'active') return { ...identity, state: 'active' };
const error = readError(value.error);
return value.state === 'failed' && error ? { ...identity, state: 'failed', error } : undefined;
}

function readCredentials(value: unknown): PluginInventoryCredentials | undefined {
if (!isRecord(value)) return undefined;
const variables = readNames(value.variables);
const byBackend = value.byBackend === undefined ? {} : value.byBackend;
if (!variables || !isRecord(byBackend)) return undefined;
const entries = Object.entries(byBackend).map(([backend, names]) => [backend, readNames(names)]);
if (entries.some(([, names]) => names === undefined)) return undefined;
return {
variables,
byBackend: Object.fromEntries(entries) as Record<string, readonly string[]>,
};
}

function isSource(value: unknown): value is PluginSource {
return value === 'builtin' || value === 'store' || value === 'embedder';
}

function readNames(value: unknown): readonly string[] | undefined {
return Array.isArray(value) && value.every((name) => typeof name === 'string')
? value
: undefined;
}

function readError(value: unknown): PluginInventoryError | undefined {
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') {
return undefined;
}
return {
code: value.code,
message: value.message,
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
};
}
3 changes: 3 additions & 0 deletions packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type ProcessLockAcquisition,
} from '@agent-device/host-kit/file';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts';
import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature';
import {
Expand Down Expand Up @@ -51,6 +52,8 @@ export type DaemonRegistrationFields = Readonly<{
codeOrigin: DaemonCodeOrigin;
codeSignature: string;
policyDigest?: string;
/** The provider plugin inventory, so a client can read what this daemon registered. */
plugins?: readonly PluginInventoryEntry[];
}>;
type DaemonRegistrationRemoval =
| Readonly<{ state: 'removed' }>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function daemonRuntimeDepsFixture(
hostDiagnostics: hostDiagnosticsFixture(),
ownerLifecycle: platformOwnerLifecycleFixture(),
providerDeviceRuntimes: [],
skippedProviderRuntimes: [],
pluginInventory: [],
providerRuntimeIds: [],
recoverableProviderIds: [],
deviceRuntimeGateway: Object.freeze({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
Expand All @@ -6,7 +7,7 @@ import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/tes
const lifecycleEvents = vi.hoisted(() => [] as string[]);
const startupFailure = vi.hoisted(() => ({ active: false }));
const providerComposition = vi.hoisted(() => ({
skipped: [] as Array<{ provider: string; error: Error & { code: string } }>,
inventory: [] as PluginInventoryEntry[],
}));

import {
Expand Down Expand Up @@ -42,7 +43,7 @@ function startRuntime(stateDir: string, exit: (code: number) => void) {
lifecycleEvents.push('gateway-shutdown');
},
}),
skippedProviderRuntimes: providerComposition.skipped,
pluginInventory: providerComposition.inventory,
})),
env: {
...process.env,
Expand Down Expand Up @@ -75,7 +76,7 @@ function publishSuccessor(paths: DaemonPaths): void {
afterEach(() => {
startupFailure.active = false;
lifecycleEvents.length = 0;
providerComposition.skipped = [];
providerComposition.inventory = [];
vi.restoreAllMocks();
});

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

test('a skipped provider runtime is logged after this daemon publishes its record', async () => {
test('a failed provider plugin is logged and published after this daemon publishes its record', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-');
const error = Object.assign(new Error('Limrun instance access is missing X.'), {
code: 'INVALID_ARGS',
});
providerComposition.skipped = [{ provider: 'limrun', error }];
const failed = {
provider: 'limrun',
source: 'builtin',
state: 'failed',
error: { code: 'INVALID_ARGS', message: 'Limrun instance access is missing X.' },
credentials: { variables: [], byBackend: {} },
} as const;
providerComposition.inventory = [failed];
const runtime = await startRuntime(stateDir, () => {});
try {
const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped');
expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' });
const event = logEvents(stateDir).find(({ phase }) => phase === 'provider_plugin_failed');
expect(event?.data).toMatchObject({
provider: 'limrun',
source: 'builtin',
code: 'INVALID_ARGS',
});
const published = JSON.parse(fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'));
expect(published.plugins).toEqual([failed]);
} finally {
await runtime?.shutdown();
}
Expand Down
25 changes: 15 additions & 10 deletions packages/daemon/src/server/http/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import type {
ProviderExpiredLeaseRecovery,
} from '@agent-device/contracts/device';
import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics';
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';

const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000;
const LEASE_EXPIRY_SWEEP_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -124,11 +125,11 @@ export type DaemonRuntimeDeps = Readonly<{
ownerLifecycle: PlatformOwnerLifecycle;
providerDeviceRuntimes: readonly ProviderDeviceRuntime[];
/**
* Provider runtimes the composition skipped. Data only: this server records each one as a
* `provider_runtime_skipped` diagnostic after publishing its registration, so log emission
* stays in this module.
* Every provider plugin the composition saw, active or failed. Data only: this server publishes
* it in the daemon registration and records each failed entry as a `provider_plugin_failed`
* diagnostic, so log emission stays in this module.
*/
skippedProviderRuntimes: readonly Readonly<{ provider: string; error: AppError }>[];
pluginInventory: readonly PluginInventoryEntry[];
interactorResolution: InteractorResolution;
providerDeviceAdmission: ProviderDeviceAdmission;
cleanupManagedWebOrphans: (
Expand Down Expand Up @@ -713,6 +714,7 @@ export async function startDaemonRuntime(
codeOrigin: daemonCodeOrigin,
codeSignature: daemonCodeSignature,
policyDigest: daemonPolicy?.digest,
plugins: deps.pluginInventory,
});
if (socketPort) stdout.write(`AGENT_DEVICE_DAEMON_PORT=${socketPort}\n`);
if (httpPort) stdout.write(`AGENT_DEVICE_DAEMON_HTTP_PORT=${httpPort}\n`);
Expand Down Expand Up @@ -798,12 +800,15 @@ export async function startDaemonRuntime(
publishDaemonInfo(socketPort, httpPort);
stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity);
await flushDaemonStartupDiagnostics(logPath, startupDiagnostics);
for (const skipped of deps.skippedProviderRuntimes) {
await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', {
provider: skipped.provider,
code: skipped.error.code,
message: skipped.error.message,
hint: skipped.error.details?.hint,
for (const plugin of deps.pluginInventory.filter(({ state }) => state === 'failed')) {
await emitDaemonDiagnostic(logPath, 'provider_plugin_failed', {
provider: plugin.provider,
package: plugin.package,
source: plugin.source,
code: plugin.error?.code,
reason: plugin.error?.reason,
message: plugin.error?.message,
hint: plugin.error?.hint,
});
}
// After publication: publishDaemonInfo truncates daemon.log, so anything
Expand Down
15 changes: 13 additions & 2 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,25 @@
"dependencies": {
"@agent-device/contracts": "workspace:*",
"@agent-device/host-kit": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*"
"@agent-device/kernel": "workspace:*"
},
"exports": {
"./connection": {
"types": "./src/connection.ts",
"default": "./src/connection.ts"
},
"./contract": {
"types": "./src/contract.ts",
"default": "./src/contract.ts"
},
"./host": {
"types": "./src/host.ts",
"default": "./src/host.ts"
},
"./inventory": {
"types": "./src/inventory.ts",
"default": "./src/inventory.ts"
},
"./load": {
"types": "./src/load.ts",
"default": "./src/load.ts"
Expand All @@ -27,6 +34,10 @@
"types": "./src/manifest.ts",
"default": "./src/manifest.ts"
},
"./registry": {
"types": "./src/registry.ts",
"default": "./src/registry.ts"
},
"./store": {
"types": "./src/store.ts",
"default": "./src/store.ts"
Expand Down
Loading
Loading