mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Implement missions plugin advance loop
This commit is contained in:
1 parent
14e52f3ac7
commit
4024c47ea6
12 files changed
+4401
-506
No files matched your search
@@ -4,24 +4,26 @@ First-party Missions plugin package for the current Paperclip alpha plugin runti
|
||||
|
||||
This package now carries the installable mission workflow surface:
|
||||
|
||||
- manifest id `paperclip.missions`
|
||||
- manifest id `paperclipai.plugin-missions`
|
||||
- scoped plugin API routes under `/api/plugins/:pluginId/api/*`
|
||||
- namespace migrations for mission-owned tables
|
||||
- worker handlers for mission initialization, decomposition, summary, advance, and waivers
|
||||
- worker handlers for mission initialization, draft summary reads, and placeholder follow-up workflow routes
|
||||
- package-local typecheck, test, and build verification
|
||||
|
||||
## Declared Surfaces
|
||||
|
||||
- API routes
|
||||
- `POST /issues/:issueId/missions/init`
|
||||
- `POST /issues/:issueId/decompose`
|
||||
- `GET /issues/:issueId/mission/summary`
|
||||
- `POST /issues/:issueId/mission/advance`
|
||||
- `POST /issues/:issueId/mission/findings/:findingId/waive`
|
||||
- `GET /issues/:issueId/missions/summary`
|
||||
- `POST /issues/:issueId/missions/decompose`
|
||||
- `POST /issues/:issueId/missions/advance`
|
||||
- `POST /issues/:issueId/missions/findings/:findingKey/waive`
|
||||
- `GET /missions`
|
||||
- UI slots
|
||||
- plugin `page`
|
||||
- issue `taskDetailView`
|
||||
- issue `toolbarButton`
|
||||
- plugin `dashboardWidget`
|
||||
- plugin `settingsPage`
|
||||
|
||||
## Verify
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
import type { Issue, PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
parseMissionFeaturesDocument,
|
||||
parseMissionValidationContractDocument,
|
||||
type MissionFeature,
|
||||
type MissionMilestone,
|
||||
} from "./mission-documents.js";
|
||||
|
||||
type GeneratedIssueKind = "milestone" | "feature" | "validation";
|
||||
|
||||
type DecomposeActor = {
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type GeneratedIssueSpec = {
|
||||
kind: GeneratedIssueKind;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
parentId: string;
|
||||
originKind: `plugin:${string}`;
|
||||
originId: string;
|
||||
};
|
||||
|
||||
type DecomposedIssueResult = {
|
||||
kind: GeneratedIssueKind;
|
||||
key: string;
|
||||
issueId: string | null;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
created: boolean;
|
||||
blockedByIssueIds: string[];
|
||||
};
|
||||
|
||||
export type MissionDecompositionResult = {
|
||||
missionIssueId: string;
|
||||
milestoneCount: number;
|
||||
featureCount: number;
|
||||
validationCount: number;
|
||||
createdIssueIds: string[];
|
||||
updatedIssueIds: string[];
|
||||
issues: DecomposedIssueResult[];
|
||||
};
|
||||
|
||||
type MissionDocumentBundle = {
|
||||
validationContract: IssueDocumentLike;
|
||||
features: IssueDocumentLike;
|
||||
missionBrief: IssueDocumentLike | null;
|
||||
workerGuidelines: IssueDocumentLike | null;
|
||||
services: IssueDocumentLike | null;
|
||||
knowledgeBase: IssueDocumentLike | null;
|
||||
};
|
||||
|
||||
type IssueDocumentLike = NonNullable<Awaited<ReturnType<PluginContext["issues"]["documents"]["get"]>>>;
|
||||
|
||||
function issueReference(issue: Pick<Issue, "identifier" | "id">) {
|
||||
if (!issue.identifier) return `\`${issue.id}\``;
|
||||
const prefix = issue.identifier.split("-")[0] || "PAP";
|
||||
return `[${issue.identifier}](/${prefix}/issues/${issue.identifier})`;
|
||||
}
|
||||
|
||||
function billingCodeForMission(issue: Pick<Issue, "billingCode" | "identifier" | "id">) {
|
||||
return issue.billingCode?.trim() || `mission:${issue.identifier ?? issue.id}`;
|
||||
}
|
||||
|
||||
function docContextLines(docs: MissionDocumentBundle) {
|
||||
return [
|
||||
docs.missionBrief ? "- Read `mission-brief` before execution." : null,
|
||||
docs.workerGuidelines ? "- Follow `worker-guidelines` for execution and handoff." : null,
|
||||
docs.services ? "- Use `services` for local commands and environment setup." : null,
|
||||
docs.knowledgeBase ? "- Check `knowledge-base` for prior discoveries and constraints." : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
}
|
||||
|
||||
function summaryLines(summary: string) {
|
||||
return [summary.trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildMilestoneDescription(input: {
|
||||
mission: Pick<Issue, "id" | "identifier">;
|
||||
milestone: MissionMilestone;
|
||||
docs: MissionDocumentBundle;
|
||||
}) {
|
||||
const { mission, milestone, docs } = input;
|
||||
return [
|
||||
`Mission milestone generated from ${issueReference(mission)}.`,
|
||||
"",
|
||||
`Mission key: \`${milestone.id}\``,
|
||||
"",
|
||||
"Summary:",
|
||||
...summaryLines(milestone.summary),
|
||||
"",
|
||||
"Features in scope:",
|
||||
...milestone.features.map((feature) => `- \`${feature.id}\` ${feature.title}`),
|
||||
...(milestone.depends_on.length > 0
|
||||
? ["", "Milestone dependencies:", ...milestone.depends_on.map((id) => `- \`${id}\``)]
|
||||
: []),
|
||||
...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildFeatureDescription(input: {
|
||||
mission: Pick<Issue, "id" | "identifier">;
|
||||
milestone: MissionMilestone;
|
||||
feature: MissionFeature;
|
||||
docs: MissionDocumentBundle;
|
||||
}) {
|
||||
const { mission, milestone, feature, docs } = input;
|
||||
return [
|
||||
`Mission feature generated from ${issueReference(mission)}.`,
|
||||
"",
|
||||
`Mission key: \`${feature.id}\``,
|
||||
`Milestone: \`${milestone.id}\` ${milestone.title}`,
|
||||
`Kind: \`${feature.kind}\``,
|
||||
"",
|
||||
"Summary:",
|
||||
...summaryLines(feature.summary),
|
||||
"",
|
||||
"Claimed validation assertions:",
|
||||
...feature.claimed_assertion_ids.map((id) => `- \`${id}\``),
|
||||
"",
|
||||
"Acceptance criteria:",
|
||||
...feature.acceptance_criteria.map((criterion) => `- ${criterion}`),
|
||||
...(feature.depends_on.length > 0
|
||||
? ["", "Depends on features:", ...feature.depends_on.map((id) => `- \`${id}\``)]
|
||||
: []),
|
||||
...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildValidationDescription(input: {
|
||||
mission: Pick<Issue, "id" | "identifier">;
|
||||
milestone: MissionMilestone;
|
||||
docs: MissionDocumentBundle;
|
||||
}) {
|
||||
const { mission, milestone, docs } = input;
|
||||
const assertionIds = [...new Set(milestone.features.flatMap((feature) => feature.claimed_assertion_ids))];
|
||||
return [
|
||||
`Mission validation gate generated from ${issueReference(mission)}.`,
|
||||
"",
|
||||
`Mission key: \`${milestone.id}:validation\``,
|
||||
`Milestone: \`${milestone.id}\` ${milestone.title}`,
|
||||
"",
|
||||
"Validate the milestone after every feature dependency is complete.",
|
||||
"",
|
||||
"Assertions in scope:",
|
||||
...assertionIds.map((assertionId) => `- \`${assertionId}\``),
|
||||
...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function featureOriginId(missionIssueId: string, featureId: string) {
|
||||
return `${missionIssueId}:feature:${featureId}`;
|
||||
}
|
||||
|
||||
function milestoneOriginId(missionIssueId: string, milestoneId: string) {
|
||||
return `${missionIssueId}:milestone:${milestoneId}`;
|
||||
}
|
||||
|
||||
function validationOriginId(missionIssueId: string, milestoneId: string) {
|
||||
return `${missionIssueId}:validation:${milestoneId}`;
|
||||
}
|
||||
|
||||
function generatedOriginKind(ctx: PluginContext, kind: GeneratedIssueKind): `plugin:${string}` {
|
||||
return `plugin:${ctx.manifest.id}:${kind}` as `plugin:${string}`;
|
||||
}
|
||||
|
||||
async function getMissionDocuments(ctx: PluginContext, issueId: string, companyId: string): Promise<MissionDocumentBundle> {
|
||||
const [
|
||||
validationContract,
|
||||
features,
|
||||
missionBrief,
|
||||
workerGuidelines,
|
||||
services,
|
||||
knowledgeBase,
|
||||
] = await Promise.all([
|
||||
ctx.issues.documents.get(issueId, "validation-contract", companyId),
|
||||
ctx.issues.documents.get(issueId, "features", companyId),
|
||||
ctx.issues.documents.get(issueId, "mission-brief", companyId),
|
||||
ctx.issues.documents.get(issueId, "worker-guidelines", companyId),
|
||||
ctx.issues.documents.get(issueId, "services", companyId),
|
||||
ctx.issues.documents.get(issueId, "knowledge-base", companyId),
|
||||
]);
|
||||
|
||||
if (!validationContract) throw new Error("Mission requires a validation-contract document before decomposition");
|
||||
if (!features) throw new Error("Mission requires a features document before decomposition");
|
||||
|
||||
return {
|
||||
validationContract,
|
||||
features,
|
||||
missionBrief,
|
||||
workerGuidelines,
|
||||
services,
|
||||
knowledgeBase,
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeAssertCheckoutOwner(ctx: PluginContext, mission: Issue, companyId: string, actor: DecomposeActor) {
|
||||
if (!actor.actorAgentId || !actor.actorRunId) return;
|
||||
if (mission.status !== "in_progress") return;
|
||||
await ctx.issues.assertCheckoutOwner({
|
||||
issueId: mission.id,
|
||||
companyId,
|
||||
actorAgentId: actor.actorAgentId,
|
||||
actorRunId: actor.actorRunId,
|
||||
});
|
||||
}
|
||||
|
||||
async function findGeneratedIssue(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
originKind: GeneratedIssueSpec["originKind"],
|
||||
originId: string,
|
||||
) {
|
||||
const existing = await ctx.issues.list({
|
||||
companyId,
|
||||
originKind,
|
||||
originId,
|
||||
limit: 1,
|
||||
});
|
||||
return existing[0] ?? null;
|
||||
}
|
||||
|
||||
async function upsertGeneratedIssue(
|
||||
ctx: PluginContext,
|
||||
mission: Issue,
|
||||
companyId: string,
|
||||
actor: DecomposeActor,
|
||||
spec: GeneratedIssueSpec,
|
||||
) {
|
||||
const existing = await findGeneratedIssue(ctx, companyId, spec.originKind, spec.originId);
|
||||
const updatePatch = {
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
priority: "medium" as const,
|
||||
billingCode: billingCodeForMission(mission),
|
||||
originKind: spec.originKind,
|
||||
originId: spec.originId,
|
||||
executionWorkspaceId: mission.executionWorkspaceId ?? undefined,
|
||||
executionWorkspacePreference: mission.executionWorkspacePreference ?? undefined,
|
||||
executionWorkspaceSettings:
|
||||
(mission.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? undefined,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const updated = await ctx.issues.update(existing.id, updatePatch, companyId, actor);
|
||||
return { issue: updated, created: false };
|
||||
}
|
||||
|
||||
const created = await ctx.issues.create({
|
||||
companyId,
|
||||
projectId: mission.projectId ?? undefined,
|
||||
goalId: mission.goalId ?? undefined,
|
||||
parentId: spec.parentId,
|
||||
inheritExecutionWorkspaceFromIssueId: mission.id,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
billingCode: billingCodeForMission(mission),
|
||||
originKind: spec.originKind,
|
||||
originId: spec.originId,
|
||||
executionWorkspaceId: mission.executionWorkspaceId ?? undefined,
|
||||
executionWorkspacePreference: mission.executionWorkspacePreference ?? undefined,
|
||||
executionWorkspaceSettings:
|
||||
(mission.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? undefined,
|
||||
actor,
|
||||
});
|
||||
return { issue: created, created: true };
|
||||
}
|
||||
|
||||
async function recordLink(
|
||||
ctx: PluginContext,
|
||||
missionIssueId: string,
|
||||
spec: GeneratedIssueSpec,
|
||||
generatedIssueId: string,
|
||||
) {
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${ctx.db.namespace}.mission_issue_links (
|
||||
mission_issue_id,
|
||||
generated_issue_id,
|
||||
generated_kind,
|
||||
generated_key,
|
||||
origin_kind,
|
||||
origin_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (mission_issue_id, generated_kind, generated_key) DO UPDATE SET
|
||||
generated_issue_id = EXCLUDED.generated_issue_id,
|
||||
origin_kind = EXCLUDED.origin_kind,
|
||||
origin_id = EXCLUDED.origin_id,
|
||||
updated_at = now()`,
|
||||
[missionIssueId, generatedIssueId, spec.kind, spec.key, spec.originKind, spec.originId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function decomposeMission(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
dryRun?: boolean;
|
||||
actor?: DecomposeActor;
|
||||
},
|
||||
): Promise<MissionDecompositionResult> {
|
||||
const actor = input.actor ?? {};
|
||||
const mission = await ctx.issues.get(input.issueId, input.companyId);
|
||||
if (!mission) throw new Error(`Mission issue not found: ${input.issueId}`);
|
||||
await maybeAssertCheckoutOwner(ctx, mission, input.companyId, actor);
|
||||
|
||||
const docs = await getMissionDocuments(ctx, mission.id, input.companyId);
|
||||
const validationContract = parseMissionValidationContractDocument(docs.validationContract.body ?? "");
|
||||
const featurePlan = parseMissionFeaturesDocument(docs.features.body ?? "");
|
||||
const assertionIds = new Set(validationContract.assertions.map((assertion) => assertion.id));
|
||||
|
||||
for (const milestone of featurePlan.milestones) {
|
||||
for (const dependencyId of milestone.depends_on) {
|
||||
if (!featurePlan.milestones.some((candidate) => candidate.id === dependencyId)) {
|
||||
throw new Error(`Milestone ${milestone.id} depends on unknown milestone ${dependencyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const feature of milestone.features) {
|
||||
for (const assertionId of feature.claimed_assertion_ids) {
|
||||
if (!assertionIds.has(assertionId)) {
|
||||
throw new Error(`Feature ${feature.id} claims unknown validation assertion ${assertionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyId of feature.depends_on) {
|
||||
const dependencyExists = featurePlan.milestones.some((candidateMilestone) =>
|
||||
candidateMilestone.features.some((candidateFeature) => candidateFeature.id === dependencyId),
|
||||
);
|
||||
if (!dependencyExists) {
|
||||
throw new Error(`Feature ${feature.id} depends on unknown feature ${dependencyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const milestoneCount = featurePlan.milestones.length;
|
||||
const featureCount = featurePlan.milestones.reduce((count, milestone) => count + milestone.features.length, 0);
|
||||
const validationCount = featurePlan.milestones.length;
|
||||
|
||||
const milestoneSpecs = featurePlan.milestones.map((milestone): GeneratedIssueSpec => ({
|
||||
kind: "milestone",
|
||||
key: milestone.id,
|
||||
title: `Mission milestone: ${milestone.title}`,
|
||||
description: buildMilestoneDescription({ mission, milestone, docs }),
|
||||
parentId: mission.id,
|
||||
originKind: generatedOriginKind(ctx, "milestone"),
|
||||
originId: milestoneOriginId(mission.id, milestone.id),
|
||||
}));
|
||||
|
||||
const featureSpecs = featurePlan.milestones.flatMap((milestone) =>
|
||||
milestone.features.map((feature): GeneratedIssueSpec => ({
|
||||
kind: "feature",
|
||||
key: feature.id,
|
||||
title: `Mission feature: ${feature.title}`,
|
||||
description: buildFeatureDescription({ mission, milestone, feature, docs }),
|
||||
parentId: "",
|
||||
originKind: generatedOriginKind(ctx, "feature"),
|
||||
originId: featureOriginId(mission.id, feature.id),
|
||||
})),
|
||||
);
|
||||
|
||||
const validationSpecs = featurePlan.milestones.map((milestone): GeneratedIssueSpec => ({
|
||||
kind: "validation",
|
||||
key: `${milestone.id}:validation`,
|
||||
title: `Mission validation: ${milestone.title}`,
|
||||
description: buildValidationDescription({ mission, milestone, docs }),
|
||||
parentId: "",
|
||||
originKind: generatedOriginKind(ctx, "validation"),
|
||||
originId: validationOriginId(mission.id, milestone.id),
|
||||
}));
|
||||
|
||||
if (input.dryRun) {
|
||||
return {
|
||||
missionIssueId: mission.id,
|
||||
milestoneCount,
|
||||
featureCount,
|
||||
validationCount,
|
||||
createdIssueIds: [],
|
||||
updatedIssueIds: [],
|
||||
issues: [...milestoneSpecs, ...featureSpecs, ...validationSpecs].map((spec) => ({
|
||||
kind: spec.kind,
|
||||
key: spec.key,
|
||||
issueId: null,
|
||||
identifier: null,
|
||||
title: spec.title,
|
||||
created: false,
|
||||
blockedByIssueIds: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const milestoneIssueIds = new Map<string, string>();
|
||||
const featureIssueIds = new Map<string, string>();
|
||||
const validationIssueIds = new Map<string, string>();
|
||||
const resultIssues: DecomposedIssueResult[] = [];
|
||||
const createdIssueIds: string[] = [];
|
||||
const updatedIssueIds: string[] = [];
|
||||
|
||||
for (const spec of milestoneSpecs) {
|
||||
const { issue, created } = await upsertGeneratedIssue(ctx, mission, input.companyId, actor, spec);
|
||||
await recordLink(ctx, mission.id, spec, issue.id);
|
||||
milestoneIssueIds.set(spec.key, issue.id);
|
||||
if (created) createdIssueIds.push(issue.id);
|
||||
else updatedIssueIds.push(issue.id);
|
||||
resultIssues.push({
|
||||
kind: spec.kind,
|
||||
key: spec.key,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title,
|
||||
created,
|
||||
blockedByIssueIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const milestone of featurePlan.milestones) {
|
||||
const parentId = milestoneIssueIds.get(milestone.id);
|
||||
if (!parentId) throw new Error(`Missing generated milestone issue for ${milestone.id}`);
|
||||
|
||||
for (const feature of milestone.features) {
|
||||
const spec = featureSpecs.find((candidate) => candidate.key === feature.id);
|
||||
if (!spec) throw new Error(`Missing generated feature spec for ${feature.id}`);
|
||||
const { issue, created } = await upsertGeneratedIssue(
|
||||
ctx,
|
||||
mission,
|
||||
input.companyId,
|
||||
actor,
|
||||
{ ...spec, parentId },
|
||||
);
|
||||
await recordLink(ctx, mission.id, { ...spec, parentId }, issue.id);
|
||||
featureIssueIds.set(feature.id, issue.id);
|
||||
if (created) createdIssueIds.push(issue.id);
|
||||
else updatedIssueIds.push(issue.id);
|
||||
resultIssues.push({
|
||||
kind: "feature",
|
||||
key: feature.id,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title,
|
||||
created,
|
||||
blockedByIssueIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
const validationSpec = validationSpecs.find((candidate) => candidate.key === `${milestone.id}:validation`);
|
||||
if (!validationSpec) throw new Error(`Missing validation spec for ${milestone.id}`);
|
||||
const { issue, created } = await upsertGeneratedIssue(
|
||||
ctx,
|
||||
mission,
|
||||
input.companyId,
|
||||
actor,
|
||||
{ ...validationSpec, parentId },
|
||||
);
|
||||
await recordLink(ctx, mission.id, { ...validationSpec, parentId }, issue.id);
|
||||
validationIssueIds.set(milestone.id, issue.id);
|
||||
if (created) createdIssueIds.push(issue.id);
|
||||
else updatedIssueIds.push(issue.id);
|
||||
resultIssues.push({
|
||||
kind: "validation",
|
||||
key: `${milestone.id}:validation`,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title,
|
||||
created,
|
||||
blockedByIssueIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
const blockersByIssueId = new Map<string, string[]>();
|
||||
|
||||
for (const milestone of featurePlan.milestones) {
|
||||
const milestoneIssueId = milestoneIssueIds.get(milestone.id);
|
||||
const validationIssueId = validationIssueIds.get(milestone.id);
|
||||
if (!milestoneIssueId || !validationIssueId) {
|
||||
throw new Error(`Missing generated milestone or validation issue for ${milestone.id}`);
|
||||
}
|
||||
|
||||
const dependencyValidationIds = milestone.depends_on
|
||||
.map((dependencyId) => validationIssueIds.get(dependencyId))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
blockersByIssueId.set(milestoneIssueId, [...new Set([validationIssueId, ...dependencyValidationIds])]);
|
||||
|
||||
const milestoneFeatureIds = milestone.features
|
||||
.map((feature) => featureIssueIds.get(feature.id))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
blockersByIssueId.set(validationIssueId, [...new Set(milestoneFeatureIds)]);
|
||||
|
||||
for (const feature of milestone.features) {
|
||||
const featureIssueId = featureIssueIds.get(feature.id);
|
||||
if (!featureIssueId) throw new Error(`Missing generated feature issue for ${feature.id}`);
|
||||
|
||||
const dependencyFeatureIds = feature.depends_on
|
||||
.map((dependencyId) => featureIssueIds.get(dependencyId))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
blockersByIssueId.set(featureIssueId, [...new Set([...dependencyFeatureIds, ...dependencyValidationIds])]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resultIssue of resultIssues) {
|
||||
if (!resultIssue.issueId) continue;
|
||||
const blockedByIssueIds = blockersByIssueId.get(resultIssue.issueId) ?? [];
|
||||
await ctx.issues.relations.setBlockedBy(resultIssue.issueId, blockedByIssueIds, input.companyId, actor);
|
||||
await ctx.issues.update(
|
||||
resultIssue.issueId,
|
||||
{ status: blockedByIssueIds.length > 0 ? "blocked" : "todo" },
|
||||
input.companyId,
|
||||
actor,
|
||||
);
|
||||
resultIssue.blockedByIssueIds = blockedByIssueIds;
|
||||
}
|
||||
|
||||
if (createdIssueIds.length > 0) {
|
||||
const body = [
|
||||
"<!-- paperclip:plugin-missions:decompose -->",
|
||||
"Mission decomposition synced by the Missions plugin.",
|
||||
"",
|
||||
`- Created issues: ${createdIssueIds.length}`,
|
||||
`- Updated issues: ${updatedIssueIds.length}`,
|
||||
`- Milestones: ${milestoneCount}`,
|
||||
`- Features: ${featureCount}`,
|
||||
`- Validations: ${validationCount}`,
|
||||
].join("\n");
|
||||
await ctx.issues.createComment(mission.id, body, input.companyId, {
|
||||
authorAgentId: actor.actorAgentId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
missionIssueId: mission.id,
|
||||
milestoneCount,
|
||||
featureCount,
|
||||
validationCount,
|
||||
createdIssueIds,
|
||||
updatedIssueIds: [...new Set(updatedIssueIds.filter((issueId) => !createdIssueIds.includes(issueId)))],
|
||||
issues: resultIssues,
|
||||
};
|
||||
}
|
||||
@@ -29,17 +29,21 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issues.checkout",
|
||||
"issues.wakeup",
|
||||
"issues.orchestration.read",
|
||||
"issue.comments.create",
|
||||
"issue.relations.read",
|
||||
"issue.relations.write",
|
||||
"issue.documents.read",
|
||||
"issue.documents.write",
|
||||
"issue.subtree.read",
|
||||
"agents.read",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.action.register",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
MISSION_FINDING_WAIVER_MARKER_PREFIX,
|
||||
MISSION_REQUIRED_DOCUMENT_KEYS,
|
||||
buildMissionFindingWaiverEntry,
|
||||
parseMissionFeaturesDocument,
|
||||
parseMissionValidationContractDocument,
|
||||
parseMissionValidationReportDocument,
|
||||
@@ -16,6 +15,7 @@ export {
|
||||
export { isMissionRequiredDocumentKey } from "./mission/index.js";
|
||||
|
||||
import {
|
||||
buildMissionFindingWaiverEntry as buildMissionFindingWaiverEntryBase,
|
||||
isMissionValidationReportKey,
|
||||
missionFindingWaiverMarker,
|
||||
parseMissionFindingWaivers as parseMissionFindingWaiversBase,
|
||||
@@ -77,6 +77,18 @@ export function buildMissionEventEntry(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMissionFindingWaiverEntry(input: {
|
||||
findingId: string;
|
||||
rationale: string;
|
||||
actorLabel: string;
|
||||
createdAt: Date | string;
|
||||
}) {
|
||||
return {
|
||||
marker: missionFindingWaiverMarker(input.findingId),
|
||||
body: buildMissionFindingWaiverEntryBase(input),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendDecisionLogEntry(
|
||||
existingBody: string | null | undefined,
|
||||
entry: { marker: string; body: string },
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import type { Issue, PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
MISSION_REQUIRED_DOCUMENT_KEYS,
|
||||
isValidationReportDocumentKey,
|
||||
parseMissionFeaturesDocument,
|
||||
parseMissionValidationContractDocument,
|
||||
parseMissionValidationReportDocument,
|
||||
parseValidationReportRound,
|
||||
type MissionRequiredDocumentKey,
|
||||
type MissionState,
|
||||
} from "./mission-documents.js";
|
||||
|
||||
type MissionInitializationActor = {
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type PersistedMissionRecord = {
|
||||
missionIssueId: string;
|
||||
companyId: string;
|
||||
state: MissionState;
|
||||
billingCode: string | null;
|
||||
rootOriginKind: string | null;
|
||||
rootOriginId: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
initializedAt: string;
|
||||
updatedAt: string;
|
||||
hasRootLink: boolean;
|
||||
hasInitializationEvent: boolean;
|
||||
};
|
||||
|
||||
export type MissionParseProblem = {
|
||||
issueId: string;
|
||||
key: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MissionDocumentChecklistItem = {
|
||||
key: MissionRequiredDocumentKey;
|
||||
present: boolean;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
export type MissionValidationReportSummary = {
|
||||
key: string;
|
||||
title: string | null;
|
||||
round: number;
|
||||
summary: string;
|
||||
findingCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MissionIssueSummary = {
|
||||
issueId: string;
|
||||
isMission: boolean;
|
||||
canInitialize: boolean;
|
||||
initializeDisabledReason: string | null;
|
||||
state: MissionState | null;
|
||||
nextAction: string;
|
||||
documentChecklist: MissionDocumentChecklistItem[];
|
||||
missingRequiredDocumentKeys: MissionRequiredDocumentKey[];
|
||||
parseProblems: MissionParseProblem[];
|
||||
validationReports: MissionValidationReportSummary[];
|
||||
openFindingCount: number;
|
||||
settings: {
|
||||
billingCode: string | null;
|
||||
rootOriginKind: string | null;
|
||||
rootOriginId: string | null;
|
||||
databaseNamespace: string;
|
||||
requiredDocumentKeys: readonly MissionRequiredDocumentKey[];
|
||||
};
|
||||
persistence: PersistedMissionRecord | null;
|
||||
};
|
||||
|
||||
export type MissionInitializationResult = {
|
||||
created: boolean;
|
||||
issueId: string;
|
||||
createdDocumentKeys: MissionRequiredDocumentKey[];
|
||||
summary: MissionIssueSummary;
|
||||
};
|
||||
|
||||
const PERSISTENCE_STATE_NAMESPACE = "mission-initialization";
|
||||
const PERSISTENCE_STATE_KEY = "persisted";
|
||||
const DEFAULT_MISSION_STATE: MissionState = "draft";
|
||||
|
||||
const REQUIRED_DOCUMENT_TITLES: Record<MissionRequiredDocumentKey, string> = {
|
||||
plan: "Mission Plan",
|
||||
"mission-brief": "Mission Brief",
|
||||
"validation-contract": "Validation Contract",
|
||||
features: "Features",
|
||||
"worker-guidelines": "Worker Guidelines",
|
||||
services: "Services",
|
||||
"knowledge-base": "Knowledge Base",
|
||||
"decision-log": "Decision Log",
|
||||
};
|
||||
|
||||
function pluginOriginPrefix(ctx: PluginContext) {
|
||||
return `plugin:${ctx.manifest.id}`;
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function getPersistedMissionFallback(ctx: PluginContext, issueId: string) {
|
||||
const fallback = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
namespace: PERSISTENCE_STATE_NAMESPACE,
|
||||
stateKey: PERSISTENCE_STATE_KEY,
|
||||
});
|
||||
return fallback && typeof fallback === "object" ? fallback as PersistedMissionRecord : null;
|
||||
}
|
||||
|
||||
async function setPersistedMissionFallback(ctx: PluginContext, issueId: string, record: PersistedMissionRecord) {
|
||||
await ctx.state.set(
|
||||
{
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
namespace: PERSISTENCE_STATE_NAMESPACE,
|
||||
stateKey: PERSISTENCE_STATE_KEY,
|
||||
},
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
async function getMissionByRootIssue(ctx: PluginContext, companyId: string, issueId: string) {
|
||||
const namespace = ctx.db.namespace;
|
||||
const rows = await ctx.db.query<{
|
||||
mission_issue_id: string;
|
||||
company_id: string;
|
||||
state: string;
|
||||
billing_code: string | null;
|
||||
root_origin_kind: string | null;
|
||||
root_origin_id: string | null;
|
||||
settings_json_text: string;
|
||||
initialized_at: string | Date;
|
||||
updated_at: string | Date;
|
||||
has_root_link: boolean;
|
||||
has_initialization_event: boolean;
|
||||
}>(
|
||||
`SELECT
|
||||
m.mission_issue_id,
|
||||
m.company_id,
|
||||
m.state,
|
||||
m.billing_code,
|
||||
m.root_origin_kind,
|
||||
m.root_origin_id,
|
||||
m.settings_json::text AS settings_json_text,
|
||||
m.initialized_at,
|
||||
m.updated_at,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM ${namespace}.mission_issue_links l
|
||||
WHERE l.mission_issue_id = m.mission_issue_id
|
||||
AND l.generated_issue_id = m.mission_issue_id
|
||||
AND l.generated_kind = 'root'
|
||||
AND l.generated_key = 'root'
|
||||
) AS has_root_link,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM ${namespace}.mission_events e
|
||||
WHERE e.mission_issue_id = m.mission_issue_id
|
||||
AND e.event_key = 'initialized'
|
||||
) AS has_initialization_event
|
||||
FROM ${namespace}.missions m
|
||||
WHERE m.company_id = $1
|
||||
AND m.mission_issue_id = $2
|
||||
LIMIT 1`,
|
||||
[companyId, issueId],
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return getPersistedMissionFallback(ctx, issueId);
|
||||
|
||||
const record: PersistedMissionRecord = {
|
||||
missionIssueId: row.mission_issue_id,
|
||||
companyId: row.company_id,
|
||||
state: (row.state as MissionState) ?? DEFAULT_MISSION_STATE,
|
||||
billingCode: row.billing_code,
|
||||
rootOriginKind: row.root_origin_kind,
|
||||
rootOriginId: row.root_origin_id,
|
||||
settings: JSON.parse(row.settings_json_text || "{}") as Record<string, unknown>,
|
||||
initializedAt: normalizeTimestamp(row.initialized_at),
|
||||
updatedAt: normalizeTimestamp(row.updated_at),
|
||||
hasRootLink: Boolean(row.has_root_link),
|
||||
hasInitializationEvent: Boolean(row.has_initialization_event),
|
||||
};
|
||||
await setPersistedMissionFallback(ctx, issueId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function buildDocumentBody(key: MissionRequiredDocumentKey, issue: Issue) {
|
||||
switch (key) {
|
||||
case "plan":
|
||||
return [
|
||||
"# Mission Plan",
|
||||
"",
|
||||
"## Objective",
|
||||
issue.title,
|
||||
"",
|
||||
"## Scope",
|
||||
issue.description?.trim() || "Capture the concrete operator scope for this mission.",
|
||||
"",
|
||||
"## Risks",
|
||||
"- Document the main execution risks before decomposition.",
|
||||
].join("\n");
|
||||
case "mission-brief":
|
||||
return [
|
||||
"# Mission Brief",
|
||||
"",
|
||||
`- Root issue: ${issue.title}`,
|
||||
`- Identifier: ${issue.identifier ?? issue.id}`,
|
||||
`- Billing code: ${issue.billingCode ?? `mission:${issue.id}`}`,
|
||||
"",
|
||||
"## Desired outcome",
|
||||
"Describe the operator-visible outcome this mission must deliver.",
|
||||
].join("\n");
|
||||
case "validation-contract":
|
||||
return JSON.stringify(
|
||||
{
|
||||
assertions: [
|
||||
{
|
||||
id: "VAL-MISSION-001",
|
||||
title: "Mission outcome is validated",
|
||||
user_value: "The mission solves the root issue for the operator.",
|
||||
scope: "Root mission issue",
|
||||
setup: "Replace this placeholder with the setup needed to validate the mission.",
|
||||
steps: ["Replace this placeholder with concrete validation steps."],
|
||||
oracle: "Reference the evidence that proves the mission outcome worked.",
|
||||
tooling: ["manual_review"],
|
||||
evidence: [
|
||||
{
|
||||
kind: "primary",
|
||||
description: "Attach the operator-facing proof artifact for the completed mission.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
claimed_by: [],
|
||||
status: "draft",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "features":
|
||||
return JSON.stringify(
|
||||
{
|
||||
milestones: [
|
||||
{
|
||||
id: "MILESTONE-MISSION-001",
|
||||
title: "Initial mission slice",
|
||||
summary: "Replace this placeholder with the first bounded milestone.",
|
||||
features: [
|
||||
{
|
||||
id: "FEAT-MISSION-001",
|
||||
title: "Draft feature slice",
|
||||
kind: "original",
|
||||
summary: "Replace this placeholder with the first concrete feature or task slice.",
|
||||
acceptance_criteria: ["Replace this placeholder with concrete acceptance criteria."],
|
||||
claimed_assertion_ids: [],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "worker-guidelines":
|
||||
return [
|
||||
"# Worker Guidelines",
|
||||
"",
|
||||
"- Keep changes scoped to the root mission goal.",
|
||||
"- Leave operator-readable progress in comments and documents.",
|
||||
"- Capture verification evidence in the validation artifacts.",
|
||||
].join("\n");
|
||||
case "services":
|
||||
return [
|
||||
"# Services",
|
||||
"",
|
||||
"- Record external services, APIs, and environments this mission depends on.",
|
||||
].join("\n");
|
||||
case "knowledge-base":
|
||||
return [
|
||||
"# Knowledge Base",
|
||||
"",
|
||||
"- Capture durable discoveries, links, and references for this mission here.",
|
||||
].join("\n");
|
||||
case "decision-log":
|
||||
return [
|
||||
"# Decision Log",
|
||||
"",
|
||||
"- Record mission-level decisions, tradeoffs, and approvals here.",
|
||||
].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMissionDocuments(ctx: PluginContext, companyId: string, issue: Issue) {
|
||||
const existing = await ctx.issues.documents.list(issue.id, companyId);
|
||||
const existingKeys = new Set(existing.map((document) => document.key));
|
||||
const createdDocumentKeys: MissionRequiredDocumentKey[] = [];
|
||||
|
||||
for (const key of MISSION_REQUIRED_DOCUMENT_KEYS) {
|
||||
if (existingKeys.has(key)) continue;
|
||||
await ctx.issues.documents.upsert({
|
||||
issueId: issue.id,
|
||||
companyId,
|
||||
key,
|
||||
title: REQUIRED_DOCUMENT_TITLES[key],
|
||||
body: buildDocumentBody(key, issue),
|
||||
changeSummary: "Initialized mission document bundle",
|
||||
});
|
||||
createdDocumentKeys.push(key);
|
||||
}
|
||||
|
||||
return createdDocumentKeys;
|
||||
}
|
||||
|
||||
async function ensureRootIssueMetadata(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
issue: Issue,
|
||||
actor: MissionInitializationActor,
|
||||
) {
|
||||
const originPrefix = pluginOriginPrefix(ctx);
|
||||
const originKind = issue.originKind ?? "manual";
|
||||
const canAdoptOrigin = originKind === "manual" || originKind === originPrefix || originKind.startsWith(`${originPrefix}:`);
|
||||
if (!canAdoptOrigin) {
|
||||
throw new Error(`Issue origin '${originKind}' is already owned by another workflow`);
|
||||
}
|
||||
|
||||
const patch: Parameters<PluginContext["issues"]["update"]>[1] = {};
|
||||
const finalOriginKind = originKind === "manual" ? originPrefix : originKind;
|
||||
const finalOriginId = issue.originId ?? `mission:${issue.id}`;
|
||||
const finalBillingCode = issue.billingCode ?? `mission:${issue.id}`;
|
||||
|
||||
if (issue.originKind !== finalOriginKind) patch.originKind = finalOriginKind as Issue["originKind"];
|
||||
if (issue.originId !== finalOriginId) patch.originId = finalOriginId;
|
||||
if (issue.billingCode !== finalBillingCode) patch.billingCode = finalBillingCode;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return {
|
||||
issue,
|
||||
finalOriginKind,
|
||||
finalOriginId,
|
||||
finalBillingCode,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await ctx.issues.update(issue.id, patch, companyId, actor);
|
||||
return {
|
||||
issue: updated,
|
||||
finalOriginKind,
|
||||
finalOriginId,
|
||||
finalBillingCode,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPersistedSettings(ctx: PluginContext, issue: Issue, billingCode: string) {
|
||||
return {
|
||||
pluginId: ctx.manifest.id,
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
requiredDocumentKeys: [...MISSION_REQUIRED_DOCUMENT_KEYS],
|
||||
initializedFromStatus: issue.status,
|
||||
rootIdentifier: issue.identifier ?? issue.id,
|
||||
billingCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMissionInitialization(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
companyId: string;
|
||||
issue: Issue;
|
||||
rootOriginKind: string;
|
||||
rootOriginId: string;
|
||||
billingCode: string;
|
||||
createdDocumentKeys: MissionRequiredDocumentKey[];
|
||||
actor: MissionInitializationActor;
|
||||
},
|
||||
) {
|
||||
const namespace = ctx.db.namespace;
|
||||
const settings = buildPersistedSettings(ctx, input.issue, input.billingCode);
|
||||
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${namespace}.missions (
|
||||
mission_issue_id,
|
||||
company_id,
|
||||
state,
|
||||
billing_code,
|
||||
root_origin_kind,
|
||||
root_origin_id,
|
||||
settings_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
ON CONFLICT (mission_issue_id) DO UPDATE SET
|
||||
billing_code = EXCLUDED.billing_code,
|
||||
root_origin_kind = EXCLUDED.root_origin_kind,
|
||||
root_origin_id = EXCLUDED.root_origin_id,
|
||||
updated_at = now()`,
|
||||
[
|
||||
input.issue.id,
|
||||
input.companyId,
|
||||
DEFAULT_MISSION_STATE,
|
||||
input.billingCode,
|
||||
input.rootOriginKind,
|
||||
input.rootOriginId,
|
||||
JSON.stringify(settings),
|
||||
],
|
||||
);
|
||||
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${namespace}.mission_issue_links (
|
||||
mission_issue_id,
|
||||
generated_issue_id,
|
||||
generated_kind,
|
||||
generated_key,
|
||||
origin_kind,
|
||||
origin_id
|
||||
) VALUES ($1, $2, 'root', 'root', $3, $4)
|
||||
ON CONFLICT (mission_issue_id, generated_kind, generated_key) DO UPDATE SET
|
||||
origin_kind = EXCLUDED.origin_kind,
|
||||
origin_id = EXCLUDED.origin_id,
|
||||
updated_at = now()`,
|
||||
[input.issue.id, input.issue.id, input.rootOriginKind, input.rootOriginId],
|
||||
);
|
||||
|
||||
const payload = {
|
||||
actorAgentId: input.actor.actorAgentId ?? null,
|
||||
actorUserId: input.actor.actorUserId ?? null,
|
||||
actorRunId: input.actor.actorRunId ?? null,
|
||||
createdDocumentKeys: input.createdDocumentKeys,
|
||||
};
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${namespace}.mission_events (
|
||||
mission_issue_id,
|
||||
company_id,
|
||||
event_key,
|
||||
event_type,
|
||||
payload_json
|
||||
) VALUES ($1, $2, 'initialized', 'mission_initialized', $3::jsonb)
|
||||
ON CONFLICT (mission_issue_id, event_key) DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
updated_at = now()`,
|
||||
[input.issue.id, input.companyId, JSON.stringify(payload)],
|
||||
);
|
||||
|
||||
const record: PersistedMissionRecord = {
|
||||
missionIssueId: input.issue.id,
|
||||
companyId: input.companyId,
|
||||
state: DEFAULT_MISSION_STATE,
|
||||
billingCode: input.billingCode,
|
||||
rootOriginKind: input.rootOriginKind,
|
||||
rootOriginId: input.rootOriginId,
|
||||
settings,
|
||||
initializedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
hasRootLink: true,
|
||||
hasInitializationEvent: true,
|
||||
};
|
||||
await setPersistedMissionFallback(ctx, input.issue.id, record);
|
||||
}
|
||||
|
||||
export async function buildMissionIssueSummary(ctx: PluginContext, companyId: string, issueId: string): Promise<MissionIssueSummary> {
|
||||
const issue = await ctx.issues.get(issueId, companyId);
|
||||
if (!issue) throw new Error(`Issue not found: ${issueId}`);
|
||||
|
||||
const persisted = await getMissionByRootIssue(ctx, companyId, issueId);
|
||||
const documents = await ctx.issues.documents.list(issueId, companyId);
|
||||
const checklist: MissionDocumentChecklistItem[] = MISSION_REQUIRED_DOCUMENT_KEYS.map((key) => {
|
||||
const document = documents.find((candidate) => candidate.key === key);
|
||||
return {
|
||||
key,
|
||||
present: Boolean(document),
|
||||
title: document?.title ?? null,
|
||||
};
|
||||
});
|
||||
const missingRequiredDocumentKeys = checklist.filter((item) => !item.present).map((item) => item.key);
|
||||
const parseProblems: MissionParseProblem[] = [];
|
||||
const validationReports: MissionValidationReportSummary[] = [];
|
||||
let openFindingCount = 0;
|
||||
|
||||
const relevantKeys = new Set(
|
||||
documents
|
||||
.map((document) => document.key)
|
||||
.filter((key) => key === "validation-contract" || key === "features" || isValidationReportDocumentKey(key)),
|
||||
);
|
||||
|
||||
for (const key of relevantKeys) {
|
||||
const document = await ctx.issues.documents.get(issueId, key, companyId);
|
||||
if (!document) continue;
|
||||
try {
|
||||
if (key === "validation-contract") {
|
||||
parseMissionValidationContractDocument(document.body);
|
||||
continue;
|
||||
}
|
||||
if (key === "features") {
|
||||
parseMissionFeaturesDocument(document.body);
|
||||
continue;
|
||||
}
|
||||
if (!isValidationReportDocumentKey(key)) continue;
|
||||
const parsed = parseMissionValidationReportDocument(document.body, {
|
||||
round: parseValidationReportRound(key) ?? undefined,
|
||||
});
|
||||
validationReports.push({
|
||||
key,
|
||||
title: document.title,
|
||||
round: parsed.round,
|
||||
summary: parsed.summary,
|
||||
findingCount: parsed.findings.length,
|
||||
updatedAt: normalizeTimestamp(document.updatedAt),
|
||||
});
|
||||
openFindingCount += parsed.findings.filter((finding) => finding.status === "open").length;
|
||||
} catch (error) {
|
||||
parseProblems.push({
|
||||
issueId,
|
||||
key,
|
||||
message: stringifyError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validationReports.sort((left, right) => left.round - right.round);
|
||||
|
||||
const originPrefix = pluginOriginPrefix(ctx);
|
||||
const issueOriginKind = issue.originKind ?? null;
|
||||
const initializeDisabledReason = persisted
|
||||
? "This issue is already initialized as a mission."
|
||||
: issueOriginKind === "manual" ||
|
||||
issueOriginKind === originPrefix ||
|
||||
(issueOriginKind !== null && issueOriginKind.startsWith(`${originPrefix}:`))
|
||||
? null
|
||||
: `Issue origin '${issue.originKind}' is already owned by another workflow.`;
|
||||
|
||||
const nextAction = !persisted
|
||||
? "Initialize this issue as a mission to create the namespace rows and document bundle."
|
||||
: missingRequiredDocumentKeys.length > 0
|
||||
? "Complete the missing mission documents before decomposition."
|
||||
: parseProblems.length > 0
|
||||
? "Repair invalid mission documents before decomposition."
|
||||
: persisted.state === "draft"
|
||||
? "Review the mission brief, validation contract, and feature plan before decomposition."
|
||||
: "Mission initialization is complete.";
|
||||
|
||||
return {
|
||||
issueId,
|
||||
isMission: Boolean(persisted),
|
||||
canInitialize: !persisted && initializeDisabledReason === null,
|
||||
initializeDisabledReason,
|
||||
state: persisted?.state ?? null,
|
||||
nextAction,
|
||||
documentChecklist: checklist,
|
||||
missingRequiredDocumentKeys,
|
||||
parseProblems,
|
||||
validationReports,
|
||||
openFindingCount,
|
||||
settings: {
|
||||
billingCode: persisted?.billingCode ?? issue.billingCode ?? null,
|
||||
rootOriginKind: persisted?.rootOriginKind ?? issue.originKind ?? null,
|
||||
rootOriginId: persisted?.rootOriginId ?? issue.originId ?? null,
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
requiredDocumentKeys: MISSION_REQUIRED_DOCUMENT_KEYS,
|
||||
},
|
||||
persistence: persisted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeMission(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
},
|
||||
): Promise<MissionInitializationResult> {
|
||||
const actor = {
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
};
|
||||
const issue = await ctx.issues.get(input.issueId, input.companyId);
|
||||
if (!issue) {
|
||||
throw new Error(`Issue not found: ${input.issueId}`);
|
||||
}
|
||||
|
||||
if (actor.actorAgentId && actor.actorRunId && issue.status === "in_progress" && issue.assigneeAgentId === actor.actorAgentId) {
|
||||
await ctx.issues.assertCheckoutOwner({
|
||||
issueId: issue.id,
|
||||
companyId: input.companyId,
|
||||
actorAgentId: actor.actorAgentId,
|
||||
actorRunId: actor.actorRunId,
|
||||
});
|
||||
}
|
||||
|
||||
const persistedBefore = await getMissionByRootIssue(ctx, input.companyId, issue.id);
|
||||
const {
|
||||
issue: updatedIssue,
|
||||
finalOriginKind,
|
||||
finalOriginId,
|
||||
finalBillingCode,
|
||||
} = await ensureRootIssueMetadata(ctx, input.companyId, issue, actor);
|
||||
|
||||
const createdDocumentKeys = await ensureMissionDocuments(ctx, input.companyId, updatedIssue);
|
||||
await persistMissionInitialization(ctx, {
|
||||
companyId: input.companyId,
|
||||
issue: updatedIssue,
|
||||
rootOriginKind: finalOriginKind,
|
||||
rootOriginId: finalOriginId,
|
||||
billingCode: finalBillingCode,
|
||||
createdDocumentKeys,
|
||||
actor,
|
||||
});
|
||||
|
||||
return {
|
||||
created: !persistedBefore,
|
||||
issueId: updatedIssue.id,
|
||||
createdDocumentKeys,
|
||||
summary: await buildMissionIssueSummary(ctx, input.companyId, updatedIssue.id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
import type { Issue, PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
MISSION_REQUIRED_DOCUMENT_KEYS,
|
||||
appendDecisionLogEntry,
|
||||
buildMissionEventEntry,
|
||||
buildMissionFindingWaiverEntry,
|
||||
isMissionRequiredDocumentKey,
|
||||
isValidationReportDocumentKey,
|
||||
parseMissionFeaturesDocument,
|
||||
parseMissionFindingWaivers,
|
||||
parseMissionValidationContractDocument,
|
||||
parseMissionValidationReportDocument,
|
||||
parseValidationReportRound,
|
||||
type MissionFeaturesDocument,
|
||||
type MissionFinding,
|
||||
type MissionFindingWaiverRecord,
|
||||
type MissionRequiredDocumentKey,
|
||||
type MissionState,
|
||||
type MissionValidationContract,
|
||||
type MissionValidationReport,
|
||||
} from "./mission-documents.js";
|
||||
|
||||
export type MissionIssueKind = "mission" | "milestone" | "feature" | "validation" | "fix" | "fix_loop" | "other";
|
||||
export type MissionStopReason =
|
||||
| "approval_required"
|
||||
| "budget_incident"
|
||||
| "invocation_block"
|
||||
| "unresolved_blockers"
|
||||
| "missing_assignee"
|
||||
| "paused_assignee"
|
||||
| "parse_error"
|
||||
| "max_validation_rounds";
|
||||
|
||||
export interface MissionDocumentChecklistItem {
|
||||
key: MissionRequiredDocumentKey;
|
||||
present: boolean;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface MissionParseProblem {
|
||||
issueId: string;
|
||||
key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MissionWorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: Issue["status"];
|
||||
assigneeAgentId: string | null;
|
||||
kind: MissionIssueKind;
|
||||
hasUnresolvedBlockers: boolean;
|
||||
originKind: string | null;
|
||||
originId: string | null;
|
||||
}
|
||||
|
||||
export interface MissionValidationReportProjection {
|
||||
issueId: string;
|
||||
documentKey: string;
|
||||
title: string | null;
|
||||
round: number;
|
||||
validatorRole: MissionValidationReport["validator_role"];
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MissionFindingProjection extends MissionFinding {
|
||||
sourceIssueId: string;
|
||||
sourceReportKey: string;
|
||||
sourceReportTitle: string | null;
|
||||
round: number;
|
||||
validatorRole: MissionValidationReport["validator_role"];
|
||||
computedStatus: MissionFinding["status"];
|
||||
fixIssueId: string | null;
|
||||
waiver: MissionFindingWaiverRecord | null;
|
||||
}
|
||||
|
||||
export interface MissionSummary {
|
||||
isMission: boolean;
|
||||
missionIssueId: string;
|
||||
missionIdentifier: string | null;
|
||||
state: MissionState | null;
|
||||
documentChecklist: MissionDocumentChecklistItem[];
|
||||
missingRequiredDocumentKeys: MissionRequiredDocumentKey[];
|
||||
parseProblems: MissionParseProblem[];
|
||||
validationReports: MissionValidationReportProjection[];
|
||||
findings: MissionFindingProjection[];
|
||||
workItems: MissionWorkItem[];
|
||||
wakeableIssueIds: string[];
|
||||
stopReasons: MissionStopReason[];
|
||||
nextAction: string;
|
||||
openApprovalCount: number;
|
||||
openBudgetIncidentCount: number;
|
||||
invocationBlockCount: number;
|
||||
latestValidationRound: number;
|
||||
}
|
||||
|
||||
export interface MissionAdvanceResult {
|
||||
issueId: string;
|
||||
outcome: "paused" | "woke_issues" | "created_fixes" | "complete" | "noop";
|
||||
stopReason: MissionStopReason | null;
|
||||
wokenIssueIds: string[];
|
||||
createdFixIssueIds: string[];
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MissionWaiveFindingResult {
|
||||
issueId: string;
|
||||
findingId: string;
|
||||
waived: boolean;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
type PluginIssueOriginKindLike = `plugin:${string}`;
|
||||
|
||||
const DEFAULT_MAX_VALIDATION_ROUNDS = 3;
|
||||
const ACTIVE_AGENT_STATUSES = new Set(["active", "idle", "running"]);
|
||||
const PAUSED_AGENT_STATUSES = new Set(["paused", "pending_approval", "terminated", "error"]);
|
||||
|
||||
function missionOriginPrefix(ctx: PluginContext) {
|
||||
return `plugin:${ctx.manifest.id}` as PluginIssueOriginKindLike;
|
||||
}
|
||||
|
||||
function kindFromOrigin(originKind: string | null | undefined, ctx: PluginContext): MissionIssueKind {
|
||||
const prefix = missionOriginPrefix(ctx);
|
||||
if (!originKind) return "other";
|
||||
if (originKind === prefix) return "mission";
|
||||
if (originKind === `${prefix}:milestone`) return "milestone";
|
||||
if (originKind === `${prefix}:feature`) return "feature";
|
||||
if (originKind === `${prefix}:validation`) return "validation";
|
||||
if (originKind === `${prefix}:fix`) return "fix";
|
||||
if (originKind === `${prefix}:fix_loop` || originKind === `${prefix}:fix-loop`) return "fix_loop";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: Issue["status"]) {
|
||||
return status === "done" || status === "cancelled";
|
||||
}
|
||||
|
||||
function isWakeableStatus(status: Issue["status"]) {
|
||||
return status === "todo" || status === "in_progress";
|
||||
}
|
||||
|
||||
function isResolvableApprovalStatus(status: string) {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
return normalized === "approved" || normalized === "denied" || normalized === "cancelled";
|
||||
}
|
||||
|
||||
function buildActorLabel(actor: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }) {
|
||||
if (actor.actorUserId) return `user:${actor.actorUserId}`;
|
||||
if (actor.actorAgentId) return `agent:${actor.actorAgentId}`;
|
||||
if (actor.actorRunId) return `run:${actor.actorRunId}`;
|
||||
return "plugin";
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function unique<T>(values: T[]) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
async function loadRelevantDocuments(ctx: PluginContext, companyId: string, issueId: string) {
|
||||
const documents = await ctx.issues.documents.list(issueId, companyId);
|
||||
const relevant = documents.filter(
|
||||
(document) => isMissionRequiredDocumentKey(document.key) || isValidationReportDocumentKey(document.key),
|
||||
);
|
||||
|
||||
const entries = await Promise.all(
|
||||
relevant.map(async (document) => ({
|
||||
summary: document,
|
||||
document: await ctx.issues.documents.get(issueId, document.key, companyId),
|
||||
})),
|
||||
);
|
||||
|
||||
return entries.filter((entry): entry is { summary: typeof relevant[number]; document: NonNullable<typeof entry.document> } => {
|
||||
return entry.document !== null;
|
||||
});
|
||||
}
|
||||
|
||||
async function parseMissionDocuments(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
subtreeIssues: Issue[],
|
||||
) {
|
||||
const checklist: MissionDocumentChecklistItem[] = MISSION_REQUIRED_DOCUMENT_KEYS.map((key) => ({
|
||||
key,
|
||||
present: false,
|
||||
title: null,
|
||||
}));
|
||||
const parseProblems: MissionParseProblem[] = [];
|
||||
const validationReports: Array<{
|
||||
issueId: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
parsed: MissionValidationReport;
|
||||
}> = [];
|
||||
|
||||
let validationContract: MissionValidationContract | null = null;
|
||||
let featuresDocument: MissionFeaturesDocument | null = null;
|
||||
let decisionLogBody = "";
|
||||
|
||||
for (const issue of subtreeIssues) {
|
||||
const documents = await loadRelevantDocuments(ctx, companyId, issue.id);
|
||||
for (const { summary, document } of documents) {
|
||||
if (isMissionRequiredDocumentKey(summary.key) && issue.id === issueId) {
|
||||
const item = checklist.find((candidate) => candidate.key === summary.key);
|
||||
if (item) {
|
||||
item.present = true;
|
||||
item.title = summary.title;
|
||||
}
|
||||
|
||||
try {
|
||||
if (summary.key === "validation-contract") {
|
||||
validationContract = parseMissionValidationContractDocument(document.body);
|
||||
} else if (summary.key === "features") {
|
||||
featuresDocument = parseMissionFeaturesDocument(document.body);
|
||||
} else if (summary.key === "decision-log") {
|
||||
decisionLogBody = document.body;
|
||||
}
|
||||
} catch (error) {
|
||||
parseProblems.push({
|
||||
issueId,
|
||||
key: summary.key,
|
||||
message: stringifyError(error),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isValidationReportDocumentKey(summary.key)) continue;
|
||||
|
||||
try {
|
||||
const round = parseValidationReportRound(summary.key) ?? undefined;
|
||||
validationReports.push({
|
||||
issueId: issue.id,
|
||||
key: summary.key,
|
||||
title: summary.title,
|
||||
updatedAt: summary.updatedAt.toISOString(),
|
||||
parsed: parseMissionValidationReportDocument(document.body, { round }),
|
||||
});
|
||||
} catch (error) {
|
||||
parseProblems.push({
|
||||
issueId: issue.id,
|
||||
key: summary.key,
|
||||
message: stringifyError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationReports.sort((a, b) => a.parsed.round - b.parsed.round);
|
||||
|
||||
return {
|
||||
checklist,
|
||||
parseProblems,
|
||||
validationContract,
|
||||
featuresDocument,
|
||||
validationReports,
|
||||
decisionLogBody,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFindingFixOriginId(missionIssueId: string, findingId: string) {
|
||||
return `${missionIssueId}:fix:${findingId}`;
|
||||
}
|
||||
|
||||
async function buildSummaryInternal(ctx: PluginContext, companyId: string, issueId: string): Promise<{
|
||||
mission: Issue;
|
||||
summary: MissionSummary;
|
||||
validationContract: MissionValidationContract | null;
|
||||
featuresDocument: MissionFeaturesDocument | null;
|
||||
decisionLogBody: string;
|
||||
}> {
|
||||
const mission = await ctx.issues.get(issueId, companyId);
|
||||
if (!mission) {
|
||||
throw new Error(`Mission issue not found: ${issueId}`);
|
||||
}
|
||||
|
||||
const subtree = await ctx.issues.getSubtree(issueId, companyId, { includeRelations: false });
|
||||
const orchestration = await ctx.issues.summaries.getOrchestration({
|
||||
issueId,
|
||||
companyId,
|
||||
includeSubtree: true,
|
||||
billingCode: mission.billingCode ?? null,
|
||||
});
|
||||
const parsedDocuments = await parseMissionDocuments(ctx, companyId, issueId, subtree.issues);
|
||||
const waivers = parseMissionFindingWaivers(parsedDocuments.decisionLogBody);
|
||||
|
||||
const issueMap = new Map(subtree.issues.map((issue) => [issue.id, issue]));
|
||||
const unresolvedBlockersByIssueId = new Map<string, string[]>();
|
||||
for (const [relatedIssueId, relation] of Object.entries(orchestration.relations)) {
|
||||
unresolvedBlockersByIssueId.set(
|
||||
relatedIssueId,
|
||||
relation.blockedBy.filter((blocker) => blocker.status !== "done").map((blocker) => blocker.id),
|
||||
);
|
||||
}
|
||||
|
||||
const fixIssues = subtree.issues.filter((issue) => kindFromOrigin(issue.originKind, ctx) === "fix");
|
||||
const fixIssueByOriginId = new Map(
|
||||
fixIssues.map((issue) => [issue.originId ?? buildFindingFixOriginId(issueId, issue.id), issue]),
|
||||
);
|
||||
|
||||
const findings: MissionFindingProjection[] = parsedDocuments.validationReports.flatMap((report) =>
|
||||
report.parsed.findings.map((finding) => {
|
||||
const waiver = waivers.get(finding.id) ?? null;
|
||||
const fixIssue = fixIssueByOriginId.get(buildFindingFixOriginId(issueId, finding.id)) ?? null;
|
||||
let computedStatus: MissionFinding["status"] = finding.status;
|
||||
if (waiver) computedStatus = "waived";
|
||||
else if (fixIssue?.status === "done") computedStatus = "resolved";
|
||||
else if (fixIssue) computedStatus = "fix_created";
|
||||
|
||||
return {
|
||||
...finding,
|
||||
computedStatus,
|
||||
sourceIssueId: report.issueId,
|
||||
sourceReportKey: report.key,
|
||||
sourceReportTitle: report.title,
|
||||
round: report.parsed.round,
|
||||
validatorRole: report.parsed.validator_role,
|
||||
fixIssueId: fixIssue?.id ?? null,
|
||||
waiver,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const relevantKinds = new Set<MissionIssueKind>(["feature", "validation", "fix", "fix_loop"]);
|
||||
const assigneeCache = new Map<string, Awaited<ReturnType<PluginContext["agents"]["get"]>>>();
|
||||
const workItems: MissionWorkItem[] = [];
|
||||
const wakeableIssueIds: string[] = [];
|
||||
let missingAssignee = false;
|
||||
let pausedAssignee = false;
|
||||
|
||||
for (const issue of subtree.issues) {
|
||||
const kind = kindFromOrigin(issue.originKind, ctx);
|
||||
if (!relevantKinds.has(kind)) continue;
|
||||
|
||||
const unresolvedBlockers = unresolvedBlockersByIssueId.get(issue.id) ?? [];
|
||||
const item: MissionWorkItem = {
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
kind,
|
||||
hasUnresolvedBlockers: unresolvedBlockers.length > 0,
|
||||
originKind: issue.originKind ?? null,
|
||||
originId: issue.originId ?? null,
|
||||
};
|
||||
workItems.push(item);
|
||||
|
||||
if (!isWakeableStatus(issue.status) || item.hasUnresolvedBlockers) continue;
|
||||
if (!issue.assigneeAgentId) {
|
||||
missingAssignee = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!assigneeCache.has(issue.assigneeAgentId)) {
|
||||
assigneeCache.set(issue.assigneeAgentId, await ctx.agents.get(issue.assigneeAgentId, companyId));
|
||||
}
|
||||
const assignee = assigneeCache.get(issue.assigneeAgentId) ?? null;
|
||||
if (!assignee || PAUSED_AGENT_STATUSES.has(assignee.status)) {
|
||||
pausedAssignee = true;
|
||||
continue;
|
||||
}
|
||||
if (!ACTIVE_AGENT_STATUSES.has(assignee.status)) continue;
|
||||
wakeableIssueIds.push(issue.id);
|
||||
}
|
||||
|
||||
const blockingFindings = findings.filter(
|
||||
(finding) => finding.severity === "blocking" && !["resolved", "waived"].includes(finding.computedStatus),
|
||||
);
|
||||
const activeFeatureOrFixIssues = workItems.filter(
|
||||
(item) =>
|
||||
(item.kind === "feature" || item.kind === "fix" || item.kind === "fix_loop") &&
|
||||
!isTerminalStatus(item.status),
|
||||
);
|
||||
const activeValidationIssues = workItems.filter((item) => item.kind === "validation" && !isTerminalStatus(item.status));
|
||||
const missionBlockers = unresolvedBlockersByIssueId.get(issueId) ?? [];
|
||||
const openApprovals = orchestration.approvals.filter((approval) => !isResolvableApprovalStatus(approval.status));
|
||||
|
||||
const stopReasons: MissionStopReason[] = [];
|
||||
const missingRequiredDocumentKeys = parsedDocuments.checklist
|
||||
.filter((item) => !item.present)
|
||||
.map((item) => item.key);
|
||||
|
||||
if (parsedDocuments.parseProblems.length > 0) stopReasons.push("parse_error");
|
||||
if (orchestration.openBudgetIncidents.length > 0) stopReasons.push("budget_incident");
|
||||
if (orchestration.invocationBlocks.length > 0) stopReasons.push("invocation_block");
|
||||
if (openApprovals.length > 0) stopReasons.push("approval_required");
|
||||
if (missionBlockers.length > 0) stopReasons.push("unresolved_blockers");
|
||||
if (missingAssignee) stopReasons.push("missing_assignee");
|
||||
if (pausedAssignee) stopReasons.push("paused_assignee");
|
||||
|
||||
const latestValidationRound = parsedDocuments.validationReports.reduce(
|
||||
(max, report) => Math.max(max, report.parsed.round),
|
||||
0,
|
||||
);
|
||||
|
||||
let state: MissionState | null = null;
|
||||
const missionOriginKind = mission.originKind ?? null;
|
||||
if (
|
||||
missionOriginKind === missionOriginPrefix(ctx) ||
|
||||
(missionOriginKind !== null && missionOriginKind.startsWith(`${missionOriginPrefix(ctx)}:`)) ||
|
||||
missingRequiredDocumentKeys.length > 0 ||
|
||||
parsedDocuments.validationReports.length > 0
|
||||
) {
|
||||
if (missingRequiredDocumentKeys.length > 0 || parsedDocuments.parseProblems.length > 0) state = "draft";
|
||||
else if (blockingFindings.length > 0 && fixIssues.some((issue) => !isTerminalStatus(issue.status))) state = "fixing";
|
||||
else if (activeValidationIssues.length > 0) state = "validating";
|
||||
else if (stopReasons.length > 0) state = "paused";
|
||||
else if (
|
||||
parsedDocuments.validationReports.length > 0 &&
|
||||
blockingFindings.length === 0 &&
|
||||
activeFeatureOrFixIssues.length === 0 &&
|
||||
activeValidationIssues.length === 0
|
||||
) {
|
||||
state = "complete";
|
||||
} else if (activeFeatureOrFixIssues.length > 0 || wakeableIssueIds.length > 0) {
|
||||
state = "working";
|
||||
} else {
|
||||
state = "ready";
|
||||
}
|
||||
}
|
||||
|
||||
const nextAction = (() => {
|
||||
if (state === null) return "Initialize this issue as a mission before advancing it.";
|
||||
if (missingRequiredDocumentKeys.length > 0) return "Complete the required mission documents before advancing.";
|
||||
if (parsedDocuments.parseProblems.length > 0) return "Repair invalid mission documents before advancing.";
|
||||
if (orchestration.openBudgetIncidents.length > 0) return "Resolve open budget incidents before advancing mission work.";
|
||||
if (orchestration.invocationBlocks.length > 0) return "Resolve invocation blocks before advancing mission work.";
|
||||
if (openApprovals.length > 0) return "Resolve linked approvals before advancing mission work.";
|
||||
if (missionBlockers.length > 0) return "Resolve root mission blockers before advancing mission work.";
|
||||
if (missingAssignee) return "Assign all wakeable mission issues before advancing.";
|
||||
if (pausedAssignee) return "Resume or reassign paused mission assignees before advancing.";
|
||||
if (blockingFindings.length > 0) return "Advance the mission to create or wake bounded fix issues.";
|
||||
if (activeValidationIssues.length > 0) return "Wake assigned validation issues and review their findings.";
|
||||
if (wakeableIssueIds.length > 0) return "Wake the next assigned mission issues.";
|
||||
if (state === "complete") return "Record the final mission report and close the root issue when ready.";
|
||||
return "Decompose the mission into milestone, feature, and validation issues.";
|
||||
})();
|
||||
|
||||
return {
|
||||
mission,
|
||||
summary: {
|
||||
isMission: state !== null,
|
||||
missionIssueId: mission.id,
|
||||
missionIdentifier: mission.identifier,
|
||||
state,
|
||||
documentChecklist: parsedDocuments.checklist,
|
||||
missingRequiredDocumentKeys,
|
||||
parseProblems: parsedDocuments.parseProblems,
|
||||
validationReports: parsedDocuments.validationReports.map((report) => ({
|
||||
issueId: report.issueId,
|
||||
documentKey: report.key,
|
||||
title: report.title,
|
||||
round: report.parsed.round,
|
||||
validatorRole: report.parsed.validator_role,
|
||||
summary: report.parsed.summary,
|
||||
updatedAt: report.updatedAt,
|
||||
})),
|
||||
findings,
|
||||
workItems,
|
||||
wakeableIssueIds: unique(wakeableIssueIds),
|
||||
stopReasons: unique(stopReasons),
|
||||
nextAction,
|
||||
openApprovalCount: openApprovals.length,
|
||||
openBudgetIncidentCount: orchestration.openBudgetIncidents.length,
|
||||
invocationBlockCount: orchestration.invocationBlocks.length,
|
||||
latestValidationRound,
|
||||
},
|
||||
validationContract: parsedDocuments.validationContract,
|
||||
featuresDocument: parsedDocuments.featuresDocument,
|
||||
decisionLogBody: parsedDocuments.decisionLogBody,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildMissionSummary(ctx: PluginContext, companyId: string, issueId: string) {
|
||||
const { summary } = await buildSummaryInternal(ctx, companyId, issueId);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function appendDecisionLog(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
existingBody: string,
|
||||
entry: { marker: string; body: string },
|
||||
) {
|
||||
const nextBody = appendDecisionLogEntry(existingBody, entry);
|
||||
if (nextBody === existingBody.trim()) return nextBody;
|
||||
await ctx.issues.documents.upsert({
|
||||
issueId,
|
||||
companyId,
|
||||
key: "decision-log",
|
||||
title: "Decision Log",
|
||||
body: nextBody,
|
||||
changeSummary: "Recorded mission decision",
|
||||
});
|
||||
return nextBody;
|
||||
}
|
||||
|
||||
async function ensureFixIssue(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
companyId: string;
|
||||
mission: Issue;
|
||||
featuresDocument: MissionFeaturesDocument | null;
|
||||
finding: MissionFindingProjection;
|
||||
actor: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null };
|
||||
},
|
||||
) {
|
||||
const originKind = `${missionOriginPrefix(ctx)}:fix` as PluginIssueOriginKindLike;
|
||||
const originId = buildFindingFixOriginId(input.mission.id, input.finding.id);
|
||||
const existing = await ctx.issues.list({
|
||||
companyId: input.companyId,
|
||||
originKind,
|
||||
originId,
|
||||
});
|
||||
if (existing[0]) return existing[0];
|
||||
|
||||
let parentId = input.mission.id;
|
||||
const milestoneId = input.featuresDocument?.milestones.find((milestone) =>
|
||||
milestone.features.some((feature) => feature.claimed_assertion_ids.includes(input.finding.assertion_id ?? "")),
|
||||
)?.id;
|
||||
if (milestoneId) {
|
||||
const milestoneIssues = await ctx.issues.list({
|
||||
companyId: input.companyId,
|
||||
originKind: `${missionOriginPrefix(ctx)}:milestone` as PluginIssueOriginKindLike,
|
||||
});
|
||||
const milestoneIssue = milestoneIssues.find((issue) => issue.originId?.includes(milestoneId));
|
||||
if (milestoneIssue) parentId = milestoneIssue.id;
|
||||
}
|
||||
|
||||
return ctx.issues.create({
|
||||
companyId: input.companyId,
|
||||
projectId: input.mission.projectId ?? undefined,
|
||||
goalId: input.mission.goalId ?? undefined,
|
||||
parentId,
|
||||
inheritExecutionWorkspaceFromIssueId: input.mission.id,
|
||||
title: `Fix ${input.finding.id}: ${input.finding.title}`,
|
||||
description: [
|
||||
`# Fix ${input.finding.id}`,
|
||||
"",
|
||||
`Source report: ${input.finding.sourceReportKey}`,
|
||||
`Assertion: ${input.finding.assertion_id ?? "none"}`,
|
||||
"",
|
||||
"## Expected",
|
||||
input.finding.expected,
|
||||
"",
|
||||
"## Actual",
|
||||
input.finding.actual,
|
||||
"",
|
||||
"## Reproduction",
|
||||
...input.finding.repro_steps.map((step) => `- ${step}`),
|
||||
"",
|
||||
"## Evidence",
|
||||
...input.finding.evidence.map((evidence) => `- ${evidence}`),
|
||||
"",
|
||||
"## Recommended Scope",
|
||||
input.finding.recommended_fix_scope ?? "Fix the bounded behavior described above and leave validation evidence.",
|
||||
].join("\n"),
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: input.mission.assigneeAgentId ?? undefined,
|
||||
billingCode: input.mission.billingCode ?? `mission:${input.mission.id}`,
|
||||
originKind,
|
||||
originId,
|
||||
actor: input.actor,
|
||||
});
|
||||
}
|
||||
|
||||
export async function advanceMission(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
maxValidationRounds?: number;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
},
|
||||
): Promise<MissionAdvanceResult> {
|
||||
const actor = {
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
};
|
||||
const actorLabel = buildActorLabel(actor);
|
||||
const { mission, summary, featuresDocument, decisionLogBody } = await buildSummaryInternal(
|
||||
ctx,
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
);
|
||||
|
||||
if (!summary.isMission) {
|
||||
throw new Error("Issue is not initialized as a mission");
|
||||
}
|
||||
|
||||
const stopReason =
|
||||
summary.stopReasons[0] ??
|
||||
(summary.latestValidationRound >= (input.maxValidationRounds ?? DEFAULT_MAX_VALIDATION_ROUNDS) &&
|
||||
summary.findings.some(
|
||||
(finding) => finding.severity === "blocking" && !["resolved", "waived"].includes(finding.computedStatus),
|
||||
)
|
||||
? "max_validation_rounds"
|
||||
: null);
|
||||
|
||||
if (stopReason) {
|
||||
const result: MissionAdvanceResult = {
|
||||
issueId: input.issueId,
|
||||
outcome: "paused",
|
||||
stopReason,
|
||||
wokenIssueIds: [],
|
||||
createdFixIssueIds: [],
|
||||
details: {
|
||||
stopReasons: summary.stopReasons,
|
||||
latestValidationRound: summary.latestValidationRound,
|
||||
},
|
||||
};
|
||||
|
||||
await appendDecisionLog(
|
||||
ctx,
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
decisionLogBody,
|
||||
buildMissionEventEntry({
|
||||
markerKey: `advance:paused:${stopReason}:${summary.latestValidationRound}`,
|
||||
title: `Advance Paused ${summary.missionIdentifier ?? input.issueId}`,
|
||||
lines: [
|
||||
`Outcome: paused`,
|
||||
`Stop reason: ${stopReason}`,
|
||||
`Actor: ${actorLabel}`,
|
||||
`At: ${new Date().toISOString()}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const createdFixIssues: Issue[] = [];
|
||||
for (const finding of summary.findings) {
|
||||
if (finding.severity !== "blocking" || ["resolved", "waived"].includes(finding.computedStatus)) continue;
|
||||
const fixIssue = await ensureFixIssue(ctx, {
|
||||
companyId: input.companyId,
|
||||
mission,
|
||||
featuresDocument,
|
||||
finding,
|
||||
actor,
|
||||
});
|
||||
createdFixIssues.push(fixIssue);
|
||||
}
|
||||
|
||||
let decisionLogBodyAfterFixes = decisionLogBody;
|
||||
if (createdFixIssues.length > 0) {
|
||||
decisionLogBodyAfterFixes = await appendDecisionLog(
|
||||
ctx,
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
decisionLogBodyAfterFixes,
|
||||
buildMissionEventEntry({
|
||||
markerKey: `advance:fixes:${createdFixIssues.map((issue) => issue.id).join(",")}`,
|
||||
title: `Advance Created Fixes ${summary.missionIdentifier ?? input.issueId}`,
|
||||
lines: [
|
||||
`Outcome: created fixes`,
|
||||
`Actor: ${actorLabel}`,
|
||||
`Created issue ids: ${createdFixIssues.map((issue) => issue.id).join(", ")}`,
|
||||
`At: ${new Date().toISOString()}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const wakeableIssueIds = unique([...summary.wakeableIssueIds, ...createdFixIssues.map((issue) => issue.id)]);
|
||||
if (wakeableIssueIds.length > 0) {
|
||||
const wakeups = await ctx.issues.requestWakeups(wakeableIssueIds, input.companyId, {
|
||||
reason: "mission_advance",
|
||||
contextSource: "missions.advance",
|
||||
idempotencyKeyPrefix: `mission:${input.issueId}:advance`,
|
||||
...actor,
|
||||
});
|
||||
const wokenIssueIds = wakeups.filter((result) => result.queued).map((result) => result.issueId);
|
||||
const result: MissionAdvanceResult = {
|
||||
issueId: input.issueId,
|
||||
outcome: "woke_issues",
|
||||
stopReason: null,
|
||||
wokenIssueIds,
|
||||
createdFixIssueIds: createdFixIssues.map((issue) => issue.id),
|
||||
details: {
|
||||
requestedIssueIds: wakeableIssueIds,
|
||||
},
|
||||
};
|
||||
|
||||
await appendDecisionLog(
|
||||
ctx,
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
decisionLogBodyAfterFixes,
|
||||
buildMissionEventEntry({
|
||||
markerKey: `advance:wakeup:${wokenIssueIds.join(",")}`,
|
||||
title: `Advance Woke Issues ${summary.missionIdentifier ?? input.issueId}`,
|
||||
lines: [
|
||||
`Outcome: woke issues`,
|
||||
`Actor: ${actorLabel}`,
|
||||
`Wakeups: ${wokenIssueIds.join(", ")}`,
|
||||
`At: ${new Date().toISOString()}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (
|
||||
summary.validationReports.length > 0 &&
|
||||
summary.findings.every((finding) => ["resolved", "waived"].includes(finding.computedStatus))
|
||||
) {
|
||||
const result: MissionAdvanceResult = {
|
||||
issueId: input.issueId,
|
||||
outcome: "complete",
|
||||
stopReason: null,
|
||||
wokenIssueIds: [],
|
||||
createdFixIssueIds: createdFixIssues.map((issue) => issue.id),
|
||||
details: {
|
||||
latestValidationRound: summary.latestValidationRound,
|
||||
},
|
||||
};
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: MissionAdvanceResult = {
|
||||
issueId: input.issueId,
|
||||
outcome: createdFixIssues.length > 0 ? "created_fixes" : "noop",
|
||||
stopReason: null,
|
||||
wokenIssueIds: [],
|
||||
createdFixIssueIds: createdFixIssues.map((issue) => issue.id),
|
||||
details: {
|
||||
nextAction: summary.nextAction,
|
||||
},
|
||||
};
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function waiveMissionFinding(
|
||||
ctx: PluginContext,
|
||||
input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
findingId: string;
|
||||
rationale: string;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
},
|
||||
): Promise<MissionWaiveFindingResult> {
|
||||
const actor = {
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
};
|
||||
const actorLabel = buildActorLabel(actor);
|
||||
const { summary, decisionLogBody } = await buildSummaryInternal(ctx, input.companyId, input.issueId);
|
||||
const finding = summary.findings.find((candidate) => candidate.id === input.findingId);
|
||||
if (!finding) {
|
||||
throw new Error(`Mission finding not found: ${input.findingId}`);
|
||||
}
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
const waiverEntry = buildMissionFindingWaiverEntry({
|
||||
findingId: input.findingId,
|
||||
rationale: input.rationale,
|
||||
actorLabel,
|
||||
createdAt,
|
||||
});
|
||||
await appendDecisionLog(ctx, input.companyId, input.issueId, decisionLogBody, waiverEntry);
|
||||
await ctx.state.set(
|
||||
{ scopeKind: "issue", scopeId: input.issueId, stateKey: `waiver:${input.findingId}` },
|
||||
{
|
||||
findingId: input.findingId,
|
||||
rationale: input.rationale,
|
||||
actorLabel,
|
||||
createdAt,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
issueId: input.issueId,
|
||||
findingId: input.findingId,
|
||||
waived: true,
|
||||
rationale: input.rationale,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,8 @@ import {
|
||||
type PluginSettingsPageProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import type { MissionInitializationResult, MissionIssueSummary } from "../mission-initialization.js";
|
||||
|
||||
type EntitySlotProps = {
|
||||
context: PluginHostContext & {
|
||||
@@ -26,24 +28,7 @@ type SurfaceStatus = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type MissionSummary = {
|
||||
status: "not_configured";
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
missionIssueId: string;
|
||||
missionIdentifier: string | null;
|
||||
missionTitle: string;
|
||||
childIssueCount: number;
|
||||
documentCount: number;
|
||||
blockerCount: number;
|
||||
routeKeys: string[];
|
||||
uiSlotIds: string[];
|
||||
databaseNamespace: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type MissionsList = {
|
||||
status: "not_configured";
|
||||
companyId: string;
|
||||
missions: Array<{
|
||||
issueId: string;
|
||||
@@ -94,33 +79,71 @@ const secondaryButtonStyle = {
|
||||
color: "#111827",
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
function IssueScopedPanel({
|
||||
data,
|
||||
onAdvance,
|
||||
}: {
|
||||
data: MissionSummary;
|
||||
onAdvance: () => Promise<void>;
|
||||
}) {
|
||||
function SummaryRows({ data }: { data: MissionIssueSummary }) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>Mission Scaffold</strong>
|
||||
<button style={buttonStyle} type="button" onClick={() => void onAdvance()}>
|
||||
Advance
|
||||
</button>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
<div style={rowStyle}><span>Issue</span><code>{data.missionIdentifier ?? data.issueId}</code></div>
|
||||
<div style={rowStyle}><span>Children</span><strong>{data.childIssueCount}</strong></div>
|
||||
<div style={rowStyle}><span>Documents</span><strong>{data.documentCount}</strong></div>
|
||||
<div style={rowStyle}><span>Blockers</span><strong>{data.blockerCount}</strong></div>
|
||||
<div style={rowStyle}><span>Namespace</span><code>{data.databaseNamespace}</code></div>
|
||||
</div>
|
||||
<div>{data.message}</div>
|
||||
<div style={gridStyle}>
|
||||
<div style={rowStyle}><span>State</span><strong>{data.state ?? "not-initialized"}</strong></div>
|
||||
<div style={rowStyle}><span>Billing</span><code>{data.settings.billingCode ?? "unset"}</code></div>
|
||||
<div style={rowStyle}><span>Namespace</span><code>{data.settings.databaseNamespace}</code></div>
|
||||
<div style={rowStyle}><span>Open Findings</span><strong>{data.openFindingCount}</strong></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checklist({ data }: { data: MissionIssueSummary }) {
|
||||
return (
|
||||
<div style={gridStyle}>
|
||||
{data.documentChecklist.map((item) => (
|
||||
<div key={item.key} style={rowStyle}>
|
||||
<code>{item.key}</code>
|
||||
<strong>{item.present ? item.title ?? "present" : "missing"}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InitializeMissionButton({
|
||||
companyId,
|
||||
issueId,
|
||||
disabledReason,
|
||||
onDone,
|
||||
}: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
disabledReason: string | null;
|
||||
onDone?: (result: MissionInitializationResult) => Promise<void> | void;
|
||||
}) {
|
||||
const initializeMission = usePluginAction("initialize-mission");
|
||||
const toast = usePluginToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
disabled={pending || Boolean(disabledReason)}
|
||||
title={disabledReason ?? undefined}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await initializeMission({ companyId, issueId }) as MissionInitializationResult;
|
||||
toast({
|
||||
title: result.created ? "Mission initialized" : "Mission already initialized",
|
||||
body: result.summary.nextAction,
|
||||
tone: "success",
|
||||
});
|
||||
await onDone?.(result);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending ? "Initializing..." : "Init Mission"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MissionsPage({ context }: PluginPageProps) {
|
||||
const companyId = context.companyId;
|
||||
const { data, loading, error, refresh } = usePluginData<MissionsList>("missions-list", {
|
||||
@@ -128,7 +151,7 @@ export function MissionsPage({ context }: PluginPageProps) {
|
||||
});
|
||||
|
||||
if (!companyId) return <div style={panelStyle}>Open a company to view missions.</div>;
|
||||
if (loading) return <div style={panelStyle}>Loading missions scaffold...</div>;
|
||||
if (loading) return <div style={panelStyle}>Loading missions...</div>;
|
||||
if (error) return <div style={panelStyle}>Missions page error: {error.message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -142,7 +165,7 @@ export function MissionsPage({ context }: PluginPageProps) {
|
||||
</div>
|
||||
<div>{data.message}</div>
|
||||
{data.missions.length === 0 ? (
|
||||
<div>No mission issues are registered yet.</div>
|
||||
<div>No root mission issues have been initialized yet.</div>
|
||||
) : (
|
||||
<div style={gridStyle}>
|
||||
{data.missions.map((mission) => (
|
||||
@@ -158,56 +181,77 @@ export function MissionsPage({ context }: PluginPageProps) {
|
||||
}
|
||||
|
||||
export function MissionIssuePanel({ context }: EntitySlotProps) {
|
||||
const { data, loading, error, refresh } = usePluginData<MissionSummary>("mission-summary", {
|
||||
const { data, loading, error, refresh } = usePluginData<MissionIssueSummary>("mission-summary", {
|
||||
companyId: context.companyId,
|
||||
issueId: context.entityId,
|
||||
});
|
||||
const advanceMission = usePluginAction("advance-mission");
|
||||
const toast = usePluginToast();
|
||||
|
||||
if (!context.companyId || !context.entityId) {
|
||||
return <div style={panelStyle}>Mission controls need an issue context.</div>;
|
||||
}
|
||||
if (loading) return <div style={panelStyle}>Loading mission scaffold...</div>;
|
||||
if (loading) return <div style={panelStyle}>Loading mission summary...</div>;
|
||||
if (error) return <div style={panelStyle}>Mission panel error: {error.message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
if (!data.isMission) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<strong>Mission</strong>
|
||||
<div>This issue has not been initialized as a mission yet.</div>
|
||||
<div>{data.nextAction}</div>
|
||||
<InitializeMissionButton
|
||||
companyId={context.companyId}
|
||||
issueId={context.entityId}
|
||||
disabledReason={data.initializeDisabledReason}
|
||||
onDone={async () => {
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IssueScopedPanel
|
||||
data={data}
|
||||
onAdvance={async () => {
|
||||
await advanceMission({ companyId: context.companyId, issueId: context.entityId });
|
||||
toast({
|
||||
title: "Mission route reserved",
|
||||
body: "Advance is wired, but runtime behavior lands in a follow-up issue.",
|
||||
tone: "info",
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
<div style={panelStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>Mission</strong>
|
||||
<button style={secondaryButtonStyle} type="button" onClick={() => refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<SummaryRows data={data} />
|
||||
<div><strong>Next action:</strong> {data.nextAction}</div>
|
||||
<Checklist data={data} />
|
||||
{data.parseProblems.length > 0 ? (
|
||||
<div style={gridStyle}>
|
||||
{data.parseProblems.map((problem) => (
|
||||
<div key={`${problem.key}:${problem.message}`} style={{ color: "#b45309" }}>
|
||||
<code>{problem.key}</code>: {problem.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MissionToolbarButton({ context }: EntitySlotProps) {
|
||||
const initializeMission = usePluginAction("initialize-mission");
|
||||
const toast = usePluginToast();
|
||||
const { data, loading, refresh } = usePluginData<MissionIssueSummary>("mission-summary", {
|
||||
companyId: context.companyId,
|
||||
issueId: context.entityId,
|
||||
});
|
||||
|
||||
if (!context.companyId || !context.entityId || loading || !data || data.isMission) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!context.companyId || !context.entityId) return;
|
||||
await initializeMission({ companyId: context.companyId, issueId: context.entityId });
|
||||
toast({
|
||||
title: "Mission scaffold ready",
|
||||
body: "Initialization is wired to the worker shell.",
|
||||
tone: "success",
|
||||
});
|
||||
<InitializeMissionButton
|
||||
companyId={context.companyId}
|
||||
issueId={context.entityId}
|
||||
disabledReason={data.initializeDisabledReason}
|
||||
onDone={async () => {
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
Init Mission
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import type { Issue, PluginApiRequestInput, PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import { definePlugin, runWorker, type PluginApiRequestInput } from "@paperclipai/plugin-sdk";
|
||||
import { decomposeMission, type MissionDecompositionResult } from "./decompose.js";
|
||||
import { initializeMission, type MissionInitializationResult } from "./mission-initialization.js";
|
||||
import {
|
||||
advanceMission,
|
||||
buildMissionSummary,
|
||||
waiveMissionFinding,
|
||||
type MissionAdvanceResult,
|
||||
type MissionSummary,
|
||||
type MissionWaiveFindingResult,
|
||||
} from "./mission-runtime.js";
|
||||
import manifest, { MISSIONS_API_ROUTE_KEYS, MISSIONS_UI_SLOT_IDS } from "./manifest.js";
|
||||
|
||||
const PLUGIN_ORIGIN = `plugin:${manifest.id}` as const;
|
||||
|
||||
type PlaceholderResponse = {
|
||||
status: "not_configured";
|
||||
routeKey: string;
|
||||
type InitializeInput = {
|
||||
companyId: string;
|
||||
issueId: string | null;
|
||||
findingKey: string | null;
|
||||
message: string;
|
||||
issueId: string;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type DecomposeInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
dryRun?: boolean;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type AdvanceInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
maxValidationRounds?: number;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type WaiveInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
findingId: string;
|
||||
rationale: string;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type SurfaceStatus = {
|
||||
@@ -21,118 +57,91 @@ type SurfaceStatus = {
|
||||
routeKeys: string[];
|
||||
uiSlotIds: string[];
|
||||
pluginId: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type MissionSummary = {
|
||||
status: "not_configured";
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
missionIssueId: string;
|
||||
missionIdentifier: string | null;
|
||||
missionTitle: string;
|
||||
childIssueCount: number;
|
||||
documentCount: number;
|
||||
blockerCount: number;
|
||||
routeKeys: string[];
|
||||
uiSlotIds: string[];
|
||||
databaseNamespace: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type MissionsListItem = {
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: Issue["status"];
|
||||
};
|
||||
|
||||
type MissionsList = {
|
||||
status: "not_configured";
|
||||
status: "ok";
|
||||
companyId: string;
|
||||
missions: MissionsListItem[];
|
||||
routeKeys: string[];
|
||||
pageRoute: string;
|
||||
message: string;
|
||||
missions: Array<{
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
let getSurfaceStatus: ((companyId: string | null) => Promise<SurfaceStatus>) | null = null;
|
||||
let getMissionSummary: ((companyId: string, issueId: string) => Promise<MissionSummary>) | null = null;
|
||||
let getMissionsList: ((companyId: string) => Promise<MissionsList>) | null = null;
|
||||
let runInitialize: ((input: InitializeInput) => Promise<MissionInitializationResult>) | null = null;
|
||||
let runDecompose: ((input: DecomposeInput) => Promise<MissionDecompositionResult>) | null = null;
|
||||
let loadMissionSummary: ((companyId: string, issueId: string) => Promise<MissionSummary>) | null = null;
|
||||
let runAdvance: ((input: AdvanceInput) => Promise<MissionAdvanceResult>) | null = null;
|
||||
let runWaive: ((input: WaiveInput) => Promise<MissionWaiveFindingResult>) | null = null;
|
||||
let listMissions: ((companyId: string) => Promise<MissionsList>) | null = null;
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
function stringField(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function requireIssue(ctx: PluginContext, companyId: string, issueId: string) {
|
||||
const issue = await ctx.issues.get(issueId, companyId);
|
||||
if (!issue) throw new Error(`Issue not found: ${issueId}`);
|
||||
return issue;
|
||||
function booleanField(value: unknown) {
|
||||
return value === true || value === "true";
|
||||
}
|
||||
|
||||
function buildPlaceholderResponse(input: {
|
||||
routeKey: string;
|
||||
companyId: string;
|
||||
issueId?: string | null;
|
||||
findingKey?: string | null;
|
||||
}): PlaceholderResponse {
|
||||
return {
|
||||
status: "not_configured",
|
||||
routeKey: input.routeKey,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId ?? null,
|
||||
findingKey: input.findingKey ?? null,
|
||||
message: "Missions Phase 0 scaffold is installed. Runtime behavior lands in follow-up issues.",
|
||||
};
|
||||
function numberField(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
getSurfaceStatus = async (companyId) => ({
|
||||
status: "ok",
|
||||
checkedAt: new Date().toISOString(),
|
||||
companyId,
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
pluginId: ctx.manifest.id,
|
||||
message: "Missions scaffold loaded successfully.",
|
||||
});
|
||||
runInitialize = async (input: InitializeInput) =>
|
||||
initializeMission(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
});
|
||||
|
||||
getMissionSummary = async (companyId, issueId) => {
|
||||
const issue = await requireIssue(ctx, companyId, issueId);
|
||||
const subtree = await ctx.issues.getSubtree(issueId, companyId, { includeRoot: false });
|
||||
const documents = await ctx.issues.documents.list(issueId, companyId);
|
||||
const relations = await ctx.issues.relations.get(issueId, companyId);
|
||||
runDecompose = async (input: DecomposeInput) =>
|
||||
decomposeMission(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
dryRun: input.dryRun,
|
||||
actor: {
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: "not_configured",
|
||||
companyId,
|
||||
issueId,
|
||||
missionIssueId: issue.id,
|
||||
missionIdentifier: issue.identifier,
|
||||
missionTitle: issue.title,
|
||||
childIssueCount: subtree.issueIds.length,
|
||||
documentCount: documents.length,
|
||||
blockerCount: relations.blockedBy.length,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
message: "Mission summary is a scaffold response until the runtime issues land.",
|
||||
};
|
||||
};
|
||||
loadMissionSummary = async (companyId: string, issueId: string) => buildMissionSummary(ctx, companyId, issueId);
|
||||
|
||||
getMissionsList = async (companyId) => {
|
||||
runAdvance = async (input: AdvanceInput) =>
|
||||
advanceMission(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
maxValidationRounds: input.maxValidationRounds,
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
});
|
||||
|
||||
runWaive = async (input: WaiveInput) =>
|
||||
waiveMissionFinding(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
findingId: input.findingId,
|
||||
rationale: input.rationale,
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
});
|
||||
|
||||
listMissions = async (companyId: string) => {
|
||||
const issues = await ctx.issues.list({
|
||||
companyId,
|
||||
originKind: PLUGIN_ORIGIN,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "not_configured",
|
||||
status: "ok",
|
||||
companyId,
|
||||
missions: issues.map((issue) => ({
|
||||
issueId: issue.id,
|
||||
@@ -140,145 +149,205 @@ const plugin = definePlugin({
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
})),
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
pageRoute: "missions",
|
||||
message: "Mission list wiring is present. Mission lifecycle behavior is not implemented in Phase 0.",
|
||||
};
|
||||
};
|
||||
|
||||
ctx.data.register("surface-status", async (params) => {
|
||||
if (!getSurfaceStatus) throw new Error("Surface status is not ready");
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : null;
|
||||
return getSurfaceStatus(companyId);
|
||||
});
|
||||
ctx.data.register("surface-status", async (params) => ({
|
||||
status: "ok",
|
||||
checkedAt: new Date().toISOString(),
|
||||
companyId: stringField(params.companyId),
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
pluginId: manifest.id,
|
||||
} satisfies SurfaceStatus));
|
||||
|
||||
ctx.data.register("mission-summary", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
if (!getMissionSummary) throw new Error("Mission summary is not ready");
|
||||
return getMissionSummary(companyId, issueId);
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||||
if (!loadMissionSummary) throw new Error("Mission summary is not ready");
|
||||
return loadMissionSummary(companyId, issueId);
|
||||
});
|
||||
|
||||
ctx.data.register("missions-list", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
if (!getMissionsList) throw new Error("Missions list is not ready");
|
||||
return getMissionsList(companyId);
|
||||
const companyId = stringField(params.companyId);
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
if (!listMissions) throw new Error("Mission list is not ready");
|
||||
return listMissions(companyId);
|
||||
});
|
||||
|
||||
ctx.actions.register("initialize-mission", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
await requireIssue(ctx, companyId, issueId);
|
||||
return buildPlaceholderResponse({ routeKey: "initialize-mission", companyId, issueId });
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||||
if (!runInitialize) throw new Error("Mission initialization is not ready");
|
||||
return runInitialize({
|
||||
companyId,
|
||||
issueId,
|
||||
actorAgentId: stringField(params.actorAgentId),
|
||||
actorUserId: stringField(params.actorUserId),
|
||||
actorRunId: stringField(params.actorRunId),
|
||||
});
|
||||
});
|
||||
|
||||
ctx.actions.register("decompose-mission", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
await requireIssue(ctx, companyId, issueId);
|
||||
return buildPlaceholderResponse({ routeKey: "decompose-mission", companyId, issueId });
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||||
if (!runDecompose) throw new Error("Mission decomposition is not ready");
|
||||
return runDecompose({
|
||||
companyId,
|
||||
issueId,
|
||||
dryRun: booleanField(params.dryRun),
|
||||
actorAgentId: stringField(params.actorAgentId),
|
||||
actorUserId: stringField(params.actorUserId),
|
||||
actorRunId: stringField(params.actorRunId),
|
||||
});
|
||||
});
|
||||
|
||||
ctx.actions.register("advance-mission", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
await requireIssue(ctx, companyId, issueId);
|
||||
return buildPlaceholderResponse({ routeKey: "advance-mission", companyId, issueId });
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||||
if (!runAdvance) throw new Error("Mission advance is not ready");
|
||||
return runAdvance({
|
||||
companyId,
|
||||
issueId,
|
||||
maxValidationRounds: numberField(params.maxValidationRounds),
|
||||
actorAgentId: stringField(params.actorAgentId),
|
||||
actorUserId: stringField(params.actorUserId),
|
||||
actorRunId: stringField(params.actorRunId),
|
||||
});
|
||||
});
|
||||
|
||||
ctx.actions.register("waive-mission-finding", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
const findingKey = requireString(params.findingKey, "findingKey");
|
||||
requireString(params.rationale, "rationale");
|
||||
await requireIssue(ctx, companyId, issueId);
|
||||
return buildPlaceholderResponse({
|
||||
routeKey: "waive-mission-finding",
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
const findingId = stringField(params.findingId) ?? stringField(params.findingKey);
|
||||
const rationale = stringField(params.rationale);
|
||||
if (!companyId || !issueId || !findingId || !rationale) {
|
||||
throw new Error("companyId, issueId, findingId, and rationale are required");
|
||||
}
|
||||
if (!runWaive) throw new Error("Mission waiver flow is not ready");
|
||||
return runWaive({
|
||||
companyId,
|
||||
issueId,
|
||||
findingKey,
|
||||
findingId,
|
||||
rationale,
|
||||
actorAgentId: stringField(params.actorAgentId),
|
||||
actorUserId: stringField(params.actorUserId),
|
||||
actorRunId: stringField(params.actorRunId),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input: PluginApiRequestInput) {
|
||||
switch (input.routeKey) {
|
||||
case "initialize-mission":
|
||||
return {
|
||||
status: 202,
|
||||
body: buildPlaceholderResponse({
|
||||
routeKey: input.routeKey,
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
}),
|
||||
};
|
||||
if (input.routeKey === "initialize-mission") {
|
||||
if (!runInitialize) throw new Error("Mission initialization is not ready");
|
||||
const result = await runInitialize({
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
});
|
||||
return {
|
||||
status: result.created ? 201 : 200,
|
||||
body: result,
|
||||
};
|
||||
}
|
||||
|
||||
case "mission-summary":
|
||||
if (!getMissionSummary) throw new Error("Mission summary is not ready");
|
||||
return {
|
||||
status: 200,
|
||||
body: await getMissionSummary(input.companyId, input.params.issueId),
|
||||
};
|
||||
if (input.routeKey === "mission-summary") {
|
||||
if (!loadMissionSummary) throw new Error("Mission summary is not ready");
|
||||
return {
|
||||
status: 200,
|
||||
body: await loadMissionSummary(input.companyId, input.params.issueId),
|
||||
};
|
||||
}
|
||||
|
||||
case "decompose-mission":
|
||||
case "advance-mission":
|
||||
return {
|
||||
status: 202,
|
||||
body: buildPlaceholderResponse({
|
||||
routeKey: input.routeKey,
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
}),
|
||||
};
|
||||
if (input.routeKey === "decompose-mission") {
|
||||
if (!runDecompose) throw new Error("Mission decomposition is not ready");
|
||||
const body = input.body as Record<string, unknown> | null;
|
||||
return {
|
||||
status: 200,
|
||||
body: await runDecompose({
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
dryRun: booleanField(body?.dryRun),
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case "waive-mission-finding": {
|
||||
const rationale = (input.body as Record<string, unknown> | null)?.rationale;
|
||||
if (typeof rationale !== "string" || rationale.trim().length === 0) {
|
||||
return {
|
||||
status: 422,
|
||||
body: { error: "rationale is required" },
|
||||
};
|
||||
}
|
||||
if (input.routeKey === "advance-mission") {
|
||||
if (!runAdvance) throw new Error("Mission advance is not ready");
|
||||
const body = input.body as Record<string, unknown> | null;
|
||||
return {
|
||||
status: 200,
|
||||
body: await runAdvance({
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
maxValidationRounds: numberField(body?.maxValidationRounds),
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "waive-mission-finding") {
|
||||
if (!runWaive) throw new Error("Mission waiver flow is not ready");
|
||||
const body = input.body as Record<string, unknown> | null;
|
||||
const rationale = stringField(body?.rationale);
|
||||
const findingId = input.params.findingKey ?? input.params.findingId;
|
||||
if (!rationale) {
|
||||
return {
|
||||
status: 202,
|
||||
body: buildPlaceholderResponse({
|
||||
routeKey: input.routeKey,
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
findingKey: input.params.findingKey,
|
||||
}),
|
||||
status: 422,
|
||||
body: { error: "rationale is required" },
|
||||
};
|
||||
}
|
||||
|
||||
case "missions-list":
|
||||
if (!getMissionsList) throw new Error("Missions list is not ready");
|
||||
return {
|
||||
status: 200,
|
||||
body: await getMissionsList(input.companyId),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: `Unknown missions route: ${input.routeKey}` },
|
||||
};
|
||||
return {
|
||||
status: 200,
|
||||
body: await runWaive({
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
findingId,
|
||||
rationale,
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "missions-list") {
|
||||
if (!listMissions) throw new Error("Mission list is not ready");
|
||||
return {
|
||||
status: 200,
|
||||
body: await listMissions(input.companyId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: `Unknown missions route: ${input.routeKey}` },
|
||||
};
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Missions plugin scaffold is running",
|
||||
message: "Missions plugin worker is running",
|
||||
details: {
|
||||
pluginId: manifest.id,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
hasDatabaseNamespace: Boolean(manifest.database),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -1,10 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/plugin-sdk";
|
||||
import type { Agent, Issue } from "@paperclipai/plugin-sdk";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest, { MISSIONS_API_ROUTE_KEYS, MISSIONS_UI_SLOT_IDS } from "../src/manifest.js";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
const PLUGIN_ORIGIN = `plugin:${manifest.id}` as const;
|
||||
@@ -52,252 +50,411 @@ function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">
|
||||
};
|
||||
}
|
||||
|
||||
describe("missions plugin scaffold", () => {
|
||||
it("declares the phase 0 routes, slots, and namespace migration contract", () => {
|
||||
function agent(input: Partial<Agent> & Pick<Agent, "id" | "companyId" | "name" | "status">): Agent {
|
||||
const now = new Date();
|
||||
const { id, companyId, name, status, ...rest } = input;
|
||||
return {
|
||||
id,
|
||||
companyId,
|
||||
name,
|
||||
urlKey: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status,
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 100000,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertMissionDocuments(
|
||||
harness: ReturnType<typeof createTestHarness>,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
const docs = new Map<string, string>([
|
||||
["plan", "# Mission Plan\n"],
|
||||
["mission-brief", "# Mission Brief\n"],
|
||||
[
|
||||
"validation-contract",
|
||||
JSON.stringify(
|
||||
{
|
||||
assertions: [
|
||||
{
|
||||
id: "VAL-MISSION-001",
|
||||
title: "Mission validation",
|
||||
user_value: "Advance loop works",
|
||||
scope: "Mission runtime",
|
||||
setup: "Plugin test harness",
|
||||
steps: ["Advance the mission"],
|
||||
oracle: "Only valid issues are woken",
|
||||
tooling: ["manual_review"],
|
||||
evidence: [{ kind: "primary", description: "Mission summary", required: true }],
|
||||
claimed_by: ["FEAT-MISSION-001"],
|
||||
status: "unclaimed",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
],
|
||||
[
|
||||
"features",
|
||||
JSON.stringify(
|
||||
{
|
||||
milestones: [
|
||||
{
|
||||
id: "MILESTONE-MISSION-001",
|
||||
title: "Core Mission Flow",
|
||||
summary: "Advance and validation loop",
|
||||
features: [
|
||||
{
|
||||
id: "FEAT-MISSION-001",
|
||||
title: "Advance mission",
|
||||
kind: "original",
|
||||
summary: "Drive assigned work forward",
|
||||
acceptance_criteria: ["Wake assigned and unblocked work"],
|
||||
claimed_assertion_ids: ["VAL-MISSION-001"],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
],
|
||||
["worker-guidelines", "# Worker Guidelines\n"],
|
||||
["services", "# Services\n"],
|
||||
["knowledge-base", "# Knowledge Base\n"],
|
||||
["decision-log", "# Decision Log\n"],
|
||||
]);
|
||||
|
||||
for (const [key, body] of docs) {
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId,
|
||||
companyId,
|
||||
key,
|
||||
title: key,
|
||||
body,
|
||||
changeSummary: `Seeded ${key}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("missions plugin package", () => {
|
||||
it("declares mission initialization surfaces", () => {
|
||||
expect(manifest).toMatchObject({
|
||||
id: "paperclipai.plugin-missions",
|
||||
apiVersion: 1,
|
||||
database: {
|
||||
namespaceSlug: "missions",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["issues"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(manifest.apiRoutes?.map((route) => route.routeKey)).toEqual(MISSIONS_API_ROUTE_KEYS);
|
||||
expect(manifest.ui?.slots?.map((slot) => slot.id)).toEqual(MISSIONS_UI_SLOT_IDS);
|
||||
expect(manifest.apiRoutes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ routeKey: "initialize-mission", path: "/issues/:issueId/missions/init" }),
|
||||
expect.objectContaining({ routeKey: "mission-summary", path: "/issues/:issueId/missions/summary" }),
|
||||
]),
|
||||
);
|
||||
expect(manifest.ui?.slots).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "page", routePath: "missions" }),
|
||||
expect.objectContaining({ type: "taskDetailView", entityTypes: ["issue"] }),
|
||||
expect.objectContaining({ type: "toolbarButton", entityTypes: ["issue"] }),
|
||||
expect.objectContaining({ type: "settingsPage" }),
|
||||
expect.objectContaining({ type: "page", exportName: "MissionsPage" }),
|
||||
expect.objectContaining({ type: "taskDetailView", exportName: "MissionIssuePanel" }),
|
||||
expect.objectContaining({ type: "toolbarButton", exportName: "MissionToolbarButton" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes a root mission, creates required documents, and returns a draft summary", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Initialize the mission root",
|
||||
identifier: "TST-1",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.performAction<{
|
||||
created: boolean;
|
||||
createdDocumentKeys: string[];
|
||||
summary: {
|
||||
isMission: boolean;
|
||||
state: string | null;
|
||||
persistence: { hasRootLink: boolean; hasInitializationEvent: boolean } | null;
|
||||
};
|
||||
}>("initialize-mission", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.createdDocumentKeys).toHaveLength(8);
|
||||
expect(result.summary).toMatchObject({
|
||||
isMission: true,
|
||||
state: "draft",
|
||||
persistence: {
|
||||
hasRootLink: true,
|
||||
hasInitializationEvent: true,
|
||||
},
|
||||
});
|
||||
|
||||
const rootIssue = await harness.ctx.issues.get(rootIssueId, companyId);
|
||||
expect(rootIssue).toMatchObject({
|
||||
originKind: "plugin:paperclipai.plugin-missions",
|
||||
originId: `mission:${rootIssueId}`,
|
||||
billingCode: `mission:${rootIssueId}`,
|
||||
});
|
||||
|
||||
const docs = await harness.ctx.issues.documents.list(rootIssueId, companyId);
|
||||
expect(new Set(docs.map((document) => document.key))).toEqual(
|
||||
new Set([
|
||||
"plan",
|
||||
"mission-brief",
|
||||
"validation-contract",
|
||||
"features",
|
||||
"worker-guidelines",
|
||||
"services",
|
||||
"knowledge-base",
|
||||
"decision-log",
|
||||
]),
|
||||
);
|
||||
|
||||
const migrationsDir = path.resolve(import.meta.dirname, "../migrations");
|
||||
const firstMigration = readFileSync(path.join(migrationsDir, "001_mission_issue_links.sql"), "utf8");
|
||||
const secondMigration = readFileSync(path.join(migrationsDir, "002_missions.sql"), "utf8");
|
||||
expect(firstMigration).toContain("plugin_missions_8ceb7cd69c.mission_issue_links");
|
||||
expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.missions");
|
||||
expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.mission_findings");
|
||||
expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.mission_events");
|
||||
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".missions"))).toBe(true);
|
||||
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_issue_links"))).toBe(true);
|
||||
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_events"))).toBe(true);
|
||||
});
|
||||
|
||||
it("registers placeholder data and action handlers backed by the worker shell", async () => {
|
||||
it("keeps initialization idempotent and surfaces parse errors in the summary", async () => {
|
||||
const companyId = randomUUID();
|
||||
const missionIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const blockerIssueId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: missionIssueId,
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Mission root",
|
||||
identifier: "PAP-1684",
|
||||
originKind: PLUGIN_ORIGIN,
|
||||
}),
|
||||
issue({
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: missionIssueId,
|
||||
title: "Mission child",
|
||||
originKind: `${PLUGIN_ORIGIN}:feature`,
|
||||
}),
|
||||
issue({
|
||||
id: blockerIssueId,
|
||||
companyId,
|
||||
title: "Upstream blocker",
|
||||
title: "Mission with parser errors",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.performAction("initialize-mission", { companyId, issueId: rootIssueId });
|
||||
const second = await harness.performAction<{
|
||||
created: boolean;
|
||||
createdDocumentKeys: string[];
|
||||
summary: { isMission: boolean; state: string | null };
|
||||
}>("initialize-mission", { companyId, issueId: rootIssueId });
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.createdDocumentKeys).toEqual([]);
|
||||
expect(second.summary).toMatchObject({
|
||||
isMission: true,
|
||||
state: "draft",
|
||||
});
|
||||
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: missionIssueId,
|
||||
issueId: rootIssueId,
|
||||
companyId,
|
||||
key: "mission-brief",
|
||||
title: "Mission Brief",
|
||||
body: "# Mission Brief\n",
|
||||
changeSummary: "Seed mission brief",
|
||||
key: "validation-contract",
|
||||
title: "Validation Contract",
|
||||
body: "{ not-valid-json }",
|
||||
});
|
||||
await harness.ctx.issues.relations.setBlockedBy(missionIssueId, [blockerIssueId], companyId);
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const surfaceStatus = await harness.getData<{
|
||||
pluginId: string;
|
||||
routeKeys: string[];
|
||||
uiSlotIds: string[];
|
||||
databaseNamespace: string;
|
||||
}>("surface-status", { companyId });
|
||||
expect(surfaceStatus).toMatchObject({
|
||||
pluginId: manifest.id,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
});
|
||||
expect(surfaceStatus.databaseNamespace).toContain("test_paperclipai_plugin_missions");
|
||||
|
||||
const summary = await harness.getData<{
|
||||
missionIssueId: string;
|
||||
childIssueCount: number;
|
||||
documentCount: number;
|
||||
blockerCount: number;
|
||||
status: string;
|
||||
}>("mission-summary", { companyId, issueId: missionIssueId });
|
||||
expect(summary).toMatchObject({
|
||||
missionIssueId,
|
||||
childIssueCount: 1,
|
||||
documentCount: 1,
|
||||
blockerCount: 1,
|
||||
status: "not_configured",
|
||||
});
|
||||
|
||||
const missionsList = await harness.getData<MissionsListResult>("missions-list", { companyId });
|
||||
expect(missionsList).toMatchObject({
|
||||
isMission: boolean;
|
||||
parseProblems: Array<{ key: string }>;
|
||||
}>("mission-summary", {
|
||||
companyId,
|
||||
pageRoute: "missions",
|
||||
status: "not_configured",
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
message: "Mission list wiring is present. Mission lifecycle behavior is not implemented in Phase 0.",
|
||||
});
|
||||
expect(missionsList.missions).toEqual([
|
||||
{
|
||||
issueId: missionIssueId,
|
||||
identifier: "PAP-1684",
|
||||
title: "Mission root",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
harness.performAction("initialize-mission", { companyId, issueId: missionIssueId }),
|
||||
).resolves.toMatchObject({
|
||||
routeKey: "initialize-mission",
|
||||
issueId: missionIssueId,
|
||||
status: "not_configured",
|
||||
});
|
||||
|
||||
await expect(plugin.definition.onHealth?.()).resolves.toMatchObject({
|
||||
status: "ok",
|
||||
details: {
|
||||
pluginId: manifest.id,
|
||||
routeKeys: [...MISSIONS_API_ROUTE_KEYS],
|
||||
uiSlotIds: [...MISSIONS_UI_SLOT_IDS],
|
||||
},
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
expect(summary.isMission).toBe(true);
|
||||
expect(summary.parseProblems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "validation-contract" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches scoped api routes with placeholder bodies and validates waiver input", async () => {
|
||||
it("wakes assigned unblocked work and creates a single fix issue for blocking findings", async () => {
|
||||
const companyId = randomUUID();
|
||||
const missionIssueId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const readyFeatureId = randomUUID();
|
||||
const validationIssueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.seed({
|
||||
agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })],
|
||||
issues: [
|
||||
issue({
|
||||
id: missionIssueId,
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Mission root",
|
||||
identifier: "PAP-1684",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: PLUGIN_ORIGIN,
|
||||
}),
|
||||
issue({
|
||||
id: readyFeatureId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Ready feature",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: `${PLUGIN_ORIGIN}:feature`,
|
||||
originId: "FEAT-MISSION-001",
|
||||
}),
|
||||
issue({
|
||||
id: validationIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Validation round",
|
||||
status: "done",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: `${PLUGIN_ORIGIN}:validation`,
|
||||
originId: "VAL-ROUND-001",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "initialize-mission",
|
||||
method: "POST",
|
||||
path: `/issues/${missionIssueId}/missions/init`,
|
||||
params: { issueId: missionIssueId },
|
||||
query: {},
|
||||
body: {},
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 202,
|
||||
body: {
|
||||
routeKey: "initialize-mission",
|
||||
issueId: missionIssueId,
|
||||
status: "not_configured",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "mission-summary",
|
||||
method: "GET",
|
||||
path: `/issues/${missionIssueId}/missions/summary`,
|
||||
params: { issueId: missionIssueId },
|
||||
query: {},
|
||||
body: null,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
missionIssueId,
|
||||
status: "not_configured",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "missions-list",
|
||||
method: "GET",
|
||||
path: "/missions",
|
||||
params: {},
|
||||
query: { companyId },
|
||||
body: null,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
missions: [
|
||||
await upsertMissionDocuments(harness, companyId, rootIssueId);
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: validationIssueId,
|
||||
companyId,
|
||||
key: "validation-report-round-1",
|
||||
title: "Validation Report Round 1",
|
||||
body: JSON.stringify({
|
||||
round: 1,
|
||||
validator_role: "scrutiny_validator",
|
||||
summary: "Found one blocking issue",
|
||||
findings: [
|
||||
{
|
||||
issueId: missionIssueId,
|
||||
identifier: "PAP-1684",
|
||||
title: "Mission root",
|
||||
status: "todo",
|
||||
id: "FINDING-MISSION-001",
|
||||
severity: "blocking",
|
||||
assertion_id: "VAL-MISSION-001",
|
||||
title: "Advance skips fix loop",
|
||||
evidence: ["Validation report"],
|
||||
repro_steps: ["Run advance mission"],
|
||||
expected: "Blocking findings create one fix issue",
|
||||
actual: "No fix issue existed",
|
||||
status: "open",
|
||||
},
|
||||
],
|
||||
status: "not_configured",
|
||||
},
|
||||
}),
|
||||
changeSummary: "Seeded validation findings",
|
||||
});
|
||||
|
||||
const firstAdvance = await harness.performAction<{
|
||||
outcome: string;
|
||||
createdFixIssueIds: string[];
|
||||
wokenIssueIds: string[];
|
||||
}>("advance-mission", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
actorAgentId: agentId,
|
||||
actorRunId: "run_1",
|
||||
});
|
||||
const secondAdvance = await harness.performAction<{ createdFixIssueIds: string[] }>("advance-mission", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
actorAgentId: agentId,
|
||||
actorRunId: "run_2",
|
||||
});
|
||||
|
||||
const fixIssues = await harness.ctx.issues.list({
|
||||
companyId,
|
||||
originKind: `${PLUGIN_ORIGIN}:fix`,
|
||||
});
|
||||
|
||||
expect(firstAdvance.outcome).toBe("woke_issues");
|
||||
expect(firstAdvance.wokenIssueIds).toEqual(expect.arrayContaining([readyFeatureId]));
|
||||
expect(firstAdvance.createdFixIssueIds).toHaveLength(1);
|
||||
expect(secondAdvance.createdFixIssueIds).toEqual(firstAdvance.createdFixIssueIds);
|
||||
expect(fixIssues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires waiver rationale and records approved waivers", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const validationIssueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })],
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Mission root",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: PLUGIN_ORIGIN,
|
||||
}),
|
||||
issue({
|
||||
id: validationIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Validation round",
|
||||
status: "done",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: `${PLUGIN_ORIGIN}:validation`,
|
||||
originId: "VAL-ROUND-001",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await upsertMissionDocuments(harness, companyId, rootIssueId);
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: validationIssueId,
|
||||
companyId,
|
||||
key: "validation-report-round-1",
|
||||
title: "Validation Report Round 1",
|
||||
body: JSON.stringify({
|
||||
round: 1,
|
||||
validator_role: "scrutiny_validator",
|
||||
summary: "Found one blocking issue",
|
||||
findings: [
|
||||
{
|
||||
id: "FINDING-MISSION-001",
|
||||
severity: "blocking",
|
||||
assertion_id: "VAL-MISSION-001",
|
||||
title: "Advance skips fix loop",
|
||||
evidence: ["Validation report"],
|
||||
repro_steps: ["Run advance mission"],
|
||||
expected: "Blocking findings create one fix issue",
|
||||
actual: "No fix issue existed",
|
||||
status: "open",
|
||||
},
|
||||
],
|
||||
}),
|
||||
changeSummary: "Seeded validation findings",
|
||||
});
|
||||
|
||||
await expect(
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "waive-mission-finding",
|
||||
method: "POST",
|
||||
path: `/issues/${missionIssueId}/missions/findings/FINDING-001/waive`,
|
||||
params: { issueId: missionIssueId, findingKey: "FINDING-001" },
|
||||
path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`,
|
||||
params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" },
|
||||
query: {},
|
||||
body: {},
|
||||
actor: {
|
||||
@@ -319,10 +476,10 @@ describe("missions plugin scaffold", () => {
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "waive-mission-finding",
|
||||
method: "POST",
|
||||
path: `/issues/${missionIssueId}/missions/findings/FINDING-001/waive`,
|
||||
params: { issueId: missionIssueId, findingKey: "FINDING-001" },
|
||||
path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`,
|
||||
params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" },
|
||||
query: {},
|
||||
body: { rationale: "Defer to follow-up issue" },
|
||||
body: { rationale: "Known acceptable risk for this mission" },
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
@@ -334,50 +491,52 @@ describe("missions plugin scaffold", () => {
|
||||
headers: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 202,
|
||||
body: {
|
||||
routeKey: "waive-mission-finding",
|
||||
issueId: missionIssueId,
|
||||
findingKey: "FINDING-001",
|
||||
status: "not_configured",
|
||||
},
|
||||
status: 200,
|
||||
body: expect.objectContaining({
|
||||
waived: true,
|
||||
findingId: "FINDING-MISSION-001",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces checkout ownership for agent-triggered initialization on in-progress issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Checked out mission root",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
checkoutRunId: runId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onApiRequest?.({
|
||||
routeKey: "unknown-route",
|
||||
method: "GET",
|
||||
path: "/unknown",
|
||||
params: {},
|
||||
routeKey: "initialize-mission",
|
||||
method: "POST",
|
||||
path: `/issues/${rootIssueId}/missions/init`,
|
||||
params: { issueId: rootIssueId },
|
||||
query: {},
|
||||
body: null,
|
||||
body: {},
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
userId: null,
|
||||
runId: randomUUID(),
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 404,
|
||||
body: { error: "Unknown missions route: unknown-route" },
|
||||
});
|
||||
).rejects.toThrow("Issue run ownership conflict");
|
||||
});
|
||||
});
|
||||
|
||||
type MissionsListResult = {
|
||||
status: string;
|
||||
companyId: string;
|
||||
missions: Array<{
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
}>;
|
||||
routeKeys: string[];
|
||||
pageRoute: string;
|
||||
message: string;
|
||||
};
|
||||
Generated
+43
@@ -428,6 +428,49 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/plugins/plugin-missions:
|
||||
dependencies:
|
||||
'@paperclipai/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
devDependencies:
|
||||
'@rollup/plugin-node-resolve':
|
||||
specifier: ^16.0.1
|
||||
version: 16.0.3(rollup@4.60.1)
|
||||
'@rollup/plugin-typescript':
|
||||
specifier: ^12.1.2
|
||||
version: 12.3.0(rollup@4.60.1)(tslib@2.8.1)(typescript@5.9.3)
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.0
|
||||
'@types/react':
|
||||
specifier: ^19.0.8
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
esbuild:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
rollup:
|
||||
specifier: '>=4.59.0'
|
||||
version: 4.60.1
|
||||
tslib:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages/plugins/sdk:
|
||||
dependencies:
|
||||
'@paperclipai/shared':
|
||||
|
||||
Reference in new issue
Block a user