Skip to content
329 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;
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.53 /**
54 * Set when the space was deleted (migration 0007). The row stays, so the
55 * trash's rows that point at it keep their space's name and rules (whoever
56 * could edit them still restores them) and General, once deleted, is not
57 * made again; listings leave deleted spaces out.
58 */
59 deleted_at: string | null;
60 deleted_by: string | 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.61};
62
63/** A space, with who is in it and the viewer's role (null: they can't read it). */
64export type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
65
66export const now = () => new Date().toISOString();
67
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.68/**
69 * Kept across requests in this isolate: the workspace behind a slug for
70 * half a minute (identity answers it in ~80 ms, and every folio call
71 * starts with it), and which workspaces already have their General
72 * space (made once, never unmade). A rename reaches the old slug within
73 * that half minute, which is what the site's own redirect allows.
74 */
75const WORKSPACE_TTL_MS = 30_000;
76const workspaces = new Map<string, { at: number; value: Promise<Workspace | null> }>();
77const defaultsMade = new Set<string>();
78
79/** For tests: forgets everything kept across requests. */
80export function forgetKept(): void {
81 workspaces.clear();
82 defaultsMade.clear();
83}
84
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.85export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules {
86 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
87}
88
89export function isMember(viewer: Viewer, workspace: string): boolean {
90 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
91}
92
93export function userKey(viewer: Pick<User, "id">): string {
94 return principalKey({ kind: "user", id: viewer.id });
95}
96
97export class Who {
98 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
99 private readonly people = new Map<string, Promise<Map<string, Member>>>();
100 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
101 private readonly spaces = new Map<string, Promise<Omit<Space, "role">[]>>();
102 readonly usernames = new Map<string, string>();
103 private readonly agents = new Map<string, WorkspaceAgent | null>();
104
105 constructor(private readonly env: WhoEnv) {}
106
107 workspace(slug: string): Promise<Workspace | null> {
108 const key = String(slug ?? "").toLowerCase();
109 let found = this.workspaces.get(key);
110 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.111 const kept = workspaces.get(key);
112 if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = kept.value;
113 else {
114 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
115 workspaces.set(key, { at: Date.now(), value: found });
116 // Only an answer is kept: a miss or a failure is asked again next time.
117 void found.then((w) => {
118 if (!w) workspaces.delete(key);
119 });
120 }
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.121 this.workspaces.set(key, found);
122 }
123 return found;
124 }
125
126 /** The workspace acting for itself: how this service asks identity about its members. */
127 actor(workspace: Workspace): User {
128 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
129 }
130
131 /** The workspace's people by username (lowercased). */
132 members(workspace: Workspace): Promise<Map<string, Member>> {
133 let found = this.people.get(workspace.id);
134 if (!found) {
135 found = identityClient(this.env.IDENTITY)
136 .listMembers(workspace.slug, this.actor(workspace))
137 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
138 .catch(() => new Map<string, Member>());
139 this.people.set(workspace.id, found);
140 }
141 return found;
142 }
143
144 /** Each member's teams (slugs, lowercased), by username. */
145 teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
146 let found = this.teams.get(workspace.id);
147 if (!found) {
148 found = identityClient(this.env.IDENTITY)
149 .teamMemberships(this.actor(workspace), workspace.slug)
150 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
151 .catch(() => new Map<string, Set<string>>());
152 this.teams.set(workspace.id, found);
153 }
154 return found;
155 }
156
157 async nameUsers(ids: string[]): Promise<void> {
158 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
159 if (!unnamed.length) return;
160 const named = await identityClient(this.env.IDENTITY)
161 .usernames(unnamed)
162 .catch(() => ({}) as Record<string, string>);
163 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
164 }
165
166 async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
167 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
168 if (wanted.length) {
169 let found: WorkspaceAgent[] = [];
170 try {
171 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
172 } catch (error) {
173 console.error("folios could not resolve agents", error);
174 }
175 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
176 }
177 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
178 }
179
180 /** How member keys show. Anything that isn't a person or agent shows as g1t. */
181 async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
182 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
183 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
184 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
185 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
186 const out = new Map<string, MemberProfile>();
187 for (const p of principals) {
188 if (p.kind === "user") {
189 const username = this.usernames.get(p.id) ?? null;
190 const person = username ? people.get(username.toLowerCase()) : undefined;
191 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 });
192 } else {
193 const agent = agents.get(p.id) ?? null;
194 out.set(principalKey(p), {
195 ...p,
196 name: agent?.handle ?? p.id,
197 display_name: agent?.display_name ?? "Former agent",
198 avatar: agent?.avatar ?? null,
199 role: agent?.role ?? null,
200 title: agent?.title || null,
201 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.202 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.203 });
204 }
205 }
206 for (const key of keys) {
207 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 });
208 }
209 return out;
210 }
211
212 async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
213 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts.");
214 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts.");
215 const workspace = await this.workspace(slug);
216 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
217 }
218
219 viewerOwner(viewer: User, slug: string): boolean {
220 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
221 }
222
223 /** A person as access sees them: their teams, and whether they own the workspace. */
224 async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> {
225 const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>();
226 return { user_id: user.id, owner, teams };
227 }
228
229 /** The viewer as access sees them. */
230 viewerPerson(workspace: Workspace, viewer: User): Promise<Person> {
231 return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug));
232 }
233
234 /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */
235 async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> {
236 const unique = [...new Set(ids.map(String))].slice(0, 200);
237 await this.nameUsers(unique);
238 const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]);
239 return unique.map((id) => {
240 const username = this.usernames.get(id)?.toLowerCase() ?? "";
241 const member = members.get(username);
242 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() };
243 });
244 }
245
246 /** Every space in the workspace (archived too), with members and projects. */
247 allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> {
248 let found = this.spaces.get(workspace.id);
249 if (!found) {
250 found = (async () => {
251 const db = this.env.DB;
252 const [spaces, members, projects] = await Promise.all([
253 db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(),
254 db
255 .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 = ?")
256 .bind(workspace.id)
257 .all<{ space_id: string; principal: string; role: DocRole }>(),
258 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 }>(),
259 ]);
260 return spaces.results.map((row) => ({
261 row,
262 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
263 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
264 }));
265 })();
266 this.spaces.set(workspace.id, found);
267 }
268 return found;
269 }
270
271 /** Forget cached spaces after one changed. */
272 forgetSpaces(): void {
273 this.spaces.clear();
274 }
275
276 /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */
277 async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> {
278 const spaces = await this.allSpaces(workspace);
279 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
280 }
281
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.282 /**
283 * Makes the workspace's General space, once. A deleted General still
284 * has its row (`deleted_at` set), so it is never made again: a
285 * workspace may have no spaces.
286 */
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.287 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.288 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.289 const db = this.env.DB;
290 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.291 if (found) {
292 defaultsMade.add(workspace.id);
293 return;
294 }
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.295 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));
296 await db
297 .prepare(
298 "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, ?, ?)",
299 )
300 .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
301 .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.302 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.303 this.forgetSpaces();
304 }
305
306 toSpace(space: Space, pageCount = 0): DocSpace {
307 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
308 return {
309 id: space.row.id,
310 workspace_id: space.row.workspace_id,
311 slug: space.row.slug,
312 name: space.row.name,
313 description: space.row.description,
314 icon: space.row.icon,
315 kind: space.row.kind,
316 team: space.row.team,
317 default_role: space.row.kind === "private" ? null : space.row.default_role,
318 agent_mode: space.row.agent_mode,
319 editors_can_share: !!space.row.editors_can_share,
320 is_default: !!space.row.is_default,
321 projects: space.projects,
322 created_by: created,
323 created_at: space.row.created_at,
324 archived_at: space.row.archived_at,
325 viewer_role: space.role ?? "view",
326 page_count: pageCount,
327 };
328 }
329}

This file's history is long; its oldest lines are credited to the oldest commit read.