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
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,9 @@ A Deployment Task Timeline section for one Deployment Result Resource, presentin

### Deployment Task Success Record

The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the Default Open Port through its best Public Address as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity.
The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the Default Open Port through its best Public Address as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its primary HTTP(S) entry may be shared — copied, shown as a QR code, or posted to a social network — as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither.

_Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading.
_Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading, share project, share deployment.

### Deployment Celebration

Expand Down
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"nuqs": "^2.8.9",
"ogl": "^1.0.11",
"pg": "^8.20.0",
"qrcode.react": "^4.2.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-zoom-pan-pinch": "^3.7.0",
Expand Down
87 changes: 87 additions & 0 deletions apps/ui/src/features/chat/tool/chat-deploy-task-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,90 @@ test("chat createDeployTask refuses behind the pre-deploy wall and never creates
});
assert.equal(createCalls, 0);
});

const templateSource = {
args: { port: "25565" },
kind: "template",
templateName: "eaglercraft-server",
} as const;

function templateCatalogItem(name: string, category: string[]) {
return {
args: [],
category,
description: `${name} template`,
icon: "",
name,
readme: "",
sourceRepos: [],
title: name,
};
}

async function createTemplateTask(
listTemplateCatalog: () => Promise<ReturnType<typeof templateCatalogItem>[]>
) {
const sources: unknown[] = [];
const { createDeployTaskTools } = await import("./chat-deploy-task-tool");
const deployTaskTools = createDeployTaskTools(githubToolOptions(), {
createDeployTaskAction: (_context, input) => {
sources.push(input.create.source);
return Promise.resolve({
kind: "created",
launched: null,
task: { id: "task-11" } as never,
});
},
getDeployTaskEngineContext: () => null as never,
getDeployTaskSnapshot: () => Promise.resolve(null),
listTemplateCatalog,
runDeployTask: () => Promise.resolve(),
toDeployTaskDTO: (task: unknown) => task as never,
});
assert.ok(deployTaskTools.createDeployTask.execute);
const result = await deployTaskTools.createDeployTask.execute(
{
intention: "deploy the eaglercraft template",
source: templateSource,
target: { kind: "newProject", displayName: "eaglercraft" },
},
{ context: {}, messages: [], toolCallId: "tool-call-11" }
);
assert.ok(result != null && "ok" in result && result.ok);
assert.equal(sources.length, 1);
return sources[0];
}

test("chat createDeployTask snapshots the template's catalog categories into the source", async () => {
const source = await createTemplateTask(() =>
Promise.resolve([
templateCatalogItem("memos", ["tool"]),
templateCatalogItem("eaglercraft-server", ["game", "tool"]),
])
);
assert.deepEqual(source, {
...templateSource,
templateCategories: ["game", "tool"],
});
});

test("chat createDeployTask never lets the model declare template categories", () => {
const parsed = createDeployTaskToolInputSchema.parse({
intention: "deploy the eaglercraft template",
source: { ...templateSource, templateCategories: ["ai"] },
target: { kind: "newProject" },
});
assert.equal("templateCategories" in parsed.source, false);
});

test("chat createDeployTask still creates a template task when the catalog cannot answer", async () => {
const unknown = await createTemplateTask(() =>
Promise.resolve([templateCatalogItem("memos", ["tool"])])
);
assert.deepEqual(unknown, templateSource);

const unreachable = await createTemplateTask(() =>
Promise.reject(new Error("TEMPLATE_PROVIDER_URL is not configured."))
);
assert.deepEqual(unreachable, templateSource);
});
38 changes: 35 additions & 3 deletions apps/ui/src/features/chat/tool/chat-deploy-task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ import {
toDeployTaskDTO,
} from "@/features/deploy/task/service";
import {
type DeploymentTaskSource,
type DeploymentTaskTarget,
submitDeployTaskInputSchema,
} from "@/features/deploy/task/types";
import { listTemplateCatalog } from "@/features/deploy/template-provider-core";
import { IdentityBindingSupersededError } from "@/lib/identity-fingerprint-core";

const GITHUB_CONNECTION_REQUIRED_ERROR =
Expand Down Expand Up @@ -108,6 +110,7 @@ export function createDeployTaskTools(
getDeployTaskSnapshot?: typeof getDeployTaskSnapshot;
getDeployTaskTimelineSnapshot?: typeof getDeployTaskTimelineSnapshot;
judgeWorkspaceBillingStandingForActor?: typeof judgeWorkspaceBillingStandingForActor;
listTemplateCatalog?: typeof listTemplateCatalog;
runDeployTask?: typeof runDeployTask;
submitDeployTaskInputAction?: typeof submitDeployTaskInputAction;
toDeployTaskDTO?: typeof toDeployTaskDTO;
Expand Down Expand Up @@ -135,6 +138,34 @@ export function createDeployTaskTools(
dependencies.adoptLegacyGithubConnectionForOwner ??
adoptLegacyGithubConnectionForOwner;

const readCatalog = dependencies.listTemplateCatalog ?? listTemplateCatalog;
/**
* A template source snapshots the catalog's declared categories into the
* task so the Deployment Task Success Record can speak to a game or an AI
* app later (AIM-354). The model is never trusted to copy them: they are
* read off the catalog by template name here, the way the panes read them
* off the chosen catalog item. An unreachable catalog or an unknown name
* leaves the source as declared — the deploy itself does not depend on it.
*/
async function withTemplateCategories(
source: DeploymentTaskSource
): Promise<DeploymentTaskSource> {
if (source.kind !== "template") {
return source;
}
try {
const catalog = await readCatalog();
const categories = catalog.find(
(item) => item.name === source.templateName
)?.category;
return categories == null || categories.length === 0
? source
: { ...source, templateCategories: [...categories] };
} catch {
return source;
}
}

const judgeStanding =
dependencies.judgeWorkspaceBillingStandingForActor ??
judgeWorkspaceBillingStandingForActor;
Expand Down Expand Up @@ -241,15 +272,16 @@ export function createDeployTaskTools(
credentialBinding = bindingResolution.credentialBinding;
}

const source = await withTemplateCategories(input.source);
const result = await createTask(engineContext(), {
create: {
...(credentialBinding == null ? {} : { credentialBinding }),
createdFrom: "chat",
creatingActor: actionActor,
namespace,
prompt: input.prompt,
runner: defaultRunnerForSource(input.source),
source: input.source,
runner: defaultRunnerForSource(source),
source,
target,
},
resolveTarget: resolveDeployTaskTargetForCreate,
Expand All @@ -263,7 +295,7 @@ export function createDeployTaskTools(
// persists a stripped copy, so sensitive values reach the
// runner only through this in-memory hand-off (ADR 0037).
sourceArgValues:
input.source.kind === "template" ? input.source.args : undefined,
source.kind === "template" ? source.args : undefined,
taskId: task.id,
}),
});
Expand Down
Loading