From 4be58b39fb5968aaf8ffca18403039f0e1081dc8 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 31 Aug 2026 20:27:04 -0400 Subject: [PATCH] feat: orchestrate provider-aware team knowledge context --- compose.development.yml | 6 + package-lock.json | 8 +- package.json | 2 +- .../runtime/foundation-runtime-utilities.ts | 3 + .../policy/workdays/chat-activity-profile.ts | 2 +- .../capacity/providers/execution-provider.ts | 2 +- .../services/build/demand-compiler.ts | 7 +- .../agents/context-query-check-service.ts | 44 ++++++- .../lifecycle/assignment-lifecycle-service.ts | 11 +- .../lifecycle/context-capacity/overflow.ts | 17 +++ .../planning/assignment-function.ts | 51 ++++---- .../planning/assignment-operational-paths.ts | 4 +- .../cross-project-read-repositories.ts | 87 +++++++++++++ .../support/assignment-function-store.ts | 18 +++ .../provider-synthesis-context-service.ts | 5 +- .../workdays/policy/workday-agent-policy.ts | 2 + .../treedx/workday-treedx-connection.ts | 5 +- .../project-agent-activity-refs.ts | 11 +- .../repositories/treedx-commit-replication.ts | 11 +- .../treedx-proxy-token-service.ts | 4 +- src/api/control-plane/catalog/index.ts | 4 +- .../catalog/knowledge-sharing/operations.ts | 66 ++++++++++ .../catalog/project-operations.ts | 4 + .../control-plane/catalog/team-operations.ts | 21 +++- .../knowledge/knowledge-review-service.ts | 4 +- .../knowledge/knowledge-workspace-service.ts | 16 +++ .../capacity/communication-service.ts | 27 +++-- .../providers/provider-runtime-service.ts | 21 +++- .../treedx/proxy-operation-service.ts | 73 ++++++++++- .../treedx/infrastructure-client.ts | 15 ++- .../treedx/upstream-operation.ts | 7 +- src/api/discussions/content.ts | 14 ++- src/api/discussions/discussion-service.ts | 8 +- .../knowledge/gateway-treedx-connection.ts | 17 ++- .../runtime/context-query-runtime.ts | 5 +- src/api/store/installers/teams.ts | 3 + src/api/store/installers/treedx.ts | 6 + src/api/store/interface.ts | 5 + .../claims/claim-platform-operation.ts | 6 +- .../evaluate-team-deletion-blockers.ts | 3 +- .../ensure-managed-team-library-project.ts | 27 +++++ src/api/store/teams/creation/create-team.ts | 1 + .../repositories/queries/get-tree-dx-share.ts | 6 + .../list-tree-dx-shares-for-recipient.ts | 9 ++ .../updates/revoke-tree-dx-share.ts | 9 ++ src/api/support/app.ts | 3 + src/api/support/server.ts | 16 +++ src/api/teams/managed-team-library-service.ts | 114 ++++++++++++++++++ .../library-provider-reconciliation.ts | 22 +++- .../project-knowledge-binding.ts | 37 ++++-- .../knowledge/publication-executor.ts | 32 +++-- .../knowledge/remote-publication.ts | 27 +++-- .../treedx/commit-replication-executor.ts | 3 + .../treedx/commit-replication-scheduler.ts | 44 ++++--- .../cross-project-read-repositories.test.ts | 37 ++++++ .../project-agent-context-layers.test.ts | 10 ++ .../discussion-targeted-read.test.ts | 44 +++++++ .../control-plane/knowledge/shares.test.ts | 63 ++++++++++ .../control-plane/protocol-contract.test.ts | 3 +- .../provider-context-overflow-status.test.ts | 21 ++++ .../seeds/project-knowledge-binding.test.ts | 2 +- .../teams/delete-operation.test.ts | 8 +- .../managed-team-library-deletion.test.ts | 24 ++++ .../managed-team-library-readiness.test.ts | 19 +++ .../treedx/proxy-operations.test.ts | 10 ++ .../treedx/upstream-operation.test.ts | 7 ++ treeseed.package.yaml | 2 +- 67 files changed, 1081 insertions(+), 144 deletions(-) create mode 100644 src/api/capacity/services/capacity/assignments/lifecycle/context-capacity/overflow.ts create mode 100644 src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts create mode 100644 src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts create mode 100644 src/api/control-plane/catalog/knowledge-sharing/operations.ts create mode 100644 src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts create mode 100644 src/api/store/treedx/repositories/queries/get-tree-dx-share.ts create mode 100644 src/api/store/treedx/repositories/queries/list-tree-dx-shares-for-recipient.ts create mode 100644 src/api/store/treedx/repositories/updates/revoke-tree-dx-share.ts create mode 100644 src/api/teams/managed-team-library-service.ts create mode 100644 tests/unit/control-plane/capacity/context-query/cross-project-read-repositories.test.ts create mode 100644 tests/unit/control-plane/capacity/context-query/project-agent-context-layers.test.ts create mode 100644 tests/unit/control-plane/discussions/discussion-targeted-read.test.ts create mode 100644 tests/unit/control-plane/knowledge/shares.test.ts create mode 100644 tests/unit/control-plane/providers/provider-context-overflow-status.test.ts create mode 100644 tests/unit/control-plane/teams/managed-team-library-deletion.test.ts create mode 100644 tests/unit/control-plane/teams/managed-team-library-readiness.test.ts diff --git a/compose.development.yml b/compose.development.yml index 4beb8d4d..0bcc8cbb 100644 --- a/compose.development.yml +++ b/compose.development.yml @@ -20,6 +20,7 @@ services: TREESEED_MAILPIT_SMTP_HOST: TREESEED_MAILPIT_SMTP_PORT: TREESEED_TREEDX_URL: + TREESEED_LOCAL_TREEDX_HOSTS: treedx,host.docker.internal TREESEED_TREEDX_JWT_ISSUER: TREESEED_TREEDX_JWT_AUDIENCE: POSTGRES_USER: @@ -36,6 +37,8 @@ services: - "127.0.0.1:3000:3000" volumes: - ${TREESEED_DEVELOPMENT_WORKSPACE_ROOT:?TreeSeed development workspace root is required}:${TREESEED_DEVELOPMENT_WORKSPACE_ROOT:?TreeSeed development workspace root is required}:ro + extra_hosts: + - "host.docker.internal:host-gateway" tmpfs: - /tmp networks: @@ -63,6 +66,7 @@ services: TREESEED_COMPONENT_DATA_ROOT: TREESEED_API_BASE_URL: TREESEED_TREEDX_URL: + TREESEED_LOCAL_TREEDX_HOSTS: treedx,host.docker.internal TREESEED_TREEDX_JWT_ISSUER: TREESEED_TREEDX_JWT_AUDIENCE: TREESEED_DATABASE_URL: @@ -80,6 +84,8 @@ services: TREESEED_DEVELOPMENT_MODE: volumes: - ${TREESEED_DEVELOPMENT_WORKSPACE_ROOT:?TreeSeed development workspace root is required}:${TREESEED_DEVELOPMENT_WORKSPACE_ROOT:?TreeSeed development workspace root is required}:ro + extra_hosts: + - "host.docker.internal:host-gateway" tmpfs: - /tmp networks: diff --git a/package-lock.json b/package-lock.json index 8dc606bb..771cf514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/sdk": "0.13.0-rc.59", + "@treeseed/sdk": "0.13.0-rc.66", "@treeseed/treedx": "0.3.0-rc.3", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -1228,9 +1228,9 @@ "license": "MIT" }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.59", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.59.tgz", - "integrity": "sha512-nEO0fm5Ii7Gb1e74DH0vWmt8lT6Ta8t/nIEFrZkK3Vvxx2yjc9EtlQR6C1q5m8vMGUbmfT1lBuexKx3lDKH7LA==", + "version": "0.13.0-rc.66", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.66.tgz", + "integrity": "sha512-zrzTWzfDKxtcqmlvCJPHWReCosb2QLGFg/Sh+zQGZl0OTC7oi3BqG0s4hJbWMS8mw4ANw4taCJLZKCMP1clzNA==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.4", diff --git a/package.json b/package.json index 783c4d19..d6dfeb25 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/sdk": "0.13.0-rc.59", + "@treeseed/sdk": "0.13.0-rc.66", "@treeseed/treedx": "0.3.0-rc.3", "drizzle-orm": "^0.45.2", "hono": "4.13.3", diff --git a/src/api/app/support/runtime/foundation-runtime-utilities.ts b/src/api/app/support/runtime/foundation-runtime-utilities.ts index 724d67ce..4154ded4 100644 --- a/src/api/app/support/runtime/foundation-runtime-utilities.ts +++ b/src/api/app/support/runtime/foundation-runtime-utilities.ts @@ -1,5 +1,6 @@ import { getSiteAuthConfig } from '../../../../auth/config.ts'; import { backfillUserEmailAddresses,normalizeBaseUrl,parseBooleanEnvValue,redactedRequestTarget } from '../index.ts'; +import { reconcileManagedTeamLibraries } from '../../../teams/managed-team-library-service.ts'; export async function accountDeletionBlockers(store, principal) { const teams = await store.listTeamsForPrincipal(principal); const blockers = teams @@ -83,6 +84,8 @@ export function requestClientIp(c) { export async function ensureControlPlaneCredentialSchema(store) { await store.ensureInitialized(); await backfillUserEmailAddresses(store); + await store.backfillManagedTeamLibraryProjects(); + if(String(process.env.TREESEED_GITHUB_TOKEN??'').trim())await reconcileManagedTeamLibraries(store,process.env); } export function sanitizedReturnTo(value) { const target = String(value ?? '/app/'); diff --git a/src/api/capacity/policy/workdays/chat-activity-profile.ts b/src/api/capacity/policy/workdays/chat-activity-profile.ts index 573649de..c08f8312 100644 --- a/src/api/capacity/policy/workdays/chat-activity-profile.ts +++ b/src/api/capacity/policy/workdays/chat-activity-profile.ts @@ -28,7 +28,7 @@ export function compileDefaultChatActivityProfile( enabled: true, handler: 'writer', prompt: { - system: `Participate as ${agentSlug} in a TreeSeed Discussion. Begin at the exact project repository root, read its AGENTS.md, and inspect source, scripts, tests, and CI whenever the question depends on implementation evidence. Use the assignment's exact project TreeDX library as the default knowledge context. Answer from your configured identity and durable instructions, cite exact TreeDX content or repository refs, distinguish evidence from inference, and keep the response scoped to the current turn. If either repository or TreeDX read context is missing, report an execution-context defect instead of asking the user to supply files owned by the project. You may create or update discussion messages, linked notes, questions, and proposals. Questions must declare their owning project, requested audience, related objectives, and answer policy so they can enter the team inbox. Proposals must declare their owning project, proposal type, evidence, objective links, and complete plan; never describe a proposal as approved until an exact-version governed inbox action accepts it. When human input is required, create a durable question instead of burying the request in prose. Never change knowledge or code without an approved governed acting assignment.${specialization.responseStyle ? ` Response style: ${specialization.responseStyle}` : ''}`, + system: `Participate as ${agentSlug} in a TreeSeed Discussion. Use the mandatory context pack, agent-wide TreeDX context queries, and this activity profile's additive queries before making focused follow-up TreeDX reads or searches. Do not expect or request a wholesale code or knowledge repository snapshot for chat. Answer from your configured identity and durable instructions, cite exact TreeDX content, distinguish evidence from inference, and keep the response scoped to the current turn. If required TreeDX query or tool context is missing, report an execution-context defect instead of asking the user to supply files owned by the project. You may create or update discussion messages, linked notes, questions, and proposals. Questions must declare their owning project, requested audience, related objectives, and answer policy so they can enter the team inbox. Proposals must declare their owning project, proposal type, evidence, objective links, and complete plan; never describe a proposal as approved until an exact-version governed inbox action accepts it. When human input is required, create a durable question instead of burying the request in prose. Never change knowledge or code without an approved governed acting assignment.${specialization.responseStyle ? ` Response style: ${specialization.responseStyle}` : ''}`, task: specialization.promptTask ?? 'Respond to the committed Discussion turn and produce durable, source-grounded output.', }, branchPolicy: { kind: 'staging-content', base: 'staging' }, diff --git a/src/api/capacity/repositories/capacity/providers/execution-provider.ts b/src/api/capacity/repositories/capacity/providers/execution-provider.ts index 12174626..8255f811 100644 --- a/src/api/capacity/repositories/capacity/providers/execution-provider.ts +++ b/src/api/capacity/repositories/capacity/providers/execution-provider.ts @@ -156,7 +156,7 @@ export function upsertCapacityExecutionProviderOperations(input: { }) : []; const offerOperations: CapacityDatabaseOperation[] = Array.isArray(entry.offers) ? entry.offers.map((offerValue) => { const offer = record(offerValue); - return { query: `INSERT INTO execution_capability_offers (capacity_provider_id,execution_provider_id,offer_id,offer_digest,offer_json,status,last_seen_at) VALUES (?,?,?,?,?,'active',?) ON CONFLICT (capacity_provider_id,offer_id) DO UPDATE SET execution_provider_id=EXCLUDED.execution_provider_id,offer_digest=EXCLUDED.offer_digest,offer_json=EXCLUDED.offer_json,status='active',last_seen_at=EXCLUDED.last_seen_at`, params: [input.providerId,id,String(offer.offerId),String(offer.offerDigest),JSON.stringify(offer),input.createdAt] }; + return { query: `INSERT INTO execution_capability_offers (capacity_provider_id,execution_provider_id,offer_id,offer_digest,offer_json,status,last_seen_at) VALUES (?,?,?,?,?,'active',?) ON CONFLICT (capacity_provider_id,offer_id) DO UPDATE SET execution_provider_id=EXCLUDED.execution_provider_id,offer_digest=EXCLUDED.offer_digest,offer_json=EXCLUDED.offer_json,status=CASE WHEN execution_capability_offers.status='context_overflow' AND execution_capability_offers.offer_digest=EXCLUDED.offer_digest THEN 'context_overflow' ELSE 'active' END,last_seen_at=EXCLUDED.last_seen_at`, params: [input.providerId,id,String(offer.offerId),String(offer.offerDigest),JSON.stringify(offer),input.createdAt] }; }) : []; return [executionProviderOperation, ...laneOperations, ...offerOperations]; }); diff --git a/src/api/capacity/services/build/demand-compiler.ts b/src/api/capacity/services/build/demand-compiler.ts index 31bcfc15..e077a63a 100644 --- a/src/api/capacity/services/build/demand-compiler.ts +++ b/src/api/capacity/services/build/demand-compiler.ts @@ -327,8 +327,10 @@ async function compilePlanningDemands( : { requestedSeconds: estimate.seconds, providerFloor: 0, floorSource: 'unbounded-session' }; const requestedSeconds = allocation.requestedSeconds; const contextReferences=[ - ...agent.contextQueryRefs.map((reference)=>({kind:'query' as const,...reference})), - ...agent.contextQuerySetRefs.map((reference)=>({kind:'query-set' as const,...reference})), + ...agent.contextQueryLayers.agent.queryRefs.map((reference)=>({kind:'query' as const,...reference,layer:'agent' as const})), + ...agent.contextQueryLayers.agent.querySetRefs.map((reference)=>({kind:'query-set' as const,...reference,layer:'agent' as const})), + ...agent.contextQueryLayers.activity.queryRefs.map((reference)=>({kind:'query' as const,...reference,layer:'activity' as const})), + ...agent.contextQueryLayers.activity.querySetRefs.map((reference)=>({kind:'query-set' as const,...reference,layer:'activity' as const})), ]; const definitionBaseRef=capacityWorkdayContentBaseRef(run.environment,agent.branchPolicy,agent.sourceImmutableRef); const contentBaseRef=capacityWorkdayRuntimeContentRef(source.payload,definitionBaseRef); @@ -354,6 +356,7 @@ async function compilePlanningDemands( permissions: agent.permissions, tools: agent.toolPolicy, capabilityRequirements: agent.capabilityRequirements } : undefined, groupIds:agent.groupIds, contextQueryRefs:contextReferences, + contextQueryLayers:agent.contextQueryLayers, instructionTemplateRefs:agent.instructionTemplateRefs, contextQueryChecks:verifiedContext.map((check)=>({ id:check.id,testId:check.testId,testRef:check.testRef,definition:check.definition, checkedAt:check.checkedAt,expiresAt:check.expiresAt,latencyMs:check.latencyMs,stats:check.stats,assertions:check.assertions, diff --git a/src/api/capacity/services/capacity/agents/context-query-check-service.ts b/src/api/capacity/services/capacity/agents/context-query-check-service.ts index d59347ed..ad3c0fea 100644 --- a/src/api/capacity/services/capacity/agents/context-query-check-service.ts +++ b/src/api/capacity/services/capacity/agents/context-query-check-service.ts @@ -21,6 +21,7 @@ function safeId(value:unknown) { } function path(root:string,collection:string,id:string) { return projectLibraryPath(root, collection, `${safeId(id)}.mdx`); } function digest(value:unknown) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); } +function strings(value:unknown) { return Array.isArray(value)?[...new Set(value.map(String).map((item)=>item.trim()).filter(Boolean))]:[]; } export async function executeCurrentContext(connection:Awaited>,exactRef:string,request:Record) { if(!connection) throw new CapacityGovernanceError('context_query_treedx_unavailable','Project TreeDX content is unavailable.',409); // A successful context response is not proof that TreeDX's derived graph includes @@ -52,6 +53,43 @@ function rowCheck(row:Record) { export class ContextQueryCheckService { constructor(private readonly store:CapacityGovernanceDatabase) {} + private async queryProjects(teamId:string,projectId:string,query:DeclarativeContextQuery) { + const sources=query.sources?.length?query.sources:[{scope:'current-project' as const}],projects=new Map(); + const add=(id:string,paths:string[],source:string)=>projects.set(id,{projectId:id,paths:paths.length?paths:['**'],source}); + for(const selector of sources) { + if(selector.scope==='current-project') { add(projectId,['**'],'current-project'); continue; } + if(selector.scope==='team-library') { + const teamProject=await (this.store as any).getProjectByTeamAndSlug(teamId,'team'); + if(!teamProject) throw new CapacityGovernanceError('team_library_unavailable','The managed Team Library is unavailable.',409); + add(String(teamProject.id),['**'],'team-library'); continue; + } + if(selector.scope==='same-team') { + const teamProjects=await (this.store as any).listTeamProjects(teamId),ids=new Set(selector.projectIds??[]),slugs=new Set(selector.projectSlugs??[]); + const selected=ids.size||slugs.size?teamProjects.filter((project:Record)=>ids.has(String(project.id))||slugs.has(String(project.slug))):teamProjects; + if(selected.length!==(ids.size+slugs.size||teamProjects.length))throw new CapacityGovernanceError('context_query_source_missing','A selected same-team project does not exist.',404); + for(const project of selected)add(String(project.id),['**'],'same-team');continue; + } + const shares=(await (this.store as any).listTreeDxSharesForRecipient(teamId)).filter((share:Record)=>String(share.teamId)===selector.teamId&&share.status==='active'&&(!share.expiresAt||Date.parse(String(share.expiresAt))>Date.now())); + const eligible=new Map>();for(const share of shares){const grant=record(share.trustGrant);if(!strings(grant.operations).includes('context'))continue;for(const id of strings(grant.projectIds??share.projectId))eligible.set(id,grant);} + const selected=selector.projectIds?.length?selector.projectIds:[...eligible.keys()]; + for(const id of selected){const grant=eligible.get(id);if(!grant)throw new CapacityGovernanceError('context_query_share_denied','An active knowledge share does not cover the selected project.',403);add(id,strings(grant.paths),`shared-team:${selector.teamId}`);} + } + return [...projects.values()]; + } + + private async executeQuerySources(teamId:string,projectId:string,query:DeclarativeContextQuery,request:Record) { + const selected=await this.queryProjects(teamId,projectId,query),results=[] as Array<{projectId:string;source:string;ref:string;result:any}>; + for(const source of selected) { + const connection=await resolveKnowledgeGatewayConnection(this.store,{projectId:source.projectId,write:true,authoringPaths:true}); + if(!connection)throw new CapacityGovernanceError('context_query_treedx_unavailable',`TreeDX content is unavailable for source project ${source.projectId}.`,409); + const result=await executeCurrentContext(connection,connection.baseRef,{...request,scopePaths:source.paths}); + results.push({projectId:source.projectId,source:source.source,ref:connection.baseRef,result}); + } + const unpack=(value:any)=>record(record(value).payload??value),nodes=results.flatMap((entry)=>Array.isArray(unpack(entry.result).nodes)?unpack(entry.result).nodes:[]),edges=results.flatMap((entry)=>Array.isArray(unpack(entry.result).edges)?unpack(entry.result).edges:[]); + return {nodes,edges,sources:results.map(({result,...source})=>{const value=unpack(result),sourceNodes=Array.isArray(value.nodes)?value.nodes.map(record):[]; + return {...source,paths:[...new Set(sourceNodes.map((node)=>String(node.path??'').trim()).filter(Boolean))].sort()};}),memberResults:results.map((entry)=>entry.result)}; + } + async definitionCommit(projectId:string) { const connection=await resolveKnowledgeGatewayConnection(this.store,{projectId,write:false,authoringPaths:true}); if(!connection) return null; @@ -125,13 +163,13 @@ export class ContextQueryCheckService { // graph for the exact current ref, but it never writes or commits query results. const exactConnection=await resolveKnowledgeGatewayConnection(this.store,{projectId,write:true,authoringPaths:true,readRefs:[exactRef]}); if(!exactConnection) throw new CapacityGovernanceError('context_query_treedx_unavailable','Project TreeDX content is unavailable.',409); - const execute=async(request:Record)=>executeCurrentContext(exactConnection,exactRef,request); let report:Record; let definition:{kind:'query'|'query-set';id:string;revision:number;commit:string}; if(test.kind==='context-query') { const querySource=await this.source(projectId,exactRef,path(initial.contentPath,COLLECTIONS.query,test.queryRef!.id)); const validation=validateContentFrontmatter('agent_context_query',querySource.frontmatter); if(!validation.ok||!validation.data) throw new CapacityGovernanceError('context_query_definition_invalid','Context query definition is invalid.',422,{diagnostics:validation.diagnostics}); - report=await executeContextQueryTest({query:validation.data as DeclarativeContextQuery,test,execute}) as Record; + const query=validation.data as DeclarativeContextQuery; + report=await executeContextQueryTest({query,test,execute:(request)=>this.executeQuerySources(teamId,projectId,query,request)}) as Record; definition={kind:'query',id:test.queryRef!.id,revision:test.queryRef!.revision,commit:exactRef}; } else { const setSource=await this.source(projectId,exactRef,path(initial.contentPath,COLLECTIONS.set,test.querySetRef!.id)); @@ -144,7 +182,7 @@ export class ContextQueryCheckService { if(!validation.ok||!validation.data) throw new CapacityGovernanceError('context_query_definition_invalid','Query-set member is invalid.',422,{reference,diagnostics:validation.diagnostics}); queries.push(validation.data as DeclarativeContextQuery); } - report=await executeContextQuerySetTest({querySet,queries,test,execute:async(_query,request)=>execute(request)}) as Record; + report=await executeContextQuerySetTest({querySet,queries,test,execute:(query,request)=>this.executeQuerySources(teamId,projectId,query,request)}) as Record; definition={kind:'query-set',id:test.querySetRef!.id,revision:test.querySetRef!.revision,commit:exactRef}; } const checkedAt=String(report.checkedAt??new Date().toISOString()); const fresh=Math.min(604_800,Math.max(300,Number(input.freshForSeconds??DEFAULT_FRESH_SECONDS))); diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts index f47dd09d..5b118067 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts @@ -19,6 +19,7 @@ import { archivedConversationCancellation,assignmentFailureDisposition } from '. import { contentIntegrationRequirementOperation } from './assignment-content-integration-requirement.ts'; import { semanticCompletionPreflightRequired } from './assignment-completion-preflight-service.ts'; import { assertAssignmentCompletionEvidence } from './completion/assignment-completion-evidence.ts'; +import { quarantineContextOverflowOffer } from './context-capacity/overflow.ts'; type JsonRecord = Record; export interface ExtendedProviderAssignmentLifecycleRequest extends ProviderAssignmentLifecycleRequest { activeSeconds?: number | null; @@ -30,20 +31,14 @@ export interface ExtendedProviderAssignmentLifecycleRequest extends ProviderAssi interface ProviderAssignmentLifecycleStore extends CapacityGovernanceDatabase, AssignmentDeliverableStore, AssignmentPlanningOutputStore, ResearchWorkflowProjectionStore { getProviderAssignment(teamId: string, assignmentId: string): Promise; recordAgentFallbackOutput(input: AgentFallbackOutputWrite): Promise; - recordProviderAssignmentExplanation( - teamId: string, - assignmentId: string, - input: ProviderAssignmentExplanationWrite, - ): Promise; + recordProviderAssignmentExplanation(teamId:string,assignmentId:string,input:ProviderAssignmentExplanationWrite):Promise; updateCapacityWorkdayRun(teamId: string, runId: string, input: JsonRecord): Promise; } - export interface ProviderAssignmentLifecycleMutationResult { assignment: DurableProviderAssignment; leaseToken: string | null; leaseSeconds: number | null; } - function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } @@ -263,6 +258,7 @@ export class ProviderAssignmentLifecycleService { return { assignment: repaired, leaseToken: null, leaseSeconds: null }; } if (!activeLeaseOwnedBy(assignment, principal, input.leaseToken, now)) return null; + const contextCapacityAlert=await quarantineContextOverflowOffer({store:this.store,assignment,code:input.code,observedAt:now}); if (record(assignment.metadata).cancellationRequested === true) { return this.transition(principal, assignment, input, now, { status: 'cancelled', timestampColumn: 'failed_at', defaultCode: 'operator_cancelled', defaultReason: String(record(assignment.metadata).cancellationReason ?? 'Assignment cancelled by a team operator.'), @@ -311,6 +307,7 @@ export class ProviderAssignmentLifecycleService { if (input.fallbackOutput) await this.persistFallback(assignment, input.fallbackOutput); const metadata = { ...record(assignment.metadata), + ...(contextCapacityAlert?{contextCapacityAlert}:{}), lastReturn: { reason: input.reason ?? input.message ?? null, code: input.code ?? null, diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/context-capacity/overflow.ts b/src/api/capacity/services/capacity/assignments/lifecycle/context-capacity/overflow.ts new file mode 100644 index 00000000..ce41ac80 --- /dev/null +++ b/src/api/capacity/services/capacity/assignments/lifecycle/context-capacity/overflow.ts @@ -0,0 +1,17 @@ +import type { DurableProviderAssignment } from '../../../../../repositories/capacity/assignments/assignment.ts'; + +const record=(value:unknown):Record=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; + +export async function quarantineContextOverflowOffer(input:{ + store:{run(query:string,parameters?:unknown[]):Promise}; + assignment:DurableProviderAssignment; + code:string|undefined; + observedAt:string; +}) { + if(input.code!=='provider_context_capacity_overflow')return null; + const offerId=String(record(input.assignment.metadata).offerId??'').trim(); + if(offerId)await input.store.run(`UPDATE execution_capability_offers SET status='context_overflow',last_seen_at=? WHERE capacity_provider_id=? AND execution_provider_id=? AND offer_id=?`,[ + input.observedAt,input.assignment.capacityProviderId,input.assignment.executionProviderId,offerId, + ]); + return {code:input.code,offerId,observedAt:input.observedAt,advertisedCapacity:record(input.assignment.metadata).contextCapacity??null}; +} diff --git a/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts b/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts index e5260464..abb5ff07 100644 --- a/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts +++ b/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts @@ -1,16 +1,14 @@ import type { CapacitySupplyPolicy } from "@treeseed/sdk/agent-capacity"; import { capacitySupplyCandidateStatus, selectCapacitySupply } from '../../../../policy/supply-selection.ts'; import { evaluateMinimumAssignmentDuration } from '../../../../policy/timing/assignment-duration.ts'; -import type { CapacityGovernanceDatabase } from "../../../../database.ts"; import { CapacityGovernanceError } from "../../../../database.ts"; import type { DurableProviderAssignment } from "../../../../repositories/capacity/assignments/assignment.ts"; import { CapacityWorkdayDemandRepository } from "../../../../repositories/capacity/workdays/workday-demand.ts"; import { CapacityWorkdayParticipationRepository } from "../../../../repositories/capacity/workdays/workday-participation.ts"; -import { CapacityWorkdayRunRepository,type DurableCapacityWorkdayRun } from "../../../../repositories/capacity/workdays/workday-run.ts"; +import { CapacityWorkdayRunRepository } from "../../../../repositories/capacity/workdays/workday-run.ts"; import type { ProviderLeasePrincipal } from "../../../accounts/lease-authority-service.ts"; import type { ProviderSynthesisExecutionProvider } from "../../providers/provider-synthesis-context-service.ts"; import { evaluateDurableWorkdayContinuation } from "../../workdays/lifecycle/workday-continuation-service.ts"; -import type { ConfiguredWorkspaceInput } from "../../workdays/treedx/workday-treedx-workspace-service.ts"; import { workdayTreeDxWorkspaceId } from "../../workdays/treedx/workday-treedx-workspace-service.ts"; import { admitSynthesizedProviderAssignment } from "../admission/assignment-admission-service.ts"; import { teamSupplyPolicy } from "../../../../domain/supply-policy.ts"; @@ -27,26 +25,13 @@ import { assignmentConfigurationAttribution } from './assignment-configuration-a import { negotiateAssignmentCapabilityOffers, persistCapabilityNegotiation } from './support/capability-offer-negotiation.ts'; import { resolveAssignmentContentPathScope } from './assignment-content-path-scope.ts'; import { bindOperationHandoffAssignment } from '../handoffs/operation-handoff-lifecycle-service.ts'; +import { resolveCrossProjectReadRepositories } from './context/cross-project-read-repositories.ts'; import { assignmentRecord as record, assignmentText as text, deterministicAssignmentId as assignmentId, type AssignmentJsonRecord as JsonRecord } from './support/assignment-function-support.ts'; import { recordAssignmentDenial } from './support/assignment-denial.ts'; +import type { AssignmentFunctionStore } from './support/assignment-function-store.ts'; export { assignmentConfigurationAttribution } from './assignment-configuration-attribution.ts'; export { resolveAssignmentContentPathScope } from './assignment-content-path-scope.ts'; -interface AssignmentFunctionStore extends CapacityGovernanceDatabase { - getProject(projectId: string): Promise; - getTeam(teamId: string): Promise; - listHubRepositories(projectId: string): Promise; - getProjectArchitecture(projectId: string): Promise; - getProviderAssignment( - teamId: string, - assignmentId: string, - ): Promise; - createCapacityWorkdayTreeDxWorkspace( - project: { id: string }, - run: DurableCapacityWorkdayRun, - input: ConfiguredWorkspaceInput, - ): Promise; -} async function assignmentInput( store: AssignmentFunctionStore, demand: Awaited>, @@ -89,6 +74,15 @@ async function assignmentInput( 409, { demandId: demand.id, projectId: demand.projectId }, ); + const teamLibraryProject=await store.getProjectByTeamAndSlug(demand.teamId,'team'); + if(!teamLibraryProject)throw new CapacityGovernanceError('capacity_team_library_missing','The managed Team Library project is unavailable.',409,{teamId:demand.teamId}); + const teamLibraryProvisioning=record(record(teamLibraryProject.metadata).provisioning); + if(text(teamLibraryProvisioning.state)!=='known-good')throw new CapacityGovernanceError('capacity_team_library_not_ready','The managed Team Library has not reached a verified known-good state.',409,{teamId:demand.teamId,projectId:teamLibraryProject.id,state:text(teamLibraryProvisioning.state)||'unknown'}); + const teamLibraryBinding=await store.getProjectTreeDxLibrary(text(teamLibraryProject.id)); + const teamLibraryRepositoryId=text(teamLibraryBinding?.repositoryId,record(record(record(teamLibraryBinding?.topology).contentRepository).treeDx).repositoryId); + const teamLibraryRef=text(record(teamLibraryBinding?.metadata).resolvedRef,teamLibraryBinding?.contentRepositoryRef); + if(!teamLibraryRepositoryId||!teamLibraryRef)throw new CapacityGovernanceError('capacity_team_library_not_ready','The managed Team Library has no verified current TreeDX view.',409,{teamId:demand.teamId,projectId:teamLibraryProject.id}); + const sameTeamReadRepositories=await resolveCrossProjectReadRepositories({store,teamId:demand.teamId,projectId:demand.projectId,teamLibraryProject,payload,now:new Date(now)}); const planning = demand.mode === "planning"; const contentPrefix = contentRoot === '.' ? '' : `${contentRoot.replace(/\/+$/u, '')}/`; const projectSlug = text(project.slug) || demand.projectId; @@ -96,7 +90,6 @@ async function assignmentInput( const coreObjectiveCandidates = [`${coreObjectivePath}.mdx`, `${coreObjectivePath}.md`]; const projectReadmePath = `${contentPrefix}README.md`; const identityAnchorPaths = [...coreObjectiveCandidates, projectReadmePath]; - // The admitted demand freezes the selected profile; intent cannot decide authority. const activityType=demand.activityType; const executionMode = demand.metadata.executionMode === 'production' ? 'production' as const : 'simulation' as const; const requiredCapabilities = Array.isArray(demand.metadata.requiredCapabilities) @@ -160,7 +153,7 @@ async function assignmentInput( startedAt: null, minimumDeadlineAt: null, } : null; - const taskReadPaths = resolveAssignmentContentPathScope(payload, 'read', contentRoot, ["**"]); + const taskReadPaths = resolveAssignmentContentPathScope(payload, 'read', contentRoot, executionKind === 'conversation' ? [] : ["**"]); const sourceMessageRefs = [...new Set([ text(payload.discussionMessageId), text(payload.subjectPath), ...(Array.isArray(payload.operationHandoffSourceMessageRefs) ? payload.operationHandoffSourceMessageRefs.map(String) : []), @@ -176,7 +169,8 @@ async function assignmentInput( const bootstrapReadPaths = assignmentBootstrapReadPaths(contentRoot, payload.agentContentPath, intent.subjectPath); const contextQueryReadPaths = assignmentContextQueryReadPaths(contentRoot, payload.contextQueryRefs, payload.contextQueryChecks); const instructionTemplateReadPaths = assignmentInstructionTemplateReadPaths(contentRoot, payload.instructionTemplateRefs); - const allowedReadPaths = mergeAssignmentPathScopes(taskReadPaths, discussionMessageReadPaths, bootstrapReadPaths, identityAnchorPaths, contextQueryReadPaths, instructionTemplateReadPaths, operationalPaths); + const allowedReadPaths = mergeAssignmentPathScopes(taskReadPaths, discussionMessageReadPaths, bootstrapReadPaths, identityAnchorPaths, + [`${contentPrefix}agents/**`,`${contentPrefix}objectives/**`],contextQueryReadPaths, instructionTemplateReadPaths, operationalPaths); const allowedWritePaths = mergeAssignmentPathScopes(taskWritePaths, operationalPaths); const workspaceAllowedPaths = mergeAssignmentPathScopes(allowedReadPaths, allowedWritePaths); const workspaceId = workdayTreeDxWorkspaceId(id); @@ -186,6 +180,8 @@ async function assignmentInput( const treedxProxyHandle = assignmentTreeDxProxyHandle({ assignmentId: id, teamId: demand.teamId, projectId: demand.projectId, executionMode, repositoryId, workspaceId, allowedPaths: workspaceAllowedPaths, allowedReadPaths, allowedWritePaths, expiresAt: authorityExpiresAt, demandId: demand.id, workdayRunId: demand.workdayRunId }); + treedxProxyHandle.readRepositories=sameTeamReadRepositories; + treedxProxyHandle.metadata={...record(treedxProxyHandle.metadata),readRepositories:treedxProxyHandle.readRepositories}; const capacityBudget = timing.capacityBudget; const capacityEnvelope = { ...record(payload.capacityEnvelope), @@ -202,7 +198,6 @@ async function assignmentInput( requestedSeconds: demand.requestedSeconds, reservedSeconds: demand.requestedSeconds, activeSeconds: 0, - elapsedSeconds: 0, releasedSeconds: 0, overrunSeconds: 0, budget: capacityBudget, @@ -259,6 +254,7 @@ async function assignmentInput( providerSessionId: sessionId, executionProviderId: executionProvider.id, offerId: selectedOffer?.offerId ?? null, + contextCapacity: selectedOffer?.contextCapacity ?? null, capabilityDemand, capabilityNegotiation: negotiationReceipt, laneId: lane.id, @@ -317,6 +313,7 @@ async function assignmentInput( metadata: { demandId: demand.id, offerId: selectedOffer?.offerId ?? null, + contextCapacity: selectedOffer?.contextCapacity ?? null, capabilityDemand, capabilityNegotiation: negotiationReceipt, executionMode, @@ -324,14 +321,22 @@ async function assignmentInput( activityType: demand.activityType, executionPolicy: record(payload.executionPolicy), chatProfile: record(payload.chatProfile), + permissions: record(payload.permissions), + toolPolicy: record(payload.toolPolicy), + contextQueryRefs: Array.isArray(payload.contextQueryRefs) ? payload.contextQueryRefs : [], + contextQueryChecks: Array.isArray(payload.contextQueryChecks) ? payload.contextQueryChecks : [], + contextQueryLayers: record(payload.contextQueryLayers), + communication: record(demand.metadata.communication), identityManifest: executionKind === 'conversation' ? { schemaVersion: 'treeseed.agent-identity-manifest/v1', agentHandle: `@${projectSlug}/${text(demand.agentId)}`, teamId: demand.teamId, projectId: demand.projectId, projectSlug, agentSlug: text(demand.agentId), repositoryId, immutableRef: contentBaseRef, agentProfile: { path: text(payload.agentContentPath), expectedRevision: contentBaseRef }, - coreObjective: { path: coreObjectivePath, candidates: coreObjectiveCandidates, expectedRevision: contentBaseRef }, + coreObjective: { path: coreObjectivePath, expectedRevision: contentBaseRef }, projectReadme: { path: projectReadmePath, expectedRevision: contentBaseRef }, + teamLibrary: { projectId:text(teamLibraryProject.id),projectSlug:'team',repositoryId:teamLibraryRepositoryId,immutableRef:teamLibraryRef, + readme:{path:'README.md',expectedRevision:teamLibraryRef},coreObjective:{path:'objectives/core',expectedRevision:teamLibraryRef} }, instructionTemplates: instructionTemplateReadPaths.map((path) => ({ path, expectedRevision: contentBaseRef })), } : {}, contextManifest: allowedReadPaths.map((path) => ({ path, immutableRef: contentBaseRef, access: 'read' })), diff --git a/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts b/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts index 35041605..155b0f76 100644 --- a/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts +++ b/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts @@ -83,8 +83,8 @@ export function assignmentTreeDxProxyHandle(input: { return { id: `tdx_${input.assignmentId}`, teamId: input.teamId, projectId: input.projectId, assignmentId: input.assignmentId, executionMode: input.executionMode, repositoryId: input.repositoryId, workspaceId: input.workspaceId, status: 'provisioning', - scopes: ['project:read', 'project:write', 'workspace:read', 'workspace:write', 'files:read', 'files:search', 'files:write', 'git:commit'], - allowedOperations: ['files:read', 'files:search', 'files:write', 'git:commit', 'workspace:write'], + scopes: ['project:read', 'project:write', 'workspace:read', 'workspace:write', 'files:read', 'files:search', 'graph:query', 'files:write', 'git:commit'], + allowedOperations: ['files:read', 'files:search', 'graph:query', 'files:write', 'git:commit', 'workspace:write'], allowedPaths: input.allowedPaths, allowedReadPaths: input.allowedReadPaths, allowedWritePaths: input.allowedWritePaths, expiresAt: input.expiresAt, metadata: { source: 'workday-demand', demandId: input.demandId, workdayRunId: input.workdayRunId, diff --git a/src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts b/src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts new file mode 100644 index 00000000..fc044c95 --- /dev/null +++ b/src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts @@ -0,0 +1,87 @@ +import type { CapacityGovernanceDatabase } from '../../../../../database.ts'; + +type JsonRecord = Record; +type ReadRepository = { + projectId:string; + projectSlug:string; + repositoryId:string; + baseRef:string; + allowedPaths:string[]; + allowedModels:string[]; + source:'team-library'|'same-team'|'shared-team'; +}; + +interface Store extends CapacityGovernanceDatabase { + getProject(projectId:string):Promise; + getProjectTreeDxLibrary(projectId:string):Promise; + listTeamProjects(teamId:string):Promise; + listTreeDxSharesForRecipient(teamId:string):Promise; +} + +const record=(value:unknown):JsonRecord=>value&&typeof value==='object'&&!Array.isArray(value)?value as JsonRecord:{}; +const text=(...values:unknown[])=>values.find((value)=>typeof value==='string'&&value.trim())?.toString().trim()??''; +const stringList=(value:unknown)=>Array.isArray(value)?[...new Set(value.map(String).map((item)=>item.trim()).filter(Boolean))]:[]; + +function contentScope(payload:JsonRecord){ + const collections:Record={agent:'agents',book:'books',decision:'decisions',knowledge:'knowledge',note:'notes',objective:'objectives',page:'pages',proposal:'proposals',question:'questions',agent_context_query:'agent-context-queries',agent_context_query_set:'agent-context-query-sets',agent_instruction_template:'agent-instruction-templates'}; + const readable=new Set(['describe','query','read']),models:string[]=[],paths:string[]=[]; + for(const [model,value] of Object.entries(record(record(payload.permissions).content))){ + const policy=record(value),operations=stringList(policy.operations);if(!operations.some((operation)=>readable.has(operation)))continue; + models.push(model);const configured=record(policy.filters).paths; + if(Array.isArray(configured)&&configured.length)paths.push(...configured.map(String));else if(collections[model])paths.push(`${collections[model]}/**`); + } + return {models:[...new Set(models)],paths:[...new Set(paths.map((path)=>path.replace(/^\.\//u,'')).filter((path)=>path&&!path.startsWith('/')&&!path.split('/').includes('..')))]}; +} + +function intersectValues(left:string[],right:string[]){ + if(!left.length)return []; + if(!right.length)return left; + const allowed=new Set(right);return left.filter((value)=>allowed.has(value)); +} + +function intersectPaths(activityPaths:string[],grantPaths:string[]){ + const normalizedGrant=grantPaths.length?grantPaths:['**']; + if(!activityPaths.length)return []; + if(normalizedGrant.includes('**'))return activityPaths; + return activityPaths.filter((path)=>normalizedGrant.some((grant)=>{ + const prefix=grant.replace(/\/\*\*$/u,'').replace(/\/$/u,''); + return path===grant||path===prefix||path.startsWith(`${prefix}/`)||grant.startsWith(`${path.replace(/\/\*\*$/u,'').replace(/\/$/u,'')}/`); + })); +} + +function bindingIdentity(binding:JsonRecord|null){ + return { + repositoryId:text(binding?.repositoryId,record(record(record(binding?.topology).contentRepository).treeDx).repositoryId), + baseRef:text(record(binding?.metadata).resolvedRef,binding?.contentRepositoryRef), + }; +} + +export async function resolveCrossProjectReadRepositories(input:{ + store:Store; teamId:string; projectId:string; teamLibraryProject:JsonRecord; payload:JsonRecord; now?:Date; +}):Promise { + const result:ReadRepository[]=[],scope=contentScope(input.payload),now=input.now??new Date(); + for(const candidate of await input.store.listTeamProjects(input.teamId)){ + if(text(candidate.id)===input.projectId)continue; + const identity=bindingIdentity(await input.store.getProjectTreeDxLibrary(text(candidate.id))); + if(!identity.repositoryId||!identity.baseRef)continue; + const teamLibrary=text(candidate.id)===text(input.teamLibraryProject.id); + result.push({projectId:text(candidate.id),projectSlug:text(candidate.slug),...identity, + allowedPaths:[...new Set([...(teamLibrary?['README.md','objectives/**']:[]),...scope.paths])],allowedModels:scope.models, + source:teamLibrary?'team-library':'same-team'}); + } + for(const share of await input.store.listTreeDxSharesForRecipient(input.teamId)){ + if(share.status!=='active'||(share.expiresAt&&Date.parse(String(share.expiresAt))<=now.getTime()))continue; + const grant=record(share.trustGrant); + if(!stringList(grant.operations).some((operation)=>['read','query','context','graph'].includes(operation)))continue; + const allowedModels=intersectValues(scope.models,stringList(grant.contentModels)); + const allowedPaths=intersectPaths(scope.paths,stringList(grant.paths)); + if(!allowedModels.length||!allowedPaths.length)continue; + for(const projectId of stringList(grant.projectIds??share.projectId)){ + const project=await input.store.getProject(projectId); + if(!project||text(project.teamId,project.team_id)!==text(share.teamId))continue; + const identity=bindingIdentity(await input.store.getProjectTreeDxLibrary(projectId)); + if(identity.repositoryId&&identity.baseRef)result.push({projectId,projectSlug:text(project.slug),...identity,allowedPaths,allowedModels,source:'shared-team'}); + } + } + return result; +} diff --git a/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts b/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts new file mode 100644 index 00000000..e79d45b2 --- /dev/null +++ b/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts @@ -0,0 +1,18 @@ +import type { CapacityGovernanceDatabase } from '../../../../../database.ts'; +import type { DurableProviderAssignment } from '../../../../../repositories/capacity/assignments/assignment.ts'; +import type { DurableCapacityWorkdayRun } from '../../../../../repositories/capacity/workdays/workday-run.ts'; +import type { ConfiguredWorkspaceInput } from '../../../workdays/treedx/workday-treedx-workspace-service.ts'; +import type { AssignmentJsonRecord as JsonRecord } from './assignment-function-support.ts'; + +export interface AssignmentFunctionStore extends CapacityGovernanceDatabase { + getProject(projectId: string): Promise; + getProjectByTeamAndSlug(teamId: string, slug: string): Promise; + getProjectTreeDxLibrary(projectId: string): Promise; + listTeamProjects(teamId: string): Promise; + listTreeDxSharesForRecipient(teamId: string): Promise; + getTeam(teamId: string): Promise; + listHubRepositories(projectId: string): Promise; + getProjectArchitecture(projectId: string): Promise; + getProviderAssignment(teamId: string, assignmentId: string): Promise; + createCapacityWorkdayTreeDxWorkspace(project: { id: string }, run: DurableCapacityWorkdayRun, input: ConfiguredWorkspaceInput): Promise; +} diff --git a/src/api/capacity/services/capacity/providers/provider-synthesis-context-service.ts b/src/api/capacity/services/capacity/providers/provider-synthesis-context-service.ts index dbc6bc31..cabdb73a 100644 --- a/src/api/capacity/services/capacity/providers/provider-synthesis-context-service.ts +++ b/src/api/capacity/services/capacity/providers/provider-synthesis-context-service.ts @@ -180,10 +180,13 @@ export async function resolveProviderSynthesisContext( sessionEnvironment: session.environment, }); } + const unavailableOffers=await database.all(`SELECT execution_provider_id,offer_id,status FROM execution_capability_offers WHERE capacity_provider_id=? AND status<>'active'`,[principal.capacityProviderId]); + const blocked=new Set(unavailableOffers.map((offer)=>`${String(offer.execution_provider_id)}:${String(offer.offer_id)}`)); + const eligibleProviders=executionProviders(row).map((provider)=>({...provider,offers:provider.offers.filter((offer)=>!blocked.has(`${provider.id}:${offer.offerId}`))})); return { provider: { id: String(authority.provider_id), status: String(authority.provider_status) }, session, - executionProviders: executionProviders(row), + executionProviders: eligibleProviders, now, environment: input.environment ?? session.environment, }; diff --git a/src/api/capacity/services/capacity/workdays/policy/workday-agent-policy.ts b/src/api/capacity/services/capacity/workdays/policy/workday-agent-policy.ts index 5b37678d..d1baf627 100644 --- a/src/api/capacity/services/capacity/workdays/policy/workday-agent-policy.ts +++ b/src/api/capacity/services/capacity/workdays/policy/workday-agent-policy.ts @@ -15,6 +15,7 @@ export type CapacityWorkdayAgent = { contentPath: string | null; contextQueryRefs:Array<{id:string;revision:number}>; contextQuerySetRefs:Array<{id:string;revision:number}>; + contextQueryLayers:ProjectAgentActivityRef['contextQueryLayers']; instructionTemplateRefs:Array<{id:string;revision:number}>; sourceImmutableRef: string | null; handler: EngineeringHandlerKind; @@ -192,6 +193,7 @@ export function capacityWorkdayAgentsFromClasses(agentClasses: unknown[], select contentPath: selectedActivity.contentPath, contextQueryRefs:selectedActivity.contextQueryRefs, contextQuerySetRefs:selectedActivity.contextQuerySetRefs, + contextQueryLayers:selectedActivity.contextQueryLayers, instructionTemplateRefs:selectedActivity.instructionTemplateRefs, sourceImmutableRef: text(metadata.immutableRef) || null, handler: configuredHandler, diff --git a/src/api/capacity/services/capacity/workdays/treedx/workday-treedx-connection.ts b/src/api/capacity/services/capacity/workdays/treedx/workday-treedx-connection.ts index dd11f62f..f4e29238 100644 --- a/src/api/capacity/services/capacity/workdays/treedx/workday-treedx-connection.ts +++ b/src/api/capacity/services/capacity/workdays/treedx/workday-treedx-connection.ts @@ -22,8 +22,9 @@ export async function resolveWorkdayTreeDxConnection( ) { const library = await store.getProjectTreeDxLibrary(input.projectId); const treeDx = record(record(record(library?.topology).contentRepository).treeDx); - const baseUrl = text(treeDx.baseUrl, treeDx.registryUrl, store.config.TREESEED_TREEDX_URL, store.config.TREESEED_TREEDX_BASE_URL, - store.config.treedxBaseUrl, process.env.TREESEED_TREEDX_URL, process.env.TREESEED_TREEDX_BASE_URL) || 'http://127.0.0.1:4000'; + const baseUrl = text(process.env.TREESEED_TREEDX_URL, process.env.TREESEED_TREEDX_BASE_URL, + store.config.TREESEED_TREEDX_URL, store.config.TREESEED_TREEDX_BASE_URL, store.config.treedxBaseUrl, + treeDx.baseUrl, treeDx.registryUrl) || 'http://127.0.0.1:4000'; const repositoryId = text(input.repositoryId, library?.repositoryId, treeDx.repositoryId); if (!repositoryId) return null; const token = treeDxDelegationAuthority().mint({ diff --git a/src/api/capacity/services/projects/projects-core/project-agent-activity-refs.ts b/src/api/capacity/services/projects/projects-core/project-agent-activity-refs.ts index 54fab764..70ef9ce1 100644 --- a/src/api/capacity/services/projects/projects-core/project-agent-activity-refs.ts +++ b/src/api/capacity/services/projects/projects-core/project-agent-activity-refs.ts @@ -15,6 +15,10 @@ export interface ProjectAgentActivityRef { contentPath: string | null; contextQueryRefs: Array<{id:string;revision:number}>; contextQuerySetRefs: Array<{id:string;revision:number}>; + contextQueryLayers: { + agent: {queryRefs:Array<{id:string;revision:number}>;querySetRefs:Array<{id:string;revision:number}>}; + activity: {queryRefs:Array<{id:string;revision:number}>;querySetRefs:Array<{id:string;revision:number}>}; + }; instructionTemplateRefs: Array<{id:string;revision:number}>; activityType: string; handlerId: string; @@ -39,13 +43,16 @@ export function projectAgentActivityRefs(handlerRefs: unknown, activityType: str if (profile.enabled === false) return []; const agentId = text(agent.slug ?? agent.agentId); const handlerId = text(profile.handler); + const agentQueryRefs=revisionRefs(agent.contextQueryRefs), activityQueryRefs=revisionRefs(profile.contextQueryRefs); + const agentQuerySetRefs=revisionRefs(agent.contextQuerySetRefs), activityQuerySetRefs=revisionRefs(profile.contextQuerySetRefs); return agentId && handlerId ? [{ agentId, agentName: text(agent.name ?? agent.title) ?? agentId, groupIds: Array.isArray(agent.groupIds) ? agent.groupIds.map(String).filter(Boolean) : [], contentPath: text(agent.contentPath), activityType, handlerId, profile, identity: record(agent.identity), summary: text(agent.summary), - contextQueryRefs:revisionRefs(agent.contextQueryRefs,profile.contextQueryRefs), - contextQuerySetRefs:revisionRefs(agent.contextQuerySetRefs,profile.contextQuerySetRefs), + contextQueryRefs:revisionRefs(agentQueryRefs,activityQueryRefs), + contextQuerySetRefs:revisionRefs(agentQuerySetRefs,activityQuerySetRefs), + contextQueryLayers:{agent:{queryRefs:agentQueryRefs,querySetRefs:agentQuerySetRefs},activity:{queryRefs:activityQueryRefs,querySetRefs:activityQuerySetRefs}}, instructionTemplateRefs:revisionRefs(agent.instructionTemplateRefs,profile.instructionTemplateRefs), }] : []; }); diff --git a/src/api/capacity/services/treedx/repositories/treedx-commit-replication.ts b/src/api/capacity/services/treedx/repositories/treedx-commit-replication.ts index 0fc387c9..7dfde0e7 100644 --- a/src/api/capacity/services/treedx/repositories/treedx-commit-replication.ts +++ b/src/api/capacity/services/treedx/repositories/treedx-commit-replication.ts @@ -22,7 +22,16 @@ export async function enqueueTreeDxCommitReplication(database: CapacityGovernanc const r2ObjectKey = `_treeseed/mirrors/teams/${input.teamId}/projects/${input.projectId}/manifest.json`; await database.run(`INSERT INTO treedx_commit_replications (id,team_id,project_id,repository_id,commit_sha,source_ref,github_ref,r2_object_key,status,github_status,r2_status,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?, 'pending','pending','pending',?,?) ON CONFLICT(project_id,commit_sha) DO NOTHING`, [ + VALUES (?,?,?,?,?,?,?,?, 'pending','pending','pending',?,?) ON CONFLICT(project_id,commit_sha) DO UPDATE SET + source_ref=CASE WHEN excluded.source_ref LIKE 'refs/heads/%' OR excluded.source_ref LIKE 'refs/remotes/origin/%' + THEN excluded.source_ref ELSE treedx_commit_replications.source_ref END, + status=CASE WHEN CAST(treedx_commit_replications.r2_receipt_json AS TEXT) LIKE '%treeseed.treedx-r2-file-mirror-skipped/v1%' + AND (excluded.source_ref LIKE 'refs/heads/%' OR excluded.source_ref LIKE 'refs/remotes/origin/%') + THEN 'pending' ELSE treedx_commit_replications.status END, + r2_status=CASE WHEN CAST(treedx_commit_replications.r2_receipt_json AS TEXT) LIKE '%treeseed.treedx-r2-file-mirror-skipped/v1%' + AND (excluded.source_ref LIKE 'refs/heads/%' OR excluded.source_ref LIKE 'refs/remotes/origin/%') + THEN 'pending' ELSE treedx_commit_replications.r2_status END, + updated_at=excluded.updated_at`, [ id, input.teamId, input.projectId, repositoryId, input.commitSha, sourceRef, githubRef, r2ObjectKey, input.createdAt, input.createdAt, ]); diff --git a/src/api/capacity/services/treedx/repositories/treedx-proxy-token-service.ts b/src/api/capacity/services/treedx/repositories/treedx-proxy-token-service.ts index 51e62747..3a1f0fa0 100644 --- a/src/api/capacity/services/treedx/repositories/treedx-proxy-token-service.ts +++ b/src/api/capacity/services/treedx/repositories/treedx-proxy-token-service.ts @@ -80,10 +80,10 @@ export function resolveTreeDxProxyBaseUrl(runtime: TreeDxProxyRuntime, library: const topology = record(library?.topology); const contentRepository = record(topology.contentRepository); const treeDx = record(contentRepository.treeDx); - const value = text(treeDx.baseUrl) - || text(env.TREESEED_TREEDX_URL) + const value = text(env.TREESEED_TREEDX_URL) || text(env.TREESEED_TREEDX_BASE_URL) || text(env.TREESEED_PUBLIC_TREEDX_BASE_URL) + || text(treeDx.baseUrl) || 'http://127.0.0.1:4000'; try { return resolveTreeDxServiceUrl(value, env); } catch (error) { diff --git a/src/api/control-plane/catalog/index.ts b/src/api/control-plane/catalog/index.ts index 1741644a..5165afc4 100644 --- a/src/api/control-plane/catalog/index.ts +++ b/src/api/control-plane/catalog/index.ts @@ -27,12 +27,13 @@ import { createSeedOperations, type SeedOperationDependencies } from './seeds/in import { createFeedbackOperations, type FeedbackOperationDependencies } from './feedback/index.ts'; import { createTeamAccessOperation, createTeamArchiveOperation, createTeamCreateOperation, createTeamDeleteOperation, createTeamDeletionReadinessOperation, createTeamInviteAcceptOperation, createTeamInviteOperation, createTeamInviteResendOperation, createTeamInviteRevokeOperation, createTeamInvitesOperation, createTeamInviteShowOperation, createTeamLeaveOperation, createTeamMemberRemovalBlockersOperation, createTeamMembersOperation, createTeamMemberRemoveOperation, createTeamMemberUpdateOperation, createTeamOwnershipTransferOperation, createTeamProfileOperation, createTeamRestoreOperation, createTeamsListOperation, createTeamUpdateOperation, type TeamOperationDependencies } from './team-operations.ts'; import { createCapabilityOntologyOperations, type CapabilityOntologyOperationDependencies } from './capabilities/index.ts'; +import { createKnowledgeShareOperations,type KnowledgeShareOperationDependencies } from './knowledge-sharing/operations.ts'; export * from './operation-registry.ts'; export const controlPlaneOperations = new OperationRegistry([statusOperation]); -export function createApiControlPlaneOperations(dependencies: DeepHealthDependencies & ProjectOperationDependencies & AccountOperationDependencies & TeamOperationDependencies & KnowledgeOperationDependencies & DiscussionOperationDependencies & GovernanceOperationDependencies & InboxOperationDependencies & RepositoryOperationDependencies & ServiceOperationDependencies & CapacityPlanOperationDependencies & PlanningAndEstimateOperationDependencies & AgentGovernanceOperationDependencies & CommunicationOperationDependencies & WorkdayOperationDependencies & AgentOperationDependencies & CapacityQueryOperationDependencies & AssignmentOperationDependencies & PlatformOperationDependencies & ProviderOperationDependencies & ProviderAssignmentOperationDependencies & TreeDxOperationDependencies & TreeAiOperationDependencies & RealtimeOperationDependencies & SeedOperationDependencies & FeedbackOperationDependencies & CapabilityOntologyOperationDependencies) { +export function createApiControlPlaneOperations(dependencies: DeepHealthDependencies & ProjectOperationDependencies & AccountOperationDependencies & TeamOperationDependencies & KnowledgeOperationDependencies & DiscussionOperationDependencies & GovernanceOperationDependencies & InboxOperationDependencies & RepositoryOperationDependencies & ServiceOperationDependencies & CapacityPlanOperationDependencies & PlanningAndEstimateOperationDependencies & AgentGovernanceOperationDependencies & CommunicationOperationDependencies & WorkdayOperationDependencies & AgentOperationDependencies & CapacityQueryOperationDependencies & AssignmentOperationDependencies & PlatformOperationDependencies & ProviderOperationDependencies & ProviderAssignmentOperationDependencies & TreeDxOperationDependencies & TreeAiOperationDependencies & RealtimeOperationDependencies & SeedOperationDependencies & FeedbackOperationDependencies & CapabilityOntologyOperationDependencies & KnowledgeShareOperationDependencies) { return new OperationRegistry([ statusOperation, createReadinessOperation(dependencies), @@ -63,6 +64,7 @@ export function createApiControlPlaneOperations(dependencies: DeepHealthDependen ...createPlatformOperations(dependencies), ...createProviderRegistrationAndAvailabilityOperations(dependencies), ...createCapabilityOntologyOperations(dependencies), + ...createKnowledgeShareOperations(dependencies), ...createProviderAssignmentOperations(dependencies), ...createTreeDxOperations(dependencies), ...createTreeAiOperations(dependencies), diff --git a/src/api/control-plane/catalog/knowledge-sharing/operations.ts b/src/api/control-plane/catalog/knowledge-sharing/operations.ts new file mode 100644 index 00000000..c6fa5472 --- /dev/null +++ b/src/api/control-plane/catalog/knowledge-sharing/operations.ts @@ -0,0 +1,66 @@ +import { CONTROL_PLANE_OPERATIONS,knowledgeShareGrantInputSchema,knowledgeShareSchema,teamKnowledgeRequestSchema } from '@treeseed/sdk/operator-contracts'; +import type { TreeDxProxyOperationService } from '../../repositories/treedx/proxy-operation-service.ts'; +import { ControlPlaneOperationError,type BoundOperation,type OperationInvocationContext } from '../operation-registry.ts'; + +const record=(value:unknown):Record=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; +const strings=(value:unknown)=>Array.isArray(value)?[...new Set(value.map(String).map((item)=>item.trim()).filter(Boolean))]:[]; +const stringValues=(value:unknown)=>typeof value==='string'&&value.trim()?[value.trim()]:strings(value); + +export interface KnowledgeShareOperationDependencies { store:any; treeDxProxy:TreeDxProxyOperationService; } + +function principal(context:OperationInvocationContext){if(!context.principal)throw new ControlPlaneOperationError(401,'authentication_required','Authentication is required.');return context.principal;} +async function access(dependencies:KnowledgeShareOperationDependencies,teamId:string,context:OperationInvocationContext,owner=false){ + const actor=principal(context),admin=actor.roles?.some((role)=>role==='admin'||role==='platform_admin')||actor.permissions?.includes('*:*:*'); + if(!admin&&!await dependencies.store.principalCanAccessTeam(actor,teamId))throw new ControlPlaneOperationError(403,'team_forbidden','The principal cannot access this team.'); + if(owner&&!admin){const membership=await dependencies.store.resolvePrincipalTeamContext(teamId,actor);if(!membership?.roles?.includes('team_owner'))throw new ControlPlaneOperationError(403,'team_owner_required','Team owner authority is required.');} + return actor; +} +function view(row:Record){ + const grant=record(row.trustGrant),parsed=knowledgeShareSchema.parse({schemaVersion:'treeseed.knowledge-share/v1',id:row.id,sourceTeamId:row.teamId,targetTeamId:row.targetTeamId, + projectIds:strings(grant.projectIds??row.projectId),contentModels:strings(grant.contentModels),paths:strings(grant.paths).length?strings(grant.paths):['**'], + operations:strings(grant.operations),expiresAt:row.expiresAt??null,status:row.status,createdAt:row.createdAt,updatedAt:row.updatedAt,revokedAt:row.revokedAt??null}); + return parsed; +} +function active(row:Record){return row.status==='active'&&(!row.expiresAt||Date.parse(String(row.expiresAt))>Date.now());} + +async function resolveProjects(dependencies:KnowledgeShareOperationDependencies,teamId:string,body:unknown,operation:'read'|'query'|'context'|'graph'){ + const request=teamKnowledgeRequestSchema.parse(body),sameTeam=await dependencies.store.listTeamProjects(teamId),bySlug=new Map(sameTeam.map((project:Record)=>[String(project.slug),project])); + const sameIds=new Set(sameTeam.map((project:Record)=>String(project.id))); + const missingIds=request.projectIds.filter((id)=>!sameIds.has(id)),missingSlugs=request.projectSlugs.filter((slug)=>!bySlug.has(slug)); + if(missingIds.length||missingSlugs.length)throw new ControlPlaneOperationError(404,'knowledge_project_not_found','A selected same-team project was not found.'); + const requestedSame=new Set([...request.projectIds,...request.projectSlugs.map((slug)=>String(bySlug.get(slug)!.id))]); + const selectedSame=(requestedSame.size?sameTeam.filter((project:Record)=>requestedSame.has(String(project.id))):sameTeam) + .map((project:Record)=>({projectId:String(project.id),paths:['**']})); + if(selectedSame.length!==(requestedSame.size||sameTeam.length))throw new ControlPlaneOperationError(404,'knowledge_project_not_found','A selected same-team project was not found.'); + const incoming=(await dependencies.store.listTreeDxSharesForRecipient(teamId)).filter(active),selectedShared:Array<{projectId:string;paths:string[]}>=[]; + const filters=record(record(request.request).filters); + const requestModels=[...new Set([ + ...stringValues(filters.model), + ...stringValues(filters.models), + ])]; + for(const source of request.sharedSources){ + const shares=incoming.filter((row:Record)=>row.teamId===source.teamId&&strings(record(row.trustGrant).operations).includes(operation)); + const eligible=new Map>();for(const share of shares)for(const projectId of strings(record(share.trustGrant).projectIds??share.projectId))eligible.set(projectId,share); + const selected=source.projectIds.length?source.projectIds:[...eligible.keys()]; + for(const projectId of selected){const share=eligible.get(projectId);if(!share)throw new ControlPlaneOperationError(403,'knowledge_share_denied','The requested shared project is not covered by an active grant.'); + const grant=record(share.trustGrant),models=strings(grant.contentModels);if(models.length&&(!requestModels.length||requestModels.some((model)=>!models.includes(model))))throw new ControlPlaneOperationError(403,'knowledge_model_denied','The request must select only content models allowed by the knowledge share.'); + selectedShared.push({projectId,paths:strings(grant.paths).length?strings(grant.paths):['**']});} + } + return {request,projects:[...selectedSame,...selectedShared]}; +} + +function federated(dependencies:KnowledgeShareOperationDependencies,kind:'search'|'query'|'context'|'graph'):BoundOperation{ + const binding=CONTROL_PLANE_OPERATIONS.knowledge[kind==='search'?'teamSearch':kind==='query'?'teamQuery':kind==='context'?'teamContext':'teamGraph']; + const upstream=CONTROL_PLANE_OPERATIONS.treedx.federated[kind]; + return {binding,async handler(input,context){const actor=await access(dependencies,input.path.teamId,context);const resolved=await resolveProjects(dependencies,input.path.teamId,input.body,kind==='search'?'read':kind); + return dependencies.treeDxProxy.invokeAuthorizedFederation({principal:actor,teamId:input.path.teamId,descriptor:upstream.descriptor,projects:resolved.projects,request:resolved.request.request,context});}}; +} + +export function createKnowledgeShareOperations(dependencies:KnowledgeShareOperationDependencies):BoundOperation[]{return [ + {binding:CONTROL_PLANE_OPERATIONS.knowledge.shares.list,async handler(input,context){await access(dependencies,input.path.teamId,context);const outgoing=await dependencies.store.listTreeDxShares(input.path.teamId),incoming=await dependencies.store.listTreeDxSharesForRecipient(input.path.teamId);return {items:[...outgoing,...incoming].map(view)};}}, + {binding:CONTROL_PLANE_OPERATIONS.knowledge.shares.show,async handler(input,context){await access(dependencies,input.path.teamId,context);const row=await dependencies.store.getTreeDxShare(input.path.shareId);if(!row||(row.teamId!==input.path.teamId&&row.targetTeamId!==input.path.teamId))throw new ControlPlaneOperationError(404,'knowledge_share_not_found','The knowledge share was not found.');return view(row);}}, + {binding:CONTROL_PLANE_OPERATIONS.knowledge.shares.grant,async handler(input,context){await access(dependencies,input.path.teamId,context,true);const body=knowledgeShareGrantInputSchema.parse(input.body);if(body.targetTeamId===input.path.teamId)throw new ControlPlaneOperationError(400,'knowledge_share_same_team','Same-team reads do not require a share.');if(!await dependencies.store.getTeam(body.targetTeamId))throw new ControlPlaneOperationError(404,'target_team_not_found','The recipient team was not found.');for(const projectId of body.projectIds){const details=await dependencies.store.getProjectDetails(projectId);if(!details||details.project.teamId!==input.path.teamId)throw new ControlPlaneOperationError(400,'knowledge_share_project_invalid','Every shared project must belong to the source team.');} + const row=await dependencies.store.createTreeDxShare(input.path.teamId,{scope:'library',targetTeamId:body.targetTeamId,trustGrant:{schemaVersion:'treeseed.knowledge-share/v1',projectIds:body.projectIds,contentModels:body.contentModels,paths:body.paths,operations:body.operations},expiresAt:body.expiresAt,status:'active'});await dependencies.store.recordAuditEvent({actorType:'user',actorId:context.principal!.id,eventType:'knowledge.share.granted',targetType:'knowledge_share',targetId:row.id,data:{sourceTeamId:input.path.teamId,targetTeamId:body.targetTeamId,projectIds:body.projectIds}});return view(row);}}, + {binding:CONTROL_PLANE_OPERATIONS.knowledge.shares.revoke,async handler(input,context){await access(dependencies,input.path.teamId,context,true);const existing=await dependencies.store.getTreeDxShare(input.path.shareId);if(!existing||existing.teamId!==input.path.teamId)throw new ControlPlaneOperationError(404,'knowledge_share_not_found','The knowledge share was not found.');const row=await dependencies.store.revokeTreeDxShare(input.path.teamId,input.path.shareId);await dependencies.store.recordAuditEvent({actorType:'user',actorId:context.principal!.id,eventType:'knowledge.share.revoked',targetType:'knowledge_share',targetId:input.path.shareId});return view(row);}}, + federated(dependencies,'search'),federated(dependencies,'query'),federated(dependencies,'context'),federated(dependencies,'graph'), +];} diff --git a/src/api/control-plane/catalog/project-operations.ts b/src/api/control-plane/catalog/project-operations.ts index 003e0a25..eaa626c1 100644 --- a/src/api/control-plane/catalog/project-operations.ts +++ b/src/api/control-plane/catalog/project-operations.ts @@ -121,6 +121,7 @@ export function createProjectCreateOperation(dependencies: ProjectOperationDepen const slug = String(body.slug ?? '').trim(); const name = String(body.name ?? '').trim(); if (!slug || !name) throw new ControlPlaneOperationError(400, 'project_input_invalid', 'Project slug and name are required.'); + if (slug === 'team') throw new ControlPlaneOperationError(409,'system_project_reserved','The team project is created and managed automatically.'); try { return await dependencies.store.createProject(input.path.teamId, { ...(typeof body.id === 'string' ? { id: body.id } : {}), slug, name, @@ -149,6 +150,7 @@ export function createProjectUpdateOperation(dependencies: ProjectOperationDepen binding: CONTROL_PLANE_OPERATIONS.projects.update, async handler(input, context) { const access = await projectManageAccess(dependencies, input.path.projectId, context); + if(access.details.project.metadata?.kind==='system-team-library')throw new ControlPlaneOperationError(409,'system_project_managed','The Team Library cannot be renamed, transferred, archived, or edited as a normal project.'); const currentRevision = String(access.details.project.updatedAt ?? ''); if (currentRevision !== context.ifMatch) throw new ControlPlaneOperationError(412, 'project_revision_changed', 'The project changed since it was read.'); const body = input.body as Record; @@ -177,6 +179,7 @@ function projectInventoryOperation( binding, async handler(input, context) { const access = await projectManageAccess(dependencies, input.path.projectId, context); + if(access.details.project.metadata?.kind==='system-team-library')throw new ControlPlaneOperationError(409,'system_project_managed','The Team Library lifecycle is bound to its team.'); if (status === 'archived') { const blockers = await dependencies.capacity.evaluateProjectDeletionBlockers(input.path.projectId); if (blockers.length) throw new ControlPlaneOperationError(409, 'project_blocked', 'The project still has active work and cannot be archived.'); @@ -214,6 +217,7 @@ export function createProjectDeleteOperation(dependencies: ProjectOperationDepen binding: CONTROL_PLANE_OPERATIONS.projects.remove, async handler(input, context) { const access = await projectManageAccess(dependencies, input.path.projectId, context); + if(access.details.project.metadata?.kind==='system-team-library')throw new ControlPlaneOperationError(409,'system_project_managed','The Team Library is deleted only with its owning team.'); const currentRevision = String(access.details.project.updatedAt ?? ''); if (currentRevision !== context.ifMatch) throw new ControlPlaneOperationError(412, 'project_revision_changed', 'The project changed since it was read.'); const expected = `DELETE ${access.details.project.slug}`; diff --git a/src/api/control-plane/catalog/team-operations.ts b/src/api/control-plane/catalog/team-operations.ts index f223f1c2..8be7b0e3 100644 --- a/src/api/control-plane/catalog/team-operations.ts +++ b/src/api/control-plane/catalog/team-operations.ts @@ -31,8 +31,13 @@ export interface TeamOperationDependencies { leaveTeam(teamId: string, userId: string): Promise>; prepareTeamDeletion(teamId: string, confirmation: string): Promise>; recordAuditEvent(event: Record): Promise; + getProjectByTeamAndSlug(teamId:string,slug:string):Promise|null>; + getProjectTreeDxLibrary(projectId:string):Promise|null>; }; deliverTeamInvite(input: { invite: Record; team: Record; token: string }): Promise; + reconcileManagedTeamLibrary(teamId:string):Promise>; + deleteManagedTeamLibraryResources(input:{teamId:string;project:Record}):Promise>; + treeDxProxy:{invoke(descriptor:unknown,input:Record,context:Record):Promise}; } function authenticatedPrincipal(context: { principal?: Record }) { @@ -211,8 +216,10 @@ export function createTeamCreateOperation(dependencies: TeamOperationDependencie logoUrl: typeof body.logoUrl === 'string' ? body.logoUrl : null, profileSummary: typeof body.profileSummary === 'string' ? body.profileSummary : typeof body.description === 'string' ? body.description : null, metadata: body.metadata && typeof body.metadata === 'object' ? body.metadata : {}, ownerUserId: principal.id }); + await dependencies.reconcileManagedTeamLibrary(String(team.id)); + const readyTeam=await dependencies.store.getTeam(String(team.id)); await dependencies.store.recordAuditEvent({ actorType: 'user', actorId: principal.id, eventType: 'team.created', targetType: 'team', targetId: team.id, data: { name: team.name } }); - return team; + return readyTeam??team; } catch (error) { const message = error instanceof Error ? error.message : 'The team could not be created.'; const conflict = /already taken|already used/u.test(message); @@ -432,10 +439,20 @@ export function createTeamDeleteOperation(dependencies: TeamOperationDependencie throw new ControlPlaneOperationError(409, 'stale', 'The team changed before deletion could be authorized.'); if (!await consumeReauthentication(dependencies.store, access.principal, 'team_delete', body)) throw new ControlPlaneOperationError(401, 'reauthentication_required', 'Current credentials were not accepted.'); + const teamLibrary=await dependencies.store.getProjectByTeamAndSlug(input.path.teamId,'team'); + if(!teamLibrary||teamLibrary.metadata?.kind!=='system-team-library') + throw new ControlPlaneOperationError(409,'team_library_missing','The protected Team Library must be present before team deletion can complete.'); + const library=await dependencies.store.getProjectTreeDxLibrary(String(teamLibrary.id)); + let treeDx:unknown=null; + if(library?.repositoryId)treeDx=await dependencies.treeDxProxy.invoke( + CONTROL_PLANE_OPERATIONS.treedx.repositories.retire.descriptor, + {path:{projectId:String(teamLibrary.id),repoId:String(library.repositoryId)},query:{},body:{}},context as Record); + const resources=await dependencies.deleteManagedTeamLibraryResources({teamId:input.path.teamId,project:teamLibrary}); const result = await deleteTeamCapacityAggregate(dependencies.store as any, input.path.teamId, String(body.confirmation ?? '')); if (!result.ok) teamMutationFailure(result, 'team_delete_failed', 'The team could not be deleted.'); await dependencies.store.recordAuditEvent({ actorType: 'user', actorId: access.principal.id, eventType: 'team.deleted', targetType: 'team', targetId: input.path.teamId }); - return { ok: true, deleted: true, teamId: input.path.teamId }; + return { ok: true, deleted: true, teamId: input.path.teamId, + receipt:{schemaVersion:'treeseed.team-deletion-receipt/v1',teamId:input.path.teamId,treeDx,resources,providerIds:result.providerIds??[],completedAt:new Date().toISOString()} }; } }; } diff --git a/src/api/control-plane/knowledge/knowledge-review-service.ts b/src/api/control-plane/knowledge/knowledge-review-service.ts index e5afaa3b..040fb0dc 100644 --- a/src/api/control-plane/knowledge/knowledge-review-service.ts +++ b/src/api/control-plane/knowledge/knowledge-review-service.ts @@ -105,8 +105,10 @@ export function createKnowledgeReviewService(store: any) { } const gate = editorialReviewGate(review); if (!gate.ok) throw new KnowledgeOperationError(409, gate.code, 'The editorial review gate is incomplete.'); + const connection = await resolveKnowledgeGatewayConnection(store, { projectId: workspace.projectId, write: false }); + if (!connection) throw new KnowledgeOperationError(503, 'knowledge_repository_unavailable', 'The project knowledge repository is unavailable.'); const publication = await store.createKnowledgePublication({ workspaceId: workspace.id, reviewId, - projectId: workspace.projectId, commitSha: review.commitSha, publishedRef: workspace.baseRef }); + projectId: workspace.projectId, commitSha: review.commitSha, publishedRef: connection.publicationRef }); const operation = await store.createPlatformOperation({ namespace: 'knowledge', operation: 'publish_review', target: 'control_plane_operations_runner', idempotencyKey: `knowledge-publication:${publication.id}`, input: { publicationId: publication.id, simulation: { ...simulation.evidence, operatorPrincipalId: access.principal.id } }, diff --git a/src/api/control-plane/knowledge/knowledge-workspace-service.ts b/src/api/control-plane/knowledge/knowledge-workspace-service.ts index 6b9d6b0c..ef331cfc 100644 --- a/src/api/control-plane/knowledge/knowledge-workspace-service.ts +++ b/src/api/control-plane/knowledge/knowledge-workspace-service.ts @@ -10,6 +10,11 @@ import { recordTreeDxAuthoringState } from '../../capacity/services/treedx/repos import { KnowledgeOperationError } from './knowledge-operation-error.ts'; import { createKnowledgeAuthorization, type KnowledgePrincipal } from './knowledge-authorization.ts'; import { allowedKnowledgePath } from './knowledge-path.ts'; +import { AGENT_OPERATIONAL_CONTENT_COLLECTIONS,validateContentFrontmatter } from '@treeseed/sdk/content-validation'; +import { parseFrontmatterDocument } from '../../content/frontmatter.ts'; + +const operationalModels=new Map([...Object.entries(AGENT_OPERATIONAL_CONTENT_COLLECTIONS).map(([model,collection])=>[collection,model] as const),['agent-tests','agent_test']]); +function operationalModel(path:string){const collection=path.split('/').at(-2)??path.split('/')[0]??'';return operationalModels.get(collection)??operationalModels.get(path.split('/')[0]??'')??null;} export function createKnowledgeWorkspaceService(store: any, reader: { projectCatalog(principal: KnowledgePrincipal, projectId: string): Promise> }) { const authorization = createKnowledgeAuthorization(store); @@ -101,6 +106,17 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat write: true, workspaceRefs: [access.workspace.branchName], authoringPaths: true }); if (!connection) throw new KnowledgeOperationError(503, 'knowledge_repository_unavailable', 'The project knowledge repository is unavailable.'); const sourcePath = text(input.sourcePath); + if(input.kind==='operational-content') { + const model=operationalModel(sourcePath);if(!model)throw new KnowledgeOperationError(422,'operational_content_path_invalid','Choose a registered operational-content collection.'); + if(!allowedKnowledgePath(access.workspace,sourcePath))throw new KnowledgeOperationError(422,'knowledge_path_invalid','The operational content path is outside this workspace.'); + const content=typeof input.content==='string'?input.content:'';if(!content.trim())throw new KnowledgeOperationError(422,'operational_content_required','Operational content is required.'); + const validation=validateContentFrontmatter(model as never,parseFrontmatterDocument(content).frontmatter);if(!validation.ok)throw new KnowledgeOperationError(422,'operational_content_invalid',`The ${model} content is invalid: ${validation.diagnostics.map((entry)=>entry.message).join(' ')}`); + let before:null|string=null;if(input.create!==true){const current=await connection.client.readFile({workspaceId:access.workspace.treeDxWorkspaceId,path:sourcePath});if(!text(input.expectedSha)||text(input.expectedSha)!==current.sha)throw new KnowledgeOperationError(409,'stale_workspace_file','The operational content changed. Reload before saving.');before=current.content;} + const result=await applyTextChangeset({client:connection.client,workspace:{workspaceId:access.workspace.treeDxWorkspaceId,baseCommitSha:access.workspace.baseCommitSha,baseRef:access.workspace.baseRef},changes:[{path:sourcePath,before,after:content}],idempotencyKey:`operational-content-${workspaceId}-${access.workspace.version}`}); + const updated=await store.updateKnowledgeWorkspace(workspaceId,{version:access.workspace.version,status:'draft'});if(!updated.ok)throw new KnowledgeOperationError(409,'stale_workspace','The draft changed. Reload before saving.'); + await store.recordAuditEvent({eventType:'knowledge.operational_content.updated',actorType:'user',actorId:access.principal.id,targetType:model,targetId:sourcePath,data:{workspaceId,projectId:access.workspace.projectId,path:sourcePath}}); + return {result,workspace:updated.workspace}; + } if (input.kind === 'agent-profile') { if (!sourcePath.startsWith(projectLibraryPath(connection.contentPath, 'agents') + '/')) throw new KnowledgeOperationError(422, 'agent_profile_path_invalid', 'Agent profiles must remain in the agents collection.'); if (!allowedKnowledgePath(access.workspace, sourcePath)) throw new KnowledgeOperationError(422, 'knowledge_path_invalid', 'The agent profile path is outside this workspace.'); diff --git a/src/api/control-plane/repositories/capacity/communication-service.ts b/src/api/control-plane/repositories/capacity/communication-service.ts index bf137008..3d875d8d 100644 --- a/src/api/control-plane/repositories/capacity/communication-service.ts +++ b/src/api/control-plane/repositories/capacity/communication-service.ts @@ -37,6 +37,11 @@ function record(value: unknown): Row { } function text(value: unknown, fallback = '') { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } +function strings(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean); + if (typeof value === 'string') try { return strings(JSON.parse(value)); } catch { return []; } + return []; +} function timestamp(value: unknown, fallback = '') { if (value instanceof Date) return value.toISOString(); const candidate = text(value); @@ -201,7 +206,11 @@ export function createCommunicationService(store: any, discussions?: { create(pr const invocation = invocations.find((row: Row) => text(row.project_id) === projectId)!; const metadata = record(invocation.metadata_json); const communication = record(metadata.communication); const discussionId = text(metadata.discussionId); const details = await contentStore.getProjectDetails(projectId); - const history = await loadDiscussions({ store: contentStore, projectId, discussionId, collection: 'messages', limit: 200 }); + const exactPaths = [...new Set(invocations.filter((row: Row) => text(row.project_id) === projectId).flatMap((row: Row) => [ + strings(row.content_refs_json)[0], text(row.final_message_ref), + ]).filter(Boolean))]; + const history = await loadDiscussions({ store: contentStore, projectId, discussionId, + exactPaths, collection: 'messages', limit: Math.max(1, exactPaths.length) }); const messages = history.messages as Row[]; const sourceMessageId = text(metadata.sourceMessageId); const topic = await store.first('SELECT id,slug FROM communication_discussion_topics WHERE id=? AND team_id=? LIMIT 1', [text(communication.topicId), teamId]); const stream = await store.first('SELECT id,project_id FROM communication_discussion_streams WHERE id=? AND team_id=? LIMIT 1', [text(communication.streamId), teamId]); @@ -209,30 +218,32 @@ export function createCommunicationService(store: any, discussions?: { create(pr projects.set(projectId, { slug: text(details?.project?.slug, projectId), discussionId, messages, source: messages.find((message) => text(message.id) === sourceMessageId), topic, stream }); } + const first = projects.get(projectIds[0]!)!; + const eventRows = await store.all('SELECT * FROM communication_topic_events WHERE team_id=? AND topic_id=? AND send_id=? ORDER BY sequence', [teamId, first.topic.id, sendId]); const responses = invocations.flatMap((invocation: Row) => { const path = text(invocation.final_message_ref); const message = projects.get(text(invocation.project_id))?.messages.find((candidate) => text(candidate.path) === path); - if (!message) return []; - const frontmatter = record(message.frontmatter); + const projected = eventRows.find((candidate: Row) => text(candidate.invocation_id) === text(invocation.id) + && text(candidate.event_type) === 'agent.response' && text(record(candidate.payload_json).messageRef) === path); + if (!message && !projected) return []; + const frontmatter = record(message?.frontmatter), projectedPayload = record(projected?.payload_json); const outcome = text(record(invocation.response_json).outcome, 'responded'); return [{ projectId: text(invocation.project_id), projectSlug: projects.get(text(invocation.project_id))?.slug ?? text(invocation.project_id), agentSlug: text(invocation.agent_id), invocationId: text(invocation.id), - assignmentId: text(invocation.assignment_id) || null, messageRef: path, markdown: text(message.body), status: 'responded', + assignmentId: text(invocation.assignment_id) || null, messageRef: path, markdown: text(message?.body, text(projectedPayload.markdown)), status: 'responded', requirement: text(record(record(invocation.metadata_json).communication).requirement, 'required'), ...(outcome === 'abstained' ? { status: 'abstained' } : {}), - createdAt: text(frontmatter.createdAt, timestamp(invocation.completed_at, new Date().toISOString())) }]; + createdAt: text(frontmatter.createdAt, timestamp(projected?.occurred_at, timestamp(invocation.completed_at, new Date().toISOString()))) }]; }); const statuses = invocations.map((row: Row) => text(row.status)); const finished = statuses.filter((status: string) => ['completed', 'suspended', 'failed', 'cancelled'].includes(status)).length; const status = finished === invocations.length ? responses.length === invocations.length ? 'complete' : responses.length ? 'partial' : 'failed' : statuses.some((value: string) => ['admitted', 'running'].includes(value)) ? 'running' : 'queued'; - const first = projects.get(projectIds[0]!)!; const targetStatus = (row: Row) => text(record(row.response_json).outcome) === 'abstained' ? 'abstained' : text(row.final_message_ref) || text(row.status) === 'suspended' ? 'responded' : text(row.status) === 'failed' ? 'failed' : text(row.status) === 'cancelled' ? 'cancelled' : ['admitted', 'running'].includes(text(row.status)) ? 'running' : 'queued'; - const eventRows = await store.all('SELECT * FROM communication_topic_events WHERE team_id=? AND topic_id=? AND send_id=? ORDER BY sequence', [teamId, first.topic.id, sendId]); const targets = await Promise.all(invocations.map(async (row: Row) => { const assignment = assignmentByInvocation.get(text(row.id)) ?? {}; return ({ projectId: text(row.project_id), projectSlug: projects.get(text(row.project_id))?.slug ?? text(row.project_id), agentSlug: text(row.agent_id), definitionRevision: text(row.agent_revision), revisions: { project: text(record(record(row.metadata_json).revisions).project, text(record(row.metadata_json).sourceCommit, text(row.agent_revision))), @@ -252,7 +263,7 @@ export function createCommunicationService(store: any, discussions?: { create(pr channel: text(first.topic.slug), topic: { id: text(first.topic.id), slug: text(first.topic.slug) }, projectStreams: projectIds.map((projectId) => { const project = projects.get(projectId)!; return { id: text(project.stream.id), projectId, projectSlug: project.slug, discussionId: project.discussionId, - messageRef: text(project.source?.path), + messageRef: text(project.source?.path, strings(invocations.find((row: Row) => text(row.project_id) === projectId)?.content_refs_json)[0]), }; }), status, targets, responses, events: eventRows.map((row: Row) => eventRow(row, text(first.topic.slug))), createdAt: timestamp(invocations[0]?.requested_at, new Date().toISOString()), updatedAt: timestamp(invocations.at(-1)?.updated_at, timestamp(invocations[0]?.requested_at, new Date().toISOString())), replayed }; diff --git a/src/api/control-plane/repositories/providers/provider-runtime-service.ts b/src/api/control-plane/repositories/providers/provider-runtime-service.ts index 5b71093d..0beecd2f 100644 --- a/src/api/control-plane/repositories/providers/provider-runtime-service.ts +++ b/src/api/control-plane/repositories/providers/provider-runtime-service.ts @@ -115,14 +115,27 @@ export function createProviderRuntimeService(store: CapacityGovernanceDatabase, }, async status(principal: UserPrincipal | null | undefined, teamId: string, providerId: string) { const item = await this.show(principal, teamId, providerId); - const sessions = await store.all(`SELECT id, status, expires_at, refreshed_at, metadata_json FROM capacity_provider_availability_sessions WHERE team_id = ? AND capacity_provider_id = ? ORDER BY created_at DESC LIMIT 5`, [teamId, providerId]); - const activeExecutionProvider = await store.first(`SELECT COUNT(*) AS count FROM capacity_execution_providers WHERE capacity_provider_id = ? AND status = 'active'`, [providerId]); + const [sessions, activeExecutionProvider, unavailableOffers] = await Promise.all([ + store.all(`SELECT id, status, expires_at, refreshed_at, metadata_json FROM capacity_provider_availability_sessions WHERE team_id = ? AND capacity_provider_id = ? ORDER BY created_at DESC LIMIT 5`, [teamId, providerId]), + store.first(`SELECT COUNT(*) AS count FROM capacity_execution_providers WHERE capacity_provider_id = ? AND status = 'active'`, [providerId]), + store.all(`SELECT offer_id, execution_provider_id, status, last_seen_at FROM execution_capability_offers + WHERE capacity_provider_id = ? AND status <> 'active' ORDER BY last_seen_at DESC, offer_id ASC`, [providerId]), + ]); const activeExecutionProviderCount = Number(activeExecutionProvider?.count ?? 0); - return { provider: item, healthy: providerAvailabilityIsRunnable(sessions, activeExecutionProviderCount), activeExecutionProviderCount, availability: sessions }; + return { provider: item, healthy: providerAvailabilityIsRunnable(sessions, activeExecutionProviderCount), activeExecutionProviderCount, + availability: sessions, unavailableOffers, alerts: unavailableOffers.map((offer) => ({ + code: offer.status === 'context_overflow' ? 'provider_context_capacity_overflow' : 'provider_offer_unavailable', + offerId: offer.offer_id, executionProviderId: offer.execution_provider_id, status: offer.status, + observedAt: offer.last_seen_at, + })) }; }, async diagnose(principal: UserPrincipal | null | undefined, teamId: string, providerId: string) { const status = await this.status(principal, teamId, providerId); - return { ...status, blockers: status.healthy ? [] : ['provider_availability_unhealthy'], nextActions: status.healthy ? [] : ['Start or reconcile the local provider manager.'] }; + const offerBlockers = status.unavailableOffers.map((offer) => `${offer.status}:${offer.offer_id}`); + return { ...status, blockers: [...(status.healthy ? [] : ['provider_availability_unhealthy']), ...offerBlockers], + nextActions: [...(status.healthy ? [] : ['Start or reconcile the local provider manager.']), + ...(status.unavailableOffers.some((offer) => offer.status === 'context_overflow') + ? ['Publish an updated context-capacity offer and pass conformance before re-enabling it.'] : [])] }; }, async connect(principal: UserPrincipal | null | undefined, teamId: string, _idempotencyKey: string) { const actor = await requireManage(principal, teamId); diff --git a/src/api/control-plane/repositories/treedx/proxy-operation-service.ts b/src/api/control-plane/repositories/treedx/proxy-operation-service.ts index 4fcc5675..282ad30b 100644 --- a/src/api/control-plane/repositories/treedx/proxy-operation-service.ts +++ b/src/api/control-plane/repositories/treedx/proxy-operation-service.ts @@ -53,6 +53,12 @@ function publicLibrary(library: Record | null) { return { ...value, connectionId: instanceId ?? null }; } +function currentLibraryView(library: Record | null): string { + const topology = record(record(record(library?.topology).contentRepository)); + const metadata = record(library?.metadata); + return String(library?.contentRepositoryRef ?? topology.ref ?? metadata.resolvedRef ?? '').trim(); +} + const treeDxCapabilityGroups = ['repositories', 'workspaces', 'files', 'blobs', 'search', 'graph', 'context', 'artifacts', 'capabilities', 'health']; async function acceptServiceContract(store: Store, acceptedConnectionId: string) { @@ -120,19 +126,25 @@ async function authorize(store: Store, projectId: string, permission: Permission if (principal.teamId !== details.project.teamId) return reject('treedx_proxy_team_mismatch', 'Capacity provider cannot access this project.'); if (!identity.assignmentId || !identity.handleId) return reject('treedx_proxy_handle_missing', 'Capacity provider TreeDX proxy access requires an assignment-scoped proxy handle.'); const assignment = await store.getProviderAssignment(principal.teamId, identity.assignmentId); - if (!assignment || assignment.projectId !== projectId || assignment.capacityProviderId !== principal.capacityProviderId) return reject('treedx_proxy_assignment_mismatch', 'TreeDX proxy handle is not bound to this provider assignment.'); + if (!assignment || assignment.capacityProviderId !== principal.capacityProviderId) return reject('treedx_proxy_assignment_mismatch', 'TreeDX proxy handle is not bound to this provider assignment.'); if (assignment.leaseState !== 'leased' || !assignment.leaseExpiresAt || Date.parse(String(assignment.leaseExpiresAt)) <= Date.now()) return reject('treedx_proxy_assignment_not_leased', 'TreeDX proxy handle requires an active assignment lease.'); - const handle = await store.getTreeDxProxyHandle(principal.teamId, projectId, identity.handleId); + const handle = await store.getTreeDxProxyHandle(principal.teamId, String(assignment.projectId), identity.handleId); if (!handle || handle.assignmentId && handle.assignmentId !== identity.assignmentId) return reject('treedx_proxy_scope_mismatch', 'TreeDX proxy handle scope does not match the active assignment.'); + const readRepositories=Array.isArray(record(handle.metadata).readRepositories)?(record(handle.metadata).readRepositories as unknown[]).map(record):[]; + const readGrant=readRepositories.find((grant)=>String(grant.projectId)===projectId&&(!resources.repoId||String(grant.repositoryId)===String(resources.repoId))); + const owningProject=String(assignment.projectId)===projectId; + if(!owningProject&&!readGrant)return reject('treedx_proxy_project_denied','The assignment has no read authority for this project.'); + if(!owningProject&&permission==='projects:manage:team')return reject('treedx_proxy_cross_project_write_denied','Cross-project TreeDX writes are prohibited.'); if (handle.tokenHash && (!identity.token || createHash('sha256').update(identity.token).digest('hex') !== handle.tokenHash)) return reject('treedx_proxy_token_mismatch', 'TreeDX proxy handle token does not match.'); if (!requiredHandleScopes(scope).some((value) => (handle.scopes as unknown[] ?? []).map(String).includes(value))) return reject('treedx_proxy_scope_denied', 'TreeDX proxy handle does not allow this operation.'); const requestPaths = scope.paths.filter((value) => value !== '**' && value !== '*'); for (const pathValue of requestPaths.length ? requestPaths : [null]) { - const evaluated = evaluateTreeDxProxyHandleAccess(handle, { teamId: principal.teamId, projectId, assignmentId: identity.assignmentId, + const evaluated = evaluateTreeDxProxyHandleAccess(handle, { teamId: principal.teamId, projectId:String(assignment.projectId), assignmentId: identity.assignmentId, repositoryId: String(resources.repoId ?? scope.repoIds.find((value) => value !== '*') ?? '') || null, workspaceId: typeof resources.workspaceId === 'string' ? resources.workspaceId : null, operation: scope.capabilities[0] ?? null, path: pathValue, token: identity.token }); - if (!evaluated.ok) return reject(evaluated.code ?? 'treedx_proxy_request_denied', evaluated.reason ?? 'TreeDX proxy handle does not allow this request.', evaluated.metadata ?? {}); + if (!evaluated.ok&&owningProject) return reject(evaluated.code ?? 'treedx_proxy_request_denied', evaluated.reason ?? 'TreeDX proxy handle does not allow this request.', evaluated.metadata ?? {}); + if(readGrant&&pathValue){const allowed=Array.isArray(readGrant.allowedPaths)?readGrant.allowedPaths.map(String):[];if(!allowed.some((pattern)=>pattern==='**'||pattern==='*'||pathValue===pattern||pattern.endsWith('/**')&&pathValue.startsWith(pattern.slice(0,-3))))return reject('treedx_proxy_path_denied','The cross-project read path is outside its bounded authority.');} } return { actorType: 'capacity_provider', principal, details, assignment, handle }; } @@ -141,6 +153,14 @@ function actorId(access: Access) { return access.actorType === 'capacity_provider' ? String((access.principal as ProviderPrincipal).capacityProviderId) : String(access.principal.id); } +export function bindCurrentLibraryView(operation: { method: string }, input: { path: Record; query: Record; body: unknown }, library: Record | null) { + const view = currentLibraryView(library); + if (!view) throw new CapacityGovernanceError('treedx_current_view_unavailable', 'The project library current view is not ready.', 409); + return operation.method === 'GET' + ? { ...input, query: { ...input.query, ref: view } } + : { ...input, body: { ...record(input.body), ref: view } }; +} + function normalizedError(error: unknown): never { if (error instanceof TreeDxUpstreamAdmissionError) { const status = error.code === 'treedx_upstream_busy' ? 429 : error.code === 'treedx_upstream_cancelled' ? 409 : 503; @@ -160,6 +180,42 @@ export function createTreeDxProxyOperationService(storeValue: CapacityGovernance const store = storeValue as Store; const admission = new TreeDxUpstreamAdmission(); return { + async invokeAuthorizedFederation(input: { principal: OperationInvocationContext['principal']; teamId: string; + descriptor: ControlPlaneOperationDescriptor; projects: Array<{ projectId:string; paths:string[] }>; + request: Record; context: OperationInvocationContext }) { + if (!input.principal) throw new CapacityGovernanceError('authentication_required','Authentication is required.',401); + if (!administrator(input.principal) && !await store.principalCanAccessTeam(input.principal,input.teamId)) { + throw new CapacityGovernanceError('treedx_access_denied','The principal cannot access this team.',403); + } + if (input.descriptor.upstream?.service !== 'treedx' || !input.descriptor.operationId.startsWith('treedx.federated.')) { + throw new CapacityGovernanceError('treedx_mapping_missing','The team knowledge operation has no authoritative federated TreeDX mapping.',500); + } + const sources=[] as Array<{projectId:string;repositoryId:string;ref:string;paths:string[];library:Record}>; + for (const requested of input.projects) { + const details=await store.getProjectDetails(requested.projectId); + if (!details) throw new CapacityGovernanceError('project_not_found',`Unknown project "${requested.projectId}".`,404); + const library=await store.getProjectTreeDxLibrary(requested.projectId),repoId=repositoryId(library),ref=currentLibraryView(library); + if (!library||!repoId||!ref) throw new CapacityGovernanceError('treedx_binding_unavailable',`Project "${requested.projectId}" has no current TreeDX library view.`,503); + sources.push({projectId:requested.projectId,repositoryId:repoId,ref,paths:requested.paths.length?requested.paths:['**'],library}); + } + if (!sources.length) throw new CapacityGovernanceError('treedx_federated_scope_empty','No authorized project libraries were selected.',400); + const anchor=sources[0]!,baseUrl=resolveTreeDxProxyBaseUrl(runtime,anchor.library); + const operation=requireTreeDxOperation(input.descriptor.upstream.operationId); + const request={...input.request,repoIds:sources.map((source)=>source.repositoryId), + refs:Object.fromEntries(sources.map((source)=>[source.repositoryId,source.ref])), + paths:Object.fromEntries(sources.map((source)=>[source.repositoryId,source.paths]))}; + const scope=treeDxOperationScope(operation,{path:{},query:{},body:request},sources.map((source)=>source.repositoryId)); + const token=resolveTreeDxProxyToken(runtime,baseUrl,anchor.projectId,{...scope,repoIds:sources.map((source)=>source.repositoryId), + refs:[...new Set(sources.map((source)=>source.ref))],paths:[...new Set(sources.flatMap((source)=>source.paths))]}, + {connectionId:connectionId(anchor.library,baseUrl)}); + try { + const payload=await admission.run({connectionId:connectionId(anchor.library,baseUrl),projectId:anchor.projectId,actorId:String(input.principal.id),retryable:true,signal:input.context.signal, + transient:(error)=>error instanceof TypeError||error instanceof TreeDxApiError&&(error.status===429||error.status>=500), + invoke:()=>invokeOfficialTreeDxOperation({client:createOfficialTreeDxClient({baseUrl,token,fetchImpl:runtime.fetchImpl,timeoutMs:15_000}),operation, + path:{},query:{},body:request,requestId:input.context.requestId,traceparent:input.context.traceparent,signal:input.context.signal})}); + return {result:payload,sources:sources.map(({library:_library,...source})=>source)}; + } catch(error) { return normalizedError(error); } + }, async library(principal: OperationInvocationContext['principal'], projectId: string) { await authorize(store, projectId, 'projects:read:team', treeDxTokenScope(), 'GET', 'treedx.library.show', {}, { interface: 'internal', requestId: '', principal }); return publicLibrary(await store.getProjectTreeDxLibrary(projectId)); @@ -208,13 +264,18 @@ export function createTreeDxProxyOperationService(storeValue: CapacityGovernance const scope = treeDxOperationScope(operation, input, repositoryId(library) ? [repositoryId(library)!] : []); const permission: Permission = descriptor.kind === 'read' ? 'projects:read:team' : 'projects:manage:team'; const access = await authorize(store, projectId, permission, scope, operation.method, operation.path, input.query, context, input.path); + const upstreamInput = descriptor.kind === 'read' && access.actorType === 'capacity_provider' + ? bindCurrentLibraryView(operation, input, library) : input; if (input.path.workspaceId) await verifyTreeDxWorkspace({ runtime, projectId, library, workspaceId: String(input.path.workspaceId) }); const baseUrl = resolveTreeDxProxyBaseUrl(runtime, library); // TreeDX grants upstream authority to the control-plane service identity. The // end user or capacity provider remains the audited actor below, but must not // replace the service principal in the bounded delegation token. + const crossProjectGrant=access.actorType==='capacity_provider'&&String(access.handle?.projectId)!==projectId + ?(Array.isArray(record(access.handle?.metadata).readRepositories)?(record(access.handle?.metadata).readRepositories as unknown[]).map(record):[]).find((grant)=>String(grant.projectId)===projectId):null; const compactScope = access.actorType === 'capacity_provider' ? { ...scope, - paths: treeDxProxyAuthorizedPathPatterns(access.handle, scope.capabilities[0] ?? null).length + refs: [currentLibraryView(library)], + paths: crossProjectGrant&&Array.isArray(crossProjectGrant.allowedPaths)?crossProjectGrant.allowedPaths.map(String):treeDxProxyAuthorizedPathPatterns(access.handle, scope.capabilities[0] ?? null).length ? treeDxProxyAuthorizedPathPatterns(access.handle, scope.capabilities[0] ?? null) : ['**'] } : scope; const token = resolveTreeDxProxyToken(runtime, baseUrl, projectId, compactScope, { connectionId: connectionId(library, baseUrl) }); try { @@ -223,7 +284,7 @@ export function createTreeDxProxyOperationService(storeValue: CapacityGovernance transient: (error) => error instanceof TypeError || error instanceof TreeDxApiError && (error.status === 429 || error.status >= 500), invoke: async () => invokeOfficialTreeDxOperation({ client: createOfficialTreeDxClient({ baseUrl, token, fetchImpl: runtime.fetchImpl, timeoutMs: 15_000 }), operation, - path: input.path, query: input.query, body: input.body, requestId: context.requestId, + path: upstreamInput.path, query: upstreamInput.query, body: upstreamInput.body, requestId: context.requestId, traceparent: context.traceparent, idempotencyKey: context.idempotencyKey, signal: context.signal, }), }); diff --git a/src/api/control-plane/treedx/infrastructure-client.ts b/src/api/control-plane/treedx/infrastructure-client.ts index c69c8c03..e5af6d8e 100644 --- a/src/api/control-plane/treedx/infrastructure-client.ts +++ b/src/api/control-plane/treedx/infrastructure-client.ts @@ -30,17 +30,20 @@ export class TreeDxInfrastructureClient { commit(input: Input) { const { workspaceId, ...body } = input; return this.upstream.files.commit(String(workspaceId), body) as Promise; } getRepository(repoId: string) { return this.upstream.repositories.get(repoId) as Promise; } - push(input: Input) { const { repoId, ...body } = input; return this.upstream.repositories.push(String(repoId), body) as Promise; } - fetchRemote(input: Input) { const { repoId, ...body } = input; return this.upstream.repositories.sync(String(repoId), body) as Promise; } - promoteRef(input: Input) { + async listRepositoryRefs(repoId: string) { const result: any = await this.upstream.repositories.refs(repoId); return result?.refs ?? result; } + async push(input: Input) { const { repoId, ...body } = input; const result: any = await this.upstream.repositories.push(String(repoId), body); return result?.push ?? result; } + async fetchRemote(input: Input) { const { repoId, ...body } = input; const result: any = await this.upstream.repositories.sync(String(repoId), body); return result?.fetch ?? result?.sync ?? result; } + async promoteRef(input: Input) { const { repoId, ...body } = input; const operation = requireTreeDxOperation('promoteRepositoryRef'); - return this.upstream.operation(operation.method, operation.path, { pathParams: { repo_id: repoId }, body }); + const result = await this.upstream.operation(operation.method, operation.path, { pathParams: { repo_id: repoId }, body }); + return result?.promotion ?? result; } - retireRef(input: Input) { + async retireRef(input: Input) { const { repoId, ...body } = input; const operation = requireTreeDxOperation('retireRepositoryRef'); - return this.upstream.operation(operation.method, operation.path, { pathParams: { repo_id: repoId }, body }); + const result = await this.upstream.operation(operation.method, operation.path, { pathParams: { repo_id: repoId }, body }); + return result?.retirement ?? result; } getPlacement(repoId: string) { return this.upstream.registry.getPlacement(repoId) as Promise; } diff --git a/src/api/control-plane/treedx/upstream-operation.ts b/src/api/control-plane/treedx/upstream-operation.ts index a2d21a81..6ddaca2f 100644 --- a/src/api/control-plane/treedx/upstream-operation.ts +++ b/src/api/control-plane/treedx/upstream-operation.ts @@ -5,6 +5,7 @@ type InputRecord = Record; const operations = new Map(TREEDX_OPENAPI_OPERATIONS.map((operation) => [operation.operationId, operation])); const reservedQueryKeys = new Set(['assignmentId', 'treeDxProxyHandleId', 'treeDxProxyToken']); +const contentExtensions = ['.mdx', '.md', '.markdown', '.json', '.yaml', '.yml', '.toml']; function record(value: unknown): InputRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as InputRecord : {}; @@ -45,11 +46,15 @@ export function treeDxOperationScope(operation: TreeDxOpenApiOperation, input: { const value = source[name]; return Array.isArray(value) ? value.map(String) : value === undefined || value === null ? [] : [String(value)]; })); + const requestedPaths = [...new Set(strings(['path', 'paths', 'scopePaths']))]; + const scopedPaths = operation.operationId === 'readRepositoryFile' + ? requestedPaths.flatMap((value) => /\.[^/]+$/u.test(value) ? [value] : [value, ...contentExtensions.map((extension) => `${value}${extension}`)]) + : requestedPaths; return { repoIds: [...new Set([...strings(['repoId', 'repo_id', 'repositoryId', 'repository_id']), ...fallbackRepositoryIds])], capabilities: [...operation.requiredCapabilities], refs: [...new Set(strings(['ref', 'refs', 'baseRef', 'targetRef', 'sourceRef']))], - paths: [...new Set(strings(['path', 'paths', 'scopePaths']))], + paths: [...new Set(scopedPaths)], }; } diff --git a/src/api/discussions/content.ts b/src/api/discussions/content.ts index 7d835148..6b2f6d0d 100644 --- a/src/api/discussions/content.ts +++ b/src/api/discussions/content.ts @@ -43,6 +43,7 @@ export function mentionedAgentSlugs(body: string) { export async function loadDiscussions(input: { store: any; projectId: string; discussionId?: string; query?: string; collection?: 'discussions' | 'messages' | 'events'; limit?: number; after?: string; + exactPaths?: string[]; exactMessageIds?: string[]; includeDiscussion?: boolean; }) { const connection = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: false, communicationPaths: true, @@ -50,10 +51,21 @@ export async function loadDiscussions(input: { if (!connection) throw new Error('The project TreeDX repository is unavailable for Discussion history.'); const discussionRef = `refs/heads/${connection.authoringBranch.replace(/^refs\/heads\//u, '')}`; const selected = input.discussionId ? slug(input.discussionId) : null; + const exactPaths = [...new Set([ + ...(input.exactPaths ?? []).map((path) => text(path)).filter(Boolean), + ...(selected ? (input.exactMessageIds ?? []).map((messageId) => projectLibraryPath(connection.contentPath, + 'discussion-messages', selected, `${text(messageId)}.mdx`)).filter((path) => !path.endsWith('/.mdx')) : []), + ...(selected && input.includeDiscussion === true + ? [projectLibraryPath(connection.contentPath, 'discussions', `${selected}.mdx`)] : []), + ])]; const patterns = selected ? [projectLibraryPath(connection.contentPath, 'discussions', `${selected}.mdx`), projectLibraryPath(connection.contentPath, 'discussion-messages', selected, '**'), projectLibraryPath(connection.contentPath, 'discussion-events', selected, '**')] : [projectLibraryPath(connection.contentPath, 'discussions/**')]; - const listed = await connection.client.listRepositoryPaths({ repoId: connection.repositoryId, ref: discussionRef, paths: patterns, kinds: ['blob'], extensions: ['.md', '.mdx'], limit: 1_000, allowProtected: true }); + // Known messages and discussion records must be read directly. Enumerating a + // repository tree for an idempotency check made every chat send proportional + // to the size of the library and could exhaust the gateway request deadline. + const listed = exactPaths.length ? { entries: exactPaths.map((path) => ({ path })), resolvedRef: discussionRef } + : await connection.client.listRepositoryPaths({ repoId: connection.repositoryId, ref: discussionRef, paths: patterns, kinds: ['blob'], extensions: ['.md', '.mdx'], limit: 1_000, allowProtected: true }); const readableAuthoring = await listReadableTreeDxAuthoringState(input.store, input.projectId); const branchPaths = (listed.entries ?? []).map((entry: unknown) => text((entry as Row)?.path)).filter(Boolean); const journalPaths = readableAuthoring.flatMap((state) => Array.isArray(state.changedPaths) diff --git a/src/api/discussions/discussion-service.ts b/src/api/discussions/discussion-service.ts index 72a53950..a8b65014 100644 --- a/src/api/discussions/discussion-service.ts +++ b/src/api/discussions/discussion-service.ts @@ -137,9 +137,9 @@ export function createDiscussionService(dependencies: { store: any; capacity: an 'The explicit parent assignment does not exist in this team and project.'); const waitingMessageId = text(record(parentAssignment?.metadata_json).waitingMessageId); const continuationHistory = parentAssignment ? await loadDiscussions({ store, projectId, discussionId, - query: waitingMessageId || undefined, collection: 'messages', limit: 10 }) : { messages: [] }; + exactMessageIds: waitingMessageId ? [waitingMessageId] : [], collection: 'messages', limit: 10 }) : { messages: [] }; const continuation = continuationEvidence(parentAssignment, discussionId, continuationHistory.messages); - const history = await loadDiscussions({ store, projectId, discussionId, query: messageId, collection: 'messages' }).catch(() => ({ messages: [] })); + const history = await loadDiscussions({ store, projectId, discussionId, exactMessageIds: [messageId], collection: 'messages' }).catch(() => ({ messages: [] })); const replay = history.messages.find((entry: any) => text(entry.id) === messageId); if (replay) { if (text(replay.body) !== messageBody) throw new DiscussionServiceError(409, 'discussion_idempotency_conflict', @@ -163,7 +163,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an } contextRefs = await validateDiscussionContextRefs({ store, projectId, teamId, values: body.contextRefs }); const existing = text(body.discussionId) ? await loadDiscussions({ store, projectId, discussionId, - collection: 'discussions', limit: 1 }).catch(() => ({ discussions: [] })) : { discussions: [] }; + includeDiscussion: true, collection: 'discussions', limit: 1 }).catch(() => ({ discussions: [] })) : { discussions: [] }; authored = await commitDiscussionMessage({ store, projectId, teamId, principal, body: messageBody, intent: body.intent === 'propose' ? 'propose' : 'discuss', discussionId, messageId, createDiscussion: !text(body.discussionId) || (body.createDiscussion === true && existing.discussions.length === 0), topic: text(record(existing.discussions[0]?.frontmatter).topic) || text(body.topic) || undefined, @@ -173,7 +173,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an replyTo: text(body.replyTo) || undefined, ...(continuation ?? {}) }); } catch (error) { failure(error, 503, 'discussion_content_unavailable'); } const observed = await loadDiscussions({ store, projectId, discussionId: authored.discussion.id, - query: authored.message.id, collection: 'messages' }); + exactPaths: [authored.message.path], collection: 'messages' }); const observedMessage = observed.messages.find((entry: any) => text(entry.path) === authored.message.path); if (!observedMessage || text(observedMessage.body) !== messageBody) throw new DiscussionServiceError(503, 'discussion_readback_failed', 'TreeDX did not authoritatively return the committed Discussion message.'); diff --git a/src/api/knowledge/gateway-treedx-connection.ts b/src/api/knowledge/gateway-treedx-connection.ts index 7029ce28..5dd4189b 100644 --- a/src/api/knowledge/gateway-treedx-connection.ts +++ b/src/api/knowledge/gateway-treedx-connection.ts @@ -30,6 +30,13 @@ export function projectLibraryPath(root: string, ...parts: string[]): string { return [normalizedRoot === '.' ? '' : normalizedRoot, ...normalizedParts].filter(Boolean).join('/'); } +export function canonicalTreeDxBranchRef(value: unknown): string { + const branch = text(value, 'staging') + .replace(/^refs\/heads\//u, '') + .replace(/^refs\/remotes\/origin\//u, ''); + return `refs/heads/${branch}`; +} + export interface KnowledgeGatewayConnection { client: TreeDxInfrastructureClient; baseUrl: string; @@ -40,6 +47,7 @@ export interface KnowledgeGatewayConnection { allowedPaths: string[]; nodeId: string; authoringBranch: string; + publicationRef: string; } export async function resolveKnowledgeGatewayConnection(store: any, input: { @@ -59,9 +67,9 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { const topology = record(library.topology); const contentRepository = record(topology.contentRepository); const treeDx = record(contentRepository.treeDx); - const configuredBaseUrl = text(treeDx.baseUrl, treeDx.registryUrl, store.config.TREESEED_TREEDX_URL, - store.config.TREESEED_TREEDX_BASE_URL, store.config.treedxBaseUrl, - process.env.TREESEED_TREEDX_URL, process.env.TREESEED_TREEDX_BASE_URL) || 'http://127.0.0.1:4000'; + const configuredBaseUrl = text(process.env.TREESEED_TREEDX_URL, process.env.TREESEED_TREEDX_BASE_URL, + store.config.TREESEED_TREEDX_URL, store.config.TREESEED_TREEDX_BASE_URL, store.config.treedxBaseUrl, + treeDx.baseUrl, treeDx.registryUrl) || 'http://127.0.0.1:4000'; const runtimeEnvironment = { ...process.env, ...store.config }; const baseUrl = resolveTreeDxServiceUrl(configuredBaseUrl, runtimeEnvironment); const repositoryId = text(library.repositoryId, treeDx.repositoryId); @@ -78,7 +86,7 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { '.treeseed/agents/**','.treeseed/governance/proposal-types/**','.treeseed/seeds/**','seeds/**','scenes/**', ] : [])]; const authoringBranch = text(contentRepository.authoringBranch, topology.authoringBranch, 'staging'); - const canonicalAuthoringRef = `refs/heads/${authoringBranch.replace(/^refs\/heads\//u, '')}`; + const canonicalAuthoringRef = canonicalTreeDxBranchRef(authoringBranch); const token = treeDxDelegationAuthority().mint({ actorId: text(store.config.TREESEED_TREEDX_PROXY_ACTOR_ID, process.env.TREESEED_TREEDX_PROXY_ACTOR_ID) || 'treeseed-api', tenantId: text(store.config.TREESEED_TREEDX_PROXY_TENANT_ID, process.env.TREESEED_TREEDX_PROXY_TENANT_ID) || 'treeseed-control-plane', @@ -110,5 +118,6 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { allowedPaths, nodeId: text(library.instanceId, treeDx.instanceId), authoringBranch, + publicationRef: canonicalAuthoringRef, }; } diff --git a/src/api/knowledge/runtime/context-query-runtime.ts b/src/api/knowledge/runtime/context-query-runtime.ts index a8d31cea..4ae59d24 100644 --- a/src/api/knowledge/runtime/context-query-runtime.ts +++ b/src/api/knowledge/runtime/context-query-runtime.ts @@ -37,6 +37,8 @@ function unpack(value: unknown) { function resultFacts(value: unknown) { const pack = unpack(value); const nodes = Array.isArray(pack.nodes) ? pack.nodes.map(record) : []; const edges = Array.isArray(pack.edges) ? pack.edges.map(record) : []; + const directSources=Array.isArray(pack.sources)?pack.sources.map(record):[]; + const memberSources=Array.isArray(pack.memberResults)?pack.memberResults.flatMap((member)=>{const value=unpack(member);return Array.isArray(value.sources)?value.sources.map(record):[];}):[]; const serialized = JSON.stringify({ nodes, edges }); const identities = [...new Set(nodes.flatMap((node) => { const data = record(node.data); const frontmatter = record(data.frontmatter); @@ -46,7 +48,8 @@ function resultFacts(value: unknown) { const paths = [...new Set(nodes.map((node) => node.path).filter((entry): entry is string => typeof entry === 'string'))].sort(); const schemaVersions = [...new Set(nodes.map((node) => record(record(node.data).frontmatter).schemaVersion).filter((entry): entry is string => typeof entry === 'string'))].sort(); const reportedTokens = typeof pack.totalTokenEstimate === 'number' ? pack.totalTokenEstimate : null; - return { itemCount: nodes.length, bytes: new TextEncoder().encode(serialized).byteLength, estimatedTokens: reportedTokens ?? Math.ceil(serialized.length / 4), reportedTokens, identities, relations, paths, schemaVersions }; + return { itemCount: nodes.length, bytes: new TextEncoder().encode(serialized).byteLength, estimatedTokens: reportedTokens ?? Math.ceil(serialized.length / 4), reportedTokens, identities, relations, paths, schemaVersions, + sources:[...directSources,...memberSources].map((source)=>({projectId:String(source.projectId??''),source:String(source.source??''),ref:String(source.ref??''),paths:Array.isArray(source.paths)?source.paths.map(String):[]})).filter((source)=>source.projectId) }; } function assertions(test: ContextQueryTestDefinition, stats: ReturnType, latencyMs: number): Assertion[] { diff --git a/src/api/store/installers/teams.ts b/src/api/store/installers/teams.ts index eabdf5fc..ca892465 100644 --- a/src/api/store/installers/teams.ts +++ b/src/api/store/installers/teams.ts @@ -14,6 +14,7 @@ import { getSeedServicePrincipalMembershipMethod,reconcileSeedServicePrincipalMe import { teamPublicNameExistsMethod } from '../teams/contracts/team-public-name-exists.ts'; import { createTeamInviteMethod } from '../teams/creation/create-team-invite.ts'; import { createTeamMethod } from '../teams/creation/create-team.ts'; +import { backfillManagedTeamLibraryProjectsMethod,ensureManagedTeamLibraryProjectMethod } from '../teams/contracts/managed-library/ensure-managed-team-library-project.ts'; import { prepareTeamDeletionMethod } from '../teams/creation/prepare-team-deletion.ts'; import { upsertTeamInboxItemMethod } from '../teams/creation/upsert-team-inbox-item.ts'; import { upsertTeamMemberMethod } from '../teams/creation/upsert-team-member.ts'; @@ -74,6 +75,8 @@ export function installTeamsStoreMethods(prototype: ControlPlaneStore) { prototype.principalCanManageServices = principalCanManageServicesMethod; prototype.authenticateTeamApiKey = authenticateTeamApiKeyMethod; prototype.createTeam = createTeamMethod; + prototype.ensureManagedTeamLibraryProject = ensureManagedTeamLibraryProjectMethod; + prototype.backfillManagedTeamLibraryProjects = backfillManagedTeamLibraryProjectsMethod; prototype.getTeam = getTeamMethod; prototype.getTeamBySlug = getTeamBySlugMethod; prototype.getTeamByName = getTeamByNameMethod; diff --git a/src/api/store/installers/treedx.ts b/src/api/store/installers/treedx.ts index ac3e761f..6f34f0a7 100644 --- a/src/api/store/installers/treedx.ts +++ b/src/api/store/installers/treedx.ts @@ -10,6 +10,9 @@ import { getTeamTreeDxMethod } from '../treedx/repositories/queries/get-team-tre import { listTreeDxDeploymentsMethod } from '../treedx/repositories/queries/list-tree-dx-deployments.ts'; import { listTreeDxMirrorsMethod } from '../treedx/repositories/queries/list-tree-dx-mirrors.ts'; import { listTreeDxSharesMethod } from '../treedx/repositories/queries/list-tree-dx-shares.ts'; +import { getTreeDxShareMethod } from '../treedx/repositories/queries/get-tree-dx-share.ts'; +import { listTreeDxSharesForRecipientMethod } from '../treedx/repositories/queries/list-tree-dx-shares-for-recipient.ts'; +import { revokeTreeDxShareMethod } from '../treedx/repositories/updates/revoke-tree-dx-share.ts'; import { syncTreeDxMirrorMethod } from '../treedx/repositories/updates/sync-tree-dx-mirror.ts'; import { updateTreeDxDeploymentMethod } from '../treedx/repositories/updates/update-tree-dx-deployment.ts'; @@ -24,6 +27,9 @@ export function installTreedxStoreMethods(prototype: ControlPlaneStore) { prototype.syncTreeDxMirror = syncTreeDxMirrorMethod; prototype.listTreeDxShares = listTreeDxSharesMethod; prototype.createTreeDxShare = createTreeDxShareMethod; + prototype.getTreeDxShare = getTreeDxShareMethod; + prototype.listTreeDxSharesForRecipient = listTreeDxSharesForRecipientMethod; + prototype.revokeTreeDxShare = revokeTreeDxShareMethod; prototype.upsertProjectTreeDxLibrary = upsertProjectTreeDxLibraryMethod; prototype.getProjectTreeDxLibrary = getProjectTreeDxLibraryMethod; prototype.ensureHubContentSourceTreeDx = ensureHubContentSourceTreeDxMethod; diff --git a/src/api/store/interface.ts b/src/api/store/interface.ts index f7dc7213..14b4efe0 100644 --- a/src/api/store/interface.ts +++ b/src/api/store/interface.ts @@ -32,6 +32,8 @@ declare module '../persistence/store.ts' { principalCanManageTeam: OmitThisParameter; authenticateTeamApiKey: OmitThisParameter; createTeam: OmitThisParameter; + ensureManagedTeamLibraryProject: OmitThisParameter; + backfillManagedTeamLibraryProjects: OmitThisParameter; getTeam: OmitThisParameter; getTeamBySlug: OmitThisParameter; getTeamByName: OmitThisParameter; @@ -52,6 +54,9 @@ declare module '../persistence/store.ts' { syncTreeDxMirror: OmitThisParameter; listTreeDxShares: OmitThisParameter; createTreeDxShare: OmitThisParameter; + getTreeDxShare: OmitThisParameter; + listTreeDxSharesForRecipient: OmitThisParameter; + revokeTreeDxShare: OmitThisParameter; upsertProjectTreeDxLibrary: OmitThisParameter; getProjectTreeDxLibrary: OmitThisParameter; getProjectRepositoryTopology: OmitThisParameter; diff --git a/src/api/store/operations/lifecycle/claims/claim-platform-operation.ts b/src/api/store/operations/lifecycle/claims/claim-platform-operation.ts index f587a88c..56974014 100644 --- a/src/api/store/operations/lifecycle/claims/claim-platform-operation.ts +++ b/src/api/store/operations/lifecycle/claims/claim-platform-operation.ts @@ -17,14 +17,16 @@ export async function claimPlatformOperationMethod(this: ControlPlaneStore, inpu OR (status IN ('leased', 'running') AND lease_expires_at IS NOT NULL AND lease_expires_at < ?) ) ${capabilityWhere} - ORDER BY created_at ASC LIMIT ?`, [input.operationId, now, ...capabilities, limit]) + ORDER BY CASE namespace WHEN 'knowledge' THEN 0 WHEN 'feedback' THEN 1 ELSE 2 END, + created_at ASC LIMIT ?`, [input.operationId, now, ...capabilities, limit]) : await this.all(`SELECT * FROM platform_operations WHERE ( status = 'queued' OR (status IN ('leased', 'running') AND lease_expires_at IS NOT NULL AND lease_expires_at < ?) ) ${capabilityWhere} - ORDER BY created_at ASC LIMIT ?`, [now, ...capabilities, limit]); + ORDER BY CASE namespace WHEN 'knowledge' THEN 0 WHEN 'feedback' THEN 1 ELSE 2 END, + created_at ASC LIMIT ?`, [now, ...capabilities, limit]); const row = rows[0]; if (!row) return null; diff --git a/src/api/store/teams/contracts/evaluate-team-deletion-blockers.ts b/src/api/store/teams/contracts/evaluate-team-deletion-blockers.ts index eb792597..ad0acd6d 100644 --- a/src/api/store/teams/contracts/evaluate-team-deletion-blockers.ts +++ b/src/api/store/teams/contracts/evaluate-team-deletion-blockers.ts @@ -33,7 +33,8 @@ export async function evaluateTeamDeletionBlockersMethod(this: ControlPlaneStore const deletedProjectIds = new Set(projectRows .filter((row) => parseJson(row.metadata_json, {})?.deletion?.status === 'succeeded') .map((row) => row.id)); - const projects = projectRows.filter((row) => !deletedProjectIds.has(row.id)); + const projects = projectRows.filter((row) => !deletedProjectIds.has(row.id) + && parseJson(row.metadata_json, {})?.kind !== 'system-team-library'); return [ ...projects.map((row) => ({ code: 'project', id: row.id, label: row.name, href: `/app/projects/${row.id}/settings` })), ...services.map((row) => ({ code: 'service_connection', id: row.id, label: row.display_name, href: '/app/services' })), diff --git a/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts b/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts new file mode 100644 index 00000000..84d7fe0a --- /dev/null +++ b/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts @@ -0,0 +1,27 @@ +import { ControlPlaneStore,isoNow,parseJson } from '../../../../persistence/store.ts'; + +export const MANAGED_TEAM_PROJECT_SLUG='team'; +export const MANAGED_TEAM_PROJECT_KIND='system-team-library'; + +export async function ensureManagedTeamLibraryProjectMethod(this:ControlPlaneStore,teamId:string){ + await this.ensureInitialized(); + const existing=await this.getProjectByTeamAndSlug(teamId,MANAGED_TEAM_PROJECT_SLUG); + if(existing){ + if(existing.metadata?.kind!==MANAGED_TEAM_PROJECT_KIND||existing.metadata?.systemManaged!==true)throw new Error('The reserved team project slug is occupied by a user-managed project.'); + return existing; + } + const details=await this.createProject(teamId,{slug:MANAGED_TEAM_PROJECT_SLUG,name:'Team Library',description:'System-managed shared knowledge for this team.',metadata:{kind:MANAGED_TEAM_PROJECT_KIND,systemManaged:true, + libraryOnly:true,library:{repositoryName:'team-library',defaultBranch:'main',integrationBranch:'staging',status:'provisioning'}, + inventory:{status:'active'},provisioning:{state:'pending',requiredFiles:['README.md','objectives/core']}}}); + const project=details?.project??details,teamRow=await this.first('SELECT metadata_json FROM teams WHERE id = ? LIMIT 1',[teamId]); + const metadata=parseJson(teamRow?.metadata_json,{});metadata.teamLibrary={projectId:project.id,projectSlug:MANAGED_TEAM_PROJECT_SLUG,state:'provisioning'}; + await this.run('UPDATE teams SET metadata_json = ?, updated_at = ? WHERE id = ?',[JSON.stringify(metadata),isoNow(),teamId]); + return project; +} + +export async function backfillManagedTeamLibraryProjectsMethod(this:ControlPlaneStore){ + await this.ensureInitialized(); + const teams=await this.all('SELECT id FROM teams ORDER BY created_at ASC'); + const results=[];for(const team of teams)results.push(await this.ensureManagedTeamLibraryProject(String(team.id))); + return results; +} diff --git a/src/api/store/teams/creation/create-team.ts b/src/api/store/teams/creation/create-team.ts index 59e085d5..63eb61fc 100644 --- a/src/api/store/teams/creation/create-team.ts +++ b/src/api/store/teams/creation/create-team.ts @@ -36,5 +36,6 @@ export async function createTeamMethod(this: ControlPlaneStore, input) { if (input.ownerUserId) { await this.upsertTeamMember(id, input.ownerUserId, 'team_owner'); } + await this.ensureManagedTeamLibraryProject(id); return this.getTeam(id); } diff --git a/src/api/store/treedx/repositories/queries/get-tree-dx-share.ts b/src/api/store/treedx/repositories/queries/get-tree-dx-share.ts new file mode 100644 index 00000000..17fca482 --- /dev/null +++ b/src/api/store/treedx/repositories/queries/get-tree-dx-share.ts @@ -0,0 +1,6 @@ +import { ControlPlaneStore,serializeTreeDxShare } from '../../../../persistence/store.ts'; + +export async function getTreeDxShareMethod(this: ControlPlaneStore, shareId: string) { + await this.ensureInitialized(); + return serializeTreeDxShare(await this.first('SELECT * FROM treedx_shares WHERE id = ? LIMIT 1', [shareId])); +} diff --git a/src/api/store/treedx/repositories/queries/list-tree-dx-shares-for-recipient.ts b/src/api/store/treedx/repositories/queries/list-tree-dx-shares-for-recipient.ts new file mode 100644 index 00000000..652e28af --- /dev/null +++ b/src/api/store/treedx/repositories/queries/list-tree-dx-shares-for-recipient.ts @@ -0,0 +1,9 @@ +import { ControlPlaneStore,serializeTreeDxShare } from '../../../../persistence/store.ts'; + +export async function listTreeDxSharesForRecipientMethod(this: ControlPlaneStore, targetTeamId: string) { + await this.ensureInitialized(); + const now=new Date().toISOString(); + const rows=await this.all(`SELECT * FROM treedx_shares WHERE target_team_id = ? AND status = 'active' + AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC`,[targetTeamId,now]); + return rows.map(serializeTreeDxShare).filter(Boolean); +} diff --git a/src/api/store/treedx/repositories/updates/revoke-tree-dx-share.ts b/src/api/store/treedx/repositories/updates/revoke-tree-dx-share.ts new file mode 100644 index 00000000..24e9fd34 --- /dev/null +++ b/src/api/store/treedx/repositories/updates/revoke-tree-dx-share.ts @@ -0,0 +1,9 @@ +import { isoNow,ControlPlaneStore,serializeTreeDxShare } from '../../../../persistence/store.ts'; + +export async function revokeTreeDxShareMethod(this: ControlPlaneStore, teamId: string, shareId: string) { + await this.ensureInitialized(); + const timestamp=isoNow(); + await this.run(`UPDATE treedx_shares SET status = 'revoked', revoked_at = ?, updated_at = ? + WHERE id = ? AND team_id = ? AND status = 'active'`,[timestamp,timestamp,shareId,teamId]); + return serializeTreeDxShare(await this.first('SELECT * FROM treedx_shares WHERE id = ? AND team_id = ? LIMIT 1',[shareId,teamId])); +} diff --git a/src/api/support/app.ts b/src/api/support/app.ts index 41814d67..8dca2663 100644 --- a/src/api/support/app.ts +++ b/src/api/support/app.ts @@ -55,6 +55,7 @@ import { } from '../app/support/index.ts'; import { createControlPlanePostgresDatabase } from './control-plane-postgres.js'; import { listUserEmailAddresses, sendTeamInviteEmail } from '../app/support/accounts/authentication-email.ts'; +import { deleteManagedTeamLibraryResources,reconcileManagedTeamLibrary } from '../teams/managed-team-library-service.ts'; export * from '../app/support/index.ts'; @@ -246,6 +247,8 @@ export function createPlatformApiApp(options: any = {}) { githubWebhook: createGitHubWebhookService(store), services: createServiceConnectionService(store), deliverTeamInvite: (input) => sendTeamInviteEmail(invitationContext, input), + reconcileManagedTeamLibrary: (teamId) => reconcileManagedTeamLibrary(store,teamId,process.env), + deleteManagedTeamLibraryResources: (input) => deleteManagedTeamLibraryResources({...input,env:process.env,fetchImpl:options.fetchImpl??fetch}), listUserEmailAddresses: (userId) => listUserEmailAddresses(store, userId), accountEmails: createAccountEmailService(store, invitationContext), accountRegistration, diff --git a/src/api/support/server.ts b/src/api/support/server.ts index 410226cb..9e17260d 100644 --- a/src/api/support/server.ts +++ b/src/api/support/server.ts @@ -7,6 +7,8 @@ import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { createPlatformApiApp } from './app.js'; import { createControlPlanePostgresDatabase } from './control-plane-postgres.js'; +import { ensureControlPlaneCredentialSchema } from '../app/support/runtime/foundation-runtime-utilities.ts'; +import { ControlPlaneStore } from '../persistence/store.js'; function hasRequestBody(method) { return method !== 'GET' && method !== 'HEAD'; @@ -61,10 +63,18 @@ export async function createApiServer(options: any = {}): Promise { void honoNodeHandler(app, req, res).catch((error) => { @@ -78,6 +88,12 @@ export async function createApiServer(options: any = {}): Promise((resolvePromise) => { server.listen(config.port, config.host, () => resolvePromise()); }); + try { + await ensureControlPlaneCredentialSchema(store); + } catch (error) { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + throw error; + } return { app, diff --git a/src/api/teams/managed-team-library-service.ts b/src/api/teams/managed-team-library-service.ts new file mode 100644 index 00000000..6067ee26 --- /dev/null +++ b/src/api/teams/managed-team-library-service.ts @@ -0,0 +1,114 @@ +import { ensureProjectKnowledgeBinding } from '../../control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts'; +import { reconcileLibraryProvider } from '../../control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts'; +import { createR2PublicationClient } from '../providers/cloudflare/r2-publication-client.ts'; +import { enqueueTreeDxCommitReplication } from '../capacity/services/treedx/repositories/treedx-commit-replication.ts'; + +const text=(...values:unknown[])=>values.find((value)=>typeof value==='string'&&value.trim())?.toString().trim()??''; +const record=(value:unknown):Record=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; + +const seedFiles:Record={ + 'README.md':'# Team Library\n\nSystem-managed, team-wide knowledge for communication, governance, research, management delegation, and cross-project coordination.\n', + 'objectives/core.mdx':'---\nid: team-core\ntitle: Team Core Objective\nstatus: active\ngroup_ids: []\n---\n\nBuild a coherent engineering team whose projects share trustworthy knowledge, coordinate explicitly, and preserve project-scoped authority.\n', + 'knowledge/communication-standards.mdx':'---\nid: team-communication-standards\ntitle: Team Communication Standards\nstatus: active\n---\n\nCommunicate decisions, uncertainty, evidence, owners, and next actions clearly. Use cross-project discussions for coordination without weakening project-scoped writes.\n', + 'knowledge/governance.mdx':'---\nid: team-governance\ntitle: Team Governance\nstatus: active\n---\n\nQuestions request clarification. Proposals request governed change. Agents must not represent a proposal as approved without the corresponding governance action.\n', + 'knowledge/research-and-citation.mdx':'---\nid: team-research-citation\ntitle: Research and Citation\nstatus: active\n---\n\nDistinguish evidence from inference, cite authoritative sources, report uncertainty, and preserve enough provenance for another contributor to verify the conclusion.\n', + 'knowledge/management-delegation.mdx':'---\nid: team-management-delegation\ntitle: Management Delegation\nstatus: active\n---\n\nTranslate direction into explicit outcomes, constraints, owners, dependencies, verification, and escalation conditions. Never broaden authority implicitly.\n', + 'knowledge/cross-project-coordination.mdx':'---\nid: team-cross-project-coordination\ntitle: Cross-project Coordination\nstatus: active\n---\n\nRead across authorized team projects to understand consequences. Keep every write and commit bound to the assignment owning project.\n', + 'agent-context-queries/team-shared-foundations.mdx':'---\nid: team-shared-foundations\ntitle: Team Shared Foundations\ndescription: Retrieve team communication, governance, research, delegation, and cross-project coordination guidance.\nrevision: 1\nmaturity: validated\npurpose: research\nquery: communication governance research citation management delegation cross-project coordination\ntarget:\n kind: content\n paths: [knowledge/**]\nrelations: [related, references]\ndepth: 1\nresultLimit: 20\ncontextBudget:\n maxItems: 20\n maxCharacters: 24000\ntokenBudget: 6000\nformat: summary\nsources:\n - scope: current-project\nrequirement: preferred\npriority: 80\nsummarization: deterministic\nfilters: {}\n---\n\nTeam-wide foundations used by project agent context queries.\n', + 'agent-tests/team-shared-foundations.mdx':'---\nid: team-shared-foundations-test\nagent: system-team-library\nkind: context-query\nqueryRef:\n id: team-shared-foundations\n revision: 1\ntestRef: team-shared-foundations-test-v1\nexpectedIdentities: []\nexpectedRelations: []\nexpectedPaths: []\nexpectedSchemaVersions: []\nresultBounds:\n min: 0\n max: 20\nbudget:\n maxContextItems: 20\n maxTokens: 6000\nmaxLatencyMs: 10000\n---\n\nVerifies that the managed Team Library query compiles and executes within its declared bounds.\n', +}; + +export async function reconcileManagedTeamLibrary(store:any,teamId:string,env:NodeJS.ProcessEnv=process.env) { + const team=await store.getTeam(teamId),project=await store.ensureManagedTeamLibraryProject(teamId); + if(!team||!project)throw new Error('Managed Team Library identity is unavailable.'); + const metadata=record(team.metadata),configuredOwner=text(env.TREESEED_GITHUB_LIBRARY_OWNER,env.TREESEED_GITHUB_OWNER,metadata.githubOwner,metadata.repositoryOwner); + let owner=configuredOwner; + if(!owner) { + const binding=await store.first(`SELECT binding.owner FROM project_remote_repository_bindings binding + JOIN projects project ON project.id = binding.project_id + WHERE project.team_id = ? AND binding.owner IS NOT NULL AND binding.owner <> '' + ORDER BY binding.updated_at DESC LIMIT 1`,[teamId]); + owner=text(binding?.owner); + } + if(!owner) { + const projects=await store.listTeamProjects(teamId); + for(const candidate of projects)for(const repository of await store.listHubRepositories(String(candidate.id)))if(repository.owner){owner=String(repository.owner);break;} + } + if(!owner)throw new Error('A GitHub library owner must be configured before the managed Team Library can be provisioned.'); + const provider=await reconcileLibraryProvider({store,teamId,projectId:String(project.id),projectSlug:'team',owner,name:'team-library',visibility:'private',lifecycle:'create-or-adopt',env,fetchImpl:store.config?.fetchImpl,seedFiles}); + const binding=await ensureProjectKnowledgeBinding({store,projectId:String(project.id),teamId,projectSlug:'team',libraryRoot:'.',libraryRef:'refs/remotes/origin/staging',libraryRepositoryUrl:`https://github.com/${owner}/team-library.git`,libraryDefaultBranch:'main',libraryCredentialId:provider.credentialId,expectedUpstreamHeads:provider.heads,env}); + const now=new Date().toISOString(); + await enqueueTreeDxCommitReplication(store,{teamId,projectId:String(project.id),commitSha:binding.resolvedRef,sourceRef:binding.sourceRef,createdAt:now}); + const replication=await store.first(`SELECT status,r2_status,r2_receipt_json FROM treedx_commit_replications + WHERE project_id=? AND commit_sha=? LIMIT 1`,[project.id,binding.resolvedRef]); + const r2Receipt=record(typeof replication?.r2_receipt_json==='string'?JSON.parse(replication.r2_receipt_json):replication?.r2_receipt_json); + const mirrorReady=replication?.status==='complete'&&replication?.r2_status==='verified' + &&r2Receipt.schemaVersion==='treeseed.treedx-r2-file-mirror/v2'&&r2Receipt.commitSha===binding.resolvedRef; + const provisioning=mirrorReady?{state:'known-good',completedAt:now,requiredFiles:['README.md','objectives/core']} + :{state:'replicating',updatedAt:now,requiredFiles:['README.md','objectives/core']}; + const projectMetadata={...record(project.metadata),library:{...record(record(project.metadata).library),status:mirrorReady?'known-good':'replicating',owner,repositoryName:'team-library',heads:provider.heads},provisioning}; + await store.run('UPDATE projects SET metadata_json = ?, updated_at = ? WHERE id = ?',[JSON.stringify(projectMetadata),now,project.id]); + const teamMetadata={...metadata,teamLibrary:{projectId:project.id,projectSlug:'team',state:mirrorReady?'known-good':'replicating',repository:`${owner}/team-library`,repositoryId:binding.repositoryId}}; + await store.run('UPDATE teams SET metadata_json = ?, updated_at = ? WHERE id = ?',[JSON.stringify(teamMetadata),now,teamId]); + return {teamId,projectId:project.id,repository:`${owner}/team-library`,state:mirrorReady?'known-good':'replicating',...binding}; +} + +export async function markManagedTeamLibraryMirrorKnownGood(store:any,input:{teamId:string;projectId:string;commitSha:string;r2Receipt:unknown}) { + const project=await store.getProject(input.projectId);if(record(project?.metadata).kind!=='system-team-library')return false; + const library=await store.getProjectTreeDxLibrary(input.projectId),metadata=record(library?.metadata),receipt=record(input.r2Receipt); + if(text(metadata.resolvedRef)!==input.commitSha||receipt.schemaVersion!=='treeseed.treedx-r2-file-mirror/v2'||receipt.commitSha!==input.commitSha)return false; + const now=new Date().toISOString(),projectMetadata={...record(project.metadata),library:{...record(record(project.metadata).library),status:'known-good'}, + provisioning:{state:'known-good',completedAt:now,requiredFiles:['README.md','objectives/core']}}; + await store.run('UPDATE projects SET metadata_json=?,updated_at=? WHERE id=?',[JSON.stringify(projectMetadata),now,input.projectId]); + const team=await store.getTeam(input.teamId),teamMetadata={...record(team?.metadata),teamLibrary:{...record(record(team?.metadata).teamLibrary),state:'known-good'}}; + await store.run('UPDATE teams SET metadata_json=?,updated_at=? WHERE id=?',[JSON.stringify(teamMetadata),now,input.teamId]);return true; +} + +export async function reconcileManagedTeamLibraries(store:any,env:NodeJS.ProcessEnv=process.env) { + const projects=await store.backfillManagedTeamLibraryProjects(),results=[]; + for(const project of projects) { + const teamId=String(project.teamId??project.team_id); + try { results.push(await reconcileManagedTeamLibrary(store,teamId,env)); } + catch(error) { + const message=error instanceof Error?error.message:String(error); + await store.run(`UPDATE projects SET metadata_json = jsonb_set(COALESCE(metadata_json::jsonb,'{}'::jsonb), + '{provisioning}', jsonb_build_object('state','blocked','message',?::text,'updatedAt',?::text), true)::text, + updated_at = ? WHERE id = ?`,[message,new Date().toISOString(),new Date().toISOString(),project.id]).catch(()=>undefined); + results.push({teamId,projectId:project.id,state:'blocked',error:message}); + } + } + return results; +} + +async function deleteR2Prefix(client:ReturnType,prefix:string) { + const keys=await client.list(prefix); + for(let offset=0;offsetclient.delete(key))); + return keys.length; +} + +/** Permanently removes the externally owned resources of the system Team Library. */ +export async function deleteManagedTeamLibraryResources(input:{teamId:string;project:Record;env?:NodeJS.ProcessEnv;fetchImpl?:typeof fetch}) { + if(input.project.metadata?.kind!=='system-team-library'||input.project.metadata?.systemManaged!==true) + throw new Error('Managed Team Library cleanup requires the protected system project.'); + const env=input.env??process.env,fetchImpl=input.fetchImpl??fetch,library=record(input.project.metadata?.library); + const owner=text(library.owner),name=text(library.repositoryName,'team-library'),token=text(env.TREESEED_GITHUB_TOKEN); + if(!owner||name!=='team-library'||!token)throw new Error('Managed Team Library GitHub deletion authority is unavailable.'); + const response=await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,{ + method:'DELETE',headers:{accept:'application/vnd.github+json',authorization:`Bearer ${token}`,'user-agent':'treeseed-team-library-deleter','x-github-api-version':'2022-11-28'}, + }); + if(!response.ok&&response.status!==404)throw new Error(`GitHub Team Library deletion failed (HTTP ${response.status}).`); + const common={accountId:text(env.TREESEED_CLOUDFLARE_ACCOUNT_ID),bucket:text(env.TREESEED_CONTENT_BUCKET_NAME)}; + const apiToken=text(env.TREESEED_CLOUDFLARE_API_TOKEN); + const r2=apiToken?createR2PublicationClient({...common,authMode:'api-token',apiToken},fetchImpl) + :createR2PublicationClient({...common,authMode:'s3',accessKeyId:text(env.TREESEED_R2_ACCESS_KEY_ID),secretAccessKey:text(env.TREESEED_R2_SECRET_ACCESS_KEY)},fetchImpl); + if(!common.accountId||!common.bucket||(!apiToken&&(!text(env.TREESEED_R2_ACCESS_KEY_ID)||!text(env.TREESEED_R2_SECRET_ACCESS_KEY)))) + throw new Error('Managed Team Library R2 deletion authority is unavailable.'); + const teamSegment=encodeURIComponent(input.teamId); + const [contentObjects,manifestObjects]=await Promise.all([ + deleteR2Prefix(r2,`teams/${teamSegment}/`),deleteR2Prefix(r2,`_treeseed/mirrors/teams/${teamSegment}/`), + ]); + return {schemaVersion:'treeseed.team-library-deletion-receipt/v1',teamId:input.teamId,projectId:String(input.project.id), + github:{repository:`${owner}/${name}`,deleted:true,alreadyAbsent:response.status===404}, + r2:{bucket:common.bucket,prefixes:[`teams/${teamSegment}/`,`_treeseed/mirrors/teams/${teamSegment}/`],deletedObjects:contentObjects+manifestObjects}, + completedAt:new Date().toISOString()}; +} diff --git a/src/control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts b/src/control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts index 7578ae3d..c14ced7b 100644 --- a/src/control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts +++ b/src/control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts @@ -25,6 +25,17 @@ async function repositoryHead(input: { fetchImpl: typeof fetch; token?: string; return head; } +async function seedRepositoryFiles(input:{fetchImpl:typeof fetch;token:string;owner:string;name:string;branch:string;files:Record}) { + for(const [path,content] of Object.entries(input.files).sort(([left],[right])=>left.localeCompare(right))) { + const endpoint=`/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.name)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`; + const existing=await github({...input,path:`${endpoint}?ref=${encodeURIComponent(input.branch)}`}); + const decoded=typeof existing?.content==='string'?Buffer.from(existing.content.replace(/\s/gu,''),'base64').toString('utf8'):''; + const generatedReadme=path==='README.md'&&decoded.trim().toLowerCase()===`# ${input.name}`.toLowerCase(); + if(existing&&!generatedReadme)continue; + await github({...input,path:endpoint,method:'PUT',body:{message:`Seed managed Team Library ${path}`,content:Buffer.from(content,'utf8').toString('base64'),branch:input.branch,...(existing?.sha?{sha:existing.sha}:{})}}); + } +} + async function ensureEnvironmentAuthority(input: { store: any; teamId: string; projectId: string; owner: string; name: string; repository: Record; heads: Record }) { const now = new Date().toISOString(); const connectionId = identifier('service-connection', `${input.teamId}:github:seed-library`); @@ -64,7 +75,7 @@ async function ensureEnvironmentAuthority(input: { store: any; teamId: string; p export async function reconcileLibraryProvider(input: { store: any; teamId: string; projectId: string; projectSlug: string; owner: string; name: string; visibility: 'public'|'private'; - lifecycle: 'create-or-adopt'|'adopt-only'; env: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; + lifecycle: 'create-or-adopt'|'adopt-only'; env: NodeJS.ProcessEnv; fetchImpl?: typeof fetch;seedFiles?:Record; }) { const fetchImpl = input.fetchImpl ?? fetch; const token = String(input.env.TREESEED_GITHUB_TOKEN ?? '').trim() || undefined; let repository = await github({ fetchImpl, token, path: `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.name)}` }); @@ -85,7 +96,7 @@ export async function reconcileLibraryProvider(input: { } const observedVisibility = repository.private === true ? 'private' : 'public'; if (observedVisibility !== input.visibility) throw new Error(`GitHub library ${input.owner}/${input.name} visibility is ${observedVisibility}, expected ${input.visibility}.`); - const main = await repositoryHead({ fetchImpl, token, owner:input.owner,name:input.name,branch:'main' }); + let main = await repositoryHead({ fetchImpl, token, owner:input.owner,name:input.name,branch:'main' }); let staging = await repositoryHead({ fetchImpl, token, owner:input.owner,name:input.name,branch:'staging', optional:true }); if (!staging) { if (!token || input.lifecycle !== 'create-or-adopt') throw new Error(`GitHub library ${input.owner}/${input.name} is missing its required staging branch.`); @@ -93,6 +104,13 @@ export async function reconcileLibraryProvider(input: { method:'POST',body:{ref:'refs/heads/staging',sha:main} }); staging = await repositoryHead({ fetchImpl, token, owner:input.owner,name:input.name,branch:'staging' }); } + if(input.seedFiles&&Object.keys(input.seedFiles).length) { + if(!token)throw new Error(`TREESEED_GITHUB_TOKEN is required to seed managed library ${input.owner}/${input.name}.`); + await seedRepositoryFiles({fetchImpl,token,owner:input.owner,name:input.name,branch:'main',files:input.seedFiles}); + await seedRepositoryFiles({fetchImpl,token,owner:input.owner,name:input.name,branch:'staging',files:input.seedFiles}); + main=await repositoryHead({fetchImpl,token,owner:input.owner,name:input.name,branch:'main'}); + staging=await repositoryHead({fetchImpl,token,owner:input.owner,name:input.name,branch:'staging'}); + } const heads = { main, staging }; if (!token) return { heads, credentialId: undefined }; const authority = await ensureEnvironmentAuthority({ ...input, repository, heads }); diff --git a/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts b/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts index d08c324f..acda8727 100644 --- a/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts +++ b/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts @@ -4,6 +4,7 @@ import { treeDxDelegationAuthority } from '../../../../../api/control-plane/tree import { parseFrontmatterDocument } from '../../../../../api/content/frontmatter.ts'; import { repositoryDefinitionSource, validateAgentDefinitionSource } from '../../../../../api/control-plane/repositories/agents/agent-definition-source.ts'; import { resolveTreeDxServiceUrl } from '../../../../../api/control-plane/treedx/connection-url.ts'; +import { ContextQueryCheckService } from '../../../../../api/capacity/services/capacity/agents/context-query-check-service.ts'; function text(...values: unknown[]): string { for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); @@ -79,21 +80,19 @@ function strings(value: unknown): string[] { async function reconcileProjectAgentClasses(input: { store: any; client: TreeDxClient; repositoryId: string; projectId: string; teamId: string; projectSlug: string; ref: string; + paths?:string[];discoveredRef?:string; }) { - const listed = queryResult(await input.client.query.listPaths(input.repositoryId, { - ref: input.ref, paths: ['agents/**'], extensions: ['.md', '.mdx', '.yaml', '.yml'], kinds: ['blob'], limit: 500, allowProtected: true, - })); - const paths = resultItems(listed).map((entry) => text(object(entry).path, entry)).filter(Boolean).sort(); + const paths=input.paths??[]; if (input.projectSlug === 'sdk' && paths.length !== 8) { throw new Error(`SDK library reconciliation requires exactly eight agent definitions; TreeDX returned ${paths.length}.`); } - if (!paths.length) return { count: 0, immutableRef: text(listed.resolvedRef) }; + if (!paths.length) return { count: 0, immutableRef: text(input.discoveredRef,input.ref) }; const read = queryResult(await input.client.query.readFile(input.repositoryId, { - ref: text(listed.resolvedRef, input.ref), paths, encoding: 'utf8', parseFrontmatter: true, allowProtected: true, + ref: text(input.discoveredRef, input.ref), paths, encoding: 'utf8', parseFrontmatter: true, allowProtected: true, })); const files = resultItems(read); if (files.length !== paths.length) throw new Error('TreeDX did not read back every discovered agent definition.'); - const immutableRef = text(read.resolvedRef, listed.resolvedRef); + const immutableRef = text(read.resolvedRef,input.discoveredRef); if (!/^[a-f0-9]{40}$/u.test(immutableRef)) throw new Error('TreeDX agent definitions did not resolve to an immutable commit.'); const definitions = files.map((file) => { const row = object(file); const path = text(row.path); const source = repositoryDefinitionSource(row); @@ -138,6 +137,21 @@ async function reconcileProjectAgentClasses(input: { return { count: definitions.length, classes: groups.size, immutableRef }; } +async function verifyContextQueryCatalog(input:{store:any;projectId:string;teamId:string;ref:string}) { + const checks=new ContextQueryCheckService(input.store),catalog=await checks.catalog(input.projectId,input.ref); + const referenced=new Map(catalog.agentReferences.map((entry:any)=>[`${entry.kind}:${entry.id}@${entry.revision}`,{kind:entry.kind,id:entry.id,revision:entry.revision}])); + const relevantTests=catalog.tests.filter((test:any)=>referenced.has(`${test.definitionKind}:${test.definitionId}@${test.definitionRevision}`)); + const tested=new Set(relevantTests.map((test:any)=>`${test.definitionKind}:${test.definitionId}@${test.definitionRevision}`)); + const missing=[...referenced.entries()].filter(([key])=>!tested.has(key)).map(([,reference])=>reference); + if(missing.length)throw new Error(`Agent context references have no isolated tests: ${missing.map((item:any)=>`${item.kind}:${item.id}@${item.revision}`).join(', ')}.`); + for(const test of relevantTests) { + const result=await checks.check(input.teamId,input.projectId,{testId:test.id,idempotencyKey:`library-reconcile:${input.projectId}:${input.ref}:${test.id}`}); + if(result.status!=='passing')throw new Error(`Context-query test ${test.id} did not pass for ${input.ref}.`); + } + if(referenced.size)await checks.requirePassing(input.teamId,input.projectId,input.ref,[...referenced.values()] as any); + return {references:referenced.size,tests:relevantTests.length}; +} + function repositoryCatalog(response: unknown): TreeDxRepositorySummary[] { if (!response || typeof response !== 'object' || !Array.isArray((response as { repos?: unknown }).repos)) { throw new Error('TreeDX repository catalog response is invalid.'); @@ -250,7 +264,12 @@ export async function ensureProjectKnowledgeBinding(input: { metadata: { repositoryName, libraryRoot: input.libraryRoot ?? '.', upstreamBacked: true, upstreamHeads, resolvedRef: text(listing.resolvedRef), searchIndex: { ready: true, segmentCount: index.segmentCount }, reconciledLocalRuntime: true }, }); + const discoveredAgents=queryResult(await client.query.listPaths(repository.repoId,{ref:requestedRef,paths:['agents/**'],extensions:['.md','.mdx','.yaml','.yml'],kinds:['blob'],limit:500,allowProtected:true})); + const agentPaths=resultItems(discoveredAgents).map((entry)=>text(object(entry).path,entry)).filter(Boolean).sort(); + const contextQueries=agentPaths.length?await verifyContextQueryCatalog({store:input.store,projectId:input.projectId,teamId:input.teamId,ref:text(listing.resolvedRef)}):{references:0,tests:0}; const agents = await reconcileProjectAgentClasses({ store: input.store, client, repositoryId: repository.repoId, - projectId: input.projectId, teamId: input.teamId, projectSlug: input.projectSlug, ref: requestedRef }); - return { kind: 'projectKnowledgeBinding', projectId: input.projectId, repositoryId: repository.repoId, agents }; + projectId: input.projectId, teamId: input.teamId, projectSlug: input.projectSlug, ref: requestedRef, + paths:agentPaths,discoveredRef:text(discoveredAgents.resolvedRef,listing.resolvedRef) }); + return { kind: 'projectKnowledgeBinding', projectId: input.projectId, repositoryId: repository.repoId, + resolvedRef: text(listing.resolvedRef), sourceRef: requestedRef, contextQueries, agents }; } diff --git a/src/operations-runner/knowledge/publication-executor.ts b/src/operations-runner/knowledge/publication-executor.ts index d3227b60..a6f1292a 100644 --- a/src/operations-runner/knowledge/publication-executor.ts +++ b/src/operations-runner/knowledge/publication-executor.ts @@ -19,6 +19,12 @@ export function isManagedLocalPublication(repository: { storageKind?: unknown; r && (repository.remoteUrl === null || repository.remoteUrl === undefined || repository.remoteUrl === ''); } +export function treeDxPrimaryNodeId(value: unknown) { + if (!value || typeof value !== 'object') return ''; + const result = value as Record; + return String(result.primaryNodeId ?? result.placement?.primaryNodeId ?? ''); +} + export function knowledgeRunnerEnvironment(options: any) { return options.environment ?? options.config?.environment ?? process.env.TREESEED_PLATFORM_RUNNER_ENVIRONMENT; @@ -70,7 +76,7 @@ export function createKnowledgePublicationExecutor(options: any) { async run(input: any, context: any) { const store = options.controlPlaneStore; if (!store) throw new Error('Knowledge publication requires a control-plane store.'); - const publication = await store.first('SELECT * FROM knowledge_publications WHERE id = ?', [String(input?.publicationId ?? '')]); + let publication = await store.first('SELECT * FROM knowledge_publications WHERE id = ?', [String(input?.publicationId ?? '')]); if (!publication || !['queued', 'completed'].includes(publication.status)) { throw new Error('Recoverable knowledge publication was not found.'); } @@ -105,13 +111,19 @@ export function createKnowledgePublicationExecutor(options: any) { throw new Error('Knowledge publication editorial context trace is missing or stale.'); } const connection = await resolveConnection(store, { projectId: workspace.projectId, - write: false, publishRefs: [workspace.branchName, publication.published_ref] }); + write: false, publishRefs: [workspace.branchName, publication.published_ref, + `refs/treedx/commits/${publication.commit_sha}`] }); if (!connection) throw new Error('The project TreeDX repository is unavailable.'); + if (publication.published_ref !== connection.publicationRef) { + await store.run('UPDATE knowledge_publications SET published_ref = ? WHERE id = ? AND status = ?', + [connection.publicationRef, publication.id, 'queued']); + publication = { ...publication, published_ref: connection.publicationRef }; + } const repository = await connection.client.getRepository(connection.repositoryId); const managedLocal = isManagedLocalPublication(repository, environment); const external = !managedLocal && !localPublicationRemote(repository.remoteUrl); const publicationConnection = external ? { ...connection, - nodeId: (await connection.client.getPlacement(connection.repositoryId)).primaryNodeId } : connection; + nodeId: treeDxPrimaryNodeId(await connection.client.getPlacement(connection.repositoryId)) } : connection; if (external && !publicationConnection.nodeId) throw new Error('TreeDX did not resolve the repository primary node for credential delivery.'); const recoveredManifest = await publicationStorage.readCurrent(workspace.teamId); const publicationAlreadyApplied = containsPublicationCommit(recoveredManifest, publication, workspace); @@ -124,10 +136,16 @@ export function createKnowledgePublicationExecutor(options: any) { publicationRef: publication.published_ref, authoringRef: workspace.branchName, fetchImpl: options.fetchImpl, }); - const push = publicationAlreadyApplied ? undefined : managedLocal - ? await connection.client.promoteRef({ repoId: connection.repositoryId, sourceRef: workspace.branchName, - destinationRef: publication.published_ref, expectedDestinationHead: workspace.baseCommitSha }) - : remote?.push ?? remote?.promotion; + let push = publicationAlreadyApplied ? undefined : remote?.push ?? remote?.promotion; + if (!publicationAlreadyApplied && managedLocal) { + const promote = (sourceRef: string) => connection.client.promoteRef({ repoId: connection.repositoryId, sourceRef, + destinationRef: publication.published_ref, expectedDestinationHead: workspace.baseCommitSha }); + try { push = await promote(workspace.branchName); } + catch (error) { + if (!(error instanceof Error) || !/ref or object not found/iu.test(error.message)) throw error; + push = await promote(`refs/treedx/commits/${publication.commit_sha}`); + } + } if (push?.rejectedRefs?.length) throw new Error('The publication ref changed after review. Rebase and review the knowledge again.'); const graph = publicationAlreadyApplied ? undefined : await completedGraphRefresh(connection.client, { repoId: connection.repositoryId, ref: publication.published_ref, paths: workspace.allowedPaths }); diff --git a/src/operations-runner/knowledge/remote-publication.ts b/src/operations-runner/knowledge/remote-publication.ts index 2ada4c56..58cd992d 100644 --- a/src/operations-runner/knowledge/remote-publication.ts +++ b/src/operations-runner/knowledge/remote-publication.ts @@ -16,6 +16,10 @@ async function requireTreeDxRef(client: any, repositoryId: string, ref: string, if (head !== expectedHead) throw new Error(`TreeDX ref ${ref} did not resolve the reviewed publication commit.`); } +function missingSourceRef(error: unknown) { + return error instanceof Error && /ref or object not found/iu.test(error.message); +} + async function remoteHead(input: { store: any; binding: any; fetchImpl?: typeof fetch }) { const credential = await resolveGitHubCredentialAuthority({ store: input.store, authorityId: input.binding.authority_id, @@ -40,14 +44,21 @@ export async function publishRemoteRepository(input: { } let push; if (observed !== input.reviewedCommit) { - const credential = await createRemoteGitCredentialDelivery({ - ...input, repositoryBindingId: binding.id, credentialAuthorityId: binding.authority_id, - nodeId: input.connection.nodeId, sourceRef: fullHead(input.authoringRef), - destinationRef: fullHead(input.publicationRef), expectedRemoteHead, purpose: 'push', - }); - push = await input.connection.client.push({ repoId: input.connection.repositoryId, remoteName: 'origin', - remoteUrl: binding.clone_url, credentialId: credential.deliveryId, - refspecs: [`${fullHead(input.authoringRef)}:${fullHead(input.publicationRef)}`], expectedRemoteHead: expectedRemoteHead ?? '' }); + const destinationRef = fullHead(input.publicationRef); + const pushFrom = async (sourceRef: string) => { + const credential = await createRemoteGitCredentialDelivery({ + ...input, repositoryBindingId: binding.id, credentialAuthorityId: binding.authority_id, + nodeId: input.connection.nodeId, sourceRef, destinationRef, expectedRemoteHead, purpose: 'push', + }); + return input.connection.client.push({ repoId: input.connection.repositoryId, + remoteName: 'origin', remoteUrl: binding.clone_url, credentialId: credential.deliveryId, + refspecs: [`${sourceRef}:${destinationRef}`], expectedRemoteHead: expectedRemoteHead ?? '' }); + }; + try { push = await pushFrom(fullHead(input.authoringRef)); } + catch (error) { + if (!missingSourceRef(error)) throw error; + push = await pushFrom(`refs/treedx/commits/${input.reviewedCommit}`); + } if (push.afterHead !== input.reviewedCommit) throw new Error('Remote Git read-back did not match the reviewed commit.'); const readBack = await remoteHead({ store: input.store, binding, fetchImpl: input.fetchImpl }); if (readBack !== input.reviewedCommit) throw new Error('The provider remote did not retain the reviewed publication commit.'); diff --git a/src/operations-runner/treedx/commit-replication-executor.ts b/src/operations-runner/treedx/commit-replication-executor.ts index 21f82d81..85aa1207 100644 --- a/src/operations-runner/treedx/commit-replication-executor.ts +++ b/src/operations-runner/treedx/commit-replication-executor.ts @@ -4,6 +4,7 @@ import { githubRepositoryHead } from '../../providers/github/repository-client.t import { resolveGitHubCredentialAuthority } from '../../security/provider-credential-authority.ts'; import { createRemoteGitCredentialDelivery } from '../../security/remote-git-credential-delivery.ts'; import { isR2ReplicationReceipt, mirrorTreeDxCommit, resolveCanonicalTreeDxRef, TREE_DX_MIRROR_SCHEMA, TREE_DX_MIRROR_SKIPPED_SCHEMA } from './r2-file-mirror.ts'; +import { markManagedTeamLibraryMirrorKnownGood } from '../../api/teams/managed-team-library-service.ts'; function setting(options: any, name: string) { return String(options.config?.[name] ?? process.env[name] ?? '').trim(); @@ -182,6 +183,8 @@ export function createTreeDxCommitReplicationExecutor(options: any) { const completedAt = new Date().toISOString(); await store.run("UPDATE treedx_commit_replications SET status='complete',next_attempt_at=NULL,completed_at=?,updated_at=? WHERE id=?", [completedAt, completedAt, row.id]); + await markManagedTeamLibraryMirrorKnownGood(store,{teamId:row.team_id,projectId:row.project_id, + commitSha:row.commit_sha,r2Receipt}); await context.checkpoint({ phase: 'treedx.commit.replicated', replicationId: row.id, commitSha: row.commit_sha }, { kind: 'treedx.commit.replicated', data: { projectId: row.project_id, commitSha: row.commit_sha } }); return { replicationId: row.id, status: 'complete', commitSha: row.commit_sha, github: githubReceipt, r2: r2Receipt }; diff --git a/src/operations-runner/treedx/commit-replication-scheduler.ts b/src/operations-runner/treedx/commit-replication-scheduler.ts index 96301ac2..d5838f26 100644 --- a/src/operations-runner/treedx/commit-replication-scheduler.ts +++ b/src/operations-runner/treedx/commit-replication-scheduler.ts @@ -24,25 +24,31 @@ export class TreeDxCommitReplicationScheduler { WHERE repository_id IS NOT NULL ORDER BY project_id`); let discovered = 0; for (const library of libraries) { - const connection = await resolveKnowledgeGatewayConnection(this.store, { projectId: library.project_id, write: false }); - if (!connection) continue; - const response = await connection.client.upstream.repositories.refs(connection.repositoryId); - const candidates = refs(response).filter((ref: any) => String(ref.name ?? '').startsWith('refs/heads/') - || String(ref.name ?? '').startsWith('refs/treedx/commits/') || String(ref.name ?? '') === this.canonicalRemoteRef); - const byCommit = new Map(); - for (const ref of candidates) { - const commitSha = String(ref.target ?? ref.sha ?? ''); - if (!/^[a-f0-9]{40}$/u.test(commitSha)) continue; - const name = String(ref.name); - const current = byCommit.get(commitSha); - const rank = (value: string) => value === this.canonicalRef ? 0 : value === this.canonicalRemoteRef ? 1 - : value.startsWith('refs/heads/') ? 2 : 3; - if (!current || rank(name) < rank(current)) byCommit.set(commitSha, name); - } - for (const [commitSha, sourceRef] of byCommit) { - await enqueueTreeDxCommitReplication(this.store, { teamId: library.team_id, projectId: library.project_id, - commitSha, sourceRef, createdAt: now }); - discovered += 1; + try { + const connection = await resolveKnowledgeGatewayConnection(this.store, { projectId: library.project_id, write: false }); + if (!connection) continue; + const response = await connection.client.upstream.repositories.refs(connection.repositoryId); + const candidates = refs(response).filter((ref: any) => String(ref.name ?? '').startsWith('refs/heads/') + || String(ref.name ?? '').startsWith('refs/treedx/commits/') || String(ref.name ?? '') === this.canonicalRemoteRef); + const byCommit = new Map(); + for (const ref of candidates) { + const commitSha = String(ref.target ?? ref.sha ?? ''); + if (!/^[a-f0-9]{40}$/u.test(commitSha)) continue; + const name = String(ref.name); + const current = byCommit.get(commitSha); + const rank = (value: string) => value === this.canonicalRef ? 0 : value === this.canonicalRemoteRef ? 1 + : value.startsWith('refs/heads/') ? 2 : 3; + if (!current || rank(name) < rank(current)) byCommit.set(commitSha, name); + } + for (const [commitSha, sourceRef] of byCommit) { + await enqueueTreeDxCommitReplication(this.store, { teamId: library.team_id, projectId: library.project_id, + commitSha, sourceRef, createdAt: now }); + discovered += 1; + } + } catch { + // One stale or unavailable binding must not prevent canonical mirrors + // for every other team/project from being discovered and queued. + continue; } } return discovered; diff --git a/tests/unit/control-plane/capacity/context-query/cross-project-read-repositories.test.ts b/tests/unit/control-plane/capacity/context-query/cross-project-read-repositories.test.ts new file mode 100644 index 00000000..12df415c --- /dev/null +++ b/tests/unit/control-plane/capacity/context-query/cross-project-read-repositories.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveCrossProjectReadRepositories } from '../../../../../src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts'; + +describe('assignment cross-project TreeDX authority', () => { + it('intersects direct shares with activity content permissions and ignores expired grants', async () => { + const projects = new Map([ + ['team-library', { id: 'team-library', slug: 'team', teamId: 'team-a' }], + ['same-team', { id: 'same-team', slug: 'other', teamId: 'team-a' }], + ['shared', { id: 'shared', slug: 'shared-project', teamId: 'team-b' }], + ]); + const store = { + listTeamProjects: vi.fn(async () => [projects.get('team-library'), projects.get('same-team')]), + getProject: vi.fn(async (id:string) => projects.get(id) ?? null), + getProjectTreeDxLibrary: vi.fn(async (id:string) => ({ repositoryId: `repo-${id}`, contentRepositoryRef: `ref-${id}` })), + listTreeDxSharesForRecipient: vi.fn(async () => [ + { teamId: 'team-b', status: 'active', expiresAt: null, trustGrant: { + projectIds: ['shared'], operations: ['read'], contentModels: ['knowledge','objective'], paths: ['knowledge/**'], + } }, + { teamId: 'team-c', status: 'active', expiresAt: '2020-01-01T00:00:00Z', trustGrant: { + projectIds: ['expired'], operations: ['read'], contentModels: ['knowledge'], paths: ['**'], + } }, + ]), + }; + const repositories = await resolveCrossProjectReadRepositories({ + store: store as never, teamId: 'team-a', projectId: 'current', teamLibraryProject: projects.get('team-library')!, + payload: { permissions: { content: { + knowledge: { operations: ['read'], filters: { paths: ['knowledge/**'] } }, + question: { operations: ['write'] }, + } } }, + }); + expect(repositories).toEqual([ + expect.objectContaining({ projectId: 'team-library', source: 'team-library', allowedModels: ['knowledge'] }), + expect.objectContaining({ projectId: 'same-team', source: 'same-team', allowedPaths: ['knowledge/**'] }), + expect.objectContaining({ projectId: 'shared', source: 'shared-team', allowedModels: ['knowledge'], allowedPaths: ['knowledge/**'] }), + ]); + }); +}); diff --git a/tests/unit/control-plane/capacity/context-query/project-agent-context-layers.test.ts b/tests/unit/control-plane/capacity/context-query/project-agent-context-layers.test.ts new file mode 100644 index 00000000..df257598 --- /dev/null +++ b/tests/unit/control-plane/capacity/context-query/project-agent-context-layers.test.ts @@ -0,0 +1,10 @@ +import {describe,expect,it} from 'vitest'; +import {projectAgentActivityRefs} from '../../../../../src/api/capacity/services/projects/projects-core/project-agent-activity-refs.ts'; + +describe('agent context query layers',()=>{ + it('keeps general agent queries ahead of additive activity queries',()=>{ + const [agent]=projectAgentActivityRefs({agents:[{slug:'architect',name:'Architect',contextQueryRefs:[{id:'project-foundation',revision:1}],contextQuerySetRefs:[{id:'shared-knowledge',revision:2}],activities:{chat:{enabled:true,handler:'writer',contextQueryRefs:[{id:'chat-focus',revision:3}],contextQuerySetRefs:[{id:'discussion-context',revision:4}]}}}]},'chat'); + expect(agent.contextQueryLayers).toEqual({agent:{queryRefs:[{id:'project-foundation',revision:1}],querySetRefs:[{id:'shared-knowledge',revision:2}]},activity:{queryRefs:[{id:'chat-focus',revision:3}],querySetRefs:[{id:'discussion-context',revision:4}]}}); + expect(agent.contextQueryRefs.map((value)=>value.id)).toEqual(['project-foundation','chat-focus']); + }); +}); diff --git a/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts new file mode 100644 index 00000000..24f10aea --- /dev/null +++ b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listRepositoryPaths: vi.fn(), + readRepositoryFiles: vi.fn(), +})); + +vi.mock('../../../../src/api/knowledge/gateway-treedx-connection.ts', () => ({ + projectLibraryPath: (...parts: string[]) => parts.filter(Boolean).join('/'), + resolveKnowledgeGatewayConnection: vi.fn(async () => ({ + repositoryId: 'repo-1', contentPath: '', authoringBranch: 'staging', + client: { + listRepositoryPaths: mocks.listRepositoryPaths, + readRepositoryFiles: mocks.readRepositoryFiles, + }, + })), +})); + +import { loadDiscussions } from '../../../../src/api/discussions/content.ts'; + +describe('targeted Discussion reads', () => { + beforeEach(() => { + mocks.listRepositoryPaths.mockReset(); + mocks.readRepositoryFiles.mockReset().mockImplementation(async ({ ref, paths }) => ({ + resolvedRef: ref, + files: paths.map((path: string) => ({ path, content: `---\ntitle: Direct message\ndiscussionId: discussion-1\nauthorId: user-1\nauthorType: user\nintent: discuss\ncreatedAt: 2026-08-31T12:00:00.000Z\n---\nHello\n` })), + })); + }); + + it('reads known message identities without enumerating the repository tree', async () => { + const result = await loadDiscussions({ + store: { all: vi.fn(async () => []) }, + projectId: 'project-1', discussionId: 'discussion-1', + exactMessageIds: ['message-1'], collection: 'messages', + }); + + expect(mocks.listRepositoryPaths).not.toHaveBeenCalled(); + expect(mocks.readRepositoryFiles).toHaveBeenCalledWith(expect.objectContaining({ + paths: ['discussion-messages/discussion-1/message-1.mdx'], + })); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.body).toBe('Hello'); + }); +}); diff --git a/tests/unit/control-plane/knowledge/shares.test.ts b/tests/unit/control-plane/knowledge/shares.test.ts new file mode 100644 index 00000000..17c253af --- /dev/null +++ b/tests/unit/control-plane/knowledge/shares.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CONTROL_PLANE_OPERATIONS } from '@treeseed/sdk/operator-contracts'; +import { createKnowledgeShareOperations } from '../../../../src/api/control-plane/catalog/knowledge-sharing/operations.ts'; + +const context = { + interface: 'rest' as const, + requestId: 'request-1', + principal: { id: 'user-1', roles: ['team_owner'] }, +}; + +function fixture() { + const invokeAuthorizedFederation = vi.fn(async (input) => ({ sources: input.projects })); + const store = { + principalCanAccessTeam: vi.fn(async () => true), + resolvePrincipalTeamContext: vi.fn(async () => ({ roles: ['team_owner'] })), + listTeamProjects: vi.fn(async () => [ + { id: 'project-local', slug: 'local', teamId: 'team-target' }, + ]), + listTreeDxSharesForRecipient: vi.fn(async () => [{ + id: 'share-1', teamId: 'team-source', targetTeamId: 'team-target', status: 'active', expiresAt: null, + trustGrant: { projectIds: ['project-shared'], contentModels: ['knowledge'], paths: ['knowledge/**'], operations: ['query'] }, + }]), + }; + const operations = createKnowledgeShareOperations({ store, treeDxProxy: { invokeAuthorizedFederation } as never }); + return { operations, invokeAuthorizedFederation }; +} + +describe('team knowledge share operations', () => { + it('accepts a scalar model filter and preserves source path bounds', async () => { + const { operations, invokeAuthorizedFederation } = fixture(); + const operation = operations.find(({ binding }) => binding === CONTROL_PLANE_OPERATIONS.knowledge.teamQuery)!; + await operation.handler({ + path: { teamId: 'team-target' }, query: {}, body: { + projectIds: [], projectSlugs: [], + sharedSources: [{ teamId: 'team-source', projectIds: ['project-shared'] }], + request: { filters: { model: 'knowledge' } }, + }, + }, context); + expect(invokeAuthorizedFederation).toHaveBeenCalledWith(expect.objectContaining({ + teamId: 'team-target', + projects: [ + { projectId: 'project-local', paths: ['**'] }, + { projectId: 'project-shared', paths: ['knowledge/**'] }, + ], + })); + }); + + it('does not include shared-team projects unless the request names the source team', async () => { + const { operations, invokeAuthorizedFederation } = fixture(); + const operation = operations.find(({ binding }) => binding === CONTROL_PLANE_OPERATIONS.knowledge.teamQuery)!; + await operation.handler({ path: { teamId: 'team-target' }, query: {}, body: { + projectIds: [], projectSlugs: [], sharedSources: [], request: { filters: { model: 'knowledge' } }, + } }, context); + expect(invokeAuthorizedFederation.mock.calls[0]![0].projects).toEqual([{ projectId: 'project-local', paths: ['**'] }]); + }); + + it('fails closed instead of broadening an unknown project selector to every same-team project',async()=>{ + const {operations,invokeAuthorizedFederation}=fixture(); + const operation=operations.find(({binding})=>binding===CONTROL_PLANE_OPERATIONS.knowledge.teamQuery)!; + await expect(operation.handler({path:{teamId:'team-target'},query:{},body:{projectIds:[],projectSlugs:['missing'],sharedSources:[],request:{filters:{model:'knowledge'}}}},context)).rejects.toMatchObject({code:'knowledge_project_not_found'}); + expect(invokeAuthorizedFederation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/control-plane/protocol-contract.test.ts b/tests/unit/control-plane/protocol-contract.test.ts index 36f81f98..d4944f3b 100644 --- a/tests/unit/control-plane/protocol-contract.test.ts +++ b/tests/unit/control-plane/protocol-contract.test.ts @@ -40,7 +40,7 @@ describe('control-plane protocol contract', () => { async listTeamProjects() { return []; }, async listTeamsForPrincipal() { return []; }, async loadTeamProfileByName() { return null; }, - async getTeam(teamId: string) { return { id: teamId, name: 'TreeSeed', status: 'active', lifecycleVersion: 1, updatedAt: 'revision-1' }; }, + async getTeam(teamId: string) { return teamId==='team-new'?{id:teamId,name:'treeseed-labs',displayName:'treeseed-labs',ownerUserId:'user_1',status:'active'}:{ id: teamId, name: 'TreeSeed', status: 'active', lifecycleVersion: 1, updatedAt: 'revision-1' }; }, async principalCanAccessTeam() { return true; }, async getTeamAccessSummary(teamId: string) { return { teamId, roles: ['project_lead'] }; }, async resolvePrincipalTeamContext() { return { roles: ['project_lead'] }; }, @@ -72,6 +72,7 @@ describe('control-plane protocol contract', () => { ...overrides, }); const apiDependencies = (overrides: Record = {}) => ({ store: operationStore(overrides), treeDxProxy: { async invoke() { throw new Error('Unexpected TreeDX proxy invocation.'); } }, capacity: { async evaluateProjectDeletionBlockers() { return []; } }, async deliverTeamInvite() {}, async listUserEmailAddresses() { return []; }, + async reconcileManagedTeamLibrary(teamId:string) { return {teamId,state:'known-good'}; }, accountEmails: { async add() { return { ok: true }; }, async verify() { return { ok: true }; }, async makePrimary() { return { ok: true }; }, async remove() { return { ok: true, items: [] }; } } }); const confirmationService = () => { const consumed = new Set(); diff --git a/tests/unit/control-plane/providers/provider-context-overflow-status.test.ts b/tests/unit/control-plane/providers/provider-context-overflow-status.test.ts new file mode 100644 index 00000000..f167ec76 --- /dev/null +++ b/tests/unit/control-plane/providers/provider-context-overflow-status.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createProviderRuntimeService } from '../../../../src/api/control-plane/repositories/providers/provider-runtime-service.ts'; + +describe('provider context-capacity status', () => { + it('surfaces quarantined offers as status alerts and diagnostic blockers', async () => { + const store = { + all: vi.fn(async (query: string) => query.includes('execution_capability_offers') + ? [{ offer_id: 'codex-chat', execution_provider_id: 'private-adapter', status: 'context_overflow', last_seen_at: '2026-08-31T00:00:00.000Z' }] + : query.includes('availability_sessions') ? [{ status: 'open', expires_at: '2099-01-01T00:00:00.000Z' }] : []), + first: vi.fn(async (query: string) => query.includes('COUNT(*)') ? { count: 1 } : null), + principalCanAccessTeam: vi.fn(async () => true), principalCanManageTeam: vi.fn(async () => true), + } as any; + const service = createProviderRuntimeService(store, { environment: 'test' }); + service.show = vi.fn(async () => ({ id: 'provider-1' })) as any; + const status = await service.diagnose({ id: 'owner' }, 'team-1', 'provider-1'); + expect(status.healthy).toBe(true); + expect(status.alerts).toEqual([expect.objectContaining({ code: 'provider_context_capacity_overflow', offerId: 'codex-chat' })]); + expect(status.blockers).toContain('context_overflow:codex-chat'); + expect(status.nextActions).toContain('Publish an updated context-capacity offer and pass conformance before re-enabling it.'); + }); +}); diff --git a/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts b/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts index 39ffd1a0..87eb707e 100644 --- a/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts +++ b/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts @@ -42,7 +42,7 @@ describe('seed TreeDX knowledge binding', () => { store, projectId: 'project-platform', teamId: 'team-treeseed', projectSlug: 'platform', libraryRoot: '.', libraryRepositoryUrl: 'https://github.com/treeseed-ai/platform-library.git', libraryRef: 'refs/remotes/origin/staging', env: { NODE_ENV: 'test', TREESEED_TREEDX_URL: 'http://127.0.0.1:4000' }, dependencyState: {}, - })).resolves.toEqual({ kind: 'projectKnowledgeBinding', projectId: 'project-platform', repositoryId: 'repo-platform', agents: { count: 0, immutableRef: '2'.repeat(40) } }); + })).resolves.toEqual({ kind: 'projectKnowledgeBinding', projectId: 'project-platform', repositoryId: 'repo-platform', resolvedRef:'2'.repeat(40),sourceRef:'refs/remotes/origin/staging',contextQueries:{references:0,tests:0},agents: { count: 0, immutableRef: '2'.repeat(40) } }); expect(calls).toEqual([ { method: 'GET', path: '/api/v1/repos', body: undefined }, { method: 'POST', path: '/api/v1/repos', body: { repositoryName: 'treeseed-platform', defaultRef: 'refs/heads/main' } }, diff --git a/tests/unit/control-plane/teams/delete-operation.test.ts b/tests/unit/control-plane/teams/delete-operation.test.ts index 8539310d..f04e6a66 100644 --- a/tests/unit/control-plane/teams/delete-operation.test.ts +++ b/tests/unit/control-plane/teams/delete-operation.test.ts @@ -8,13 +8,16 @@ function dependencies(prepared: Record) { ? { id: 'grant-1', expires_at: '2099-01-01T00:00:00Z' } : null); const batch = vi.fn(async () => undefined); const prepareTeamDeletion = vi.fn(async () => prepared); + const deleteManagedTeamLibraryResources = vi.fn(async () => ({ schemaVersion: 'treeseed.team-library-deletion-receipt/v1' })); return { first, batch, prepareTeamDeletion, value: { store: { async getTeam() { return { id: 'team-1', name: 'tree-team', status: 'archived', lifecycleVersion: 4 }; }, + async getProjectByTeamAndSlug() { return { id: 'team-library-project', metadata: { kind: 'system-team-library', systemManaged: true } }; }, + async getProjectTreeDxLibrary() { return { repositoryId: 'repo-team-library' }; }, async principalCanAccessTeam() { return true; }, async resolvePrincipalTeamContext() { return { roles: ['team_owner'] }; }, first, run: vi.fn(async () => undefined), all: vi.fn(async () => []), batch, prepareTeamDeletion, listTeamMembers: vi.fn(async () => []), recordAuditEvent: vi.fn(async () => undefined), - } } as any }; + }, treeDxProxy: { invoke: vi.fn(async () => ({ retired: true })) }, deleteManagedTeamLibraryResources } as any }; } describe('team deletion catalog operation', () => { @@ -31,7 +34,8 @@ describe('team deletion catalog operation', () => { const fixture = dependencies({ ok: true, team: { id: 'team-1' } }); await expect(createTeamDeleteOperation(fixture.value).handler({ path: { teamId: 'team-1' }, query: {}, body: { confirmation: 'DELETE tree-team', reauthenticationGrantId: 'grant-1', - } }, context)).resolves.toEqual({ ok: true, deleted: true, teamId: 'team-1' }); + } }, context)).resolves.toEqual(expect.objectContaining({ ok: true, deleted: true, teamId: 'team-1', + receipt: expect.objectContaining({ schemaVersion: 'treeseed.team-deletion-receipt/v1' }) })); expect(fixture.prepareTeamDeletion).toHaveBeenCalledTimes(2); expect(fixture.batch).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts b/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts new file mode 100644 index 00000000..0ae040f5 --- /dev/null +++ b/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { deleteManagedTeamLibraryResources } from '../../../../src/api/teams/managed-team-library-service.ts'; + +describe('managed Team Library deletion', () => { + it('deletes the exact GitHub repository and both team-scoped R2 prefixes', async () => { + const requests:string[]=[]; + const fetchImpl=vi.fn(async (input:string|URL|Request,init?:RequestInit) => { + const url=String(input);requests.push(`${init?.method??'GET'} ${url}`); + if(url.startsWith('https://api.github.com/'))return new Response(null,{status:204}); + if((init?.method??'GET')==='GET')return new Response('false',{status:200}); + return new Response(null,{status:204}); + }); + const result=await deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-team',metadata:{kind:'system-team-library',systemManaged:true,library:{owner:'treeseed-ai',repositoryName:'team-library'}}}, + env:{TREESEED_GITHUB_TOKEN:'github-token',TREESEED_CLOUDFLARE_ACCOUNT_ID:'account',TREESEED_CONTENT_BUCKET_NAME:'treeseed-dev-library',TREESEED_R2_ACCESS_KEY_ID:'access',TREESEED_R2_SECRET_ACCESS_KEY:'secret'},fetchImpl:fetchImpl as typeof fetch}); + expect(result).toEqual(expect.objectContaining({teamId:'team-1',github:{repository:'treeseed-ai/team-library',deleted:true,alreadyAbsent:false},r2:expect.objectContaining({deletedObjects:0})})); + expect(requests.some((request)=>request.startsWith('DELETE https://api.github.com/repos/treeseed-ai/team-library'))).toBe(true); + expect(requests.filter((request)=>request.startsWith('GET https://account.r2.cloudflarestorage.com/treeseed-dev-library/')).length).toBe(2); + }); + + it('refuses to delete a normal project', async () => { + await expect(deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-1',metadata:{}},env:{}})) + .rejects.toThrow('protected system project'); + }); +}); diff --git a/tests/unit/control-plane/teams/managed-team-library-readiness.test.ts b/tests/unit/control-plane/teams/managed-team-library-readiness.test.ts new file mode 100644 index 00000000..72d1ab58 --- /dev/null +++ b/tests/unit/control-plane/teams/managed-team-library-readiness.test.ts @@ -0,0 +1,19 @@ +import { describe,expect,it } from 'vitest'; +import { markManagedTeamLibraryMirrorKnownGood } from '../../../../src/api/teams/managed-team-library-service.ts'; + +describe('managed Team Library readiness',()=>{ + it('becomes known-good only for the exact verified canonical R2 mirror',async()=>{ + const runs:Array<{query:string;params:unknown[]}>=[]; + const store:any={ + async getProject(){return {id:'team-project',metadata:{kind:'system-team-library',library:{status:'replicating'},provisioning:{state:'replicating'}}};}, + async getProjectTreeDxLibrary(){return {metadata:{resolvedRef:'a'.repeat(40)}};}, + async getTeam(){return {metadata:{teamLibrary:{projectId:'team-project',state:'replicating'}}};}, + async run(query:string,params:unknown[]){runs.push({query,params});}, + }; + expect(await markManagedTeamLibraryMirrorKnownGood(store,{teamId:'team',projectId:'team-project',commitSha:'b'.repeat(40),r2Receipt:{schemaVersion:'treeseed.treedx-r2-file-mirror/v2',commitSha:'b'.repeat(40)}})).toBe(false); + expect(runs).toHaveLength(0); + expect(await markManagedTeamLibraryMirrorKnownGood(store,{teamId:'team',projectId:'team-project',commitSha:'a'.repeat(40),r2Receipt:{schemaVersion:'treeseed.treedx-r2-file-mirror/v2',commitSha:'a'.repeat(40)}})).toBe(true); + expect(runs).toHaveLength(2); + expect(String(runs[0]?.params[0])).toContain('known-good'); + }); +}); diff --git a/tests/unit/control-plane/treedx/proxy-operations.test.ts b/tests/unit/control-plane/treedx/proxy-operations.test.ts index 73768e40..64502eb0 100644 --- a/tests/unit/control-plane/treedx/proxy-operations.test.ts +++ b/tests/unit/control-plane/treedx/proxy-operations.test.ts @@ -1,6 +1,7 @@ import { CONTROL_PLANE_OPERATION_LIST, CONTROL_PLANE_OPERATIONS } from '@treeseed/sdk/operator-contracts'; import { describe, expect, it, vi } from 'vitest'; import { createTreeDxOperations } from '../../../../src/api/control-plane/catalog/treedx/index.ts'; +import { bindCurrentLibraryView } from '../../../../src/api/control-plane/repositories/treedx/proxy-operation-service.ts'; const service = () => ({ library: vi.fn(), bindLibrary: vi.fn(), serviceContract: vi.fn(), listWorkspaces: vi.fn(), invoke: vi.fn() }) as any; @@ -19,4 +20,13 @@ describe('TreeDX proxy operation catalog', () => { expect(treeDxProxy.invoke).toHaveBeenCalledWith(operation.binding.descriptor, { path: { projectId: 'project-1', repoId: 'repo-1' }, query: {}, body: { branch: 'work' } }, context); }); + + it('resolves the current project library view without exposing storage revisions to callers', () => { + const input = { path: { projectId: 'project-1', repoId: 'repo-1' }, query: {}, body: { paths: ['objectives/core'] } }; + expect(bindCurrentLibraryView({ method: 'POST' }, input, { contentRepositoryRef: 'current-library-view' })).toEqual({ + ...input, body: { paths: ['objectives/core'], ref: 'current-library-view' }, + }); + expect(input.body).not.toHaveProperty('ref'); + }); + }); diff --git a/tests/unit/control-plane/treedx/upstream-operation.test.ts b/tests/unit/control-plane/treedx/upstream-operation.test.ts index 7e83e15d..f50b4bbf 100644 --- a/tests/unit/control-plane/treedx/upstream-operation.test.ts +++ b/tests/unit/control-plane/treedx/upstream-operation.test.ts @@ -14,4 +14,11 @@ describe('authoritative TreeDX upstream operations', () => { expect(treeDxQuery({ cursor: 'next', limit: 25, assignmentId: 'assignment-1', treeDxProxyToken: 'secret' })) .toEqual({ cursor: 'next', limit: 25 }); }); + + it('authorizes the physical candidates for an extensionless content read', () => { + const operation = requireTreeDxOperation('readRepositoryFile'); + expect(treeDxOperationScope(operation, { body: { paths: ['objectives/core'] } }, ['repo-1']).paths) + .toEqual(['objectives/core', 'objectives/core.mdx', 'objectives/core.md', 'objectives/core.markdown', + 'objectives/core.json', 'objectives/core.yaml', 'objectives/core.yml', 'objectives/core.toml']); + }); }); diff --git a/treeseed.package.yaml b/treeseed.package.yaml index fe71271a..cf01b87e 100644 --- a/treeseed.package.yaml +++ b/treeseed.package.yaml @@ -102,7 +102,7 @@ development: kind: rebuild-restart platforms: [linux-amd64, linux-arm64] runtimeRequirements: [node>=22] - sourceRoots: [src/operations-runner, src/api/knowledge, src/api/providers/cloudflare, src/security, src/providers, drizzle] + sourceRoots: [src/operations-runner, src/api, src/security, src/providers, drizzle] ignoredPaths: [dist, node_modules] operations: build: { command: npm, args: [run, build:dist], environment: {}, timeoutSeconds: 1800 }