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:
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// What the platform tells an agent about the surface it is speaking through.
|
||||
//
|
||||
@@ -107,6 +108,21 @@ export function compileAffordances(affordances: Affordance[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
|
||||
return compileAffordances(loadAffordances(root));
|
||||
/**
|
||||
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
|
||||
*
|
||||
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
|
||||
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
|
||||
* plugins describing the same surface differently is information rather than a collision.
|
||||
*/
|
||||
export function loadAffordancesAcross(roots: string[]): Affordance[] {
|
||||
return roots.flatMap((root) => loadAffordances(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
|
||||
* called once per session, which is what makes that affordable.
|
||||
*/
|
||||
export async function affordanceBlock(): Promise<string | null> {
|
||||
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
|
||||
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
|
||||
// would strip them all and nobody would notice until an agent stopped knowing something.
|
||||
|
||||
const root = join(tmpdir(), `aff-pub-${process.pid}`);
|
||||
|
||||
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
|
||||
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
|
||||
|
||||
const withAffordance = (appName: string, publisher = 'officerdev') => {
|
||||
const p = plugin(appName, publisher);
|
||||
mkdirSync(affordanceSourceFor(p), { recursive: true });
|
||||
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
|
||||
return p;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('publishing', () => {
|
||||
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
|
||||
});
|
||||
|
||||
test('copies what the plugin ships and counts it', async () => {
|
||||
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
|
||||
});
|
||||
|
||||
test('a plugin shipping none is not a failure — most ship none', async () => {
|
||||
const bare = plugin('test-bare');
|
||||
mkdirSync(bare.dir, { recursive: true });
|
||||
expect(await publishAffordances(bare)).toBe(0);
|
||||
});
|
||||
|
||||
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
});
|
||||
|
||||
test('two publishers with the same plugin name do not collide', async () => {
|
||||
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
|
||||
await publishAffordances(withAffordance('test-gitea', 'alice'));
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishing', () => {
|
||||
test('removes the plugin', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
await publishAffordances(p);
|
||||
await unpublishAffordances(p);
|
||||
expect(existsSync(affordanceDirFor(p))).toBe(false);
|
||||
});
|
||||
|
||||
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
|
||||
const a = withAffordance('test-gitea');
|
||||
const b = withAffordance('test-music');
|
||||
await publishAffordances(a);
|
||||
await publishAffordances(b);
|
||||
await unpublishAffordances(a);
|
||||
expect(existsSync(affordanceDirFor(b))).toBe(true);
|
||||
});
|
||||
|
||||
test('is safe when nothing was ever published', async () => {
|
||||
await unpublishAffordances(plugin('test-never'));
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's affordances into the store the agent reads.
|
||||
//
|
||||
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
|
||||
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
|
||||
// what git already does. The plugin knows; nothing was carrying that across.
|
||||
//
|
||||
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
|
||||
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
|
||||
// files" would drift, and this one is already understood.
|
||||
//
|
||||
// ── Why the destination is namespaced ──
|
||||
//
|
||||
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
|
||||
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
|
||||
// without opening it. `core` occupies the appName slot for the platform's own.
|
||||
|
||||
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
|
||||
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
|
||||
|
||||
/**
|
||||
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
|
||||
* ordinary case and not a failure.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
|
||||
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
|
||||
* install would make a half-finished copy destructive.
|
||||
*/
|
||||
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = affordanceSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = affordanceDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's affordances.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user