Skip to content

Commit 002bf55

Browse files
fix: enforce target placement capabilities
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e4d624ee-fa8f-49d2-8b39-0ec71fc31e84
1 parent 495f55e commit 002bf55

7 files changed

Lines changed: 95 additions & 11 deletions

File tree

common/architecture-registry.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
DEFAULT_TARGET,
77
SkillArchitecture,
88
TARGETS,
9+
buildTargetFor,
910
defineArchitectures,
1011
enabledArchitectureLabels,
12+
requireTargetPlacement,
1113
} from "./architecture-registry";
1214
import type {
1315
ArchitectureDefinition,
@@ -49,11 +51,28 @@ test("the manifest derives architecture validation and target availability", ()
4951
);
5052
assert.equal(DEFAULT_TARGET.architecture, "scout");
5153
assert.equal(DEFAULT_TARGET.kind, "skill");
54+
assert.equal(
55+
buildTargetFor(DEFAULT_TARGET.architecture, "automation").installTargetLabel,
56+
"Scout",
57+
);
5258

5359
assert.deepEqual(enabledArchitectureLabels("skill"), ["Scout", "Cowork"]);
5460
assert.deepEqual(enabledArchitectureLabels("automation"), ["Scout"]);
5561
});
5662

63+
test("target placements are enforced from the manifest", () => {
64+
assert.equal(requireTargetPlacement("scout", "skill", "install").architecture, "scout");
65+
assert.equal(requireTargetPlacement("cowork", "skill", "export").architecture, "cowork");
66+
assert.throws(
67+
() => requireTargetPlacement("cowork", "skill", "install"),
68+
/Architecture "cowork" target "skill" does not support "install" placement/,
69+
);
70+
assert.throws(
71+
() => requireTargetPlacement("copilot-studio", "skill", "export"),
72+
/Architecture "copilot-studio" target "skill" is not enabled/,
73+
);
74+
});
75+
5776
test("architecture definitions reject invalid registry shapes", () => {
5877
const skill = {
5978
kind: "skill" as const,

common/architecture-registry.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface ArchitectureTargetDefinition {
1212
label: string;
1313
enabled: boolean;
1414
note: string;
15+
/** Ordered delivery capabilities; the first entry is the primary/default placement. */
1516
placements: readonly TargetPlacement[];
1617
}
1718

@@ -187,6 +188,38 @@ export const TARGETS: readonly BuildTarget[] = ARCHITECTURE_MANIFEST.flatMap(
187188
})),
188189
);
189190

191+
export function buildTargetFor(
192+
architecture: SkillArchitecture,
193+
kind: BuildKind,
194+
): BuildTarget {
195+
const target = TARGETS.find(
196+
(candidate) => candidate.architecture === architecture && candidate.kind === kind,
197+
);
198+
if (!target) {
199+
throw new Error(
200+
`No "${kind}" target is configured for architecture "${architecture}".`,
201+
);
202+
}
203+
return target;
204+
}
205+
206+
export function requireTargetPlacement(
207+
architecture: SkillArchitecture,
208+
kind: BuildKind,
209+
placement: TargetPlacement,
210+
): BuildTarget {
211+
const target = buildTargetFor(architecture, kind);
212+
if (!target.enabled) {
213+
throw new Error(`Architecture "${architecture}" target "${kind}" is not enabled.`);
214+
}
215+
if (!target.placements.includes(placement)) {
216+
throw new Error(
217+
`Architecture "${architecture}" target "${kind}" does not support "${placement}" placement.`,
218+
);
219+
}
220+
return target;
221+
}
222+
190223
const defaultTarget = TARGETS.find((target) => target.enabled);
191224
if (!defaultTarget) {
192225
throw new Error("Architecture manifest must contain at least one enabled target.");

electron/skillbuilder/builder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { approveAll, type CopilotSession } from "@github/copilot-sdk";
77
import {
88
BuiltSkillSchema,
99
renderSkillMarkdown,
10+
requireTargetPlacement,
1011
SkillPlanSchema,
1112
slugifySkillName,
1213
toBuiltSkill,
@@ -145,6 +146,7 @@ export class SkillBuilder extends AgentBuilder<LiveBuild> {
145146
// proposed plan for older callers that don't pass one.
146147
const plan = editedPlan ? SkillPlanSchema.parse(editedPlan) : held?.lastPlan ?? null;
147148
if (!plan) throw new Error("There is no plan to build from yet.");
149+
requireTargetPlacement(plan.architecture, "skill", target.kind);
148150
// The pool may have evicted the live conversation while the user edited the plan;
149151
// recreate one so export always works.
150152
if (!held) held = await this.createLive(sessionId, plan.architecture);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { SkillPlanSchema } from "../../common/skill";
5+
import { SkillBuilder } from "./builder";
6+
7+
test("skill creation rejects a placement unsupported by the plan architecture", async () => {
8+
const builder = new SkillBuilder(() => undefined);
9+
const plan = SkillPlanSchema.parse({
10+
architecture: "cowork",
11+
name: "reviewed-skill",
12+
title: "Reviewed skill",
13+
description: "A reviewed skill.",
14+
});
15+
16+
await assert.rejects(
17+
builder.create("placement-validation", plan, { kind: "install" }),
18+
/Architecture "cowork" target "skill" does not support "install" placement/,
19+
);
20+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"build": "tsc --noEmit && npm run build:vite",
1515
"typecheck": "tsc --noEmit",
1616
"typecheck:evals": "tsc --noEmit -p evals/tsconfig.json",
17-
"test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/narration.test.ts electron/recording-controls-bounds.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
17+
"test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/narration.test.ts electron/recording-controls-bounds.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
1818
"eval": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts",
1919
"eval:builder": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/builder/run.ts",
2020
"eval:skill": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/skillbuilder/run.ts",

src/Library.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import type {
1717
SkillArchitecture,
1818
SkillPlan,
1919
} from "../common/skill";
20-
import { ARCHITECTURES, DEFAULT_TARGET, TARGETS } from "../common/skill";
20+
import {
21+
ARCHITECTURES,
22+
DEFAULT_TARGET,
23+
TARGETS,
24+
buildTargetFor,
25+
} from "../common/skill";
2126
import type { AutomationPlan, BuiltAutomation } from "../common/automation";
2227
import {
2328
DEFAULT_NARRATION_LANGUAGE,
@@ -1207,6 +1212,11 @@ function AutomationBuilderView({
12071212
const [builtName, setBuiltName] = useState("");
12081213
const canceled = useRef(false);
12091214
const inFlight = useRef(false);
1215+
// The initial architecture can reflect a prior skill choice while a saved automation loads.
1216+
const automationInstallTargetLabel =
1217+
phase === "done"
1218+
? buildTargetFor(architecture, "automation").installTargetLabel
1219+
: "";
12101220

12111221
const updatePlan = useCallback((part: Partial<AutomationPlan>) => {
12121222
setPlan((prev) => (prev ? { ...prev, ...part } : prev));
@@ -1399,7 +1409,9 @@ function AutomationBuilderView({
13991409
</p>
14001410
{exportedPath && <p className="sb-path">{exportedPath}</p>}
14011411
<p className="sb-import-hint">
1402-
Import it into Scout: open Scout → Automations → Import, and choose this bundle folder.
1412+
Import it into {automationInstallTargetLabel}: open{" "}
1413+
{automationInstallTargetLabel} → Automations → Import, and choose this bundle
1414+
folder.
14031415
</p>
14041416
</div>
14051417
)}

src/skill-placement.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { TARGETS, type BuildTarget, type SkillArchitecture } from "../common/architecture-registry";
1+
import {
2+
buildTargetFor,
3+
type BuildTarget,
4+
type SkillArchitecture,
5+
} from "../common/architecture-registry";
26
import type { SkillPlacement } from "../common/ipc";
37

48
export interface SkillPlacementAction {
@@ -16,13 +20,7 @@ export interface SkillPlacementModel {
1620
}
1721

1822
export function skillTargetFor(architecture: SkillArchitecture): BuildTarget {
19-
const target = TARGETS.find(
20-
(candidate) => candidate.kind === "skill" && candidate.architecture === architecture,
21-
);
22-
if (!target) {
23-
throw new Error(`No skill target is configured for architecture "${architecture}".`);
24-
}
25-
return target;
23+
return buildTargetFor(architecture, "skill");
2624
}
2725

2826
export function skillPlacementModel(target: BuildTarget): SkillPlacementModel {

0 commit comments

Comments
 (0)