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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 38 additions & 28 deletions apps/hub/src/projects/next-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ type ProjectRun = {
failureMessage: string | null;
};

// Runs are newest first. A failure stands on the newest run of a role, and the
// first such specialist in that order is the one the next step already names.
function newestFailedSpecialist(
runs: readonly ProjectRun[],
): ProjectRun | undefined {
const newest = new Map<WorkflowRole, ProjectRun>();
for (const run of runs) if (!newest.has(run.role)) newest.set(run.role, run);
return [...newest.values()].find(
(run) => run.role !== "coordinator" && run.status === "failed",
);
}

/**
* Where the project stands and the person's one next step, from native state only: run status,
* each live run's latest committed event, pending approval records of live runs, the hub's live
Expand Down Expand Up @@ -194,6 +206,11 @@ export function createProjectNextStep(
async status(actor: Actor, projectId: string): Promise<ProjectStatus> {
const { status } = await readableProject(actor, projectId);
const all = await projectRuns(actor);
const failed = newestFailedSpecialist(all);
const note = (body: ProjectStatus): ProjectStatus =>
failed === undefined
? body
: { ...body, failedSpecialist: failed.role };
const states = await liveStates(all);
const pending = await db
.select({
Expand Down Expand Up @@ -256,19 +273,19 @@ export function createProjectNextStep(
const now = (lead: string) => `${lead} ${appNow}`;

if (status === "archived")
return {
return note({
working,
now: now(text.archived),
app,
next: { text: text.next.archived },
};
});
const asked = pending[0];
if (asked) {
const name = (asked.tool as { name?: unknown } | null)?.name;
const tool =
typeof name === "string" && name !== "" ? name : "approval";
const kinds = (asked.args as { kinds?: unknown } | null)?.kinds;
return {
return note({
working,
now: now(who),
waiting: { kind: "approval", approvalId: asked.id, tool },
Expand All @@ -281,75 +298,68 @@ export function createProjectNextStep(
: [],
),
},
};
});
}
if (!states.some((run) => run.role === "coordinator"))
return {
return note({
working,
now: now(text.stopped("coordinator", undefined)),
app,
next: {
text: text.next.resumeCoordinator,
action: "resume_coordinator",
},
};
});
const waitOn = busy[0] ?? starting[0];
if (waitOn !== undefined)
return {
return note({
working,
now: now(who),
app,
next: { text: text.next.wait(waitOn) },
};
});
if (unknown)
return {
return note({
working,
now: now(who),
app,
next: { text: text.next.unknown },
};
// A failure stands while its run is the newest of its role; runs are newest first.
const newest = new Map<WorkflowRole, ProjectRun>();
for (const run of all)
if (!newest.has(run.role)) newest.set(run.role, run);
const failed = [...newest.values()].find(
(run) => run.role !== "coordinator" && run.status === "failed",
);
});
if (failed)
return {
return note({
working,
now: now(text.stopped(failed.role, await failureReason(failed))),
app,
next: { text: text.next.retry, action: "reply" },
};
});
if (coderRan && app.state === "failed")
return {
return note({
working,
now: now(who),
app,
next: { text: text.next.fixApp, action: "reply" },
};
});
if (coderRan && app.state === "running")
return {
return note({
working,
now: now(who),
app,
next: { text: text.next.tryApp, action: "open_app" },
};
});
if (coderRan && app.state === "starting")
return {
return note({
working,
now: now(who),
app,
next: { text: text.next.waitApp },
};
return {
});
return note({
working,
now: now(who),
waiting: { kind: "reply" },
app,
next: { text: text.next.reply, action: "reply" },
};
});
},
};
}
Expand All @@ -369,7 +379,7 @@ export function mountProjectNextStepRoutes(
tags: ["Projects"],
operationId: "readProjectNextStep",
description:
"Who is at work in the project, what waits on the person, the app's state and the person's one next step, derived only from native state: run status and each live run's latest committed event (a run parked on input is idle), pending approval records of live runs, the hub's live app and the latest filed app version. Never from model text.",
"Who is at work in the project, what waits on the person, the app's state and the person's one next step, derived only from native state: run status and each live run's latest committed event (a run parked on input is idle), pending approval records of live runs, the hub's live app and the latest filed app version. failedSpecialist is the specialist whose newest run failed, when one has. Never from model text.",
security,
responses: {
200: {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,21 @@ export const wording = {
dropFiles: "Drop files to attach them",
noCoordinator: "This project has no running coordinator.",
},
/** The composer's placeholder when no approval is open. An open approval keeps approval.composerPlaceholder. */
composer: {
passAlong: (role: string) => `Ask the ${role}…`,
role: {
coordinator: "coordinator",
writer: "writer",
reviewer: "reviewer",
designer: "designer",
coder: "app builder",
},
change: "What needs to change…",
fix: "What needs fixing…",
making: "What are you making…",
reply: "What do you want to say…",
},
/** What a person's move is waiting on, as the bell and gate name it. */
wait: {
permission: "The coordinator asks permission to continue",
Expand Down
146 changes: 146 additions & 0 deletions apps/web/src/pages/workspace/composer-placeholder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, test } from "bun:test";
import type { ProjectStatus } from "@corbits-builder/schemas/project-status";
import { wording } from "../../config.ts";
import { composerPlaceholder } from "./composer-placeholder.ts";

function standing(input: {
working?: ProjectStatus["working"];
app?: ProjectStatus["app"];
failedSpecialist?: NonNullable<ProjectStatus["failedSpecialist"]>;
}): ProjectStatus {
return {
working: input.working ?? [],
now: "Standing.",
app: input.app ?? { state: "not_started" },
next: { text: "Reply to the coordinator" },
...(input.failedSpecialist === undefined
? {}
: { failedSpecialist: input.failedSpecialist }),
};
}

function placeholder(input: {
approvalOpen?: boolean;
chatFirst?: boolean;
status?: ProjectStatus;
}): string {
return composerPlaceholder({
approvalOpen: input.approvalOpen ?? false,
chatFirst: input.chatFirst ?? false,
status: input.status,
});
}

describe("composer placeholder", () => {
test("a working role beats a running app, and the first working role is named", () => {
expect(
placeholder({
status: standing({
working: [
{ role: "writer", state: "starting" },
{ role: "reviewer", state: "working" },
{ role: "designer", state: "working" },
],
app: { state: "running" },
failedSpecialist: "coder",
}),
}),
).toBe("Ask the reviewer…");
});

test("a starting role is named when nobody is working", () => {
expect(
placeholder({
status: standing({
working: [
{ role: "coordinator", state: "unknown" },
{ role: "writer", state: "starting" },
{ role: "designer", state: "starting" },
],
app: { state: "running" },
}),
}),
).toBe("Ask the writer…");
});

test("the coder is called the app builder", () => {
expect(
placeholder({
status: standing({
working: [{ role: "coder", state: "working" }],
app: { state: "running" },
}),
}),
).toBe("Ask the app builder…");
});

test("a running app beats a failure", () => {
expect(
placeholder({
chatFirst: true,
status: standing({
app: { state: "running" },
failedSpecialist: "writer",
}),
}),
).toBe("What needs to change…");
});

test("a failed app beats an empty project", () => {
expect(
placeholder({
chatFirst: true,
status: standing({ app: { state: "failed" } }),
}),
).toBe("What needs fixing…");
});

test("a failed specialist beats an empty project", () => {
expect(
placeholder({
chatFirst: true,
status: standing({
app: { state: "stopped" },
failedSpecialist: "designer",
}),
}),
).toBe("What needs fixing…");
});

test("an empty project beats the fallback", () => {
expect(
placeholder({
chatFirst: true,
status: standing({
working: [{ role: "writer", state: "unknown" }],
app: { state: "not_started" },
}),
}),
).toBe("What are you making…");
});

test("anything else asks for a reply", () => {
expect(
placeholder({
status: standing({
working: [{ role: "coder", state: "unknown" }],
app: { state: "stopped" },
}),
}),
).toBe("What do you want to say…");
});

test("an open approval keeps the approval placeholder", () => {
expect(
placeholder({
approvalOpen: true,
chatFirst: true,
status: standing({
working: [{ role: "coder", state: "working" }],
app: { state: "failed", reason: "crashed" },
failedSpecialist: "coder",
}),
}),
).toBe(wording.approval.composerPlaceholder);
});
});
41 changes: 41 additions & 0 deletions apps/web/src/pages/workspace/composer-placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* The composer's placeholder, from project status rather than the next-step
* sentence. An open approval keeps its own line.
*/
import type {
ProjectStatus,
ProjectWorker,
} from "@corbits-builder/schemas/project-status";
import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows";
import { wording } from "../../config.ts";

const copy = wording.composer;
const roleLabel: Record<WorkflowRole, string> = copy.role;

function passAlongRole(
working: readonly ProjectWorker[],
): WorkflowRole | undefined {
return (
working.find((item) => item.state === "working")?.role ??
working.find((item) => item.state === "starting")?.role
);
}

export function composerPlaceholder(input: {
approvalOpen: boolean;
/** Absent until project status has loaded. */
status: ProjectStatus | undefined;
/** No pane deliverables, design draft, or concepts yet. */
chatFirst: boolean;
}): string {
if (input.approvalOpen) return wording.approval.composerPlaceholder;
const status = input.status;
if (status === undefined) return copy.reply;
const role = passAlongRole(status.working);
if (role !== undefined) return copy.passAlong(roleLabel[role]);
if (status.app.state === "running") return copy.change;
if (status.app.state === "failed" || status.failedSpecialist !== undefined)
return copy.fix;
if (input.chatFirst) return copy.making;
return copy.reply;
}
Loading