Skip to content
317 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.1/**
2 * Who and where, for the artifacts service's folio code: the workspace by
3 * slug, its people, agents and teams, how member keys show, and the
4 * spaces with a person's role in each. Cached per request (one instance
5 * per request). Docs' page code (src/index.ts, `Docs`) keeps its own copy
6 * of these until it is removed.
7 */
8import {
9 fail,
10 identityClient,
11 newId,
12 ok,
13 parsePrincipalKey,
14 principalKey,
15 workspaceAgentsClient,
16 type DocAgentMode,
17 type DocRole,
18 type DocSpace,
19 type DocSpaceKind,
20 type Member,
21 type MemberProfile,
22 type Principal,
23 type Result,
24 type ServiceBinding,
25 type User,
26 type Viewer,
27 type Workspace,
28 type WorkspaceAgent,
29} from "@g1t/contracts";
30
31import { roleOf, type Person, type SpaceRules } from "./access.ts";
32import { freeSlug } from "./slugs.ts";
33
34export type WhoEnv = { DB: D1Database; IDENTITY: ServiceBinding; AGENTS: ServiceBinding };
35
36export type SpaceRow = {
37 id: string;
38 workspace_id: string;
39 slug: string;
40 name: string;
41 description: string | null;
42 icon: string | null;
43 kind: DocSpaceKind;
44 team: string | null;
45 default_role: DocRole | null;
46 agent_mode: DocAgentMode;
47 /** 1: people with edit access may share what is in it (migration 0005). */
48 editors_can_share: number;
49 is_default: number;
50 created_by: string;
51 created_at: string;
52 archived_at: string | null;
53};
54
55/** A space, with who is in it and the viewer's role (null: they can't read it). */
56export type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
57
58export const now = () => new Date().toISOString();
59
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.60/**
61 * Kept across requests in this isolate: the workspace behind a slug for
62 * half a minute (identity answers it in ~80 ms, and every folio call
63 * starts with it), and which workspaces already have their General
64 * space (made once, never unmade). A rename reaches the old slug within
65 * that half minute, which is what the site's own redirect allows.
66 */
67const WORKSPACE_TTL_MS = 30_000;
68const workspaces = new Map<string, { at: number; value: Promise<Workspace | null> }>();
69const defaultsMade = new Set<string>();
70
71/** For tests: forgets everything kept across requests. */
72export function forgetKept(): void {
73 workspaces.clear();
74 defaultsMade.clear();
75}
76
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.77export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules {
78 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
79}
80
81export function isMember(viewer: Viewer, workspace: string): boolean {
82 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
83}
84
85export function userKey(viewer: Pick<User, "id">): string {
86 return principalKey({ kind: "user", id: viewer.id });
87}
88
89export class Who {
90 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
91 private readonly people = new Map<string, Promise<Map<string, Member>>>();
92 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
93 private readonly spaces = new Map<string, Promise<Omit<Space, "role">[]>>();
94 readonly usernames = new Map<string, string>();
95 private readonly agents = new Map<string, WorkspaceAgent | null>();
96
97 constructor(private readonly env: WhoEnv) {}
98
99 workspace(slug: string): Promise<Workspace | null> {
100 const key = String(slug ?? "").toLowerCase();
101 let found = this.workspaces.get(key);
102 if (!found) {
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.103 const kept = workspaces.get(key);
104 if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = kept.value;
105 else {
106 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
107 workspaces.set(key, { at: Date.now(), value: found });
108 // Only an answer is kept: a miss or a failure is asked again next time.
109 void found.then((w) => {
110 if (!w) workspaces.delete(key);
111 });
112 }
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.113 this.workspaces.set(key, found);
114 }
115 return found;
116 }
117
118 /** The workspace acting for itself: how this service asks identity about its members. */
119 actor(workspace: Workspace): User {
120 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
121 }
122
123 /** The workspace's people by username (lowercased). */
124 members(workspace: Workspace): Promise<Map<string, Member>> {
125 let found = this.people.get(workspace.id);
126 if (!found) {
127 found = identityClient(this.env.IDENTITY)
128 .listMembers(workspace.slug, this.actor(workspace))
129 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
130 .catch(() => new Map<string, Member>());
131 this.people.set(workspace.id, found);
132 }
133 return found;
134 }
135
136 /** Each member's teams (slugs, lowercased), by username. */
137 teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
138 let found = this.teams.get(workspace.id);
139 if (!found) {
140 found = identityClient(this.env.IDENTITY)
141 .teamMemberships(this.actor(workspace), workspace.slug)
142 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
143 .catch(() => new Map<string, Set<string>>());
144 this.teams.set(workspace.id, found);
145 }
146 return found;
147 }
148
149 async nameUsers(ids: string[]): Promise<void> {
150 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
151 if (!unnamed.length) return;
152 const named = await identityClient(this.env.IDENTITY)
153 .usernames(unnamed)
154 .catch(() => ({}) as Record<string, string>);
155 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
156 }
157
158 async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
159 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
160 if (wanted.length) {
161 let found: WorkspaceAgent[] = [];
162 try {
163 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
164 } catch (error) {
165 console.error("folios could not resolve agents", error);
166 }
167 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
168 }
169 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
170 }
171
172 /** How member keys show. Anything that isn't a person or agent shows as g1t. */
173 async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
174 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
175 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
176 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
177 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
178 const out = new Map<string, MemberProfile>();
179 for (const p of principals) {
180 if (p.kind === "user") {
181 const username = this.usernames.get(p.id) ?? null;
182 const person = username ? people.get(username.toLowerCase()) : undefined;
183 out.set(principalKey(p), { ...p, name: username ?? "ghost", display_name: person?.name || username || "Former member", avatar: person?.avatar ?? null, role: null, title: null, avatar_seed: null });
184 } else {
185 const agent = agents.get(p.id) ?? null;
186 out.set(principalKey(p), {
187 ...p,
188 name: agent?.handle ?? p.id,
189 display_name: agent?.display_name ?? "Former agent",
190 avatar: agent?.avatar ?? null,
191 role: agent?.role ?? null,
192 title: agent?.title || null,
193 avatar_seed: agent?.avatar_seed ?? null,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.194 look: agent?.look ?? null,
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.195 });
196 }
197 }
198 for (const key of keys) {
199 if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null });
200 }
201 return out;
202 }
203
204 async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
205 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts.");
206 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts.");
207 const workspace = await this.workspace(slug);
208 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
209 }
210
211 viewerOwner(viewer: User, slug: string): boolean {
212 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
213 }
214
215 /** A person as access sees them: their teams, and whether they own the workspace. */
216 async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> {
217 const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>();
218 return { user_id: user.id, owner, teams };
219 }
220
221 /** The viewer as access sees them. */
222 viewerPerson(workspace: Workspace, viewer: User): Promise<Person> {
223 return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug));
224 }
225
226 /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */
227 async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> {
228 const unique = [...new Set(ids.map(String))].slice(0, 200);
229 await this.nameUsers(unique);
230 const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]);
231 return unique.map((id) => {
232 const username = this.usernames.get(id)?.toLowerCase() ?? "";
233 const member = members.get(username);
234 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() };
235 });
236 }
237
238 /** Every space in the workspace (archived too), with members and projects. */
239 allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> {
240 let found = this.spaces.get(workspace.id);
241 if (!found) {
242 found = (async () => {
243 const db = this.env.DB;
244 const [spaces, members, projects] = await Promise.all([
245 db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(),
246 db
247 .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?")
248 .bind(workspace.id)
249 .all<{ space_id: string; principal: string; role: DocRole }>(),
250 db.prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?").bind(workspace.id).all<{ space_id: string; repo: string }>(),
251 ]);
252 return spaces.results.map((row) => ({
253 row,
254 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
255 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
256 }));
257 })();
258 this.spaces.set(workspace.id, found);
259 }
260 return found;
261 }
262
263 /** Forget cached spaces after one changed. */
264 forgetSpaces(): void {
265 this.spaces.clear();
266 }
267
268 /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */
269 async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> {
270 const spaces = await this.allSpaces(workspace);
271 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
272 }
273
274 /** Makes the workspace's General space, once. */
275 async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.276 if (defaultsMade.has(workspace.id)) return;
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.277 const db = this.env.DB;
278 const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.279 if (found) {
280 defaultsMade.add(workspace.id);
281 return;
282 }
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.283 const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
284 await db
285 .prepare(
286 "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)",
287 )
288 .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
289 .run();
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.290 defaultsMade.add(workspace.id);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.291 this.forgetSpaces();
292 }
293
294 toSpace(space: Space, pageCount = 0): DocSpace {
295 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
296 return {
297 id: space.row.id,
298 workspace_id: space.row.workspace_id,
299 slug: space.row.slug,
300 name: space.row.name,
301 description: space.row.description,
302 icon: space.row.icon,
303 kind: space.row.kind,
304 team: space.row.team,
305 default_role: space.row.kind === "private" ? null : space.row.default_role,
306 agent_mode: space.row.agent_mode,
307 editors_can_share: !!space.row.editors_can_share,
308 is_default: !!space.row.is_default,
309 projects: space.projects,
310 created_by: created,
311 created_at: space.row.created_at,
312 archived_at: space.row.archived_at,
313 viewer_role: space.role ?? "view",
314 page_count: pageCount,
315 };
316 }
317}