Skip to content

Commit e1e41fb

Browse files
committed
fix(hub): move on after an approval unless the person asks to wait
1 parent 57ef0e4 commit e1e41fb

11 files changed

Lines changed: 342 additions & 95 deletions

File tree

‎apps/hub/src/projects/deploy-tools.test.ts‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ function writerTools(preferences: Record<string, string> = {}) {
222222
].map((item) => ({ ...item, sha256: "a".repeat(64) }));
223223
const deployed: string[] = [];
224224
const approvals: unknown[] = [];
225+
const inFlight: Record<string, unknown>[] = [];
225226
const fetch = async (path: string, init: RequestInit) => {
226227
const body = init.body ? JSON.parse(String(init.body)) : undefined;
227228
if (path.endsWith("/workflows/deployments")) {
@@ -235,7 +236,7 @@ function writerTools(preferences: Record<string, string> = {}) {
235236
});
236237
}
237238
if (path.endsWith("/artifacts") && init.method === "GET")
238-
return Response.json({ artifacts: documents, inFlight: [], preferences });
239+
return Response.json({ artifacts: documents, inFlight, preferences });
239240
if (path.includes("/artifacts/") && init.method === "GET") {
240241
const source = documents.find((item) =>
241242
path.includes("/artifacts/" + item["artifactId"]),
@@ -287,7 +288,7 @@ function writerTools(preferences: Record<string, string> = {}) {
287288
{ id: "call", name, arguments: args },
288289
new AbortController().signal,
289290
);
290-
return { run, deployed, approvals, documents };
291+
return { run, deployed, approvals, documents, inFlight };
291292
}
292293

293294
const writer = (kind: string) => ({
@@ -296,6 +297,48 @@ const writer = (kind: string) => ({
296297
"Write the " + kind + " from the filed documents and the person's answers.",
297298
});
298299

300+
test("an approval reports the steps whose gates it opened, unless one is under way", async () => {
301+
const { run, documents, inFlight } = writerTools();
302+
const canStart = async (kinds: string[]) => {
303+
const result = await run("request_approval", { kinds });
304+
return [JSON.parse(String(result.content)).canStart, result.detail];
305+
};
306+
307+
// The filed constraints are not approved yet, so approving the brief opens nothing.
308+
expect(await canStart(["problem_brief"])).toEqual([[], undefined]);
309+
inFlight.push({ runId: "w", role: "writer", status: "running" });
310+
expect(await canStart(["constraints"])).toEqual([[], undefined]);
311+
inFlight.pop();
312+
expect(await canStart(["constraints"])).toEqual([
313+
["approaches"],
314+
{ canStart: ["approaches"] },
315+
]);
316+
317+
for (const kind of ["approaches", "product_requirements"])
318+
documents.push({
319+
kind,
320+
artifactId: kind,
321+
title: kind,
322+
version: 1,
323+
sha256: "a".repeat(64),
324+
});
325+
expect(await canStart(["approaches", "product_requirements"])).toEqual([
326+
["design"],
327+
{ canStart: ["design"] },
328+
]);
329+
inFlight.push({ runId: "d", role: "designer", status: "running" });
330+
expect(await canStart(["product_requirements"])).toEqual([[], undefined]);
331+
inFlight.pop();
332+
documents.push({
333+
kind: "design_option",
334+
artifactId: "o",
335+
title: "Calm",
336+
version: 1,
337+
sha256: "a".repeat(64),
338+
});
339+
expect(await canStart(["product_requirements"])).toEqual([[], undefined]);
340+
});
341+
299342
test("one ask approves several documents, and a writer starts once every earlier one is approved", async () => {
300343
const { run, deployed, approvals } = writerTools();
301344

‎apps/hub/src/projects/deploy-tools.ts‎

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,14 @@ export function createDeployTools(options?: {
380380
const materials: ArtifactRef[] = [];
381381
const approved = new Map<string, number>();
382382
const openQuestions = new Map<string, number>();
383+
// Options a person has yet to choose from mean the design is already under way.
384+
let designOptions = false;
383385
for (const item of list.artifacts) {
384386
const { kind, artifactId, version, sha256, approvedVersion } = item;
385-
if (kind === "design_option") continue;
387+
if (kind === "design_option") {
388+
designOptions = true;
389+
continue;
390+
}
386391
if (kind === MATERIAL_KIND)
387392
materials.push({ kind, artifactId, version, sha256 });
388393
byKind.set(kind, { artifactId, version, sha256 });
@@ -396,6 +401,7 @@ export function createDeployTools(options?: {
396401
materials,
397402
approved,
398403
openQuestions,
404+
designOptions,
399405
inFlight: list.inFlight,
400406
preferences: list.preferences,
401407
};
@@ -430,18 +436,96 @@ export function createDeployTools(options?: {
430436
) => hub(projectPath + "/artifact-approvals", { tool, artifacts });
431437
const refsOf = (byKind: Map<string, Ref>) =>
432438
[...byKind].map(([kind, ref]) => ({ kind, ...ref }));
439+
const notFiled = (kind: string) =>
440+
new Refusal(
441+
kind +
442+
"_not_filed: " +
443+
(kind === "design"
444+
? "have the designer make it with design_app first"
445+
: "write and file the " + kind + " first"),
446+
);
433447
const required = (byKind: Map<string, Ref>, kind: string) => {
434448
const ref = byKind.get(kind);
435-
if (!ref)
436-
throw new Refusal(
437-
kind +
438-
"_not_filed: " +
439-
(kind === "design"
440-
? "have the designer make it with design_app first"
441-
: "write and file the " + kind + " first"),
442-
);
449+
if (!ref) throw notFiled(kind);
443450
return ref;
444451
};
452+
type Listed = Awaited<ReturnType<typeof listed>>;
453+
/*
454+
* Each start tool refuses with its gate's answer, and an approval reports the steps whose
455+
* gates then pass. The coordinator learns what it may start from the rule that enforces it,
456+
* so loosening a gate loosens what it is told.
457+
*/
458+
const writerGate = (
459+
{ byKind, approved, preferences }: Listed,
460+
kind: (typeof DOCUMENT_ORDER)[number],
461+
): Refusal | undefined => {
462+
if (!byKind.has("problem_brief")) return notFiled("problem_brief");
463+
const earlier = DOCUMENT_ORDER.slice(0, DOCUMENT_ORDER.indexOf(kind));
464+
// An imported project may lack an earlier document that its later ones were written without.
465+
const writtenWithout = (index: number) =>
466+
DOCUMENT_ORDER.slice(index + 1).some((later) => byKind.has(later));
467+
for (const [index, before] of earlier.entries()) {
468+
const ref = byKind.get(before);
469+
if (!ref) {
470+
if (writtenWithout(index)) continue;
471+
return new Refusal(
472+
before +
473+
"_missing: " +
474+
(before === "design"
475+
? "have the designer make it with design_app"
476+
: "write and file the " + before) +
477+
" before the " +
478+
kind,
479+
);
480+
}
481+
if (
482+
!agentDecidesApprovals(preferences) &&
483+
approved.get(before) !== ref.version
484+
)
485+
return notApproved(before, ref.version);
486+
}
487+
return undefined;
488+
};
489+
const designGate = ({ byKind }: Listed) => {
490+
const missing = DESIGNER_INPUTS.find((kind) => !byKind.has(kind));
491+
return missing === undefined ? undefined : notFiled(missing);
492+
};
493+
const coderGate = ({ byKind, approved, preferences }: Listed) => {
494+
const missing = CODER_DOCUMENTS.find((kind) => !byKind.has(kind));
495+
if (missing !== undefined) return notFiled(missing);
496+
for (const kind of DOCUMENT_ORDER) {
497+
const ref = byKind.get(kind);
498+
if (!ref) return notFiled(kind);
499+
// The design is approved by choosing it, whoever decides the other documents.
500+
if (
501+
(kind === "design" || !agentDecidesApprovals(preferences)) &&
502+
approved.get(kind) !== ref.version
503+
)
504+
return notApproved(kind, ref.version);
505+
}
506+
return undefined;
507+
};
508+
// A step already made or under way is not offered again, whatever its gate says.
509+
const startable = (state: Listed) => {
510+
const busy = new Set(state.inFlight.map((run) => run.role));
511+
const open = (
512+
step: string,
513+
role: string,
514+
gate: Refusal | undefined,
515+
) => !state.byKind.has(step) && !busy.has(role) && gate === undefined;
516+
return [
517+
...DOCUMENT_ORDER.filter(
518+
(kind) =>
519+
kind in WRITER_PACKAGES &&
520+
open(kind, "writer", writerGate(state, kind)),
521+
),
522+
...(!state.designOptions &&
523+
open("design", "designer", designGate(state))
524+
? ["design"]
525+
: []),
526+
...(open("app", "coder", coderGate(state)) ? ["app"] : []),
527+
];
528+
};
445529
// A fresh deployment warms its workspace with the current versions, the ones named here.
446530
const inputs = (
447531
kinds: readonly InputKind[],
@@ -504,10 +588,11 @@ export function createDeployTools(options?: {
504588
try {
505589
signal.throwIfAborted();
506590
let result: unknown;
591+
let canStart: readonly string[] = [];
507592
if (call.name === "request_approval") {
508593
const { kinds } = validArguments(RequestApproval, call.arguments);
509-
const { byKind, materials, openQuestions, preferences } =
510-
await listed();
594+
const state = await listed();
595+
const { byKind, materials, openQuestions, preferences } = state;
511596
const open = [...new Set(kinds)].flatMap((kind) => {
512597
const count = openQuestions.get(kind);
513598
return count
@@ -530,38 +615,17 @@ export function createDeployTools(options?: {
530615
return materials;
531616
});
532617
await recordApproval("request_approval", documents);
533-
result = { approved: documents };
618+
for (const { kind, version } of documents)
619+
state.approved.set(kind, version);
620+
canStart = startable(state);
621+
result = { approved: documents, canStart };
534622
} else if (call.name === "deploy_writer") {
535623
const input = validArguments(DeployWriter, call.arguments);
536-
const { byKind, approved, preferences } = await listed();
624+
const state = await listed();
625+
const { byKind, preferences } = state;
626+
const refusal = writerGate(state, input.kind);
627+
if (refusal) throw refusal;
537628
const briefRef = required(byKind, "problem_brief");
538-
const earlier = DOCUMENT_ORDER.slice(
539-
0,
540-
DOCUMENT_ORDER.indexOf(input.kind),
541-
);
542-
// An imported project may lack an earlier document that its later ones were written without.
543-
const writtenWithout = (index: number) =>
544-
DOCUMENT_ORDER.slice(index + 1).some((kind) => byKind.has(kind));
545-
for (const [index, kind] of earlier.entries()) {
546-
const ref = byKind.get(kind);
547-
if (!ref) {
548-
if (writtenWithout(index)) continue;
549-
throw new Refusal(
550-
kind +
551-
"_missing: " +
552-
(kind === "design"
553-
? "have the designer make it with design_app"
554-
: "write and file the " + kind) +
555-
" before the " +
556-
input.kind,
557-
);
558-
}
559-
if (
560-
!agentDecidesApprovals(preferences) &&
561-
approved.get(kind) !== ref.version
562-
)
563-
throw notApproved(kind, ref.version);
564-
}
565629
const target = byKind.get(input.kind);
566630
const sourceRefs = [...byKind]
567631
.filter(
@@ -623,26 +687,17 @@ export function createDeployTools(options?: {
623687
options.projectId + ":coder",
624688
"coder",
625689
async () => {
626-
const { byKind, approved, inFlight, preferences } =
627-
await listed();
628-
for (const kind of CODER_DOCUMENTS) required(byKind, kind);
690+
const state = await listed();
691+
const { byKind, inFlight, preferences } = state;
629692
const live = inFlight.find((run) => run.role === "coder");
630693
if (live)
631694
throw new Refusal(
632695
"coder_already_running: the coder is building in run " +
633696
live.runId +
634697
"; send it changes with instruct_coder",
635698
);
636-
// The design is approved by choosing it, whoever decides the other documents.
637-
for (const kind of DOCUMENT_ORDER) {
638-
const { version } = required(byKind, kind);
639-
if (
640-
(kind === "design" ||
641-
!agentDecidesApprovals(preferences)) &&
642-
approved.get(kind) !== version
643-
)
644-
throw notApproved(kind, version);
645-
}
699+
const refusal = coderGate(state);
700+
if (refusal) throw refusal;
646701
const design = required(byKind, "design");
647702
return deploy(
648703
"coder",
@@ -696,8 +751,10 @@ export function createDeployTools(options?: {
696751
options.projectId + ":designer:" + concept.label,
697752
"designer",
698753
async () => {
699-
const { byKind, inFlight, preferences } = await listed();
700-
for (const kind of DESIGNER_INPUTS) required(byKind, kind);
754+
const state = await listed();
755+
const { byKind, inFlight, preferences } = state;
756+
const refusal = designGate(state);
757+
if (refusal) throw refusal;
701758
const message =
702759
designLookIdeas(input.lookIdeas) +
703760
coderTaskMessage(input.instruction);
@@ -832,7 +889,11 @@ export function createDeployTools(options?: {
832889
content: "unknown_deploy_tool",
833890
isError: true,
834891
};
835-
return { callId: call.id, content: JSON.stringify(result) };
892+
return {
893+
callId: call.id,
894+
content: JSON.stringify(result),
895+
...(canStart.length ? { detail: { canStart } } : {}),
896+
};
836897
} catch (error) {
837898
return {
838899
callId: call.id,

0 commit comments

Comments
 (0)