Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/api/capacity/services/build/ready-execution-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export function executionNodeRunScope(run: Pick<DurableCapacityWorkdayRun, 'id'
return { sql: `(node.workday_id=? OR (node.workday_id IS NULL AND node.kind<>'communication'))`, parameters: [run.id] };
}

/** Governance review is planning work; paired work-item review is acting work. */
export function isProposalGovernanceReview(node: Pick<ExecutionNode, 'kind' | 'pairRole' | 'sourceRef'>): boolean {
return node.kind === 'reviewing' && node.pairRole === null && node.sourceRef.model === 'proposal';
}

export async function workItemContext(store: any, node: ExecutionNode): Promise<ExactEntityReference[]> {
const source = node.sourceRef;
if (node.kind === 'communication' && source.store === 'treedx' && source.repository && source.commit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export async function livingAllocationInputs(store: CapacityGovernanceDatabase,
WHERE node.team_id=? AND ${scope.sql} AND node.status='ready'
AND ${projectIds.length ? `node.project_id IN (${projectIds.map(() => '?').join(',')})` : 'false'}
AND node.required_capabilities_json::jsonb @> ?::jsonb
AND ${plan.state === 'closing' ? "node.kind='reporting'" : phase === 'planning' ? "node.kind IN ('planning','estimating','communication')" : "node.kind NOT IN ('planning','estimating','reporting')"}`,
AND ${plan.state === 'closing' ? "node.kind='reporting'" : phase === 'planning'
? "(node.kind IN ('planning','estimating','communication') OR (node.kind='reviewing' AND node.pair_role IS NULL AND node.source_ref_json::jsonb->>'model'='proposal'))"
: "(node.kind NOT IN ('planning','estimating','reporting') AND NOT (node.kind='reviewing' AND node.pair_role IS NULL AND node.source_ref_json::jsonb->>'model'='proposal'))"}`,
[run.teamId, ...scope.parameters, ...projectIds, JSON.stringify([input.capabilityId])]);
const usage = commitments.filter(row => row.work_day_id === run.id).map(row => ({ planning: row.mode === 'planning',
seconds: ['reserved', 'consuming'].includes(String(row.state)) ? Math.max(Number(row.reserved_seconds), Number(row.active_seconds)) : Number(row.active_seconds) }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ interface Store extends CapacityGovernanceDatabase {

type JsonRecord = Record<string, unknown>;

export function assignmentAccountingMode(assignment: Pick<AssignmentAttempt, 'effectiveProfile' | 'sourceRef' | 'workItemId'>): 'planning' | 'acting' {
return assignment.effectiveProfile.activity === 'planning' || assignment.effectiveProfile.activity === 'estimating'
|| (assignment.effectiveProfile.activity === 'reviewing' && assignment.sourceRef.model === 'proposal'
&& assignment.workItemId === 'proposal-review') ? 'planning' : 'acting';
}

/** Atomically claim one normalized node and create its one reservation/attempt. */
export async function admitLivingExecutionAssignment(store: Store, input: {
principal: ProviderLeasePrincipal;
Expand Down Expand Up @@ -43,8 +49,7 @@ export async function admitLivingExecutionAssignment(store: Store, input: {
if (!input.allocation.admitted || input.allocation.allocatedSeconds !== assignment.limits.maximumSeconds) {
throw new CapacityGovernanceError('assignment_allocation_mismatch', 'Assignment limits must match the allocator-issued duration.', 409);
}
const mode = assignment.effectiveProfile.activity === 'planning' || assignment.effectiveProfile.activity === 'estimating'
? 'planning' : 'acting';
const mode = assignmentAccountingMode(assignment);
const decisionId = assignment.authorityRefs.find((reference) => reference.model === 'decision')?.id ?? null;
const proposalId = assignment.sourceRef.model === 'proposal' ? assignment.sourceRef.id : null;
const timing = compileAssignmentTimeBudget({ now: input.now, requestedSeconds: assignment.limits.maximumSeconds, configuredBudget: { deadline: assignment.deadline } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { assignmentSourceBranch } from '@treeseed/sdk/capacity-provider/sandbox'
import { CapacityGovernanceError } from '../../../../../database.ts';
import type { ProviderLeasePrincipal } from '../../../../accounts/lease-authority-service.ts';
import type { ProviderSynthesisExecutionProvider } from '../../../providers/provider-synthesis-context-service.ts';
import type { ReadyExecutionNode } from '../../../../build/ready-execution-node.ts';
import { isProposalGovernanceReview, type ReadyExecutionNode } from '../../../../build/ready-execution-node.ts';
import type { DurableCapacityWorkdayRun } from '../../../../../repositories/capacity/workdays/workday-run.ts';
import { workdayTreeDxWorkspaceId } from '../../../workdays/treedx/workday-treedx-workspace-service.ts';
import { assignmentPreparationSeconds, compileAssignmentTimeBudget } from '../assignment-time-budget.ts';
Expand Down Expand Up @@ -150,8 +150,9 @@ export function buildAssignmentAttempt(input: {
'Assignment admission found contradictory execution mode authority.', 409,
{ run: input.run.executionMode, appliedPlan: appliedPlan.executionMode },
);
const planning = ['planning', 'estimating'].includes(candidate.node.kind);
const windowEnd = planning ? workdayPlanningEndsAt(appliedPlan) : appliedPlan.endsAt;
const planningTurn = ['planning', 'estimating'].includes(candidate.node.kind);
const planningPhase = planningTurn || isProposalGovernanceReview(candidate.node);
const windowEnd = planningPhase ? workdayPlanningEndsAt(appliedPlan) : appliedPlan.endsAt;
const preparationSeconds = assignmentPreparationSeconds(undefined);
const utcDayEnd = Date.parse(`${input.now.slice(0, 10)}T00:00:00.000Z`) + 86_400_000;
const availableSeconds = candidate.node.kind === 'reporting' && appliedPlan.state === 'closing'
Expand All @@ -172,7 +173,7 @@ export function buildAssignmentAttempt(input: {
{ id: 'capability-day', remainingSeconds: remaining(capabilityLimits.dailyActiveSecondsLimit, observation.capabilityUsage[capability]) }, ...allocationInputs.constraints],
providerMinimumSeconds: capabilityLimits.minimumAssignmentSeconds,
providerMaximumSeconds: capabilityLimits.maximumAssignmentSeconds,
...(planning ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) });
...(planningTurn ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) });
if (!allocation.admitted) throw new CapacityGovernanceError('capacity_assignment_allocation_deferred',
'The remaining execution window cannot fit the viable task minimum.', 409, { nodeId: candidate.node.id, allocation });
const deadline = compileAssignmentTimeBudget({ now: input.now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CapacityWorkdayRunRepository } from '../../../../../repositories/capaci
import { CapacityGovernanceError } from '../../../../../database.ts';
import type { ProviderLeasePrincipal } from '../../../../accounts/lease-authority-service.ts';
import type { ProviderSynthesisExecutionProvider } from '../../../providers/provider-synthesis-context-service.ts';
import { listReadyExecutionNodes } from '../../../../build/ready-execution-node.ts';
import { isProposalGovernanceReview, listReadyExecutionNodes } from '../../../../build/ready-execution-node.ts';
import { capacityWorkdayRequestedProjectReferences, resolveCapacityWorkdayProjects } from '../../../workdays/policy/workday-project-policy.ts';
import { admitLivingExecutionAssignment } from '../../admission/living-execution-admission.ts';
import { buildAssignmentAttempt } from './assignment-attempt-builder.ts';
Expand All @@ -19,6 +19,17 @@ const record = (value: unknown): Record<string, unknown> => {
};

const unique = (values: Array<string | undefined>) => [...new Set(values.filter((value): value is string => Boolean(value)))];

export function isNodeEligibleInWorkdayPhase(
node: Parameters<typeof isProposalGovernanceReview>[0], phase: 'planning' | 'acting', closing: boolean,
): boolean {
if (closing) return node.kind === 'reporting';
if (node.kind === 'reporting') return false;
if (node.kind === 'communication') return true;
const planningWork = node.kind === 'planning' || node.kind === 'estimating' || isProposalGovernanceReview(node);
return phase === 'planning' ? planningWork : !planningWork;
}

export function reservationFairUsage(rows: Record<string, unknown>[]) {
return rows.map((row) => ({ projectId: String(row.project_id), agentClass: String(row.agent_class),
seconds: ['reserved', 'consuming'].includes(String(row.state))
Expand Down Expand Up @@ -141,12 +152,9 @@ export async function assignNextReadyExecutionNode(
capacityWorkdayRequestedProjectReferences(run.parameters),
await store.listTeamProjects(run.teamId),
);
const phase = workdayPhase(appliedPlan, now);
const candidates = (await Promise.all(projects.map((project) => listReadyExecutionNodes(store, run, project)))).flat()
.filter((candidate) => candidate.node.kind === 'communication' || appliedPlan.state === 'closing'
|| (workdayPhase(appliedPlan, now) === 'planning'
? ['planning', 'estimating'].includes(candidate.node.kind)
: !['planning', 'estimating'].includes(candidate.node.kind)))
.filter((candidate) => appliedPlan.state === 'closing' ? candidate.node.kind === 'reporting' : candidate.node.kind !== 'reporting')
.filter((candidate) => isNodeEligibleInWorkdayPhase(candidate.node, phase, appliedPlan.state === 'closing'))
.filter((candidate) => appliedPlan.state === 'closing'
|| Date.parse(appliedPlan.endsAt) - Date.parse(now) >= (candidate.node.estimate?.minimumSeconds ?? 1) * 1_000);
const prior = await store.all(`SELECT node.project_id,node.agent_class,reservation.active_seconds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ExecutionNode } from '@treeseed/sdk/agent-capacity';
import { CapacityGovernanceError,type CapacityGovernanceDatabase } from '../../../../database.ts';
import { canonicalJson,sha256 } from '../../../../security.ts';
import { decodeExecutionNode } from '../../../../../control-plane/repositories/capacity/execution/execution-graph-storage.ts';
import { isProposalGovernanceReview } from '../../../build/ready-execution-node.ts';
import { readTeamWorkdayProfile } from '../../../../../control-plane/repositories/capacity/workdays/profile-service.ts';

type JsonRecord = Record<string,unknown>;
Expand Down Expand Up @@ -122,7 +123,7 @@ export class WorkdayPreflightService {
const node=decodeExecutionNode(entry) as ExecutionNode;
if(selectedProposals.size&&node.sourceRef.model==='proposal'&&!selectedProposals.has(node.sourceRef.id)) return [];
const decisionRef=node.authorityRefs.find((reference)=>reference.model==='decision');
const proposalReview=node.kind==='reviewing'&&node.pairRole===null&&node.sourceRef.model==='proposal';
const proposalReview=isProposalGovernanceReview(node);
if(!node.id||selectedDecisions.size&&!proposalReview&&(!decisionRef||!selectedDecisions.has(decisionRef.id))) return [];
const mode=node.kind==='acting'||node.kind==='reviewing'&&!proposalReview?'acting' as const:'planning' as const;
if(intent.planningOnly&&mode==='acting') return [];
Expand Down
20 changes: 18 additions & 2 deletions src/api/control-plane/repositories/capacity/workday-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,24 @@ export function createWorkdayService(store: any) {
if (!run) throw new CapacityOperationError(404, 'workday_not_found', 'Workday not found.');
if (run.status !== 'running') throw new CapacityOperationError(409, 'workday_not_active', 'Only an active workday can enter closeout.');
const lifecycle = await advanceLivingWorkday(store, run, new Date().toISOString(), true);
await reconcileExecutionGraph(store, teamId);
return { run: await store.getCapacityWorkdayRun(teamId, runId), lifecycle, reason: body.reason ?? null };
// Stopping must remain available when the current proposal graph is invalid.
// The workday is already closing, so a failed refresh cannot admit new work.
let reconciliation: { status: 'current' | 'deferred'; code?: string } = { status: 'current' };
try { await reconcileExecutionGraph(store, teamId); }
catch (error) {
const code = String((error as { code?: unknown }).code ?? 'graph_reconciliation_failed');
const now = new Date().toISOString();
const current = await store.getCapacityWorkdayRun(teamId, runId);
if (current?.status === 'running' && current.parameters.appliedPlan?.state === 'closing') {
await store.updateCapacityWorkdayRun(teamId, runId, {
status: 'failed', completedAt: now,
parameters: { ...current.parameters, appliedPlan: { ...current.parameters.appliedPlan, state: 'ended', endedAt: now } },
error: { code, message: 'Workday stopped because its execution graph could not reconcile.' },
});
}
reconciliation = { status: 'deferred', code };
}
return { run: await store.getCapacityWorkdayRun(teamId, runId), lifecycle, reconciliation, reason: body.reason ?? null };
} catch (error) { translate(error); }
},
async events(principal: CapacityPrincipal, teamId: string, runId: string, query: Record<string, unknown>) {
Expand Down
24 changes: 21 additions & 3 deletions src/api/discussions/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ class DiscussionAuthoringError extends Error {
export function discussionAuthoringWorkspaceRefs(authoringRef: string, workspace: { baseCommitSha?: string; baseRef?: string } | null | undefined) {
return [...new Set([authoringRef, text(workspace?.baseCommitSha), text(workspace?.baseRef)].filter(Boolean))];
}
export function discussionAuthoringAuthority(input: {
explicitRef?: string | null; parentWorkdayId?: string | null; executionMode?: string | null; authorType?: string | null;
}) {
const simulation = input.executionMode === 'simulation' && Boolean(input.parentWorkdayId);
return {
ref: text(input.explicitRef) || (simulation ? `refs/heads/${input.parentWorkdayId}` : ''),
state: input.authorType === 'agent' || simulation ? 'unpublished' as const : 'integrated' as const,
};
}
export function discussionEventPathIdentity(value: string) {
const readable = value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 40) || 'event';
const digest = createHash('sha256').update(value).digest('hex').slice(0, 24);
Expand Down Expand Up @@ -181,7 +190,14 @@ export async function commitDiscussionMessage(input: {
authoringRef?: string | null;
authoringWorkspace?: { workspaceId: string; baseCommitSha: string; baseRef: string; allowedPaths?: string[] } | null;
}) {
const authoringRef = text(input.authoringRef);
const workday = input.parentWorkdayId
? await input.store.getCapacityWorkdayRun(input.teamId, input.parentWorkdayId) : null;
if (input.parentWorkdayId && !workday) throw Object.assign(new Error('The addressed workday is unavailable.'), {
status: 409, code: 'discussion_workday_unavailable',
});
const authoring = discussionAuthoringAuthority({ explicitRef: input.authoringRef,
parentWorkdayId: input.parentWorkdayId, executionMode: workday?.executionMode, authorType: input.authorType });
const authoringRef = authoring.ref;
if (input.authorType === 'agent' && input.assignmentId && !/^refs\/heads\/assignment_[A-Za-z0-9_-]+$/u.test(authoringRef)) {
throw Object.assign(new Error('Assignment-authored Discussion messages require the exact isolated assignment ref.'), {
status: 409, code: 'discussion_assignment_ref_required', details: { assignmentId: input.assignmentId },
Expand Down Expand Up @@ -232,7 +248,7 @@ export async function commitDiscussionMessage(input: {
const commit = await connection.client.commit({ workspaceId: workspace.workspaceId, message: `discussion: ${topic}`, author: { name: authorName, email: text(input.principal.email, 'discussion@users.treeseed.local') } })
.catch((error: unknown) => { throw new DiscussionAuthoringError('commit', error); });
const actorType = input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user';
if (actorType === 'agent') {
if (authoring.state === 'unpublished') {
await recordTreeDxAuthoringState(input.store,'unpublished',{ projectId:input.projectId,repositoryId:connection.repositoryId,commitSha:commit.commitSha,ref:commit.branchName,changedPaths:commit.changedPaths,assignmentId:input.assignmentId ?? null,actorType,actorId:authorId });
} else {
if (commit.changedPaths.length) {
Expand All @@ -252,7 +268,9 @@ export async function commitDiscussionMessage(input: {
commitSha:commit.commitSha,ref:commit.branchName,changedPaths:commit.changedPaths,assignmentId:input.assignmentId ?? null,
actorType,actorId:authorId });
}
await projectTreeDxCommitSignals(input.store, { projectId: input.projectId, commitSha: commit.commitSha, immutableRef: commit.branchName, changedPaths: commit.changedPaths, changeSummary: `Discussion message: ${topic}`, actorType: input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user', actorId: authorId });
// Only integrated content enters the shared replication and graph signal path.
// Simulation/assignment messages remain readable from their exact journaled commit.
if (authoring.state === 'integrated') await projectTreeDxCommitSignals(input.store, { projectId: input.projectId, commitSha: commit.commitSha, immutableRef: commit.branchName, changedPaths: commit.changedPaths, changeSummary: `Discussion message: ${topic}`, actorType: input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user', actorId: authorId });
await session.close();
return { discussion: { id: discussionId, topic, path: discussionPath }, message: { id: messageId, authorLabel: authorName, body: input.body, path: messagePath }, event: { path: eventPath }, mentions, commitSha: commit.commitSha, changeset: { ...changeset, resultCommitSha: commit.commitSha }, snapshotDigest: createHash('sha256').update(commit.commitSha).digest('hex') };
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/api/discussions/discussion-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an
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,
parentWorkdayId,
createDiscussion: !text(body.discussionId) || (body.createDiscussion === true && existing.discussions.length === 0), topic: text(record(existing.discussions[0]?.frontmatter).topic) || text(body.topic) || undefined,
fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [], contextRefs,
recipients: Array.isArray(body.recipients) ? body.recipients.map(String) : [],
Expand Down
Loading
Loading