feat(web): build project loop workspace
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"@code/auth": "workspace:*",
|
"@code/auth": "workspace:*",
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@code/ui": "workspace:*",
|
"@code/ui": "workspace:*",
|
||||||
"@flue/react": "1.0.0-beta.9",
|
"@flue/react": "1.0.0-beta.9",
|
||||||
"@flue/sdk": "1.0.0-beta.9",
|
"@flue/sdk": "1.0.0-beta.9",
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitest": "catalog:",
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,11 @@ import { useFlueClient } from "@flue/react";
|
|||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
import { useAction, useMutation, useQuery } from "convex/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildProjectLoopView,
|
||||||
|
summarizeProjectIssues,
|
||||||
|
} from "@/lib/projects/project-evidence";
|
||||||
|
|
||||||
const errorMessage = (error: unknown) =>
|
const errorMessage = (error: unknown) =>
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
@@ -15,6 +20,8 @@ export const useProjectWorkspace = () => {
|
|||||||
const [repository, setRepository] = useState("");
|
const [repository, setRepository] = useState("");
|
||||||
const [issueTitle, setIssueTitle] = useState("");
|
const [issueTitle, setIssueTitle] = useState("");
|
||||||
const [issueBody, setIssueBody] = useState("");
|
const [issueBody, setIssueBody] = useState("");
|
||||||
|
const [selectedIssueId, setSelectedIssueId] =
|
||||||
|
useState<Id<"projectIssues"> | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||||
@@ -55,11 +62,12 @@ export const useProjectWorkspace = () => {
|
|||||||
setPendingAction("issue");
|
setPendingAction("issue");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createIssue({
|
const issueId = await createIssue({
|
||||||
body: issueBody,
|
body: issueBody,
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
title: issueTitle,
|
title: issueTitle,
|
||||||
});
|
});
|
||||||
|
setSelectedIssueId(issueId);
|
||||||
setIssueTitle("");
|
setIssueTitle("");
|
||||||
setIssueBody("");
|
setIssueBody("");
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
@@ -94,23 +102,36 @@ export const useProjectWorkspace = () => {
|
|||||||
projects?.find(
|
projects?.find(
|
||||||
(project) => project.id === (activeProjectId as unknown as string)
|
(project) => project.id === (activeProjectId as unknown as string)
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
const selectedIssue =
|
||||||
|
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
|
||||||
|
const projectLoop = buildProjectLoopView({
|
||||||
|
artifacts,
|
||||||
|
issue: selectedIssue,
|
||||||
|
source: selectedProject?.sources[0],
|
||||||
|
});
|
||||||
|
const issueSummary = summarizeProjectIssues(issues);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
artifacts,
|
artifacts,
|
||||||
connectRepository,
|
connectRepository,
|
||||||
error,
|
error,
|
||||||
issueBody,
|
issueBody,
|
||||||
|
issueSummary,
|
||||||
issueTitle,
|
issueTitle,
|
||||||
issues,
|
issues,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
|
projectLoop,
|
||||||
projects,
|
projects,
|
||||||
raiseIssue,
|
raiseIssue,
|
||||||
repository,
|
repository,
|
||||||
|
selectedIssue,
|
||||||
|
selectedIssueId,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedProjectId: activeProjectId,
|
selectedProjectId: activeProjectId,
|
||||||
setIssueBody,
|
setIssueBody,
|
||||||
setIssueTitle,
|
setIssueTitle,
|
||||||
setRepository,
|
setRepository,
|
||||||
|
setSelectedIssueId,
|
||||||
setSelectedProjectId,
|
setSelectedProjectId,
|
||||||
startIssue,
|
startIssue,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
91
apps/web/src/lib/projects/project-evidence.test.ts
Normal file
91
apps/web/src/lib/projects/project-evidence.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildProjectLoopView,
|
||||||
|
summarizeProjectIssues,
|
||||||
|
} from "./project-evidence";
|
||||||
|
|
||||||
|
const makeIssue = (
|
||||||
|
status: Doc<"projectIssues">["status"],
|
||||||
|
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
|
||||||
|
): Doc<"projectIssues"> =>
|
||||||
|
({
|
||||||
|
_creationTime: 1,
|
||||||
|
_id: issueId,
|
||||||
|
body: "Make the project loop visible in the web app.",
|
||||||
|
createdAt: 1000,
|
||||||
|
number: 8,
|
||||||
|
projectId: "project-1",
|
||||||
|
status,
|
||||||
|
title: "Build project loop UI",
|
||||||
|
updatedAt: 2000,
|
||||||
|
}) as Doc<"projectIssues">;
|
||||||
|
|
||||||
|
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
|
||||||
|
({
|
||||||
|
_creationTime: 1,
|
||||||
|
_id: "artifact-1",
|
||||||
|
content,
|
||||||
|
createdAt: 1000,
|
||||||
|
path: "artifacts.md",
|
||||||
|
projectId: "project-1",
|
||||||
|
revision: 2,
|
||||||
|
updatedAt: 2000,
|
||||||
|
}) as Doc<"projectArtifacts">;
|
||||||
|
|
||||||
|
describe("project evidence", () => {
|
||||||
|
test("turns published PR evidence into a review action", () => {
|
||||||
|
const view = buildProjectLoopView({
|
||||||
|
artifacts: [
|
||||||
|
makeArtifact(
|
||||||
|
"Verification: 14 unit tests passed.\nPull request: PR #42"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
issue: makeIssue("completed"),
|
||||||
|
source: {
|
||||||
|
host: "git.example.com",
|
||||||
|
repositoryPath: "puter/zopu",
|
||||||
|
url: "https://git.example.com/puter/zopu",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view?.progress).toBe(100);
|
||||||
|
expect(view?.verification).toBe("passed");
|
||||||
|
expect(view?.pullRequest.reviewUrl).toBe(
|
||||||
|
"https://git.example.com/puter/zopu/pulls/42"
|
||||||
|
);
|
||||||
|
expect(view?.verificationNote).toContain(
|
||||||
|
"Verification: 14 unit tests passed."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the review slot pending when no PR is published", () => {
|
||||||
|
const view = buildProjectLoopView({
|
||||||
|
artifacts: [
|
||||||
|
makeArtifact("The run is recording implementation evidence."),
|
||||||
|
],
|
||||||
|
issue: makeIssue("working"),
|
||||||
|
source: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view?.verification).toBe("running");
|
||||||
|
expect(view?.pullRequest.state).toBe("pending");
|
||||||
|
expect(view?.pullRequest.reviewUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("summarizes the project queue without inventing work", () => {
|
||||||
|
const summary = summarizeProjectIssues([
|
||||||
|
makeIssue("working"),
|
||||||
|
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
|
||||||
|
makeIssue("completed", "issue-3" as Id<"projectIssues">),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toEqual({
|
||||||
|
active: 2,
|
||||||
|
completed: 1,
|
||||||
|
needsInput: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
325
apps/web/src/lib/projects/project-evidence.ts
Normal file
325
apps/web/src/lib/projects/project-evidence.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import {
|
||||||
|
getProjectWorkProgress,
|
||||||
|
getProjectWorkStatus,
|
||||||
|
} from "@code/primitives/project-work";
|
||||||
|
import type {
|
||||||
|
ProjectIssueStatus,
|
||||||
|
ProjectVerificationStatus,
|
||||||
|
} from "@code/primitives/project-work";
|
||||||
|
|
||||||
|
type ProjectIssue = Doc<"projectIssues">;
|
||||||
|
type ProjectArtifact = Doc<"projectArtifacts">;
|
||||||
|
|
||||||
|
export interface ProjectSourceSummary {
|
||||||
|
readonly host: string;
|
||||||
|
readonly repositoryPath: string;
|
||||||
|
readonly url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectIssueSummary {
|
||||||
|
readonly active: number;
|
||||||
|
readonly completed: number;
|
||||||
|
readonly needsInput: number;
|
||||||
|
readonly total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectVerificationCheck {
|
||||||
|
readonly detail: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly state: "attention" | "complete" | "current" | "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectActivityItem {
|
||||||
|
readonly detail: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPullRequest {
|
||||||
|
readonly number: number | undefined;
|
||||||
|
readonly reviewUrl: string | undefined;
|
||||||
|
readonly state: "available" | "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectLoopView {
|
||||||
|
readonly activity: readonly ProjectActivityItem[];
|
||||||
|
readonly currentState: string;
|
||||||
|
readonly nextAction: string;
|
||||||
|
readonly progress: number;
|
||||||
|
readonly pullRequest: ProjectPullRequest;
|
||||||
|
readonly status: ReturnType<typeof getProjectWorkStatus>;
|
||||||
|
readonly verification: ProjectVerificationStatus;
|
||||||
|
readonly verificationChecks: readonly ProjectVerificationCheck[];
|
||||||
|
readonly verificationNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
|
||||||
|
completed:
|
||||||
|
"Implementation and verification are complete. Review the resulting change.",
|
||||||
|
failed:
|
||||||
|
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
|
||||||
|
"needs-input":
|
||||||
|
"The run is paused until a project decision or missing piece is resolved.",
|
||||||
|
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
|
||||||
|
queued: "The issue is queued for the project manager to pick up.",
|
||||||
|
working:
|
||||||
|
"The project manager is working through the issue and recording durable evidence.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEXT_ACTION: Readonly<Record<ProjectIssueStatus, string>> = {
|
||||||
|
completed: "Review the pull request",
|
||||||
|
failed: "Review the failure and retry",
|
||||||
|
"needs-input": "Resolve the open question",
|
||||||
|
open: "Start the project manager",
|
||||||
|
queued: "Watch the run progress",
|
||||||
|
working: "Wait for verification",
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number): string =>
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(timestamp);
|
||||||
|
|
||||||
|
const relativeTime = (timestamp: number): string => {
|
||||||
|
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||||
|
if (minutes < 1) {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
}
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
return `${Math.round(hours / 24)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
|
||||||
|
artifacts.map((artifact) => artifact.content).join("\n");
|
||||||
|
|
||||||
|
const extractPullRequest = (
|
||||||
|
artifacts: readonly ProjectArtifact[],
|
||||||
|
source: ProjectSourceSummary | undefined
|
||||||
|
): ProjectPullRequest => {
|
||||||
|
const text = allArtifactText(artifacts);
|
||||||
|
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
|
||||||
|
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
|
||||||
|
const numberValue = numberMatch?.groups?.number;
|
||||||
|
const number = numberValue ? Number(numberValue) : undefined;
|
||||||
|
let reviewUrl = urlMatch;
|
||||||
|
if (!reviewUrl && number && source) {
|
||||||
|
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number,
|
||||||
|
reviewUrl,
|
||||||
|
state: reviewUrl ? "available" : "pending",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractVerificationNote = (
|
||||||
|
artifacts: readonly ProjectArtifact[],
|
||||||
|
status: ProjectIssueStatus
|
||||||
|
): string => {
|
||||||
|
const evidenceLine = allArtifactText(artifacts)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
|
||||||
|
|
||||||
|
if (evidenceLine) {
|
||||||
|
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return "The agent reported completion after its verification step.";
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return "Verification did not complete successfully.";
|
||||||
|
}
|
||||||
|
if (status === "working") {
|
||||||
|
return "Verification will appear here as the run records evidence.";
|
||||||
|
}
|
||||||
|
return "No verification evidence has been recorded yet.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
|
||||||
|
if (status === "queued") {
|
||||||
|
return {
|
||||||
|
detail: "Agent admission is queued",
|
||||||
|
label: "Agent run",
|
||||||
|
state: "current",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "open") {
|
||||||
|
return {
|
||||||
|
detail: "Start the issue to create a run",
|
||||||
|
label: "Agent run",
|
||||||
|
state: "upcoming",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: "Project manager run is attached",
|
||||||
|
label: "Agent run",
|
||||||
|
state: "complete",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeVerificationCheck = (
|
||||||
|
verification: ProjectVerificationStatus
|
||||||
|
): ProjectVerificationCheck => {
|
||||||
|
if (verification === "passed") {
|
||||||
|
return {
|
||||||
|
detail: "Verification passed",
|
||||||
|
label: "Verification",
|
||||||
|
state: "complete",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (verification === "failed") {
|
||||||
|
return {
|
||||||
|
detail: "Review the preserved failure",
|
||||||
|
label: "Verification",
|
||||||
|
state: "attention",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (verification === "blocked") {
|
||||||
|
return {
|
||||||
|
detail: "Waiting on a project decision",
|
||||||
|
label: "Verification",
|
||||||
|
state: "attention",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (verification === "running") {
|
||||||
|
return {
|
||||||
|
detail: "Evidence is being recorded",
|
||||||
|
label: "Verification",
|
||||||
|
state: "current",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: "No verification result yet",
|
||||||
|
label: "Verification",
|
||||||
|
state: "upcoming",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeChecks = (
|
||||||
|
status: ProjectIssueStatus,
|
||||||
|
verification: ProjectVerificationStatus,
|
||||||
|
pullRequest: ProjectPullRequest
|
||||||
|
): readonly ProjectVerificationCheck[] => {
|
||||||
|
const issueCheck: ProjectVerificationCheck =
|
||||||
|
status === "open"
|
||||||
|
? {
|
||||||
|
detail: "Waiting to be started",
|
||||||
|
label: "Issue accepted",
|
||||||
|
state: "upcoming",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
detail: "Issue context is attached",
|
||||||
|
label: "Issue accepted",
|
||||||
|
state: "complete",
|
||||||
|
};
|
||||||
|
const pullRequestCheck: ProjectVerificationCheck =
|
||||||
|
pullRequest.state === "available"
|
||||||
|
? {
|
||||||
|
detail: "Ready for human review",
|
||||||
|
label: "Gitea pull request",
|
||||||
|
state: "complete",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
detail: "Appears after a verified run publishes one",
|
||||||
|
label: "Gitea pull request",
|
||||||
|
state: "upcoming",
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
issueCheck,
|
||||||
|
makeRunCheck(status),
|
||||||
|
makeVerificationCheck(verification),
|
||||||
|
pullRequestCheck,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildActivity = (
|
||||||
|
issue: ProjectIssue,
|
||||||
|
artifacts: readonly ProjectArtifact[],
|
||||||
|
statusLabel: string
|
||||||
|
): readonly ProjectActivityItem[] => {
|
||||||
|
const artifact = [...artifacts]
|
||||||
|
.toSorted((left, right) => right.updatedAt - left.updatedAt)
|
||||||
|
.find((candidate) => candidate.path !== "work.md");
|
||||||
|
const items: ProjectActivityItem[] = [
|
||||||
|
{
|
||||||
|
detail: `Issue #${issue.number} entered the project loop`,
|
||||||
|
label: "Issue created",
|
||||||
|
time: formatTime(issue.createdAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (issue.updatedAt !== issue.createdAt) {
|
||||||
|
items.unshift({
|
||||||
|
detail: `Status is now ${statusLabel.toLowerCase()}`,
|
||||||
|
label: "Work state updated",
|
||||||
|
time: relativeTime(issue.updatedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact) {
|
||||||
|
items.unshift({
|
||||||
|
detail: `Durable project evidence changed in ${artifact.path}`,
|
||||||
|
label: "Evidence recorded",
|
||||||
|
time: relativeTime(artifact.updatedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const summarizeProjectIssues = (
|
||||||
|
issues: readonly ProjectIssue[] | undefined
|
||||||
|
): ProjectIssueSummary => {
|
||||||
|
if (!issues) {
|
||||||
|
return { active: 0, completed: 0, needsInput: 0, total: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: issues.filter(
|
||||||
|
(issue) => !["completed", "failed"].includes(issue.status)
|
||||||
|
).length,
|
||||||
|
completed: issues.filter((issue) => issue.status === "completed").length,
|
||||||
|
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
|
||||||
|
total: issues.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProjectLoopView = ({
|
||||||
|
artifacts,
|
||||||
|
issue,
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
readonly artifacts: readonly ProjectArtifact[] | undefined;
|
||||||
|
readonly issue: ProjectIssue | undefined;
|
||||||
|
readonly source: ProjectSourceSummary | undefined;
|
||||||
|
}): ProjectLoopView | null => {
|
||||||
|
if (!issue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getProjectWorkStatus(issue.status);
|
||||||
|
const { verification } = status;
|
||||||
|
const resolvedArtifacts = artifacts ?? [];
|
||||||
|
const pullRequest = extractPullRequest(resolvedArtifacts, source);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity: buildActivity(issue, resolvedArtifacts, status.label),
|
||||||
|
currentState: STATUS_TEXT[issue.status],
|
||||||
|
nextAction: NEXT_ACTION[issue.status],
|
||||||
|
progress: getProjectWorkProgress(issue.status),
|
||||||
|
pullRequest,
|
||||||
|
status,
|
||||||
|
verification,
|
||||||
|
verificationChecks: makeChecks(issue.status, verification, pullRequest),
|
||||||
|
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
"include": [
|
||||||
|
"**/*",
|
||||||
|
"**/.server/**/*",
|
||||||
|
"**/.client/**/*",
|
||||||
|
".react-router/types/**/*"
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
||||||
"types": ["node", "vite/client"],
|
"types": ["node", "vite/client"],
|
||||||
@@ -10,6 +15,8 @@
|
|||||||
"rootDirs": [".", "./.react-router/types"],
|
"rootDirs": [".", "./.react-router/types"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
|
"@code/primitives": ["../../packages/primitives/src/index.ts"],
|
||||||
|
"@code/primitives/*": ["../../packages/primitives/src/*"],
|
||||||
"@code/ui/*": ["../../packages/ui/src/*"]
|
"@code/ui/*": ["../../packages/ui/src/*"]
|
||||||
},
|
},
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./agent-os": "./src/agent-os.ts",
|
"./agent-os": "./src/agent-os.ts",
|
||||||
"./project": "./src/project.ts",
|
"./project": "./src/project.ts",
|
||||||
|
"./project-work": "./src/project-work.ts",
|
||||||
"./signal": "./src/signal.ts"
|
"./signal": "./src/signal.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||||
export * from "./agent-os";
|
export * from "./agent-os";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
|
export * from "./project-work";
|
||||||
export * from "./signal";
|
export * from "./signal";
|
||||||
|
|||||||
30
packages/primitives/src/project-work.test.ts
Normal file
30
packages/primitives/src/project-work.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { getProjectWorkProgress, getProjectWorkStatus } from "./project-work";
|
||||||
|
|
||||||
|
describe("project work state mapping", () => {
|
||||||
|
test("maps issue and run statuses to the same product language", () => {
|
||||||
|
expect(getProjectWorkStatus("working")).toEqual({
|
||||||
|
label: "In progress",
|
||||||
|
phase: "active",
|
||||||
|
verification: "running",
|
||||||
|
});
|
||||||
|
expect(getProjectWorkStatus("needs-input")).toEqual({
|
||||||
|
label: "Needs your input",
|
||||||
|
phase: "waiting",
|
||||||
|
verification: "blocked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps progress monotonic through the normal loop", () => {
|
||||||
|
expect(getProjectWorkProgress("open")).toBeLessThan(
|
||||||
|
getProjectWorkProgress("queued")
|
||||||
|
);
|
||||||
|
expect(getProjectWorkProgress("queued")).toBeLessThan(
|
||||||
|
getProjectWorkProgress("working")
|
||||||
|
);
|
||||||
|
expect(getProjectWorkProgress("working")).toBeLessThan(
|
||||||
|
getProjectWorkProgress("completed")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/primitives/src/project-work.ts
Normal file
115
packages/primitives/src/project-work.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export const PROJECT_ISSUE_STATUSES = [
|
||||||
|
"open",
|
||||||
|
"queued",
|
||||||
|
"working",
|
||||||
|
"needs-input",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProjectIssueStatus = (typeof PROJECT_ISSUE_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PROJECT_RUN_STATUSES = [
|
||||||
|
"ready",
|
||||||
|
"queued",
|
||||||
|
"working",
|
||||||
|
"needs-input",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProjectRunStatus = (typeof PROJECT_RUN_STATUSES)[number];
|
||||||
|
|
||||||
|
export type ProjectWorkPhase =
|
||||||
|
| "captured"
|
||||||
|
| "queued"
|
||||||
|
| "active"
|
||||||
|
| "waiting"
|
||||||
|
| "completed"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type ProjectVerificationStatus =
|
||||||
|
| "not-started"
|
||||||
|
| "running"
|
||||||
|
| "blocked"
|
||||||
|
| "passed"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface ProjectWorkStatus {
|
||||||
|
readonly label: string;
|
||||||
|
readonly phase: ProjectWorkPhase;
|
||||||
|
readonly verification: ProjectVerificationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Readonly<
|
||||||
|
Record<ProjectIssueStatus | ProjectRunStatus, ProjectWorkStatus>
|
||||||
|
> = {
|
||||||
|
completed: {
|
||||||
|
label: "Completed",
|
||||||
|
phase: "completed",
|
||||||
|
verification: "passed",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "Failed",
|
||||||
|
phase: "failed",
|
||||||
|
verification: "failed",
|
||||||
|
},
|
||||||
|
"needs-input": {
|
||||||
|
label: "Needs your input",
|
||||||
|
phase: "waiting",
|
||||||
|
verification: "blocked",
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
label: "Ready to start",
|
||||||
|
phase: "captured",
|
||||||
|
verification: "not-started",
|
||||||
|
},
|
||||||
|
queued: {
|
||||||
|
label: "Queued",
|
||||||
|
phase: "queued",
|
||||||
|
verification: "not-started",
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
label: "Ready",
|
||||||
|
phase: "queued",
|
||||||
|
verification: "not-started",
|
||||||
|
},
|
||||||
|
working: {
|
||||||
|
label: "In progress",
|
||||||
|
phase: "active",
|
||||||
|
verification: "running",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectWorkStatus = (
|
||||||
|
status: ProjectIssueStatus | ProjectRunStatus
|
||||||
|
): ProjectWorkStatus => STATUS_MAP[status];
|
||||||
|
|
||||||
|
export const getProjectWorkProgress = (
|
||||||
|
status: ProjectIssueStatus | ProjectRunStatus
|
||||||
|
): number => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed": {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
case "failed": {
|
||||||
|
return 58;
|
||||||
|
}
|
||||||
|
case "needs-input": {
|
||||||
|
return 78;
|
||||||
|
}
|
||||||
|
case "working": {
|
||||||
|
return 68;
|
||||||
|
}
|
||||||
|
case "queued":
|
||||||
|
case "ready": {
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
case "open": {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user