fix(plugin): remediate ML-07 R2 findings
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Task: Remediate accepted MakeLore ML-07 R2 findings
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260827-plugin-ml07-remediation-r2-6e9a3c41
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260827-plugin-ml07-remediation-r2-6e9a3c41-plugin-ml07-remediation-r2
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml07-remediation-r2-6e9a3c41
|
||||
- Base commit: 29cf322f1ac0500295c1afec076800aea3908eb3
|
||||
- Owner: ml07-remediator
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Make the Data Service adapter validate the registry-supplied parsed tool instead of consulting a second static tool-definition table.
|
||||
- Prevent stale project-scoped Plugin Center mutations from committing after a newer project load starts.
|
||||
- Add persisted Pi run/resource identity evidence across reconnect and event replay through the existing extension bridge and capability registry.
|
||||
- Refresh plugin policy only before a parent worker can materialize an assigned server-backed plugin; child and core-only resolutions must not wait.
|
||||
- Change only the delegated product files, directly corresponding tests, and this record.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve the fixed first-party plugin boundary: package definitions are Main-authoritative, registry dispatch is the sole non-core tool path, and Pi remains the sole coding runtime.
|
||||
- Preserve policy refresh before materializing an enabled and assigned server-backed plugin. Do not add facades, migration/checksum machinery, or a replay framework.
|
||||
- Base and sole parent must remain `29cf322f1ac0500295c1afec076800aea3908eb3`; do not touch the coordinator or user root worktrees, push, or open a PR.
|
||||
- Existing coordinator and reviewer tasks are read-only peers. The prior ML-07 remediation worktree is complete and must not be reused.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Concurrent and Planning gates passed with `task_context.py`; status identifies this exact worktree, branch, base, task, and owner.
|
||||
- Read the project memory entry/current-state/architecture/domain/data-flow/success criteria and relevant ADR/task records, including the coordinator, R2 reviewers, and prior remediation.
|
||||
- Read the canonical plugin implementation spec (especially §§6-7.5, 10.2-12), detailed design (especially §§8.2-8.9, 15-17), and ML-07 plan ticket.
|
||||
- Confirmed no ownership conflict: coordinator owns integration/its record; R2 reviewers are read-only; this task exclusively owns the bounded remediation files and focused tests.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a red adapter test proving a registry-supplied schema controls validation, then remove the adapter's static definition lookup and run adapter/manifest/registry focus tests.
|
||||
2. Add stale A-mutation/B-load store tests, then apply one generation/project guard consistently to `setEnabled` and adjacent Data Service mutation commits.
|
||||
3. Move the refresh decision to the registry's existing role/assignment resolution seam; add never-settling child/core-only tests plus a server-backed parent refresh test, and remove the unconditional composition wrapper.
|
||||
4. Extend the existing Pi extension bundle test to persist a run ID, reconnect/re-import, replay the same resource ID, and assert both calls traverse the bridge/real registry with the identical `pi:<runId>:<resourceId>` request ID. Change production replay code only if this exposes a defect.
|
||||
5. Run affected focused suites, typecheck, lint, build, and package proof proportionate to the changes; complete the task documentation gate and create one clean remediation commit.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The Data Service adapter now validates and dispatches the immutable parsed tool supplied by the registry; its production dependency on `DATA_SERVICE_TOOL_DEFINITIONS` is removed while the typed ten-operation switch and domain bounds remain code-owned.
|
||||
- Project-scoped enable/configure/reset/remove-collection/remove-project mutations capture both active project and load generation. A late project A response cannot inspect, commit, or trigger a reload after project B starts loading.
|
||||
- Policy refresh now occurs inside the registry resource-resolution seam only for a parent with an assigned Skill owned by a server-backed plugin with a registered adapter. Child and parent/core-only resolution return without touching a stalled refresh; an assigned server-backed parent still refreshes before reading project enablement or policy state.
|
||||
- The existing Pi extension bundle test persists `persisted-run`, reimports the generated extension to model reconnect, replays `persisted-resource`, and sends both calls through the authenticated bridge and real capability registry. Both results retain the exact request ID `pi:persisted-run:persisted-resource`; no production replay change was needed.
|
||||
- The async/sync manifest loaders were intentionally not consolidated: their only duplication is the inherently different filesystem API flow, while both feed the same parser/validator. Refactoring that optional shape would not improve runtime authority within this bounded remediation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red evidence: adapter registry-schema test initially received success because the adapter replaced the supplied tool with the static definition; after the fix it rejects with `plugin_input_invalid` and the backend is not called.
|
||||
- Red evidence: stale A enable and four adjacent Data Service mutation tests initially overwrote B or continued into inspect; after generation/project guards both pass without stale inspect/reload.
|
||||
- Red evidence: assigned server-backed parent registry resolution initially never called refresh; after moving the decision into the registry it refreshes before enablement lookup. Never-settling refresh tests prove child and parent/core-only paths do not wait or call refresh.
|
||||
- `pnpm exec vitest run` focused affected set: 8 files, 44 tests passed (`data-service-plugin-adapter`, plugin manifest/registry/composition/routes, store, Pi extension bundle, Pi resource loader).
|
||||
- `pnpm run typecheck`: passed.
|
||||
- Focused ESLint over all changed product/test files: passed with zero warnings/errors. Full `pnpm run lint` reported zero errors and five pre-existing unrelated warnings in Home/Makelore files.
|
||||
- `pnpm run package:stage:win-x64`: passed (Vite renderer/Main/preload/utility builds and Pi runtime staging).
|
||||
- First `pnpm run verify:artifact:pi` correctly reported a missing unpacked executable because Electron builder had not yet run. After `node scripts/run-electron-builder.mjs --win --publish never`, the verifier passed, including the package-owned Data Service manifest/Skill/tools and four core Skill roots.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Coordinator will rerun the integration ledger/full suite after applying the sole remediation commit.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -38,7 +38,6 @@ import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
|
||||
import { createProjectPluginService } from '../coding-plugins/project-service';
|
||||
import {
|
||||
createCodingCapabilityRegistry,
|
||||
type CodingCapabilityRegistry,
|
||||
} from '../coding-plugins/registry';
|
||||
import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service';
|
||||
import { PluginPolicyClient } from '../services/plugin-policy-client';
|
||||
@@ -201,16 +200,7 @@ export function createCodingComposition(
|
||||
return active.projectId;
|
||||
},
|
||||
});
|
||||
const refreshingCapabilityRegistry: CodingCapabilityRegistry = {
|
||||
async resolveWorkerResources(input) {
|
||||
await policyClient.refresh();
|
||||
return await capabilityRegistry.resolveWorkerResources(input);
|
||||
},
|
||||
async invoke(input) {
|
||||
return await capabilityRegistry.invoke(input);
|
||||
},
|
||||
};
|
||||
productTools.configureCapabilityRegistry(refreshingCapabilityRegistry);
|
||||
productTools.configureCapabilityRegistry(capabilityRegistry);
|
||||
plugins = createCodingProjectPluginService({
|
||||
projects,
|
||||
projectPlugins,
|
||||
@@ -233,7 +223,7 @@ export function createCodingComposition(
|
||||
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
|
||||
: {}),
|
||||
extensionHost,
|
||||
capabilityRegistry: refreshingCapabilityRegistry,
|
||||
capabilityRegistry,
|
||||
}),
|
||||
});
|
||||
const childOpener = createPiManagedSubagentChildOpener({
|
||||
@@ -249,7 +239,7 @@ export function createCodingComposition(
|
||||
...(getLocalProxyCredential
|
||||
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
|
||||
: {}),
|
||||
capabilityRegistry: refreshingCapabilityRegistry,
|
||||
capabilityRegistry,
|
||||
});
|
||||
const subagents = new PiSubagentScheduler({
|
||||
openChild: childOpener,
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from '../../../shared/data-service';
|
||||
import {
|
||||
DATA_SERVICE_PLUGIN_ID,
|
||||
DATA_SERVICE_TOOL_DEFINITIONS,
|
||||
type CodingPluginToolDefinition,
|
||||
} from '../../../shared/coding-plugins';
|
||||
import type { DataServiceOperations } from '../../services/data-service-client';
|
||||
@@ -170,10 +169,6 @@ type DataServiceResult =
|
||||
| DataServiceInstanceRemoval
|
||||
| null;
|
||||
|
||||
function toolByName(toolName: string): CodingPluginToolDefinition | undefined {
|
||||
return DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === toolName);
|
||||
}
|
||||
|
||||
async function callDataService(
|
||||
operations: DataServiceOperations,
|
||||
toolName: string,
|
||||
@@ -271,14 +266,13 @@ export class DataServicePluginAdapter implements CodingPluginAdapter {
|
||||
tool: CodingPluginToolDefinition,
|
||||
input: unknown,
|
||||
): Promise<AdapterInvocationResult<DataServiceResult>> {
|
||||
const canonicalTool = toolByName(tool.name);
|
||||
if (!canonicalTool || !validateInput(canonicalTool, input)) {
|
||||
if (!validateInput(tool, input)) {
|
||||
return failure('plugin_input_invalid', 'Data Service tool input is invalid');
|
||||
}
|
||||
try {
|
||||
return projectResult(await callDataService(
|
||||
this.operations,
|
||||
canonicalTool.name,
|
||||
tool.name,
|
||||
input as InputRecord,
|
||||
_context.projectPath,
|
||||
));
|
||||
|
||||
@@ -113,7 +113,10 @@ export interface CodingCapabilityRegistryPort {
|
||||
export type CodingCapabilityRegistry = CodingCapabilityRegistryPort;
|
||||
|
||||
export interface CodingCapabilityRegistryOptions {
|
||||
policyClient: Pick<{ getState(): PluginPolicyClientState }, 'getState'>;
|
||||
policyClient: {
|
||||
getState(): PluginPolicyClientState;
|
||||
refresh(): Promise<void>;
|
||||
};
|
||||
getEnabledPluginIds?: (projectPath: string) => Promise<readonly string[]>;
|
||||
projectPlugins?: {
|
||||
getEnabledPluginIds(projectPath: string): Promise<readonly string[]>;
|
||||
@@ -375,6 +378,21 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
const hasAssignedServerPlugin = this.definitions.some((definition) => (
|
||||
definition.requiresBackend
|
||||
&& this.adaptersByPluginId.has(definition.id)
|
||||
&& definition.skills.some(({ id }) => assigned.includes(id))
|
||||
));
|
||||
if (!hasAssignedServerPlugin) {
|
||||
return {
|
||||
catalogRevision: this.options.policyClient.getState().revision,
|
||||
pluginIds: [],
|
||||
effectiveSkillIds: effectiveCoreSkills,
|
||||
skillEntries: effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` })),
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
await this.options.policyClient.refresh();
|
||||
const enabled = await this.enabledPluginIds(input.projectPath);
|
||||
const state = this.options.policyClient.getState();
|
||||
const pluginIds: string[] = [];
|
||||
|
||||
@@ -84,14 +84,17 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
});
|
||||
},
|
||||
setEnabled(projectId, pluginId, enabled) {
|
||||
const generation = loadGeneration;
|
||||
return operation(`enabled:${projectId}:${pluginId}`, async () => {
|
||||
const projection = await deps.setEnabled(projectId, pluginId, enabled);
|
||||
let dataService = get().projectId === projectId ? get().dataService : null;
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
let dataService = get().dataService;
|
||||
const item = projection.items.find(({ id }) => id === pluginId);
|
||||
if (!enabled) {
|
||||
dataService = null;
|
||||
} else if (item?.settingsSurface === 'data-service' && item.backend.status === 'ready') {
|
||||
const inspected = await deps.inspectDataService();
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
if (inspected.success && inspected.data) dataService = inspected.data;
|
||||
}
|
||||
set({
|
||||
@@ -100,34 +103,47 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
});
|
||||
},
|
||||
configure(collections) {
|
||||
const projectId = get().projectId;
|
||||
const generation = loadGeneration;
|
||||
return operation('data-service:configure', async () => {
|
||||
const result = await deps.configureDataService(collections);
|
||||
if (!result.success || !result.data) throw new Error(result.error || '开发数据空间创建失败');
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
set({ dataService: result.data, error: null });
|
||||
if (get().projectId) await get().load(get().projectId as string);
|
||||
if (projectId) await get().load(projectId);
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
const projectId = get().projectId;
|
||||
const generation = loadGeneration;
|
||||
return operation('data-service:reset', async () => {
|
||||
const result = await deps.resetDataService();
|
||||
if (!result.success || !result.data) throw new Error(result.error || '开发数据重置失败');
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
set({ dataService: result.data });
|
||||
});
|
||||
},
|
||||
removeCollection(collection) {
|
||||
const projectId = get().projectId;
|
||||
const generation = loadGeneration;
|
||||
return operation(`data-service:collection:${collection}`, async () => {
|
||||
const result = await deps.removeCollection(collection);
|
||||
if (!result.success) throw new Error(result.error || '移除 collection 失败');
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
const inspected = await deps.inspectDataService();
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
if (inspected.success && inspected.data) set({ dataService: inspected.data });
|
||||
});
|
||||
},
|
||||
removeProject() {
|
||||
const projectId = get().projectId;
|
||||
const generation = loadGeneration;
|
||||
return operation('data-service:remove-project', async () => {
|
||||
const result = await deps.removeProject();
|
||||
if (!result.success) throw new Error(result.error || '删除开发数据空间失败');
|
||||
if (generation !== loadGeneration || get().projectId !== projectId) return;
|
||||
set({ dataService: null });
|
||||
if (get().projectId) await get().load(get().projectId as string);
|
||||
if (projectId) await get().load(projectId);
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -86,7 +86,7 @@ function adapter(): CodingPluginAdapter {
|
||||
|
||||
function registry(overrides: Partial<ConstructorParameters<typeof CodingCapabilityRegistryImpl>[0]> = {}) {
|
||||
return new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy },
|
||||
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
||||
getEnabledPluginIds: async () => ['makelore.data-service'],
|
||||
adapters: [adapter()],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
@@ -114,13 +114,51 @@ describe('CodingCapabilityRegistry', () => {
|
||||
expect(child.pluginIds).toEqual([]);
|
||||
expect(child.tools).toEqual([]);
|
||||
const unavailable = await registry({
|
||||
policyClient: { getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }) },
|
||||
policyClient: {
|
||||
getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}).resolveWorkerResources({
|
||||
projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'parent',
|
||||
});
|
||||
expect(unavailable.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not wait for policy refresh when child or parent core-only resources cannot consume plugins', async () => {
|
||||
const refresh = vi.fn(() => new Promise<void>(() => {}));
|
||||
const policyClient = { getState: () => policy, refresh };
|
||||
const child = await registry({ policyClient }).resolveWorkerResources({
|
||||
projectPath: context.projectPath,
|
||||
assignedSkillIds: context.skillIds,
|
||||
role: 'child',
|
||||
});
|
||||
const coreOnly = await registry({ policyClient }).resolveWorkerResources({
|
||||
projectPath: context.projectPath,
|
||||
assignedSkillIds: ['grilling'],
|
||||
role: 'parent',
|
||||
});
|
||||
|
||||
expect(child.tools).toEqual([]);
|
||||
expect(coreOnly.effectiveSkillIds).toEqual(['grilling']);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes policy before resolving an assigned server-backed parent plugin', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const getEnabledPluginIds = vi.fn().mockResolvedValue(['makelore.data-service']);
|
||||
await registry({
|
||||
policyClient: { getState: () => policy, refresh },
|
||||
getEnabledPluginIds,
|
||||
}).resolveWorkerResources({
|
||||
projectPath: context.projectPath,
|
||||
assignedSkillIds: context.skillIds,
|
||||
role: 'parent',
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(refresh.mock.invocationCallOrder[0]).toBeLessThan(getEnabledPluginIds.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('revalidates invocation, derives the stable Pi request id, and preserves domain faults', async () => {
|
||||
const result = await registry().invoke({
|
||||
toolName: 'data_service_get_document',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCodingPluginsStore } from '@/stores/coding-plugins';
|
||||
import type {
|
||||
DataServiceCollectionRemoval,
|
||||
DataServiceHostResult,
|
||||
DataServiceInstanceRemoval,
|
||||
DataServiceInstanceState,
|
||||
} from '../../shared/data-service';
|
||||
|
||||
function projection(enabled = false, projectId = 'local-project') {
|
||||
return {
|
||||
@@ -15,6 +21,31 @@ function projection(enabled = false, projectId = 'local-project') {
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function dataServiceInstance(instanceId: string): DataServiceInstanceState {
|
||||
return {
|
||||
instance_id: instanceId, project_id: 'cloud-project', collections: [],
|
||||
usage: { document_count: 0, total_bytes: 0 },
|
||||
limits: {
|
||||
max_collections: 20, max_documents: 1000, max_total_bytes: 20_971_520,
|
||||
max_document_bytes: 65_536, list_default_limit: 50, list_max_limit: 100,
|
||||
list_max_data_bytes: 1_048_576, mutations_per_minute: 120,
|
||||
},
|
||||
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function success<T>(data: T): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('coding plugins store', () => {
|
||||
it('coalesces duplicate loads and mutations while retaining the last projection on failure', async () => {
|
||||
let resolveLoad!: (value: ReturnType<typeof projection>) => void;
|
||||
@@ -87,6 +118,66 @@ describe('coding plugins store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a late project A enable mutation overwrite loaded project B', async () => {
|
||||
const enabledA = deferred<ReturnType<typeof projection>>();
|
||||
const inspectDataService = vi.fn();
|
||||
const store = createCodingPluginsStore({
|
||||
list: vi.fn().mockResolvedValue(projection(false, 'project-b')),
|
||||
setEnabled: vi.fn(() => enabledA.promise),
|
||||
inspectDataService,
|
||||
});
|
||||
store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') });
|
||||
|
||||
const mutation = store.getState().setEnabled('project-a', 'makelore.data-service', true);
|
||||
await store.getState().load('project-b');
|
||||
enabledA.resolve(projection(true, 'project-a'));
|
||||
await mutation;
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
projectId: 'project-b', projection: { project: { localProjectId: 'project-b' } },
|
||||
});
|
||||
expect(inspectDataService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let adjacent late Data Service mutations commit or reload over project B', async () => {
|
||||
const configure = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
||||
const reset = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
||||
const removeCollection = deferred<DataServiceHostResult<DataServiceCollectionRemoval>>();
|
||||
const removeProject = deferred<DataServiceHostResult<DataServiceInstanceRemoval>>();
|
||||
const inspectDataService = vi.fn();
|
||||
const list = vi.fn().mockResolvedValue(projection(false, 'project-b'));
|
||||
const store = createCodingPluginsStore({
|
||||
list,
|
||||
inspectDataService,
|
||||
configureDataService: vi.fn(() => configure.promise),
|
||||
resetDataService: vi.fn(() => reset.promise),
|
||||
removeCollection: vi.fn(() => removeCollection.promise),
|
||||
removeProject: vi.fn(() => removeProject.promise),
|
||||
});
|
||||
store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') });
|
||||
|
||||
const mutations = [
|
||||
store.getState().configure(['todos']),
|
||||
store.getState().reset(),
|
||||
store.getState().removeCollection('todos'),
|
||||
store.getState().removeProject(),
|
||||
];
|
||||
await store.getState().load('project-b');
|
||||
configure.resolve(success(dataServiceInstance('late-configure')));
|
||||
reset.resolve(success(dataServiceInstance('late-reset')));
|
||||
removeCollection.resolve(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } }));
|
||||
removeProject.resolve(success({ removed: true }));
|
||||
await Promise.all(mutations);
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
projectId: 'project-b',
|
||||
projection: { project: { localProjectId: 'project-b' } },
|
||||
dataService: null,
|
||||
});
|
||||
expect(inspectDataService).not.toHaveBeenCalled();
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('retains ready Data Service usage when enabling an existing instance', async () => {
|
||||
const readyProjection = projection(true);
|
||||
readyProjection.items[0] = {
|
||||
@@ -104,6 +195,7 @@ describe('coding plugins store', () => {
|
||||
const store = createCodingPluginsStore({
|
||||
setEnabled: vi.fn().mockResolvedValue(readyProjection), inspectDataService,
|
||||
});
|
||||
store.setState({ projectId: 'local-project' });
|
||||
|
||||
await store.getState().setEnabled('local-project', 'makelore.data-service', true);
|
||||
|
||||
|
||||
@@ -46,6 +46,28 @@ function operations(): DataServiceOperations {
|
||||
}
|
||||
|
||||
describe('Data Service plugin adapter', () => {
|
||||
it('validates the parsed tool supplied by the capability registry', async () => {
|
||||
const dataService = operations();
|
||||
const adapter = createDataServicePluginAdapter(dataService);
|
||||
const inspect = DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === 'data_service_inspect');
|
||||
if (!inspect) throw new Error('inspect definition missing');
|
||||
|
||||
const result = await adapter.invoke(context, {
|
||||
...inspect,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['registry_token'],
|
||||
properties: { registry_token: { type: 'string', minLength: 1 } },
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false, status: 422, code: 'plugin_input_invalid', data: null,
|
||||
});
|
||||
expect(dataService.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps all ten package operations through DataServiceOperations', async () => {
|
||||
const dataService = operations();
|
||||
const adapter = createDataServicePluginAdapter(dataService);
|
||||
|
||||
@@ -4,13 +4,15 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent';
|
||||
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry';
|
||||
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
||||
import {
|
||||
DATA_SERVICE_PLUGIN_DEFINITION,
|
||||
type CodingPluginToolDefinition,
|
||||
@@ -46,6 +48,118 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('Makelore Pi extension bundle', () => {
|
||||
it('hydrates persisted Pi identity through reconnect and event replay into the capability registry', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-persisted-identity-'));
|
||||
roots.push(root);
|
||||
const policy: PluginPolicyClientState = {
|
||||
status: 'current', revision: 23, lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1, catalog_version: 'catalog-23', pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion],
|
||||
status: 'active',
|
||||
capabilities: [{
|
||||
capability_id: 'data-service.control',
|
||||
operations: [{
|
||||
operation: 'inspect',
|
||||
billing: { mode: 'included', entitlement_scope: null, notice: 'Included' },
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const invoke = vi.fn(async () => ({
|
||||
success: true as const, status: 200, code: null, error: null, retryable: false as const,
|
||||
payload_schema: 'data-service.v1', data: { instance_id: 'instance-a' },
|
||||
}));
|
||||
const capabilityRegistry = new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
||||
getEnabledPluginIds: async () => [DATA_SERVICE_PLUGIN_DEFINITION.id],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
adapters: [{
|
||||
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
async inspect() { return { status: 'ready' }; },
|
||||
invoke,
|
||||
}],
|
||||
});
|
||||
const host = new PiManagedExtensionHost();
|
||||
host.configureProductTools(new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
capabilityRegistry,
|
||||
}));
|
||||
hosts.push(host);
|
||||
const inspectTool = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(
|
||||
({ name }) => name === 'data_service_inspect',
|
||||
);
|
||||
if (!inspectTool) throw new Error('inspect definition missing');
|
||||
const worker = await host.registerWorker({
|
||||
conversationId: 'persisted-conversation', generation: 1, projectId: 'project-a',
|
||||
projectPath: root, extensionsDir: root,
|
||||
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
||||
catalogRevision: policy.revision,
|
||||
tools: [inspectTool],
|
||||
});
|
||||
await host.bindRun('persisted-conversation', 1, 'persisted-run');
|
||||
const persistedContext = JSON.parse(await readFile(
|
||||
worker.env.MAKELORE_PI_CONTEXT_FILE as string,
|
||||
'utf8',
|
||||
)) as { runId?: string };
|
||||
expect(persistedContext.runId).toBe('persisted-run');
|
||||
|
||||
const previous = {
|
||||
bridge: process.env.MAKELORE_PI_BRIDGE_URL,
|
||||
token: process.env.MAKELORE_PI_WORKER_TOKEN,
|
||||
context: process.env.MAKELORE_PI_CONTEXT_FILE,
|
||||
role: process.env.MAKELORE_PI_WORKER_ROLE,
|
||||
};
|
||||
Object.assign(process.env, worker.env);
|
||||
try {
|
||||
const executeAfterHydration = async (connection: string) => {
|
||||
const module = await import(
|
||||
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?connection=${connection}`
|
||||
) as {
|
||||
default(factory: {
|
||||
registerTool(tool: ExtensionTool): void;
|
||||
on(event: string, handler: ExtensionHandler): void;
|
||||
}): void | Promise<void>;
|
||||
};
|
||||
const tools = new Map<string, ExtensionTool>();
|
||||
await module.default({
|
||||
registerTool: (tool) => tools.set(tool.name, tool),
|
||||
on: () => undefined,
|
||||
});
|
||||
return await tools.get('data_service_inspect')?.execute?.(
|
||||
'persisted-resource', {}, new AbortController().signal,
|
||||
);
|
||||
};
|
||||
|
||||
const first = await executeAfterHydration('initial');
|
||||
const replay = await executeAfterHydration('reconnect');
|
||||
expect(first).toMatchObject({
|
||||
details: {
|
||||
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
request_id: 'pi:persisted-run:persisted-resource',
|
||||
},
|
||||
});
|
||||
expect(replay).toMatchObject({
|
||||
details: { request_id: 'pi:persisted-run:persisted-resource' },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL'
|
||||
: key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN'
|
||||
: key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE'
|
||||
: 'MAKELORE_PI_WORKER_ROLE';
|
||||
if (value === undefined) delete process.env[environmentKey];
|
||||
else process.env[environmentKey] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('materializes only the frozen plugin declarations and lease metadata', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-dynamic-bundle-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('Pi managed resource loader', () => {
|
||||
},
|
||||
};
|
||||
const registry = new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy },
|
||||
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
||||
getEnabledPluginIds: async () => enabled ? [DATA_SERVICE_PLUGIN_DEFINITION.id] : [],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
adapters: [{
|
||||
|
||||
Reference in New Issue
Block a user