blob: 3a16745447d9ec432890a9d3e3b6e71e92a75a82 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Service, type Context, type Disposable } from './plugin-kernel.js';
import type { PluginAgentService } from './plugin-agent-service.js';
import type { PluginSessionQueryCaller } from './plugin-session-query-service.js';
import {
pluginIdentity,
registerPluginContribution,
type MakaContributionIdentity,
} from './plugin-runtime.js';
import { PluginScopeRegistry } from './plugin-scope-registry.js';
declare module './plugin-kernel.js' {
interface Context {
readonly sources: PluginSourceService;
}
}
/** Native locators remain adapter-owned; IDs include account/tenant identity via source.id. */
export interface SourceObject {
readonly id: string;
readonly locator: Readonly<Record<string, unknown>>;
readonly revision: string;
readonly kind: string;
readonly title?: string;
readonly url?: string;
readonly updatedAt?: number;
}
export interface SourcePage {
readonly items: readonly SourceObject[];
readonly next?: string;
}
export interface SourceRead {
readonly status: 'ok' | 'deleted' | 'unavailable' | 'version_unavailable';
readonly object?: SourceObject;
readonly content?: unknown;
}
export interface PluginSourceAdapter {
readonly id: string;
readonly description: string;
/** Describes provider-supported query fields and the configured enumeration scope. */
readonly queryHelp: string;
readonly scope: Readonly<Record<string, unknown>>;
/** Cheap scoped change token. Must check permission; must not enumerate bodies or run a model.
* Null means no complete observation yet. Unsupported adapters use periodic discovery. */
activityToken?(caller: PluginSessionQueryCaller): Promise<string | null>;
/** Enumerates metadata only. Pagination is not an index coverage declaration. */
enumerate(cursor: string | undefined, caller: PluginSessionQueryCaller): Promise<SourcePage>;
query(
request: Readonly<Record<string, unknown>>,
caller: PluginSessionQueryCaller,
): Promise<SourcePage>;
/** Must check current read permission, even for locally cached evidence. Errors fail closed. */
authorize(
objects: readonly SourceObject[],
caller: PluginSessionQueryCaller,
): Promise<readonly string[]>;
read(object: SourceObject, caller: PluginSessionQueryCaller): Promise<SourceRead>;
}
interface RegisteredSource extends MakaContributionIdentity {
readonly adapter: PluginSourceAdapter;
readonly token: symbol;
retired: boolean;
}
/** Generic read-only provider registry. No credentials, bodies or provider SDKs enter indexes. */
export class PluginSourceService extends Service {
private readonly adapters = new PluginScopeRegistry<RegisteredSource>();
constructor(
ctx: Context,
private readonly agents: PluginAgentService,
) {
super(ctx, 'sources');
}
register(adapter: PluginSourceAdapter): Disposable<Promise<void>> {
if (this.ctx.maka && this.ctx.maka.rootId !== 'profile')
throw Error('Sources require profile scope');
if (!/^[a-z][a-z0-9._-]{0,79}$/.test(adapter.id) || adapter.id === 'maka')
throw Error('Invalid or reserved source ID');
const identity = pluginIdentity(this.ctx);
return registerPluginContribution(this.ctx, `source:${adapter.id}`, () => {
const existing = this.adapters.get('profile', adapter.id);
if (existing && existing.entryId !== identity.entryId)
throw Error('Source already registered by another entry');
return this.adapters.publish('profile', adapter.id, {
...identity,
adapter,
token: Symbol(adapter.id),
retired: false,
});
});
}
list() {
this.caller();
return this.adapters
.entries('profile')
.map(({ adapter: { id, description, queryHelp, scope } }) => ({
id,
description,
queryHelp,
scope,
storage: 'on-demand' as const,
}));
}
async activityToken(id: string): Promise<string | null> {
const caller = this.caller();
const adapter = this.adapter(id);
return adapter.activityToken ? adapter.activityToken(caller) : null;
}
async enumerate(id: string, cursor?: string) {
const caller = this.caller();
return this.page(await this.adapter(id).enumerate(cursor, caller));
}
async query(id: string, request: Readonly<Record<string, unknown>>) {
const caller = this.caller();
return this.page(await this.adapter(id).query(structuredClone(request), caller));
}
async authorize(id: string, objects: readonly SourceObject[]) {
const caller = this.caller();
return this.adapter(id).authorize(objects, caller);
}
async read(id: string, object: SourceObject): Promise<SourceRead> {
const caller = this.caller(),
adapter = this.adapter(id);
if (!(await adapter.authorize([object], caller)).includes(object.id))
throw Error('Source read permission denied');
const result = await adapter.read(object, caller);
if (result.status === 'ok') {
if (!result.object || result.object.id !== object.id || result.content === undefined)
throw Error('Invalid source read response');
// Never misrepresent a latest object as the immutable cited version.
if (result.object.revision !== object.revision)
return { status: 'version_unavailable', object: result.object };
}
return result;
}
private page(page: SourcePage) {
if (!Array.isArray(page.items) || (page.next !== undefined && typeof page.next !== 'string'))
throw Error('Invalid source page');
for (const o of page.items) {
if (
typeof o.id !== 'string' ||
!o.id ||
typeof o.revision !== 'string' ||
!o.revision ||
typeof o.kind !== 'string' ||
!o.kind ||
!o.locator ||
typeof o.locator !== 'object' ||
Array.isArray(o.locator)
)
throw Error('Source objects require stable identity, revision and locator');
}
return {
items: page.items.map((o) =>
structuredClone({
id: o.id,
locator: o.locator,
revision: o.revision,
kind: o.kind,
...(o.title !== undefined ? { title: o.title } : {}),
...(o.url !== undefined ? { url: o.url } : {}),
...(o.updatedAt !== undefined ? { updatedAt: o.updatedAt } : {}),
}),
),
...(page.next ? { next: page.next } : {}),
};
}
private adapter(id: string) {
const adapter = this.adapters.get('profile', id)?.adapter;
if (!adapter) throw Error(`Source unavailable: ${id}`);
return adapter;
}
private caller(): PluginSessionQueryCaller {
return Object.freeze({ invocation: this.agents.requireInvocation() });
}
}