feat(id): brand PermissionID, PtyID, QuestionID, and ToolID (#17042)

This commit is contained in:
Kit Langton
2026-03-11 21:49:57 -04:00
committed by GitHub
parent b0bca6342e
commit 2a4dedc210
19 changed files with 127 additions and 52 deletions

View File

@@ -3,13 +3,13 @@ import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
Schema.brand("AccountID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
Schema.brand("OrgID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>

View File

@@ -4,7 +4,7 @@ import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"))
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
@@ -12,6 +12,6 @@ export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
zod: z.string().startsWith("wrk").pipe(z.custom<WorkspaceID>()),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})),
)

View File

@@ -3,10 +3,10 @@ import { Bus } from "@/bus"
import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -22,7 +22,7 @@ export namespace Permission {
export const Info = z
.object({
id: z.string(),
id: PermissionID.zod,
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: SessionID.zod,
@@ -45,7 +45,7 @@ export namespace Permission {
"permission.replied",
z.object({
sessionID: SessionID.zod,
permissionID: z.string(),
permissionID: PermissionID.zod,
response: z.string(),
}),
),
@@ -118,7 +118,7 @@ export namespace Permission {
const keys = toKeys(input.pattern, input.type)
if (covered(keys, approvedForSession)) return
const info: Info = {
id: Identifier.ascending("permission"),
id: PermissionID.ascending(),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,

View File

@@ -1,8 +1,8 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "@/session/schema"
import { PermissionID } from "./schema"
import { Instance } from "@/project/instance"
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
@@ -69,7 +69,7 @@ export namespace PermissionNext {
export const Request = z
.object({
id: Identifier.schema("permission"),
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
@@ -102,7 +102,7 @@ export namespace PermissionNext {
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: z.string(),
requestID: PermissionID.zod,
reply: Reply,
}),
),
@@ -143,7 +143,7 @@ export namespace PermissionNext {
if (rule.action === "deny")
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
const id = input.id ?? PermissionID.ascending()
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
@@ -164,7 +164,7 @@ export namespace PermissionNext {
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
}),

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
export type PermissionID = typeof permissionIdSchema.Type
export const PermissionID = permissionIdSchema.pipe(
withStatics((schema: typeof permissionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
})),
)

View File

@@ -3,7 +3,7 @@ import z from "zod"
import { withStatics } from "@/util/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId"))
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
export type ProjectID = typeof projectIdSchema.Type

View File

@@ -2,12 +2,12 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
export namespace Pty {
const log = Log.create({ service: "pty" })
@@ -40,7 +40,7 @@ export namespace Pty {
export const Info = z
.object({
id: Identifier.schema("pty"),
id: PtyID.zod,
title: z.string(),
command: z.string(),
args: z.array(z.string()),
@@ -77,8 +77,8 @@ export namespace Pty {
export const Event = {
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: Identifier.schema("pty") })),
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
}
interface ActiveSession {
@@ -118,7 +118,7 @@ export namespace Pty {
}
export async function create(input: CreateInput) {
const id = Identifier.create("pty", false)
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (command.endsWith("sh")) {
@@ -234,7 +234,7 @@ export namespace Pty {
}
}
session.subscribers.clear()
Bus.publish(Event.Deleted, { id })
Bus.publish(Event.Deleted, { id: session.info.id })
}
export function resize(id: string, cols: number, rows: number) {

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("pty", id)),
zod: Identifier.schema("pty").pipe(z.custom<PtyID>()),
})),
)

View File

@@ -1,10 +1,10 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "@/session/schema"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question {
const log = Log.create({ service: "question" })
@@ -34,7 +34,7 @@ export namespace Question {
export const Request = z
.object({
id: Identifier.schema("question"),
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
@@ -67,7 +67,7 @@ export namespace Question {
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: z.string(),
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
@@ -75,7 +75,7 @@ export namespace Question {
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: z.string(),
requestID: QuestionID.zod,
}),
),
}
@@ -101,7 +101,7 @@ export namespace Question {
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
export type QuestionID = typeof questionIdSchema.Type
export const QuestionID = questionIdSchema.pipe(
withStatics((schema: typeof questionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
})),
)

View File

@@ -2,6 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -28,7 +29,7 @@ export const PermissionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: PermissionID.zod,
}),
),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),

View File

@@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -72,7 +73,7 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
@@ -99,7 +100,7 @@ export const PtyRoutes = lazy(() =>
...errors(400),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
@@ -124,7 +125,7 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
await Pty.remove(c.req.valid("param").ptyID)
return c.json(true)
@@ -148,9 +149,9 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return

View File

@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "../../question"
import z from "zod"
import { errors } from "../error"
@@ -51,7 +52,7 @@ export const QuestionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: QuestionID.zod,
}),
),
validator("json", Question.Reply),
@@ -86,7 +87,7 @@ export const QuestionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: QuestionID.zod,
}),
),
async (c) => {

View File

@@ -15,6 +15,7 @@ import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -957,7 +958,7 @@ export const SessionRoutes = lazy(() =>
"param",
z.object({
sessionID: SessionID.zod,
permissionID: z.string(),
permissionID: PermissionID.zod,
}),
),
validator("json", z.object({ response: PermissionNext.Reply })),

View File

@@ -4,7 +4,7 @@ import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionId"))
const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID"))
export type SessionID = typeof sessionIdSchema.Type
@@ -12,11 +12,11 @@ export const SessionID = sessionIdSchema.pipe(
withStatics((schema: typeof sessionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)),
zod: z.string().startsWith("ses").pipe(z.custom<SessionID>()),
zod: Identifier.schema("session").pipe(z.custom<SessionID>()),
})),
)
const messageIdSchema = Schema.String.pipe(Schema.brand("MessageId"))
const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID"))
export type MessageID = typeof messageIdSchema.Type
@@ -24,11 +24,11 @@ export const MessageID = messageIdSchema.pipe(
withStatics((schema: typeof messageIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)),
zod: z.string().startsWith("msg").pipe(z.custom<MessageID>()),
zod: Identifier.schema("message").pipe(z.custom<MessageID>()),
})),
)
const partIdSchema = Schema.String.pipe(Schema.brand("PartId"))
const partIdSchema = Schema.String.pipe(Schema.brand("PartID"))
export type PartID = typeof partIdSchema.Type
@@ -36,6 +36,6 @@ export const PartID = partIdSchema.pipe(
withStatics((schema: typeof partIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)),
zod: z.string().startsWith("prt").pipe(z.custom<PartID>()),
zod: Identifier.schema("part").pipe(z.custom<PartID>()),
})),
)

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID"))
export type ToolID = typeof toolIdSchema.Type
export const ToolID = toolIdSchema.pipe(
withStatics((schema: typeof toolIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("tool", id)),
zod: Identifier.schema("tool").pipe(z.custom<ToolID>()),
})),
)

View File

@@ -7,6 +7,7 @@ import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { ToolID } from "./schema"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -90,7 +91,7 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const id = Identifier.ascending("tool")
const id = ToolID.ascending()
const filepath = path.join(DIR, id)
await Filesystem.write(filepath, text)

View File

@@ -1,6 +1,7 @@
import { test, expect } from "bun:test"
import os from "os"
import { PermissionNext } from "../../src/permission/next"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
@@ -522,7 +523,7 @@ test("reply - once resolves the pending ask", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test1",
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -532,7 +533,7 @@ test("reply - once resolves the pending ask", async () => {
})
await PermissionNext.reply({
requestID: "permission_test1",
requestID: PermissionID.make("per_test1"),
reply: "once",
})
@@ -547,7 +548,7 @@ test("reply - reject throws RejectedError", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test2",
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -557,7 +558,7 @@ test("reply - reject throws RejectedError", async () => {
})
await PermissionNext.reply({
requestID: "permission_test2",
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
@@ -572,7 +573,7 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test3",
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -582,7 +583,7 @@ test("reply - always persists approval and resolves", async () => {
})
await PermissionNext.reply({
requestID: "permission_test3",
requestID: PermissionID.make("per_test3"),
reply: "always",
})
@@ -613,7 +614,7 @@ test("reply - reject cancels all pending for same session", async () => {
directory: tmp.path,
fn: async () => {
const askPromise1 = PermissionNext.ask({
id: "permission_test4a",
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -623,7 +624,7 @@ test("reply - reject cancels all pending for same session", async () => {
})
const askPromise2 = PermissionNext.ask({
id: "permission_test4b",
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
@@ -638,7 +639,7 @@ test("reply - reject cancels all pending for same session", async () => {
// Reject the first one
await PermissionNext.reply({
requestID: "permission_test4a",
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})

View File

@@ -1,6 +1,7 @@
import { test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
@@ -131,7 +132,7 @@ test("reply - does nothing for unknown requestID", async () => {
directory: tmp.path,
fn: async () => {
await Question.reply({
requestID: "que_unknown",
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
// Should not throw
@@ -204,7 +205,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject("que_unknown")
await Question.reject(QuestionID.make("que_unknown"))
// Should not throw
},
})