diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 025db4b..68ec331 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -36,7 +36,8 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { Name: "list_clients", Description: "List the Macs with byre's app open: the human's own machines, not the one byre (and your worktree) runs on. " + "Each has its name, which the other client tools take as client, the id of that running app (pass it as client instead when two apps are open on one Mac), the human using it, and when the human last did something in byre from it. " + - "The most recently active one is listed first; it is usually the Mac the human sits at.", + "A message from the human says which Mac it was sent from (\"Sent from (client )\"); that is the Mac the human sits at, so show things there, naming it by that id. " + + "The most recently active one is listed first.", InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { return m.listClients(), nil diff --git a/internal/agents/compact.go b/internal/agents/compact.go index b325dde..288b8f3 100644 --- a/internal/agents/compact.go +++ b/internal/agents/compact.go @@ -44,7 +44,7 @@ func (m *Manager) Compact(ctx context.Context, sessionID, model string) (api.Age m.mu.Lock() m.handoffModel[sessionID] = model m.mu.Unlock() - return m.launch(ctx, sess, task, "byre", prompt, "", nil) + return m.launch(ctx, sess, task, "byre", prompt, "", "", nil) } func (m *Manager) continuationModel(sess api.AgentSession) string { @@ -90,7 +90,7 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error if err != nil { return err } - _, err = m.createLead(ctx, creator, leadHandoffPreamble+handoff, model, "byre", "", nil) + _, err = m.createLead(ctx, creator, leadHandoffPreamble+handoff, model, "byre", "", "", nil) return err } @@ -112,7 +112,7 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error if err != nil { return err } - _, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", "", nil) + _, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", "", "", nil) if errors.Is(err, ErrConflict) { m.startDiscard(task.Id) return nil diff --git a/internal/agents/fork.go b/internal/agents/fork.go index 6b67168..a613d58 100644 --- a/internal/agents/fork.go +++ b/internal/agents/fork.go @@ -10,6 +10,7 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" ) @@ -73,7 +74,7 @@ func (m *Manager) forkTool(l *live) mcp.Tool { if err != nil { return "", err } - _, _, err = m.Message(ctx, sessionID, agentActor, args.Prompt, "", "") + _, _, err = m.Message(ctx, sessionID, agentActor, args.Prompt, "", "", "") if err != nil { return "", fmt.Errorf("task %s forked, but its agent did not start: %w", t.Id, err) } @@ -121,7 +122,7 @@ func (m *Manager) AnswerAgain(ctx context.Context, creator api.User, create api. return api.Task{}, err } - id := store.NewID() + id := ids.New(ids.InboxItem) err = m.store.CreateAnsweredItem(ctx, api.InboxItem{ Id: id, ProjectId: t.ProjectId, @@ -196,13 +197,13 @@ func (m *Manager) fork(ctx context.Context, src api.Task, project api.Project, n return api.Task{}, "", err } - id := newUUID() + id := ids.New(ids.Session) err = m.store.CreateSession(ctx, api.AgentSession{ Id: id, TaskId: t.Id, Vendor: srcSess.Vendor, Model: srcSess.Model, - VendorSessionId: initialVendorSessionID(srcSess.Vendor, id), + VendorSessionId: initialVendorSessionID(srcSess.Vendor), State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: creator.Login, diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index 187bc7b..69c7f4d 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -12,6 +12,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" ) @@ -92,7 +93,7 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string return "", err } - id := store.NewID() + id := ids.New(ids.InboxItem) attachments := args.Attachments if attachments == nil { @@ -353,7 +354,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error { for _, p := range pending { images = append(images, p.Response.Images...) } - after, err := m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), "", images) + after, err := m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), "", "", images) if errors.Is(err, ErrConflict) { slog.Info("deliver held: session changed state", "session", sessionID, "err", err, "responses", ids) return nil @@ -392,7 +393,7 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { } msgs := []queued{} for _, in := range inputs { - msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID}) + msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID, client: in.Client}) } return midTurn{text: m.recordQueued(ctx, l, msgs), leadInputs: inputs} } @@ -415,7 +416,7 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { if len(mt.taskInputs) > 0 { msgs := []queued{} for _, in := range mt.taskInputs { - msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID}) + msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID, client: in.Client}) } parts = append(parts, m.recordQueued(ctx, l, msgs)) } diff --git a/internal/agents/lead.go b/internal/agents/lead.go index bd38d13..7a8cf7b 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -10,14 +10,15 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/store" ) const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them. - Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended) or suggesting the human discard it (with its reasoning), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that canceled or dropped), starting to merge, merged or discarded, and every change of a task's state, as " state: → ." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created, started merging, merged or discarded never wake you on their own; they arrive only alongside another signal. The states are running, idle, finished (a workflow stage's task once its finish_stage has taken), review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle or finished. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately. - Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it. -- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge or suggested for discarding), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo//, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY. -- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest///<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that. +- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge or suggested for discarding), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/prj3f2a9C1b7dQ/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY. +- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/prj3f2a9C1b7dQ/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that. - Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, and the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up: - Discussing, picking or starting TODO items, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved). - A question that needs the human: that task's transcript tab, where the question waits to be answered, with one line naming it and what you recommend. @@ -33,7 +34,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. - Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine. - Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and the workflow finishes once every stage has; byre then discards the run's other stage tasks, leaving the task of each last stage (one per branch when it ends in a parallel) to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". -- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's color, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript. +- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/tskDa7e4K7305e). byre draws it in that task's color, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>. - You are long-running. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. - Don't commit, push, or touch git branches yourself.` @@ -59,11 +60,22 @@ func actorName(actor string) string { return leadOwner(actor) + "'s lead agent" } -func agentText(actor, text string) string { - if !isLeadActor(actor) { +func (m *Manager) agentText(actor, client, text string) string { + if isLeadActor(actor) { + return fmt.Sprintf("This message is from %s, not from %s:\n\n%s", actorName(actor), leadOwner(actor), text) + } + if client == "" { return text } - return fmt.Sprintf("This message is from %s, not from %s:\n\n%s", actorName(actor), leadOwner(actor), text) + return fmt.Sprintf("Sent from %s.\n\n%s", m.clientName(client), text) +} + +func (m *Manager) clientName(client string) string { + host := m.clients.Host(client) + if host == "" { + return "client " + client + } + return fmt.Sprintf("%s (client %s)", host, client) } func humanActor(actor string) bool { @@ -87,7 +99,7 @@ func (m *Manager) activeLead(ctx context.Context, owner string) (api.AgentSessio return api.AgentSession{}, false, nil } -func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, actor, sendID string, images []string) (api.AgentSession, error) { +func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, actor, sendID, client string, images []string) (api.AgentSession, error) { _, found, err := m.activeLead(ctx, owner.Login) if err != nil { return api.AgentSession{}, err @@ -96,14 +108,14 @@ func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, return api.AgentSession{}, fmt.Errorf("%w: %s already has a lead session", ErrConflict, owner.Login) } - id := newUUID() + id := ids.New(ids.Session) vendor := vendorOf(model) sess := api.AgentSession{ Id: id, Owner: owner.Login, Vendor: vendor, Model: model, - VendorSessionId: initialVendorSessionID(vendor, id), + VendorSessionId: initialVendorSessionID(vendor), State: api.AgentSessionStateIdle, Role: api.Lead, CreatedBy: owner.Login, @@ -113,10 +125,10 @@ func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, if err != nil { return api.AgentSession{}, err } - return m.turn(ctx, sess, api.Task{}, actor, prompt, sendID, images) + return m.turn(ctx, sess, api.Task{}, actor, prompt, sendID, client, images) } -func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, sendID string, images []string) (api.AgentSession, error) { +func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, sendID, client string, images []string) (api.AgentSession, error) { m.setStartedTodo(user.Login, false) m.leadMu.Lock() @@ -137,11 +149,11 @@ func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, s return prior, nil } if !found { - return m.createLead(ctx, user, text, model, user.Login, sendID, images) + return m.createLead(ctx, user, text, model, user.Login, sendID, client, images) } if sess.State == api.AgentSessionStateIdle { - s, err := m.Send(ctx, sess.Id, user.Login, text, model, sendID, images...) + s, err := m.Send(ctx, sess.Id, user.Login, text, model, sendID, client, images...) if !errors.Is(err, ErrConflict) { return s, err } @@ -151,7 +163,7 @@ func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, s if err != nil { return api.AgentSession{}, err } - err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: user.Login, Kind: store.LeadMessage, Actor: user.Login, Text: text, Images: images, SendID: sendID}) + err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: user.Login, Kind: store.LeadMessage, Actor: user.Login, Text: text, Images: images, SendID: sendID, Client: client}) if err != nil { return api.AgentSession{}, err } @@ -173,9 +185,9 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error { } batch, actor, text, images := nextLeadTurn(inputs) - sendID := "" + sendID, client := "", "" if batch[0].Kind == store.LeadMessage { - sendID = batch[0].SendID + sendID, client = batch[0].SendID, batch[0].Client } if batch[0].Kind == store.LeadSignal && m.interrupted(sess.Id) { return nil @@ -188,7 +200,7 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error { } text += "\n\n" + state } - _, err = m.Send(ctx, sess.Id, actor, text, "", sendID, images...) + _, err = m.Send(ctx, sess.Id, actor, text, "", sendID, client, images...) if errors.Is(err, ErrConflict) { return nil } diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 5cab9c8..5ce56d4 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -196,13 +196,13 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { return fmt.Sprintf("Posted to %s's board; every agent of its team gets it.", task.Id), nil } if !found { - _, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), "", nil) + _, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), "", "", nil) if err != nil { return "", err } return fmt.Sprintf("Started an agent on %s with your message.", task.Id), nil } - _, queued, err := m.Message(ctx, sess.Id, leadActor(user.Login), args.Text, args.Model, "") + _, queued, err := m.Message(ctx, sess.Id, leadActor(user.Login), args.Text, args.Model, "", "") if err != nil { return "", err } @@ -301,7 +301,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { } return fmt.Sprintf("Task %s %q created from %s and its team of %d agents started.", task.Id, task.Title, task.BaseRef, len(args.Agents)), nil } - _, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), "", nil) + _, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), "", "", nil) if err != nil { return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } diff --git a/internal/agents/manager.go b/internal/agents/manager.go index d94ed75..2217129 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -2,7 +2,6 @@ package agents import ( "context" - "crypto/rand" "encoding/json" "errors" "fmt" @@ -18,6 +17,7 @@ import ( "g.fc.run/ai/byre/internal/claude" "g.fc.run/ai/byre/internal/clients" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/terminal" @@ -186,7 +186,7 @@ func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, return m.store.GetTask(ctx, sess.TaskId) } -func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model, sendID string, images ...string) (api.AgentSession, error) { +func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model, sendID, client string, images ...string) (api.AgentSession, error) { unlock := m.lockDelivery("create:" + task.Id) defer unlock() @@ -201,12 +201,12 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p } if found { slog.Info("first message to an unused session", "session", unused, "task", task.Id) - return m.Send(ctx, unused, creator.Login, prompt, model, sendID, images...) + return m.Send(ctx, unused, creator.Login, prompt, model, sendID, client, images...) } - return m.create(ctx, task, creator, "", prompt, model, creator.Login, sendID, images) + return m.create(ctx, task, creator, "", prompt, model, creator.Login, sendID, client, images) } -func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor, sendID string, images []string) (api.AgentSession, error) { +func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor, sendID, client string, images []string) (api.AgentSession, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.AgentSession{}, ErrNoGitIdentity } @@ -219,7 +219,7 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, m if err != nil { return api.AgentSession{}, err } - return m.turn(ctx, sess, task, actor, prompt, sendID, images) + return m.turn(ctx, sess, task, actor, prompt, sendID, client, images) } func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.User, member, model string) (api.AgentSession, error) { @@ -233,14 +233,14 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use } } - id := newUUID() + id := ids.New(ids.Session) vendor := vendorOf(model) sess := api.AgentSession{ Id: id, TaskId: task.Id, Vendor: vendor, Model: model, - VendorSessionId: initialVendorSessionID(vendor, id), + VendorSessionId: initialVendorSessionID(vendor), State: api.AgentSessionStateIdle, Role: api.Implementer, Member: member, @@ -252,7 +252,7 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use return sess, err } -func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model, sendID string, images ...string) (api.AgentSession, error) { +func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model, sendID, client string, images ...string) (api.AgentSession, error) { sess, err := m.store.GetSession(ctx, sessionID) if err != nil { return api.AgentSession{}, err @@ -272,7 +272,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model, sendI return api.AgentSession{}, err } - return m.turn(ctx, sess, task, actor, text, sendID, images) + return m.turn(ctx, sess, task, actor, text, sendID, client, images) } func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) { @@ -575,12 +575,12 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) { return u, nil } -func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) (api.AgentSession, error) { +func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID, client string, images []string) (api.AgentSession, error) { err := m.startRunning(ctx, sess, task.Id, actor) if err != nil { return api.AgentSession{}, err } - return m.launch(ctx, sess, task, actor, text, sendID, images) + return m.launch(ctx, sess, task, actor, text, sendID, client, images) } func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskID, actor string) error { @@ -602,20 +602,20 @@ func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskI return nil } -func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) (api.AgentSession, error) { - imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, sendID, images) +func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID, client string, images []string) (api.AgentSession, error) { + imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, sendID, client, images) if err != nil { m.failStart(ctx, sess.Id, actor, err) return api.AgentSession{}, err } - err = m.deliver(ctx, sess, task, actor, text, imgs, compacting) + err = m.deliver(ctx, sess, task, actor, client, text, imgs, compacting) if err != nil { return api.AgentSession{}, err } return m.store.GetSession(ctx, sess.Id) } -func (m *Manager) deliver(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, imgs []image, compacting bool) error { +func (m *Manager) deliver(ctx context.Context, sess api.AgentSession, task api.Task, actor, client, text string, imgs []image, compacting bool) error { l, err := m.ensureLive(ctx, sess, task) if err == nil { err = m.syncModel(ctx, l, sess.Model) @@ -626,7 +626,7 @@ func (m *Manager) deliver(ctx context.Context, sess api.AgentSession, task api.T } m.beginTurn(l, compacting) - err = m.send(ctx, l, agentText(actor, text), imgs) + err = m.send(ctx, l, m.agentText(actor, client, text), imgs) if err != nil { m.endMergeTurn(sess.Id, false) return err @@ -640,6 +640,7 @@ func (m *Manager) deliverRecorded(ctx context.Context, sess api.AgentSession, ta return false, err } text, _ := ev.Payload["text"].(string) + client, _ := ev.Payload["client"].(string) ids := []string{} raw, _ := ev.Payload["images"].([]any) for _, id := range raw { @@ -651,7 +652,7 @@ func (m *Manager) deliverRecorded(ctx context.Context, sess api.AgentSession, ta m.failStart(ctx, sess.Id, ev.Actor, err) return true, err } - return true, m.deliver(ctx, sess, task, ev.Actor, text, imgs, isHandoffPrompt(ev.Actor, text)) + return true, m.deliver(ctx, sess, task, ev.Actor, client, text, imgs, isHandoffPrompt(ev.Actor, text)) } func (m *Manager) readImages(ctx context.Context, ids []string) ([]image, error) { @@ -702,12 +703,12 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag } func (m *Manager) message(ctx context.Context, sess api.AgentSession, task api.Task, l *live, actor, text string, images []string) error { - imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, "", images) + imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, "", "", images) if err != nil { return err } m.beginTurn(l, compacting) - return m.send(ctx, l, agentText(actor, text), imgs) + return m.send(ctx, l, m.agentText(actor, "", text), imgs) } func (m *Manager) send(ctx context.Context, l *live, text string, imgs []image) error { @@ -718,7 +719,7 @@ func (m *Manager) send(ctx context.Context, l *live, text string, imgs []image) return m.store.MarkSessionDelivered(ctx, l.id) } -func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) ([]image, bool, error) { +func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID, client string, images []string) ([]image, bool, error) { implementer := sess.Role == api.Implementer if implementer && isMergeInPrompt(actor, text) { m.ownMerge(sess.Id, task.Id, task.WorktreePath) @@ -752,6 +753,9 @@ func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task if sendID != "" { payload["send_id"] = sendID } + if client != "" { + payload["client"] = client + } err = m.store.RecordTurnMessage(ctx, sess.Id, newEvent(sess.Id, sess.Owner, task.ProjectId, task.Id, actor, "user.message", payload)) if err != nil { return nil, false, err @@ -1473,11 +1477,3 @@ func decode(raw json.RawMessage) (any, error) { err := json.Unmarshal(raw, &v) return v, err } - -func newUUID() string { - b := make([]byte, 16) - rand.Read(b) - b[6] = b[6]&0x0f | 0x40 - b[8] = b[8]&0x3f | 0x80 - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) -} diff --git a/internal/agents/mergein.go b/internal/agents/mergein.go index 59bd8a2..eec6c02 100644 --- a/internal/agents/mergein.go +++ b/internal/agents/mergein.go @@ -51,7 +51,7 @@ func (m *Manager) headStartMerge(ctx context.Context, task api.Task, user api.Us } prompt := mergeInPrompt(target) + fmt.Sprintf(" This task's Merge is held while another task merges into %s; this turn gets a head start, and byre merges %s in again when the held merge starts. If you stop for substantive changes, call stop_merge first, so byre drops the held merge and nothing is pushed before the review.", target, target) go func() { - _, err := m.launch(context.Background(), sess, task, "byre", prompt, "", nil) + _, err := m.launch(context.Background(), sess, task, "byre", prompt, "", "", nil) if err != nil { slog.Error("start held merge head start", "task", task.Id, "err", err) } @@ -64,9 +64,9 @@ func (m *Manager) sendMergeIn(ctx context.Context, task api.Task, user api.User, return api.AgentSession{}, err } if found { - return m.Send(ctx, sess.Id, "byre", prompt, "", "") + return m.Send(ctx, sess.Id, "byre", prompt, "", "", "") } - return m.create(ctx, task, user, sess.Member, prompt, "", "byre", "", nil) + return m.create(ctx, task, user, sess.Member, prompt, "", "byre", "", "", nil) } func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User, title string) (api.Task, error) { @@ -189,7 +189,7 @@ func (m *Manager) launchMerge(sess api.AgentSession, task api.Task, project api. } func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task api.Task, project api.Project, prompt string) error { - _, compacting, err := m.recordMessage(ctx, sess, task, "byre", prompt, "", nil) + _, compacting, err := m.recordMessage(ctx, sess, task, "byre", prompt, "", "", nil) if err != nil { return err } diff --git a/internal/agents/mergemain.go b/internal/agents/mergemain.go index 6a901a0..7830fcb 100644 --- a/internal/agents/mergemain.go +++ b/internal/agents/mergemain.go @@ -223,7 +223,7 @@ func (m *Manager) MergeToMain(ctx context.Context, task api.Task, project api.Pr } if err == nil { m.clearMergeAfterReview(ctx, task.Id) - _, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "", "") + _, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "", "", "") } if err != nil { m.endLanding(task.Id, sess.Id) diff --git a/internal/agents/process.go b/internal/agents/process.go index b35073f..e53c608 100644 --- a/internal/agents/process.go +++ b/internal/agents/process.go @@ -2,6 +2,7 @@ package agents import ( "context" + "crypto/rand" "fmt" "log/slog" "os" @@ -43,11 +44,15 @@ func vendorOf(model string) api.AgentSessionVendor { return api.Claude } -func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string { +func initialVendorSessionID(vendor api.AgentSessionVendor) string { if vendor != api.Claude { return "" } - return id + b := make([]byte, 16) + rand.Read(b) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } func (m *Manager) changeModel(ctx context.Context, sess api.AgentSession, model, actor string) (api.AgentSession, error) { diff --git a/internal/agents/queue.go b/internal/agents/queue.go index f1a1165..aeb16d5 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -39,7 +39,7 @@ func (m *Manager) lockDelivery(sessionID string) func() { } } -func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, sendID string, images ...string) (api.AgentSession, bool, error) { +func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, sendID, client string, images ...string) (api.AgentSession, bool, error) { unlock := m.lockDelivery(sessionID) defer unlock() @@ -60,7 +60,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, se } held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || m.heldForLanding(sess.TaskId, sess.Id)) if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) { - sess, err = m.Send(ctx, sessionID, actor, text, model, sendID, images...) + sess, err = m.Send(ctx, sessionID, actor, text, model, sendID, client, images...) return sess, false, err } @@ -73,7 +73,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, se if err != nil { return api.AgentSession{}, false, err } - err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images, SendID: sendID}, task.ProjectId, sess.Id) + err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images, SendID: sendID, Client: client}, task.ProjectId, sess.Id) if err != nil { return api.AgentSession{}, false, err } @@ -153,7 +153,7 @@ func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (boo } in := inputs[0] - _, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.SendID, in.Images...) + _, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.SendID, in.Client, in.Images...) if errors.Is(err, ErrConflict) { slog.Info("queued message held: session changed state", "session", sess.Id, "task", task.Id, "err", err) return true, nil @@ -188,6 +188,7 @@ type queued struct { actor string text string sendID string + client string } func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskInput { @@ -278,6 +279,9 @@ func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) stri if q.sendID != "" { payload["send_id"] = q.sendID } + if q.client != "" { + payload["client"] = q.client + } err := m.emit(ctx, l, q.actor, "user.message", payload) if err != nil { slog.Error("record mid-turn message", "session", l.id, "err", err) @@ -293,6 +297,10 @@ func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) stri fmt.Fprintf(b, "%s (not %s) sent you this message while you were working; take it into account now:\n\n%s", actorName(q.actor), leadOwner(q.actor), q.text) continue } + if q.client != "" { + fmt.Fprintf(b, "%s sent you this message from %s while you were working; take it into account now:\n\n%s", q.actor, m.clientName(q.client), q.text) + continue + } fmt.Fprintf(b, "%s sent you this message while you were working; take it into account now:\n\n%s", q.actor, q.text) } slog.Info("queued messages sent mid-turn", "session", l.id, "count", len(msgs)) diff --git a/internal/agents/ready.go b/internal/agents/ready.go index 811073e..ebdabd2 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -242,7 +242,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview if stagePrompt != "" { prompt = stagePrompt } - _, err = m.Send(ctx, sess.Id, "byre", prompt, "", "") + _, err = m.Send(ctx, sess.Id, "byre", prompt, "", "", "") if err != nil { return err } diff --git a/internal/agents/respond.go b/internal/agents/respond.go index 19cc51e..cf32e55 100644 --- a/internal/agents/respond.go +++ b/internal/agents/respond.go @@ -10,6 +10,7 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/store" ) @@ -89,7 +90,7 @@ func newResponse(itemID, user string, create api.InboxResponseCreate) api.InboxR images = *create.Images } return api.InboxResponse{ - Id: store.NewID(), + Id: ids.New(ids.InboxResponse), ItemId: itemID, User: user, Type: create.Type, diff --git a/internal/agents/review.go b/internal/agents/review.go index cb0a117..e58aed1 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -15,6 +15,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/workflow" @@ -210,7 +211,7 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap now := time.Now().UTC() if len(changes.Files) == 0 { slog.Info("review skipped, snapshot has no diff", "task", task.Id, "snapshot", snap.N) - return m.store.CreateReview(ctx, api.Review{Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, "") + return m.store.CreateReview(ctx, api.Review{Id: ids.New(ids.Review), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, "") } frozen, err := m.store.FreezeWorkflow(ctx, project.ReviewWorkflow) @@ -219,7 +220,7 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap } if err != nil { return m.store.CreateReview(ctx, api.Review{ - Id: store.NewID(), + Id: ids.New(ids.Review), TaskId: task.Id, SnapshotN: snap.N, Stage: project.ReviewWorkflow, @@ -293,7 +294,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, defID string, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) { model := workflow.Get(def, stage).Model vendor := vendorOf(model) - reviewID := store.NewID() + reviewID := ids.New(ids.Review) recorded := false defer func() { if err == nil || recorded { @@ -335,13 +336,13 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api. return err } - id := newUUID() + id := ids.New(ids.Session) sess := api.AgentSession{ Id: id, TaskId: task.Id, Vendor: vendor, Model: model, - VendorSessionId: initialVendorSessionID(vendor, id), + VendorSessionId: initialVendorSessionID(vendor), State: api.AgentSessionStateIdle, Role: api.Reviewer, CreatedBy: task.CreatedBy, @@ -369,7 +370,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api. } recorded = true - _, err = m.turn(ctx, sess, task, "byre", prompt, "", nil) + _, err = m.turn(ctx, sess, task, "byre", prompt, "", "", nil) if err != nil { m.endReviewer(ctx, sess.Id, project, reviewID) m.failReview(ctx, reviewID, "the reviewer didn't start: "+err.Error()) @@ -509,7 +510,7 @@ func (m *Manager) deliverFindings(ctx context.Context, r api.Review, reviews []a slog.Info("review findings not delivered, task has no live agent", "task", r.TaskId, "snapshot", r.SnapshotN) return } - _, _, err = m.Message(ctx, s.Id, "byre", text, "", "") + _, _, err = m.Message(ctx, s.Id, "byre", text, "", "", "") if err != nil { slog.Error("deliver review findings", "task", r.TaskId, "snapshot", r.SnapshotN, "session", s.Id, "err", err) return diff --git a/internal/agents/snapshots.go b/internal/agents/snapshots.go index 72c83f3..235e08b 100644 --- a/internal/agents/snapshots.go +++ b/internal/agents/snapshots.go @@ -12,6 +12,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/claude" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" ) @@ -165,7 +166,7 @@ func (m *Manager) takeSnapshot(ctx context.Context, t snapTarget, trigger api.Sn } snap := api.Snapshot{ - Id: store.NewID(), + Id: ids.New(ids.Snapshot), TaskId: t.taskID, AgentSessionId: t.sessionID, N: n, diff --git a/internal/agents/subtasks.go b/internal/agents/subtasks.go index 511ff6a..f9ac3a0 100644 --- a/internal/agents/subtasks.go +++ b/internal/agents/subtasks.go @@ -73,7 +73,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool { if err != nil { return "", err } - _, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, "", nil) + _, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, "", "", nil) if err != nil { return "", fmt.Errorf("subtask %s created, but its agent did not start: %w", task.Id, err) } @@ -294,10 +294,10 @@ func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, return err } if !found { - _, err = m.create(ctx, task, user, sess.Member, text, "", actor, "", nil) + _, err = m.create(ctx, task, user, sess.Member, text, "", actor, "", "", nil) return err } - _, _, err = m.Message(ctx, sess.Id, actor, text, "", "") + _, _, err = m.Message(ctx, sess.Id, actor, text, "", "", "") return err } diff --git a/internal/agents/summary.go b/internal/agents/summary.go index 6053f09..d06c662 100644 --- a/internal/agents/summary.go +++ b/internal/agents/summary.go @@ -202,7 +202,7 @@ func (m *Manager) launchDiscard(ctx context.Context, taskID string) (bool, error if err != nil { return false, err } - _, err = m.launch(ctx, sess, task, "byre", discardPrompt, "", nil) + _, err = m.launch(ctx, sess, task, "byre", discardPrompt, "", "", nil) return err == nil, err } diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index b7cf87e..c37055d 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -10,6 +10,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/todo" ) @@ -18,7 +19,7 @@ func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, cr return m.createTask(ctx, project, api.Task{Title: title, CreatedBy: createdBy}, nil, base, actor) } -func (m *Manager) StartTask(ctx context.Context, project api.Project, creator api.User, title, prompt, model string, images []string) (api.Task, error) { +func (m *Manager) StartTask(ctx context.Context, project api.Project, creator api.User, title, prompt, model, client string, images []string) (api.Task, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.Task{}, ErrNoGitIdentity } @@ -33,13 +34,13 @@ func (m *Manager) StartTask(ctx context.Context, project api.Project, creator ap if err != nil { return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } - imgs, compacting, err := m.recordMessage(ctx, sess, task, creator.Login, prompt, "", images) + imgs, compacting, err := m.recordMessage(ctx, sess, task, creator.Login, prompt, "", client, images) if err != nil { m.failStart(ctx, sess.Id, creator.Login, err) return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } go func() { - err := m.deliver(context.Background(), sess, task, creator.Login, prompt, imgs, compacting) + err := m.deliver(context.Background(), sess, task, creator.Login, client, prompt, imgs, compacting) if err != nil { slog.Error("start task agent", "task", task.Id, "err", err) } @@ -84,7 +85,7 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap if err != nil { return nil, err } - _, err = m.create(ctx, task, creator, "", todoPrompt(item, discuss), model, actor, "", nil) + _, err = m.create(ctx, task, creator, "", todoPrompt(item, discuss), model, actor, "", "", nil) if err != nil { return nil, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } @@ -108,7 +109,7 @@ func todoPrompt(item api.TodoItem, discuss bool) string { const discussPrompt = "The human wants to discuss this item with you before any of it is built, to judge whether it should be built at all and in what shape. Zoom out before going into detail. Read what you need to understand it, then give your view: what problem it solves and whether that is worth solving, whether we should do it, what the alternatives are (another approach, a smaller change, or nothing), and how you would approach it, with the trade-offs and doubts you see. Talk it over in your final messages, where the human replies in the chat, and use ask_human as often as it helps. Each question belongs to that zoomed-out discussion, such as whether to do it, which alternative, or what shape, rather than the narrow details of getting it built, and later questions can build on earlier answers. Keep the conversation going turn by turn, and change nothing until they tell you to build it." func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, frozen *store.FrozenWorkflow, base, actor string) (api.Task, error) { - t.Id = store.NewID() + t.Id = ids.New(ids.Task) t.ProjectId = project.Id if t.MergeTitle == "" { t.MergeTitle = t.Title diff --git a/internal/agents/team.go b/internal/agents/team.go index 1a4ddff..8962968 100644 --- a/internal/agents/team.go +++ b/internal/agents/team.go @@ -95,7 +95,7 @@ func (m *Manager) startTeam(ctx context.Context, task api.Task, creator api.User } failed := []string{} for i, sess := range sessions { - _, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), "", nil) + _, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), "", "", nil) if err != nil { failed = append(failed, fmt.Sprintf("%q: %v", members[i].Name, err)) } @@ -189,7 +189,7 @@ func (m *Manager) post(ctx context.Context, task api.Task, from poster, text str func (m *Manager) deliverPost(ctx context.Context, task api.Task, s api.AgentSession, msg string) { started, err := m.store.SessionStarted(ctx, s.Id) if err == nil && started { - _, _, err = m.Message(ctx, s.Id, boardActor, msg, "", "") + _, _, err = m.Message(ctx, s.Id, boardActor, msg, "", "", "") } if err == nil && started { return diff --git a/internal/agents/terminal.go b/internal/agents/terminal.go index 76384e9..0f19bf3 100644 --- a/internal/agents/terminal.go +++ b/internal/agents/terminal.go @@ -166,7 +166,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool { func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) { return func(exit string, out []byte) { text := fmt.Sprintf("The line you left unsent in the terminal was run, and the shell is back at its prompt with exit status %s (130 means it was canceled with Ctrl-C). The terminal's output since you typed it:\n\n%s", exit, terminal.Text(out)) - _, _, err := m.Message(context.Background(), l.id, "byre", text, "", "") + _, _, err := m.Message(context.Background(), l.id, "byre", text, "", "", "") if err != nil { slog.Error("wake agent on terminal prompt", "session", l.id, "task", l.taskID, "err", err) return diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index 0e2f7b3..342786e 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -10,6 +10,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/workflow" @@ -231,7 +232,7 @@ func (m *Manager) StartWorkflow(ctx context.Context, project api.Project, creato frozen.Title = title frozen.Audit = audit - run := store.NewID() + run := ids.New(ids.WorkflowRun) tasks := []api.Task{} for _, stage := range workflow.Starts(frozen.Definition) { task, err := m.startStage(ctx, project, creator, frozen, run, stage, base, todoItem, nil, actor) @@ -287,7 +288,7 @@ func (m *Manager) beginStage(ctx context.Context, task api.Task, project api.Pro s := workflow.Get(frozen.Definition, stage) message := stageMessage(frozen, stage, parents, project.DefaultBranch) if s.Agents == nil { - return m.create(ctx, task, creator, "", message, s.Model, "byre", "", nil) + return m.create(ctx, task, creator, "", message, s.Model, "byre", "", "", nil) } err = m.startTeam(ctx, task, creator, message, *s.Agents, "byre") if err != nil { @@ -567,7 +568,7 @@ func (m *Manager) resumeStage(ctx context.Context, task api.Task, project api.Pr } message = teamPrompt(message, mem) } - _, err = m.turn(ctx, sess, task, "byre", message, "", nil) + _, err = m.turn(ctx, sess, task, "byre", message, "", "", nil) if err != nil { return ids, err } diff --git a/internal/clients/clients.go b/internal/clients/clients.go index d67d19a..8e608f6 100644 --- a/internal/clients/clients.go +++ b/internal/clients/clients.go @@ -2,7 +2,6 @@ package clients import ( "context" - "crypto/rand" "encoding/json" "errors" "fmt" @@ -16,6 +15,7 @@ import ( "github.com/coder/websocket" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" ) const ( @@ -181,6 +181,16 @@ func (c *conn) ping(ctx context.Context) { } } +func (r *Registry) Host(id string) string { + r.mu.Lock() + defer r.mu.Unlock() + e := r.clients[id] + if e == nil { + return "" + } + return e.Host +} + func (r *Registry) List() []Client { r.mu.Lock() defer r.mu.Unlock() @@ -197,7 +207,7 @@ func (r *Registry) List() []Client { func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) (api.ClientReply, error) { r.mu.Lock() matches := []*conn{} - ids := []string{} + matched := []string{} connected := []string{} for _, e := range r.clients { if e.conn == nil { @@ -206,7 +216,7 @@ func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) connected = append(connected, fmt.Sprintf("%s (%s)", e.Host, e.ID)) if e.ID == name || e.Host == name { matches = append(matches, e.conn) - ids = append(ids, e.ID) + matched = append(matched, e.ID) } } r.mu.Unlock() @@ -215,12 +225,12 @@ func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) return api.ClientReply{}, fmt.Errorf("no client named %q is connected; connected: %s", name, strings.Join(connected, ", ")) } if len(matches) > 1 { - slices.Sort(ids) - return api.ClientReply{}, fmt.Errorf("%d clients on %s are connected; name one by its id: %s", len(matches), name, strings.Join(ids, ", ")) + slices.Sort(matched) + return api.ClientReply{}, fmt.Errorf("%d clients on %s are connected; name one by its id: %s", len(matches), name, strings.Join(matched, ", ")) } c := matches[0] - req.Id = rand.Text() + req.Id = ids.New(ids.ClientRequest) ch := make(chan api.ClientReply, 1) c.mu.Lock() c.pending[req.Id] = ch @@ -254,7 +264,7 @@ func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) } func (r *Registry) AddTransfer(t Transfer) string { - id := rand.Text() + id := ids.New(ids.Transfer) r.mu.Lock() defer r.mu.Unlock() r.transfers[id] = t diff --git a/internal/git/rename.go b/internal/git/rename.go new file mode 100644 index 0000000..892a115 --- /dev/null +++ b/internal/git/rename.go @@ -0,0 +1,195 @@ +package git + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +func RenameRefs(repo string, rename func(string) string) error { + branches, err := run(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/byre/") + if err != nil { + return err + } + for _, b := range splitLines(branches) { + if rename(b) == b { + continue + } + _, err = run(repo, "branch", "-m", b, rename(b)) + if err != nil { + return err + } + } + + snaps, err := run(repo, "for-each-ref", "--format=%(objectname) %(refname)", "refs/byre/tasks/") + if err != nil { + return err + } + b := &strings.Builder{} + for _, l := range splitLines(snaps) { + sha, ref, _ := strings.Cut(l, " ") + if rename(ref) == ref { + continue + } + fmt.Fprintf(b, "create %s %s\ndelete %s %s\n", rename(ref), sha, ref, sha) + } + if b.Len() == 0 { + return nil + } + _, err = runInput(repo, nil, b.String(), "update-ref", "--stdin") + return err +} + +func RepairWorktrees(repo string, paths []string) error { + if len(paths) == 0 { + return nil + } + _, err := run(repo, append([]string{"worktree", "repair"}, paths...)...) + return err +} + +func RewriteNotes(repo string, rewrite func(string) string) error { + parent, stderr, code, err := runCode(repo, nil, "rev-parse", "--verify", "--quiet", NotesRef) + if err != nil { + return err + } + if code == 1 { + return nil + } + if code != 0 { + return fmt.Errorf("git rev-parse %s: exit status %d: %s", NotesRef, code, stderr) + } + + listing, err := run(repo, "ls-tree", "-r", parent) + if err != nil { + return err + } + paths := map[string]string{} + shas := []string{} + for _, l := range splitLines(listing) { + meta, path, _ := strings.Cut(l, "\t") + fields := strings.Fields(meta) + paths[fields[2]] = path + shas = append(shas, fields[2]) + } + if len(shas) == 0 { + return nil + } + + contents, err := catBlobs(repo, shas) + if err != nil { + return err + } + + dir, err := os.MkdirTemp("", "byre-notes-*") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + changed := []string{} + files := []string{} + for _, sha := range shas { + text := rewrite(contents[sha]) + if text == contents[sha] { + continue + } + file := filepath.Join(dir, sha) + err = os.WriteFile(file, []byte(text), 0o644) + if err != nil { + return err + } + changed = append(changed, sha) + files = append(files, file) + } + if len(changed) == 0 { + return nil + } + + blobs, err := runInput(repo, nil, strings.Join(files, "\n")+"\n", "hash-object", "-w", "--stdin-paths") + if err != nil { + return err + } + index := []string{"GIT_INDEX_FILE=" + filepath.Join(dir, "index")} + _, err = runEnv(repo, index, "read-tree", parent) + if err != nil { + return err + } + b := &strings.Builder{} + for i, blob := range splitLines(blobs) { + fmt.Fprintf(b, "100644 %s\t%s\n", blob, paths[changed[i]]) + } + _, err = runInput(repo, index, b.String(), "update-index", "--index-info") + if err != nil { + return err + } + tree, err := runEnv(repo, index, "write-tree") + if err != nil { + return err + } + who, err := run(repo, "log", "-1", "--format=%cn%n%ce", parent) + if err != nil { + return err + } + name, email, _ := strings.Cut(who, "\n") + env := []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email} + commit, err := runEnv(repo, env, "commit-tree", "-p", parent, "-m", "Rewrite byre ids in notes", tree) + if err != nil { + return err + } + _, err = run(repo, "update-ref", NotesRef, commit, parent) + return err +} + +func catBlobs(repo string, shas []string) (map[string]string, error) { + out, err := runRaw(repo, strings.Join(shas, "\n")+"\n", "cat-file", "--batch") + if err != nil { + return nil, err + } + r := bufio.NewReader(strings.NewReader(out)) + contents := map[string]string{} + for range shas { + header, err := r.ReadString('\n') + if err != nil { + return nil, err + } + fields := strings.Fields(header) + if len(fields) != 3 { + return nil, fmt.Errorf("git cat-file --batch: unexpected header %q", header) + } + size, err := strconv.Atoi(fields[2]) + if err != nil { + return nil, err + } + body := make([]byte, size+1) + _, err = io.ReadFull(r, body) + if err != nil { + return nil, err + } + contents[fields[0]] = string(body[:size]) + } + return contents, nil +} + +func splitLines(out string) []string { + if out == "" { + return []string{} + } + return strings.Split(out, "\n") +} + +func runRaw(dir, input string, args ...string) (string, error) { + out, stderr, code, err := runCodeInput(context.Background(), dir, nil, strings.NewReader(input), args...) + if err != nil { + return "", err + } + if code != 0 { + return "", fmt.Errorf("git %s: exit status %d: %s", strings.Join(args, " "), code, stderr) + } + return out, nil +} diff --git a/internal/ids/ids.go b/internal/ids/ids.go new file mode 100644 index 0000000..4787bf8 --- /dev/null +++ b/internal/ids/ids.go @@ -0,0 +1,72 @@ +package ids + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "fmt" + "strings" +) + +const ( + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + randomLength = 11 +) + +const ( + Project = "prj" + Task = "tsk" + Session = "ses" + Snapshot = "snp" + Review = "rev" + InboxItem = "inb" + InboxResponse = "rsp" + Blob = "blb" + LeadNote = "not" + WorkflowDefinition = "wfd" + WorkflowRun = "run" + Send = "snd" + Client = "cli" + ClientRequest = "req" + Transfer = "xfr" +) + +var prefixes = map[string]bool{ + Project: true, Task: true, Session: true, Snapshot: true, Review: true, + InboxItem: true, InboxResponse: true, Blob: true, LeadNote: true, + WorkflowDefinition: true, WorkflowRun: true, Send: true, + Client: true, ClientRequest: true, Transfer: true, +} + +func New(prefix string) string { + check(prefix) + out := make([]byte, randomLength) + buf := make([]byte, 1) + for i := 0; i < len(out); { + rand.Read(buf) + if int(buf[0]) >= 256/len(alphabet)*len(alphabet) { + continue + } + out[i] = alphabet[int(buf[0])%len(alphabet)] + i++ + } + return prefix + string(out) +} + +func Derive(prefix string, parts ...string) string { + check(prefix) + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + n := binary.BigEndian.Uint64(sum[:8]) + out := make([]byte, randomLength) + for i := range out { + out[i] = alphabet[n%uint64(len(alphabet))] + n /= uint64(len(alphabet)) + } + return prefix + string(out) +} + +func check(prefix string) { + if !prefixes[prefix] { + panic(fmt.Sprintf("no id prefix %q", prefix)) + } +} diff --git a/internal/server/lead.go b/internal/server/lead.go index 8220875..9ee8764 100644 --- a/internal/server/lead.go +++ b/internal/server/lead.go @@ -67,7 +67,7 @@ func (s *Server) MessageLead(w http.ResponseWriter, r *http.Request) { sendID = *req.SendId } - sess, err := s.agents.MessageLead(r.Context(), u, req.Text, model, sendID, images) + sess, err := s.agents.MessageLead(r.Context(), u, req.Text, model, sendID, r.Header.Get("Byre-Client"), images) if err != nil { writeAgentError(w, err) return diff --git a/internal/server/server.go b/internal/server/server.go index 1910948..65e7aa2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/clients" "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/terminal" "g.fc.run/ai/byre/internal/todo" @@ -295,7 +296,7 @@ func (s *Server) createProject(ctx context.Context, name, repoPath, createdBy st } p := api.Project{ - Id: store.NewID(), + Id: ids.New(ids.Project), Name: name, RepoPath: info.Root, DefaultBranch: info.DefaultBranch, @@ -407,7 +408,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st if req.Model != nil { model = *req.Model } - t, err := s.agents.StartTask(r.Context(), p, u, title, prompt, model, images) + t, err := s.agents.StartTask(r.Context(), p, u, title, prompt, model, r.Header.Get("Byre-Client"), images) if err != nil { writeAgentError(w, err) return @@ -896,7 +897,7 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st sendID = *req.SendId } - sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, sendID, images...) + sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, sendID, r.Header.Get("Byre-Client"), images...) if err != nil { writeAgentError(w, err) return @@ -938,7 +939,7 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s sendID = *req.SendId } - sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, sendID, images...) + sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, sendID, r.Header.Get("Byre-Client"), images...) if err != nil { writeAgentError(w, err) return diff --git a/internal/store/blob.go b/internal/store/blob.go index e4d2658..2adb734 100644 --- a/internal/store/blob.go +++ b/internal/store/blob.go @@ -9,6 +9,7 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" ) const MaxBlobBytes = 5 << 20 @@ -22,7 +23,7 @@ var ImageTypes = map[string]bool{ func (s *Store) CreateBlob(ctx context.Context, contentType string, data []byte, actor string) (api.Blob, error) { b := api.Blob{ - Id: NewID(), + Id: ids.New(ids.Blob), ContentType: contentType, CreatedBy: actor, CreatedAt: time.Now().UTC(), diff --git a/internal/store/definition.go b/internal/store/definition.go index 93e7a3f..a8b7517 100644 --- a/internal/store/definition.go +++ b/internal/store/definition.go @@ -7,10 +7,11 @@ import ( "fmt" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" ) func insertDefinition(ctx context.Context, tx *sql.Tx, def api.WorkflowDefinition) (string, error) { - id := NewID() + id := ids.New(ids.WorkflowDefinition) _, err := tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id, description) VALUES (?, ?)`, id, def.Description) if err != nil { return "", err diff --git a/internal/store/lead.go b/internal/store/lead.go index d6e65bc..c541645 100644 --- a/internal/store/lead.go +++ b/internal/store/lead.go @@ -25,6 +25,7 @@ type LeadInput struct { Text string Images []string SendID string + Client string CreatedAt time.Time } @@ -34,14 +35,14 @@ func (s *Store) AddLeadInput(ctx context.Context, in LeadInput) error { return err } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - _, err := tx.ExecContext(ctx, `INSERT INTO lead_inputs (owner, kind, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, now()) + _, err := tx.ExecContext(ctx, `INSERT INTO lead_inputs (owner, kind, actor, text, images, send_id, client_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, in.Client, now()) return err }) } func (s *Store) LeadInputs(ctx context.Context, owner string) ([]LeadInput, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, owner, kind, actor, text, images, send_id, created_at FROM lead_inputs WHERE owner = ? ORDER BY id`, owner) + rows, err := s.db.QueryContext(ctx, `SELECT id, owner, kind, actor, text, images, send_id, client_id, created_at FROM lead_inputs WHERE owner = ? ORDER BY id`, owner) if err != nil { return nil, err } @@ -53,7 +54,7 @@ func (s *Store) LeadInputs(ctx context.Context, owner string) ([]LeadInput, erro kind := "" images := "" createdAt := "" - err = rows.Scan(&in.ID, &in.Owner, &kind, &in.Actor, &in.Text, &images, &in.SendID, &createdAt) + err = rows.Scan(&in.ID, &in.Owner, &kind, &in.Actor, &in.Text, &images, &in.SendID, &in.Client, &createdAt) if err != nil { return nil, err } @@ -78,8 +79,8 @@ func (s *Store) RestoreLeadInputs(ctx context.Context, inputs []LeadInput) error if err != nil { return err } - _, err = tx.ExecContext(ctx, `INSERT INTO lead_inputs (id, owner, kind, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - in.ID, in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, formatTime(in.CreatedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO lead_inputs (id, owner, kind, actor, text, images, send_id, client_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + in.ID, in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, in.Client, formatTime(in.CreatedAt)) if err != nil { return err } diff --git a/internal/store/leadnotes.go b/internal/store/leadnotes.go index 33486da..7af49b8 100644 --- a/internal/store/leadnotes.go +++ b/internal/store/leadnotes.go @@ -8,6 +8,7 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/ids" ) func (s *Store) LeadNotes(ctx context.Context, owner string) ([]api.LeadNote, error) { @@ -51,7 +52,7 @@ func (s *Store) SaveLeadNote(ctx context.Context, owner, id, projectID, text, ac } err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { if id == "" { - n.Id = NewID() + n.Id = ids.New(ids.LeadNote) _, err := tx.ExecContext(ctx, `INSERT INTO lead_notes (id, owner, text, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, n.Id, owner, text, projectID, formatTime(at), formatTime(at)) if err != nil { diff --git a/internal/store/migratedefinitions.go b/internal/store/migratedefinitions.go index 0293249..c8a76f1 100644 --- a/internal/store/migratedefinitions.go +++ b/internal/store/migratedefinitions.go @@ -10,7 +10,7 @@ import ( "g.fc.run/ai/byre/internal/api" ) -func migrateWorkflowDefinitions(tx *sql.Tx) error { +func migrateWorkflowDefinitions(tx *sql.Tx, _ string) error { _, err := tx.Exec(` CREATE TABLE workflow_definitions ( id TEXT PRIMARY KEY, diff --git a/internal/store/migrateids.go b/internal/store/migrateids.go new file mode 100644 index 0000000..d745b83 --- /dev/null +++ b/internal/store/migrateids.go @@ -0,0 +1,252 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "g.fc.run/ai/byre/internal/git" + "g.fc.run/ai/byre/internal/ids" +) + +var idToken = regexp.MustCompile(`[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}|queued-[0-9]+|[0-9A-Za-z]+`) + +var keptColumns = map[string]bool{ + "agent_sessions.vendor_session_id": true, + "agent_sessions.forked_from": true, +} + +func migratePrefixIDs(tx *sql.Tx, dataDir string) error { + renames, err := idRenames(tx) + if err != nil { + return err + } + rewrite := func(s string) string { + return idToken.ReplaceAllStringFunc(s, func(tok string) string { + if to, ok := renames[tok]; ok { + return to + } + return tok + }) + } + + tables, err := textColumns(tx) + if err != nil { + return err + } + for table, columns := range tables { + err = rewriteTable(tx, table, columns, rewrite) + if err != nil { + return fmt.Errorf("%s: %w", table, err) + } + } + + repos := map[string]string{} + rows, err := tx.Query(`SELECT id, repo_path FROM projects`) + if err != nil { + return err + } + for rows.Next() { + id, repo := "", "" + err = rows.Scan(&id, &repo) + if err != nil { + rows.Close() + return err + } + repos[id] = repo + } + rows.Close() + err = rows.Err() + if err != nil { + return err + } + + for _, sub := range []string{"blobs", "hands", "antigravity", "worktrees"} { + err = renameEntries(filepath.Join(dataDir, sub), rewrite) + if err != nil { + return err + } + } + for project, repo := range repos { + dir := filepath.Join(dataDir, "worktrees", project) + err = renameEntries(dir, rewrite) + if err != nil { + return err + } + worktrees, err := filepath.Glob(filepath.Join(dir, "*", ".git")) + if err != nil { + return err + } + for i, w := range worktrees { + worktrees[i] = filepath.Dir(w) + } + err = git.RepairWorktrees(repo, worktrees) + if err != nil { + return err + } + err = git.RenameRefs(repo, rewrite) + if err != nil { + return err + } + err = git.RewriteNotes(repo, rewrite) + if err != nil { + return err + } + } + return nil +} + +func idRenames(tx *sql.Tx) (map[string]string, error) { + sources := []struct{ prefix, query string }{ + {ids.Project, `SELECT id FROM projects`}, + {ids.Task, `SELECT id FROM tasks`}, + {ids.WorkflowRun, `SELECT DISTINCT workflow_run FROM tasks`}, + {ids.Session, `SELECT id FROM agent_sessions`}, + {ids.Snapshot, `SELECT id FROM snapshots`}, + {ids.Review, `SELECT id FROM reviews`}, + {ids.InboxItem, `SELECT id FROM inbox_items`}, + {ids.InboxResponse, `SELECT id FROM inbox_responses`}, + {ids.Blob, `SELECT id FROM blobs`}, + {ids.LeadNote, `SELECT id FROM lead_notes`}, + {ids.WorkflowDefinition, `SELECT id FROM workflow_definitions`}, + {ids.Send, `SELECT DISTINCT send_id FROM task_inputs`}, + {ids.Send, `SELECT DISTINCT send_id FROM lead_inputs`}, + {ids.Send, `SELECT DISTINCT json_extract(payload, '$.send_id') FROM events WHERE kind IN ('user.queued', 'user.message') AND json_extract(payload, '$.send_id') IS NOT NULL`}, + } + + renames := map[string]string{} + for _, src := range sources { + rows, err := tx.Query(src.query) + if err != nil { + return nil, err + } + for rows.Next() { + old := "" + err = rows.Scan(&old) + if err != nil { + rows.Close() + return nil, err + } + _, mapped := renames[old] + if old == "" || mapped || strings.HasPrefix(old, src.prefix) { + continue + } + renames[old] = ids.Derive(src.prefix, old) + } + rows.Close() + err = rows.Err() + if err != nil { + return nil, err + } + } + return renames, nil +} + +func textColumns(tx *sql.Tx) (map[string][]string, error) { + rows, err := tx.Query(`SELECT m.name, p.name FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT IN ('migrations', 'sqlite_sequence') AND upper(p.type) = 'TEXT'`) + if err != nil { + return nil, err + } + defer rows.Close() + + tables := map[string][]string{} + for rows.Next() { + table, column := "", "" + err = rows.Scan(&table, &column) + if err != nil { + return nil, err + } + if keptColumns[table+"."+column] { + continue + } + tables[table] = append(tables[table], column) + } + return tables, rows.Err() +} + +func rewriteTable(tx *sql.Tx, table string, columns []string, rewrite func(string) string) error { + quoted := []string{} + for _, c := range columns { + quoted = append(quoted, `"`+c+`"`) + } + query := fmt.Sprintf(`SELECT rowid, %s FROM "%s" WHERE rowid > ? ORDER BY rowid LIMIT 2000`, strings.Join(quoted, ", "), table) + + after := int64(0) + for { + type change struct { + rowid int64 + column string + value string + } + changes := []change{} + n := 0 + + rows, err := tx.Query(query, after) + if err != nil { + return err + } + for rows.Next() { + values := make([]sql.NullString, len(columns)) + dest := []any{&after} + for i := range values { + dest = append(dest, &values[i]) + } + err = rows.Scan(dest...) + if err != nil { + rows.Close() + return err + } + n++ + for i, v := range values { + if !v.Valid { + continue + } + to := rewrite(v.String) + if to != v.String { + changes = append(changes, change{after, columns[i], to}) + } + } + } + rows.Close() + err = rows.Err() + if err != nil { + return err + } + + for _, c := range changes { + _, err = tx.Exec(fmt.Sprintf(`UPDATE "%s" SET "%s" = ? WHERE rowid = ?`, table, c.column), c.value, c.rowid) + if err != nil { + return err + } + } + if n == 0 { + return nil + } + } +} + +func renameEntries(dir string, rewrite func(string) string) error { + entries, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, e := range entries { + to := rewrite(e.Name()) + if to == e.Name() { + continue + } + err = os.Rename(filepath.Join(dir, e.Name()), filepath.Join(dir, to)) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/store/migrateworkflows.go b/internal/store/migrateworkflows.go index 0c35cd9..16f3b2f 100644 --- a/internal/store/migrateworkflows.go +++ b/internal/store/migrateworkflows.go @@ -26,7 +26,7 @@ type oldDefinition struct { Steps *json.RawMessage `json:"steps"` } -func migrateWorkflowSteps(tx *sql.Tx) error { +func migrateWorkflowSteps(tx *sql.Tx, _ string) error { err := convertColumn(tx, "workflows", "name", "definition") if err != nil { return err diff --git a/internal/store/migrations/086-add-input-clients.sql b/internal/store/migrations/086-add-input-clients.sql new file mode 100644 index 0000000..b6de745 --- /dev/null +++ b/internal/store/migrations/086-add-input-clients.sql @@ -0,0 +1,2 @@ +ALTER TABLE task_inputs ADD COLUMN client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE lead_inputs ADD COLUMN client_id TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/store.go b/internal/store/store.go index 33584f1..62b9398 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2,13 +2,12 @@ package store import ( "context" - "crypto/rand" "database/sql" "embed" - "encoding/hex" "encoding/json" "errors" "fmt" + "path/filepath" "slices" "strconv" "strings" @@ -29,12 +28,13 @@ type migration struct { key string name string sql string - run func(*sql.Tx) error + run func(tx *sql.Tx, dataDir string) error } -var goMigrations = map[string]func(*sql.Tx) error{ +var goMigrations = map[string]func(tx *sql.Tx, dataDir string) error{ "071-workflow-steps": migrateWorkflowSteps, "078-normalize-workflow-definitions": migrateWorkflowDefinitions, + "085-prefix-ids": migratePrefixIDs, } func loadMigrations() ([]migration, error) { @@ -89,7 +89,7 @@ func Open(path string, portFirst int, onEvent func([]api.Event)) (*Store, error) return nil, err } - err = migrate(db) + err = migrate(db, filepath.Dir(path)) if err != nil { db.Close() return nil, err @@ -98,7 +98,7 @@ func Open(path string, portFirst int, onEvent func([]api.Event)) (*Store, error) return &Store{db: db, path: path, portFirst: portFirst, onEvent: onEvent}, nil } -func migrate(db *sql.DB) error { +func migrate(db *sql.DB, dataDir string) error { migrations, err := loadMigrations() if err != nil { return err @@ -119,7 +119,7 @@ func migrate(db *sql.DB) error { continue } - err = applyMigration(db, m) + err = applyMigration(db, m, dataDir) if err != nil { return fmt.Errorf("migration %s: %w", m.name, err) } @@ -147,7 +147,7 @@ func appliedMigrations(db *sql.DB) (map[string]bool, error) { return applied, rows.Err() } -func applyMigration(db *sql.DB, m migration) error { +func applyMigration(db *sql.DB, m migration, dataDir string) error { ctx := context.Background() conn, err := db.Conn(ctx) if err != nil { @@ -168,7 +168,7 @@ func applyMigration(db *sql.DB, m migration) error { defer tx.Rollback() if m.run != nil { - err = m.run(tx) + err = m.run(tx, dataDir) } else { _, err = tx.Exec(m.sql) } @@ -218,12 +218,6 @@ func (s *Store) Path() string { return s.path } -func NewID() string { - b := make([]byte, 6) - rand.Read(b) - return hex.EncodeToString(b) -} - func (s *Store) EnsureUser(ctx context.Context, login, displayName string) (api.User, error) { _, err := s.db.ExecContext(ctx, ` INSERT INTO users (login, display_name, created_at) VALUES (?, ?, ?) diff --git a/internal/store/taskinputs.go b/internal/store/taskinputs.go index 669e11b..223d21b 100644 --- a/internal/store/taskinputs.go +++ b/internal/store/taskinputs.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "time" + + "g.fc.run/ai/byre/internal/ids" ) type TaskInput struct { @@ -16,6 +18,7 @@ type TaskInput struct { Text string Images []string SendID string + Client string CreatedAt time.Time } @@ -25,15 +28,15 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi return err } if in.SendID == "" { - in.SendID = NewID() + in.SendID = ids.New(ids.Send) } payload := map[string]any{"text": in.Text, "send_id": in.SendID} if len(in.Images) > 0 { payload["images"] = in.Images } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, now()) + res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, send_id, client_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, in.Client, now()) if err != nil { return err } @@ -82,7 +85,7 @@ func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, } func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskInput, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, send_id, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member) + rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, send_id, client_id, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member) if err != nil { return nil, err } @@ -93,7 +96,7 @@ func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskIn in := TaskInput{} images := "" createdAt := "" - err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &in.SendID, &createdAt) + err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &in.SendID, &in.Client, &createdAt) if err != nil { return nil, err } @@ -117,8 +120,8 @@ func (s *Store) RestoreTaskInputs(ctx context.Context, inputs []TaskInput) error if err != nil { return err } - _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, formatTime(in.CreatedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, send_id, client_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, in.Client, formatTime(in.CreatedAt)) if err != nil { return err } diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift index 4f4e27f..0e3e8f2 100644 --- a/mac/Sources/Byre/API.swift +++ b/mac/Sources/Byre/API.swift @@ -101,7 +101,7 @@ func errorText(_ error: any Error) -> String { return String(describing: e) } -private let clientID = UUID().uuidString +private let clientID = newID("cli") let macName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName final class API: Sendable { diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index 4c88d42..8c62c44 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -545,7 +545,7 @@ struct ComposerView: View { let imgs = images.take(draftKey) text = "" let sent = drafts.startSend(draftKey, value, imgs.count) - let sendID = UUID().uuidString + let sendID = newID("snd") let cancel = chat.team ? { true } : chat.builder.showPending(value, imgs, sendID) let sessionId = m.live?.id Task { diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift index 42cd978..406991b 100644 --- a/mac/Sources/Byre/Drafts.swift +++ b/mac/Sources/Byre/Drafts.swift @@ -38,6 +38,45 @@ final class DraftSend { } } +@MainActor +func migrateRecordIDs() { + var changed = false + for v in fetchRecords(FetchDescriptor<VisitRecord>()) where migratedID("tsk", v.taskId) != v.taskId { + v.taskId = migratedID("tsk", v.taskId) + changed = true + } + let composerKey = { (key: String) in + key.hasPrefix("message:") ? "message:" + migratedID("tsk", String(key.dropFirst("message:".count))) : migratedID("tsk", key) + } + let task = { (key: String) in migratedID("tsk", key) } + let item = { (key: String) in migratedID("inb", key) } + func rekey<V>(_ d: [String: V], _ f: (String) -> String) -> [String: V] { + Dictionary(d.map { (f($0.key), $0.value) }) { a, _ in a } + } + for record in fetchRecords(FetchDescriptor<DraftRecord>()) { + guard var saved = try? JSONDecoder().decode(Saved.self, from: record.json) else { + continue + } + let comments = saved.comments ?? [:] + let moved = saved.composer.keys.contains { composerKey($0) != $0 } || saved.checked.keys.contains { item($0) != $0 } || saved.texts.keys.contains { item($0) != $0 } || comments.keys.contains { task($0) != $0 } + guard moved else { + continue + } + saved.composer = rekey(saved.composer, composerKey) + saved.checked = rekey(saved.checked, item) + saved.texts = rekey(saved.texts, item) + saved.comments = saved.comments.map { rekey($0, task) } + guard let json = try? JSONEncoder().encode(saved) else { + continue + } + record.json = json + changed = true + } + if changed { + saveRecords() + } +} + func draftOrigin(_ origin: URL) -> String { let c = URLComponents(url: origin, resolvingAgainstBaseURL: false)! let scheme = (c.scheme ?? "https").lowercased() diff --git a/mac/Sources/Byre/Forwards.swift b/mac/Sources/Byre/Forwards.swift index 3d4861a..e1f97b9 100644 --- a/mac/Sources/Byre/Forwards.swift +++ b/mac/Sources/Byre/Forwards.swift @@ -28,7 +28,7 @@ final class Forwards { } var c = URLComponents() c.scheme = "http" - c.host = "\(slug(key.name).isEmpty ? "preview" : slug(key.name))-\(taskId.prefix(6)).localhost" + c.host = "\(slug(key.name).isEmpty ? "preview" : slug(key.name))-\(taskId.lowercased()).localhost" c.port = local c.path = "/" return c.url diff --git a/mac/Sources/Byre/IDs.swift b/mac/Sources/Byre/IDs.swift new file mode 100644 index 0000000..ff974f5 --- /dev/null +++ b/mac/Sources/Byre/IDs.swift @@ -0,0 +1,22 @@ +import CryptoKit +import Foundation + +private let idAlphabet = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + +func newID(_ prefix: String) -> String { + prefix + String((0..<11).map { _ in idAlphabet.randomElement()! }) +} + +// IDs from before prefixed IDs were 12 lowercase hex characters; the server renamed each to this same derivation. +func migratedID(_ prefix: String, _ old: String) -> String { + guard old.count == 12, old.allSatisfy({ "0123456789abcdef".contains($0) }) else { + return old + } + var n = SHA256.hash(data: Data(old.utf8)).prefix(8).reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + var out = "" + for _ in 0..<11 { + out.append(idAlphabet[Int(n % UInt64(idAlphabet.count))]) + n /= UInt64(idAlphabet.count) + } + return prefix + out +} diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 52a4144..fb509f2 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -21,6 +21,7 @@ final class Servers { @ObservationIgnored var openWindow: (() -> Void)? private init() { + migrateRecordIDs() load() current = origins.first Task { @@ -30,6 +31,7 @@ final class Servers { } synced(event, end) if event.type == .import { + migrateRecordIDs() for connection in connections.values { connection.store?.drafts?.reload() }