Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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