Add ask_human and the inbox with batched responses and delivery to agents
This commit is contained in:
@@ -206,6 +206,62 @@ paths:
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/inbox:
|
||||
get:
|
||||
operationId: listInbox
|
||||
description: >
|
||||
Inbox items, highest priority first (blocked, gate, review, fyi), then oldest first.
|
||||
Filters narrow the one query to a project's or a task's view.
|
||||
parameters:
|
||||
- { name: project_id, in: query, required: false, schema: { type: string } }
|
||||
- { name: task_id, in: query, required: false, schema: { type: string } }
|
||||
- { name: state, in: query, required: false, schema: { $ref: "#/components/schemas/InboxState" } }
|
||||
- { name: include_snoozed, in: query, required: false, schema: { type: boolean } }
|
||||
responses:
|
||||
"200":
|
||||
description: Matching items with their responses. Defaults to open, unsnoozed items.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/InboxItem" }
|
||||
|
||||
/api/inbox/{itemId}:
|
||||
parameters:
|
||||
- { name: itemId, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: getInboxItem
|
||||
responses:
|
||||
"200":
|
||||
description: The item with its responses.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/InboxItem" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/inbox/responses:
|
||||
post:
|
||||
operationId: respondInbox
|
||||
description: >
|
||||
Answers one or more items in one submission. Answers reach the agent as one message once
|
||||
it is idle and none of its blocking questions are unanswered.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/InboxRespond" }
|
||||
responses:
|
||||
"200":
|
||||
description: The updated items.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/InboxItem" }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/events:
|
||||
get:
|
||||
operationId: streamEvents
|
||||
@@ -330,6 +386,96 @@ components:
|
||||
properties:
|
||||
text: { type: string }
|
||||
|
||||
Attachment:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind: { type: string, enum: [file, diff, image, diagram, task, snapshot, url] }
|
||||
path: { type: string, description: "file: repo-relative path." }
|
||||
start_line: { type: integer }
|
||||
end_line: { type: integer }
|
||||
ref: { type: string, description: "diff: git revision range." }
|
||||
url: { type: string }
|
||||
mermaid: { type: string, description: "diagram: Mermaid source." }
|
||||
image: { type: string, description: "image: data URL." }
|
||||
task_id: { type: string }
|
||||
snapshot_id: { type: string }
|
||||
caption: { type: string }
|
||||
|
||||
InboxState:
|
||||
type: string
|
||||
enum: [open, answered, redirected, dismissed, superseded]
|
||||
|
||||
InboxItem:
|
||||
type: object
|
||||
required: [id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at, responses]
|
||||
properties:
|
||||
id: { type: string }
|
||||
project_id: { type: string }
|
||||
task_id: { type: string }
|
||||
agent_session_id: { type: string }
|
||||
thread_id: { type: string }
|
||||
kind: { type: string, enum: [question, report, session_error] }
|
||||
priority: { type: string, enum: [blocked, gate, review, fyi] }
|
||||
blocking: { type: boolean }
|
||||
payload:
|
||||
type: object
|
||||
description: >
|
||||
question: question, why_blocked, options, recommended_option, attachments.
|
||||
report: done, open_questions, next_steps, risks, deviations.
|
||||
session_error: error.
|
||||
state: { $ref: "#/components/schemas/InboxState" }
|
||||
snoozed_until: { type: string, format: date-time }
|
||||
created_at: { type: string, format: date-time }
|
||||
resolved_by: { type: string }
|
||||
resolved_at: { type: string, format: date-time }
|
||||
responses:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/InboxResponse" }
|
||||
|
||||
InboxResponseType:
|
||||
type: string
|
||||
enum: [option, free_form, redirect, question, snooze, dismiss]
|
||||
|
||||
InboxResponse:
|
||||
type: object
|
||||
required: [id, item_id, user, type, attachments, created_at]
|
||||
properties:
|
||||
id: { type: string }
|
||||
item_id: { type: string }
|
||||
user: { type: string }
|
||||
type: { $ref: "#/components/schemas/InboxResponseType" }
|
||||
option: { type: string }
|
||||
text: { type: string }
|
||||
attachments:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Attachment" }
|
||||
snooze_until: { type: string, format: date-time }
|
||||
created_at: { type: string, format: date-time }
|
||||
delivered_at: { type: string, format: date-time }
|
||||
|
||||
InboxResponseCreate:
|
||||
type: object
|
||||
required: [item_id, type]
|
||||
properties:
|
||||
item_id: { type: string }
|
||||
type: { $ref: "#/components/schemas/InboxResponseType" }
|
||||
option: { type: string, description: "option: one of the question's options." }
|
||||
text: { type: string, description: "Required for free_form, redirect, question; an optional note on option." }
|
||||
attachments:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Attachment" }
|
||||
snooze_until: { type: string, format: date-time, description: "snooze: when the item reappears." }
|
||||
|
||||
InboxRespond:
|
||||
type: object
|
||||
required: [responses]
|
||||
properties:
|
||||
responses:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { $ref: "#/components/schemas/InboxResponseCreate" }
|
||||
|
||||
Event:
|
||||
type: object
|
||||
required: [seq, kind, actor, payload, created_at]
|
||||
|
||||
@@ -51,6 +51,25 @@ byre chooses the Claude session ID (`vendor_session_id`) up front, so every sess
|
||||
|
||||
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `agent.report`, `turn.ended`, and `session.state`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`.
|
||||
|
||||
## Inbox
|
||||
|
||||
Agents ask questions with the `ask_human` MCP tool; a malformed call (no `why_blocked`, fewer than two options, a recommendation that isn't one of the options, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. Each `report` call and each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item.
|
||||
|
||||
`GET /api/inbox` is one query over open, unsnoozed items, sorted by priority (`blocked`, `gate`, `review`, `fyi`) then age; `project_id` and `task_id` narrow it to a project or task view, and `state` and `include_snoozed` widen it. Blocking questions and errors are `blocked`; reports and non-blocking questions are `fyi`.
|
||||
|
||||
`POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item:
|
||||
|
||||
| type | allowed on | item becomes |
|
||||
|---|---|---|
|
||||
| `option` (with optional note in `text`) | questions | `answered` |
|
||||
| `free_form` | questions | `answered` |
|
||||
| `question` (a follow-up from the human) | questions | stays `open` |
|
||||
| `redirect` | any | `redirected` |
|
||||
| `snooze` | any | stays `open`, hidden until `snooze_until` |
|
||||
| `dismiss` | reports, errors | `dismissed` |
|
||||
|
||||
Answers reach the agent as one message, listing each answer under the question or report it responds to, once the session is idle and none of its blocking questions are still waiting for the human. A follow-up question tells the agent to reply with `ask_human` using the item's `thread_id`; the new item supersedes the open one in that thread. Every item change is an `inbox.created` or `inbox.updated` event carrying the whole item, including who resolved it.
|
||||
|
||||
## Events
|
||||
|
||||
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/report"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
var attachmentKinds = []api.AttachmentKind{
|
||||
api.AttachmentKindFile, api.AttachmentKindDiff, api.AttachmentKindImage, api.AttachmentKindDiagram,
|
||||
api.AttachmentKindTask, api.AttachmentKindSnapshot, api.AttachmentKindUrl,
|
||||
}
|
||||
|
||||
type askArgs struct {
|
||||
Question string `json:"question"`
|
||||
WhyBlocked string `json:"why_blocked"`
|
||||
Options []string `json:"options"`
|
||||
RecommendedOption string `json:"recommended_option"`
|
||||
Attachments []api.Attachment `json:"attachments"`
|
||||
Blocking *bool `json:"blocking"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
}
|
||||
|
||||
func (m *Manager) askTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "ask_human",
|
||||
Description: "Ask the human a question through their inbox. Use this for every question; never ask in plain prose. " +
|
||||
"Offer concrete options and recommend one. If blocking is true, call report and end your turn after asking; " +
|
||||
"the answer arrives as your next message. If blocking is false, continue with work that doesn't depend on the answer. " +
|
||||
"Ask several questions in one turn by calling this once per question; they are answered together. " +
|
||||
"To answer a follow-up question from the human, call this again with the thread_id they gave you.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {"type": "string"},
|
||||
"why_blocked": {"type": "string", "description": "What you can't do until this is answered, and why you can't decide it yourself."},
|
||||
"options": {"type": "array", "items": {"type": "string"}, "minItems": 2},
|
||||
"recommended_option": {"type": "string", "description": "One of options."},
|
||||
"attachments": {"type": "array", "items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["file", "diff", "image", "diagram", "task", "snapshot", "url"]},
|
||||
"path": {"type": "string"},
|
||||
"start_line": {"type": "integer"},
|
||||
"end_line": {"type": "integer"},
|
||||
"ref": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
"mermaid": {"type": "string"},
|
||||
"task_id": {"type": "string"},
|
||||
"snapshot_id": {"type": "string"},
|
||||
"caption": {"type": "string"}
|
||||
},
|
||||
"required": ["kind"]
|
||||
}},
|
||||
"blocking": {"type": "boolean"},
|
||||
"thread_id": {"type": "string"}
|
||||
},
|
||||
"required": ["question", "why_blocked", "options", "recommended_option", "blocking"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.ask(ctx, l, raw)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := askArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed ask_human call: %w", err)
|
||||
}
|
||||
|
||||
err = m.validateAsk(ctx, l, args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := store.NewID()
|
||||
thread := args.ThreadID
|
||||
if thread == "" {
|
||||
thread = id
|
||||
}
|
||||
|
||||
priority := api.Fyi
|
||||
if *args.Blocking {
|
||||
priority = api.Blocked
|
||||
}
|
||||
|
||||
attachments := args.Attachments
|
||||
if attachments == nil {
|
||||
attachments = []api.Attachment{}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"question": args.Question,
|
||||
"why_blocked": args.WhyBlocked,
|
||||
"options": args.Options,
|
||||
"recommended_option": args.RecommendedOption,
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.InboxItemKindQuestion, priority, *args.Blocking, store.ToPayload(payload)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if *args.Blocking {
|
||||
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: call report, then end your turn. The answer will arrive as your next message.", id), nil
|
||||
}
|
||||
return fmt.Sprintf("Question %s filed in the human's inbox. Continue with work that doesn't depend on it; the answer will arrive as a later message.", id), nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error {
|
||||
problems := []string{}
|
||||
|
||||
if strings.TrimSpace(args.Question) == "" {
|
||||
problems = append(problems, "question is empty")
|
||||
}
|
||||
if strings.TrimSpace(args.WhyBlocked) == "" {
|
||||
problems = append(problems, "why_blocked is empty: say what you can't do until this is answered and why you can't decide it yourself")
|
||||
}
|
||||
if len(args.Options) < 2 {
|
||||
problems = append(problems, "options needs at least two concrete choices")
|
||||
}
|
||||
if !slices.Contains(args.Options, args.RecommendedOption) {
|
||||
problems = append(problems, "recommended_option must be exactly one of options")
|
||||
}
|
||||
if args.Blocking == nil {
|
||||
problems = append(problems, "blocking is required")
|
||||
}
|
||||
for i, a := range args.Attachments {
|
||||
if !slices.Contains(attachmentKinds, a.Kind) {
|
||||
problems = append(problems, fmt.Sprintf("attachments[%d].kind %q is not one of %v", i, a.Kind, attachmentKinds))
|
||||
}
|
||||
}
|
||||
|
||||
if args.ThreadID != "" {
|
||||
item, err := m.store.GetInboxItem(ctx, args.ThreadID)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
problems = append(problems, fmt.Sprintf("thread_id %s does not exist", args.ThreadID))
|
||||
case err != nil:
|
||||
return err
|
||||
case item.AgentSessionId != l.id:
|
||||
problems = append(problems, fmt.Sprintf("thread_id %s belongs to another session", args.ThreadID))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("ask_human rejected, nothing was filed. Fix and call again: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) fileReport(l *live, r report.Report) {
|
||||
id := store.NewID()
|
||||
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindReport, api.Fyi, false, store.ToPayload(r)))
|
||||
if err != nil {
|
||||
slog.Error("file report", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) fileError(l *live, msg string) {
|
||||
id := store.NewID()
|
||||
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindSessionError, api.Blocked, true, map[string]any{"error": msg}))
|
||||
if err != nil {
|
||||
slog.Error("file session error", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, priority api.InboxItemPriority, blocking bool, payload map[string]any) api.InboxItem {
|
||||
return api.InboxItem{
|
||||
Id: id,
|
||||
ProjectId: l.projectID,
|
||||
TaskId: l.taskID,
|
||||
AgentSessionId: l.id,
|
||||
ThreadId: thread,
|
||||
Kind: kind,
|
||||
Priority: priority,
|
||||
Blocking: blocking,
|
||||
Payload: payload,
|
||||
State: api.Open,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Responses: []api.InboxResponse{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
m.deliverMu.Lock()
|
||||
defer m.deliverMu.Unlock()
|
||||
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.State != api.AgentSessionStateIdle {
|
||||
return nil
|
||||
}
|
||||
|
||||
waiting, err := m.store.AwaitingHuman(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if waiting > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pending, err := m.store.Undelivered(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), true)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ids := []string{}
|
||||
for _, p := range pending {
|
||||
ids = append(ids, p.Response.Id)
|
||||
}
|
||||
return m.store.MarkDelivered(ctx, ids)
|
||||
}
|
||||
|
||||
func deliveryMessage(pending []store.Pending) string {
|
||||
b := &strings.Builder{}
|
||||
b.WriteString("Responses from your inbox:\n")
|
||||
|
||||
for _, p := range pending {
|
||||
r := p.Response
|
||||
b.WriteString("\n")
|
||||
|
||||
switch p.Item.Kind {
|
||||
case api.InboxItemKindQuestion:
|
||||
fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"])
|
||||
case api.InboxItemKindReport:
|
||||
fmt.Fprintf(b, "Your report %s\n", p.Item.Id)
|
||||
case api.InboxItemKindSessionError:
|
||||
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
|
||||
}
|
||||
|
||||
switch r.Type {
|
||||
case api.InboxResponseTypeOption:
|
||||
fmt.Fprintf(b, "%s chose: %s\n", r.User, *r.Option)
|
||||
if r.Text != nil && *r.Text != "" {
|
||||
fmt.Fprintf(b, "Note: %s\n", *r.Text)
|
||||
}
|
||||
case api.InboxResponseTypeFreeForm:
|
||||
fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, *r.Text)
|
||||
case api.InboxResponseTypeRedirect:
|
||||
fmt.Fprintf(b, "%s redirected you. These instructions replace your current direction: %s\n", r.User, *r.Text)
|
||||
case api.InboxResponseTypeQuestion:
|
||||
fmt.Fprintf(b, "%s asked a follow-up question: %s\nAnswer it by calling ask_human with thread_id %q.\n", r.User, *r.Text, p.Item.ThreadId)
|
||||
}
|
||||
|
||||
for _, a := range r.Attachments {
|
||||
j, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(b, "Attachment: %s\n", j)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestValidateAsk(t *testing.T) {
|
||||
m := &Manager{}
|
||||
l := &live{id: "s1"}
|
||||
yes := true
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args askArgs
|
||||
want []string
|
||||
}{
|
||||
{"valid", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes}, nil},
|
||||
{"empty", askArgs{}, []string{"question is empty", "why_blocked is empty", "at least two", "recommended_option", "blocking is required"}},
|
||||
{"one option", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a"}, RecommendedOption: "a", Blocking: &yes}, []string{"at least two"}},
|
||||
{"bad recommendation", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "c", Blocking: &yes}, []string{"recommended_option"}},
|
||||
{"bad attachment", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes,
|
||||
Attachments: []api.Attachment{{Kind: "video"}}}, []string{`attachments[0].kind "video"`}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := m.validateAsk(context.Background(), l, c.args)
|
||||
if len(c.want) == 0 {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("want rejection mentioning %v", c.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nothing was filed") {
|
||||
t.Fatalf("rejection doesn't say nothing was filed: %v", err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("rejection %q doesn't mention %q", err, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryMessage(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.InboxItemKindQuestion, Payload: map[string]any{"question": "Which color?"}}
|
||||
report := api.InboxItem{Id: "r1", ThreadId: "r1", Kind: api.InboxItemKindReport, Payload: map[string]any{}}
|
||||
|
||||
msg := deliveryMessage([]store.Pending{
|
||||
{Item: question, Response: api.InboxResponse{User: "alice", Type: api.InboxResponseTypeOption, Option: str("blue"), Text: str("navy if possible")}},
|
||||
{Item: question, Response: api.InboxResponse{User: "bob", Type: api.InboxResponseTypeQuestion, Text: str("what is it for?"),
|
||||
Attachments: []api.Attachment{{Kind: api.AttachmentKindUrl, Url: str("https://example.com")}}}},
|
||||
{Item: report, Response: api.InboxResponse{User: "alice", Type: api.InboxResponseTypeRedirect, Text: str("stop and write tests")}},
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Your question q1: Which color?",
|
||||
"alice chose: blue",
|
||||
"Note: navy if possible",
|
||||
"bob asked a follow-up question: what is it for?",
|
||||
`calling ask_human with thread_id "q1"`,
|
||||
`"url":"https://example.com"`,
|
||||
"Your report r1",
|
||||
"alice redirected you. These instructions replace your current direction: stop and write tests",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,18 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
|
||||
deliverMu sync.Mutex
|
||||
}
|
||||
|
||||
type live struct {
|
||||
sess *claude.Session
|
||||
gate *report.Gate
|
||||
id string
|
||||
taskID string
|
||||
projectID string
|
||||
stopping bool
|
||||
sess *claude.Session
|
||||
gate *report.Gate
|
||||
id string
|
||||
taskID string
|
||||
projectID string
|
||||
stopping bool
|
||||
interrupted bool
|
||||
}
|
||||
|
||||
func New(st *store.Store) *Manager {
|
||||
@@ -119,6 +122,10 @@ func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSes
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s has no turn running", ErrConflict, sess.Id)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l.interrupted = true
|
||||
m.mu.Unlock()
|
||||
|
||||
ictx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -219,7 +226,10 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateRunning, actor, nil)
|
||||
err = m.store.TransitionSession(ctx, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor)
|
||||
if errors.Is(err, store.ErrStateChanged) {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: %w", ErrConflict, err)
|
||||
}
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
@@ -238,6 +248,10 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l.interrupted = false
|
||||
m.mu.Unlock()
|
||||
|
||||
l.gate.Reset()
|
||||
return l.sess.Send(text)
|
||||
}
|
||||
@@ -266,6 +280,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
if err != nil {
|
||||
slog.Error("record report", "session", l.id, "err", err)
|
||||
}
|
||||
m.fileReport(l, r)
|
||||
}}
|
||||
|
||||
opts := claude.Options{
|
||||
@@ -273,7 +288,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
Model: sess.Model,
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{l.gate.Tool()}}},
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{l.gate.Tool(), m.askTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"Stop": {{Func: l.gate.StopHook}},
|
||||
},
|
||||
@@ -330,6 +345,7 @@ func (m *Manager) pump(l *live) {
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
}
|
||||
|
||||
func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
@@ -380,7 +396,27 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
m.mu.Unlock()
|
||||
|
||||
if res.IsError && !interrupted {
|
||||
m.fileError(l, fmt.Sprintf("The turn ended with an error (%s): %s", res.Subtype, res.Result))
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := m.Deliver(context.Background(), l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
|
||||
case "system":
|
||||
if ev.Subtype == "init" {
|
||||
|
||||
@@ -296,6 +296,156 @@ func TestStopTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const colorPrompt = `I need a file color.txt containing exactly one color name. Don't pick it yourself: ` +
|
||||
`ask me with ask_human (blocking) whether it should be red or blue, recommending red. ` +
|
||||
`When you get my answer, write the file and commit it with the message "add color".`
|
||||
|
||||
func (e *env) openQuestions(sessionID string) []api.InboxItem {
|
||||
e.t.Helper()
|
||||
items, err := e.store.ListInbox(e.ctx, store.InboxFilter{SessionID: sessionID, State: api.Open})
|
||||
if err != nil {
|
||||
e.t.Fatalf("list inbox: %v", err)
|
||||
}
|
||||
questions := []api.InboxItem{}
|
||||
for _, item := range items {
|
||||
if item.Kind == api.InboxItemKindQuestion {
|
||||
questions = append(questions, item)
|
||||
}
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
func (e *env) respond(m *agents.Manager, item api.InboxItem, typ api.InboxResponseType, option, text string, state api.InboxState) {
|
||||
e.t.Helper()
|
||||
resp := api.InboxResponse{
|
||||
Id: store.NewID(),
|
||||
ItemId: item.Id,
|
||||
User: login,
|
||||
Type: typ,
|
||||
Attachments: []api.Attachment{},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if option != "" {
|
||||
resp.Option = &option
|
||||
}
|
||||
if text != "" {
|
||||
resp.Text = &text
|
||||
}
|
||||
|
||||
err := e.store.Respond(e.ctx, login, []store.Resolution{{Response: resp, State: state}})
|
||||
if err != nil {
|
||||
e.t.Fatalf("respond: %v", err)
|
||||
}
|
||||
err = m.Deliver(e.ctx, item.AgentSessionId)
|
||||
if err != nil {
|
||||
e.t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskHumanRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, colorPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
questions := e.openQuestions(sess.Id)
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("want one open question, got %+v", questions)
|
||||
}
|
||||
q := questions[0]
|
||||
if !q.Blocking || q.Priority != api.Blocked || q.Payload["recommended_option"] != "red" {
|
||||
t.Fatalf("unexpected question %+v", q)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.task.WorktreePath, "color.txt")); err == nil {
|
||||
t.Fatalf("agent wrote color.txt before getting an answer")
|
||||
}
|
||||
|
||||
waiting, err := e.store.AwaitingHuman(e.ctx, sess.Id)
|
||||
if err != nil || waiting != 1 {
|
||||
t.Fatalf("awaiting human: %d %v", waiting, err)
|
||||
}
|
||||
|
||||
e.respond(m, q, api.InboxResponseTypeOption, "blue", "", api.Answered)
|
||||
events := e.waitIdle()
|
||||
|
||||
delivered := false
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "user.message" && strings.Contains(ev.Payload["text"].(string), "chose: blue") {
|
||||
delivered = true
|
||||
}
|
||||
}
|
||||
if !delivered {
|
||||
t.Fatalf("answer was not delivered: %v", kinds(events))
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(e.task.WorktreePath, "color.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("agent didn't write color.txt: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "blue" {
|
||||
t.Fatalf("color.txt contains %q, want blue", out)
|
||||
}
|
||||
|
||||
pending, err := e.store.Undelivered(e.ctx, sess.Id)
|
||||
if err != nil || len(pending) != 0 {
|
||||
t.Fatalf("responses left undelivered: %+v %v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFollowUpQuestionThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, colorPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
questions := e.openQuestions(sess.Id)
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("want one open question, got %+v", questions)
|
||||
}
|
||||
first := questions[0]
|
||||
|
||||
e.respond(m, first, api.InboxResponseTypeQuestion, "", "Before I pick: which of the two did you recommend, and why?", api.Open)
|
||||
e.waitIdle()
|
||||
|
||||
questions = e.openQuestions(sess.Id)
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("want one open question after the follow-up, got %+v", questions)
|
||||
}
|
||||
second := questions[0]
|
||||
if second.Id == first.Id || second.ThreadId != first.ThreadId {
|
||||
t.Fatalf("follow-up didn't continue the thread: first %s/%s, second %s/%s", first.Id, first.ThreadId, second.Id, second.ThreadId)
|
||||
}
|
||||
|
||||
old, err := e.store.GetInboxItem(e.ctx, first.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get first question: %v", err)
|
||||
}
|
||||
if old.State != api.Superseded {
|
||||
t.Fatalf("first question is %s, want superseded", old.State)
|
||||
}
|
||||
|
||||
e.respond(m, second, api.InboxResponseTypeFreeForm, "", "Use green instead of either.", api.Answered)
|
||||
e.waitIdle()
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(e.task.WorktreePath, "color.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("agent didn't write color.txt: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "green" {
|
||||
t.Fatalf("color.txt contains %q, want green", out)
|
||||
}
|
||||
}
|
||||
|
||||
func scratchRepo(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
gitOut(t, dir, "init", "--initial-branch=main")
|
||||
|
||||
+408
-28
@@ -72,6 +72,141 @@ func (e AgentSessionVendor) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AttachmentKind.
|
||||
const (
|
||||
AttachmentKindDiagram AttachmentKind = "diagram"
|
||||
AttachmentKindDiff AttachmentKind = "diff"
|
||||
AttachmentKindFile AttachmentKind = "file"
|
||||
AttachmentKindImage AttachmentKind = "image"
|
||||
AttachmentKindSnapshot AttachmentKind = "snapshot"
|
||||
AttachmentKindTask AttachmentKind = "task"
|
||||
AttachmentKindUrl AttachmentKind = "url"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AttachmentKind enum.
|
||||
func (e AttachmentKind) Valid() bool {
|
||||
switch e {
|
||||
case AttachmentKindDiagram:
|
||||
return true
|
||||
case AttachmentKindDiff:
|
||||
return true
|
||||
case AttachmentKindFile:
|
||||
return true
|
||||
case AttachmentKindImage:
|
||||
return true
|
||||
case AttachmentKindSnapshot:
|
||||
return true
|
||||
case AttachmentKindTask:
|
||||
return true
|
||||
case AttachmentKindUrl:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for InboxItemKind.
|
||||
const (
|
||||
InboxItemKindQuestion InboxItemKind = "question"
|
||||
InboxItemKindReport InboxItemKind = "report"
|
||||
InboxItemKindSessionError InboxItemKind = "session_error"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the InboxItemKind enum.
|
||||
func (e InboxItemKind) Valid() bool {
|
||||
switch e {
|
||||
case InboxItemKindQuestion:
|
||||
return true
|
||||
case InboxItemKindReport:
|
||||
return true
|
||||
case InboxItemKindSessionError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for InboxItemPriority.
|
||||
const (
|
||||
Blocked InboxItemPriority = "blocked"
|
||||
Fyi InboxItemPriority = "fyi"
|
||||
Gate InboxItemPriority = "gate"
|
||||
Review InboxItemPriority = "review"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the InboxItemPriority enum.
|
||||
func (e InboxItemPriority) Valid() bool {
|
||||
switch e {
|
||||
case Blocked:
|
||||
return true
|
||||
case Fyi:
|
||||
return true
|
||||
case Gate:
|
||||
return true
|
||||
case Review:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for InboxResponseType.
|
||||
const (
|
||||
InboxResponseTypeDismiss InboxResponseType = "dismiss"
|
||||
InboxResponseTypeFreeForm InboxResponseType = "free_form"
|
||||
InboxResponseTypeOption InboxResponseType = "option"
|
||||
InboxResponseTypeQuestion InboxResponseType = "question"
|
||||
InboxResponseTypeRedirect InboxResponseType = "redirect"
|
||||
InboxResponseTypeSnooze InboxResponseType = "snooze"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the InboxResponseType enum.
|
||||
func (e InboxResponseType) Valid() bool {
|
||||
switch e {
|
||||
case InboxResponseTypeDismiss:
|
||||
return true
|
||||
case InboxResponseTypeFreeForm:
|
||||
return true
|
||||
case InboxResponseTypeOption:
|
||||
return true
|
||||
case InboxResponseTypeQuestion:
|
||||
return true
|
||||
case InboxResponseTypeRedirect:
|
||||
return true
|
||||
case InboxResponseTypeSnooze:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for InboxState.
|
||||
const (
|
||||
Answered InboxState = "answered"
|
||||
Dismissed InboxState = "dismissed"
|
||||
Open InboxState = "open"
|
||||
Redirected InboxState = "redirected"
|
||||
Superseded InboxState = "superseded"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the InboxState enum.
|
||||
func (e InboxState) Valid() bool {
|
||||
switch e {
|
||||
case Answered:
|
||||
return true
|
||||
case Dismissed:
|
||||
return true
|
||||
case Open:
|
||||
return true
|
||||
case Redirected:
|
||||
return true
|
||||
case Superseded:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for TaskStatus.
|
||||
const (
|
||||
TaskStatusActive TaskStatus = "active"
|
||||
@@ -114,6 +249,32 @@ type AgentSessionState string
|
||||
// AgentSessionVendor defines model for AgentSession.Vendor.
|
||||
type AgentSessionVendor string
|
||||
|
||||
// Attachment defines model for Attachment.
|
||||
type Attachment struct {
|
||||
Caption *string `json:"caption,omitempty"`
|
||||
EndLine *int `json:"end_line,omitempty"`
|
||||
|
||||
// Image image: data URL.
|
||||
Image *string `json:"image,omitempty"`
|
||||
Kind AttachmentKind `json:"kind"`
|
||||
|
||||
// Mermaid diagram: Mermaid source.
|
||||
Mermaid *string `json:"mermaid,omitempty"`
|
||||
|
||||
// Path file: repo-relative path.
|
||||
Path *string `json:"path,omitempty"`
|
||||
|
||||
// Ref diff: git revision range.
|
||||
Ref *string `json:"ref,omitempty"`
|
||||
SnapshotId *string `json:"snapshot_id,omitempty"`
|
||||
StartLine *int `json:"start_line,omitempty"`
|
||||
TaskId *string `json:"task_id,omitempty"`
|
||||
Url *string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentKind defines model for Attachment.Kind.
|
||||
type AttachmentKind string
|
||||
|
||||
// Error defines model for Error.
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
@@ -131,6 +292,74 @@ type Event struct {
|
||||
TaskId *string `json:"task_id,omitempty"`
|
||||
}
|
||||
|
||||
// InboxItem defines model for InboxItem.
|
||||
type InboxItem struct {
|
||||
AgentSessionId string `json:"agent_session_id"`
|
||||
Blocking bool `json:"blocking"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id string `json:"id"`
|
||||
Kind InboxItemKind `json:"kind"`
|
||||
|
||||
// Payload question: question, why_blocked, options, recommended_option, attachments. report: done, open_questions, next_steps, risks, deviations. session_error: error.
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Priority InboxItemPriority `json:"priority"`
|
||||
ProjectId string `json:"project_id"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
ResolvedBy *string `json:"resolved_by,omitempty"`
|
||||
Responses []InboxResponse `json:"responses"`
|
||||
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
|
||||
State InboxState `json:"state"`
|
||||
TaskId string `json:"task_id"`
|
||||
ThreadId string `json:"thread_id"`
|
||||
}
|
||||
|
||||
// InboxItemKind defines model for InboxItem.Kind.
|
||||
type InboxItemKind string
|
||||
|
||||
// InboxItemPriority defines model for InboxItem.Priority.
|
||||
type InboxItemPriority string
|
||||
|
||||
// InboxRespond defines model for InboxRespond.
|
||||
type InboxRespond struct {
|
||||
Responses []InboxResponseCreate `json:"responses"`
|
||||
}
|
||||
|
||||
// InboxResponse defines model for InboxResponse.
|
||||
type InboxResponse struct {
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
Id string `json:"id"`
|
||||
ItemId string `json:"item_id"`
|
||||
Option *string `json:"option,omitempty"`
|
||||
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Type InboxResponseType `json:"type"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
// InboxResponseCreate defines model for InboxResponseCreate.
|
||||
type InboxResponseCreate struct {
|
||||
Attachments *[]Attachment `json:"attachments,omitempty"`
|
||||
ItemId string `json:"item_id"`
|
||||
|
||||
// Option option: one of the question's options.
|
||||
Option *string `json:"option,omitempty"`
|
||||
|
||||
// SnoozeUntil snooze: when the item reappears.
|
||||
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
|
||||
|
||||
// Text Required for free_form, redirect, question; an optional note on option.
|
||||
Text *string `json:"text,omitempty"`
|
||||
Type InboxResponseType `json:"type"`
|
||||
}
|
||||
|
||||
// InboxResponseType defines model for InboxResponseType.
|
||||
type InboxResponseType string
|
||||
|
||||
// InboxState defines model for InboxState.
|
||||
type InboxState string
|
||||
|
||||
// MessageCreate defines model for MessageCreate.
|
||||
type MessageCreate struct {
|
||||
Text string `json:"text"`
|
||||
@@ -214,6 +443,17 @@ type StreamEventsParams struct {
|
||||
LastEventID *int64 `json:"Last-Event-ID,omitempty"`
|
||||
}
|
||||
|
||||
// ListInboxParams defines parameters for ListInbox.
|
||||
type ListInboxParams struct {
|
||||
ProjectId *string `form:"project_id,omitempty" json:"project_id,omitempty"`
|
||||
TaskId *string `form:"task_id,omitempty" json:"task_id,omitempty"`
|
||||
State *InboxState `form:"state,omitempty" json:"state,omitempty"`
|
||||
IncludeSnoozed *bool `form:"include_snoozed,omitempty" json:"include_snoozed,omitempty"`
|
||||
}
|
||||
|
||||
// RespondInboxJSONRequestBody defines body for RespondInbox for application/json ContentType.
|
||||
type RespondInboxJSONRequestBody = InboxRespond
|
||||
|
||||
// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType.
|
||||
type UpdateMeJSONRequestBody = UserUpdate
|
||||
|
||||
@@ -238,6 +478,15 @@ type ServerInterface interface {
|
||||
// (GET /api/events)
|
||||
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
|
||||
|
||||
// (GET /api/inbox)
|
||||
ListInbox(w http.ResponseWriter, r *http.Request, params ListInboxParams)
|
||||
|
||||
// (POST /api/inbox/responses)
|
||||
RespondInbox(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/inbox/{itemId})
|
||||
GetInboxItem(w http.ResponseWriter, r *http.Request, itemId string)
|
||||
|
||||
// (GET /api/me)
|
||||
GetMe(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
@@ -358,6 +607,118 @@ func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.R
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListInbox operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListInbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// Parameter object where we will unmarshal all parameters from the context
|
||||
var params ListInboxParams
|
||||
|
||||
// ------------- Optional query parameter "project_id" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "project_id", r.URL.Query(), ¶ms.ProjectId, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "project_id"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "project_id", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "task_id" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "task_id", r.URL.Query(), ¶ms.TaskId, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "task_id"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "task_id", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "state" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "state", r.URL.Query(), ¶ms.State, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "state"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "state", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "include_snoozed" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "include_snoozed", r.URL.Query(), ¶ms.IncludeSnoozed, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_snoozed"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_snoozed", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListInbox(w, r, params)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// RespondInbox operation middleware
|
||||
func (siw *ServerInterfaceWrapper) RespondInbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.RespondInbox(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetInboxItem operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetInboxItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "itemId" -------------
|
||||
var itemId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "itemId", r.PathValue("itemId"), &itemId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "itemId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetInboxItem(w, r, itemId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -808,6 +1169,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}", wrapper.GetInboxItem)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/responses", wrapper.RespondInbox)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus)
|
||||
|
||||
@@ -819,34 +1183,50 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
// const string: with thousands of chunks the chained `+` fold is several
|
||||
// times slower for the Go compiler than parsing a slice literal.
|
||||
var swaggerSpec = []string{
|
||||
"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",
|
||||
"02eeHPYpZ7Yv6HfHP0wlW6//DgAA//8=",
|
||||
"zFpbj9u4Ff4rB2qB7AIae3Y3KFDnKd1NiymSdpGZPG0DgxaPbe5IpEJSnrgD//eCN10sypZdezZPo5HI",
|
||||
"w3P5zpV+TjJRlIIj1yqZPScSVSm4QvvPOymFNA+Z4Bq5No+kLHOWEc0En/6uBDfvVLbGgpinP0tcJrPk",
|
||||
"T9OG6tR9VVNHbbfbpQlFlUlWGiLJzB0zScwHv9aQertCru9RKeYOKaUoUWrmWMskEo10TixTSyEL85RQ",
|
||||
"ovFGswKTNNHbEpNZorRkfJXs0nrPYmv29D4zGn1dCIq5+dJl+iN+qVBppGAXvAEsSr2FAglXoNcIG+RU",
|
||||
"yFcKKC5JletJjCUpcjS0kVdFMvstYUWZY4Fco0w+R9YrTXR3A82NrLLi3CxJrTlz1Eij+zVRj/MBQR3D",
|
||||
"beJZTiqKUUJu8Vw5+8RJGvnwS8UkUsdq0jBQHxcUHCMZ5PV66lgwbUOg4VAsfsdMGw7fak2ydeFhu4ce",
|
||||
"4s0YUQNyOs8Zx9ZHxjWuUFqQFGSFfTTY1zOgRBP49PF91NaPjNO2dpfMykTZcpkEwuZfspKk8KoyKuCk",
|
||||
"VGuhkzSpZB41RoGyIM4EXbY8sRl8cCtAiUpmGGWvJHrdp2CYnIHEUtxIzIlmGwSzMo5m4/x9HpbLGayY",
|
||||
"BokbZkwLkvBVnIkg7BBGlSZSHzDPIXwb7R0FqTVSDE51LOwiqUClPCIOEw4Lo7Q3UZSSTLsje6IQExsP",
|
||||
"O196VogMGI2gY5sL0v7WsF9KYR4HbYZfOgwwrv/yujl8lPX2lGlIemZTr6aGxaOR4Y4vxNc7jUVE5WMU",
|
||||
"u8hF9miem48LIXIk/Fy1Dxy0HzFsxjFeZbRRCmliQuAVLT4/pwdN1/XMQG4G4SmFp/V2buVDmoKwK1UK",
|
||||
"EjNRFMgp0rl7mQKp46uagGNnBlRwNPuQzwNNlQLHr3quNJaGFFOPKgWKG2aLCDWBjggzsH8m/+FJGoMa",
|
||||
"E5LpbVsrnt0kTVY+WeCG4VOSJssti2vkMGAlKpFvTjRhvWmguugUVkxjoY7VSxamH/026x6OJpGSbF2w",
|
||||
"FOK/SOcV1ywfz2ldQxw9/N6uPBJW9VoioeMrgJby2+VAz/PalGtfr+3f8sK254d6oeWEbdUPRgOnZtoP",
|
||||
"CP+n2X62fNgczfid2/7DviX3tDSaXYWRANZ45WiOW5VSBGXnxDOKOdugvEwUNDIMgU8M13HOO051Do1f",
|
||||
"dRzm9sUJpn8wG0zZoVCOdI0gqd/kD007Rh2X3fbw9wIwGWelbgJy72cgOIJY2q4pZI1XKmSfgTqxa9wu",
|
||||
"Xfd1Bk9r5JaqYQ4kkrJEIi3F09DQb/2M3WApJCwl4txQM1mSMomZTmsp3gDhXg6SAxcaQYQXUbnORNk+",
|
||||
"mGocWXpHUfLgTw0Z1dsrTWrhbBR10iVpuw5xqrZ9iyqYUtF820omnVPQUCBcPZlQ0TrC/uMJ2mdVlSgV",
|
||||
"0oGu9oOrrYfAPuDTe0qzq2Kq+tUlrBeZQfhZwXwhCc/Wp4wpOClwoPAohB76VIp56PtGxCd7Rntbj+H6",
|
||||
"uNN6da/iIQMekG20AD3eY3z4cdMQH/UoKFZRFuUIkPl10bM10ZWKdoFsg3NTKal400sX88VWu9Ujuqyc",
|
||||
"KD3HjSu3xrZmJp/PbY2m+gHRjuqglCJDpVBBVkmJXOdb8HOpSZSmrwQHpFIK51nOwomRBZrI09xvv4ts",
|
||||
"CLSU2FNQi9G0a40uk10lxUz8QNRj38ALonDuRyf9XnM4ENSTvrMC0PUHp8fGAjXeQ0Jwuj0+w2Q6j4eD",
|
||||
"JyEftUlZJ8S0bjtiSdda36eYNraq+T8t0BkEDKapAbn285RdFiP+yVeaXbKUqTIn2/lgEF0xPceCsHzw",
|
||||
"6+DWXKwYP86yW5Z2WWmRbvMwJNinkka1djb3ezyOZMZsY3wpWgZLFluJdn4t3V1Fcju5ndy6whc5KVky",
|
||||
"S36a3E5+StyU1fI9JSWbElowPm08YYXWK418djhyR5NZ8g/U9wFrnXb0x9vbi13O+BMitzP3KDcoYY0k",
|
||||
"12sgnEJliq2JWbtLnRw2WLYliJG4USZLuKUTeEeytfvnlQJGgSlgWoHCL2/cGJ25axQ7GwWi4J/3//7X",
|
||||
"xP2rgCw1SnhPlL6xb27ufoHvhLQ73LcvFcotlESSAjXK74FIBIkGf0hhgUshEUzADhzZgVNX8/daIinc",
|
||||
"kdZ2nphKZr89Jwb5yRoJta2aQ1nSYSlJW9o/mmR3qadpWW9IWnlOJPX5KFRMuevsdqOsmF2s7DtL/9LO",
|
||||
"2sVt7WCBmWJ/EAq2FbD9mEphzVZrVKZycFMdWDKpNHxXDyBXRGMKbp6XwnLLvk+NiTmInJqNdv0E/s5y",
|
||||
"YxXgRErxZEFgWkoHAS2AgI/ypquUQMCk71cKDNmY3d8zpS2jA0bfM1AnhQyrcMC8zQjs5K1h2jXOxdsj",
|
||||
"vSGKjGd5RXHuR4sxpupR9wiQHY5H48dpdljfGzr0QfmB6GzN+MoBDJ6YXhs0MAk1oxP4xbUsyiDDBOgU",
|
||||
"Ku7ldfv6cJ525CyFiiD7re1jlRtmSChMfHFsMG5fqmphGlrT+kNYLNGEQRu1Vj7OmaX+sggEzwwRGxxp",
|
||||
"jjb6cj8tMdEyzEDrgYOyYa7ioamOodvPOgPApbvI/pug24tlk85MdddNtVpWuPvmkPOwRqhsidGAIE1e",
|
||||
"O75ip9T8h981mNWvT1r919Gr9+H4bFi8o7tDNUMj/hXLhpaO4zq1kzfrhwavjReeqq/dLh6KfV0ewpdV",
|
||||
"S7IPt0Oh9XPQrCsSh7T5Aa+pRlu5D2gwI3luXLxSaH8kYxRRRZh0BbLn8/Iu3arBr+DQ5yonuKxXzmke",
|
||||
"23hVexIRRYCpCH5tpgDXD15h6DgidL3N81DfqAAQn6G6QrjGM1C+Dky6k7xRSPnh0ocPgYXjU1DUBbAy",
|
||||
"ffZPR+JwW99X8pAjcndlvnTMrbVwXtiNqXNaDzsHnfHBD+Cu74l2aDeygmjaDCtBaiDX9CnflP7TgTLW",
|
||||
"ea7rvX2b5KZhtvgM8zBYSlHYNY3I/irAL5/06k5H+cH9qu0asac1XHvhwOMwMhx1jCKvXFA2Acr/hkJN",
|
||||
"n/3TkQAVfmN7xQDV+S3vgJ48s1fykloV50WpmE6njGuUsnL3PldjYchR33HqvNTftoCuJO973V1gctDM",
|
||||
"P76omWulwRMxMcP/UGTyUg1U1JK+31Z/iCHvNZFaAXGRopI8zC2YCoOACbQ8BIpKaVigHQn0DX6PnPoL",
|
||||
"8SvF2e51+6hQ+7IYs0pcEwX+hu8b7eVtjTB9Nn+OhOg6aV4pPh/KX63cdemg7CQ/LyJ3lTcNV4bjPPic",
|
||||
"g4fc9yMWYtMtmOoqyZRMvhyCh+a7xExIar+6yweQWBAWid4/e7H+SADUt7HnQeEy3lHH7YNdwX1Y9BKN",
|
||||
"QTcSjWsQPADcsDeIlHavM74dXzuWsXhXEGB80AsUmnKF6dAzFKUddjOt/KVPvHpxOaZdulw+nXV/9PPC",
|
||||
"ncOYdGbqgm51/M1kst3ufwEAAP//",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
+39
-39
File diff suppressed because one or more lines are too long
+18
-18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
-15
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) ListInbox(w http.ResponseWriter, r *http.Request, params api.ListInboxParams) {
|
||||
f := store.InboxFilter{State: api.Open}
|
||||
if params.ProjectId != nil {
|
||||
f.ProjectID = *params.ProjectId
|
||||
}
|
||||
if params.TaskId != nil {
|
||||
f.TaskID = *params.TaskId
|
||||
}
|
||||
if params.State != nil {
|
||||
f.State = *params.State
|
||||
}
|
||||
if params.IncludeSnoozed != nil {
|
||||
f.IncludeSnoozed = *params.IncludeSnoozed
|
||||
}
|
||||
|
||||
items, err := s.store.ListInbox(r.Context(), f)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID string) {
|
||||
item, err := s.store.GetInboxItem(r.Context(), itemID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
type httpError struct {
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.InboxRespond{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Responses) == 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("responses is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
resolutions := []store.Resolution{}
|
||||
sessions := []string{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, create := range req.Responses {
|
||||
if seen[create.ItemId] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("item %s answered twice in one submission", create.ItemId))
|
||||
return
|
||||
}
|
||||
seen[create.ItemId] = true
|
||||
|
||||
item, err := s.store.GetInboxItem(r.Context(), create.ItemId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
state, herr := checkResponse(item, create)
|
||||
if herr != nil {
|
||||
writeError(w, herr.status, herr.err)
|
||||
return
|
||||
}
|
||||
|
||||
attachments := []api.Attachment{}
|
||||
if create.Attachments != nil {
|
||||
attachments = *create.Attachments
|
||||
}
|
||||
|
||||
resolutions = append(resolutions, store.Resolution{
|
||||
State: state,
|
||||
Response: api.InboxResponse{
|
||||
Id: store.NewID(),
|
||||
ItemId: item.Id,
|
||||
User: actor(r),
|
||||
Type: create.Type,
|
||||
Option: create.Option,
|
||||
Text: create.Text,
|
||||
Attachments: attachments,
|
||||
SnoozeUntil: create.SnoozeUntil,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
})
|
||||
if !slices.Contains(sessions, item.AgentSessionId) {
|
||||
sessions = append(sessions, item.AgentSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
err := s.store.Respond(r.Context(), actor(r), resolutions)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range sessions {
|
||||
go func() {
|
||||
err := s.agents.Deliver(context.Background(), id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", id, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
items := []api.InboxItem{}
|
||||
for _, res := range resolutions {
|
||||
item, err := s.store.GetInboxItem(r.Context(), res.Response.ItemId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxState, *httpError) {
|
||||
if item.State != api.Open {
|
||||
return "", &httpError{http.StatusConflict, fmt.Errorf("item %s is %s", item.Id, item.State)}
|
||||
}
|
||||
|
||||
text := ""
|
||||
if c.Text != nil {
|
||||
text = strings.TrimSpace(*c.Text)
|
||||
}
|
||||
question := item.Kind == api.InboxItemKindQuestion
|
||||
bad := func(format string, args ...any) (api.InboxState, *httpError) {
|
||||
return "", &httpError{http.StatusBadRequest, fmt.Errorf("item %s: "+format, append([]any{item.Id}, args...)...)}
|
||||
}
|
||||
|
||||
switch c.Type {
|
||||
case api.InboxResponseTypeOption:
|
||||
if !question {
|
||||
return bad("only questions have options")
|
||||
}
|
||||
if c.Option == nil || !hasOption(item, *c.Option) {
|
||||
return bad("option must be one of the question's options")
|
||||
}
|
||||
return api.Answered, nil
|
||||
|
||||
case api.InboxResponseTypeFreeForm:
|
||||
if !question {
|
||||
return bad("only questions take answers")
|
||||
}
|
||||
if text == "" {
|
||||
return bad("free_form needs text")
|
||||
}
|
||||
return api.Answered, nil
|
||||
|
||||
case api.InboxResponseTypeQuestion:
|
||||
if !question {
|
||||
return bad("only questions take follow-up questions")
|
||||
}
|
||||
if text == "" {
|
||||
return bad("question needs text")
|
||||
}
|
||||
return api.Open, nil
|
||||
|
||||
case api.InboxResponseTypeRedirect:
|
||||
if text == "" {
|
||||
return bad("redirect needs text")
|
||||
}
|
||||
return api.Redirected, nil
|
||||
|
||||
case api.InboxResponseTypeSnooze:
|
||||
if c.SnoozeUntil == nil || !c.SnoozeUntil.After(time.Now()) {
|
||||
return bad("snooze needs a future snooze_until")
|
||||
}
|
||||
return api.Open, nil
|
||||
|
||||
case api.InboxResponseTypeDismiss:
|
||||
if question {
|
||||
return bad("questions need an answer or a redirect, not a dismissal")
|
||||
}
|
||||
return api.Dismissed, nil
|
||||
}
|
||||
|
||||
return bad("unknown response type %q", c.Type)
|
||||
}
|
||||
|
||||
func hasOption(item api.InboxItem, option string) bool {
|
||||
options, ok := item.Payload["options"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, o := range options {
|
||||
if o == option {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestCheckResponse(t *testing.T) {
|
||||
question := api.InboxItem{
|
||||
Id: "q1",
|
||||
Kind: api.InboxItemKindQuestion,
|
||||
State: api.Open,
|
||||
Payload: map[string]any{"options": []any{"red", "blue"}},
|
||||
}
|
||||
report := api.InboxItem{Id: "r1", Kind: api.InboxItemKindReport, State: api.Open, Payload: map[string]any{}}
|
||||
closed := question
|
||||
closed.State = api.Answered
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
future := time.Now().Add(time.Hour)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
item api.InboxItem
|
||||
create api.InboxResponseCreate
|
||||
state api.InboxState
|
||||
status int
|
||||
}{
|
||||
{"option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("blue")}, api.Answered, 0},
|
||||
{"option with note", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red"), Text: str("but darker")}, api.Answered, 0},
|
||||
{"unknown option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("green")}, "", http.StatusBadRequest},
|
||||
{"missing option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption}, "", http.StatusBadRequest},
|
||||
{"free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str("green")}, api.Answered, 0},
|
||||
{"empty free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str(" ")}, "", http.StatusBadRequest},
|
||||
{"follow-up question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeQuestion, Text: str("why?")}, api.Open, 0},
|
||||
{"redirect question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
|
||||
{"redirect report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
|
||||
{"snooze", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &future}, api.Open, 0},
|
||||
{"snooze into past", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &past}, "", http.StatusBadRequest},
|
||||
{"dismiss report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, api.Dismissed, 0},
|
||||
{"dismiss question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, "", http.StatusBadRequest},
|
||||
{"option on report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusBadRequest},
|
||||
{"already answered", closed, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusConflict},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
state, herr := checkResponse(c.item, c.create)
|
||||
if c.status != 0 {
|
||||
if herr == nil || herr.status != c.status {
|
||||
t.Fatalf("want status %d, got %+v", c.status, herr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if herr != nil {
|
||||
t.Fatalf("unexpected error %v", herr.err)
|
||||
}
|
||||
if state != c.state {
|
||||
t.Fatalf("want state %s, got %s", c.state, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
var priorities = []api.InboxItemPriority{api.Blocked, api.Gate, api.Review, api.Fyi}
|
||||
|
||||
func priorityRank(p api.InboxItemPriority) int {
|
||||
for i, q := range priorities {
|
||||
if q == p {
|
||||
return i
|
||||
}
|
||||
}
|
||||
panic("unknown inbox priority " + string(p))
|
||||
}
|
||||
|
||||
func DeliverableResponse(t api.InboxResponseType) bool {
|
||||
switch t {
|
||||
case api.InboxResponseTypeOption, api.InboxResponseTypeFreeForm, api.InboxResponseTypeRedirect, api.InboxResponseTypeQuestion:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) CreateInboxItem(ctx context.Context, item api.InboxItem) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
superseded := []string{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM inbox_items WHERE thread_id = ? AND state = ?`, item.ThreadId, string(api.Open))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
id := ""
|
||||
err = rows.Scan(&id)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
superseded = append(superseded, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range superseded {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ? WHERE id = ?`, string(api.Superseded), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = emitItem(ctx, tx, emit, "inbox.updated", id, agentActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(item.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, item.Id, item.ProjectId, item.TaskId, item.AgentSessionId, item.ThreadId, string(item.Kind), priorityRank(item.Priority),
|
||||
item.Blocking, string(payload), string(item.State), formatTime(item.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return emitItem(ctx, tx, emit, "inbox.created", item.Id, agentActor)
|
||||
})
|
||||
}
|
||||
|
||||
const agentActor = "agent"
|
||||
|
||||
func (s *Store) GetInboxItem(ctx context.Context, id string) (api.InboxItem, error) {
|
||||
item, err := getItem(ctx, s.db, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return item, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
type InboxFilter struct {
|
||||
ProjectID string
|
||||
TaskID string
|
||||
SessionID string
|
||||
State api.InboxState
|
||||
IncludeSnoozed bool
|
||||
}
|
||||
|
||||
func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem, error) {
|
||||
where := []string{"state = ?"}
|
||||
args := []any{string(f.State)}
|
||||
|
||||
if f.ProjectID != "" {
|
||||
where = append(where, "project_id = ?")
|
||||
args = append(args, f.ProjectID)
|
||||
}
|
||||
if f.TaskID != "" {
|
||||
where = append(where, "task_id = ?")
|
||||
args = append(args, f.TaskID)
|
||||
}
|
||||
if f.SessionID != "" {
|
||||
where = append(where, "agent_session_id = ?")
|
||||
args = append(args, f.SessionID)
|
||||
}
|
||||
if !f.IncludeSnoozed {
|
||||
where = append(where, "(snoozed_until IS NULL OR snoozed_until <= ?)")
|
||||
args = append(args, now())
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY priority, created_at`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
id := ""
|
||||
err = rows.Scan(&id)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := []api.InboxItem{}
|
||||
for _, id := range ids {
|
||||
item, err := getItem(ctx, s.db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type Resolution struct {
|
||||
Response api.InboxResponse
|
||||
State api.InboxState
|
||||
}
|
||||
|
||||
func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolution) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
for _, r := range resolutions {
|
||||
resp := r.Response
|
||||
attachments, err := json.Marshal(resp.Attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, snooze_until, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), resp.Option, resp.Text, string(attachments),
|
||||
formatTimePtr(resp.SnoozeUntil), formatTime(resp.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.State == api.Open {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET snoozed_until = ? WHERE id = ?`, formatTimePtr(resp.SnoozeUntil), resp.ItemId)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
|
||||
string(r.State), actor, now(), resp.ItemId)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = emitItem(ctx, tx, emit, "inbox.updated", resp.ItemId, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) AwaitingHuman(ctx context.Context, sessionID string) (int, error) {
|
||||
n := 0
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM inbox_items i
|
||||
WHERE i.agent_session_id = ? AND i.kind = ? AND i.blocking AND i.state = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id AND r.type IN (?, ?, ?, ?))
|
||||
`, sessionID, string(api.InboxItemKindQuestion), string(api.Open),
|
||||
string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion)).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
type Pending struct {
|
||||
Item api.InboxItem
|
||||
Response api.InboxResponse
|
||||
}
|
||||
|
||||
func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT r.id, r.item_id FROM inbox_responses r JOIN inbox_items i ON i.id = r.item_id
|
||||
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?, ?, ?)
|
||||
ORDER BY r.created_at
|
||||
`, sessionID, string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type pair struct{ resp, item string }
|
||||
pairs := []pair{}
|
||||
for rows.Next() {
|
||||
p := pair{}
|
||||
err = rows.Scan(&p.resp, &p.item)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
}
|
||||
rows.Close()
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pending := []Pending{}
|
||||
for _, p := range pairs {
|
||||
item, err := getItem(ctx, s.db, p.item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range item.Responses {
|
||||
if r.Id == p.resp {
|
||||
pending = append(pending, Pending{Item: item, Response: r})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkDelivered(ctx context.Context, responseIDs []string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
at := now()
|
||||
for _, id := range responseIDs {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = ? WHERE id = ?`, at, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type querier interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
|
||||
item := api.InboxItem{}
|
||||
kind := ""
|
||||
priority := 0
|
||||
payload := ""
|
||||
state := ""
|
||||
createdAt := ""
|
||||
snoozed := sql.NullString{}
|
||||
resolvedBy := sql.NullString{}
|
||||
resolvedAt := sql.NullString{}
|
||||
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state,
|
||||
snoozed_until, created_at, resolved_by, resolved_at
|
||||
FROM inbox_items WHERE id = ?
|
||||
`, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &kind, &priority, &item.Blocking,
|
||||
&payload, &state, &snoozed, &createdAt, &resolvedBy, &resolvedAt)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
item.Kind = api.InboxItemKind(kind)
|
||||
item.Priority = priorities[priority]
|
||||
item.State = api.InboxState(state)
|
||||
err = json.Unmarshal([]byte(payload), &item.Payload)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.SnoozedUntil, err = parseTimePtr(snoozed)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.ResolvedAt, err = parseTimePtr(resolvedAt)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if resolvedBy.Valid {
|
||||
item.ResolvedBy = &resolvedBy.String
|
||||
}
|
||||
|
||||
item.Responses, err = getResponses(ctx, q, id)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) {
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT id, item_id, user_login, type, option, text, attachments, snooze_until, created_at, delivered_at
|
||||
FROM inbox_responses WHERE item_id = ? ORDER BY created_at
|
||||
`, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
responses := []api.InboxResponse{}
|
||||
for rows.Next() {
|
||||
r := api.InboxResponse{}
|
||||
typ := ""
|
||||
option := sql.NullString{}
|
||||
text := sql.NullString{}
|
||||
attachments := ""
|
||||
snooze := sql.NullString{}
|
||||
createdAt := ""
|
||||
delivered := sql.NullString{}
|
||||
|
||||
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &option, &text, &attachments, &snooze, &createdAt, &delivered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.Type = api.InboxResponseType(typ)
|
||||
if option.Valid {
|
||||
r.Option = &option.String
|
||||
}
|
||||
if text.Valid {
|
||||
r.Text = &text.String
|
||||
}
|
||||
err = json.Unmarshal([]byte(attachments), &r.Attachments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.SnoozeUntil, err = parseTimePtr(snooze)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.DeliveredAt, err = parseTimePtr(delivered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses = append(responses, r)
|
||||
}
|
||||
return responses, rows.Err()
|
||||
}
|
||||
|
||||
func emitItem(ctx context.Context, tx *sql.Tx, emit emitFunc, kind, id, actor string) error {
|
||||
item, err := getItem(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{
|
||||
Kind: kind,
|
||||
ProjectId: &item.ProjectId,
|
||||
TaskId: &item.TaskId,
|
||||
AgentSessionId: &item.AgentSessionId,
|
||||
Actor: actor,
|
||||
Payload: ToPayload(item),
|
||||
})
|
||||
}
|
||||
|
||||
func formatTimePtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return formatTime(*t)
|
||||
}
|
||||
|
||||
func parseTimePtr(s sql.NullString) (*time.Time, error) {
|
||||
if !s.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := parseTime(s.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
+55
-3
@@ -78,6 +78,41 @@ CREATE TABLE agent_sessions (
|
||||
|
||||
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
|
||||
CREATE INDEX agent_sessions_state ON agent_sessions(state);
|
||||
`, `
|
||||
CREATE TABLE inbox_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
|
||||
thread_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
blocking INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
snoozed_until TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_by TEXT REFERENCES users(login),
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX inbox_items_open ON inbox_items(state, priority, created_at);
|
||||
CREATE INDEX inbox_items_session ON inbox_items(agent_session_id, state);
|
||||
|
||||
CREATE TABLE inbox_responses (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES inbox_items(id),
|
||||
user_login TEXT NOT NULL REFERENCES users(login),
|
||||
type TEXT NOT NULL,
|
||||
option TEXT,
|
||||
text TEXT,
|
||||
attachments TEXT NOT NULL,
|
||||
snooze_until TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX inbox_responses_item ON inbox_responses(item_id, created_at);
|
||||
`}
|
||||
|
||||
type Store struct {
|
||||
@@ -384,13 +419,24 @@ func (s *Store) querySessions(ctx context.Context, query string, args ...any) ([
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
var ErrStateChanged = errors.New("session state changed")
|
||||
|
||||
func (s *Store) SetSessionState(ctx context.Context, id string, state api.AgentSessionState, actor string, payload map[string]any) error {
|
||||
return s.transitionSession(ctx, id, nil, state, actor, payload)
|
||||
}
|
||||
|
||||
func (s *Store) TransitionSession(ctx context.Context, id string, from, to api.AgentSessionState, actor string) error {
|
||||
return s.transitionSession(ctx, id, &from, to, actor, nil)
|
||||
}
|
||||
|
||||
func (s *Store) transitionSession(ctx context.Context, id string, from *api.AgentSessionState, state api.AgentSessionState, actor string, payload map[string]any) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
taskID := ""
|
||||
projectID := ""
|
||||
current := ""
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT t.id, t.project_id FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
|
||||
`, id).Scan(&taskID, &projectID)
|
||||
SELECT t.id, t.project_id, s.state FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
|
||||
`, id).Scan(&taskID, &projectID, ¤t)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -398,6 +444,10 @@ func (s *Store) SetSessionState(ctx context.Context, id string, state api.AgentS
|
||||
return err
|
||||
}
|
||||
|
||||
if from != nil && current != string(*from) {
|
||||
return fmt.Errorf("%w: session %s is %s, not %s", ErrStateChanged, id, current, *from)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(state), id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -560,8 +610,10 @@ func now() string {
|
||||
return formatTime(time.Now())
|
||||
}
|
||||
|
||||
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
return t.UTC().Format(timeLayout)
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
|
||||
Reference in New Issue
Block a user