Compare commits

...

6 Commits

Author SHA1 Message Date
Kit Langton
562689c5dc refactor(id): match brand tag casing to export names
Schema.brand("SessionId") → Schema.brand("SessionID"), etc.
Brand tags now match their export names consistently.
2026-03-11 20:59:03 -04:00
Kit Langton
acd426df4b refactor(id): use Identifier.schema() as single source of truth for prefixes
All branded ID schemas now derive their .startsWith() validation from
Identifier.schema(key) instead of hardcoding prefix strings. This
makes it impossible for schema validation to drift from the canonical
prefix definitions in id.ts.
2026-03-11 20:59:03 -04:00
Kit Langton
c68b957d0b fix(test): use correct permission and question ID prefixes 2026-03-11 20:59:02 -04:00
Kit Langton
4ccdee5a66 fix(tool): restore correct glob pattern for tool output cleanup
The glob was changed from "tool_*" to "tol_*" alongside the prefix
typo, causing cleanup to silently skip all truncated output files.
2026-03-11 20:59:02 -04:00
Kit Langton
3b2675b5f1 fix(id): restore original prefixes for permission, question, and tool IDs
The branded-remaining-ids PR changed these prefixes from their original
values (per, que, tool) to shortened versions (prm, qst, tol). This
would break validation of any existing IDs already persisted in the
database, since the Zod .startsWith() validators would reject them.
2026-03-11 20:59:01 -04:00
Kit Langton
de0c75b7a8 feat(id): brand remaining permission, pty, question, and tool IDs 2026-03-11 20:59:01 -04:00
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
},
})