capabilities: read a plugin's where they live, stop copying them in
Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -17,10 +17,6 @@ import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
|
||||
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
|
||||
import { publishSkills, unpublishSkills } from '../skills/publish';
|
||||
import { publishTasks, unpublishTasks } from '../api/tasks/publish';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
@@ -403,53 +399,6 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
|
||||
}
|
||||
|
||||
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
|
||||
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
|
||||
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
|
||||
// repository, copied to a platform-managed place.
|
||||
//
|
||||
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
|
||||
// over it would cost the owner the plugin.
|
||||
try {
|
||||
const published = await publishAffordances(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
|
||||
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
|
||||
try {
|
||||
const published = await publishNotifications(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
|
||||
// not learn the reference exists, which is the state every plugin was in before this.
|
||||
try {
|
||||
const published = await publishSkills(plugin);
|
||||
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// And the same for tasks, which is how a plugin reaches the FILE BROWSER'S CONTEXT MENU. A task with a
|
||||
// file trigger appears on a right-click with no platform change at all — `matchesTrigger` in
|
||||
// officerdev/src/apps/FileBrowser/useTasks.ts already does that work. What was missing was only the
|
||||
// delivery, so the capability had to be installed by hand beside the plugin and removed by hand after.
|
||||
try {
|
||||
const published = await publishTasks(plugin);
|
||||
if (published) await step(steps, onStep, `tasks: ${published} published to capabilities/tasks/`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `tasks: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
|
||||
if (plugin.web && forUserId !== undefined) {
|
||||
@@ -547,12 +496,8 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
|
||||
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
|
||||
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
|
||||
// worse than none, because it reads as true.
|
||||
if (plugin) await unpublishAffordances(plugin).catch(() => {});
|
||||
// And its notification codes, which stop resolving rather than resolving to something stale. A device
|
||||
// still holding a queued push renders the unknown-code fallback, which is a defined state.
|
||||
if (plugin) await unpublishNotifications(plugin).catch(() => {});
|
||||
if (plugin) await unpublishSkills(plugin).catch(() => {});
|
||||
if (plugin) await unpublishTasks(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)');
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user