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
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ lines in `.env` (see `.env.example`). For local development,

| Directory | Responsibility |
| -------------------- | ------------------------------------------------------------------------------ |
| `packages/errors` | `DomainError`, user text, and what of a failure may leave the system |
| `packages/contracts` | Portable API schemas and shared wire types |
| `packages/core` | Domain services, database schema and migrations, and the durable workflows |
| `packages/server` | HTTP API, authentication, configuration, and process composition |
Expand All @@ -32,6 +33,10 @@ lines in `.env` (see `.env.example`). For local development,
| `packages/docs` | The docs pages, which the website publishes |
| `packages/website` | Landing page and docs, prerendered and served from Cloudflare |

When an area's errors are all `DomainError`s, add its path to two lists: the
`biome.json` override that runs the rules in `lint/`, and the Effect language
service override in `tsconfig.base.json`.

## Docs

The docs are part of the website, at `/docs`. Each page is an MDX file in
Expand Down
8 changes: 8 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@
}
},
"overrides": [
{
"includes": ["packages/server/src/http/errors.ts"],
"plugins": [
"./lint/errors-implement-domain-error.grit",
"./lint/no-effect-promise.grit",
"./lint/no-unchecked-user-text.grit"
]
},
{
"includes": [
"packages/**/src/**",
Expand Down
14 changes: 14 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions lint/errors-implement-domain-error.grit
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
language js(typescript)

or {
`class $name extends $base { $_ }` as $class where {
$base <: or {
contains `Schema.TaggedError`,
contains `Schema.Error`
},
not $class <: contains `implements DomainError`,
register_diagnostic(span=$name, message="This error class must implement `DomainError` from `@sugabots/errors`, so it has a user message, a retry flag and a severity.")
},
or {
`Data.TaggedError`,
`Data.Error`,
`class $_ extends Error { $_ }`
} as $other where {
register_diagnostic(span=$other, message="Declare errors with `Schema.TaggedError`, implementing `DomainError` from `@sugabots/errors`.")
}
}
5 changes: 5 additions & 0 deletions lint/no-effect-promise.grit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
language js(typescript)

`Effect.promise($_)` as $call where {
register_diagnostic(span=$call, message="`Effect.promise` turns a rejection into a defect, which nobody can explain to the person who saw it. Use `Effect.tryPromise` and catch into a `DomainError` class.")
}
10 changes: 10 additions & 0 deletions lint/no-unchecked-user-text.grit
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
language js(typescript)

or {
`UserText.unchecked($_)` as $unchecked,
`$_.schema.make($_)` as $unchecked,
`$_ as UserText` as $unchecked,
`$_ as DisplayName` as $unchecked
} where {
register_diagnostic(span=$unchecked, message="Write user text with `userText`, and get display names with `DisplayName.fromRecord`. Text made any other way isn't checked.")
}
1 change: 1 addition & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sugabots/errors": "workspace:*",
"effect": "catalog:",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-to-string": "^4.0.0",
Expand Down
21 changes: 14 additions & 7 deletions packages/contracts/src/http/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PublicError, UserText } from "@sugabots/errors";
import { Schema } from "effect";

/**
Expand All @@ -10,28 +11,34 @@ import { Schema } from "effect";
* class.
*/

/** What went wrong, for people to read. */
const message = UserText.schema;

/**
* Anything machine-readable behind the message: the fields that failed
* validation, or the code better-auth refused with.
*/
const details = Schema.optional(Schema.Unknown);

/** The `DomainError` behind the status, when there is one. */
const error = Schema.optional(PublicError);

export class BadRequest extends Schema.TaggedError<BadRequest>()(
"BadRequest",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 400 },
) {}

export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 401 },
) {}

/** The caller can see the thing, and may not do this to it. */
export class Forbidden extends Schema.TaggedError<Forbidden>()(
"Forbidden",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 403 },
) {}

Expand All @@ -41,7 +48,7 @@ export class Forbidden extends Schema.TaggedError<Forbidden>()(
*/
export class NotFound extends Schema.TaggedError<NotFound>()(
"NotFound",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 404 },
) {}

Expand All @@ -53,20 +60,20 @@ export const refused = [NotFound, Forbidden] as const;

export class Conflict extends Schema.TaggedError<Conflict>()(
"Conflict",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 409 },
) {}

export class PayloadTooLarge extends Schema.TaggedError<PayloadTooLarge>()(
"PayloadTooLarge",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 413 },
) {}

/** Something went wrong on the server. The message says nothing about what. */
export class InternalServerError extends Schema.TaggedError<InternalServerError>()(
"InternalServerError",
{ message: Schema.String, details },
{ message, details, error },
{ httpApiStatus: 500 },
) {}

Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@openrouter/ai-sdk-provider": "^3.1.0",
"@sugabots/accounting": "workspace:*",
"@sugabots/contracts": "workspace:*",
"@sugabots/errors": "workspace:*",
"@sugabots/workflow": "workspace:*",
"ai": "^7.0.100",
"cron-parser": "^5.10.1",
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/accounts/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
export * as Accounts from "./accounts.ts";

import { randomBytes } from "node:crypto";
import { userText } from "@sugabots/errors";
import { and, eq, gt } from "drizzle-orm";
import { Config, Context, Data, DateTime, Effect, Layer } from "effect";
import { CurrentActor } from "../authorization/current-actor.ts";
import { Database, query } from "../database/database.ts";
import { user, workspaceInvite } from "../database/schema.ts";
import { Installation } from "../installation/installation.ts";
import { type UserFacing, UserMessage } from "../user-message.ts";
import type { UserFacing } from "../user-message.ts";

/** Who may have an account here. */
export interface Interface {
Expand Down Expand Up @@ -92,7 +93,7 @@ export interface Admission {

export class SignUpClosed extends Data.TaggedError("SignUpClosed") implements UserFacing {
get userMessage() {
return UserMessage.of`Signups are invite only. Ask a member to invite you.`;
return userText`Signups are invite only. Ask a member to invite you.`;
}
}

Expand All @@ -101,13 +102,13 @@ export class ReferralLinkInvalid
implements UserFacing
{
get userMessage() {
return UserMessage.of`This invite link isn't valid. Check you have all of it, or ask whoever sent it for a new one.`;
return userText`This invite link isn't valid. Check you have all of it, or ask whoever sent it for a new one.`;
}
}

export class ReferralsOff extends Data.TaggedError("ReferralsOff") implements UserFacing {
get userMessage() {
return UserMessage.of`This installation does not sign people up by referral.`;
return userText`This installation does not sign people up by referral.`;
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/authorization/access.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ToolApprovalDeciders, WorkspaceRole } from "@sugabots/contracts";
import { userText } from "@sugabots/errors";
import { and, asc, eq, type SQL, type SQLWrapper, sql } from "drizzle-orm";
import { Data, Effect } from "effect";
import type { Executor } from "../database/database.ts";
import type * as schema from "../database/schema.ts";
import { agent, pod, podMember, thread, workspace, workspaceMember } from "../database/schema.ts";
import { isUuid } from "../ids/ids.ts";
import { type UserFacing, UserMessage } from "../user-message.ts";
import type { UserFacing } from "../user-message.ts";
import {
type Actor,
mayInPod,
Expand Down Expand Up @@ -78,7 +79,7 @@ export class ResourceHidden
implements UserFacing
{
get userMessage() {
return UserMessage.of`No such ${this.resource}`;
return userText`No such ${this.resource}`;
}
}

Expand All @@ -93,7 +94,7 @@ export class ActionForbidden
return `Lacks the ${this.permission} permission`;
}
get userMessage() {
return UserMessage.of`You are not allowed to do that`;
return userText`You are not allowed to do that`;
}
}

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/conversations/chats/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
PERSONAL_POD_SLUG,
textWithoutNarration,
} from "@sugabots/contracts";
import { userText } from "@sugabots/errors";
import {
and,
asc,
Expand Down Expand Up @@ -67,7 +68,7 @@ import {
user,
} from "../../database/schema.ts";
import { isUuid } from "../../ids/ids.ts";
import { type UserFacing, UserMessage } from "../../user-message.ts";
import type { UserFacing } from "../../user-message.ts";
import { crewAgentRow, toAgent } from "../../workspaces/agents/agent.ts";
import { type CursorPoint, decodeCursor, earlierThan, encodeCursor } from "../cursor.ts";
import { respondingIn } from "../floor/floor.ts";
Expand Down Expand Up @@ -357,7 +358,7 @@ export const layer = layerNoDeps.pipe(Layer.provide([Authorization.layer, Visibi

export class InvalidChatCursor extends Data.TaggedError("InvalidChatCursor") implements UserFacing {
get userMessage() {
return UserMessage.of`That chat cursor is invalid`;
return userText`That chat cursor is invalid`;
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/conversations/chats/chats.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * as Chats from "./chats.ts";

import type { Chat, Message } from "@sugabots/contracts";
import { userText } from "@sugabots/errors";
import { and, eq } from "drizzle-orm";
import { Context, Data, Effect, Layer } from "effect";
import type { AuthorizationDenied, ResourceHidden } from "../../authorization/access.ts";
Expand All @@ -9,7 +10,7 @@ import { CurrentActor } from "../../authorization/current-actor.ts";
import { Visibility } from "../../authorization/visibility.ts";
import { query, serviceOperations, transaction } from "../../database/database.ts";
import { agent, pod } from "../../database/schema.ts";
import { type UserFacing, UserMessage } from "../../user-message.ts";
import type { UserFacing } from "../../user-message.ts";
import { FloorControl } from "../floor/floor-control.ts";
import { crewOf, personAuthor, personColumns, toMessage } from "../threads/participants.ts";
import { ThreadRepository } from "../threads/repository.ts";
Expand Down Expand Up @@ -140,7 +141,7 @@ export class ChatPlacementRejected
implements UserFacing
{
get userMessage() {
return UserMessage.of`The agent and pod are not available for this chat`;
return userText`The agent and pod are not available for this chat`;
}
}

Expand All @@ -153,7 +154,7 @@ export class ChatAgentHasNoModel
implements UserFacing
{
get userMessage() {
return UserMessage.of`This agent has no model chosen, so it cannot answer yet`;
return userText`This agent has no model chosen, so it cannot answer yet`;
}
}

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/conversations/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type {
PersonParticipant,
ToolCallPart,
} from "@sugabots/contracts";
import type { UserText } from "@sugabots/errors";
import { Data } from "effect";
import type { UserMessage } from "../user-message.ts";
import type { TurnReason } from "./sql.ts";
import type { Turns } from "./turns/turns.ts";

Expand Down Expand Up @@ -80,7 +80,7 @@ export type ConversationEvent = Data.TaggedEnum<{
readonly agentId: string;
readonly messageId: string;
/** What people are told went wrong. */
readonly userMessage: UserMessage;
readonly userMessage: UserText;
readonly willRetry: boolean;
};
/** The turn was cancelled, or found stopped and ended as cancelled, keeping the reply written so far. */
Expand Down Expand Up @@ -117,7 +117,7 @@ export type ConversationEvent = Data.TaggedEnum<{
* Every attempt at choosing who speaks after a message in the thread
* failed. `userMessage` says so in words fit for people.
*/
FacilitationFailed: { readonly threadId: string; readonly userMessage: UserMessage };
FacilitationFailed: { readonly threadId: string; readonly userMessage: UserText };
/** A reply called a tool: the call is running, or waiting for a person to approve it. */
ToolCallStarted: ToolCallChange;
/** A person allowed or denied a call, which the lists hear of, since it no longer waits on anyone. */
Expand Down
Loading
Loading