Skip to content
Open
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
98 changes: 98 additions & 0 deletions docs/design/onboarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Onboarding and the family photo

## The idea

The old "Getting started" dialog said, in effect, go find a contribution and
connect your agent. Nothing happened when you did. The new onboarding is built
around one task that is fun, a little competitive, and exercises every part of
the loop: **get your agent into the family photo.**

The family photo is one big group picture of everyone who has joined the
project, agents and people alike. Each portrait is an image an agent made of
itself and posted to the project's `family-photo` session. Join order is your
spot: the front row fills first, rows get wider and smaller toward the back,
and there is always a dotted outline where the next person goes. It scales to
a thousand.

## What was built

- **`FamilyPhoto`** (`ui/src/family/FamilyPhoto.tsx`): the picture. A
deterministic seat plan (`seats(capacity)`) places portraits on a hillside
scene; portraits are circle-cropped, scale with depth and with the frame, and
the viewer's own are ringed in yellow. The next three open spots are drawn,
and the first is marked as yours until you are in it. The scene is a
placeholder SVG until the real source image is dropped in via the `scene`
prop; nothing else needs to change.
- **Onboarding** (`ui/src/family/Onboarding.tsx`), three steps that complete
themselves:
1. _There's a spot for you_: the photo, compact, with your spot marked.
2. _Connect your agent_: the exact `axp park family-photo …` command with a
copy button. The step ticks when an executor owned by you appears in the
registry.
3. _Your first task: a self-portrait_: what the agent will be asked, with the
prompt visible. A maintainer can send it from here; the intended path is
automation that sends it the moment an agent parks (an AAMP route or a
small maintainer-side watcher; see below).
- **Gateway** (`src/workspace.ts`): `GET /api/family` lists portraits from
every session whose task is `family-photo` or `family-photo-N`, in join
order; `GET /api/portrait?session&digest` serves an image blob inline with
`nosniff`, a sandboxed CSP and immutable caching. These are the only blobs
the gateway serves as images, and only PNG, JPEG, WebP, GIF or SVG under
1.5 MB. The page CSP now allows `blob:` images so authenticated fetches can
be shown through object URLs; `<img src>` alone cannot carry the token.
- **Demo**: the fixture creates the `family-photo` session and posts seven
portraits the way an agent would (blob upload, then a comment with the image
reference and a caption). Procedural villager faces stand in for real
portraits.
- **Test**: the browser suite checks the photo, join order, the image
endpoint's headers, that it is unreachable without the token, and the
onboarding dialog.

## The protocol, unchanged

A portrait is a comment whose body contains `axp-blob:/<session>/<sha256>`.
Comments are authenticated, ordered, durable, capped at 256 per session and
already part of export. That is why no new command was added. The 256 cap is
the reason the gateway accepts `family-photo-2`, `-3`, …: a project shards
when the front sessions fill. A dedicated `_axp/portrait` command with
one-per-principal enforcement is the natural next protocol step; the UI
would not change.

## Malicious, competitive, collaborative

- Front-row spots are first come, first served. That is the competition.
- One portrait per person is a convention today, not a rule; the gateway shows
every portrait a principal posts. Enforcing it belongs in the host.
- An agent's image tool runs under its contributor's permissions; tool
approval is unchanged, so a portrait that requires a tool the maintainer
won't allow doesn't get made. Blob size is capped by the host and again by
the portrait endpoint.
- SVG portraits are served with a sandboxed CSP and only ever loaded through
`<img>`, which does not execute scripts.

## The portrait generator

Portraits should look like one family, so there is a generator:
[maceip/axp-avatar](https://github.com/maceip/axp-avatar), branch
`axp/generative-inputs`. `tools/slice.py` cuts the uploaded sheets into layers
(backgrounds, mannequin heads with hair or hats, villager and alternative
bases, face accessories with the mannequin subtracted, isolated headwear) and
`tools/compose.py` turns a seed into a stable 512px avatar: skin retoned by
seed, the notion-avatar line parts (eyebrows, eyes, nose, mouth, sometimes a
beard) placed by mapping the notion face box onto the head's skin box, then
glasses and a hat. `--transparent` gives the family-photo cutout. The task
prompt points agents at it as the "our tool" option; agents with their own
image tool are free to use that instead.

The sheets are game renders and are fine as prototype inputs; anything
published should replace them with our own drawings in the same slots (the
README in that repo says so too). The big empty group scene will arrive in the
same repo and drops into `FamilyPhoto`'s `scene` prop.

## Next

- The real scene image, and a layout pass against it (the seat plan is a
function; rows, pitch and depth are four numbers).
- Automation for step three: send `FAMILY_TASK_PROMPT` when a new executor
claims the family session.
- Show the portrait beside its author everywhere avatars appear today.
17 changes: 17 additions & 0 deletions src/workspace-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,20 @@ export interface WorkspaceCommand {
| { kind: "accept"; checkpoint: string; manifestDigest: string }
| { kind: "submit"; checkpoint: string; model: string };
}

/** One person's portrait in the family photo. */
export interface Portrait {
id: string;
author: string;
session: string;
digest: string;
createdAt: number;
caption: string;
}
export interface FamilyPhoto {
/** sessions that feed the photo (task `family-photo`, `family-photo-2`, …) */
sessions: string[];
/** in join order; index is the spot */
portraits: Portrait[];
capacity: number;
}
104 changes: 103 additions & 1 deletion src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,24 @@ import { id, sha, digest } from "./protocol/schema.js";
import { Codes, requireThat, ProtocolError } from "./protocol/errors.js";
import { hashObject } from "./hash.js";
import { WorkspaceCommands } from "./workspace-commands.js";

import type {
Contribution,
ContributionDetail,
FamilyPhoto,
Portrait,
WorkspaceView,
} from "./workspace-contract.js";
const PORTRAIT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/svg+xml",
]);
const PORTRAIT_MAX_BYTES = 1_500_000;
/** Spots in the photo. The layout scales to this; sharding sessions raises it. */
const FAMILY_CAPACITY = 1000;

export interface WorkspaceOptions {
url: string;
Expand Down Expand Up @@ -232,6 +245,58 @@ export class WorkspaceServer {
totalTurns: chat.turns.length,
};
}
/** The family photo: every portrait posted to the project's family sessions.
*
* A portrait is a discussion comment in a session whose task is
* `family-photo` (or `family-photo-N`, so a project can shard past the
* 256-comment discussion cap) whose body references an image blob in that
* session. Order is join order, which is what decides your spot. */
private async family(client: AxpClient): Promise<FamilyPhoto> {
const { items } = await client.ahp.request("listSessions", {
channel: ROOT,
});
const portraits: Portrait[] = [];
const sessions: string[] = [];
// Comments posted in the same millisecond keep the host's sequence.
const order = new Map<string, number>();
for (const item of items) {
const session = id.parse(item.resource.slice("ahp-session:/".length));
const state = await this.snapshot<ExchangeState>(
client,
channels(session).exchange,
);
Comment on lines +262 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid serially snapshotting the full session catalog

For repositories with many contributions, every family-photo refresh now performs one awaited subscription RPC for every readable session before it can determine which sessions have the family task. With a remote host, roughly 150 sessions at 100 ms latency already exceed the UI's 15-second API timeout, and above the workspace's 128-snapshot cache bound the sequential traversal evicts entries that the next refresh needs, causing the scan to repeat. Filter using catalog metadata or fetch with bounded concurrency and a durable task index/cache.

Useful? React with 👍 / 👎.

if (!/^family-photo(-\d+)?$/.test(state.task)) continue;
sessions.push(session);
const prefix = `axp-blob:/${encodeURIComponent(state.resource)}/`;
for (const [index, comment] of (state.discussion ?? []).entries()) {
const match = comment.body.match(
new RegExp(
`${prefix.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}([a-f0-9]{64})`,
),
);
if (!match) continue;
order.set(comment.id, index);
portraits.push({
id: comment.id,
author: comment.author,
session,
digest: match[1]!,
createdAt: comment.createdAt,
caption: comment.body
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/axp-blob:\/\S+/g, "")
.trim()
.slice(0, 140),
});
}
}
portraits.sort(
(a, b) =>
a.createdAt - b.createdAt ||
(order.get(a.id) ?? 0) - (order.get(b.id) ?? 0),
);
return { sessions, portraits, capacity: FAMILY_CAPACITY };
}
private async workspace(
client: AxpClient,
offset: number,
Expand Down Expand Up @@ -333,7 +398,7 @@ export class WorkspaceServer {
response.setHeader("referrer-policy", "no-referrer");
response.setHeader(
"content-security-policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
);
try {
requireThat(
Expand Down Expand Up @@ -435,6 +500,43 @@ export class WorkspaceServer {
}
return;
}
if (url.pathname === "/api/family" && request.method === "GET") {
json(200, await this.family(client));
return;
}
if (url.pathname === "/api/portrait" && request.method === "GET") {
// Portraits are the only blobs the gateway serves as images. Content
// addressing makes them immutable, so they cache for a year; nosniff
// and an inline disposition keep an <img> the only consumer.
const session = id.parse(url.searchParams.get("session"));
const sha256 = digest.parse(url.searchParams.get("digest"));
const blob = await client.call("_axp/blobGet", {
channel: channels(session).exchange,
digest: sha256,
});
requireThat(
PORTRAIT_TYPES.has(blob.mediaType),
Codes.invalid,
"Portraits must be PNG, JPEG, WebP, GIF or SVG",
);
const bytes = Buffer.from(blob.data, "base64");
requireThat(
bytes.length <= PORTRAIT_MAX_BYTES,
Codes.limit,
"Portraits are limited to 1.5 MB",
);
response.writeHead(200, {
"content-type": blob.mediaType,
"content-length": bytes.length,
"content-disposition": "inline",
"cache-control": "private, max-age=31536000, immutable",
"x-content-type-options": "nosniff",
"content-security-policy":
"sandbox; default-src 'none'; style-src 'unsafe-inline'",
});
response.end(bytes);
return;
}
if (url.pathname === "/api/patch" && request.method === "GET") {
const session = id.parse(url.searchParams.get("session"));
const checkpoint = sha.parse(url.searchParams.get("checkpoint"));
Expand Down
66 changes: 66 additions & 0 deletions test/browser/workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,69 @@ test("stored agent content is inspectable and downloadable without executing its
await f.close();
}
});

test("the family photo shows every posted portrait in join order and serves images only through the gateway", async ({
page,
}) => {
const f = await workspaceFixture();
try {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const link = new URL(await f.open("contributor"));
await page.goto(link.href);
await page
.getByRole("navigation", { name: "Workspace" })
.getByRole("button", { name: "People" })
.click();
const photo = page.locator(".family-photo");
await expect(photo.locator(".fp-person")).toHaveCount(7);
// portraits load through the authenticated portrait endpoint as blob: URLs
await expect(photo.locator(".fp-person img").first()).toHaveAttribute(
"src",
/^blob:/,
);
await expect(photo.locator("figcaption")).toContainText(
"7 of 1000 spots taken",
);
// the contributor's own portraits are marked, and the next spot is offered
await expect(photo.locator(".fp-person.is-mine")).toHaveCount(3);
await expect(photo.locator(".fp-open")).toHaveCount(3);
// the endpoint refuses to serve a non-image blob and is not reachable without the token
const access = new URLSearchParams(link.hash.slice(1)).get("access");
const family = await page.request.get(`${link.origin}/api/family`, {
headers: { authorization: `Bearer ${access}` },
});
const listed = (await family.json()) as {
portraits: { session: string; digest: string; author: string }[];
};
expect(listed.portraits.map((p) => p.author).slice(0, 3)).toEqual([
"contributor",
"maintainer",
"verifier",
]);
const first = listed.portraits[0]!;
const image = await page.request.get(
`${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`,
{ headers: { authorization: `Bearer ${access}` } },
);
expect(image.status()).toBe(200);
expect(image.headers()["content-type"]).toBe("image/svg+xml");
expect(image.headers()["content-disposition"]).toBe("inline");
expect(image.headers()["x-content-type-options"]).toBe("nosniff");
const anonymous = await page.request.get(
`${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`,
);
expect(anonymous.status()).toBe(403);
// onboarding is built around the photo
await page.getByRole("button", { name: "Getting started" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("Join the family photo");
// the fixture's contributor already has an agent connected, so step two
// completes itself instead of showing the park command
await expect(dialog).toContainText("Local ACP agent is here");
await expect(dialog.locator(".fp-person")).toHaveCount(7);
expect(errors).toEqual([]);
} finally {
await f.close();
}
});
Loading
Loading