feat(coding): add Pi Data Service product tools
This commit is contained in:
1 parent
44bcdf52fc
commit
1d63233cd0
12 files changed
+790
-26
No files matched your search
@@ -0,0 +1,132 @@
|
||||
# Task: Implement ML-04 purpose-specific Pi product tools
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260826-ml04-pi-data-tools-8d3c91a7
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260826-ml04-pi-data-tools-8d3c91a7-ml04-pi-data-tools
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-ml04-pi-data-tools-8d3c91a7
|
||||
- Base commit: e15d8b7f51bea6b56558c981964082a4e76e523c
|
||||
- Owner: ml04-pi-tools
|
||||
- Status: Ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement ML-04 in the MakeLore Electron Main/Pi boundary: ten purpose-specific
|
||||
Data Service Pi tools, strict protocol schemas, safe result details, destructive
|
||||
confirmation handling, and one-to-one in-process dispatch through the existing
|
||||
ML-03 `DataServiceOperations` seam.
|
||||
- Primary owned paths are `electron/coding-runtime/pi/product-tools.ts`,
|
||||
`electron/coding-runtime/pi/extensions/makelore-runtime.ts`,
|
||||
`electron/api/coding-composition.ts`, and focused Pi product-tool/extension
|
||||
tests. Only directly required shared contracts/projectors and the Data Service
|
||||
trusted-project-path seam may change.
|
||||
- Explicitly out of scope: ML-05 preview, ML-06 browser work, unrelated Renderer
|
||||
work, any OpenCode compatibility or generic multiplexer, loopback HTTP,
|
||||
credentials or arbitrary project/owner/path inputs, and root-main/push/PR
|
||||
operations. The only Renderer touch is the type-required conversation detail
|
||||
summary for the new safe `data-service.v1` contract.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve Pi `0.84.2` as the sole runtime and preserve both parent-only controls:
|
||||
bundled extension registration and the extension-host bridge `403` for child
|
||||
workers.
|
||||
- Tool identifiers are the exact ten underscore names from spec 9.4. Every input
|
||||
is closed (`additionalProperties: false`), validated at the product-tool seam,
|
||||
and derives authority from `PiProductToolContext.projectPath`; no tool input may
|
||||
contain owner, project, path, token, endpoint, URL, or local handles.
|
||||
- Reuse the single Main `DataServiceOperations` instance created by composition.
|
||||
Pass trusted Pi context projectPath at the deep operation boundary so active
|
||||
project identity is checked by the existing ML-03 service rather than by a
|
||||
second adapter or network hop.
|
||||
- Destructive operations require literal `confirmed: true` and return the safe
|
||||
Data Service envelope/detail without leaking credentials, local paths, or
|
||||
arbitrary upstream data.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task identity: `20260826-ml04-pi-data-tools-8d3c91a7`; mode `feature`; owner
|
||||
`ml04-pi-tools`; branch
|
||||
`codex/20260826-ml04-pi-data-tools-8d3c91a7-ml04-pi-data-tools`; isolated
|
||||
worktree `D:\Datas\OthersProjects\makelore-ml04-pi-data-tools-8d3c91a7`; base
|
||||
`e15d8b7f51bea6b56558c981964082a4e76e523c`.
|
||||
- Concurrent-task findings: the ML-00..ML-09 coordinator owns integration
|
||||
sequencing but not these implementation paths; existing Pi product-tools,
|
||||
extension-host, child-worker, and core-host-api work is already in the base.
|
||||
Other active planning records are placeholders or unrelated E2E/media work;
|
||||
no semantic ownership conflict was found for this scope.
|
||||
- Read before planning: repository `AGENTS.md`; `.project-docs` entry,
|
||||
memory-index, current-state, decision index, ADR-006, system overview,
|
||||
module-map, data-flow, business-rules, glossary, evidence/reflection/
|
||||
commitments/stale-item indexes; all relevant active/ready peer task records;
|
||||
the server-side ML plan and Data Service integration specification sections
|
||||
8.5, 9.1–9.4.
|
||||
- Relevant understanding: Main composition already creates one
|
||||
`DataServiceOperations`; `CodingProjectService` exposes
|
||||
`requireActiveRealProjectWithIdentity`; Pi product tools currently dispatch
|
||||
existing product operations in-process; the extension bundle forwards parent
|
||||
invocations to the extension-host bridge; the bridge already rejects child
|
||||
product invocation with `403`.
|
||||
- Known documentation uncertainty: project positioning and success-criteria
|
||||
documents remain mostly placeholders; this task therefore treats the accepted
|
||||
ML plan/spec and existing code/contracts as authoritative.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Extend shared Data Service result/detail contracts and product-tool projectors
|
||||
only as needed to represent a safe, versioned result envelope.
|
||||
2. Add strict ten-tool validation and one-to-one dispatch in Pi `product-tools`,
|
||||
passing trusted context projectPath to the single Data Service operations
|
||||
object; wire that object from composition.
|
||||
3. Add all ten parent-only registrations and closed parameter schemas to the
|
||||
bundled Makelore Pi extension while retaining existing registration behavior
|
||||
and bridge child-worker rejection.
|
||||
4. Add focused contract, Pi product-tool, bundle, and bridge tests proving exact
|
||||
IDs/schemas, dispatch/authority, safe details, confirmations, and absence of
|
||||
forbidden input fields.
|
||||
5. Run focused tests, typecheck, lint, diff/doc-drift checks; update this task
|
||||
record and complete the task-context gate.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added the ten exact parent-only Pi Data Service tools with closed, bounded
|
||||
protocol schemas and matching Main-side input validation. Each operation
|
||||
dispatches directly to the one composition-owned `DataServiceOperations`
|
||||
instance; active-project operations receive the trusted Pi projectPath and
|
||||
owner-wide listing does not.
|
||||
- Added versioned `data-service.v1` safe result details with bounded envelope
|
||||
projection, wired them through the existing conversation projector, and kept
|
||||
destructive operations behind literal `confirmed: true`.
|
||||
- Preserved the bundled parent-role registration gate and extension-host child
|
||||
bridge `403`; added tests for all ten registrations, dispatch routing,
|
||||
trusted path authority, forbidden input fields, and confirmation rejection.
|
||||
- No canonical project memory was changed. The only non-Main contract touch is
|
||||
the minimal conversation timeline summary required for the new discriminated
|
||||
safe-detail type to remain type-safe and visible.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec vitest run tests/unit/pi-product-tools.test.ts tests/unit/pi-extension-bundle.test.ts tests/unit/pi-extension-host.test.ts --maxWorkers=1` — 23 passed.
|
||||
- Data Service/projector plus Pi focused suites — 62 passed.
|
||||
- Full unit suite excluding the separately serialized pressure test — 186
|
||||
files, 1,574 passed, 2 skipped.
|
||||
- `pnpm typecheck` — passed.
|
||||
- `pnpm lint:check` — passed with the five pre-existing repository warnings in
|
||||
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
|
||||
- `git diff --check` — passed.
|
||||
- Final task-aware doc-drift check and task-context completion are run before
|
||||
handoff.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- The coordinator advanced after this task's required base with ML-03 commit
|
||||
`0ecceef15d2e43190f835fd71a7a1478222f1ba8` / coordinator frontier
|
||||
`44bcdf52...`; when integrating, retain its Data Service error-code/status
|
||||
consistency correction while applying this task's trusted-project-path
|
||||
method-signature hunk.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. The accepted ML-04 plan/spec and existing architecture remain the
|
||||
authority; no canonical ADR or project-memory update is proposed.
|
||||
@@ -207,6 +207,7 @@ export function createCodingComposition(
|
||||
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
|
||||
});
|
||||
const dataService = createDataServiceOperations({ projects });
|
||||
productTools.configureDataService(dataService);
|
||||
return {
|
||||
attachments,
|
||||
dataService,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PiSubagentScheduler,
|
||||
} from './subagent';
|
||||
import {
|
||||
isPiProductToolName,
|
||||
type PiProductToolName,
|
||||
type PiProductTools,
|
||||
} from './product-tools';
|
||||
@@ -121,7 +122,7 @@ function bridgeRequest(value: unknown): value is BridgeRequest {
|
||||
if (!common) return false;
|
||||
if (value.action === 'subagent.dispatch') return 'request' in value;
|
||||
if (value.action === 'product.invoke') {
|
||||
return typeof value.toolName === 'string' && 'input' in value;
|
||||
return isPiProductToolName(value.toolName) && 'input' in value;
|
||||
}
|
||||
if (value.action === 'changes.bash') return true;
|
||||
if (value.action === 'changes.touched') {
|
||||
|
||||
@@ -14,6 +14,12 @@ const MUTATION_TOOLS = new Set([
|
||||
'write',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'data_service_configure',
|
||||
'data_service_put_document',
|
||||
'data_service_delete_document',
|
||||
'data_service_remove_collection',
|
||||
'data_service_reset',
|
||||
'data_service_remove_project',
|
||||
]);
|
||||
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
|
||||
const leases = new Map();
|
||||
@@ -283,6 +289,127 @@ export default function makeloreRuntime(pi) {
|
||||
'Read the safe selected-skill and command catalog for this managed worker.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_configure',
|
||||
'Data Service configure',
|
||||
'Configure collections for the active Makelore project Data Service instance.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collections'],
|
||||
properties: {
|
||||
collections: {
|
||||
type: 'array', minItems: 0, maxItems: 20,
|
||||
items: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_inspect',
|
||||
'Data Service inspect',
|
||||
'Inspect the active Makelore project Data Service instance.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_list_projects',
|
||||
'Data Service projects',
|
||||
'List Data Service instances available to the signed-in account.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_get_document',
|
||||
'Data Service get document',
|
||||
'Read one document from a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection', 'document_id'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_list_documents',
|
||||
'Data Service list documents',
|
||||
'List documents from a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
||||
cursor: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_put_document',
|
||||
'Data Service put document',
|
||||
'Create or update one document in a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['collection', 'document_id', 'data'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
|
||||
data: { type: 'object' },
|
||||
if_revision: { type: 'integer', minimum: 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_delete_document',
|
||||
'Data Service delete document',
|
||||
'Delete one document from the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['collection', 'document_id', 'confirmed'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
|
||||
if_revision: { type: 'integer', minimum: 1 },
|
||||
confirmed: { type: 'boolean', const: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_remove_collection',
|
||||
'Data Service remove collection',
|
||||
'Remove a collection from the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection', 'confirmed'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
confirmed: { type: 'boolean', const: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_reset',
|
||||
'Data Service reset',
|
||||
'Reset all collections in the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['confirmed'],
|
||||
properties: { confirmed: { type: 'boolean', const: true } },
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_remove_project',
|
||||
'Data Service remove project',
|
||||
'Remove the active Makelore project Data Service instance after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['confirmed'],
|
||||
properties: { confirmed: { type: 'boolean', const: true } },
|
||||
},
|
||||
);
|
||||
|
||||
pi.on('tool_call', async (event, ctx) => {
|
||||
if (!MUTATION_TOOLS.has(event.toolName)) return;
|
||||
|
||||
@@ -13,6 +13,14 @@ import type {
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../../shared/coding-product-tools';
|
||||
import {
|
||||
DATA_SERVICE_PI_TOOL_NAMES,
|
||||
type DataServiceHostResult,
|
||||
type DataServicePiToolName,
|
||||
type DataServiceToolData,
|
||||
type DataServiceToolDetailsV1,
|
||||
} from '../../../shared/data-service';
|
||||
import type { DataServiceOperations } from '../../services/data-service-client';
|
||||
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
|
||||
import { PiAgentBrowserTool } from './extensions/agent-browser';
|
||||
import { reportChangedFiles } from './extensions/changed-file';
|
||||
@@ -25,7 +33,122 @@ export type PiProductToolName =
|
||||
| 'game_asset_review'
|
||||
| 'task_state'
|
||||
| 'changed_file'
|
||||
| 'runtime_context';
|
||||
| 'runtime_context'
|
||||
| DataServicePiToolName;
|
||||
|
||||
export { DATA_SERVICE_PI_TOOL_NAMES };
|
||||
|
||||
const PI_PRODUCT_TOOL_NAMES = new Set<string>([
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
...DATA_SERVICE_PI_TOOL_NAMES,
|
||||
]);
|
||||
|
||||
export function isPiProductToolName(value: unknown): value is PiProductToolName {
|
||||
return typeof value === 'string' && PI_PRODUCT_TOOL_NAMES.has(value);
|
||||
}
|
||||
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function invalidDataServiceInput(): never {
|
||||
throw new Error('Data Service tool input is invalid');
|
||||
}
|
||||
|
||||
function exactInput(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) return invalidDataServiceInput();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))
|
||||
|| required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function collection(value: unknown): string {
|
||||
if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) return invalidDataServiceInput();
|
||||
return value;
|
||||
}
|
||||
|
||||
function documentId(value: unknown): string {
|
||||
if (typeof value !== 'string' || value === '.' || value === '..'
|
||||
|| !DOCUMENT_ID_PATTERN.test(value)) return invalidDataServiceInput();
|
||||
return value;
|
||||
}
|
||||
|
||||
function revision(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) return invalidDataServiceInput();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function collections(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 20) return invalidDataServiceInput();
|
||||
return value.map(collection);
|
||||
}
|
||||
|
||||
function data(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) return invalidDataServiceInput();
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (typeof encoded !== 'string' || Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
} catch {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function limit(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 100) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_CURSOR_LENGTH) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function confirmation(value: unknown): true {
|
||||
if (value !== true) return invalidDataServiceInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataServiceResult<T extends DataServiceToolData | null>(
|
||||
operation: DataServicePiToolName,
|
||||
result: DataServiceHostResult<T>,
|
||||
): PiProductToolResult {
|
||||
const details: DataServiceToolDetailsV1 = {
|
||||
schema: 'data-service.v1',
|
||||
operation,
|
||||
...result,
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiProductToolContext {
|
||||
conversationId: string;
|
||||
@@ -46,16 +169,23 @@ export interface PiProductToolsOptions {
|
||||
attachments: CodingAttachmentStore;
|
||||
bundledSkillsDir: string;
|
||||
changeTracker?: ConversationChangeTracker;
|
||||
dataService?: DataServiceOperations;
|
||||
}
|
||||
|
||||
export class PiProductTools {
|
||||
readonly changeTracker: ConversationChangeTracker;
|
||||
private readonly browser: PiAgentBrowserTool;
|
||||
private readonly gameAssets = new PiGameAssetTools();
|
||||
private dataService: DataServiceOperations | undefined;
|
||||
|
||||
constructor(private readonly options: PiProductToolsOptions) {
|
||||
this.changeTracker = options.changeTracker ?? new ConversationChangeTracker();
|
||||
this.browser = new PiAgentBrowserTool(options.browser, options.attachments);
|
||||
this.dataService = options.dataService;
|
||||
}
|
||||
|
||||
configureDataService(dataService: DataServiceOperations): void {
|
||||
this.dataService = dataService;
|
||||
}
|
||||
|
||||
beginRun(input: { conversationId: string; runId: string; projectPath: string }) {
|
||||
@@ -107,6 +237,77 @@ export class PiProductTools {
|
||||
if (toolName === 'changed_file') {
|
||||
return await reportChangedFiles(this.changeTracker, context, input);
|
||||
}
|
||||
if (toolName === 'data_service_configure') {
|
||||
const body = exactInput(input, ['collections']);
|
||||
const result = await this.requireDataService().configure(
|
||||
{ collections: collections(body.collections) }, context.projectPath,
|
||||
);
|
||||
return dataServiceResult(toolName, result);
|
||||
}
|
||||
if (toolName === 'data_service_inspect') {
|
||||
exactInput(input, []);
|
||||
return dataServiceResult(toolName, await this.requireDataService().inspect(context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_list_projects') {
|
||||
exactInput(input, []);
|
||||
return dataServiceResult(toolName, await this.requireDataService().listProjects());
|
||||
}
|
||||
if (toolName === 'data_service_get_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().getDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_list_documents') {
|
||||
const body = exactInput(input, ['collection'], ['limit', 'cursor']);
|
||||
const requestedLimit = limit(body.limit);
|
||||
const requestedCursor = cursor(body.cursor);
|
||||
return dataServiceResult(toolName, await this.requireDataService().listDocuments({
|
||||
collection: collection(body.collection),
|
||||
...(requestedLimit === undefined ? {} : { limit: requestedLimit }),
|
||||
...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_put_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id', 'data'], ['if_revision']);
|
||||
const requestedRevision = revision(body.if_revision);
|
||||
return dataServiceResult(toolName, await this.requireDataService().putDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
data: data(body.data),
|
||||
...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_delete_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id', 'confirmed'], ['if_revision']);
|
||||
const requestedRevision = revision(body.if_revision);
|
||||
return dataServiceResult(toolName, await this.requireDataService().deleteDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }),
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_remove_collection') {
|
||||
const body = exactInput(input, ['collection', 'confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().removeCollection({
|
||||
collection: collection(body.collection),
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_reset') {
|
||||
const body = exactInput(input, ['confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().reset({
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_remove_project') {
|
||||
const body = exactInput(input, ['confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().removeProject({
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable');
|
||||
const skills = await this.listSkills(context.skillIds);
|
||||
const details: RuntimeContextDetailsV1 = {
|
||||
@@ -119,4 +320,9 @@ export class PiProductTools {
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
private requireDataService(): DataServiceOperations {
|
||||
if (!this.dataService) throw new Error('Data Service tools are unavailable');
|
||||
return this.dataService;
|
||||
}
|
||||
}
|
||||
@@ -77,16 +77,16 @@ type FetchImplementation = typeof fetch;
|
||||
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
|
||||
|
||||
export type DataServiceOperations = {
|
||||
configure(input: { collections: string[] }): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
inspect(): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
configure(input: { collections: string[] }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
inspect(trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
listProjects(): Promise<DataServiceHostResult<DataServiceInstanceList>>;
|
||||
getDocument(input: { collection: string; document_id: string }): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
listDocuments(input: { collection: string; limit?: number; cursor?: string }): Promise<DataServiceHostResult<DataServiceDocumentList>>;
|
||||
putDocument(input: DataServicePutDocumentInput): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }): Promise<DataServiceHostResult<null>>;
|
||||
removeCollection(input: DataServiceCollectionTargetInput): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
|
||||
reset(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
removeProject(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceRemoval>>;
|
||||
getDocument(input: { collection: string; document_id: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
listDocuments(input: { collection: string; limit?: number; cursor?: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocumentList>>;
|
||||
putDocument(input: DataServicePutDocumentInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }, trustedProjectPath?: string): Promise<DataServiceHostResult<null>>;
|
||||
removeCollection(input: DataServiceCollectionTargetInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
|
||||
reset(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
removeProject(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceRemoval>>;
|
||||
};
|
||||
|
||||
export type DataServiceCloudClientDependencies = {
|
||||
@@ -747,10 +747,11 @@ export function createDataServiceOperations(
|
||||
const client = dependencies.client ?? new DataServiceCloudClient();
|
||||
|
||||
async function withActive<T>(
|
||||
trustedProjectPath: string | undefined,
|
||||
operation: (projectId: string) => Promise<DataServiceHostResult<T>>,
|
||||
): Promise<DataServiceHostResult<T>> {
|
||||
try {
|
||||
const active = await dependencies.projects.requireActiveRealProjectWithIdentity();
|
||||
const active = await dependencies.projects.requireActiveRealProjectWithIdentity(trustedProjectPath);
|
||||
return await operation(active.projectId);
|
||||
} catch (error) {
|
||||
return projectLocalProjectError<T>(error);
|
||||
@@ -759,26 +760,26 @@ export function createDataServiceOperations(
|
||||
|
||||
return {
|
||||
listProjects: () => client.listProjects(),
|
||||
configure: ({ collections }) => withActive((projectId) => client.configure(projectId, collections)),
|
||||
inspect: () => withActive((projectId) => client.inspect(projectId)),
|
||||
getDocument: ({ collection, document_id }) => withActive(
|
||||
configure: ({ collections }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.configure(projectId, collections)),
|
||||
inspect: (trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.inspect(projectId)),
|
||||
getDocument: ({ collection, document_id }, trustedProjectPath) => withActive(trustedProjectPath,
|
||||
(projectId) => client.getDocument(projectId, collection, document_id),
|
||||
),
|
||||
listDocuments: ({ collection, limit, cursor }) => withActive(
|
||||
listDocuments: ({ collection, limit, cursor }, trustedProjectPath) => withActive(trustedProjectPath,
|
||||
(projectId) => client.listDocuments(projectId, collection, limit, cursor),
|
||||
),
|
||||
putDocument: (input) => withActive((projectId) => client.putDocument(projectId, input)),
|
||||
deleteDocument: ({ collection, document_id, if_revision, confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
|
||||
putDocument: (input, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.putDocument(projectId, input)),
|
||||
deleteDocument: ({ collection, document_id, if_revision, confirmed }, trustedProjectPath) => confirmed === true
|
||||
? withActive(trustedProjectPath, (projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
removeCollection: ({ collection, confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.removeCollection(projectId, collection))
|
||||
removeCollection: ({ collection, confirmed }, trustedProjectPath) => confirmed === true
|
||||
? withActive(trustedProjectPath, (projectId) => client.removeCollection(projectId, collection))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
reset: ({ confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.reset(projectId))
|
||||
reset: ({ confirmed }, trustedProjectPath) => confirmed === true
|
||||
? withActive(trustedProjectPath, (projectId) => client.reset(projectId))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
removeProject: ({ confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.removeProject(projectId))
|
||||
removeProject: ({ confirmed }, trustedProjectPath) => confirmed === true
|
||||
? withActive(trustedProjectPath, (projectId) => client.removeProject(projectId))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DataServiceToolDetailsV1 } from './data-service';
|
||||
|
||||
export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high';
|
||||
|
||||
export interface ProductModelRef {
|
||||
@@ -192,6 +194,8 @@ export interface RuntimeContextDetailsV1 {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type { DataServiceToolDetailsV1 } from './data-service';
|
||||
|
||||
export interface SubagentDetailsV1 {
|
||||
schema: 'subagent.v1';
|
||||
dispatchId: string;
|
||||
@@ -214,6 +218,7 @@ export type KnownToolDetails =
|
||||
| AgentBrowserDetailsV1
|
||||
| GameAssetsDetailsV1
|
||||
| RuntimeContextDetailsV1
|
||||
| DataServiceToolDetailsV1
|
||||
| SubagentDetailsV1;
|
||||
|
||||
export interface ConversationToolNode {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type {
|
||||
AgentBrowserDetailsV1,
|
||||
ChangedFileDetailsV1,
|
||||
DataServiceToolDetailsV1,
|
||||
GameAssetsDetailsV1,
|
||||
KnownToolDetails,
|
||||
RuntimeContextDetailsV1,
|
||||
TaskStateDetailsV1,
|
||||
} from './coding-conversation-contracts';
|
||||
import {
|
||||
DATA_SERVICE_PI_TOOL_NAMES,
|
||||
type DataServiceErrorContext,
|
||||
type DataServiceToolData,
|
||||
type DataServicePiToolName,
|
||||
} from './data-service';
|
||||
|
||||
const PRODUCT_TOOL_NAMES = new Set([
|
||||
'agent_browser',
|
||||
@@ -14,8 +21,21 @@ const PRODUCT_TOOL_NAMES = new Set([
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
...DATA_SERVICE_PI_TOOL_NAMES,
|
||||
]);
|
||||
|
||||
const DATA_SERVICE_CONTEXT_KEYS = new Set([
|
||||
'resource',
|
||||
'limit',
|
||||
'current',
|
||||
'attempted',
|
||||
'actual',
|
||||
'allowed',
|
||||
'current_revision',
|
||||
'retry_after_seconds',
|
||||
]);
|
||||
const DATA_SERVICE_CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -133,6 +153,63 @@ function runtimeContextDetails(value: Record<string, unknown>): RuntimeContextDe
|
||||
return { schema: 'runtime-context.v1', skills, commands };
|
||||
}
|
||||
|
||||
function dataServiceContext(value: unknown): DataServiceErrorContext | undefined | null {
|
||||
if (value === undefined) return undefined;
|
||||
const source = record(value);
|
||||
if (!source || Object.keys(source).length > 8) return null;
|
||||
const context: DataServiceErrorContext = {};
|
||||
for (const [key, item] of Object.entries(source)) {
|
||||
if (!DATA_SERVICE_CONTEXT_KEYS.has(key)) return null;
|
||||
if (key === 'resource') {
|
||||
if (typeof item !== 'string' || !DATA_SERVICE_CONTEXT_RESOURCES.has(item)) return null;
|
||||
context[key] = item;
|
||||
} else if (Number.isSafeInteger(item) && (item as number) >= 0) {
|
||||
context[key] = item as number;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return Object.keys(context).length > 0 ? context : undefined;
|
||||
}
|
||||
|
||||
function dataServiceDetails(value: Record<string, unknown>): DataServiceToolDetailsV1 | null {
|
||||
if (!DATA_SERVICE_PI_TOOL_NAMES.includes(value.operation as DataServicePiToolName)) return null;
|
||||
if (typeof value.success !== 'boolean'
|
||||
|| !Number.isSafeInteger(value.status)
|
||||
|| (value.status as number) < 100 || (value.status as number) > 599
|
||||
|| (value.code !== null && text(value.code, 128) === null)
|
||||
|| (value.error !== null && text(value.error, 2_000) === null)
|
||||
|| typeof value.retryable !== 'boolean'
|
||||
|| !Object.prototype.hasOwnProperty.call(value, 'data')) return null;
|
||||
if (value.success) {
|
||||
if ((value.status as number) < 200 || (value.status as number) > 299
|
||||
|| value.code !== null || value.error !== null || value.retryable !== false) return null;
|
||||
} else if ((value.status as number) < 400 || value.code === null || value.error === null) {
|
||||
return null;
|
||||
}
|
||||
const retryAfter = value.retry_after_seconds;
|
||||
if (retryAfter !== undefined
|
||||
&& (!Number.isSafeInteger(retryAfter) || (retryAfter as number) < 0 || (retryAfter as number) > 86_400)) {
|
||||
return null;
|
||||
}
|
||||
const context = dataServiceContext(value.context);
|
||||
if (context === null) return null;
|
||||
const data = value.data === null ? null : record(value.data);
|
||||
if (value.data !== null && !data) return null;
|
||||
return {
|
||||
schema: 'data-service.v1',
|
||||
operation: value.operation as DataServicePiToolName,
|
||||
success: value.success,
|
||||
status: value.status as number,
|
||||
code: value.code as string | null,
|
||||
error: value.error as string | null,
|
||||
retryable: value.retryable,
|
||||
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter as number }),
|
||||
...(context === undefined ? {} : { context }),
|
||||
data: data as DataServiceToolData | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function productToolDetails(value: unknown): Exclude<KnownToolDetails, { schema: 'subagent.v1' | 'write-lease.v1' }> | null {
|
||||
const details = record(value);
|
||||
if (!details) return null;
|
||||
@@ -144,6 +221,7 @@ export function productToolDetails(value: unknown): Exclude<KnownToolDetails, {
|
||||
if (details.schema === 'agent-browser.v1') return browserDetails(details);
|
||||
if (details.schema === 'game-assets.v1') return gameAssetDetails(details);
|
||||
if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details);
|
||||
if (details.schema === 'data-service.v1') return dataServiceDetails(details);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ export type DataServiceDocumentList = {
|
||||
|
||||
export type DataServiceErrorContext = Record<string, string | number>;
|
||||
|
||||
export const DATA_SERVICE_PI_TOOL_NAMES = [
|
||||
'data_service_configure',
|
||||
'data_service_inspect',
|
||||
'data_service_list_projects',
|
||||
'data_service_get_document',
|
||||
'data_service_list_documents',
|
||||
'data_service_put_document',
|
||||
'data_service_delete_document',
|
||||
'data_service_remove_collection',
|
||||
'data_service_reset',
|
||||
'data_service_remove_project',
|
||||
] as const;
|
||||
|
||||
export type DataServicePiToolName = (typeof DATA_SERVICE_PI_TOOL_NAMES)[number];
|
||||
|
||||
export type DataServiceHostResult<T> = {
|
||||
success: boolean;
|
||||
status: number;
|
||||
@@ -73,6 +88,19 @@ export type DataServiceHostResult<T> = {
|
||||
data: T | null;
|
||||
};
|
||||
|
||||
export type DataServiceToolData =
|
||||
| DataServiceInstanceState
|
||||
| DataServiceInstanceList
|
||||
| DataServiceDocument
|
||||
| DataServiceDocumentList
|
||||
| DataServiceCollectionRemoval
|
||||
| DataServiceInstanceRemoval;
|
||||
|
||||
export type DataServiceToolDetailsV1 = DataServiceHostResult<DataServiceToolData> & {
|
||||
schema: 'data-service.v1';
|
||||
operation: DataServicePiToolName;
|
||||
};
|
||||
|
||||
export type DataServicePutDocumentInput = {
|
||||
collection: string;
|
||||
document_id: string;
|
||||
|
||||
@@ -303,6 +303,16 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (details.schema === 'data-service.v1') {
|
||||
return (
|
||||
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
|
||||
<p className="font-semibold">数据服务 · {details.operation}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SubagentGraph details={details} />;
|
||||
});
|
||||
|
||||
|
||||
@@ -15,9 +15,24 @@ import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
|
||||
type ExtensionTool = {
|
||||
name: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
execute?: (...arguments_: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
async function post(
|
||||
registration: Awaited<ReturnType<PiManagedExtensionHost['registerWorker']>>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return await fetch(registration.env.MAKELORE_PI_BRIDGE_URL as string, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${registration.env.MAKELORE_PI_WORKER_TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
const hosts: PiManagedExtensionHost[] = [];
|
||||
|
||||
@@ -209,7 +224,26 @@ describe('Makelore Pi extension bundle', () => {
|
||||
expect([...tools.keys()]).toEqual([
|
||||
'ask_user', 'subagent', 'agent_browser', 'game_asset_browser',
|
||||
'game_asset_review', 'task_state', 'changed_file', 'runtime_context',
|
||||
'data_service_configure', 'data_service_inspect', 'data_service_list_projects',
|
||||
'data_service_get_document', 'data_service_list_documents', 'data_service_put_document',
|
||||
'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset',
|
||||
'data_service_remove_project',
|
||||
]);
|
||||
const dataServiceTools = [
|
||||
'data_service_configure', 'data_service_inspect', 'data_service_list_projects',
|
||||
'data_service_get_document', 'data_service_list_documents', 'data_service_put_document',
|
||||
'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset',
|
||||
'data_service_remove_project',
|
||||
];
|
||||
for (const name of dataServiceTools) {
|
||||
const tool = tools.get(name);
|
||||
expect(tool?.parameters).toMatchObject({
|
||||
type: 'object', additionalProperties: false,
|
||||
});
|
||||
expect(Object.keys(tool?.parameters?.properties ?? {})).not.toEqual(
|
||||
expect.arrayContaining(['owner', 'project', 'path', 'token', 'endpoint', 'url', 'handle']),
|
||||
);
|
||||
}
|
||||
|
||||
const updates: unknown[] = [];
|
||||
const subagentResult = await tools.get('subagent')?.execute?.(
|
||||
@@ -317,6 +351,12 @@ describe('Makelore Pi extension bundle', () => {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
});
|
||||
expect(tools).toEqual([]);
|
||||
const forgedParentTool = await post(child, {
|
||||
action: 'product.invoke', conversationId: 'conversation-child', workerGeneration: 1,
|
||||
runId: 'run-parent', resourceId: 'forged-data-tool',
|
||||
toolName: 'data_service_inspect', input: {},
|
||||
});
|
||||
expect(forgedParentTool.status).toBe(403);
|
||||
await writeFile(path.join(root, 'child.txt'), 'after\n', 'utf8');
|
||||
await handlers.get('tool_call')?.({
|
||||
toolName: 'write', toolCallId: 'child-write', input: { path: 'child.txt' },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../electron/coding-projects/skill-registry';
|
||||
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
|
||||
import type { DataServiceOperations } from '../../electron/services/data-service-client';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
@@ -231,6 +232,36 @@ describe('PI-090 product tools', () => {
|
||||
schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a',
|
||||
})).toBeNull();
|
||||
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: { instance_id: 'instance-a' },
|
||||
owner: 'must-be-dropped',
|
||||
})).toEqual({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: { instance_id: 'instance-a' },
|
||||
});
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_unknown',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('stores browser screenshots as attachment ids and never returns base64', async () => {
|
||||
@@ -288,4 +319,108 @@ describe('PI-090 product tools', () => {
|
||||
expect(JSON.stringify(result)).not.toContain(root);
|
||||
expect(JSON.stringify(result)).not.toContain('data:');
|
||||
});
|
||||
|
||||
it('dispatches all Data Service tools through the shared adapter and trusted project path', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-tools-');
|
||||
const response = (data: unknown) => ({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data,
|
||||
});
|
||||
const dataService = {
|
||||
configure: vi.fn().mockResolvedValue(response({ configured: true })),
|
||||
inspect: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
listProjects: vi.fn().mockResolvedValue(response({ items: [], total: 0, instance_limit: 20 })),
|
||||
getDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
listDocuments: vi.fn().mockResolvedValue(response({ items: [], next_cursor: null, limit: 50 })),
|
||||
putDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
deleteDocument: vi.fn().mockResolvedValue(response(null)),
|
||||
removeCollection: vi.fn().mockResolvedValue(response({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
|
||||
reset: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
removeProject: vi.fn().mockResolvedValue(response({ removed: true })),
|
||||
} as unknown as DataServiceOperations;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
|
||||
await tools.execute('data_service_configure', context, { collections: ['todos'] });
|
||||
await tools.execute('data_service_inspect', context, {});
|
||||
await tools.execute('data_service_list_projects', context, {});
|
||||
await tools.execute('data_service_get_document', context, {
|
||||
collection: 'todos', document_id: 'one',
|
||||
});
|
||||
await tools.execute('data_service_list_documents', context, {
|
||||
collection: 'todos', limit: 50, cursor: 'cursor-a',
|
||||
});
|
||||
await tools.execute('data_service_put_document', context, {
|
||||
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
||||
});
|
||||
await tools.execute('data_service_delete_document', context, {
|
||||
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
||||
});
|
||||
await tools.execute('data_service_remove_collection', context, {
|
||||
collection: 'todos', confirmed: true,
|
||||
});
|
||||
await tools.execute('data_service_reset', context, { confirmed: true });
|
||||
const removed = await tools.execute('data_service_remove_project', context, { confirmed: true });
|
||||
|
||||
expect(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, root);
|
||||
expect(dataService.inspect).toHaveBeenCalledWith(root);
|
||||
expect(dataService.listProjects).toHaveBeenCalledWith();
|
||||
expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, root);
|
||||
expect(dataService.listDocuments).toHaveBeenCalledWith({
|
||||
collection: 'todos', limit: 50, cursor: 'cursor-a',
|
||||
}, root);
|
||||
expect(dataService.putDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
||||
}, root);
|
||||
expect(dataService.deleteDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
||||
}, root);
|
||||
expect(dataService.removeCollection).toHaveBeenCalledWith({ collection: 'todos', confirmed: true }, root);
|
||||
expect(dataService.reset).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(dataService.removeProject).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(removed.details).toMatchObject({
|
||||
schema: 'data-service.v1', operation: 'data_service_remove_project',
|
||||
success: true, status: 200, data: { removed: true },
|
||||
});
|
||||
expect(JSON.stringify(removed)).not.toContain(root);
|
||||
});
|
||||
|
||||
it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-input-');
|
||||
const dataService = {
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data: null,
|
||||
}),
|
||||
removeProject: vi.fn(),
|
||||
} as unknown as DataServiceOperations;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
|
||||
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' })).rejects.toThrow(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
await expect(tools.execute('data_service_put_document', context, {
|
||||
collection: 'todos', document_id: 'one', data: {}, path: root,
|
||||
})).rejects.toThrow('Data Service tool input is invalid');
|
||||
await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).rejects.toThrow(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
expect(dataService.inspect).not.toHaveBeenCalled();
|
||||
expect(dataService.removeProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user