tarakan-client/internal/app/work.go
Maxfield Luke 58b29a7f84
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
2026-07-29 04:56:36 -04:00

1001 lines
34 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/atomine-elektrine/tarakan-client/internal/agent"
"github.com/atomine-elektrine/tarakan-client/internal/api"
"github.com/atomine-elektrine/tarakan-client/internal/reviewdoc"
"github.com/atomine-elektrine/tarakan-client/internal/session"
"github.com/atomine-elektrine/tarakan-client/internal/snapshot"
)
// workEvent is one live update from a background job (progress line and/or final result).
type workEvent struct {
line string
footer bool // if true, only update footer (don't spam transcript)
final tea.Msg
finished bool
}
// workEventMsg is delivered on the Bubble Tea thread from listenWorkEvents.
type workEventMsg struct {
event workEvent
}
// Pending, human-reviewable artifacts. Nothing is sent to Tarakan until the
// contributor issues an explicit submit command.
type pendingEvidence struct {
taskID int64
evidence string
}
type pendingScan struct {
host, owner, name, commit string
model string
runID string
document api.ScanDocument
}
// pendingJobReport is a structured Review Format result for a claimed Job.
// Published via /submit-report (same wire path as `tarakan report --job`).
type pendingJobReport struct {
taskID int64
title string
model string
promptVersion string
document api.ScanDocument
}
type pendingVerdict struct {
host, owner, name string
scanID int64
checks []findingCheck
}
type findingCheck struct {
findingID string
verdict api.FindingVerdict
}
// Async result messages routed through Update.
type noticeMsg struct {
body string
err error
}
type evidenceReadyMsg struct {
taskID int64
evidence string
err error
}
type reviewReadyMsg struct {
host, owner, name, commit string
model string
runID string
document api.ScanDocument
raw string
err error
}
type jobReportReadyMsg struct {
taskID int64
title string
model string
promptVersion string
document api.ScanDocument
raw string
err error
}
type verdictReadyMsg struct {
host, owner, name string
scanID int64
checks []findingCheck
raw string
err error
}
// startJobMsg kicks off /report for a job after the TUI mounts (CLI --job).
type startJobMsg struct {
id int64
}
// startPickupMsg lists open jobs and starts /report on the next claimable one.
type startPickupMsg struct{}
// pickedJobMsg is returned after auto-pickup selects a job id.
type pickedJobMsg struct {
id int64
title string
err error
empty bool
}
func (m Model) executeWorkCommand(cmd command) (tea.Model, tea.Cmd) {
switch cmd.name {
case "jobs":
return m.startWork("Loading review tasks…", m.cmdJobs())
case "task":
id, ok := m.requireID(cmd, "/task <id>")
if !ok {
return m.done()
}
return m.startWork("Loading task…", m.cmdTask(id))
case "claim", "release":
id, ok := m.requireID(cmd, "/"+cmd.name+" <id>")
if !ok {
return m.done()
}
status := map[string]string{"claim": "Claiming task…", "release": "Releasing task…"}[cmd.name]
return m.startWork(status, m.cmdMutateTask(cmd.name, id))
case "queue":
return m.startWork("Loading the review queue…", m.cmdQueue())
case "scans":
return m.requireRepo(func(owner, name string) (tea.Model, tea.Cmd) {
return m.startWork("Loading reviews…", m.cmdScans(owner, name))
})
case "report":
// /report with no id → auto-pick next claimable report job.
// /report <id> → that job.
if len(cmd.args) == 0 {
return m.beginPickup()
}
id, ok := m.requireID(cmd, "/report [job id]")
if !ok {
return m.done()
}
return m.beginReportJob(id)
case "pickup":
return m.beginPickup()
case "submit-report":
return m.handleSubmitJobReport()
case "run":
id, ok := m.requireID(cmd, "/run <id>")
if !ok {
return m.done()
}
return m.startProgressWork(fmt.Sprintf("Running agent on task #%d…", id), m.runRunTask(id))
case "submit":
return m.handleSubmitTask(cmd)
case "review":
return m.requireRepo(func(owner, name string) (tea.Model, tea.Cmd) {
if !m.hasAgent() {
return m.notice("No agent CLI selected. Use /agent to pick one.")
}
return m.startProgressWork("Starting ad-hoc review…", m.runReview(owner, name))
})
case "submit-review":
return m.handleSubmitReview()
case "verify":
id, ok := m.requireID(cmd, "/verify <scan id>")
if !ok {
return m.done()
}
return m.requireRepo(func(owner, name string) (tea.Model, tea.Cmd) {
if !m.hasAgent() {
return m.notice("No agent CLI selected. Use /agent to pick one.")
}
return m.startProgressWork(fmt.Sprintf("Starting verify for review #%d…", id), m.runVerify(owner, name, id))
})
case "submit-verdict":
return m.handleSubmitVerdict()
}
return m.done()
}
func (m Model) beginReportJob(id int64) (tea.Model, tea.Cmd) {
if m.apiConfig.Token == "" {
return m.notice("Sign in first with /login.")
}
if !m.hasAgent() {
return m.notice("No agent CLI selected. Use /agent to pick one (claude, codex, grok).")
}
return m.startProgressWork(
fmt.Sprintf("Starting report job #%d…", id),
m.runReportJob(id),
)
}
func (m Model) beginPickup() (tea.Model, tea.Cmd) {
if m.apiConfig.Token == "" {
return m.notice("Sign in first with /login.")
}
if !m.hasAgent() {
return m.notice("No agent CLI selected. Use /agent to pick one (claude, codex, grok).")
}
return m.startProgressWork(
"Looking for an open report job in the global queue…",
m.runPickupJob(),
)
}
// handleWorkEvent applies live progress lines while a background job runs, then
// dispatches the final result when finished.
func (m Model) handleWorkEvent(message workEventMsg) (tea.Model, tea.Cmd) {
ev := message.event
if ev.line != "" {
m.busyStatus = ev.line
if !ev.footer {
m.transcript.Append(session.RoleSystem, ev.line)
}
m.refreshTranscript()
m.resize(m.width, m.height)
}
if !ev.finished {
return m, listenWorkEvents(m.workEvents)
}
m.workEvents = nil
m.busyStatus = ""
if ev.final == nil {
m.busy = false
m.updateInputHint()
m.refreshTranscript()
return m, nil
}
return m.handleWorkMessage(ev.final)
}
func (m Model) handleWorkMessage(message tea.Msg) (tea.Model, tea.Cmd) {
m.busy = false
m.busyStatus = ""
m.workEvents = nil
switch message := message.(type) {
case pickedJobMsg:
if message.err != nil {
m.transcript.Append(session.RoleSystem, "Error: "+message.err.Error())
break
}
if message.empty {
m.transcript.Append(session.RoleSystem,
"No report jobs to work (none open, and no active claim of yours). Use /jobs or /report <id>.")
break
}
m.transcript.Append(session.RoleSystem, fmt.Sprintf(
"Picked job #%d (%s). Claiming and running agent…", message.id, message.title))
m.refreshTranscript()
return m.beginReportJob(message.id)
case noticeMsg:
if message.err != nil {
m.transcript.Append(session.RoleSystem, "Error: "+message.err.Error())
} else {
m.transcript.Append(session.RoleSystem, message.body)
}
case evidenceReadyMsg:
if message.err != nil {
m.transcript.Append(session.RoleSystem, "Error: "+message.err.Error())
} else {
m.pendingEvidence = &pendingEvidence{taskID: message.taskID, evidence: message.evidence}
m.transcript.Append(session.RoleAgent, message.evidence)
m.transcript.Append(session.RoleSystem, fmt.Sprintf(
"Agent evidence ready for task %d. Review it above, then:\n /submit %d <summary>", message.taskID, message.taskID))
}
case reviewReadyMsg:
if message.err != nil {
body := "Error: " + message.err.Error()
if strings.TrimSpace(message.raw) != "" {
body += "\n\nAgent output:\n" + strings.TrimSpace(message.raw)
}
m.transcript.Append(session.RoleSystem, body)
} else {
m.pendingScan = &pendingScan{host: message.host, owner: message.owner, name: message.name, commit: message.commit, model: message.model, runID: message.runID, document: message.document}
m.transcript.Append(session.RoleSystem, formatDocument(message.document)+
"\n\nReview the findings above, then /submit-review to record them (or /review again).")
}
case jobReportReadyMsg:
if message.err != nil {
body := "Error: " + message.err.Error()
if strings.TrimSpace(message.raw) != "" {
body += "\n\nAgent output:\n" + strings.TrimSpace(message.raw)
}
m.transcript.Append(session.RoleSystem, body)
} else {
m.pendingJobReport = &pendingJobReport{
taskID: message.taskID,
title: message.title,
model: message.model,
promptVersion: message.promptVersion,
document: message.document,
}
m.transcript.Append(session.RoleSystem, fmt.Sprintf(
"Job #%d (%s) - claimed and reviewed.\n%s\n\nReview the findings, then:\n /submit-report\nto publish the Report and complete the job (or /report %d to re-run).",
message.taskID, message.title, formatDocument(message.document), message.taskID))
}
case verdictReadyMsg:
if message.err != nil {
body := "Error: " + message.err.Error()
if strings.TrimSpace(message.raw) != "" {
body += "\n\nAgent output:\n" + strings.TrimSpace(message.raw)
}
m.transcript.Append(session.RoleSystem, body)
} else {
m.pendingVerdict = &pendingVerdict{host: message.host, owner: message.owner, name: message.name, scanID: message.scanID, checks: message.checks}
var summary strings.Builder
fmt.Fprintf(&summary, "Proposed %d per-finding check(s) for review %d:\n", len(message.checks), message.scanID)
for _, check := range message.checks {
fmt.Fprintf(&summary, " %s · %s · %s\n", check.findingID, check.verdict.Verdict, check.verdict.Notes)
}
summary.WriteString("\n/submit-verdict to record each check.")
m.transcript.Append(session.RoleSystem, summary.String())
}
}
m.updateInputHint()
m.refreshTranscript()
m.resize(m.width, m.height)
return m, nil
}
// --- command builders (each returns a tea.Cmd running off the UI thread) ---
func (m Model) cmdJobs() tea.Cmd {
owner, name, ok := m.repoSlug()
return m.withClient(func(client *api.Client) tea.Msg {
if !ok {
return noticeMsg{err: errNoRepo}
}
tasks, err := client.ListTasks(context.Background(), owner, name)
if err != nil {
return noticeMsg{err: err}
}
if len(tasks) == 0 {
return noticeMsg{body: "No open tasks for " + owner + "/" + name + "."}
}
var b strings.Builder
fmt.Fprintf(&b, "Open tasks for %s/%s:\n", owner, name)
for _, t := range tasks {
// What the job is worth, when anything is escrowed against it.
paid := ""
if label := t.Contract.Label(); label != "" {
paid = " · " + label
}
fmt.Fprintf(&b, " #%d [%s] %s - %s (%s)%s\n", t.ID, t.Status, t.Kind, t.Title, t.Capability, paid)
}
return noticeMsg{body: strings.TrimRight(b.String(), "\n")}
})
}
func (m Model) cmdTask(id int64) tea.Cmd {
return m.withClient(func(client *api.Client) tea.Msg {
t, err := client.GetTask(context.Background(), id)
if err != nil {
return noticeMsg{err: err}
}
body := fmt.Sprintf("Task #%d - %s\n%s/%s @ %s\nstatus %s · kind %s · capability %s\n\n%s",
t.ID, t.Title, t.Repository.Owner, t.Repository.Name, shortSHA(t.CommitSHA),
t.Status, t.Kind, t.Capability, t.Description)
return noticeMsg{body: body}
})
}
func (m Model) cmdMutateTask(action string, id int64) tea.Cmd {
return m.withClient(func(client *api.Client) tea.Msg {
var t api.Task
var err error
if action == "claim" {
t, err = client.ClaimTask(context.Background(), id)
} else {
t, err = client.ReleaseTask(context.Background(), id)
}
if err != nil {
return noticeMsg{err: err}
}
return noticeMsg{body: fmt.Sprintf("Task #%d is now %s.", t.ID, t.Status)}
})
}
func (m Model) cmdQueue() tea.Cmd {
return m.withClient(func(client *api.Client) tea.Msg {
repos, err := client.ListReviewableRepositories(context.Background(), "unscanned")
if err != nil {
return noticeMsg{err: err}
}
if len(repos) == 0 {
return noticeMsg{body: "The review queue is empty."}
}
var b strings.Builder
b.WriteString("Repositories awaiting review:\n")
for _, r := range repos {
fmt.Fprintf(&b, " %s (%s)\n", r.Slug(), valueOr(r.PrimaryLanguage, "?"))
}
return noticeMsg{body: strings.TrimRight(b.String(), "\n")}
})
}
func (m Model) cmdScans(owner, name string) tea.Cmd {
host := m.repository.Host
return m.withClient(func(client *api.Client) tea.Msg {
scans, err := client.ListScansForHost(context.Background(), host, owner, name)
if err != nil {
return noticeMsg{err: err}
}
if len(scans) == 0 {
return noticeMsg{body: "No visible reviews for " + owner + "/" + name + "."}
}
var b strings.Builder
fmt.Fprintf(&b, "Reviews of %s/%s:\n", owner, name)
for _, s := range scans {
state := s.ReviewStatus
if s.Verified {
state = "verified/" + state
}
fmt.Fprintf(&b, " #%d by @%s - %d finding(s), %s [%s]\n", s.ID, valueOr(s.Submitter, "?"), s.FindingsCount, state, s.Visibility)
if s.DetailsVisible {
for _, f := range s.Findings {
fmt.Fprintf(&b, " [%s] %s%s - %s\n", f.Severity, f.File, findingLines(f), f.Title)
}
}
}
return noticeMsg{body: strings.TrimRight(b.String(), "\n")}
})
}
func (m Model) runRunTask(id int64) func(report func(string)) tea.Msg {
root, commit := m.repository.Root, m.repository.Commit
provider := m.selected
return m.withClientProgress(func(client *api.Client, report func(string)) tea.Msg {
report(fmt.Sprintf("Loading task #%d…", id))
task, err := client.GetTask(context.Background(), id)
if err != nil {
return evidenceReadyMsg{taskID: id, err: err}
}
if task.Capability != "agent" {
return evidenceReadyMsg{taskID: id, err: fmt.Errorf("task %d needs %s work; /run only automates agent-capability tasks", id, valueOr(task.Capability, "?"))}
}
if len(task.CommitSHA) == 40 {
commit = task.CommitSHA
}
report(fmt.Sprintf("Task #%d · %s · pin %s", id, task.Title, shortSHA(commit)))
output, err := runAgentInSnapshot(root, commit, provider, taskPrompt(task), report)
if err != nil {
return evidenceReadyMsg{taskID: id, err: err}
}
if reviewdoc.FindingKinds[task.Kind] {
doc, parseErr := reviewdoc.Parse(output)
if parseErr != nil {
return evidenceReadyMsg{taskID: id, err: parseErr}
}
doc, err = reconcileDocumentForHostContext(context.Background(), client, task.Repository.Host, task.Repository.Owner, task.Repository.Name, commit, root, provider, doc, report)
if err != nil {
return evidenceReadyMsg{taskID: id, err: err}
}
encoded, marshalErr := json.MarshalIndent(doc, "", " ")
if marshalErr != nil {
return evidenceReadyMsg{taskID: id, err: marshalErr}
}
output = string(encoded)
}
return evidenceReadyMsg{taskID: id, evidence: output}
})
}
func (m Model) runPickupJob() func(report func(string)) tea.Msg {
localOwner, localName, _ := m.repoSlug()
filter := m.queueFilter
return m.withClientProgress(func(client *api.Client, report func(string)) tea.Msg {
report("Fetching global job queue…")
tasks, err := client.ListOpenJobs(context.Background(), filter)
if err != nil {
return pickedJobMsg{err: fmt.Errorf("global job queue: %w", err)}
}
report(fmt.Sprintf("Queue returned %d job(s); picking a report job…", len(tasks)))
task, found := pickReportJobPreferring(tasks, localOwner, localName, filter)
if !found {
return pickedJobMsg{empty: true}
}
title := task.Title
if slug := task.Repository.Slug(); slug != "" {
title = slug + " · " + title
}
if task.Status == "claimed" {
title = title + " [your claim]"
}
return pickedJobMsg{id: task.ID, title: title}
})
}
// runReportJob claims a Job (if needed), runs the agent for Review Format JSON,
// and returns a pending structured report for human review before publish.
func (m Model) runReportJob(id int64) func(report func(string)) tea.Msg {
local := m.repository
provider := m.selected
modelID := provider.ModelIdentifier()
return m.withClientProgress(func(client *api.Client, report func(string)) tea.Msg {
report(fmt.Sprintf("Fetching job #%d…", id))
task, err := client.GetTask(context.Background(), id)
if err != nil {
return jobReportReadyMsg{taskID: id, err: err}
}
slug := task.Repository.Slug()
report(fmt.Sprintf("Job #%d · %s · %s · %s @ %s",
id, valueOr(slug, "?"), task.Kind, task.Title, shortSHA(task.CommitSHA)))
if !reviewdoc.FindingKinds[task.Kind] {
return jobReportReadyMsg{taskID: id, err: fmt.Errorf(
"job %d kind %q is not a Report job; use /run for prose tasks or tarakan check for verify_findings",
id, valueOr(task.Kind, "?"))}
}
commit := task.CommitSHA
if len(commit) < 40 {
return jobReportReadyMsg{taskID: id, err: fmt.Errorf("job %d has no full commit SHA to pin", id)}
}
claimedHere := !isMyActiveClaim(task)
keepClaim := false
report(fmt.Sprintf("Claiming job #%d…", id))
// No-op if we already hold the claim; errors if someone else does.
if _, err := client.ClaimTask(context.Background(), id); err != nil {
return jobReportReadyMsg{taskID: id, err: fmt.Errorf("claim job: %w", err)}
}
defer func() {
if claimedHere && !keepClaim {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := client.ReleaseTask(ctx, id); err != nil {
report(fmt.Sprintf("Warning: release failed for job #%d: %v", id, err))
} else {
report(fmt.Sprintf("Released job #%d after failed run", id))
}
}
}()
report(fmt.Sprintf("Claim held on job #%d", id))
root, cleanup, err := worktreeForTask(local, task, client.BaseURL(), report)
if err != nil {
return jobReportReadyMsg{taskID: id, title: task.Title, model: modelID, err: err}
}
defer cleanup()
prompt := reviewdoc.TaskFormatPromptForKind(task.Kind, task.Title, task.Description)
output, err := runAgentInSnapshot(root, commit, provider, prompt, report)
if err != nil {
return jobReportReadyMsg{taskID: id, title: task.Title, model: modelID, raw: output, err: err}
}
report("Parsing Review Format from agent output…")
doc, err := reviewdoc.Parse(output)
if err != nil {
return jobReportReadyMsg{taskID: id, title: task.Title, model: modelID, raw: output, err: err}
}
doc, err = reconcileDocumentForHostContext(context.Background(), client, task.Repository.Host, task.Repository.Owner, task.Repository.Name, commit, root, provider, doc, report)
if err != nil {
return jobReportReadyMsg{taskID: id, title: task.Title, model: modelID, raw: output, err: err}
}
report(fmt.Sprintf("Parsed %d finding(s) for job #%d", len(doc.Findings), id))
keepClaim = true
return jobReportReadyMsg{
taskID: id,
title: task.Title,
model: modelID,
promptVersion: "github.com/atomine-elektrine/tarakan-client/v2",
document: doc,
}
})
}
func (m Model) runReview(owner, name string) func(report func(string)) tea.Msg {
root, commit, provider := m.repository.Root, m.repository.Commit, m.selected
host := m.repository.Host
model := provider.ModelIdentifier()
return m.withClientProgress(func(client *api.Client, report func(string)) tea.Msg {
report(fmt.Sprintf("Ad-hoc review of %s/%s @ %s…", owner, name, shortSHA(commit)))
output, err := runAgentInSnapshot(root, commit, provider, reviewdoc.FormatPrompt, report)
if err != nil {
return reviewReadyMsg{host: host, owner: owner, name: name, commit: commit, model: model, raw: output, err: err}
}
report("Parsing Scan Format…")
doc, err := reviewdoc.Parse(output)
if err != nil {
return reviewReadyMsg{host: host, owner: owner, name: name, commit: commit, model: model, raw: output, err: err}
}
doc, err = reconcileDocumentForHostContext(context.Background(), client, host, owner, name, commit, root, provider, doc, report)
if err != nil {
return reviewReadyMsg{host: host, owner: owner, name: name, commit: commit, model: model, raw: output, err: err}
}
runID, err := api.NewRunID()
if err != nil {
return reviewReadyMsg{host: host, owner: owner, name: name, commit: commit, model: model, err: err}
}
return reviewReadyMsg{host: host, owner: owner, name: name, commit: commit, model: model, runID: runID, document: doc}
})
}
func (m Model) runVerify(owner, name string, scanID int64) func(report func(string)) tea.Msg {
root, commit, provider := m.repository.Root, m.repository.Commit, m.selected
host := m.repository.Host
return m.withClientProgress(func(client *api.Client, report func(string)) tea.Msg {
report(fmt.Sprintf("Loading review #%d on %s/%s…", scanID, owner, name))
scans, err := client.ListScansForHost(context.Background(), host, owner, name)
if err != nil {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, err: err}
}
var target *api.Scan
for i := range scans {
if scans[i].ID == scanID {
target = &scans[i]
}
}
if target == nil {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, err: fmt.Errorf("review %d is not visible here (need a reviews:read reviewer-tier token)", scanID)}
}
if !target.DetailsVisible || len(target.Findings) == 0 {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, err: fmt.Errorf("review %d has no visible findings to verify", scanID)}
}
for _, finding := range target.Findings {
if finding.CanonicalFindingID == "" {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, err: fmt.Errorf("review %d has not been assimilated into canonical finding memory", scanID)}
}
}
if len(target.CommitSHA) == 40 {
commit = target.CommitSHA
}
report(fmt.Sprintf("Verifying review #%d (%d finding(s)) @ %s…", scanID, len(target.Findings), shortSHA(commit)))
output, err := runAgentInSnapshot(root, commit, provider, verifyPrompt(*target), report)
if err != nil {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, raw: output, err: err}
}
report("Parsing verdict…")
checks, err := parseFindingChecks(output, commit)
if err != nil {
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, raw: output, err: err}
}
return verdictReadyMsg{host: host, owner: owner, name: name, scanID: scanID, checks: checks}
})
}
// --- submit handlers (confirm-then-send, using stored pending state) ---
func (m Model) handleSubmitTask(cmd command) (tea.Model, tea.Cmd) {
// Prefer structured job report if pending for this id (after /report).
if len(cmd.args) >= 1 {
if id, err := strconv.ParseInt(cmd.args[0], 10, 64); err == nil {
if m.pendingJobReport != nil && m.pendingJobReport.taskID == id {
return m.handleSubmitJobReport()
}
}
}
if len(cmd.args) < 2 {
return m.notice("Usage: /submit <id> <summary text> (or /submit-report after /report)")
}
id, err := strconv.ParseInt(cmd.args[0], 10, 64)
if err != nil {
return m.notice("First argument must be a task id.")
}
if m.pendingEvidence == nil || m.pendingEvidence.taskID != id {
return m.notice(fmt.Sprintf("No pending agent evidence for task %d. Run /run %d or /report %d first.", id, id, id))
}
summary := strings.Join(cmd.args[1:], " ")
evidence := m.pendingEvidence.evidence
m.pendingEvidence = nil
return m.startWork("Submitting contribution…", m.withClient(func(client *api.Client) tea.Msg {
t, err := client.SubmitTask(context.Background(), id, api.Submission{Provenance: "agent", Summary: summary, Evidence: evidence})
if err != nil {
return noticeMsg{err: err}
}
return noticeMsg{body: fmt.Sprintf("Submitted task #%d for independent review (status %s).", t.ID, t.Status)}
}))
}
func (m Model) handleSubmitJobReport() (tea.Model, tea.Cmd) {
if m.pendingJobReport == nil {
return m.notice("No pending job report. Run /report <job id> first.")
}
p := *m.pendingJobReport
m.pendingJobReport = nil
doc := p.document
summary := reviewdoc.SummaryFromDocument(doc, 2_000)
return m.startWork("Publishing Report and completing job…", m.withClient(func(client *api.Client) tea.Msg {
t, err := client.SubmitTask(context.Background(), p.taskID, api.Submission{
// Agent-produced Review Format via /report; human only gates publish.
// "hybrid" would mean substantial human rewrite of the findings.
Provenance: "agent",
Summary: summary,
Model: p.model,
PromptVersion: p.promptVersion,
Document: &doc,
})
if err != nil {
return noticeMsg{err: err}
}
body := fmt.Sprintf("Published Report via Job #%d (status %s).", t.ID, t.Status)
if t.LinkedReview != nil {
body = fmt.Sprintf("Published Report #%d (%d findings, %s) via Job #%d.",
t.LinkedReview.ID, t.LinkedReview.FindingsCount, t.LinkedReview.ReviewStatus, t.ID)
}
return noticeMsg{body: body}
}))
}
func (m Model) handleSubmitReview() (tea.Model, tea.Cmd) {
if m.pendingScan == nil {
return m.notice("No pending review. Run /review first.")
}
p := *m.pendingScan
m.pendingScan = nil
return m.startWork("Submitting review…", m.withClient(func(client *api.Client) tea.Msg {
scan, err := client.SubmitScanForHost(context.Background(), p.host, p.owner, p.name, api.ScanSubmission{
CommitSHA: p.commit, Provenance: "agent", ReviewKind: "code_review",
Model: p.model, PromptVersion: "github.com/atomine-elektrine/tarakan-client/v2", RunID: p.runID, Document: p.document,
})
if err != nil {
return noticeMsg{err: err}
}
return noticeMsg{body: fmt.Sprintf("Submitted review #%d: %d finding(s), %s.", scan.ID, scan.FindingsCount, scan.ReviewStatus)}
}))
}
func reconcileDocumentForHostContext(
ctx context.Context,
client *api.Client,
host, owner, name, commit, root string,
provider agent.Provider,
discovery api.ScanDocument,
report func(string),
) (api.ScanDocument, error) {
memory, err := client.GetRepositoryMemoryForHost(ctx, host, owner, name, commit)
if err != nil {
return api.ScanDocument{}, fmt.Errorf("load repository memory: %w", err)
}
if len(memory.Findings) == 0 || len(discovery.Findings) == 0 {
return discovery, nil
}
// The suppression count is the operator's own saving: settled non-bugs the
// agent is told not to re-report, and that they therefore do not spend a
// verification round arguing about.
if suppressed := memory.Suppressions.Total(); suppressed > 0 {
report(fmt.Sprintf(
"Reconciling %d finding(s) against %d canonical issue(s) · %d known non-bug(s) suppressed",
len(discovery.Findings), len(memory.Findings), suppressed,
))
} else {
report(fmt.Sprintf("Reconciling %d finding(s) against %d canonical issue(s)…", len(discovery.Findings), len(memory.Findings)))
}
output, err := runAgentInSnapshotContext(
ctx,
root,
commit,
provider,
reviewdoc.ReconciliationPrompt(memory, discovery),
report,
)
if err != nil {
return api.ScanDocument{}, err
}
return reviewdoc.Parse(output)
}
func (m Model) handleSubmitVerdict() (tea.Model, tea.Cmd) {
if m.pendingVerdict == nil {
return m.notice("No pending verdict. Run /verify <scan id> first.")
}
p := *m.pendingVerdict
m.pendingVerdict = nil
return m.startWork("Submitting per-finding checks…", m.withClient(func(client *api.Client) tea.Msg {
for _, check := range p.checks {
check.verdict.Provenance = "agent"
if err := client.SubmitFindingVerdictForHost(context.Background(), p.host, p.owner, p.name, check.findingID, check.verdict); err != nil {
return noticeMsg{err: fmt.Errorf("check finding %s: %w", check.findingID, err)}
}
}
return noticeMsg{body: fmt.Sprintf("Recorded %d agent check(s) from review #%d. Agent-only checks add corroboration but do not create verification quorum.",
len(p.checks), p.scanID)}
}))
}
// --- small helpers ---
func (m Model) startWork(status string, cmd tea.Cmd) (tea.Model, tea.Cmd) {
m.busy = true
m.busyStatus = status
m.transcript.Append(session.RoleSystem, status)
m.refreshTranscript()
m.resize(m.width, m.height)
return m, cmd
}
// startProgressWork runs work on a background goroutine and streams status
// lines into the transcript/footer until a final result message arrives.
func (m Model) startProgressWork(initial string, run func(report func(string)) tea.Msg) (tea.Model, tea.Cmd) {
m.busy = true
m.busyStatus = initial
m.transcript.Append(session.RoleSystem, initial)
m.refreshTranscript()
m.resize(m.width, m.height)
// Large buffer so rapid tool events (many greps) are not dropped while the
// Bubble Tea loop catches up - dropping made the TUI look "stuck".
ch := make(chan workEvent, 512)
m.workEvents = ch
go func() {
var lastFooter string
report := func(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
// Agent stream lines only refresh the footer; major steps go to transcript.
footerOnly := isAgentStreamLine(line)
if footerOnly {
if line == lastFooter {
return
}
lastFooter = line
}
// Prefer blocking briefly over silent drops so activity keeps flowing.
select {
case ch <- workEvent{line: line, footer: footerOnly}:
case <-time.After(2 * time.Second):
// UI stalled; drop this line but keep the agent running.
}
}
final := run(report)
// Final must never be dropped.
ch <- workEvent{finished: true, final: final}
close(ch)
}()
return m, listenWorkEvents(ch)
}
// isAgentStreamLine is true for high-frequency chatter that should only update
// the footer (not flood the transcript). Tool/subagent lines like "→ Read …"
// return false so they appear in the log.
func isAgentStreamLine(line string) bool {
trimmed := strings.TrimSpace(line)
// Live tool / subagent activity from Grok session stream → transcript.
if strings.HasPrefix(trimmed, "→ ") || strings.HasPrefix(trimmed, "✓ ") || strings.HasPrefix(trimmed, "✗ ") {
return false
}
// Token-level thinking / writing pulse.
if strings.HasPrefix(trimmed, "…") || strings.HasPrefix(trimmed, "...") {
return true
}
// Legacy CLI stderr prefixes.
for _, prefix := range []string{
"Grok Build:", "Claude Code:", "OpenAI Codex:",
"Ollama:", "OpenRouter:",
} {
if strings.HasPrefix(line, prefix) {
return true
}
}
if i := strings.Index(line, ": "); i > 0 && i < 40 {
rest := line[i+2:]
if strings.HasPrefix(rest, "packing ") || strings.HasPrefix(rest, "calling ") || rest == "… (working)" {
return true
}
}
return false
}
func listenWorkEvents(ch <-chan workEvent) tea.Cmd {
if ch == nil {
return nil
}
return func() tea.Msg {
ev, ok := <-ch
if !ok {
return workEventMsg{event: workEvent{finished: true}}
}
return workEventMsg{event: ev}
}
}
// withClientProgress is like withClient but passes a progress reporter into the work body.
func (m Model) withClientProgress(fn func(*api.Client, func(string)) tea.Msg) func(report func(string)) tea.Msg {
cfg := m.apiConfig
return func(report func(string)) tea.Msg {
if report == nil {
report = func(string) {}
}
client, err := cfg.Client()
if err != nil {
return noticeMsg{err: fmt.Errorf("%w - use /token and /url (or --token / --url)", err)}
}
return fn(client, report)
}
}
func (m Model) notice(text string) (tea.Model, tea.Cmd) {
m.transcript.Append(session.RoleSystem, text)
m.refreshTranscript()
return m, nil
}
func (m Model) done() (tea.Model, tea.Cmd) {
m.refreshTranscript()
return m, nil
}
func (m Model) requireID(cmd command, usage string) (int64, bool) {
if len(cmd.args) == 0 {
m.transcript.Append(session.RoleSystem, "Usage: "+usage)
return 0, false
}
id, err := strconv.ParseInt(cmd.args[0], 10, 64)
if err != nil {
m.transcript.Append(session.RoleSystem, "Usage: "+usage)
return 0, false
}
return id, true
}
func (m Model) requireRepo(fn func(owner, name string) (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) {
owner, name, ok := m.repoSlug()
if !ok {
return m.notice("The current directory has no git remote origin (owner/name). Set origin or cd into the job's clone.")
}
return fn(owner, name)
}
func (m Model) repoSlug() (string, string, bool) {
if owner, name, ok := m.repository.RemoteSlug(); ok {
return owner, name, true
}
if m.repository.GitHubOwner != "" && m.repository.GitHubName != "" {
return m.repository.GitHubOwner, m.repository.GitHubName, true
}
return "", "", false
}
func (m Model) hasAgent() bool { return m.selected.Name != "" }
func (m Model) withClient(fn func(*api.Client) tea.Msg) tea.Cmd {
cfg := m.apiConfig
return func() tea.Msg {
client, err := cfg.Client()
if err != nil {
return noticeMsg{err: fmt.Errorf("%w - use /token and /url (or --token / --url)", err)}
}
return fn(client)
}
}
func runAgentInSnapshot(root, commit string, provider agent.Provider, prompt string, report func(string)) (string, error) {
return runAgentInSnapshotContext(context.Background(), root, commit, provider, prompt, report)
}
func runAgentInSnapshotContext(ctx context.Context, root, commit string, provider agent.Provider, prompt string, report func(string)) (string, error) {
if report == nil {
report = func(string) {}
}
if provider.Name == "" {
return "", fmt.Errorf("no agent CLI selected")
}
if commit == "" {
return "", fmt.Errorf("the current repository has no commit to pin a review to")
}
report("Preparing isolated snapshot @ " + shortSHA(commit) + "…")
pinned, err := snapshot.Create(root, commit)
if err != nil {
return "", fmt.Errorf("prepare pinned snapshot: %w", err)
}
defer pinned.Close()
report("Snapshot ready. Running " + provider.Description + " (this can take a while)…")
started := time.Now()
output, err := agent.Run(ctx, provider, agent.Request{
Prompt: prompt,
Directory: pinned.Root,
Progress: report,
})
elapsed := time.Since(started).Round(time.Second)
if err != nil {
report(provider.Description + " failed after " + elapsed.String())
return output, err
}
report(provider.Description + " finished in " + elapsed.String())
if changed, changeErr := pinned.Changed(); changeErr != nil {
return output, fmt.Errorf("snapshot could not be verified after the run: %w", changeErr)
} else if changed {
return output, fmt.Errorf("refusing output because the agent modified its read-only snapshot")
}
report("Snapshot unchanged (agent stayed read-only)")
return output, nil
}